@giamme/forge 0.0.0-stage → 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.
Files changed (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,816 @@
1
+ /* forge-ui 3D engine: archivision's iso-glow renderer, cut down to what forge's run maps draw.
2
+
3
+ Ported from archivision (assets/shell-3d.html and assets/figure.js; MIT, © 2026 Giamme). Its drawing
4
+ style comes from iso-glow, under this licence:
5
+
6
+ MIT License
7
+ Copyright (c) 2026 April Zhu (iso-glow: lighting, palettes, gallery shell, examples)
8
+ Copyright (c) 2026 Tolga Cohce (iso-figure, on which iso-glow is based)
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
10
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
11
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
13
+ following conditions: The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
16
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
17
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
19
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+
21
+ three.js 0.160.0 (MIT, © 2010-2023 three.js authors) comes from cdn.jsdelivr.net through index.html's
22
+ import map. Differences from archivision: the canvas lives in a container, not the whole window; nothing
23
+ writes inline styles or <style> elements (forge-ui's CSP forbids both, style.css holds every rule and
24
+ colour); paint() lights an element toward its own colour (a task status), not only the one accent; and
25
+ frames are drawn on demand, not every display refresh. */
26
+ import * as THREE from 'three';
27
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
28
+ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
29
+ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
30
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
31
+ import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
32
+ import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
33
+ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
34
+ import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
35
+ import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
36
+
37
+ export { THREE };
38
+
39
+ const TS = 0.01; // one plan unit is a centimetre; three.js works in metres
40
+ const W = (x, y, z) => new THREE.Vector3(x * TS, z * TS, y * TS); // plan x right, y toward the viewer, z up
41
+ const MONO3 = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
42
+ const NOISE = `float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }`;
43
+ const CW = .61; // monospace advance, in em
44
+ // the iso figures' light and dark themes, tinted to forge-ui's enamel panel; style.css's --stage-* tokens win when set
45
+ const THEMES = {
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
+ };
49
+ const PAL = {
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
+ };
53
+
54
+ /* ---------- text: wrap and fit monospace print into a box (archivision's figure.js) ---------- */
55
+ const words = s => String(s).replace(/ ·/g, ' ·').split(' ');
56
+ export function wrap(s, n) {
57
+ const o = []; let c = '';
58
+ const add = (w, sep) => { if (!c) c = w; else if ((c + sep + w).length <= n) c += sep + w; else { o.push(c); c = w; } };
59
+ for (const w of words(s)) {
60
+ if (w.length <= n) add(w, ' ');
61
+ else w.split(/(?<=[_(\/.-])|(?<=[a-z0-9])(?=[A-Z])/).forEach((q, i) => {
62
+ if (q.length <= n) add(q, i ? '' : ' ');
63
+ else for (let k = 0; k < q.length; k += n) add(q.slice(k, k + n), k || i ? '' : ' '); // a token longer than a line is cut
64
+ });
65
+ }
66
+ if (c) o.push(c); return o;
67
+ }
68
+ export function fit(s, width, hmax, fmax, lines, fmin = 3.5, step = .5) {
69
+ s = String(s == null ? '' : s); if (!s) return null;
70
+ for (let f = fmax; f >= fmin; f -= step) {
71
+ const n = Math.floor(width / (CW * f)); if (n < 1) continue;
72
+ const L = wrap(s, n);
73
+ if (L.length <= lines && L.every(l => l.length <= n) && L.length * f * 1.18 <= hmax) return { f, L };
74
+ }
75
+ // still too long at the smallest size: keep the first lines and end with an ellipsis
76
+ const f = fmin, n = Math.max(1, Math.floor(width / (CW * f))), L = wrap(s, n).slice(0, Math.max(1, Math.min(lines, Math.floor(hmax / (f * 1.18)))));
77
+ const last = L.length - 1; if (L[last].length >= n) L[last] = L[last].slice(0, n - 1) + '…'; else L[last] += '…';
78
+ return { f, L };
79
+ }
80
+ export const ellipsis = (s, n) => { s = String(s == null ? '' : s); return s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + '…'; };
81
+
82
+ export async function createEngine(host, { reduceMotion = false } = {}) {
83
+ const box = () => { const r = host.getBoundingClientRect(); return [Math.max(1, Math.round(r.width)), Math.max(1, Math.round(r.height))]; };
84
+ let [VW, VH] = box();
85
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
86
+ renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
87
+ renderer.setSize(VW, VH);
88
+ const canvas = renderer.domElement;
89
+ canvas.className = 'stage-canvas';
90
+ canvas.tabIndex = 0;
91
+ host.append(canvas);
92
+ const scene = new THREE.Scene();
93
+ scene.background = new THREE.Color(THEMES.dark.bg);
94
+ const persp = new THREE.PerspectiveCamera(28, VW / VH, 0.05, 900);
95
+ const ortho = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.05, 900);
96
+ let orthoH = 10, camera = persp;
97
+ const controls = new OrbitControls(camera, canvas);
98
+ controls.enableDamping = true; controls.maxPolarAngle = Math.PI * .495; controls.minDistance = .3; controls.maxDistance = 400;
99
+ controls.enableZoom = false; // the page scrolls past the stage until the stage is engaged
100
+ const hemi = new THREE.HemisphereLight(0xdde4ff, 0x080808, .7); scene.add(hemi);
101
+ const ambient = new THREE.AmbientLight(0xffffff, 0); scene.add(ambient);
102
+ const sun = new THREE.DirectionalLight(0xffffff, .7); sun.position.set(6, 14, 10); scene.add(sun);
103
+ const fill = new THREE.DirectionalLight(0x8899bb, .2); fill.position.set(-10, 4, -8); scene.add(fill);
104
+ // screen-space soft gradients and dither, so glows and pools stay smooth instead of banded
105
+ const target = new THREE.WebGLRenderTarget(VW, VH, { samples: 4, type: THREE.HalfFloatType });
106
+ const composer = new EffectComposer(renderer, target);
107
+ const renderPass = new RenderPass(scene, camera); composer.addPass(renderPass);
108
+ composer.addPass(new OutputPass());
109
+ composer.addPass(new ShaderPass({
110
+ uniforms: { tDiffuse: { value: null } },
111
+ vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
112
+ fragmentShader: `uniform sampler2D tDiffuse; varying vec2 vUv; ${NOISE}
113
+ void main() { vec4 c = texture2D(tDiffuse, vUv); c.rgb += (hash(gl_FragCoord.xy) - 0.5) / 255.0; gl_FragColor = c; }`,
114
+ }));
115
+ composer.setSize(VW, VH);
116
+
117
+ /* ---------- materials ---------- */
118
+ const accent = new THREE.Color('#ff9d3c');
119
+ const devRes = () => renderer.getDrawingBufferSize(new THREE.Vector2());
120
+ const bodyMat = new THREE.MeshStandardMaterial({ color: 0x1b1c1f, roughness: .9, metalness: .05, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
121
+ const LINE_COL = new THREE.Color(0x4f5357), MAJOR_COL = new THREE.Color(0x393c3f), EDGE_COL = new THREE.Color(0x27292c), BG_COL = new THREE.Color(0x121314);
122
+ const lineOpts = { linewidth: 1, resolution: devRes(), polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -6 };
123
+ const lineMat = new LineMaterial({ color: EDGE_COL, ...lineOpts });
124
+ const majorMat = new LineMaterial({ color: MAJOR_COL, ...lineOpts });
125
+ const accentLineMat = new LineMaterial({ color: accent.clone(), linewidth: 1, resolution: devRes(), polygonOffset: true, polygonOffsetFactor: -3, polygonOffsetUnits: -8 });
126
+ const outlineMat = new THREE.ShaderMaterial({
127
+ uniforms: { color: { value: LINE_COL.clone() }, thickness: { value: 1 }, res: { value: devRes() } },
128
+ vertexShader: `uniform float thickness; uniform vec2 res;
129
+ void main() {
130
+ vec4 p = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
131
+ vec4 n = projectionMatrix * vec4(normalize(normalMatrix * normal), 0.0);
132
+ vec2 d = length(n.xy) > 1e-5 ? normalize(n.xy) : vec2(0.0);
133
+ p.xy += d * thickness * 2.0 / res * p.w;
134
+ gl_Position = p;
135
+ }`,
136
+ fragmentShader: `uniform vec3 color; void main() { gl_FragColor = vec4(color, 1.0); }`,
137
+ side: THREE.BackSide, polygonOffset: true, polygonOffsetFactor: 6, polygonOffsetUnits: 24,
138
+ });
139
+ const recessMat = new THREE.MeshStandardMaterial({ color: 0x0f1011, roughness: .95, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 });
140
+ let theme = 'dark';
141
+ // lamp halos: one screen-facing point per lamp, shaded per pixel
142
+ const glowMats = new Set();
143
+ function glowPoints(positions, size, color = accent) {
144
+ const g = new THREE.BufferGeometry().setFromPoints(positions);
145
+ const m = new THREE.ShaderMaterial({
146
+ uniforms: { color: { value: new THREE.Color(color) }, intensity: { value: 0 }, size: { value: size }, scale: { value: 1 }, light: { value: theme === 'light' ? 1 : 0 }, ortho: { value: camera === ortho ? 1 : 0 } },
147
+ transparent: true, depthWrite: false, blending: theme === 'light' ? THREE.NormalBlending : THREE.AdditiveBlending, toneMapped: false,
148
+ vertexShader: `uniform float size, scale, ortho;
149
+ void main() {
150
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
151
+ gl_PointSize = ortho > 0.5 ? size * scale : size * scale / -mv.z;
152
+ mv.xyz -= normalize(mv.xyz) * size * 0.5;
153
+ gl_Position = projectionMatrix * mv;
154
+ }`,
155
+ fragmentShader: `uniform vec3 color; uniform float intensity, light; ${NOISE}
156
+ void main() {
157
+ float d = length(gl_PointCoord - 0.5) * 2.0; if (d > 1.0) discard;
158
+ float halo = exp(-d * d * 9.0) * (1.0 - d);
159
+ float core = 1.0 - smoothstep(0.05, 0.12, d);
160
+ float a = (halo * 0.7 + core * 0.9) * intensity + (hash(gl_FragCoord.xy) - 0.5) / 255.0;
161
+ vec3 c = mix(color, vec3(1.0), core * 0.6 * (1.0 - light));
162
+ gl_FragColor = light > 0.5 ? vec4(c, clamp(a, 0.0, 1.0)) : vec4(c * max(a, 0.0), 1.0);
163
+ }`,
164
+ });
165
+ glowMats.add(m);
166
+ const pts = new THREE.Points(g, m);
167
+ pts.raycast = () => {};
168
+ return pts;
169
+ }
170
+ // soft radial pool (light on the plate), computed per pixel and dithered; dark theme only when additive
171
+ const softGlows = new Set();
172
+ function softMat(color, additive) {
173
+ const m = new THREE.ShaderMaterial({
174
+ uniforms: { color: { value: new THREE.Color(color) }, opacity: { value: 0 } },
175
+ transparent: true, depthWrite: false, blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending, toneMapped: false,
176
+ vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
177
+ fragmentShader: `uniform vec3 color; uniform float opacity; varying vec2 vUv; ${NOISE}
178
+ void main() {
179
+ float d = length(vUv - 0.5) * 2.0;
180
+ float a = smoothstep(1.0, 0.0, d); a *= a;
181
+ a = a * opacity + (hash(gl_FragCoord.xy) - 0.5) / 255.0;
182
+ gl_FragColor = vec4(color, max(a, 0.0));
183
+ }`,
184
+ });
185
+ if (additive) { softGlows.add(m); m.visible = theme !== 'light'; }
186
+ return m;
187
+ }
188
+
189
+ /* ---------- lines and solids ---------- */
190
+ const fat = (arr, mat = lineMat) => new LineSegments2(new LineSegmentsGeometry().setPositions(arr), mat);
191
+ const loopL = (pts, mat = majorMat) => { const out = []; for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; out.push(a.x, a.y, a.z, b.x, b.y, b.z); } return fat(out, mat); };
192
+ const noRay = o => { o.raycast = () => {}; return o; };
193
+ function addOutline(m) { const o = noRay(new THREE.Mesh(m.geometry, outlineMat)); o.userData.outline = true; m.add(o); }
194
+ // Crease lines that stay continuous: a sharp edge starts a line, followed through softer straight-on neighbours.
195
+ function creaseInfo(geo) {
196
+ if (geo.userData.crease) return geo.userData.crease;
197
+ const src = new THREE.BufferGeometry().setAttribute('position', geo.attributes.position);
198
+ if (geo.index) src.setIndex(geo.index);
199
+ const g = mergeVertices(src, 1e-4);
200
+ const p = g.attributes.position, ix = g.index.array, nV = p.count;
201
+ const P = i => new THREE.Vector3().fromBufferAttribute(p, i);
202
+ const faceN = [], edgeFaces = new Map();
203
+ for (let f = 0; f < ix.length / 3; f++) {
204
+ const a = ix[3 * f], b = ix[3 * f + 1], c = ix[3 * f + 2];
205
+ const n = new THREE.Vector3().crossVectors(P(b).sub(P(a)), P(c).sub(P(a)));
206
+ faceN.push(n.lengthSq() > 1e-14 ? n.normalize() : null);
207
+ for (const [u, v] of [[a, b], [b, c], [c, a]]) {
208
+ if (u === v) continue;
209
+ const k = Math.min(u, v) * nV + Math.max(u, v);
210
+ (edgeFaces.get(k) || edgeFaces.set(k, []).get(k)).push(f);
211
+ }
212
+ }
213
+ const edges = [];
214
+ for (const [k, fs] of edgeFaces) {
215
+ const a = Math.floor(k / nV), b = k % nV;
216
+ const ns = fs.map(f => faceN[f]).filter(Boolean);
217
+ let ang = 180;
218
+ if (ns.length >= 2) { ang = 0; for (let i = 1; i < ns.length; i++) ang = Math.max(ang, THREE.MathUtils.radToDeg(ns[0].angleTo(ns[i]))); }
219
+ else if (ns.length === 0) continue;
220
+ const dir = P(b).sub(P(a)), len = dir.length();
221
+ if (len > 0) edges.push({ a, b, ang, dir: dir.divideScalar(len), len });
222
+ }
223
+ const byVert = new Map();
224
+ edges.forEach((e, i) => { for (const v of [e.a, e.b]) (byVert.get(v) || byVert.set(v, []).get(v)).push(i); });
225
+ g.dispose(); src.dispose();
226
+ return (geo.userData.crease = { p, edges, byVert });
227
+ }
228
+ const GROW = .55, STRAIGHT = .9;
229
+ function creaseLines(geo, thr) {
230
+ const { p, edges, byVert } = creaseInfo(geo), low = thr * GROW;
231
+ const on = new Uint8Array(edges.length), queue = [];
232
+ edges.forEach((e, i) => { if (e.ang >= thr) { on[i] = 1; queue.push(i); } });
233
+ while (queue.length) {
234
+ const i = queue.pop(), e = edges[i];
235
+ for (const [v, out] of [[e.a, -1], [e.b, 1]]) {
236
+ for (const j of byVert.get(v)) {
237
+ if (on[j]) continue; const f = edges[j]; if (f.ang < low) continue;
238
+ const d = f.dir.dot(e.dir) * (f.a === v ? 1 : -1) * out;
239
+ if (d > STRAIGHT) { on[j] = 1; queue.push(j); }
240
+ }
241
+ }
242
+ }
243
+ const parent = new Map(), find = x => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
244
+ edges.forEach((e, i) => { if (!on[i]) return; for (const v of [e.a, e.b]) if (!parent.has(v)) parent.set(v, v); parent.set(find(e.a), find(e.b)); });
245
+ const total = new Map(); edges.forEach((e, i) => { if (on[i]) { const r = find(e.a); total.set(r, (total.get(r) || 0) + e.len); } });
246
+ const out = [];
247
+ edges.forEach((e, i) => { if (on[i] && total.get(find(e.a)) >= .06) out.push(p.getX(e.a), p.getY(e.a), p.getZ(e.a), p.getX(e.b), p.getY(e.b), p.getZ(e.b)); });
248
+ return out;
249
+ }
250
+ function roundRect(sh, x, y, w, h, r) {
251
+ sh.moveTo(x + r, y); sh.lineTo(x + w - r, y); sh.quadraticCurveTo(x + w, y, x + w, y + r);
252
+ sh.lineTo(x + w, y + h - r); sh.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
253
+ sh.lineTo(x + r, y + h); sh.quadraticCurveTo(x, y + h, x, y + h - r);
254
+ sh.lineTo(x, y + r); sh.quadraticCurveTo(x, y, x + r, y);
255
+ }
256
+ // iso box: rounded in plan, sharp top and bottom rims, with its silhouette and crease hairlines
257
+ function rbox(x, y, z, w, d, h, r, mat, parent) {
258
+ r = Math.max(.01, Math.min(r, w / 2 - .01, d / 2 - .01));
259
+ const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, r);
260
+ const g = new THREE.ExtrudeGeometry(sh, { depth: h, bevelEnabled: false, curveSegments: 10 });
261
+ g.rotateX(-Math.PI / 2); g.translate(0, z, 0); g.scale(TS, TS, TS);
262
+ const m = new THREE.Mesh(g, mat || bodyMat);
263
+ addOutline(m);
264
+ m.add(noRay(fat(creaseLines(g, 30), lineMat)));
265
+ const mj = noRay(fat(creaseLines(g, 60), majorMat)); mj.renderOrder = 1; m.add(mj);
266
+ parent.add(m); return m;
267
+ }
268
+ // flat recess panel lying on a top face
269
+ function inset(x, y, z, w, d, r, parent) {
270
+ const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, Math.min(r, w / 2, d / 2));
271
+ const g = new THREE.ShapeGeometry(sh, 12); g.rotateX(-Math.PI / 2); g.translate(0, z + .05, 0); g.scale(TS, TS, TS);
272
+ const m = noRay(new THREE.Mesh(g, recessMat)); parent.add(m);
273
+ parent.add(noRay(loopL(sh.getPoints(12).map(p => W(p.x, -p.y, z + .06)), lineMat))); return m;
274
+ }
275
+ const seg = (arr, parent, mat = lineMat) => { const l = noRay(fat(arr.flatMap(([a, b]) => [...W(...a).toArray(), ...W(...b).toArray()]), mat)); parent.add(l); return l; };
276
+ const polyLine = (pts, mat, parent) => { const out = []; for (let i = 0; i < pts.length - 1; i++) out.push(...W(...pts[i]).toArray(), ...W(...pts[i + 1]).toArray()); const l = noRay(fat(out, mat)); parent.add(l); return l; };
277
+ const cumLen = pts => pts.reduce((a, p, i) => (a.push(i ? a[i - 1] + Math.hypot(...p.map((v, j) => v - pts[i - 1][j])) : 0), a), []);
278
+ const alongPts = (pts, cum, t) => { const d = t * cum[cum.length - 1]; let i = 1; while (i < cum.length - 1 && cum[i] < d) i++;
279
+ const f = (d - cum[i - 1]) / (cum[i] - cum[i - 1] || 1); return pts[i - 1].map((v, j) => v + (pts[i][j] - v) * f); };
280
+ const moveGlow = (gp, p, k) => { gp.geometry.attributes.position.setXYZ(0, ...W(...p).toArray()); gp.geometry.attributes.position.needsUpdate = true; gp.material.uniforms.intensity.value = k; };
281
+
282
+ /* ---------- per-element materials, so anything can dim, fade or light up on its own ---------- */
283
+ const OUT_FS = 'uniform vec3 color; uniform float opacity; void main() { gl_FragColor = vec4(color, opacity); }';
284
+ const INKHI = new THREE.Color(), DETAIL = new THREE.Color(), GLASSON = new THREE.Color(), TMPC = new THREE.Color();
285
+ let PALC = null;
286
+ const lineMats = new Set();
287
+ function palette() {
288
+ const C = PAL[theme], light = theme === 'light', hi = TMPC.copy(accent).lerp(new THREE.Color(light ? 0x000000 : 0xffffff), light ? .3 : .42);
289
+ PALC = { ...C, acc: '#' + accent.getHexString(), accHi: '#' + hi.getHexString(), light };
290
+ INKHI.set(C.inkHi); DETAIL.set(C.detail); GLASSON.set(C.glassOn);
291
+ }
292
+ palette();
293
+ const setOp = (m, o) => { m.opacity = o; const t = o < .999; if (m.transparent !== t) { m.transparent = t; m.needsUpdate = true; } };
294
+ function lmat(o = {}) {
295
+ const m = new LineMaterial({ color: 0x777777, linewidth: 1, resolution: devRes(), polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -6, ...o });
296
+ m.uniforms.resolution = lineMat.uniforms.resolution; // follows the resize
297
+ lineMats.add(m);
298
+ return m;
299
+ }
300
+ function mats() {
301
+ const out = new THREE.ShaderMaterial({ uniforms: { color: { value: new THREE.Color() }, thickness: outlineMat.uniforms.thickness, res: outlineMat.uniforms.res, opacity: { value: 1 } },
302
+ vertexShader: outlineMat.vertexShader, fragmentShader: OUT_FS, side: THREE.BackSide, polygonOffset: true, polygonOffsetFactor: 6, polygonOffsetUnits: 24 });
303
+ return { body: bodyMat.clone(), out, line: lmat(), major: lmat(), labs: [] };
304
+ }
305
+ // vis: 1 normal → 0 gone into the background; hot: 0..1 toward col (an element's status, or the accent);
306
+ // hov: 0..1 brighter; op: lines and print; opB: the solid; tint: 0..1 of col into the body
307
+ function paint(M, vis, hot, hov, op, opB, col = accent, tint = 0) {
308
+ const mix = (dst, base) => { dst.copy(base); if (hov > .01) dst.lerp(INKHI, hov * .5); if (hot > .01) dst.lerp(col, hot); if (vis < .999) dst.lerp(BG_COL, 1 - vis); };
309
+ mix(M.line.color, lineMat.color); mix(M.major.color, majorMat.color); mix(M.out.uniforms.color.value, outlineMat.uniforms.color.value);
310
+ M.line.linewidth = M.major.linewidth = lineMat.linewidth * (1 + .5 * hot);
311
+ M.body.color.copy(bodyMat.color); if (tint > .001) M.body.color.lerp(col, tint); if (vis < .999) M.body.color.lerp(BG_COL, (1 - vis) * .6);
312
+ setOp(M.body, opB); setOp(M.line, op); setOp(M.major, op);
313
+ M.out.uniforms.opacity.value = op; if (M.out.transparent !== op < .999) M.out.transparent = op < .999;
314
+ const lo = op * (.1 + .9 * vis); for (const m of M.labs) m.opacity = lo;
315
+ }
316
+ const lineCol = (m, base, hot, vis, col = accent) => { m.color.copy(base); if (hot > .01) m.color.lerp(col, hot); if (vis < .999) m.color.lerp(BG_COL, 1 - vis); };
317
+ function solid(x, y, z, w, d, h, r, M, parent, ud) {
318
+ r = Math.max(.01, Math.min(r, w / 2 - .01, d / 2 - .01));
319
+ const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, r);
320
+ const g = new THREE.ExtrudeGeometry(sh, { depth: h, bevelEnabled: false, curveSegments: r > 3 ? 10 : 4 });
321
+ g.rotateX(-Math.PI / 2); g.translate(0, z, 0); g.scale(TS, TS, TS);
322
+ const m = new THREE.Mesh(g, M.body); m.userData = ud || {};
323
+ m.add(noRay(new THREE.Mesh(g, M.out)));
324
+ for (const [thr, mat] of [[30, M.line], [60, M.major]]) { const a = creaseLines(g, thr); if (a.length) { const l = noRay(fat(a, mat)); if (mat === M.major) l.renderOrder = 1; m.add(l); } }
325
+ parent.add(m); return m;
326
+ }
327
+ const loopAt = (x, y, w, d, r, z, mat, parent, n = 8) => { const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, Math.max(.01, Math.min(r, w / 2 - .01, d / 2 - .01)));
328
+ const l = noRay(loopL(sh.getPoints(n).map(p => W(p.x, -p.y, z)), mat)); parent.add(l); return l; };
329
+ // a flat rounded plate on a roof (a tint, a status lamp): basic material, drawn over the roof
330
+ function roofPlate(x, y, z, w, d, r, mat, parent) {
331
+ const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, Math.max(.01, Math.min(r, w / 2 - .01, d / 2 - .01)));
332
+ const g = new THREE.ShapeGeometry(sh, 8); g.rotateX(-Math.PI / 2); g.translate(0, z, 0); g.scale(TS, TS, TS);
333
+ const m = noRay(new THREE.Mesh(g, mat)); m.renderOrder = 2; parent.add(m); return m;
334
+ }
335
+ // an arrowhead at the end of a cable
336
+ function cone(pts, size, mat, parent) {
337
+ const n = pts.length - 1, e = W(...pts[n]), dir = e.clone().sub(W(...pts[Math.max(0, n - 3)])).normalize();
338
+ const c = noRay(new THREE.Mesh(new THREE.ConeGeometry(size * .7 * TS, size * 1.9 * TS, 14), mat));
339
+ c.position.copy(e).addScaledVector(dir, -size * .95 * TS); c.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir); parent.add(c); return c;
340
+ }
341
+
342
+ /* ---------- print: canvases lying on faces, drawn in plan units ---------- */
343
+ const TXT = new Set();
344
+ const font = (x, f, s, wt = 400) => { x.font = `${wt} ${(f * s).toFixed(2)}px ${MONO3}`; };
345
+ function tplane(w, h, P, draw, parent, M) {
346
+ const s = Math.max(.6, Math.min(P, 1024 / w, 1024 / h)), cw = Math.max(2, Math.ceil(w * s)), ch = Math.max(2, Math.ceil(h * s));
347
+ const c = document.createElement('canvas'); c.width = cw; c.height = ch;
348
+ const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
349
+ const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, toneMapped: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
350
+ const mesh = noRay(new THREE.Mesh(new THREE.PlaneGeometry(w * TS, h * TS), mat)); mesh.renderOrder = 3;
351
+ const t = { c, x: c.getContext('2d'), tex, s, w, h, mesh, mat, gone: false };
352
+ t.redraw = () => { if (c.width !== cw || c.height !== ch) { c.width = cw; c.height = ch; } const x = t.x; x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, cw, ch); draw(x, s, PALC, t); t.gone = false; tex.needsUpdate = true; };
353
+ tex.onUpdate = () => { c.width = c.height = 1; }; // once on the GPU the canvas is not needed until the next redraw
354
+ mesh.onBeforeRender = () => { if (t.gone) t.redraw(); };
355
+ mesh.userData.tp = t;
356
+ t.redraw(); TXT.add(t); parent.add(mesh); if (M) M.labs.push(mat); return t;
357
+ }
358
+ const release = g => g.traverse(o => { const t = o.userData && o.userData.tp; if (t && !t.gone) { t.tex.dispose(); t.c.width = t.c.height = 1; t.gone = true; } });
359
+ const roofAt = (t, x, y, z) => { t.mesh.rotation.x = -Math.PI / 2; t.mesh.position.copy(W(x + t.w / 2, y + t.h / 2, z + .14)); return t; };
360
+ const frontAt = (t, x, y, ztop) => { t.mesh.position.copy(W(x + t.w / 2, y + .14, ztop - t.h / 2)); return t; };
361
+ function lines(x, s, F, X, Y, color, maxW, wt) {
362
+ font(x, F.f, s, wt); x.fillStyle = color; x.textAlign = 'left'; x.textBaseline = 'alphabetic';
363
+ F.L.forEach((ln, i) => x.fillText(ln, X * s, (Y + F.f * (.82 + i * 1.18)) * s, maxW * s));
364
+ }
365
+ const rr = (x, s, X, Y, w, h, r) => { x.beginPath(); x.roundRect(X * s, Y * s, Math.max(0, w) * s, Math.max(0, h) * s, Math.max(0, Math.min(r, w / 2, h / 2)) * s); };
366
+ // device pixels per plan unit once a box of this size is framed (how sharp its print is drawn)
367
+ const pxIn = (w, d) => Math.max(2, Math.min(16, 2 * Math.min(1700, VW) * .6 / Math.max(w, d * 1.6)));
368
+
369
+ /* ---------- cables: arcs from roof edge to roof edge (archivision's ends/curve/arc) ---------- */
370
+ const ends = (a, b, ha, hb) => {
371
+ const g = [b.x - a.x - a.w, a.x - b.x - b.w, b.y - a.y - a.d, a.y - b.y - b.d], k = g.indexOf(Math.max(...g));
372
+ const ov = (a0, a1, b0, b1) => { const lo = Math.max(a0, b0), hi = Math.min(a1, b1); return lo < hi ? (lo + hi) / 2 : null; };
373
+ if (k < 2) { const yc = ov(a.y, a.y + a.d, b.y, b.y + b.d), ya = yc ?? a.y + a.d / 2, yb = yc ?? b.y + b.d / 2;
374
+ return k === 0 ? [[a.x + a.w, ya, ha], [b.x, yb, hb]] : [[a.x, ya, ha], [b.x + b.w, yb, hb]]; }
375
+ const xc = ov(a.x, a.x + a.w, b.x, b.x + b.w), xa = xc ?? a.x + a.w / 2, xb = xc ?? b.x + b.w / 2;
376
+ return k === 2 ? [[xa, a.y + a.d, ha], [xb, b.y, hb]] : [[xa, a.y, ha], [xb, b.y + b.d, hb]];
377
+ };
378
+ const curve = (A, B, lift0, k, n = 24) => {
379
+ const dist = Math.hypot(B[0] - A[0], B[1] - A[1]), c = [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2, Math.max(A[2], B[2]) + lift0 + dist * k], pts = [];
380
+ for (let i = 0; i <= n; i++) { const t = i / n, u = 1 - t; pts.push([0, 1, 2].map(j => u * u * A[j] + 2 * u * t * c[j] + t * t * B[j])); }
381
+ return pts;
382
+ };
383
+ const arc = (a, b, rise = 40, k = .26, cap = 260) => {
384
+ const dist = Math.hypot(b[0] - a[0], b[1] - a[1]), t0 = Math.max(a[2], b[2]) + rise + Math.min(cap, dist * k);
385
+ const c = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, t0], pts = [];
386
+ for (let i = 0; i <= 40; i++) { const t = i / 40, u = 1 - t; pts.push([0, 1, 2].map(j => u * u * a[j] + 2 * u * t * c[j] + t * t * b[j])); }
387
+ return pts;
388
+ };
389
+
390
+ /* ---------- colours from style.css: status colours (--st on .st-<status>) and the stage tokens ---------- */
391
+ const probe = document.createElement('span');
392
+ probe.className = 'stage-probe'; probe.setAttribute('aria-hidden', 'true'); host.append(probe);
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)
395
+ function cssColor(className, prop) {
396
+ const key = className + '|' + prop;
397
+ if (!COLORS.has(key)) {
398
+ probe.className = 'stage-probe ' + className;
399
+ probe.style.setProperty('color', `var(${prop})`);
400
+ const cs = getComputedStyle(probe), v = cs.getPropertyValue(prop).trim() ? cs.color : '';
401
+ let c = null; try { if (v) c = new THREE.Color(v); } catch (_) { c = null; }
402
+ COLORS.set(key, c);
403
+ }
404
+ return COLORS.get(key);
405
+ }
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();
413
+
414
+ /* ---------- theme: follows the page (prefers-color-scheme), colours from style.css ---------- */
415
+ const themeHooks = new Set();
416
+ let appliedKey = '';
417
+ function setTheme(t) {
418
+ theme = t; const T = THEMES[t], light = t === 'light';
419
+ COLORS.clear();
420
+ const bg = cssColor('', '--stage-bg'), acc = cssColor('', '--stage-accent');
421
+ BG_COL.set(bg || T.bg); scene.background.copy(BG_COL);
422
+ if (acc) accent.copy(acc);
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));
427
+ accentLineMat.color.copy(accent);
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;
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; }
430
+ for (const m of softGlows) m.visible = !light;
431
+ palette();
432
+ for (const f of themeHooks) f(t);
433
+ for (const t of TXT) if (!t.gone) t.redraw();
434
+ invalidate();
435
+ }
436
+ // the page's theme: forge-ui's own switch (theme.js) when it is there, else the system setting
437
+ const media = matchMedia('(prefers-color-scheme: light)');
438
+ const resolved = () => window.ForgeTheme ? window.ForgeTheme.resolved() : media.matches ? 'light' : 'dark';
439
+ const onScheme = () => setTheme(resolved());
440
+ media.addEventListener('change', onScheme);
441
+ window.addEventListener('forge-theme', onScheme);
442
+
443
+ function applyWeight() {
444
+ const dist = camera.position.distanceTo(controls.target);
445
+ const ppm = camera === ortho ? VH * ortho.zoom / orthoH : VH / (dist * fovK());
446
+ const k = THREE.MathUtils.clamp((ppm / 40 - .35) / .65, 0, 1);
447
+ const fm = THEMES[theme].fadeMin, fade = fm + (1 - fm) * k, dpr = renderer.getPixelRatio();
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;
450
+ lineMat.linewidth = majorMat.linewidth = dpr; accentLineMat.linewidth = 1.6 * dpr;
451
+ lineMat.color.copy(BG_COL).lerp(EDGE_COL, fade);
452
+ majorMat.color.copy(BG_COL).lerp(MAJOR_COL, fade);
453
+ outlineMat.uniforms.thickness.value = .5 * dpr;
454
+ outlineMat.uniforms.color.value.copy(BG_COL).lerp(LINE_COL, fade);
455
+ for (const f of weightHooks) f();
456
+ }
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;
460
+
461
+ /* ---------- camera: frame a box, keeping the user's angle ---------- */
462
+ const fovK = () => 2 * Math.tan(THREE.MathUtils.degToRad(persp.fov / 2));
463
+ function orthoFrustum() { const a = VW / VH; ortho.top = orthoH / 2; ortho.bottom = -orthoH / 2; ortho.left = -orthoH * a / 2; ortho.right = orthoH * a / 2; ortho.updateProjectionMatrix(); }
464
+ function setOrtho(on) {
465
+ if (on === (camera === ortho)) return;
466
+ if (on) {
467
+ orthoH = camera.position.distanceTo(controls.target) * fovK();
468
+ ortho.position.copy(camera.position); ortho.quaternion.copy(camera.quaternion); ortho.zoom = 1; orthoFrustum();
469
+ camera = ortho;
470
+ } else {
471
+ const d = orthoH / fovK() / ortho.zoom;
472
+ persp.position.copy(controls.target).add(ortho.position.clone().sub(controls.target).setLength(d));
473
+ persp.quaternion.copy(ortho.quaternion); camera = persp;
474
+ }
475
+ controls.object = camera; renderPass.camera = camera; controls.update(); appliedKey = '';
476
+ for (const m of glowMats) m.uniforms.ortho.value = on ? 1 : 0;
477
+ invalidate();
478
+ }
479
+ const DIR_ISO = new THREE.Vector3(1, 1, 1).normalize(), UP = new THREE.Vector3(0, 1, 0);
480
+ let CAMT = null, NOW = performance.now();
481
+ const ez = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
482
+ const clamp01 = v => Math.max(0, Math.min(1, v));
483
+ // bx: a plan box [x0, y0, z0, x1, y1, z1], or {pts: [[x, y, z], …]} to fit the points themselves (tighter for
484
+ // round or scattered things); inset: pixels on each side that overlays cover, kept clear of what is framed
485
+ function fitFor(bx, dir, inset = null) {
486
+ const f = dir.clone().negate(), r = new THREE.Vector3().crossVectors(f, UP).normalize(), u = new THREE.Vector3().crossVectors(r, f);
487
+ const pts = Array.isArray(bx) ? [] : bx.pts.map(p => W(...p));
488
+ if (Array.isArray(bx)) for (const X of [bx[0], bx[3]]) for (const Y of [bx[1], bx[4]]) for (const Z of [bx[2], bx[5]]) pts.push(W(X, Y, Z));
489
+ if (!pts.length) pts.push(new THREE.Vector3());
490
+ const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
491
+ for (const p of pts) { const q = [p.dot(r), p.dot(u), p.dot(f)]; for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], q[i]); hi[i] = Math.max(hi[i], q[i]); } }
492
+ const c = r.clone().multiplyScalar((lo[0] + hi[0]) / 2).addScaledVector(u, (lo[1] + hi[1]) / 2).addScaledVector(f, (lo[2] + hi[2]) / 2);
493
+ const I = { l: 0, r: 0, t: 0, b: 0, ...(inset || {}) };
494
+ if (I.l + I.r > VW * .7) I.l = I.r = 0; // overlays that cover most of the stage: frame under them
495
+ if (I.t + I.b > VH * .7) I.t = I.b = 0;
496
+ const fw = (VW - I.l - I.r) / VW, fh = (VH - I.t - I.b) / VH;
497
+ const tv = Math.tan(THREE.MathUtils.degToRad(persp.fov / 2)), th = tv * VW / VH;
498
+ let D = .5;
499
+ for (const p0 of pts) {
500
+ const p = p0.clone().sub(c), px = p.dot(r), py = p.dot(u), pz = p.dot(f);
501
+ D = Math.max(D, Math.abs(px) / (th * fw * .97) - pz, Math.abs(py) / (tv * fh * .95) - pz);
502
+ }
503
+ // what is framed sits in the middle of the uncovered part of the stage
504
+ c.addScaledVector(r, -(I.l - I.r) / VW * D * th).addScaledVector(u, (I.t - I.b) / VH * D * tv);
505
+ return { c, D };
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;
524
+ function frameBox(bx, dur = 900, minEl = 26, dirOverride = null, inset = null) {
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();
528
+ let dir = dirOverride ? dirOverride.clone().normalize() : camera.position.clone().sub(controls.target);
529
+ dir = dir.lengthSq() > 1e-8 ? dir.normalize() : DIR_ISO.clone();
530
+ const el = Math.asin(THREE.MathUtils.clamp(dir.y, -1, 1)), lo = THREE.MathUtils.degToRad(minEl);
531
+ if (el < lo) { const hz = Math.hypot(dir.x, dir.z) || 1; dir.set(dir.x / hz * Math.cos(lo), Math.sin(lo), dir.z / hz * Math.cos(lo)); }
532
+ const { c, D } = fitFor(bx, dir, inset);
533
+ const to = { t: c, off: dir.clone().multiplyScalar(D), zoom: camera === ortho ? orthoH / (D * fovK()) : null };
534
+ if (reduceMotion || dur <= 1) {
535
+ controls.target.copy(to.t); camera.position.copy(to.t).add(to.off);
536
+ if (to.zoom) { ortho.zoom = to.zoom; ortho.updateProjectionMatrix(); }
537
+ CAMT = null; invalidate(); return;
538
+ }
539
+ CAMT = { t0: performance.now(), dur, fromT: controls.target.clone(), fromO: camera.position.clone().sub(controls.target), fromZ: camera === ortho ? ortho.zoom : null, to };
540
+ controls.enableDamping = false; invalidate();
541
+ }
542
+ function camStep(now) {
543
+ if (!CAMT) return false;
544
+ const k = ez(clamp01((now - CAMT.t0) / CAMT.dur));
545
+ controls.target.lerpVectors(CAMT.fromT, CAMT.to.t, k);
546
+ camera.position.copy(controls.target).add(CAMT.fromO.clone().lerp(CAMT.to.off, k));
547
+ if (camera === ortho && CAMT.to.zoom && CAMT.fromZ) { ortho.zoom = THREE.MathUtils.lerp(CAMT.fromZ, CAMT.to.zoom, k); ortho.updateProjectionMatrix(); }
548
+ if (k >= 1) { controls.enableDamping = true; CAMT = null; }
549
+ return true;
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
+ }
567
+ const cancelCam = () => { if (CAMT) { controls.enableDamping = true; CAMT = null; } };
568
+ const settleCam = () => { if (CAMT) { camStep(CAMT.t0 + CAMT.dur + 1); controls.update(); } }; // jump to where a glide ends (tests)
569
+
570
+ /* ---------- the loop: frames only while something moves ---------- */
571
+ const tickers = new Set();
572
+ let raf = 0, last = 0, wakeUntil = 0, visible = true, onScreen = true, disposed = false;
573
+ const tmpV = new THREE.Vector2();
574
+ function invalidate(ms = 0) {
575
+ if (disposed) return;
576
+ if (ms) wakeUntil = Math.max(wakeUntil, performance.now() + ms);
577
+ if (!raf && visible && onScreen) raf = requestAnimationFrame(frame);
578
+ }
579
+ function frame(now) {
580
+ raf = 0; if (disposed) return;
581
+ // capped, not fixed: on a slow GPU an opening still finishes on time, in fewer frames
582
+ const dt = last ? Math.min(.25, (now - last) / 1000) : 1 / 60; last = now; NOW = now;
583
+ let more = camStep(now);
584
+ for (const f of tickers) if (f(now, dt)) more = true;
585
+ if (controls.update()) more = true;
586
+ applyWeight();
587
+ const bufH = renderer.getDrawingBufferSize(tmpV).y, pxScale = camera === ortho ? bufH * ortho.zoom / orthoH : bufH / fovK();
588
+ for (const m of glowMats) m.uniforms.scale.value = pxScale;
589
+ composer.render();
590
+ // controls.update() fires 'change', whose invalidate() may already have asked for the next frame:
591
+ // ask once, or the copies double every frame while the camera moves
592
+ if ((more || now < wakeUntil) && visible && onScreen) { if (!raf) raf = requestAnimationFrame(frame); }
593
+ else if (!raf) last = 0;
594
+ }
595
+ controls.addEventListener('change', () => invalidate());
596
+ controls.addEventListener('start', () => { autoFrame = null; });
597
+ const onVisibility = () => { visible = document.visibilityState === 'visible'; if (visible) invalidate(); };
598
+ document.addEventListener('visibilitychange', onVisibility);
599
+ const io = typeof IntersectionObserver === 'function' ? new IntersectionObserver(es => { onScreen = es.some(e => e.isIntersecting); if (onScreen) invalidate(); }) : null;
600
+ if (io) io.observe(host);
601
+ function resize() {
602
+ const [w, h] = box(); if (w === VW && h === VH) return;
603
+ VW = w; VH = h;
604
+ persp.aspect = VW / VH; persp.updateProjectionMatrix(); orthoFrustum();
605
+ renderer.setSize(VW, VH); composer.setSize(VW, VH);
606
+ const res = devRes();
607
+ outlineMat.uniforms.res.value.copy(res); lineMat.resolution.copy(res); majorMat.resolution.copy(res); accentLineMat.resolution.copy(res);
608
+ appliedKey = ''; invalidate();
609
+ if (autoFrame) { const f = autoFrame; frameBox(f.bx, 0, f.minEl, f.dirOverride, f.inset); }
610
+ }
611
+ const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
612
+ if (ro) ro.observe(host);
613
+
614
+ /* ---------- pointer: picking, click (not drag) and hover ---------- */
615
+ const RAY = new THREE.Raycaster(), NDC = new THREE.Vector2();
616
+ function pick(cx, cy, objects, ok) {
617
+ const r = canvas.getBoundingClientRect();
618
+ NDC.set((cx - r.left) / r.width * 2 - 1, -((cy - r.top) / r.height) * 2 + 1); RAY.setFromCamera(NDC, camera);
619
+ for (const h of RAY.intersectObjects(objects, false)) if (!ok || ok(h.object)) return h.object;
620
+ return null;
621
+ }
622
+ // screen position (px inside the canvas) of a plan point
623
+ const VP = new THREE.Vector3();
624
+ function project(x, y, z) {
625
+ camera.updateMatrixWorld(); VP.copy(W(x, y, z)).project(camera);
626
+ return { x: (VP.x + 1) / 2 * VW, y: (1 - VP.y) / 2 * VH, on: VP.z < 1 && VP.z > -1 && Math.abs(VP.x) < 1.04 && Math.abs(VP.y) < 1.04 };
627
+ }
628
+ function pickHit(cx, cy, objects, ok) {
629
+ rayAt(cx, cy);
630
+ for (const h of RAY.intersectObjects(objects, false)) if (!ok || ok(h.object, h)) return { object: h.object, instanceId: h.instanceId, point: h.point };
631
+ return null;
632
+ }
633
+ function rayAt(cx, cy) {
634
+ const r = canvas.getBoundingClientRect();
635
+ NDC.set((cx - r.left) / r.width * 2 - 1, -((cy - r.top) / r.height) * 2 + 1); RAY.setFromCamera(NDC, camera);
636
+ return RAY;
637
+ }
638
+ /* Control profiles: 'plate' is the run and map views (orbit above the plate, zoom only once the stage
639
+ is engaged, so the page still scrolls); 'free' is the full-screen cloud (look from anywhere, always zoom). */
640
+ let profile = 'plate', engaged = false;
641
+ function setProfile(name) {
642
+ profile = name === 'free' ? 'free' : 'plate';
643
+ const free = profile === 'free';
644
+ controls.minPolarAngle = free ? .02 : 0;
645
+ controls.maxPolarAngle = free ? Math.PI - .02 : Math.PI * .495;
646
+ controls.enableZoom = free || engaged;
647
+ controls.screenSpacePanning = true;
648
+ controls.zoomToCursor = free;
649
+ controls.maxDistance = free ? 2500 : 400;
650
+ persp.far = ortho.far = free ? 6000 : 900;
651
+ persp.updateProjectionMatrix(); ortho.updateProjectionMatrix();
652
+ invalidate();
653
+ }
654
+ const handlers = { click: null, hover: null, key: null, grab: null, drag: null, drop: null, dbl: null };
655
+ let down = null, hoverAt = null, grab = null;
656
+ const engage = on => { engaged = on; controls.enableZoom = profile === 'free' || on; canvas.classList.toggle('engaged', on); };
657
+ // A grab claims the pointer before OrbitControls sees it: this capture listener on the host runs first,
658
+ // and controls.enabled = false makes OrbitControls ignore the press that follows on the canvas.
659
+ host.addEventListener('pointerdown', e => {
660
+ if (e.button !== 0 || !handlers.grab || e.target !== canvas) return;
661
+ const token = handlers.grab(e.clientX, e.clientY, e); if (!token) return;
662
+ grab = { token, x: e.clientX, y: e.clientY, moved: false, id: e.pointerId, damping: controls.enableDamping };
663
+ controls.enabled = false; controls.enableDamping = false;
664
+ try { canvas.setPointerCapture(e.pointerId); } catch (_) { /* no capture: moves still arrive over the canvas */ }
665
+ canvas.classList.add('grabbing');
666
+ }, true);
667
+ function endGrab(e, how) {
668
+ const g = grab; grab = null; if (!g) return;
669
+ controls.enabled = true; controls.enableDamping = g.damping;
670
+ try { canvas.releasePointerCapture(g.id); } catch (_) { /* already released */ }
671
+ canvas.classList.remove('grabbing');
672
+ if (how === 'cancel') { if (handlers.drop) handlers.drop(0, 0, e, g.token, { cancel: true }); }
673
+ else {
674
+ if (!g.moved && handlers.click) handlers.click(e.clientX, e.clientY, e);
675
+ if (handlers.drop) handlers.drop(e.clientX, e.clientY, e, g.token, { moved: g.moved });
676
+ }
677
+ invalidate();
678
+ }
679
+ canvas.addEventListener('pointerdown', e => { down = [e.clientX, e.clientY]; cancelCam(); engage(true); canvas.focus({ preventScroll: true }); });
680
+ canvas.addEventListener('pointerup', e => {
681
+ if (grab) { down = null; endGrab(e, 'up'); return; }
682
+ if (!down || Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 5) { down = null; return; }
683
+ down = null; if (handlers.click) handlers.click(e.clientX, e.clientY, e);
684
+ });
685
+ canvas.addEventListener('pointercancel', e => { if (grab) endGrab(e, 'cancel'); });
686
+ canvas.addEventListener('pointermove', e => {
687
+ hoverAt = [e.clientX, e.clientY];
688
+ if (grab) {
689
+ if (!grab.moved && Math.hypot(e.clientX - grab.x, e.clientY - grab.y) > 5) grab.moved = true;
690
+ if (grab.moved && handlers.drag) { handlers.drag(e.clientX, e.clientY, e, grab.token); invalidate(); }
691
+ return;
692
+ }
693
+ if (!down && handlers.hover) handlers.hover(e.clientX, e.clientY);
694
+ });
695
+ canvas.addEventListener('pointerleave', () => { hoverAt = null; if (!grab && handlers.hover) handlers.hover(null, null); });
696
+ canvas.addEventListener('dblclick', e => { if (handlers.dbl) handlers.dbl(e.clientX, e.clientY, e); });
697
+ canvas.addEventListener('blur', () => engage(false));
698
+ canvas.addEventListener('wheel', () => cancelCam(), { passive: true });
699
+ canvas.addEventListener('keydown', e => {
700
+ if (grab && e.key === 'Escape') { e.preventDefault(); endGrab(e, 'cancel'); return; }
701
+ if (handlers.key && handlers.key(e)) { e.preventDefault(); invalidate(); }
702
+ });
703
+
704
+ /* ---------- shared solids: one extruded box and its crease lines per size, reused by many elements ---------- */
705
+ const SHARED = new Map();
706
+ function unitSolid(key, w, d, h, r) {
707
+ if (SHARED.has(key)) return SHARED.get(key);
708
+ r = Math.max(.01, Math.min(r, w / 2 - .01, d / 2 - .01));
709
+ const sh = new THREE.Shape(); roundRect(sh, -w / 2, -d / 2, w, d, r);
710
+ const geo = new THREE.ExtrudeGeometry(sh, { depth: h, bevelEnabled: false, curveSegments: r > 3 ? 10 : 4 });
711
+ geo.rotateX(-Math.PI / 2); geo.scale(TS, TS, TS);
712
+ const l30 = new LineSegmentsGeometry().setPositions(creaseLines(geo, 30));
713
+ const l60 = new LineSegmentsGeometry().setPositions(creaseLines(geo, 60));
714
+ for (const g of [geo, l30, l60]) g.userData.shared = key;
715
+ const unit = { key, w, d, h, geo, l30, l60 };
716
+ SHARED.set(key, unit); return unit;
717
+ }
718
+ // an element on a shared solid: its own materials (so paint() lights it alone), the unit's geometry
719
+ function solidFrom(unit, M, parent, ud) {
720
+ const m = new THREE.Mesh(unit.geo, M.body); m.userData = ud || {};
721
+ m.add(noRay(new THREE.Mesh(unit.geo, M.out)));
722
+ const a = noRay(new LineSegments2(unit.l30, M.line)), b = noRay(new LineSegments2(unit.l60, M.major)); b.renderOrder = 1;
723
+ a.userData.crease = true; m.add(a, b);
724
+ parent.add(m); return m;
725
+ }
726
+ function disposeShared(prefix = '') {
727
+ for (const [k, u] of [...SHARED]) if (k.startsWith(prefix)) { u.geo.dispose(); u.l30.dispose(); u.l60.dispose(); SHARED.delete(k); }
728
+ }
729
+
730
+ /* ---------- sprites: labels that always face the camera ---------- */
731
+ // screen: true keeps a constant size in pixels (px tall), else the sprite is w × h plan units in the world
732
+ const SCREEN = new Set();
733
+ function sprite(w, h, P, draw, { screen = false, px = 14 } = {}) {
734
+ const s = Math.max(.6, Math.min(P, 1024 / w, 1024 / h)), cw = Math.max(2, Math.ceil(w * s)), ch = Math.max(2, Math.ceil(h * s));
735
+ const c = document.createElement('canvas'); c.width = cw; c.height = ch;
736
+ const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
737
+ const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, toneMapped: false, sizeAttenuation: !screen });
738
+ const sp = new THREE.Sprite(mat); sp.renderOrder = 4;
739
+ const t = { c, x: c.getContext('2d'), tex, s, w, h, mesh: sp, mat, gone: false, screen, px };
740
+ t.redraw = () => { if (c.width !== cw || c.height !== ch) { c.width = cw; c.height = ch; } const x = t.x; x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, cw, ch); draw(x, s, PALC, t); t.gone = false; tex.needsUpdate = true; };
741
+ tex.onUpdate = () => { c.width = c.height = 1; };
742
+ sp.onBeforeRender = () => { if (t.gone) t.redraw(); };
743
+ sp.userData.tp = t;
744
+ if (screen) { SCREEN.add(t); screenScale(t); } else sp.scale.set(w * TS, h * TS, 1);
745
+ t.redraw(); TXT.add(t); return t;
746
+ }
747
+ // a screen sprite's world scale for its pixel height: perspective sprites without size attenuation are
748
+ // sized in view units at distance 1; orthographic ones in world units
749
+ function screenScale(t) {
750
+ const unit = camera === ortho ? (ortho.top - ortho.bottom) / ortho.zoom / VH : 2 * Math.tan(THREE.MathUtils.degToRad(persp.fov / 2)) / VH;
751
+ t.mesh.scale.set(t.px * t.w / t.h * unit, t.px * unit, 1);
752
+ }
753
+ weightHooks.add(() => { for (const t of SCREEN) screenScale(t); });
754
+
755
+ /* ---------- instanced outlines: the silhouette shader, per instance ---------- */
756
+ function outlineInstanced() {
757
+ return new THREE.ShaderMaterial({
758
+ uniforms: { color: { value: new THREE.Color() }, thickness: outlineMat.uniforms.thickness, res: outlineMat.uniforms.res, opacity: { value: 1 } },
759
+ vertexShader: `uniform float thickness; uniform vec2 res;
760
+ void main() {
761
+ mat4 m = modelViewMatrix;
762
+ #ifdef USE_INSTANCING
763
+ m = m * instanceMatrix;
764
+ #endif
765
+ vec4 p = projectionMatrix * m * vec4(position, 1.0);
766
+ vec4 n = projectionMatrix * vec4(normalize(mat3(m) * normal), 0.0);
767
+ vec2 d = length(n.xy) > 1e-5 ? normalize(n.xy) : vec2(0.0);
768
+ p.xy += d * thickness * 2.0 / res * p.w;
769
+ gl_Position = p;
770
+ }`,
771
+ fragmentShader: OUT_FS, side: THREE.BackSide, transparent: true, polygonOffset: true, polygonOffsetFactor: 6, polygonOffsetUnits: 24,
772
+ });
773
+ }
774
+ // paint()'s colour mix for one colour (instance colours of orbs)
775
+ function mixColor(out, base, vis, hot, hov, col = accent) {
776
+ out.copy(base); if (hov > .01) out.lerp(INKHI, hov * .5); if (hot > .01) out.lerp(col, hot); if (vis < .999) out.lerp(BG_COL, 1 - vis);
777
+ return out;
778
+ }
779
+
780
+ function disposeGroup(g) {
781
+ g.traverse(o => {
782
+ if (o.geometry && !o.geometry.userData.shared) o.geometry.dispose();
783
+ const t = o.userData && o.userData.tp; if (t) { t.tex.dispose(); TXT.delete(t); SCREEN.delete(t); }
784
+ for (const m of [].concat(o.material || [])) {
785
+ if (m === bodyMat || m === lineMat || m === majorMat || m === outlineMat || m === recessMat || m === accentLineMat) continue;
786
+ glowMats.delete(m); softGlows.delete(m); lineMats.delete(m);
787
+ if (m.map) m.map.dispose(); m.dispose();
788
+ }
789
+ });
790
+ if (g.parent) g.parent.remove(g);
791
+ }
792
+ function dispose() {
793
+ disposed = true; if (raf) cancelAnimationFrame(raf); raf = 0;
794
+ media.removeEventListener('change', onScheme); window.removeEventListener('forge-theme', onScheme); document.removeEventListener('visibilitychange', onVisibility);
795
+ if (io) io.disconnect(); if (ro) ro.disconnect();
796
+ controls.dispose(); disposeGroup(scene); target.dispose(); composer.dispose && composer.dispose(); renderer.dispose();
797
+ canvas.remove(); probe.remove();
798
+ }
799
+
800
+ onScheme();
801
+ controls.target.set(0, 0, 0); camera.position.copy(DIR_ISO).multiplyScalar(20);
802
+
803
+ return {
804
+ THREE, W, TS, CW, MONO3, scene, renderer, canvas, controls, host,
805
+ get camera() { return camera; }, get theme() { return theme; }, get PALC() { return PALC; }, get size() { return [VW, VH]; }, get hover() { return hoverAt; },
806
+ accent, BG_COL, INKHI, DETAIL, GLASSON, bodyMat, lineMat, majorMat, outlineMat, recessMat, accentLineMat,
807
+ glowPoints, softMat, fat, loopL, noRay, rbox, inset, seg, polyLine, cumLen, alongPts, moveGlow, roundRect,
808
+ mats, lmat, paint, lineCol, setOp, solid, loopAt, roofPlate, cone,
809
+ tplane, release, roofAt, frontAt, lines, rr, font, fit, wrap, ellipsis, pxIn,
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,
813
+ setProfile, get profile() { return profile; }, unitSolid, solidFrom, disposeShared, sprite, outlineInstanced, mixColor,
814
+ LineSegments2, LineSegmentsGeometry, creaseLines,
815
+ };
816
+ }