@ifc-lite/viewer 1.17.6 → 1.19.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 (156) hide show
  1. package/.turbo/turbo-build.log +20 -15
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +949 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
  5. package/dist/assets/decode-worker-Collf_X_.js +1320 -0
  6. package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
  7. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
  8. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  9. package/dist/assets/index-0XpVr_S5.css +1 -0
  10. package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
  11. package/dist/assets/index-XwKzDuw6.js +22 -0
  12. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
  13. package/dist/assets/sandbox-Baez7n-t.js +9682 -0
  14. package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
  15. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
  16. package/dist/index.html +6 -6
  17. package/package.json +11 -10
  18. package/src/apache-arrow.d.ts +30 -0
  19. package/src/components/viewer/AddElementPanel.tsx +758 -0
  20. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  21. package/src/components/viewer/ChatPanel.tsx +64 -2
  22. package/src/components/viewer/CommandPalette.tsx +56 -7
  23. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  24. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  25. package/src/components/viewer/ExportDialog.tsx +19 -1
  26. package/src/components/viewer/MainToolbar.tsx +73 -12
  27. package/src/components/viewer/PointCloudPanel.tsx +174 -0
  28. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  29. package/src/components/viewer/SearchInline.tsx +669 -0
  30. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  31. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  32. package/src/components/viewer/SearchModal.text.tsx +388 -0
  33. package/src/components/viewer/SearchModal.tsx +235 -0
  34. package/src/components/viewer/ToolOverlays.tsx +5 -0
  35. package/src/components/viewer/ViewerLayout.tsx +24 -4
  36. package/src/components/viewer/Viewport.tsx +29 -2
  37. package/src/components/viewer/ViewportContainer.tsx +45 -5
  38. package/src/components/viewer/ViewportOverlays.tsx +13 -2
  39. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  40. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  41. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  42. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  43. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  44. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  45. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  46. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  47. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  48. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  49. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  50. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  51. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  52. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  53. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  54. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  55. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  56. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  57. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  58. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  59. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  60. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  61. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  62. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  63. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  64. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  65. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  66. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  67. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  68. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  69. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  70. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  71. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  72. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  73. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  74. package/src/components/viewer/selectionHandlers.ts +446 -0
  75. package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
  76. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  77. package/src/components/viewer/useMouseControls.ts +9 -1
  78. package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
  79. package/src/components/viewer/usePointCloudSync.ts +98 -0
  80. package/src/hooks/ingest/pointCloudIngest.ts +391 -0
  81. package/src/hooks/ingest/viewerModelIngest.ts +32 -3
  82. package/src/hooks/useIfcFederation.ts +72 -3
  83. package/src/hooks/useIfcLoader.ts +89 -13
  84. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  85. package/src/hooks/useSandbox.ts +1 -1
  86. package/src/hooks/useSearchIndex.ts +125 -0
  87. package/src/index.css +66 -0
  88. package/src/lib/llm/system-prompt.test.ts +14 -0
  89. package/src/lib/llm/system-prompt.ts +102 -1
  90. package/src/lib/llm/types.ts +6 -0
  91. package/src/lib/recent-files.ts +38 -4
  92. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  93. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  94. package/src/lib/scripts/templates.ts +7 -0
  95. package/src/lib/search/common-ifc-types.ts +36 -0
  96. package/src/lib/search/filter-evaluate.test.ts +537 -0
  97. package/src/lib/search/filter-evaluate.ts +610 -0
  98. package/src/lib/search/filter-rules.test.ts +119 -0
  99. package/src/lib/search/filter-rules.ts +198 -0
  100. package/src/lib/search/filter-schema.test.ts +233 -0
  101. package/src/lib/search/filter-schema.ts +146 -0
  102. package/src/lib/search/recent-searches.test.ts +116 -0
  103. package/src/lib/search/recent-searches.ts +93 -0
  104. package/src/lib/search/result-export.test.ts +101 -0
  105. package/src/lib/search/result-export.ts +104 -0
  106. package/src/lib/search/saved-filters.test.ts +118 -0
  107. package/src/lib/search/saved-filters.ts +154 -0
  108. package/src/lib/search/tier0-scan.test.ts +196 -0
  109. package/src/lib/search/tier0-scan.ts +237 -0
  110. package/src/lib/search/tier1-index.test.ts +242 -0
  111. package/src/lib/search/tier1-index.ts +448 -0
  112. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  113. package/src/sdk/adapters/export-adapter.ts +404 -1
  114. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  115. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  116. package/src/sdk/adapters/model-compat.ts +8 -2
  117. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  118. package/src/sdk/adapters/store-adapter.ts +201 -0
  119. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  120. package/src/sdk/local-backend.ts +16 -8
  121. package/src/services/desktop-export.ts +3 -1
  122. package/src/services/desktop-native-metadata.ts +41 -18
  123. package/src/services/file-dialog.ts +8 -3
  124. package/src/services/tauri-modules.d.ts +25 -0
  125. package/src/store/basketVisibleSet.ts +3 -0
  126. package/src/store/globalId.ts +4 -1
  127. package/src/store/index.ts +79 -1
  128. package/src/store/slices/addElementMeshes.ts +365 -0
  129. package/src/store/slices/addElementSlice.ts +275 -0
  130. package/src/store/slices/annotationsSlice.test.ts +133 -0
  131. package/src/store/slices/annotationsSlice.ts +251 -0
  132. package/src/store/slices/dataSlice.test.ts +23 -4
  133. package/src/store/slices/dataSlice.ts +1 -1
  134. package/src/store/slices/modelSlice.test.ts +67 -9
  135. package/src/store/slices/modelSlice.ts +39 -7
  136. package/src/store/slices/mutationSlice.ts +964 -3
  137. package/src/store/slices/overlayCompositor.test.ts +164 -0
  138. package/src/store/slices/overlaySlice.test.ts +93 -0
  139. package/src/store/slices/overlaySlice.ts +151 -0
  140. package/src/store/slices/pinboardSlice.test.ts +6 -1
  141. package/src/store/slices/playbackSlice.ts +128 -0
  142. package/src/store/slices/pointCloudSlice.ts +102 -0
  143. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  144. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  145. package/src/store/slices/scheduleSlice.test.ts +694 -0
  146. package/src/store/slices/scheduleSlice.ts +1330 -0
  147. package/src/store/slices/searchSlice.test.ts +342 -0
  148. package/src/store/slices/searchSlice.ts +341 -0
  149. package/src/store/slices/selectionSlice.test.ts +46 -0
  150. package/src/store/slices/selectionSlice.ts +20 -0
  151. package/src/store/types.ts +7 -0
  152. package/src/store.ts +14 -0
  153. package/vite.config.ts +1 -0
  154. package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
  155. package/dist/assets/index-_bfZsDCC.css +0 -1
  156. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -8,10 +8,138 @@
8
8
 
9
9
  import { type StateCreator } from 'zustand';
10
10
  import type { ViewerState } from '../index.js';
11
- import type { MutablePropertyView } from '@ifc-lite/mutations';
11
+ import type { MutablePropertyView, NewEntity, IfcAttributeValue } from '@ifc-lite/mutations';
12
+ import { StoreEditor } from '@ifc-lite/mutations';
12
13
  import type { Mutation, ChangeSet, PropertyValue } from '@ifc-lite/mutations';
13
14
  import { PropertyValueType, QuantityType } from '@ifc-lite/data';
15
+ import {
16
+ addBeamToStore,
17
+ addColumnToStore,
18
+ addDoorToStore,
19
+ addMemberToStore,
20
+ addPlateToStore,
21
+ addRoofToStore,
22
+ addSlabToStore,
23
+ addSpaceToStore,
24
+ addWallToStore,
25
+ addWindowToStore,
26
+ resolveSpatialAnchor,
27
+ duplicateInStore,
28
+ resolveDuplicateSource,
29
+ generateSpacesFromWalls,
30
+ type BeamInStoreParams,
31
+ type ColumnInStoreParams,
32
+ type DoorInStoreParams,
33
+ type DuplicateInStoreOptions,
34
+ type GenerateSpacesOptions,
35
+ type GenerateSpacesResult,
36
+ type MemberInStoreParams,
37
+ type PlateInStoreParams,
38
+ type RoofInStoreParams,
39
+ type SlabInStoreParams,
40
+ type SpaceInStoreParams,
41
+ type WallInStoreParams,
42
+ type WindowInStoreParams,
43
+ } from '@ifc-lite/create';
14
44
  import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
45
+ import type { MeshData } from '@ifc-lite/geometry';
46
+ import { getEntityBounds } from '@/utils/viewportUtils';
47
+ import { toGlobalIdFromModels } from '../globalId.js';
48
+ import { buildElementMesh, type ElementMeshPayload } from './addElementMeshes.js';
49
+ import type { AddElementType } from './addElementSlice.js';
50
+
51
+ /**
52
+ * IFC-space directions for {@link MutationSlice.duplicateEntity}.
53
+ *
54
+ * Axes match the IFC storey-local frame, which the user already sees
55
+ * in the Raw STEP tab:
56
+ * - +X / -X — east / west
57
+ * - +Y / -Y — north / south
58
+ * - +Z / -Z — up / down
59
+ *
60
+ * The slice converts these to a viewer-space delta when cloning the
61
+ * source's meshes for immediate render.
62
+ */
63
+ export type DuplicateDirection = '+X' | '-X' | '+Y' | '-Y' | '+Z' | '-Z';
64
+
65
+ /** Default direction used when neither the menu nor `⌘D` provides one. */
66
+ export const DUPLICATE_DEFAULT_DIRECTION: DuplicateDirection = '+X';
67
+
68
+ /** Fallback step in metres when the source has no mesh in geometry. */
69
+ const DUPLICATE_FALLBACK_STEP = 1;
70
+
71
+ interface ViewerBox {
72
+ /** Per-axis sizes in viewer scene coordinates. */
73
+ size: { x: number; y: number; z: number };
74
+ }
75
+
76
+ /**
77
+ * Compute the IFC-space offset for a directional duplicate, sized to
78
+ * the source's bounding box so the duplicate sits next to the source
79
+ * (edge-to-edge) rather than overlapping it.
80
+ *
81
+ * Mapping (renderer is Y-up, IFC is Z-up):
82
+ * viewer X = IFC X (matching axis)
83
+ * viewer Y = IFC Z (up)
84
+ * viewer Z = -IFC Y (forward)
85
+ */
86
+ function ifcOffsetForDirection(dir: DuplicateDirection, bbox: ViewerBox): [number, number, number] {
87
+ const sx = bbox.size.x || DUPLICATE_FALLBACK_STEP;
88
+ const sy = bbox.size.z || DUPLICATE_FALLBACK_STEP; // viewer Z → IFC Y
89
+ const sz = bbox.size.y || DUPLICATE_FALLBACK_STEP; // viewer Y → IFC Z
90
+ switch (dir) {
91
+ case '+X': return [sx, 0, 0];
92
+ case '-X': return [-sx, 0, 0];
93
+ case '+Y': return [0, sy, 0];
94
+ case '-Y': return [0, -sy, 0];
95
+ case '+Z': return [0, 0, sz];
96
+ case '-Z': return [0, 0, -sz];
97
+ }
98
+ }
99
+
100
+ /** Convert an IFC-space delta to the viewer's Y-up scene frame. */
101
+ function viewerDeltaFromIfc(ifc: [number, number, number]): { x: number; y: number; z: number } {
102
+ return { x: ifc[0], y: ifc[2], z: -ifc[1] };
103
+ }
104
+
105
+ /**
106
+ * Clone every mesh tagged with `sourceGlobalId` and translate its
107
+ * vertex positions by `viewerOffset`. Normals are reused (translation
108
+ * doesn't affect orientation). Returns an empty array when the source
109
+ * isn't currently in the geometry result — caller falls back to
110
+ * relying on the export-only overlay.
111
+ */
112
+ function cloneMeshesWithOffset(
113
+ meshes: MeshData[] | undefined,
114
+ sourceGlobalId: number,
115
+ newGlobalId: number,
116
+ viewerOffset: { x: number; y: number; z: number },
117
+ ): MeshData[] {
118
+ if (!meshes || meshes.length === 0) return [];
119
+ const out: MeshData[] = [];
120
+ for (const m of meshes) {
121
+ if (m.expressId !== sourceGlobalId) continue;
122
+ const positions = new Float32Array(m.positions.length);
123
+ for (let i = 0; i < m.positions.length; i += 3) {
124
+ positions[i] = m.positions[i] + viewerOffset.x;
125
+ positions[i + 1] = m.positions[i + 1] + viewerOffset.y;
126
+ positions[i + 2] = m.positions[i + 2] + viewerOffset.z;
127
+ }
128
+ out.push({
129
+ expressId: newGlobalId,
130
+ positions,
131
+ normals: m.normals,
132
+ indices: m.indices,
133
+ color: m.color,
134
+ ifcType: m.ifcType,
135
+ modelIndex: m.modelIndex,
136
+ // Per-vertex entity ids only matter for color-merged batches;
137
+ // a single-mesh duplicate carries one expressId everywhere.
138
+ entityIds: m.entityIds ? new Uint32Array(m.entityIds.length).fill(newGlobalId) : undefined,
139
+ });
140
+ }
141
+ return out;
142
+ }
15
143
 
16
144
  /** Tracks georeferencing field mutations per model */
17
145
  export interface GeorefMutationData {
@@ -23,6 +151,14 @@ export interface MutationSlice {
23
151
  // State
24
152
  /** Mutation views per model */
25
153
  mutationViews: Map<string, MutablePropertyView>;
154
+ /** Per-model StoreEditor caches (created on demand). Keyed by mutation-view modelId. */
155
+ storeEditors: Map<string, StoreEditor>;
156
+ /**
157
+ * Tombstoned overlay entities, keyed by `${modelId}:${expressId}`. Stashed
158
+ * so undo of a `removeEntity` on a freshly-added overlay entity can replay
159
+ * the same NewEntity record back into the view.
160
+ */
161
+ removedNewEntities: Map<string, NewEntity>;
26
162
  /** All change sets */
27
163
  changeSets: Map<string, ChangeSet>;
28
164
  /** Active change set ID */
@@ -124,6 +260,115 @@ export interface MutationSlice {
124
260
  oldValue?: string
125
261
  ) => Mutation | null;
126
262
 
263
+ // Actions - Store-Level Mutations (raw STEP entity edits)
264
+ /**
265
+ * Edit a positional STEP argument by zero-based index. Used by the Raw
266
+ * STEP editor for non-IfcRoot entities (profile dimensions, cartesian
267
+ * point coords, etc.) where the attribute has no symbolic name.
268
+ */
269
+ setPositionalAttribute: (
270
+ modelId: string,
271
+ entityId: number,
272
+ index: number,
273
+ value: IfcAttributeValue
274
+ ) => Mutation | null;
275
+ /**
276
+ * Tombstone an entity (existing source entity) or forget it (overlay-only).
277
+ * Returns true if the entity was known to the store or overlay.
278
+ */
279
+ removeEntity: (modelId: string, expressId: number) => boolean;
280
+ /**
281
+ * Add a fully-anchored IfcColumn (and its sub-graph) to a parsed model.
282
+ * Returns the new column's expressId, or null if the model can't be
283
+ * resolved or the storey anchor lookup fails.
284
+ */
285
+ addColumn: (
286
+ modelId: string,
287
+ storeyExpressId: number,
288
+ params: ColumnInStoreParams
289
+ ) => { expressId: number } | { error: string };
290
+ /** Add an IfcWall anchored to a storey. */
291
+ addWall: (
292
+ modelId: string,
293
+ storeyExpressId: number,
294
+ params: WallInStoreParams
295
+ ) => { expressId: number } | { error: string };
296
+ /** Add an IfcSlab anchored to a storey. */
297
+ addSlab: (
298
+ modelId: string,
299
+ storeyExpressId: number,
300
+ params: SlabInStoreParams
301
+ ) => { expressId: number } | { error: string };
302
+ /** Add an IfcBeam anchored to a storey. */
303
+ addBeam: (
304
+ modelId: string,
305
+ storeyExpressId: number,
306
+ params: BeamInStoreParams
307
+ ) => { expressId: number } | { error: string };
308
+ /** Add a free-standing IfcDoor anchored to a storey. */
309
+ addDoor: (
310
+ modelId: string,
311
+ storeyExpressId: number,
312
+ params: DoorInStoreParams
313
+ ) => { expressId: number } | { error: string };
314
+ /** Add a free-standing IfcWindow anchored to a storey. */
315
+ addWindow: (
316
+ modelId: string,
317
+ storeyExpressId: number,
318
+ params: WindowInStoreParams
319
+ ) => { expressId: number } | { error: string };
320
+ /** Add an IfcSpace (room) — rectangle or polygon footprint. */
321
+ addSpace: (
322
+ modelId: string,
323
+ storeyExpressId: number,
324
+ params: SpaceInStoreParams
325
+ ) => { expressId: number } | { error: string };
326
+ /** Add an IfcRoof (flat roof) — slab-like rectangle or polygon. */
327
+ addRoof: (
328
+ modelId: string,
329
+ storeyExpressId: number,
330
+ params: RoofInStoreParams
331
+ ) => { expressId: number } | { error: string };
332
+ /** Add an IfcPlate (thin flat element) — slab-like rectangle or polygon. */
333
+ addPlate: (
334
+ modelId: string,
335
+ storeyExpressId: number,
336
+ params: PlateInStoreParams
337
+ ) => { expressId: number } | { error: string };
338
+ /** Add an IfcMember (generic structural — brace, post, strut). */
339
+ addMember: (
340
+ modelId: string,
341
+ storeyExpressId: number,
342
+ params: MemberInStoreParams
343
+ ) => { expressId: number } | { error: string };
344
+ /**
345
+ * Auto-generate IfcSpace volumes for every enclosed area formed by
346
+ * the storey's walls (existing + overlay). When `dryRun: true` the
347
+ * detection runs but no IfcSpace is emitted — useful for live UI
348
+ * previews.
349
+ */
350
+ generateSpacesFromWalls: (
351
+ modelId: string,
352
+ storeyExpressId: number,
353
+ options?: GenerateSpacesOptions,
354
+ ) => GenerateSpacesResult | { error: string };
355
+ /**
356
+ * Duplicate an existing IfcRoot product in a chosen direction.
357
+ * Offset magnitude is one source-bbox dimension along the picked
358
+ * IFC axis (so a 3m wall steps 3m, a 0.4m column steps 0.4m).
359
+ * Geometry is shared with the source via Representation reference
360
+ * AND mirrored into the renderer's mesh list with the offset
361
+ * applied — so the duplicate appears in 3D the moment the action
362
+ * fires, not just in the export overlay. Returns the new entity's
363
+ * express id, or an error message.
364
+ */
365
+ duplicateEntity: (
366
+ modelId: string,
367
+ sourceExpressId: number,
368
+ direction?: DuplicateDirection,
369
+ options?: DuplicateInStoreOptions
370
+ ) => { expressId: number; globalId: number } | { error: string };
371
+
127
372
  // Actions - Undo/Redo
128
373
  /** Undo last mutation for a model */
129
374
  undo: (modelId: string) => void;
@@ -167,6 +412,155 @@ function generateChangeSetId(): string {
167
412
  return `cs_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
168
413
  }
169
414
 
415
+ /**
416
+ * Get-or-create the per-model `StoreEditor`. The editor pairs a parsed
417
+ * `IfcDataStore` with a `MutablePropertyView`; both must already exist
418
+ * (the data store comes from `models`, the view from PropertiesPanel's
419
+ * lazy-init effect). Returns null if either is missing.
420
+ */
421
+ function getOrCreateStoreEditor(
422
+ get: () => ViewerState,
423
+ set: (partial: Partial<ViewerState>) => void,
424
+ modelId: string,
425
+ ): StoreEditor | null {
426
+ const state = get();
427
+ const cached = state.storeEditors.get(modelId);
428
+ if (cached) return cached;
429
+
430
+ const view = state.mutationViews.get(modelId);
431
+ if (!view) return null;
432
+
433
+ const model = state.models.get(modelId);
434
+ const dataStore = model?.ifcDataStore;
435
+ if (!dataStore) return null;
436
+
437
+ const editor = new StoreEditor(dataStore, view);
438
+ const next = new Map(state.storeEditors);
439
+ next.set(modelId, editor);
440
+ set({ storeEditors: next });
441
+ return editor;
442
+ }
443
+
444
+ /**
445
+ * Shared dispatcher for the wall/slab/beam in-store builders. Mirrors the
446
+ * structure of `addColumn` (resolve store/view/editor/anchor → run the
447
+ * builder → push a CREATE_ENTITY undo entry → mark dirty + bump version)
448
+ * without copy-pasting that block per element type.
449
+ */
450
+ function runInStoreElementBuilder(
451
+ get: () => ViewerState,
452
+ set: (partial: Partial<ViewerState> | ((s: ViewerState) => Partial<ViewerState>)) => void,
453
+ modelId: string,
454
+ storeyExpressId: number,
455
+ ifcType: string,
456
+ errorContext: string,
457
+ build: (editor: StoreEditor, anchor: ReturnType<typeof resolveSpatialAnchor>) => number,
458
+ meshPayload?: ElementMeshPayload,
459
+ ): { expressId: number } | { error: string } {
460
+ const state = get();
461
+ const model = state.models.get(modelId);
462
+ const dataStore = model?.ifcDataStore;
463
+ if (!dataStore) return { error: `No model loaded for id "${modelId}"` };
464
+
465
+ const view = state.mutationViews.get(modelId);
466
+ if (!view) return { error: 'Model has no editable mutation view yet' };
467
+
468
+ const editor = getOrCreateStoreEditor(get, set, modelId);
469
+ if (!editor) return { error: 'Failed to create store editor' };
470
+
471
+ let entityId: number;
472
+ try {
473
+ const anchor = resolveSpatialAnchor(dataStore, storeyExpressId);
474
+ entityId = build(editor, anchor);
475
+ } catch (err) {
476
+ return { error: err instanceof Error ? err.message : `Failed to ${errorContext}` };
477
+ }
478
+
479
+ // Build a renderer-frame mesh for the new element so it appears in
480
+ // 3D the moment the action commits — the ImportError-only behaviour
481
+ // before this would only surface the change after an export+reparse.
482
+ if (meshPayload) {
483
+ const storeyElevation =
484
+ dataStore.spatialHierarchy?.storeyElevations?.get(storeyExpressId) ?? 0;
485
+ const globalId = toGlobalIdFromModels(state.models, modelId, entityId);
486
+ const mesh = buildElementMesh({
487
+ type: meshPayload.type,
488
+ globalId,
489
+ storeyElevation,
490
+ payload: meshPayload,
491
+ });
492
+ if (mesh) {
493
+ const cross = get() as unknown as {
494
+ appendGeometryBatch?: (batch: MeshData[]) => void;
495
+ };
496
+ cross.appendGeometryBatch?.([mesh]);
497
+ }
498
+ }
499
+
500
+ set((s) => {
501
+ const newUndoStacks = new Map(s.undoStacks);
502
+ const stack = newUndoStacks.get(modelId) || [];
503
+ const mutation: Mutation = {
504
+ id: `mut_${ifcType.toLowerCase()}_${entityId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
505
+ type: 'CREATE_ENTITY',
506
+ timestamp: Date.now(),
507
+ modelId,
508
+ entityId,
509
+ attributeName: ifcType,
510
+ };
511
+ newUndoStacks.set(modelId, [...stack, mutation]);
512
+
513
+ const newRedoStacks = new Map(s.redoStacks);
514
+ newRedoStacks.set(modelId, []);
515
+
516
+ const newDirty = new Set(s.dirtyModels);
517
+ newDirty.add(modelId);
518
+
519
+ return {
520
+ undoStacks: newUndoStacks,
521
+ redoStacks: newRedoStacks,
522
+ dirtyModels: newDirty,
523
+ mutationVersion: s.mutationVersion + 1,
524
+ };
525
+ });
526
+
527
+ return { expressId: entityId };
528
+ }
529
+
530
+ /**
531
+ * Build the polygon corner ring used by slab/roof/plate/space mesh
532
+ * previews from a builder param object that may be in rectangle or
533
+ * polygon mode. Rectangle = 4 corners CCW from `Position` +
534
+ * Width/Depth; polygon = the `OuterCurve` lifted to 3D at z = 0.
535
+ */
536
+ function profileCornersFromParams(
537
+ params:
538
+ | { Profile?: 'rectangle'; Position: [number, number, number]; Width: number; Depth: number }
539
+ | { Profile: 'polygon'; OuterCurve: Array<[number, number]>; Position?: [number, number, number] },
540
+ ): Array<[number, number, number]> {
541
+ if ('Profile' in params && params.Profile === 'polygon') {
542
+ const z = params.Position?.[2] ?? 0;
543
+ return params.OuterCurve.map(([x, y]) => [x, y, z]);
544
+ }
545
+ const rect = params as {
546
+ Position: [number, number, number]; Width: number; Depth: number;
547
+ };
548
+ const [px, py, pz] = rect.Position;
549
+ return [
550
+ [px, py, pz],
551
+ [px + rect.Width, py, pz],
552
+ [px + rect.Width, py + rect.Depth, pz],
553
+ [px, py + rect.Depth, pz],
554
+ ];
555
+ }
556
+
557
+ /** Decode the `@N` form used to encode positional indices into Mutation.attributeName. */
558
+ function positionalIndex(attributeName: string | undefined): number | null {
559
+ if (!attributeName || attributeName[0] !== '@') return null;
560
+ const n = Number(attributeName.slice(1));
561
+ return Number.isFinite(n) && n >= 0 && Number.isInteger(n) ? n : null;
562
+ }
563
+
170
564
  export const createMutationSlice: StateCreator<
171
565
  ViewerState,
172
566
  [],
@@ -175,6 +569,8 @@ export const createMutationSlice: StateCreator<
175
569
  > = (set, get) => ({
176
570
  // Initial state
177
571
  mutationViews: new Map(),
572
+ storeEditors: new Map(),
573
+ removedNewEntities: new Map(),
178
574
  changeSets: new Map(),
179
575
  activeChangeSetId: null,
180
576
  undoStacks: new Map(),
@@ -253,9 +649,23 @@ export const createMutationSlice: StateCreator<
253
649
  set((state) => {
254
650
  const newViews = new Map(state.mutationViews);
255
651
  newViews.delete(modelId);
652
+ const newEditors = new Map(state.storeEditors);
653
+ newEditors.delete(modelId);
256
654
  const newDirty = new Set(state.dirtyModels);
257
655
  newDirty.delete(modelId);
258
- return { mutationViews: newViews, dirtyModels: newDirty };
656
+ // Drop any stashed undo payloads owned by this model so they don't
657
+ // leak into future mutation views with the same id.
658
+ const newRemoved = new Map(state.removedNewEntities);
659
+ const prefix = `${modelId}:`;
660
+ for (const key of [...newRemoved.keys()]) {
661
+ if (key.startsWith(prefix)) newRemoved.delete(key);
662
+ }
663
+ return {
664
+ mutationViews: newViews,
665
+ storeEditors: newEditors,
666
+ dirtyModels: newDirty,
667
+ removedNewEntities: newRemoved,
668
+ };
259
669
  });
260
670
  },
261
671
 
@@ -462,6 +872,402 @@ export const createMutationSlice: StateCreator<
462
872
  return mutation;
463
873
  },
464
874
 
875
+ // Store-Level Mutations
876
+ setPositionalAttribute: (modelId, entityId, index, value) => {
877
+ const view = get().mutationViews.get(modelId);
878
+ if (!view) return null;
879
+
880
+ const editor = getOrCreateStoreEditor(get, set, modelId);
881
+ if (!editor) return null;
882
+
883
+ // Capture prior overlay value (if any) for undo. We can't recover the
884
+ // base STEP value from here without parsing the source — that's the
885
+ // RawStepRow's job — so undo of "first override" simply removes the
886
+ // override, falling back to the original buffer value.
887
+ const prior = view.getPositionalMutationsForEntity(entityId)?.get(index);
888
+ editor.setPositionalAttribute(entityId, index, value);
889
+
890
+ set((state) => {
891
+ const newUndoStacks = new Map(state.undoStacks);
892
+ const stack = newUndoStacks.get(modelId) || [];
893
+ const mutation: Mutation = {
894
+ id: `mut_pos_${entityId}_${index}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
895
+ type: 'UPDATE_POSITIONAL_ATTRIBUTE',
896
+ timestamp: Date.now(),
897
+ modelId,
898
+ entityId,
899
+ attributeName: `@${index}`,
900
+ oldValue: (prior ?? null) as PropertyValue,
901
+ newValue: value as PropertyValue,
902
+ };
903
+ newUndoStacks.set(modelId, [...stack, mutation]);
904
+
905
+ const newRedoStacks = new Map(state.redoStacks);
906
+ newRedoStacks.set(modelId, []);
907
+
908
+ const newDirty = new Set(state.dirtyModels);
909
+ newDirty.add(modelId);
910
+
911
+ return {
912
+ undoStacks: newUndoStacks,
913
+ redoStacks: newRedoStacks,
914
+ dirtyModels: newDirty,
915
+ mutationVersion: state.mutationVersion + 1,
916
+ };
917
+ });
918
+
919
+ // Return the mutation we just pushed onto the undo stack.
920
+ const stack = get().undoStacks.get(modelId);
921
+ return stack ? stack[stack.length - 1] : null;
922
+ },
923
+
924
+ removeEntity: (modelId, expressId) => {
925
+ const view = get().mutationViews.get(modelId);
926
+ if (!view) return false;
927
+ const editor = getOrCreateStoreEditor(get, set, modelId);
928
+ if (!editor) return false;
929
+
930
+ // Stash the overlay record (if any) BEFORE the editor forgets it, so
931
+ // undo can re-add the exact same NewEntity. For source-buffer entities
932
+ // there's nothing to stash — undo just removes the tombstone.
933
+ const overlayRecord = view.getNewEntity(expressId);
934
+ const removed = editor.removeEntity(expressId);
935
+ if (!removed) return false;
936
+
937
+ set((state) => {
938
+ const newRemoved = new Map(state.removedNewEntities);
939
+ if (overlayRecord) {
940
+ newRemoved.set(`${modelId}:${expressId}`, overlayRecord);
941
+ }
942
+
943
+ const newUndoStacks = new Map(state.undoStacks);
944
+ const stack = newUndoStacks.get(modelId) || [];
945
+ const mutation: Mutation = {
946
+ id: `mut_del_${expressId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
947
+ type: 'DELETE_ENTITY',
948
+ timestamp: Date.now(),
949
+ modelId,
950
+ entityId: expressId,
951
+ };
952
+ newUndoStacks.set(modelId, [...stack, mutation]);
953
+
954
+ const newRedoStacks = new Map(state.redoStacks);
955
+ newRedoStacks.set(modelId, []);
956
+
957
+ const newDirty = new Set(state.dirtyModels);
958
+ newDirty.add(modelId);
959
+
960
+ return {
961
+ removedNewEntities: newRemoved,
962
+ undoStacks: newUndoStacks,
963
+ redoStacks: newRedoStacks,
964
+ dirtyModels: newDirty,
965
+ mutationVersion: state.mutationVersion + 1,
966
+ };
967
+ });
968
+
969
+ return true;
970
+ },
971
+
972
+ addColumn: (modelId, storeyExpressId, params) => {
973
+ const state = get();
974
+ const model = state.models.get(modelId);
975
+ const dataStore = model?.ifcDataStore;
976
+ if (!dataStore) return { error: `No model loaded for id "${modelId}"` };
977
+
978
+ // The dialog passes the same modelId used by the model store; mutation
979
+ // views are keyed identically (no legacy normalization needed in the
980
+ // multi-model path the dialog operates in).
981
+ const view = state.mutationViews.get(modelId);
982
+ if (!view) return { error: 'Model has no editable mutation view yet' };
983
+
984
+ const editor = getOrCreateStoreEditor(get, set, modelId);
985
+ if (!editor) return { error: 'Failed to create store editor' };
986
+
987
+ let columnId: number;
988
+ try {
989
+ const anchor = resolveSpatialAnchor(dataStore, storeyExpressId);
990
+ const result = addColumnToStore(editor, anchor, params);
991
+ columnId = result.columnId;
992
+ } catch (err) {
993
+ return { error: err instanceof Error ? err.message : 'Failed to add column' };
994
+ }
995
+
996
+ // Inject a renderer-frame box mesh so the column appears in 3D
997
+ // immediately. Same coordinate-frame plumbing as
998
+ // `runInStoreElementBuilder`, kept inline since this action
999
+ // pre-dates the shared helper.
1000
+ const storeyElevationCol =
1001
+ dataStore.spatialHierarchy?.storeyElevations?.get(storeyExpressId) ?? 0;
1002
+ const columnGlobalId = toGlobalIdFromModels(state.models, modelId, columnId);
1003
+ const columnMesh = buildElementMesh({
1004
+ type: 'column',
1005
+ globalId: columnGlobalId,
1006
+ storeyElevation: storeyElevationCol,
1007
+ payload: {
1008
+ type: 'column',
1009
+ params: { Width: params.Width, Depth: params.Depth, Height: params.Height },
1010
+ position: params.Position,
1011
+ },
1012
+ });
1013
+ if (columnMesh) {
1014
+ const cross = get() as unknown as {
1015
+ appendGeometryBatch?: (batch: MeshData[]) => void;
1016
+ };
1017
+ cross.appendGeometryBatch?.([columnMesh]);
1018
+ }
1019
+
1020
+ set((s) => {
1021
+ const newUndoStacks = new Map(s.undoStacks);
1022
+ const stack = newUndoStacks.get(modelId) || [];
1023
+ const mutation: Mutation = {
1024
+ id: `mut_col_${columnId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
1025
+ type: 'CREATE_ENTITY',
1026
+ timestamp: Date.now(),
1027
+ modelId,
1028
+ entityId: columnId,
1029
+ attributeName: 'IFCCOLUMN',
1030
+ };
1031
+ newUndoStacks.set(modelId, [...stack, mutation]);
1032
+
1033
+ const newRedoStacks = new Map(s.redoStacks);
1034
+ newRedoStacks.set(modelId, []);
1035
+
1036
+ const newDirty = new Set(s.dirtyModels);
1037
+ newDirty.add(modelId);
1038
+
1039
+ return {
1040
+ undoStacks: newUndoStacks,
1041
+ redoStacks: newRedoStacks,
1042
+ dirtyModels: newDirty,
1043
+ mutationVersion: s.mutationVersion + 1,
1044
+ };
1045
+ });
1046
+
1047
+ return { expressId: columnId };
1048
+ },
1049
+
1050
+ addWall: (modelId, storeyExpressId, params) => {
1051
+ return runInStoreElementBuilder(
1052
+ get, set, modelId, storeyExpressId, 'IFCWALL', 'add wall',
1053
+ (editor, anchor) => addWallToStore(editor, anchor, params).wallId,
1054
+ { type: 'wall', params: { Thickness: params.Thickness, Height: params.Height }, start: params.Start, end: params.End },
1055
+ );
1056
+ },
1057
+
1058
+ addSlab: (modelId, storeyExpressId, params) => {
1059
+ return runInStoreElementBuilder(
1060
+ get, set, modelId, storeyExpressId, 'IFCSLAB', 'add slab',
1061
+ (editor, anchor) => addSlabToStore(editor, anchor, params).slabId,
1062
+ { type: 'slab', params: { Width: 0, Depth: 0, Thickness: params.Thickness }, corners: profileCornersFromParams(params) },
1063
+ );
1064
+ },
1065
+
1066
+ addBeam: (modelId, storeyExpressId, params) => {
1067
+ return runInStoreElementBuilder(
1068
+ get, set, modelId, storeyExpressId, 'IFCBEAM', 'add beam',
1069
+ (editor, anchor) => addBeamToStore(editor, anchor, params).beamId,
1070
+ { type: 'beam', params: { Width: params.Width, Height: params.Height }, start: params.Start, end: params.End },
1071
+ );
1072
+ },
1073
+
1074
+ addDoor: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
1075
+ get, set, modelId, storeyExpressId, 'IFCDOOR', 'add door',
1076
+ (editor, anchor) => addDoorToStore(editor, anchor, params).doorId,
1077
+ { type: 'door', params: { Width: params.Width, Height: params.Height, FrameThickness: params.FrameThickness ?? 0.05 }, position: params.Position },
1078
+ ),
1079
+
1080
+ addWindow: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
1081
+ get, set, modelId, storeyExpressId, 'IFCWINDOW', 'add window',
1082
+ (editor, anchor) => addWindowToStore(editor, anchor, params).windowId,
1083
+ { type: 'window', params: { Width: params.Width, Height: params.Height, FrameThickness: params.FrameThickness ?? 0.05 }, position: params.Position },
1084
+ ),
1085
+
1086
+ addSpace: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
1087
+ get, set, modelId, storeyExpressId, 'IFCSPACE', 'add space',
1088
+ (editor, anchor) => addSpaceToStore(editor, anchor, params).spaceId,
1089
+ { type: 'space', params: { Width: 0, Depth: 0, Height: params.Height }, corners: profileCornersFromParams(params) },
1090
+ ),
1091
+
1092
+ addRoof: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
1093
+ get, set, modelId, storeyExpressId, 'IFCROOF', 'add roof',
1094
+ (editor, anchor) => addRoofToStore(editor, anchor, params).roofId,
1095
+ { type: 'roof', params: { Width: 0, Depth: 0, Thickness: params.Thickness }, corners: profileCornersFromParams(params) },
1096
+ ),
1097
+
1098
+ addPlate: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
1099
+ get, set, modelId, storeyExpressId, 'IFCPLATE', 'add plate',
1100
+ (editor, anchor) => addPlateToStore(editor, anchor, params).plateId,
1101
+ { type: 'plate', params: { Width: 0, Depth: 0, Thickness: params.Thickness }, corners: profileCornersFromParams(params) },
1102
+ ),
1103
+
1104
+ addMember: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
1105
+ get, set, modelId, storeyExpressId, 'IFCMEMBER', 'add member',
1106
+ (editor, anchor) => addMemberToStore(editor, anchor, params).memberId,
1107
+ { type: 'member', params: { Width: params.Width, Height: params.Height }, start: params.Start, end: params.End },
1108
+ ),
1109
+
1110
+ generateSpacesFromWalls: (modelId, storeyExpressId, options) => {
1111
+ const state = get();
1112
+ const model = state.models.get(modelId);
1113
+ const dataStore = model?.ifcDataStore;
1114
+ if (!dataStore) return { error: `No model loaded for id "${modelId}"` };
1115
+ const view = state.mutationViews.get(modelId);
1116
+ if (!view) return { error: 'Model has no editable mutation view yet' };
1117
+
1118
+ // For dryRun the editor isn't strictly needed — we still create
1119
+ // one (cheap) so the helper signature can stay uniform.
1120
+ const editor = getOrCreateStoreEditor(get, set, modelId);
1121
+ if (!editor) return { error: 'Failed to create store editor' };
1122
+
1123
+ let result: GenerateSpacesResult;
1124
+ try {
1125
+ result = generateSpacesFromWalls(
1126
+ editor,
1127
+ dataStore,
1128
+ storeyExpressId,
1129
+ options,
1130
+ // The view exposes getNewEntities — pass it in so overlay-only
1131
+ // walls (placed via the Add Element tool) participate in the
1132
+ // detection without needing a flush to STEP first.
1133
+ {
1134
+ getNewEntities: () => view.getNewEntities(),
1135
+ },
1136
+ );
1137
+ } catch (err) {
1138
+ return { error: err instanceof Error ? err.message : 'Failed to generate spaces' };
1139
+ }
1140
+
1141
+ // dryRun → nothing emitted; skip undo / dirty bookkeeping.
1142
+ if (!result.emitted.length) return result;
1143
+
1144
+ set((s) => {
1145
+ const newUndoStacks = new Map(s.undoStacks);
1146
+ const stack = [...(newUndoStacks.get(modelId) ?? [])];
1147
+ const ts = Date.now();
1148
+ for (const e of result.emitted) {
1149
+ stack.push({
1150
+ id: `mut_ifcspace_${e.result.spaceId}_${ts}_${Math.random().toString(36).substring(2, 9)}`,
1151
+ type: 'CREATE_ENTITY',
1152
+ timestamp: ts,
1153
+ modelId,
1154
+ entityId: e.result.spaceId,
1155
+ attributeName: 'IFCSPACE',
1156
+ });
1157
+ }
1158
+ newUndoStacks.set(modelId, stack);
1159
+
1160
+ const newRedoStacks = new Map(s.redoStacks);
1161
+ newRedoStacks.set(modelId, []);
1162
+
1163
+ const newDirty = new Set(s.dirtyModels);
1164
+ newDirty.add(modelId);
1165
+
1166
+ return {
1167
+ undoStacks: newUndoStacks,
1168
+ redoStacks: newRedoStacks,
1169
+ dirtyModels: newDirty,
1170
+ mutationVersion: s.mutationVersion + 1,
1171
+ };
1172
+ });
1173
+
1174
+ return result;
1175
+ },
1176
+
1177
+ duplicateEntity: (modelId, sourceExpressId, direction = DUPLICATE_DEFAULT_DIRECTION, options) => {
1178
+ const state = get();
1179
+ const model = state.models.get(modelId);
1180
+ const dataStore = model?.ifcDataStore;
1181
+ if (!dataStore) return { error: `No model loaded for id "${modelId}"` };
1182
+
1183
+ const view = state.mutationViews.get(modelId);
1184
+ if (!view) return { error: 'Model has no editable mutation view yet' };
1185
+
1186
+ const editor = getOrCreateStoreEditor(get, set, modelId);
1187
+ if (!editor) return { error: 'Failed to create store editor' };
1188
+
1189
+ // Source's bounding box drives the offset magnitude. Multi-model
1190
+ // federations key meshes by globalId — route through the central
1191
+ // conversion helper so federation/single-model semantics stay in
1192
+ // one place (legacy stores fall through to expressId === globalId).
1193
+ const sourceGlobalId = toGlobalIdFromModels(state.models, modelId, sourceExpressId);
1194
+ const meshes = state.geometryResult?.meshes;
1195
+ const sourceBounds = getEntityBounds(meshes ?? null, sourceGlobalId);
1196
+ const bbox: ViewerBox = sourceBounds
1197
+ ? {
1198
+ size: {
1199
+ x: Math.max(sourceBounds.max.x - sourceBounds.min.x, 0),
1200
+ y: Math.max(sourceBounds.max.y - sourceBounds.min.y, 0),
1201
+ z: Math.max(sourceBounds.max.z - sourceBounds.min.z, 0),
1202
+ },
1203
+ }
1204
+ : { size: { x: DUPLICATE_FALLBACK_STEP, y: DUPLICATE_FALLBACK_STEP, z: DUPLICATE_FALLBACK_STEP } };
1205
+
1206
+ const ifcDelta = ifcOffsetForDirection(direction, bbox);
1207
+ const viewerDelta = viewerDeltaFromIfc(ifcDelta);
1208
+
1209
+ let newId: number;
1210
+ try {
1211
+ const source = resolveDuplicateSource(dataStore, sourceExpressId);
1212
+ const result = duplicateInStore(editor, source, { ...options, offset: ifcDelta });
1213
+ newId = result.newId;
1214
+ } catch (err) {
1215
+ return { error: err instanceof Error ? err.message : 'Failed to duplicate' };
1216
+ }
1217
+
1218
+ // Alias the duplicate to its source for base property / quantity
1219
+ // reads — so the property panel shows the source's psets without
1220
+ // us eagerly cloning them. The duplicate's own override slots
1221
+ // remain scoped to the new id.
1222
+ view.setEntityAlias(newId, sourceExpressId);
1223
+
1224
+ const newGlobalId = toGlobalIdFromModels(state.models, modelId, newId);
1225
+
1226
+ // Mirror the source's meshes into the geometry result with the
1227
+ // offset applied so the duplicate is visible immediately. Without
1228
+ // this the entity exists only in the export overlay — STEP-correct
1229
+ // but invisible — and the user can't tell anything happened.
1230
+ const clonedMeshes = cloneMeshesWithOffset(meshes, sourceGlobalId, newGlobalId, viewerDelta);
1231
+
1232
+ set((s) => {
1233
+ const newUndoStacks = new Map(s.undoStacks);
1234
+ const stack = newUndoStacks.get(modelId) || [];
1235
+ const mutation: Mutation = {
1236
+ id: `mut_dup_${newId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
1237
+ type: 'CREATE_ENTITY',
1238
+ timestamp: Date.now(),
1239
+ modelId,
1240
+ entityId: newId,
1241
+ attributeName: 'DUPLICATE',
1242
+ };
1243
+ newUndoStacks.set(modelId, [...stack, mutation]);
1244
+
1245
+ const newRedoStacks = new Map(s.redoStacks);
1246
+ newRedoStacks.set(modelId, []);
1247
+
1248
+ const newDirty = new Set(s.dirtyModels);
1249
+ newDirty.add(modelId);
1250
+
1251
+ return {
1252
+ undoStacks: newUndoStacks,
1253
+ redoStacks: newRedoStacks,
1254
+ dirtyModels: newDirty,
1255
+ mutationVersion: s.mutationVersion + 1,
1256
+ };
1257
+ });
1258
+
1259
+ // Append cloned meshes via the existing data slice action so the
1260
+ // renderer picks them up via its standard tick.
1261
+ if (clonedMeshes.length > 0) {
1262
+ const cross = get() as unknown as {
1263
+ appendGeometryBatch?: (batch: MeshData[]) => void;
1264
+ };
1265
+ cross.appendGeometryBatch?.(clonedMeshes);
1266
+ }
1267
+
1268
+ return { expressId: newId, globalId: newGlobalId };
1269
+ },
1270
+
465
1271
  // Undo/Redo
466
1272
  undo: (modelId) => {
467
1273
  const state = get();
@@ -564,6 +1370,53 @@ export const createMutationSlice: StateCreator<
564
1370
  view.removeAttributeMutation(mutation.entityId, mutation.attributeName);
565
1371
  }
566
1372
  }
1373
+ } else if (mutation.type === 'UPDATE_POSITIONAL_ATTRIBUTE') {
1374
+ // Positional attrs encode their index in `@N` since the existing
1375
+ // Mutation shape has no dedicated field for it.
1376
+ const index = positionalIndex(mutation.attributeName);
1377
+ if (index !== null) {
1378
+ if (mutation.oldValue === null || mutation.oldValue === undefined) {
1379
+ view.removePositionalMutation(mutation.entityId, index);
1380
+ } else {
1381
+ view.setPositionalAttribute(mutation.entityId, index, mutation.oldValue as IfcAttributeValue, true);
1382
+ }
1383
+ }
1384
+ } else if (mutation.type === 'CREATE_ENTITY') {
1385
+ // Undo of a create: stash the NewEntity payload so a subsequent redo
1386
+ // can restore it. Without this, redo finds an empty stash and becomes
1387
+ // a no-op for the create-then-undo-then-redo path.
1388
+ const overlay = view.getNewEntity(mutation.entityId);
1389
+ if (overlay) {
1390
+ set((s) => {
1391
+ const next = new Map(s.removedNewEntities);
1392
+ next.set(`${modelId}:${mutation.entityId}`, overlay);
1393
+ return { removedNewEntities: next };
1394
+ });
1395
+ }
1396
+ // The view's `deleteEntity` returns false if it's already gone, which
1397
+ // is fine for redo to re-establish.
1398
+ view.deleteEntity(mutation.entityId);
1399
+ } else if (mutation.type === 'DELETE_ENTITY') {
1400
+ // Undo of a delete: restore tombstone for source entity, OR replay
1401
+ // the stashed NewEntity record for an overlay-only entity.
1402
+ const stashKey = `${modelId}:${mutation.entityId}`;
1403
+ const stashed = get().removedNewEntities.get(stashKey);
1404
+ if (stashed) {
1405
+ view.restoreNewEntity(stashed);
1406
+ } else {
1407
+ view.restoreFromTombstone(mutation.entityId);
1408
+ }
1409
+ // Also un-hide the rendered mesh — the EntityContextMenu's
1410
+ // delete handler hid it via the visibility system, so undo has
1411
+ // to mirror that to bring the entity back into the scene.
1412
+ const cross = get() as unknown as {
1413
+ toGlobalId?: (modelId: string, expressId: number) => number;
1414
+ showEntity?: (id: number) => void;
1415
+ };
1416
+ if (cross.toGlobalId && cross.showEntity) {
1417
+ const globalId = cross.toGlobalId(modelId, mutation.entityId);
1418
+ cross.showEntity(globalId);
1419
+ }
567
1420
  }
568
1421
 
569
1422
  set((s) => {
@@ -666,6 +1519,48 @@ export const createMutationSlice: StateCreator<
666
1519
  if (mutation.attributeName && mutation.newValue !== undefined) {
667
1520
  view.setAttribute(mutation.entityId, mutation.attributeName, String(mutation.newValue), undefined, true);
668
1521
  }
1522
+ } else if (mutation.type === 'UPDATE_POSITIONAL_ATTRIBUTE') {
1523
+ const index = positionalIndex(mutation.attributeName);
1524
+ if (index !== null && mutation.newValue !== undefined) {
1525
+ view.setPositionalAttribute(mutation.entityId, index, mutation.newValue as IfcAttributeValue, true);
1526
+ }
1527
+ } else if (mutation.type === 'CREATE_ENTITY') {
1528
+ // Redo of a create: replay from the stashed NewEntity. Symmetrical to
1529
+ // DELETE_ENTITY's undo — same map, same key.
1530
+ const stashKey = `${modelId}:${mutation.entityId}`;
1531
+ const stashed = get().removedNewEntities.get(stashKey);
1532
+ if (stashed) {
1533
+ view.restoreNewEntity(stashed);
1534
+ } else {
1535
+ // Source-buffer entities have no stash; the editor's deleteEntity
1536
+ // call simply re-tombstoned them — which is exactly what we want
1537
+ // here? No — for CREATE_ENTITY redo we want the entity to come back.
1538
+ // Source-entity creates are not a real path; CREATE_ENTITY in this
1539
+ // codebase only ever fires for overlay-added entities. Nothing to
1540
+ // do if the stash is empty (means the redo is unreachable).
1541
+ }
1542
+ } else if (mutation.type === 'DELETE_ENTITY') {
1543
+ // Redo of a delete: tombstone again. For overlay-only entities we
1544
+ // first stash the NewEntity (it'll be re-fetched for the next undo).
1545
+ const overlay = view.getNewEntity(mutation.entityId);
1546
+ if (overlay) {
1547
+ set((s) => {
1548
+ const next = new Map(s.removedNewEntities);
1549
+ next.set(`${modelId}:${mutation.entityId}`, overlay);
1550
+ return { removedNewEntities: next };
1551
+ });
1552
+ }
1553
+ view.deleteEntity(mutation.entityId);
1554
+ // Re-hide the mesh — symmetric with the menu's delete handler
1555
+ // and with the undo path above.
1556
+ const cross = get() as unknown as {
1557
+ toGlobalId?: (modelId: string, expressId: number) => number;
1558
+ hideEntity?: (id: number) => void;
1559
+ };
1560
+ if (cross.toGlobalId && cross.hideEntity) {
1561
+ const globalId = cross.toGlobalId(modelId, mutation.entityId);
1562
+ cross.hideEntity(globalId);
1563
+ }
669
1564
  }
670
1565
 
671
1566
  set((s) => {
@@ -758,7 +1653,21 @@ export const createMutationSlice: StateCreator<
758
1653
 
759
1654
  // Query
760
1655
  hasChanges: (modelId) => {
761
- return get().dirtyModels.has(modelId);
1656
+ if (get().dirtyModels.has(modelId)) return true;
1657
+ // Schedule-only case: a generated schedule OR an edited parsed
1658
+ // schedule counts as a pending edit even if the user hasn't touched
1659
+ // any properties.
1660
+ const cross = get() as unknown as {
1661
+ scheduleSourceModelId?: string | null;
1662
+ scheduleIsEdited?: boolean;
1663
+ scheduleData?: { tasks: Array<{ expressId?: number }> } | null;
1664
+ };
1665
+ if (cross.scheduleSourceModelId !== modelId) return false;
1666
+ if (cross.scheduleIsEdited) return true;
1667
+ const tasks = cross.scheduleData?.tasks;
1668
+ if (!tasks) return false;
1669
+ for (const t of tasks) if (!t.expressId || t.expressId <= 0) return true;
1670
+ return false;
762
1671
  },
763
1672
 
764
1673
  getMutationsForModel: (modelId) => {
@@ -779,6 +1688,30 @@ export const createMutationSlice: StateCreator<
779
1688
  count += 1; // count the model as having modifications
780
1689
  }
781
1690
  }
1691
+ // Include generated schedule tasks — these are spliced into the STEP
1692
+ // export just like property mutations are, so they belong in the same
1693
+ // "pending changes" count the export badge reads.
1694
+ //
1695
+ // Edited parsed schedules: if the schedule has been edited (any task
1696
+ // renamed / rescheduled / deleted / etc.) count +1 to surface the
1697
+ // badge, even when no generated tasks exist. Users need some signal
1698
+ // that "edits are pending export"; a single +1 keeps the count
1699
+ // honest without inflating for every individual field change.
1700
+ const cross = get() as unknown as {
1701
+ scheduleData?: { tasks: Array<{ expressId?: number }> } | null;
1702
+ scheduleIsEdited?: boolean;
1703
+ };
1704
+ const tasks = cross.scheduleData?.tasks;
1705
+ let hasGenerated = false;
1706
+ if (tasks) {
1707
+ for (const t of tasks) {
1708
+ if (!t.expressId || t.expressId <= 0) {
1709
+ count++;
1710
+ hasGenerated = true;
1711
+ }
1712
+ }
1713
+ }
1714
+ if (cross.scheduleIsEdited && !hasGenerated) count++;
782
1715
  return count;
783
1716
  },
784
1717
 
@@ -789,6 +1722,17 @@ export const createMutationSlice: StateCreator<
789
1722
  view.clear();
790
1723
  }
791
1724
 
1725
+ // Also discard pending schedule edits owned by this model. Done via
1726
+ // the schedule slice's own action so its invariants (range, playback,
1727
+ // expanded rows) stay consistent.
1728
+ const cross = get() as unknown as {
1729
+ scheduleSourceModelId?: string | null;
1730
+ clearGeneratedSchedule?: () => number;
1731
+ };
1732
+ if (cross.scheduleSourceModelId === modelId && cross.clearGeneratedSchedule) {
1733
+ cross.clearGeneratedSchedule();
1734
+ }
1735
+
792
1736
  set((state) => {
793
1737
  const newUndoStacks = new Map(state.undoStacks);
794
1738
  newUndoStacks.delete(modelId);
@@ -802,11 +1746,22 @@ export const createMutationSlice: StateCreator<
802
1746
  const newGeorefMuts = new Map(state.georefMutations);
803
1747
  newGeorefMuts.delete(modelId);
804
1748
 
1749
+ const newRemoved = new Map(state.removedNewEntities);
1750
+ const prefix = `${modelId}:`;
1751
+ for (const key of [...newRemoved.keys()]) {
1752
+ if (key.startsWith(prefix)) newRemoved.delete(key);
1753
+ }
1754
+
1755
+ const newEditors = new Map(state.storeEditors);
1756
+ newEditors.delete(modelId);
1757
+
805
1758
  return {
806
1759
  undoStacks: newUndoStacks,
807
1760
  redoStacks: newRedoStacks,
808
1761
  dirtyModels: newDirty,
809
1762
  georefMutations: newGeorefMuts,
1763
+ removedNewEntities: newRemoved,
1764
+ storeEditors: newEditors,
810
1765
  mutationVersion: state.mutationVersion + 1,
811
1766
  };
812
1767
  });
@@ -817,11 +1772,17 @@ export const createMutationSlice: StateCreator<
817
1772
  view.clear();
818
1773
  }
819
1774
 
1775
+ // Schedule slice handles its own state transitions.
1776
+ const cross = get() as unknown as { clearGeneratedSchedule?: () => number };
1777
+ cross.clearGeneratedSchedule?.();
1778
+
820
1779
  set((state) => ({
821
1780
  undoStacks: new Map(),
822
1781
  redoStacks: new Map(),
823
1782
  dirtyModels: new Set(),
824
1783
  georefMutations: new Map(),
1784
+ removedNewEntities: new Map(),
1785
+ storeEditors: new Map(),
825
1786
  mutationVersion: state.mutationVersion + 1,
826
1787
  }));
827
1788
  },