topotopo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +44 -12
  2. package/dist/cli.js +98 -23
  3. package/dist/graph/engine.js +4 -1
  4. package/dist/graph/params.d.ts +7 -1
  5. package/dist/graph/params.js +21 -8
  6. package/dist/graph/types.d.ts +11 -2
  7. package/dist/host/export_scene.py +200 -0
  8. package/dist/host/node.d.ts +8 -0
  9. package/dist/host/node.js +114 -0
  10. package/dist/host/types.d.ts +5 -0
  11. package/dist/inspect.js +5 -2
  12. package/dist/io/common.d.ts +6 -0
  13. package/dist/io/export.js +7 -4
  14. package/dist/io/gltf.d.ts +1 -1
  15. package/dist/io/gltf.js +167 -32
  16. package/dist/io/heightmap.d.ts +1 -1
  17. package/dist/io/import.d.ts +2 -2
  18. package/dist/io/import.js +10 -3
  19. package/dist/io/png-decode.d.ts +5 -2
  20. package/dist/io/png-decode.js +10 -3
  21. package/dist/io/textures.d.ts +31 -2
  22. package/dist/io/textures.js +66 -18
  23. package/dist/io/unreal.js +22 -5
  24. package/dist/mcp/docs.d.ts +9 -2
  25. package/dist/mcp/docs.js +113 -2
  26. package/dist/mcp/inputs.d.ts +28 -0
  27. package/dist/mcp/inputs.js +377 -0
  28. package/dist/mcp/server.js +4 -1
  29. package/dist/mcp/tools.js +32 -18
  30. package/dist/nodes/code.js +2 -0
  31. package/dist/nodes/copy.js +14 -4
  32. package/dist/nodes/flow.js +136 -35
  33. package/dist/nodes/heightfield.js +67 -11
  34. package/dist/nodes/texture.d.ts +1 -1
  35. package/dist/nodes/texture.js +61 -5
  36. package/dist/render/options.js +2 -1
  37. package/dist/render/raster.d.ts +2 -0
  38. package/dist/render/raster.js +3 -3
  39. package/dist/render/texview.d.ts +9 -1
  40. package/dist/render/texview.js +43 -2
  41. package/dist/viewer/app/api.js +64 -0
  42. package/dist/viewer/app/camera.js +230 -0
  43. package/dist/viewer/app/grid.js +217 -0
  44. package/dist/viewer/app/guides.js +402 -0
  45. package/dist/viewer/app/keymap.js +187 -0
  46. package/dist/viewer/app/layers.js +225 -0
  47. package/dist/viewer/app/main.js +590 -0
  48. package/dist/viewer/app/markers.js +206 -0
  49. package/dist/viewer/app/mesh.js +172 -0
  50. package/dist/viewer/app/outliner.js +184 -0
  51. package/dist/viewer/app/props.js +432 -0
  52. package/dist/viewer/app/scene.js +529 -0
  53. package/dist/viewer/app/select.js +142 -0
  54. package/dist/viewer/app/selection.js +79 -0
  55. package/dist/viewer/app/style.css +172 -0
  56. package/dist/viewer/app/undo.js +49 -0
  57. package/dist/viewer/app/units.js +51 -0
  58. package/dist/viewer/app/vars.js +64 -0
  59. package/dist/viewer/index.html +53 -184
  60. package/dist/viewer/server.d.ts +42 -1
  61. package/dist/viewer/server.js +432 -17
  62. package/dist/workspace.d.ts +52 -2
  63. package/dist/workspace.js +95 -3
  64. package/package.json +8 -3
  65. package/shelf/brick_wall.json +1 -1
  66. package/shelf/wall_segment.json +2 -2
@@ -0,0 +1,402 @@
1
+ // Guides (docs/design/viewer.md section 7): nulls, curves, areas and volumes, the markers agents read as
2
+ // inputs/markers.json (roles site, path, zone, volume). Drawn as the standard objects they are, in one desaturated
3
+ // cyan, on top of the scene: a null is an axis cross (its radius circle only while hovered or selected), a curve a
4
+ // polyline with CV squares and a direction tick, an area a closed curve with a light diagonal hatch, a volume a wire
5
+ // box (its size printed on an edge while selected). Placement modes add them; every change saves the whole list
6
+ // (POST /api/markers) and is one undo step. Saving never moves the camera or reloads the output.
7
+ import * as THREE from 'three';
8
+ import { Line2 } from 'three/addons/lines/Line2.js';
9
+ import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
10
+ import { api } from './api.js';
11
+ import * as M from './markers.js';
12
+ import { COLORS, fatSegments, lineMaterial, disposeTree } from './scene.js';
13
+ import { fmtLen } from './units.js';
14
+
15
+ export const guideId = (id) => `guide:${id}`;
16
+ export const PLACE_KINDS = { placeNull: 'site', placeCurve: 'path', placeArea: 'zone', placeVolume: 'volume' };
17
+ const V3 = (p) => new THREE.Vector3(p[0], p[1], p[2]);
18
+ const CROSS_PX = 11, CV_PX = 6, TICK_PX = 12, PICK_PX = 7;
19
+ const STYLE = {
20
+ normal: { color: COLORS.guide, width: 1.5, opacity: 1 },
21
+ hover: { color: COLORS.sel, width: 1.5, opacity: 0.6 },
22
+ sel: { color: COLORS.sel, width: 2.2, opacity: 1 },
23
+ draft: { color: COLORS.sel, width: 1.5, opacity: 0.9 },
24
+ };
25
+
26
+ // ------------------------------------------------------------------------------------------------- drawing
27
+ function fatLine(points, st, closed = false) {
28
+ const g = new LineGeometry();
29
+ const P = closed ? [...points, points[0]] : points;
30
+ g.setPositions(P.flatMap((p) => [p.x, p.y, p.z]));
31
+ const l = new Line2(g, lineMaterial({ color: st.color, linewidth: st.width, opacity: st.opacity, depthTest: false, depthWrite: false }));
32
+ l.renderOrder = 20;
33
+ l.frustumCulled = false;
34
+ return l;
35
+ }
36
+ function pairs(list, st) {
37
+ const pos = new Float32Array(list.length * 6);
38
+ list.forEach(([a, b], i) => pos.set([a.x, a.y, a.z, b.x, b.y, b.z], i * 6));
39
+ const l = fatSegments(pos, { color: st.color, width: st.width, opacity: st.opacity, depthTest: false });
40
+ l.renderOrder = 20;
41
+ return l;
42
+ }
43
+ /** CVs: squares a fixed number of pixels wide, outlined dark. */
44
+ function squares(points, color, px = CV_PX) {
45
+ const g = new THREE.BufferGeometry().setFromPoints(points);
46
+ const mk = (c, s, o) => { const p = new THREE.Points(g, new THREE.PointsMaterial({ color: c, size: s, sizeAttenuation: false, depthTest: false, depthWrite: false, transparent: true })); p.renderOrder = o; p.frustumCulled = false; return p; };
47
+ const grp = new THREE.Group();
48
+ grp.add(mk('#16181b', px + 2, 21), mk(color, px, 22));
49
+ return grp;
50
+ }
51
+ /** An object kept `px` pixels per world unit in every view (main.js gives the metres per pixel of the view drawn). */
52
+ function screenSized(obj, at, px, wpp) {
53
+ obj.position.copy(at);
54
+ obj.onBeforeRender = (r, s, camera) => { obj.scale.setScalar(wpp(camera, obj.position) * px); obj.updateMatrixWorld(); };
55
+ return obj;
56
+ }
57
+ function boxEdges(lo, hi) {
58
+ const out = [];
59
+ for (let ax = 0; ax < 3; ax++) for (let s = 0; s < 4; s++) {
60
+ const a = [0, 0, 0], u = (ax + 1) % 3, v = (ax + 2) % 3;
61
+ a[u] = s & 1; a[v] = s >> 1;
62
+ const b = a.slice(); b[ax] = 1;
63
+ out.push([a, b].map(([i, j, k]) => new THREE.Vector3(i ? hi[0] : lo[0], j ? hi[1] : lo[1], k ? hi[2] : lo[2])));
64
+ }
65
+ return out;
66
+ }
67
+ const circle = (c, r, n = 72) => Array.from({ length: n }, (_, k) => { const a = (k / n) * Math.PI * 2; return new THREE.Vector3(c.x + Math.cos(a) * r, c.y, c.z + Math.sin(a) * r); });
68
+ /** A round hatch spacing for an area: about 14 lines across its larger side. */
69
+ function hatchSpacing(pts) {
70
+ const xs = pts.map((p) => p[0]), zs = pts.map((p) => p[2]);
71
+ const ext = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs), 1e-3);
72
+ const raw = ext / 14, e = 10 ** Math.floor(Math.log10(raw)), m = raw / e;
73
+ return (m < 1.5 ? 1 : m < 3.5 ? 2 : m < 7.5 ? 5 : 10) * e;
74
+ }
75
+
76
+ /** The objects of one guide in one state ('normal' | 'hover' | 'sel'). */
77
+ function guideObject(m, state, wpp) {
78
+ const st = STYLE[state], g = new THREE.Group(), P = m.points.map(V3);
79
+ g.name = `guide ${m.name}`;
80
+ if (m.kind === 'site') {
81
+ const c = P[0];
82
+ const cross = pairs([[new THREE.Vector3(-1, 0, 0), new THREE.Vector3(1, 0, 0)], [new THREE.Vector3(0, -1, 0), new THREE.Vector3(0, 1, 0)], [new THREE.Vector3(0, 0, -1), new THREE.Vector3(0, 0, 1)]], st);
83
+ // oriented by orient: the markers file writes identity (src/viewer/server.ts markersGeometry)
84
+ g.add(screenSized(cross, c, CROSS_PX, wpp));
85
+ if (state !== 'normal') g.add(fatLine(circle(c, m.radius ?? M.DEFAULT_RADIUS), { ...st, width: 1 }, true));
86
+ } else if (m.kind === 'volume') {
87
+ const { lo, hi } = M.boxOf(m);
88
+ g.add(pairs(boxEdges(lo, hi), st));
89
+ } else {
90
+ const zone = m.kind === 'zone';
91
+ g.add(fatLine(P, st, zone));
92
+ if (zone) {
93
+ const segs = M.hatchSegments(m.points, hatchSpacing(m.points));
94
+ if (segs.length) {
95
+ const pos = new Float32Array(segs.length * 6);
96
+ segs.forEach(([a, b], i) => pos.set([...a, ...b], i * 6));
97
+ const geo = new THREE.BufferGeometry();
98
+ geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
99
+ const h = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: 0.45 * st.opacity, depthTest: false, depthWrite: false }));
100
+ h.renderOrder = 19;
101
+ h.frustumCulled = false;
102
+ g.add(h);
103
+ }
104
+ } else if (P.length >= 2) {
105
+ // the direction tick: a chevron halfway along the last segment, pointing to the end
106
+ const a = P[P.length - 2], b = P[P.length - 1], d = b.clone().sub(a);
107
+ if (d.lengthSq() > 0) {
108
+ d.normalize();
109
+ const axis = Math.abs(d.y) > 0.95 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
110
+ const back = d.clone().negate();
111
+ const arms = [0.55, -0.55].map((t) => back.clone().applyAxisAngle(axis, t));
112
+ const tick = pairs(arms.map((v) => [new THREE.Vector3(), v]), st);
113
+ g.add(screenSized(tick, a.clone().lerp(b, 0.5), TICK_PX, wpp));
114
+ }
115
+ }
116
+ g.add(squares(P, st.color, zone ? CV_PX - 1 : CV_PX));
117
+ }
118
+ return g;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------------------------- the guides
122
+ export function createGuides(ctx) {
123
+ const G = {
124
+ list: [], saved: null, mode: null, draft: null, cursor: null, down: null,
125
+ hidden: new Set(ctx.store.get('guides.hidden', [])),
126
+ defaults: { radius: M.DEFAULT_RADIUS, height: M.DEFAULT_HEIGHT },
127
+ drawn: new Map(), draftRoot: new THREE.Group(),
128
+ };
129
+ ctx.guidesRoot.add(G.draftRoot);
130
+ const byId = (id) => G.list.find((m) => m.id === id) ?? null;
131
+ G.byId = byId;
132
+ const rememberHidden = () => ctx.store.set('guides.hidden', [...G.hidden]);
133
+ const entries = () => {
134
+ for (const id of [...ctx.objects.keys()]) if (id.startsWith('guide:') && !byId(id.slice(6))) ctx.objects.delete(id);
135
+ for (const m of G.list) {
136
+ const id = guideId(m.id), e = ctx.objects.get(id) ?? { id, kind: 'guide' };
137
+ e.marker = m; e.label = m.name; e.visible = !G.hidden.has(m.id);
138
+ ctx.objects.set(id, e);
139
+ }
140
+ };
141
+
142
+ /** Redraw the guides whose marker or state changed (state from the selection and the hover). */
143
+ G.draw = () => {
144
+ const seen = new Set();
145
+ for (const m of G.list) {
146
+ const id = guideId(m.id), visible = !G.hidden.has(m.id);
147
+ const state = ctx.selection.has(id) ? 'sel' : ctx.hover === id ? 'hover' : 'normal';
148
+ const sig = JSON.stringify([m.kind, m.points, m.radius, m.height, state]);
149
+ seen.add(m.id);
150
+ let d = G.drawn.get(m.id);
151
+ if (!d || d.sig !== sig) {
152
+ if (d) { ctx.guidesRoot.remove(d.obj); disposeTree(d.obj); }
153
+ d = { sig, obj: guideObject(m, state, ctx.pxWorld) };
154
+ G.drawn.set(m.id, d);
155
+ ctx.guidesRoot.add(d.obj);
156
+ }
157
+ d.obj.visible = visible;
158
+ }
159
+ for (const [id, d] of G.drawn) if (!seen.has(id)) { ctx.guidesRoot.remove(d.obj); disposeTree(d.obj); G.drawn.delete(id); }
160
+ ctx.requestRender();
161
+ };
162
+ /** The draft being placed: curve or area CVs with the rubber band to the cursor, or the volume being dragged. */
163
+ G.drawDraft = () => {
164
+ for (const o of [...G.draftRoot.children]) { G.draftRoot.remove(o); disposeTree(o); }
165
+ const d = G.draft;
166
+ if (d?.kind === 'volume') {
167
+ const m = { points: [[d.a.x, d.y, d.a.z], [d.b.x, d.y, d.b.z]], height: G.defaults.height };
168
+ const { lo, hi } = M.boxOf(m);
169
+ if (hi[0] > lo[0] && hi[2] > lo[2]) G.draftRoot.add(pairs(boxEdges(lo, hi), STYLE.draft));
170
+ } else if (d?.points?.length) {
171
+ const P = d.points.map(V3), live = G.cursor ? [...P, G.cursor] : P;
172
+ if (live.length >= 2) G.draftRoot.add(fatLine(live, STYLE.draft));
173
+ if (d.kind === 'zone' && live.length >= 3) G.draftRoot.add(fatLine([live[live.length - 1], live[0]], { ...STYLE.draft, opacity: 0.4, width: 1 }));
174
+ G.draftRoot.add(squares(P, COLORS.sel));
175
+ }
176
+ ctx.requestRender();
177
+ };
178
+
179
+ // ------------------------------------------------------------------ saving: the whole list, the latest wins
180
+ const save = ctx.coalesce(async () => {
181
+ const body = M.markersBody(G.list);
182
+ if (body === G.saved) return;
183
+ const r = await api.saveMarkers(body);
184
+ if (r.ok) {
185
+ G.saved = body;
186
+ const file = `inputs/markers.json${ctx.layers.where ? ' ' + ctx.layers.where : ''}`;
187
+ if (M.markersBody(G.list) !== body) { /* a newer list is on its way */ }
188
+ else if (G.mode) G.hint(` · saved to ${file}`);
189
+ else ctx.message(r.count ? `${r.count} guide${r.count === 1 ? '' : 's'} saved to ${file} (${r.points} points, ${r.prims} prims)` : `no guides: ${file} removed`, 'ok');
190
+ } else ctx.message(`guides not saved — ${r.error}`, 'err');
191
+ ctx.emit('guides');
192
+ });
193
+ G.saving = () => M.markersBody(G.list) !== G.saved;
194
+ const setList = (list) => {
195
+ G.list = M.cloneMarkers(list);
196
+ entries();
197
+ ctx.selection.prune((id) => !id.startsWith('guide:') || !!byId(id.slice(6)));
198
+ G.draw(); save(); ctx.emit('guides');
199
+ };
200
+ /** One undoable change of the list: `edit(list)` changes a copy in place. */
201
+ G.change = (label, edit, key) => {
202
+ const before = M.cloneMarkers(G.list), next = M.cloneMarkers(G.list);
203
+ if (edit(next) === false) return false;
204
+ setList(next);
205
+ const after = M.cloneMarkers(G.list);
206
+ ctx.undo.push({ label, key, undo: () => setList(before), redo: () => setList(after) });
207
+ return true;
208
+ };
209
+ G.load = async () => {
210
+ const j = await api.markers();
211
+ if (!j.markers) { if (j.error) ctx.message(`guides: ${j.error}`, 'err'); return; }
212
+ G.list = j.markers;
213
+ G.saved = M.markersBody(G.list);
214
+ for (const k of ['radius', 'height']) { const last = G.list.findLast((m) => m[k] > 0); if (last) G.defaults[k] = last[k]; }
215
+ for (const id of [...G.hidden]) if (!byId(id)) G.hidden.delete(id);
216
+ rememberHidden();
217
+ if (j.error) ctx.message(j.error, 'err');
218
+ entries();
219
+ ctx.selection.prune((id) => !id.startsWith('guide:') || !!byId(id.slice(6)));
220
+ G.draw(); ctx.emit('guides');
221
+ };
222
+
223
+ // ------------------------------------------------------------------ edits from the panels and the keymap
224
+ G.add = (m) => {
225
+ const marker = { id: 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), kind: m.kind, name: M.nextName(G.list, m.kind), points: m.points,
226
+ ...(m.kind === 'site' ? { radius: G.defaults.radius } : {}), ...(m.kind === 'volume' ? { height: G.defaults.height } : {}) };
227
+ G.change(`add ${marker.name}`, (list) => { list.push(marker); });
228
+ ctx.selection.apply([guideId(marker.id)]);
229
+ return marker;
230
+ };
231
+ G.remove = (ids) => {
232
+ const drop = new Set(ids);
233
+ const gone = G.list.filter((m) => drop.has(m.id));
234
+ if (!gone.length) return 0;
235
+ G.change(`delete ${gone.length === 1 ? gone[0].name : `${gone.length} guides`}`, (list) => { for (let i = list.length - 1; i >= 0; i--) if (drop.has(list[i].id)) list.splice(i, 1); });
236
+ return gone.length;
237
+ };
238
+ G.rename = (id, raw) => {
239
+ const m = byId(id);
240
+ if (!m) return { error: 'no such guide' };
241
+ const r = M.checkName(G.list, id, raw);
242
+ if (r.error || r.name === m.name) return r;
243
+ G.change(`rename ${m.name} to ${r.name}`, (list) => { list.find((o) => o.id === id).name = r.name; });
244
+ return r;
245
+ };
246
+ /** A null's radius or a volume's height (merged while typing). */
247
+ G.setSize = (id, key, v) => {
248
+ if (!(v > 0) || !Number.isFinite(v)) return false;
249
+ G.defaults[key] = v;
250
+ const m = byId(id);
251
+ if (!m || !(key in m) || m[key] === v) return false;
252
+ return G.change(`${key} of ${m.name}`, (list) => { list.find((o) => o.id === id)[key] = v; }, `${key}:${id}`);
253
+ };
254
+ G.setHidden = (ids, hide) => {
255
+ for (const id of ids) if (hide) G.hidden.add(id); else G.hidden.delete(id);
256
+ rememberHidden(); entries(); G.draw(); ctx.emit('guides');
257
+ };
258
+ G.showAll = () => { G.hidden.clear(); rememberHidden(); entries(); G.draw(); ctx.emit('guides'); };
259
+
260
+ // ------------------------------------------------------------------ picking (screen space, in pixels)
261
+ const segDist = (px, py, a, b) => {
262
+ const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy;
263
+ const t = l2 ? Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / l2)) : 0;
264
+ return Math.hypot(a.x + t * dx - px, a.y + t * dy - py);
265
+ };
266
+ /** Outline points of a guide as segments (world), for picking. */
267
+ const outline = (m) => {
268
+ if (m.kind === 'volume') { const { lo, hi } = M.boxOf(m); return boxEdges(lo, hi); }
269
+ if (m.kind === 'site') return [];
270
+ const P = m.points.map(V3);
271
+ if (m.kind === 'zone') P.push(P[0]);
272
+ return P.slice(1).map((p, k) => [P[k], p]);
273
+ };
274
+ /** The guide nearest a viewport pixel as slot `slot` shows it, within PICK_PX: {id, d} or null. */
275
+ G.pick = (slot, x, y) => {
276
+ const cam = ctx.views.camera(slot);
277
+ let best = null;
278
+ for (const m of G.list) {
279
+ if (G.hidden.has(m.id)) continue;
280
+ let d = Infinity;
281
+ if (m.kind === 'site') { const c = ctx.views.project(slot, V3(m.points[0]), cam); if (c) d = Math.max(0, Math.hypot(c.x - x, c.y - y) - CROSS_PX * 0.5); }
282
+ for (const [a, b] of outline(m)) { const A = ctx.views.project(slot, a, cam), B = ctx.views.project(slot, b, cam); if (A && B) d = Math.min(d, segDist(x, y, A, B)); }
283
+ if (m.kind !== 'site' && m.kind !== 'volume') for (const p of m.points) { const c = ctx.views.project(slot, V3(p), cam); if (c) d = Math.min(d, Math.hypot(c.x - x, c.y - y)); }
284
+ if (d <= PICK_PX && d < (best?.d ?? Infinity)) best = { id: guideId(m.id), d };
285
+ }
286
+ return best;
287
+ };
288
+ /** The points that must lie inside a box for a guide to be box-selected. */
289
+ G.points = (m) => (m.kind === 'volume' ? boxEdges(M.boxOf(m).lo, M.boxOf(m).hi).flat() : m.points.map(V3));
290
+ G.boxSelect = (inside) => G.list.filter((m) => !G.hidden.has(m.id) && inside(G.points(m))).map((m) => guideId(m.id));
291
+ G.bounds = (m) => { const { lo, hi } = M.boundsOf(m); return new THREE.Box3(V3(lo), V3(hi)); };
292
+ /** Labels of hovered and selected guides: [{key, at: Vector3, text, cls}] (main.js places them in every view). */
293
+ G.labels = () => {
294
+ const out = [];
295
+ for (const m of G.list) {
296
+ if (G.hidden.has(m.id)) continue;
297
+ const id = guideId(m.id), sel = ctx.selection.has(id);
298
+ if (!sel && ctx.hover !== id) continue;
299
+ const at = m.kind === 'site' ? V3(m.points[0]) : m.kind === 'zone' ? V3(M.centroid(m.points)) : m.kind === 'path' ? V3(M.halfway(m.points)) : (() => { const { lo, hi } = M.boxOf(m); return new THREE.Vector3((lo[0] + hi[0]) / 2, hi[1], (lo[2] + hi[2]) / 2); })();
300
+ out.push({ key: `n:${m.id}`, at, text: m.name, cls: sel ? 'lbl sel' : 'lbl' });
301
+ if (sel && m.kind === 'volume') {
302
+ const { lo, hi } = M.boxOf(m);
303
+ out.push({ key: `s:${m.id}`, at: new THREE.Vector3((lo[0] + hi[0]) / 2, lo[1], hi[2]), text: `${fmtLen(hi[0] - lo[0])} × ${fmtLen(hi[1] - lo[1])} × ${fmtLen(hi[2] - lo[2])} m`, cls: 'lbl dimen' });
304
+ }
305
+ }
306
+ return out;
307
+ };
308
+
309
+ // ------------------------------------------------------------------ placement modes
310
+ const HINT = {
311
+ site: () => 'Place null — click the ground or the geometry · Esc leaves',
312
+ path: () => (G.draft ? `Draw curve — ${G.draft.points.length} CV${G.draft.points.length === 1 ? '' : 's'} · click adds one · Enter finishes · Backspace removes the last · Esc cancels` : 'Draw curve — click CVs along it · Enter finishes · Esc leaves'),
313
+ zone: () => (G.draft ? `Draw area — ${G.draft.points.length} corner${G.draft.points.length === 1 ? '' : 's'} · click adds one · Enter or a click on the first corner closes it · Backspace removes the last · Esc cancels` : 'Draw area — click its corners · Enter closes it · Esc leaves'),
314
+ volume: () => (G.draft ? `Drag volume — ${fmtLen(Math.abs(G.draft.b.x - G.draft.a.x))} × ${fmtLen(Math.abs(G.draft.b.z - G.draft.a.z))} m, ${fmtLen(G.defaults.height)} m high` : 'Drag volume — drag a rectangle on the ground or the geometry; it rises by its height · Esc leaves'),
315
+ };
316
+ G.hint = (extra = '') => { if (G.mode) ctx.message(HINT[G.mode]() + extra, 'busy'); };
317
+ G.setMode = (kind) => {
318
+ G.draft = null; G.cursor = null; G.down = null;
319
+ G.mode = kind;
320
+ G.drawDraft();
321
+ ctx.emit('mode');
322
+ if (kind) G.hint(); else ctx.message('');
323
+ };
324
+ const finish = () => {
325
+ const d = G.draft;
326
+ if (!d?.points) return false;
327
+ const need = M.minPoints(d.kind);
328
+ if (d.points.length < need) { G.hint(` · a${d.kind === 'zone' ? 'n area' : ' curve'} needs ${need} points`); return true; }
329
+ G.draft = null; G.cursor = null; G.drawDraft();
330
+ G.add({ kind: d.kind, points: d.points });
331
+ G.hint(` · added ${G.list[G.list.length - 1].name}`);
332
+ return true;
333
+ };
334
+ /** Enter, Backspace, Esc in a placement mode; true when handled. */
335
+ G.key = (action) => {
336
+ if (!G.mode) return false;
337
+ if (action === 'finish') return finish() || true;
338
+ if (action === 'removeLast') {
339
+ if (!G.draft?.points) return true;
340
+ G.draft.points.pop();
341
+ if (!G.draft.points.length) { G.draft = null; G.cursor = null; }
342
+ G.drawDraft(); G.hint();
343
+ return true;
344
+ }
345
+ if (action === 'cancel') {
346
+ if (G.draft) { G.draft = null; G.cursor = null; G.drawDraft(); G.hint(' · cancelled'); return true; }
347
+ G.setMode(null);
348
+ return false; // Esc goes on to clear the selection
349
+ }
350
+ return false;
351
+ };
352
+ const mm = (p) => [M.mm(p.x), M.mm(p.y), M.mm(p.z)];
353
+ /** A left press in a placement mode (x, y in viewport pixels). */
354
+ G.pointerDown = (slot, x, y) => {
355
+ G.down = { slot, x, y };
356
+ if (G.mode !== 'volume') return;
357
+ const hit = ctx.surfacePoint(slot, x, y);
358
+ if (hit) { G.draft = { kind: 'volume', y: hit.point.y, a: hit.point.clone(), b: hit.point.clone() }; G.drawDraft(); }
359
+ };
360
+ G.pointerMove = (slot, x, y) => {
361
+ if (!G.mode) return;
362
+ if (G.draft?.kind === 'volume') {
363
+ if (!G.down) return;
364
+ // on the level of the first corner, through the view the drag began in
365
+ const p = ctx.planePoint(G.down.slot, x, y, G.draft.y);
366
+ if (p) { G.draft.b = p; G.drawDraft(); G.hint(); }
367
+ } else if (G.draft?.points?.length) {
368
+ const hit = ctx.surfacePoint(slot, x, y);
369
+ G.cursor = hit ? hit.point : null;
370
+ G.drawDraft();
371
+ }
372
+ };
373
+ /** The release of a left press: one click adds exactly one point (or one null); a drag finishes a volume. */
374
+ G.pointerUp = (slot, x, y) => {
375
+ const down = G.down;
376
+ G.down = null;
377
+ if (!G.mode || !down) return;
378
+ const moved = Math.hypot(x - down.x, y - down.y);
379
+ if (G.mode === 'volume') {
380
+ const d = G.draft;
381
+ G.draft = null; G.drawDraft();
382
+ if (!d) return G.hint(' · nothing under the pointer');
383
+ if (moved < 5 || Math.abs(d.b.x - d.a.x) < 0.01 || Math.abs(d.b.z - d.a.z) < 0.01) return G.hint(' · drag to draw the box');
384
+ G.add({ kind: 'volume', points: [[M.mm(d.a.x), M.mm(d.y), M.mm(d.a.z)], [M.mm(d.b.x), M.mm(d.y), M.mm(d.b.z)]] });
385
+ return G.hint(` · added ${G.list[G.list.length - 1].name}`);
386
+ }
387
+ if (moved > 5) return; // a drag is not a click
388
+ if (G.mode === 'zone' && G.draft?.points.length >= 3) {
389
+ const f = ctx.views.project(slot, V3(G.draft.points[0]));
390
+ if (f && Math.hypot(f.x - x, f.y - y) < 10) { finish(); return; }
391
+ }
392
+ const hit = ctx.surfacePoint(slot, x, y);
393
+ if (!hit) return G.hint(' · nothing under the pointer');
394
+ const p = mm(hit.point);
395
+ if (G.mode === 'site') { const m = G.add({ kind: 'site', points: [p] }); return G.hint(` · added ${m.name}`); }
396
+ if (!G.draft) G.draft = { kind: G.mode, points: [] };
397
+ G.draft.points.push(p);
398
+ G.drawDraft(); G.hint();
399
+ };
400
+ G.leave = () => { if (G.cursor) { G.cursor = null; G.drawDraft(); } };
401
+ return G;
402
+ }
@@ -0,0 +1,187 @@
1
+ // The keymap of docs/design/viewer.md section 9: one table of actions, three presets (Maya, the default; Houdini;
2
+ // Blender). Each row carries the text the overlay prints per preset and, for the actions phase 1 builds, the bindings:
3
+ // chord → action. Transform and snapping rows (phase 2) ship their text but no bindings, so a preset never changes
4
+ // shape later. Plain data and functions: the overlay is the only DOM code, and it runs only when called.
5
+
6
+ export const PRESETS = [
7
+ { id: 'maya', label: 'Maya' },
8
+ { id: 'houdini', label: 'Houdini' },
9
+ { id: 'blender', label: 'Blender' },
10
+ ];
11
+ export const DEFAULT_PRESET = 'maya';
12
+
13
+ const place = { 'Shift+1': 'placeNull', 'Shift+2': 'placeCurve', 'Shift+3': 'placeArea', 'Shift+4': 'placeVolume' };
14
+ const curve = { Enter: 'finish', Backspace: 'removeLast', Esc: 'cancel' };
15
+
16
+ /**
17
+ * The rows of section 9 in order. `keys` binds key chords, `drag` mouse drags (L / M / R with modifiers); a row
18
+ * without bindings for a preset is not built yet (phase 2) or has none in that application.
19
+ */
20
+ export const ROWS = [
21
+ { label: 'tumble', maya: ['Alt + L drag', { drag: { 'Alt+L': 'tumble' } }], houdini: ['Space + L drag', { drag: { 'Space+L': 'tumble' } }], blender: ['M drag', { drag: { M: 'tumble' } }] },
22
+ { label: 'track', maya: ['Alt + M drag', { drag: { 'Alt+M': 'track' } }], houdini: ['Space + M drag', { drag: { 'Space+M': 'track' } }], blender: ['Shift + M drag', { drag: { 'Shift+M': 'track' } }] },
23
+ { label: 'dolly', maya: ['Alt + R drag, wheel', { drag: { 'Alt+R': 'dolly' } }], houdini: ['Space + R drag, wheel', { drag: { 'Space+R': 'dolly' } }], blender: ['Ctrl + M drag, wheel', { drag: { 'Ctrl+M': 'dolly' } }] },
24
+ { label: 'frame selection', maya: ['F', { keys: { F: 'frameSelection' } }], houdini: ['Space+G', { keys: { 'Space+G': 'frameSelection' } }], blender: ['Numpad .', { keys: { 'Numpad.': 'frameSelection' } }] },
25
+ { label: 'frame all', maya: ['A', { keys: { A: 'frameAll' } }], houdini: ['Space+H', { keys: { 'Space+H': 'frameAll' } }], blender: ['Home', { keys: { Home: 'frameAll' } }] },
26
+ { label: 'single / quad view', maya: ['Space (tap)', { keys: { Space: 'quad' } }], houdini: ['Space+B', { keys: { 'Space+B': 'quad' } }], blender: ['Ctrl+Alt+Q', { keys: { 'Ctrl+Alt+Q': 'quad' } }] },
27
+ { label: 'perspective', maya: ['Alt+1', { keys: { 'Alt+1': 'viewPersp' } }], houdini: ['Space+1', { keys: { 'Space+1': 'viewPersp' } }], blender: ['Numpad 5 (toggles with ortho)', { keys: { Numpad5: 'ortho' } }] },
28
+ {
29
+ label: 'top / front / right',
30
+ maya: ['Alt+2 / Alt+3 / Alt+4', { keys: { 'Alt+2': 'viewTop', 'Alt+3': 'viewFront', 'Alt+4': 'viewRight' } }],
31
+ houdini: ['Space+2 / Space+3 / Space+4', { keys: { 'Space+2': 'viewTop', 'Space+3': 'viewFront', 'Space+4': 'viewRight' } }],
32
+ blender: ['Numpad 7 / 1 / 3', { keys: { Numpad7: 'viewTop', Numpad1: 'viewFront', Numpad3: 'viewRight' } }],
33
+ },
34
+ { label: 'orthographic toggle', maya: ['O', { keys: { O: 'ortho' } }], houdini: ['O', { keys: { O: 'ortho' } }], blender: ['Numpad 5', { keys: { Numpad5: 'ortho' } }] },
35
+ { label: 'select mode', maya: ['Q', { keys: { Q: 'selectMode' } }], houdini: ['S', { keys: { S: 'selectMode' } }], blender: ['W', { keys: { W: 'selectMode' } }] },
36
+ { label: 'move / rotate / scale', phase: 2, maya: ['W / E / R'], houdini: ['T / R / E'], blender: ['G / R / S'] },
37
+ { label: 'snap to grid (held)', phase: 2, maya: ['X'], houdini: ['X'], blender: ['Ctrl (held, with the snap target set to grid)'] },
38
+ { label: 'snap to surface or curve (held)', phase: 2, maya: ['C'], houdini: ['C'], blender: ['Ctrl (target: surface)'] },
39
+ { label: 'snap to point (held)', phase: 2, maya: ['V'], houdini: ['V'], blender: ['Ctrl (target: point)'] },
40
+ { label: 'snapping on / off', phase: 2, maya: ['toolbar'], houdini: ['toolbar'], blender: ['Shift+Tab'] },
41
+ {
42
+ label: 'wire / shaded / shaded + wire',
43
+ maya: ['4 / 5 / 6', { keys: { 4: 'displayWire', 5: 'displayShaded', 6: 'displayShadedWire' } }],
44
+ houdini: ['W (cycles)', { keys: { W: 'displayCycle' } }],
45
+ blender: ['Shift+Z (wire toggle), Z (cycles)', { keys: { 'Shift+Z': 'wireToggle', Z: 'displayCycle' } }],
46
+ },
47
+ { label: 'place a null / curve / area / volume', maya: ['Shift+1 / 2 / 3 / 4', { keys: place }], houdini: ['Shift+1 / 2 / 3 / 4', { keys: place }], blender: ['Shift+1 / 2 / 3 / 4', { keys: place }] },
48
+ { label: 'finish a curve / remove its last point / cancel', maya: ['Enter / Backspace / Esc', { keys: curve }], houdini: ['Enter / Backspace / Esc', { keys: curve }], blender: ['Enter / Backspace / Esc', { keys: curve }] },
49
+ { label: 'delete the selection', maya: ['Delete', { keys: { Delete: 'delete' } }], houdini: ['Delete', { keys: { Delete: 'delete' } }], blender: ['X, Delete', { keys: { X: 'delete', Delete: 'delete' } }] },
50
+ {
51
+ label: 'undo / redo (guides and vars)',
52
+ maya: ['Ctrl+Z / Ctrl+Y (also Z / Shift+Z)', { keys: { 'Ctrl+Z': 'undo', 'Ctrl+Y': 'redo', Z: 'undo', 'Shift+Z': 'redo' } }],
53
+ houdini: ['Ctrl+Z / Ctrl+Y', { keys: { 'Ctrl+Z': 'undo', 'Ctrl+Y': 'redo' } }],
54
+ blender: ['Ctrl+Z / Ctrl+Shift+Z', { keys: { 'Ctrl+Z': 'undo', 'Ctrl+Shift+Z': 'redo' } }],
55
+ },
56
+ { label: 'hide the selection / show all', maya: ['H / Shift+H', { keys: { H: 'hide', 'Shift+H': 'showAll' } }], houdini: ['H / Shift+H', { keys: { H: 'hide', 'Shift+H': 'showAll' } }], blender: ['H / Alt+H', { keys: { H: 'hide', 'Alt+H': 'showAll' } }] },
57
+ { label: 'keymap overlay', maya: ['?', { keys: { '?': 'overlay' } }], houdini: ['?', { keys: { '?': 'overlay' } }], blender: ['?', { keys: { '?': 'overlay' } }] },
58
+ ];
59
+
60
+ /** Every action a binding may name (main.js handles each). */
61
+ export const ACTIONS = [
62
+ 'tumble', 'track', 'dolly', 'frameSelection', 'frameAll', 'quad', 'viewPersp', 'viewTop', 'viewFront', 'viewRight', 'ortho',
63
+ 'selectMode', 'displayWire', 'displayShaded', 'displayShadedWire', 'displayCycle', 'wireToggle',
64
+ 'placeNull', 'placeCurve', 'placeArea', 'placeVolume', 'finish', 'removeLast', 'cancel',
65
+ 'delete', 'undo', 'redo', 'hide', 'showAll', 'overlay',
66
+ ];
67
+
68
+ const MODS = ['Ctrl', 'Alt', 'Shift', 'Space'];
69
+ /** A chord written as in ROWS ("ctrl+alt+q", "Space+G") in one spelling: modifiers in MODS order, then the key. */
70
+ export function canonical(chord) {
71
+ const parts = String(chord).split('+').map((s) => s.trim()).filter(Boolean);
72
+ // "Space" alone is the key (a tap); before another part it is the held modifier
73
+ const key = parts.pop() ?? '';
74
+ const mods = new Set(parts.map((p) => MODS.find((m) => m.toLowerCase() === p.toLowerCase()) ?? p));
75
+ for (const m of mods) if (!MODS.includes(m)) throw new Error(`unknown modifier "${m}" in "${chord}"`);
76
+ const k = key.length === 1 ? key.toUpperCase() : key;
77
+ return [...MODS.filter((m) => mods.has(m)), k].join('+');
78
+ }
79
+
80
+ /** The bindings of one preset: chord → action for keys and for drags. */
81
+ export function bindings(presetId) {
82
+ const keys = new Map(), drag = new Map();
83
+ for (const row of ROWS) {
84
+ const b = row[presetId]?.[1];
85
+ if (!b) continue;
86
+ for (const [c, a] of Object.entries(b.keys ?? {})) keys.set(canonical(c), a);
87
+ for (const [c, a] of Object.entries(b.drag ?? {})) drag.set(canonical(c), a);
88
+ }
89
+ return { keys, drag };
90
+ }
91
+ const cache = new Map();
92
+ const bound = (presetId) => { if (!cache.has(presetId)) cache.set(presetId, bindings(presetId)); return cache.get(presetId); };
93
+
94
+ /**
95
+ * The key part of a chord for a keyboard event: letters by the character typed (falling back to the physical key on
96
+ * other alphabets), digits and the numpad by the physical key, "?" whatever it takes to type it.
97
+ */
98
+ export function keyToken(e) {
99
+ const code = e.code ?? '', key = e.key ?? '';
100
+ if (code.startsWith('Numpad')) {
101
+ const rest = code.slice(6);
102
+ if (/^\d$/.test(rest)) return 'Numpad' + rest;
103
+ if (rest === 'Decimal') return 'Numpad.';
104
+ if (rest === 'Enter') return 'Enter';
105
+ return null;
106
+ }
107
+ // "?" by the character; Shift with the "/" key gives it on US layouts even when the event names only the key
108
+ if (key === '?' || (code === 'Slash' && e.shiftKey)) return '?';
109
+ if (/^Digit\d$/.test(code)) return code.slice(5);
110
+ if (/^[a-z]$/i.test(key)) return key.toUpperCase();
111
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3);
112
+ if (key === ' ' || code === 'Space') return 'Space';
113
+ if (key === 'Escape') return 'Esc';
114
+ if (['Enter', 'Backspace', 'Delete', 'Home', 'Tab'].includes(key)) return key;
115
+ return null;
116
+ }
117
+
118
+ /** The canonical chord of a keyboard event; `space` is true while Space is held (Houdini's modifier). */
119
+ export function chordOf(e, space = false) {
120
+ const k = keyToken(e);
121
+ if (!k) return null;
122
+ const mods = [];
123
+ if (e.ctrlKey || e.metaKey) mods.push('Ctrl');
124
+ if (e.altKey) mods.push('Alt');
125
+ // typing "?" takes Shift on most layouts: the chord is "?" alone
126
+ if (e.shiftKey && k !== '?') mods.push('Shift');
127
+ if (space && k !== 'Space') mods.push('Space');
128
+ return [...mods, k].join('+');
129
+ }
130
+
131
+ /** The action a key press means in a preset, or null. */
132
+ export function matchKey(presetId, e, space = false) {
133
+ const c = chordOf(e, space);
134
+ return c ? bound(presetId).keys.get(c) ?? null : null;
135
+ }
136
+
137
+ /** The navigation a mouse drag starts in a preset (tumble, track, dolly), or null. `button` 0 / 1 / 2 = L / M / R. */
138
+ export function matchDrag(presetId, { button, altKey = false, ctrlKey = false, metaKey = false, shiftKey = false, space = false }) {
139
+ const b = ['L', 'M', 'R'][button];
140
+ if (!b) return null;
141
+ const mods = [];
142
+ if (ctrlKey || metaKey) mods.push('Ctrl');
143
+ if (altKey) mods.push('Alt');
144
+ if (shiftKey) mods.push('Shift');
145
+ if (space) mods.push('Space');
146
+ return bound(presetId).drag.get([...mods, b].join('+')) ?? null;
147
+ }
148
+
149
+ /** True when Space acts as a held modifier in the preset (Houdini) rather than as a key of its own. */
150
+ export const spaceIsModifier = (presetId) => [...bound(presetId).keys.keys(), ...bound(presetId).drag.keys()].some((c) => c.startsWith('Space+') || c.includes('+Space+'));
151
+
152
+ /** The overlay's table for a preset: [action label, binding text, built] per row of section 9. */
153
+ export function overlayRows(presetId) {
154
+ return ROWS.map((r) => ({ label: r.label, text: r[presetId]?.[0] ?? '', built: !r.phase }));
155
+ }
156
+
157
+ /** Show (or hide, when shown) the keymap overlay for a preset over `host`. Returns the overlay element or null. */
158
+ export function toggleOverlay(host, presetId) {
159
+ const old = host.querySelector('.keymap-overlay');
160
+ if (old) { old.remove(); return null; }
161
+ const doc = host.ownerDocument;
162
+ const box = doc.createElement('div');
163
+ box.className = 'keymap-overlay';
164
+ const panel = doc.createElement('div');
165
+ panel.className = 'km-panel';
166
+ const head = doc.createElement('div');
167
+ head.className = 'km-head';
168
+ head.textContent = `Keymap — ${PRESETS.find((p) => p.id === presetId)?.label ?? presetId}`;
169
+ const table = doc.createElement('table');
170
+ for (const r of overlayRows(presetId)) {
171
+ const tr = doc.createElement('tr');
172
+ if (!r.built) tr.className = 'later';
173
+ const a = doc.createElement('td'), b = doc.createElement('td');
174
+ a.textContent = r.label;
175
+ b.textContent = r.text + (r.built ? '' : ' (phase 2)');
176
+ tr.append(a, b);
177
+ table.append(tr);
178
+ }
179
+ const foot = doc.createElement('div');
180
+ foot.className = 'km-foot';
181
+ foot.textContent = 'click selects, Shift-click adds, Ctrl-click removes, a drag selects what lies wholly inside its box, Esc clears · ? or Esc closes';
182
+ panel.append(head, table, foot);
183
+ box.append(panel);
184
+ box.addEventListener('pointerdown', (e) => { if (e.target === box) box.remove(); });
185
+ host.append(box);
186
+ return box;
187
+ }