topotopo 0.2.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 +19 -2
- package/dist/cli.js +67 -4
- package/dist/graph/engine.js +14 -0
- package/dist/graph/types.d.ts +17 -0
- 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/io/targets.d.ts +31 -0
- package/dist/io/targets.js +75 -0
- package/dist/mcp/docs.d.ts +1 -1
- package/dist/mcp/docs.js +15 -2
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +87 -34
- package/dist/nodes/code.js +32 -6
- package/dist/viewer/app/camera.js +26 -6
- package/dist/viewer/app/export-popover.js +68 -0
- package/dist/viewer/app/image-view.js +114 -0
- package/dist/viewer/app/keymap.js +15 -3
- package/dist/viewer/app/layers.js +23 -4
- package/dist/viewer/app/main.js +70 -14
- package/dist/viewer/app/outliner.js +56 -15
- package/dist/viewer/app/outputs.css +73 -0
- package/dist/viewer/app/outputs.js +240 -0
- package/dist/viewer/app/select.js +13 -2
- package/dist/viewer/app/status.js +20 -0
- package/dist/viewer/app/style.css +1 -0
- package/dist/viewer/index.html +19 -5
- package/dist/viewer/outputs.d.ts +54 -0
- package/dist/viewer/outputs.js +436 -0
- package/dist/viewer/server.d.ts +5 -0
- package/dist/viewer/server.js +48 -0
- package/dist/workspace.d.ts +8 -2
- package/dist/workspace.js +37 -3
- package/package.json +4 -3
|
@@ -11,6 +11,8 @@ export const MAX_UPLOAD = 512 * 1024 * 1024; // POST /api/inputs limit (src/view
|
|
|
11
11
|
const stampOf = (f) => `${f.mtime}:${f.size}`;
|
|
12
12
|
const errText = (e) => String(e?.message ?? e).replace(/^\[f\] /gm, '');
|
|
13
13
|
export const layerId = (path) => `layer:${path}`;
|
|
14
|
+
export const isInputImage = (path) => /\.(png|jpe?g|webp)$/i.test(path);
|
|
15
|
+
export const inputImageURL = (f) => '/api/input_image?' + new URLSearchParams({ path: f.path, rev: stampOf(f) });
|
|
14
16
|
|
|
15
17
|
export function createLayers(ctx) {
|
|
16
18
|
const memo = ctx.store;
|
|
@@ -61,11 +63,19 @@ export function createLayers(ctx) {
|
|
|
61
63
|
L.loaded.set(path, slot);
|
|
62
64
|
L.errors.delete(path);
|
|
63
65
|
entry(path); changed();
|
|
64
|
-
const r = await api.input(path);
|
|
65
66
|
let src = null;
|
|
66
67
|
try {
|
|
67
|
-
if (
|
|
68
|
-
|
|
68
|
+
if (isInputImage(path)) {
|
|
69
|
+
const img = new Image(); img.src = inputImageURL(f); await img.decode();
|
|
70
|
+
const texture = new THREE.Texture(img); texture.colorSpace = THREE.SRGBColorSpace; texture.needsUpdate = true;
|
|
71
|
+
const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide });
|
|
72
|
+
material.name = 'image:' + path;
|
|
73
|
+
src = new THREE.Mesh(new THREE.PlaneGeometry(img.naturalWidth / img.naturalHeight, 1), material);
|
|
74
|
+
} else {
|
|
75
|
+
const r = await api.input(path);
|
|
76
|
+
if (!r.ok) throw new Error(r.error);
|
|
77
|
+
src = await parseGLB(r.buf);
|
|
78
|
+
}
|
|
69
79
|
} catch (e) {
|
|
70
80
|
if (L.loaded.get(path) === slot) {
|
|
71
81
|
L.loaded.delete(path); L.shown.delete(path); remember();
|
|
@@ -106,9 +116,17 @@ export function createLayers(ctx) {
|
|
|
106
116
|
const stamp = stampOf(f), have = L.info.get(path);
|
|
107
117
|
if (have && have.stamp === stamp) return;
|
|
108
118
|
L.info.set(path, { stamp, pending: true });
|
|
109
|
-
|
|
119
|
+
let r;
|
|
120
|
+
if (isInputImage(path)) {
|
|
121
|
+
try {
|
|
122
|
+
const img = new Image(); img.src = inputImageURL(f); await img.decode();
|
|
123
|
+
r = { ok: true, summary: `${img.naturalWidth} × ${img.naturalHeight} · ${L.fileText(f)}` };
|
|
124
|
+
} catch { r = { ok: false, error: 'Cannot decode this image. Check that the file is a valid PNG, JPEG or WebP.' }; }
|
|
125
|
+
} else r = await api.inputInfo(path);
|
|
126
|
+
if (L.info.get(path)?.stamp !== stamp) return;
|
|
110
127
|
if (r.ok) L.info.set(path, { stamp, summary: r.summary });
|
|
111
128
|
else { L.info.set(path, { stamp, error: errText(r.error) }); L.errors.set(path, { stamp, msg: errText(r.error) }); }
|
|
129
|
+
if (r.ok) L.errors.delete(path);
|
|
112
130
|
changed();
|
|
113
131
|
}
|
|
114
132
|
|
|
@@ -187,6 +205,7 @@ export function createLayers(ctx) {
|
|
|
187
205
|
const r = await uploadOne(f, files.length > 1 ? ` (${i + 1}/${files.length})` : '');
|
|
188
206
|
if (r.path) {
|
|
189
207
|
added.push(r.path);
|
|
208
|
+
if (/\.(png|jpe?g|webp)$/i.test(r.path)) { await L.refresh(); await ctx.outputs?.openInput(r.path); continue; }
|
|
190
209
|
// show it at once, and select it
|
|
191
210
|
L.shown.add(r.path);
|
|
192
211
|
for (const m of [L.info, L.errors]) m.delete(r.path);
|
package/dist/viewer/app/main.js
CHANGED
|
@@ -11,11 +11,13 @@ import { createGuides, PLACE_KINDS } from './guides.js';
|
|
|
11
11
|
import { createSelect } from './select.js';
|
|
12
12
|
import { createOutliner } from './outliner.js';
|
|
13
13
|
import { createProps } from './props.js';
|
|
14
|
+
import { createOutputPanel } from './outputs.js';
|
|
14
15
|
import { createSelection, parseId } from './selection.js';
|
|
15
16
|
import { createUndo } from './undo.js';
|
|
16
17
|
import { EDITOR_NAMES } from './markers.js';
|
|
17
18
|
import * as KM from './keymap.js';
|
|
18
19
|
import { fmtStep, fmtSize, fmtNum } from './units.js';
|
|
20
|
+
import { connectedLine, graphsLine } from './status.js';
|
|
19
21
|
|
|
20
22
|
const $ = (id) => document.getElementById(id);
|
|
21
23
|
const BG = new THREE.Color('#2b2d31');
|
|
@@ -84,8 +86,8 @@ const grid = createGrid(scene);
|
|
|
84
86
|
ctx.sceneBounds = ({ pictures = true, guides = true } = {}) => {
|
|
85
87
|
const b = new THREE.Box3();
|
|
86
88
|
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
|
+
if (!ctx.isolated) b.union(ctx.layers.bounds({ pictures }));
|
|
90
|
+
if (guides && !ctx.isolated) for (const m of ctx.guides.list) if (!ctx.guides.hidden.has(m.id)) b.union(ctx.guides.bounds(m));
|
|
89
91
|
return b;
|
|
90
92
|
};
|
|
91
93
|
let sphere = { center: new THREE.Vector3(), radius: 10 }, sphereAt = -1;
|
|
@@ -134,7 +136,7 @@ ctx.guides = createGuides(ctx);
|
|
|
134
136
|
ctx.select = createSelect(ctx);
|
|
135
137
|
ctx.surfacePoint = ctx.select.surfacePoint;
|
|
136
138
|
ctx.planePoint = ctx.select.planePoint;
|
|
137
|
-
const outliner = createOutliner(ctx, $('tree'));
|
|
139
|
+
const outliner = createOutliner(ctx, { scene: $('tree'), inputs: $('inputs-list') });
|
|
138
140
|
const props = createProps(ctx);
|
|
139
141
|
|
|
140
142
|
// ------------------------------------------------------------------------------------------- graphs and the output
|
|
@@ -144,6 +146,7 @@ ctx.graph = {
|
|
|
144
146
|
/** the node whose result is shown: the chosen one, else the graph's output */
|
|
145
147
|
shownNode() { const g = this.current(); return this.node ?? g?.output ?? null; },
|
|
146
148
|
show(node) {
|
|
149
|
+
if (ctx.outputs?.active) ctx.outputs.scene();
|
|
147
150
|
const g = this.current();
|
|
148
151
|
this.node = !g || node === g.output ? null : node;
|
|
149
152
|
ctx.selection.apply(['out']);
|
|
@@ -165,10 +168,12 @@ const refreshGraphs = ctx.coalesce(async () => {
|
|
|
165
168
|
const g = G.current();
|
|
166
169
|
if (G.node && !g?.nodes.some((n) => n.name === G.node)) G.node = null;
|
|
167
170
|
props.controls.render(g, issued);
|
|
168
|
-
|
|
171
|
+
const line = graphsLine(msgEl.textContent, list.length);
|
|
172
|
+
if (line) ctx.message(line);
|
|
169
173
|
ctx.emit('graphs');
|
|
170
174
|
});
|
|
171
175
|
$('graph').onchange = (e) => {
|
|
176
|
+
if (ctx.outputs?.active) ctx.outputs.scene();
|
|
172
177
|
ctx.graph.name = e.target.value; ctx.graph.node = null;
|
|
173
178
|
local.set('graph', ctx.graph.name);
|
|
174
179
|
props.controls.render(ctx.graph.current());
|
|
@@ -178,13 +183,14 @@ $('graph').onchange = (e) => {
|
|
|
178
183
|
|
|
179
184
|
let firstFrame = !views.placed;
|
|
180
185
|
const loadGeo = ctx.coalesce(async () => {
|
|
186
|
+
if (ctx.outputs?.active) { await ctx.outputs.refresh(); return; }
|
|
181
187
|
const G = ctx.graph, o = ctx.output;
|
|
182
188
|
if (!G.name) return;
|
|
183
189
|
const graph = G.name, node = G.node ?? '';
|
|
184
190
|
o.loading = true; ctx.emit('output');
|
|
185
191
|
const r = await api.geo(graph, node);
|
|
186
192
|
o.loading = false;
|
|
187
|
-
if (graph !== G.name) return;
|
|
193
|
+
if (graph !== G.name || node !== (G.node ?? '') || ctx.outputs?.active) return;
|
|
188
194
|
if (!r.ok) {
|
|
189
195
|
// the last good geometry stays
|
|
190
196
|
o.error = r.error;
|
|
@@ -193,6 +199,7 @@ const loadGeo = ctx.coalesce(async () => {
|
|
|
193
199
|
}
|
|
194
200
|
let src;
|
|
195
201
|
try { src = await parseGLB(r.buf); } catch (e) { o.error = String(e?.message ?? e); ctx.emit('output'); updateCook(); return; }
|
|
202
|
+
if (graph !== G.name || node !== (G.node ?? '') || ctx.outputs?.active) { new MeshObject('', src).dispose(); return; }
|
|
196
203
|
const prev = o.mesh;
|
|
197
204
|
o.mesh = new MeshObject('out', src);
|
|
198
205
|
o.mesh.onReady = () => { ctx.requestRender(); ctx.emit('output'); };
|
|
@@ -215,6 +222,9 @@ const loadGeo = ctx.coalesce(async () => {
|
|
|
215
222
|
|
|
216
223
|
function updateCook() {
|
|
217
224
|
const el = $('cook'), o = ctx.output, g = ctx.graph.current();
|
|
225
|
+
if (ctx.outputs?.active?.kind === 'image') {
|
|
226
|
+
const image = ctx.outputs.active; el.textContent = image.width ? `${image.width} × ${image.height} · ${image.layers?.length ?? 1} layer(s)` : image.label; return;
|
|
227
|
+
}
|
|
218
228
|
if (!g) { el.textContent = ''; return; }
|
|
219
229
|
const m = /geometry: ([\d,]+) points, ([\d,]+) prims/.exec(o.summary ?? '');
|
|
220
230
|
const parts = [`${g.name}/${ctx.graph.shownNode() ?? ''}`];
|
|
@@ -284,7 +294,8 @@ function drawViews() {
|
|
|
284
294
|
renderer.setScissor(r.x, glY, r.w, r.h);
|
|
285
295
|
renderer.clear();
|
|
286
296
|
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)); }
|
|
297
|
+
for (const e of entries) { e.mesh.root.visible = e.id === 'out' && ctx.isolated ? !ctx.previewEmpty : !!e.visible; e.mesh.setMode(modeOf(e.display ?? 'view', s.shading)); }
|
|
298
|
+
ctx.layersRoot.visible = ctx.guidesRoot.visible = !ctx.isolated;
|
|
288
299
|
steps[i] = grid.prepare(cam, views.worldPerPixel(i));
|
|
289
300
|
lights.follow(camera, cam.target);
|
|
290
301
|
renderer.render(scene, camera);
|
|
@@ -395,12 +406,12 @@ for (const g of document.querySelectorAll('.grip')) {
|
|
|
395
406
|
|
|
396
407
|
// ------------------------------------------------------------------------------------------- actions
|
|
397
408
|
/** Keys go to a field being typed in (text and number fields, menus), not to the keymap. */
|
|
398
|
-
const
|
|
399
|
-
const typing = (t) => (t instanceof HTMLInputElement && TEXT_INPUTS.has(t.type)) || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement || !!t?.isContentEditable;
|
|
409
|
+
const typing = (t) => t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement || !!t?.isContentEditable || !!t?.closest?.('button, summary, [role="button"], [role="menu"]');
|
|
400
410
|
const selectedOf = (kind) => ctx.selection.items.filter((id) => ctx.objects.get(parseId(id).obj)?.kind === kind && parseId(id).piece === null);
|
|
401
411
|
const hiddenByH = { layers: new Set() };
|
|
402
412
|
const DISPLAY_CYCLE = ['shaded', 'wire', 'shadedWire'];
|
|
403
413
|
function run(action) {
|
|
414
|
+
if (document.querySelector('dialog[open]') || document.getElementById('image-view')?.hidden === false) return;
|
|
404
415
|
const slot = views.slots[views.active];
|
|
405
416
|
switch (action) {
|
|
406
417
|
case 'frameSelection': return frameSelection();
|
|
@@ -456,6 +467,7 @@ const undoKeys = () => KM.overlayRows(preset).find((r) => r.label.startsWith('un
|
|
|
456
467
|
// keys; Space is a tap (Maya) or a held modifier (Houdini)
|
|
457
468
|
const space = { down: false, tap: false, t: 0 };
|
|
458
469
|
addEventListener('keydown', (e) => {
|
|
470
|
+
if (document.querySelector('dialog[open]')) return;
|
|
459
471
|
if (typing(e.target)) return;
|
|
460
472
|
if (e.code === 'Space' || e.key === ' ') {
|
|
461
473
|
e.preventDefault();
|
|
@@ -470,6 +482,7 @@ addEventListener('keydown', (e) => {
|
|
|
470
482
|
run(action);
|
|
471
483
|
});
|
|
472
484
|
addEventListener('keyup', (e) => {
|
|
485
|
+
if (document.querySelector('dialog[open]')) { space.down = false; return; }
|
|
473
486
|
if (e.code === 'Space' || e.key === ' ') {
|
|
474
487
|
const tap = space.down && space.tap && performance.now() - space.t < 400;
|
|
475
488
|
space.down = false;
|
|
@@ -496,7 +509,9 @@ canvas.addEventListener('pointerdown', (e) => {
|
|
|
496
509
|
e.preventDefault();
|
|
497
510
|
if (nav) {
|
|
498
511
|
const sel = ctx.select.selectionBounds();
|
|
499
|
-
|
|
512
|
+
const topo = preset === 'topotopo', hit = topo ? ctx.select.navigationPoint(slot, x, y) : null;
|
|
513
|
+
const pivot = topo ? views.anchor(slot, hit) : sel.isEmpty() ? null : sel.getCenter(new THREE.Vector3());
|
|
514
|
+
drag = { kind: 'nav', nav: topo && nav === 'tumble' && views.cam(slot).ortho ? 'track' : nav, slot, x, y, id: e.pointerId, pivot, topo };
|
|
500
515
|
viewport.classList.add('nav');
|
|
501
516
|
return;
|
|
502
517
|
}
|
|
@@ -511,7 +526,7 @@ canvas.addEventListener('pointermove', (e) => {
|
|
|
511
526
|
const dx = x - drag.x, dy = y - drag.y;
|
|
512
527
|
drag.x = x; drag.y = y;
|
|
513
528
|
if (drag.nav === 'tumble') views.tumble(drag.slot, dx, dy, drag.pivot);
|
|
514
|
-
else if (drag.nav === 'track') views.track(drag.slot, dx, dy);
|
|
529
|
+
else if (drag.nav === 'track') views.track(drag.slot, dx, dy, drag.topo ? views.cam(drag.slot).pivot : undefined);
|
|
515
530
|
else views.dolly(drag.slot, Math.exp(-(dx - dy) * 0.006));
|
|
516
531
|
ctx.requestRender();
|
|
517
532
|
} else if (drag.kind === 'place') ctx.guides.pointerMove(drag.slot, x, y);
|
|
@@ -537,6 +552,8 @@ const endDrag = (e, cancel = false) => {
|
|
|
537
552
|
};
|
|
538
553
|
canvas.addEventListener('pointerup', (e) => endDrag(e));
|
|
539
554
|
canvas.addEventListener('pointercancel', (e) => endDrag(e, true));
|
|
555
|
+
canvas.addEventListener('lostpointercapture', (e) => endDrag(e, true));
|
|
556
|
+
addEventListener('blur', () => { if (drag) endDrag({ pointerId: drag.id, clientX: 0, clientY: 0 }, true); });
|
|
540
557
|
canvas.addEventListener('pointerleave', () => { if (!drag) { cursor.slot = -1; ctx.select.hover(-1); ctx.guides.leave(); requestOverlays(); } });
|
|
541
558
|
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
542
559
|
canvas.addEventListener('wheel', (e) => {
|
|
@@ -545,7 +562,11 @@ canvas.addEventListener('wheel', (e) => {
|
|
|
545
562
|
const { x, y } = at(e), slot = views.slotAt(x, y);
|
|
546
563
|
if (slot < 0) return;
|
|
547
564
|
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * 400 : e.deltaY;
|
|
548
|
-
|
|
565
|
+
const factor = Math.exp(Math.max(-1, Math.min(1, dy * 0.0015)));
|
|
566
|
+
if (preset === 'topotopo') {
|
|
567
|
+
const hit = ctx.select.navigationPoint(slot, x, y); if (hit) views.anchor(slot, hit);
|
|
568
|
+
views.zoomAt(slot, factor, hit ?? views.targetPlanePoint(slot, x, y, views.cam(slot).pivot));
|
|
569
|
+
} else views.dolly(slot, factor, views.targetPlanePoint(slot, x, y));
|
|
549
570
|
clearTimeout(canvas.wheelTimer); canvas.wheelTimer = setTimeout(saveViews, 300);
|
|
550
571
|
ctx.requestRender();
|
|
551
572
|
}, { passive: false });
|
|
@@ -561,18 +582,52 @@ ctx.on('*', (type) => {
|
|
|
561
582
|
});
|
|
562
583
|
ctx.selection.onChange(() => ctx.emit('selection'));
|
|
563
584
|
|
|
585
|
+
// Each deliverable owns a camera; the scene view keeps its camera and layer visibility.
|
|
586
|
+
const outputViews = new Map();
|
|
587
|
+
let sceneView = null, previewSerial = 0;
|
|
588
|
+
let sceneGraph = null;
|
|
589
|
+
ctx.rememberOutputView = (id) => { if (id) outputViews.set(id, views.toJSON()); else if (!ctx.isolated) { sceneView = views.placed ? views.toJSON() : null; sceneGraph = { name: ctx.graph.name, node: ctx.graph.node }; } };
|
|
590
|
+
ctx.hideModelPreview = () => { previewSerial++; ctx.previewEmpty = true; ctx.selection.clear(); ctx.requestRender(); };
|
|
591
|
+
ctx.outputGraph = (item) => {
|
|
592
|
+
if (item.graph && ctx.graph.list.some((g) => g.name === item.graph)) {
|
|
593
|
+
ctx.graph.name = item.graph; ctx.graph.node = item.node; $('graph').value = item.graph;
|
|
594
|
+
props.controls.render(ctx.graph.current());
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
ctx.previewOutput = async (item, bytes, update) => {
|
|
598
|
+
const serial = ++previewSerial, src = await parseGLB(bytes);
|
|
599
|
+
if (serial !== previewSerial || ctx.outputs.active?.id !== item.id) { new MeshObject('', src).dispose(); return; }
|
|
600
|
+
const mesh = new MeshObject('out', src); mesh.onReady = () => ctx.requestRender();
|
|
601
|
+
ctx.output.mesh?.dispose(); ctx.output.mesh = mesh; ctx.outputRoot.add(mesh.root); ctx.previewEmpty = false;
|
|
602
|
+
ctx.output.summary = item.summary ?? ''; ctx.output.error = item.error ?? null;
|
|
603
|
+
ctx.outputGraph(item);
|
|
604
|
+
outEntry(); ctx.selection.clear();
|
|
605
|
+
if (!update || !outputViews.has(item.id)) {
|
|
606
|
+
const saved = outputViews.get(item.id);
|
|
607
|
+
if (saved) views.load(saved);
|
|
608
|
+
else { layoutViews(); views.frame(views.active, mesh.bounds()); }
|
|
609
|
+
outputViews.set(item.id, views.toJSON());
|
|
610
|
+
}
|
|
611
|
+
firstFrame = false; ctx.emit('output'); ctx.requestRender(); syncTop();
|
|
612
|
+
};
|
|
613
|
+
ctx.restoreScene = () => { previewSerial++; ctx.previewEmpty = false; if (sceneView) views.load(sceneView); else firstFrame = true; if (sceneGraph) { ctx.graph.name = sceneGraph.name; ctx.graph.node = sceneGraph.node; $('graph').value = ctx.graph.name; props.controls.render(ctx.graph.current()); } loadGeo(); ctx.requestRender(); };
|
|
614
|
+
ctx.frameOutput = frameAll;
|
|
615
|
+
ctx.outputs = createOutputPanel(ctx);
|
|
616
|
+
|
|
564
617
|
const es = api.events({
|
|
565
618
|
open() {
|
|
566
|
-
ctx.message(
|
|
619
|
+
ctx.message(connectedLine(ctx.graph.list.length));
|
|
567
620
|
// 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(); }
|
|
621
|
+
if (es.opened) { refreshGraphs().then(loadGeo); ctx.outputs.refresh(); ctx.layers.refresh(); ctx.layers.loadWhere(); ctx.guides.load(); }
|
|
569
622
|
es.opened = true;
|
|
570
623
|
},
|
|
571
624
|
error() { ctx.message('disconnected (server stopped?)', 'err'); },
|
|
572
625
|
async message(ev) {
|
|
573
|
-
if (ev.
|
|
626
|
+
if (ev.outputs !== undefined) { ctx.outputs.refresh(); return; }
|
|
627
|
+
if (ev.inputs !== undefined) { ctx.layers.refresh(); ctx.outputs.refresh(); return; }
|
|
574
628
|
await refreshGraphs();
|
|
575
629
|
if (ev.graph === ctx.graph.name) loadGeo();
|
|
630
|
+
ctx.outputs.refresh();
|
|
576
631
|
},
|
|
577
632
|
});
|
|
578
633
|
|
|
@@ -581,6 +636,7 @@ outliner.render();
|
|
|
581
636
|
props.render();
|
|
582
637
|
ctx.requestRender();
|
|
583
638
|
await refreshGraphs();
|
|
639
|
+
await ctx.outputs.refresh();
|
|
584
640
|
loadGeo();
|
|
585
641
|
ctx.layers.refresh();
|
|
586
642
|
ctx.layers.loadWhere();
|
|
@@ -1,28 +1,60 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// (click one to show its result; the square is its display flag)
|
|
1
|
+
// The left panel (docs/design/viewer.md section 2): three tabs. Scene is the outliner tree: the output (the node being
|
|
2
|
+
// shown), the graph's nodes (click one to show its result; the square is its display flag) and the guides. Outputs
|
|
3
|
+
// is the published results (outputs.js). Inputs is the files in inputs/ (reference layers) with Add input.
|
|
3
4
|
// Rows carry a visibility eye, a display mode and the name; selecting a row selects in the viewport and back.
|
|
4
5
|
// Rows are kept and updated in place, so an open menu survives a refresh.
|
|
5
6
|
import { opOf } from './selection.js';
|
|
6
7
|
import { DISPLAY_MODES, LAYER_MODES } from './scene.js';
|
|
7
8
|
import { EDITOR_NAMES } from './markers.js';
|
|
8
9
|
import { fmtBytes } from './units.js';
|
|
9
|
-
import { layerId } from './layers.js';
|
|
10
|
+
import { layerId, isInputImage, inputImageURL } from './layers.js';
|
|
10
11
|
import { guideId } from './guides.js';
|
|
11
12
|
|
|
12
13
|
const EYE = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8 12.1 12.5 8 12.5 1.5 8 1.5 8z"/><circle class="pupil" cx="8" cy="8" r="2"/><path class="slash" d="M3 13L13 3"/></svg>';
|
|
13
14
|
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
|
14
15
|
|
|
15
|
-
export function createOutliner(ctx,
|
|
16
|
+
export function createOutliner(ctx, panes) {
|
|
17
|
+
// the tabs: one pane shown, remembered per browser; arrow keys move between tabs
|
|
18
|
+
const tabs = [...document.querySelectorAll('#outliner [data-tab]')], paneEls = [...document.querySelectorAll('#outliner [data-pane]')];
|
|
19
|
+
let tab = ctx.store.get('outliner.tab', 'outputs');
|
|
20
|
+
const showTab = (name, focus = false) => {
|
|
21
|
+
if (!tabs.some((t) => t.dataset.tab === name)) name = 'outputs';
|
|
22
|
+
tab = name; ctx.store.set('outliner.tab', name);
|
|
23
|
+
for (const t of tabs) { const on = t.dataset.tab === name; t.classList.toggle('on', on); t.setAttribute('aria-selected', String(on)); t.tabIndex = on ? 0 : -1; if (on && focus) t.focus(); }
|
|
24
|
+
for (const p of paneEls) p.hidden = p.dataset.pane !== name;
|
|
25
|
+
};
|
|
26
|
+
for (const t of tabs) {
|
|
27
|
+
t.onclick = () => showTab(t.dataset.tab);
|
|
28
|
+
t.onkeydown = (e) => {
|
|
29
|
+
if (!['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
const i = tabs.indexOf(t), next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
|
|
32
|
+
showTab(next.dataset.tab, true);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
showTab(tab);
|
|
36
|
+
const tabCount = (name, n) => { const c = document.querySelector(`#tab-${name} .count`); if (c) c.textContent = n ? String(n) : ''; };
|
|
37
|
+
const picker = document.getElementById('input-file');
|
|
38
|
+
document.getElementById('add-input').onclick = () => picker.click();
|
|
39
|
+
picker.onchange = () => { ctx.layers.upload([...picker.files], []); picker.value = ''; };
|
|
40
|
+
const sizes = [32, 56, 88], sizeButton = document.getElementById('thumb-size');
|
|
41
|
+
let size = ctx.store.get('outliner.thumbnailSize', 56);
|
|
42
|
+
if (!sizes.includes(size)) size = 56;
|
|
43
|
+
const applySize = () => { document.getElementById('outliner').style.setProperty('--thumb-size', size + 'px'); sizeButton.textContent = size + ' px'; };
|
|
44
|
+
sizeButton.onclick = () => { size = sizes[(sizes.indexOf(size) + 1) % sizes.length]; ctx.store.set('outliner.thumbnailSize', size); applySize(); };
|
|
45
|
+
applySize();
|
|
16
46
|
const closed = new Set(ctx.store.get('outliner.closed', []));
|
|
17
47
|
const sections = new Map();
|
|
18
|
-
|
|
48
|
+
/** A collapsible section in a pane (the scene tree by default); a headless one is just rows (the Inputs tab). */
|
|
49
|
+
const section = (key, title, pane = panes.scene, headless = false) => {
|
|
19
50
|
let s = sections.get(key);
|
|
20
51
|
if (s) return s;
|
|
21
|
-
const box = el('div', 'sec' + (closed.has(key) ? ' closed' : '')), head = el('div', 'sh'), rows = el('div', 'rows'), count = el('span', 'count');
|
|
52
|
+
const box = el('div', 'sec' + (closed.has(key) && !headless ? ' closed' : '')), head = el('div', 'sh'), rows = el('div', 'rows'), count = el('span', 'count');
|
|
22
53
|
head.append(el('span', '', title), count);
|
|
23
54
|
head.onclick = () => { box.classList.toggle('closed'); if (box.classList.contains('closed')) closed.add(key); else closed.delete(key); ctx.store.set('outliner.closed', [...closed]); };
|
|
55
|
+
head.hidden = headless;
|
|
24
56
|
box.append(head, rows);
|
|
25
|
-
|
|
57
|
+
pane.append(box);
|
|
26
58
|
s = { box, rows, count, keyed: new Map(), tail: [] };
|
|
27
59
|
sections.set(key, s);
|
|
28
60
|
return s;
|
|
@@ -74,7 +106,7 @@ export function createOutliner(ctx, host) {
|
|
|
74
106
|
function render() {
|
|
75
107
|
// output: the node being shown
|
|
76
108
|
{
|
|
77
|
-
const s = section('output', '
|
|
109
|
+
const s = section('output', 'Scene');
|
|
78
110
|
const g = ctx.graph.current();
|
|
79
111
|
s.count.textContent = '';
|
|
80
112
|
sync(s, g ? [{ key: 'out' }] : [], () => {
|
|
@@ -122,22 +154,30 @@ export function createOutliner(ctx, host) {
|
|
|
122
154
|
};
|
|
123
155
|
});
|
|
124
156
|
}
|
|
125
|
-
// inputs: reference layers
|
|
157
|
+
// inputs: reference layers, in the Inputs tab
|
|
126
158
|
{
|
|
127
|
-
const s = section('inputs', 'Inputs');
|
|
159
|
+
const s = section('inputs', 'Inputs', panes.inputs, true);
|
|
128
160
|
const L = ctx.layers;
|
|
129
|
-
|
|
161
|
+
tabCount('inputs', L.list.length);
|
|
162
|
+
const where = document.getElementById('inputs-where');
|
|
163
|
+
where.textContent = L.where ? `inputs/ ${L.where}` : 'inputs/'; where.title = L.dir ?? '';
|
|
130
164
|
sync(s, L.list.map((f) => ({ key: f.path, f })), ({ f: first }) => {
|
|
131
165
|
const path = first.path, id = layerId(path);
|
|
132
|
-
const wrap = el('div', ''), row = el('div', 'row'), ey = eye(() => L.show(path, !L.shown.has(path))), nm = el('span', 'nm', path), mt = el('span', 'mt'), er = el('div', 'rowerr');
|
|
166
|
+
const wrap = el('div', ''), row = el('div', 'row input-card'), ey = eye(() => L.show(path, !L.shown.has(path))), nm = el('span', 'nm', path), mt = el('span', 'mt'), er = el('div', 'rowerr');
|
|
133
167
|
const ds = displaySelect(LAYER_MODES, (m) => L.setDisplay(path, m));
|
|
134
|
-
|
|
168
|
+
const thumb = el('img', 'input-thumb'), body = el('div', 'input-body'), controls = el('div', 'input-controls'), icon = el('span', 'file-icon', first.ext?.toUpperCase() || 'FILE');
|
|
169
|
+
thumb.alt = ''; thumb.loading = 'lazy'; thumb.onerror = () => { thumb.hidden = true; icon.hidden = false; };
|
|
170
|
+
controls.append(ey, ds, mt); body.append(nm, controls); row.append(thumb, icon, body);
|
|
135
171
|
wrap.append(row, er);
|
|
136
172
|
selectRow(row, id);
|
|
173
|
+
row.onclick = (e) => { if (!e.target.closest('button,select,input') && isInputImage(path)) ctx.outputs.openInput(path); };
|
|
137
174
|
row.addEventListener('pointerenter', () => setTimeout(() => L.fetchInfo(path), 150));
|
|
138
175
|
return {
|
|
139
176
|
el: wrap,
|
|
140
177
|
update({ f }) {
|
|
178
|
+
const canPreview = isInputImage(path) || ['mesh', 'geometry', 'points'].includes(f.kind) || /\.(obj|stl|ply|glb|gltf|blend|geo|bgeo|json|csv)$/i.test(path);
|
|
179
|
+
const src = canPreview ? (isInputImage(path) ? inputImageURL(f) : '/api/input_thumb?' + new URLSearchParams({ path, rev: `${f.mtime}:${f.size}` })) : '';
|
|
180
|
+
if (thumb.dataset.source !== src) { thumb.dataset.source = src; thumb.hidden = !src; icon.hidden = !!src; if (src) thumb.src = src; }
|
|
141
181
|
const on = L.shown.has(path), e = ctx.objects.get(id);
|
|
142
182
|
ey.classList.toggle('off', !on);
|
|
143
183
|
ey.title = on ? 'hide (unloads the layer)' : 'show as a reference layer';
|
|
@@ -151,9 +191,10 @@ export function createOutliner(ctx, host) {
|
|
|
151
191
|
},
|
|
152
192
|
};
|
|
153
193
|
});
|
|
154
|
-
if (!L.list.length) note(s, '
|
|
155
|
-
|
|
194
|
+
if (!L.list.length) note(s, 'No inputs yet: Add input, or drop meshes, curves, points, PNG, JPEG or WebP files anywhere on this page. The agent hears about new files and reads them as inputs/<name>.');
|
|
195
|
+
else note(s, 'a file node reads these as inputs/<name>; the agent hears about new ones');
|
|
156
196
|
}
|
|
197
|
+
tabCount('outputs', ctx.outputs?.count);
|
|
157
198
|
// guides
|
|
158
199
|
{
|
|
159
200
|
const s = section('guides', 'Guides');
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* the left panel's tabs: Scene, Outputs, Inputs */
|
|
2
|
+
.phead.tabs { padding: 0 6px 0 0; gap: 0; align-items: stretch; }
|
|
3
|
+
.phead.tabs [role=tab] { padding: 6px 10px 5px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; color: var(--dim); font: inherit; letter-spacing: .06em; text-transform: uppercase; }
|
|
4
|
+
.phead.tabs [role=tab]:hover { color: var(--text); }
|
|
5
|
+
.phead.tabs [role=tab].on { color: var(--text); border-bottom-color: var(--sel); }
|
|
6
|
+
.phead.tabs [role=tab]:focus-visible { outline: 1px solid var(--sel); outline-offset: -1px; }
|
|
7
|
+
.phead.tabs [role=tab] .count { margin-left: 3px; color: var(--faint); letter-spacing: 0; }
|
|
8
|
+
#thumb-size { margin: auto 0 auto auto; font-size: 10px; letter-spacing: 0; min-width: 48px; }
|
|
9
|
+
.tabpane { flex: 1; }
|
|
10
|
+
.tabpane[hidden] { display: none; }
|
|
11
|
+
/* the bars at the top of the Outputs and Inputs tabs stay in view while the cards scroll */
|
|
12
|
+
.outputs-bar, .inputs-bar { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--panel); border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
|
|
13
|
+
.outputs-bar .primary, .inputs-bar .primary { flex: none; padding: 4px 12px; }
|
|
14
|
+
.outputs-bar .dim, .inputs-bar .dim { font-size: 11px; }
|
|
15
|
+
.primary { border-color: var(--sel); }
|
|
16
|
+
button:disabled { opacity: .45; cursor: default; }
|
|
17
|
+
/* input cards */
|
|
18
|
+
.input-card.row { height: auto; min-height: calc(var(--thumb-size) + 12px); padding: 6px; gap: 8px; }
|
|
19
|
+
.input-thumb, .file-icon { width: var(--thumb-size); height: var(--thumb-size); flex: none; object-fit: contain; background: var(--view); }
|
|
20
|
+
.file-icon { display: inline-flex; align-items: center; justify-content: center; color: var(--dim); font-size: 10px; overflow: hidden; }
|
|
21
|
+
.input-body { min-width: 0; flex: 1; }
|
|
22
|
+
.input-body .nm { display: block; white-space: normal; overflow-wrap: anywhere; }
|
|
23
|
+
.input-controls { display: flex; gap: 3px; align-items: center; margin-top: 6px; flex-wrap: wrap; }
|
|
24
|
+
.input-controls .mt { margin-left: auto; }
|
|
25
|
+
/* output cards: thumbnail, name, what it is, where it exports, and its Export button */
|
|
26
|
+
.output-group { padding: 12px 10px 5px; color: var(--dim); font-size: 11px; overflow-wrap: anywhere; }
|
|
27
|
+
.output-card { display: flex; align-items: center; width: calc(100% - 8px); margin: 3px 4px; padding: 5px; gap: 8px; text-align: left; background: var(--bg); border: 1px solid transparent; min-height: calc(var(--thumb-size) + 12px); cursor: pointer; }
|
|
28
|
+
.output-card:hover { background: var(--hover-bg); }
|
|
29
|
+
.output-card:focus-visible { outline: 1px solid var(--sel); }
|
|
30
|
+
.output-card.selected { border-color: var(--sel); background: var(--sel-bg); }
|
|
31
|
+
.output-card.child { margin-left: 12px; width: calc(100% - 16px); }
|
|
32
|
+
.output-card img { width: var(--thumb-size); height: var(--thumb-size); object-fit: contain; background: var(--view); flex: none; }
|
|
33
|
+
.output-card .output-body { display: flex; flex: 1; flex-direction: column; min-width: 0; justify-content: center; gap: 3px; }
|
|
34
|
+
.output-card .output-label { overflow-wrap: anywhere; }
|
|
35
|
+
.output-card small { overflow: hidden; text-overflow: ellipsis; max-height: 32px; }
|
|
36
|
+
.output-export { flex: none; padding: 3px 10px; }
|
|
37
|
+
.output-target { align-self: flex-start; max-width: 100%; padding: 0; border: 0; background: none; color: var(--dim); font-size: 11px; text-align: left; overflow-wrap: anywhere; cursor: pointer; }
|
|
38
|
+
.output-target:hover { color: var(--text); text-decoration: underline; }
|
|
39
|
+
.output-target.unset { font-style: italic; color: var(--faint); }
|
|
40
|
+
.saved-outputs { border-top: 1px solid var(--line); margin-top: 8px; }
|
|
41
|
+
.saved-outputs > summary { padding: 8px 10px; color: var(--dim); cursor: pointer; }
|
|
42
|
+
.output-menu { position: fixed; z-index: 30; min-width: 190px; padding: 4px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 20px #0007; border-radius: 4px; }
|
|
43
|
+
.output-menu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 7px 12px; }
|
|
44
|
+
.output-menu button:hover, .output-menu button:focus { background: var(--hover-bg); outline: none; }
|
|
45
|
+
/* the export target popover: a format and a file inside exports/ */
|
|
46
|
+
.export-popover { position: fixed; z-index: 40; display: flex; flex-direction: column; gap: 8px; width: 330px; max-width: calc(100vw - 12px); padding: 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 4px; box-shadow: 0 6px 20px #0008; }
|
|
47
|
+
.export-title { font-weight: 600; overflow-wrap: anywhere; }
|
|
48
|
+
.export-field { display: grid; grid-template-columns: 48px minmax(0, 1fr); align-items: center; gap: 8px; }
|
|
49
|
+
.export-field select, .export-field input { width: 100%; min-width: 0; padding: 4px 6px; }
|
|
50
|
+
.export-where { font: var(--mono); font-size: 11px; overflow-wrap: anywhere; }
|
|
51
|
+
.export-error { color: var(--err); font-size: 11px; }
|
|
52
|
+
.export-error:empty { display: none; }
|
|
53
|
+
.export-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
54
|
+
.export-actions button { padding: 4px 12px; }
|
|
55
|
+
.dim { color: var(--dim); }
|
|
56
|
+
.err { color: var(--err); }
|
|
57
|
+
.empty { padding: 12px; color: var(--dim); }
|
|
58
|
+
#output-details { padding: 12px; border-bottom: 1px solid var(--line); max-height: 36%; overflow: auto; overflow-wrap: anywhere; }
|
|
59
|
+
#output-details p { white-space: pre-wrap; max-height: 120px; overflow: auto; }
|
|
60
|
+
#output-details a { display: block; margin: 6px 0; color: #9dc9ee; }
|
|
61
|
+
#top { gap: 7px; }
|
|
62
|
+
#top .primary { border-color: var(--sel); color: var(--text); }
|
|
63
|
+
#image-view { position: absolute; inset: 0; display: flex; flex-direction: column; z-index: 4; background: var(--view); }
|
|
64
|
+
.image-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 7px; background: var(--panel); border-bottom: 1px solid var(--line); }
|
|
65
|
+
.image-tools label { white-space: nowrap; }
|
|
66
|
+
.image-dim { color: var(--dim); font: var(--mono); }
|
|
67
|
+
.image-stage { position: relative; flex: 1; min-height: 0; background: #303236; overflow: hidden; }
|
|
68
|
+
.image-stage.checker { background-color: #35383c; background-image: conic-gradient(#45484c 25%, transparent 0 50%, #45484c 0 75%, transparent 0); background-size: 24px 24px; }
|
|
69
|
+
.image-stage canvas { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; }
|
|
70
|
+
.image-stage canvas:active { cursor: grabbing; }
|
|
71
|
+
.image-error { position: absolute; left: 10px; bottom: 10px; padding: 8px; background: var(--bg); color: var(--err); }
|
|
72
|
+
.image-mode > #gl, .image-mode > #views, .image-mode > #boxsel { visibility: hidden; pointer-events: none; }
|
|
73
|
+
@media (max-width: 1100px) { #top .readout { display: none; } #top { gap: 4px; } #top .tb select { max-width: 120px; } }
|