concept-collection / surfacefun-interactive
Interactive surfacefun mesh refinement (numbl project)
A numbl project deployed to GitHub Pages: a surfacefun cubed-sphere mesh rendered in a uihtml figure with a slider that refines it interactively via the two-way data/event bridge. The figure app (app/) builds to a single HTML file that refine_demo.m loads; the deploy workflow builds it, then bundles the project with the numbl browser IDE.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit a65b3e2c55b5 Browse files
18 changed files+3732−0
.github/workflows/deploy.ymladded+57−0View file
@@ -0,0 +1,57 @@
1+name: Deploy numbl project to GitHub Pages
2+
3+# Builds the single-file web app, bundles this numbl project with the browser
4+# IDE, and publishes it to GitHub Pages on every push to main.
5+#
6+# One-time setup: Settings → Pages → "Build and deployment" → Source →
7+# "GitHub Actions".
8+
9+on:
10+ push:
11+ branches: [main]
12+ workflow_dispatch:
13+
14+permissions:
15+ contents: read
16+ pages: write
17+ id-token: write
18+
19+concurrency:
20+ group: pages
21+ cancel-in-progress: false
22+
23+jobs:
24+ build:
25+ runs-on: ubuntu-latest
26+ steps:
27+ - uses: actions/checkout@v4
28+
29+ - uses: actions/setup-node@v6
30+ with:
31+ node-version: 24
32+
33+ # Build the single-file figure app (app/dist/index.html) that refine_demo.m
34+ # loads via uihtml. build-site then bundles app/dist/index.html into the
35+ # project (the rest of app/ is excluded via .numblignore).
36+ - name: Build figure app
37+ run: |
38+ cd app
39+ npm ci
40+ npm run build
41+
42+ - uses: flatironinstitute/numbl/.github/actions/build-site@main
43+ with:
44+ project-dir: .
45+ # Build numbl from the main branch (development version, which includes
46+ # the uihtml two-way data/event bridge) rather than the npm release.
47+ numbl-ref: main
48+
49+ deploy:
50+ needs: build
51+ runs-on: ubuntu-latest
52+ environment:
53+ name: github-pages
54+ url: ${{ steps.deployment.outputs.page_url }}
55+ steps:
56+ - id: deployment
57+ uses: actions/deploy-pages@v4
.gitignoreadded+4−0View file
@@ -0,0 +1,4 @@
1+# Local output from `numbl build-site`
2+/_site/
3+/dist/
4+node_modules/
.numblignoreadded+10−0View file
@@ -0,0 +1,10 @@
1+# Files NOT to bundle into the deployed numbl project. Only the built
2+# single-file app (app/dist/index.html) is needed at runtime — exclude the web
3+# app's source and config. (node_modules, .git, .github are excluded by default.)
4+.gitignore
5+app/src
6+app/index.html
7+app/package.json
8+app/package-lock.json
9+app/tsconfig.json
10+app/vite.config.ts
README.mdadded+25−0View file
@@ -0,0 +1,25 @@
1+# Interactive surface mesh refinement
2+
3+Runs in your browser through [numbl](https://numbl.org) — no install.
4+
5+## ▶ [Open `refine_demo.m`](refine_demo.m) and click **Run**
6+
7+A cubed-sphere [surfacefun](https://github.com/danfortunato/surfacefun) mesh
8+appears in the figure. Drag the **Refinement level** slider: the page sends the
9+level back to the script, which refines the mesh with surfacefun and returns the
10+new patches — the surface re-renders in place (drag to rotate, scroll to zoom).
11+
12+It's a live demo of numbl's `uihtml` two-way bridge: data flows script → figure,
13+and the slider drives work back in the interpreter, figure → script.
14+
15+## How it works
16+
17+- [`refine_demo.m`](refine_demo.m) — loads surfacefun via `mip`, builds the base
18+ mesh, sends it to the figure, and refines on demand in its
19+ `HTMLEventReceivedFcn` callback.
20+- `app/` — a small React/three.js app (built to a single HTML file) that renders
21+ the surface with numbl's own surface renderer and hosts the slider.
22+
23+On every push to `main`, the
24+[deploy workflow](.github/workflows/deploy.yml) builds the app, bundles the
25+project with the numbl browser IDE, and publishes it to GitHub Pages.
app/.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules/
2+dist/
app/index.htmladded+21−0View file
@@ -0,0 +1,21 @@
1+<!DOCTYPE html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>surfacefun interactive mesh</title>
7+ <style>
8+ html,
9+ body,
10+ #root {
11+ margin: 0;
12+ height: 100%;
13+ width: 100%;
14+ }
15+ </style>
16+ </head>
17+ <body>
18+ <div id="root"></div>
19+ <script type="module" src="/src/main.tsx"></script>
20+ </body>
21+</html>
app/package-lock.jsonadded+2052−0View file
This diff is 2,057 lines long and is not shown.
app/package.jsonadded+25−0View file
@@ -0,0 +1,25 @@
1+{
2+ "name": "surfacefun-interactive-app",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "vite build",
9+ "preview": "vite preview"
10+ },
11+ "dependencies": {
12+ "react": "^18.3.1",
13+ "react-dom": "^18.3.1",
14+ "three": "^0.169.0"
15+ },
16+ "devDependencies": {
17+ "@types/react": "^18.3.12",
18+ "@types/react-dom": "^18.3.1",
19+ "@types/three": "^0.169.0",
20+ "@vitejs/plugin-react": "^4.3.4",
21+ "typescript": "^5.6.3",
22+ "vite": "^6.0.7",
23+ "vite-plugin-singlefile": "^2.1.0"
24+ }
25+}
app/src/App.tsxadded+134−0View file
@@ -0,0 +1,134 @@
1+import { useEffect, useMemo, useState, type CSSProperties } from "react";
2+import { SurfView } from "./render/SurfView.js";
3+import type { SurfTrace } from "./render/types.js";
4+import { onData, onHostEvent, sendToMATLAB } from "./bridge.js";
5+
6+/** Mesh payload from the numbl script: one flat (column-major) x/y/z array per
7+ * patch, each an `n x n` grid. Mirrors what refine_demo.m sends. */
8+interface MeshData {
9+ n: number;
10+ x: number[][];
11+ y: number[][];
12+ z: number[][];
13+ npatches: number;
14+ level?: number;
15+ maxLevel?: number;
16+}
17+
18+function isMeshData(d: unknown): d is MeshData {
19+ return (
20+ !!d &&
21+ typeof d === "object" &&
22+ Array.isArray((d as MeshData).x) &&
23+ typeof (d as MeshData).n === "number"
24+ );
25+}
26+
27+export function App() {
28+ const [mesh, setMesh] = useState<MeshData | null>(null);
29+ const [level, setLevel] = useState(0);
30+ const [busy, setBusy] = useState(false);
31+
32+ // Initial mesh arrives via Data; refinements arrive via "mesh" events
33+ // (which update React state without remounting the iframe, so the camera /
34+ // orientation is preserved across refinements).
35+ useEffect(() => {
36+ const apply = (d: unknown) => {
37+ if (!isMeshData(d)) return;
38+ setMesh(d);
39+ if (typeof d.level === "number") setLevel(d.level);
40+ setBusy(false);
41+ };
42+ const offData = onData(apply);
43+ const offMesh = onHostEvent("mesh", apply);
44+ return () => {
45+ offData();
46+ offMesh();
47+ };
48+ }, []);
49+
50+ const traces = useMemo<SurfTrace[]>(() => {
51+ if (!mesh) return [];
52+ const out: SurfTrace[] = [];
53+ for (let k = 0; k < mesh.x.length; k++) {
54+ out.push({
55+ x: mesh.x[k],
56+ y: mesh.y[k],
57+ z: mesh.z[k],
58+ rows: mesh.n,
59+ cols: mesh.n,
60+ });
61+ }
62+ return out;
63+ }, [mesh]);
64+
65+ const maxLevel = mesh?.maxLevel ?? 3;
66+
67+ const onSlider = (v: number) => {
68+ setLevel(v);
69+ setBusy(true);
70+ sendToMATLAB("refine", v);
71+ };
72+
73+ return (
74+ <div style={rootStyle}>
75+ {mesh ? (
76+ <SurfView surfTraces={traces} shading="faceted" />
77+ ) : (
78+ <div style={waitingStyle}>Waiting for mesh from the script…</div>
79+ )}
80+
81+ <div style={panelStyle}>
82+ <div style={{ fontWeight: 600, marginBottom: 8 }}>surfacefun mesh</div>
83+ <label style={{ display: "block", fontSize: 13 }}>
84+ Refinement level: <b>{level}</b>
85+ <input
86+ type="range"
87+ min={0}
88+ max={maxLevel}
89+ step={1}
90+ value={level}
91+ onChange={e => onSlider(Number(e.target.value))}
92+ style={{ width: "100%", marginTop: 4 }}
93+ />
94+ </label>
95+ <div style={{ fontSize: 12, color: "#475569", marginTop: 4 }}>
96+ patches: {mesh?.npatches ?? "—"}
97+ {busy ? " · refining…" : ""}
98+ </div>
99+ <div style={{ fontSize: 11, color: "#94a3b8", marginTop: 6 }}>
100+ drag to rotate · scroll to zoom
101+ </div>
102+ </div>
103+ </div>
104+ );
105+}
106+
107+const rootStyle: CSSProperties = {
108+ position: "absolute",
109+ inset: 0,
110+ background: "#ffffff",
111+ fontFamily: "system-ui, -apple-system, Arial, sans-serif",
112+};
113+
114+const waitingStyle: CSSProperties = {
115+ position: "absolute",
116+ inset: 0,
117+ display: "flex",
118+ alignItems: "center",
119+ justifyContent: "center",
120+ color: "#94a3b8",
121+};
122+
123+const panelStyle: CSSProperties = {
124+ position: "absolute",
125+ top: 12,
126+ left: 12,
127+ width: 220,
128+ padding: "12px 14px",
129+ background: "rgba(255,255,255,0.92)",
130+ border: "1px solid #e2e8f0",
131+ borderRadius: 8,
132+ boxShadow: "0 1px 4px rgba(0,0,0,0.1)",
133+ color: "#0f172a",
134+};
app/src/bridge.tsadded+71−0View file
@@ -0,0 +1,71 @@
1+// Bridge to the numbl/MATLAB `uihtml` host. The host calls a global
2+// `setup(htmlComponent)` once the page loads; we expose small subscribe/send
3+// helpers so React components don't race that callback (latest Data is buffered,
4+// and host-event listeners attach whenever the component becomes available).
5+
6+interface HtmlComponent {
7+ Data: unknown;
8+ addEventListener(name: string, fn: (e: { Data?: unknown }) => void): void;
9+ sendEventToMATLAB(name: string, data: unknown): void;
10+}
11+
12+type DataListener = (data: unknown) => void;
13+type EventListener = (data: unknown) => void;
14+
15+let htmlComponent: HtmlComponent | null = null;
16+let latestData: unknown = undefined;
17+const dataListeners = new Set<DataListener>();
18+const eventListeners = new Map<string, Set<EventListener>>();
19+const attachedNames = new Set<string>();
20+
21+function deliverData(d: unknown): void {
22+ latestData = d;
23+ dataListeners.forEach(fn => fn(d));
24+}
25+
26+/** Attach a single host listener for `name` that fans out to our listener set. */
27+function attach(name: string): void {
28+ if (!htmlComponent || attachedNames.has(name)) return;
29+ attachedNames.add(name);
30+ htmlComponent.addEventListener(name, e => {
31+ const fns = eventListeners.get(name);
32+ if (fns) fns.forEach(fn => fn(e?.Data));
33+ });
34+}
35+
36+// Register the global the host calls after the page loads.
37+(window as unknown as { setup: (hc: HtmlComponent) => void }).setup = hc => {
38+ htmlComponent = hc;
39+ hc.addEventListener("DataChanged", () => deliverData(hc.Data));
40+ for (const name of eventListeners.keys()) attach(name);
41+ if (hc.Data != null) deliverData(hc.Data);
42+};
43+
44+/** Subscribe to the `Data` channel (script → page). Fires immediately with the
45+ * latest data if it has already arrived. */
46+export function onData(fn: DataListener): () => void {
47+ dataListeners.add(fn);
48+ if (latestData !== undefined) fn(latestData);
49+ return () => {
50+ dataListeners.delete(fn);
51+ };
52+}
53+
54+/** Subscribe to a named host event (from MATLAB `sendEventToHTMLSource`). */
55+export function onHostEvent(name: string, fn: EventListener): () => void {
56+ let set = eventListeners.get(name);
57+ if (!set) {
58+ set = new Set();
59+ eventListeners.set(name, set);
60+ }
61+ set.add(fn);
62+ attach(name);
63+ return () => {
64+ set!.delete(fn);
65+ };
66+}
67+
68+/** Send an event back to the interpreter (page → script). */
69+export function sendToMATLAB(name: string, data: unknown): void {
70+ htmlComponent?.sendEventToMATLAB(name, data);
71+}
app/src/main.tsxadded+12−0View file
@@ -0,0 +1,12 @@
1+import { StrictMode } from "react";
2+import { createRoot } from "react-dom/client";
3+// Import the bridge first so `window.setup` is defined before the host's
4+// bootstrap calls it (it defers to DOMContentLoaded, after module scripts run).
5+import "./bridge.js";
6+import { App } from "./App.js";
7+
8+createRoot(document.getElementById("root")!).render(
9+ <StrictMode>
10+ <App />
11+ </StrictMode>
12+);
app/src/render/SurfView.tsxadded+1115−0View file
@@ -0,0 +1,1115 @@
1+import { useRef, useEffect, type CSSProperties } from "react";
2+import * as THREE from "three";
3+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
4+import { Line2 } from "three/examples/jsm/lines/Line2.js";
5+import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
6+import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
7+import type {
8+ SurfTrace,
9+ Plot3Trace,
10+ Bar3Trace,
11+ Quiver3Trace,
12+} from "./types.js";
13+import { colormapLookup } from "./surfColormap.js";
14+
15+// Color order for plot3 traces
16+const TRACE_COLORS = [
17+ [0, 0.447, 0.741], // #0072BD blue
18+ [0.85, 0.325, 0.098], // #D95319 red-orange
19+ [0.929, 0.694, 0.125], // #EDB120 yellow
20+ [0.494, 0.184, 0.556], // #7E2F8E purple
21+ [0.466, 0.674, 0.188], // #77AC30 green
22+ [0.301, 0.745, 0.933], // #4DBEEE cyan
23+ [0.635, 0.078, 0.184], // #A2142F dark red
24+];
25+
26+interface SurfViewProps {
27+ surfTraces: SurfTrace[];
28+ plot3Traces?: Plot3Trace[];
29+ bar3Traces?: Bar3Trace[];
30+ bar3hTraces?: Bar3Trace[];
31+ quiver3Traces?: Quiver3Trace[];
32+ shading?: "faceted" | "flat" | "interp";
33+ colorbar?: boolean;
34+ colorbarLocation?: string;
35+ colormap?: string;
36+ /** `axis off` hides the axes box/lines (the plotted surfaces remain). */
37+ axisVisible?: boolean;
38+}
39+
40+export function SurfView({
41+ surfTraces,
42+ plot3Traces = [],
43+ bar3Traces = [],
44+ bar3hTraces = [],
45+ quiver3Traces = [],
46+ shading,
47+ colorbar,
48+ colorbarLocation,
49+ colormap,
50+ axisVisible,
51+}: SurfViewProps) {
52+ const containerRef = useRef<HTMLDivElement>(null);
53+ const stateRef = useRef<{
54+ renderer: THREE.WebGLRenderer;
55+ scene: THREE.Scene;
56+ camera: THREE.OrthographicCamera;
57+ controls: OrbitControls;
58+ animId: number;
59+ } | null>(null);
60+
61+ // Set up the three.js scene once
62+ useEffect(() => {
63+ const container = containerRef.current;
64+ if (!container) return;
65+
66+ const renderer = new THREE.WebGLRenderer({ antialias: true });
67+ renderer.setPixelRatio(window.devicePixelRatio);
68+ renderer.setClearColor(0xffffff);
69+ container.appendChild(renderer.domElement);
70+
71+ const scene = new THREE.Scene();
72+
73+ // Orthographic camera — frustum will be sized on resize
74+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 100);
75+ camera.position.set(1.2, 0.8, 1.2);
76+ camera.lookAt(0, 0, 0);
77+
78+ const controls = new OrbitControls(camera, renderer.domElement);
79+ controls.enablePan = false;
80+ controls.enableZoom = true;
81+
82+ // Ambient + directional light
83+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
84+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
85+ dirLight.position.set(2, 3, 2);
86+ scene.add(dirLight);
87+
88+ const animId = requestAnimationFrame(function loop() {
89+ controls.update();
90+ renderer.render(scene, camera);
91+ stateRef.current!.animId = requestAnimationFrame(loop);
92+ });
93+
94+ stateRef.current = { renderer, scene, camera, controls, animId };
95+
96+ // Handle resize
97+ const observer = new ResizeObserver(() => {
98+ const rect = container.getBoundingClientRect();
99+ if (rect.width === 0 || rect.height === 0) return;
100+ renderer.setSize(rect.width, rect.height);
101+ const aspect = rect.width / rect.height;
102+ const frustumSize = 1.2;
103+ camera.left = -frustumSize * aspect;
104+ camera.right = frustumSize * aspect;
105+ camera.top = frustumSize;
106+ camera.bottom = -frustumSize;
107+ camera.updateProjectionMatrix();
108+ });
109+ observer.observe(container);
110+
111+ return () => {
112+ observer.disconnect();
113+ cancelAnimationFrame(stateRef.current?.animId ?? animId);
114+ controls.dispose();
115+ renderer.dispose();
116+ container.removeChild(renderer.domElement);
117+ stateRef.current = null;
118+ };
119+ }, []);
120+
121+ // Rebuild scene when data changes
122+ useEffect(() => {
123+ const st = stateRef.current;
124+ if (!st) return;
125+ const { scene } = st;
126+
127+ // Remove old meshes/lines (keep lights)
128+ const toRemove: THREE.Object3D[] = [];
129+ scene.traverse(obj => {
130+ if (
131+ obj instanceof THREE.Mesh ||
132+ obj instanceof THREE.LineSegments ||
133+ obj instanceof THREE.Line
134+ ) {
135+ toRemove.push(obj);
136+ }
137+ });
138+ for (const obj of toRemove) {
139+ scene.remove(obj);
140+ if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
141+ }
142+
143+ if (
144+ surfTraces.length === 0 &&
145+ plot3Traces.length === 0 &&
146+ bar3Traces.length === 0 &&
147+ bar3hTraces.length === 0 &&
148+ quiver3Traces.length === 0
149+ )
150+ return;
151+
152+ // Compute global data ranges across both surf and plot3 traces
153+ let xMin = Infinity,
154+ xMax = -Infinity;
155+ let yMin = Infinity,
156+ yMax = -Infinity;
157+ let zMin = Infinity,
158+ zMax = -Infinity;
159+
160+ const updateRange = (
161+ arr: number[],
162+ updateMin: { v: number },
163+ updateMax: { v: number }
164+ ) => {
165+ for (const v of arr) {
166+ if (isFinite(v)) {
167+ if (v < updateMin.v) updateMin.v = v;
168+ if (v > updateMax.v) updateMax.v = v;
169+ }
170+ }
171+ };
172+
173+ const xMinRef = { v: xMin },
174+ xMaxRef = { v: xMax };
175+ const yMinRef = { v: yMin },
176+ yMaxRef = { v: yMax };
177+ const zMinRef = { v: zMin },
178+ zMaxRef = { v: zMax };
179+
180+ for (const trace of surfTraces) {
181+ updateRange(trace.x, xMinRef, xMaxRef);
182+ updateRange(trace.y, yMinRef, yMaxRef);
183+ updateRange(trace.z, zMinRef, zMaxRef);
184+ }
185+ for (const trace of plot3Traces) {
186+ updateRange(trace.x, xMinRef, xMaxRef);
187+ updateRange(trace.y, yMinRef, yMaxRef);
188+ updateRange(trace.z, zMinRef, zMaxRef);
189+ }
190+ for (const trace of bar3Traces) {
191+ updateRange(trace.x, xMinRef, xMaxRef);
192+ updateRange(trace.y, yMinRef, yMaxRef);
193+ updateRange(trace.z, zMinRef, zMaxRef);
194+ // Bars extend to zero on z-axis
195+ if (0 < zMinRef.v) zMinRef.v = 0;
196+ }
197+ for (const trace of bar3hTraces) {
198+ // bar3h: bars extend along x-axis, positions on y and z axes
199+ updateRange(trace.y, yMinRef, yMaxRef);
200+ updateRange(trace.z, zMinRef, zMaxRef);
201+ updateRange(trace.x, xMinRef, xMaxRef);
202+ // Bars extend to zero on x-axis
203+ if (0 < xMinRef.v) xMinRef.v = 0;
204+ }
205+ for (const trace of quiver3Traces) {
206+ // Include both the arrow tails and the arrow heads.
207+ updateRange(trace.x, xMinRef, xMaxRef);
208+ updateRange(trace.y, yMinRef, yMaxRef);
209+ updateRange(trace.z, zMinRef, zMaxRef);
210+ updateRange(
211+ trace.x.map((v, i) => v + (trace.u[i] ?? 0)),
212+ xMinRef,
213+ xMaxRef
214+ );
215+ updateRange(
216+ trace.y.map((v, i) => v + (trace.v[i] ?? 0)),
217+ yMinRef,
218+ yMaxRef
219+ );
220+ updateRange(
221+ trace.z.map((v, i) => v + (trace.w[i] ?? 0)),
222+ zMinRef,
223+ zMaxRef
224+ );
225+ }
226+
227+ xMin = xMinRef.v;
228+ xMax = xMaxRef.v;
229+ yMin = yMinRef.v;
230+ yMax = yMaxRef.v;
231+ zMin = zMinRef.v;
232+ zMax = zMaxRef.v;
233+
234+ if (!isFinite(xMin)) return;
235+ if (xMax === xMin) {
236+ xMin -= 1;
237+ xMax += 1;
238+ }
239+ if (yMax === yMin) {
240+ yMin -= 1;
241+ yMax += 1;
242+ }
243+ if (zMax === zMin) {
244+ zMin -= 1;
245+ zMax += 1;
246+ }
247+
248+ const xRange = xMax - xMin || 1;
249+ const yRange = yMax - yMin || 1;
250+ const zRange2 = zMax - zMin || 1;
251+ const rangeMax = Math.max(xRange, yRange, zRange2);
252+ const cxData = (xMin + xMax) / 2;
253+ const cyData = (yMin + yMax) / 2;
254+ const czData = (zMin + zMax) / 2;
255+
256+ // For bar3/bar3h: use per-axis scaling when z range dominates x/y range.
257+ // This prevents bars from appearing as thin sticks in histogram2-style data.
258+ const hasOnlyBars =
259+ surfTraces.length === 0 &&
260+ plot3Traces.length === 0 &&
261+ (bar3Traces.length > 0 || bar3hTraces.length > 0);
262+ const barRangeMax = hasOnlyBars ? Math.max(xRange, yRange) : rangeMax;
263+ // normBar scales x/y to fill the view; normZ still uses rangeMax for z
264+ const normBar = (v: number, center: number) => (v - center) / barRangeMax;
265+ const normBarZ = (v: number, center: number) =>
266+ (v - center) / (hasOnlyBars ? Math.max(barRangeMax, zRange2) : rangeMax);
267+
268+ // Normalize a data point to [-0.5, 0.5] range
269+ const norm = (v: number, center: number) => (v - center) / rangeMax;
270+
271+ // Color range (caxis) for surf vertex colors: the explicit color data C
272+ // when present (surf(x,y,z,C)), otherwise the height Z, taken globally
273+ // across all surf traces. This is independent of the geometry's z extent
274+ // — using the z extent washes out a surface whose C range is much smaller
275+ // (e.g. a solution plotted on a curved surface), and would disagree with
276+ // the colorbar (which already uses the C range).
277+ let cMin = Infinity;
278+ let cMax = -Infinity;
279+ for (const trace of surfTraces) {
280+ for (const v of trace.c ?? trace.z) {
281+ if (isFinite(v)) {
282+ if (v < cMin) cMin = v;
283+ if (v > cMax) cMax = v;
284+ }
285+ }
286+ }
287+ if (!isFinite(cMin)) {
288+ cMin = zMin;
289+ cMax = zMax;
290+ }
291+ const cRange = cMax - cMin || 1;
292+
293+ // ── Render surf traces ──────────────────────────────────────────────
294+ for (const trace of surfTraces) {
295+ const { rows, cols, x, y, z } = trace;
296+ const alpha = trace.faceAlpha ?? 1;
297+
298+ // Build indexed geometry
299+ const positions = new Float32Array(rows * cols * 3);
300+ const colors = new Float32Array(rows * cols * 3);
301+
302+ for (let j = 0; j < cols; j++) {
303+ for (let i = 0; i < rows; i++) {
304+ const idx = j * rows + i; // column-major
305+ const vi = i * cols + j; // vertex index for buffer (row-major)
306+
307+ const nx = norm(x[idx], cxData);
308+ const ny = norm(y[idx], cyData);
309+ const nz = norm(z[idx], czData);
310+
311+ // three.js: X=right, Y=up, Z=towards camera
312+ // Map data X→three X, data Y→three Z, data Z→three Y
313+ positions[vi * 3] = nx;
314+ positions[vi * 3 + 1] = nz;
315+ positions[vi * 3 + 2] = ny;
316+
317+ const cval = trace.c ? trace.c[idx] : z[idx];
318+ const t = (cval - cMin) / cRange;
319+ const [r, g, b] = colormapLookup(t);
320+ colors[vi * 3] = r;
321+ colors[vi * 3 + 1] = g;
322+ colors[vi * 3 + 2] = b;
323+ }
324+ }
325+
326+ // Triangle indices
327+ const indices: number[] = [];
328+ for (let i = 0; i < rows - 1; i++) {
329+ for (let j = 0; j < cols - 1; j++) {
330+ const a = i * cols + j;
331+ const b = i * cols + (j + 1);
332+ const c = (i + 1) * cols + j;
333+ const d = (i + 1) * cols + (j + 1);
334+ indices.push(a, c, b);
335+ indices.push(b, c, d);
336+ }
337+ }
338+
339+ const geometry = new THREE.BufferGeometry();
340+ geometry.setAttribute(
341+ "position",
342+ new THREE.BufferAttribute(positions, 3)
343+ );
344+ geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
345+ geometry.setIndex(indices);
346+ geometry.computeVertexNormals();
347+
348+ // Determine effective shading mode
349+ const shadingMode = shading ?? "faceted";
350+ const useFlat = shadingMode === "faceted" || shadingMode === "flat";
351+
352+ // Face material
353+ const showFaces = trace.faceColor !== "none";
354+ if (showFaces) {
355+ let faceMaterial: THREE.Material;
356+ if (Array.isArray(trace.faceColor)) {
357+ const [r, g, b] = trace.faceColor;
358+ faceMaterial = new THREE.MeshPhongMaterial({
359+ color: new THREE.Color(r, g, b),
360+ flatShading: useFlat,
361+ opacity: alpha,
362+ transparent: alpha < 1,
363+ side: THREE.DoubleSide,
364+ });
365+ } else {
366+ faceMaterial = new THREE.MeshPhongMaterial({
367+ vertexColors: true,
368+ flatShading: useFlat,
369+ opacity: alpha,
370+ transparent: alpha < 1,
371+ side: THREE.DoubleSide,
372+ });
373+ }
374+ scene.add(new THREE.Mesh(geometry, faceMaterial));
375+ }
376+
377+ // Edge wireframe — hidden for "flat" and "interp" shading modes
378+ const showEdges = trace.edgeColor !== "none" && shadingMode === "faceted";
379+ if (showEdges) {
380+ const edgePositions: number[] = [];
381+ const edgeColors: number[] = [];
382+ for (let i = 0; i < rows; i++) {
383+ for (let j = 0; j < cols; j++) {
384+ const vi = i * cols + j;
385+ // Horizontal edge (to the right)
386+ if (j < cols - 1) {
387+ const vi2 = i * cols + (j + 1);
388+ edgePositions.push(
389+ positions[vi * 3],
390+ positions[vi * 3 + 1],
391+ positions[vi * 3 + 2],
392+ positions[vi2 * 3],
393+ positions[vi2 * 3 + 1],
394+ positions[vi2 * 3 + 2]
395+ );
396+ edgeColors.push(
397+ colors[vi * 3],
398+ colors[vi * 3 + 1],
399+ colors[vi * 3 + 2],
400+ colors[vi2 * 3],
401+ colors[vi2 * 3 + 1],
402+ colors[vi2 * 3 + 2]
403+ );
404+ }
405+ // Vertical edge (downward)
406+ if (i < rows - 1) {
407+ const vi2 = (i + 1) * cols + j;
408+ edgePositions.push(
409+ positions[vi * 3],
410+ positions[vi * 3 + 1],
411+ positions[vi * 3 + 2],
412+ positions[vi2 * 3],
413+ positions[vi2 * 3 + 1],
414+ positions[vi2 * 3 + 2]
415+ );
416+ edgeColors.push(
417+ colors[vi * 3],
418+ colors[vi * 3 + 1],
419+ colors[vi * 3 + 2],
420+ colors[vi2 * 3],
421+ colors[vi2 * 3 + 1],
422+ colors[vi2 * 3 + 2]
423+ );
424+ }
425+ }
426+ }
427+
428+ const edgeGeometry = new THREE.BufferGeometry();
429+ edgeGeometry.setAttribute(
430+ "position",
431+ new THREE.Float32BufferAttribute(edgePositions, 3)
432+ );
433+
434+ let edgeMat: THREE.LineBasicMaterial;
435+ if (Array.isArray(trace.edgeColor)) {
436+ const [r, g, b] = trace.edgeColor;
437+ edgeMat = new THREE.LineBasicMaterial({
438+ color: new THREE.Color(r, g, b),
439+ });
440+ } else {
441+ edgeMat = new THREE.LineBasicMaterial({
442+ color: 0x000000,
443+ opacity: 0.3,
444+ transparent: true,
445+ });
446+ }
447+ scene.add(new THREE.LineSegments(edgeGeometry, edgeMat));
448+ }
449+ }
450+
451+ // ── Render plot3 traces ─────────────────────────────────────────────
452+ for (let ti = 0; ti < plot3Traces.length; ti++) {
453+ const trace = plot3Traces[ti];
454+ const { x, y, z } = trace;
455+
456+ // Determine color
457+ const defaultColor = TRACE_COLORS[ti % TRACE_COLORS.length];
458+ const color = trace.color ?? defaultColor;
459+ const threeColor = new THREE.Color(color[0], color[1], color[2]);
460+
461+ // Build line points (skip NaN/Inf to create line breaks)
462+ const showLine = trace.lineStyle !== "none";
463+ if (showLine) {
464+ // Build segments of consecutive finite points
465+ const segments: THREE.Vector3[][] = [];
466+ let currentSegment: THREE.Vector3[] = [];
467+
468+ for (let i = 0; i < x.length; i++) {
469+ if (isFinite(x[i]) && isFinite(y[i]) && isFinite(z[i])) {
470+ const nx = norm(x[i], cxData);
471+ const ny = norm(y[i], cyData);
472+ const nz = norm(z[i], czData);
473+ // Map: data X→three X, data Z→three Y, data Y→three Z
474+ currentSegment.push(new THREE.Vector3(nx, nz, ny));
475+ } else {
476+ if (currentSegment.length > 0) {
477+ segments.push(currentSegment);
478+ currentSegment = [];
479+ }
480+ }
481+ }
482+ if (currentSegment.length > 0) {
483+ segments.push(currentSegment);
484+ }
485+
486+ // Use Line2 + LineMaterial for proper line width support
487+ // (THREE.LineBasicMaterial.linewidth is ignored on most platforms)
488+ const lw = trace.lineWidth ?? 2;
489+ const isDashed =
490+ trace.lineStyle === "--" ||
491+ trace.lineStyle === ":" ||
492+ trace.lineStyle === "-.";
493+
494+ for (const seg of segments) {
495+ if (seg.length < 2) continue;
496+ const positions: number[] = [];
497+ for (const pt of seg) {
498+ positions.push(pt.x, pt.y, pt.z);
499+ }
500+
501+ if (isDashed) {
502+ // Fall back to LineDashedMaterial for dash patterns
503+ // (Line2/LineMaterial doesn't support dashes)
504+ const dashedMat = new THREE.LineDashedMaterial({
505+ color: threeColor,
506+ linewidth: lw,
507+ dashSize: trace.lineStyle === ":" ? 0.01 : 0.03,
508+ gapSize: trace.lineStyle === ":" ? 0.02 : 0.015,
509+ });
510+ const geo = new THREE.BufferGeometry().setFromPoints(seg);
511+ const line = new THREE.Line(geo, dashedMat);
512+ line.computeLineDistances();
513+ scene.add(line);
514+ } else {
515+ const geo = new LineGeometry();
516+ geo.setPositions(positions);
517+ const mat = new LineMaterial({
518+ color: threeColor.getHex(),
519+ linewidth: lw,
520+ worldUnits: false,
521+ resolution: new THREE.Vector2(
522+ st.renderer.domElement.width || 800,
523+ st.renderer.domElement.height || 600
524+ ),
525+ });
526+ scene.add(new Line2(geo, mat));
527+ }
528+ }
529+ }
530+
531+ // Draw markers as small spheres/points
532+ if (trace.marker && trace.marker !== "none") {
533+ const markerSize = (trace.markerSize ?? 6) / 600; // scale to normalized space
534+ const markerColor = trace.markerEdgeColor
535+ ? new THREE.Color(
536+ trace.markerEdgeColor[0],
537+ trace.markerEdgeColor[1],
538+ trace.markerEdgeColor[2]
539+ )
540+ : threeColor;
541+
542+ const indices = trace.markerIndices
543+ ? trace.markerIndices.map(i => i - 1) // 1-based
544+ : Array.from({ length: x.length }, (_, i) => i);
545+
546+ const markerGeo = new THREE.SphereGeometry(markerSize, 8, 8);
547+ const markerMat = new THREE.MeshBasicMaterial({ color: markerColor });
548+
549+ for (const i of indices) {
550+ if (i < 0 || i >= x.length) continue;
551+ if (!isFinite(x[i]) || !isFinite(y[i]) || !isFinite(z[i])) continue;
552+ const nx = norm(x[i], cxData);
553+ const ny = norm(y[i], cyData);
554+ const nz = norm(z[i], czData);
555+ const mesh = new THREE.Mesh(markerGeo, markerMat);
556+ mesh.position.set(nx, nz, ny);
557+ scene.add(mesh);
558+ }
559+ }
560+ }
561+
562+ // ── Render quiver3 traces (3-D arrows) ───────────────────────────────
563+ for (const trace of quiver3Traces) {
564+ const { x, y, z, u, v, w } = trace;
565+ const color = trace.color ?? [0, 0.447, 0.741];
566+ const threeColor = new THREE.Color(color[0], color[1], color[2]);
567+ const lw = trace.lineWidth ?? 0.5;
568+ // data (x,y,z) → three (X, Z, Y), matching the surf/plot3 mapping.
569+ const toThree = (dx: number, dy: number, dz: number) =>
570+ new THREE.Vector3(norm(dx, cxData), norm(dz, czData), norm(dy, cyData));
571+ const up = new THREE.Vector3(0, 1, 0);
572+ const segs: number[] = []; // pairs of endpoints for LineSegments
573+
574+ for (let i = 0; i < x.length; i++) {
575+ if (
576+ !isFinite(x[i]) ||
577+ !isFinite(y[i]) ||
578+ !isFinite(z[i]) ||
579+ !isFinite(u[i]) ||
580+ !isFinite(v[i]) ||
581+ !isFinite(w[i])
582+ )
583+ continue;
584+ const tail = toThree(x[i], y[i], z[i]);
585+ const head = toThree(x[i] + u[i], y[i] + v[i], z[i] + w[i]);
586+ // Shaft
587+ segs.push(tail.x, tail.y, tail.z, head.x, head.y, head.z);
588+
589+ if (trace.showArrowHead) {
590+ const dir = new THREE.Vector3().subVectors(head, tail);
591+ const len = dir.length();
592+ if (len > 1e-9) {
593+ dir.multiplyScalar(1 / len);
594+ let perp = new THREE.Vector3().crossVectors(dir, up);
595+ if (perp.lengthSq() < 1e-12)
596+ perp = new THREE.Vector3().crossVectors(
597+ dir,
598+ new THREE.Vector3(1, 0, 0)
599+ );
600+ perp.normalize();
601+ const barb = Math.min(0.3 * len, len);
602+ const back = dir.clone().multiplyScalar(-1);
603+ const cosA = Math.cos((20 * Math.PI) / 180);
604+ const sinA = Math.sin((20 * Math.PI) / 180);
605+ const b1 = head
606+ .clone()
607+ .addScaledVector(back, barb * cosA)
608+ .addScaledVector(perp, barb * sinA);
609+ const b2 = head
610+ .clone()
611+ .addScaledVector(back, barb * cosA)
612+ .addScaledVector(perp, -barb * sinA);
613+ segs.push(head.x, head.y, head.z, b1.x, b1.y, b1.z);
614+ segs.push(head.x, head.y, head.z, b2.x, b2.y, b2.z);
615+ }
616+ }
617+ }
618+
619+ if (segs.length > 0) {
620+ const geo = new THREE.BufferGeometry();
621+ geo.setAttribute("position", new THREE.Float32BufferAttribute(segs, 3));
622+ const mat = new THREE.LineBasicMaterial({
623+ color: threeColor,
624+ linewidth: lw,
625+ });
626+ scene.add(new THREE.LineSegments(geo, mat));
627+ }
628+
629+ // Markers at the arrow bases (LineSpec marker or 'filled').
630+ if (trace.marker && trace.marker !== "none") {
631+ const markerSize = 6 / 600;
632+ const markerGeo = new THREE.SphereGeometry(markerSize, 8, 8);
633+ const markerMat = new THREE.MeshBasicMaterial({ color: threeColor });
634+ for (let i = 0; i < x.length; i++) {
635+ if (!isFinite(x[i]) || !isFinite(y[i]) || !isFinite(z[i])) continue;
636+ const p = toThree(x[i], y[i], z[i]);
637+ const mesh = new THREE.Mesh(markerGeo, markerMat);
638+ mesh.position.set(p.x, p.y, p.z);
639+ scene.add(mesh);
640+ }
641+ }
642+ }
643+
644+ // ── Render bar3 traces (vertical 3D bars) ────────────────────────────
645+ for (const trace of bar3Traces) {
646+ const halfW = (trace.width / 2) * 0.9; // slight shrink to show gaps
647+ const zRangeT = zMax - zMin || 1;
648+ for (let i = 0; i < trace.x.length; i++) {
649+ const bx = trace.x[i];
650+ const by = trace.y[i];
651+ const bz = trace.z[i];
652+ if (!isFinite(bz)) continue;
653+
654+ const barHeight = Math.abs(normBarZ(bz, czData) - normBarZ(0, czData));
655+ const barCenter = (normBarZ(bz, czData) + normBarZ(0, czData)) / 2;
656+
657+ const geo = new THREE.BoxGeometry(
658+ (halfW * 2) / barRangeMax,
659+ barHeight,
660+ (halfW * 2) / barRangeMax
661+ );
662+
663+ const t = (bz - zMin) / zRangeT;
664+ const [cr, cg, cb] = trace.color ?? colormapLookup(t);
665+ const mat = new THREE.MeshPhongMaterial({
666+ color: new THREE.Color(cr, cg, cb),
667+ });
668+ const mesh = new THREE.Mesh(geo, mat);
669+ // data X→three X, data Z→three Y, data Y→three Z
670+ mesh.position.set(normBar(bx, cxData), barCenter, normBar(by, cyData));
671+ scene.add(mesh);
672+
673+ // Edge wireframe
674+ const edges = new THREE.EdgesGeometry(geo);
675+ const lineMat = new THREE.LineBasicMaterial({
676+ color: 0x000000,
677+ opacity: 0.3,
678+ transparent: true,
679+ });
680+ const wireframe = new THREE.LineSegments(edges, lineMat);
681+ wireframe.position.copy(mesh.position);
682+ scene.add(wireframe);
683+ }
684+ }
685+
686+ // ── Render bar3h traces (horizontal 3D bars) ───────────────────────
687+ for (const trace of bar3hTraces) {
688+ const halfW = (trace.width / 2) * 0.9;
689+ const xRangeH = xMax - xMin || 1;
690+ // bar3h: x=positions (category axis, mapped to z-axis in MATLAB),
691+ // y=bar lengths (value axis, mapped to y/horizontal),
692+ // z values are the bar lengths, x values are positions
693+ // Reinterpret: y-positions on z-axis, x-values are bar lengths on x-axis
694+ for (let i = 0; i < trace.x.length; i++) {
695+ const pos = trace.y[i]; // position on y-axis
696+ const colIdx = trace.x[i]; // position on x-axis (column)
697+ const len = trace.z[i]; // bar length along x-axis
698+ if (!isFinite(len)) continue;
699+
700+ const barLength = Math.abs(normBar(len, cxData) - normBar(0, cxData));
701+ const barCenter = (normBar(len, cxData) + normBar(0, cxData)) / 2;
702+
703+ const geo = new THREE.BoxGeometry(
704+ barLength,
705+ (halfW * 2) / barRangeMax,
706+ (halfW * 2) / barRangeMax
707+ );
708+
709+ const t = (len - xMin) / xRangeH;
710+ const [cr, cg, cb] = trace.color ?? colormapLookup(t);
711+ const mat = new THREE.MeshPhongMaterial({
712+ color: new THREE.Color(cr, cg, cb),
713+ });
714+ const mesh = new THREE.Mesh(geo, mat);
715+ mesh.position.set(
716+ barCenter,
717+ normBar(colIdx, czData),
718+ normBar(pos, cyData)
719+ );
720+ scene.add(mesh);
721+
722+ const edges = new THREE.EdgesGeometry(geo);
723+ const lineMat = new THREE.LineBasicMaterial({
724+ color: 0x000000,
725+ opacity: 0.3,
726+ transparent: true,
727+ });
728+ const wireframe = new THREE.LineSegments(edges, lineMat);
729+ wireframe.position.copy(mesh.position);
730+ scene.add(wireframe);
731+ }
732+ }
733+
734+ // Axis lines (hidden by `axis off`)
735+ if (axisVisible !== false) {
736+ addAxisLines(
737+ scene,
738+ xMin,
739+ xMax,
740+ yMin,
741+ yMax,
742+ zMin,
743+ zMax,
744+ rangeMax,
745+ cxData,
746+ cyData,
747+ czData
748+ );
749+ }
750+ }, [
751+ surfTraces,
752+ plot3Traces,
753+ bar3Traces,
754+ bar3hTraces,
755+ quiver3Traces,
756+ shading,
757+ axisVisible,
758+ ]);
759+
760+ // Compute color range for the colorbar from surf traces (uses C if present,
761+ // otherwise Z). Falls back to bar3 z values when no surf traces are present.
762+ let cbMin = Infinity;
763+ let cbMax = -Infinity;
764+ for (const t of surfTraces) {
765+ const arr = t.c ?? t.z;
766+ for (const v of arr) {
767+ if (isFinite(v)) {
768+ if (v < cbMin) cbMin = v;
769+ if (v > cbMax) cbMax = v;
770+ }
771+ }
772+ }
773+ if (!isFinite(cbMin)) {
774+ for (const t of bar3Traces) {
775+ for (const v of t.z) {
776+ if (isFinite(v)) {
777+ if (v < cbMin) cbMin = v;
778+ if (v > cbMax) cbMax = v;
779+ }
780+ }
781+ }
782+ }
783+ const haveColorRange = isFinite(cbMin) && isFinite(cbMax);
784+ if (cbMin === cbMax) {
785+ cbMin -= 0.5;
786+ cbMax += 0.5;
787+ }
788+
789+ return (
790+ <div style={{ position: "relative", width: "100%", height: "100%" }}>
791+ <div ref={containerRef} style={{ position: "absolute", inset: 0 }} />
792+ {colorbar && haveColorRange && (
793+ <ColorbarOverlay
794+ location={(colorbarLocation ?? "eastoutside").toLowerCase()}
795+ dMin={cbMin}
796+ dMax={cbMax}
797+ colormap={colormap}
798+ />
799+ )}
800+ </div>
801+ );
802+}
803+
804+// ── Colorbar overlay (HTML, drawn on top of the Three.js canvas) ────────
805+
806+function ColorbarOverlay({
807+ location,
808+ dMin,
809+ dMax,
810+ colormap,
811+}: {
812+ location: string;
813+ dMin: number;
814+ dMax: number;
815+ colormap?: string;
816+}) {
817+ // Build a CSS gradient from N samples of the colormap.
818+ // (colormap name is currently unused — surfColormap.colormapLookup uses parula.)
819+ void colormap;
820+ const N = 32;
821+ const stops: string[] = [];
822+ for (let i = 0; i < N; i++) {
823+ const t = i / (N - 1);
824+ const [r, g, b] = colormapLookup(t);
825+ const rgb = `rgb(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)})`;
826+ stops.push(`${rgb} ${(t * 100).toFixed(2)}%`);
827+ }
828+ const horizontal =
829+ location === "northoutside" ||
830+ location === "southoutside" ||
831+ location === "north" ||
832+ location === "south";
833+ // Vertical gradients go bottom→top so the max sits at the top.
834+ const gradient = horizontal
835+ ? `linear-gradient(to right, ${stops.join(",")})`
836+ : `linear-gradient(to top, ${stops.join(",")})`;
837+
838+ const fmt = (v: number) =>
839+ Number.isInteger(v) ? String(v) : v.toPrecision(3);
840+
841+ // Position styles per location
842+ const barThickness = 16;
843+ const containerStyle: CSSProperties = {
844+ position: "absolute",
845+ pointerEvents: "none",
846+ fontFamily: "sans-serif",
847+ fontSize: 10,
848+ color: "#333",
849+ };
850+
851+ const barStyle: CSSProperties = {
852+ background: gradient,
853+ border: "1px solid #999",
854+ boxSizing: "border-box",
855+ };
856+
857+ switch (location) {
858+ case "eastoutside":
859+ return (
860+ <div
861+ style={{
862+ ...containerStyle,
863+ top: 12,
864+ bottom: 12,
865+ right: 8,
866+ width: 50,
867+ display: "flex",
868+ alignItems: "stretch",
869+ }}
870+ >
871+ <div style={{ ...barStyle, width: barThickness, height: "100%" }} />
872+ <div
873+ style={{
874+ marginLeft: 4,
875+ display: "flex",
876+ flexDirection: "column",
877+ justifyContent: "space-between",
878+ }}
879+ >
880+ <span>{fmt(dMax)}</span>
881+ <span>{fmt(dMin)}</span>
882+ </div>
883+ </div>
884+ );
885+ case "westoutside":
886+ return (
887+ <div
888+ style={{
889+ ...containerStyle,
890+ top: 12,
891+ bottom: 12,
892+ left: 8,
893+ width: 50,
894+ display: "flex",
895+ alignItems: "stretch",
896+ flexDirection: "row-reverse",
897+ }}
898+ >
899+ <div style={{ ...barStyle, width: barThickness, height: "100%" }} />
900+ <div
901+ style={{
902+ marginRight: 4,
903+ display: "flex",
904+ flexDirection: "column",
905+ justifyContent: "space-between",
906+ textAlign: "right",
907+ }}
908+ >
909+ <span>{fmt(dMax)}</span>
910+ <span>{fmt(dMin)}</span>
911+ </div>
912+ </div>
913+ );
914+ case "northoutside":
915+ return (
916+ <div
917+ style={{
918+ ...containerStyle,
919+ left: 12,
920+ right: 12,
921+ top: 8,
922+ height: 32,
923+ display: "flex",
924+ flexDirection: "column",
925+ }}
926+ >
927+ <div
928+ style={{
929+ display: "flex",
930+ justifyContent: "space-between",
931+ marginBottom: 2,
932+ }}
933+ >
934+ <span>{fmt(dMin)}</span>
935+ <span>{fmt(dMax)}</span>
936+ </div>
937+ <div style={{ ...barStyle, height: barThickness, width: "100%" }} />
938+ </div>
939+ );
940+ case "southoutside":
941+ return (
942+ <div
943+ style={{
944+ ...containerStyle,
945+ left: 12,
946+ right: 12,
947+ bottom: 8,
948+ height: 32,
949+ display: "flex",
950+ flexDirection: "column-reverse",
951+ }}
952+ >
953+ <div
954+ style={{
955+ display: "flex",
956+ justifyContent: "space-between",
957+ marginTop: 2,
958+ }}
959+ >
960+ <span>{fmt(dMin)}</span>
961+ <span>{fmt(dMax)}</span>
962+ </div>
963+ <div style={{ ...barStyle, height: barThickness, width: "100%" }} />
964+ </div>
965+ );
966+ case "east":
967+ return (
968+ <div
969+ style={{
970+ ...containerStyle,
971+ top: 24,
972+ bottom: 24,
973+ right: 24,
974+ width: 50,
975+ display: "flex",
976+ flexDirection: "row-reverse",
977+ alignItems: "stretch",
978+ }}
979+ >
980+ <div style={{ ...barStyle, width: barThickness, height: "100%" }} />
981+ <div
982+ style={{
983+ marginRight: 4,
984+ display: "flex",
985+ flexDirection: "column",
986+ justifyContent: "space-between",
987+ textAlign: "right",
988+ }}
989+ >
990+ <span>{fmt(dMax)}</span>
991+ <span>{fmt(dMin)}</span>
992+ </div>
993+ </div>
994+ );
995+ case "west":
996+ return (
997+ <div
998+ style={{
999+ ...containerStyle,
1000+ top: 24,
1001+ bottom: 24,
1002+ left: 24,
1003+ width: 50,
1004+ display: "flex",
1005+ alignItems: "stretch",
1006+ }}
1007+ >
1008+ <div style={{ ...barStyle, width: barThickness, height: "100%" }} />
1009+ <div
1010+ style={{
1011+ marginLeft: 4,
1012+ display: "flex",
1013+ flexDirection: "column",
1014+ justifyContent: "space-between",
1015+ }}
1016+ >
1017+ <span>{fmt(dMax)}</span>
1018+ <span>{fmt(dMin)}</span>
1019+ </div>
1020+ </div>
1021+ );
1022+ case "north":
1023+ return (
1024+ <div
1025+ style={{
1026+ ...containerStyle,
1027+ left: 24,
1028+ right: 24,
1029+ top: 24,
1030+ height: 32,
1031+ display: "flex",
1032+ flexDirection: "column-reverse",
1033+ }}
1034+ >
1035+ <div
1036+ style={{
1037+ display: "flex",
1038+ justifyContent: "space-between",
1039+ marginTop: 2,
1040+ }}
1041+ >
1042+ <span>{fmt(dMin)}</span>
1043+ <span>{fmt(dMax)}</span>
1044+ </div>
1045+ <div style={{ ...barStyle, height: barThickness, width: "100%" }} />
1046+ </div>
1047+ );
1048+ case "south":
1049+ return (
1050+ <div
1051+ style={{
1052+ ...containerStyle,
1053+ left: 24,
1054+ right: 24,
1055+ bottom: 24,
1056+ height: 32,
1057+ display: "flex",
1058+ flexDirection: "column",
1059+ }}
1060+ >
1061+ <div
1062+ style={{
1063+ display: "flex",
1064+ justifyContent: "space-between",
1065+ marginBottom: 2,
1066+ }}
1067+ >
1068+ <span>{fmt(dMin)}</span>
1069+ <span>{fmt(dMax)}</span>
1070+ </div>
1071+ <div style={{ ...barStyle, height: barThickness, width: "100%" }} />
1072+ </div>
1073+ );
1074+ default:
1075+ return null;
1076+ }
1077+}
1078+
1079+function addAxisLines(
1080+ scene: THREE.Scene,
1081+ xMin: number,
1082+ xMax: number,
1083+ yMin: number,
1084+ yMax: number,
1085+ zMin: number,
1086+ zMax: number,
1087+ rangeMax: number,
1088+ cxData: number,
1089+ cyData: number,
1090+ czData: number
1091+) {
1092+ const norm = (v: number, center: number) => (v - center) / rangeMax;
1093+
1094+ const axes: {
1095+ from: [number, number, number];
1096+ to: [number, number, number];
1097+ }[] = [
1098+ { from: [xMin, yMin, zMin], to: [xMax, yMin, zMin] },
1099+ { from: [xMin, yMin, zMin], to: [xMin, yMax, zMin] },
1100+ { from: [xMin, yMin, zMin], to: [xMin, yMin, zMax] },
1101+ ];
1102+
1103+ const mat = new THREE.LineBasicMaterial({ color: 0x333333 });
1104+
1105+ for (const axis of axes) {
1106+ const pts = [axis.from, axis.to].map(([ax, ay, az]) => {
1107+ const nx = norm(ax, cxData);
1108+ const ny = norm(ay, cyData);
1109+ const nz = norm(az, czData);
1110+ return new THREE.Vector3(nx, nz, ny); // data X→X, data Z→Y, data Y→Z
1111+ });
1112+ const geo = new THREE.BufferGeometry().setFromPoints(pts);
1113+ scene.add(new THREE.Line(geo, mat));
1114+ }
1115+}
app/src/render/surfColormap.tsadded+29−0View file
@@ -0,0 +1,29 @@
1+/** Parula-inspired colormap for surface plots */
2+
3+const COLORMAP: [number, number, number][] = [
4+ [0.2422, 0.1504, 0.6603],
5+ [0.281, 0.3228, 0.9579],
6+ [0.1786, 0.5289, 0.9682],
7+ [0.0689, 0.6948, 0.8394],
8+ [0.128, 0.789, 0.5927],
9+ [0.3391, 0.849, 0.3798],
10+ [0.633, 0.8518, 0.2091],
11+ [0.8902, 0.8044, 0.1137],
12+ [0.9905, 0.6816, 0.0235],
13+ [0.9763, 0.517, 0.034],
14+];
15+
16+export function colormapLookup(t: number): [number, number, number] {
17+ // Treat NaN/non-finite as 0 so we never index outside COLORMAP.
18+ const safe = Number.isFinite(t) ? t : 0;
19+ const clamped = Math.max(0, Math.min(1, safe));
20+ const idx = clamped * (COLORMAP.length - 1);
21+ const lo = Math.floor(idx);
22+ const hi = Math.min(lo + 1, COLORMAP.length - 1);
23+ const frac = idx - lo;
24+ return [
25+ COLORMAP[lo][0] + frac * (COLORMAP[hi][0] - COLORMAP[lo][0]),
26+ COLORMAP[lo][1] + frac * (COLORMAP[hi][1] - COLORMAP[lo][1]),
27+ COLORMAP[lo][2] + frac * (COLORMAP[hi][2] - COLORMAP[lo][2]),
28+ ];
29+}
app/src/render/types.tsadded+67−0View file
@@ -0,0 +1,67 @@
1+// Trace types consumed by SurfView — duplicated from numbl's src/graphics/types.ts
2+// so this app renders surfaces with the exact same code numbl uses.
3+
4+export interface Plot3Trace {
5+ x: number[];
6+ y: number[];
7+ z: number[];
8+ lineStyle?: string;
9+ marker?: string;
10+ color?: [number, number, number];
11+ lineWidth?: number;
12+ markerSize?: number;
13+ markerEdgeColor?: [number, number, number];
14+ markerFaceColor?: [number, number, number];
15+ markerIndices?: number[];
16+ id?: number;
17+}
18+
19+export interface SurfTrace {
20+ /** X coordinates: flat array of length rows*cols (column-major) */
21+ x: number[];
22+ /** Y coordinates: flat array of length rows*cols (column-major) */
23+ y: number[];
24+ /** Z values: flat array of length rows*cols (column-major) */
25+ z: number[];
26+ /** Number of rows in the grid */
27+ rows: number;
28+ /** Number of columns in the grid */
29+ cols: number;
30+ /** Optional color data (same shape as Z) */
31+ c?: number[];
32+ edgeColor?: [number, number, number] | "none" | "flat" | "interp";
33+ faceColor?:
34+ | [number, number, number]
35+ | "flat"
36+ | "interp"
37+ | "none"
38+ | "texturemap";
39+ faceAlpha?: number;
40+}
41+
42+export interface Bar3Trace {
43+ x: number[];
44+ y: number[];
45+ z: number[];
46+ rows: number;
47+ cols: number;
48+ width: number;
49+ color?: [number, number, number];
50+}
51+
52+export interface Quiver3Trace {
53+ x: number[];
54+ y: number[];
55+ z: number[];
56+ u: number[];
57+ v: number[];
58+ w: number[];
59+ showArrowHead: boolean;
60+ color?: [number, number, number];
61+ lineStyle?: string;
62+ lineWidth?: number;
63+ marker?: string;
64+ markerFilled?: boolean;
65+ autoScale?: boolean;
66+ autoScaleFactor?: number;
67+}
app/tsconfig.jsonadded+20−0View file
@@ -0,0 +1,20 @@
1+{
2+ "compilerOptions": {
3+ "target": "ESNext",
4+ "useDefineForClassFields": true,
5+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
6+ "module": "ESNext",
7+ "skipLibCheck": true,
8+ "moduleResolution": "bundler",
9+ "allowImportingTsExtensions": true,
10+ "resolveJsonModule": true,
11+ "isolatedModules": true,
12+ "noEmit": true,
13+ "jsx": "react-jsx",
14+ "strict": true,
15+ "noUnusedLocals": false,
16+ "noUnusedParameters": false,
17+ "noFallthroughCasesInSwitch": true
18+ },
19+ "include": ["src"]
20+}
app/vite.config.tsadded+16−0View file
@@ -0,0 +1,16 @@
1+import { defineConfig } from "vite";
2+import react from "@vitejs/plugin-react";
3+import { viteSingleFile } from "vite-plugin-singlefile";
4+
5+// Builds the whole app (JS + CSS, including three.js) inlined into a single
6+// dist/index.html, so the numbl `.m` can read it with `fileread` and pass it to
7+// uihtml (HTMLSource) — no supporting files, works in numbl and real MATLAB.
8+export default defineConfig({
9+ plugins: [react(), viteSingleFile()],
10+ build: {
11+ target: "esnext",
12+ cssCodeSplit: false,
13+ assetsInlineLimit: 100_000_000,
14+ chunkSizeWarningLimit: 100_000_000,
15+ },
16+});
numbl-project.jsonadded+4−0View file
@@ -0,0 +1,4 @@
1+{
2+ "title": "Interactive surface mesh refinement",
3+ "entry": "README.md"
4+}
refine_demo.madded+68−0View file
@@ -0,0 +1,68 @@
1+function refine_demo
2+%REFINE_DEMO Interactive surfacefun mesh refinement via a uihtml figure.
3+%
4+% Runs in the numbl IDE and via `numbl run --plot`. Shows a cubed-sphere
5+% surfacemesh rendered with numbl's surface renderer (duplicated into the
6+% bundled web app). Drag the "Refinement level" slider in the figure: the
7+% page sends the level to this script, which refines the base mesh with
8+% surfacefun and sends the new patches back — the figure re-renders without
9+% losing the camera orientation.
10+%
11+% The web app is prebuilt into app/dist/index.html (cd app && npm run build).
12+
13+mip load --install flatironinstitute/flatironinstitute/surfacefun
14+
15+here = fileparts(mfilename('fullpath'));
16+html = fileread(fullfile(here, 'app', 'dist', 'index.html'));
17+
18+maxLevel = 3; % slider range 0..maxLevel
19+n = 8; % Chebyshev order per patch
20+dom0 = surfacemesh.sphere(n); % base cubed sphere (6 patches)
21+
22+data0 = meshToData(dom0, 0, maxLevel);
23+
24+fig = figure;
25+gl = uigridlayout(fig, [1 1], 'Padding', [0 0 0 0], ...
26+ 'RowHeight', {'1x'}, 'ColumnWidth', {'1x'});
27+uihtml(gl, 'HTMLSource', html, 'Data', data0, ...
28+ 'HTMLEventReceivedFcn', @(src, ev) onRefine(src, ev, dom0, maxLevel));
29+end
30+
31+function onRefine(src, ev, dom0, maxLevel)
32+% Refine the base mesh to the requested absolute level and send it back.
33+if ~strcmp(ev.HTMLEventName, 'refine')
34+ return
35+end
36+level = max(0, min(maxLevel, round(ev.HTMLEventData)));
37+if level == 0
38+ dom = dom0;
39+else
40+ dom = refine(dom0, level);
41+end
42+sendEventToHTMLSource(src, 'mesh', meshToData(dom, level, maxLevel));
43+fprintf('refined to level %d: %d patches\n', level, length(dom));
44+end
45+
46+function data = meshToData(dom, level, maxLevel)
47+% Pack a surfacemesh into a plain struct the web app can render: one flat
48+% (column-major) x/y/z array per patch, each an n-by-n grid.
49+np = length(dom);
50+px = cell(1, np);
51+py = cell(1, np);
52+pz = cell(1, np);
53+for k = 1:np
54+ % real() drops any (all-zero) imaginary part the solver/JIT may carry;
55+ % the geometry is real and jsonencode (numbl and MATLAB) rejects complex.
56+ px{k} = real(dom.x{k}(:).');
57+ py{k} = real(dom.y{k}(:).');
58+ pz{k} = real(dom.z{k}(:).');
59+end
60+data = struct();
61+data.n = size(dom.x{1}, 1);
62+data.x = px;
63+data.y = py;
64+data.z = pz;
65+data.npatches = np;
66+data.level = level;
67+data.maxLevel = maxLevel;
68+end