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.
@@ -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 (!r.ok) throw new Error(r.error);
68
- src = await parseGLB(r.buf);
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
- const r = await api.inputInfo(path);
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);
@@ -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
- if (!g) ctx.message('no graphs yet — build one via MCP');
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 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;
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
- drag = { kind: 'nav', nav, slot, x, y, id: e.pointerId, pivot: sel.isEmpty() ? null : sel.getCenter(new THREE.Vector3()) };
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
- views.dolly(slot, Math.exp(Math.max(-1, Math.min(1, dy * 0.0015))), views.targetPlanePoint(slot, x, y));
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('live — updates on every build, edit and file save');
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.inputs !== undefined) { ctx.layers.refresh(); return; }
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 outliner (docs/design/viewer.md section 2): one tree of the output (the node being shown), the graph's nodes
2
- // (click one to show its result; the square is its display flag), the inputs (reference layers) and the guides.
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, host) {
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
- const section = (key, title) => {
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
- host.append(box);
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', '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
- s.count.textContent = L.list.length || '';
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
- row.append(ey, ds, nm, mt);
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, 'none yet — drop files anywhere on this page (OBJ, STL, PLY, glTF/GLB, Houdini .geo, CSV, points JSON, PNG)');
155
- if (L.where) { const n = note(s, `files go to inputs/ ${L.where}; a file node reads them as inputs/<name>`); n.title = L.dir; }
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; } }