topotopo 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -12
- package/dist/cli.js +98 -23
- package/dist/graph/engine.js +4 -1
- package/dist/graph/params.d.ts +7 -1
- package/dist/graph/params.js +21 -8
- package/dist/graph/types.d.ts +11 -2
- package/dist/host/export_scene.py +200 -0
- package/dist/host/node.d.ts +8 -0
- package/dist/host/node.js +114 -0
- package/dist/host/types.d.ts +5 -0
- package/dist/inspect.js +5 -2
- package/dist/io/common.d.ts +6 -0
- package/dist/io/export.js +7 -4
- package/dist/io/gltf.d.ts +1 -1
- package/dist/io/gltf.js +167 -32
- package/dist/io/heightmap.d.ts +1 -1
- package/dist/io/import.d.ts +2 -2
- package/dist/io/import.js +10 -3
- package/dist/io/png-decode.d.ts +5 -2
- package/dist/io/png-decode.js +10 -3
- package/dist/io/textures.d.ts +31 -2
- package/dist/io/textures.js +66 -18
- package/dist/io/unreal.js +22 -5
- package/dist/mcp/docs.d.ts +9 -2
- package/dist/mcp/docs.js +113 -2
- package/dist/mcp/inputs.d.ts +28 -0
- package/dist/mcp/inputs.js +377 -0
- package/dist/mcp/server.js +4 -1
- package/dist/mcp/tools.js +32 -18
- package/dist/nodes/code.js +2 -0
- package/dist/nodes/copy.js +14 -4
- package/dist/nodes/flow.js +136 -35
- package/dist/nodes/heightfield.js +67 -11
- package/dist/nodes/texture.d.ts +1 -1
- package/dist/nodes/texture.js +61 -5
- package/dist/render/options.js +2 -1
- package/dist/render/raster.d.ts +2 -0
- package/dist/render/raster.js +3 -3
- package/dist/render/texview.d.ts +9 -1
- package/dist/render/texview.js +43 -2
- package/dist/viewer/app/api.js +64 -0
- package/dist/viewer/app/camera.js +230 -0
- package/dist/viewer/app/grid.js +217 -0
- package/dist/viewer/app/guides.js +402 -0
- package/dist/viewer/app/keymap.js +187 -0
- package/dist/viewer/app/layers.js +225 -0
- package/dist/viewer/app/main.js +590 -0
- package/dist/viewer/app/markers.js +206 -0
- package/dist/viewer/app/mesh.js +172 -0
- package/dist/viewer/app/outliner.js +184 -0
- package/dist/viewer/app/props.js +432 -0
- package/dist/viewer/app/scene.js +529 -0
- package/dist/viewer/app/select.js +142 -0
- package/dist/viewer/app/selection.js +79 -0
- package/dist/viewer/app/style.css +172 -0
- package/dist/viewer/app/undo.js +49 -0
- package/dist/viewer/app/units.js +51 -0
- package/dist/viewer/app/vars.js +64 -0
- package/dist/viewer/index.html +53 -184
- package/dist/viewer/server.d.ts +42 -1
- package/dist/viewer/server.js +432 -17
- package/dist/workspace.d.ts +52 -2
- package/dist/workspace.js +95 -3
- package/package.json +8 -3
- package/shelf/brick_wall.json +1 -1
- package/shelf/wall_segment.json +2 -2
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// The properties column (docs/design/viewer.md sections 2 and 8): what the selection is (a guide's name, kind,
|
|
2
|
+
// translate / rotate / scale, radius or height and the attributes the LLM reads; a layer's file, counts, bbox and
|
|
3
|
+
// display mode; the output's summary; a piece's size), and below it the Controls: the graph's exposed vars as
|
|
4
|
+
// sliders, fields, checkboxes, menus and colour pickers. Drags post at most every 100 ms; each control resets to its
|
|
5
|
+
// default; undeclared vars are listed under "other vars". Transform fields are read-only until phase 2.
|
|
6
|
+
import { api } from './api.js';
|
|
7
|
+
import * as M from './markers.js';
|
|
8
|
+
import { parseId } from './selection.js';
|
|
9
|
+
import { DISPLAY_MODES, LAYER_MODES } from './scene.js';
|
|
10
|
+
import { fmtLen, fmtNum, fmtSize, fmtCount } from './units.js';
|
|
11
|
+
import { isNum, same, HEX3, fmt, short, jsonKind, kindOf, blank, rangeOf, autoStep, snapTo, controlList } from './vars.js';
|
|
12
|
+
|
|
13
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
|
14
|
+
const $ = (id) => document.getElementById(id);
|
|
15
|
+
|
|
16
|
+
export function createProps(ctx) {
|
|
17
|
+
const body = $('props-body');
|
|
18
|
+
let shownKey = null, dirty = false;
|
|
19
|
+
|
|
20
|
+
// ------------------------------------------------------------------ building blocks
|
|
21
|
+
const group = (title, ...extra) => { const g = el('div', 'pgroup'), h = el('h3'); h.append(el('span', '', title), el('span', 'grow'), ...extra); g.append(h); return g; };
|
|
22
|
+
const row = (label, value, title) => {
|
|
23
|
+
const r = el('div', 'prow'), l = el('span', 'pl', label), v = el('span', 'pv');
|
|
24
|
+
if (value instanceof Node) v.append(value); else v.textContent = value ?? '';
|
|
25
|
+
if (title) r.title = title;
|
|
26
|
+
r.append(l, v);
|
|
27
|
+
return r;
|
|
28
|
+
};
|
|
29
|
+
const xyz = (vals, step = 1) => {
|
|
30
|
+
const box = el('div', 'xyz');
|
|
31
|
+
for (const x of vals) { const f = el('input'); f.readOnly = true; f.value = fmtNum(x, step < 1 ? 3 : 2); f.title = 'read-only until phase 2 (transform)'; box.append(f); }
|
|
32
|
+
return box;
|
|
33
|
+
};
|
|
34
|
+
const numberField = (value, onSet, title) => {
|
|
35
|
+
const f = el('input');
|
|
36
|
+
f.type = 'number'; f.min = '0.001'; f.step = 'any'; f.value = fmt(value); f.title = title ?? '';
|
|
37
|
+
f.oninput = () => { const v = Number(f.value); if (f.value.trim() !== '' && Number.isFinite(v) && v > 0) onSet(v); };
|
|
38
|
+
f.onkeydown = (e) => { if (e.key === 'Enter' || e.key === 'Escape') f.blur(); };
|
|
39
|
+
return f;
|
|
40
|
+
};
|
|
41
|
+
const bboxRows = (g, b) => {
|
|
42
|
+
if (!b || b.isEmpty()) return;
|
|
43
|
+
const size = b.getSize(new ctx.THREE.Vector3()), c = b.getCenter(new ctx.THREE.Vector3());
|
|
44
|
+
g.append(row('size', fmtSize(size.toArray())), row('centre', xyz(c.toArray())), row('min', xyz(b.min.toArray())), row('max', xyz(b.max.toArray())));
|
|
45
|
+
};
|
|
46
|
+
const summaryBlock = (text) => { const p = el('pre', 'summary'); p.textContent = text; return p; };
|
|
47
|
+
const displayRow = (modes, value, onChange) => {
|
|
48
|
+
const s = el('select');
|
|
49
|
+
for (const [v, t] of modes) s.append(new Option(t, v));
|
|
50
|
+
s.value = value;
|
|
51
|
+
s.onchange = () => onChange(s.value);
|
|
52
|
+
return row('display', s);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------------ one object
|
|
56
|
+
function guide(m) {
|
|
57
|
+
const out = [];
|
|
58
|
+
const id = m.id, kind = M.EDITOR_NAMES[m.kind];
|
|
59
|
+
const del = el('button', 'danger', 'Delete');
|
|
60
|
+
del.title = 'delete this guide (Delete)';
|
|
61
|
+
del.onclick = () => ctx.guides.remove([id]);
|
|
62
|
+
const g = group(kind, del);
|
|
63
|
+
const name = el('input');
|
|
64
|
+
name.type = 'text'; name.value = m.name; name.spellcheck = false; name.maxLength = 100;
|
|
65
|
+
name.onkeydown = (e) => { if (e.key === 'Enter') name.blur(); else if (e.key === 'Escape') { name.value = ctx.guides.byId(id)?.name ?? ''; name.classList.remove('bad'); name.blur(); } };
|
|
66
|
+
name.onchange = () => {
|
|
67
|
+
const r = ctx.guides.rename(id, name.value);
|
|
68
|
+
if (r.error) { ctx.message(r.error, 'err'); name.classList.add('bad'); name.value = ctx.guides.byId(id)?.name ?? ''; setTimeout(() => name.classList.remove('bad'), 1500); }
|
|
69
|
+
else name.value = r.name;
|
|
70
|
+
};
|
|
71
|
+
g.append(row('name', name), row('kind', `${kind} · role "${m.kind}"`));
|
|
72
|
+
const piv = M.pivotOf(m);
|
|
73
|
+
g.append(row('translate', xyz(piv)), row('rotate', xyz([0, 0, 0])), row('scale', xyz([1, 1, 1])));
|
|
74
|
+
out.push(g);
|
|
75
|
+
const k = group('shape');
|
|
76
|
+
if (m.kind === 'site') k.append(row('radius (m)', numberField(m.radius, (v) => ctx.guides.setSize(id, 'radius', v), 'pscale in inputs/markers.json')));
|
|
77
|
+
if (m.kind === 'volume') {
|
|
78
|
+
const { lo, hi } = M.boxOf(m);
|
|
79
|
+
k.append(row('height (m)', numberField(m.height, (v) => ctx.guides.setSize(id, 'height', v), 'how far the box rises above its lower corner')), row('size', fmtSize([hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]])));
|
|
80
|
+
}
|
|
81
|
+
if (m.kind === 'path') k.append(row('length', `${fmtLen(M.pathLength(m.points))} m`));
|
|
82
|
+
if (m.kind === 'zone') k.append(row('area', `${fmtNum(M.topArea(m.points), 1)} m² from above`));
|
|
83
|
+
if (m.kind !== 'site') {
|
|
84
|
+
const list = el('div', 'mono');
|
|
85
|
+
list.textContent = m.points.map((p, i) => `${m.kind === 'volume' ? 'corner' : 'cv'} ${i} ${p.map((x) => fmtNum(x, 2)).join(', ')}`).join('\n');
|
|
86
|
+
list.style.whiteSpace = 'pre';
|
|
87
|
+
k.append(row(m.kind === 'volume' ? 'corners' : 'CVs', list));
|
|
88
|
+
} else k.append(row('point', xyz(m.points[0])));
|
|
89
|
+
out.push(k);
|
|
90
|
+
// what the agent reads
|
|
91
|
+
const a = group('attributes the LLM reads');
|
|
92
|
+
const t = el('table', 'attrs');
|
|
93
|
+
for (const r of M.markerAttributes(m)) {
|
|
94
|
+
const tr = el('tr');
|
|
95
|
+
tr.append(el('td', '', `${r.cls} ${r.name}`), el('td', '', typeof r.value === 'string' ? JSON.stringify(r.value) : Array.isArray(r.value) ? `[${r.value.join(', ')}]` : fmt(r.value)));
|
|
96
|
+
t.append(tr);
|
|
97
|
+
}
|
|
98
|
+
const elems = M.markerElements(m);
|
|
99
|
+
a.append(t, el('div', 'hint', `inputs/markers.json${ctx.guides.saving() ? ' (saving…)' : ''}: ${elems.text}. Read it with {"type":"file","params":{"path":"inputs/markers.json"}}; this one: group "${M.groupOf(m)}", every ${m.kind}: "@role=${m.kind}"`));
|
|
100
|
+
out.push(a);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function layer(e) {
|
|
104
|
+
const L = ctx.layers, f = e.file, out = [];
|
|
105
|
+
const g = group('reference layer');
|
|
106
|
+
g.append(row('file', `inputs/${e.path}`), ...(f ? [row('kind', L.fileText(f))] : []));
|
|
107
|
+
g.append(displayRow(LAYER_MODES, L.display.get(e.path) ?? 'view', (m) => L.setDisplay(e.path, m)));
|
|
108
|
+
if (!L.shown.has(e.path)) g.append(el('div', 'hint', 'hidden — the eye in the outliner shows it'));
|
|
109
|
+
if (e.mesh) {
|
|
110
|
+
const s = e.mesh.src;
|
|
111
|
+
g.append(row('offset', xyz(s.position.toArray())), row('scale', xyz(s.scale.toArray())));
|
|
112
|
+
}
|
|
113
|
+
out.push(g);
|
|
114
|
+
const info = L.info.get(e.path);
|
|
115
|
+
const sg = group('contents');
|
|
116
|
+
if (!info) { L.fetchInfo(e.path); sg.append(el('div', 'hint', '…')); }
|
|
117
|
+
else if (info.pending) sg.append(el('div', 'hint', '…'));
|
|
118
|
+
else if (info.error) sg.append(el('div', 'hint', info.error));
|
|
119
|
+
else sg.append(summaryBlock(info.summary));
|
|
120
|
+
out.push(sg);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
function output() {
|
|
124
|
+
const o = ctx.output, out = [];
|
|
125
|
+
const g = group('output');
|
|
126
|
+
const graph = ctx.graph.current();
|
|
127
|
+
g.append(row('graph', graph?.name ?? ''), row('node', ctx.graph.shownNode() ?? ''), displayRow(DISPLAY_MODES, o.display, (m) => o.setDisplay(m)));
|
|
128
|
+
if (o.report?.cooked) g.append(row('cook', `${fmtNum(o.report.ms, 0)} ms${o.report.ok ? '' : ' · failed'}`));
|
|
129
|
+
const ec = o.mesh?.edgeCounts();
|
|
130
|
+
if (ec) g.append(row('edges', `${fmtCount(ec.open)} open · ${fmtCount(ec.nonManifold)} non-manifold · ${fmtCount(ec.flipped)} flipped`, 'drawn as the render tool draws them: pink open, yellow non-manifold, violet winding flips'));
|
|
131
|
+
out.push(g);
|
|
132
|
+
const sg = group('summary');
|
|
133
|
+
sg.append(o.summary ? summaryBlock(o.summary) : el('div', 'hint', o.error ?? '…'));
|
|
134
|
+
out.push(sg);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
function piece(e, k) {
|
|
138
|
+
const out = [], g = group(`piece ${k} of ${e.kind === 'output' ? 'the output' : `inputs/${e.path}`}`);
|
|
139
|
+
const at = e.mesh?.partOf(k);
|
|
140
|
+
if (at?.part.a?.start) g.append(row('triangles', fmtCount(at.part.a.start[at.local + 1] - at.part.a.start[at.local])));
|
|
141
|
+
else if (at?.part.type === 'inst') g.append(row('instance', `${at.local} of ${at.part.count}`));
|
|
142
|
+
bboxRows(g, e.mesh?.bounds(k));
|
|
143
|
+
g.append(el('div', 'hint', 'a connected piece of the geometry; the outliner row selects the whole object'));
|
|
144
|
+
out.push(g);
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
function many(ids) {
|
|
148
|
+
const kinds = {};
|
|
149
|
+
for (const id of ids) {
|
|
150
|
+
const { obj, piece: p } = parseId(id), e = ctx.objects.get(obj);
|
|
151
|
+
const k = p !== null ? 'piece' : e?.kind === 'guide' ? M.EDITOR_NAMES[e.marker.kind] : e?.kind === 'layer' ? 'layer' : 'output';
|
|
152
|
+
kinds[k] = (kinds[k] ?? 0) + 1;
|
|
153
|
+
}
|
|
154
|
+
const g = group(`${ids.length} selected`);
|
|
155
|
+
g.append(row('kinds', Object.entries(kinds).map(([k, n]) => `${n} ${k}${n === 1 ? '' : k.endsWith('x') ? 'es' : 's'}`).join(', ')));
|
|
156
|
+
bboxRows(g, ctx.select.selectionBounds());
|
|
157
|
+
return [g];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Rebuild the panel for the selection (not while a field in it is being edited). */
|
|
161
|
+
function render(force = false) {
|
|
162
|
+
const ids = ctx.selection.items;
|
|
163
|
+
const lead = ctx.selection.lead;
|
|
164
|
+
const key = JSON.stringify([ids, ctx.version()]);
|
|
165
|
+
if (!force && key === shownKey) return;
|
|
166
|
+
if (body.contains(document.activeElement) && /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement.tagName)) { dirty = true; return; }
|
|
167
|
+
shownKey = key; dirty = false;
|
|
168
|
+
let parts;
|
|
169
|
+
if (!lead) {
|
|
170
|
+
const g = group('nothing selected');
|
|
171
|
+
g.append(el('div', 'hint', 'click in the view or in the outliner; Shift adds, Ctrl removes, drag a box. F frames the selection, A everything.'));
|
|
172
|
+
parts = [g];
|
|
173
|
+
} else if (ids.length > 1) parts = many(ids);
|
|
174
|
+
else {
|
|
175
|
+
const { obj, piece: p } = parseId(lead), e = ctx.objects.get(obj);
|
|
176
|
+
if (!e) parts = [group('gone')];
|
|
177
|
+
else if (p !== null) parts = piece(e, p);
|
|
178
|
+
else if (e.kind === 'guide') parts = guide(e.marker);
|
|
179
|
+
else if (e.kind === 'layer') parts = layer(e);
|
|
180
|
+
else parts = output();
|
|
181
|
+
}
|
|
182
|
+
body.replaceChildren(...parts);
|
|
183
|
+
}
|
|
184
|
+
body.addEventListener('focusout', () => setTimeout(() => { if (dirty && !body.contains(document.activeElement)) render(true); }));
|
|
185
|
+
|
|
186
|
+
return { render, controls: createControls(ctx) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------------------------- the Controls
|
|
190
|
+
// One control per declared param (graph.params), then the undeclared vars. Values are POSTed to /api/vars (sliders at
|
|
191
|
+
// most every 100 ms, plus once on release). Updates from the server set values in place; the panel is rebuilt only
|
|
192
|
+
// when the set of params changes. A finished change (release, Enter, a pick) is one undo step.
|
|
193
|
+
function createControls(ctx) {
|
|
194
|
+
const box = $('controls'), count = $('controls-count');
|
|
195
|
+
const ctl = {
|
|
196
|
+
graph: null, sig: null, rows: new Map(), active: null, deferred: null,
|
|
197
|
+
pending: {}, timer: 0, lastSend: -1e9, seq: 0,
|
|
198
|
+
// var → id of its newest POST not answered yet / time its newest POST was answered: older server state is ignored
|
|
199
|
+
unacked: new Map(), ackAt: new Map(),
|
|
200
|
+
// var → its value when the change being made began (the undo step's "before")
|
|
201
|
+
begin: new Map(), values: new Map(),
|
|
202
|
+
};
|
|
203
|
+
addEventListener('pointerup', () => endDrag(), true);
|
|
204
|
+
addEventListener('pointercancel', () => endDrag(), true);
|
|
205
|
+
function endDrag() {
|
|
206
|
+
ctl.active = null;
|
|
207
|
+
// after the slider's own change event (its final value) has been sent
|
|
208
|
+
if (ctl.deferred) setTimeout(() => { if (!ctl.deferred || ctl.active) return; const [g, t] = ctl.deferred; ctl.deferred = null; render(g, t); });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const WIDGETS = {
|
|
212
|
+
number(name, vd, value, cw) {
|
|
213
|
+
// declared int: integer values; an undeclared whole number gets integer slider steps but takes any typed value
|
|
214
|
+
const int = vd.type === 'int', intSteps = int || (vd.type === undefined && Number.isInteger(value));
|
|
215
|
+
let cur = value;
|
|
216
|
+
const r = rangeOf(vd, cur, intSteps);
|
|
217
|
+
const slider = el('input'), num = el('input');
|
|
218
|
+
slider.type = 'range'; num.type = 'number'; num.step = int ? String(r.step) : 'any';
|
|
219
|
+
const fit = () => { slider.min = r.lo; slider.max = r.hi; slider.step = r.step; if (r.fixLo) num.min = r.lo; if (r.fixHi) num.max = r.hi; };
|
|
220
|
+
const widen = (v) => {
|
|
221
|
+
const lo = !r.fixLo && v < r.lo, hi = !r.fixHi && v > r.hi;
|
|
222
|
+
if (lo) r.lo = v;
|
|
223
|
+
if (hi) r.hi = v;
|
|
224
|
+
if ((lo || hi) && !r.fixStep) r.step = autoStep(r);
|
|
225
|
+
if (lo || hi) fit();
|
|
226
|
+
};
|
|
227
|
+
const show = () => { slider.value = cur; num.value = fmt(cur); };
|
|
228
|
+
slider.addEventListener('pointerdown', () => { ctl.active = name; });
|
|
229
|
+
slider.addEventListener('input', () => { cur = snapTo(r, Number(slider.value)); num.value = fmt(cur); queueVar(name, cur); });
|
|
230
|
+
// a finished change gives the keys back to the viewport
|
|
231
|
+
slider.addEventListener('change', () => { queueVar(name, cur, true); slider.blur(); });
|
|
232
|
+
num.addEventListener('change', () => {
|
|
233
|
+
let v = Number(num.value);
|
|
234
|
+
if (num.value.trim() === '' || !Number.isFinite(v)) return show();
|
|
235
|
+
if (int) v = Math.round(v);
|
|
236
|
+
if (r.fixLo) v = Math.max(v, r.lo);
|
|
237
|
+
if (r.fixHi) v = Math.min(v, r.hi);
|
|
238
|
+
widen(v); cur = v; show(); queueVar(name, v, true);
|
|
239
|
+
});
|
|
240
|
+
cw.append(slider, num);
|
|
241
|
+
fit(); widen(cur); show();
|
|
242
|
+
return { set(v) { if (!isNum(v)) return; cur = v; widen(v); show(); }, get: () => cur, typing: () => document.activeElement === num };
|
|
243
|
+
},
|
|
244
|
+
bool(name, vd, value, cw) {
|
|
245
|
+
const asNum = isNum(value), b = el('input');
|
|
246
|
+
b.type = 'checkbox';
|
|
247
|
+
const get = () => (asNum ? (b.checked ? 1 : 0) : b.checked);
|
|
248
|
+
b.onchange = () => { queueVar(name, get(), true); b.blur(); };
|
|
249
|
+
cw.append(b);
|
|
250
|
+
const set = (v) => { b.checked = !!v; };
|
|
251
|
+
set(value);
|
|
252
|
+
return { set, get };
|
|
253
|
+
},
|
|
254
|
+
select(name, vd, value, cw) {
|
|
255
|
+
const opts = vd.options.map(String);
|
|
256
|
+
// a number var with string options holds the option's index
|
|
257
|
+
const byIndex = isNum(value) && !opts.includes(String(value));
|
|
258
|
+
const s = el('select');
|
|
259
|
+
for (const o of opts) s.append(new Option(o, o));
|
|
260
|
+
const get = () => (byIndex ? opts.indexOf(s.value) : s.value);
|
|
261
|
+
const set = (v) => {
|
|
262
|
+
const k = byIndex ? opts[v] : String(v);
|
|
263
|
+
if (k === undefined) return;
|
|
264
|
+
// a value outside the options (set by an agent or by hand) is shown as an extra entry while it holds
|
|
265
|
+
for (const o of [...s.options]) if (!opts.includes(o.value) && o.value !== k) o.remove();
|
|
266
|
+
if (![...s.options].some((o) => o.value === k)) s.append(new Option(k, k));
|
|
267
|
+
s.value = k;
|
|
268
|
+
};
|
|
269
|
+
s.onchange = () => { queueVar(name, get(), true); s.blur(); };
|
|
270
|
+
cw.append(s); set(value);
|
|
271
|
+
return { set, get };
|
|
272
|
+
},
|
|
273
|
+
text(name, vd, value, cw) {
|
|
274
|
+
const t = el('input');
|
|
275
|
+
t.type = 'text'; t.spellcheck = false;
|
|
276
|
+
t.onchange = () => queueVar(name, t.value, true);
|
|
277
|
+
cw.append(t);
|
|
278
|
+
const set = (v) => { t.value = v == null ? '' : String(v); };
|
|
279
|
+
set(value);
|
|
280
|
+
return { set, get: () => t.value, typing: () => document.activeElement === t };
|
|
281
|
+
},
|
|
282
|
+
vector(name, vd, value, cw) {
|
|
283
|
+
const int = /^ivec/.test(vd.type ?? '');
|
|
284
|
+
let cur = value.slice();
|
|
285
|
+
const fields = cur.map((_, i) => {
|
|
286
|
+
const f = el('input', 'vec');
|
|
287
|
+
f.type = 'number'; f.step = int ? '1' : 'any'; f.title = `${name}.${'xyzw'[i]}`;
|
|
288
|
+
f.onchange = () => {
|
|
289
|
+
let v = Number(f.value);
|
|
290
|
+
if (f.value.trim() === '' || !Number.isFinite(v)) { f.value = fmt(cur[i]); return; }
|
|
291
|
+
if (int) v = Math.round(v);
|
|
292
|
+
cur = cur.slice(); cur[i] = v; f.value = fmt(v);
|
|
293
|
+
queueVar(name, cur, true);
|
|
294
|
+
};
|
|
295
|
+
return f;
|
|
296
|
+
});
|
|
297
|
+
cw.append(...fields);
|
|
298
|
+
const set = (v) => { if (!Array.isArray(v)) return; cur = v.slice(); fields.forEach((f, i) => { f.value = fmt(cur[i] ?? 0); }); };
|
|
299
|
+
set(cur);
|
|
300
|
+
return { set, get: () => cur, typing: () => fields.includes(document.activeElement) };
|
|
301
|
+
},
|
|
302
|
+
color(name, vd, value, cw) {
|
|
303
|
+
// "#rrggbb" strings, or [r, g, b(, a)] in 0..1
|
|
304
|
+
const arr = Array.isArray(value), c = el('input');
|
|
305
|
+
c.type = 'color';
|
|
306
|
+
let cur = value;
|
|
307
|
+
const hex = (v) => (arr ? '#' + v.slice(0, 3).map((x) => Math.round(Math.min(1, Math.max(0, x)) * 255).toString(16).padStart(2, '0')).join('')
|
|
308
|
+
: HEX3.test(v) ? '#' + [...v.slice(1)].map((ch) => ch + ch).join('') : v);
|
|
309
|
+
const parse = (h) => (arr ? [1, 3, 5].map((i) => +(parseInt(h.slice(i, i + 2), 16) / 255).toFixed(4)).concat(cur.slice(3)) : h);
|
|
310
|
+
c.addEventListener('input', () => { cur = parse(c.value); queueVar(name, cur); });
|
|
311
|
+
c.addEventListener('change', () => { cur = parse(c.value); queueVar(name, cur, true); c.blur(); });
|
|
312
|
+
cw.append(c);
|
|
313
|
+
const set = (v) => { if (arr ? !Array.isArray(v) : typeof v !== 'string') return; cur = v; c.value = hex(v); };
|
|
314
|
+
set(value);
|
|
315
|
+
return { set, get: () => cur };
|
|
316
|
+
},
|
|
317
|
+
json(name, vd, value, cw) {
|
|
318
|
+
const t = el('input');
|
|
319
|
+
t.type = 'text'; t.spellcheck = false; t.title = 'JSON value';
|
|
320
|
+
let cur = value;
|
|
321
|
+
t.onchange = () => {
|
|
322
|
+
try { cur = JSON.parse(t.value); } catch { t.classList.add('bad'); return; }
|
|
323
|
+
t.classList.remove('bad');
|
|
324
|
+
queueVar(name, cur, true);
|
|
325
|
+
};
|
|
326
|
+
cw.append(t);
|
|
327
|
+
const set = (v) => { cur = v; t.value = JSON.stringify(v) ?? ''; t.classList.remove('bad'); };
|
|
328
|
+
set(value);
|
|
329
|
+
return { set, get: () => cur, typing: () => document.activeElement === t };
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
function controlRow(name, vd, value) {
|
|
334
|
+
const declared = !!vd;
|
|
335
|
+
vd = vd ?? {};
|
|
336
|
+
const def = declared ? vd.default : undefined;
|
|
337
|
+
if (value === undefined) value = def !== undefined ? def : blank(vd);
|
|
338
|
+
const label = (typeof vd.label === 'string' && vd.label) || name;
|
|
339
|
+
const wrap = el('div', 'ctl'), cw = el('div', 'cw');
|
|
340
|
+
wrap.title = [label !== name ? name : '', declared ? vd.desc : 'not declared in the graph\'s "params"'].filter(Boolean).join(' — ');
|
|
341
|
+
const w = WIDGETS[kindOf(declared ? vd : null, value)](name, vd, value, cw);
|
|
342
|
+
const reset = el('button', 'ib' + (def === undefined ? ' gone' : ''), '↺');
|
|
343
|
+
if (def !== undefined) reset.title = `reset ${name} to ${short(def)}`;
|
|
344
|
+
reset.onclick = () => { w.set(def); queueVar(name, def, true); reset.blur(); };
|
|
345
|
+
wrap.append(el('span', 'cl', label), cw, reset);
|
|
346
|
+
const r = {
|
|
347
|
+
def,
|
|
348
|
+
set(v) { w.set(v); r.sync(); },
|
|
349
|
+
get: () => w.get(),
|
|
350
|
+
typing: () => w.typing?.() ?? false,
|
|
351
|
+
sync() { reset.disabled = def === undefined || same(w.get(), def); },
|
|
352
|
+
};
|
|
353
|
+
r.sync();
|
|
354
|
+
ctl.rows.set(name, r);
|
|
355
|
+
ctl.values.set(name, value);
|
|
356
|
+
return wrap;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function render(g, issued = performance.now()) {
|
|
360
|
+
const params = g?.params ?? {}, vars = g?.vars ?? {};
|
|
361
|
+
const { declared, other } = controlList(params, vars);
|
|
362
|
+
const sig = g ? JSON.stringify([g.name, params, other.map((k) => [k, jsonKind(vars[k]), Array.isArray(vars[k]) ? vars[k].length : 0])]) : '';
|
|
363
|
+
if (sig === ctl.sig) return applyValues(vars, issued);
|
|
364
|
+
// the set of params changed: rebuild, but not under a slider being dragged
|
|
365
|
+
if (ctl.active && g?.name === ctl.graph) { ctl.deferred = [g, issued]; return; }
|
|
366
|
+
if (ctl.graph !== (g?.name ?? null)) {
|
|
367
|
+
if (ctl.graph) flushVars();
|
|
368
|
+
ctl.pending = {}; ctl.unacked.clear(); ctl.ackAt.clear(); ctl.begin.clear(); ctl.values.clear();
|
|
369
|
+
}
|
|
370
|
+
ctl.graph = g?.name ?? null; ctl.sig = sig; ctl.rows.clear();
|
|
371
|
+
box.replaceChildren();
|
|
372
|
+
count.textContent = declared.length + other.length || '';
|
|
373
|
+
if (!declared.length && !other.length) { box.append(el('div', 'none', g ? 'none — this graph exposes no vars' : 'none')); return; }
|
|
374
|
+
for (const k of declared) box.append(controlRow(k, params[k] ?? {}, vars[k]));
|
|
375
|
+
if (other.length) {
|
|
376
|
+
box.append(el('div', 'sub', declared.length ? 'other vars' : 'vars (none declared in "params")'));
|
|
377
|
+
for (const k of other) box.append(controlRow(k, null, vars[k]));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Server state into the controls, except where the person has edits on the way or is dragging/typing. */
|
|
382
|
+
function applyValues(vars, issued) {
|
|
383
|
+
for (const [k, r] of ctl.rows) {
|
|
384
|
+
if (k in ctl.pending || ctl.unacked.has(k) || (ctl.ackAt.get(k) ?? -1e9) > issued || ctl.active === k || r.typing()) continue;
|
|
385
|
+
const v = k in vars ? vars[k] : r.def;
|
|
386
|
+
r.set(v);
|
|
387
|
+
ctl.values.set(k, v);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** A change from a control; `final` ends the change (one undo step from its start). `undoing` skips the record. */
|
|
392
|
+
function queueVar(name, value, final = false, undoing = false) {
|
|
393
|
+
if (!undoing && !ctl.begin.has(name)) ctl.begin.set(name, ctl.values.get(name));
|
|
394
|
+
ctl.pending[name] = value;
|
|
395
|
+
ctl.values.set(name, value);
|
|
396
|
+
ctl.rows.get(name)?.sync();
|
|
397
|
+
if (final) {
|
|
398
|
+
const before = ctl.begin.get(name), graph = ctl.graph;
|
|
399
|
+
ctl.begin.delete(name);
|
|
400
|
+
if (!undoing && !same(before, value)) {
|
|
401
|
+
const apply = (v) => {
|
|
402
|
+
if (ctl.graph === graph && ctl.rows.has(name)) { ctl.rows.get(name).set(v); queueVar(name, v, true, true); }
|
|
403
|
+
else api.vars(graph, { [name]: v });
|
|
404
|
+
};
|
|
405
|
+
ctx.undo.push({ label: `${name} = ${short(value)}`, undo: () => apply(before), redo: () => apply(value) });
|
|
406
|
+
}
|
|
407
|
+
return flushVars();
|
|
408
|
+
}
|
|
409
|
+
const wait = 100 - (performance.now() - ctl.lastSend);
|
|
410
|
+
if (wait <= 0) flushVars();
|
|
411
|
+
else if (!ctl.timer) ctl.timer = setTimeout(flushVars, wait);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function flushVars() {
|
|
415
|
+
clearTimeout(ctl.timer); ctl.timer = 0;
|
|
416
|
+
const vars = ctl.pending, graph = ctl.graph;
|
|
417
|
+
if (!graph || !Object.keys(vars).length) return;
|
|
418
|
+
ctl.pending = {};
|
|
419
|
+
const id = ++ctl.seq;
|
|
420
|
+
ctl.lastSend = performance.now();
|
|
421
|
+
for (const k in vars) ctl.unacked.set(k, id);
|
|
422
|
+
const j = await api.vars(graph, vars);
|
|
423
|
+
const now = performance.now();
|
|
424
|
+
for (const k in vars) if (ctl.unacked.get(k) === id) { ctl.unacked.delete(k); ctl.ackAt.set(k, now); }
|
|
425
|
+
if (graph !== ctl.graph) return;
|
|
426
|
+
const what = Object.entries(vars).map(([k, v]) => `${k} = ${short(v)}`).join(', ');
|
|
427
|
+
if (j.ok) ctx.message(`${what} · cooked in ${Math.round(j.ms)} ms`, 'ok');
|
|
428
|
+
else ctx.message(`${j.vars ? `${what} applied, cook failed` : `${what} not applied`} — ${(j.errors ?? []).join('; ') || 'unknown error'}`, 'err');
|
|
429
|
+
if (j.vars) applyValues(j.vars, now);
|
|
430
|
+
}
|
|
431
|
+
return { render, flush: flushVars };
|
|
432
|
+
}
|