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,225 @@
|
|
|
1
|
+
// Reference layers (docs/design/viewer.md section 7): the person's files in inputs/, one outliner row each, shown
|
|
2
|
+
// when their eye is on (remembered in this browser), drawn in a display mode of their own (as the view by default;
|
|
3
|
+
// ghost is one of the modes). PNGs stand as unlit image planes at their pixel aspect, one metre tall, beside the
|
|
4
|
+
// scene. Files dropped on the page are uploaded to inputs/ (POST /api/inputs).
|
|
5
|
+
import { api } from './api.js';
|
|
6
|
+
import { MeshObject, parseGLB, isPicture, LAYER_MODES } from './scene.js';
|
|
7
|
+
import { fmtBytes } from './units.js';
|
|
8
|
+
import * as THREE from 'three';
|
|
9
|
+
|
|
10
|
+
export const MAX_UPLOAD = 512 * 1024 * 1024; // POST /api/inputs limit (src/viewer/server.ts)
|
|
11
|
+
const stampOf = (f) => `${f.mtime}:${f.size}`;
|
|
12
|
+
const errText = (e) => String(e?.message ?? e).replace(/^\[f\] /gm, '');
|
|
13
|
+
export const layerId = (path) => `layer:${path}`;
|
|
14
|
+
|
|
15
|
+
export function createLayers(ctx) {
|
|
16
|
+
const memo = ctx.store;
|
|
17
|
+
const L = {
|
|
18
|
+
list: [], where: '', dir: '',
|
|
19
|
+
shown: new Set(memo.get('layers.shown', [])),
|
|
20
|
+
display: new Map(Object.entries(memo.get('layers.display', {}))),
|
|
21
|
+
loaded: new Map(), // path → {stamp, mesh: MeshObject|null}
|
|
22
|
+
info: new Map(), // path → {stamp, summary?, error?, pending?}
|
|
23
|
+
errors: new Map(), // path → {stamp, msg}
|
|
24
|
+
placedX: null, // where the next picture stands
|
|
25
|
+
restored: false,
|
|
26
|
+
};
|
|
27
|
+
const remember = () => { memo.set('layers.shown', [...L.shown]); memo.set('layers.display', Object.fromEntries(L.display)); };
|
|
28
|
+
const changed = () => ctx.emit('layers');
|
|
29
|
+
|
|
30
|
+
/** The outliner / properties entry of an input file. */
|
|
31
|
+
const entry = (path) => {
|
|
32
|
+
const f = L.list.find((x) => x.path === path);
|
|
33
|
+
const id = layerId(path);
|
|
34
|
+
const prev = ctx.objects.get(id);
|
|
35
|
+
const e = prev ?? { id, kind: 'layer', path };
|
|
36
|
+
e.file = f;
|
|
37
|
+
e.label = path;
|
|
38
|
+
e.mesh = L.loaded.get(path)?.mesh ?? null;
|
|
39
|
+
e.loading = !!L.loaded.get(path) && !e.mesh;
|
|
40
|
+
e.visible = L.shown.has(path);
|
|
41
|
+
e.display = L.display.get(path) ?? 'view';
|
|
42
|
+
e.pickable = !!e.mesh && path !== 'markers.json';
|
|
43
|
+
e.surface = !!e.mesh && path !== 'markers.json' && !e.mesh.picture;
|
|
44
|
+
ctx.objects.set(id, e);
|
|
45
|
+
return e;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function drop(path) {
|
|
49
|
+
const l = L.loaded.get(path);
|
|
50
|
+
if (!l) return;
|
|
51
|
+
L.loaded.delete(path);
|
|
52
|
+
if (l.mesh) l.mesh.dispose();
|
|
53
|
+
entry(path);
|
|
54
|
+
ctx.requestRender();
|
|
55
|
+
}
|
|
56
|
+
async function load(path) {
|
|
57
|
+
const f = L.list.find((x) => x.path === path);
|
|
58
|
+
if (!f) return;
|
|
59
|
+
drop(path);
|
|
60
|
+
const slot = { stamp: stampOf(f), mesh: null };
|
|
61
|
+
L.loaded.set(path, slot);
|
|
62
|
+
L.errors.delete(path);
|
|
63
|
+
entry(path); changed();
|
|
64
|
+
const r = await api.input(path);
|
|
65
|
+
let src = null;
|
|
66
|
+
try {
|
|
67
|
+
if (!r.ok) throw new Error(r.error);
|
|
68
|
+
src = await parseGLB(r.buf);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (L.loaded.get(path) === slot) {
|
|
71
|
+
L.loaded.delete(path); L.shown.delete(path); remember();
|
|
72
|
+
L.errors.set(path, { stamp: slot.stamp, msg: errText(e) });
|
|
73
|
+
}
|
|
74
|
+
entry(path); changed();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (L.loaded.get(path) !== slot) { new MeshObject('', src).dispose(); return; } // hidden or replaced meanwhile
|
|
78
|
+
const picture = isPicture(src);
|
|
79
|
+
if (picture) placePicture(src);
|
|
80
|
+
src.name = `inputs/${path}`;
|
|
81
|
+
const mesh = new MeshObject(layerId(path), src, { picture });
|
|
82
|
+
mesh.onReady = () => ctx.requestRender();
|
|
83
|
+
slot.mesh = mesh;
|
|
84
|
+
ctx.layersRoot.add(mesh.root);
|
|
85
|
+
entry(path);
|
|
86
|
+
fetchInfo(path);
|
|
87
|
+
changed();
|
|
88
|
+
ctx.requestRender();
|
|
89
|
+
}
|
|
90
|
+
/** One metre tall at its pixel aspect, standing in a row right of the scene, its foot on the scene's floor. */
|
|
91
|
+
function placePicture(src) {
|
|
92
|
+
src.updateMatrixWorld(true);
|
|
93
|
+
const b0 = new THREE.Box3().setFromObject(src);
|
|
94
|
+
const h = Math.max(1e-6, b0.max.y - b0.min.y), s = 1 / h;
|
|
95
|
+
const scene = ctx.sceneBounds({ pictures: false });
|
|
96
|
+
const gap = 0.25;
|
|
97
|
+
if (L.placedX === null) L.placedX = scene.isEmpty() ? 0 : scene.max.x + gap;
|
|
98
|
+
src.scale.setScalar(s);
|
|
99
|
+
src.position.set(L.placedX - b0.min.x * s, (scene.isEmpty() ? 0 : scene.min.y) - b0.min.y * s, scene.isEmpty() ? 0 : (scene.min.z + scene.max.z) / 2);
|
|
100
|
+
src.updateMatrixWorld(true);
|
|
101
|
+
L.placedX += (b0.max.x - b0.min.x) * s + gap;
|
|
102
|
+
}
|
|
103
|
+
async function fetchInfo(path) {
|
|
104
|
+
const f = L.list.find((x) => x.path === path);
|
|
105
|
+
if (!f) return;
|
|
106
|
+
const stamp = stampOf(f), have = L.info.get(path);
|
|
107
|
+
if (have && have.stamp === stamp) return;
|
|
108
|
+
L.info.set(path, { stamp, pending: true });
|
|
109
|
+
const r = await api.inputInfo(path);
|
|
110
|
+
if (r.ok) L.info.set(path, { stamp, summary: r.summary });
|
|
111
|
+
else { L.info.set(path, { stamp, error: errText(r.error) }); L.errors.set(path, { stamp, msg: errText(r.error) }); }
|
|
112
|
+
changed();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
L.fetchInfo = fetchInfo;
|
|
116
|
+
/** Eye on / off: on loads the layer, off frees it. */
|
|
117
|
+
L.show = (path, on) => {
|
|
118
|
+
if (on) { L.shown.add(path); if (!L.loaded.has(path)) load(path); }
|
|
119
|
+
else { L.shown.delete(path); drop(path); }
|
|
120
|
+
remember(); entry(path); changed();
|
|
121
|
+
};
|
|
122
|
+
L.setDisplay = (path, mode) => {
|
|
123
|
+
if (!LAYER_MODES.some(([m]) => m === mode)) return;
|
|
124
|
+
if (mode === 'view') L.display.delete(path); else L.display.set(path, mode);
|
|
125
|
+
remember(); entry(path); changed(); ctx.requestRender();
|
|
126
|
+
};
|
|
127
|
+
L.refresh = ctx.coalesce(async () => {
|
|
128
|
+
const list = await api.inputs();
|
|
129
|
+
if (!list) return;
|
|
130
|
+
L.list = list;
|
|
131
|
+
const byPath = new Map(list.map((f) => [f.path, f]));
|
|
132
|
+
if (!L.restored) { L.restored = true; for (const p of [...L.shown]) if (!byPath.has(p)) L.shown.delete(p); }
|
|
133
|
+
// a replaced file reloads; a removed one goes away
|
|
134
|
+
for (const p of [...L.shown]) {
|
|
135
|
+
const f = byPath.get(p);
|
|
136
|
+
if (!f) { L.shown.delete(p); drop(p); continue; }
|
|
137
|
+
const l = L.loaded.get(p);
|
|
138
|
+
if (!l || l.stamp !== stampOf(f)) load(p);
|
|
139
|
+
}
|
|
140
|
+
for (const [p] of L.loaded) if (!byPath.has(p)) drop(p);
|
|
141
|
+
for (const m of [L.info, L.errors]) for (const [p, v] of m) if (!byPath.has(p) || v.stamp !== stampOf(byPath.get(p))) m.delete(p);
|
|
142
|
+
for (const id of [...ctx.objects.keys()]) if (id.startsWith('layer:') && !byPath.has(id.slice(6))) ctx.objects.delete(id);
|
|
143
|
+
for (const f of list) entry(f.path);
|
|
144
|
+
remember(); changed();
|
|
145
|
+
});
|
|
146
|
+
L.loadWhere = async () => {
|
|
147
|
+
const j = await api.inputsDir();
|
|
148
|
+
if (!j?.where) return;
|
|
149
|
+
L.where = String(j.where); L.dir = String(j.dir ?? '');
|
|
150
|
+
const dw = document.getElementById('drop-where');
|
|
151
|
+
if (dw) dw.textContent = `inputs/ ${L.where}`;
|
|
152
|
+
changed();
|
|
153
|
+
};
|
|
154
|
+
/** Bounds of the shown layers (pictures too unless `pictures` is false). */
|
|
155
|
+
L.bounds = ({ pictures = true } = {}) => {
|
|
156
|
+
const b = new THREE.Box3();
|
|
157
|
+
for (const [, l] of L.loaded) if (l.mesh && l.mesh.root.visible && (pictures || !l.mesh.picture)) b.union(l.mesh.bounds());
|
|
158
|
+
return b;
|
|
159
|
+
};
|
|
160
|
+
L.fileText = (f) => `${f.kind}${f.ext ? ` (.${f.ext})` : ''} · ${fmtBytes(f.size)} · ${new Date(f.mtime).toLocaleString()}`;
|
|
161
|
+
|
|
162
|
+
// ------------------------------------------------------------------ drop zone: files go to inputs/
|
|
163
|
+
async function uploadOne(f, tag) {
|
|
164
|
+
if (f.size > MAX_UPLOAD) return { error: `${f.name}: ${fmtBytes(f.size)} is over the ${MAX_UPLOAD / 1024 ** 2} MB limit` };
|
|
165
|
+
const put = async (overwrite) => {
|
|
166
|
+
const t0 = performance.now();
|
|
167
|
+
const tick = () => ctx.message(`uploading ${f.name}${tag} · ${fmtBytes(f.size)} · ${((performance.now() - t0) / 1000).toFixed(0)} s`, 'busy');
|
|
168
|
+
tick();
|
|
169
|
+
const timer = setInterval(tick, 500);
|
|
170
|
+
try { return await api.upload(f, overwrite); } finally { clearInterval(timer); }
|
|
171
|
+
};
|
|
172
|
+
let r = await put(false);
|
|
173
|
+
if (r.status === 409) {
|
|
174
|
+
ctx.message(`${f.name}${tag}: inputs/ already has this file`);
|
|
175
|
+
if (!await ctx.ask(`inputs/${r.j.path ?? f.name} exists — replace it?`, 'Replace', 'Keep')) return { kept: r.j.path ?? f.name };
|
|
176
|
+
r = await put(true);
|
|
177
|
+
}
|
|
178
|
+
if (r.status !== 200 || !r.j.ok) return { error: `${f.name}: ${r.j.error ?? `upload failed (${r.status})`}` };
|
|
179
|
+
return { path: r.j.path };
|
|
180
|
+
}
|
|
181
|
+
let uploads = Promise.resolve();
|
|
182
|
+
L.upload = (files, folders) => {
|
|
183
|
+
uploads = uploads.then(async () => {
|
|
184
|
+
const added = [], kept = [], errors = [];
|
|
185
|
+
if (folders.length) errors.push(`folders are not supported, drop the files inside instead (skipped ${folders.map((f) => f + '/').join(', ')})`);
|
|
186
|
+
for (const [i, f] of files.entries()) {
|
|
187
|
+
const r = await uploadOne(f, files.length > 1 ? ` (${i + 1}/${files.length})` : '');
|
|
188
|
+
if (r.path) {
|
|
189
|
+
added.push(r.path);
|
|
190
|
+
// show it at once, and select it
|
|
191
|
+
L.shown.add(r.path);
|
|
192
|
+
for (const m of [L.info, L.errors]) m.delete(r.path);
|
|
193
|
+
remember();
|
|
194
|
+
await L.refresh();
|
|
195
|
+
ctx.selection.apply([layerId(r.path)]);
|
|
196
|
+
} else if (r.kept) kept.push(r.kept);
|
|
197
|
+
else if (r.error) errors.push(r.error);
|
|
198
|
+
}
|
|
199
|
+
const msg = [added.length ? `added ${added.map((p) => 'inputs/' + p).join(', ')}${L.where ? ' ' + L.where : ''}` : '', kept.length ? `kept the existing ${kept.map((p) => 'inputs/' + p).join(', ')}` : '', ...errors].filter(Boolean).join(' · ');
|
|
200
|
+
if (msg) ctx.message(msg, errors.length ? 'err' : added.length ? 'ok' : '');
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
let depth = 0;
|
|
204
|
+
const fileDrag = (e) => [...(e.dataTransfer?.types ?? [])].includes('Files');
|
|
205
|
+
addEventListener('dragenter', (e) => { if (!fileDrag(e)) return; e.preventDefault(); if (++depth === 1) document.body.classList.add('dropping'); });
|
|
206
|
+
addEventListener('dragover', (e) => { if (!fileDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
|
|
207
|
+
addEventListener('dragleave', (e) => { if (!fileDrag(e)) return; if (--depth <= 0) { depth = 0; document.body.classList.remove('dropping'); } });
|
|
208
|
+
addEventListener('drop', (e) => {
|
|
209
|
+
if (!fileDrag(e)) return;
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
depth = 0; document.body.classList.remove('dropping');
|
|
212
|
+
// read the items now: the browser empties the DataTransfer after this event
|
|
213
|
+
const files = [], folders = [];
|
|
214
|
+
for (const it of Array.from(e.dataTransfer.items ?? [])) {
|
|
215
|
+
if (it.kind !== 'file') continue;
|
|
216
|
+
const en = it.webkitGetAsEntry?.();
|
|
217
|
+
if (en?.isDirectory) { folders.push(en.name); continue; }
|
|
218
|
+
const f = it.getAsFile();
|
|
219
|
+
if (f) files.push(f);
|
|
220
|
+
}
|
|
221
|
+
if (!e.dataTransfer.items) files.push(...e.dataTransfer.files);
|
|
222
|
+
L.upload(files, folders);
|
|
223
|
+
});
|
|
224
|
+
return L;
|
|
225
|
+
}
|