concept-collection / surfacefun-interactive
Add interactive vector field demo with arrow scale slider
Creates vector_field_demo.m and vector_field_viewer.m: a surfacemesh sphere with a tangent vector field (projection of (1,0,0) onto each patch). The arrow scale slider in the figure is purely client-side — no MATLAB round-trip needed, so it responds instantly. App.tsx now detects data.type === 'vectorfield' and renders the arrow-scale panel instead of the refinement panel. Both demos share the same built app. Also adds CLAUDE.md with architecture notes for future agents. Co-authored-by: Jeremy Magland <magland@users.noreply.github.com>
claude[bot] <41898282+claude[bot]@users.noreply.github.com> committed commit 31eb018ca06f parent 0d4c10f Browse files
6 changed files+240−43
CLAUDE.mdadded+38−0View file
@@ -0,0 +1,38 @@
1+# CLAUDE.md
2+
3+Tips for future agents working in this repo.
4+
5+## Architecture
6+
7+- `app/` — React/Three.js single-file widget (`npm run build` → `app/dist/index.html`)
8+- `*.m` at root — MATLAB scripts users open and run in [numbl](https://numbl.org)
9+- `numbl-project.json` — project metadata; `entry` is the landing page
10+
11+## Data flow
12+
13+**MATLAB → figure:** set `uihtml(..., 'Data', struct)` or call `sendEventToHTMLSource(src, 'eventName', data)`
14+
15+**Figure → MATLAB:** call `sendToMATLAB('eventName', value)` (see `bridge.ts`); receive via `HTMLEventReceivedFcn`
16+
17+## Adding a new demo
18+
19+1. Create a MATLAB plumbing script (e.g. `new_demo_viewer.m`) — see `mesh_refiner.m` or `vector_field_viewer.m`
20+2. Create a user-facing script (e.g. `new_demo.m`) — thin wrapper that builds data and calls the viewer
21+3. In `App.tsx`, add a type guard for the new data format and render appropriate controls
22+4. Update `README.md` to link the new script
23+
24+## Client-side vs. server-side interactivity
25+
26+- **Low-latency control** (e.g. arrow scaling): update React state only — instant, no MATLAB round-trip
27+- **Computation-heavy change** (e.g. mesh refinement): `sendToMATLAB(event, value)` → MATLAB recomputes → `sendEventToHTMLSource` → React re-renders
28+
29+## Key files
30+
31+| File | Purpose |
32+|------|---------|
33+| `app/src/App.tsx` | Main React component; detects data type, renders controls |
34+| `app/src/render/SurfView.tsx` | Three.js renderer; supports surf, quiver3, plot3, bar3 traces |
35+| `app/src/render/types.ts` | TypeScript types for trace formats |
36+| `app/src/bridge.ts` | `onData` / `onHostEvent` / `sendToMATLAB` helpers |
37+| `mesh_refiner.m` | Opens figure, sends mesh, handles refinement callbacks |
38+| `vector_field_viewer.m` | Opens figure, sends surface + vector field once (no callback) |
README.mdmodified+14−8View file
@@ -1,4 +1,4 @@
1-# Interactive surface mesh refinement
1+# Interactive surfacefun demos
22
33 Runs in your browser through [numbl](https://numbl.org) — no install.
44
@@ -9,17 +9,23 @@ appears in the figure. Drag the **Refinement level** slider: the page sends the
99 level back to the script, which refines the mesh with surfacefun and returns the
1010 new patches — the surface re-renders in place (drag to rotate, scroll to zoom).
1111
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.
12+## ▶ [Open `vector_field_demo.m`](vector_field_demo.m) and click **Run**
13+
14+A tangent vector field (arrows) is displayed on a sphere. Use the **Arrow
15+scale** slider to make the arrows larger or smaller — scaling updates instantly
16+in the browser with no round-trip to the script. The field is the tangential
17+projection of a constant vector onto the surface, giving a smooth "wind" pattern.
1418
1519 ## How it works
1620
17-- [`refine_demo.m`](refine_demo.m) — the whole user-facing script: load
18- surfacefun, build a mesh, and hand it to `mesh_refiner`.
19-- `mesh_refiner.m` — the plumbing: opens the figure, sends the mesh to it, and
20- refines the mesh on each slider change (the script → figure → script wiring).
21+- [`refine_demo.m`](refine_demo.m) — builds a mesh and hands it to `mesh_refiner`
22+- [`vector_field_demo.m`](vector_field_demo.m) — builds a mesh and vector field; hands them to `vector_field_viewer`
23+- `mesh_refiner.m` / `vector_field_viewer.m` — open the figure, send data; `mesh_refiner` also handles slider callbacks
2124 - `app/` — a small React/three.js app (built to a single HTML file) that renders
22- the surface with numbl's own surface renderer and hosts the slider.
25+ the surface and hosts the controls
26+
27+The figure app detects the data type (`data.type === 'vectorfield'` vs. plain
28+mesh data) and switches between the refinement panel and the arrow-scale panel.
2329
2430 On every push to `main`, the
2531 [deploy workflow](.github/workflows/deploy.yml) builds the app, bundles the
app/src/App.tsxmodified+99−34View file
@@ -1,6 +1,6 @@
11 import { useEffect, useMemo, useState, type CSSProperties } from "react";
22 import { SurfView } from "./render/SurfView.js";
3-import type { SurfTrace } from "./render/types.js";
3+import type { SurfTrace, Quiver3Trace } from "./render/types.js";
44 import { onData, onHostEvent, sendToMATLAB } from "./bridge.js";
55
66 /** Mesh payload from the numbl script: one flat (column-major) x/y/z array per
@@ -15,6 +15,19 @@ interface MeshData {
1515 maxLevel?: number;
1616 }
1717
18+/** Vector field payload: surface patches + sampled tangent vectors at patch centers. */
19+interface VectorFieldData extends MeshData {
20+ type: "vectorfield";
21+ vectors: {
22+ x: number[];
23+ y: number[];
24+ z: number[];
25+ u: number[];
26+ v: number[];
27+ w: number[];
28+ };
29+}
30+
1831 function isMeshData(d: unknown): d is MeshData {
1932 return (
2033 !!d &&
@@ -24,14 +37,20 @@ function isMeshData(d: unknown): d is MeshData {
2437 );
2538 }
2639
40+function isVectorFieldData(d: unknown): d is VectorFieldData {
41+ return (
42+ isMeshData(d) &&
43+ (d as VectorFieldData).type === "vectorfield" &&
44+ !!(d as VectorFieldData).vectors
45+ );
46+}
47+
2748 export function App() {
2849 const [mesh, setMesh] = useState<MeshData | null>(null);
2950 const [level, setLevel] = useState(0);
3051 const [busy, setBusy] = useState(false);
52+ const [arrowScale, setArrowScale] = useState(0.15);
3153
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).
3554 useEffect(() => {
3655 const apply = (d: unknown) => {
3756 if (!isMeshData(d)) return;
@@ -47,21 +66,36 @@ export function App() {
4766 };
4867 }, []);
4968
50- const traces = useMemo<SurfTrace[]>(() => {
69+ const surfTraces = useMemo<SurfTrace[]>(() => {
5170 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;
71+ return mesh.x.map((_, k) => ({
72+ x: mesh.x[k],
73+ y: mesh.y[k],
74+ z: mesh.z[k],
75+ rows: mesh.n,
76+ cols: mesh.n,
77+ }));
6378 }, [mesh]);
6479
80+ const quiverTraces = useMemo<Quiver3Trace[]>(() => {
81+ if (!isVectorFieldData(mesh)) return [];
82+ const { vectors } = mesh;
83+ return [
84+ {
85+ x: vectors.x,
86+ y: vectors.y,
87+ z: vectors.z,
88+ u: vectors.u.map(v => v * arrowScale),
89+ v: vectors.v.map(v => v * arrowScale),
90+ w: vectors.w.map(v => v * arrowScale),
91+ showArrowHead: true,
92+ color: [0.85, 0.325, 0.098] as [number, number, number],
93+ lineWidth: 1.5,
94+ },
95+ ];
96+ }, [mesh, arrowScale]);
97+
98+ const isVectorField = isVectorFieldData(mesh);
6599 const maxLevel = mesh?.maxLevel ?? 3;
66100
67101 const onSlider = (v: number) => {
@@ -73,29 +107,60 @@ export function App() {
73107 return (
74108 <div style={rootStyle}>
75109 {mesh ? (
76- <SurfView surfTraces={traces} shading="faceted" />
110+ <SurfView
111+ surfTraces={surfTraces}
112+ quiver3Traces={quiverTraces}
113+ shading="faceted"
114+ />
77115 ) : (
78116 <div style={waitingStyle}>Waiting for mesh from the script…</div>
79117 )}
80118
81119 <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>
120+ {isVectorField ? (
121+ <>
122+ <div style={{ fontWeight: 600, marginBottom: 8 }}>
123+ Vector field on surface
124+ </div>
125+ <label style={{ display: "block", fontSize: 13 }}>
126+ Arrow scale: <b>{arrowScale.toFixed(2)}</b>
127+ <input
128+ type="range"
129+ min={0.02}
130+ max={0.5}
131+ step={0.01}
132+ value={arrowScale}
133+ onChange={e => setArrowScale(Number(e.target.value))}
134+ style={{ width: "100%", marginTop: 4 }}
135+ />
136+ </label>
137+ <div style={{ fontSize: 12, color: "#475569", marginTop: 4 }}>
138+ patches: {mesh?.npatches ?? "—"}
139+ </div>
140+ </>
141+ ) : (
142+ <>
143+ <div style={{ fontWeight: 600, marginBottom: 8 }}>
144+ surfacefun mesh
145+ </div>
146+ <label style={{ display: "block", fontSize: 13 }}>
147+ Refinement level: <b>{level}</b>
148+ <input
149+ type="range"
150+ min={0}
151+ max={maxLevel}
152+ step={1}
153+ value={level}
154+ onChange={e => onSlider(Number(e.target.value))}
155+ style={{ width: "100%", marginTop: 4 }}
156+ />
157+ </label>
158+ <div style={{ fontSize: 12, color: "#475569", marginTop: 4 }}>
159+ patches: {mesh?.npatches ?? "—"}
160+ {busy ? " · refining…" : ""}
161+ </div>
162+ </>
163+ )}
99164 <div style={{ fontSize: 11, color: "#94a3b8", marginTop: 6 }}>
100165 drag to rotate · scroll to zoom
101166 </div>
numbl-project.jsonmodified+1−1View file
@@ -1,4 +1,4 @@
11 {
2- "title": "Interactive surface mesh refinement",
2+ "title": "Interactive surfacefun demos",
33 "entry": "README.md"
44 }
vector_field_demo.madded+11−0View file
@@ -0,0 +1,11 @@
1+% Interactive vector field on a spherical surface.
2+%
3+% Displays a tangent vector field (arrows) on a sphere. Use the Arrow scale
4+% slider to make arrows larger or smaller — scaling is instant (no MATLAB
5+% round-trip). Drag the surface to rotate; scroll to zoom.
6+
7+mip load --install flatironinstitute/flatironinstitute/surfacefun
8+
9+dom = surfacemesh.sphere(4); % sphere with 4×4 patches per cube face
10+
11+vector_field_viewer(dom);
vector_field_viewer.madded+77−0View file
@@ -0,0 +1,77 @@
1+function vector_field_viewer(dom)
2+%VECTOR_FIELD_VIEWER Interactive figure showing a tangent vector field on a surface.
3+% VECTOR_FIELD_VIEWER(DOM) opens a figure that renders a tangent vector
4+% field on the surfacemesh DOM. The arrow scale can be adjusted with a
5+% slider in the figure (purely client-side; no MATLAB callback needed).
6+
7+html = fileread(fullfile('app', 'dist', 'index.html'));
8+data = build_data(dom);
9+
10+fig = figure;
11+gl = uigridlayout(fig, [1 1], 'Padding', [0 0 0 0], ...
12+ 'RowHeight', {'1x'}, 'ColumnWidth', {'1x'});
13+uihtml(gl, 'HTMLSource', html, 'Data', data);
14+end
15+
16+function data = build_data(dom)
17+%BUILD_DATA Pack surface patches and a sampled tangent vector field.
18+% Samples one arrow per patch at the patch center. The vector field is the
19+% tangential projection of (1, 0, 0) onto the surface — a smooth "wind"
20+% field that vanishes near the poles and is strongest near the equator.
21+
22+np = length(dom);
23+px = cell(1, np);
24+py = cell(1, np);
25+pz = cell(1, np);
26+for k = 1:np
27+ px{k} = real(dom.x{k}(:).');
28+ py{k} = real(dom.y{k}(:).');
29+ pz{k} = real(dom.z{k}(:).');
30+end
31+
32+% Sample one tangent vector at the center of each patch.
33+vx = zeros(1, np);
34+vy = zeros(1, np);
35+vz = zeros(1, np);
36+uu = zeros(1, np);
37+vv = zeros(1, np);
38+ww = zeros(1, np);
39+
40+for k = 1:np
41+ n = size(dom.x{k}, 1);
42+ mid = ceil(n / 2);
43+ cx = real(dom.x{k}(mid, mid));
44+ cy = real(dom.y{k}(mid, mid));
45+ cz = real(dom.z{k}(mid, mid));
46+
47+ % Outward unit normal (sphere: normal = normalised position)
48+ r = sqrt(cx^2 + cy^2 + cz^2);
49+ nx = cx / r; ny = cy / r; nz = cz / r;
50+
51+ % Tangential projection of (1, 0, 0)
52+ dot_val = nx; % (1,0,0) · normal
53+ tu = 1 - dot_val * nx;
54+ tv = - dot_val * ny;
55+ tw = - dot_val * nz;
56+
57+ % Normalise so all arrows have the same base length
58+ tmag = sqrt(tu^2 + tv^2 + tw^2);
59+ if tmag > 1e-10
60+ tu = tu / tmag;
61+ tv = tv / tmag;
62+ tw = tw / tmag;
63+ end
64+
65+ vx(k) = cx; vy(k) = cy; vz(k) = cz;
66+ uu(k) = tu; vv(k) = tv; ww(k) = tw;
67+end
68+
69+data = struct();
70+data.type = 'vectorfield';
71+data.n = size(dom.x{1}, 1);
72+data.x = px;
73+data.y = py;
74+data.z = pz;
75+data.npatches = np;
76+data.vectors = struct('x', vx, 'y', vy, 'z', vz, 'u', uu, 'v', vv, 'w', ww);
77+end