@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/assets/{Arrow.dom-CNguvlQi.js → Arrow.dom-CSgnLhN4.js} +1 -1
  3. package/dist/assets/{browser-D6lgLpkA.js → browser-qSKWrKQW.js} +1 -1
  4. package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
  5. package/dist/assets/{index-UaDsJsCR.js → index-4Y4XaV8N.js} +22043 -21152
  6. package/dist/assets/index-ByrFvN5A.css +1 -0
  7. package/dist/assets/{index-BMwpw264.js → index-CN7qDq7G.js} +4 -4
  8. package/dist/assets/{native-bridge-DqELq4X0.js → native-bridge-CSFDsEkg.js} +1 -1
  9. package/dist/assets/{wasm-bridge-CVWvHlfH.js → wasm-bridge-Zf90ysEm.js} +1 -1
  10. package/dist/index.html +2 -2
  11. package/package.json +19 -19
  12. package/src/components/viewer/AxisHelper.tsx +57 -2
  13. package/src/components/viewer/BulkPropertyEditor.tsx +3 -14
  14. package/src/components/viewer/DataConnector.tsx +3 -14
  15. package/src/components/viewer/ExportChangesButton.tsx +3 -14
  16. package/src/components/viewer/ExportDialog.tsx +3 -14
  17. package/src/components/viewer/HierarchyPanel.tsx +75 -12
  18. package/src/components/viewer/MainToolbar.tsx +51 -18
  19. package/src/components/viewer/PropertiesPanel.tsx +245 -77
  20. package/src/components/viewer/PropertyEditor.tsx +80 -18
  21. package/src/components/viewer/ViewerLayout.tsx +2 -2
  22. package/src/components/viewer/Viewport.tsx +5 -1
  23. package/src/components/viewer/ViewportContainer.tsx +59 -37
  24. package/src/components/viewer/ViewportOverlays.tsx +7 -6
  25. package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -0
  26. package/src/components/viewer/hierarchy/treeDataBuilder.ts +153 -1
  27. package/src/components/viewer/hierarchy/types.ts +3 -0
  28. package/src/components/viewer/hierarchy/useHierarchyTree.ts +15 -7
  29. package/src/components/viewer/properties/BsddCard.tsx +2 -2
  30. package/src/components/viewer/properties/PropertySetCard.tsx +20 -4
  31. package/src/components/viewer/properties/encodingUtils.ts +2 -0
  32. package/src/components/viewer/useGeometryStreaming.ts +189 -55
  33. package/src/components/viewer/useMouseControls.ts +55 -14
  34. package/src/components/viewer/useTouchControls.ts +2 -0
  35. package/src/hooks/useIfc.ts +19 -1
  36. package/src/hooks/useIfcCache.ts +6 -1
  37. package/src/hooks/useIfcFederation.ts +16 -1
  38. package/src/hooks/useIfcLoader.ts +16 -4
  39. package/src/index.css +96 -0
  40. package/src/store/slices/dataSlice.ts +9 -4
  41. package/src/utils/configureMutationView.ts +37 -0
  42. package/src/utils/localParsingUtils.ts +3 -1
  43. package/tsconfig.json +12 -1
  44. package/dist/assets/ifc-lite_bg-B6s-pcv0.wasm +0 -0
  45. 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
- // Filter geometry based on type visibility only
177
- // PERFORMANCE FIX: Don't filter by storey or hiddenEntities here
178
- // Instead, let the renderer handle visibility filtering at the batch level
179
- // This avoids expensive batch rebuilding when visibility changes
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
- let meshes = mergedGeometryResult.meshes;
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
- // Filter by type visibility (spatial elements)
188
- meshes = meshes.filter(mesh => {
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
- // Check type visibility
192
- if (ifcType === 'IfcSpace' && !typeVisibility.spaces) {
193
- return false;
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
- // Apply transparency for spatial elements
206
- meshes = meshes.map(mesh => {
207
- const ifcType = mesh.ifcType;
208
- const isSpace = ifcType === 'IfcSpace';
209
- const isOpening = ifcType === 'IfcOpeningElement';
210
-
211
- if (isSpace || isOpening) {
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
- return mesh;
223
- });
232
+ filteredSourceLenRef.current = allMeshes.length;
224
233
 
225
- return meshes;
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
- if (viewCubeRef.current) {
45
- const viewCubeRotationX = -rotation.elevation;
46
- const viewCubeRotationY = -rotation.azimuth;
47
- viewCubeRef.current.updateRotation(viewCubeRotationX, viewCubeRotationY);
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): unknown {
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 {