@ifc-lite/viewer 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.turbo/turbo-build.log +59 -43
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +496 -0
  4. package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  7. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  8. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  9. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  10. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  11. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  12. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  13. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  14. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  15. package/dist/assets/index-CSWgTe1s.css +1 -0
  16. package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
  17. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  18. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  19. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
  20. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  21. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
  22. package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
  23. package/dist/assets/three-CDRZThFA.js +4057 -0
  24. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
  25. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  26. package/dist/index.html +10 -9
  27. package/dist/samples/building-architecture.ifc +453 -0
  28. package/dist/samples/hello-wall.ifc +1054 -0
  29. package/dist/samples/infra-bridge.ifc +962 -0
  30. package/index.html +1 -1
  31. package/package.json +15 -10
  32. package/public/samples/building-architecture.ifc +453 -0
  33. package/public/samples/hello-wall.ifc +1054 -0
  34. package/public/samples/infra-bridge.ifc +962 -0
  35. package/src/App.tsx +37 -3
  36. package/src/components/mcp/HeroScene.tsx +876 -0
  37. package/src/components/mcp/McpLanding.tsx +1318 -0
  38. package/src/components/mcp/McpPlayground.tsx +524 -0
  39. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  40. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  41. package/src/components/mcp/README.md +171 -0
  42. package/src/components/mcp/data.ts +659 -0
  43. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  44. package/src/components/mcp/playground-files.ts +107 -0
  45. package/src/components/mcp/playground-uploads.ts +122 -0
  46. package/src/components/mcp/types.ts +65 -0
  47. package/src/components/mcp/use-mcp-page.ts +109 -0
  48. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  49. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  50. package/src/components/viewer/DeviationPanel.tsx +172 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  52. package/src/components/viewer/HoverTooltip.tsx +5 -0
  53. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  54. package/src/components/viewer/IDSPanel.tsx +80 -26
  55. package/src/components/viewer/MainToolbar.tsx +79 -7
  56. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  57. package/src/components/viewer/MobileToolbar.tsx +326 -0
  58. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  59. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  60. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  61. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  62. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  63. package/src/components/viewer/StatusBar.tsx +14 -0
  64. package/src/components/viewer/ViewerLayout.tsx +288 -95
  65. package/src/components/viewer/Viewport.tsx +86 -18
  66. package/src/components/viewer/ViewportContainer.tsx +60 -15
  67. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  68. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  69. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  70. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  71. package/src/components/viewer/selectionHandlers.ts +41 -0
  72. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  73. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  74. package/src/components/viewer/useAnimationLoop.ts +22 -0
  75. package/src/components/viewer/useMouseControls.ts +296 -3
  76. package/src/components/viewer/usePointCloudSync.ts +8 -1
  77. package/src/components/viewer/useRenderUpdates.ts +21 -1
  78. package/src/components/viewer/useTouchControls.ts +100 -41
  79. package/src/generated/mcp-catalog.json +82 -0
  80. package/src/hooks/federationLoadGate.test.ts +90 -0
  81. package/src/hooks/federationLoadGate.ts +127 -0
  82. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  83. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  84. package/src/hooks/useDrawingGeneration.ts +81 -8
  85. package/src/hooks/useIDS.ts +90 -10
  86. package/src/hooks/useIfcFederation.ts +94 -16
  87. package/src/hooks/useIfcLoader.ts +289 -64
  88. package/src/hooks/useViewerSelectors.ts +10 -0
  89. package/src/lib/geo/cesium-bridge.ts +84 -67
  90. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  91. package/src/lib/geo/clamp-anchor.ts +57 -0
  92. package/src/lib/geo/effective-georef.test.ts +79 -1
  93. package/src/lib/geo/effective-georef.ts +83 -0
  94. package/src/lib/geo/reproject.ts +26 -13
  95. package/src/lib/geo/terrain-elevation.ts +166 -0
  96. package/src/lib/lens/adapter.ts +1 -1
  97. package/src/lib/llm/context-builder.ts +1 -1
  98. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  99. package/src/lib/perf/memoryAccounting.ts +235 -0
  100. package/src/sdk/adapters/mutation-view.ts +1 -1
  101. package/src/store/constants.ts +39 -2
  102. package/src/store/index.ts +6 -1
  103. package/src/store/slices/cesiumSlice.ts +1 -1
  104. package/src/store/slices/idsSlice.ts +24 -0
  105. package/src/store/slices/loadingSlice.ts +12 -0
  106. package/src/store/slices/pointCloudSlice.ts +72 -1
  107. package/src/store/slices/sectionSlice.test.ts +590 -1
  108. package/src/store/slices/sectionSlice.ts +344 -17
  109. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  110. package/src/store/slices/uiSlice.ts +60 -2
  111. package/src/store/types.ts +42 -0
  112. package/src/store.ts +13 -0
  113. package/src/utils/acquireFileBuffer.test.ts +231 -0
  114. package/src/utils/acquireFileBuffer.ts +128 -0
  115. package/src/utils/ifcConfig.ts +24 -0
  116. package/src/utils/nativeSpatialDataStore.ts +20 -2
  117. package/src/utils/spatialHierarchy.test.ts +116 -0
  118. package/src/utils/spatialHierarchy.ts +23 -0
  119. package/tailwind.config.js +5 -0
  120. package/tsconfig.json +1 -0
  121. package/vite.config.ts +12 -0
  122. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  123. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  124. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  125. package/dist/assets/exporters-BraHBeoi.js +0 -81583
  126. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  127. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  128. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  129. package/dist/assets/index-0XpVr_S5.css +0 -1
@@ -3,10 +3,26 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  /**
6
- * Section plane visual indicator/gizmo
6
+ * Section plane visual indicator/gizmo.
7
+ *
8
+ * In addition to the cardinal-axis corner badge (existing), this also
9
+ * renders the 3D drag gizmo for face-picked custom planes (issue #243):
10
+ * a violet dot at the live plane anchor (`pickedAt` projected onto the
11
+ * current plane via `customPlaneCenter`) plus an arrow along the picked
12
+ * normal that the user can click + drag to slide the cut plane
13
+ * perpendicular to its surface. Anchoring to the projected center —
14
+ * instead of `pickedAt` directly — keeps the gizmo glued to the plane
15
+ * as `distance` changes; using `pickedAt` directly would freeze the
16
+ * gizmo at the original face-pick location while the geometry clip
17
+ * slides to the new distance. The drag math projects the cursor delta
18
+ * onto the screen-projected normal and converts pixels-per-meter via
19
+ * the camera's point-projection of `center + normal * 1m`.
7
20
  */
8
21
 
22
+ import { useEffect, useRef, useState, useCallback } from 'react';
9
23
  import { AXIS_INFO } from './sectionConstants';
24
+ import { useViewerStore, customPlaneCenter } from '@/store';
25
+ import { getGlobalRenderer } from '@/hooks/useBCF';
10
26
 
11
27
  interface SectionPlaneVisualizationProps {
12
28
  axis: 'down' | 'front' | 'side';
@@ -22,7 +38,21 @@ export function SectionPlaneVisualization({ axis, enabled }: SectionPlaneVisuali
22
38
  side: '#FF9800', // Orange for side cuts
23
39
  };
24
40
 
25
- const color = axisColors[axis];
41
+ // Custom plane (face-pick) — paints violet to match the renderer's
42
+ // gizmo quad so the user reads "this is a non-cardinal cut".
43
+ const CUSTOM_COLOR = '#9C6BDE';
44
+ const customPlane = useViewerStore((s) => s.sectionPlane.custom);
45
+ const setSectionCustomDistance = useViewerStore((s) => s.setSectionCustomDistance);
46
+ const setPreviewStride = useViewerStore((s) => s.setPointCloudPreviewStride);
47
+ const pointCloudAssetCount = useViewerStore((s) => s.pointCloudAssetCount);
48
+ // Live face-pick hover preview (issue #243 follow-up). Only set
49
+ // while pick mode is armed AND the cursor has dwelled ~200ms over a
50
+ // surface. Drives the violet quad + arrow that telegraph "this is
51
+ // where I'll cut if you click here" before the user commits.
52
+ const sectionPickPreview = useViewerStore((s) => s.sectionPickPreview);
53
+ const isCustom = customPlane !== undefined;
54
+
55
+ const color = isCustom ? CUSTOM_COLOR : axisColors[axis];
26
56
 
27
57
  return (
28
58
  <svg
@@ -56,7 +86,7 @@ export function SectionPlaneVisualization({ axis, enabled }: SectionPlaneVisuali
56
86
  fontSize="11"
57
87
  fontWeight="bold"
58
88
  >
59
- {AXIS_INFO[axis].label.toUpperCase()}
89
+ {isCustom ? 'CUS' : AXIS_INFO[axis].label.toUpperCase()}
60
90
  </text>
61
91
  {/* Active indicator */}
62
92
  {enabled && (
@@ -73,6 +103,357 @@ export function SectionPlaneVisualization({ axis, enabled }: SectionPlaneVisuali
73
103
  </text>
74
104
  )}
75
105
  </g>
106
+
107
+ {enabled && customPlane && (
108
+ <CustomPlaneDragGizmo
109
+ color={CUSTOM_COLOR}
110
+ customPlane={customPlane}
111
+ setDistance={setSectionCustomDistance}
112
+ onDragStart={() => { if (pointCloudAssetCount > 0) setPreviewStride(4); }}
113
+ onDragEnd={() => setPreviewStride(1)}
114
+ />
115
+ )}
116
+
117
+ {/* Face-pick hover preview — purely visual, click-through. */}
118
+ {sectionPickPreview && (
119
+ <SectionPickPreviewOverlay
120
+ color={CUSTOM_COLOR}
121
+ preview={sectionPickPreview}
122
+ />
123
+ )}
76
124
  </svg>
77
125
  );
78
126
  }
127
+
128
+ /**
129
+ * Translucent violet quad + tiny normal arrow painted on the surface
130
+ * the user is hovering while section pick mode is armed (issue #243
131
+ * follow-up). Purely a hint — does not commit a section plane;
132
+ * `selectionHandlers.ts` does that on click.
133
+ *
134
+ * Rendered as an SVG overlay to match `CustomPlaneDragGizmo` (no new
135
+ * GPU pipeline, follows the camera "for free" via per-frame
136
+ * projection). The quad's footprint follows `tangent`/`bitangent` of
137
+ * the hit normal so it looks like a flat square laid on the surface
138
+ * regardless of camera angle, and its on-screen radius is clamped to
139
+ * `[24px, 80px]` so it stays readable from any zoom.
140
+ *
141
+ * Pointer-events are forced off so the overlay never intercepts the
142
+ * click that would commit the actual cut — the SVG container above
143
+ * already disables them, but child <g> elements with `pointerEvents:
144
+ * 'auto'` (e.g. the drag gizmo's circle) co-exist in the same tree.
145
+ */
146
+ function SectionPickPreviewOverlay(props: {
147
+ color: string;
148
+ preview: NonNullable<ReturnType<typeof useViewerStore.getState>['sectionPickPreview']>;
149
+ }) {
150
+ const { color, preview } = props;
151
+ // Project the four quad corners + the arrow tip every animation
152
+ // frame so the overlay tracks camera orbit/pan without any extra
153
+ // store subscription. Cheap (5 mat-mul per frame).
154
+ const [proj, setProj] = useState<{
155
+ quad: Array<{ x: number; y: number }>;
156
+ foot: { x: number; y: number };
157
+ tip: { x: number; y: number };
158
+ } | null>(null);
159
+
160
+ useEffect(() => {
161
+ let raf = 0;
162
+ const project = () => {
163
+ const renderer = getGlobalRenderer();
164
+ const camera = renderer?.getCamera();
165
+ const canvas = renderer?.getCanvas();
166
+ if (camera && canvas) {
167
+ const w = canvas.clientWidth, h = canvas.clientHeight;
168
+ const [px, py, pz] = preview.point;
169
+ const [nx, ny, nz] = preview.normal;
170
+
171
+ // Build an orthonormal in-plane basis from the normal. This
172
+ // duplicates `planeBasis()` from the renderer package — done
173
+ // inline to keep the overlay self-contained and avoid pulling
174
+ // a renderer dep into the React layer just for two cross
175
+ // products. The choice of seed (Z vs X) avoids a degenerate
176
+ // cross when the normal is near ±Y.
177
+ const seedX = Math.abs(ny) > 0.9 ? 1 : 0;
178
+ const seedY = Math.abs(ny) > 0.9 ? 0 : 0;
179
+ const seedZ = Math.abs(ny) > 0.9 ? 0 : 1;
180
+ // tangent = normalize(cross(normal, seed))
181
+ let tx = ny * seedZ - nz * seedY;
182
+ let ty = nz * seedX - nx * seedZ;
183
+ let tz = nx * seedY - ny * seedX;
184
+ const tLen = Math.hypot(tx, ty, tz) || 1;
185
+ tx /= tLen; ty /= tLen; tz /= tLen;
186
+ // bitangent = cross(normal, tangent)
187
+ const bx = ny * tz - nz * ty;
188
+ const by = nz * tx - nx * tz;
189
+ const bz = nx * ty - ny * tx;
190
+
191
+ // Quad half-extent: 0.5m world to start; we'll clamp the
192
+ // visible size in screen pixels below by interpolating along
193
+ // the projected diagonal if the apparent size lands outside
194
+ // [24, 80]px.
195
+ const halfWorld = 0.5;
196
+
197
+ const corner = (s: number, t: number) => {
198
+ const wx = px + tx * s + bx * t;
199
+ const wy = py + ty * s + by * t;
200
+ const wz = pz + tz * s + bz * t;
201
+ return camera.projectToScreen({ x: wx, y: wy, z: wz }, w, h);
202
+ };
203
+
204
+ const c0 = corner(-halfWorld, -halfWorld);
205
+ const c1 = corner( halfWorld, -halfWorld);
206
+ const c2 = corner( halfWorld, halfWorld);
207
+ const c3 = corner(-halfWorld, halfWorld);
208
+ const foot = camera.projectToScreen({ x: px, y: py, z: pz }, w, h);
209
+ // Arrow tip 0.4m along the normal — half a typical wall
210
+ // thickness, enough for the arrowhead to read at default
211
+ // zoom without dwarfing small objects.
212
+ const tip = camera.projectToScreen(
213
+ { x: px + nx * 0.4, y: py + ny * 0.4, z: pz + nz * 0.4 },
214
+ w, h,
215
+ );
216
+
217
+ if (c0 && c1 && c2 && c3 && foot && tip) {
218
+ // On-screen size clamp: rescale the four corners about the
219
+ // foot so the apparent diagonal falls in [24px, 80px]. This
220
+ // keeps the preview readable at extreme zooms (a 1m quad
221
+ // can otherwise shrink to 2px from far away or fill the
222
+ // canvas up close).
223
+ const dx = c2.x - c0.x;
224
+ const dy = c2.y - c0.y;
225
+ const diag = Math.hypot(dx, dy) || 1;
226
+ const minPx = 50; // ~50px diagonal — visible but not
227
+ // overpowering
228
+ const maxPx = 140;
229
+ const scale = diag < minPx ? minPx / diag
230
+ : diag > maxPx ? maxPx / diag
231
+ : 1;
232
+ const rescale = (c: { x: number; y: number }) => ({
233
+ x: foot.x + (c.x - foot.x) * scale,
234
+ y: foot.y + (c.y - foot.y) * scale,
235
+ });
236
+ setProj({
237
+ quad: [rescale(c0), rescale(c1), rescale(c2), rescale(c3)],
238
+ foot,
239
+ tip,
240
+ });
241
+ }
242
+ }
243
+ raf = requestAnimationFrame(project);
244
+ };
245
+ project();
246
+ return () => cancelAnimationFrame(raf);
247
+ }, [preview.point, preview.normal, preview.faceKey]);
248
+
249
+ if (!proj) return null;
250
+
251
+ const { quad, foot, tip } = proj;
252
+ // Arrow pixel length capped at 36px so it stays a small "telltale"
253
+ // rather than visually competing with the quad. Direction comes
254
+ // from the projected normal so it tracks camera orientation.
255
+ const adx = tip.x - foot.x, ady = tip.y - foot.y;
256
+ const aLen = Math.hypot(adx, ady) || 1;
257
+ const ARROW_PX = Math.min(36, aLen);
258
+ const tipX = foot.x + (adx / aLen) * ARROW_PX;
259
+ const tipY = foot.y + (ady / aLen) * ARROW_PX;
260
+
261
+ return (
262
+ <g style={{ pointerEvents: 'none' }} aria-hidden>
263
+ {/* Translucent violet quad — the "you'll cut here" hint. */}
264
+ <polygon
265
+ points={quad.map((p) => `${p.x},${p.y}`).join(' ')}
266
+ fill={color}
267
+ fillOpacity="0.28"
268
+ stroke={color}
269
+ strokeWidth="1.5"
270
+ strokeOpacity="0.7"
271
+ />
272
+ {/* Tiny normal arrow — shaft. */}
273
+ <line
274
+ x1={foot.x} y1={foot.y}
275
+ x2={tipX} y2={tipY}
276
+ stroke={color} strokeWidth="2" strokeLinecap="round"
277
+ opacity="0.9"
278
+ />
279
+ {/* Arrowhead — small triangle perpendicular to the shaft. */}
280
+ <polygon
281
+ points={(() => {
282
+ const ux = adx / aLen, uy = ady / aLen;
283
+ const nxp = -uy, nyp = ux;
284
+ const baseX = tipX - ux * 6;
285
+ const baseY = tipY - uy * 6;
286
+ const ax = baseX + nxp * 4, ay = baseY + nyp * 4;
287
+ const bx = baseX - nxp * 4, by = baseY - nyp * 4;
288
+ return `${tipX},${tipY} ${ax},${ay} ${bx},${by}`;
289
+ })()}
290
+ fill={color} opacity="0.95"
291
+ />
292
+ </g>
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Click+drag arrow that translates the custom section plane along its
298
+ * picked normal. Uses screen-space projection of `center` (= pickedAt
299
+ * projected onto the live plane) and `center + normal` to convert
300
+ * cursor pixels into world units — resolution-independent and works
301
+ * for any tilt.
302
+ *
303
+ * Re-projects the anchor every animation frame while dragging so the
304
+ * gizmo stays glued to the live plane even if the camera moves
305
+ * (orbit / pan are still allowed underneath this overlay because we
306
+ * only call `setPointerCapture` on the handle's <circle>).
307
+ */
308
+ function CustomPlaneDragGizmo(props: {
309
+ color: string;
310
+ customPlane: NonNullable<ReturnType<typeof useViewerStore.getState>['sectionPlane']['custom']>;
311
+ setDistance: (d: number) => void;
312
+ onDragStart: () => void;
313
+ onDragEnd: () => void;
314
+ }) {
315
+ const { color, customPlane, setDistance, onDragStart, onDragEnd } = props;
316
+ const [proj, setProj] = useState<{ p0: { x: number; y: number }; p1: { x: number; y: number } } | null>(null);
317
+ const dragStateRef = useRef<{
318
+ active: boolean;
319
+ startDistance: number;
320
+ startCursor: { x: number; y: number };
321
+ screenNormal: { x: number; y: number };
322
+ pixelsPerMeter: number;
323
+ } | null>(null);
324
+
325
+ // Project the gizmo's two anchor points (foot + tip-of-arrow) every
326
+ // animation frame so it follows the camera. Cheap: two
327
+ // matrix-multiplies per frame.
328
+ //
329
+ // The foot anchor is `pickedAt` projected onto the LIVE plane (not
330
+ // `pickedAt` itself). As the user drags the gizmo only `distance`
331
+ // changes; pickedAt sits off the moving plane, so anchoring the
332
+ // gizmo to it would leave the arrow stranded at the original pick
333
+ // location while the cut slides along the normal. Using the
334
+ // projected center keeps the gizmo glued to the actual cut plane.
335
+ useEffect(() => {
336
+ let raf = 0;
337
+ const project = () => {
338
+ const renderer = getGlobalRenderer();
339
+ const camera = renderer?.getCamera();
340
+ const canvas = renderer?.getCanvas();
341
+ if (camera && canvas) {
342
+ const center = customPlaneCenter(customPlane);
343
+ const tipWorld = {
344
+ x: center[0] + customPlane.normal[0],
345
+ y: center[1] + customPlane.normal[1],
346
+ z: center[2] + customPlane.normal[2],
347
+ };
348
+ const footWorld = {
349
+ x: center[0],
350
+ y: center[1],
351
+ z: center[2],
352
+ };
353
+ const w = canvas.clientWidth, h = canvas.clientHeight;
354
+ const p0 = camera.projectToScreen(footWorld, w, h);
355
+ const p1 = camera.projectToScreen(tipWorld, w, h);
356
+ if (p0 && p1) {
357
+ setProj({ p0, p1 });
358
+ }
359
+ }
360
+ raf = requestAnimationFrame(project);
361
+ };
362
+ project();
363
+ return () => cancelAnimationFrame(raf);
364
+ }, [customPlane.pickedAt, customPlane.normal, customPlane.distance]);
365
+
366
+ const handlePointerDown = useCallback((e: React.PointerEvent<SVGCircleElement>) => {
367
+ if (!proj) return;
368
+ e.stopPropagation();
369
+ e.preventDefault();
370
+ (e.target as Element).setPointerCapture(e.pointerId);
371
+ const dx = proj.p1.x - proj.p0.x;
372
+ const dy = proj.p1.y - proj.p0.y;
373
+ const ppm = Math.hypot(dx, dy);
374
+ if (ppm < 1e-3) return; // edge-on view — drag would be unstable
375
+ dragStateRef.current = {
376
+ active: true,
377
+ startDistance: customPlane.distance,
378
+ startCursor: { x: e.clientX, y: e.clientY },
379
+ screenNormal: { x: dx / ppm, y: dy / ppm },
380
+ pixelsPerMeter: ppm,
381
+ };
382
+ onDragStart();
383
+ }, [proj, customPlane.distance, onDragStart]);
384
+
385
+ const handlePointerMove = useCallback((e: React.PointerEvent<SVGCircleElement>) => {
386
+ const s = dragStateRef.current;
387
+ if (!s?.active) return;
388
+ e.stopPropagation();
389
+ const cdx = e.clientX - s.startCursor.x;
390
+ const cdy = e.clientY - s.startCursor.y;
391
+ // Project cursor delta onto the screen-projected normal, then
392
+ // convert pixels → meters via `pixelsPerMeter`.
393
+ const screenDelta = cdx * s.screenNormal.x + cdy * s.screenNormal.y;
394
+ const meters = screenDelta / s.pixelsPerMeter;
395
+ setDistance(s.startDistance + meters);
396
+ }, [setDistance]);
397
+
398
+ const handlePointerUp = useCallback((e: React.PointerEvent<SVGCircleElement>) => {
399
+ if (dragStateRef.current?.active) {
400
+ dragStateRef.current.active = false;
401
+ try {
402
+ (e.target as Element).releasePointerCapture(e.pointerId);
403
+ } catch (_err) {
404
+ /* cleanup — safe to ignore: pointer already released by browser */
405
+ }
406
+ onDragEnd();
407
+ }
408
+ }, [onDragEnd]);
409
+
410
+ if (!proj) return null;
411
+
412
+ // Arrow goes 60px past `p0` along the projected normal direction so
413
+ // it stays a consistent visual size regardless of camera distance —
414
+ // we'd otherwise get a tiny arrow when the camera is far away.
415
+ const dx = proj.p1.x - proj.p0.x;
416
+ const dy = proj.p1.y - proj.p0.y;
417
+ const len = Math.hypot(dx, dy) || 1;
418
+ const ARROW_PX = 60;
419
+ const tipX = proj.p0.x + (dx / len) * ARROW_PX;
420
+ const tipY = proj.p0.y + (dy / len) * ARROW_PX;
421
+
422
+ return (
423
+ <g style={{ pointerEvents: 'auto' }}>
424
+ <line
425
+ x1={proj.p0.x} y1={proj.p0.y}
426
+ x2={tipX} y2={tipY}
427
+ stroke={color} strokeWidth="3" strokeLinecap="round"
428
+ opacity="0.85"
429
+ />
430
+ {/* Tip arrowhead — small triangle perpendicular to the line. */}
431
+ <polygon
432
+ points={(() => {
433
+ const nx = -dy / len, ny = dx / len; // perpendicular to direction
434
+ const baseX = tipX - (dx / len) * 8;
435
+ const baseY = tipY - (dy / len) * 8;
436
+ const ax = baseX + nx * 5, ay = baseY + ny * 5;
437
+ const bx = baseX - nx * 5, by = baseY - ny * 5;
438
+ return `${tipX},${tipY} ${ax},${ay} ${bx},${by}`;
439
+ })()}
440
+ fill={color} opacity="0.9"
441
+ />
442
+ {/* Foot dot — the actual click+drag target. Larger hit area than
443
+ visual radius for finger-friendly UX. */}
444
+ <circle
445
+ cx={proj.p0.x} cy={proj.p0.y} r={10}
446
+ fill={color}
447
+ fillOpacity="0.85"
448
+ stroke="white" strokeWidth="2"
449
+ cursor="grab"
450
+ onPointerDown={handlePointerDown}
451
+ onPointerMove={handlePointerMove}
452
+ onPointerUp={handlePointerUp}
453
+ onPointerCancel={handlePointerUp}
454
+ >
455
+ <title>Drag to slide the cut along its normal</title>
456
+ </circle>
457
+ </g>
458
+ );
459
+ }
@@ -39,6 +39,12 @@ export interface UseAnimationLoopParams {
39
39
  visualEnhancementRef: MutableRefObject<VisualEnhancementOptions>;
40
40
  sectionPlaneRef: MutableRefObject<SectionPlane>;
41
41
  sectionRangeRef: MutableRefObject<{ min: number; max: number } | null>;
42
+ /**
43
+ * Mirror of the renderer's model bounds, written each frame after
44
+ * render. Read by the section face-pick handler so the cardinal-
45
+ * fallback `position` % can be computed against the live extents.
46
+ */
47
+ modelBoundsRef?: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } } | null>;
42
48
  selectedEntityIdsRef: MutableRefObject<Set<number> | undefined>;
43
49
  coordinateInfoRef: MutableRefObject<CoordinateInfo | undefined>;
44
50
  isInteractingRef: MutableRefObject<boolean>;
@@ -73,6 +79,7 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
73
79
  visualEnhancementRef,
74
80
  sectionPlaneRef,
75
81
  sectionRangeRef,
82
+ modelBoundsRef,
76
83
  selectedEntityIdsRef,
77
84
  coordinateInfoRef,
78
85
  isInteractingRef,
@@ -181,10 +188,25 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
181
188
  capStyle: sectionPlaneRef.current.capStyle,
182
189
  min: sectionRangeRef.current?.min,
183
190
  max: sectionRangeRef.current?.max,
191
+ // Custom (face-picked) plane override (issue #243). When set
192
+ // the renderer uses these verbatim and ignores axis/position/
193
+ // min/max for the clip math; cap polygons are still emitted
194
+ // through the same Section2DOverlayRenderer with a custom
195
+ // basis so the silhouette lands on the tilted plane.
196
+ normal: sectionPlaneRef.current.custom?.normal,
197
+ distance: sectionPlaneRef.current.custom?.distance,
184
198
  } : undefined,
185
199
  terrainClipY: terrainClipYRef.current ?? undefined,
186
200
  });
187
201
  lastRenderTime = currentTime;
202
+ // Snapshot the renderer's current model bounds so the section
203
+ // face-pick handler can compute a correct cardinal-fallback
204
+ // `position` percentage. Cheap (a few field reads) and avoids a
205
+ // race where the click handler reads stale bounds during the
206
+ // first few frames after a model loads.
207
+ if (modelBoundsRef) {
208
+ modelBoundsRef.current = renderer.getModelBounds() ?? modelBoundsRef.current;
209
+ }
188
210
  }
189
211
 
190
212
  // 4. Sync UI widgets