@ifc-lite/viewer 1.14.0 → 1.14.2
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/CHANGELOG.md +48 -0
- package/dist/assets/{Arrow.dom-CNguvlQi.js → Arrow.dom-CSgnLhN4.js} +1 -1
- package/dist/assets/{browser-D6lgLpkA.js → browser-qSKWrKQW.js} +1 -1
- package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
- package/dist/assets/{index-UaDsJsCR.js → index-4Y4XaV8N.js} +22043 -21152
- package/dist/assets/index-ByrFvN5A.css +1 -0
- package/dist/assets/{index-BMwpw264.js → index-CN7qDq7G.js} +4 -4
- package/dist/assets/{native-bridge-DqELq4X0.js → native-bridge-CSFDsEkg.js} +1 -1
- package/dist/assets/{wasm-bridge-CVWvHlfH.js → wasm-bridge-Zf90ysEm.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +19 -19
- package/src/components/viewer/AxisHelper.tsx +57 -2
- package/src/components/viewer/BulkPropertyEditor.tsx +3 -14
- package/src/components/viewer/DataConnector.tsx +3 -14
- package/src/components/viewer/ExportChangesButton.tsx +3 -14
- package/src/components/viewer/ExportDialog.tsx +3 -14
- package/src/components/viewer/HierarchyPanel.tsx +75 -12
- package/src/components/viewer/MainToolbar.tsx +51 -18
- package/src/components/viewer/PropertiesPanel.tsx +245 -77
- package/src/components/viewer/PropertyEditor.tsx +80 -18
- package/src/components/viewer/ViewerLayout.tsx +2 -2
- package/src/components/viewer/Viewport.tsx +5 -1
- package/src/components/viewer/ViewportContainer.tsx +59 -37
- package/src/components/viewer/ViewportOverlays.tsx +7 -6
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +153 -1
- package/src/components/viewer/hierarchy/types.ts +3 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +15 -7
- package/src/components/viewer/properties/BsddCard.tsx +2 -2
- package/src/components/viewer/properties/PropertySetCard.tsx +20 -4
- package/src/components/viewer/properties/encodingUtils.ts +2 -0
- package/src/components/viewer/useGeometryStreaming.ts +189 -55
- package/src/components/viewer/useMouseControls.ts +55 -14
- package/src/components/viewer/useTouchControls.ts +2 -0
- package/src/hooks/useIfc.ts +19 -1
- package/src/hooks/useIfcCache.ts +6 -1
- package/src/hooks/useIfcFederation.ts +16 -1
- package/src/hooks/useIfcLoader.ts +16 -4
- package/src/index.css +96 -0
- package/src/store/slices/dataSlice.ts +9 -4
- package/src/utils/configureMutationView.ts +37 -0
- package/src/utils/localParsingUtils.ts +3 -1
- package/tsconfig.json +12 -1
- package/dist/assets/ifc-lite_bg-B6s-pcv0.wasm +0 -0
- package/dist/assets/index-Qp8stcGO.css +0 -1
|
@@ -40,12 +40,15 @@ import { useRenderUpdates } from './useRenderUpdates.js';
|
|
|
40
40
|
|
|
41
41
|
interface ViewportProps {
|
|
42
42
|
geometry: MeshData[] | null;
|
|
43
|
+
/** Monotonic counter that increments when geometry changes — used to trigger
|
|
44
|
+
* streaming effects even when the geometry array reference is stable. */
|
|
45
|
+
geometryVersion?: number;
|
|
43
46
|
coordinateInfo?: CoordinateInfo;
|
|
44
47
|
computedIsolatedIds?: Set<number> | null;
|
|
45
48
|
modelIdToIndex?: Map<string, number>;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelIdToIndex }: ViewportProps) {
|
|
51
|
+
export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIsolatedIds, modelIdToIndex }: ViewportProps) {
|
|
49
52
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
50
53
|
const rendererRef = useRef<Renderer | null>(null);
|
|
51
54
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
@@ -789,6 +792,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
789
792
|
rendererRef,
|
|
790
793
|
isInitialized,
|
|
791
794
|
geometry,
|
|
795
|
+
geometryVersion,
|
|
792
796
|
coordinateInfo,
|
|
793
797
|
isStreaming,
|
|
794
798
|
geometryBoundsRef,
|
|
@@ -173,58 +173,79 @@ export function ViewportContainer() {
|
|
|
173
173
|
// Check if any models are loaded (even if hidden) - used to show empty 3D vs starting UI
|
|
174
174
|
const hasLoadedModels = storeModels.size > 0 || (geometryResult?.meshes && geometryResult.meshes.length > 0);
|
|
175
175
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
176
|
+
// PERF: Incremental geometry filtering using refs.
|
|
177
|
+
// Instead of creating a new 200K+ element array every batch (~200ms),
|
|
178
|
+
// we push ONLY new meshes into a cached array — O(batch_size) not O(total).
|
|
179
|
+
// A version counter triggers downstream re-renders via the Viewport prop.
|
|
180
|
+
const filteredCacheRef = useRef<MeshData[]>([]);
|
|
181
|
+
const filteredSourceLenRef = useRef(0);
|
|
182
|
+
const filteredTypeVisRef = useRef(typeVisibility);
|
|
183
|
+
const filteredVersionRef = useRef(0);
|
|
184
|
+
|
|
180
185
|
const filteredGeometry = useMemo(() => {
|
|
181
186
|
if (!mergedGeometryResult?.meshes) {
|
|
187
|
+
filteredCacheRef.current = [];
|
|
188
|
+
filteredSourceLenRef.current = 0;
|
|
189
|
+
filteredVersionRef.current = 0;
|
|
182
190
|
return null;
|
|
183
191
|
}
|
|
184
192
|
|
|
185
|
-
|
|
193
|
+
const allMeshes = mergedGeometryResult.meshes;
|
|
194
|
+
const cache = filteredCacheRef.current;
|
|
195
|
+
|
|
196
|
+
// Full rebuild if: type visibility changed, source shrunk (new file), or empty cache
|
|
197
|
+
const prevVis = filteredTypeVisRef.current;
|
|
198
|
+
const typeVisChanged =
|
|
199
|
+
prevVis.spaces !== typeVisibility.spaces ||
|
|
200
|
+
prevVis.openings !== typeVisibility.openings ||
|
|
201
|
+
prevVis.site !== typeVisibility.site;
|
|
202
|
+
if (typeVisChanged || allMeshes.length < filteredSourceLenRef.current) {
|
|
203
|
+
cache.length = 0;
|
|
204
|
+
filteredSourceLenRef.current = 0;
|
|
205
|
+
filteredTypeVisRef.current = typeVisibility;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const needsFilter = !typeVisibility.spaces || !typeVisibility.openings || !typeVisibility.site;
|
|
209
|
+
const prevCacheLen = cache.length;
|
|
186
210
|
|
|
187
|
-
//
|
|
188
|
-
|
|
211
|
+
// Only process NEW meshes since last run — O(batch_size) not O(total)
|
|
212
|
+
for (let i = filteredSourceLenRef.current; i < allMeshes.length; i++) {
|
|
213
|
+
const mesh = allMeshes[i];
|
|
189
214
|
const ifcType = mesh.ifcType;
|
|
190
215
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
if (needsFilter) {
|
|
217
|
+
if (ifcType === 'IfcSpace' && !typeVisibility.spaces) continue;
|
|
218
|
+
if (ifcType === 'IfcOpeningElement' && !typeVisibility.openings) continue;
|
|
219
|
+
if (ifcType === 'IfcSite' && !typeVisibility.site) continue;
|
|
194
220
|
}
|
|
195
|
-
if (ifcType === 'IfcOpeningElement' && !typeVisibility.openings) {
|
|
196
|
-
return false;
|
|
197
|
-
}
|
|
198
|
-
if (ifcType === 'IfcSite' && !typeVisibility.site) {
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return true;
|
|
203
|
-
});
|
|
204
221
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// Create a new color array with reduced opacity
|
|
213
|
-
const newColor: [number, number, number, number] = [
|
|
214
|
-
mesh.color[0],
|
|
215
|
-
mesh.color[1],
|
|
216
|
-
mesh.color[2],
|
|
217
|
-
Math.min(mesh.color[3] * 0.3, 0.3), // Semi-transparent (30% opacity max)
|
|
218
|
-
];
|
|
219
|
-
return { ...mesh, color: newColor };
|
|
222
|
+
if (ifcType === 'IfcSpace' || ifcType === 'IfcOpeningElement') {
|
|
223
|
+
cache.push({
|
|
224
|
+
...mesh,
|
|
225
|
+
color: [mesh.color[0], mesh.color[1], mesh.color[2], Math.min(mesh.color[3] * 0.3, 0.3)],
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
cache.push(mesh);
|
|
220
229
|
}
|
|
230
|
+
}
|
|
221
231
|
|
|
222
|
-
|
|
223
|
-
});
|
|
232
|
+
filteredSourceLenRef.current = allMeshes.length;
|
|
224
233
|
|
|
225
|
-
|
|
234
|
+
// Only bump version when cache content actually changed — avoids
|
|
235
|
+
// unnecessary downstream re-renders when memo runs with same data.
|
|
236
|
+
if (cache.length !== prevCacheLen || typeVisChanged) {
|
|
237
|
+
filteredVersionRef.current++;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Return the same array reference — downstream change detection uses
|
|
241
|
+
// geometryVersion (which increments each batch) instead of array identity.
|
|
242
|
+
return cache;
|
|
226
243
|
}, [mergedGeometryResult, typeVisibility]);
|
|
227
244
|
|
|
245
|
+
// Version counter that changes every batch — triggers useGeometryStreaming
|
|
246
|
+
// without requiring a new geometry array reference.
|
|
247
|
+
const geometryVersion = filteredVersionRef.current;
|
|
248
|
+
|
|
228
249
|
// Compute combined isolation set (storeys + manual isolation)
|
|
229
250
|
// This is passed to the renderer for batch-level visibility filtering
|
|
230
251
|
// Now supports multi-model: aggregates elements from all models for selected storeys
|
|
@@ -580,6 +601,7 @@ export function ViewportContainer() {
|
|
|
580
601
|
|
|
581
602
|
<Viewport
|
|
582
603
|
geometry={filteredGeometry}
|
|
604
|
+
geometryVersion={geometryVersion}
|
|
583
605
|
coordinateInfo={mergedGeometryResult?.coordinateInfo}
|
|
584
606
|
computedIsolatedIds={computedIsolatedIds}
|
|
585
607
|
modelIdToIndex={modelIdToIndex}
|
|
@@ -16,7 +16,7 @@ import { goHomeFromStore } from '@/store/homeView';
|
|
|
16
16
|
import { useIfc } from '@/hooks/useIfc';
|
|
17
17
|
import { cn } from '@/lib/utils';
|
|
18
18
|
import { ViewCube, type ViewCubeRef } from './ViewCube';
|
|
19
|
-
import { AxisHelper } from './AxisHelper';
|
|
19
|
+
import { AxisHelper, type AxisHelperRef } from './AxisHelper';
|
|
20
20
|
|
|
21
21
|
export function ViewportOverlays() {
|
|
22
22
|
const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
|
|
@@ -31,6 +31,7 @@ export function ViewportOverlays() {
|
|
|
31
31
|
// Use refs for rotation to avoid re-renders - ViewCube updates itself directly
|
|
32
32
|
const cameraRotationRef = useRef({ azimuth: 45, elevation: 25 });
|
|
33
33
|
const viewCubeRef = useRef<ViewCubeRef | null>(null);
|
|
34
|
+
const axisHelperRef = useRef<AxisHelperRef | null>(null);
|
|
34
35
|
|
|
35
36
|
// Local state for scale - updated via callback, no global re-renders
|
|
36
37
|
const [scale, setScale] = useState(10);
|
|
@@ -41,11 +42,10 @@ export function ViewportOverlays() {
|
|
|
41
42
|
const handleRotationChange = (rotation: { azimuth: number; elevation: number }) => {
|
|
42
43
|
cameraRotationRef.current = rotation;
|
|
43
44
|
// Update ViewCube directly via ref (no React re-render)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
45
|
+
const viewCubeRotationX = -rotation.elevation;
|
|
46
|
+
const viewCubeRotationY = -rotation.azimuth;
|
|
47
|
+
viewCubeRef.current?.updateRotation(viewCubeRotationX, viewCubeRotationY);
|
|
48
|
+
axisHelperRef.current?.updateRotation(viewCubeRotationX, viewCubeRotationY);
|
|
49
49
|
};
|
|
50
50
|
setOnCameraRotationChange(handleRotationChange);
|
|
51
51
|
return () => setOnCameraRotationChange(null);
|
|
@@ -193,6 +193,7 @@ export function ViewportOverlays() {
|
|
|
193
193
|
{/* Axis Helper (bottom-left, above scale bar) - IFC Z-up convention */}
|
|
194
194
|
<div className="absolute bottom-16 left-4">
|
|
195
195
|
<AxisHelper
|
|
196
|
+
ref={axisHelperRef}
|
|
196
197
|
rotationX={initialRotationX}
|
|
197
198
|
rotationY={initialRotationY}
|
|
198
199
|
/>
|
|
@@ -24,6 +24,7 @@ import { isSpatialContainer } from './types';
|
|
|
24
24
|
const TYPE_ICONS: Record<string, React.ElementType> = {
|
|
25
25
|
'unified-storey': Layers,
|
|
26
26
|
'model-header': FileBox,
|
|
27
|
+
'ifc-type': Building2,
|
|
27
28
|
IfcProject: FolderKanban,
|
|
28
29
|
IfcSite: MapPin,
|
|
29
30
|
IfcBuilding: Building2,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
|
-
import { IfcTypeEnum, type SpatialNode } from '@ifc-lite/data';
|
|
5
|
+
import { IfcTypeEnum, EntityFlags, RelationshipType, type SpatialNode } from '@ifc-lite/data';
|
|
6
6
|
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
7
7
|
import type { FederatedModel } from '@/store';
|
|
8
8
|
import type { TreeNode, NodeType, StoreyData, UnifiedStorey } from './types';
|
|
@@ -433,6 +433,158 @@ export function buildTypeTree(
|
|
|
433
433
|
return nodes;
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
+
/** Build tree data grouped by IFC type entities (IfcWallType, IfcDoorType, etc.).
|
|
437
|
+
* Shows each type entity as a parent node with its typed instances (occurrences) as children.
|
|
438
|
+
* Uses IfcRelDefinesByType relationships to find type→occurrence mappings.
|
|
439
|
+
* Entities without a type are grouped under an "Untyped" section per IFC class. */
|
|
440
|
+
export function buildIfcTypeTree(
|
|
441
|
+
models: Map<string, FederatedModel>,
|
|
442
|
+
ifcDataStore: IfcDataStore | null | undefined,
|
|
443
|
+
expandedNodes: Set<string>,
|
|
444
|
+
isMultiModel: boolean,
|
|
445
|
+
geometricIds?: Set<number>,
|
|
446
|
+
): TreeNode[] {
|
|
447
|
+
// Collect type entities and their typed instances
|
|
448
|
+
interface TypeEntry {
|
|
449
|
+
typeExpressId: number;
|
|
450
|
+
typeName: string; // e.g. "W01"
|
|
451
|
+
typeClassName: string; // e.g. "IfcWallType"
|
|
452
|
+
modelId: string;
|
|
453
|
+
globalId: number;
|
|
454
|
+
instances: Array<{ expressId: number; globalId: number; name: string; modelId: string }>;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Group by type class name (e.g. "IfcWallType") → individual types
|
|
458
|
+
const typeClassGroups = new Map<string, TypeEntry[]>();
|
|
459
|
+
|
|
460
|
+
const processDataStore = (dataStore: IfcDataStore, modelId: string, idOffset: number) => {
|
|
461
|
+
if (!dataStore.relationships) return;
|
|
462
|
+
|
|
463
|
+
// Find all type entities (entities with IS_TYPE flag)
|
|
464
|
+
for (let i = 0; i < dataStore.entities.count; i++) {
|
|
465
|
+
const flags = dataStore.entities.flags[i];
|
|
466
|
+
if (!(flags & EntityFlags.IS_TYPE)) continue;
|
|
467
|
+
|
|
468
|
+
const expressId = dataStore.entities.expressId[i];
|
|
469
|
+
const typeClassName = dataStore.entities.getTypeName(expressId);
|
|
470
|
+
|
|
471
|
+
// Skip relationship entities and non-product types
|
|
472
|
+
if (typeClassName.startsWith('IfcRel') || typeClassName === 'Unknown') continue;
|
|
473
|
+
const typeName = dataStore.entities.getName(expressId) || `#${expressId}`;
|
|
474
|
+
|
|
475
|
+
// Get instances via DefinesByType (forward: type → occurrences)
|
|
476
|
+
const instanceIds = dataStore.relationships.getRelated(expressId, RelationshipType.DefinesByType, 'forward');
|
|
477
|
+
const instances: TypeEntry['instances'] = [];
|
|
478
|
+
|
|
479
|
+
for (const instId of instanceIds) {
|
|
480
|
+
const instGlobalId = instId + idOffset;
|
|
481
|
+
if (geometricIds && geometricIds.size > 0 && !geometricIds.has(instGlobalId)) continue;
|
|
482
|
+
const instName = dataStore.entities.getName(instId) || `#${instId}`;
|
|
483
|
+
instances.push({ expressId: instId, globalId: instGlobalId, name: instName, modelId });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const entry: TypeEntry = {
|
|
487
|
+
typeExpressId: expressId,
|
|
488
|
+
typeName,
|
|
489
|
+
typeClassName,
|
|
490
|
+
modelId,
|
|
491
|
+
globalId: expressId + idOffset,
|
|
492
|
+
instances,
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
if (!typeClassGroups.has(typeClassName)) {
|
|
496
|
+
typeClassGroups.set(typeClassName, []);
|
|
497
|
+
}
|
|
498
|
+
typeClassGroups.get(typeClassName)!.push(entry);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
if (models.size > 0) {
|
|
503
|
+
for (const [modelId, model] of models) {
|
|
504
|
+
if (model.ifcDataStore) {
|
|
505
|
+
processDataStore(model.ifcDataStore, modelId, model.idOffset ?? 0);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} else if (ifcDataStore) {
|
|
509
|
+
processDataStore(ifcDataStore, 'legacy', 0);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const nodes: TreeNode[] = [];
|
|
513
|
+
|
|
514
|
+
// Sort type class groups alphabetically
|
|
515
|
+
const sortedClassNames = Array.from(typeClassGroups.keys()).sort();
|
|
516
|
+
|
|
517
|
+
for (const className of sortedClassNames) {
|
|
518
|
+
const types = typeClassGroups.get(className)!;
|
|
519
|
+
const classNodeId = `typeclass-${className}`;
|
|
520
|
+
const isClassExpanded = expandedNodes.has(classNodeId);
|
|
521
|
+
|
|
522
|
+
// Total instances across all types in this class
|
|
523
|
+
const totalInstances = types.reduce((sum, t) => sum + t.instances.length, 0);
|
|
524
|
+
// Collect all instance globalIds for visibility/isolation
|
|
525
|
+
const allInstanceGlobalIds = types.flatMap(t => t.instances.map(i => i.globalId));
|
|
526
|
+
|
|
527
|
+
nodes.push({
|
|
528
|
+
id: classNodeId,
|
|
529
|
+
expressIds: allInstanceGlobalIds,
|
|
530
|
+
modelIds: [],
|
|
531
|
+
name: className,
|
|
532
|
+
type: 'type-group',
|
|
533
|
+
depth: 0,
|
|
534
|
+
hasChildren: types.length > 0,
|
|
535
|
+
isExpanded: isClassExpanded,
|
|
536
|
+
isVisible: true,
|
|
537
|
+
elementCount: totalInstances,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
if (isClassExpanded) {
|
|
541
|
+
// Sort types by name
|
|
542
|
+
types.sort((a, b) => a.typeName.localeCompare(b.typeName));
|
|
543
|
+
|
|
544
|
+
for (const typeEntry of types) {
|
|
545
|
+
const typeNodeId = `ifctype-${typeEntry.modelId}-${typeEntry.typeExpressId}`;
|
|
546
|
+
const isTypeExpanded = expandedNodes.has(typeNodeId);
|
|
547
|
+
const instanceGlobalIds = typeEntry.instances.map(i => i.globalId);
|
|
548
|
+
const suffix = isMultiModel ? ` [${models.get(typeEntry.modelId)?.name || typeEntry.modelId}]` : '';
|
|
549
|
+
|
|
550
|
+
nodes.push({
|
|
551
|
+
id: typeNodeId,
|
|
552
|
+
expressIds: instanceGlobalIds,
|
|
553
|
+
entityExpressId: typeEntry.typeExpressId,
|
|
554
|
+
modelIds: [typeEntry.modelId],
|
|
555
|
+
name: `${typeEntry.typeName}${suffix}`,
|
|
556
|
+
type: 'ifc-type',
|
|
557
|
+
depth: 1,
|
|
558
|
+
hasChildren: typeEntry.instances.length > 0,
|
|
559
|
+
isExpanded: isTypeExpanded,
|
|
560
|
+
isVisible: true,
|
|
561
|
+
elementCount: typeEntry.instances.length,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (isTypeExpanded) {
|
|
565
|
+
typeEntry.instances.sort((a, b) => a.name.localeCompare(b.name));
|
|
566
|
+
for (const inst of typeEntry.instances) {
|
|
567
|
+
const instSuffix = isMultiModel ? ` [${models.get(inst.modelId)?.name || inst.modelId}]` : '';
|
|
568
|
+
nodes.push({
|
|
569
|
+
id: `element-${inst.modelId}-${inst.expressId}`,
|
|
570
|
+
expressIds: [inst.globalId],
|
|
571
|
+
modelIds: [inst.modelId],
|
|
572
|
+
name: inst.name + instSuffix,
|
|
573
|
+
type: 'element',
|
|
574
|
+
depth: 2,
|
|
575
|
+
hasChildren: false,
|
|
576
|
+
isExpanded: false,
|
|
577
|
+
isVisible: true,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return nodes;
|
|
586
|
+
}
|
|
587
|
+
|
|
436
588
|
/** Filter nodes based on search query */
|
|
437
589
|
export function filterNodes(nodes: TreeNode[], searchQuery: string): TreeNode[] {
|
|
438
590
|
if (!searchQuery.trim()) return nodes;
|
|
@@ -11,12 +11,15 @@ export type NodeType =
|
|
|
11
11
|
| 'IfcBuilding' // Building node
|
|
12
12
|
| 'IfcBuildingStorey' // Storey node
|
|
13
13
|
| 'type-group' // IFC class grouping header (e.g., "IfcWall (47)")
|
|
14
|
+
| 'ifc-type' // IFC type entity node (e.g., "IfcWallType/W01")
|
|
14
15
|
| 'element'; // Individual element
|
|
15
16
|
|
|
16
17
|
export interface TreeNode {
|
|
17
18
|
id: string; // Unique ID for the node (can be composite)
|
|
18
19
|
/** Express IDs this node represents (for elements/storeys) */
|
|
19
20
|
expressIds: number[];
|
|
21
|
+
/** Structured entity expressId for selectable non-element nodes (for example IFC type entities) */
|
|
22
|
+
entityExpressId?: number;
|
|
20
23
|
/** Model IDs this node belongs to */
|
|
21
24
|
modelIds: string[];
|
|
22
25
|
name: string;
|
|
@@ -12,11 +12,12 @@ import {
|
|
|
12
12
|
getUnifiedStoreyElements as getUnifiedStoreyElementsFn,
|
|
13
13
|
buildTreeData,
|
|
14
14
|
buildTypeTree,
|
|
15
|
+
buildIfcTypeTree,
|
|
15
16
|
filterNodes,
|
|
16
17
|
splitNodes,
|
|
17
18
|
} from './treeDataBuilder';
|
|
18
19
|
|
|
19
|
-
export type GroupingMode = 'spatial' | 'type';
|
|
20
|
+
export type GroupingMode = 'spatial' | 'type' | 'ifc-type';
|
|
20
21
|
|
|
21
22
|
interface UseHierarchyTreeParams {
|
|
22
23
|
models: Map<string, FederatedModel>;
|
|
@@ -164,11 +165,15 @@ export function useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryR
|
|
|
164
165
|
return geometryResult?.meshes.length ?? 0;
|
|
165
166
|
}, [models, geometryResult?.meshes.length]);
|
|
166
167
|
|
|
167
|
-
// Pre-computed set of global IDs with geometry — stable across color changes
|
|
168
|
+
// Pre-computed set of global IDs with geometry — stable across color changes.
|
|
169
|
+
// PERF: Skip when no geometry source exists (during initial streaming before
|
|
170
|
+
// any data is ready). Gate on models OR ifcDataStore so federated scenarios
|
|
171
|
+
// (models.size > 0 but ifcDataStore is null) still build the set correctly.
|
|
172
|
+
const hasGeometrySource = models.size > 0 || !!ifcDataStore;
|
|
168
173
|
const geometricIds = useMemo(
|
|
169
|
-
() => buildGeometricIdSet(models, geometryResult),
|
|
170
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- meshCount is a stable proxy
|
|
171
|
-
[models, meshCount]
|
|
174
|
+
() => hasGeometrySource ? buildGeometricIdSet(models, geometryResult) : new Set<number>(),
|
|
175
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- meshCount is a stable proxy; hasGeometrySource gates streaming
|
|
176
|
+
[models, hasGeometrySource ? meshCount : 0]
|
|
172
177
|
);
|
|
173
178
|
|
|
174
179
|
// Build the tree data structure based on grouping mode
|
|
@@ -178,6 +183,9 @@ export function useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryR
|
|
|
178
183
|
if (groupingMode === 'type') {
|
|
179
184
|
return buildTypeTree(models, ifcDataStore, expandedNodes, isMultiModel, geometricIds);
|
|
180
185
|
}
|
|
186
|
+
if (groupingMode === 'ifc-type') {
|
|
187
|
+
return buildIfcTypeTree(models, ifcDataStore, expandedNodes, isMultiModel, geometricIds);
|
|
188
|
+
}
|
|
181
189
|
return buildTreeData(models, ifcDataStore, expandedNodes, isMultiModel, unifiedStoreys);
|
|
182
190
|
},
|
|
183
191
|
[models, ifcDataStore, expandedNodes, isMultiModel, unifiedStoreys, groupingMode, geometricIds]
|
|
@@ -207,9 +215,9 @@ export function useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryR
|
|
|
207
215
|
});
|
|
208
216
|
}, []);
|
|
209
217
|
|
|
210
|
-
// Get all elements for a node (handles type groups, unified storeys, single storeys, model contributions, and elements)
|
|
218
|
+
// Get all elements for a node (handles type groups, ifc-type, unified storeys, single storeys, model contributions, and elements)
|
|
211
219
|
const getNodeElements = useCallback((node: TreeNode): number[] => {
|
|
212
|
-
if (node.type === 'type-group') {
|
|
220
|
+
if (node.type === 'type-group' || node.type === 'ifc-type') {
|
|
213
221
|
// GlobalIds are pre-stored on the node during tree construction — O(1)
|
|
214
222
|
return node.expressIds;
|
|
215
223
|
}
|
|
@@ -16,7 +16,7 @@ import { Button } from '@/components/ui/button';
|
|
|
16
16
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
17
17
|
import { Badge } from '@/components/ui/badge';
|
|
18
18
|
import { useViewerStore } from '@/store';
|
|
19
|
-
import { PropertyValueType, QuantityType } from '@ifc-lite/data';
|
|
19
|
+
import { type PropertyValue, PropertyValueType, QuantityType } from '@ifc-lite/data';
|
|
20
20
|
import {
|
|
21
21
|
fetchClassInfo,
|
|
22
22
|
bsddDataTypeLabel,
|
|
@@ -59,7 +59,7 @@ function toPropertyValueType(bsddType: string | null): PropertyValueType {
|
|
|
59
59
|
return PropertyValueType.Label;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function defaultValue(_bsddType: string | null):
|
|
62
|
+
function defaultValue(_bsddType: string | null): PropertyValue {
|
|
63
63
|
// Always return empty string – user fills in values manually
|
|
64
64
|
return '';
|
|
65
65
|
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* Property set display component with edit support.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { Sparkles, PenLine } from 'lucide-react';
|
|
10
|
-
import { PropertyEditor } from '../PropertyEditor';
|
|
9
|
+
import { Sparkles, PenLine, Building2 } from 'lucide-react';
|
|
10
|
+
import { PropertyEditor, type PropertyEditScope } from '../PropertyEditor';
|
|
11
11
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
12
12
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
13
13
|
import { Badge } from '@/components/ui/badge';
|
|
@@ -19,24 +19,31 @@ export interface PropertySetCardProps {
|
|
|
19
19
|
modelId?: string;
|
|
20
20
|
entityId?: number;
|
|
21
21
|
enableEditing?: boolean;
|
|
22
|
+
/** Whether this property set is inherited from the type entity */
|
|
23
|
+
isTypeProperty?: boolean;
|
|
24
|
+
typeEditScope?: PropertyEditScope;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
export function PropertySetCard({ pset, modelId, entityId, enableEditing }: PropertySetCardProps) {
|
|
27
|
+
export function PropertySetCard({ pset, modelId, entityId, enableEditing, isTypeProperty, typeEditScope }: PropertySetCardProps) {
|
|
25
28
|
// Check if any property in this set is mutated
|
|
26
29
|
const hasMutations = pset.properties.some(p => p.isMutated);
|
|
27
30
|
const isNewPset = pset.isNewPset;
|
|
28
31
|
|
|
29
|
-
// Dynamic styling based on mutation state
|
|
32
|
+
// Dynamic styling based on mutation state and source
|
|
30
33
|
const borderClass = isNewPset
|
|
31
34
|
? 'border-2 border-amber-400/50 dark:border-amber-500/30'
|
|
32
35
|
: hasMutations
|
|
33
36
|
? 'border-2 border-purple-300/50 dark:border-purple-500/30'
|
|
37
|
+
: isTypeProperty
|
|
38
|
+
? 'border-2 border-indigo-200/60 dark:border-indigo-800/40'
|
|
34
39
|
: 'border-2 border-zinc-200 dark:border-zinc-800';
|
|
35
40
|
|
|
36
41
|
const bgClass = isNewPset
|
|
37
42
|
? 'bg-amber-50/30 dark:bg-amber-950/20'
|
|
38
43
|
: hasMutations
|
|
39
44
|
? 'bg-purple-50/20 dark:bg-purple-950/10'
|
|
45
|
+
: isTypeProperty
|
|
46
|
+
? 'bg-indigo-50/20 dark:bg-indigo-950/10'
|
|
40
47
|
: 'bg-white dark:bg-zinc-950';
|
|
41
48
|
|
|
42
49
|
return (
|
|
@@ -58,6 +65,14 @@ export function PropertySetCard({ pset, modelId, entityId, enableEditing }: Prop
|
|
|
58
65
|
<TooltipContent>Has modified properties</TooltipContent>
|
|
59
66
|
</Tooltip>
|
|
60
67
|
)}
|
|
68
|
+
{isTypeProperty && !isNewPset && !hasMutations && (
|
|
69
|
+
<Tooltip>
|
|
70
|
+
<TooltipTrigger asChild>
|
|
71
|
+
<Building2 className="h-3.5 w-3.5 text-indigo-400 shrink-0" />
|
|
72
|
+
</TooltipTrigger>
|
|
73
|
+
<TooltipContent>Inherited from type — edits apply to all instances of this type</TooltipContent>
|
|
74
|
+
</Tooltip>
|
|
75
|
+
)}
|
|
61
76
|
<span className="font-bold text-xs text-zinc-900 dark:text-zinc-100 truncate flex-1 min-w-0">{decodeIfcString(pset.name)}</span>
|
|
62
77
|
<span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 border border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400 shrink-0">{pset.properties.length}</span>
|
|
63
78
|
</CollapsibleTrigger>
|
|
@@ -115,6 +130,7 @@ export function PropertySetCard({ pset, modelId, entityId, enableEditing }: Prop
|
|
|
115
130
|
psetName={pset.name}
|
|
116
131
|
propName={prop.name}
|
|
117
132
|
currentValue={prop.value}
|
|
133
|
+
editScope={typeEditScope}
|
|
118
134
|
/>
|
|
119
135
|
) : (
|
|
120
136
|
<span className={`font-mono select-all break-words ${isMutated ? 'text-purple-900 dark:text-purple-100 font-semibold' : 'text-zinc-900 dark:text-zinc-100'}`}>
|
|
@@ -21,6 +21,8 @@ export interface PropertySet {
|
|
|
21
21
|
name: string;
|
|
22
22
|
properties: Array<{ name: string; value: unknown; isMutated?: boolean }>;
|
|
23
23
|
isNewPset?: boolean;
|
|
24
|
+
/** Where this property set originates from: 'instance' (occurrence) or 'type' (inherited from IfcTypeObject) */
|
|
25
|
+
source?: 'instance' | 'type';
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export interface QuantitySet {
|