@thatopen/services 0.3.11 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/cli/commands/revit.d.ts +9 -0
  2. package/dist/cli/lib/revit-addin.d.ts +20 -0
  3. package/dist/cli.js +101 -0
  4. package/dist/cloud/LasToPotree.d.ts +59 -0
  5. package/dist/cloud/SplatToFq.d.ts +38 -0
  6. package/dist/cloud/index.d.ts +2 -0
  7. package/docs/builtin/paths.json +86 -86
  8. package/docs/cloud/GaussianSplatConverter.md +43 -0
  9. package/docs/cloud/PointCloudConverter.md +53 -0
  10. package/docs/revit-collab-quickstart.md +151 -0
  11. package/package.json +95 -95
  12. package/src/built-in/ClashesManager/example.ts +283 -283
  13. package/src/built-in/UIManager/src/clashes-panel/example.ts +160 -160
  14. package/src/built-in/UIManager/src/models-list/example.ts +122 -118
  15. package/src/cli/templates/app/package.json +33 -33
  16. package/src/cli/templates/app/src/setups/camera-tools.ts +0 -63
  17. package/src/cli/templates/app/src/setups/camera-views.ts +0 -201
  18. package/src/cli/templates/app/src/setups/card-header.ts +0 -35
  19. package/src/cli/templates/app/src/setups/clipper-tool.ts +0 -773
  20. package/src/cli/templates/app/src/setups/clipper.ts +0 -75
  21. package/src/cli/templates/app/src/setups/cloud-runner.ts +0 -13
  22. package/src/cli/templates/app/src/setups/exploded-view.ts +0 -246
  23. package/src/cli/templates/app/src/setups/helper-panel.ts +0 -88
  24. package/src/cli/templates/app/src/setups/hider.ts +0 -98
  25. package/src/cli/templates/app/src/setups/index.ts +0 -23
  26. package/src/cli/templates/app/src/setups/inspection.ts +0 -136
  27. package/src/cli/templates/app/src/setups/measurement-panel.ts +0 -188
  28. package/src/cli/templates/app/src/setups/measurement-tool.ts +0 -588
  29. package/src/cli/templates/app/src/setups/measurements.ts +0 -133
  30. package/src/cli/templates/app/src/setups/navigation-gizmo.ts +0 -316
  31. package/src/cli/templates/app/src/setups/plans-panel.ts +0 -619
  32. package/src/cli/templates/app/src/setups/right-sidebar.ts +0 -178
  33. package/src/cli/templates/app/src/setups/styles-panel.ts +0 -110
  34. package/src/cli/templates/app/src/setups/styles.ts +0 -375
  35. package/src/cli/templates/app/src/setups/tool-mode-manager.ts +0 -156
  36. package/src/cli/templates/app/src/setups/tool-mode.ts +0 -23
  37. package/src/cli/templates/app/src/setups/toolbar.ts +0 -229
  38. package/src/cli/templates/app/src/setups/ui-manager.ts +0 -27
@@ -1,773 +0,0 @@
1
- import * as THREE from "three";
2
- import * as OBC from "@thatopen/components";
3
- import * as OBF from "@thatopen/components-front";
4
- import { toolModeManager, type ManagedTool } from "./tool-mode-manager";
5
- import type { InstanceRow } from "./inspection";
6
-
7
- /**
8
- * Clipping / section-plane TOOL (logic only; the UI lives in clipper-panel.ts).
9
- *
10
- * Two parts:
11
- *
12
- * 1. PLANES (OBC.Clipper) — placing mode (double-click a surface drops a plane on
13
- * the picked face), drag the gizmo to move, Delete removes the hovered plane,
14
- * per-plane + master enable, clear-all. `localClippingPlanes` is on (before any
15
- * plane) so cuts sit exactly on the plane.
16
- *
17
- * 2. SECTION STYLING (OBF.ClipStyler) — one global "Section" style (a fill + an
18
- * edge line) applied to the whole model ("All") at every clip plane, matching
19
- * the stock ClipStyler example. The deferred-overlay treatment (depthTest/
20
- * depthWrite off, line resolution, renderOrder-on-top, registration into
21
- * `postproduction.basePass.isolatedMaterials`) lives centrally in ClipEdges, so
22
- * here we only supply the fill/edge colours + opacity. Colour changes rebuild
23
- * the section edges (cheap).
24
- */
25
- type ClipEdges = ReturnType<OBF.ClipStyler["createFromClipping"]>;
26
-
27
- export interface SectionStyle {
28
- fill: string; // "#rrggbb"
29
- line: string; // "#rrggbb"
30
- opacity: number; // 0..1 fill opacity
31
- width: number; // edge line width (px)
32
- }
33
-
34
- export interface ClipperTool {
35
- readonly clipper: OBC.Clipper;
36
- /** Fires when the set of planes (or their enabled state) changes. */
37
- readonly onChanged: OBC.Event<void>;
38
- /** Fires when the section style (colours/opacity/visibility) changes. */
39
- readonly onStyleChanged: OBC.Event<void>;
40
-
41
- // ── Planes ──
42
- setPlacing(on: boolean): void;
43
- isPlacing(): boolean;
44
- setEnabled(on: boolean): void;
45
- isEnabled(): boolean;
46
- /** Enable/disable a single plane AND show/hide its section accordingly. */
47
- setPlaneEnabled(id: string, on: boolean): void;
48
- deletePlane(id: string): void;
49
- clearAll(): void;
50
- /** Per-plane rows for the Objects outliner (W2): hide / enable / delete. */
51
- instances(): InstanceRow[];
52
-
53
- // ── Single global section style ──
54
- getSectionStyle(): SectionStyle;
55
- setSectionFill(hex: string): void;
56
- setSectionLine(hex: string): void;
57
- setSectionOpacity(value: number): void;
58
- setSectionWidth(value: number): void;
59
- /** Master visibility of the section fills/edges. */
60
- setStylingVisible(on: boolean): void;
61
- isStylingVisible(): boolean;
62
- }
63
-
64
- const CLASSIFICATION = "Categories"; // OBC.Classifier.byCategory default
65
- export const STYLE_NAME = "Section";
66
-
67
- /**
68
- * Register the global "Section" ClipStyler style idempotently, so other
69
- * consumers (e.g. the floor-plans panel calling `createFromView`) can reference
70
- * it by name WITHOUT depending on `clipperTool()` having constructed first.
71
- * No-op if it already exists — `clipperTool` registers a live, user-editable
72
- * instance in its `rebuildStyles`, and this must not clobber that one.
73
- */
74
- export const ensureSectionStyle = (components: OBC.Components) => {
75
- const styler = components.get(OBF.ClipStyler);
76
- if (styler.styles.get(STYLE_NAME)) return;
77
- styler.styles.set(STYLE_NAME, {
78
- // NearPlaneLineMaterial: a fat line (real px width) that ALSO discards
79
- // segments behind the camera plane in-shader, so it gives thick edges WITHOUT
80
- // the near-plane "infinity streak" the stock fat LineMaterial produced.
81
- linesMaterial: new OBF.NearPlaneLineMaterial({
82
- color: "#111111",
83
- linewidth: 2,
84
- }),
85
- fillsMaterial: new THREE.MeshBasicMaterial({
86
- color: "#a96eec",
87
- side: THREE.DoubleSide,
88
- }),
89
- });
90
- };
91
-
92
- const clipperToolImpl = (components: OBC.Components): ClipperTool => {
93
- const clipper = components.get(OBC.Clipper);
94
- clipper.localClippingPlanes = true; // before any plane is created
95
- clipper.enabled = true;
96
- clipper.visible = true;
97
- // Constant-size plane representation (world units): disable auto-scaling (which
98
- // otherwise sizes each plane to the model/surface) and pin a fixed size — tune
99
- // the single value to taste.
100
- clipper.autoScalePlanes = false;
101
- clipper.size = 5;
102
-
103
- const styler = components.get(OBF.ClipStyler);
104
- const classifier = components.get(OBC.Classifier);
105
- const fragments = components.get(OBC.FragmentsManager);
106
- const worlds = components.get(OBC.Worlds);
107
- const getWorld = () => [...worlds.list.values()][0] as OBC.World | undefined;
108
-
109
- const onChanged = new OBC.Event<void>();
110
- const onStyleChanged = new OBC.Event<void>();
111
-
112
- const manager = toolModeManager(components);
113
-
114
- // ── Plane placing / canvas binding ───────────────────────────────
115
- let placing = false;
116
- let canvas: HTMLElement | undefined;
117
-
118
- // Registered with the ToolMode manager: when another tool takes over, exit
119
- // placing mode locally (the manager handles hover/select suppression).
120
- const managed: ManagedTool = {
121
- id: "clipper",
122
- label: "Drawing clipping plane",
123
- icon: "mdi:scissors-cutting",
124
- onDeactivate: () => {
125
- placing = false;
126
- if (canvas) canvas.style.cursor = "";
127
- onChanged.trigger();
128
- },
129
- };
130
-
131
- const onDblClick = () => {
132
- const world = getWorld();
133
- if (!placing || !world) return;
134
- void clipper.create(world);
135
- };
136
- const onKeyDown = (event: KeyboardEvent) => {
137
- if (event.code !== "Delete" && event.code !== "Backspace") return;
138
- const world = getWorld();
139
- if (world) void clipper.delete(world);
140
- };
141
- const ensureCanvas = () => {
142
- if (canvas) return canvas;
143
- const renderer = getWorld()?.renderer as
144
- | OBF.PostproductionRenderer
145
- | undefined;
146
- const c = renderer?.three.domElement;
147
- if (c) {
148
- canvas = c;
149
- canvas.addEventListener("dblclick", onDblClick);
150
- window.addEventListener("keydown", onKeyDown);
151
- }
152
- return canvas;
153
- };
154
- ensureCanvas();
155
-
156
- // ── Section styling state ────────────────────────────────────────
157
- // `categories` is only kept to satisfy buildEdgesFor's classify gate (the
158
- // section builds once the model is classified); it no longer drives styling.
159
- let categories: string[] = [];
160
- const edgesByPlane = new Map<string, ClipEdges>();
161
- const planesWired = new Set<string>();
162
- // Planes currently being dragged. SimplePlane fires TransformControls "change"
163
- // every drag tick → notifyManager → clipper.list.onItemSet, which would
164
- // re-section the plane LIVE on every frame. That spams overlapping async
165
- // rebuilds (which race and drop edges — the "missing edges while dragging") and
166
- // costs a full re-section per frame. We instead BLINK: hide on drag-start, skip
167
- // the per-frame rebuild while dragging, and rebuild once on release.
168
- const draggingPlanes = new Set<string>();
169
- // Gizmo (TransformControls) + section-plane-mesh materials we registered into
170
- // the postproduction overlay set per plane, so we can unregister on delete.
171
- const gizmoMatsByPlane = new Map<string, THREE.Material[]>();
172
-
173
- const sectionStyle: SectionStyle = {
174
- // Pre-compensated purple: the deferred postproduction linearizes scene
175
- // colors (sRGB→linear ≈ c^2.4), so #6528d7 would render as #2105ad. Feeding
176
- // the linear→sRGB inverse (#a96eec) makes the fill GRADE to the app purple
177
- // #6528d7 on screen, while still shaded naturally by the PEN scene.
178
- fill: "#a96eec",
179
- line: "#111111",
180
- opacity: 1,
181
- width: 2,
182
- };
183
-
184
- // One global "Section" style (fill + edge line) applied to the whole model
185
- // ("All"), matching the stock ClipStyler example. The deferred-overlay
186
- // treatment (depthTest/depthWrite off, line resolution, renderOrder-on-top,
187
- // overlay registration) lives centrally in ClipEdges.
188
- const rebuildStyles = () => {
189
- styler.styles.set(STYLE_NAME, {
190
- // NearPlaneLineMaterial: fat line (real px width via `linewidth`) whose
191
- // shader discards segments behind the camera plane, so we get thick section
192
- // edges with NO near-plane "infinity streak". Re-enables the Edge-width
193
- // control (the setter drives `linewidth`). ClipEdges' fat-line path
194
- // (isLineMaterial) builds a LineSegments2 and keeps `resolution` in sync.
195
- linesMaterial: new OBF.NearPlaneLineMaterial({
196
- color: sectionStyle.line,
197
- linewidth: sectionStyle.width,
198
- }),
199
- fillsMaterial: new THREE.MeshBasicMaterial({
200
- color: sectionStyle.fill,
201
- side: THREE.DoubleSide,
202
- transparent: sectionStyle.opacity < 1,
203
- opacity: sectionStyle.opacity,
204
- }),
205
- });
206
- };
207
- rebuildStyles();
208
-
209
- const buildItems = () => {
210
- return { All: { style: STYLE_NAME } } as Record<
211
- string,
212
- { style: string; data?: Record<string, string[]> }
213
- >;
214
- };
215
-
216
- const buildEdgesFor = (planeId: string) => {
217
- const plane = clipper.list.get(planeId);
218
- if (!getWorld() || categories.length === 0 || !plane) return;
219
- // A DISABLED plane applies no cut, so it must show no section — hide its
220
- // existing fill/edges and don't compute a new one. Re-enabling fires
221
- // onItemSet again (via SimplePlane.notifyManager), which rebuilds + shows it.
222
- if (!plane.enabled) {
223
- const existing = edgesByPlane.get(planeId);
224
- if (existing) existing.visible = false;
225
- // Request a frame so the now-hidden section disappears immediately rather
226
- // than lingering until the next camera move (the deferred overlay only
227
- // re-composites on demand).
228
- getWorld()?.renderer?.update();
229
- return;
230
- }
231
- styler.world = getWorld() ?? null;
232
- // Drop any previous edges for this plane (DataMap delete disposes them).
233
- if (styler.list.has(planeId)) styler.list.delete(planeId);
234
- edgesByPlane.delete(planeId);
235
-
236
- const items = buildItems();
237
- if (Object.keys(items).length === 0) return;
238
- // link:false → no auto onDraggingEnded/onDisposed listeners (we manage those
239
- // once per plane below, so rebuilds don't accumulate handlers).
240
- const edges = styler.createFromClipping(planeId, {
241
- id: planeId,
242
- items,
243
- link: false,
244
- world: getWorld() ?? undefined,
245
- });
246
- edgesByPlane.set(planeId, edges);
247
- // edges.update() is async (awaits getSection); the new overlay materials
248
- // only exist once it resolves. Kick a render THEN so the recoloured/rebuilt
249
- // section appears immediately instead of staying blank until a camera move
250
- // invalidates the deferred frame.
251
- void Promise.resolve(edges.update())
252
- .then(() => {
253
- // The regenerated section fill/edge meshes keep a stale bounding sphere,
254
- // so three.js frustum-culls them even when they're on screen (the fill
255
- // flickers out as the camera moves). Disable culling on them — they're
256
- // tiny section overlays, so the cull saving is irrelevant.
257
- edges.three.traverse((o) => {
258
- o.frustumCulled = false;
259
- });
260
- getWorld()?.renderer?.update();
261
- })
262
- .catch((error) =>
263
- console.warn("[clipper] section build skipped:", error),
264
- );
265
- };
266
-
267
- const rebuildAllEdges = () => {
268
- for (const [id] of clipper.list) buildEdgesFor(id);
269
- };
270
-
271
- // One drag listener per plane; it updates whatever edges currently belong to
272
- // the plane (survives rebuilds since it reads edgesByPlane live).
273
- const wirePlane = (planeId: string) => {
274
- if (planesWired.has(planeId)) return;
275
- const plane = clipper.list.get(planeId);
276
- if (!plane) return;
277
- planesWired.add(planeId);
278
- // Suppress the dynamic-anchor pivot dot while the plane gizmo is being
279
- // dragged (it would otherwise pop up on the press). The dot renders off
280
- // world.onDynamicAnchorSet, so turning dynamicAnchor off for the drag stops
281
- // it cleanly with no change to viewports-manager.
282
- plane.onDraggingStarted.add(() => {
283
- draggingPlanes.add(planeId); // suppress the per-frame live re-section (blink)
284
- const world = getWorld() as unknown as { dynamicAnchor?: boolean } | undefined;
285
- if (world) world.dynamicAnchor = false;
286
- // Re-prune the TransformControls guide lines (the ±1e6 AXIS/X/Y/Z helper
287
- // lines that read as thin "infinity" lines): three creates/shows them
288
- // lazily, so the one-shot prune at registerGizmo time can run before they
289
- // exist. By drag time they're present — prune + hide again (idempotent).
290
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
291
- const helper = (plane as any).controls?.getHelper?.();
292
- pruneInfiniteGuides(helper);
293
- hideGizmoHelpers(helper);
294
- tintGizmo(planeId); // keep the handle purple through the drag highlight
295
- // Hide this plane's section fill + edges while dragging — they'd be stale
296
- // mid-move anyway, and hiding dodges the per-frame re-section cost. Re-shown
297
- // + recomputed on release below.
298
- const edges = edgesByPlane.get(planeId);
299
- if (edges) edges.visible = false;
300
- });
301
- plane.onDraggingEnded.add(() => {
302
- draggingPlanes.delete(planeId); // re-enable rebuilds; do the one rebuild below
303
- const world = getWorld() as unknown as { dynamicAnchor?: boolean } | undefined;
304
- if (world) world.dynamicAnchor = true;
305
- const edges = edgesByPlane.get(planeId);
306
- if (edges) {
307
- // Rebuild the section at the NEW plane position FIRST; only reveal it once
308
- // the rebuild resolves. Showing before the update lets the stale geometry
309
- // (still at the pre-drag position) render for a frame → a flash of the old
310
- // section snapping to the new one. update() is async (awaits getSection),
311
- // so flip visible + kick a render in its completion, never before it.
312
- void Promise.resolve(edges.update())
313
- .then(() => {
314
- edges.visible = true;
315
- getWorld()?.renderer?.update();
316
- })
317
- .catch((error) =>
318
- console.warn("[clipper] section update skipped:", error),
319
- );
320
- }
321
- });
322
- };
323
-
324
- // Classify loaded models by IFC category, refresh the category list + styles,
325
- // and (re)build all section edges. Resilient: models can be mid-load or
326
- // disposed when fragments.list fires (the project has multiple frags), so the
327
- // worker may not yet/no longer have a model id — byCategory then throws
328
- // "Model not found". We debounce (coalesce the multi-frag load), wrap in
329
- // try/catch, and retry once the list settles, so a transient miss never
330
- // surfaces as an uncaught runtime error.
331
- let classifyTimer: ReturnType<typeof setTimeout> | undefined;
332
- const scheduleClassify = () => {
333
- if (classifyTimer) clearTimeout(classifyTimer);
334
- classifyTimer = setTimeout(() => void classify(), 250);
335
- };
336
- const classify = async () => {
337
- if (fragments.list.size === 0) {
338
- categories = [];
339
- rebuildAllEdges();
340
- onStyleChanged.trigger();
341
- return;
342
- }
343
- try {
344
- await classifier.byCategory();
345
- } catch (error) {
346
- // A model wasn't queryable yet (mid-load) — retry after it settles.
347
- console.warn("[clipper] classification deferred:", error);
348
- scheduleClassify();
349
- return;
350
- }
351
- const groups = classifier.list.get(CLASSIFICATION);
352
- categories = groups ? [...groups.keys()].sort() : [];
353
- rebuildAllEdges();
354
- onStyleChanged.trigger();
355
- };
356
-
357
- fragments.list.onItemSet.add(scheduleClassify);
358
- fragments.list.onItemDeleted.add(scheduleClassify);
359
- if (fragments.list.size > 0) scheduleClassify();
360
-
361
- // The postproduction overlay material set, or undefined on a non-postproduction
362
- // renderer / before it's initialised (the basePass getter throws if early).
363
- const isolatedMaterials = (): THREE.Material[] | undefined => {
364
- const renderer = getWorld()?.renderer as
365
- | { postproduction?: { basePass?: { isolatedMaterials?: THREE.Material[] } } }
366
- | undefined;
367
- try {
368
- return renderer?.postproduction?.basePass?.isolatedMaterials;
369
- } catch {
370
- return undefined;
371
- }
372
- };
373
-
374
- // The clipper's gizmo (TransformControls helper) + section-plane mesh are basic
375
- // single-output materials. classify() hides them by OBJECT visibility, but
376
- // TransformControls re-asserts handle visibility in updateMatrixWorld, so they
377
- // leak into the deferred MRT capture and spam "missing fragment shader
378
- // outputs". Register their materials into the overlay set so the deferred path
379
- // hides them at MATERIAL level during capture and redraws them on top; mark
380
- // preserveBlending so the overlay redraw keeps the gizmo's normal look.
381
- // three.js TransformControls draws infinite axis/delta GUIDE lines (named
382
- // X/Y/Z/AXIS/DELTA, extending ±1e6) to show the active drag axis. In the
383
- // deferred overlay those render as black streaks running to the horizon. Remove
384
- // them — detected by a huge geometry extent so we drop only the guides, never
385
- // the small draggable handles.
386
- const pruneInfiniteGuides = (root: THREE.Object3D | undefined) => {
387
- if (!root) return;
388
- const doomed: THREE.Object3D[] = [];
389
- root.traverse((o) => {
390
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
391
- const anyO = o as any;
392
- if (
393
- !(anyO.isLine || anyO.isLineSegments || anyO.isLine2 || anyO.isLineSegments2)
394
- ) {
395
- return;
396
- }
397
- const geo = (o as THREE.Line).geometry as THREE.BufferGeometry | undefined;
398
- if (!geo) return;
399
- geo.computeBoundingBox?.();
400
- const bb = geo.boundingBox;
401
- if (bb && bb.min.distanceTo(bb.max) > 1e4) doomed.push(o);
402
- });
403
- for (const o of doomed) o.removeFromParent();
404
- };
405
-
406
- // The clip plane's TransformControls draws helper clutter that has nothing to do
407
- // with the draggable handle: an opaque WHITE bounds cylinder (renders as a solid
408
- // white volume) plus translucent plane/scale/rotate guide quads, pickers and
409
- // helper octahedrons. With postproduction ON the deferred capture hides them, but
410
- // the forward path (the cheap resize path turns postpro off) draws them — that's
411
- // the "white volume" + "guide quads on resize". Hide every NON-handle helper at
412
- // MATERIAL level and flag it `keepHidden` so the PostproductionRenderer.enabled
413
- // setter no longer force-reveals it. We keep only the actual translate arrows:
414
- // in three's TransformControls the arrow/shaft materials are fully opaque
415
- // (opacity 1), while every guide/picker/helper material is translucent
416
- // (opacity < 1) — a stable signal set once at material creation. The opaque
417
- // white bounds cylinder is the lone opaque exception, caught explicitly.
418
- // material.visible is never touched by TransformControls (it only re-asserts
419
- // object.visible per frame), so this sticks; raycast picking ignores it so
420
- // dragging still works.
421
- const hideGizmoHelpers = (root: THREE.Object3D | undefined) => {
422
- if (!root) return;
423
- root.traverse((o) => {
424
- const mesh = o as THREE.Mesh;
425
- if (!mesh.isMesh) return;
426
- const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
427
- for (const m of list) {
428
- if (!m) continue;
429
- const basic = m as THREE.MeshBasicMaterial;
430
- const translucent = basic.transparent && (basic.opacity ?? 1) < 1;
431
- const opaqueWhite =
432
- !basic.transparent &&
433
- (basic.opacity ?? 1) >= 1 &&
434
- basic.color?.getHex?.() === 0xffffff &&
435
- mesh.geometry?.type === "CylinderGeometry";
436
- if (translucent || opaqueWhite) {
437
- m.visible = false;
438
- m.userData.keepHidden = true;
439
- }
440
- }
441
- });
442
- };
443
-
444
- const registerGizmo = (planeId: string) => {
445
- const isolated = isolatedMaterials();
446
- if (!isolated) return;
447
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
448
- const plane = clipper.list.get(planeId) as any;
449
- if (!plane) return;
450
- // Strip the infinite axis/delta guide lines first so we neither register nor
451
- // draw them, and hide the opaque white bounds cylinder (the white volume).
452
- pruneInfiniteGuides(plane.controls?.getHelper?.());
453
- hideGizmoHelpers(plane.controls?.getHelper?.());
454
- const mats: THREE.Material[] = [];
455
- const collect = (root: THREE.Object3D | undefined) =>
456
- root?.traverse((o) => {
457
- const m = (o as THREE.Mesh).material;
458
- if (!m) return;
459
- for (const mat of Array.isArray(m) ? m : [m]) if (mat) mats.push(mat);
460
- });
461
- collect(plane.helper);
462
- collect(plane.controls?.getHelper?.());
463
- for (const m of mats) {
464
- m.userData.preserveBlending = true;
465
- if (!isolated.includes(m)) isolated.push(m);
466
- }
467
- gizmoMatsByPlane.set(planeId, mats);
468
-
469
- // three creates/shows the ±1e6 AXIS/X/Y/Z guide lines lazily, so they may not
470
- // exist yet at this synchronous pass. Re-prune next frame to catch them even
471
- // before the first drag (they can render visible pre-interaction).
472
- requestAnimationFrame(() => {
473
- const helper = plane.controls?.getHelper?.();
474
- pruneInfiniteGuides(helper);
475
- hideGizmoHelpers(helper);
476
- getWorld()?.renderer?.update();
477
- });
478
- };
479
-
480
- const unregisterGizmo = (planeId: string) => {
481
- const mats = gizmoMatsByPlane.get(planeId);
482
- if (!mats) return;
483
- const isolated = isolatedMaterials();
484
- if (isolated) {
485
- for (const m of mats) {
486
- const i = isolated.indexOf(m);
487
- if (i >= 0) isolated.splice(i, 1);
488
- }
489
- }
490
- gizmoMatsByPlane.delete(planeId);
491
- };
492
-
493
- // ── Gizmo POLISH: recolor the draggable handle to the app purple ──────────
494
- // The clip-plane handle (three TransformControls arrows) ships in stock axis
495
- // colours, which read as "raw". The gizmo is overlay-composited (ungraded), so
496
- // a literal #6528d7 renders as the true app purple. The only catch: three
497
- // re-applies a cached original colour (`material._color`) every
498
- // updateMatrixWorld before layering the hover/active highlight — so we recolour
499
- // BOTH the live colour AND that cache, or our purple snaps back on hover/drag.
500
- // Targets only the opaque, visible arrow handles (the translucent guides + the
501
- // white bounds cylinder are skipped, same signal hideGizmoHelpers uses).
502
- const HANDLE_PURPLE = 0x6528d7;
503
- const tintGizmo = (planeId: string) => {
504
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
505
- const plane = clipper.list.get(planeId) as any;
506
- const helper = plane?.controls?.getHelper?.();
507
- if (!helper) return;
508
- // SimplePlane sets showX/showY false → only the Z (normal) translate gizmo
509
- // shows. three's TC translate gizmo puts an arrowhead at BOTH +z and −z (plus
510
- // a thin line); the −z one is the "bodyless" arrow pointing away from the
511
- // plane. Drop it. (object.visible would be re-asserted each frame, so
512
- // removeFromParent like the guide-line prune.) The drag hit-area is the
513
- // separate centered _arrowBoundBox picker, so removing the visible back
514
- // arrowhead doesn't affect dragging.
515
- const doomed: THREE.Object3D[] = [];
516
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
517
- helper.traverse((o: any) => {
518
- if (!o.isMesh) return;
519
- const list = Array.isArray(o.material) ? o.material : [o.material];
520
- const opaqueVisible = list.some(
521
- (m: any) => m && m.visible !== false && (m.opacity ?? 1) >= 1,
522
- );
523
- // Back arrowhead: three's setupGizmo BAKES the layout offset into the
524
- // GEOMETRY and resets mesh.position to (0,0,0) — so detect by the geometry's
525
- // bbox center, not mesh.position. The translate cones are CylinderGeometry;
526
- // the one baked at z≈-0.5 is the bodyless back arrow (the +0.5 front arrow
527
- // and the z≈0 line/shaft stay; invisible pickers excluded by opaqueVisible).
528
- const geo = o.geometry as THREE.BufferGeometry | undefined;
529
- if (geo && !geo.boundingBox) geo.computeBoundingBox?.();
530
- const bb = geo?.boundingBox;
531
- const centerZ = bb ? (bb.min.z + bb.max.z) / 2 : 0;
532
- if (opaqueVisible && geo?.type === "CylinderGeometry" && centerZ < -0.2) {
533
- doomed.push(o);
534
- return;
535
- }
536
- for (const m of list) {
537
- if (!m?.color?.setHex) continue;
538
- const opacity = m.opacity ?? 1;
539
- // three's TC arrow materials are transparent:true WITH opacity 1, so we
540
- // must NOT require !transparent (that skipped the handles entirely — why
541
- // the recolor looked like nothing changed). Recolor every VISIBLE handle
542
- // (opacity >= 1), matching hideGizmoHelpers' kept set; skip only the faded
543
- // guides/pickers (opacity < 1) and the opaque white bounds cylinder.
544
- const translucent = m.transparent && opacity < 1;
545
- const whiteCylinder =
546
- opacity >= 1 &&
547
- m.color.getHex?.() === 0xffffff &&
548
- o.geometry?.type === "CylinderGeometry";
549
- if (translucent || whiteCylinder) continue;
550
- m.color.setHex(HANDLE_PURPLE);
551
- if (m._color?.setHex) m._color.setHex(HANDLE_PURPLE); // patch TC's cache
552
- }
553
- });
554
- for (const o of doomed) o.removeFromParent();
555
- };
556
-
557
- // Plane lifecycle → edges + panel refresh.
558
- clipper.list.onItemSet.add(({ key }) => {
559
- wirePlane(key);
560
- registerGizmo(key);
561
- tintGizmo(key);
562
- // three creates/colours the handle materials lazily, so re-tint next frame to
563
- // catch anything not present in this synchronous pass.
564
- requestAnimationFrame(() => {
565
- tintGizmo(key);
566
- getWorld()?.renderer?.update();
567
- });
568
- // While the plane is being dragged, skip the live re-section (blink): the
569
- // section stays hidden until release, where onDraggingEnded rebuilds it once.
570
- if (!draggingPlanes.has(key)) buildEdgesFor(key);
571
- onChanged.trigger();
572
- });
573
- clipper.list.onItemDeleted.add((key) => {
574
- if (styler.list.has(key)) styler.list.delete(key);
575
- edgesByPlane.delete(key);
576
- planesWired.delete(key);
577
- unregisterGizmo(key);
578
- onChanged.trigger();
579
- });
580
-
581
- // ── In-place section style mutation ──────────────────────────────
582
- // Mutate the LIVE drawn materials rather than rebuilding the ClipEdges: a
583
- // rebuild tears down + recreates the overlay meshes, which vanish until a
584
- // camera move re-composites. Local-clipping mode CLONES the style materials per
585
- // ClipEdges, so we must touch the actual rendered clones (inside each
586
- // ClipEdges.three), not the style template — and also the template so newly
587
- // built sections (and W2's plan views) match. Colour/width are pure uniforms
588
- // (no recompile); only a transparent-flag flip needs needsUpdate.
589
- // rAF-coalesced render kick: dragging a colour/opacity/width control fires many
590
- // input events per frame, each of which would otherwise force a full deferred
591
- // re-composite. Collapse them to at most one recomposite per frame.
592
- let _renderScheduled = false;
593
- const requestRender = () => {
594
- if (_renderScheduled) return;
595
- _renderScheduled = true;
596
- requestAnimationFrame(() => {
597
- _renderScheduled = false;
598
- getWorld()?.renderer?.update();
599
- });
600
- };
601
-
602
- const templateMaterials = () =>
603
- styler.styles.get(STYLE_NAME) as
604
- | { linesMaterial?: THREE.Material; fillsMaterial?: THREE.Material }
605
- | undefined;
606
-
607
- const eachSectionMaterial = (
608
- which: "line" | "fill",
609
- fn: (m: THREE.Material) => void,
610
- ) => {
611
- // Detect line vs fill by OBJECT type (covers both the fat LineSegments2 and a
612
- // plain THREE.LineSegments) rather than a material flag, so it's robust to
613
- // whichever line material the style uses.
614
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
615
- const isLineObj = (o: any) =>
616
- !!(o.isLine || o.isLineSegments || o.isLine2 || o.isLineSegments2);
617
- const tpl = templateMaterials();
618
- const tplMat = which === "line" ? tpl?.linesMaterial : tpl?.fillsMaterial;
619
- if (tplMat) fn(tplMat);
620
- for (const [, edges] of edgesByPlane) {
621
- edges.three.traverse((o) => {
622
- const m = (o as THREE.Mesh).material as THREE.Material | undefined;
623
- if (!m) return;
624
- if ((which === "line") === isLineObj(o)) fn(m);
625
- });
626
- }
627
- };
628
-
629
- return {
630
- clipper,
631
- onChanged,
632
- onStyleChanged,
633
-
634
- setPlacing(on) {
635
- placing = on;
636
- const c = ensureCanvas();
637
- if (c) c.style.cursor = on ? "crosshair" : "";
638
- // Route through the central manager: entering placing makes this the sole
639
- // active tool (suppressing hover/select + exiting any other tool); exiting
640
- // restores them.
641
- if (on) manager.setActive(managed);
642
- else manager.clearActive(managed);
643
- },
644
- isPlacing: () => placing,
645
- setEnabled(on) {
646
- clipper.enabled = on;
647
- for (const [, plane] of clipper.list) plane.enabled = on;
648
- // Explicitly resync every section to its plane's enabled state (don't rely
649
- // on SimplePlane.notifyManager → onItemSet, which the DataMap can dedup).
650
- rebuildAllEdges();
651
- onChanged.trigger();
652
- },
653
- isEnabled: () => clipper.enabled,
654
- setPlaneEnabled(id, on) {
655
- const plane = clipper.list.get(id);
656
- if (!plane) return;
657
- plane.enabled = on;
658
- // buildEdgesFor hides the section when disabled, rebuilds + shows it when
659
- // enabled — called directly so it never depends on onItemSet firing.
660
- buildEdgesFor(id);
661
- onChanged.trigger();
662
- },
663
- deletePlane(id) {
664
- const world = getWorld();
665
- if (world) void clipper.delete(world, id);
666
- },
667
- clearAll() {
668
- clipper.deleteAll();
669
- onChanged.trigger();
670
- },
671
- instances() {
672
- const rows: InstanceRow[] = [];
673
- let i = 0;
674
- for (const [id, plane] of clipper.list) {
675
- const idx = ++i;
676
- const p = plane as unknown as { visible: boolean; enabled: boolean };
677
- rows.push({
678
- id,
679
- kind: "clip",
680
- type: "Clip plane",
681
- label: `Clip plane ${idx}`,
682
- visible: p.visible,
683
- enabled: p.enabled,
684
- setVisible: (on) => {
685
- // Hide the gizmo + plane mesh AND the section fill/edges together.
686
- p.visible = on;
687
- const edges = edgesByPlane.get(id);
688
- if (edges) edges.visible = on;
689
- requestRender();
690
- onChanged.trigger();
691
- },
692
- setEnabled: (on) => {
693
- plane.enabled = on;
694
- buildEdgesFor(id); // hides section when off, rebuilds + shows when on
695
- onChanged.trigger();
696
- },
697
- remove: () => {
698
- const world = getWorld();
699
- if (world) void clipper.delete(world, id);
700
- },
701
- });
702
- }
703
- return rows;
704
- },
705
-
706
- getSectionStyle: () => ({ ...sectionStyle }),
707
- setSectionFill(hex) {
708
- sectionStyle.fill = hex;
709
- // MeshBasicMaterial.color is a Color — mutate in place (uniform, no recompile).
710
- eachSectionMaterial("fill", (m) =>
711
- (m as THREE.MeshBasicMaterial).color.set(hex),
712
- );
713
- requestRender();
714
- onStyleChanged.trigger();
715
- },
716
- setSectionLine(hex) {
717
- sectionStyle.line = hex;
718
- // LineMaterial.color assigns straight to the diffuse uniform — give it a
719
- // THREE.Color (a string uploads as black); uniform-only, no recompile.
720
- eachSectionMaterial("line", (m) => {
721
- (m as unknown as { color: THREE.Color }).color = new THREE.Color(hex);
722
- });
723
- requestRender();
724
- onStyleChanged.trigger();
725
- },
726
- setSectionOpacity(value) {
727
- sectionStyle.opacity = value;
728
- // Only the opacity uniform — keep `transparent` TRUE (set in the lib so the
729
- // section sorts in front of the translucent hover). Opacity still applies:
730
- // the overlay pass writes (colour, alpha) and straight-alpha composites it,
731
- // so the fill stays translucent without toggling the transparent flag (which
732
- // would drop the section back into the opaque group, behind the hover).
733
- eachSectionMaterial("fill", (m) => {
734
- m.opacity = value;
735
- });
736
- requestRender();
737
- onStyleChanged.trigger();
738
- },
739
- setSectionWidth(value) {
740
- sectionStyle.width = value;
741
- // LineMaterial.linewidth is a uniform — no recompile.
742
- eachSectionMaterial("line", (m) => {
743
- (m as unknown as { linewidth: number }).linewidth = value;
744
- });
745
- requestRender();
746
- onStyleChanged.trigger();
747
- },
748
- setStylingVisible(on) {
749
- styler.visible = on;
750
- onStyleChanged.trigger();
751
- },
752
- isStylingVisible: () => styler.visible,
753
- };
754
- };
755
-
756
- // Real OBC.Component wrapper so the ClipperTool wrapper is a per-`components`
757
- // singleton, resolved by UUID via `components.get(...)`. Because `components` is
758
- // a single shared instance across bundle boundaries, the toolbar built-in and
759
- // the app's side panels (clipper-panel, objects) all get the SAME ClipperTool.
760
- export class ClipperToolComponent extends OBC.Component {
761
- static readonly uuid = "9b2c7e10-3a4f-4c21-8b6d-1f0a2c3d4e51" as const;
762
- enabled = true;
763
- readonly tool: ClipperTool;
764
- constructor(components: OBC.Components) {
765
- super(components);
766
- components.add(ClipperToolComponent.uuid, this);
767
- this.tool = clipperToolImpl(components);
768
- }
769
- }
770
-
771
- /** Per-Components singleton accessor for the clipping tool. */
772
- export const clipperTool = (components: OBC.Components): ClipperTool =>
773
- components.get(ClipperToolComponent).tool;