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.
- package/README.md +44 -12
- package/dist/cli.js +98 -23
- package/dist/graph/engine.js +4 -1
- package/dist/graph/params.d.ts +7 -1
- package/dist/graph/params.js +21 -8
- package/dist/graph/types.d.ts +11 -2
- package/dist/host/export_scene.py +200 -0
- package/dist/host/node.d.ts +8 -0
- package/dist/host/node.js +114 -0
- package/dist/host/types.d.ts +5 -0
- package/dist/inspect.js +5 -2
- package/dist/io/common.d.ts +6 -0
- package/dist/io/export.js +7 -4
- package/dist/io/gltf.d.ts +1 -1
- package/dist/io/gltf.js +167 -32
- package/dist/io/heightmap.d.ts +1 -1
- package/dist/io/import.d.ts +2 -2
- package/dist/io/import.js +10 -3
- package/dist/io/png-decode.d.ts +5 -2
- package/dist/io/png-decode.js +10 -3
- package/dist/io/textures.d.ts +31 -2
- package/dist/io/textures.js +66 -18
- package/dist/io/unreal.js +22 -5
- package/dist/mcp/docs.d.ts +9 -2
- package/dist/mcp/docs.js +113 -2
- package/dist/mcp/inputs.d.ts +28 -0
- package/dist/mcp/inputs.js +377 -0
- package/dist/mcp/server.js +4 -1
- package/dist/mcp/tools.js +32 -18
- package/dist/nodes/code.js +2 -0
- package/dist/nodes/copy.js +14 -4
- package/dist/nodes/flow.js +136 -35
- package/dist/nodes/heightfield.js +67 -11
- package/dist/nodes/texture.d.ts +1 -1
- package/dist/nodes/texture.js +61 -5
- package/dist/render/options.js +2 -1
- package/dist/render/raster.d.ts +2 -0
- package/dist/render/raster.js +3 -3
- package/dist/render/texview.d.ts +9 -1
- package/dist/render/texview.js +43 -2
- package/dist/viewer/app/api.js +64 -0
- package/dist/viewer/app/camera.js +230 -0
- package/dist/viewer/app/grid.js +217 -0
- package/dist/viewer/app/guides.js +402 -0
- package/dist/viewer/app/keymap.js +187 -0
- package/dist/viewer/app/layers.js +225 -0
- package/dist/viewer/app/main.js +590 -0
- package/dist/viewer/app/markers.js +206 -0
- package/dist/viewer/app/mesh.js +172 -0
- package/dist/viewer/app/outliner.js +184 -0
- package/dist/viewer/app/props.js +432 -0
- package/dist/viewer/app/scene.js +529 -0
- package/dist/viewer/app/select.js +142 -0
- package/dist/viewer/app/selection.js +79 -0
- package/dist/viewer/app/style.css +172 -0
- package/dist/viewer/app/undo.js +49 -0
- package/dist/viewer/app/units.js +51 -0
- package/dist/viewer/app/vars.js +64 -0
- package/dist/viewer/index.html +53 -184
- package/dist/viewer/server.d.ts +42 -1
- package/dist/viewer/server.js +432 -17
- package/dist/workspace.d.ts +52 -2
- package/dist/workspace.js +95 -3
- package/package.json +8 -3
- package/shelf/brick_wall.json +1 -1
- package/shelf/wall_segment.json +2 -2
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// The viewer page (docs/design/viewer.md): boot, layout, the render loop over the views, the keymap and the mouse,
|
|
2
|
+
// the status bar and the server's event stream. The camera moves only when the person moves it or frames: loading,
|
|
3
|
+
// cooking, saving and placing never frame (the one exception is the very first view, when nothing is remembered).
|
|
4
|
+
import * as THREE from 'three';
|
|
5
|
+
import { api } from './api.js';
|
|
6
|
+
import { createViews, CAMERA_NAMES, SHADINGS } from './camera.js';
|
|
7
|
+
import { createGrid, createOverlays, cursorText } from './grid.js';
|
|
8
|
+
import { MeshObject, parseGLB, createLights, LINE_MATERIALS } from './scene.js';
|
|
9
|
+
import { createLayers } from './layers.js';
|
|
10
|
+
import { createGuides, PLACE_KINDS } from './guides.js';
|
|
11
|
+
import { createSelect } from './select.js';
|
|
12
|
+
import { createOutliner } from './outliner.js';
|
|
13
|
+
import { createProps } from './props.js';
|
|
14
|
+
import { createSelection, parseId } from './selection.js';
|
|
15
|
+
import { createUndo } from './undo.js';
|
|
16
|
+
import { EDITOR_NAMES } from './markers.js';
|
|
17
|
+
import * as KM from './keymap.js';
|
|
18
|
+
import { fmtStep, fmtSize, fmtNum } from './units.js';
|
|
19
|
+
|
|
20
|
+
const $ = (id) => document.getElementById(id);
|
|
21
|
+
const BG = new THREE.Color('#2b2d31');
|
|
22
|
+
|
|
23
|
+
// ------------------------------------------------------------------------------------------- storage
|
|
24
|
+
// per-viewer conveniences: the keymap and panel sizes per browser, cameras and shown layers per viewer address
|
|
25
|
+
const store = (prefix) => ({
|
|
26
|
+
get(k, def) { try { const v = localStorage.getItem(prefix + k); return v === null ? def : JSON.parse(v); } catch { return def; } },
|
|
27
|
+
set(k, v) { try { localStorage.setItem(prefix + k, JSON.stringify(v)); } catch { /* storage blocked */ } },
|
|
28
|
+
});
|
|
29
|
+
const local = store(`topotopo.viewer.${location.host}.`), browser = store('topotopo.viewer.');
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------------------------------------- context
|
|
32
|
+
const listeners = new Map();
|
|
33
|
+
let version = 0;
|
|
34
|
+
const ctx = {
|
|
35
|
+
THREE, store: local, objects: new Map(), hover: null,
|
|
36
|
+
on(type, f) { if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(f); },
|
|
37
|
+
emit(type) { if (type !== 'hover') version++; for (const f of listeners.get(type) ?? []) f(); for (const f of listeners.get('*') ?? []) f(type); },
|
|
38
|
+
version: () => version,
|
|
39
|
+
/** At most one run of fn at a time; calls during a run ask for one more run after it, and wait for that. */
|
|
40
|
+
coalesce(fn) {
|
|
41
|
+
let busy = null, again = false;
|
|
42
|
+
return () => {
|
|
43
|
+
if (busy) { again = true; return busy; }
|
|
44
|
+
busy = (async () => { try { do { again = false; await fn(); } while (again); } finally { busy = null; } })();
|
|
45
|
+
return busy;
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ------------------------------------------------------------------------------------------- status bar
|
|
51
|
+
const msgEl = $('msg');
|
|
52
|
+
let heldUntil = 0;
|
|
53
|
+
/** A line in the status bar; `hold` ms keeps it over routine lines (an undo over the save that follows it). */
|
|
54
|
+
ctx.message = (text, kind = '', hold = 0) => {
|
|
55
|
+
const now = performance.now();
|
|
56
|
+
if (!hold && kind !== 'err' && now < heldUntil) return;
|
|
57
|
+
heldUntil = hold ? now + hold : 0;
|
|
58
|
+
msgEl.textContent = text; msgEl.className = kind; msgEl.title = text;
|
|
59
|
+
};
|
|
60
|
+
/** A small question in the status bar; resolves to the answer. */
|
|
61
|
+
ctx.ask = (text, yes = 'Yes', no = 'No') => new Promise((resolve) => {
|
|
62
|
+
$('ask-text').textContent = text; $('ask-yes').textContent = yes; $('ask-no').textContent = no;
|
|
63
|
+
$('ask').hidden = false;
|
|
64
|
+
const done = (v) => { $('ask').hidden = true; $('ask-yes').onclick = $('ask-no').onclick = null; resolve(v); };
|
|
65
|
+
$('ask-yes').onclick = () => done(true);
|
|
66
|
+
$('ask-no').onclick = () => done(false);
|
|
67
|
+
$('ask-yes').focus();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ------------------------------------------------------------------------------------------- three.js
|
|
71
|
+
const viewport = $('viewport'), canvas = $('gl');
|
|
72
|
+
// logarithmic depth: a 1 km terrain and a door handle in one view without near-plane clipping or z-fighting
|
|
73
|
+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, logarithmicDepthBuffer: true });
|
|
74
|
+
renderer.autoClear = false;
|
|
75
|
+
const scene = new THREE.Scene();
|
|
76
|
+
ctx.outputRoot = new THREE.Group(); ctx.outputRoot.name = 'output';
|
|
77
|
+
ctx.layersRoot = new THREE.Group(); ctx.layersRoot.name = 'layers';
|
|
78
|
+
ctx.guidesRoot = new THREE.Group(); ctx.guidesRoot.name = 'guides';
|
|
79
|
+
scene.add(ctx.outputRoot, ctx.layersRoot, ctx.guidesRoot);
|
|
80
|
+
const lights = createLights(scene);
|
|
81
|
+
const grid = createGrid(scene);
|
|
82
|
+
|
|
83
|
+
/** Bounds of everything drawn (the output, the shown layers, the guides). */
|
|
84
|
+
ctx.sceneBounds = ({ pictures = true, guides = true } = {}) => {
|
|
85
|
+
const b = new THREE.Box3();
|
|
86
|
+
if (ctx.output.mesh && ctx.output.visible) b.union(ctx.output.mesh.bounds());
|
|
87
|
+
b.union(ctx.layers.bounds({ pictures }));
|
|
88
|
+
if (guides) for (const m of ctx.guides.list) if (!ctx.guides.hidden.has(m.id)) b.union(ctx.guides.bounds(m));
|
|
89
|
+
return b;
|
|
90
|
+
};
|
|
91
|
+
let sphere = { center: new THREE.Vector3(), radius: 10 }, sphereAt = -1;
|
|
92
|
+
const views = createViews(() => {
|
|
93
|
+
if (sphereAt !== version) {
|
|
94
|
+
sphereAt = version;
|
|
95
|
+
const b = ctx.sceneBounds();
|
|
96
|
+
sphere = b.isEmpty() ? { center: new THREE.Vector3(), radius: 10 } : { center: b.getCenter(new THREE.Vector3()), radius: Math.max(b.getSize(new THREE.Vector3()).length() / 2, 1e-3) };
|
|
97
|
+
}
|
|
98
|
+
return sphere;
|
|
99
|
+
});
|
|
100
|
+
ctx.views = views;
|
|
101
|
+
views.load(local.get('views', null));
|
|
102
|
+
const saveViews = () => local.set('views', views.toJSON());
|
|
103
|
+
|
|
104
|
+
// what is being drawn: per view, the slot and its rect (constant-size marks scale by it)
|
|
105
|
+
let drawing = { rect: { h: 1 } };
|
|
106
|
+
/** Metres per pixel at a world point for the view being drawn. */
|
|
107
|
+
ctx.pxWorld = (camera, at) => {
|
|
108
|
+
const h = Math.max(1, drawing.rect.h);
|
|
109
|
+
if (camera.isOrthographicCamera) return (camera.top - camera.bottom) / camera.zoom / h;
|
|
110
|
+
const d = Math.max(1e-9, -at.clone().applyMatrix4(camera.matrixWorldInverse).z);
|
|
111
|
+
return (2 * d * Math.tan((camera.fov * Math.PI) / 360)) / h;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// ------------------------------------------------------------------------------------------- selection, undo, parts
|
|
115
|
+
ctx.selection = createSelection((obj) => ctx.objects.get(obj)?.mesh?.pieceCount() ?? 0);
|
|
116
|
+
ctx.undo = createUndo();
|
|
117
|
+
ctx.setHover = (id) => { if (ctx.hover === id) return; ctx.hover = id; ctx.emit('hover'); };
|
|
118
|
+
|
|
119
|
+
// the output: the node being shown, its display mode and visibility remembered
|
|
120
|
+
ctx.output = {
|
|
121
|
+
mesh: null, summary: '', error: null, report: null, loading: false,
|
|
122
|
+
visible: local.get('output.visible', true), display: local.get('output.display', 'view'),
|
|
123
|
+
setVisible(v) { this.visible = v; local.set('output.visible', v); outEntry(); ctx.emit('output'); ctx.requestRender(); },
|
|
124
|
+
setDisplay(m) { this.display = m; local.set('output.display', m); outEntry(); ctx.emit('output'); ctx.requestRender(); },
|
|
125
|
+
};
|
|
126
|
+
const outEntry = () => {
|
|
127
|
+
const o = ctx.output;
|
|
128
|
+
ctx.objects.set('out', { id: 'out', kind: 'output', label: 'output', mesh: o.mesh, visible: o.visible, display: o.display, pickable: true, surface: true });
|
|
129
|
+
};
|
|
130
|
+
outEntry();
|
|
131
|
+
|
|
132
|
+
ctx.layers = createLayers(ctx);
|
|
133
|
+
ctx.guides = createGuides(ctx);
|
|
134
|
+
ctx.select = createSelect(ctx);
|
|
135
|
+
ctx.surfacePoint = ctx.select.surfacePoint;
|
|
136
|
+
ctx.planePoint = ctx.select.planePoint;
|
|
137
|
+
const outliner = createOutliner(ctx, $('tree'));
|
|
138
|
+
const props = createProps(ctx);
|
|
139
|
+
|
|
140
|
+
// ------------------------------------------------------------------------------------------- graphs and the output
|
|
141
|
+
ctx.graph = {
|
|
142
|
+
list: [], name: null, node: null,
|
|
143
|
+
current() { return this.list.find((g) => g.name === this.name) ?? null; },
|
|
144
|
+
/** the node whose result is shown: the chosen one, else the graph's output */
|
|
145
|
+
shownNode() { const g = this.current(); return this.node ?? g?.output ?? null; },
|
|
146
|
+
show(node) {
|
|
147
|
+
const g = this.current();
|
|
148
|
+
this.node = !g || node === g.output ? null : node;
|
|
149
|
+
ctx.selection.apply(['out']);
|
|
150
|
+
loadGeo(); ctx.emit('graphs');
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const refreshGraphs = ctx.coalesce(async () => {
|
|
154
|
+
const issued = performance.now();
|
|
155
|
+
const list = await api.graphs();
|
|
156
|
+
if (!list) return;
|
|
157
|
+
const G = ctx.graph;
|
|
158
|
+
G.list = list;
|
|
159
|
+
const names = list.map((g) => g.name);
|
|
160
|
+
const sel = $('graph');
|
|
161
|
+
// the options are rebuilt only when the graphs change, so an open list stays open
|
|
162
|
+
if (names.join('\n') !== [...sel.options].map((o) => o.value).join('\n')) sel.replaceChildren(...names.map((n) => new Option(n, n)));
|
|
163
|
+
if (!names.includes(G.name)) { G.name = local.get('graph', null); if (!names.includes(G.name)) G.name = names[0] ?? null; G.node = null; }
|
|
164
|
+
sel.value = G.name ?? '';
|
|
165
|
+
const g = G.current();
|
|
166
|
+
if (G.node && !g?.nodes.some((n) => n.name === G.node)) G.node = null;
|
|
167
|
+
props.controls.render(g, issued);
|
|
168
|
+
if (!g) ctx.message('no graphs yet — build one via MCP');
|
|
169
|
+
ctx.emit('graphs');
|
|
170
|
+
});
|
|
171
|
+
$('graph').onchange = (e) => {
|
|
172
|
+
ctx.graph.name = e.target.value; ctx.graph.node = null;
|
|
173
|
+
local.set('graph', ctx.graph.name);
|
|
174
|
+
props.controls.render(ctx.graph.current());
|
|
175
|
+
ctx.emit('graphs');
|
|
176
|
+
loadGeo();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
let firstFrame = !views.placed;
|
|
180
|
+
const loadGeo = ctx.coalesce(async () => {
|
|
181
|
+
const G = ctx.graph, o = ctx.output;
|
|
182
|
+
if (!G.name) return;
|
|
183
|
+
const graph = G.name, node = G.node ?? '';
|
|
184
|
+
o.loading = true; ctx.emit('output');
|
|
185
|
+
const r = await api.geo(graph, node);
|
|
186
|
+
o.loading = false;
|
|
187
|
+
if (graph !== G.name) return;
|
|
188
|
+
if (!r.ok) {
|
|
189
|
+
// the last good geometry stays
|
|
190
|
+
o.error = r.error;
|
|
191
|
+
ctx.emit('output'); updateCook();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
let src;
|
|
195
|
+
try { src = await parseGLB(r.buf); } catch (e) { o.error = String(e?.message ?? e); ctx.emit('output'); updateCook(); return; }
|
|
196
|
+
const prev = o.mesh;
|
|
197
|
+
o.mesh = new MeshObject('out', src);
|
|
198
|
+
o.mesh.onReady = () => { ctx.requestRender(); ctx.emit('output'); };
|
|
199
|
+
o.error = null;
|
|
200
|
+
ctx.outputRoot.add(o.mesh.root);
|
|
201
|
+
if (prev) prev.dispose();
|
|
202
|
+
outEntry();
|
|
203
|
+
// pieces that no longer exist leave the selection (counting pieces only when some are selected)
|
|
204
|
+
if (ctx.selection.items.some((id) => id.startsWith('out#'))) ctx.selection.prune((id) => { const { obj, piece } = parseId(id); return obj !== 'out' || piece === null || piece < o.mesh.pieceCount(); });
|
|
205
|
+
// the very first view frames what there is; later loads never move the camera
|
|
206
|
+
if (firstFrame) { firstFrame = false; frameEverywhere(); }
|
|
207
|
+
ctx.emit('output');
|
|
208
|
+
ctx.requestRender();
|
|
209
|
+
const [info, report] = await Promise.all([api.info(graph, node), api.report(graph)]);
|
|
210
|
+
if (graph !== G.name) return;
|
|
211
|
+
o.summary = info?.summary ?? '';
|
|
212
|
+
o.report = report;
|
|
213
|
+
ctx.emit('output'); updateCook();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
function updateCook() {
|
|
217
|
+
const el = $('cook'), o = ctx.output, g = ctx.graph.current();
|
|
218
|
+
if (!g) { el.textContent = ''; return; }
|
|
219
|
+
const m = /geometry: ([\d,]+) points, ([\d,]+) prims/.exec(o.summary ?? '');
|
|
220
|
+
const parts = [`${g.name}/${ctx.graph.shownNode() ?? ''}`];
|
|
221
|
+
if (m) parts.push(`${m[1]} points`, `${m[2]} prims`);
|
|
222
|
+
if (o.report?.cooked) parts.push(`cook ${fmtNum(o.report.ms, 0)} ms`);
|
|
223
|
+
const err = o.error ?? (o.report?.errors?.length ? o.report.errors[0] : null) ?? g.nodes.find((n) => n.error)?.error;
|
|
224
|
+
el.replaceChildren(document.createTextNode(parts.join(' · ')));
|
|
225
|
+
if (err) { const e = document.createElement('span'); e.className = 'err'; e.textContent = ` · ${String(err).split('\n')[0]}`; el.append(e); el.title = String(err); }
|
|
226
|
+
else el.title = parts.join(' · ');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ------------------------------------------------------------------------------------------- framing
|
|
230
|
+
/** The views' rects for the viewport as it is now (framing needs each view's aspect). */
|
|
231
|
+
const layoutViews = () => views.layout(viewport.clientWidth, viewport.clientHeight);
|
|
232
|
+
function frameEverywhere() {
|
|
233
|
+
const b = ctx.sceneBounds();
|
|
234
|
+
if (b.isEmpty()) return;
|
|
235
|
+
// each slot as the quad shows it, then the single view as it is
|
|
236
|
+
const quad = views.quad;
|
|
237
|
+
views.quad = true; layoutViews();
|
|
238
|
+
for (let i = 0; i < 4; i++) if (i !== views.active) views.frame(i, b);
|
|
239
|
+
views.quad = quad; layoutViews();
|
|
240
|
+
views.frame(views.active, b);
|
|
241
|
+
saveViews();
|
|
242
|
+
}
|
|
243
|
+
function frameSelection() {
|
|
244
|
+
const b = ctx.select.selectionBounds();
|
|
245
|
+
if (b.isEmpty()) return frameAll();
|
|
246
|
+
layoutViews(); views.frame(views.active, b); saveViews(); ctx.requestRender();
|
|
247
|
+
}
|
|
248
|
+
function frameAll() {
|
|
249
|
+
let b = ctx.sceneBounds();
|
|
250
|
+
if (b.isEmpty()) b = new THREE.Box3(new THREE.Vector3(-5, -1, -5), new THREE.Vector3(5, 1, 5));
|
|
251
|
+
layoutViews(); views.frame(views.active, b); saveViews(); ctx.requestRender();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ------------------------------------------------------------------------------------------- drawing
|
|
255
|
+
const overlays = createOverlays($('views'), (slot, axis) => { setActive(slot); views.lookAlong(slot, axis); saveViews(); syncTop(); ctx.requestRender(); });
|
|
256
|
+
const cursor = { slot: -1, x: 0, y: 0 };
|
|
257
|
+
let queued = false, overlayQueued = false, lastSize = '', inDraw = false;
|
|
258
|
+
ctx.requestRender = () => { if (!queued && !inDraw) { queued = true; requestAnimationFrame(draw); } };
|
|
259
|
+
const requestOverlays = () => { if (!overlayQueued && !queued) { overlayQueued = true; requestAnimationFrame(() => { overlayQueued = false; if (!queued) drawOverlays(); }); } };
|
|
260
|
+
const modeOf = (display, shading) => (display === 'view' ? shading : display);
|
|
261
|
+
const steps = [1, 1, 1, 1];
|
|
262
|
+
|
|
263
|
+
function draw() {
|
|
264
|
+
queued = false;
|
|
265
|
+
inDraw = true;
|
|
266
|
+
try { drawViews(); } finally { inDraw = false; }
|
|
267
|
+
drawOverlays();
|
|
268
|
+
}
|
|
269
|
+
function drawViews() {
|
|
270
|
+
const W = viewport.clientWidth, H = viewport.clientHeight, dpr = Math.min(2, devicePixelRatio || 1);
|
|
271
|
+
if (`${W}x${H}@${dpr}` !== lastSize) { lastSize = `${W}x${H}@${dpr}`; renderer.setPixelRatio(dpr); renderer.setSize(W, H, false); }
|
|
272
|
+
views.layout(W, H);
|
|
273
|
+
renderer.setScissorTest(true);
|
|
274
|
+
renderer.setClearColor(BG, 1);
|
|
275
|
+
const entries = [...ctx.objects.values()].filter((e) => e.mesh);
|
|
276
|
+
for (const e of entries) e.mesh.bboxMode = e.display === 'bbox';
|
|
277
|
+
// highlights and guide states first, so this frame shows them
|
|
278
|
+
ctx.select.highlight();
|
|
279
|
+
for (const s of views.visible()) {
|
|
280
|
+
const i = views.slots.indexOf(s), r = s.rect, camera = views.camera(i), cam = views.cam(i);
|
|
281
|
+
drawing = { slot: i, rect: r };
|
|
282
|
+
const glY = H - r.y - r.h;
|
|
283
|
+
renderer.setViewport(r.x, glY, r.w, r.h);
|
|
284
|
+
renderer.setScissor(r.x, glY, r.w, r.h);
|
|
285
|
+
renderer.clear();
|
|
286
|
+
for (const m of LINE_MATERIALS) m.resolution.set(r.w, r.h);
|
|
287
|
+
for (const e of entries) { e.mesh.root.visible = !!e.visible; e.mesh.setMode(modeOf(e.display ?? 'view', s.shading)); }
|
|
288
|
+
steps[i] = grid.prepare(cam, views.worldPerPixel(i));
|
|
289
|
+
lights.follow(camera, cam.target);
|
|
290
|
+
renderer.render(scene, camera);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Readouts, the gizmo, labels and the pivot cross of every view (DOM, no WebGL). */
|
|
295
|
+
function drawOverlays() {
|
|
296
|
+
const selBox = ctx.selection.size ? ctx.select.selectionBounds() : null;
|
|
297
|
+
const selSize = selBox && !selBox.isEmpty() ? fmtSize(selBox.getSize(new THREE.Vector3()).toArray()) : '';
|
|
298
|
+
const pivot = selBox && !selBox.isEmpty() ? selBox.getCenter(new THREE.Vector3()) : null;
|
|
299
|
+
const labels = ctx.guides.labels();
|
|
300
|
+
for (let i = 0; i < 4; i++) {
|
|
301
|
+
const s = views.slots[i], ov = overlays[i], visible = views.visible().includes(s);
|
|
302
|
+
if (!visible) { ov.update({ visible: false }); continue; }
|
|
303
|
+
const cam = views.cam(i), camera = views.camera(i);
|
|
304
|
+
let cur = '';
|
|
305
|
+
if (cursor.slot === i && cam.ortho) {
|
|
306
|
+
const p = cam.name === 'persp' ? ctx.select.planePoint(i, cursor.x, cursor.y, 0) : ctx.surfacePlane(i, cursor.x, cursor.y);
|
|
307
|
+
cur = cursorText(cam, p, steps[i]);
|
|
308
|
+
}
|
|
309
|
+
ov.update({ rect: s.rect, visible, quad: views.quad, active: i === views.active, cam, camera, step: steps[i], cursor: cur, selSize });
|
|
310
|
+
// labels of hovered / selected guides
|
|
311
|
+
const seen = new Set();
|
|
312
|
+
for (const l of labels) {
|
|
313
|
+
const q = views.project(i, l.at, camera);
|
|
314
|
+
let e = ov.labels.querySelector(`[data-k="${CSS.escape(l.key)}"]`);
|
|
315
|
+
if (!q) { e?.remove(); continue; }
|
|
316
|
+
if (!e) { e = document.createElement('div'); e.dataset.k = l.key; ov.labels.append(e); }
|
|
317
|
+
seen.add(l.key);
|
|
318
|
+
if (e.textContent !== l.text) e.textContent = l.text;
|
|
319
|
+
e.className = l.cls;
|
|
320
|
+
e.style.left = `${q.x - s.rect.x}px`; e.style.top = `${q.y - s.rect.y}px`;
|
|
321
|
+
}
|
|
322
|
+
for (const e of [...ov.labels.children]) if (!seen.has(e.dataset.k)) e.remove();
|
|
323
|
+
// the pivot of the selection: a small cross
|
|
324
|
+
const q = pivot && views.project(i, pivot, camera);
|
|
325
|
+
ov.marks.innerHTML = q ? `<g stroke="#f0a030" stroke-width="1.5"><line x1="${q.x - s.rect.x - 6}" y1="${q.y - s.rect.y}" x2="${q.x - s.rect.x + 6}" y2="${q.y - s.rect.y}"/><line x1="${q.x - s.rect.x}" y1="${q.y - s.rect.y - 6}" x2="${q.x - s.rect.x}" y2="${q.y - s.rect.y + 6}"/></g>` : '';
|
|
326
|
+
}
|
|
327
|
+
const st = $('step');
|
|
328
|
+
const t = `grid ${fmtStep(steps[views.active])}`;
|
|
329
|
+
if (st.textContent !== t) st.textContent = t;
|
|
330
|
+
}
|
|
331
|
+
/** The cursor on an axis view's construction plane. */
|
|
332
|
+
ctx.surfacePlane = (i, x, y) => {
|
|
333
|
+
const c = views.cam(i), f = { top: 1, bottom: 1, front: 2, back: 2, right: 0, left: 0 }[c.name];
|
|
334
|
+
const n = new THREE.Vector3(f === 0 ? 1 : 0, f === 1 ? 1 : 0, f === 2 ? 1 : 0);
|
|
335
|
+
return views.ray(i, x, y).ray.intersectPlane(new THREE.Plane(n, 0), new THREE.Vector3());
|
|
336
|
+
};
|
|
337
|
+
new ResizeObserver(() => ctx.requestRender()).observe(viewport);
|
|
338
|
+
|
|
339
|
+
// ------------------------------------------------------------------------------------------- top bar
|
|
340
|
+
let preset = browser.get('keymap', KM.DEFAULT_PRESET);
|
|
341
|
+
if (!KM.PRESETS.some((p) => p.id === preset)) preset = KM.DEFAULT_PRESET;
|
|
342
|
+
$('preset').replaceChildren(...KM.PRESETS.map((p) => new Option(p.label, p.id)));
|
|
343
|
+
$('preset').value = preset;
|
|
344
|
+
$('preset').onchange = (e) => { preset = e.target.value; browser.set('keymap', preset); e.target.blur(); ctx.message(`keymap: ${KM.PRESETS.find((p) => p.id === preset).label} (? shows it)`); };
|
|
345
|
+
$('view-cam').replaceChildren(...CAMERA_NAMES.map((n) => new Option(n, n)));
|
|
346
|
+
$('view-cam').onchange = (e) => { views.setCam(views.active, e.target.value); saveViews(); e.target.blur(); syncTop(); ctx.requestRender(); };
|
|
347
|
+
$('view-shading').replaceChildren(...SHADINGS.map(([v, t]) => new Option(t, v)));
|
|
348
|
+
$('view-shading').onchange = (e) => { views.slots[views.active].shading = e.target.value; saveViews(); e.target.blur(); ctx.requestRender(); };
|
|
349
|
+
$('quad').onclick = () => { toggleQuad(); $('quad').blur(); };
|
|
350
|
+
$('help').onclick = () => { KM.toggleOverlay(document.body, preset); $('help').blur(); };
|
|
351
|
+
function syncTop() {
|
|
352
|
+
const s = views.slots[views.active];
|
|
353
|
+
$('view-cam').value = s.cam;
|
|
354
|
+
$('view-shading').value = s.shading;
|
|
355
|
+
$('quad').classList.toggle('on', views.quad);
|
|
356
|
+
$('quad').textContent = views.quad ? 'single' : 'quad';
|
|
357
|
+
const mode = ctx.guides.mode;
|
|
358
|
+
$('mode').textContent = mode ? `placing ${EDITOR_NAMES[mode]}` : '';
|
|
359
|
+
}
|
|
360
|
+
function setActive(i) { if (i >= 0 && views.active !== i) { views.active = i; syncTop(); requestOverlays(); } }
|
|
361
|
+
function toggleQuad(at = cursor.slot) {
|
|
362
|
+
if (views.quad && at >= 0) views.active = at;
|
|
363
|
+
views.quad = !views.quad;
|
|
364
|
+
// the new rects at once: the next click must not wait for a frame
|
|
365
|
+
layoutViews();
|
|
366
|
+
saveViews(); syncTop(); ctx.requestRender();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ------------------------------------------------------------------------------------------- panels: drag to resize
|
|
370
|
+
const layout = browser.get('layout', {});
|
|
371
|
+
const root = document.documentElement.style;
|
|
372
|
+
const applyLayout = () => {
|
|
373
|
+
if (layout.left) root.setProperty('--left', `${layout.left}px`);
|
|
374
|
+
if (layout.right) root.setProperty('--right', `${layout.right}px`);
|
|
375
|
+
if (layout.split) root.setProperty('--split', `${layout.split}%`);
|
|
376
|
+
};
|
|
377
|
+
applyLayout();
|
|
378
|
+
for (const g of document.querySelectorAll('.grip')) {
|
|
379
|
+
g.addEventListener('pointerdown', (e) => {
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
g.setPointerCapture(e.pointerId);
|
|
382
|
+
g.classList.add('drag');
|
|
383
|
+
const kind = g.dataset.grip, props0 = $('props').getBoundingClientRect();
|
|
384
|
+
const move = (ev) => {
|
|
385
|
+
if (kind === 'left') layout.left = Math.round(Math.max(160, Math.min(innerWidth * 0.45, ev.clientX)));
|
|
386
|
+
else if (kind === 'right') layout.right = Math.round(Math.max(220, Math.min(innerWidth * 0.5, innerWidth - ev.clientX)));
|
|
387
|
+
else layout.split = Math.round(Math.max(10, Math.min(90, ((ev.clientY - props0.top - 26) / Math.max(1, props0.height - 52)) * 100)));
|
|
388
|
+
applyLayout(); ctx.requestRender();
|
|
389
|
+
};
|
|
390
|
+
const up = () => { g.classList.remove('drag'); g.removeEventListener('pointermove', move); g.removeEventListener('pointerup', up); browser.set('layout', layout); };
|
|
391
|
+
g.addEventListener('pointermove', move);
|
|
392
|
+
g.addEventListener('pointerup', up);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ------------------------------------------------------------------------------------------- actions
|
|
397
|
+
/** Keys go to a field being typed in (text and number fields, menus), not to the keymap. */
|
|
398
|
+
const TEXT_INPUTS = new Set(['text', 'number', 'search', 'email', 'url', 'password', 'tel']);
|
|
399
|
+
const typing = (t) => (t instanceof HTMLInputElement && TEXT_INPUTS.has(t.type)) || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement || !!t?.isContentEditable;
|
|
400
|
+
const selectedOf = (kind) => ctx.selection.items.filter((id) => ctx.objects.get(parseId(id).obj)?.kind === kind && parseId(id).piece === null);
|
|
401
|
+
const hiddenByH = { layers: new Set() };
|
|
402
|
+
const DISPLAY_CYCLE = ['shaded', 'wire', 'shadedWire'];
|
|
403
|
+
function run(action) {
|
|
404
|
+
const slot = views.slots[views.active];
|
|
405
|
+
switch (action) {
|
|
406
|
+
case 'frameSelection': return frameSelection();
|
|
407
|
+
case 'frameAll': return frameAll();
|
|
408
|
+
case 'quad': return toggleQuad();
|
|
409
|
+
case 'viewPersp': case 'viewTop': case 'viewFront': case 'viewRight':
|
|
410
|
+
views.setCam(views.active, action.slice(4).toLowerCase()); saveViews(); syncTop(); return ctx.requestRender();
|
|
411
|
+
case 'ortho': views.toggleOrtho(views.active); saveViews(); syncTop(); return ctx.requestRender();
|
|
412
|
+
case 'selectMode': ctx.guides.setMode(null); return syncTop();
|
|
413
|
+
case 'displayWire': case 'displayShaded': case 'displayShadedWire':
|
|
414
|
+
slot.shading = { displayWire: 'wire', displayShaded: 'shaded', displayShadedWire: 'shadedWire' }[action]; break;
|
|
415
|
+
case 'displayCycle': slot.shading = DISPLAY_CYCLE[(DISPLAY_CYCLE.indexOf(slot.shading) + 1) % 3]; break;
|
|
416
|
+
case 'wireToggle': slot.lastShading = slot.shading === 'wire' ? slot.lastShading : slot.shading; slot.shading = slot.shading === 'wire' ? slot.lastShading ?? 'shaded' : 'wire'; break;
|
|
417
|
+
case 'placeNull': case 'placeCurve': case 'placeArea': case 'placeVolume': ctx.guides.setMode(PLACE_KINDS[action]); return syncTop();
|
|
418
|
+
case 'finish': case 'removeLast': ctx.guides.key(action); return;
|
|
419
|
+
case 'cancel': {
|
|
420
|
+
if (document.querySelector('.keymap-overlay')) return KM.toggleOverlay(document.body, preset);
|
|
421
|
+
if (ctx.select.press) return ctx.select.cancel();
|
|
422
|
+
if (ctx.guides.key('cancel')) return;
|
|
423
|
+
ctx.guides.setMode(null); ctx.selection.clear(); return syncTop();
|
|
424
|
+
}
|
|
425
|
+
case 'delete': {
|
|
426
|
+
const ids = selectedOf('guide').map((id) => id.slice(6));
|
|
427
|
+
const n = ctx.guides.remove(ids);
|
|
428
|
+
if (n) ctx.message(`deleted ${n} guide${n === 1 ? '' : 's'} · ${undoKeys()} brings ${n === 1 ? 'it' : 'them'} back`, '', 2000);
|
|
429
|
+
else if (ctx.selection.size) ctx.message('Delete removes guides; the output and reference layers are files and stay (hide them with H)');
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
case 'undo': case 'redo': {
|
|
433
|
+
const label = action === 'undo' ? ctx.undo.undo() : ctx.undo.redo();
|
|
434
|
+
return ctx.message(label ? `${action === 'undo' ? 'undid' : 'redid'} ${label}` : `nothing to ${action}`, '', 2000);
|
|
435
|
+
}
|
|
436
|
+
case 'hide': {
|
|
437
|
+
if (selectedOf('output').length || ctx.selection.items.some((id) => parseId(id).obj === 'out')) ctx.output.setVisible(false);
|
|
438
|
+
for (const id of selectedOf('layer')) { const p = id.slice(6); hiddenByH.layers.add(p); ctx.layers.show(p, false); }
|
|
439
|
+
ctx.guides.setHidden(selectedOf('guide').map((id) => id.slice(6)), true);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
case 'showAll': {
|
|
443
|
+
ctx.output.setVisible(true);
|
|
444
|
+
for (const p of hiddenByH.layers) if (ctx.layers.list.some((f) => f.path === p)) ctx.layers.show(p, true);
|
|
445
|
+
hiddenByH.layers.clear();
|
|
446
|
+
ctx.guides.showAll();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
case 'overlay': return KM.toggleOverlay(document.body, preset);
|
|
450
|
+
default: return;
|
|
451
|
+
}
|
|
452
|
+
saveViews(); syncTop(); ctx.requestRender();
|
|
453
|
+
}
|
|
454
|
+
const undoKeys = () => KM.overlayRows(preset).find((r) => r.label.startsWith('undo'))?.text.split(' / ')[0] ?? 'undo';
|
|
455
|
+
|
|
456
|
+
// keys; Space is a tap (Maya) or a held modifier (Houdini)
|
|
457
|
+
const space = { down: false, tap: false, t: 0 };
|
|
458
|
+
addEventListener('keydown', (e) => {
|
|
459
|
+
if (typing(e.target)) return;
|
|
460
|
+
if (e.code === 'Space' || e.key === ' ') {
|
|
461
|
+
e.preventDefault();
|
|
462
|
+
if (!e.repeat && !space.down) { space.down = true; space.tap = true; space.t = performance.now(); }
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (space.down) space.tap = false;
|
|
466
|
+
if (e.key === 'Alt') { e.preventDefault(); return; }
|
|
467
|
+
const action = KM.matchKey(preset, e, space.down && KM.spaceIsModifier(preset));
|
|
468
|
+
if (!action) return;
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
run(action);
|
|
471
|
+
});
|
|
472
|
+
addEventListener('keyup', (e) => {
|
|
473
|
+
if (e.code === 'Space' || e.key === ' ') {
|
|
474
|
+
const tap = space.down && space.tap && performance.now() - space.t < 400;
|
|
475
|
+
space.down = false;
|
|
476
|
+
if (tap && !typing(e.target)) { const a = KM.matchKey(preset, { key: ' ', code: 'Space' }); if (a) run(a); }
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (e.key === 'Alt') e.preventDefault();
|
|
480
|
+
});
|
|
481
|
+
addEventListener('blur', () => { space.down = false; });
|
|
482
|
+
|
|
483
|
+
// the mouse in the viewport
|
|
484
|
+
let drag = null;
|
|
485
|
+
const at = (e) => { const r = viewport.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
|
|
486
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
487
|
+
layoutViews();
|
|
488
|
+
const { x, y } = at(e), slot = views.slotAt(x, y);
|
|
489
|
+
if (slot < 0 || drag) return;
|
|
490
|
+
setActive(slot);
|
|
491
|
+
space.tap = false;
|
|
492
|
+
// the keys go to the viewport again (preventDefault below keeps the browser from moving the focus itself)
|
|
493
|
+
if (document.activeElement instanceof HTMLElement && document.activeElement !== document.body) document.activeElement.blur();
|
|
494
|
+
const nav = KM.matchDrag(preset, { button: e.button, altKey: e.altKey, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, space: space.down && KM.spaceIsModifier(preset) });
|
|
495
|
+
try { canvas.setPointerCapture(e.pointerId); } catch { /* a pointer the browser does not track */ }
|
|
496
|
+
e.preventDefault();
|
|
497
|
+
if (nav) {
|
|
498
|
+
const sel = ctx.select.selectionBounds();
|
|
499
|
+
drag = { kind: 'nav', nav, slot, x, y, id: e.pointerId, pivot: sel.isEmpty() ? null : sel.getCenter(new THREE.Vector3()) };
|
|
500
|
+
viewport.classList.add('nav');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (e.button !== 0) return;
|
|
504
|
+
if (ctx.guides.mode) { drag = { kind: 'place', slot, id: e.pointerId }; ctx.guides.pointerDown(slot, x, y); }
|
|
505
|
+
else { drag = { kind: 'select', slot, id: e.pointerId }; ctx.select.down(slot, x, y, e); }
|
|
506
|
+
});
|
|
507
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
508
|
+
const { x, y } = at(e);
|
|
509
|
+
if (drag && e.pointerId === drag.id) {
|
|
510
|
+
if (drag.kind === 'nav') {
|
|
511
|
+
const dx = x - drag.x, dy = y - drag.y;
|
|
512
|
+
drag.x = x; drag.y = y;
|
|
513
|
+
if (drag.nav === 'tumble') views.tumble(drag.slot, dx, dy, drag.pivot);
|
|
514
|
+
else if (drag.nav === 'track') views.track(drag.slot, dx, dy);
|
|
515
|
+
else views.dolly(drag.slot, Math.exp(-(dx - dy) * 0.006));
|
|
516
|
+
ctx.requestRender();
|
|
517
|
+
} else if (drag.kind === 'place') ctx.guides.pointerMove(drag.slot, x, y);
|
|
518
|
+
else ctx.select.move(x, y);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
layoutViews();
|
|
522
|
+
const slot = views.slotAt(x, y);
|
|
523
|
+
cursor.slot = slot; cursor.x = x; cursor.y = y;
|
|
524
|
+
if (slot >= 0 && views.cam(slot).ortho) requestOverlays();
|
|
525
|
+
if (ctx.guides.mode) { if (slot >= 0) ctx.guides.pointerMove(slot, x, y); }
|
|
526
|
+
else ctx.select.hover(slot, x, y);
|
|
527
|
+
});
|
|
528
|
+
const endDrag = (e, cancel = false) => {
|
|
529
|
+
if (!drag || e.pointerId !== drag.id) return;
|
|
530
|
+
const d = drag;
|
|
531
|
+
drag = null;
|
|
532
|
+
viewport.classList.remove('nav');
|
|
533
|
+
const { x, y } = at(e);
|
|
534
|
+
if (d.kind === 'nav') saveViews();
|
|
535
|
+
else if (d.kind === 'place') { if (!cancel) ctx.guides.pointerUp(d.slot, x, y); }
|
|
536
|
+
else if (cancel) ctx.select.cancel(); else ctx.select.up(x, y);
|
|
537
|
+
};
|
|
538
|
+
canvas.addEventListener('pointerup', (e) => endDrag(e));
|
|
539
|
+
canvas.addEventListener('pointercancel', (e) => endDrag(e, true));
|
|
540
|
+
canvas.addEventListener('pointerleave', () => { if (!drag) { cursor.slot = -1; ctx.select.hover(-1); ctx.guides.leave(); requestOverlays(); } });
|
|
541
|
+
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
542
|
+
canvas.addEventListener('wheel', (e) => {
|
|
543
|
+
e.preventDefault();
|
|
544
|
+
layoutViews();
|
|
545
|
+
const { x, y } = at(e), slot = views.slotAt(x, y);
|
|
546
|
+
if (slot < 0) return;
|
|
547
|
+
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * 400 : e.deltaY;
|
|
548
|
+
views.dolly(slot, Math.exp(Math.max(-1, Math.min(1, dy * 0.0015))), views.targetPlanePoint(slot, x, y));
|
|
549
|
+
clearTimeout(canvas.wheelTimer); canvas.wheelTimer = setTimeout(saveViews, 300);
|
|
550
|
+
ctx.requestRender();
|
|
551
|
+
}, { passive: false });
|
|
552
|
+
|
|
553
|
+
// ------------------------------------------------------------------------------------------- refresh on changes
|
|
554
|
+
ctx.on('*', (type) => {
|
|
555
|
+
if (type === 'hover') { ctx.requestRender(); return; }
|
|
556
|
+
outliner.render();
|
|
557
|
+
props.render();
|
|
558
|
+
if (type === 'mode') { viewport.classList.toggle('placing', !!ctx.guides.mode); ctx.setHover(null); syncTop(); }
|
|
559
|
+
if (type === 'output' || type === 'graphs') updateCook();
|
|
560
|
+
ctx.requestRender();
|
|
561
|
+
});
|
|
562
|
+
ctx.selection.onChange(() => ctx.emit('selection'));
|
|
563
|
+
|
|
564
|
+
const es = api.events({
|
|
565
|
+
open() {
|
|
566
|
+
ctx.message('live — updates on every build, edit and file save');
|
|
567
|
+
// after a reconnect the server may have restarted: fetch everything again
|
|
568
|
+
if (es.opened) { refreshGraphs().then(loadGeo); ctx.layers.refresh(); ctx.layers.loadWhere(); ctx.guides.load(); }
|
|
569
|
+
es.opened = true;
|
|
570
|
+
},
|
|
571
|
+
error() { ctx.message('disconnected (server stopped?)', 'err'); },
|
|
572
|
+
async message(ev) {
|
|
573
|
+
if (ev.inputs !== undefined) { ctx.layers.refresh(); return; }
|
|
574
|
+
await refreshGraphs();
|
|
575
|
+
if (ev.graph === ctx.graph.name) loadGeo();
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
syncTop();
|
|
580
|
+
outliner.render();
|
|
581
|
+
props.render();
|
|
582
|
+
ctx.requestRender();
|
|
583
|
+
await refreshGraphs();
|
|
584
|
+
loadGeo();
|
|
585
|
+
ctx.layers.refresh();
|
|
586
|
+
ctx.layers.loadWhere();
|
|
587
|
+
ctx.guides.load();
|
|
588
|
+
// for debugging in the browser console: the context, and a frame drawn now (where requestAnimationFrame is paused)
|
|
589
|
+
ctx.draw = draw;
|
|
590
|
+
window.topotopo = ctx;
|