@giamme/forge 0.0.0-stage → 0.8.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 +181 -0
- package/LICENSE +21 -0
- package/README.md +1854 -2
- package/SKILL.md +122 -0
- package/VERSION +1 -0
- package/forge +74 -0
- package/package.json +26 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +353 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +665 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runtime.py +533 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +198 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +83 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +287 -0
- package/ui/THIRD_PARTY_NOTICES.md +69 -0
- package/ui/docs/api.md +888 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +204 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +583 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1336 -0
- package/ui/forge_ui/server.py +303 -0
- package/ui/forge_ui/static/app.js +924 -0
- package/ui/forge_ui/static/cloud3d.js +646 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +394 -0
- package/ui/forge_ui/static/diffview.js +216 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/index.html +55 -0
- package/ui/forge_ui/static/iso.js +765 -0
- package/ui/forge_ui/static/leaderboard.js +315 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +526 -0
- package/ui/forge_ui/static/runmodel.js +651 -0
- package/ui/forge_ui/static/scene3d.js +996 -0
- package/ui/forge_ui/static/search.js +119 -0
- package/ui/forge_ui/static/style.css +618 -0
|
@@ -0,0 +1,765 @@
|
|
|
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
|
+
// same tokens as the iso figures' light and dark themes
|
|
45
|
+
const THEMES = {
|
|
46
|
+
dark: { bg: 0x121314, body: 0x1b1c1f, line: 0x4f5357, major: 0x393c3f, edge: 0x27292c, sky: 0xdde4ff, ground: 0x080808, hemi: .7, amb: 0, sun: .7, fill: .2, fadeMin: .45 },
|
|
47
|
+
light: { bg: 0xe9eaea, body: 0xf1f1f0, line: 0x5d6266, major: 0x8a8f93, edge: 0xb9bcbf, sky: 0xffffff, ground: 0xe6e6e6, hemi: .9, amb: 1.6, sun: .35, fill: .15, fadeMin: .8 },
|
|
48
|
+
};
|
|
49
|
+
const PAL = {
|
|
50
|
+
light: { line: '#8e9397', detail: '#c3c6c8', recess: '#dcdddd', ink: '#6a6f73', inkHi: '#16181a', glassOff: '#e4e5e5', glassOn: '#f6ece2', bg: '#f6f6f5' },
|
|
51
|
+
dark: { line: '#4f5357', detail: '#2b2e30', recess: '#0f1011', ink: '#868b8f', inkHi: '#e6e8e9', glassOff: '#141516', glassOn: '#22170f', bg: '#121314' },
|
|
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(light ? '#c3c6c8' : '#34373a'); 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
|
+
function cssColor(className, prop) {
|
|
395
|
+
const key = className + '|' + prop;
|
|
396
|
+
if (!COLORS.has(key)) {
|
|
397
|
+
probe.className = 'stage-probe ' + className;
|
|
398
|
+
const v = getComputedStyle(probe).getPropertyValue(prop).trim();
|
|
399
|
+
let c = null; try { if (v) c = new THREE.Color(v); } catch (_) { c = null; }
|
|
400
|
+
COLORS.set(key, c);
|
|
401
|
+
}
|
|
402
|
+
return COLORS.get(key);
|
|
403
|
+
}
|
|
404
|
+
const statusColor = s => cssColor('st-' + String(s || 'unknown').toLowerCase().replace(/[^a-z0-9_-]/g, ''), '--st') || new THREE.Color('#888888');
|
|
405
|
+
const statusHex = s => '#' + statusColor(s).getHexString();
|
|
406
|
+
|
|
407
|
+
/* ---------- theme: follows the page (prefers-color-scheme), colours from style.css ---------- */
|
|
408
|
+
const themeHooks = new Set();
|
|
409
|
+
let appliedKey = '';
|
|
410
|
+
function setTheme(t) {
|
|
411
|
+
theme = t; const T = THEMES[t], light = t === 'light';
|
|
412
|
+
COLORS.clear();
|
|
413
|
+
const bg = cssColor('', '--stage-bg'), acc = cssColor('', '--stage-accent');
|
|
414
|
+
BG_COL.set(bg || T.bg); scene.background.copy(BG_COL);
|
|
415
|
+
if (acc) accent.copy(acc);
|
|
416
|
+
bodyMat.color.set(T.body);
|
|
417
|
+
LINE_COL.set(T.line); MAJOR_COL.set(T.major); EDGE_COL.set(T.edge); appliedKey = '';
|
|
418
|
+
recessMat.color.set(light ? 0xdcdddd : 0x0f1011);
|
|
419
|
+
accentLineMat.color.copy(accent);
|
|
420
|
+
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
|
+
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; }
|
|
422
|
+
for (const m of softGlows) m.visible = !light;
|
|
423
|
+
palette();
|
|
424
|
+
for (const f of themeHooks) f(t);
|
|
425
|
+
for (const t of TXT) if (!t.gone) t.redraw();
|
|
426
|
+
invalidate();
|
|
427
|
+
}
|
|
428
|
+
const media = matchMedia('(prefers-color-scheme: light)');
|
|
429
|
+
const onScheme = () => setTheme(media.matches ? 'light' : 'dark');
|
|
430
|
+
media.addEventListener('change', onScheme);
|
|
431
|
+
|
|
432
|
+
function applyWeight() {
|
|
433
|
+
const dist = camera.position.distanceTo(controls.target);
|
|
434
|
+
const ppm = camera === ortho ? VH * ortho.zoom / orthoH : VH / (dist * fovK());
|
|
435
|
+
const k = THREE.MathUtils.clamp((ppm / 40 - .35) / .65, 0, 1);
|
|
436
|
+
const fm = THEMES[theme].fadeMin, fade = fm + (1 - fm) * k, dpr = renderer.getPixelRatio();
|
|
437
|
+
const key = fade.toFixed(2) + '|' + dpr + '|' + theme + '|' + (camera === ortho); if (key === appliedKey) return; appliedKey = key;
|
|
438
|
+
lineMat.linewidth = majorMat.linewidth = dpr; accentLineMat.linewidth = 1.6 * dpr;
|
|
439
|
+
lineMat.color.copy(BG_COL).lerp(EDGE_COL, fade);
|
|
440
|
+
majorMat.color.copy(BG_COL).lerp(MAJOR_COL, fade);
|
|
441
|
+
outlineMat.uniforms.thickness.value = .5 * dpr;
|
|
442
|
+
outlineMat.uniforms.color.value.copy(BG_COL).lerp(LINE_COL, fade);
|
|
443
|
+
for (const f of weightHooks) f();
|
|
444
|
+
}
|
|
445
|
+
const weightHooks = new Set();
|
|
446
|
+
|
|
447
|
+
/* ---------- camera: frame a box, keeping the user's angle ---------- */
|
|
448
|
+
const fovK = () => 2 * Math.tan(THREE.MathUtils.degToRad(persp.fov / 2));
|
|
449
|
+
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(); }
|
|
450
|
+
function setOrtho(on) {
|
|
451
|
+
if (on === (camera === ortho)) return;
|
|
452
|
+
if (on) {
|
|
453
|
+
orthoH = camera.position.distanceTo(controls.target) * fovK();
|
|
454
|
+
ortho.position.copy(camera.position); ortho.quaternion.copy(camera.quaternion); ortho.zoom = 1; orthoFrustum();
|
|
455
|
+
camera = ortho;
|
|
456
|
+
} else {
|
|
457
|
+
const d = orthoH / fovK() / ortho.zoom;
|
|
458
|
+
persp.position.copy(controls.target).add(ortho.position.clone().sub(controls.target).setLength(d));
|
|
459
|
+
persp.quaternion.copy(ortho.quaternion); camera = persp;
|
|
460
|
+
}
|
|
461
|
+
controls.object = camera; renderPass.camera = camera; controls.update(); appliedKey = '';
|
|
462
|
+
for (const m of glowMats) m.uniforms.ortho.value = on ? 1 : 0;
|
|
463
|
+
invalidate();
|
|
464
|
+
}
|
|
465
|
+
const DIR_ISO = new THREE.Vector3(1, 1, 1).normalize(), UP = new THREE.Vector3(0, 1, 0);
|
|
466
|
+
let CAMT = null, NOW = performance.now();
|
|
467
|
+
const ez = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
468
|
+
const clamp01 = v => Math.max(0, Math.min(1, v));
|
|
469
|
+
// bx: a plan box [x0, y0, z0, x1, y1, z1], or {pts: [[x, y, z], …]} to fit the points themselves (tighter for
|
|
470
|
+
// round or scattered things); inset: pixels on each side that overlays cover, kept clear of what is framed
|
|
471
|
+
function fitFor(bx, dir, inset = null) {
|
|
472
|
+
const f = dir.clone().negate(), r = new THREE.Vector3().crossVectors(f, UP).normalize(), u = new THREE.Vector3().crossVectors(r, f);
|
|
473
|
+
const pts = Array.isArray(bx) ? [] : bx.pts.map(p => W(...p));
|
|
474
|
+
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));
|
|
475
|
+
if (!pts.length) pts.push(new THREE.Vector3());
|
|
476
|
+
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
477
|
+
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]); } }
|
|
478
|
+
const c = r.clone().multiplyScalar((lo[0] + hi[0]) / 2).addScaledVector(u, (lo[1] + hi[1]) / 2).addScaledVector(f, (lo[2] + hi[2]) / 2);
|
|
479
|
+
const I = { l: 0, r: 0, t: 0, b: 0, ...(inset || {}) };
|
|
480
|
+
if (I.l + I.r > VW * .7) I.l = I.r = 0; // overlays that cover most of the stage: frame under them
|
|
481
|
+
if (I.t + I.b > VH * .7) I.t = I.b = 0;
|
|
482
|
+
const fw = (VW - I.l - I.r) / VW, fh = (VH - I.t - I.b) / VH;
|
|
483
|
+
const tv = Math.tan(THREE.MathUtils.degToRad(persp.fov / 2)), th = tv * VW / VH;
|
|
484
|
+
let D = .5;
|
|
485
|
+
for (const p0 of pts) {
|
|
486
|
+
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 * .96) - pz, Math.abs(py) / (tv * fh * .92) - pz);
|
|
488
|
+
}
|
|
489
|
+
// what is framed sits in the middle of the uncovered part of the stage
|
|
490
|
+
c.addScaledVector(r, -(I.l - I.r) / VW * D * th).addScaledVector(u, (I.t - I.b) / VH * D * tv);
|
|
491
|
+
return { c, D };
|
|
492
|
+
}
|
|
493
|
+
function frameBox(bx, dur = 900, minEl = 26, dirOverride = null, inset = null) {
|
|
494
|
+
resize(); // the host may have just been shown or moved: fit its real size
|
|
495
|
+
let dir = dirOverride ? dirOverride.clone().normalize() : camera.position.clone().sub(controls.target);
|
|
496
|
+
dir = dir.lengthSq() > 1e-8 ? dir.normalize() : DIR_ISO.clone();
|
|
497
|
+
const el = Math.asin(THREE.MathUtils.clamp(dir.y, -1, 1)), lo = THREE.MathUtils.degToRad(minEl);
|
|
498
|
+
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)); }
|
|
499
|
+
const { c, D } = fitFor(bx, dir, inset);
|
|
500
|
+
const to = { t: c, off: dir.clone().multiplyScalar(D), zoom: camera === ortho ? orthoH / (D * fovK()) : null };
|
|
501
|
+
if (reduceMotion || dur <= 1) {
|
|
502
|
+
controls.target.copy(to.t); camera.position.copy(to.t).add(to.off);
|
|
503
|
+
if (to.zoom) { ortho.zoom = to.zoom; ortho.updateProjectionMatrix(); }
|
|
504
|
+
CAMT = null; invalidate(); return;
|
|
505
|
+
}
|
|
506
|
+
CAMT = { t0: performance.now(), dur, fromT: controls.target.clone(), fromO: camera.position.clone().sub(controls.target), fromZ: camera === ortho ? ortho.zoom : null, to };
|
|
507
|
+
controls.enableDamping = false; invalidate();
|
|
508
|
+
}
|
|
509
|
+
function camStep(now) {
|
|
510
|
+
if (!CAMT) return false;
|
|
511
|
+
const k = ez(clamp01((now - CAMT.t0) / CAMT.dur));
|
|
512
|
+
controls.target.lerpVectors(CAMT.fromT, CAMT.to.t, k);
|
|
513
|
+
camera.position.copy(controls.target).add(CAMT.fromO.clone().lerp(CAMT.to.off, k));
|
|
514
|
+
if (camera === ortho && CAMT.to.zoom && CAMT.fromZ) { ortho.zoom = THREE.MathUtils.lerp(CAMT.fromZ, CAMT.to.zoom, k); ortho.updateProjectionMatrix(); }
|
|
515
|
+
if (k >= 1) { controls.enableDamping = true; CAMT = null; }
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
const cancelCam = () => { if (CAMT) { controls.enableDamping = true; CAMT = null; } };
|
|
519
|
+
const settleCam = () => { if (CAMT) { camStep(CAMT.t0 + CAMT.dur + 1); controls.update(); } }; // jump to where a glide ends (tests)
|
|
520
|
+
|
|
521
|
+
/* ---------- the loop: frames only while something moves ---------- */
|
|
522
|
+
const tickers = new Set();
|
|
523
|
+
let raf = 0, last = 0, wakeUntil = 0, visible = true, onScreen = true, disposed = false;
|
|
524
|
+
const tmpV = new THREE.Vector2();
|
|
525
|
+
function invalidate(ms = 0) {
|
|
526
|
+
if (disposed) return;
|
|
527
|
+
if (ms) wakeUntil = Math.max(wakeUntil, performance.now() + ms);
|
|
528
|
+
if (!raf && visible && onScreen) raf = requestAnimationFrame(frame);
|
|
529
|
+
}
|
|
530
|
+
function frame(now) {
|
|
531
|
+
raf = 0; if (disposed) return;
|
|
532
|
+
// capped, not fixed: on a slow GPU an opening still finishes on time, in fewer frames
|
|
533
|
+
const dt = last ? Math.min(.25, (now - last) / 1000) : 1 / 60; last = now; NOW = now;
|
|
534
|
+
let more = camStep(now);
|
|
535
|
+
for (const f of tickers) if (f(now, dt)) more = true;
|
|
536
|
+
if (controls.update()) more = true;
|
|
537
|
+
applyWeight();
|
|
538
|
+
const bufH = renderer.getDrawingBufferSize(tmpV).y, pxScale = camera === ortho ? bufH * ortho.zoom / orthoH : bufH / fovK();
|
|
539
|
+
for (const m of glowMats) m.uniforms.scale.value = pxScale;
|
|
540
|
+
composer.render();
|
|
541
|
+
// controls.update() fires 'change', whose invalidate() may already have asked for the next frame:
|
|
542
|
+
// ask once, or the copies double every frame while the camera moves
|
|
543
|
+
if ((more || now < wakeUntil) && visible && onScreen) { if (!raf) raf = requestAnimationFrame(frame); }
|
|
544
|
+
else if (!raf) last = 0;
|
|
545
|
+
}
|
|
546
|
+
controls.addEventListener('change', () => invalidate());
|
|
547
|
+
const onVisibility = () => { visible = document.visibilityState === 'visible'; if (visible) invalidate(); };
|
|
548
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
549
|
+
const io = typeof IntersectionObserver === 'function' ? new IntersectionObserver(es => { onScreen = es.some(e => e.isIntersecting); if (onScreen) invalidate(); }) : null;
|
|
550
|
+
if (io) io.observe(host);
|
|
551
|
+
function resize() {
|
|
552
|
+
const [w, h] = box(); if (w === VW && h === VH) return;
|
|
553
|
+
VW = w; VH = h;
|
|
554
|
+
persp.aspect = VW / VH; persp.updateProjectionMatrix(); orthoFrustum();
|
|
555
|
+
renderer.setSize(VW, VH); composer.setSize(VW, VH);
|
|
556
|
+
const res = devRes();
|
|
557
|
+
outlineMat.uniforms.res.value.copy(res); lineMat.resolution.copy(res); majorMat.resolution.copy(res); accentLineMat.resolution.copy(res);
|
|
558
|
+
appliedKey = ''; invalidate();
|
|
559
|
+
}
|
|
560
|
+
const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
|
|
561
|
+
if (ro) ro.observe(host);
|
|
562
|
+
|
|
563
|
+
/* ---------- pointer: picking, click (not drag) and hover ---------- */
|
|
564
|
+
const RAY = new THREE.Raycaster(), NDC = new THREE.Vector2();
|
|
565
|
+
function pick(cx, cy, objects, ok) {
|
|
566
|
+
const r = canvas.getBoundingClientRect();
|
|
567
|
+
NDC.set((cx - r.left) / r.width * 2 - 1, -((cy - r.top) / r.height) * 2 + 1); RAY.setFromCamera(NDC, camera);
|
|
568
|
+
for (const h of RAY.intersectObjects(objects, false)) if (!ok || ok(h.object)) return h.object;
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
// screen position (px inside the canvas) of a plan point
|
|
572
|
+
const VP = new THREE.Vector3();
|
|
573
|
+
function project(x, y, z) {
|
|
574
|
+
camera.updateMatrixWorld(); VP.copy(W(x, y, z)).project(camera);
|
|
575
|
+
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 };
|
|
576
|
+
}
|
|
577
|
+
function pickHit(cx, cy, objects, ok) {
|
|
578
|
+
rayAt(cx, cy);
|
|
579
|
+
for (const h of RAY.intersectObjects(objects, false)) if (!ok || ok(h.object, h)) return { object: h.object, instanceId: h.instanceId, point: h.point };
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
function rayAt(cx, cy) {
|
|
583
|
+
const r = canvas.getBoundingClientRect();
|
|
584
|
+
NDC.set((cx - r.left) / r.width * 2 - 1, -((cy - r.top) / r.height) * 2 + 1); RAY.setFromCamera(NDC, camera);
|
|
585
|
+
return RAY;
|
|
586
|
+
}
|
|
587
|
+
/* Control profiles: 'plate' is the run and map views (orbit above the plate, zoom only once the stage
|
|
588
|
+
is engaged, so the page still scrolls); 'free' is the full-screen cloud (look from anywhere, always zoom). */
|
|
589
|
+
let profile = 'plate', engaged = false;
|
|
590
|
+
function setProfile(name) {
|
|
591
|
+
profile = name === 'free' ? 'free' : 'plate';
|
|
592
|
+
const free = profile === 'free';
|
|
593
|
+
controls.minPolarAngle = free ? .02 : 0;
|
|
594
|
+
controls.maxPolarAngle = free ? Math.PI - .02 : Math.PI * .495;
|
|
595
|
+
controls.enableZoom = free || engaged;
|
|
596
|
+
controls.screenSpacePanning = true;
|
|
597
|
+
controls.zoomToCursor = free;
|
|
598
|
+
controls.maxDistance = free ? 2500 : 400;
|
|
599
|
+
persp.far = ortho.far = free ? 6000 : 900;
|
|
600
|
+
persp.updateProjectionMatrix(); ortho.updateProjectionMatrix();
|
|
601
|
+
invalidate();
|
|
602
|
+
}
|
|
603
|
+
const handlers = { click: null, hover: null, key: null, grab: null, drag: null, drop: null, dbl: null };
|
|
604
|
+
let down = null, hoverAt = null, grab = null;
|
|
605
|
+
const engage = on => { engaged = on; controls.enableZoom = profile === 'free' || on; canvas.classList.toggle('engaged', on); };
|
|
606
|
+
// A grab claims the pointer before OrbitControls sees it: this capture listener on the host runs first,
|
|
607
|
+
// and controls.enabled = false makes OrbitControls ignore the press that follows on the canvas.
|
|
608
|
+
host.addEventListener('pointerdown', e => {
|
|
609
|
+
if (e.button !== 0 || !handlers.grab || e.target !== canvas) return;
|
|
610
|
+
const token = handlers.grab(e.clientX, e.clientY, e); if (!token) return;
|
|
611
|
+
grab = { token, x: e.clientX, y: e.clientY, moved: false, id: e.pointerId, damping: controls.enableDamping };
|
|
612
|
+
controls.enabled = false; controls.enableDamping = false;
|
|
613
|
+
try { canvas.setPointerCapture(e.pointerId); } catch (_) { /* no capture: moves still arrive over the canvas */ }
|
|
614
|
+
canvas.classList.add('grabbing');
|
|
615
|
+
}, true);
|
|
616
|
+
function endGrab(e, how) {
|
|
617
|
+
const g = grab; grab = null; if (!g) return;
|
|
618
|
+
controls.enabled = true; controls.enableDamping = g.damping;
|
|
619
|
+
try { canvas.releasePointerCapture(g.id); } catch (_) { /* already released */ }
|
|
620
|
+
canvas.classList.remove('grabbing');
|
|
621
|
+
if (how === 'cancel') { if (handlers.drop) handlers.drop(0, 0, e, g.token, { cancel: true }); }
|
|
622
|
+
else {
|
|
623
|
+
if (!g.moved && handlers.click) handlers.click(e.clientX, e.clientY, e);
|
|
624
|
+
if (handlers.drop) handlers.drop(e.clientX, e.clientY, e, g.token, { moved: g.moved });
|
|
625
|
+
}
|
|
626
|
+
invalidate();
|
|
627
|
+
}
|
|
628
|
+
canvas.addEventListener('pointerdown', e => { down = [e.clientX, e.clientY]; cancelCam(); engage(true); canvas.focus({ preventScroll: true }); });
|
|
629
|
+
canvas.addEventListener('pointerup', e => {
|
|
630
|
+
if (grab) { down = null; endGrab(e, 'up'); return; }
|
|
631
|
+
if (!down || Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 5) { down = null; return; }
|
|
632
|
+
down = null; if (handlers.click) handlers.click(e.clientX, e.clientY, e);
|
|
633
|
+
});
|
|
634
|
+
canvas.addEventListener('pointercancel', e => { if (grab) endGrab(e, 'cancel'); });
|
|
635
|
+
canvas.addEventListener('pointermove', e => {
|
|
636
|
+
hoverAt = [e.clientX, e.clientY];
|
|
637
|
+
if (grab) {
|
|
638
|
+
if (!grab.moved && Math.hypot(e.clientX - grab.x, e.clientY - grab.y) > 5) grab.moved = true;
|
|
639
|
+
if (grab.moved && handlers.drag) { handlers.drag(e.clientX, e.clientY, e, grab.token); invalidate(); }
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (!down && handlers.hover) handlers.hover(e.clientX, e.clientY);
|
|
643
|
+
});
|
|
644
|
+
canvas.addEventListener('pointerleave', () => { hoverAt = null; if (!grab && handlers.hover) handlers.hover(null, null); });
|
|
645
|
+
canvas.addEventListener('dblclick', e => { if (handlers.dbl) handlers.dbl(e.clientX, e.clientY, e); });
|
|
646
|
+
canvas.addEventListener('blur', () => engage(false));
|
|
647
|
+
canvas.addEventListener('wheel', () => cancelCam(), { passive: true });
|
|
648
|
+
canvas.addEventListener('keydown', e => {
|
|
649
|
+
if (grab && e.key === 'Escape') { e.preventDefault(); endGrab(e, 'cancel'); return; }
|
|
650
|
+
if (handlers.key && handlers.key(e)) { e.preventDefault(); invalidate(); }
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
/* ---------- shared solids: one extruded box and its crease lines per size, reused by many elements ---------- */
|
|
654
|
+
const SHARED = new Map();
|
|
655
|
+
function unitSolid(key, w, d, h, r) {
|
|
656
|
+
if (SHARED.has(key)) return SHARED.get(key);
|
|
657
|
+
r = Math.max(.01, Math.min(r, w / 2 - .01, d / 2 - .01));
|
|
658
|
+
const sh = new THREE.Shape(); roundRect(sh, -w / 2, -d / 2, w, d, r);
|
|
659
|
+
const geo = new THREE.ExtrudeGeometry(sh, { depth: h, bevelEnabled: false, curveSegments: r > 3 ? 10 : 4 });
|
|
660
|
+
geo.rotateX(-Math.PI / 2); geo.scale(TS, TS, TS);
|
|
661
|
+
const l30 = new LineSegmentsGeometry().setPositions(creaseLines(geo, 30));
|
|
662
|
+
const l60 = new LineSegmentsGeometry().setPositions(creaseLines(geo, 60));
|
|
663
|
+
for (const g of [geo, l30, l60]) g.userData.shared = key;
|
|
664
|
+
const unit = { key, w, d, h, geo, l30, l60 };
|
|
665
|
+
SHARED.set(key, unit); return unit;
|
|
666
|
+
}
|
|
667
|
+
// an element on a shared solid: its own materials (so paint() lights it alone), the unit's geometry
|
|
668
|
+
function solidFrom(unit, M, parent, ud) {
|
|
669
|
+
const m = new THREE.Mesh(unit.geo, M.body); m.userData = ud || {};
|
|
670
|
+
m.add(noRay(new THREE.Mesh(unit.geo, M.out)));
|
|
671
|
+
const a = noRay(new LineSegments2(unit.l30, M.line)), b = noRay(new LineSegments2(unit.l60, M.major)); b.renderOrder = 1;
|
|
672
|
+
a.userData.crease = true; m.add(a, b);
|
|
673
|
+
parent.add(m); return m;
|
|
674
|
+
}
|
|
675
|
+
function disposeShared(prefix = '') {
|
|
676
|
+
for (const [k, u] of [...SHARED]) if (k.startsWith(prefix)) { u.geo.dispose(); u.l30.dispose(); u.l60.dispose(); SHARED.delete(k); }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/* ---------- sprites: labels that always face the camera ---------- */
|
|
680
|
+
// screen: true keeps a constant size in pixels (px tall), else the sprite is w × h plan units in the world
|
|
681
|
+
const SCREEN = new Set();
|
|
682
|
+
function sprite(w, h, P, draw, { screen = false, px = 14 } = {}) {
|
|
683
|
+
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));
|
|
684
|
+
const c = document.createElement('canvas'); c.width = cw; c.height = ch;
|
|
685
|
+
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
|
|
686
|
+
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, toneMapped: false, sizeAttenuation: !screen });
|
|
687
|
+
const sp = new THREE.Sprite(mat); sp.renderOrder = 4;
|
|
688
|
+
const t = { c, x: c.getContext('2d'), tex, s, w, h, mesh: sp, mat, gone: false, screen, px };
|
|
689
|
+
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; };
|
|
690
|
+
tex.onUpdate = () => { c.width = c.height = 1; };
|
|
691
|
+
sp.onBeforeRender = () => { if (t.gone) t.redraw(); };
|
|
692
|
+
sp.userData.tp = t;
|
|
693
|
+
if (screen) { SCREEN.add(t); screenScale(t); } else sp.scale.set(w * TS, h * TS, 1);
|
|
694
|
+
t.redraw(); TXT.add(t); return t;
|
|
695
|
+
}
|
|
696
|
+
// a screen sprite's world scale for its pixel height: perspective sprites without size attenuation are
|
|
697
|
+
// sized in view units at distance 1; orthographic ones in world units
|
|
698
|
+
function screenScale(t) {
|
|
699
|
+
const unit = camera === ortho ? (ortho.top - ortho.bottom) / ortho.zoom / VH : 2 * Math.tan(THREE.MathUtils.degToRad(persp.fov / 2)) / VH;
|
|
700
|
+
t.mesh.scale.set(t.px * t.w / t.h * unit, t.px * unit, 1);
|
|
701
|
+
}
|
|
702
|
+
weightHooks.add(() => { for (const t of SCREEN) screenScale(t); });
|
|
703
|
+
|
|
704
|
+
/* ---------- instanced outlines: the silhouette shader, per instance ---------- */
|
|
705
|
+
function outlineInstanced() {
|
|
706
|
+
return new THREE.ShaderMaterial({
|
|
707
|
+
uniforms: { color: { value: new THREE.Color() }, thickness: outlineMat.uniforms.thickness, res: outlineMat.uniforms.res, opacity: { value: 1 } },
|
|
708
|
+
vertexShader: `uniform float thickness; uniform vec2 res;
|
|
709
|
+
void main() {
|
|
710
|
+
mat4 m = modelViewMatrix;
|
|
711
|
+
#ifdef USE_INSTANCING
|
|
712
|
+
m = m * instanceMatrix;
|
|
713
|
+
#endif
|
|
714
|
+
vec4 p = projectionMatrix * m * vec4(position, 1.0);
|
|
715
|
+
vec4 n = projectionMatrix * vec4(normalize(mat3(m) * normal), 0.0);
|
|
716
|
+
vec2 d = length(n.xy) > 1e-5 ? normalize(n.xy) : vec2(0.0);
|
|
717
|
+
p.xy += d * thickness * 2.0 / res * p.w;
|
|
718
|
+
gl_Position = p;
|
|
719
|
+
}`,
|
|
720
|
+
fragmentShader: OUT_FS, side: THREE.BackSide, transparent: true, polygonOffset: true, polygonOffsetFactor: 6, polygonOffsetUnits: 24,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
// paint()'s colour mix for one colour (instance colours of orbs)
|
|
724
|
+
function mixColor(out, base, vis, hot, hov, col = accent) {
|
|
725
|
+
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);
|
|
726
|
+
return out;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function disposeGroup(g) {
|
|
730
|
+
g.traverse(o => {
|
|
731
|
+
if (o.geometry && !o.geometry.userData.shared) o.geometry.dispose();
|
|
732
|
+
const t = o.userData && o.userData.tp; if (t) { t.tex.dispose(); TXT.delete(t); SCREEN.delete(t); }
|
|
733
|
+
for (const m of [].concat(o.material || [])) {
|
|
734
|
+
if (m === bodyMat || m === lineMat || m === majorMat || m === outlineMat || m === recessMat || m === accentLineMat) continue;
|
|
735
|
+
glowMats.delete(m); softGlows.delete(m); lineMats.delete(m);
|
|
736
|
+
if (m.map) m.map.dispose(); m.dispose();
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
if (g.parent) g.parent.remove(g);
|
|
740
|
+
}
|
|
741
|
+
function dispose() {
|
|
742
|
+
disposed = true; if (raf) cancelAnimationFrame(raf); raf = 0;
|
|
743
|
+
media.removeEventListener('change', onScheme); document.removeEventListener('visibilitychange', onVisibility);
|
|
744
|
+
if (io) io.disconnect(); if (ro) ro.disconnect();
|
|
745
|
+
controls.dispose(); disposeGroup(scene); target.dispose(); composer.dispose && composer.dispose(); renderer.dispose();
|
|
746
|
+
canvas.remove(); probe.remove();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
onScheme();
|
|
750
|
+
controls.target.set(0, 0, 0); camera.position.copy(DIR_ISO).multiplyScalar(20);
|
|
751
|
+
|
|
752
|
+
return {
|
|
753
|
+
THREE, W, TS, CW, MONO3, scene, renderer, canvas, controls, host,
|
|
754
|
+
get camera() { return camera; }, get theme() { return theme; }, get PALC() { return PALC; }, get size() { return [VW, VH]; }, get hover() { return hoverAt; },
|
|
755
|
+
accent, BG_COL, INKHI, DETAIL, GLASSON, bodyMat, lineMat, majorMat, outlineMat, recessMat, accentLineMat,
|
|
756
|
+
glowPoints, softMat, fat, loopL, noRay, rbox, inset, seg, polyLine, cumLen, alongPts, moveGlow, roundRect,
|
|
757
|
+
mats, lmat, paint, lineCol, setOp, solid, loopAt, roofPlate, cone,
|
|
758
|
+
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,
|
|
762
|
+
setProfile, get profile() { return profile; }, unitSolid, solidFrom, disposeShared, sprite, outlineInstanced, mixColor,
|
|
763
|
+
LineSegments2, LineSegmentsGeometry, creaseLines,
|
|
764
|
+
};
|
|
765
|
+
}
|