@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.
- package/.turbo/turbo-build.log +59 -43
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +496 -0
- package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +10 -9
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/index.html +1 -1
- package/package.json +15 -10
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +79 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +60 -15
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/generated/mcp-catalog.json +82 -0
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +12 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-BraHBeoi.js +0 -81583
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- 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
|
-
|
|
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
|