topotopo 0.1.0 → 0.3.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 +61 -12
- package/dist/cli.js +161 -23
- package/dist/graph/engine.js +18 -1
- package/dist/graph/params.d.ts +7 -1
- package/dist/graph/params.js +21 -8
- package/dist/graph/types.d.ts +28 -2
- package/dist/host/artifacts.d.ts +21 -0
- package/dist/host/artifacts.js +58 -0
- package/dist/host/export-targets.d.ts +33 -0
- package/dist/host/export-targets.js +84 -0
- 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/targets.d.ts +31 -0
- package/dist/io/targets.js +75 -0
- 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 +126 -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.d.ts +1 -1
- package/dist/mcp/tools.js +118 -51
- package/dist/nodes/code.js +34 -6
- 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 +250 -0
- package/dist/viewer/app/export-popover.js +68 -0
- package/dist/viewer/app/grid.js +217 -0
- package/dist/viewer/app/guides.js +402 -0
- package/dist/viewer/app/image-view.js +114 -0
- package/dist/viewer/app/keymap.js +199 -0
- package/dist/viewer/app/layers.js +244 -0
- package/dist/viewer/app/main.js +646 -0
- package/dist/viewer/app/markers.js +206 -0
- package/dist/viewer/app/mesh.js +172 -0
- package/dist/viewer/app/outliner.js +225 -0
- package/dist/viewer/app/outputs.css +73 -0
- package/dist/viewer/app/outputs.js +240 -0
- package/dist/viewer/app/props.js +432 -0
- package/dist/viewer/app/scene.js +529 -0
- package/dist/viewer/app/select.js +153 -0
- package/dist/viewer/app/selection.js +79 -0
- package/dist/viewer/app/status.js +20 -0
- package/dist/viewer/app/style.css +173 -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 +67 -184
- package/dist/viewer/outputs.d.ts +54 -0
- package/dist/viewer/outputs.js +436 -0
- package/dist/viewer/server.d.ts +47 -1
- package/dist/viewer/server.js +480 -17
- package/dist/workspace.d.ts +60 -4
- package/dist/workspace.js +131 -5
- package/package.json +9 -3
- package/shelf/brick_wall.json +1 -1
- 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,114 @@
|
|
|
1
|
+
// A 2D image inspector, independent of the 3D camera. View state is remembered per output.
|
|
2
|
+
export function channelPixels(source, mode) {
|
|
3
|
+
const pixels = new Uint8ClampedArray(source);
|
|
4
|
+
const c = ['R', 'G', 'B', 'A'].indexOf(mode);
|
|
5
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
6
|
+
if (c >= 0) pixels[i] = pixels[i + 1] = pixels[i + 2] = source[i + c];
|
|
7
|
+
if (mode !== 'RGBA') pixels[i + 3] = 255;
|
|
8
|
+
}
|
|
9
|
+
return pixels;
|
|
10
|
+
}
|
|
11
|
+
export function zoomImage(state, x, y, factor) {
|
|
12
|
+
const scale = Math.max(0.01, Math.min(64, state.scale * factor)), ratio = scale / state.scale;
|
|
13
|
+
return { scale, x: x - (x - state.x) * ratio, y: y - (y - state.y) * ratio };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createImageView(host, message) {
|
|
17
|
+
const panel = document.createElement('section'); panel.id = 'image-view'; panel.hidden = true;
|
|
18
|
+
panel.innerHTML = `<div class="image-tools"><button data-act="fit">Fit</button><button data-act="actual">100%</button>
|
|
19
|
+
<select aria-label="Image channel">${['RGB', 'RGBA', 'R', 'G', 'B', 'A'].map((m) => `<option>${m}</option>`).join('')}</select>
|
|
20
|
+
<select aria-label="Image layer"></select><label><input type="checkbox" data-act="checker" checked> Checkerboard</label>
|
|
21
|
+
<label><input type="checkbox" data-act="uv"> UV</label><span class="image-dim"></span></div>
|
|
22
|
+
<div class="image-stage checker"><canvas aria-label="Image preview"></canvas><div class="image-error" hidden></div></div>`;
|
|
23
|
+
host.append(panel);
|
|
24
|
+
const stage = panel.querySelector('.image-stage'), canvas = panel.querySelector('canvas'), context = canvas.getContext('2d');
|
|
25
|
+
const mode = panel.querySelector('[aria-label="Image channel"]'), layers = panel.querySelector('[aria-label="Image layer"]');
|
|
26
|
+
const uvToggle = panel.querySelector('[data-act="uv"]'), dim = panel.querySelector('.image-dim'), error = panel.querySelector('.image-error');
|
|
27
|
+
const memory = new Map();
|
|
28
|
+
let item = null, bitmap = null, data = null, raster = document.createElement('canvas'), segments = [], sequence = 0;
|
|
29
|
+
let state = { scale: 1, x: 0, y: 0 }, drag = null, fitNew = true;
|
|
30
|
+
function remember() { if (item) memory.set(item.id, { ...state, mode: mode.value, layer: layers.value }); }
|
|
31
|
+
function draw() {
|
|
32
|
+
if (panel.hidden) return;
|
|
33
|
+
const dpr = Math.min(2, devicePixelRatio || 1), w = stage.clientWidth, h = stage.clientHeight;
|
|
34
|
+
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
|
|
35
|
+
context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, w, h);
|
|
36
|
+
if (!bitmap) return;
|
|
37
|
+
context.save(); context.translate(state.x, state.y); context.scale(state.scale, state.scale);
|
|
38
|
+
context.imageSmoothingEnabled = state.scale < 1;
|
|
39
|
+
context.drawImage(raster, 0, 0);
|
|
40
|
+
if (uvToggle.checked && segments.length) {
|
|
41
|
+
context.beginPath();
|
|
42
|
+
for (const [u, v, s, t] of segments) { context.moveTo(u * raster.width, (1 - v) * raster.height); context.lineTo(s * raster.width, (1 - t) * raster.height); }
|
|
43
|
+
context.strokeStyle = '#f0a030'; context.lineWidth = 1 / state.scale; context.stroke();
|
|
44
|
+
}
|
|
45
|
+
context.restore(); dim.textContent = `${raster.width} × ${raster.height} · ${Math.round(state.scale * 100)}%`;
|
|
46
|
+
}
|
|
47
|
+
function fit() {
|
|
48
|
+
if (!bitmap) return;
|
|
49
|
+
state.scale = Math.min((stage.clientWidth - 32) / raster.width, (stage.clientHeight - 32) / raster.height);
|
|
50
|
+
state.scale = Math.max(0.01, state.scale);
|
|
51
|
+
state.x = (stage.clientWidth - raster.width * state.scale) / 2; state.y = (stage.clientHeight - raster.height * state.scale) / 2;
|
|
52
|
+
remember(); draw();
|
|
53
|
+
}
|
|
54
|
+
function applyChannels() {
|
|
55
|
+
if (!data) return;
|
|
56
|
+
raster.getContext('2d').putImageData(new ImageData(channelPixels(data.data, item.generated ? 'RGBA' : mode.value), data.width, data.height), 0, 0);
|
|
57
|
+
draw();
|
|
58
|
+
}
|
|
59
|
+
async function load() {
|
|
60
|
+
if (!item) return;
|
|
61
|
+
const seq = ++sequence, current = item;
|
|
62
|
+
error.hidden = true;
|
|
63
|
+
const q = new URLSearchParams({ id: current.id, layer: layers.value, mode: mode.value, rev: current.revision });
|
|
64
|
+
const url = current.sourceURL ?? (current.generated ? '/api/output/image?' + q : '/api/output/file?' + q);
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(url);
|
|
67
|
+
if (!response.ok) throw new Error((await response.json()).error);
|
|
68
|
+
const next = await createImageBitmap(await response.blob(), { premultiplyAlpha: 'none' });
|
|
69
|
+
if (seq !== sequence) { next.close(); return; }
|
|
70
|
+
bitmap?.close(); bitmap = next;
|
|
71
|
+
raster.width = next.width; raster.height = next.height;
|
|
72
|
+
const c = raster.getContext('2d', { willReadFrequently: true }); c.drawImage(next, 0, 0);
|
|
73
|
+
data = c.getImageData(0, 0, next.width, next.height); applyChannels();
|
|
74
|
+
if (fitNew) { fitNew = false; fit(); }
|
|
75
|
+
if (uvToggle.checked) await loadUV();
|
|
76
|
+
} catch (e) { if (seq === sequence) { error.textContent = e.message; error.hidden = false; message(e.message, 'err'); } }
|
|
77
|
+
}
|
|
78
|
+
async function loadUV() {
|
|
79
|
+
if (!item?.generated || !uvToggle.checked) { segments = []; draw(); return; }
|
|
80
|
+
const id = item.id;
|
|
81
|
+
try {
|
|
82
|
+
const r = await fetch('/api/output/uv?' + new URLSearchParams({ id })), j = await r.json();
|
|
83
|
+
if (item?.id !== id) return;
|
|
84
|
+
if (!r.ok) throw new Error(j.error);
|
|
85
|
+
segments = j.segments; draw();
|
|
86
|
+
} catch (e) { message(e.message, 'err'); }
|
|
87
|
+
}
|
|
88
|
+
panel.querySelector('[data-act="fit"]').onclick = fit;
|
|
89
|
+
panel.querySelector('[data-act="actual"]').onclick = () => { state = zoomImage(state, stage.clientWidth / 2, stage.clientHeight / 2, 1 / state.scale); remember(); draw(); };
|
|
90
|
+
panel.querySelector('[data-act="checker"]').onchange = (e) => stage.classList.toggle('checker', e.target.checked);
|
|
91
|
+
mode.onchange = () => { remember(); if (item?.generated) load(); else applyChannels(); };
|
|
92
|
+
layers.onchange = () => { remember(); load(); };
|
|
93
|
+
uvToggle.onchange = loadUV;
|
|
94
|
+
canvas.onpointerdown = (e) => { if (![0, 1, 2].includes(e.button)) return; e.preventDefault(); canvas.setPointerCapture(e.pointerId); drag = { id: e.pointerId, x: e.clientX, y: e.clientY }; };
|
|
95
|
+
canvas.onpointermove = (e) => { if (!drag || e.pointerId !== drag.id) return; state.x += e.clientX - drag.x; state.y += e.clientY - drag.y; drag.x = e.clientX; drag.y = e.clientY; draw(); };
|
|
96
|
+
const end = () => { drag = null; remember(); };
|
|
97
|
+
canvas.onpointerup = end; canvas.onpointercancel = end; canvas.onlostpointercapture = end; addEventListener('blur', end);
|
|
98
|
+
canvas.oncontextmenu = (e) => e.preventDefault();
|
|
99
|
+
canvas.addEventListener('wheel', (e) => { e.preventDefault(); const r = canvas.getBoundingClientRect(); const delta = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1); state = zoomImage(state, e.clientX - r.left, e.clientY - r.top, Math.exp(Math.max(-1, Math.min(1, -delta * 0.0015)))); remember(); draw(); }, { passive: false });
|
|
100
|
+
new ResizeObserver(draw).observe(stage);
|
|
101
|
+
return {
|
|
102
|
+
show(next) {
|
|
103
|
+
const same = item?.id === next.id; if (!same) remember(); item = next; panel.hidden = false; host.classList.add('image-mode');
|
|
104
|
+
if (!same) { bitmap?.close(); bitmap = null; data = null; segments = []; uvToggle.checked = false; dim.textContent = 'Loading…'; }
|
|
105
|
+
const saved = memory.get(next.id); if (!same) { state = saved ? { scale: saved.scale, x: saved.x, y: saved.y } : { scale: 1, x: 0, y: 0 }; fitNew = !saved; }
|
|
106
|
+
layers.replaceChildren(...(next.layers ?? ['Image']).map((l) => new Option(l, l)));
|
|
107
|
+
layers.value = next.layers?.includes(saved?.layer) ? saved.layer : next.layer ?? 'Image'; layers.hidden = !next.layers?.length;
|
|
108
|
+
mode.value = saved?.mode ?? (next.generated ? 'RGB' : 'RGBA'); uvToggle.disabled = !next.generated;
|
|
109
|
+
load();
|
|
110
|
+
},
|
|
111
|
+
hide() { remember(); sequence++; item = null; bitmap?.close(); bitmap = null; data = null; panel.hidden = true; host.classList.remove('image-mode'); },
|
|
112
|
+
get visible() { return !panel.hidden; },
|
|
113
|
+
};
|
|
114
|
+
}
|