topotopo 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -12
- package/dist/cli.js +98 -23
- package/dist/graph/engine.js +4 -1
- package/dist/graph/params.d.ts +7 -1
- package/dist/graph/params.js +21 -8
- package/dist/graph/types.d.ts +11 -2
- package/dist/host/export_scene.py +200 -0
- package/dist/host/node.d.ts +8 -0
- package/dist/host/node.js +114 -0
- package/dist/host/types.d.ts +5 -0
- package/dist/inspect.js +5 -2
- package/dist/io/common.d.ts +6 -0
- package/dist/io/export.js +7 -4
- package/dist/io/gltf.d.ts +1 -1
- package/dist/io/gltf.js +167 -32
- package/dist/io/heightmap.d.ts +1 -1
- package/dist/io/import.d.ts +2 -2
- package/dist/io/import.js +10 -3
- package/dist/io/png-decode.d.ts +5 -2
- package/dist/io/png-decode.js +10 -3
- package/dist/io/textures.d.ts +31 -2
- package/dist/io/textures.js +66 -18
- package/dist/io/unreal.js +22 -5
- package/dist/mcp/docs.d.ts +9 -2
- package/dist/mcp/docs.js +113 -2
- package/dist/mcp/inputs.d.ts +28 -0
- package/dist/mcp/inputs.js +377 -0
- package/dist/mcp/server.js +4 -1
- package/dist/mcp/tools.js +32 -18
- package/dist/nodes/code.js +2 -0
- package/dist/nodes/copy.js +14 -4
- package/dist/nodes/flow.js +136 -35
- package/dist/nodes/heightfield.js +67 -11
- package/dist/nodes/texture.d.ts +1 -1
- package/dist/nodes/texture.js +61 -5
- package/dist/render/options.js +2 -1
- package/dist/render/raster.d.ts +2 -0
- package/dist/render/raster.js +3 -3
- package/dist/render/texview.d.ts +9 -1
- package/dist/render/texview.js +43 -2
- package/dist/viewer/app/api.js +64 -0
- package/dist/viewer/app/camera.js +230 -0
- package/dist/viewer/app/grid.js +217 -0
- package/dist/viewer/app/guides.js +402 -0
- package/dist/viewer/app/keymap.js +187 -0
- package/dist/viewer/app/layers.js +225 -0
- package/dist/viewer/app/main.js +590 -0
- package/dist/viewer/app/markers.js +206 -0
- package/dist/viewer/app/mesh.js +172 -0
- package/dist/viewer/app/outliner.js +184 -0
- package/dist/viewer/app/props.js +432 -0
- package/dist/viewer/app/scene.js +529 -0
- package/dist/viewer/app/select.js +142 -0
- package/dist/viewer/app/selection.js +79 -0
- package/dist/viewer/app/style.css +172 -0
- package/dist/viewer/app/undo.js +49 -0
- package/dist/viewer/app/units.js +51 -0
- package/dist/viewer/app/vars.js +64 -0
- package/dist/viewer/index.html +53 -184
- package/dist/viewer/server.d.ts +42 -1
- package/dist/viewer/server.js +432 -17
- package/dist/workspace.d.ts +52 -2
- package/dist/workspace.js +95 -3
- package/package.json +8 -3
- package/shelf/brick_wall.json +1 -1
- package/shelf/wall_segment.json +2 -2
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
// Geometry in the viewport (docs/design/viewer.md section 7): the cooked output and, through the same MeshObject,
|
|
2
|
+
// the reference layers. Display modes (as the view's shading, shaded, wire, shaded + wire, points, bounding box,
|
|
3
|
+
// ghost), backfaces tinted blue and the open / non-manifold / flipped edges drawn as the render tool draws them,
|
|
4
|
+
// connected pieces for picking and box selection, and the orange selection wireframe.
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
7
|
+
import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
|
|
8
|
+
import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
|
|
9
|
+
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
|
|
10
|
+
import { weld, pieces, pieceBounds, edgeClasses, subsetIndex, edgePairs, EDGE_COLORS } from './mesh.js';
|
|
11
|
+
|
|
12
|
+
export const COLORS = { sel: '#f0a030', wire: '#a9aeb5', wireOver: '#101113', box: '#a9aeb5', points: '#c9cdd2', line: '#c9cdd2', guide: '#7fb4c1' };
|
|
13
|
+
export const DISPLAY_MODES = [
|
|
14
|
+
['view', 'as view'], ['shaded', 'shaded'], ['wire', 'wire'], ['shadedWire', 'shaded + wire'], ['points', 'points'], ['bbox', 'bounding box'],
|
|
15
|
+
];
|
|
16
|
+
export const LAYER_MODES = [...DISPLAY_MODES, ['ghost', 'ghost']];
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------------------------- shader patches
|
|
19
|
+
/**
|
|
20
|
+
* Pull lines drawn over a surface a little towards the camera so they win the depth test (polygon offset does not
|
|
21
|
+
* reach a logarithmic depth buffer, which writes gl_FragDepth): 0.15 % of the distance in persp, a sliver of the
|
|
22
|
+
* depth range in ortho. Works on the built-in materials and on LineMaterial.
|
|
23
|
+
*/
|
|
24
|
+
export function nudge(material) {
|
|
25
|
+
const prev = material.onBeforeCompile;
|
|
26
|
+
material.onBeforeCompile = (sh, r) => {
|
|
27
|
+
prev?.call(material, sh, r);
|
|
28
|
+
const pull = /* glsl */`
|
|
29
|
+
if (isPerspectiveMatrix(projectionMatrix)) mvPosition.xyz *= 0.9985;
|
|
30
|
+
else mvPosition.z += 4e-5 * (-2.0 / projectionMatrix[2][2]);`;
|
|
31
|
+
if (sh.vertexShader.includes('#include <project_vertex>'))
|
|
32
|
+
sh.vertexShader = sh.vertexShader.replace('#include <project_vertex>', `#include <project_vertex>\n{${pull}\n gl_Position = projectionMatrix * mvPosition; }`);
|
|
33
|
+
else sh.vertexShader = sh.vertexShader.replace('vec4 end = modelViewMatrix * vec4( instanceEnd, 1.0 );',
|
|
34
|
+
`vec4 end = modelViewMatrix * vec4( instanceEnd, 1.0 );\n{ vec4 mvPosition = start; ${pull} start = mvPosition; mvPosition = end; ${pull} end = mvPosition; }`);
|
|
35
|
+
};
|
|
36
|
+
const key = material.customProgramCacheKey?.bind(material);
|
|
37
|
+
material.customProgramCacheKey = () => `${key ? key() : ''}|nudge`;
|
|
38
|
+
return material;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Backfaces tinted blue as the render tool tints them (src/render/raster.ts): colour × 0.35 + (0.12, 0.22, 0.6). */
|
|
42
|
+
export function backTint(material) {
|
|
43
|
+
if (material.userData.backTint) return material;
|
|
44
|
+
material.userData.backTint = true;
|
|
45
|
+
material.side = THREE.DoubleSide;
|
|
46
|
+
const prev = material.onBeforeCompile;
|
|
47
|
+
material.onBeforeCompile = (sh, r) => {
|
|
48
|
+
prev?.call(material, sh, r);
|
|
49
|
+
sh.fragmentShader = sh.fragmentShader.replace('#include <color_fragment>', `#include <color_fragment>
|
|
50
|
+
if (!gl_FrontFacing) {
|
|
51
|
+
vec3 s = pow(max(diffuseColor.rgb, vec3(0.0)), vec3(1.0 / 2.2)) * 0.35 + vec3(0.12, 0.22, 0.6);
|
|
52
|
+
diffuseColor.rgb = pow(s, vec3(2.2));
|
|
53
|
+
}`);
|
|
54
|
+
};
|
|
55
|
+
material.customProgramCacheKey = () => 'backtint';
|
|
56
|
+
material.needsUpdate = true;
|
|
57
|
+
return material;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every LineMaterial on screen: its resolution is the size of the view being drawn (main.js sets it per view). */
|
|
61
|
+
export const LINE_MATERIALS = new Set();
|
|
62
|
+
export function lineMaterial(opts) {
|
|
63
|
+
const m = new LineMaterial({ linewidth: 1.5, transparent: true, ...opts });
|
|
64
|
+
LINE_MATERIALS.add(m);
|
|
65
|
+
const dispose = m.dispose.bind(m);
|
|
66
|
+
m.dispose = () => { LINE_MATERIALS.delete(m); dispose(); };
|
|
67
|
+
return m;
|
|
68
|
+
}
|
|
69
|
+
/** Fat line segments from a Float32Array of [x, y, z, x, y, z] pairs, one colour or per-vertex colours. */
|
|
70
|
+
export function fatSegments(pos, { color = '#ffffff', colors = null, width = 1.5, opacity = 1, depthTest = true, over = false } = {}) {
|
|
71
|
+
const g = new LineSegmentsGeometry();
|
|
72
|
+
g.setPositions(pos);
|
|
73
|
+
if (colors) g.setColors(colors);
|
|
74
|
+
const m = lineMaterial({ color: colors ? '#ffffff' : color, vertexColors: !!colors, linewidth: width, opacity, depthTest, depthWrite: false });
|
|
75
|
+
if (over) nudge(m);
|
|
76
|
+
const l = new LineSegments2(g, m);
|
|
77
|
+
l.frustumCulled = false;
|
|
78
|
+
return l;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Free an object's geometries and materials, except the shared ones in `keep`. */
|
|
82
|
+
export function disposeTree(obj, keep = SHARED) {
|
|
83
|
+
obj.traverse((o) => {
|
|
84
|
+
if (o.geometry && !keep.has(o.geometry)) o.geometry.dispose();
|
|
85
|
+
for (const m of [o.material ?? []].flat()) {
|
|
86
|
+
if (keep.has(m)) continue;
|
|
87
|
+
for (const v of Object.values(m)) if (v?.isTexture) v.dispose();
|
|
88
|
+
m.dispose();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --------------------------------------------------------------------------------------------- shared materials
|
|
94
|
+
const wireLight = new THREE.MeshBasicMaterial({ color: COLORS.wire, wireframe: true });
|
|
95
|
+
const wireDark = nudge(new THREE.MeshBasicMaterial({ color: COLORS.wireOver, wireframe: true, transparent: true, opacity: 0.32, depthWrite: false }));
|
|
96
|
+
const ghostMesh = new THREE.MeshStandardMaterial({ color: 0xaab0ba, roughness: 0.85, metalness: 0, transparent: true, opacity: 0.3, depthWrite: false, side: THREE.DoubleSide });
|
|
97
|
+
const ghostLine = new THREE.LineBasicMaterial({ color: 0xc4cad4, transparent: true, opacity: 0.6, depthWrite: false });
|
|
98
|
+
const ghostPoint = new THREE.PointsMaterial({ color: 0xc4cad4, size: 3, sizeAttenuation: false, transparent: true, opacity: 0.75, depthWrite: false });
|
|
99
|
+
const pointMat = new THREE.PointsMaterial({ color: COLORS.points, size: 2, sizeAttenuation: false });
|
|
100
|
+
const boxMat = new THREE.LineBasicMaterial({ color: COLORS.box });
|
|
101
|
+
const hoverMat = nudge(new THREE.LineBasicMaterial({ color: COLORS.sel, transparent: true, opacity: 0.2, depthWrite: false }));
|
|
102
|
+
const SHARED = new Set([wireLight, wireDark, ghostMesh, ghostLine, ghostPoint, pointMat, boxMat, hoverMat]);
|
|
103
|
+
/** Above this many triangles a hovered piece shows its bounding box rather than its wireframe. */
|
|
104
|
+
const HOVER_WIRE_MAX = 20000;
|
|
105
|
+
|
|
106
|
+
// ------------------------------------------------------------------------------------------------ mesh objects
|
|
107
|
+
/** A compact Float32Array of a geometry's positions (an interleaved or offset attribute is copied). */
|
|
108
|
+
function positionsOf(g) {
|
|
109
|
+
const a = g.attributes.position;
|
|
110
|
+
if (!a.isInterleavedBufferAttribute && a.array instanceof Float32Array && a.itemSize === 3 && !a.normalized && a.array.length === a.count * 3) return a.array;
|
|
111
|
+
const out = new Float32Array(a.count * 3);
|
|
112
|
+
for (let i = 0; i < a.count; i++) { out[i * 3] = a.getX(i); out[i * 3 + 1] = a.getY(i); out[i * 3 + 2] = a.getZ(i); }
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
const indexOf = (g) => (g.index ? g.index.array : null);
|
|
116
|
+
const ray3 = { e1: new THREE.Vector3(), e2: new THREE.Vector3(), p: new THREE.Vector3(), t: new THREE.Vector3(), q: new THREE.Vector3() };
|
|
117
|
+
/** Möller–Trumbore, both sides: the distance along the ray, or Infinity. */
|
|
118
|
+
function hitTri(ray, P, a, b, c) {
|
|
119
|
+
const { e1, e2, p, t, q } = ray3;
|
|
120
|
+
e1.set(P[b * 3] - P[a * 3], P[b * 3 + 1] - P[a * 3 + 1], P[b * 3 + 2] - P[a * 3 + 2]);
|
|
121
|
+
e2.set(P[c * 3] - P[a * 3], P[c * 3 + 1] - P[a * 3 + 1], P[c * 3 + 2] - P[a * 3 + 2]);
|
|
122
|
+
p.crossVectors(ray.direction, e2);
|
|
123
|
+
const det = e1.dot(p);
|
|
124
|
+
if (Math.abs(det) < 1e-12) return Infinity;
|
|
125
|
+
const inv = 1 / det;
|
|
126
|
+
t.set(ray.origin.x - P[a * 3], ray.origin.y - P[a * 3 + 1], ray.origin.z - P[a * 3 + 2]);
|
|
127
|
+
const u = t.dot(p) * inv;
|
|
128
|
+
if (u < 0 || u > 1) return Infinity;
|
|
129
|
+
q.crossVectors(t, e1);
|
|
130
|
+
const v = ray.direction.dot(q) * inv;
|
|
131
|
+
if (v < 0 || u + v > 1) return Infinity;
|
|
132
|
+
const d = e2.dot(q) * inv;
|
|
133
|
+
return d > 0 ? d : Infinity;
|
|
134
|
+
}
|
|
135
|
+
const boxCorners = (b) => Array.from({ length: 8 }, (_, k) => new THREE.Vector3(k & 1 ? b.max.x : b.min.x, k & 2 ? b.max.y : b.min.y, k & 4 ? b.max.z : b.min.z));
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A loaded GLB scene with display modes, pieces, picking and highlights. Pieces are numbered across the object's
|
|
139
|
+
* drawables: each connected part of a mesh, each instance of an instanced mesh, each polyline, each point cloud.
|
|
140
|
+
*/
|
|
141
|
+
export class MeshObject {
|
|
142
|
+
constructor(id, src, { picture = false } = {}) {
|
|
143
|
+
this.id = id;
|
|
144
|
+
this.picture = picture;
|
|
145
|
+
this.root = new THREE.Group();
|
|
146
|
+
this.root.name = id;
|
|
147
|
+
this.src = src;
|
|
148
|
+
this.root.add(src);
|
|
149
|
+
this.parts = [];
|
|
150
|
+
src.updateMatrixWorld(true);
|
|
151
|
+
src.traverse((o) => {
|
|
152
|
+
const type = o.isInstancedMesh ? 'inst' : o.isMesh ? 'mesh' : o.isLineSegments ? 'lines' : o.isLine ? 'strip' : o.isPoints ? 'points' : null;
|
|
153
|
+
if (!type) return;
|
|
154
|
+
const part = { obj: o, type, orig: o.material, offset: 0, count: 1, a: null };
|
|
155
|
+
if (type === 'mesh' || type === 'inst') for (const m of [o.material].flat()) backTint(m);
|
|
156
|
+
if (type === 'points') for (const m of [o.material].flat()) { m.size = 3; m.sizeAttenuation = false; }
|
|
157
|
+
if ((type === 'lines' || type === 'strip') && !o.geometry.attributes.color) for (const m of [o.material].flat()) if (m.color?.getHex() === 0xffffff) m.color.set(COLORS.line);
|
|
158
|
+
this.parts.push(part);
|
|
159
|
+
});
|
|
160
|
+
this.extras = new THREE.Group();
|
|
161
|
+
this.root.add(this.extras);
|
|
162
|
+
this.mode = null;
|
|
163
|
+
/** drawn as its bounding box (set from the entry's display mode before each frame) */
|
|
164
|
+
this.bboxMode = false;
|
|
165
|
+
this.bboxObj = null;
|
|
166
|
+
this.hl = { key: '', objs: [] };
|
|
167
|
+
this.analysed = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Piece analysis, on first need. */
|
|
171
|
+
analyse() {
|
|
172
|
+
if (this.analysed) return;
|
|
173
|
+
this.analysed = true;
|
|
174
|
+
let offset = 0;
|
|
175
|
+
for (const part of this.parts) {
|
|
176
|
+
const g = part.obj.geometry;
|
|
177
|
+
part.offset = offset;
|
|
178
|
+
if (part.type === 'inst') { part.count = part.obj.count; g.computeBoundingBox(); }
|
|
179
|
+
else if (part.type === 'points') part.count = 1;
|
|
180
|
+
else {
|
|
181
|
+
const P = positionsOf(g), I = indexOf(g), n = g.attributes.position.count, w = weld(P);
|
|
182
|
+
let index = I;
|
|
183
|
+
// a strip's segments as pairs
|
|
184
|
+
if (part.type === 'strip') {
|
|
185
|
+
const m = I ? I.length : n, s = new Uint32Array(Math.max(0, m - 1) * 2);
|
|
186
|
+
for (let k = 0; k + 1 < m; k++) { s[k * 2] = I ? I[k] : k; s[k * 2 + 1] = I ? I[k + 1] : k + 1; }
|
|
187
|
+
index = s;
|
|
188
|
+
}
|
|
189
|
+
const per = part.type === 'mesh' ? 3 : 2;
|
|
190
|
+
const pc = pieces(index, n, w, per);
|
|
191
|
+
part.a = { P, index, n, w, per, pc, bounds: pieceBounds(P, pc.vert, pc.count) };
|
|
192
|
+
part.count = pc.count;
|
|
193
|
+
if (part.type === 'mesh') {
|
|
194
|
+
// triangles of each piece, for picking
|
|
195
|
+
const start = new Uint32Array(pc.count + 1);
|
|
196
|
+
for (const p of pc.elem) start[p + 1]++;
|
|
197
|
+
for (let k = 0; k < pc.count; k++) start[k + 1] += start[k];
|
|
198
|
+
const fill = start.slice(0, pc.count), tris = new Uint32Array(pc.elem.length);
|
|
199
|
+
pc.elem.forEach((p, t) => { tris[fill[p]++] = t; });
|
|
200
|
+
part.a.start = start; part.a.tris = tris;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
offset += part.count;
|
|
204
|
+
}
|
|
205
|
+
this.pieceTotal = offset;
|
|
206
|
+
}
|
|
207
|
+
pieceCount() { this.analyse(); return this.pieceTotal; }
|
|
208
|
+
/** {part, local} of a piece number */
|
|
209
|
+
partOf(k) { this.analyse(); for (const p of this.parts) if (k >= p.offset && k < p.offset + p.count) return { part: p, local: k - p.offset }; return null; }
|
|
210
|
+
|
|
211
|
+
/** World bounds of the object, or of one piece. */
|
|
212
|
+
bounds(piece = null) {
|
|
213
|
+
this.root.updateMatrixWorld(true);
|
|
214
|
+
if (piece === null) {
|
|
215
|
+
const b = new THREE.Box3();
|
|
216
|
+
for (const p of this.parts) {
|
|
217
|
+
const o = p.obj, g = o.geometry;
|
|
218
|
+
if (!g.attributes.position?.count) continue;
|
|
219
|
+
if (o.isInstancedMesh) { o.computeBoundingBox(); if (o.boundingBox) b.union(o.boundingBox.clone().applyMatrix4(o.matrixWorld)); continue; }
|
|
220
|
+
if (!g.boundingBox) g.computeBoundingBox();
|
|
221
|
+
b.union(g.boundingBox.clone().applyMatrix4(o.matrixWorld));
|
|
222
|
+
}
|
|
223
|
+
return b;
|
|
224
|
+
}
|
|
225
|
+
const at = this.partOf(piece);
|
|
226
|
+
if (!at) return new THREE.Box3();
|
|
227
|
+
const { part, local } = at;
|
|
228
|
+
if (part.type === 'inst') {
|
|
229
|
+
const m = new THREE.Matrix4();
|
|
230
|
+
part.obj.getMatrixAt(local, m);
|
|
231
|
+
return part.obj.geometry.boundingBox.clone().applyMatrix4(m.premultiply(part.obj.matrixWorld));
|
|
232
|
+
}
|
|
233
|
+
if (part.type === 'points') return new THREE.Box3().setFromObject(part.obj, true);
|
|
234
|
+
const B = part.a.bounds, o = local * 6;
|
|
235
|
+
return new THREE.Box3(new THREE.Vector3(B[o], B[o + 1], B[o + 2]), new THREE.Vector3(B[o + 3], B[o + 4], B[o + 5])).applyMatrix4(part.obj.matrixWorld);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The nearest piece the world ray `rc` (a Raycaster) hits: {piece, dist, point} or null. */
|
|
239
|
+
pick(rc, linePx = 0) {
|
|
240
|
+
if (!this.root.visible) return null;
|
|
241
|
+
this.analyse();
|
|
242
|
+
this.root.updateMatrixWorld(true);
|
|
243
|
+
let best = null;
|
|
244
|
+
const consider = (piece, dist, point) => { if (dist < (best?.dist ?? Infinity)) best = { piece, dist, point }; };
|
|
245
|
+
if (this.bboxMode) {
|
|
246
|
+
const b = this.bounds(), p = rc.ray.intersectBox(b, new THREE.Vector3());
|
|
247
|
+
if (p) consider(0, p.distanceTo(rc.ray.origin), p);
|
|
248
|
+
return best ? { ...best, piece: null } : null;
|
|
249
|
+
}
|
|
250
|
+
for (const part of this.parts) {
|
|
251
|
+
const o = part.obj;
|
|
252
|
+
if (part.type === 'mesh') {
|
|
253
|
+
const inv = o.matrixWorld.clone().invert(), local = rc.ray.clone().applyMatrix4(inv);
|
|
254
|
+
const { P, index, bounds, start, tris } = part.a, box = new THREE.Box3();
|
|
255
|
+
for (let k = 0; k < part.count; k++) {
|
|
256
|
+
box.min.set(bounds[k * 6], bounds[k * 6 + 1], bounds[k * 6 + 2]); box.max.set(bounds[k * 6 + 3], bounds[k * 6 + 4], bounds[k * 6 + 5]);
|
|
257
|
+
if (!local.intersectsBox(box)) continue;
|
|
258
|
+
let near = Infinity;
|
|
259
|
+
for (let s = start[k]; s < start[k + 1]; s++) {
|
|
260
|
+
const t = tris[s];
|
|
261
|
+
const a = index ? index[t * 3] : t * 3, b = index ? index[t * 3 + 1] : t * 3 + 1, c = index ? index[t * 3 + 2] : t * 3 + 2;
|
|
262
|
+
const d = hitTri(local, P, a, b, c);
|
|
263
|
+
if (d < near) near = d;
|
|
264
|
+
}
|
|
265
|
+
if (near < Infinity) {
|
|
266
|
+
const p = local.at(near, new THREE.Vector3()).applyMatrix4(o.matrixWorld);
|
|
267
|
+
consider(part.offset + k, p.distanceTo(rc.ray.origin), p);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else if (part.type === 'inst') {
|
|
271
|
+
const m = new THREE.Matrix4(), g = o.geometry, P = positionsOf(g), I = indexOf(g), nTri = (I ? I.length : P.length / 3) / 3;
|
|
272
|
+
for (let k = 0; k < o.count; k++) {
|
|
273
|
+
o.getMatrixAt(k, m);
|
|
274
|
+
m.premultiply(o.matrixWorld);
|
|
275
|
+
const local = rc.ray.clone().applyMatrix4(m.clone().invert());
|
|
276
|
+
if (!local.intersectsBox(g.boundingBox)) continue;
|
|
277
|
+
let near = Infinity;
|
|
278
|
+
for (let t = 0; t < nTri; t++) {
|
|
279
|
+
const d = hitTri(local, P, I ? I[t * 3] : t * 3, I ? I[t * 3 + 1] : t * 3 + 1, I ? I[t * 3 + 2] : t * 3 + 2);
|
|
280
|
+
if (d < near) near = d;
|
|
281
|
+
}
|
|
282
|
+
if (near < Infinity) { const p = local.at(near, new THREE.Vector3()).applyMatrix4(m); consider(part.offset + k, p.distanceTo(rc.ray.origin), p); }
|
|
283
|
+
}
|
|
284
|
+
} else if (linePx > 0 && o.visible) {
|
|
285
|
+
rc.params.Line.threshold = linePx; rc.params.Points.threshold = linePx;
|
|
286
|
+
const hits = rc.intersectObject(o, false);
|
|
287
|
+
if (!hits.length) continue;
|
|
288
|
+
const h = hits[0], vi = h.index ?? 0;
|
|
289
|
+
const local = part.type === 'points' ? 0 : Math.max(0, part.a.pc.vert[vi] ?? 0);
|
|
290
|
+
consider(part.offset + local, h.distance, h.point.clone());
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return best;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Pieces whose whole bounds project inside a slot rect: `inside(corners)` tests world points. */
|
|
297
|
+
boxPieces(inside) {
|
|
298
|
+
if (!this.root.visible) return [];
|
|
299
|
+
if (this.bboxMode) return inside(boxCorners(this.bounds())) ? [null] : [];
|
|
300
|
+
this.analyse();
|
|
301
|
+
const out = [];
|
|
302
|
+
for (let k = 0; k < this.pieceTotal; k++) if (inside(boxCorners(this.bounds(k)))) out.push(k);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Apply a display mode (resolved from 'view' by the caller) before a view renders. */
|
|
307
|
+
setMode(mode) {
|
|
308
|
+
if (this.mode === mode) return;
|
|
309
|
+
this.mode = mode;
|
|
310
|
+
const meshOn = mode === 'shaded' || mode === 'wire' || mode === 'shadedWire' || mode === 'ghost';
|
|
311
|
+
for (const part of this.parts) {
|
|
312
|
+
const o = part.obj;
|
|
313
|
+
if (part.type === 'mesh' || part.type === 'inst') {
|
|
314
|
+
o.visible = meshOn;
|
|
315
|
+
o.material = mode === 'wire' ? wireLight : mode === 'ghost' ? ghostMesh : part.orig;
|
|
316
|
+
if (mode === 'ghost' && o.isInstancedMesh) o.instanceColor = null;
|
|
317
|
+
this.wireOver(part, mode === 'shadedWire');
|
|
318
|
+
this.edges(part, mode === 'shaded' || mode === 'wire' || mode === 'shadedWire');
|
|
319
|
+
this.pointsOf(part, mode === 'points');
|
|
320
|
+
} else if (part.type === 'points') {
|
|
321
|
+
o.visible = mode !== 'bbox';
|
|
322
|
+
o.material = mode === 'ghost' ? ghostPoint : part.orig;
|
|
323
|
+
} else {
|
|
324
|
+
o.visible = mode !== 'bbox' && mode !== 'points';
|
|
325
|
+
o.material = mode === 'ghost' ? ghostLine : part.orig;
|
|
326
|
+
this.pointsOf(part, mode === 'points');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (mode === 'bbox' && !this.bboxObj) {
|
|
330
|
+
this.bboxObj = new THREE.Box3Helper(this.bounds(), COLORS.box);
|
|
331
|
+
this.bboxObj.material.dispose();
|
|
332
|
+
this.bboxObj.material = boxMat;
|
|
333
|
+
this.extras.add(this.bboxObj);
|
|
334
|
+
}
|
|
335
|
+
if (this.bboxObj) this.bboxObj.visible = mode === 'bbox';
|
|
336
|
+
}
|
|
337
|
+
wireOver(part, on) {
|
|
338
|
+
if (!on && !part.wire) return;
|
|
339
|
+
if (!part.wire) {
|
|
340
|
+
const o = part.obj;
|
|
341
|
+
part.wire = o.isInstancedMesh ? new THREE.InstancedMesh(o.geometry, wireDark, o.count) : new THREE.Mesh(o.geometry, wireDark);
|
|
342
|
+
if (o.isInstancedMesh) { part.wire.instanceMatrix = o.instanceMatrix; part.wire.instanceColor = null; }
|
|
343
|
+
part.wire.frustumCulled = false;
|
|
344
|
+
part.wire.renderOrder = 2;
|
|
345
|
+
o.add(part.wire);
|
|
346
|
+
}
|
|
347
|
+
part.wire.visible = on;
|
|
348
|
+
}
|
|
349
|
+
/** The render tool's open / non-manifold / flipped edges of a mesh (not of instances), found after the first frame. */
|
|
350
|
+
edges(part, on) {
|
|
351
|
+
// a picture is an image, not a surface: no edges to check
|
|
352
|
+
if (part.type !== 'mesh' || this.picture || (!on && !part.edgeObj)) return;
|
|
353
|
+
if (part.edgeObj === undefined) {
|
|
354
|
+
part.edgeObj = null;
|
|
355
|
+
setTimeout(() => { if (!this.disposed) { this.findEdges(part); this.mode = null; this.onReady?.(); } }, 0);
|
|
356
|
+
}
|
|
357
|
+
if (part.edgeObj) part.edgeObj.visible = on;
|
|
358
|
+
}
|
|
359
|
+
findEdges(part) {
|
|
360
|
+
this.analyse();
|
|
361
|
+
const { P, index, n, w } = part.a, e = edgeClasses(index, n, w);
|
|
362
|
+
part.edgeCounts = { open: e.open.length / 2, nonManifold: e.nonManifold.length / 2, flipped: e.flipped.length / 2 };
|
|
363
|
+
const total = e.open.length + e.nonManifold.length + e.flipped.length;
|
|
364
|
+
if (!total) return;
|
|
365
|
+
const pos = new Float32Array(total * 3), col = new Float32Array(total * 3);
|
|
366
|
+
let o = 0;
|
|
367
|
+
for (const [list, c] of [[e.open, EDGE_COLORS.open], [e.nonManifold, EDGE_COLORS.nonManifold], [e.flipped, EDGE_COLORS.flipped]]) {
|
|
368
|
+
const lc = new THREE.Color().setRGB(c[0], c[1], c[2], THREE.SRGBColorSpace);
|
|
369
|
+
for (const v of list) { pos.set(P.subarray(v * 3, v * 3 + 3), o * 3); col.set([lc.r, lc.g, lc.b], o * 3); o++; }
|
|
370
|
+
}
|
|
371
|
+
part.edgeObj = fatSegments(pos, { colors: col, width: 2, over: true });
|
|
372
|
+
part.edgeObj.renderOrder = 3;
|
|
373
|
+
part.edgeObj.name = 'open edges';
|
|
374
|
+
part.obj.add(part.edgeObj);
|
|
375
|
+
}
|
|
376
|
+
/** Edge counts once known: {open, nonManifold, flipped} or null. */
|
|
377
|
+
edgeCounts() {
|
|
378
|
+
const ps = this.parts.filter((p) => p.type === 'mesh');
|
|
379
|
+
if (!ps.length || ps.some((p) => !p.edgeCounts)) return ps.length ? null : { open: 0, nonManifold: 0, flipped: 0 };
|
|
380
|
+
return ps.reduce((s, p) => ({ open: s.open + p.edgeCounts.open, nonManifold: s.nonManifold + p.edgeCounts.nonManifold, flipped: s.flipped + p.edgeCounts.flipped }), { open: 0, nonManifold: 0, flipped: 0 });
|
|
381
|
+
}
|
|
382
|
+
pointsOf(part, on) {
|
|
383
|
+
if (!on && !part.points) return;
|
|
384
|
+
if (!part.points) {
|
|
385
|
+
const o = part.obj;
|
|
386
|
+
if (o.isInstancedMesh) {
|
|
387
|
+
const P = new Float32Array(o.count * 3), m = new THREE.Matrix4(), v = new THREE.Vector3();
|
|
388
|
+
for (let k = 0; k < o.count; k++) { o.getMatrixAt(k, m); v.setFromMatrixPosition(m); P.set([v.x, v.y, v.z], k * 3); }
|
|
389
|
+
const g = new THREE.BufferGeometry();
|
|
390
|
+
g.setAttribute('position', new THREE.BufferAttribute(P, 3));
|
|
391
|
+
part.points = new THREE.Points(g, pointMat);
|
|
392
|
+
} else part.points = new THREE.Points(o.geometry, pointMat);
|
|
393
|
+
part.points.frustumCulled = false;
|
|
394
|
+
// a sibling with the same transform: the mesh itself is hidden in this mode
|
|
395
|
+
o.parent.add(part.points);
|
|
396
|
+
part.points.position.copy(o.position); part.points.quaternion.copy(o.quaternion); part.points.scale.copy(o.scale);
|
|
397
|
+
}
|
|
398
|
+
part.points.visible = on;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Draw the highlight: `sel` = {whole, pieces: Set}, `hover` = {whole, piece}. Selection is an orange wireframe,
|
|
403
|
+
* hover a dimmer thinner one; both are rebuilt only when they change.
|
|
404
|
+
*/
|
|
405
|
+
highlight(sel, hover) {
|
|
406
|
+
const key = JSON.stringify([sel.whole, [...sel.pieces].sort((a, b) => a - b), hover.whole, hover.piece, !!this.bboxMode]);
|
|
407
|
+
if (key === this.hl.key) return;
|
|
408
|
+
this.hl.key = key;
|
|
409
|
+
for (const o of this.hl.objs) { o.parent?.remove(o); if (!SHARED.has(o.material)) o.material.dispose(); o.geometry.dispose(); }
|
|
410
|
+
this.hl.objs = [];
|
|
411
|
+
if (!sel.whole && !sel.pieces.size && !hover.whole && hover.piece === null) return;
|
|
412
|
+
this.analyse();
|
|
413
|
+
const box = (b, selected) => {
|
|
414
|
+
const h = new THREE.Box3Helper(b, COLORS.sel);
|
|
415
|
+
if (!selected) { h.material.dispose(); h.material = hoverMat; }
|
|
416
|
+
this.extras.add(h); this.hl.objs.push(h);
|
|
417
|
+
};
|
|
418
|
+
const add = (selected, whole, set) => {
|
|
419
|
+
if (this.bboxMode) return box(this.bounds(), selected);
|
|
420
|
+
for (const part of this.parts) {
|
|
421
|
+
const keep = new Set();
|
|
422
|
+
for (let k = 0; k < part.count; k++) if (whole || set.has(part.offset + k)) keep.add(k);
|
|
423
|
+
if (!keep.size) continue;
|
|
424
|
+
// hovering a big piece (a terrain) outlines its box: its wireframe would wash the view orange
|
|
425
|
+
if (!selected && this.triangles(part, keep) > HOVER_WIRE_MAX) { for (const k of keep) box(this.bounds(part.offset + k), false); continue; }
|
|
426
|
+
const pos = this.wirePositions(part, keep);
|
|
427
|
+
if (!pos?.length) continue;
|
|
428
|
+
let obj;
|
|
429
|
+
if (selected) obj = fatSegments(pos, { color: COLORS.sel, width: 1.5, over: true });
|
|
430
|
+
else {
|
|
431
|
+
const g = new THREE.BufferGeometry();
|
|
432
|
+
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
433
|
+
obj = new THREE.LineSegments(g, hoverMat);
|
|
434
|
+
obj.frustumCulled = false;
|
|
435
|
+
}
|
|
436
|
+
obj.renderOrder = selected ? 5 : 4;
|
|
437
|
+
// wireframes are in the part's space, boxes in world space; either way they hang off the object, shown in every mode
|
|
438
|
+
if (this.partSpace(part)) { obj.matrixAutoUpdate = false; obj.matrix.copy(part.obj.matrixWorld); }
|
|
439
|
+
this.extras.add(obj);
|
|
440
|
+
this.hl.objs.push(obj);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
if (sel.whole || sel.pieces.size) add(true, sel.whole, sel.pieces);
|
|
444
|
+
if (hover.whole || (hover.piece !== null && !sel.whole && !sel.pieces.has(hover.piece))) add(false, hover.whole, new Set(hover.piece === null ? [] : [hover.piece]));
|
|
445
|
+
}
|
|
446
|
+
/** Triangles of the chosen local pieces of a part. */
|
|
447
|
+
triangles(part, keep) {
|
|
448
|
+
if (part.type !== 'mesh') return 0;
|
|
449
|
+
let n = 0;
|
|
450
|
+
for (const k of keep) n += part.a.start[k + 1] - part.a.start[k];
|
|
451
|
+
return n;
|
|
452
|
+
}
|
|
453
|
+
/** Whether a part's highlight is drawn in its own space (wireframes) rather than in world space (boxes). */
|
|
454
|
+
partSpace(part) { return (part.type === 'mesh' && !this.picture) || part.type === 'lines' || part.type === 'strip'; }
|
|
455
|
+
/** Line pairs outlining the chosen local pieces of a part: a wireframe in part space, or boxes in world space. */
|
|
456
|
+
wirePositions(part, keep) {
|
|
457
|
+
if (part.type === 'mesh' && !this.picture) {
|
|
458
|
+
const { P, index, n, w, pc } = part.a;
|
|
459
|
+
const sub = subsetIndex(index, n, pc.elem, keep), pairs = edgePairs(sub, w);
|
|
460
|
+
const out = new Float32Array(pairs.length * 3);
|
|
461
|
+
pairs.forEach((v, i) => { out[i * 3] = P[v * 3]; out[i * 3 + 1] = P[v * 3 + 1]; out[i * 3 + 2] = P[v * 3 + 2]; });
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
if (part.type === 'lines' || part.type === 'strip') {
|
|
465
|
+
const { P, index, n, pc } = part.a, sub = subsetIndex(index, n, pc.elem, keep, 2);
|
|
466
|
+
const out = new Float32Array(sub.length * 3);
|
|
467
|
+
sub.forEach((v, i) => { out[i * 3] = P[v * 3]; out[i * 3 + 1] = P[v * 3 + 1]; out[i * 3 + 2] = P[v * 3 + 2]; });
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
// instances, point clouds and pictures: their bounding boxes
|
|
471
|
+
const out = [];
|
|
472
|
+
for (const k of keep) {
|
|
473
|
+
const b = this.bounds(part.offset + k), c = boxCorners(b);
|
|
474
|
+
for (const [i, j] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) out.push(c[i].x, c[i].y, c[i].z, c[j].x, c[j].y, c[j].z);
|
|
475
|
+
}
|
|
476
|
+
return new Float32Array(out);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Counts for the properties panel: {meshes, triangles, vertices, lines, points, instances}. */
|
|
480
|
+
stats() {
|
|
481
|
+
const s = { triangles: 0, vertices: 0, segments: 0, points: 0, instances: 0 };
|
|
482
|
+
for (const p of this.parts) {
|
|
483
|
+
const g = p.obj.geometry, n = g.attributes.position?.count ?? 0, idx = g.index?.count;
|
|
484
|
+
if (p.type === 'mesh' || p.type === 'inst') { s.triangles += (idx ?? n) / 3 * (p.type === 'inst' ? p.obj.count : 1); s.vertices += n; if (p.type === 'inst') s.instances += p.obj.count; }
|
|
485
|
+
else if (p.type === 'points') s.points += n;
|
|
486
|
+
else s.segments += p.type === 'lines' ? (idx ?? n) / 2 : Math.max(0, (idx ?? n) - 1);
|
|
487
|
+
}
|
|
488
|
+
return s;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
dispose() {
|
|
492
|
+
this.disposed = true;
|
|
493
|
+
this.root.parent?.remove(this.root);
|
|
494
|
+
for (const o of this.hl.objs) { if (!SHARED.has(o.material)) o.material.dispose(); o.geometry.dispose(); }
|
|
495
|
+
for (const p of this.parts) {
|
|
496
|
+
for (const m of [p.orig].flat()) { for (const v of Object.values(m)) if (v?.isTexture) v.dispose(); m.dispose(); }
|
|
497
|
+
p.edgeObj?.geometry.dispose(); p.edgeObj?.material.dispose();
|
|
498
|
+
if (p.points && p.points.geometry !== p.obj.geometry) p.points.geometry.dispose();
|
|
499
|
+
}
|
|
500
|
+
disposeTree(this.root);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// --------------------------------------------------------------------------------------------- loading GLBs
|
|
505
|
+
const loader = new GLTFLoader();
|
|
506
|
+
/** Parse a GLB buffer into a three.js scene, or throw. */
|
|
507
|
+
export const parseGLB = (buf) => new Promise((resolve, reject) => loader.parse(buf, '', (g) => resolve(g.scene), reject));
|
|
508
|
+
/** A PNG arrives as flat quads with unlit materials named "image:<name>" (src/render/texview.ts): a picture. */
|
|
509
|
+
export function isPicture(root) {
|
|
510
|
+
let meshes = 0, all = true;
|
|
511
|
+
root.traverse((o) => { if (o.isMesh) { meshes++; all &&= [o.material].flat().every((m) => m.name?.startsWith('image:')); } });
|
|
512
|
+
return meshes > 0 && all;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Lights for a matte look: a sky / ground fill and a key light that follows each view's camera. */
|
|
516
|
+
export function createLights(scene) {
|
|
517
|
+
const hemi = new THREE.HemisphereLight(0xdfe6f0, 0x3b342c, 1.5);
|
|
518
|
+
const key = new THREE.DirectionalLight(0xffffff, 2.0);
|
|
519
|
+
scene.add(hemi, key, key.target);
|
|
520
|
+
return {
|
|
521
|
+
/** from above the camera's left shoulder, at the view's target */
|
|
522
|
+
follow(camera, target) {
|
|
523
|
+
const r = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion), u = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion), b = new THREE.Vector3(0, 0, 1).applyQuaternion(camera.quaternion);
|
|
524
|
+
key.target.position.copy(target);
|
|
525
|
+
key.position.copy(target).addScaledVector(b, 1).addScaledVector(u, 0.8).addScaledVector(r, -0.6);
|
|
526
|
+
key.target.updateMatrixWorld(); key.updateMatrixWorld();
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Picking and selecting in the viewport (docs/design/viewer.md section 5): click selects what is under the cursor
|
|
2
|
+
// (a guide, else the nearest piece of the output or of a reference layer), Shift adds, Ctrl removes, a drag pulls a
|
|
3
|
+
// box that selects what lies wholly inside it, a click on nothing clears. Hover draws a dimmer highlight. All
|
|
4
|
+
// positions are viewport pixels.
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { opOf, parseId, pieceId } from './selection.js';
|
|
7
|
+
import { AXIS_VIEWS } from './camera.js';
|
|
8
|
+
|
|
9
|
+
const DRAG_PX = 4;
|
|
10
|
+
|
|
11
|
+
export function createSelect(ctx) {
|
|
12
|
+
const S = { press: null, boxEl: document.getElementById('boxsel'), hoverAt: null, hoverQueued: false };
|
|
13
|
+
const meshEntries = () => [...ctx.objects.values()].filter((e) => e.mesh && e.visible && e.mesh.root.visible);
|
|
14
|
+
|
|
15
|
+
/** What is under a viewport point in a slot: {id, point?} or null. Guides first (they draw on top). */
|
|
16
|
+
S.pick = (slot, x, y) => {
|
|
17
|
+
const g = ctx.guides.pick(slot, x, y);
|
|
18
|
+
if (g) return { id: g.id };
|
|
19
|
+
const rc = ctx.views.ray(slot, x, y);
|
|
20
|
+
const linePx = ctx.views.worldPerPixel(slot) * 5;
|
|
21
|
+
let best = null;
|
|
22
|
+
for (const e of meshEntries()) {
|
|
23
|
+
if (e.pickable === false) continue;
|
|
24
|
+
const h = e.mesh.pick(rc, linePx);
|
|
25
|
+
if (h && h.dist < (best?.dist ?? Infinity)) best = { ...h, entry: e };
|
|
26
|
+
}
|
|
27
|
+
if (!best) return null;
|
|
28
|
+
const n = best.entry.mesh.pieceCount();
|
|
29
|
+
return { id: best.piece === null || n <= 1 ? best.entry.id : pieceId(best.entry.id, best.piece), point: best.point };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The construction plane of a slot's camera: y = 0 for persp, top and bottom; the view's own axis plane else. */
|
|
33
|
+
const planeOf = (slot) => {
|
|
34
|
+
const c = ctx.views.cam(slot), f = c.name === 'persp' ? [0, -1, 0] : AXIS_VIEWS[c.name].f;
|
|
35
|
+
return new THREE.Plane(new THREE.Vector3(Math.abs(f[0]), Math.abs(f[1]), Math.abs(f[2])), 0);
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Where a guide lands under a viewport point: the surface of the output or of a reference layer (not pictures,
|
|
39
|
+
* not the markers file), else the slot's construction plane. {point, surface} or null.
|
|
40
|
+
*/
|
|
41
|
+
S.surfacePoint = (slot, x, y) => {
|
|
42
|
+
const rc = ctx.views.ray(slot, x, y);
|
|
43
|
+
let best = null;
|
|
44
|
+
for (const e of meshEntries()) {
|
|
45
|
+
if (e.surface === false || e.mesh.bboxMode) continue;
|
|
46
|
+
const h = e.mesh.pick(rc, 0);
|
|
47
|
+
if (h && h.dist < (best?.dist ?? Infinity)) best = h;
|
|
48
|
+
}
|
|
49
|
+
if (best) return { point: best.point, surface: true };
|
|
50
|
+
const p = rc.ray.intersectPlane(planeOf(slot), new THREE.Vector3());
|
|
51
|
+
return p ? { point: p, surface: false } : null;
|
|
52
|
+
};
|
|
53
|
+
/** A viewport point on the horizontal plane at height `level`, through slot's camera. */
|
|
54
|
+
S.planePoint = (slot, x, y, level) => ctx.views.ray(slot, x, y).ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), -level), new THREE.Vector3());
|
|
55
|
+
|
|
56
|
+
// ------------------------------------------------------------------ click, box
|
|
57
|
+
S.down = (slot, x, y, e) => { S.press = { slot, x, y, op: opOf(e), box: false }; };
|
|
58
|
+
S.move = (x, y) => {
|
|
59
|
+
const p = S.press;
|
|
60
|
+
if (!p) return;
|
|
61
|
+
// as Maya's select tool: a left drag pulls a box wherever it starts (nothing moves by dragging in phase 1)
|
|
62
|
+
if (!p.box && Math.hypot(x - p.x, y - p.y) > DRAG_PX) p.box = true;
|
|
63
|
+
if (p.box) {
|
|
64
|
+
const r = ctx.views.slots[p.slot].rect;
|
|
65
|
+
const cx = Math.max(r.x, Math.min(r.x + r.w, x)), cy = Math.max(r.y, Math.min(r.y + r.h, y));
|
|
66
|
+
Object.assign(S.boxEl.style, { left: `${Math.min(p.x, cx)}px`, top: `${Math.min(p.y, cy)}px`, width: `${Math.abs(cx - p.x)}px`, height: `${Math.abs(cy - p.y)}px` });
|
|
67
|
+
S.boxEl.hidden = false;
|
|
68
|
+
p.end = { x: cx, y: cy };
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
S.up = (x, y) => {
|
|
72
|
+
const p = S.press;
|
|
73
|
+
S.press = null;
|
|
74
|
+
S.boxEl.hidden = true;
|
|
75
|
+
if (!p) return;
|
|
76
|
+
if (p.box) {
|
|
77
|
+
const e = p.end ?? { x, y };
|
|
78
|
+
return S.boxSelect(p.slot, { x0: Math.min(p.x, e.x), y0: Math.min(p.y, e.y), x1: Math.max(p.x, e.x), y1: Math.max(p.y, e.y) }, p.op);
|
|
79
|
+
}
|
|
80
|
+
const hit = S.pick(p.slot, x, y);
|
|
81
|
+
if (hit) ctx.selection.apply([hit.id], p.op);
|
|
82
|
+
else if (p.op === 'replace') ctx.selection.clear();
|
|
83
|
+
};
|
|
84
|
+
S.cancel = () => { S.press = null; S.boxEl.hidden = true; };
|
|
85
|
+
/** Everything wholly inside a viewport rect of a slot. */
|
|
86
|
+
S.boxSelect = (slot, rect, op = 'replace') => {
|
|
87
|
+
const cam = ctx.views.camera(slot);
|
|
88
|
+
const inside = (pts) => pts.every((w) => {
|
|
89
|
+
const q = ctx.views.project(slot, w, cam);
|
|
90
|
+
return q && q.x >= rect.x0 && q.x <= rect.x1 && q.y >= rect.y0 && q.y <= rect.y1;
|
|
91
|
+
});
|
|
92
|
+
const ids = [...ctx.guides.boxSelect(inside)];
|
|
93
|
+
for (const e of meshEntries()) {
|
|
94
|
+
if (e.pickable === false) continue;
|
|
95
|
+
const ks = e.mesh.boxPieces(inside);
|
|
96
|
+
const n = e.mesh.bboxMode ? 1 : e.mesh.pieceCount();
|
|
97
|
+
if (ks.length && (ks.includes(null) || n <= 1 || ks.length === n)) ids.push(e.id);
|
|
98
|
+
else for (const k of ks) ids.push(pieceId(e.id, k));
|
|
99
|
+
}
|
|
100
|
+
if (ids.length || op === 'replace') ctx.selection.apply(ids, op);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ------------------------------------------------------------------ hover
|
|
104
|
+
/** Ask for the hover under (x, y) in a slot (picked at most every 16 ms), or clear it (slot -1). */
|
|
105
|
+
S.hover = (slot, x, y) => {
|
|
106
|
+
S.hoverAt = slot < 0 ? null : { slot, x, y };
|
|
107
|
+
if (S.hoverQueued) return;
|
|
108
|
+
S.hoverQueued = true;
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
S.hoverQueued = false;
|
|
111
|
+
const h = S.hoverAt && !S.press && !ctx.guides.mode ? S.pick(S.hoverAt.slot, S.hoverAt.x, S.hoverAt.y) : null;
|
|
112
|
+
ctx.setHover(h?.id ?? null);
|
|
113
|
+
}, 16);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ------------------------------------------------------------------ highlights, bounds, pivot
|
|
117
|
+
/** Push the selection and the hover to the objects that draw them. */
|
|
118
|
+
S.highlight = () => {
|
|
119
|
+
for (const e of meshEntries()) {
|
|
120
|
+
const sel = { whole: ctx.selection.has(e.id), pieces: new Set() }, hov = { whole: ctx.hover === e.id, piece: null };
|
|
121
|
+
for (const id of ctx.selection.items) { const { obj, piece } = parseId(id); if (obj === e.id && piece !== null) sel.pieces.add(piece); }
|
|
122
|
+
if (ctx.hover && ctx.hover !== e.id) { const { obj, piece } = parseId(ctx.hover); if (obj === e.id) hov.piece = piece; }
|
|
123
|
+
e.mesh.highlight(sel, hov);
|
|
124
|
+
}
|
|
125
|
+
ctx.guides.draw();
|
|
126
|
+
};
|
|
127
|
+
/** Bounds of one id (an object or a piece), or null when it has no geometry on screen. */
|
|
128
|
+
S.boundsOf = (id) => {
|
|
129
|
+
const { obj, piece } = parseId(id), e = ctx.objects.get(obj);
|
|
130
|
+
if (!e) return null;
|
|
131
|
+
if (e.kind === 'guide') return ctx.guides.bounds(e.marker);
|
|
132
|
+
if (!e.mesh) return null;
|
|
133
|
+
const b = e.mesh.bounds(piece);
|
|
134
|
+
return b.isEmpty() ? null : b;
|
|
135
|
+
};
|
|
136
|
+
S.selectionBounds = () => {
|
|
137
|
+
const b = new THREE.Box3();
|
|
138
|
+
for (const id of ctx.selection.items) { const x = S.boundsOf(id); if (x) b.union(x); }
|
|
139
|
+
return b;
|
|
140
|
+
};
|
|
141
|
+
return S;
|
|
142
|
+
}
|