@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.
- package/.turbo/turbo-build.log +83 -85
- package/CHANGELOG.md +104 -0
- package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-ZpTYWE3K.js} +6 -6
- package/dist/assets/{bcf-DP2AK1-_.js → bcf-Ctcu_Sc2.js} +5 -5
- package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cnx0il6E.js} +1 -1
- package/dist/assets/exporters-DSq76AVM.js +4687 -0
- package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
- package/dist/assets/{geotiff-By06vdeL.js → geotiff-A5UjhI6L.js} +10 -10
- package/dist/assets/{ids-DDkkb4mo.js → ids-DiLcGTer.js} +4 -4
- package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
- package/dist/assets/index-B9Ug2EqU.css +1 -0
- package/dist/assets/{index-CqBdDOAZ.js → index-BAH8IJVR.js} +39550 -36936
- package/dist/assets/{jpeg-B4IBTphL.js → jpeg-BzSkwo5D.js} +1 -1
- package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Cg2Rz-D5.js} +1 -1
- package/dist/assets/{lzw-CtdH775t.js → lzw-BBPPLW-0.js} +1 -1
- package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-CPojOeGE.js} +1 -1
- package/dist/assets/{packbits-DG3zn49C.js → packbits-yLSpjW-V.js} +1 -1
- package/dist/assets/parser.worker-8md211IW.js +182 -0
- package/dist/assets/raw-BQrAgxwT.js +1 -0
- package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-CsRXlgCO.js} +4715 -3081
- package/dist/assets/{server-client-D9xO_8yX.js → server-client-Bk4c1CPO.js} +1 -1
- package/dist/assets/{webimage-_-qCDjkn.js → webimage-YafxjjGr.js} +1 -1
- package/dist/assets/{zstd-DlfgC8gA.js → zstd-CkSLOiuu.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +23 -21
- package/src/App.tsx +4 -0
- package/src/components/extensions/FlavorDialog.tsx +18 -2
- package/src/components/extensions/FlavorListView.tsx +12 -3
- package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
- package/src/components/viewer/ClashPanel.tsx +370 -0
- package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
- package/src/components/viewer/CommandPalette.tsx +19 -16
- package/src/components/viewer/MainToolbar.tsx +155 -153
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +97 -12
- package/src/components/viewer/ViewportContainer.tsx +45 -3
- package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
- package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
- package/src/components/viewer/useGeometryStreaming.ts +134 -19
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
- package/src/hooks/ingest/streamCleanup.test.ts +41 -0
- package/src/hooks/ingest/streamCleanup.ts +45 -0
- package/src/hooks/ingest/viewerModelIngest.ts +118 -52
- package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
- package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
- package/src/hooks/useAlignmentLines3D.ts +164 -0
- package/src/hooks/useClash.ts +420 -0
- package/src/hooks/useIfcCache.ts +44 -18
- package/src/hooks/useIfcFederation.ts +16 -2
- package/src/hooks/useIfcLoader.ts +6 -30
- package/src/hooks/useSymbolicAnnotations.ts +170 -35
- package/src/lib/clash/persistence.ts +308 -0
- package/src/lib/geo/effective-georef.test.ts +66 -0
- package/src/services/extensions/host.ts +13 -0
- package/src/store/constants.ts +38 -14
- package/src/store/index.ts +29 -7
- package/src/store/slices/clashSlice.ts +251 -0
- package/src/store/slices/visibilitySlice.test.ts +23 -5
- package/src/store/slices/visibilitySlice.ts +19 -8
- package/src/store/types.ts +9 -0
- package/src/utils/serverDataModel.test.ts +51 -1
- package/src/utils/serverDataModel.ts +2 -26
- package/vite.config.ts +0 -5
- package/dist/assets/exporters-CZe0D8N-.js +0 -5957
- package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
- package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
- package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
- package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
- package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
- package/dist/assets/index-Bws3UAkj.css +0 -1
- package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
- package/dist/assets/raw-DY7Y_acr.js +0 -1
- package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
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
|
+
}
|