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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// The Outputs tab: each graph's published outputs as cards (click one to see it alone, its textures below it), one
|
|
2
|
+
// Export button each, Export all for every output with a target, and the files saved in exports/. A first Export
|
|
3
|
+
// asks for a format and file (export-popover.js) and stores that target in the graph; later clicks just write it.
|
|
4
|
+
import { createImageView } from './image-view.js';
|
|
5
|
+
import { fmtBytes } from './units.js';
|
|
6
|
+
import { exportPopover, defaultPath } from './export-popover.js';
|
|
7
|
+
import { inputImageURL } from './layers.js';
|
|
8
|
+
|
|
9
|
+
const url = (route, item, more = {}) => `/api/output/${route}?` + new URLSearchParams({ id: item.id, rev: item.revision, ...more });
|
|
10
|
+
const text = (tag, value, cls) => { const e = document.createElement(tag); e.textContent = value; if (cls) e.className = cls; return e; };
|
|
11
|
+
const post = async (route, body) => {
|
|
12
|
+
const r = await fetch('/api/output/' + route, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
13
|
+
const j = await r.json().catch(() => ({ error: `${route} failed (${r.status})` }));
|
|
14
|
+
if (!r.ok || !j.ok) throw new Error(j.error ?? `${route} failed (${r.status})`);
|
|
15
|
+
return j;
|
|
16
|
+
};
|
|
17
|
+
/** "just now", "5 min ago", "3 h ago", else the date */
|
|
18
|
+
export function ago(ms, now = Date.now()) {
|
|
19
|
+
const s = Math.max(0, (now - ms) / 1000);
|
|
20
|
+
if (s < 10) return 'just now';
|
|
21
|
+
if (s < 60) return `${Math.round(s)} s ago`;
|
|
22
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
|
23
|
+
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
|
24
|
+
return new Date(ms).toLocaleDateString();
|
|
25
|
+
}
|
|
26
|
+
/** A target as the graph stores it: the format alone when the path is the default, else {format, path}. */
|
|
27
|
+
export function targetSpec(item, choice, formats) {
|
|
28
|
+
const stem = item.output ?? item.graph ?? 'output';
|
|
29
|
+
const keep = item.target && item.target.format === choice.format ? item.target.options ?? {} : {};
|
|
30
|
+
const custom = choice.path !== defaultPath(stem, choice.format, formats);
|
|
31
|
+
return !custom && !Object.keys(keep).length ? choice.format : { format: choice.format, ...(custom ? { path: choice.path } : {}), ...keep };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createOutputPanel(ctx) {
|
|
35
|
+
const host = document.getElementById('outputs'), details = document.getElementById('output-details');
|
|
36
|
+
const image = createImageView(document.getElementById('viewport'), ctx.message);
|
|
37
|
+
let list = [], formats = [], selected = null, directory = '', sequence = 0, lastList = '', initial = true, refreshing = null, refreshAgain = false, busy = false;
|
|
38
|
+
const P = { active: null };
|
|
39
|
+
const bar = document.createElement('div'), all = text('button', 'Export all', 'primary'), barNote = text('span', '', 'dim');
|
|
40
|
+
bar.className = 'outputs-bar'; bar.append(all, barNote);
|
|
41
|
+
const live = document.createElement('div'), saved = document.createElement('details'), savedRows = document.createElement('div');
|
|
42
|
+
saved.className = 'saved-outputs'; saved.open = ctx.store.get('outputs.savedOpen', false);
|
|
43
|
+
saved.append(text('summary', 'Saved files'), savedRows); saved.ontoggle = () => ctx.store.set('outputs.savedOpen', saved.open);
|
|
44
|
+
const empty = text('div', 'Nothing published yet: a graph\'s output shows here once it cooks.', 'empty');
|
|
45
|
+
const cards = new Map(), groups = new Map(); let input = null;
|
|
46
|
+
host.append(bar, empty, live, saved);
|
|
47
|
+
Object.defineProperty(P, 'count', { get: () => list.filter((v) => v.exportable).length });
|
|
48
|
+
|
|
49
|
+
function showDetails(item) {
|
|
50
|
+
details.replaceChildren(); details.hidden = !item;
|
|
51
|
+
if (!item) return;
|
|
52
|
+
details.append(text('strong', item.label), text('p', item.error ?? item.summary ?? `${item.kind}${item.width ? ` · ${item.width} × ${item.height}` : ''}`, item.error ? 'err' : 'dim'));
|
|
53
|
+
if (item.exportable) details.append(text('p', item.target ? `exports to exports/${item.target.path} (${item.target.format})` : 'no export target yet: its Export button sets one', 'dim'));
|
|
54
|
+
if (item.files) for (const f of item.files) {
|
|
55
|
+
const a = text('a', `${f.name} · ${fmtBytes(f.size)}`); a.href = url('file', item, { file: f.id, download: '1' }); a.download = f.name; details.append(a);
|
|
56
|
+
}
|
|
57
|
+
if (item.files?.length > 1) { const a = text('a', 'Download ZIP'); a.href = url('zip', item); a.download = item.label + '.zip'; details.append(a); }
|
|
58
|
+
}
|
|
59
|
+
const targetLine = (item) => !item.target ? 'no export target'
|
|
60
|
+
: `${item.target.path}${item.saved ? ` · saved ${ago(item.saved.at)} · ${fmtBytes(item.saved.size)}` : ' · not exported yet'}`;
|
|
61
|
+
|
|
62
|
+
async function exportItem(item, button, change = false) {
|
|
63
|
+
if (busy) return;
|
|
64
|
+
let choice = null;
|
|
65
|
+
if (change || !item.target) {
|
|
66
|
+
choice = await exportPopover({ anchor: button, item, formats, directory });
|
|
67
|
+
if (!choice) return;
|
|
68
|
+
}
|
|
69
|
+
busy = true; render();
|
|
70
|
+
ctx.message(`exporting ${item.label}…`, 'busy');
|
|
71
|
+
try {
|
|
72
|
+
const j = await post('export', { id: item.id, ...(choice ? { export: targetSpec(item, choice, formats) } : {}) });
|
|
73
|
+
ctx.message(`exported ${item.label} → exports/${j.path}${j.files > 1 ? ` (${j.files} files)` : ''}${j.warnings?.length ? ' · ' + j.warnings.join('; ') : ''}`, j.warnings?.length ? 'err' : 'ok', 7000);
|
|
74
|
+
} catch (e) { ctx.message(e.message, 'err', 10000); }
|
|
75
|
+
finally { busy = false; await P.refresh(); render(); }
|
|
76
|
+
}
|
|
77
|
+
async function clearTarget(item) {
|
|
78
|
+
try { await post('export', { id: item.id, export: null }); ctx.message(`${item.label} has no export target now`); }
|
|
79
|
+
catch (e) { ctx.message(e.message, 'err', 10000); }
|
|
80
|
+
await P.refresh();
|
|
81
|
+
}
|
|
82
|
+
all.onclick = async () => {
|
|
83
|
+
if (busy) return;
|
|
84
|
+
busy = true; render(); ctx.message('exporting all outputs with a target…', 'busy');
|
|
85
|
+
try {
|
|
86
|
+
const j = await post('export-all', {});
|
|
87
|
+
const warnings = j.written.flatMap((w) => w.warnings ?? []);
|
|
88
|
+
ctx.message(`exported ${j.written.length} output${j.written.length === 1 ? '' : 's'}${j.skipped.length ? ` · ${j.skipped.length} without a target (their Export button sets one)` : ''}${warnings.length ? ' · ' + warnings.join('; ') : ''}`, warnings.length ? 'err' : 'ok', 8000);
|
|
89
|
+
} catch (e) { ctx.message(e.message, 'err', 10000); }
|
|
90
|
+
finally { busy = false; await P.refresh(); render(); }
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function render() {
|
|
94
|
+
const used = new Set(), usedGroups = new Set(), grouped = new Map();
|
|
95
|
+
for (const item of list) {
|
|
96
|
+
const name = item.origin === 'published' ? item.graph ?? 'Outputs' : '__saved';
|
|
97
|
+
if (!grouped.has(name)) grouped.set(name, []); grouped.get(name).push(item);
|
|
98
|
+
}
|
|
99
|
+
const published = list.filter((v) => v.exportable), withTarget = published.filter((v) => v.target);
|
|
100
|
+
all.disabled = busy || !withTarget.length;
|
|
101
|
+
all.textContent = busy ? 'Exporting…' : 'Export all';
|
|
102
|
+
all.title = withTarget.length ? `write every output that has an export target (${withTarget.length}) into exports/` : 'export an output with its button first: that sets its target';
|
|
103
|
+
barNote.textContent = published.length ? `${withTarget.length} of ${published.length} with a target` : '';
|
|
104
|
+
bar.hidden = !published.length; empty.hidden = !!list.length;
|
|
105
|
+
for (const [name, entries] of grouped) {
|
|
106
|
+
let group = groups.get(name);
|
|
107
|
+
if (!group) {
|
|
108
|
+
const box = document.createElement('div'), rows = document.createElement('div');
|
|
109
|
+
if (name !== '__saved') box.append(text('div', name, 'output-group'));
|
|
110
|
+
box.append(rows); group = { box, rows }; groups.set(name, group);
|
|
111
|
+
}
|
|
112
|
+
usedGroups.add(name);
|
|
113
|
+
const parent = name === '__saved' ? savedRows : live;
|
|
114
|
+
if (group.box.parentElement !== parent) parent.append(group.box);
|
|
115
|
+
const ordered = [], seen = new Set(), ids = new Set(entries.map((item) => item.id));
|
|
116
|
+
const visit = (item) => { if (seen.has(item.id)) return; seen.add(item.id); ordered.push(item); for (const child of entries) if (child.parent === item.id) visit(child); };
|
|
117
|
+
for (const item of entries) if (!ids.has(item.parent)) visit(item);
|
|
118
|
+
for (const item of entries) visit(item);
|
|
119
|
+
let previous = null;
|
|
120
|
+
for (const item of ordered) {
|
|
121
|
+
used.add(item.id); let card = cards.get(item.id);
|
|
122
|
+
if (!card) {
|
|
123
|
+
const row = document.createElement('div'); row.className = 'output-card'; row.tabIndex = 0; row.setAttribute('role', 'button'); row.dataset.output = item.id;
|
|
124
|
+
const thumb = document.createElement('img'); thumb.loading = 'lazy'; thumb.alt = '';
|
|
125
|
+
const icon = text('span', 'FILE', 'file-icon'), body = text('span', '', 'output-body'), label = text('span', '', 'output-label'), info = text('small', '', 'dim');
|
|
126
|
+
const target = text('button', '', 'output-target'), go = text('button', 'Export', 'output-export');
|
|
127
|
+
target.title = 'change where this output exports'; go.type = target.type = 'button';
|
|
128
|
+
body.append(label, info, target); row.append(thumb, icon, body, go);
|
|
129
|
+
card = { row, thumb, icon, label, info, target, go, item }; cards.set(item.id, card);
|
|
130
|
+
go.onclick = () => exportItem(card.item, go);
|
|
131
|
+
target.onclick = () => exportItem(card.item, go, true);
|
|
132
|
+
row.onclick = (e) => { if (!e.target.closest('input,button')) P.select(card.item.id); };
|
|
133
|
+
row.onkeydown = (e) => { if (e.target === row && ['Enter', ' '].includes(e.key)) { e.preventDefault(); P.select(card.item.id); } if (e.key === 'ContextMenu' || e.shiftKey && e.key === 'F10') { e.preventDefault(); contextMenu(card.item, row.getBoundingClientRect().left, row.getBoundingClientRect().bottom); } };
|
|
134
|
+
row.oncontextmenu = (e) => { e.preventDefault(); contextMenu(card.item, e.clientX, e.clientY); };
|
|
135
|
+
thumb.onerror = () => { thumb.hidden = true; icon.hidden = false; };
|
|
136
|
+
}
|
|
137
|
+
card.item = item;
|
|
138
|
+
const { row, thumb, icon, label, info, target, go } = card;
|
|
139
|
+
row.classList.toggle('selected', item.id === selected); row.classList.toggle('child', !!item.parent);
|
|
140
|
+
row.setAttribute('aria-label', `${item.label} — ${item.kind}`); row.setAttribute('aria-pressed', String(item.id === selected));
|
|
141
|
+
go.hidden = target.hidden = !item.exportable; go.disabled = busy || item.status === 'loading';
|
|
142
|
+
go.title = item.target ? `write exports/${item.target.path} again (overwrites it)` : 'choose a format and file, then export';
|
|
143
|
+
go.setAttribute('aria-label', `Export ${item.label}`);
|
|
144
|
+
target.textContent = item.exportable ? targetLine(item) : ''; target.classList.toggle('unset', !item.target);
|
|
145
|
+
const src = ['geometry', 'image'].includes(item.kind) && item.status !== 'loading' ? item.kind === 'image' && !item.generated ? url('file', item) : url('thumb', item) : '';
|
|
146
|
+
if (thumb.dataset.source !== src) { thumb.dataset.source = src; thumb.hidden = !src; icon.hidden = !!src; if (src) thumb.src = src; }
|
|
147
|
+
icon.textContent = item.kind === 'geometry' ? '3D' : item.kind === 'image' ? 'IMG' : item.kind.toUpperCase();
|
|
148
|
+
label.textContent = item.label; info.textContent = item.error ?? (item.status === 'loading' ? 'Cooking…' : `${item.kind}${item.width ? ` · ${item.width}×${item.height}` : ''}`); info.className = item.error ? 'err' : 'dim';
|
|
149
|
+
const next = previous ? previous.nextSibling : group.rows.firstChild; if (row !== next) group.rows.insertBefore(row, next); previous = row;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const [id, card] of cards) if (!used.has(id)) { card.row.remove(); cards.delete(id); }
|
|
153
|
+
for (const [name, group] of groups) if (!usedGroups.has(name)) { group.box.remove(); groups.delete(name); }
|
|
154
|
+
const savedCount = list.filter((v) => v.origin === 'saved' && !v.parent).length;
|
|
155
|
+
saved.hidden = !savedCount;
|
|
156
|
+
saved.querySelector('summary').textContent = `Saved files (${savedCount})`;
|
|
157
|
+
}
|
|
158
|
+
// "saved 2 min ago" keeps up without a refresh
|
|
159
|
+
setInterval(() => { for (const card of cards.values()) if (card.item.exportable) card.target.textContent = targetLine(card.item); }, 30000);
|
|
160
|
+
|
|
161
|
+
let menu = null, dismissMenu = () => {};
|
|
162
|
+
function contextMenu(item, x, y) {
|
|
163
|
+
dismissMenu(); const previous = document.activeElement;
|
|
164
|
+
menu = document.createElement('div'); menu.className = 'output-menu'; menu.setAttribute('role', 'menu');
|
|
165
|
+
const close = () => { menu?.remove(); menu = null; removeEventListener('pointerdown', outside, true); removeEventListener('blur', close); previous?.focus(); };
|
|
166
|
+
const outside = (e) => { if (!menu?.contains(e.target)) close(); }; dismissMenu = close;
|
|
167
|
+
const add = (label, action) => { const button = text('button', label); button.setAttribute('role', 'menuitem'); button.onclick = () => { close(); action(); }; menu.append(button); };
|
|
168
|
+
const download = (href, name) => { const a = document.createElement('a'); a.href = href; a.download = name; a.click(); };
|
|
169
|
+
add(item.origin === 'saved' ? 'Open' : 'Preview', () => P.select(item.id));
|
|
170
|
+
if (item.kind === 'geometry') add('Frame', async () => { await P.select(item.id); ctx.frameOutput(); });
|
|
171
|
+
if (item.exportable) {
|
|
172
|
+
const go = cards.get(item.id)?.go;
|
|
173
|
+
add(item.target ? `Export to ${item.target.path}` : 'Export…', () => exportItem(item, go));
|
|
174
|
+
add('Change export target…', () => exportItem(item, go, true));
|
|
175
|
+
if (item.target) add('Remove export target', () => clearTarget(item));
|
|
176
|
+
} else if (item.generated && item.kind === 'image') add('Download PNG', () => download(url('image', item, { mode: 'RGBA', download: '1' }), item.label + '.png'));
|
|
177
|
+
else if (item.files?.length) add(item.files.length > 1 ? 'Download ZIP' : 'Download', () => download(item.files.length > 1 ? url('zip', item) : url('file', item, { download: '1' }), item.label));
|
|
178
|
+
document.body.append(menu); menu.style.left = Math.max(4, Math.min(x, innerWidth - menu.offsetWidth - 4)) + 'px'; menu.style.top = Math.max(4, Math.min(y, innerHeight - menu.offsetHeight - 4)) + 'px';
|
|
179
|
+
menu.onkeydown = (e) => { const buttons = [...menu.children], i = buttons.indexOf(document.activeElement); if (e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); close(); } else if (['ArrowDown', 'ArrowUp'].includes(e.key)) { e.preventDefault(); buttons[(i + (e.key === 'ArrowDown' ? 1 : buttons.length - 1)) % buttons.length].focus(); } };
|
|
180
|
+
menu.firstChild.focus(); addEventListener('pointerdown', outside, true); addEventListener('blur', close);
|
|
181
|
+
}
|
|
182
|
+
P.select = async (id, update = false) => {
|
|
183
|
+
const item = input?.id === id ? input : list.find((v) => v.id === id); if (!item) return;
|
|
184
|
+
const changedSelection = selected !== id;
|
|
185
|
+
if (!update) ctx.rememberOutputView?.(selected);
|
|
186
|
+
const seq = ++sequence;
|
|
187
|
+
if (changedSelection) { image.hide(); ctx.hideModelPreview(); }
|
|
188
|
+
selected = id; P.active = item; ctx.isolated = true; ctx.guides.setMode(null);
|
|
189
|
+
render(); showDetails(item);
|
|
190
|
+
if (item.status === 'loading') { ctx.message('Preparing ' + item.label, 'busy'); return; }
|
|
191
|
+
if (item.status === 'error') { ctx.message(item.error, 'err'); return; }
|
|
192
|
+
await ctx.outputGraph?.(item);
|
|
193
|
+
if (seq !== sequence) return;
|
|
194
|
+
if (item.kind === 'image') { ctx.hideModelPreview(); image.show(item); ctx.message(item.label); ctx.emit('output'); return; }
|
|
195
|
+
image.hide();
|
|
196
|
+
if (item.kind !== 'geometry') { ctx.hideModelPreview(); return; }
|
|
197
|
+
try {
|
|
198
|
+
const r = await fetch(url('geo', item));
|
|
199
|
+
if (!r.ok) throw new Error((await r.json()).error);
|
|
200
|
+
const bytes = await r.arrayBuffer(); if (seq !== sequence) return;
|
|
201
|
+
await ctx.previewOutput(item, bytes, update);
|
|
202
|
+
ctx.message(item.label);
|
|
203
|
+
} catch (e) { if (seq === sequence) ctx.message(e.message, 'err'); }
|
|
204
|
+
};
|
|
205
|
+
P.scene = () => {
|
|
206
|
+
sequence++; initial = false; ctx.rememberOutputView?.(selected); selected = null; P.active = null;
|
|
207
|
+
image.hide(); ctx.isolated = false; showDetails(null); render(); ctx.restoreScene();
|
|
208
|
+
};
|
|
209
|
+
P.refresh = async () => {
|
|
210
|
+
if (refreshing) { refreshAgain = true; return refreshing; }
|
|
211
|
+
refreshing = (async () => {
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch('/api/outputs'), j = await response.json(); if (!response.ok) throw new Error(j.error);
|
|
214
|
+
directory = j.directory; formats = j.formats ?? [];
|
|
215
|
+
const signature = JSON.stringify(j.items);
|
|
216
|
+
if (signature === lastList) return;
|
|
217
|
+
lastList = signature; const before = P.active; list = j.items;
|
|
218
|
+
const after = input?.id === selected ? input : list.find((v) => v.id === selected); render(); ctx.emit('outputs');
|
|
219
|
+
if (after && (after.revision !== before?.revision || after.status !== before?.status)) await P.select(after.id, true);
|
|
220
|
+
else if (after) { P.active = after; showDetails(after); }
|
|
221
|
+
else if (selected && !after) P.scene();
|
|
222
|
+
else if (initial && list.length) { initial = false; const main = list.find((v) => v.generated && !v.parent); if (main) await P.select(main.id); }
|
|
223
|
+
} catch (e) { ctx.message(e.message, 'err'); }
|
|
224
|
+
finally { refreshing = null; if (refreshAgain) { refreshAgain = false; void P.refresh(); } }
|
|
225
|
+
})();
|
|
226
|
+
return refreshing;
|
|
227
|
+
};
|
|
228
|
+
document.getElementById('scene-view').onclick = P.scene;
|
|
229
|
+
P.openInput = async (path, update = false) => {
|
|
230
|
+
const f = ctx.layers.list.find((v) => v.path === path); if (!f) return;
|
|
231
|
+
input = { id: 'input:' + path, inputPath: path, label: path, kind: 'image', status: 'ready', revision: `${f.mtime}:${f.size}`, sourceURL: inputImageURL(f), summary: ctx.layers.info.get(path)?.summary ?? ctx.layers.fileText(f) };
|
|
232
|
+
await P.select(input.id, update);
|
|
233
|
+
};
|
|
234
|
+
ctx.on('layers', () => {
|
|
235
|
+
if (!P.active?.inputPath) return;
|
|
236
|
+
const f = ctx.layers.list.find((v) => v.path === P.active.inputPath);
|
|
237
|
+
if (!f) P.scene(); else if (`${f.mtime}:${f.size}` !== P.active.revision) P.openInput(f.path, true);
|
|
238
|
+
});
|
|
239
|
+
return P;
|
|
240
|
+
}
|
|
@@ -10,11 +10,11 @@ const DRAG_PX = 4;
|
|
|
10
10
|
|
|
11
11
|
export function createSelect(ctx) {
|
|
12
12
|
const S = { press: null, boxEl: document.getElementById('boxsel'), hoverAt: null, hoverQueued: false };
|
|
13
|
-
const meshEntries = () => [...ctx.objects.values()].filter((e) => e.mesh && e.visible && e.mesh.root.visible);
|
|
13
|
+
const meshEntries = () => [...ctx.objects.values()].filter((e) => e.mesh && (ctx.isolated ? e.id === 'out' && !ctx.previewEmpty : e.visible) && e.mesh.root.visible);
|
|
14
14
|
|
|
15
15
|
/** What is under a viewport point in a slot: {id, point?} or null. Guides first (they draw on top). */
|
|
16
16
|
S.pick = (slot, x, y) => {
|
|
17
|
-
const g = ctx.guides.pick(slot, x, y);
|
|
17
|
+
const g = ctx.isolated ? null : ctx.guides.pick(slot, x, y);
|
|
18
18
|
if (g) return { id: g.id };
|
|
19
19
|
const rc = ctx.views.ray(slot, x, y);
|
|
20
20
|
const linePx = ctx.views.worldPerPixel(slot) * 5;
|
|
@@ -50,6 +50,17 @@ export function createSelect(ctx) {
|
|
|
50
50
|
const p = rc.ray.intersectPlane(planeOf(slot), new THREE.Vector3());
|
|
51
51
|
return p ? { point: p, surface: false } : null;
|
|
52
52
|
};
|
|
53
|
+
/** Navigation hits actual visible surfaces, never the infinite construction plane or selection bounds. */
|
|
54
|
+
S.navigationPoint = (slot, x, y) => {
|
|
55
|
+
const rc = ctx.views.ray(slot, x, y);
|
|
56
|
+
let best = null;
|
|
57
|
+
for (const e of meshEntries()) {
|
|
58
|
+
if (e.surface === false || e.mesh.picture || e.mesh.bboxMode) continue;
|
|
59
|
+
const h = e.mesh.pick(rc, 0);
|
|
60
|
+
if (h && h.dist < (best?.dist ?? Infinity)) best = h;
|
|
61
|
+
}
|
|
62
|
+
return best?.point?.clone() ?? null;
|
|
63
|
+
};
|
|
53
64
|
/** A viewport point on the horizontal plane at height `level`, through slot's camera. */
|
|
54
65
|
S.planePoint = (slot, x, y, level) => ctx.views.ray(slot, x, y).ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), -level), new THREE.Vector3());
|
|
55
66
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The status bar's own lines (docs/design/viewer.md section 4): what the page says when nothing else is reported.
|
|
2
|
+
// Plain functions, tested in Node (test/viewer-logic.ts).
|
|
3
|
+
|
|
4
|
+
/** While the event stream is connected. */
|
|
5
|
+
export const LIVE_LINE = 'live — updates on every build, edit and file save';
|
|
6
|
+
/** While the workspace has no graph. */
|
|
7
|
+
export const NO_GRAPHS_LINE = 'no graphs yet — build one via MCP';
|
|
8
|
+
|
|
9
|
+
/** The line when the event stream (re)connects: the hint while there is no graph, else the live line. */
|
|
10
|
+
export const connectedLine = (graphCount) => (graphCount ? LIVE_LINE : NO_GRAPHS_LINE);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The line after the graph list was read, or null to keep the one shown: the hint while there is no graph, and the
|
|
14
|
+
* live line in its place once the first graph arrives. Any other line (an undo, a keymap note, an error) stays.
|
|
15
|
+
* Whichever of the two runs last at startup, the page ends on the line that fits the workspace.
|
|
16
|
+
*/
|
|
17
|
+
export function graphsLine(shown, graphCount) {
|
|
18
|
+
if (!graphCount) return NO_GRAPHS_LINE;
|
|
19
|
+
return shown === NO_GRAPHS_LINE ? LIVE_LINE : null;
|
|
20
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
--mono: 11px/1.35 ui-monospace, Consolas, "SF Mono", monospace;
|
|
10
10
|
}
|
|
11
11
|
* { box-sizing: border-box; }
|
|
12
|
+
[hidden] { display: none !important; }
|
|
12
13
|
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: var(--font); overflow: hidden; }
|
|
13
14
|
#app {
|
|
14
15
|
display: grid; height: 100%;
|
package/dist/viewer/index.html
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
<link rel="icon" href="data:,">
|
|
8
8
|
<!-- the viewer page (docs/design/viewer.md): ES modules from /app/, three.js from /vendor/three/, no build step -->
|
|
9
9
|
<link rel="stylesheet" href="/app/style.css">
|
|
10
|
+
<link rel="stylesheet" href="/app/outputs.css">
|
|
10
11
|
<script type="importmap">{ "imports": { "three": "/vendor/three/build/three.module.js", "three/addons/": "/vendor/three/examples/jsm/" } }</script>
|
|
11
12
|
<script type="module" src="/app/main.js"></script>
|
|
12
13
|
</head>
|
|
@@ -14,18 +15,30 @@
|
|
|
14
15
|
<div id="app">
|
|
15
16
|
<header id="top">
|
|
16
17
|
<label class="tb" title="the graph shown">graph <select id="graph"></select></label>
|
|
17
|
-
<label class="tb" title="the current view's camera
|
|
18
|
-
<label class="tb" title="the current view's shading
|
|
18
|
+
<label class="tb" title="the current view's camera; see ? for active key bindings">view <select id="view-cam"></select></label>
|
|
19
|
+
<label class="tb" title="the current view's shading; see ? for active key bindings">shading <select id="view-shading"></select></label>
|
|
19
20
|
<span class="tb readout" id="step" title="the current view's grid step"></span>
|
|
20
21
|
<button class="tb" id="quad" title="single / quad view">quad</button>
|
|
21
22
|
<span class="grow"></span>
|
|
23
|
+
<button class="tb" id="scene-view" title="back to the whole scene after looking at one output">Scene</button>
|
|
22
24
|
<span class="tb mode" id="mode"></span>
|
|
23
25
|
<label class="tb" title="keyboard and mouse bindings, remembered in this browser">keymap <select id="preset"></select></label>
|
|
24
26
|
<button class="tb" id="help" title="the keymap (?)">?</button>
|
|
25
27
|
</header>
|
|
26
28
|
<aside id="outliner" class="panel">
|
|
27
|
-
<div class="phead">
|
|
28
|
-
|
|
29
|
+
<div class="phead tabs" role="tablist" aria-label="Scene, outputs and inputs">
|
|
30
|
+
<button role="tab" id="tab-scene" data-tab="scene" aria-controls="tree">Scene</button>
|
|
31
|
+
<button role="tab" id="tab-outputs" data-tab="outputs" aria-controls="outputs-pane">Outputs <span class="count"></span></button>
|
|
32
|
+
<button role="tab" id="tab-inputs" data-tab="inputs" aria-controls="inputs-pane">Inputs <span class="count"></span></button>
|
|
33
|
+
<button id="thumb-size" title="Cycle thumbnail size" aria-label="Cycle thumbnail size">56 px</button>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="pscroll tabpane" id="tree" data-pane="scene" role="tabpanel" aria-labelledby="tab-scene"></div>
|
|
36
|
+
<div class="pscroll tabpane" id="outputs-pane" data-pane="outputs" role="tabpanel" aria-labelledby="tab-outputs"><div id="outputs"></div></div>
|
|
37
|
+
<div class="pscroll tabpane" id="inputs-pane" data-pane="inputs" role="tabpanel" aria-labelledby="tab-inputs">
|
|
38
|
+
<div class="inputs-bar"><button class="primary" id="add-input">Add input…</button><span class="dim" id="inputs-where"></span></div>
|
|
39
|
+
<input id="input-file" type="file" multiple hidden accept=".glb,.gltf,.obj,.stl,.ply,.geo,.bgeo,.blend,.csv,.json,.png,.jpg,.jpeg,.webp">
|
|
40
|
+
<div id="inputs-list"></div>
|
|
41
|
+
</div>
|
|
29
42
|
<div class="grip v" data-grip="left" title="drag to resize"></div>
|
|
30
43
|
</aside>
|
|
31
44
|
<main id="viewport">
|
|
@@ -36,6 +49,7 @@
|
|
|
36
49
|
<aside id="props" class="panel">
|
|
37
50
|
<div class="grip v" data-grip="right" title="drag to resize"></div>
|
|
38
51
|
<div class="phead">Properties</div>
|
|
52
|
+
<div id="output-details" hidden></div>
|
|
39
53
|
<div class="pscroll" id="props-body"></div>
|
|
40
54
|
<div class="grip h" data-grip="split" title="drag to resize"></div>
|
|
41
55
|
<div class="phead">Controls <span class="count" id="controls-count"></span></div>
|
|
@@ -48,6 +62,6 @@
|
|
|
48
62
|
<span id="cook"></span>
|
|
49
63
|
</footer>
|
|
50
64
|
</div>
|
|
51
|
-
<div id="drop">drop files to add them to <span id="drop-where">inputs/</span><br><small>they show as reference layers in the
|
|
65
|
+
<div id="drop">drop files to add them to <span id="drop-where">inputs/</span><br><small>they show in the Inputs tab and as reference layers in the view</small></div>
|
|
52
66
|
</body>
|
|
53
67
|
</html>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type http from 'node:http';
|
|
2
|
+
import type { Workspace } from '../workspace.ts';
|
|
3
|
+
export interface OutputEntry {
|
|
4
|
+
origin?: 'published' | 'saved';
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
graph?: string;
|
|
8
|
+
node?: string;
|
|
9
|
+
parent?: string;
|
|
10
|
+
kind: 'geometry' | 'image' | 'file' | 'bundle';
|
|
11
|
+
status: 'loading' | 'ready' | 'error';
|
|
12
|
+
revision: string;
|
|
13
|
+
error?: string;
|
|
14
|
+
summary?: string;
|
|
15
|
+
width?: number;
|
|
16
|
+
height?: number;
|
|
17
|
+
layers?: string[];
|
|
18
|
+
layer?: string;
|
|
19
|
+
image?: string;
|
|
20
|
+
generated?: boolean;
|
|
21
|
+
/** a published output's card has an Export button: `output` is its id (null = the graph's default output) */
|
|
22
|
+
exportable?: boolean;
|
|
23
|
+
output?: string | null;
|
|
24
|
+
/** where it exports (posix path inside exports/, the export tool's options), or null before a first export sets it */
|
|
25
|
+
target?: {
|
|
26
|
+
format: string;
|
|
27
|
+
path: string;
|
|
28
|
+
options: Record<string, unknown>;
|
|
29
|
+
} | null;
|
|
30
|
+
/** its last export to that target: newest file time (ms), total bytes, file count */
|
|
31
|
+
saved?: {
|
|
32
|
+
at: number;
|
|
33
|
+
size: number;
|
|
34
|
+
files: number;
|
|
35
|
+
};
|
|
36
|
+
files?: {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
size: number;
|
|
40
|
+
}[];
|
|
41
|
+
}
|
|
42
|
+
/** Predictable ZIP bytes, retaining the bundle directory structure. */
|
|
43
|
+
export declare function outputZip(files: {
|
|
44
|
+
name: string;
|
|
45
|
+
bytes: Uint8Array;
|
|
46
|
+
}[]): Uint8Array;
|
|
47
|
+
export interface OutputsOptions {
|
|
48
|
+
/** after a target is set in a graph (topotopo view writes it back into the graph file) */
|
|
49
|
+
persistGraph?: (graph: string) => void;
|
|
50
|
+
}
|
|
51
|
+
export declare function createOutputs(ws: Workspace, changed: () => void, opts?: OutputsOptions): {
|
|
52
|
+
close(): void;
|
|
53
|
+
handle(req: http.IncomingMessage, res: http.ServerResponse, u: URL, readJSON: (r: http.IncomingMessage) => Promise<any>): Promise<boolean>;
|
|
54
|
+
};
|