@ifc-lite/viewer 1.25.0 → 1.25.1

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 (36) hide show
  1. package/.turbo/turbo-build.log +28 -28
  2. package/CHANGELOG.md +37 -0
  3. package/dist/assets/{basketViewActivator-CU8_toGq.js → basketViewActivator-Dkn92C04.js} +6 -6
  4. package/dist/assets/{bcf-DXGDhw56.js → bcf-DP2AK1-_.js} +1 -1
  5. package/dist/assets/{deflate-Bb1_H2Yf.js → deflate-BYqYwhkl.js} +1 -1
  6. package/dist/assets/{exporters-DZhLN0ux.js → exporters-CZe0D8N-.js} +60 -60
  7. package/dist/assets/{geometry-controller.worker-DQOSYqtw.js → geometry-controller.worker-pD49_fH6.js} +2 -2
  8. package/dist/assets/{geometry.worker-B62e03Ao.js → geometry.worker-D4c-06r5.js} +1 -1
  9. package/dist/assets/{geotiff-y0ZxbRJd.js → geotiff-By06vdeL.js} +10 -10
  10. package/dist/assets/{ids-DruUNtfD.js → ids-DDkkb4mo.js} +3 -3
  11. package/dist/assets/{ifc-lite-Ch2T9pP9.js → ifc-lite-DxGqDbjO.js} +2 -2
  12. package/dist/assets/{ifc-lite_bg-iH_07wf8.wasm → ifc-lite_bg-BNeu7R_V.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-D7O1WHgP.wasm → ifc-lite_bg-DuxUZomW.wasm} +0 -0
  14. package/dist/assets/{index-Dr88ZlSY.js → index-CqBdDOAZ.js} +24241 -24014
  15. package/dist/assets/{jpeg-B3_loqFe.js → jpeg-B4IBTphL.js} +1 -1
  16. package/dist/assets/{lerc-nkwS8ZUe.js → lerc-DQ3jI0Ke.js} +1 -1
  17. package/dist/assets/{lzw-D3cW5Wpg.js → lzw-CtdH775t.js} +1 -1
  18. package/dist/assets/{native-bridge-BcYJooq8.js → native-bridge-DA8wxaN_.js} +1 -1
  19. package/dist/assets/{packbits-DDN4xzB5.js → packbits-DG3zn49C.js} +1 -1
  20. package/dist/assets/{parser.worker-BW1IMUed.js → parser.worker-BZZcO7DB.js} +1 -1
  21. package/dist/assets/raw-DY7Y_acr.js +1 -0
  22. package/dist/assets/{sandbox-DETNEyQb.js → sandbox-D1pQT-5R.js} +2 -2
  23. package/dist/assets/{server-client-CmzJOeS7.js → server-client-D9xO_8yX.js} +1 -1
  24. package/dist/assets/{wasm-bridge-CT7mK9W0.js → wasm-bridge-DMX8Acuf.js} +1 -1
  25. package/dist/assets/{webimage-CBjgg4up.js → webimage-_-qCDjkn.js} +1 -1
  26. package/dist/assets/{workerHelpers-IEQDo8r3.js → workerHelpers-Crstj4Oa.js} +1 -1
  27. package/dist/assets/{zstd-C8oQ6qdS.js → zstd-DlfgC8gA.js} +1 -1
  28. package/dist/index.html +6 -6
  29. package/package.json +3 -3
  30. package/src/components/viewer/BCFPanel.tsx +8 -1
  31. package/src/components/viewer/Section2DPanel.tsx +6 -2
  32. package/src/components/viewer/bcf/BCFTopicDetail.tsx +24 -0
  33. package/src/hooks/useBCF.ts +98 -16
  34. package/src/hooks/useDrawingGeneration.ts +149 -3
  35. package/src/hooks/useSymbolicAnnotations.ts +70 -26
  36. package/dist/assets/raw-CoIXstQ-.js +0 -1
@@ -13,13 +13,15 @@
13
13
 
14
14
  import { useCallback, useRef } from 'react';
15
15
  import { useViewerStore } from '@/store';
16
- import type { BCFViewpoint } from '@ifc-lite/bcf';
16
+ import type { BCFTopic, BCFViewpoint } from '@ifc-lite/bcf';
17
17
  import {
18
18
  createViewpoint,
19
19
  extractViewpointState,
20
+ computeMarkerPositions,
20
21
  type ViewerCameraState,
21
22
  type ViewerSectionPlane,
22
23
  type ViewerBounds,
24
+ type OverlayBBox,
23
25
  } from '@ifc-lite/bcf';
24
26
  import type { Renderer } from '@ifc-lite/renderer';
25
27
  import {
@@ -52,6 +54,10 @@ interface UseBCFResult {
52
54
  createViewpointFromState: (options?: CreateViewpointOptions) => Promise<BCFViewpoint | null>;
53
55
  /** Apply a viewpoint to the viewer */
54
56
  applyViewpoint: (viewpoint: BCFViewpoint, animate?: boolean) => void;
57
+ /** Animate the camera to a BCF topic's 3D location (without changing selection/visibility) */
58
+ zoomToTopic: (topic: BCFTopic) => void;
59
+ /** Whether a topic has enough data to zoom to */
60
+ canZoomToTopic: (topic: BCFTopic) => boolean;
55
61
  /** Capture a snapshot from the canvas */
56
62
  captureSnapshot: () => Promise<string | null>;
57
63
  /** Set the canvas ref for snapshot capture */
@@ -96,6 +102,21 @@ export function clearGlobalRefs(): void {
96
102
  globalRendererRef = null;
97
103
  }
98
104
 
105
+ /** Apply extracted BCF camera state without touching selection, visibility, or section plane. */
106
+ function applyCameraState(
107
+ renderer: Renderer,
108
+ camera: NonNullable<ReturnType<typeof extractViewpointState>['camera']>,
109
+ animate: boolean,
110
+ ): void {
111
+ const rendererCamera = renderer.getCamera();
112
+ if (animate) {
113
+ rendererCamera.animateTo(camera.position, camera.target, 300);
114
+ } else {
115
+ rendererCamera.setPosition(camera.position.x, camera.position.y, camera.position.z);
116
+ rendererCamera.setTarget(camera.target.x, camera.target.y, camera.target.z);
117
+ }
118
+ }
119
+
99
120
  // ============================================================================
100
121
  // Hook
101
122
  // ============================================================================
@@ -351,6 +372,28 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
351
372
  ]
352
373
  );
353
374
 
375
+ /** Restore only the viewpoint camera (used by zoom-to-topic fallback). */
376
+ const applyViewpointCamera = useCallback(
377
+ (viewpoint: BCFViewpoint, animate = true) => {
378
+ const renderer = getRenderer();
379
+ if (!renderer) {
380
+ console.warn('[useBCF] Cannot apply viewpoint camera: no renderer');
381
+ return;
382
+ }
383
+
384
+ const bounds = getBounds() ?? undefined;
385
+ const state = extractViewpointState(
386
+ viewpoint,
387
+ bounds,
388
+ renderer.getCamera().getDistance(),
389
+ );
390
+ if (state.camera) {
391
+ applyCameraState(renderer, state.camera, animate);
392
+ }
393
+ },
394
+ [getRenderer, getBounds],
395
+ );
396
+
354
397
  /**
355
398
  * Apply a viewpoint to the viewer
356
399
  */
@@ -372,22 +415,8 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
372
415
  );
373
416
  const { camera, sectionPlane: viewpointSectionPlane } = state;
374
417
 
375
- // Apply camera
376
418
  if (camera) {
377
- const rendererCamera = renderer.getCamera();
378
-
379
- if (animate) {
380
- // Animate to new position
381
- rendererCamera.animateTo(
382
- camera.position,
383
- camera.target,
384
- 300 // 300ms animation
385
- );
386
- } else {
387
- // Set immediately
388
- rendererCamera.setPosition(camera.position.x, camera.position.y, camera.position.z);
389
- rendererCamera.setTarget(camera.target.x, camera.target.y, camera.target.z);
390
- }
419
+ applyCameraState(renderer, camera, animate);
391
420
  }
392
421
 
393
422
  // Apply section plane
@@ -482,9 +511,62 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
482
511
  ]
483
512
  );
484
513
 
514
+ const canZoomToTopic = useCallback((topic: BCFTopic): boolean => {
515
+ return topic.viewpoints.length > 0;
516
+ }, []);
517
+
518
+ const zoomToTopic = useCallback(
519
+ (topic: BCFTopic) => {
520
+ const renderer = getRenderer();
521
+ if (!renderer || topic.viewpoints.length === 0) return;
522
+
523
+ const boundsLookup = (ifcGuid: string): OverlayBBox | null => {
524
+ const result = globalIdToExpressId(ifcGuid);
525
+ if (!result) return null;
526
+ return renderer.getScene().getEntityBoundingBox(result.expressId);
527
+ };
528
+
529
+ const markers = computeMarkerPositions([topic], boundsLookup, {
530
+ targetDistance: renderer.getCamera().getDistance(),
531
+ });
532
+
533
+ if (markers.length > 0) {
534
+ const marker = markers[0];
535
+
536
+ if (marker.positionSource === 'component') {
537
+ for (let i = topic.viewpoints.length - 1; i >= 0; i--) {
538
+ const vp = topic.viewpoints[i];
539
+ const guids = [
540
+ ...(vp.components?.selection ?? []),
541
+ ...(vp.components?.visibility?.exceptions ?? []),
542
+ ];
543
+ for (const comp of guids) {
544
+ if (!comp.ifcGuid) continue;
545
+ const bbox = boundsLookup(comp.ifcGuid);
546
+ if (bbox) {
547
+ void renderer.getCamera().frameBounds(bbox.min, bbox.max);
548
+ return;
549
+ }
550
+ }
551
+ }
552
+ }
553
+
554
+ const point = marker.connectorAnchor ?? marker.position;
555
+ void renderer.getCamera().framePoint(point);
556
+ return;
557
+ }
558
+
559
+ // Fallback: camera from latest viewpoint only — preserve selection/visibility
560
+ applyViewpointCamera(topic.viewpoints[topic.viewpoints.length - 1], true);
561
+ },
562
+ [applyViewpointCamera, getRenderer, globalIdToExpressId],
563
+ );
564
+
485
565
  return {
486
566
  createViewpointFromState,
487
567
  applyViewpoint,
568
+ zoomToTopic,
569
+ canZoomToTopic,
488
570
  captureSnapshot,
489
571
  setCanvasRef,
490
572
  setRendererRef,
@@ -32,6 +32,14 @@ export const AXIS_MAP: Record<'down' | 'front' | 'side', 'x' | 'y' | 'z'> = {
32
32
  side: 'x',
33
33
  };
34
34
 
35
+ // Depth of the slab IN FRONT of the section plane (in shifted-world
36
+ // metres) within which IFC annotation/grid primitives are kept. Beyond
37
+ // the slab they're culled — matches a typical plan-view "view depth"
38
+ // where dimensions for the next storey shouldn't bleed through. The
39
+ // shifted-bounds coordinate system the centroids and `position` both
40
+ // live in is already in metres (WASM applies `unit_scale` upstream).
41
+ export const ANNOTATION_VIEW_DEPTH = 1.2;
42
+
35
43
  interface UseDrawingGenerationParams {
36
44
  geometryResult: GeometryResult | null | undefined;
37
45
  ifcDataStore: { source: Uint8Array } | null;
@@ -92,10 +100,28 @@ export function useDrawingGeneration({
92
100
  // Track if this is a regeneration (vs initial generation)
93
101
  const isRegeneratingRef = useRef(false);
94
102
 
103
+ // Symbolic lines carry the parent primitive's world-space centroid so the
104
+ // 2D Section filter below can cull them against the active cut plane —
105
+ // cardinal axis OR a face-picked custom plane. The drawing-2d package's
106
+ // DrawingLine has no per-line position slot; attaching the centroid as
107
+ // extra fields keeps the change local since the canvas ignores anything
108
+ // beyond DrawingLine's declared fields.
109
+ //
110
+ // Coordinate space matches the section cutter's input (shifted bounds):
111
+ // - worldX: read from the polyline's 2D x (already RTC-shifted by WASM)
112
+ // - worldZ: -(polyline 2D y) — WASM negates Z into the 2D y axis to
113
+ // match section-cut output handedness, so flip back here
114
+ // - worldY: from the WASM `worldY` accessor (vertical elevation)
115
+ type SymbolicDrawingLine = DrawingLine & {
116
+ worldX?: number;
117
+ worldY?: number;
118
+ worldZ?: number;
119
+ };
120
+
95
121
  // Cache for symbolic representations - these don't change with section position
96
122
  // Only re-parse when model or display options change
97
123
  const symbolicCacheRef = useRef<{
98
- lines: DrawingLine[];
124
+ lines: SymbolicDrawingLine[];
99
125
  entities: Set<number>;
100
126
  sourceId: string | null;
101
127
  useSymbolic: boolean;
@@ -120,7 +146,7 @@ export function useDrawingGeneration({
120
146
 
121
147
  // Parse symbolic representations if enabled (for hybrid mode)
122
148
  // OPTIMIZATION: Cache symbolic data - it doesn't change with section position
123
- let symbolicLines: DrawingLine[] = [];
149
+ let symbolicLines: SymbolicDrawingLine[] = [];
124
150
  let entitiesWithSymbols = new Set<number>();
125
151
 
126
152
  // For multi-model: create cache key from model count and visible model IDs
@@ -167,6 +193,26 @@ export function useDrawingGeneration({
167
193
  entitiesWithSymbols.add(poly.expressId);
168
194
  const points = poly.points;
169
195
  const pointCount = poly.pointCount;
196
+ // WASM exposes `worldY` on every symbolic primitive — the
197
+ // elevation of its parent placement (Z-up IFC, world-Y here).
198
+ // The .d.ts shipped with the @ifc-lite/wasm package lags
199
+ // behind the Rust source; read defensively so a stale build
200
+ // returns undefined instead of throwing.
201
+ const polyWorldY = (poly as unknown as { worldY?: number }).worldY;
202
+ // Centroid in shifted world coords — derived from the 2D
203
+ // points the WASM extractor already emits in section-cut
204
+ // space. point.x = world X (RTC-shifted); point.y =
205
+ // -world Z (negated to match cut-output handedness), so
206
+ // flip the sign back to recover world Z. Computed once
207
+ // per source polyline and shared across its segments.
208
+ let sumX = 0;
209
+ let sumY = 0;
210
+ for (let p = 0; p < pointCount; p++) {
211
+ sumX += points[p * 2];
212
+ sumY += points[p * 2 + 1];
213
+ }
214
+ const polyWorldX = pointCount > 0 ? sumX / pointCount : undefined;
215
+ const polyWorldZ = pointCount > 0 ? -sumY / pointCount : undefined;
170
216
 
171
217
  for (let j = 0; j < pointCount - 1; j++) {
172
218
  symbolicLines.push({
@@ -180,6 +226,9 @@ export function useDrawingGeneration({
180
226
  ifcType: poly.ifcType,
181
227
  modelIndex: symbolicModelIndex,
182
228
  depth: 0,
229
+ worldX: polyWorldX,
230
+ worldY: polyWorldY,
231
+ worldZ: polyWorldZ,
183
232
  });
184
233
  }
185
234
 
@@ -195,6 +244,9 @@ export function useDrawingGeneration({
195
244
  ifcType: poly.ifcType,
196
245
  modelIndex: symbolicModelIndex,
197
246
  depth: 0,
247
+ worldX: polyWorldX,
248
+ worldY: polyWorldY,
249
+ worldZ: polyWorldZ,
198
250
  });
199
251
  }
200
252
  }
@@ -206,6 +258,12 @@ export function useDrawingGeneration({
206
258
 
207
259
  entitiesWithSymbols.add(circle.expressId);
208
260
  const numSegments = circle.isFullCircle ? 32 : 16;
261
+ const circleWorldY = (circle as unknown as { worldY?: number }).worldY;
262
+ // Centre in shifted world coords. circle.centerX is
263
+ // already RTC-shifted X; circle.centerY carries the
264
+ // negated Z (see polyline note above) — flip to recover.
265
+ const circleWorldX = circle.centerX;
266
+ const circleWorldZ = -circle.centerY;
209
267
 
210
268
  for (let j = 0; j < numSegments; j++) {
211
269
  const t1 = j / numSegments;
@@ -230,6 +288,9 @@ export function useDrawingGeneration({
230
288
  ifcType: circle.ifcType,
231
289
  modelIndex: symbolicModelIndex,
232
290
  depth: 0,
291
+ worldX: circleWorldX,
292
+ worldY: circleWorldY,
293
+ worldZ: circleWorldZ,
233
294
  });
234
295
  }
235
296
  }
@@ -375,9 +436,94 @@ export function useDrawingGeneration({
375
436
  }
376
437
  }
377
438
 
439
+ // When the user toggles `sectionPlane.flipped` on a cardinal axis,
440
+ // the cutter negates the 2D U axis (see `projectTo2D` in
441
+ // @ifc-lite/drawing-2d/math.ts and `data[6] = flipU` in the GPU
442
+ // cutter). Symbolic primitives come out of WASM in the cutter's
443
+ // UNFLIPPED basis — for the plan ('y') case `(line.x = worldX − rtc,
444
+ // line.y = −worldY + rtc)` — so on a flipped section the cut
445
+ // polygons land at −X while the symbolic lines stay at +X. The
446
+ // result the user reported: annotations sitting NEXT TO the model
447
+ // as if they were mirrored across the model's centre, instead of
448
+ // staying with the cut. Mirror symbolic X here to match the cutter
449
+ // for cardinal flipped sections. Custom face-pick planes use
450
+ // `projectTo2DBasis` (no U flip), so leave them untouched —
451
+ // symbolic alignment on an arbitrary basis is a separate problem
452
+ // and out of scope for this fix.
453
+ const mirrorSymbolicX = sectionPlane.flipped && !sectionPlane.custom;
454
+ const orientedSymbolicLines: SymbolicDrawingLine[] = mirrorSymbolicX
455
+ ? symbolicLines.map((line) => ({
456
+ ...line,
457
+ line: {
458
+ start: { x: -line.line.start.x, y: line.line.start.y },
459
+ end: { x: -line.line.end.x, y: line.line.end.y },
460
+ },
461
+ }))
462
+ : symbolicLines;
463
+
464
+ // Cull annotations to a thin view-depth slab IN FRONT of the cut.
465
+ //
466
+ // IfcAnnotation / IfcGridAxis polylines (dimensions, room tags, grid
467
+ // bubbles) live at a single elevation but have no body geometry —
468
+ // the `cutEntityIds.has(line.entityId)` filter below never matches
469
+ // them, so without this they render regardless of where the
470
+ // section sits.
471
+ //
472
+ // Reduce every cut mode (cardinal X/Y/Z + face-pick custom plane)
473
+ // to a single half-space test against a unit normal + signed
474
+ // distance. For cardinal axes the normal is the basis vector and
475
+ // distance is `position` (already in shifted-bounds coords, the
476
+ // same space the symbolic centroids land in). For custom planes
477
+ // the WASM cutter already uses `normal`/`distance` verbatim, so
478
+ // re-use both here for consistency with the cap.
479
+ //
480
+ // The kept window is `−ANNOTATION_VIEW_DEPTH ≤ signedDist ≤ 0` on
481
+ // the −normal side — the side BELOW a down-looking camera, where
482
+ // IFC dimensions live (authored at the storey's floor elevation,
483
+ // not at the cut height). This DIVERGES from
484
+ // `EdgeExtractor.filterEdgesByDepth`, which projects above the
485
+ // cut: annotations and projection edges are naturally on opposite
486
+ // sides of the cut plane. Flipped sections look at the same world
487
+ // from the opposite side, so the slab mirrors to
488
+ // `0 ≤ signedDist ≤ ANNOTATION_VIEW_DEPTH`.
489
+ //
490
+ // Anything on the wrong side of the cut, or farther than the view
491
+ // depth on the right side, is dropped — without the upper bound,
492
+ // dimensions from every storey beyond the cut stacked on top of
493
+ // each other because the half-space alone is unbounded along the
494
+ // camera axis.
495
+ //
496
+ // Annotations missing a recoverable centroid (older WASM build,
497
+ // or a degenerate polyline) are kept — over-rendering is preferable
498
+ // to silently dropping authored dimensions when the runtime can't
499
+ // classify them.
500
+ const cullNormal: [number, number, number] = sectionPlane.custom
501
+ ? sectionPlane.custom.normal
502
+ : axis === 'x' ? [1, 0, 0]
503
+ : axis === 'y' ? [0, 1, 0]
504
+ : [0, 0, 1];
505
+ const cullDistance = sectionPlane.custom ? sectionPlane.custom.distance : position;
506
+ const annotationCulled = orientedSymbolicLines.filter((line) => {
507
+ const isAnnotationLike = line.ifcType === 'IfcAnnotation' || line.ifcType === 'IfcGridAxis';
508
+ if (!isAnnotationLike) return true;
509
+ const wx = line.worldX;
510
+ const wy = line.worldY;
511
+ const wz = line.worldZ;
512
+ if (wx === undefined || wy === undefined || wz === undefined) return true;
513
+ const signedDist =
514
+ wx * cullNormal[0] +
515
+ wy * cullNormal[1] +
516
+ wz * cullNormal[2] -
517
+ cullDistance;
518
+ if (sectionPlane.flipped) {
519
+ return signedDist >= 0 && signedDist <= ANNOTATION_VIEW_DEPTH;
520
+ }
521
+ return signedDist <= 0 && signedDist >= -ANNOTATION_VIEW_DEPTH;
522
+ });
523
+
378
524
  // Only include symbolic lines for entities that are ACTUALLY being cut
379
525
  // This filters out symbols from other floors/levels not intersected by the section plane
380
- const relevantSymbolicLines = symbolicLines.filter(line =>
526
+ const relevantSymbolicLines = annotationCulled.filter(line =>
381
527
  line.entityId !== undefined && cutEntityIds.has(line.entityId)
382
528
  );
383
529
 
@@ -60,10 +60,9 @@ export interface AnnotationText2D {
60
60
  /**
61
61
  * When true, the renderer rebuilds the glyph quad in screen-aligned
62
62
  * (cameraRight, cameraUp) basis so the text always faces the camera.
63
- * Set for IfcGridAxis tags they must stay readable in top-down/ground
64
- * views where the authored world-Y up axis collapses to zero on-screen.
65
- * Defaults to false (authored, in-plane text matches BIMvision for
66
- * dimension/leader annotations that lie flat on the floor).
63
+ * Set for every annotation literal (grid bubbles, dimension callouts,
64
+ * leader labels) so they stay legible in any view flat-in-plane text
65
+ * collapses to a sliver at oblique angles (issue #812). Defaults to false.
67
66
  */
68
67
  billboard?: boolean;
69
68
  /** sRGB straight-alpha tint (0..1). Defaults to renderer near-black. */
@@ -337,12 +336,13 @@ async function parseAnnotations(
337
336
  // a little air between rows so descenders don't kiss the next cap.
338
337
  const lineSpacing = perLineHeight * 1.2;
339
338
  const bucket = ensureBucket(text.expressId, text.worldY);
340
- // IfcGridAxis bubble tags must stay readable in any view orientation
341
- // (top-down, eye-level, oblique). Tag them as billboard so the text
342
- // shader rebuilds the quad in screen-aligned basis at render time.
343
- // Other annotation text (dimensions, leader labels) keeps authored
344
- // orientation those are meant to lie flat in the floor plane.
345
- const isGridTag = text.ifcType === 'IfcGridAxis';
339
+ // All annotation text grid bubbles, dimension callouts, leader labels —
340
+ // billboards to the camera so it stays legible in any view orientation
341
+ // (top-down, eye-level, oblique). The shader rebuilds the quad in the
342
+ // screen-aligned basis at render time. Authored orientation is intentionally
343
+ // dropped: at oblique viewing angles, flat-in-plane text becomes a smeared
344
+ // sliver of pixels (issue #812). Anchor + alignment are preserved, so each
345
+ // label still sits at its authored insertion point.
346
346
  // Read per-instance style metadata. WASM emits these for grid
347
347
  // bubble parts (● fill / ○ outline / tag) and reserves them for
348
348
  // future IfcTextStyle resolution on regular annotation text.
@@ -362,7 +362,7 @@ async function parseAnnotations(
362
362
  content: lines[li],
363
363
  alignment: text.alignment,
364
364
  lineYOffset: -li * lineSpacing,
365
- billboard: isGridTag,
365
+ billboard: true,
366
366
  color: textColor,
367
367
  targetPx,
368
368
  };
@@ -658,21 +658,65 @@ export function useSymbolicAnnotationsForDrawing(params: {
658
658
  if (axis !== 'down') return EMPTY_DRAWING_ANNOTATIONS;
659
659
  void version;
660
660
 
661
- // Section view range in world Y. Matches the convention used by
662
- // `profile-projector.isInProjectionRange`:
663
- // not flipped [sectionPos, sectionPos + viewDepth]
664
- // flipped → [sectionPos - viewDepth, sectionPos]
665
- // We expand the range by a small tolerance so annotations sitting
666
- // exactly on the cut plane still match (storey elevations are
667
- // typically the cut Y).
661
+ // Section view range in world Y.
662
+ //
663
+ // For a floor-plan cut at axis='down' the camera looks DOWN through the
664
+ // cut. "In front of the camera" is therefore the side BELOW the cut —
665
+ // where the floor and authored dimensions sit (IFC convention places
666
+ // dimension annotations at the storey's floor elevation, not at the
667
+ // cut height). The user's complaint: with the slab on the +normal
668
+ // side, you had to scrub the section DOWN into the floor before
669
+ // anything showed, and then the dimensions appeared one storey BELOW
670
+ // the cut. Mirror that — keep the slab on the −normal side for the
671
+ // unflipped down section, and flip it for the reflected-ceiling case.
672
+ //
673
+ // Note this DIVERGES from `profile-projector.isInProjectionRange`,
674
+ // which projects above the cut by default. Annotations live with the
675
+ // storey floor, the projection lives with the upper-storey volume —
676
+ // they're naturally on opposite sides of the cut plane.
677
+ //
678
+ // Tolerance lets annotations authored exactly on the cut plane (e.g.
679
+ // a storey at Z=0 with a section right at the storey datum) survive.
668
680
  const TOL = 1e-3;
669
- const rangeMin = (flipped ? sectionPosWorld - viewDepth : sectionPosWorld) - TOL;
670
- const rangeMax = (flipped ? sectionPosWorld : sectionPosWorld + viewDepth) + TOL;
681
+ const rangeMin = (flipped ? sectionPosWorld : sectionPosWorld - viewDepth) - TOL;
682
+ const rangeMax = (flipped ? sectionPosWorld + viewDepth : sectionPosWorld) + TOL;
671
683
 
672
684
  const lines: DrawingLine2D[] = [];
673
685
  const texts: AnnotationText2D[] = [];
674
686
  const fills: AnnotationFill2D[] = [];
675
687
 
688
+ // The drawing-2d cutter negates the 2D U axis on flipped cardinal cuts
689
+ // (see `projectTo2D` in @ifc-lite/drawing-2d/math.ts and `flipU` in the
690
+ // GPU cutter). Annotation primitives come out of WASM in the cutter's
691
+ // UNFLIPPED basis, so on a flipped section they'd sit beside the model
692
+ // (mirrored across X=0) instead of on top of it — exactly the
693
+ // "dimensions floating to the right of the floor plan" symptom. Mirror
694
+ // X for lines/texts/fills here so they line up with the section cut
695
+ // output drawn underneath. Y stays put (the cutter only flips U).
696
+ const pushLine = flipped
697
+ ? (ln: DrawingLine2D) => lines.push({
698
+ line: {
699
+ start: { x: -ln.line.start.x, y: ln.line.start.y },
700
+ end: { x: -ln.line.end.x, y: ln.line.end.y },
701
+ },
702
+ category: ln.category,
703
+ })
704
+ : (ln: DrawingLine2D) => lines.push(ln);
705
+ const pushText = flipped
706
+ ? (t: AnnotationText2D) => texts.push({ ...t, x: -t.x, dirX: -t.dirX })
707
+ : (t: AnnotationText2D) => texts.push(t);
708
+ const pushFill = flipped
709
+ ? (f: AnnotationFill2D) => {
710
+ const src = f.points;
711
+ const dst = new Float32Array(src.length);
712
+ for (let i = 0; i < src.length; i += 2) {
713
+ dst[i] = -src[i];
714
+ dst[i + 1] = src[i + 1];
715
+ }
716
+ fills.push({ ...f, points: dst });
717
+ }
718
+ : (f: AnnotationFill2D) => fills.push(f);
719
+
676
720
  for (const store of stores) {
677
721
  const key = sourceKey(store);
678
722
  if (!key) continue;
@@ -682,9 +726,9 @@ export function useSymbolicAnnotationsForDrawing(params: {
682
726
  for (const bucket of cached.byStorey.values()) {
683
727
  const bucketY = resolveBucketY(bucket.storeyElevation, fallbackY);
684
728
  if (bucketY < rangeMin || bucketY > rangeMax) continue;
685
- for (const ln of bucket.lines) lines.push(ln);
686
- for (const t of bucket.texts) texts.push(t);
687
- for (const f of bucket.fills) fills.push(f);
729
+ for (const ln of bucket.lines) pushLine(ln);
730
+ for (const t of bucket.texts) pushText(t);
731
+ for (const f of bucket.fills) pushFill(f);
688
732
  }
689
733
 
690
734
  // Loose annotations have no resolvable storey — include them if the
@@ -692,9 +736,9 @@ export function useSymbolicAnnotationsForDrawing(params: {
692
736
  // (e.g. 3DEXPERIENCE files with orphaned storeys) usable when the
693
737
  // user is looking at the storey the fallback resolves to.
694
738
  if (fallbackY >= rangeMin && fallbackY <= rangeMax) {
695
- for (const ln of cached.loose) lines.push(ln);
696
- for (const t of cached.looseTexts) texts.push(t);
697
- for (const f of cached.looseFills) fills.push(f);
739
+ for (const ln of cached.loose) pushLine(ln);
740
+ for (const t of cached.looseTexts) pushText(t);
741
+ for (const f of cached.looseFills) pushFill(f);
698
742
  }
699
743
  }
700
744
 
@@ -1 +0,0 @@
1
- import{B as o}from"./geotiff-y0ZxbRJd.js";import"./sandbox-DETNEyQb.js";import"./lens-PYsLu_MA.js";import"./__vite-browser-external-B1O5LaIO.js";class c extends o{decodeBlock(e){return e}}export{c as default};