topotopo 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -12
- package/dist/cli.js +161 -23
- package/dist/graph/engine.js +18 -1
- package/dist/graph/params.d.ts +7 -1
- package/dist/graph/params.js +21 -8
- package/dist/graph/types.d.ts +28 -2
- package/dist/host/artifacts.d.ts +21 -0
- package/dist/host/artifacts.js +58 -0
- package/dist/host/export-targets.d.ts +33 -0
- package/dist/host/export-targets.js +84 -0
- package/dist/host/export_scene.py +200 -0
- package/dist/host/node.d.ts +8 -0
- package/dist/host/node.js +114 -0
- package/dist/host/types.d.ts +5 -0
- package/dist/inspect.js +5 -2
- package/dist/io/common.d.ts +6 -0
- package/dist/io/export.js +7 -4
- package/dist/io/gltf.d.ts +1 -1
- package/dist/io/gltf.js +167 -32
- package/dist/io/heightmap.d.ts +1 -1
- package/dist/io/import.d.ts +2 -2
- package/dist/io/import.js +10 -3
- package/dist/io/png-decode.d.ts +5 -2
- package/dist/io/png-decode.js +10 -3
- package/dist/io/targets.d.ts +31 -0
- package/dist/io/targets.js +75 -0
- package/dist/io/textures.d.ts +31 -2
- package/dist/io/textures.js +66 -18
- package/dist/io/unreal.js +22 -5
- package/dist/mcp/docs.d.ts +9 -2
- package/dist/mcp/docs.js +126 -2
- package/dist/mcp/inputs.d.ts +28 -0
- package/dist/mcp/inputs.js +377 -0
- package/dist/mcp/server.js +4 -1
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +118 -51
- package/dist/nodes/code.js +34 -6
- package/dist/nodes/copy.js +14 -4
- package/dist/nodes/flow.js +136 -35
- package/dist/nodes/heightfield.js +67 -11
- package/dist/nodes/texture.d.ts +1 -1
- package/dist/nodes/texture.js +61 -5
- package/dist/render/options.js +2 -1
- package/dist/render/raster.d.ts +2 -0
- package/dist/render/raster.js +3 -3
- package/dist/render/texview.d.ts +9 -1
- package/dist/render/texview.js +43 -2
- package/dist/viewer/app/api.js +64 -0
- package/dist/viewer/app/camera.js +250 -0
- package/dist/viewer/app/export-popover.js +68 -0
- package/dist/viewer/app/grid.js +217 -0
- package/dist/viewer/app/guides.js +402 -0
- package/dist/viewer/app/image-view.js +114 -0
- package/dist/viewer/app/keymap.js +199 -0
- package/dist/viewer/app/layers.js +244 -0
- package/dist/viewer/app/main.js +646 -0
- package/dist/viewer/app/markers.js +206 -0
- package/dist/viewer/app/mesh.js +172 -0
- package/dist/viewer/app/outliner.js +225 -0
- package/dist/viewer/app/outputs.css +73 -0
- package/dist/viewer/app/outputs.js +240 -0
- package/dist/viewer/app/props.js +432 -0
- package/dist/viewer/app/scene.js +529 -0
- package/dist/viewer/app/select.js +153 -0
- package/dist/viewer/app/selection.js +79 -0
- package/dist/viewer/app/status.js +20 -0
- package/dist/viewer/app/style.css +173 -0
- package/dist/viewer/app/undo.js +49 -0
- package/dist/viewer/app/units.js +51 -0
- package/dist/viewer/app/vars.js +64 -0
- package/dist/viewer/index.html +67 -184
- package/dist/viewer/outputs.d.ts +54 -0
- package/dist/viewer/outputs.js +436 -0
- package/dist/viewer/server.d.ts +47 -1
- package/dist/viewer/server.js +480 -17
- package/dist/workspace.d.ts +60 -4
- package/dist/workspace.js +131 -5
- package/package.json +9 -3
- package/shelf/brick_wall.json +1 -1
- package/shelf/wall_segment.json +2 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The viewer server's routes (src/viewer/server.ts), one function each. Every call resolves (never throws): failures
|
|
2
|
+
// come back as {ok: false, error} so the page can show them in the status bar.
|
|
3
|
+
|
|
4
|
+
const fail = (error) => ({ ok: false, error: String(error) });
|
|
5
|
+
const offline = 'the viewer server is not answering';
|
|
6
|
+
const q = (o) => Object.entries(o).map(([k, v]) => `${k}=${encodeURIComponent(v ?? '')}`).join('&');
|
|
7
|
+
|
|
8
|
+
async function json(url, init) {
|
|
9
|
+
let res;
|
|
10
|
+
try { res = await fetch(url, init); } catch { return { status: 0, j: { ok: false, error: offline } }; }
|
|
11
|
+
const j = await res.json().catch(() => ({ ok: false, error: `${res.status} ${res.statusText}` }));
|
|
12
|
+
return { status: res.status, j };
|
|
13
|
+
}
|
|
14
|
+
/** A GLB route: {ok, buf} or {ok: false, error}. */
|
|
15
|
+
async function glb(url) {
|
|
16
|
+
let res;
|
|
17
|
+
try { res = await fetch(url); } catch { return fail(offline); }
|
|
18
|
+
if (!res.ok) { const j = await res.json().catch(() => ({})); return fail(j.error ?? `${res.status} ${res.statusText}`); }
|
|
19
|
+
return { ok: true, buf: await res.arrayBuffer() };
|
|
20
|
+
}
|
|
21
|
+
const post = (body) => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
22
|
+
|
|
23
|
+
export const api = {
|
|
24
|
+
/** [{name, output, version, vars, params, nodes: [{name, type, in, error, ms}]}] or null */
|
|
25
|
+
async graphs() { const { status, j } = await json('/api/graphs'); return status === 200 && Array.isArray(j) ? j : null; },
|
|
26
|
+
/** the last cook of a graph: {ok, ms, target, errors, warnings} */
|
|
27
|
+
async report(graph) { const { status, j } = await json('/api/report?' + q({ graph })); return status === 200 ? j : null; },
|
|
28
|
+
geo: (graph, node) => glb('/api/geo?' + q({ graph, node })),
|
|
29
|
+
/** {target, summary, ms} or null */
|
|
30
|
+
async info(graph, node) { const { status, j } = await json('/api/info?' + q({ graph, node })); return status === 200 ? j : null; },
|
|
31
|
+
/** {ok, ms, errors, vars?} */
|
|
32
|
+
async vars(graph, vars) { const { status, j } = await json('/api/vars?' + q({ graph }), post({ vars })); return status ? j : { ok: false, ms: 0, errors: [offline] }; },
|
|
33
|
+
async inputs() { const { status, j } = await json('/api/inputs'); return status === 200 && Array.isArray(j) ? j : null; },
|
|
34
|
+
/** {dir, where} or null */
|
|
35
|
+
async inputsDir() { const { status, j } = await json('/api/inputs_dir'); return status === 200 ? j : null; },
|
|
36
|
+
/** POST /api/inputs: {status, j} as the server answers (409 when the file exists and overwrite is false) */
|
|
37
|
+
async upload(file, overwrite) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch('/api/inputs?' + q({ name: file.name, overwrite: overwrite ? 1 : 0 }), { method: 'POST', body: file });
|
|
40
|
+
return { status: res.status, j: await res.json().catch(() => ({})) };
|
|
41
|
+
} catch (e) { return { status: 0, j: { error: `upload failed: ${e?.message ?? e}` } }; }
|
|
42
|
+
},
|
|
43
|
+
input: (path) => glb('/api/input?' + q({ path })),
|
|
44
|
+
/** {ok, summary} or {ok: false, error} */
|
|
45
|
+
async inputInfo(path) {
|
|
46
|
+
const { status, j } = await json('/api/input_info?' + q({ path }));
|
|
47
|
+
return status === 200 ? { ok: true, summary: String(j.summary ?? '') } : fail(j.error ?? `${status}`);
|
|
48
|
+
},
|
|
49
|
+
/** {markers, error?} */
|
|
50
|
+
async markers() { const { status, j } = await json('/api/markers'); return status === 200 && Array.isArray(j.markers) ? j : { markers: null, error: j.error ?? offline }; },
|
|
51
|
+
/** {ok, count, points, prims} or {ok: false, error} */
|
|
52
|
+
async saveMarkers(body) {
|
|
53
|
+
const { status, j } = await json('/api/markers', { method: 'POST', headers: { 'content-type': 'application/json' }, body });
|
|
54
|
+
return status === 200 && j.ok ? j : fail(j.error ?? `${status}`);
|
|
55
|
+
},
|
|
56
|
+
/** The event stream: on({open, error, message}); returns the EventSource. */
|
|
57
|
+
events(on) {
|
|
58
|
+
const es = new EventSource('/api/events');
|
|
59
|
+
es.onopen = () => on.open?.();
|
|
60
|
+
es.onerror = () => on.error?.();
|
|
61
|
+
es.onmessage = (m) => { let ev; try { ev = JSON.parse(m.data); } catch { return; } on.message?.(ev); };
|
|
62
|
+
return es;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Cameras and views (docs/design/viewer.md section 3): our own controller, no OrbitControls. Tumble around a pivot,
|
|
2
|
+
// track, dolly towards a point, no inertia: the camera moves only while the person drags, scrolls or frames. Seven
|
|
3
|
+
// named cameras (persp and the axis views); one view, or four in a 2×2 quad, each showing one of them with its own
|
|
4
|
+
// shading. Orthographic cameras cannot tumble. Near and far planes follow the scene size (the renderer keeps a
|
|
5
|
+
// logarithmic depth buffer), so a 1 km terrain and a 2 cm bolt both draw.
|
|
6
|
+
import * as THREE from 'three';
|
|
7
|
+
|
|
8
|
+
export const CAMERA_NAMES = ['persp', 'top', 'front', 'right', 'bottom', 'back', 'left'];
|
|
9
|
+
/** Axis views as the render tool's (src/render/raster.ts ORTHO): view direction f and up u. */
|
|
10
|
+
export const AXIS_VIEWS = {
|
|
11
|
+
top: { f: [0, -1, 0], u: [0, 0, -1] }, bottom: { f: [0, 1, 0], u: [0, 0, 1] },
|
|
12
|
+
front: { f: [0, 0, -1], u: [0, 1, 0] }, back: { f: [0, 0, 1], u: [0, 1, 0] },
|
|
13
|
+
right: { f: [-1, 0, 0], u: [0, 1, 0] }, left: { f: [1, 0, 0], u: [0, 1, 0] },
|
|
14
|
+
};
|
|
15
|
+
/** The axis view that looks from the + or − side of an axis (the axis gizmo's ends). */
|
|
16
|
+
export const VIEW_OF_AXIS = { '+x': 'right', '-x': 'left', '+y': 'top', '-y': 'bottom', '+z': 'front', '-z': 'back' };
|
|
17
|
+
export const SHADINGS = [['wire', 'wire'], ['shaded', 'shaded'], ['shadedWire', 'shaded + wire']];
|
|
18
|
+
/** The render tool's persp: 32° across the narrower side, from azimuth 35°, elevation 25°. */
|
|
19
|
+
const FOV = 32, AZ = 35, EL = 25;
|
|
20
|
+
const TUMBLE = 0.3 * Math.PI / 180; // radians per pixel
|
|
21
|
+
const MIN_D = 1e-4, MAX_D = 1e7;
|
|
22
|
+
|
|
23
|
+
const quatOf = (f, u) => {
|
|
24
|
+
const z = new THREE.Vector3(...f).negate(), y = new THREE.Vector3(...u), x = new THREE.Vector3().crossVectors(y, z);
|
|
25
|
+
return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(x, y, z));
|
|
26
|
+
};
|
|
27
|
+
const perspQuat = () => {
|
|
28
|
+
const az = AZ * Math.PI / 180, el = EL * Math.PI / 180;
|
|
29
|
+
const back = new THREE.Vector3(Math.cos(el) * Math.sin(az), Math.sin(el), Math.cos(el) * Math.cos(az));
|
|
30
|
+
const m = new THREE.Matrix4().lookAt(back, new THREE.Vector3(), new THREE.Vector3(0, 1, 0));
|
|
31
|
+
return new THREE.Quaternion().setFromRotationMatrix(m);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** One named camera: where it looks (target), how (orientation), how far (persp distance / ortho half height). */
|
|
35
|
+
class Cam {
|
|
36
|
+
constructor(name) {
|
|
37
|
+
this.name = name;
|
|
38
|
+
this.ortho = name !== 'persp';
|
|
39
|
+
this.target = new THREE.Vector3();
|
|
40
|
+
this.pivot = new THREE.Vector3();
|
|
41
|
+
this.quat = name === 'persp' ? perspQuat() : quatOf(AXIS_VIEWS[name].f, AXIS_VIEWS[name].u);
|
|
42
|
+
this.dist = 12;
|
|
43
|
+
this.halfH = 6;
|
|
44
|
+
}
|
|
45
|
+
get back() { return new THREE.Vector3(0, 0, 1).applyQuaternion(this.quat); }
|
|
46
|
+
get right() { return new THREE.Vector3(1, 0, 0).applyQuaternion(this.quat); }
|
|
47
|
+
get up() { return new THREE.Vector3(0, 1, 0).applyQuaternion(this.quat); }
|
|
48
|
+
get eye() { return this.target.clone().addScaledVector(this.back, this.dist); }
|
|
49
|
+
toJSON() { return { ortho: this.ortho, target: this.target.toArray(), pivot: this.pivot.toArray(), quat: this.quat.toArray(), dist: this.dist, halfH: this.halfH }; }
|
|
50
|
+
load(j) {
|
|
51
|
+
if (!j) return;
|
|
52
|
+
const ok = (a, n) => Array.isArray(a) && a.length === n && a.every(Number.isFinite);
|
|
53
|
+
if (typeof j.ortho === 'boolean') this.ortho = j.ortho;
|
|
54
|
+
if (ok(j.target, 3)) this.target.fromArray(j.target);
|
|
55
|
+
this.pivot.copy(this.target);
|
|
56
|
+
if (ok(j.pivot, 3)) this.pivot.fromArray(j.pivot);
|
|
57
|
+
if (ok(j.quat, 4)) this.quat.fromArray(j.quat).normalize();
|
|
58
|
+
if (j.dist > 0) this.dist = Math.min(MAX_D, Math.max(MIN_D, j.dist));
|
|
59
|
+
if (j.halfH > 0) this.halfH = Math.min(MAX_D, Math.max(MIN_D, j.halfH));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Vertical field of view (degrees) giving FOV across the narrower side. */
|
|
64
|
+
const vfov = (aspect) => (aspect >= 1 ? FOV : 2 * Math.atan(Math.tan((FOV * Math.PI) / 360) / aspect) * 180 / Math.PI);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The views: four slots (quad: top, persp / front, right as in Maya and Houdini), one shown alone in single mode.
|
|
68
|
+
* `scene()` gives {center: Vector3, radius} of everything drawn, for the near and far planes.
|
|
69
|
+
*/
|
|
70
|
+
export function createViews(scene) {
|
|
71
|
+
const cams = new Map(CAMERA_NAMES.map((n) => [n, new Cam(n)]));
|
|
72
|
+
const slots = ['top', 'persp', 'front', 'right'].map((cam) => ({ cam, shading: 'shaded', rect: { x: 0, y: 0, w: 1, h: 1 }, p: null, o: null }));
|
|
73
|
+
for (const s of slots) { s.p = new THREE.PerspectiveCamera(FOV, 1, 0.01, 1000); s.o = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1000); }
|
|
74
|
+
const v = {
|
|
75
|
+
cams, slots, quad: false, active: 1,
|
|
76
|
+
/** true once a camera was placed (framed, restored or moved by the person) */
|
|
77
|
+
placed: false,
|
|
78
|
+
/** the slots on screen, with their rects (CSS px in the viewport) */
|
|
79
|
+
visible() { return v.quad ? slots : [slots[v.active]]; },
|
|
80
|
+
layout(W, H) {
|
|
81
|
+
if (!v.quad) { slots[v.active].rect = { x: 0, y: 0, w: W, h: H }; return; }
|
|
82
|
+
const w0 = Math.floor(W / 2), h0 = Math.floor(H / 2);
|
|
83
|
+
slots[0].rect = { x: 0, y: 0, w: w0, h: h0 };
|
|
84
|
+
slots[1].rect = { x: w0, y: 0, w: W - w0, h: h0 };
|
|
85
|
+
slots[2].rect = { x: 0, y: h0, w: w0, h: H - h0 };
|
|
86
|
+
slots[3].rect = { x: w0, y: h0, w: W - w0, h: H - h0 };
|
|
87
|
+
},
|
|
88
|
+
/** The slot index under a viewport point, or -1. */
|
|
89
|
+
slotAt(x, y) {
|
|
90
|
+
for (const s of v.visible()) { const r = s.rect; if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) return slots.indexOf(s); }
|
|
91
|
+
return -1;
|
|
92
|
+
},
|
|
93
|
+
cam: (i) => cams.get(slots[i].cam),
|
|
94
|
+
/** The three.js camera of slot i, set up for its rect and the scene's size. */
|
|
95
|
+
camera(i) {
|
|
96
|
+
const s = slots[i], c = cams.get(s.cam), r = s.rect, aspect = Math.max(1e-3, r.w / Math.max(1, r.h));
|
|
97
|
+
const { center, radius } = scene();
|
|
98
|
+
const R = Math.max(radius, 1e-3);
|
|
99
|
+
if (!c.ortho) {
|
|
100
|
+
const p = s.p, eye = c.eye;
|
|
101
|
+
p.fov = vfov(aspect); p.aspect = aspect;
|
|
102
|
+
const away = eye.distanceTo(center);
|
|
103
|
+
p.near = Math.max(1e-6, Math.min(c.dist, R) * 1e-4);
|
|
104
|
+
p.far = Math.max(c.dist * 200, (away + R) * 4);
|
|
105
|
+
p.position.copy(eye); p.quaternion.copy(c.quat);
|
|
106
|
+
p.updateProjectionMatrix(); p.updateMatrixWorld();
|
|
107
|
+
return p;
|
|
108
|
+
}
|
|
109
|
+
const o = s.o, D = c.target.distanceTo(center) + R * 2 + c.halfH * 4;
|
|
110
|
+
o.left = -c.halfH * aspect; o.right = c.halfH * aspect; o.top = c.halfH; o.bottom = -c.halfH;
|
|
111
|
+
o.near = 0; o.far = D * 2;
|
|
112
|
+
o.position.copy(c.target).addScaledVector(c.back, D); o.quaternion.copy(c.quat);
|
|
113
|
+
o.updateProjectionMatrix(); o.updateMatrixWorld();
|
|
114
|
+
return o;
|
|
115
|
+
},
|
|
116
|
+
/** Metres per pixel at the slot's target (the grid step and constant-size marks use it). */
|
|
117
|
+
worldPerPixel(i) {
|
|
118
|
+
const s = slots[i], c = cams.get(s.cam), h = Math.max(1, s.rect.h);
|
|
119
|
+
if (c.ortho) return (2 * c.halfH) / h;
|
|
120
|
+
return (2 * c.dist * Math.tan((vfov(s.rect.w / h) * Math.PI) / 360)) / h;
|
|
121
|
+
},
|
|
122
|
+
/** Normalised device coordinates of a viewport point (px in the viewport, as every pointer position here) in slot i. */
|
|
123
|
+
ndc(i, x, y) { const r = slots[i].rect; return new THREE.Vector2(((x - r.x) / r.w) * 2 - 1, -((y - r.y) / r.h) * 2 + 1); },
|
|
124
|
+
ray(i, x, y) { const rc = new THREE.Raycaster(); rc.setFromCamera(v.ndc(i, x, y), v.camera(i)); return rc; },
|
|
125
|
+
/** A world point in viewport pixels as slot i shows it, or null behind the camera. */
|
|
126
|
+
project(i, p, cam = v.camera(i)) {
|
|
127
|
+
const q = new THREE.Vector3(p.x, p.y, p.z).project(cam), r = slots[i].rect;
|
|
128
|
+
if (q.z > 1 || q.z < -1) return null;
|
|
129
|
+
return { x: r.x + ((q.x + 1) / 2) * r.w, y: r.y + ((1 - q.y) / 2) * r.h };
|
|
130
|
+
},
|
|
131
|
+
/** The point under a viewport point on the plane through the slot's target facing the camera. */
|
|
132
|
+
targetPlanePoint(i, x, y, at) {
|
|
133
|
+
const c = v.cam(i), rc = v.ray(i, x, y), n = c.back;
|
|
134
|
+
const p = new THREE.Vector3();
|
|
135
|
+
return rc.ray.intersectPlane(new THREE.Plane().setFromNormalAndCoplanarPoint(n, at ?? c.target), p) ? p : (at ?? c.target).clone();
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------- navigation
|
|
139
|
+
/** Tumble slot i's camera by a mouse move (px) around `pivot`; nothing in orthographic cameras. */
|
|
140
|
+
tumble(i, dx, dy, pivot) {
|
|
141
|
+
const c = v.cam(i);
|
|
142
|
+
if (c.ortho) return false;
|
|
143
|
+
const yaw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -dx * TUMBLE);
|
|
144
|
+
const pitch = new THREE.Quaternion().setFromAxisAngle(c.right, -dy * TUMBLE);
|
|
145
|
+
let q = yaw.clone().multiply(pitch);
|
|
146
|
+
// stay upright: no rolling over the top
|
|
147
|
+
if (new THREE.Vector3(0, 1, 0).applyQuaternion(q.clone().multiply(c.quat)).y < 0) q = yaw;
|
|
148
|
+
const P = (pivot ?? c.target).clone();
|
|
149
|
+
c.target.sub(P).applyQuaternion(q).add(P);
|
|
150
|
+
c.quat.premultiply(q).normalize();
|
|
151
|
+
v.placed = true;
|
|
152
|
+
return true;
|
|
153
|
+
},
|
|
154
|
+
/** Track: the point at the target's depth follows the mouse. */
|
|
155
|
+
track(i, dx, dy, at) {
|
|
156
|
+
const c = v.cam(i);
|
|
157
|
+
const depth = at && !c.ortho ? Math.max(MIN_D, c.eye.clone().sub(at).dot(c.back)) : c.dist;
|
|
158
|
+
const k = v.worldPerPixel(i) * (c.ortho ? 1 : depth / c.dist);
|
|
159
|
+
const delta = c.right.multiplyScalar(-dx * k).addScaledVector(c.up, dy * k);
|
|
160
|
+
c.target.add(delta); c.pivot.add(delta);
|
|
161
|
+
v.placed = true;
|
|
162
|
+
},
|
|
163
|
+
/** Pick a navigation pivot without changing the camera's eye, aim or distance. */
|
|
164
|
+
anchor(i, hit) {
|
|
165
|
+
const c = v.cam(i);
|
|
166
|
+
if (hit && hit.toArray().every(Number.isFinite) && c.eye.clone().sub(hit).dot(c.back) > MIN_D) c.pivot.copy(hit);
|
|
167
|
+
return c.pivot.clone();
|
|
168
|
+
},
|
|
169
|
+
zoomAt(i, factor, at) {
|
|
170
|
+
const c = v.cam(i), before = c.ortho ? c.halfH : c.dist;
|
|
171
|
+
v.dolly(i, factor, at);
|
|
172
|
+
const scale = (c.ortho ? c.halfH : c.dist) / before;
|
|
173
|
+
c.pivot.sub(at).multiplyScalar(scale).add(at);
|
|
174
|
+
},
|
|
175
|
+
/** Dolly by factor s (< 1 closer) towards `at` (a world point on the target plane; default the target). */
|
|
176
|
+
dolly(i, s, at) {
|
|
177
|
+
const c = v.cam(i);
|
|
178
|
+
const cur = c.ortho ? c.halfH : c.dist;
|
|
179
|
+
const next = Math.min(MAX_D, Math.max(MIN_D, cur * s));
|
|
180
|
+
s = next / cur;
|
|
181
|
+
if (at) c.target.sub(at).multiplyScalar(s).add(at);
|
|
182
|
+
if (c.ortho) c.halfH = next; else c.dist = next;
|
|
183
|
+
v.placed = true;
|
|
184
|
+
},
|
|
185
|
+
/** Frame a world box in slot i. */
|
|
186
|
+
frame(i, box) {
|
|
187
|
+
if (!box || box.isEmpty()) return;
|
|
188
|
+
const s = slots[i], c = cams.get(s.cam), r = s.rect, aspect = Math.max(1e-3, r.w / Math.max(1, r.h));
|
|
189
|
+
const center = box.getCenter(new THREE.Vector3()), size = box.getSize(new THREE.Vector3());
|
|
190
|
+
const radius = Math.max(size.length() / 2, 1e-3);
|
|
191
|
+
c.target.copy(center);
|
|
192
|
+
c.pivot.copy(center);
|
|
193
|
+
const half = (vfov(aspect) * Math.PI) / 360, halfW = Math.atan(Math.tan(half) * aspect);
|
|
194
|
+
c.dist = Math.min(MAX_D, Math.max(MIN_D, (radius / Math.sin(Math.min(half, halfW))) * 1.05));
|
|
195
|
+
// ortho: the box's extent across the view
|
|
196
|
+
const rt = c.right, up = c.up;
|
|
197
|
+
let ex = 0, ey = 0;
|
|
198
|
+
for (let k = 0; k < 8; k++) {
|
|
199
|
+
const p = new THREE.Vector3(k & 1 ? box.max.x : box.min.x, k & 2 ? box.max.y : box.min.y, k & 4 ? box.max.z : box.min.z).sub(center);
|
|
200
|
+
ex = Math.max(ex, Math.abs(p.dot(rt))); ey = Math.max(ey, Math.abs(p.dot(up)));
|
|
201
|
+
}
|
|
202
|
+
c.halfH = Math.min(MAX_D, Math.max(MIN_D, Math.max(ey, ex / aspect, 1e-3) * 1.1));
|
|
203
|
+
v.placed = true;
|
|
204
|
+
},
|
|
205
|
+
/** Show camera `name` in slot i. */
|
|
206
|
+
setCam(i, name) { if (cams.has(name)) slots[i].cam = name; },
|
|
207
|
+
toggleOrtho(i) {
|
|
208
|
+
const c = v.cam(i), aspect = slots[i].rect.w / Math.max(1, slots[i].rect.h), t = Math.tan((vfov(aspect) * Math.PI) / 360);
|
|
209
|
+
if (c.ortho) c.dist = Math.min(MAX_D, c.halfH / t); else c.halfH = Math.max(MIN_D, c.dist * t);
|
|
210
|
+
c.ortho = !c.ortho;
|
|
211
|
+
v.placed = true;
|
|
212
|
+
},
|
|
213
|
+
/** The axis gizmo's click: persp turns to look along the axis; an axis view becomes that axis view. */
|
|
214
|
+
lookAlong(i, axis) {
|
|
215
|
+
const name = VIEW_OF_AXIS[axis];
|
|
216
|
+
if (!name) return;
|
|
217
|
+
const c = v.cam(i);
|
|
218
|
+
if (c.name === 'persp') { c.quat.copy(quatOf(AXIS_VIEWS[name].f, AXIS_VIEWS[name].u)); v.placed = true; }
|
|
219
|
+
else {
|
|
220
|
+
// the new view looks at the same place at the same scale
|
|
221
|
+
const n = cams.get(name);
|
|
222
|
+
n.target.copy(c.target); n.halfH = c.ortho ? c.halfH : c.dist * 0.3;
|
|
223
|
+
n.pivot.copy(c.pivot);
|
|
224
|
+
slots[i].cam = name;
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
toJSON() {
|
|
228
|
+
return { cams: Object.fromEntries([...cams].map(([n, c]) => [n, c.toJSON()])), slots: slots.map((s) => ({ cam: s.cam, shading: s.shading })), quad: v.quad, active: v.active };
|
|
229
|
+
},
|
|
230
|
+
load(j) {
|
|
231
|
+
if (!j || typeof j !== 'object') return false;
|
|
232
|
+
for (const [n, c] of cams) c.load(j.cams?.[n]);
|
|
233
|
+
(j.slots ?? []).slice(0, 4).forEach((s, k) => {
|
|
234
|
+
if (cams.has(s?.cam)) slots[k].cam = s.cam;
|
|
235
|
+
if (SHADINGS.some(([id]) => id === s?.shading)) slots[k].shading = s.shading;
|
|
236
|
+
});
|
|
237
|
+
v.quad = !!j.quad;
|
|
238
|
+
if (Number.isInteger(j.active) && j.active >= 0 && j.active < 4) v.active = j.active;
|
|
239
|
+
v.placed = true;
|
|
240
|
+
return true;
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
return v;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The view name a slot prints: the camera's name, "(ortho)" / "(persp)" when it is not its own projection. */
|
|
247
|
+
export function viewLabel(c) {
|
|
248
|
+
if (c.name === 'persp') return c.ortho ? 'persp (ortho)' : 'persp';
|
|
249
|
+
return c.ortho ? c.name : `${c.name} (persp)`;
|
|
250
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The export target popover: a format and a file name inside exports/, opened by an output's Export button when it
|
|
2
|
+
// has no target yet, or to change it. Submitting stores the target in the graph and exports.
|
|
3
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
|
4
|
+
|
|
5
|
+
/** A file name stem like the server's (io/targets.ts fileStem). */
|
|
6
|
+
export const fileStem = (s) => String(s).replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^\.+|[. ]+$/g, '') || 'output';
|
|
7
|
+
|
|
8
|
+
/** The path a format writes to by default: <stem><extension>, a folder for formats without one. */
|
|
9
|
+
export function defaultPath(stem, format, formats) {
|
|
10
|
+
return fileStem(stem) + (formats.find((f) => f.format === format)?.extension ?? '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The path with its extension swapped from one format's to another's (a custom name keeps its stem). */
|
|
14
|
+
export function swapExtension(p, from, to, formats) {
|
|
15
|
+
const ext = (f) => formats.find((x) => x.format === f)?.extension ?? '';
|
|
16
|
+
const a = ext(from), b = ext(to);
|
|
17
|
+
const stem = a && p.toLowerCase().endsWith(a.toLowerCase()) ? p.slice(0, -a.length) : p;
|
|
18
|
+
return stem + b;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Open the popover under `anchor`. item: an output entry (label, kind, graph, output, target); formats: [{format, label,
|
|
23
|
+
* extension}] from /api/outputs; directory: the exports folder. Resolves to {format, path} or null (cancelled).
|
|
24
|
+
*/
|
|
25
|
+
export function exportPopover({ anchor, item, formats, directory }) {
|
|
26
|
+
document.querySelector('.export-popover')?.dispatchEvent(new Event('cancel'));
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const stem = item.output ?? item.graph ?? 'output';
|
|
29
|
+
const startFormat = item.target?.format ?? (item.kind === 'image' ? 'textures' : 'glb');
|
|
30
|
+
const box = el('form', 'export-popover');
|
|
31
|
+
box.setAttribute('role', 'dialog'); box.setAttribute('aria-label', `Export ${item.label}`);
|
|
32
|
+
const title = el('div', 'export-title', item.target ? `Export target of ${item.label}` : `Export ${item.label}`);
|
|
33
|
+
const format = el('select'); format.setAttribute('aria-label', 'Format');
|
|
34
|
+
for (const f of formats) format.append(new Option(f.label, f.format));
|
|
35
|
+
format.value = startFormat;
|
|
36
|
+
const file = el('input'); file.type = 'text'; file.spellcheck = false; file.setAttribute('aria-label', 'File in exports/');
|
|
37
|
+
file.value = item.target?.path ?? defaultPath(stem, startFormat, formats);
|
|
38
|
+
const where = el('div', 'export-where dim');
|
|
39
|
+
const error = el('div', 'export-error err');
|
|
40
|
+
const cancel = el('button', '', 'Cancel'), go = el('button', 'primary', 'Export');
|
|
41
|
+
cancel.type = 'button'; go.type = 'submit';
|
|
42
|
+
const field = (label, input) => { const l = el('label', 'export-field'); l.append(el('span', 'dim', label), input); return l; };
|
|
43
|
+
const actions = el('div', 'export-actions'); actions.append(cancel, go);
|
|
44
|
+
box.append(title, field('Format', format), field('File', file), where, error, actions);
|
|
45
|
+
let previous = format.value;
|
|
46
|
+
const sync = () => { where.textContent = `${directory}/${file.value.trim() || '…'}`; error.textContent = ''; };
|
|
47
|
+
format.onchange = () => { file.value = swapExtension(file.value.trim(), previous, format.value, formats); previous = format.value; sync(); };
|
|
48
|
+
file.oninput = sync;
|
|
49
|
+
const close = (value) => { box.remove(); removeEventListener('pointerdown', outside, true); anchor?.focus?.(); resolve(value); };
|
|
50
|
+
const outside = (e) => { if (!box.contains(e.target)) close(null); };
|
|
51
|
+
box.addEventListener('cancel', () => close(null));
|
|
52
|
+
cancel.onclick = () => close(null);
|
|
53
|
+
box.onkeydown = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(null); } };
|
|
54
|
+
box.onsubmit = (e) => {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
const p = file.value.trim().replace(/\\/g, '/');
|
|
57
|
+
if (!p || p.startsWith('/') || /^[A-Za-z]:/.test(p) || p.split('/').some((s) => !s || s === '.' || s === '..')) { error.textContent = 'a path inside exports/, without ".." or a drive'; file.focus(); return; }
|
|
58
|
+
close({ format: format.value, path: p });
|
|
59
|
+
};
|
|
60
|
+
sync();
|
|
61
|
+
document.body.append(box);
|
|
62
|
+
const r = anchor.getBoundingClientRect();
|
|
63
|
+
box.style.left = Math.max(6, Math.min(r.left, innerWidth - box.offsetWidth - 6)) + 'px';
|
|
64
|
+
box.style.top = (r.bottom + 4 + box.offsetHeight < innerHeight ? r.bottom + 4 : Math.max(6, r.top - box.offsetHeight - 4)) + 'px';
|
|
65
|
+
addEventListener('pointerdown', outside, true);
|
|
66
|
+
format.focus();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// The adaptive metre grid, the world axes, the axis gizmo and each view's readouts (docs/design/viewer.md section 4).
|
|
2
|
+
// The grid lies on y = 0 under persp and the top / bottom views, and on each side view's own plane (front: XY,
|
|
3
|
+
// right: ZY), as Maya and Houdini draw them; minor lines every `step`, major every 10 steps, the axes in the
|
|
4
|
+
// standard colours. One plane is set up again before each view renders.
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { AXIS_VIEWS, viewLabel } from './camera.js';
|
|
7
|
+
import { gridStep, MAJOR_EVERY, fmtStep, fmtPoint } from './units.js';
|
|
8
|
+
|
|
9
|
+
export const AXIS_COLORS = { x: '#d9534f', y: '#6abf4b', z: '#4a86d9' };
|
|
10
|
+
const lin = (hex) => new THREE.Color(hex);
|
|
11
|
+
|
|
12
|
+
const vertex = /* glsl */`
|
|
13
|
+
#include <common>
|
|
14
|
+
#include <logdepthbuf_pars_vertex>
|
|
15
|
+
varying vec3 vWorld;
|
|
16
|
+
void main() {
|
|
17
|
+
vec4 w = modelMatrix * vec4(position, 1.0);
|
|
18
|
+
vWorld = w.xyz;
|
|
19
|
+
gl_Position = projectionMatrix * viewMatrix * w;
|
|
20
|
+
#include <logdepthbuf_vertex>
|
|
21
|
+
}`;
|
|
22
|
+
const fragment = /* glsl */`
|
|
23
|
+
#include <common>
|
|
24
|
+
#include <logdepthbuf_pars_fragment>
|
|
25
|
+
uniform float uStep;
|
|
26
|
+
uniform int uPlane; // 0: xz, 1: xy, 2: zy
|
|
27
|
+
uniform vec3 uMinor, uMajor, uAxisU, uAxisV;
|
|
28
|
+
uniform vec3 uCenter;
|
|
29
|
+
uniform float uFade; // fade-out radius around uCenter (0: none)
|
|
30
|
+
varying vec3 vWorld;
|
|
31
|
+
float lineOf(vec2 c) {
|
|
32
|
+
vec2 g = abs(fract(c - 0.5) - 0.5) / max(fwidth(c), vec2(1e-6));
|
|
33
|
+
return 1.0 - min(min(g.x, g.y), 1.0);
|
|
34
|
+
}
|
|
35
|
+
void main() {
|
|
36
|
+
#include <logdepthbuf_fragment>
|
|
37
|
+
vec2 p = uPlane == 0 ? vWorld.xz : (uPlane == 1 ? vWorld.xy : vWorld.zy);
|
|
38
|
+
vec2 c = p / uStep;
|
|
39
|
+
vec2 fw = fwidth(c);
|
|
40
|
+
// minor cells fade out from 7 px down to 3 px, majors likewise
|
|
41
|
+
float dens = max(fw.x, fw.y);
|
|
42
|
+
float minor = lineOf(c) * (1.0 - smoothstep(0.15, 0.35, dens));
|
|
43
|
+
float major = lineOf(c / ${MAJOR_EVERY.toFixed(1)}) * (1.0 - smoothstep(0.15, 0.35, dens / ${MAJOR_EVERY.toFixed(1)}));
|
|
44
|
+
vec2 pw = max(fwidth(p), vec2(1e-9));
|
|
45
|
+
float onU = 1.0 - min(abs(p.y) / pw.y / 1.2, 1.0); // the U axis runs where p.y = 0
|
|
46
|
+
float onV = 1.0 - min(abs(p.x) / pw.x / 1.2, 1.0);
|
|
47
|
+
vec3 col = uMinor; float a = minor * 0.55;
|
|
48
|
+
if (major * 0.9 > a) { col = uMajor; a = major * 0.9; }
|
|
49
|
+
if (onU > 0.0 && onU >= a) { col = uAxisU; a = max(a, onU); }
|
|
50
|
+
if (onV > 0.0 && onV >= a) { col = uAxisV; a = max(a, onV); }
|
|
51
|
+
if (uFade > 0.0) a *= 1.0 - smoothstep(uFade * 0.55, uFade, length(vWorld - uCenter));
|
|
52
|
+
if (a < 0.004) discard;
|
|
53
|
+
gl_FragColor = vec4(col, a);
|
|
54
|
+
#include <colorspace_fragment>
|
|
55
|
+
}`;
|
|
56
|
+
|
|
57
|
+
/** The grid plane and the vertical world axis; `prepare(...)` sets them up for one view before it renders. */
|
|
58
|
+
export function createGrid(scene) {
|
|
59
|
+
const mat = new THREE.ShaderMaterial({
|
|
60
|
+
vertexShader: vertex, fragmentShader: fragment, transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
61
|
+
uniforms: {
|
|
62
|
+
uStep: { value: 1 }, uPlane: { value: 0 }, uMinor: { value: lin('#3d4046') }, uMajor: { value: lin('#575b62') },
|
|
63
|
+
uAxisU: { value: lin(AXIS_COLORS.x) }, uAxisV: { value: lin(AXIS_COLORS.z) }, uCenter: { value: new THREE.Vector3() }, uFade: { value: 0 },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const plane = new THREE.Mesh(new THREE.PlaneGeometry(2, 2, 8, 8), mat);
|
|
67
|
+
plane.renderOrder = -10;
|
|
68
|
+
plane.frustumCulled = false;
|
|
69
|
+
plane.name = 'grid';
|
|
70
|
+
scene.add(plane);
|
|
71
|
+
// the axis that stands out of the grid plane (Y under persp), through the origin
|
|
72
|
+
const yAxis = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, -1, 0), new THREE.Vector3(0, 1, 0)]),
|
|
73
|
+
new THREE.LineBasicMaterial({ color: AXIS_COLORS.y, transparent: true, opacity: 0.8 }));
|
|
74
|
+
yAxis.frustumCulled = false;
|
|
75
|
+
yAxis.renderOrder = -9;
|
|
76
|
+
yAxis.name = 'y axis';
|
|
77
|
+
scene.add(yAxis);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
plane,
|
|
81
|
+
/**
|
|
82
|
+
* Set up for one view: cam (a Cam of camera.js) and its metres per pixel at the target. Returns the grid step.
|
|
83
|
+
*/
|
|
84
|
+
prepare(cam, wpp) {
|
|
85
|
+
const step = gridStep(wpp);
|
|
86
|
+
const kind = cam.name === 'persp' ? 'persp' : cam.name;
|
|
87
|
+
const f = kind === 'persp' ? null : AXIS_VIEWS[kind].f;
|
|
88
|
+
// the plane: xz for persp / top / bottom, xy for front / back, zy for right / left
|
|
89
|
+
const planeId = !f || f[1] !== 0 ? 0 : f[2] !== 0 ? 1 : 2;
|
|
90
|
+
const u = mat.uniforms;
|
|
91
|
+
u.uStep.value = step;
|
|
92
|
+
u.uPlane.value = planeId;
|
|
93
|
+
u.uAxisU.value.set(planeId === 2 ? AXIS_COLORS.z : AXIS_COLORS.x);
|
|
94
|
+
u.uAxisV.value.set(planeId === 0 ? AXIS_COLORS.z : AXIS_COLORS.y);
|
|
95
|
+
const t = cam.target.clone();
|
|
96
|
+
// snapped to major cells, so the lines never swim
|
|
97
|
+
const big = step * MAJOR_EVERY;
|
|
98
|
+
const snap = (x) => Math.round(x / big) * big;
|
|
99
|
+
let half;
|
|
100
|
+
// ortho: the whole view (aspects up to 4); persp: far enough to fade out before the horizon
|
|
101
|
+
if (cam.ortho) half = Math.max(cam.halfH * 4, big * 2);
|
|
102
|
+
else half = Math.max(cam.dist * 40, big * 4);
|
|
103
|
+
half = Math.ceil(half / big) * big;
|
|
104
|
+
plane.scale.set(half, half, 1);
|
|
105
|
+
plane.rotation.set(0, 0, 0);
|
|
106
|
+
if (planeId === 0) { plane.rotation.x = -Math.PI / 2; plane.position.set(snap(t.x), 0, snap(t.z)); }
|
|
107
|
+
else if (planeId === 1) plane.position.set(snap(t.x), snap(t.y), 0);
|
|
108
|
+
else { plane.rotation.y = Math.PI / 2; plane.position.set(0, snap(t.y), snap(t.z)); }
|
|
109
|
+
plane.updateMatrixWorld();
|
|
110
|
+
u.uCenter.value.copy(cam.ortho ? plane.position : t.clone().projectOnPlane(new THREE.Vector3(...(planeId === 0 ? [0, 1, 0] : planeId === 1 ? [0, 0, 1] : [1, 0, 0]))));
|
|
111
|
+
u.uFade.value = cam.ortho ? 0 : half;
|
|
112
|
+
yAxis.visible = !cam.ortho && planeId === 0;
|
|
113
|
+
if (yAxis.visible) {
|
|
114
|
+
// a line whose end lies behind the camera draws nothing under a logarithmic depth buffer: cut it in front
|
|
115
|
+
const eye = cam.eye, fwd = cam.back.negate(), ahead = Math.max(1e-4, cam.dist * 1e-3);
|
|
116
|
+
const a = new THREE.Vector3(0, -half, 0), b = new THREE.Vector3(0, half, 0);
|
|
117
|
+
const da = a.clone().sub(eye).dot(fwd), db = b.clone().sub(eye).dot(fwd);
|
|
118
|
+
if (da < ahead && db < ahead) yAxis.visible = false;
|
|
119
|
+
else {
|
|
120
|
+
if (da < ahead) a.lerp(b, (ahead - da) / (db - da));
|
|
121
|
+
else if (db < ahead) b.lerp(a, (ahead - db) / (da - db));
|
|
122
|
+
const pos = yAxis.geometry.attributes.position;
|
|
123
|
+
pos.setXYZ(0, a.x, a.y, a.z); pos.setXYZ(1, b.x, b.y, b.z); pos.needsUpdate = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return step;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ------------------------------------------------------------------------------------ view overlays (DOM)
|
|
132
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
133
|
+
const svgEl = (tag, attrs = {}) => { const e = document.createElementNS(SVG, tag); for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); return e; };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Per view slot: its frame, the name, the readouts (grid step, cursor position in orthographic views, the
|
|
137
|
+
* selection's size), the axis gizmo (clicking an end calls `onAxis(slot, '+x' …)`), and layers for marks and labels.
|
|
138
|
+
*/
|
|
139
|
+
export function createOverlays(host, onAxis) {
|
|
140
|
+
return [0, 1, 2, 3].map((slot) => {
|
|
141
|
+
const box = document.createElement('div');
|
|
142
|
+
box.className = 'vw';
|
|
143
|
+
const name = document.createElement('div');
|
|
144
|
+
name.className = 'vname';
|
|
145
|
+
const info = document.createElement('div');
|
|
146
|
+
info.className = 'vinfo';
|
|
147
|
+
const marks = svgEl('svg', { class: 'marks' });
|
|
148
|
+
const labels = document.createElement('div');
|
|
149
|
+
labels.className = 'labels';
|
|
150
|
+
const giz = svgEl('svg', { class: 'gizmo', viewBox: '-32 -32 64 64' });
|
|
151
|
+
box.append(marks, labels, name, info, giz);
|
|
152
|
+
host.append(box);
|
|
153
|
+
const ends = {};
|
|
154
|
+
for (const a of ['x', 'y', 'z']) {
|
|
155
|
+
for (const sgn of ['+', '-']) {
|
|
156
|
+
const g = svgEl('g', { class: 'end' });
|
|
157
|
+
const line = sgn === '+' ? svgEl('line', { x1: 0, y1: 0, stroke: AXIS_COLORS[a], 'stroke-width': 2 }) : null;
|
|
158
|
+
const dot = svgEl('circle', { r: sgn === '+' ? 7 : 4.5, fill: sgn === '+' ? AXIS_COLORS[a] : '#2b2d31', stroke: AXIS_COLORS[a], 'stroke-width': 1.5 });
|
|
159
|
+
const txt = sgn === '+' ? svgEl('text') : null;
|
|
160
|
+
if (txt) txt.textContent = a.toUpperCase();
|
|
161
|
+
const t = svgEl('title');
|
|
162
|
+
t.textContent = `look along ${sgn === '+' ? '-' : '+'}${a.toUpperCase()} (from ${sgn}${a.toUpperCase()})`;
|
|
163
|
+
g.append(t, ...[line, dot, txt].filter(Boolean));
|
|
164
|
+
g.addEventListener('pointerdown', (e) => { e.stopPropagation(); e.preventDefault(); onAxis(slot, `${sgn}${a}`); });
|
|
165
|
+
ends[sgn + a] = { g, line, dot, txt };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
box, name, info, marks, labels, giz, ends,
|
|
170
|
+
/** Place and fill for this frame. */
|
|
171
|
+
update({ rect, visible, quad, active, cam, camera, step, cursor, selSize }) {
|
|
172
|
+
box.hidden = !visible;
|
|
173
|
+
if (!visible) return;
|
|
174
|
+
Object.assign(box.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.w}px`, height: `${rect.h}px` });
|
|
175
|
+
box.classList.toggle('quad', quad);
|
|
176
|
+
box.classList.toggle('active', active);
|
|
177
|
+
const label = viewLabel(cam);
|
|
178
|
+
if (name.dataset.t !== label) { name.dataset.t = label; name.textContent = label; }
|
|
179
|
+
const lines = [`grid ${fmtStep(step)}`];
|
|
180
|
+
if (cursor && cam.ortho) lines.push(cursor);
|
|
181
|
+
const text = lines.join('\n') + (selSize ? '\n' : '');
|
|
182
|
+
const sel = selSize ? `sel ${selSize}` : '';
|
|
183
|
+
if (info.dataset.t !== text + sel) {
|
|
184
|
+
info.dataset.t = text + sel;
|
|
185
|
+
info.textContent = text;
|
|
186
|
+
if (sel) { const s = document.createElement('span'); s.className = 'selsize'; s.textContent = sel; info.append(s); }
|
|
187
|
+
}
|
|
188
|
+
// the gizmo: world axes as the camera sees them, the far ends drawn first
|
|
189
|
+
const m = new THREE.Matrix4().extractRotation(camera.matrixWorldInverse);
|
|
190
|
+
const order = [];
|
|
191
|
+
for (const a of ['x', 'y', 'z']) for (const sgn of [1, -1]) {
|
|
192
|
+
const v = new THREE.Vector3(a === 'x' ? sgn : 0, a === 'y' ? sgn : 0, a === 'z' ? sgn : 0).applyMatrix4(m);
|
|
193
|
+
order.push({ k: (sgn > 0 ? '+' : '-') + a, x: v.x * 22, y: -v.y * 22, z: v.z });
|
|
194
|
+
}
|
|
195
|
+
order.sort((p, q) => p.z - q.z);
|
|
196
|
+
for (const o of order) {
|
|
197
|
+
const e = ends[o.k];
|
|
198
|
+
if (e.line) { e.line.setAttribute('x2', o.x.toFixed(1)); e.line.setAttribute('y2', o.y.toFixed(1)); }
|
|
199
|
+
e.dot.setAttribute('cx', o.x.toFixed(1)); e.dot.setAttribute('cy', o.y.toFixed(1));
|
|
200
|
+
if (e.txt) { e.txt.setAttribute('x', o.x.toFixed(1)); e.txt.setAttribute('y', o.y.toFixed(1)); }
|
|
201
|
+
giz.append(e.g);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The cursor's world position on a view's construction plane, printed for orthographic views. */
|
|
209
|
+
export function cursorText(cam, p, step) {
|
|
210
|
+
if (!p) return '';
|
|
211
|
+
const kind = cam.name === 'persp' ? 'top' : cam.name;
|
|
212
|
+
const f = AXIS_VIEWS[kind].f;
|
|
213
|
+
// the coordinates in the view's plane, the one along the view as the plane's
|
|
214
|
+
const axes = f[1] !== 0 ? ['x', 'z'] : f[2] !== 0 ? ['x', 'y'] : ['z', 'y'];
|
|
215
|
+
const v = { x: p.x, y: p.y, z: p.z };
|
|
216
|
+
return axes.map((a) => `${a} ${fmtPoint([v[a]], step).slice(1, -1)}`).join(' ');
|
|
217
|
+
}
|