@ifc-lite/viewer 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/{Arrow.dom-VW5W1XFO.js → Arrow.dom-CNguvlQi.js} +1 -1
  3. package/dist/assets/{browser-C6mwD6n0.js → browser-D6lgLpkA.js} +1 -1
  4. package/dist/assets/{index-DQE23JyT.js → index-BMwpw264.js} +4 -4
  5. package/dist/assets/index-Qp8stcGO.css +1 -0
  6. package/dist/assets/{index-BzoX4cQC.js → index-UaDsJsCR.js} +24458 -22069
  7. package/dist/assets/{native-bridge-BibEEmFV.js → native-bridge-DqELq4X0.js} +1 -1
  8. package/dist/assets/{wasm-bridge-CYzUd3Io.js → wasm-bridge-CVWvHlfH.js} +1 -1
  9. package/dist/index.html +2 -2
  10. package/package.json +19 -19
  11. package/src/components/viewer/BulkPropertyEditor.tsx +8 -1
  12. package/src/components/viewer/DataConnector.tsx +8 -1
  13. package/src/components/viewer/ExportChangesButton.tsx +8 -1
  14. package/src/components/viewer/ExportDialog.tsx +8 -1
  15. package/src/components/viewer/PropertiesPanel.tsx +209 -15
  16. package/src/components/viewer/properties/BsddCard.tsx +507 -0
  17. package/src/components/viewer/properties/QuantitySetCard.tsx +1 -0
  18. package/src/index.css +7 -0
  19. package/src/lib/scripts/templates/bim-globals.d.ts +33 -0
  20. package/src/lib/scripts/templates/create-building.ts +491 -0
  21. package/src/lib/scripts/templates.ts +8 -0
  22. package/src/sdk/adapters/export-adapter.ts +84 -0
  23. package/src/sdk/adapters/model-adapter.ts +8 -0
  24. package/src/services/bsdd.ts +262 -0
  25. package/src/store/index.ts +2 -2
  26. package/src/store/slices/mutationSlice.ts +155 -1
  27. package/vite.config.ts +7 -0
  28. package/dist/assets/index-Cx134arv.css +0 -1
@@ -1,4 +1,4 @@
1
- import { _ as c, __tla as __tla_0 } from "./index-BzoX4cQC.js";
1
+ import { _ as c, __tla as __tla_0 } from "./index-UaDsJsCR.js";
2
2
  let m;
3
3
  let __tla = Promise.all([
4
4
  (()=>{
@@ -1 +1 @@
1
- import{I as f,a as m}from"./index-BzoX4cQC.js";class u{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}async processGeometry(s){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),s),n=i.collectMeshes(),r=i.getBuildingRotation();performance.now();let e=0,o=0;for(const c of n)e+=c.positions.length/3,o+=c.indices.length/3;return{meshes:n,totalVertices:e,totalTriangles:o,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:r}}}async processGeometryStreaming(s,i){this.initialized||await this.init();const n=performance.now(),r=new m(this.bridge.getApi(),s);let e=0,o=0,a=0;try{for await(const t of r.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;e+=l.length;for(const d of l)o+=d.positions.length/3,a+=d.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:e,total:e,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const h=performance.now()-n,g={totalMeshes:e,totalVertices:o,totalTriangles:a,parseTimeMs:h*.3,geometryTimeMs:h*.7};return i.onComplete?.(g),g}getApi(){return this.bridge.getApi()}}export{u as WasmBridge};
1
+ import{I as f,a as m}from"./index-UaDsJsCR.js";class u{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}async processGeometry(s){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),s),n=i.collectMeshes(),r=i.getBuildingRotation();performance.now();let e=0,o=0;for(const c of n)e+=c.positions.length/3,o+=c.indices.length/3;return{meshes:n,totalVertices:e,totalTriangles:o,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:r}}}async processGeometryStreaming(s,i){this.initialized||await this.init();const n=performance.now(),r=new m(this.bridge.getApi(),s);let e=0,o=0,a=0;try{for await(const t of r.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;e+=l.length;for(const d of l)o+=d.positions.length/3,a+=d.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:e,total:e,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const h=performance.now()-n,g={totalMeshes:e,totalVertices:o,totalTriangles:a,parseTimeMs:h*.3,geometryTimeMs:h*.7};return i.onComplete?.(g),g}getApi(){return this.bridge.getApi()}}export{u as WasmBridge};
package/dist/index.html CHANGED
@@ -44,8 +44,8 @@
44
44
  <meta name="theme-color" content="#7aa2f7">
45
45
  <meta name="msapplication-TileColor" content="#1a1b26">
46
46
  <meta name="msapplication-TileImage" content="/favicon-192x192-cropped.png">
47
- <script type="module" crossorigin src="/assets/index-BzoX4cQC.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-Cx134arv.css">
47
+ <script type="module" crossorigin src="/assets/index-UaDsJsCR.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-Qp8stcGO.css">
49
49
  </head>
50
50
  <body>
51
51
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifc-lite/viewer",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "IFC-Lite viewer application",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -41,24 +41,24 @@
41
41
  "tailwind-merge": "^3.4.0",
42
42
  "tailwindcss": "^4.1.18",
43
43
  "zustand": "^4.4.0",
44
- "@ifc-lite/bcf": "^1.13.0",
45
- "@ifc-lite/cache": "^1.13.0",
46
- "@ifc-lite/data": "^1.13.0",
47
- "@ifc-lite/drawing-2d": "^1.13.0",
48
- "@ifc-lite/encoding": "^1.13.0",
49
- "@ifc-lite/export": "^1.13.0",
50
- "@ifc-lite/geometry": "^1.13.0",
51
- "@ifc-lite/ids": "^1.13.0",
52
- "@ifc-lite/lens": "^1.13.0",
53
- "@ifc-lite/lists": "^1.13.0",
54
- "@ifc-lite/mutations": "^1.13.0",
55
- "@ifc-lite/parser": "^1.13.0",
56
- "@ifc-lite/query": "^1.13.0",
57
- "@ifc-lite/renderer": "^1.13.0",
58
- "@ifc-lite/sandbox": "^1.13.0",
59
- "@ifc-lite/server-client": "^1.13.0",
60
- "@ifc-lite/spatial": "^1.13.0",
61
- "@ifc-lite/wasm": "^1.13.0"
44
+ "@ifc-lite/bcf": "^1.14.0",
45
+ "@ifc-lite/cache": "^1.14.0",
46
+ "@ifc-lite/data": "^1.14.0",
47
+ "@ifc-lite/drawing-2d": "^1.14.0",
48
+ "@ifc-lite/encoding": "^1.14.0",
49
+ "@ifc-lite/export": "^1.14.0",
50
+ "@ifc-lite/geometry": "^1.14.0",
51
+ "@ifc-lite/ids": "^1.14.0",
52
+ "@ifc-lite/lens": "^1.14.0",
53
+ "@ifc-lite/lists": "^1.14.0",
54
+ "@ifc-lite/mutations": "^1.14.0",
55
+ "@ifc-lite/parser": "^1.14.0",
56
+ "@ifc-lite/query": "^1.14.0",
57
+ "@ifc-lite/renderer": "^1.14.0",
58
+ "@ifc-lite/sandbox": "^1.14.0",
59
+ "@ifc-lite/server-client": "^1.14.0",
60
+ "@ifc-lite/spatial": "^1.14.0",
61
+ "@ifc-lite/wasm": "^1.14.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@tailwindcss/postcss": "^4.1.18",
@@ -61,7 +61,7 @@ import {
61
61
  type BulkQueryPreview,
62
62
  type BulkQueryResult,
63
63
  } from '@ifc-lite/mutations';
64
- import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
64
+ import { extractPropertiesOnDemand, extractQuantitiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
65
65
 
66
66
  // Common IFC type enum IDs (from IFC schema)
67
67
  // These correspond to the typeEnum values in EntityTable
@@ -245,6 +245,13 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
245
245
  });
246
246
  }
247
247
 
248
+ // Set up on-demand quantity extraction if the data store supports it
249
+ if (dataStore.onDemandQuantityMap && dataStore.source?.length > 0) {
250
+ mutationView.setQuantityExtractor((entityId: number) => {
251
+ return extractQuantitiesOnDemand(dataStore as IfcDataStore, entityId);
252
+ });
253
+ }
254
+
248
255
  // Register the mutation view
249
256
  registerMutationView(selectedModelId, mutationView);
250
257
  }, [selectedModel, selectedModelId, getMutationView, registerMutationView]);
@@ -71,7 +71,7 @@ import {
71
71
  type MatchResult,
72
72
  type ImportStats,
73
73
  } from '@ifc-lite/mutations';
74
- import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
74
+ import { extractPropertiesOnDemand, extractQuantitiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
75
75
 
76
76
  type MatchType = 'globalId' | 'expressId' | 'name' | 'property';
77
77
 
@@ -187,6 +187,13 @@ export function DataConnector({ trigger }: DataConnectorProps) {
187
187
  });
188
188
  }
189
189
 
190
+ // Set up on-demand quantity extraction if the data store supports it
191
+ if (dataStore.onDemandQuantityMap && dataStore.source?.length > 0) {
192
+ mutationView.setQuantityExtractor((entityId: number) => {
193
+ return extractQuantitiesOnDemand(dataStore as IfcDataStore, entityId);
194
+ });
195
+ }
196
+
190
197
  // Register the mutation view
191
198
  registerMutationView(selectedModelId, mutationView);
192
199
  }, [selectedModel, selectedModelId, getMutationView, registerMutationView]);
@@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
15
15
  import { useViewerStore } from '@/store';
16
16
  import { StepExporter } from '@ifc-lite/export';
17
17
  import { MutablePropertyView } from '@ifc-lite/mutations';
18
- import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
18
+ import { extractPropertiesOnDemand, extractQuantitiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
19
19
  import { toast } from '@/components/ui/toast';
20
20
 
21
21
  interface ExportChangesButtonProps {
@@ -87,6 +87,13 @@ export function ExportChangesButton({ className }: ExportChangesButtonProps) {
87
87
  });
88
88
  }
89
89
 
90
+ // Set up on-demand quantity extraction
91
+ if (dataStore.onDemandQuantityMap && dataStore.source?.length > 0) {
92
+ mutationView.setQuantityExtractor((entityId: number) => {
93
+ return extractQuantitiesOnDemand(dataStore as IfcDataStore, entityId);
94
+ });
95
+ }
96
+
90
97
  registerMutationView(modelInfo.id, mutationView);
91
98
  }, [modelInfo, getMutationView, registerMutationView]);
92
99
 
@@ -52,7 +52,7 @@ import { useViewerStore } from '@/store';
52
52
  import { toast } from '@/components/ui/toast';
53
53
  import { StepExporter, MergedExporter, Ifc5Exporter, type MergeModelInput } from '@ifc-lite/export';
54
54
  import { MutablePropertyView } from '@ifc-lite/mutations';
55
- import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
55
+ import { extractPropertiesOnDemand, extractQuantitiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
56
56
 
57
57
  type ExportScope = 'single' | 'merged';
58
58
  type SchemaVersion = 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5';
@@ -154,6 +154,13 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
154
154
  });
155
155
  }
156
156
 
157
+ // Set up on-demand quantity extraction if the data store supports it
158
+ if (dataStore.onDemandQuantityMap && dataStore.source?.length > 0) {
159
+ mutationView.setQuantityExtractor((entityId: number) => {
160
+ return extractQuantitiesOnDemand(dataStore as IfcDataStore, entityId);
161
+ });
162
+ }
163
+
157
164
  // Register the mutation view
158
165
  registerMutationView(selectedModelId, mutationView);
159
166
  }, [selectedModel, selectedModelId, getMutationView, registerMutationView]);
@@ -30,7 +30,7 @@ import { useViewerStore } from '@/store';
30
30
  import { useIfc } from '@/hooks/useIfc';
31
31
  import { IfcQuery } from '@ifc-lite/query';
32
32
  import { MutablePropertyView } from '@ifc-lite/mutations';
33
- import { extractPropertiesOnDemand, extractClassificationsOnDemand, extractMaterialsOnDemand, extractTypePropertiesOnDemand, extractDocumentsOnDemand, extractRelationshipsOnDemand, type IfcDataStore } from '@ifc-lite/parser';
33
+ import { extractPropertiesOnDemand, extractQuantitiesOnDemand, extractClassificationsOnDemand, extractMaterialsOnDemand, extractTypePropertiesOnDemand, extractDocumentsOnDemand, extractRelationshipsOnDemand, type IfcDataStore } from '@ifc-lite/parser';
34
34
  import type { EntityRef, FederatedModel } from '@/store/types';
35
35
 
36
36
  import { CoordVal, CoordRow } from './properties/CoordinateDisplay';
@@ -42,6 +42,7 @@ import { MaterialCard } from './properties/MaterialCard';
42
42
  import { DocumentCard } from './properties/DocumentCard';
43
43
  import { RelationshipsCard } from './properties/RelationshipsCard';
44
44
  import type { PropertySet, QuantitySet } from './properties/encodingUtils';
45
+ import { BsddCard } from './properties/BsddCard';
45
46
 
46
47
  export function PropertiesPanel() {
47
48
  const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
@@ -97,6 +98,13 @@ export function PropertiesPanel() {
97
98
  });
98
99
  }
99
100
 
101
+ // Set up on-demand quantity extractor if available
102
+ if (dataStore.onDemandQuantityMap && dataStore.source?.length > 0) {
103
+ mutationView.setQuantityExtractor((entityId: number) => {
104
+ return extractQuantitiesOnDemand(dataStore as IfcDataStore, entityId);
105
+ });
106
+ }
107
+
100
108
  registerMutationView(modelId, mutationView);
101
109
  }, [model, selectedEntity, getMutationView, registerMutationView]);
102
110
 
@@ -367,17 +375,56 @@ export function PropertiesPanel() {
367
375
  }, [entityNode, selectedEntity, mutationViews, mutationVersion]);
368
376
 
369
377
  const quantities: QuantitySet[] = useMemo(() => {
378
+ let modelId = selectedEntity?.modelId;
379
+ const expressId = selectedEntity?.expressId;
380
+
381
+ if (modelId === 'legacy') modelId = '__legacy__';
382
+
383
+ // Try mutation view first to include added quantities from bSDD
384
+ const mutationView = modelId ? mutationViews.get(modelId) : null;
385
+ if (mutationView && expressId) {
386
+ const merged = mutationView.getQuantitiesForEntity(expressId);
387
+ if (merged.length > 0) return merged;
388
+ }
389
+
390
+ // Fallback to entity node quantities
370
391
  if (!entityNode) return [];
371
392
  return entityNode.quantities();
372
- }, [entityNode]);
393
+ }, [entityNode, selectedEntity, mutationViews, mutationVersion]);
373
394
 
374
395
  // Build attributes array for display - must be before early return to maintain hook order
375
396
  // Uses schema-aware extraction to show ALL string/enum attributes for the entity type.
397
+ // Merges mutated attributes (from bSDD) into the base attribute list.
376
398
  // Note: GlobalId is intentionally excluded since it's shown in the dedicated GUID field above
377
399
  const attributes = useMemo(() => {
378
- if (!entityNode) return [];
379
- return entityNode.allAttributes();
380
- }, [entityNode]);
400
+ const base = entityNode ? entityNode.allAttributes() : [];
401
+
402
+ // Merge mutated attributes from bSDD
403
+ let modelId = selectedEntity?.modelId;
404
+ const expressId = selectedEntity?.expressId;
405
+ if (modelId === 'legacy') modelId = '__legacy__';
406
+ const mutationView = modelId ? mutationViews.get(modelId) : null;
407
+ if (mutationView && expressId) {
408
+ const mutatedAttrs = mutationView.getAttributeMutationsForEntity(expressId);
409
+ if (mutatedAttrs.length > 0) {
410
+ const baseNames = new Set(base.map(a => a.name));
411
+ const merged = [...base];
412
+ for (const ma of mutatedAttrs) {
413
+ if (baseNames.has(ma.name)) {
414
+ // Update existing attribute value
415
+ const idx = merged.findIndex(a => a.name === ma.name);
416
+ if (idx >= 0) merged[idx] = { name: ma.name, value: ma.value };
417
+ } else {
418
+ // Add new attribute
419
+ merged.push({ name: ma.name, value: ma.value });
420
+ }
421
+ }
422
+ return merged;
423
+ }
424
+ }
425
+
426
+ return base;
427
+ }, [entityNode, selectedEntity, mutationViews, mutationVersion]);
381
428
 
382
429
  // Extract classifications for the selected entity from the IFC data store
383
430
  const classifications = useMemo(() => {
@@ -545,6 +592,37 @@ export function PropertiesPanel() {
545
592
  return result;
546
593
  }, [properties, typeProperties]);
547
594
 
595
+ // Build a set of existing property keys ("PsetName:PropName") for bSDD deduplication
596
+ const existingProps = useMemo(() => {
597
+ const keys = new Set<string>();
598
+ for (const pset of mergedProperties) {
599
+ for (const prop of pset.properties) {
600
+ keys.add(`${pset.name}:${prop.name}`);
601
+ }
602
+ }
603
+ return keys;
604
+ }, [mergedProperties]);
605
+
606
+ // Build a set of existing quantity keys ("QsetName:QuantName") for bSDD deduplication
607
+ const existingQuants = useMemo(() => {
608
+ const keys = new Set<string>();
609
+ for (const qset of quantities) {
610
+ for (const q of qset.quantities) {
611
+ keys.add(`${qset.name}:${q.name}`);
612
+ }
613
+ }
614
+ return keys;
615
+ }, [quantities]);
616
+
617
+ // Build a set of existing attribute names for bSDD deduplication
618
+ const existingAttributeNames = useMemo(() => {
619
+ const names = new Set<string>();
620
+ for (const attr of attributes) {
621
+ if (attr.value) names.add(attr.name);
622
+ }
623
+ return names;
624
+ }, [attributes]);
625
+
548
626
  // Model metadata display (when clicking top-level model in hierarchy)
549
627
  if (selectedModelId) {
550
628
  const selectedModel = models.get(selectedModelId);
@@ -795,6 +873,7 @@ export function PropertiesPanel() {
795
873
  <CollapsibleTrigger className="flex items-center gap-2 w-full p-3 hover:bg-muted/50 text-left">
796
874
  <Tag className="h-4 w-4 text-muted-foreground" />
797
875
  <span className="font-medium text-sm">Attributes</span>
876
+ {editMode && <PenLine className="h-3 w-3 text-purple-500 ml-1" />}
798
877
  <span className="text-xs text-muted-foreground ml-auto">{attributes.length}</span>
799
878
  </CollapsibleTrigger>
800
879
  <CollapsibleContent>
@@ -802,11 +881,20 @@ export function PropertiesPanel() {
802
881
  {attributes.map((attr) => (
803
882
  <div key={attr.name} className="grid grid-cols-[minmax(80px,1fr)_minmax(0,2fr)] gap-2 px-3 py-1.5 text-sm">
804
883
  <span className="text-muted-foreground truncate" title={attr.name}>{attr.name}</span>
805
- <div className="overflow-x-auto scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 min-w-0">
806
- <span className="font-medium whitespace-nowrap" title={attr.value}>
807
- {attr.value}
808
- </span>
809
- </div>
884
+ {editMode && selectedEntity ? (
885
+ <AttributeEditorField
886
+ modelId={selectedEntity.modelId}
887
+ entityId={selectedEntity.expressId}
888
+ attrName={attr.name}
889
+ currentValue={attr.value}
890
+ />
891
+ ) : (
892
+ <div className="overflow-x-auto scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 min-w-0">
893
+ <span className="font-medium whitespace-nowrap" title={attr.value}>
894
+ {attr.value}
895
+ </span>
896
+ </div>
897
+ )}
810
898
  </div>
811
899
  ))}
812
900
  </div>
@@ -837,21 +925,25 @@ export function PropertiesPanel() {
837
925
 
838
926
  {/* Tabs */}
839
927
  <Tabs defaultValue="properties" className="flex-1 flex flex-col overflow-hidden">
840
- <TabsList className="tabs-list w-full justify-start rounded-none h-10 p-0" style={{ backgroundColor: 'var(--tabs-bg)', borderBottom: '1px solid var(--tabs-border)' }}>
928
+ <TabsList className="tabs-list w-full justify-start rounded-none h-9 p-0 shrink-0 flex" style={{ backgroundColor: 'var(--tabs-bg)', borderBottom: '1px solid var(--tabs-border)' }}>
841
929
  <TabsTrigger
842
930
  value="properties"
843
- className="tab-trigger flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary uppercase text-xs tracking-wider h-full"
931
+ className="tab-trigger flex-1 min-w-0 rounded-none border-b-2 border-transparent data-[state=active]:border-primary uppercase text-[11px] tracking-wide h-full px-2"
844
932
  >
845
- <FileText className="h-3.5 w-3.5 mr-2" />
846
933
  Properties
847
934
  </TabsTrigger>
848
935
  <TabsTrigger
849
936
  value="quantities"
850
- className="tab-trigger flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary uppercase text-xs tracking-wider h-full"
937
+ className="tab-trigger flex-1 min-w-0 rounded-none border-b-2 border-transparent data-[state=active]:border-primary uppercase text-[11px] tracking-wide h-full px-2"
851
938
  >
852
- <Calculator className="h-3.5 w-3.5 mr-2" />
853
939
  Quantities
854
940
  </TabsTrigger>
941
+ <TabsTrigger
942
+ value="bsdd"
943
+ className="tab-trigger flex-1 min-w-0 rounded-none border-b-2 border-transparent data-[state=active]:border-primary uppercase text-[11px] tracking-wide h-full px-2"
944
+ >
945
+ bSDD
946
+ </TabsTrigger>
855
947
  </TabsList>
856
948
 
857
949
  <ScrollArea className="flex-1 bg-white dark:bg-black">
@@ -944,12 +1036,114 @@ export function PropertiesPanel() {
944
1036
  </div>
945
1037
  )}
946
1038
  </TabsContent>
1039
+
1040
+ <TabsContent value="bsdd" className="m-0 p-3 overflow-hidden">
1041
+ {selectedEntity && (
1042
+ <BsddCard
1043
+ entityType={entityType}
1044
+ modelId={selectedEntity.modelId}
1045
+ entityId={selectedEntity.expressId}
1046
+ existingPsets={mergedProperties.map(p => p.name)}
1047
+ existingProps={existingProps}
1048
+ existingQsets={quantities.map(q => q.name)}
1049
+ existingQuants={existingQuants}
1050
+ existingAttributes={existingAttributeNames}
1051
+ />
1052
+ )}
1053
+ </TabsContent>
947
1054
  </ScrollArea>
948
1055
  </Tabs>
949
1056
  </div>
950
1057
  );
951
1058
  }
952
1059
 
1060
+ /** Inline attribute editor — pen icon to enter edit mode, input + save/cancel */
1061
+ function AttributeEditorField({
1062
+ modelId,
1063
+ entityId,
1064
+ attrName,
1065
+ currentValue,
1066
+ }: {
1067
+ modelId: string;
1068
+ entityId: number;
1069
+ attrName: string;
1070
+ currentValue: string;
1071
+ }) {
1072
+ const setAttribute = useViewerStore((s) => s.setAttribute);
1073
+ const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion);
1074
+ const [editing, setEditing] = useState(false);
1075
+ const [value, setValue] = useState(currentValue);
1076
+ const inputRef = useCallback((node: HTMLInputElement | null) => {
1077
+ if (node) { node.focus(); node.select(); }
1078
+ }, []);
1079
+
1080
+ const save = useCallback(() => {
1081
+ let normalizedModelId = modelId;
1082
+ if (modelId === 'legacy') normalizedModelId = '__legacy__';
1083
+ setAttribute(normalizedModelId, entityId, attrName, value, currentValue || undefined);
1084
+ bumpMutationVersion();
1085
+ setEditing(false);
1086
+ }, [modelId, entityId, attrName, value, currentValue, setAttribute, bumpMutationVersion]);
1087
+
1088
+ const cancel = useCallback(() => {
1089
+ setValue(currentValue);
1090
+ setEditing(false);
1091
+ }, [currentValue]);
1092
+
1093
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
1094
+ if (e.key === 'Enter') { e.preventDefault(); save(); }
1095
+ else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
1096
+ }, [save, cancel]);
1097
+
1098
+ if (editing) {
1099
+ return (
1100
+ <div className="flex items-center gap-1 min-w-0">
1101
+ <input
1102
+ ref={inputRef}
1103
+ value={value}
1104
+ onChange={(e) => setValue(e.target.value)}
1105
+ onKeyDown={handleKeyDown}
1106
+ onBlur={save}
1107
+ className="flex-1 min-w-0 h-6 px-1.5 text-sm font-mono bg-white dark:bg-zinc-900 border border-purple-300 dark:border-purple-700 outline-none focus:ring-1 focus:ring-purple-400"
1108
+ />
1109
+ <Button
1110
+ variant="ghost"
1111
+ size="icon"
1112
+ className="h-5 w-5 p-0 shrink-0 hover:bg-emerald-100 dark:hover:bg-emerald-900/30"
1113
+ onClick={save}
1114
+ >
1115
+ <Check className="h-3 w-3 text-emerald-500" />
1116
+ </Button>
1117
+ </div>
1118
+ );
1119
+ }
1120
+
1121
+ return (
1122
+ <div className="flex items-center gap-1 min-w-0 group/attr">
1123
+ <span
1124
+ className="font-medium whitespace-nowrap truncate flex-1 min-w-0 cursor-text"
1125
+ title={currentValue}
1126
+ onClick={() => setEditing(true)}
1127
+ >
1128
+ {currentValue || <span className="text-zinc-400 italic">empty</span>}
1129
+ </span>
1130
+ <Tooltip>
1131
+ <TooltipTrigger asChild>
1132
+ <Button
1133
+ variant="ghost"
1134
+ size="icon"
1135
+ className="h-5 w-5 p-0 shrink-0 opacity-0 group-hover/attr:opacity-100 hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-opacity"
1136
+ onClick={() => setEditing(true)}
1137
+ >
1138
+ <PenLine className="h-3 w-3 text-purple-500" />
1139
+ </Button>
1140
+ </TooltipTrigger>
1141
+ <TooltipContent side="left">Edit attribute</TooltipContent>
1142
+ </Tooltip>
1143
+ </div>
1144
+ );
1145
+ }
1146
+
953
1147
  /** Multi-entity panel for unified storeys - shows data from multiple entities stacked */
954
1148
  function MultiEntityPanel({
955
1149
  entities,