@ifc-lite/viewer 1.25.1 → 1.26.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 (75) hide show
  1. package/.turbo/turbo-build.log +83 -85
  2. package/CHANGELOG.md +104 -0
  3. package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-ZpTYWE3K.js} +6 -6
  4. package/dist/assets/{bcf-DP2AK1-_.js → bcf-Ctcu_Sc2.js} +5 -5
  5. package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cnx0il6E.js} +1 -1
  6. package/dist/assets/exporters-DSq76AVM.js +4687 -0
  7. package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
  8. package/dist/assets/{geotiff-By06vdeL.js → geotiff-A5UjhI6L.js} +10 -10
  9. package/dist/assets/{ids-DDkkb4mo.js → ids-DiLcGTer.js} +4 -4
  10. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  11. package/dist/assets/index-B9Ug2EqU.css +1 -0
  12. package/dist/assets/{index-CqBdDOAZ.js → index-BAH8IJVR.js} +39550 -36936
  13. package/dist/assets/{jpeg-B4IBTphL.js → jpeg-BzSkwo5D.js} +1 -1
  14. package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Cg2Rz-D5.js} +1 -1
  15. package/dist/assets/{lzw-CtdH775t.js → lzw-BBPPLW-0.js} +1 -1
  16. package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-CPojOeGE.js} +1 -1
  17. package/dist/assets/{packbits-DG3zn49C.js → packbits-yLSpjW-V.js} +1 -1
  18. package/dist/assets/parser.worker-8md211IW.js +182 -0
  19. package/dist/assets/raw-BQrAgxwT.js +1 -0
  20. package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-CsRXlgCO.js} +4715 -3081
  21. package/dist/assets/{server-client-D9xO_8yX.js → server-client-Bk4c1CPO.js} +1 -1
  22. package/dist/assets/{webimage-_-qCDjkn.js → webimage-YafxjjGr.js} +1 -1
  23. package/dist/assets/{zstd-DlfgC8gA.js → zstd-CkSLOiuu.js} +1 -1
  24. package/dist/index.html +7 -7
  25. package/package.json +23 -21
  26. package/src/App.tsx +4 -0
  27. package/src/components/extensions/FlavorDialog.tsx +18 -2
  28. package/src/components/extensions/FlavorListView.tsx +12 -3
  29. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  30. package/src/components/viewer/ClashPanel.tsx +370 -0
  31. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  32. package/src/components/viewer/CommandPalette.tsx +19 -16
  33. package/src/components/viewer/MainToolbar.tsx +155 -153
  34. package/src/components/viewer/ViewerLayout.tsx +5 -0
  35. package/src/components/viewer/Viewport.tsx +97 -12
  36. package/src/components/viewer/ViewportContainer.tsx +45 -3
  37. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  38. package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
  39. package/src/components/viewer/useGeometryStreaming.ts +134 -19
  40. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
  41. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
  42. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  43. package/src/hooks/ingest/streamCleanup.ts +45 -0
  44. package/src/hooks/ingest/viewerModelIngest.ts +118 -52
  45. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  46. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  47. package/src/hooks/useAlignmentLines3D.ts +164 -0
  48. package/src/hooks/useClash.ts +420 -0
  49. package/src/hooks/useIfcCache.ts +44 -18
  50. package/src/hooks/useIfcFederation.ts +16 -2
  51. package/src/hooks/useIfcLoader.ts +6 -30
  52. package/src/hooks/useSymbolicAnnotations.ts +170 -35
  53. package/src/lib/clash/persistence.ts +308 -0
  54. package/src/lib/geo/effective-georef.test.ts +66 -0
  55. package/src/services/extensions/host.ts +13 -0
  56. package/src/store/constants.ts +38 -14
  57. package/src/store/index.ts +29 -7
  58. package/src/store/slices/clashSlice.ts +251 -0
  59. package/src/store/slices/visibilitySlice.test.ts +23 -5
  60. package/src/store/slices/visibilitySlice.ts +19 -8
  61. package/src/store/types.ts +9 -0
  62. package/src/utils/serverDataModel.test.ts +51 -1
  63. package/src/utils/serverDataModel.ts +2 -26
  64. package/vite.config.ts +0 -5
  65. package/dist/assets/exporters-CZe0D8N-.js +0 -5957
  66. package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
  67. package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
  68. package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
  69. package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
  70. package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
  71. package/dist/assets/index-Bws3UAkj.css +0 -1
  72. package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
  73. package/dist/assets/raw-DY7Y_acr.js +0 -1
  74. package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
  75. package/dist/assets/workerHelpers-Crstj4Oa.js +0 -36
@@ -91,13 +91,31 @@ export interface AnnotationFill2D {
91
91
  };
92
92
  }
93
93
 
94
- /** Cached parse result keyed by source identity. */
94
+ /** Cached parse result keyed by source identity.
95
+ *
96
+ * IfcAnnotation and IfcGridAxis primitives are stored in PARALLEL bucket
97
+ * collections (issue #862). They share the same parse pass and the same
98
+ * storey-resolution logic, but the renderer treats them differently:
99
+ *
100
+ * - Annotation buckets always lift every storey (memory
101
+ * `feedback_3d_annotation_overlay_no_section_filter.md`: the user
102
+ * expects every storey's dimensions to be visible in 3D).
103
+ * - Grid buckets get optional section-plane filtering and an
104
+ * independent visibility toggle, so dense-grid models can hide
105
+ * grids per storey without losing dimensions.
106
+ */
95
107
  interface ParseResult {
108
+ // IfcAnnotation buckets
96
109
  byStorey: Map<number, AnnotationsForStorey>;
97
- /** Annotations with no resolvable storey — shown on every floor as a fallback. */
98
110
  loose: DrawingLine2D[];
99
111
  looseTexts: AnnotationText2D[];
100
112
  looseFills: AnnotationFill2D[];
113
+
114
+ // IfcGridAxis buckets (issue #862)
115
+ gridByStorey: Map<number, AnnotationsForStorey>;
116
+ gridLoose: DrawingLine2D[];
117
+ gridLooseTexts: AnnotationText2D[];
118
+ gridLooseFills: AnnotationFill2D[];
101
119
  }
102
120
 
103
121
  const CIRCLE_SEGMENTS_FULL = 32;
@@ -212,6 +230,10 @@ async function parseAnnotations(
212
230
  loose: [],
213
231
  looseTexts: [],
214
232
  looseFills: [],
233
+ gridByStorey: new Map(),
234
+ gridLoose: [],
235
+ gridLooseTexts: [],
236
+ gridLooseFills: [],
215
237
  };
216
238
  const source = store.source;
217
239
  if (!source || source.byteLength === 0) {
@@ -256,6 +278,7 @@ async function parseAnnotations(
256
278
  const ensureBucket = (
257
279
  expressId: number,
258
280
  primitiveWorldY: number,
281
+ ifcType: string,
259
282
  ): AnnotationsForStorey | null => {
260
283
  let effectiveY: number | null = null;
261
284
  if (Number.isFinite(primitiveWorldY) && primitiveWorldY !== 0) {
@@ -269,7 +292,11 @@ async function parseAnnotations(
269
292
  }
270
293
  if (effectiveY === null) return null;
271
294
  const key = Math.round(effectiveY * 1000);
272
- let bucket = result.byStorey.get(key);
295
+ // Issue #862: IfcGridAxis primitives land in a parallel bucket
296
+ // collection so the renderer can section-clip + visibility-toggle
297
+ // them independently of IfcAnnotation (text/dimension symbols).
298
+ const storeyMap = ifcType === 'IfcGridAxis' ? result.gridByStorey : result.byStorey;
299
+ let bucket = storeyMap.get(key);
273
300
  if (!bucket) {
274
301
  bucket = {
275
302
  storeyId: key,
@@ -278,7 +305,7 @@ async function parseAnnotations(
278
305
  texts: [],
279
306
  fills: [],
280
307
  };
281
- result.byStorey.set(key, bucket);
308
+ storeyMap.set(key, bucket);
282
309
  }
283
310
  return bucket;
284
311
  };
@@ -287,8 +314,9 @@ async function parseAnnotations(
287
314
  const poly = collection.getPolyline(i);
288
315
  if (!poly) continue;
289
316
  if (poly.ifcType !== 'IfcAnnotation' && poly.ifcType !== 'IfcGridAxis') continue;
290
- const bucket = ensureBucket(poly.expressId, poly.worldY);
291
- const out = bucket ? bucket.lines : result.loose;
317
+ const bucket = ensureBucket(poly.expressId, poly.worldY, poly.ifcType);
318
+ const looseTarget = poly.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
319
+ const out = bucket ? bucket.lines : looseTarget;
292
320
  polylineToSegments(poly.points, poly.pointCount, poly.isClosed, out);
293
321
  }
294
322
 
@@ -296,8 +324,9 @@ async function parseAnnotations(
296
324
  const circle = collection.getCircle(i);
297
325
  if (!circle) continue;
298
326
  if (circle.ifcType !== 'IfcAnnotation' && circle.ifcType !== 'IfcGridAxis') continue;
299
- const bucket = ensureBucket(circle.expressId, circle.worldY);
300
- const out = bucket ? bucket.lines : result.loose;
327
+ const bucket = ensureBucket(circle.expressId, circle.worldY, circle.ifcType);
328
+ const looseTarget = circle.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
329
+ const out = bucket ? bucket.lines : looseTarget;
301
330
  circleToSegments(
302
331
  circle.centerX,
303
332
  circle.centerY,
@@ -335,7 +364,8 @@ async function parseAnnotations(
335
364
  // Industry-standard line-spacing (CSS line-height ≈ 1.2). Picks up
336
365
  // a little air between rows so descenders don't kiss the next cap.
337
366
  const lineSpacing = perLineHeight * 1.2;
338
- const bucket = ensureBucket(text.expressId, text.worldY);
367
+ const bucket = ensureBucket(text.expressId, text.worldY, text.ifcType);
368
+ const looseTextTarget = text.ifcType === 'IfcGridAxis' ? result.gridLooseTexts : result.looseTexts;
339
369
  // All annotation text — grid bubbles, dimension callouts, leader labels —
340
370
  // billboards to the camera so it stays legible in any view orientation
341
371
  // (top-down, eye-level, oblique). The shader rebuilds the quad in the
@@ -366,7 +396,7 @@ async function parseAnnotations(
366
396
  color: textColor,
367
397
  targetPx,
368
398
  };
369
- (bucket ? bucket.texts : result.looseTexts).push(t2d);
399
+ (bucket ? bucket.texts : looseTextTarget).push(t2d);
370
400
  }
371
401
  }
372
402
 
@@ -389,8 +419,9 @@ async function parseAnnotations(
389
419
  }
390
420
  : undefined,
391
421
  };
392
- const bucket = ensureBucket(fill.expressId, fill.worldY);
393
- (bucket ? bucket.fills : result.looseFills).push(f2d);
422
+ const bucket = ensureBucket(fill.expressId, fill.worldY, fill.ifcType);
423
+ const looseFillTarget = fill.ifcType === 'IfcGridAxis' ? result.gridLooseFills : result.looseFills;
424
+ (bucket ? bucket.fills : looseFillTarget).push(f2d);
394
425
  }
395
426
  } finally {
396
427
  processor.dispose();
@@ -521,17 +552,48 @@ function resolveBucketY(elevation: number | null, fallbackY: number): number {
521
552
  return elevation === null ? fallbackY : elevation;
522
553
  }
523
554
 
555
+ /** Section-clip parameters for grid lines (issue #862). Grids ARE clipped
556
+ * by the active section plane; IfcAnnotation overlays are NOT (per the
557
+ * feedback_3d_annotation_overlay_no_section_filter memory). When
558
+ * `enabled === false` no clipping happens — grids lift to every storey
559
+ * same as annotations.
560
+ */
561
+ export interface SectionClipForGrid {
562
+ enabled: boolean;
563
+ /** World coord on the cut axis (e.g. world-Y for axis='down'). */
564
+ posWorld: number;
565
+ /** Half-thickness of the visible band around the cut, world units. */
566
+ viewDepth: number;
567
+ /** Cut axis. Only `'down'` performs vertical clipping; other axes pass through unfiltered (grid lines are vertical and don't project meaningfully onto elevation cuts). */
568
+ axis: 'down' | 'front' | 'side';
569
+ }
570
+
524
571
  export function useSymbolicAnnotations(params: {
572
+ /** Enable IfcAnnotation lift (the existing default behaviour). */
525
573
  enabled: boolean;
574
+ /**
575
+ * Enable IfcGrid lift. Independent of `enabled` so a user can hide
576
+ * annotations while keeping grids, or vice versa (issue #862).
577
+ * Defaults to `enabled` so existing call sites that don't set it
578
+ * keep the legacy combined behaviour.
579
+ */
580
+ gridEnabled?: boolean;
581
+ /** Section clipping for grids only — see [`SectionClipForGrid`]. */
582
+ gridSectionClip?: SectionClipForGrid;
526
583
  /** World Y to use for annotations with no resolvable storey. Defaults to 0. */
527
584
  fallbackY?: number;
528
585
  }): Float32Array {
529
- const { enabled, fallbackY = 0 } = params;
586
+ const { enabled, gridEnabled, gridSectionClip, fallbackY = 0 } = params;
587
+ const effectiveGridEnabled = gridEnabled ?? enabled;
530
588
  const stores = useActiveStores();
531
- const version = useAnnotationParseTrigger(enabled, stores);
589
+ // Trigger parse if EITHER subset is enabled — the parse pass is shared.
590
+ const version = useAnnotationParseTrigger(enabled || effectiveGridEnabled, stores);
591
+ const clipEnabled = !!gridSectionClip && gridSectionClip.enabled && gridSectionClip.axis === 'down';
592
+ const clipPos = clipEnabled ? gridSectionClip!.posWorld : 0;
593
+ const clipDepth = clipEnabled ? gridSectionClip!.viewDepth : 0;
532
594
 
533
595
  return useMemo(() => {
534
- if (!enabled) return EMPTY_F32;
596
+ if (!enabled && !effectiveGridEnabled) return EMPTY_F32;
535
597
  void version; // depend on parse-completion ticks
536
598
 
537
599
  const verts: number[] = [];
@@ -546,22 +608,49 @@ export function useSymbolicAnnotations(params: {
546
608
  continue;
547
609
  }
548
610
  if (debugEnabled()) {
549
- const buckets = cached.byStorey.size;
550
- const looseLines = cached.loose.length;
551
- console.log(`[annotations] store ${storeIdx}: lifting ${buckets} storey buckets + ${looseLines} loose lines (key=${key}, fallbackY=${fallbackY})`);
611
+ console.log(
612
+ `[annotations] store ${storeIdx}: annotation buckets=${cached.byStorey.size}+${cached.loose.length}loose, grid buckets=${cached.gridByStorey.size}+${cached.gridLoose.length}loose (annot=${enabled}, grid=${effectiveGridEnabled}, clip=${clipEnabled})`,
613
+ );
614
+ }
615
+
616
+ if (enabled) {
617
+ for (const bucket of cached.byStorey.values()) {
618
+ liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
619
+ }
620
+ liftTo3DLineList(cached.loose, fallbackY, verts);
552
621
  }
553
622
 
554
- for (const bucket of cached.byStorey.values()) {
555
- liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
623
+ if (effectiveGridEnabled) {
624
+ // Issue #862: section-clip grid buckets only — IfcAnnotation
625
+ // intentionally bypasses this per the feedback memory ("the
626
+ // user expects every storey's dimensions/grid bubbles to lift
627
+ // into the viewport when [the annotation toggle is] on, even
628
+ // while a section cut is active").
629
+ if (clipEnabled) {
630
+ const lo = clipPos - clipDepth;
631
+ const hi = clipPos + clipDepth;
632
+ for (const bucket of cached.gridByStorey.values()) {
633
+ const y = resolveBucketY(bucket.storeyElevation, fallbackY);
634
+ if (y < lo || y > hi) continue;
635
+ liftTo3DLineList(bucket.lines, y, verts);
636
+ }
637
+ if (fallbackY >= lo && fallbackY <= hi) {
638
+ liftTo3DLineList(cached.gridLoose, fallbackY, verts);
639
+ }
640
+ } else {
641
+ for (const bucket of cached.gridByStorey.values()) {
642
+ liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
643
+ }
644
+ liftTo3DLineList(cached.gridLoose, fallbackY, verts);
645
+ }
556
646
  }
557
- liftTo3DLineList(cached.loose, fallbackY, verts);
558
647
  storeIdx++;
559
648
  }
560
649
 
561
650
  if (debugEnabled()) console.log(`[annotations] total 3D line vertices: ${verts.length / 3} from ${stores.length} stores`);
562
651
  if (verts.length === 0) return EMPTY_F32;
563
652
  return new Float32Array(verts);
564
- }, [enabled, stores, version, fallbackY]);
653
+ }, [enabled, effectiveGridEnabled, clipEnabled, clipPos, clipDepth, stores, version, fallbackY]);
565
654
  }
566
655
 
567
656
  /**
@@ -723,13 +812,19 @@ export function useSymbolicAnnotationsForDrawing(params: {
723
812
  const cached = PARSE_CACHE.get(key);
724
813
  if (!cached) continue;
725
814
 
726
- for (const bucket of cached.byStorey.values()) {
815
+ // Drawing-2D pulls BOTH annotation and grid buckets (issue #862
816
+ // split them at parse time so the 3D viewport can clip them
817
+ // separately — the 2D Section panel still wants the combined
818
+ // overlay).
819
+ const collectBucket = (bucket: AnnotationsForStorey) => {
727
820
  const bucketY = resolveBucketY(bucket.storeyElevation, fallbackY);
728
- if (bucketY < rangeMin || bucketY > rangeMax) continue;
821
+ if (bucketY < rangeMin || bucketY > rangeMax) return;
729
822
  for (const ln of bucket.lines) pushLine(ln);
730
823
  for (const t of bucket.texts) pushText(t);
731
824
  for (const f of bucket.fills) pushFill(f);
732
- }
825
+ };
826
+ for (const bucket of cached.byStorey.values()) collectBucket(bucket);
827
+ for (const bucket of cached.gridByStorey.values()) collectBucket(bucket);
733
828
 
734
829
  // Loose annotations have no resolvable storey — include them if the
735
830
  // fallback Y lands in the view range. That keeps malformed exports
@@ -739,6 +834,9 @@ export function useSymbolicAnnotationsForDrawing(params: {
739
834
  for (const ln of cached.loose) pushLine(ln);
740
835
  for (const t of cached.looseTexts) pushText(t);
741
836
  for (const f of cached.looseFills) pushFill(f);
837
+ for (const ln of cached.gridLoose) pushLine(ln);
838
+ for (const t of cached.gridLooseTexts) pushText(t);
839
+ for (const f of cached.gridLooseFills) pushFill(f);
742
840
  }
743
841
  }
744
842
 
@@ -757,14 +855,24 @@ export function useSymbolicAnnotationsForDrawing(params: {
757
855
  */
758
856
  export function useSymbolicAnnotationsRichData(params: {
759
857
  enabled: boolean;
858
+ /** Lift grid-bubble texts + fills. Independent of `enabled` (issue #862).
859
+ * Defaults to `enabled` for legacy callers. */
860
+ gridEnabled?: boolean;
861
+ /** Section clipping for grid texts/fills only — same semantics as
862
+ * [`useSymbolicAnnotations`]. */
863
+ gridSectionClip?: SectionClipForGrid;
760
864
  fallbackY?: number;
761
865
  }): { texts: readonly AnnotationText3D[]; fills: readonly AnnotationFill3D[] } {
762
- const { enabled, fallbackY = 0 } = params;
866
+ const { enabled, gridEnabled, gridSectionClip, fallbackY = 0 } = params;
867
+ const effectiveGridEnabled = gridEnabled ?? enabled;
763
868
  const stores = useActiveStores();
764
- const version = useAnnotationParseTrigger(enabled, stores);
869
+ const version = useAnnotationParseTrigger(enabled || effectiveGridEnabled, stores);
870
+ const clipEnabled = !!gridSectionClip && gridSectionClip.enabled && gridSectionClip.axis === 'down';
871
+ const clipPos = clipEnabled ? gridSectionClip!.posWorld : 0;
872
+ const clipDepth = clipEnabled ? gridSectionClip!.viewDepth : 0;
765
873
 
766
874
  return useMemo(() => {
767
- if (!enabled) return { texts: EMPTY_TEXTS, fills: EMPTY_FILLS };
875
+ if (!enabled && !effectiveGridEnabled) return { texts: EMPTY_TEXTS, fills: EMPTY_FILLS };
768
876
  void version;
769
877
 
770
878
  const texts: AnnotationText3D[] = [];
@@ -803,18 +911,45 @@ export function useSymbolicAnnotationsRichData(params: {
803
911
  });
804
912
  };
805
913
 
806
- for (const bucket of cached.byStorey.values()) {
807
- const y = resolveBucketY(bucket.storeyElevation, fallbackY);
808
- for (const t of bucket.texts) pushText(t, y);
809
- for (const f of bucket.fills) pushFill(f, y);
914
+ if (enabled) {
915
+ for (const bucket of cached.byStorey.values()) {
916
+ const y = resolveBucketY(bucket.storeyElevation, fallbackY);
917
+ for (const t of bucket.texts) pushText(t, y);
918
+ for (const f of bucket.fills) pushFill(f, y);
919
+ }
920
+ for (const t of cached.looseTexts) pushText(t, fallbackY);
921
+ for (const f of cached.looseFills) pushFill(f, fallbackY);
922
+ }
923
+
924
+ if (effectiveGridEnabled) {
925
+ if (clipEnabled) {
926
+ const lo = clipPos - clipDepth;
927
+ const hi = clipPos + clipDepth;
928
+ for (const bucket of cached.gridByStorey.values()) {
929
+ const y = resolveBucketY(bucket.storeyElevation, fallbackY);
930
+ if (y < lo || y > hi) continue;
931
+ for (const t of bucket.texts) pushText(t, y);
932
+ for (const f of bucket.fills) pushFill(f, y);
933
+ }
934
+ if (fallbackY >= lo && fallbackY <= hi) {
935
+ for (const t of cached.gridLooseTexts) pushText(t, fallbackY);
936
+ for (const f of cached.gridLooseFills) pushFill(f, fallbackY);
937
+ }
938
+ } else {
939
+ for (const bucket of cached.gridByStorey.values()) {
940
+ const y = resolveBucketY(bucket.storeyElevation, fallbackY);
941
+ for (const t of bucket.texts) pushText(t, y);
942
+ for (const f of bucket.fills) pushFill(f, y);
943
+ }
944
+ for (const t of cached.gridLooseTexts) pushText(t, fallbackY);
945
+ for (const f of cached.gridLooseFills) pushFill(f, fallbackY);
946
+ }
810
947
  }
811
- for (const t of cached.looseTexts) pushText(t, fallbackY);
812
- for (const f of cached.looseFills) pushFill(f, fallbackY);
813
948
  }
814
949
 
815
950
  return {
816
951
  texts: texts.length ? texts : EMPTY_TEXTS,
817
952
  fills: fills.length ? fills : EMPTY_FILLS,
818
953
  };
819
- }, [enabled, stores, version, fallbackY]);
954
+ }, [enabled, effectiveGridEnabled, clipEnabled, clipPos, clipDepth, stores, version, fallbackY]);
820
955
  }
@@ -0,0 +1,308 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * localStorage persistence for clash detection settings + the user's rule preset
7
+ * set. Mirrors the lens slice's "built-ins + overrides + custom" model and the
8
+ * scripts module's quota-safe `SaveResult`:
9
+ *
10
+ * - Presets: the built-in `CLASH_RULE_PRESETS` are always present (projected to
11
+ * editable items with `enabled`/`builtin`); the user may toggle/edit them
12
+ * (stored as overrides) and add custom presets. Only customs + modified
13
+ * built-ins are persisted, so shipping a new built-in just works.
14
+ * - Settings: one flat JSON blob (mode/tolerance/clearance/clusterEpsilon/
15
+ * reportTouch/groupBy), every numeric clamped to a sane range on load.
16
+ */
17
+
18
+ import {
19
+ CLASH_RULE_PRESETS,
20
+ type ClashRulePreset,
21
+ type ClashMode,
22
+ type ClashSeverity,
23
+ } from '@ifc-lite/clash';
24
+
25
+ /** A built-in or user-defined clash rule preset, with editor/runtime flags. */
26
+ export type ClashPreset = ClashRulePreset & { enabled: boolean; builtin: boolean };
27
+
28
+ /** How the panel groups the flat clash list (display only). */
29
+ export type ClashSettingsGroupBy = 'severity' | 'rule' | 'typePair';
30
+
31
+ /** Global detection settings, persisted as one blob. */
32
+ export interface ClashGlobalSettings {
33
+ mode: ClashMode;
34
+ tolerance: number;
35
+ clearance: number;
36
+ clusterEpsilon: number;
37
+ reportTouch: boolean;
38
+ groupBy: ClashSettingsGroupBy;
39
+ }
40
+
41
+ export type SaveResult =
42
+ | { ok: true }
43
+ | { ok: false; reason: 'quota' | 'serialize' | 'too_many'; message: string };
44
+
45
+ const PRESETS_KEY = 'ifc-lite-clash-presets';
46
+ const SETTINGS_KEY = 'ifc-lite-clash-settings';
47
+ const SCHEMA_VERSION = 1;
48
+
49
+ const MAX_PRESETS = 200;
50
+ const MAX_NAME = 100;
51
+
52
+ /** [min, max] clamps applied to settings numerics on load and on commit. */
53
+ export const CLASH_BOUNDS = {
54
+ tolerance: [0, 1] as const,
55
+ clearance: [0, 5] as const,
56
+ clusterEpsilon: [0.01, 50] as const,
57
+ };
58
+
59
+ export const DEFAULT_CLASH_SETTINGS: ClashGlobalSettings = {
60
+ mode: 'hard',
61
+ tolerance: 0.002,
62
+ clearance: 0.05,
63
+ clusterEpsilon: 1.5,
64
+ reportTouch: false,
65
+ groupBy: 'severity',
66
+ };
67
+
68
+ const BUILTIN_PRESET_IDS = new Set(CLASH_RULE_PRESETS.map((p) => p.id));
69
+ const SEVERITIES: ClashSeverity[] = ['critical', 'major', 'minor', 'info'];
70
+ const GROUP_BYS: ClashSettingsGroupBy[] = ['severity', 'rule', 'typePair'];
71
+
72
+ export function clampToBounds(value: unknown, [min, max]: readonly [number, number], fallback: number): number {
73
+ const n = typeof value === 'number' ? value : Number(value);
74
+ if (!Number.isFinite(n)) return fallback;
75
+ return Math.min(max, Math.max(min, n));
76
+ }
77
+
78
+ /** Trim + length-cap a preset name; null if empty (invalid). */
79
+ export function validatePresetName(name: string): string | null {
80
+ const t = name.trim();
81
+ return t ? t.slice(0, MAX_NAME) : null;
82
+ }
83
+
84
+ /** Trim a selector; null if empty (invalid). An empty selector matches everything. */
85
+ export function validateSelector(selector: string): string | null {
86
+ const t = selector.trim();
87
+ return t ? t : null;
88
+ }
89
+
90
+ function isValidStoredPreset(p: unknown): p is ClashPreset {
91
+ if (!p || typeof p !== 'object') return false;
92
+ const r = p as Record<string, unknown>;
93
+ return (
94
+ typeof r.id === 'string' && r.id.length > 0 &&
95
+ typeof r.name === 'string' && r.name.trim().length > 0 &&
96
+ typeof r.selectorA === 'string' && r.selectorA.trim().length > 0 &&
97
+ typeof r.selectorB === 'string' && r.selectorB.trim().length > 0 &&
98
+ typeof r.severity === 'string' && SEVERITIES.includes(r.severity as ClashSeverity)
99
+ );
100
+ }
101
+
102
+ /** Read stored presets, accepting the versioned wrapper or a legacy bare array. */
103
+ function readStoredPresets(): ClashPreset[] {
104
+ try {
105
+ const raw = localStorage.getItem(PRESETS_KEY);
106
+ if (!raw) return [];
107
+ const parsed: unknown = JSON.parse(raw);
108
+ const list = Array.isArray(parsed)
109
+ ? parsed
110
+ : parsed && typeof parsed === 'object' && Array.isArray((parsed as { presets?: unknown }).presets)
111
+ ? (parsed as { presets: unknown[] }).presets
112
+ : [];
113
+ return list
114
+ .filter(isValidStoredPreset)
115
+ .map((p) => ({
116
+ id: p.id,
117
+ name: p.name,
118
+ description: typeof p.description === 'string' ? p.description : '',
119
+ severity: p.severity,
120
+ selectorA: p.selectorA,
121
+ selectorB: p.selectorB,
122
+ enabled: p.enabled !== false,
123
+ builtin: BUILTIN_PRESET_IDS.has(p.id),
124
+ }));
125
+ } catch {
126
+ return [];
127
+ }
128
+ }
129
+
130
+ /** The pristine built-in preset set, no overrides/customs — the "reset" target. */
131
+ export function defaultPresets(): ClashPreset[] {
132
+ return CLASH_RULE_PRESETS.map((p) => ({ ...p, enabled: true, builtin: true }));
133
+ }
134
+
135
+ /**
136
+ * The full preset list shown to the user: every built-in (with any saved
137
+ * override applied) followed by custom presets. Built-ins are always present
138
+ * even if storage is empty or dropped them.
139
+ */
140
+ /**
141
+ * Resolve a stored (customs + modified-built-ins) list into the full preset
142
+ * list: every built-in (with any override applied), then customs. Built-ins are
143
+ * always present, so a list from an older app version still picks up new ones.
144
+ */
145
+ export function mergeStoredPresets(stored: ClashPreset[]): ClashPreset[] {
146
+ const overrides = new Map(stored.filter((p) => p.builtin).map((p) => [p.id, p]));
147
+ const builtins: ClashPreset[] = CLASH_RULE_PRESETS.map(
148
+ (p) => overrides.get(p.id) ?? { ...p, enabled: true, builtin: true },
149
+ );
150
+ const custom = stored.filter((p) => !p.builtin);
151
+ return [...builtins, ...custom];
152
+ }
153
+
154
+ export function buildInitialPresets(): ClashPreset[] {
155
+ return mergeStoredPresets(readStoredPresets());
156
+ }
157
+
158
+ function builtinDiffersFromDefault(p: ClashPreset): boolean {
159
+ const orig = CLASH_RULE_PRESETS.find((b) => b.id === p.id);
160
+ if (!orig) return true;
161
+ return (
162
+ !p.enabled ||
163
+ p.name !== orig.name ||
164
+ p.severity !== orig.severity ||
165
+ p.selectorA !== orig.selectorA ||
166
+ p.selectorB !== orig.selectorB ||
167
+ p.description !== orig.description
168
+ );
169
+ }
170
+
171
+ /** The minimal stored shape: customs + only the built-ins that differ from default. */
172
+ export function presetsToStore(presets: ClashPreset[]): ClashPreset[] {
173
+ return [
174
+ ...presets.filter((p) => !p.builtin),
175
+ ...presets.filter((p) => p.builtin && builtinDiffersFromDefault(p)),
176
+ ];
177
+ }
178
+
179
+ /** Persist only custom presets + modified built-ins (quota-safe). */
180
+ export function savePresets(presets: ClashPreset[]): SaveResult {
181
+ const custom = presets.filter((p) => !p.builtin);
182
+ if (custom.length > MAX_PRESETS) {
183
+ return { ok: false, reason: 'too_many', message: `Too many custom rules (max ${MAX_PRESETS}).` };
184
+ }
185
+ const toStore = presetsToStore(presets);
186
+ let payload: string;
187
+ try {
188
+ payload = JSON.stringify({ schemaVersion: SCHEMA_VERSION, presets: toStore });
189
+ } catch {
190
+ return { ok: false, reason: 'serialize', message: 'Could not serialize clash rules.' };
191
+ }
192
+ try {
193
+ localStorage.setItem(PRESETS_KEY, payload);
194
+ return { ok: true };
195
+ } catch {
196
+ return { ok: false, reason: 'quota', message: 'Browser storage is full — clash rules were not saved.' };
197
+ }
198
+ }
199
+
200
+ /** Coerce arbitrary input into valid, bounds-clamped settings (defaults on junk). */
201
+ export function normalizeSettings(raw: unknown): ClashGlobalSettings {
202
+ const s = (raw && typeof raw === 'object' && 'settings' in raw
203
+ ? (raw as { settings: unknown }).settings
204
+ : raw) as Partial<ClashGlobalSettings> | null;
205
+ if (!s || typeof s !== 'object') return { ...DEFAULT_CLASH_SETTINGS };
206
+ return {
207
+ mode: s.mode === 'clearance' ? 'clearance' : 'hard',
208
+ tolerance: clampToBounds(s.tolerance, CLASH_BOUNDS.tolerance, DEFAULT_CLASH_SETTINGS.tolerance),
209
+ clearance: clampToBounds(s.clearance, CLASH_BOUNDS.clearance, DEFAULT_CLASH_SETTINGS.clearance),
210
+ clusterEpsilon: clampToBounds(s.clusterEpsilon, CLASH_BOUNDS.clusterEpsilon, DEFAULT_CLASH_SETTINGS.clusterEpsilon),
211
+ reportTouch: s.reportTouch === true,
212
+ groupBy: GROUP_BYS.includes(s.groupBy as ClashSettingsGroupBy) ? (s.groupBy as ClashSettingsGroupBy) : 'severity',
213
+ };
214
+ }
215
+
216
+ export function loadSettings(): ClashGlobalSettings {
217
+ try {
218
+ const raw = localStorage.getItem(SETTINGS_KEY);
219
+ if (!raw) return { ...DEFAULT_CLASH_SETTINGS };
220
+ return normalizeSettings(JSON.parse(raw));
221
+ } catch {
222
+ return { ...DEFAULT_CLASH_SETTINGS };
223
+ }
224
+ }
225
+
226
+ export function saveSettings(settings: ClashGlobalSettings): SaveResult {
227
+ try {
228
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify({ schemaVersion: SCHEMA_VERSION, settings }));
229
+ return { ok: true };
230
+ } catch {
231
+ return { ok: false, reason: 'quota', message: 'Browser storage is full — clash settings were not saved.' };
232
+ }
233
+ }
234
+
235
+ /** Download the user's presets (customs + modified built-ins) as a JSON file. */
236
+ export function exportPresets(presets: ClashPreset[]): void {
237
+ const custom = presets.filter((p) => !p.builtin || builtinDiffersFromDefault(p));
238
+ const blob = new Blob([JSON.stringify({ schemaVersion: SCHEMA_VERSION, presets: custom }, null, 2)], {
239
+ type: 'application/json',
240
+ });
241
+ const url = URL.createObjectURL(blob);
242
+ const a = document.createElement('a');
243
+ a.href = url;
244
+ a.download = 'clash-rules.clash-presets.json';
245
+ a.click();
246
+ URL.revokeObjectURL(url);
247
+ }
248
+
249
+ /** Parse an exported file into custom presets (ids regenerated, `builtin` stripped). */
250
+ export async function importPresets(file: File): Promise<ClashPreset[]> {
251
+ const text = await file.text();
252
+ const parsed: unknown = JSON.parse(text);
253
+ const list = Array.isArray(parsed)
254
+ ? parsed
255
+ : parsed && typeof parsed === 'object' && Array.isArray((parsed as { presets?: unknown }).presets)
256
+ ? (parsed as { presets: unknown[] }).presets
257
+ : [];
258
+ return list.filter(isValidStoredPreset).map((p) => ({
259
+ id: `custom-${crypto.randomUUID()}`,
260
+ name: p.name.slice(0, MAX_NAME),
261
+ description: typeof p.description === 'string' ? p.description : '',
262
+ severity: p.severity,
263
+ selectorA: p.selectorA,
264
+ selectorB: p.selectorB,
265
+ enabled: p.enabled !== false,
266
+ builtin: false,
267
+ }));
268
+ }
269
+
270
+ // ── Flavor integration ───────────────────────────────────────────────────────
271
+ // Clash config rides inside a flavor's generic `settings.clash` blob, so each
272
+ // flavor/profile carries its own rule-set + detection settings (and they travel
273
+ // with flavor export/import). Serialize stores the minimal shape (customs +
274
+ // modified built-ins + settings); deserialize rebuilds the full, validated state.
275
+
276
+ /** Plain-JSON snapshot of clash config stored in a flavor. */
277
+ export interface ClashFlavorConfig {
278
+ schemaVersion: number;
279
+ settings: ClashGlobalSettings;
280
+ /** Customs + modified built-ins only (built-ins are re-merged on restore). */
281
+ presets: ClashPreset[];
282
+ }
283
+
284
+ export function serializeClashConfig(presets: ClashPreset[], settings: ClashGlobalSettings): ClashFlavorConfig {
285
+ return { schemaVersion: SCHEMA_VERSION, settings: { ...settings }, presets: presetsToStore(presets) };
286
+ }
287
+
288
+ /**
289
+ * Rebuild clash state from a flavor blob: the full resolved preset list (defaults
290
+ * + the blob's overrides/customs) and bounds-clamped settings. Returns null when
291
+ * the blob is missing/garbage so the caller can skip the restore.
292
+ */
293
+ export function deserializeClashConfig(blob: unknown): { presets: ClashPreset[]; settings: ClashGlobalSettings } | null {
294
+ if (!blob || typeof blob !== 'object') return null;
295
+ const b = blob as Partial<ClashFlavorConfig>;
296
+ const storedRaw = Array.isArray(b.presets) ? b.presets : [];
297
+ const stored = storedRaw.filter(isValidStoredPreset).map((p) => ({
298
+ id: p.id,
299
+ name: p.name,
300
+ description: typeof p.description === 'string' ? p.description : '',
301
+ severity: p.severity,
302
+ selectorA: p.selectorA,
303
+ selectorB: p.selectorB,
304
+ enabled: p.enabled !== false,
305
+ builtin: BUILTIN_PRESET_IDS.has(p.id),
306
+ }));
307
+ return { presets: mergeStoredPresets(stored), settings: normalizeSettings(b.settings) };
308
+ }