@giamme/forge 0.8.0 → 0.9.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/CHANGELOG.md +24 -0
- package/README.md +106 -1766
- package/SKILL.md +3 -2
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +5 -0
- package/package.json +3 -2
- package/references/decompose.md +1 -1
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/harvest.py +1 -1
- package/scripts/forge_dataset/importer.py +1 -1
- package/scripts/forge_fractal/__init__.py +2 -1
- package/ui/README.md +80 -20
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +99 -17
- package/ui/forge_ui/export.py +16 -6
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +95 -7
- package/ui/forge_ui/server.py +10 -1
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +279 -124
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +25 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +68 -32
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +924 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/* forge-ui diff viewer: a task's diff (current, previous, committed, or exactly what one reviewer read), a final
|
|
2
2
|
review's combined diff or a Fractal node's candidate, with each review finding shown under the line it points
|
|
3
3
|
at. Defines window.ForgeDiff.render(host, ctx). All API values enter the DOM as text.
|
|
4
|
-
ctx = { run, spec: {kind: 'task'|'final'|'fractal', task, source, file, name, node}, fetchJSON(path),
|
|
4
|
+
ctx = { run, spec: {kind: 'task'|'final'|'fractal', task, source, file, name, node, finding}, fetchJSON(path),
|
|
5
5
|
rerender(), href(spec) → hash, closeHref }. */
|
|
6
6
|
window.ForgeDiff = (() => {
|
|
7
7
|
'use strict';
|
|
8
8
|
const list = v => Array.isArray(v) ? v : [];
|
|
9
|
-
const SEV = { P0: 'fail', P1: 'conflict', P2: 'timeout', P3: 'unknown' };
|
|
10
9
|
const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
11
10
|
const BUDGET = 4000; // lines drawn before the rest of the files wait for a click
|
|
12
11
|
const cache = new Map(); // url → {data, error, pending, fresh}
|
|
@@ -18,6 +17,7 @@ window.ForgeDiff = (() => {
|
|
|
18
17
|
return n;
|
|
19
18
|
}
|
|
20
19
|
const chip = (text, st) => el('span', text, 'chip st-' + st);
|
|
20
|
+
const sevChip = (text, sev) => el('span', text, 'chip sev-chip sev-' + (/^P[0-3]$/.test(sev) ? sev : 'none'));
|
|
21
21
|
const enc = encodeURIComponent;
|
|
22
22
|
const TASK_FILES = new Set(['current', 'prev', 'committed', 'existing']);
|
|
23
23
|
function urlFor(run, spec) {
|
|
@@ -47,8 +47,9 @@ window.ForgeDiff = (() => {
|
|
|
47
47
|
/* ---------- one finding, as a row under its line or in a list ---------- */
|
|
48
48
|
function findingBox(f, where) {
|
|
49
49
|
const box = el('div', undefined, 'diff-finding sev-' + (f.severity || 'P3'));
|
|
50
|
+
if (f.id) box.dataset.finding = f.id; // a mark on the stage opens the diff at this finding
|
|
50
51
|
const head = el('div', undefined, 'diff-finding-head');
|
|
51
|
-
head.append(
|
|
52
|
+
head.append(sevChip(f.severity || '?', f.severity));
|
|
52
53
|
if (f.status) head.append(el('span', f.status.toLowerCase(), 'tag'));
|
|
53
54
|
const r = f.review || {};
|
|
54
55
|
head.append(el('span', `${r.kind || 'review'}${r.attempt != null ? ' · attempt ' + r.attempt : ''}${r.spec ? ' · ' + r.spec : ''}`, 'muted'));
|
|
@@ -66,7 +67,7 @@ window.ForgeDiff = (() => {
|
|
|
66
67
|
const st = { modified: 'running', added: 'merged', deleted: 'fail', renamed: 'nochanges', copied: 'nochanges' }[f.status] || 'unknown';
|
|
67
68
|
head.append(chip(f.status, st));
|
|
68
69
|
const name = el('a', f.status === 'renamed' || f.status === 'copied' ? `${f.old} → ${f.new}` : pathOf(f), 'mono diff-path');
|
|
69
|
-
name.href = ctx.href({ ...ctx.spec, file: pathOf(f) });
|
|
70
|
+
name.href = ctx.href({ ...ctx.spec, finding: null, file: pathOf(f) });
|
|
70
71
|
head.append(name, el('span', `+${f.additions} −${f.deletions}`, 'diff-stat'));
|
|
71
72
|
if (f.binary) head.append(el('span', 'binary', 'tag'));
|
|
72
73
|
if (f.truncated) head.append(el('span', 'cut short', 'tag'));
|
|
@@ -118,15 +119,20 @@ window.ForgeDiff = (() => {
|
|
|
118
119
|
function render(host, ctx) {
|
|
119
120
|
const run = ctx.run, spec = ctx.spec, url = urlFor(run, spec), entry = load(ctx, url);
|
|
120
121
|
// a poll that brought nothing new leaves thousands of rows alone
|
|
121
|
-
const sig = [url, entry.at || 0, !!entry.error, spec.file, opened.size].join('|');
|
|
122
|
+
const sig = [url, entry.at || 0, !!entry.error, spec.file, spec.finding, opened.size].join('|');
|
|
122
123
|
if (host.diffSig === sig) return;
|
|
123
124
|
host.diffSig = sig;
|
|
125
|
+
// the finding the keyboard is on keeps it through the rebuild (a poll, a fold)
|
|
126
|
+
const had = document.activeElement && host.contains(document.activeElement) && document.activeElement.closest('[data-finding]');
|
|
127
|
+
const keep = had ? had.dataset.finding : null;
|
|
124
128
|
host.replaceChildren();
|
|
125
129
|
const D = entry.data;
|
|
126
130
|
const top = el('div', undefined, 'diff-top');
|
|
127
131
|
const what = spec.kind === 'final' ? 'Final review' : spec.kind === 'fractal' ? `${spec.task} · Fractal node ${spec.node}` : spec.task === 'solo' && run.kind === 'solo' ? 'Solo run' : `Task ${spec.task}`;
|
|
128
132
|
const title = el('h2'); title.append(el('span', 'Diff · ', 'muted'), document.createTextNode(what)); top.append(title);
|
|
129
|
-
const close = el('a',
|
|
133
|
+
const close = el('a', undefined, 'diff-close button'); close.href = ctx.closeHref;
|
|
134
|
+
if (window.ForgeIcons) close.append(window.ForgeIcons.icon('close'));
|
|
135
|
+
close.append(document.createTextNode('Close')); top.append(close);
|
|
130
136
|
host.append(top);
|
|
131
137
|
if (!D) {
|
|
132
138
|
host.append(el('p', entry.error ? (entry.error.snapshot ? 'This diff is not in this export.' : `Could not load this diff (${entry.error.status === 404 ? 'not found' : entry.error.message}).`) : 'Loading the diff…', entry.error ? 'warn' : 'muted'));
|
|
@@ -140,7 +146,7 @@ window.ForgeDiff = (() => {
|
|
|
140
146
|
const cur = spec.kind === 'final' ? spec.name : spec.kind === 'fractal' ? 'candidate' : spec.source || 'current';
|
|
141
147
|
for (const s of sources) {
|
|
142
148
|
const a = el('a', s.label || s.id, 'diff-source'); a.setAttribute('aria-current', String(s.id === cur));
|
|
143
|
-
a.href = spec.kind === 'final' ? ctx.href({ kind: 'final', name: s.id }) : ctx.href({ ...spec, source: s.id, file: null });
|
|
149
|
+
a.href = spec.kind === 'final' ? ctx.href({ kind: 'final', name: s.id }) : ctx.href({ ...spec, source: s.id, file: null, finding: null });
|
|
144
150
|
if (s.verdict) a.append(document.createTextNode(' '), chip(s.verdict, String(s.verdict).toLowerCase()));
|
|
145
151
|
a.title = `${s.bytes != null ? s.bytes + ' bytes' : ''}${s.kind ? ' · ' + s.kind : ''}`;
|
|
146
152
|
bar.append(a);
|
|
@@ -166,13 +172,13 @@ window.ForgeDiff = (() => {
|
|
|
166
172
|
const nav = el('nav', undefined, 'diff-files'); nav.setAttribute('aria-label', 'Files in this diff');
|
|
167
173
|
const ul = el('ul');
|
|
168
174
|
files.forEach((f, fi) => {
|
|
169
|
-
const li = el('li'), a = el('a', undefined, 'diff-file-link'); a.href = ctx.href({ ...spec, file: pathOf(f) }); a.dataset.fi = String(fi);
|
|
175
|
+
const li = el('li'), a = el('a', undefined, 'diff-file-link'); a.href = ctx.href({ ...spec, finding: null, file: pathOf(f) }); a.dataset.fi = String(fi);
|
|
170
176
|
const p = pathOf(f), cut = p.lastIndexOf('/');
|
|
171
177
|
a.append(el('span', cut >= 0 ? p.slice(0, cut + 1) : '', 'diff-dir'), el('span', cut >= 0 ? p.slice(cut + 1) : p, 'diff-base'));
|
|
172
178
|
const stat = el('span', undefined, 'diff-stat'); stat.append(el('span', `+${f.additions}`, 'diff-add'), document.createTextNode(' '), el('span', `−${f.deletions}`, 'diff-del'));
|
|
173
179
|
a.append(stat);
|
|
174
180
|
const ps = byFile.get(fi) || [];
|
|
175
|
-
if (ps.length) { const w = worst(ps.map(x => x.f)); a.append(
|
|
181
|
+
if (ps.length) { const w = worst(ps.map(x => x.f)); a.append(sevChip(`${ps.length}`, w)); a.title = `${ps.length} finding${ps.length === 1 ? '' : 's'}, worst ${w}`; }
|
|
176
182
|
if (spec.file && spec.file === p) a.setAttribute('aria-current', 'true');
|
|
177
183
|
li.append(a); ul.append(li);
|
|
178
184
|
});
|
|
@@ -207,7 +213,20 @@ window.ForgeDiff = (() => {
|
|
|
207
213
|
fileEls[i].scrollIntoView({ block: 'start' }); e.preventDefault();
|
|
208
214
|
}
|
|
209
215
|
};
|
|
210
|
-
|
|
216
|
+
const flash = n => { n.classList.add('flash'); setTimeout(() => n.classList.remove('flash'), 1600); };
|
|
217
|
+
const back = keep && order.find(n => n.dataset.finding === keep);
|
|
218
|
+
if (back) back.focus({ preventScroll: true });
|
|
219
|
+
if (spec.finding) {
|
|
220
|
+
const target = order.find(n => n.dataset.finding === spec.finding);
|
|
221
|
+
if (target && host.dataset.scrolledTo !== url + '\n#' + spec.finding) {
|
|
222
|
+
host.dataset.scrolledTo = url + '\n#' + spec.finding; fIdx = order.indexOf(target);
|
|
223
|
+
requestAnimationFrame(() => {
|
|
224
|
+
const live = target.isConnected ? target : [...host.querySelectorAll('.diff-finding[id]')].find(n => n.dataset.finding === spec.finding);
|
|
225
|
+
if (!live) return;
|
|
226
|
+
live.scrollIntoView({ block: 'center' }); flash(live); live.focus({ preventScroll: true });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
} else if (spec.file) {
|
|
211
230
|
const target = fileEls.find(s => s.dataset.path === spec.file);
|
|
212
231
|
if (target && host.dataset.scrolledTo !== url + '\n' + spec.file) { host.dataset.scrolledTo = url + '\n' + spec.file; requestAnimationFrame(() => target.scrollIntoView({ block: 'start' })); }
|
|
213
232
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/* forge-ui finding beacons: every review finding as a small mark over the stage. Its shape is its severity
|
|
2
|
+
(P0 spike, P1 diamond, P2 cone, P3 pin, no severity a ring), drawn like the blocks: a hairline in its --sev-*
|
|
3
|
+
colour over a translucent fill of the same, on a short post, its foot on the block it is about; a confirmed
|
|
4
|
+
finding is full size and fuller, a plausible one smaller and paler. Seen from straight above (the top-down
|
|
5
|
+
projection) the marks lie flat, so each keeps its silhouette. scene3d.js says where each one stands (an anchor
|
|
6
|
+
that follows its block as it lifts, opens or travels); this module draws them, one instanced mesh per shape
|
|
7
|
+
with the engine's hairline outline, and picks them. Marks stand still, so an idle stage draws no frames; one
|
|
8
|
+
that arrives (a new finding on a poll, one a replay reaches, one moving onto an opened task) drops in. */
|
|
9
|
+
export const SHAPES = ['P0', 'P1', 'P2', 'P3', 'none'];
|
|
10
|
+
const MAX = 512; // marks per shape
|
|
11
|
+
const SIZE = 18; // plan units per unit of the shapes below: a mark stands ~40 tall
|
|
12
|
+
const MIN_PX = 28; // a mark stands at least this tall on screen while the plate is near
|
|
13
|
+
const FAR_PPU = .5; // screen px per plan unit below which that floor shrinks with the plate, to half
|
|
14
|
+
const TALL = 2.2; // a mark's height in shape units (post and head)
|
|
15
|
+
|
|
16
|
+
export function createBeacons(E, { reduce = false } = {}) {
|
|
17
|
+
const { THREE, W, TS } = E;
|
|
18
|
+
const POST = .3; // the post's height; every head sits on it
|
|
19
|
+
const post = () => new THREE.CylinderGeometry(.035, .035, POST, 6).translate(0, POST / 2, 0);
|
|
20
|
+
const geo = {
|
|
21
|
+
P0: mergeTwo(THREE, new THREE.ConeGeometry(.42, 1.6, 4).translate(0, POST + .8, 0), post()),
|
|
22
|
+
P1: mergeTwo(THREE, new THREE.OctahedronGeometry(.62).scale(1, 1.3, 1).translate(0, POST + .8, 0), post()),
|
|
23
|
+
P2: mergeTwo(THREE, new THREE.ConeGeometry(.62, 1.2, 16).rotateX(Math.PI).translate(0, POST + .6, 0), post()),
|
|
24
|
+
P3: mergeTwo(THREE, new THREE.SphereGeometry(.5, 18, 12).translate(0, POST + .48, 0), post()),
|
|
25
|
+
none: mergeTwo(THREE, new THREE.TorusGeometry(.5, .12, 8, 24).translate(0, POST + .56, 0), post()),
|
|
26
|
+
};
|
|
27
|
+
const group = new THREE.Group(); group.name = 'beacons';
|
|
28
|
+
const meshes = {}, outlines = {};
|
|
29
|
+
for (const k of SHAPES) {
|
|
30
|
+
const m = new THREE.InstancedMesh(geo[k], new THREE.MeshBasicMaterial({ toneMapped: false, transparent: true, depthWrite: false }), MAX);
|
|
31
|
+
m.count = 0; m.frustumCulled = false; m.userData = { beacons: k }; m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
32
|
+
m.setColorAt(0, new THREE.Color()); // allocates the per-instance colours
|
|
33
|
+
const o = new THREE.InstancedMesh(geo[k], E.outlineInstanced(), MAX);
|
|
34
|
+
o.material.side = THREE.BackSide; o.count = 0; o.frustumCulled = false; o.instanceMatrix = m.instanceMatrix; o.raycast = () => {};
|
|
35
|
+
group.add(m, o); meshes[k] = m; outlines[k] = o;
|
|
36
|
+
}
|
|
37
|
+
let unit = SIZE; // plan units per shape unit this frame (marks grow when far away)
|
|
38
|
+
let items = [], byShape = Object.fromEntries(SHAPES.map(k => [k, []])), seen = null, visible = true, hot = null, flat = false;
|
|
39
|
+
const FLAT = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2); // the head toward the top of the screen
|
|
40
|
+
const M4 = new THREE.Matrix4(), Q = new THREE.Quaternion(), S = new THREE.Vector3(), P = new THREE.Vector3(), C = new THREE.Color();
|
|
41
|
+
const ez = t => 1 - Math.pow(1 - t, 3);
|
|
42
|
+
|
|
43
|
+
// items: [{key, sev: 'P0'..'P3'|null, plausible, anchor() → [x, y, z, scale?] in plan units | null (hidden), data}]
|
|
44
|
+
function set(next) {
|
|
45
|
+
const now = performance.now(), first = seen === null;
|
|
46
|
+
seen = seen || new Set();
|
|
47
|
+
items = next.map(it => ({ ...it, shape: SHAPES.includes(it.sev) ? it.sev : 'none', born: first || seen.has(it.key) || reduce ? -Infinity : now,
|
|
48
|
+
phase: (hash(it.key) % 628) / 100 }));
|
|
49
|
+
for (const it of items) seen.add(it.key);
|
|
50
|
+
byShape = Object.fromEntries(SHAPES.map(k => [k, []]));
|
|
51
|
+
for (const it of items) if (byShape[it.shape].length < MAX) byShape[it.shape].push(it);
|
|
52
|
+
paint();
|
|
53
|
+
}
|
|
54
|
+
// the fill is the severity's colour seen through: fuller when confirmed, paler when plausible; the hairline is the colour itself
|
|
55
|
+
function paint() {
|
|
56
|
+
const bg = E.BG_COL, light = E.theme === 'light';
|
|
57
|
+
for (const k of SHAPES) {
|
|
58
|
+
const m = meshes[k], list = byShape[k];
|
|
59
|
+
list.forEach((it, i) => {
|
|
60
|
+
C.copy(E.sevColor(it.sev || 'none')).lerp(bg, it.plausible ? .7 : .45);
|
|
61
|
+
if (hot === it.key) C.lerp(E.INKHI, .3);
|
|
62
|
+
m.setColorAt(i, C);
|
|
63
|
+
});
|
|
64
|
+
if (m.instanceColor) m.instanceColor.needsUpdate = true;
|
|
65
|
+
m.material.opacity = light ? .82 : .75;
|
|
66
|
+
outlines[k].material.uniforms.color.value.copy(E.sevColor(k));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// every frame: each mark where its anchor is; one that just arrived falls in. Returns whether anything moves.
|
|
70
|
+
function tick(now) {
|
|
71
|
+
if (!visible) { for (const k of SHAPES) meshes[k].count = outlines[k].count = 0; return false; }
|
|
72
|
+
let moving = false;
|
|
73
|
+
const ppu = Math.max(1e-6, E.pxPerUnit()), least = MIN_PX * Math.min(1, Math.max(.5, ppu / FAR_PPU)) / (ppu * TALL);
|
|
74
|
+
unit = Math.max(SIZE, least);
|
|
75
|
+
for (const k of SHAPES) {
|
|
76
|
+
const m = meshes[k], list = byShape[k];
|
|
77
|
+
let n = 0;
|
|
78
|
+
for (const it of list) {
|
|
79
|
+
const a = it.anchor();
|
|
80
|
+
if (!a) { it.hidden = true; continue; }
|
|
81
|
+
if (it.hidden && !reduce) it.born = now; // shown again: it arrives
|
|
82
|
+
it.hidden = false;
|
|
83
|
+
const age = (now - it.born) / 650, drop = age < 1 ? 1 - ez(Math.max(0, age)) : 0;
|
|
84
|
+
if (drop > 0) moving = true;
|
|
85
|
+
const sc = unit * (a[3] || 1) * (it.plausible ? .8 : 1) * (hot === it.key ? 1.2 : 1);
|
|
86
|
+
const lie = flat ? .7 * sc : 0; // lying flat, it floats its own thickness over the roof
|
|
87
|
+
P.copy(W(a[0], a[1], a[2] + drop * 60 + lie));
|
|
88
|
+
it.head = flat ? [a[0], a[1] - 1.15 * sc, a[2] + lie] : [a[0], a[1], a[2] + drop * 60 + 1.15 * sc]; // where its head is drawn, in plan units
|
|
89
|
+
Q.setFromAxisAngle(UP, it.phase); if (flat) Q.premultiply(FLAT);
|
|
90
|
+
S.setScalar(sc * TS);
|
|
91
|
+
M4.compose(P, Q, S); m.setMatrixAt(n, M4); it.slot = n; n++;
|
|
92
|
+
}
|
|
93
|
+
m.count = outlines[k].count = n; m.instanceMatrix.needsUpdate = true;
|
|
94
|
+
m.boundingSphere = null; // three.js keeps the first one it computes: picking needs today's
|
|
95
|
+
}
|
|
96
|
+
return moving;
|
|
97
|
+
}
|
|
98
|
+
const UP = new THREE.Vector3(0, 1, 0);
|
|
99
|
+
// the item under the pointer, or null
|
|
100
|
+
function pick(cx, cy) {
|
|
101
|
+
if (!visible) return null;
|
|
102
|
+
const h = E.pickHit(cx, cy, SHAPES.map(k => meshes[k]).filter(m => m.count));
|
|
103
|
+
if (!h || h.instanceId == null) return null;
|
|
104
|
+
const k = h.object.userData.beacons;
|
|
105
|
+
return byShape[k].find(it => it.slot === h.instanceId && it.anchor()) || null;
|
|
106
|
+
}
|
|
107
|
+
function setHot(key) { if (key !== hot) { hot = key; paint(); } }
|
|
108
|
+
E.themeHooks.add(() => paint());
|
|
109
|
+
return {
|
|
110
|
+
group, set, tick, pick, setHot, repaint: paint,
|
|
111
|
+
get count() { return items.length; },
|
|
112
|
+
get unit() { return unit; }, // what scene3d spaces marks by
|
|
113
|
+
get items() { return items; },
|
|
114
|
+
setVisible(v) { visible = !!v; group.visible = visible; },
|
|
115
|
+
setFlat(v) { flat = !!v; }, // the top-down projection
|
|
116
|
+
get flat() { return flat; },
|
|
117
|
+
dispose() { for (const k of SHAPES) { meshes[k].dispose(); outlines[k].dispose(); geo[k].dispose(); } },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hash(s) { let h = 7; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h; }
|
|
122
|
+
// two geometries as one (positions and normals), so the pin is one instance; one without an index (the
|
|
123
|
+
// octahedron) draws its vertices in order
|
|
124
|
+
function mergeTwo(THREE, a, b) {
|
|
125
|
+
const out = new THREE.BufferGeometry(), na = a.attributes.position.count, nb = b.attributes.position.count;
|
|
126
|
+
const cat = (x, y, n) => { const r = new Float32Array(x.length + y.length); r.set(x); r.set(y, x.length); return new THREE.BufferAttribute(r, n); };
|
|
127
|
+
out.setAttribute('position', cat(a.attributes.position.array, b.attributes.position.array, 3));
|
|
128
|
+
out.setAttribute('normal', cat(a.attributes.normal.array, b.attributes.normal.array, 3));
|
|
129
|
+
const order = (g, n) => g.index ? Array.from(g.index.array) : Array.from({ length: n }, (_, i) => i);
|
|
130
|
+
const ia = order(a, na), ib = order(b, nb).map(i => i + na);
|
|
131
|
+
out.setIndex(ia.concat(ib));
|
|
132
|
+
a.dispose(); b.dispose();
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
@@ -240,9 +240,9 @@
|
|
|
240
240
|
|
|
241
241
|
if (t.verdict === 'PASS' || t.verdict === 'FAIL') {
|
|
242
242
|
var pass = t.verdict === 'PASS';
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
// a drawn tick or cross, in the verdict's colour
|
|
244
|
+
el('path', { 'class': 'fg-verdict' + (pass ? ' fg-pass' : ''), d: pass ? 'M' + (NODE_W - 20) + ' 12l4 4 7-8' : 'M' + (NODE_W - 19) + ' 8l8 8m0-8l-8 8',
|
|
245
|
+
fill: 'none', 'stroke-width': 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, g);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
function select() { if (typeof options.onSelect === 'function') options.onSelect(t.id); }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/* forge-ui icons: one hairline set drawn for forge-ui on a 24-unit grid, 1.5 stroke, round joins, currentColor.
|
|
2
|
+
States come from CSS (colour, opacity); an icon never carries its own colour. Built with createElementNS: the page CSP
|
|
3
|
+
refuses markup parsed from strings. Also the model name plate, the one way a model is shown everywhere.
|
|
4
|
+
window.ForgeIcons = { icon(name, title?) → <svg class="icon icon-<name>">, modelPlate(spec) → <span class="model-plate">, names } */
|
|
5
|
+
window.ForgeIcons = (() => {
|
|
6
|
+
'use strict';
|
|
7
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
8
|
+
const P = d => ['path', { d }], C = (cx, cy, r) => ['circle', { cx, cy, r }], R = (x, y, width, height, rx = 1.5) => ['rect', { x, y, width, height, rx }];
|
|
9
|
+
const SET = {
|
|
10
|
+
map: [R(3, 4, 18, 16, 2), P('M7 9h4v3H7zM13 9h4v6h-4zM7 14h4v2H7')],
|
|
11
|
+
cloud: [C(12, 12, 2.2), P('M3.5 12c0-2.5 3.8-4.5 8.5-4.5s8.5 2 8.5 4.5-3.8 4.5-8.5 4.5S3.5 14.5 3.5 12Z'), C(18.5, 9.2, 1)],
|
|
12
|
+
models: [P('M5 20V11M10 20V5M15 20v-7M20 20V8M3 20h18')],
|
|
13
|
+
search: [C(10.5, 10.5, 6), P('m15 15 5.5 5.5')],
|
|
14
|
+
bell: [P('M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 1.5h-15Z'), P('M10 20.5a2.2 2.2 0 0 0 4 0')],
|
|
15
|
+
live: [C(12, 12, 2), P('M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14')],
|
|
16
|
+
sun: [C(12, 12, 3.8), P('M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4')],
|
|
17
|
+
moon: [P('M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10Z')],
|
|
18
|
+
system: [R(3, 4.5, 18, 12, 1.5), P('M9 20h6M12 16.5V20')],
|
|
19
|
+
immersive: [R(3, 4, 18, 16, 2), R(5.5, 6.5, 4, 8, 1), R(14.5, 6.5, 4, 11, 1)],
|
|
20
|
+
workbench: [R(3, 4, 18, 16, 2), P('M8 4v16M16 4v16M8 15h8')],
|
|
21
|
+
'panel-left': [R(3, 4, 18, 16, 2), P('M9 4v16')],
|
|
22
|
+
'panel-right': [R(3, 4, 18, 16, 2), P('M15 4v16')],
|
|
23
|
+
'panel-bottom': [R(3, 4, 18, 16, 2), P('M3 14h18')],
|
|
24
|
+
close: [P('M6 6l12 12M18 6 6 18')],
|
|
25
|
+
'chevron-down': [P('m6 9 6 6 6-6')],
|
|
26
|
+
'chevron-up': [P('m6 15 6-6 6 6')],
|
|
27
|
+
'chevron-right': [P('m9 6 6 6-6 6')],
|
|
28
|
+
'chevron-left': [P('m15 6-6 6 6 6')],
|
|
29
|
+
play: [P('M8 5.5v13l10-6.5Z')],
|
|
30
|
+
pause: [P('M8 5.5v13M16 5.5v13')],
|
|
31
|
+
plan: [P('M4 5v14M10 5v14M16 5v14'), R(5.5, 7, 3, 3, .8), R(11.5, 9, 3, 3, .8), R(11.5, 14, 3, 3, .8), R(17.5, 11, 3, 3, .8)],
|
|
32
|
+
exec: [P('M3 5v14'), R(5, 6, 8, 3, 1), R(9, 10.5, 10, 3, 1), R(6, 15, 6, 3, 1)],
|
|
33
|
+
files: [P('M6 3.5h8l4 4v13H6Z'), P('M14 3.5v4h4M9 12h6M9 15.5h6')],
|
|
34
|
+
cube: [P('M12 3 20 7.5v9L12 21l-8-4.5v-9Z'), P('M4 7.5 12 12l8-4.5M12 12v9')],
|
|
35
|
+
square: [R(5, 5, 14, 14, 1.5), P('M5 12h14M12 5v14')],
|
|
36
|
+
layers: [P('m12 4 9 4.5-9 4.5-9-4.5Z'), P('m3 12.5 9 4.5 9-4.5'), P('m3 16.5 9 4.5 9-4.5')],
|
|
37
|
+
fit: [P('M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5'), R(9, 9, 6, 6, 1)],
|
|
38
|
+
persp: [P('M3.5 19 8.5 5h7l5 14Z'), P('M10.5 5 9.5 19M13.5 5l1 14M6 12h12')],
|
|
39
|
+
iso: [P('M12 4.5 18.5 8v8L12 19.5 5.5 16V8Z'), P('M5.5 8 12 11.5 18.5 8M12 11.5v8')],
|
|
40
|
+
top: [R(4, 4, 16, 16, 2), R(8.5, 8.5, 7, 7, 1)],
|
|
41
|
+
reset: [P('M4.5 12a7.5 7.5 0 1 0 2.2-5.3'), P('M4.5 4.5v3.5H8')],
|
|
42
|
+
'zoom-in': [C(11, 11, 6.5), P('m16 16 4.5 4.5M8.5 11h5M11 8.5v5')],
|
|
43
|
+
'zoom-out': [C(11, 11, 6.5), P('m16 16 4.5 4.5M8.5 11h5')],
|
|
44
|
+
fullscreen: [P('M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5')],
|
|
45
|
+
legend: [C(6, 7, 1.5), C(6, 12, 1.5), C(6, 17, 1.5), P('M10 7h9M10 12h9M10 17h6')],
|
|
46
|
+
help: [C(12, 12, 9), P('M9.5 9.5a2.6 2.6 0 0 1 5 1c0 1.8-2.5 2.2-2.5 3.8'), C(12, 17, .6)],
|
|
47
|
+
sort: [P('M8 5v14M5 8l3-3 3 3M16 19V5M13 16l3 3 3-3')],
|
|
48
|
+
'sort-asc': [P('M12 19V5M7 10l5-5 5 5')],
|
|
49
|
+
'sort-desc': [P('M12 5v14M7 14l5 5 5-5')],
|
|
50
|
+
columns: [R(3, 4, 18, 16, 2), P('M9 4v16M15 4v16')],
|
|
51
|
+
grip: [C(9, 7, .9), C(15, 7, .9), C(9, 12, .9), C(15, 12, .9), C(9, 17, .9), C(15, 17, .9)],
|
|
52
|
+
download: [P('M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14')],
|
|
53
|
+
flag: [P('M6 21V4M6 4.5h11l-2.5 4 2.5 4H6')],
|
|
54
|
+
check: [P('m5 12.5 4.5 4.5L19 7.5')],
|
|
55
|
+
cross: [P('M6 6l12 12M18 6 6 18')],
|
|
56
|
+
filter: [P('M4 5h16l-6 7.5V19l-4-2v-4.5Z')],
|
|
57
|
+
arrow: [P('M5 12h14M13 6l6 6-6 6')],
|
|
58
|
+
eye: [P('M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z'), C(12, 12, 3)],
|
|
59
|
+
route: [C(5, 17, 2), C(19, 7, 2), P('M7 17h4l2-10h4')],
|
|
60
|
+
keyboard: [R(2.5, 6, 19, 12, 2), P('M6 10h1M9.5 10h1M13 10h1M16.5 10h1M7 14h10')],
|
|
61
|
+
chat: [P('M4 5.5h16v10.5H10l-4.5 3.5V16H4Z'), P('M8 9.5h8M8 12.5h5')],
|
|
62
|
+
terminal: [R(3, 4.5, 18, 15, 2), P('m7 9.5 3 2.5-3 2.5M12.5 15H17')],
|
|
63
|
+
pencil: [P('M4.5 19.5 5.5 15 15.5 5a2.1 2.1 0 0 1 3 3l-10 10Z'), P('m13.5 7 3 3')],
|
|
64
|
+
tool: [P('M14.5 5.5a4 4 0 0 0-5 5L4.5 15.5a2.1 2.1 0 0 0 3 3l5-5a4 4 0 0 0 5-5l-2.4 2.4-2.4-.6-.6-2.4Z')],
|
|
65
|
+
thought: [P('M7 15.5a5 5 0 1 1 8.5-3.5 4 4 0 0 1 2 7.5H8a3 3 0 0 1-1-4Z'), C(6, 20.5, .8)],
|
|
66
|
+
};
|
|
67
|
+
function icon(name, title) {
|
|
68
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
69
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
70
|
+
svg.setAttribute('class', 'icon icon-' + name);
|
|
71
|
+
svg.setAttribute('focusable', 'false');
|
|
72
|
+
if (title) { const t = document.createElementNS(NS, 'title'); t.textContent = title; svg.append(t); svg.setAttribute('role', 'img'); }
|
|
73
|
+
else svg.setAttribute('aria-hidden', 'true');
|
|
74
|
+
for (const [tag, attrs] of SET[name] || SET.square) {
|
|
75
|
+
const n = document.createElementNS(NS, tag);
|
|
76
|
+
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
|
|
77
|
+
svg.append(n);
|
|
78
|
+
}
|
|
79
|
+
return svg;
|
|
80
|
+
}
|
|
81
|
+
// a model's name plate: the alias engraved, the effort beside it ("luna:high" → LUNA | high); a bare id stays whole
|
|
82
|
+
function modelPlate(spec) {
|
|
83
|
+
const s = String(spec || '').trim(), i = s.lastIndexOf(':'), out = document.createElement('span');
|
|
84
|
+
out.className = 'model-plate'; out.title = s;
|
|
85
|
+
if (!s) { out.textContent = '—'; out.className = 'muted'; return out; }
|
|
86
|
+
const name = document.createElement('span'); name.className = 'mp-name'; name.textContent = i > 0 ? s.slice(0, i) : s; out.append(name);
|
|
87
|
+
if (i > 0 && i < s.length - 1) { // the colon stays in the text, for copying and screen readers
|
|
88
|
+
const c = document.createElement('span'); c.className = 'visually-hidden'; c.textContent = ':';
|
|
89
|
+
const e = document.createElement('span'); e.className = 'mp-effort'; e.textContent = s.slice(i + 1); out.append(c, e);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
return { icon, modelPlate, names: Object.keys(SET) };
|
|
94
|
+
})();
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
} }
|
|
14
14
|
</script>
|
|
15
15
|
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
|
16
|
+
<link rel="preload" href="D-DINCondensed-Bold.woff2" as="font" type="font/woff2" crossorigin>
|
|
16
17
|
<link rel="stylesheet" href="style.css">
|
|
18
|
+
<script src="theme.js"></script>
|
|
19
|
+
<script src="icons.js" defer></script>
|
|
20
|
+
<script src="datatable.js" defer></script>
|
|
17
21
|
<script src="runmodel.js" defer></script>
|
|
18
22
|
<script src="cloudmodel.js" defer></script>
|
|
19
23
|
<script src="modelstats.js" defer></script>
|
|
@@ -21,6 +25,7 @@
|
|
|
21
25
|
<script src="gantt.js" defer></script>
|
|
22
26
|
<script src="panel.js" defer></script>
|
|
23
27
|
<script src="diffview.js" defer></script>
|
|
28
|
+
<script src="convo.js" defer></script>
|
|
24
29
|
<script src="files.js" defer></script>
|
|
25
30
|
<script src="map3d.js" defer></script>
|
|
26
31
|
<script src="cloudui.js" defer></script>
|
|
@@ -29,23 +34,30 @@
|
|
|
29
34
|
<script src="notify.js" defer></script>
|
|
30
35
|
<script src="app.js" defer></script>
|
|
31
36
|
</head>
|
|
32
|
-
<body>
|
|
37
|
+
<body data-shell="immersive" data-runs="open" data-insp="open" data-tasks="open">
|
|
38
|
+
<script src="shell.js"></script>
|
|
33
39
|
<header class="topbar">
|
|
34
|
-
<a class="brand" href="#/">forge-ui</a>
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
<button
|
|
41
|
-
<
|
|
40
|
+
<a class="brand" href="#/" title="forge-ui · local run viewer, read only"><span class="brand-plate">forge</span><span class="brand-sub">ui</span></a>
|
|
41
|
+
<nav class="desk-nav" aria-label="Views">
|
|
42
|
+
<a class="top-link" href="#/">Map</a>
|
|
43
|
+
<a class="top-link" href="#/cloud">Cloud</a>
|
|
44
|
+
<a class="top-link" href="#/models">Models</a>
|
|
45
|
+
</nav>
|
|
46
|
+
<button class="top-search" type="button" data-jump title="Jump to a run, task, project, model or command (/)"><span class="top-search-label"><span>Search</span></span> <kbd>Ctrl K</kbd></button>
|
|
47
|
+
<div class="desk-tools">
|
|
48
|
+
<button id="bell" class="bell" type="button" aria-label="Notifications"><span class="bell-count" hidden></span></button>
|
|
49
|
+
<label class="refresh-control" title="Poll the runs for changes"><input id="refresh" type="checkbox" checked> Live</label>
|
|
50
|
+
</div>
|
|
42
51
|
</header>
|
|
43
52
|
<div id="errors" role="status" aria-live="polite" hidden></div>
|
|
44
53
|
<div class="layout">
|
|
45
|
-
<aside class="sidebar" aria-label="Runs">
|
|
46
|
-
<div class="
|
|
47
|
-
<
|
|
48
|
-
|
|
54
|
+
<aside class="sidebar module" aria-label="Runs" data-overlay>
|
|
55
|
+
<div class="module-head"><h1 class="module-title">Runs</h1><span id="run-count" class="module-count"></span><span class="module-tools"></span></div>
|
|
56
|
+
<div class="runs-tools">
|
|
57
|
+
<label class="visually-hidden" for="filter">Filter by goal, repo, ID, path or model</label>
|
|
58
|
+
<input id="filter" type="search" placeholder="Filter runs…" autocomplete="off">
|
|
59
|
+
<select id="run-sort" aria-label="Sort the runs"><option value="updated">Latest</option><option value="state">State</option><option value="repo">Repo</option></select>
|
|
60
|
+
</div>
|
|
49
61
|
<nav id="run-list" aria-label="Choose a run"><p class="empty">Loading runs…</p></nav>
|
|
50
62
|
</aside>
|
|
51
63
|
<main id="main" tabindex="-1"><p class="empty">Loading…</p></main>
|
|
@@ -41,14 +41,14 @@ const W = (x, y, z) => new THREE.Vector3(x * TS, z * TS, y * TS); // plan x ri
|
|
|
41
41
|
const MONO3 = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
|
|
42
42
|
const NOISE = `float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }`;
|
|
43
43
|
const CW = .61; // monospace advance, in em
|
|
44
|
-
//
|
|
44
|
+
// the iso figures' light and dark themes, tinted to forge-ui's enamel panel; style.css's --stage-* tokens win when set
|
|
45
45
|
const THEMES = {
|
|
46
|
-
dark: { bg:
|
|
47
|
-
light: { bg:
|
|
46
|
+
dark: { bg: 0x0e1210, body: 0x161c19, line: 0x4f5b54, major: 0x36413b, edge: 0x242c28, recess: 0x0b0e0c, sky: 0xe4efe6, ground: 0x070908, hemi: .7, amb: 0, sun: .7, fill: .2, fadeMin: .45 },
|
|
47
|
+
light: { bg: 0xc6cec4, body: 0xf3f5f0, line: 0x454f48, major: 0x6f7a72, edge: 0x9aa49b, recess: 0xdce1d9, sky: 0xffffff, ground: 0xdfe5dc, hemi: .9, amb: 1.6, sun: .35, fill: .15, fadeMin: .85 },
|
|
48
48
|
};
|
|
49
49
|
const PAL = {
|
|
50
|
-
light: { line: '#
|
|
51
|
-
dark: { line: '#
|
|
50
|
+
light: { line: '#6f7a72', detail: '#a9b2a8', recess: '#dce1d9', ink: '#2c342f', inkHi: '#111613', glassOff: '#e3e7df', glassOn: '#f6ead0', bg: '#f3f5f0' },
|
|
51
|
+
dark: { line: '#4f5b54', detail: '#28302c', recess: '#0b0e0c', ink: '#86928b', inkHi: '#e4e8e2', glassOff: '#121714', glassOn: '#221d0e', bg: '#0e1210' },
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
/* ---------- text: wrap and fit monospace print into a box (archivision's figure.js) ---------- */
|
|
@@ -287,7 +287,7 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
287
287
|
function palette() {
|
|
288
288
|
const C = PAL[theme], light = theme === 'light', hi = TMPC.copy(accent).lerp(new THREE.Color(light ? 0x000000 : 0xffffff), light ? .3 : .42);
|
|
289
289
|
PALC = { ...C, acc: '#' + accent.getHexString(), accHi: '#' + hi.getHexString(), light };
|
|
290
|
-
INKHI.set(C.inkHi); DETAIL.set(
|
|
290
|
+
INKHI.set(C.inkHi); DETAIL.set(C.detail); GLASSON.set(C.glassOn);
|
|
291
291
|
}
|
|
292
292
|
palette();
|
|
293
293
|
const setOp = (m, o) => { m.opacity = o; const t = o < .999; if (m.transparent !== t) { m.transparent = t; m.needsUpdate = true; } };
|
|
@@ -391,18 +391,25 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
391
391
|
const probe = document.createElement('span');
|
|
392
392
|
probe.className = 'stage-probe'; probe.setAttribute('aria-hidden', 'true'); host.append(probe);
|
|
393
393
|
const COLORS = new Map();
|
|
394
|
+
// a custom property's colour, resolved by the browser (tokens are light-dark() pairs, so the raw value is not a colour)
|
|
394
395
|
function cssColor(className, prop) {
|
|
395
396
|
const key = className + '|' + prop;
|
|
396
397
|
if (!COLORS.has(key)) {
|
|
397
398
|
probe.className = 'stage-probe ' + className;
|
|
398
|
-
|
|
399
|
+
probe.style.setProperty('color', `var(${prop})`);
|
|
400
|
+
const cs = getComputedStyle(probe), v = cs.getPropertyValue(prop).trim() ? cs.color : '';
|
|
399
401
|
let c = null; try { if (v) c = new THREE.Color(v); } catch (_) { c = null; }
|
|
400
402
|
COLORS.set(key, c);
|
|
401
403
|
}
|
|
402
404
|
return COLORS.get(key);
|
|
403
405
|
}
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
+
// a state's lamp as lit on the plate (--st-lit), and as ink for print on it (--st)
|
|
407
|
+
const stClass = s => 'st-' + String(s || 'unknown').toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
408
|
+
const statusColor = s => cssColor(stClass(s), '--st-lit') || cssColor(stClass(s), '--st') || new THREE.Color('#888888');
|
|
409
|
+
const statusHex = s => '#' + (cssColor(stClass(s), '--st') || statusColor(s)).getHexString();
|
|
410
|
+
// a finding severity's colour (--sev on .sev-P0…P3): a ramp of its own, never a status lamp
|
|
411
|
+
const sevColor = p => cssColor('sev-' + String(p).replace(/[^A-Za-z0-9]/g, ''), '--sev') || statusColor('unknown');
|
|
412
|
+
const sevHex = p => '#' + sevColor(p).getHexString();
|
|
406
413
|
|
|
407
414
|
/* ---------- theme: follows the page (prefers-color-scheme), colours from style.css ---------- */
|
|
408
415
|
const themeHooks = new Set();
|
|
@@ -413,9 +420,10 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
413
420
|
const bg = cssColor('', '--stage-bg'), acc = cssColor('', '--stage-accent');
|
|
414
421
|
BG_COL.set(bg || T.bg); scene.background.copy(BG_COL);
|
|
415
422
|
if (acc) accent.copy(acc);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
423
|
+
const tok = (name, d) => cssColor('', name) || new THREE.Color(d);
|
|
424
|
+
bodyMat.color.copy(tok('--stage-body', T.body));
|
|
425
|
+
LINE_COL.copy(tok('--stage-line', T.line)); MAJOR_COL.copy(tok('--stage-major', T.major)); EDGE_COL.copy(tok('--stage-edge', T.edge)); appliedKey = '';
|
|
426
|
+
recessMat.color.copy(tok('--stage-recess', T.recess));
|
|
419
427
|
accentLineMat.color.copy(accent);
|
|
420
428
|
hemi.color.set(T.sky); hemi.groundColor.set(T.ground); hemi.intensity = T.hemi; ambient.intensity = T.amb; sun.intensity = T.sun; fill.intensity = T.fill;
|
|
421
429
|
for (const m of glowMats) { m.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; m.uniforms.light.value = light ? 1 : 0; m.needsUpdate = true; m.visible = !light; }
|
|
@@ -425,16 +433,20 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
425
433
|
for (const t of TXT) if (!t.gone) t.redraw();
|
|
426
434
|
invalidate();
|
|
427
435
|
}
|
|
436
|
+
// the page's theme: forge-ui's own switch (theme.js) when it is there, else the system setting
|
|
428
437
|
const media = matchMedia('(prefers-color-scheme: light)');
|
|
429
|
-
const
|
|
438
|
+
const resolved = () => window.ForgeTheme ? window.ForgeTheme.resolved() : media.matches ? 'light' : 'dark';
|
|
439
|
+
const onScheme = () => setTheme(resolved());
|
|
430
440
|
media.addEventListener('change', onScheme);
|
|
441
|
+
window.addEventListener('forge-theme', onScheme);
|
|
431
442
|
|
|
432
443
|
function applyWeight() {
|
|
433
444
|
const dist = camera.position.distanceTo(controls.target);
|
|
434
445
|
const ppm = camera === ortho ? VH * ortho.zoom / orthoH : VH / (dist * fovK());
|
|
435
446
|
const k = THREE.MathUtils.clamp((ppm / 40 - .35) / .65, 0, 1);
|
|
436
447
|
const fm = THEMES[theme].fadeMin, fade = fm + (1 - fm) * k, dpr = renderer.getPixelRatio();
|
|
437
|
-
|
|
448
|
+
// orthographic zoom too: a screen sprite's world size follows it
|
|
449
|
+
const key = fade.toFixed(2) + '|' + dpr + '|' + theme + '|' + (camera === ortho ? ortho.zoom.toFixed(4) : 'p'); if (key === appliedKey) return; appliedKey = key;
|
|
438
450
|
lineMat.linewidth = majorMat.linewidth = dpr; accentLineMat.linewidth = 1.6 * dpr;
|
|
439
451
|
lineMat.color.copy(BG_COL).lerp(EDGE_COL, fade);
|
|
440
452
|
majorMat.color.copy(BG_COL).lerp(MAJOR_COL, fade);
|
|
@@ -443,6 +455,8 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
443
455
|
for (const f of weightHooks) f();
|
|
444
456
|
}
|
|
445
457
|
const weightHooks = new Set();
|
|
458
|
+
// screen pixels per plan unit at the point the camera looks at
|
|
459
|
+
const pxPerUnit = () => (camera === ortho ? VH * ortho.zoom / orthoH : VH / (camera.position.distanceTo(controls.target) * fovK())) * TS;
|
|
446
460
|
|
|
447
461
|
/* ---------- camera: frame a box, keeping the user's angle ---------- */
|
|
448
462
|
const fovK = () => 2 * Math.tan(THREE.MathUtils.degToRad(persp.fov / 2));
|
|
@@ -484,14 +498,33 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
484
498
|
let D = .5;
|
|
485
499
|
for (const p0 of pts) {
|
|
486
500
|
const p = p0.clone().sub(c), px = p.dot(r), py = p.dot(u), pz = p.dot(f);
|
|
487
|
-
D = Math.max(D, Math.abs(px) / (th * fw * .
|
|
501
|
+
D = Math.max(D, Math.abs(px) / (th * fw * .97) - pz, Math.abs(py) / (tv * fh * .95) - pz);
|
|
488
502
|
}
|
|
489
503
|
// what is framed sits in the middle of the uncovered part of the stage
|
|
490
504
|
c.addScaledVector(r, -(I.l - I.r) / VW * D * th).addScaledVector(u, (I.t - I.b) / VH * D * tv);
|
|
491
505
|
return { c, D };
|
|
492
506
|
}
|
|
507
|
+
// the stage covered by modules floating over it (marked data-overlay): a tall one covers a side, a wide one a top or bottom
|
|
508
|
+
function overlayInset() {
|
|
509
|
+
const H = host.getBoundingClientRect(), I = { l: 0, r: 0, t: 0, b: 0 };
|
|
510
|
+
if (!H.width || !H.height) return I;
|
|
511
|
+
const full = document.fullscreenElement;
|
|
512
|
+
for (const n of document.querySelectorAll('[data-overlay]')) {
|
|
513
|
+
if (full && !full.contains(n)) continue; // full screen: the modules are behind the stage
|
|
514
|
+
const R = n.getBoundingClientRect();
|
|
515
|
+
if (!R.width || !R.height || R.right <= H.left || R.left >= H.right || R.bottom <= H.top || R.top >= H.bottom) continue;
|
|
516
|
+
const cl = Math.max(R.left, H.left), cr = Math.min(R.right, H.right), ct = Math.max(R.top, H.top), cb = Math.min(R.bottom, H.bottom);
|
|
517
|
+
if (cb - ct > H.height * .5) { if (cl - H.left < H.right - cr) I.l = Math.max(I.l, cr - H.left); else I.r = Math.max(I.r, H.right - cl); }
|
|
518
|
+
else if (ct - H.top < H.bottom - cb) I.t = Math.max(I.t, cb - H.top); else I.b = Math.max(I.b, H.bottom - ct);
|
|
519
|
+
}
|
|
520
|
+
return I;
|
|
521
|
+
}
|
|
522
|
+
// the last framing, redone when the stage changes size, until the user moves the camera themselves
|
|
523
|
+
let autoFrame = null;
|
|
493
524
|
function frameBox(bx, dur = 900, minEl = 26, dirOverride = null, inset = null) {
|
|
494
525
|
resize(); // the host may have just been shown or moved: fit its real size
|
|
526
|
+
autoFrame = { bx, minEl, dirOverride, inset };
|
|
527
|
+
if (!inset) inset = overlayInset();
|
|
495
528
|
let dir = dirOverride ? dirOverride.clone().normalize() : camera.position.clone().sub(controls.target);
|
|
496
529
|
dir = dir.lengthSq() > 1e-8 ? dir.normalize() : DIR_ISO.clone();
|
|
497
530
|
const el = Math.asin(THREE.MathUtils.clamp(dir.y, -1, 1)), lo = THREE.MathUtils.degToRad(minEl);
|
|
@@ -515,6 +548,22 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
515
548
|
if (k >= 1) { controls.enableDamping = true; CAMT = null; }
|
|
516
549
|
return true;
|
|
517
550
|
}
|
|
551
|
+
// closer (k > 1) or farther (k < 1) toward what the camera looks at, as a short glide; the angle stays
|
|
552
|
+
function zoomBy(k, dur = 260) {
|
|
553
|
+
settleCam();
|
|
554
|
+
const off = camera.position.clone().sub(controls.target);
|
|
555
|
+
let zoom = null;
|
|
556
|
+
if (camera === ortho) zoom = THREE.MathUtils.clamp(ortho.zoom * k, .05, 40);
|
|
557
|
+
else off.setLength(THREE.MathUtils.clamp(off.length() / k, controls.minDistance || .01, controls.maxDistance));
|
|
558
|
+
autoFrame = null;
|
|
559
|
+
if (reduceMotion || dur <= 1) {
|
|
560
|
+
camera.position.copy(controls.target).add(off);
|
|
561
|
+
if (zoom) { ortho.zoom = zoom; ortho.updateProjectionMatrix(); }
|
|
562
|
+
invalidate(); return;
|
|
563
|
+
}
|
|
564
|
+
CAMT = { t0: performance.now(), dur, fromT: controls.target.clone(), fromO: camera.position.clone().sub(controls.target), fromZ: zoom ? ortho.zoom : null, to: { t: controls.target.clone(), off, zoom } };
|
|
565
|
+
controls.enableDamping = false; invalidate();
|
|
566
|
+
}
|
|
518
567
|
const cancelCam = () => { if (CAMT) { controls.enableDamping = true; CAMT = null; } };
|
|
519
568
|
const settleCam = () => { if (CAMT) { camStep(CAMT.t0 + CAMT.dur + 1); controls.update(); } }; // jump to where a glide ends (tests)
|
|
520
569
|
|
|
@@ -544,6 +593,7 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
544
593
|
else if (!raf) last = 0;
|
|
545
594
|
}
|
|
546
595
|
controls.addEventListener('change', () => invalidate());
|
|
596
|
+
controls.addEventListener('start', () => { autoFrame = null; });
|
|
547
597
|
const onVisibility = () => { visible = document.visibilityState === 'visible'; if (visible) invalidate(); };
|
|
548
598
|
document.addEventListener('visibilitychange', onVisibility);
|
|
549
599
|
const io = typeof IntersectionObserver === 'function' ? new IntersectionObserver(es => { onScreen = es.some(e => e.isIntersecting); if (onScreen) invalidate(); }) : null;
|
|
@@ -556,6 +606,7 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
556
606
|
const res = devRes();
|
|
557
607
|
outlineMat.uniforms.res.value.copy(res); lineMat.resolution.copy(res); majorMat.resolution.copy(res); accentLineMat.resolution.copy(res);
|
|
558
608
|
appliedKey = ''; invalidate();
|
|
609
|
+
if (autoFrame) { const f = autoFrame; frameBox(f.bx, 0, f.minEl, f.dirOverride, f.inset); }
|
|
559
610
|
}
|
|
560
611
|
const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
|
|
561
612
|
if (ro) ro.observe(host);
|
|
@@ -740,7 +791,7 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
740
791
|
}
|
|
741
792
|
function dispose() {
|
|
742
793
|
disposed = true; if (raf) cancelAnimationFrame(raf); raf = 0;
|
|
743
|
-
media.removeEventListener('change', onScheme); document.removeEventListener('visibilitychange', onVisibility);
|
|
794
|
+
media.removeEventListener('change', onScheme); window.removeEventListener('forge-theme', onScheme); document.removeEventListener('visibilitychange', onVisibility);
|
|
744
795
|
if (io) io.disconnect(); if (ro) ro.disconnect();
|
|
745
796
|
controls.dispose(); disposeGroup(scene); target.dispose(); composer.dispose && composer.dispose(); renderer.dispose();
|
|
746
797
|
canvas.remove(); probe.remove();
|
|
@@ -756,9 +807,9 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
|
|
|
756
807
|
glowPoints, softMat, fat, loopL, noRay, rbox, inset, seg, polyLine, cumLen, alongPts, moveGlow, roundRect,
|
|
757
808
|
mats, lmat, paint, lineCol, setOp, solid, loopAt, roofPlate, cone,
|
|
758
809
|
tplane, release, roofAt, frontAt, lines, rr, font, fit, wrap, ellipsis, pxIn,
|
|
759
|
-
ends, curve, arc, statusColor, statusHex, cssColor,
|
|
760
|
-
frameBox, fitFor, cancelCam, settleCam, setOrtho, isOrtho: () => camera === ortho, invalidate, tickers, themeHooks, weightHooks,
|
|
761
|
-
pick, pickHit, rayAt, project, handlers, disposeGroup, dispose, reduceMotion, now: () => NOW,
|
|
810
|
+
ends, curve, arc, statusColor, statusHex, sevColor, sevHex, cssColor,
|
|
811
|
+
frameBox, fitFor, zoomBy, cancelCam, settleCam, setOrtho, overlayInset, isOrtho: () => camera === ortho, invalidate, tickers, themeHooks, weightHooks,
|
|
812
|
+
pick, pickHit, rayAt, project, pxPerUnit, handlers, disposeGroup, dispose, reduceMotion, now: () => NOW,
|
|
762
813
|
setProfile, get profile() { return profile; }, unitSolid, solidFrom, disposeShared, sprite, outlineInstanced, mixColor,
|
|
763
814
|
LineSegments2, LineSegmentsGeometry, creaseLines,
|
|
764
815
|
};
|