@ifc-lite/viewer 1.17.6 → 1.18.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 (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -0,0 +1,648 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Generate a `ScheduleExtraction` from an IFC model's spatial hierarchy.
7
+ *
8
+ * The UI lives in `GenerateScheduleDialog.tsx`; this module keeps the pure
9
+ * logic so we can unit-test the schedule shape without mounting the UI.
10
+ *
11
+ * Strategies supported today:
12
+ * • `storey` — one task per IfcBuildingStorey, controlling every product
13
+ * contained in that storey (transitively through spaces, via
14
+ * `spatialHierarchy.byStorey` which the parser already flattens).
15
+ * • `building` — one task per IfcBuilding, rolling up every storey's
16
+ * products into a single task.
17
+ *
18
+ * All identifiers used downstream (globalIds, durations) are kept synthetic
19
+ * but stable — re-running the generator with the same inputs produces the
20
+ * same extraction so consumers don't see playback jitter.
21
+ */
22
+
23
+ import type {
24
+ ScheduleExtraction,
25
+ ScheduleTaskInfo,
26
+ ScheduleSequenceInfo,
27
+ WorkScheduleInfo,
28
+ } from '@ifc-lite/parser';
29
+ import type { IfcDataStore } from '@ifc-lite/parser';
30
+ import { deterministicGlobalId } from '@ifc-lite/parser';
31
+ import type { MeshData } from '@ifc-lite/geometry';
32
+
33
+ // ─────────────────────────────────────────────────────────────────────────
34
+ // Public types
35
+ // ─────────────────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Exposed strategy values use the exact IFC EXPRESS entity names per AGENTS.md
39
+ * §1 (Mandatory Schema Compliance). UI layers map these to friendly labels.
40
+ *
41
+ * `IfcBuildingStorey` / `IfcBuilding` — trust the model's spatial hierarchy.
42
+ * `IfcElement` — ignore spatial hierarchy, slice the model by the actual
43
+ * geometric Z elevation of each meshed element. A rescue hatch for IFCs
44
+ * with broken hierarchies (a common authoring issue where structural
45
+ * elements all end up assigned to the ground floor even though they're
46
+ * physically on floors 10-20).
47
+ */
48
+ export type SpatialGroupStrategy = 'IfcBuildingStorey' | 'IfcBuilding' | 'IfcElement';
49
+ export type GenerateOrder = 'bottom-up' | 'top-down';
50
+
51
+ /**
52
+ * For the `IfcElement` strategy, how to further subdivide elements that
53
+ * fall into the same Z slice.
54
+ *
55
+ * `none` — one task per Z slice, every element inside it goes to that task.
56
+ * `class` — split each Z slice by IFC class (IfcWall, IfcSlab, …), so a
57
+ * 20-floor model with 5 classes yields up to 100 tasks.
58
+ * `type` — split by the element's resolved type name (IfcRelDefinesByType
59
+ * target's Name, or ObjectType attribute fallback).
60
+ * `name` — split by the element's own Name attribute.
61
+ */
62
+ export type ElementZSubgroup = 'none' | 'class' | 'type' | 'name';
63
+
64
+ export interface GenerateScheduleOptions {
65
+ /** Which source to derive tasks from. */
66
+ strategy: SpatialGroupStrategy;
67
+ /** ISO 8601 datetime for the first task's start (e.g. "2024-05-01T08:00:00"). */
68
+ startDate: string;
69
+ /** Days per task. Each group gets the same duration. */
70
+ daysPerGroup: number;
71
+ /** Lag between groups in days (≥ 0). Applied both to dates and IfcLagTime. */
72
+ lagDays: number;
73
+ /**
74
+ * Order to visit groups when the strategy allows it. "bottom-up" goes by
75
+ * ascending elevation (site → G → 1 → …); "top-down" reverses.
76
+ */
77
+ order: GenerateOrder;
78
+ /** Skip groups whose product count is zero. */
79
+ skipEmptyGroups: boolean;
80
+ /** Create IfcRelSequence edges between consecutive groups. */
81
+ linkSequences: boolean;
82
+ /** Human name shown on the parent IfcWorkSchedule. */
83
+ scheduleName: string;
84
+ /** PredefinedType stamped on each task. */
85
+ predefinedType: string;
86
+ /**
87
+ * `IfcElement` only: height of each Z slice in metres. Typical storey
88
+ * heights are 3–4 m, so 3.0 is a sensible default. Must be positive.
89
+ * Ignored by spatial strategies.
90
+ */
91
+ heightTolerance: number;
92
+ /**
93
+ * `IfcElement` only: how to subdivide elements sharing a Z slice into
94
+ * separate tasks. Ignored by spatial strategies.
95
+ */
96
+ elementZSubgroup: ElementZSubgroup;
97
+ }
98
+
99
+ export interface GeneratePreview {
100
+ /** The extraction as it will be pushed into the viewer store. */
101
+ extraction: ScheduleExtraction;
102
+ /** Number of containers visited. */
103
+ groupCount: number;
104
+ /** Total products assigned across all groups. */
105
+ productCount: number;
106
+ /** ISO datetime of the overall schedule finish (after lag, last group end). */
107
+ finishDate: string;
108
+ /** When true, spatialHierarchy was missing/empty — preview is empty. */
109
+ empty: boolean;
110
+ }
111
+
112
+ export const DEFAULT_OPTIONS: GenerateScheduleOptions = {
113
+ strategy: 'IfcBuildingStorey',
114
+ startDate: defaultStartDate(),
115
+ daysPerGroup: 5,
116
+ lagDays: 0,
117
+ order: 'bottom-up',
118
+ skipEmptyGroups: true,
119
+ linkSequences: true,
120
+ scheduleName: 'Construction schedule',
121
+ predefinedType: 'CONSTRUCTION',
122
+ heightTolerance: 3.0,
123
+ elementZSubgroup: 'none',
124
+ };
125
+
126
+ /**
127
+ * Optional geometry context the `IfcElement` strategy needs to partition
128
+ * elements by their actual Z elevation. Meshes carry GLOBAL expressIds
129
+ * (post-federation offset); the generator converts back to LOCAL ids
130
+ * using `idOffset` so downstream task.productExpressIds match the
131
+ * animator's local-space expectations.
132
+ */
133
+ export interface GenerateModelContext {
134
+ meshes: MeshData[];
135
+ idOffset: number;
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────────────────
139
+ // Helpers
140
+ // ─────────────────────────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Compute a reasonable default start time — today at 08:00 local — evaluated
144
+ * at call time (not module load) so dialog re-opens reflect the current day.
145
+ */
146
+ export function defaultStartDate(): string {
147
+ const d = new Date();
148
+ d.setHours(8, 0, 0, 0);
149
+ return toLocalIso(d);
150
+ }
151
+
152
+ /** Emit a local-timezone ISO datetime without the trailing Z. */
153
+ export function toLocalIso(d: Date): string {
154
+ const pad = (n: number) => n.toString().padStart(2, '0');
155
+ return (
156
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T` +
157
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
158
+ );
159
+ }
160
+
161
+ const MS_PER_DAY = 86_400_000;
162
+ const MS_PER_HOUR = 3_600_000;
163
+
164
+ function addMs(iso: string, ms: number): string {
165
+ const d = new Date(iso);
166
+ // Millisecond arithmetic preserves fractional days — `setDate()` with a
167
+ // fractional argument silently truncates.
168
+ d.setTime(d.getTime() + ms);
169
+ return toLocalIso(d);
170
+ }
171
+
172
+ /**
173
+ * Format a millisecond duration as ISO 8601. Prefers whole days when the
174
+ * value divides cleanly, then whole hours, else whole minutes, else
175
+ * whole seconds. Crucially, the *input* and the returned string describe
176
+ * the same number of milliseconds — so callers that emit `timeLagSeconds`
177
+ * alongside this string (the IfcRelSequence → IfcLagTime chain) never
178
+ * drift when `durationDays` / `lagDays` is fractional.
179
+ */
180
+ function msToIso8601Duration(ms: number): string {
181
+ if (ms <= 0) return 'PT0S';
182
+ if (ms % MS_PER_DAY === 0) return `P${ms / MS_PER_DAY}D`;
183
+ if (ms % MS_PER_HOUR === 0) return `PT${ms / MS_PER_HOUR}H`;
184
+ if (ms % 60_000 === 0) return `PT${ms / 60_000}M`;
185
+ return `PT${Math.round(ms / 1000)}S`;
186
+ }
187
+
188
+ /**
189
+ * Resolve the active IfcDataStore in federation-aware order:
190
+ * 1. explicit legacy single-model `ifcDataStore`
191
+ * 2. the user's current `activeModelId` selection
192
+ * 3. only when exactly one model is loaded → take it
193
+ * Declines to guess in ambiguous multi-model cases so we never operate on
194
+ * an arbitrary insertion-order pick.
195
+ */
196
+ export function resolveActiveDataStore(
197
+ ifcDataStore: IfcDataStore | null | undefined,
198
+ activeModelId: string | null | undefined,
199
+ models: Map<string, { ifcDataStore: IfcDataStore | null }>,
200
+ ): IfcDataStore | null {
201
+ if (ifcDataStore) return ifcDataStore;
202
+ if (activeModelId) {
203
+ const active = models.get(activeModelId);
204
+ if (active?.ifcDataStore) return active.ifcDataStore;
205
+ }
206
+ if (models.size === 1) {
207
+ return models.values().next().value?.ifcDataStore ?? null;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /** Resolve a spatial-container expressId → friendly name for the task label. */
213
+ function resolveName(store: IfcDataStore, expressId: number, fallback: string): string {
214
+ const name = store.entities?.getName?.(expressId);
215
+ return typeof name === 'string' && name.length > 0 ? name : fallback;
216
+ }
217
+
218
+ /** Read the entry's elevation from the hierarchy. Falls back to 0 when absent. */
219
+ function storeyElevation(store: IfcDataStore, storeyId: number): number {
220
+ return store.spatialHierarchy?.storeyElevations?.get(storeyId) ?? 0;
221
+ }
222
+
223
+ // ─────────────────────────────────────────────────────────────────────────
224
+ // Core
225
+ // ─────────────────────────────────────────────────────────────────────────
226
+
227
+ export function canGenerateScheduleFrom(
228
+ store: IfcDataStore | null | undefined,
229
+ /** Geometry is required only for the `IfcElement` strategy. */
230
+ modelContext?: GenerateModelContext | null,
231
+ ): boolean {
232
+ if (!store) return false;
233
+ const byStorey = store.spatialHierarchy?.byStorey;
234
+ const byBuilding = store.spatialHierarchy?.byBuilding;
235
+ const hasSpatial = (byStorey?.size ?? 0) > 0 || (byBuilding?.size ?? 0) > 0;
236
+ const hasMeshes = (modelContext?.meshes?.length ?? 0) > 0;
237
+ return hasSpatial || hasMeshes;
238
+ }
239
+
240
+ /**
241
+ * Build a schedule extraction from the model's spatial hierarchy *or* (when
242
+ * the strategy is `IfcElement`) from element geometry Z slices. Returns an
243
+ * `empty` preview when the chosen strategy has no data to work with.
244
+ */
245
+ export function generateScheduleFromSpatialHierarchy(
246
+ store: IfcDataStore | null | undefined,
247
+ options: GenerateScheduleOptions,
248
+ /** Required when `options.strategy === 'IfcElement'`. */
249
+ modelContext?: GenerateModelContext | null,
250
+ ): GeneratePreview {
251
+ if (!store) {
252
+ return emptyPreview(options);
253
+ }
254
+ if (options.strategy !== 'IfcElement' && !canGenerateScheduleFrom(store)) {
255
+ return emptyPreview(options);
256
+ }
257
+ if (options.strategy === 'IfcElement' && !modelContext?.meshes?.length) {
258
+ return emptyPreview(options);
259
+ }
260
+
261
+ const containers = options.strategy === 'IfcElement'
262
+ ? collectZSliceContainers(store, modelContext!, options)
263
+ : collectContainers(store, options);
264
+
265
+ if (containers.length === 0) {
266
+ return emptyPreview(options);
267
+ }
268
+
269
+ // Deterministic seeds: every generated GlobalId hashes the strategy + the
270
+ // involved containers' real IFC GlobalIds, so two models never collide.
271
+ const generatedSeed = `gen-${options.strategy}`;
272
+ const taskGlobalIdFor = (group: GroupEntry) =>
273
+ deterministicGlobalId(`${generatedSeed}|task|${group.sourceGlobalId}`);
274
+ const sequenceGlobalIdFor = (predecessor: GroupEntry, successor: GroupEntry) =>
275
+ deterministicGlobalId(
276
+ `${generatedSeed}|seq|${predecessor.sourceGlobalId}|${successor.sourceGlobalId}`,
277
+ );
278
+ const scheduleGlobalIdFor = (groups: GroupEntry[]) =>
279
+ deterministicGlobalId(
280
+ `${generatedSeed}|schedule|${groups.map(g => g.sourceGlobalId).join('|')}`,
281
+ );
282
+
283
+ // Layout the tasks on a calendar. The first group starts at `startDate`;
284
+ // every subsequent group begins `daysPerGroup + lagDays` after the prior
285
+ // group's start.
286
+ //
287
+ // Work in milliseconds and derive *everything else* (task dates, ISO
288
+ // durations, `timeLagSeconds`) from those same ms values. Earlier iterations
289
+ // computed `timeLagSeconds` exactly (`lagDays * 86_400`) while the ISO
290
+ // string rounded fractional days to hours — a 0.3-day lag came out as 25920
291
+ // seconds next to `PT7H` (25200 seconds). Using one ms quantity everywhere
292
+ // keeps the schedule dates and IFC durations byte-consistent.
293
+ const durationMs = Math.max(MS_PER_HOUR, Math.round(options.daysPerGroup * MS_PER_DAY));
294
+ const lagMs = Math.max(0, Math.round(options.lagDays * MS_PER_DAY));
295
+ const strideMs = durationMs + lagMs;
296
+ const durationIso = msToIso8601Duration(durationMs);
297
+ const lagIso = lagMs > 0 ? msToIso8601Duration(lagMs) : undefined;
298
+
299
+ const tasks: ScheduleTaskInfo[] = [];
300
+ const sequences: ScheduleSequenceInfo[] = [];
301
+ let productCount = 0;
302
+ let prevGroup: GroupEntry | null = null;
303
+ let prevTaskGlobalId: string | null = null;
304
+
305
+ containers.forEach((group, index) => {
306
+ const groupStart = addMs(options.startDate, index * strideMs);
307
+ const groupFinish = addMs(groupStart, durationMs);
308
+ const taskGlobalId = taskGlobalIdFor(group);
309
+
310
+ tasks.push({
311
+ expressId: 0,
312
+ globalId: taskGlobalId,
313
+ name: group.name,
314
+ identification: group.identification,
315
+ longDescription: group.description,
316
+ objectType: 'Generated',
317
+ isMilestone: false,
318
+ predefinedType: options.predefinedType,
319
+ taskTime: {
320
+ scheduleStart: groupStart,
321
+ scheduleFinish: groupFinish,
322
+ scheduleDuration: durationIso,
323
+ durationType: 'WORKTIME',
324
+ },
325
+ childGlobalIds: [],
326
+ productExpressIds: group.productExpressIds,
327
+ productGlobalIds: group.productGlobalIds,
328
+ controllingScheduleGlobalIds: [],
329
+ });
330
+
331
+ productCount += group.productExpressIds.length;
332
+
333
+ if (options.linkSequences && prevGroup && prevTaskGlobalId) {
334
+ sequences.push({
335
+ globalId: sequenceGlobalIdFor(prevGroup, group),
336
+ relatingTaskGlobalId: prevTaskGlobalId,
337
+ relatedTaskGlobalId: taskGlobalId,
338
+ sequenceType: 'FINISH_START',
339
+ timeLagSeconds: lagMs > 0 ? Math.round(lagMs / 1000) : undefined,
340
+ timeLagDuration: lagIso,
341
+ });
342
+ }
343
+ prevGroup = group;
344
+ prevTaskGlobalId = taskGlobalId;
345
+ });
346
+
347
+ const scheduleGlobalId = scheduleGlobalIdFor(containers);
348
+ const scheduleFinish = addMs(
349
+ options.startDate,
350
+ Math.max(0, containers.length - 1) * strideMs + durationMs,
351
+ );
352
+ const taskGlobalIds = tasks.map(t => t.globalId);
353
+ for (const task of tasks) task.controllingScheduleGlobalIds = [scheduleGlobalId];
354
+
355
+ const workSchedule: WorkScheduleInfo = {
356
+ expressId: 0,
357
+ globalId: scheduleGlobalId,
358
+ kind: 'WorkSchedule',
359
+ name: options.scheduleName,
360
+ description: describeStrategy(options),
361
+ // Deterministic — exports must be reproducible. Anchoring on `startDate`
362
+ // reflects "this schedule was authored for that start" without smearing a
363
+ // `new Date()` wall-clock stamp across re-runs.
364
+ creationDate: options.startDate,
365
+ startTime: options.startDate,
366
+ finishTime: scheduleFinish,
367
+ predefinedType: 'PLANNED',
368
+ taskGlobalIds,
369
+ };
370
+
371
+ return {
372
+ extraction: {
373
+ workSchedules: [workSchedule],
374
+ tasks,
375
+ sequences,
376
+ hasSchedule: true,
377
+ },
378
+ groupCount: containers.length,
379
+ productCount,
380
+ finishDate: scheduleFinish,
381
+ empty: false,
382
+ };
383
+ }
384
+
385
+ // ─────────────────────────────────────────────────────────────────────────
386
+ // Container collection
387
+ // ─────────────────────────────────────────────────────────────────────────
388
+
389
+ interface GroupEntry {
390
+ /** Display name from the spatial entity's Name attribute. */
391
+ name: string;
392
+ /** Falls back to '—' when absent. */
393
+ identification?: string;
394
+ /** Longer description, if the spatial hierarchy knows it. */
395
+ description?: string;
396
+ /** Local expressIds of all products contained by this group. */
397
+ productExpressIds: number[];
398
+ /** globalIds aligned with expressIds (empty string when unknown). */
399
+ productGlobalIds: string[];
400
+ /**
401
+ * The spatial container's own IFC GlobalId (falls back to `type#expressId`).
402
+ * Seeds the deterministic generated GlobalIds so two different models never
403
+ * emit colliding task IDs.
404
+ */
405
+ sourceGlobalId: string;
406
+ }
407
+
408
+ function collectContainers(
409
+ store: IfcDataStore,
410
+ options: GenerateScheduleOptions,
411
+ ): GroupEntry[] {
412
+ const hierarchy = store.spatialHierarchy;
413
+ if (!hierarchy) return [];
414
+
415
+ let groups: Array<{ expressId: number; entry: GroupEntry; elevation: number }> = [];
416
+
417
+ if (options.strategy === 'IfcBuildingStorey') {
418
+ for (const [storeyId, elementIds] of hierarchy.byStorey) {
419
+ if (options.skipEmptyGroups && elementIds.length === 0) continue;
420
+ groups.push({
421
+ expressId: storeyId,
422
+ entry: makeGroupEntry(store, storeyId, elementIds, 'Storey'),
423
+ elevation: storeyElevation(store, storeyId),
424
+ });
425
+ }
426
+ } else {
427
+ for (const [buildingId, elementIds] of hierarchy.byBuilding) {
428
+ if (options.skipEmptyGroups && elementIds.length === 0) continue;
429
+ groups.push({
430
+ expressId: buildingId,
431
+ entry: makeGroupEntry(store, buildingId, elementIds, 'Building'),
432
+ elevation: 0,
433
+ });
434
+ }
435
+ }
436
+
437
+ // Deterministic ordering: bottom-up by elevation (storeys) / insertion
438
+ // order (buildings); top-down reverses.
439
+ groups.sort((a, b) => {
440
+ if (options.strategy === 'IfcBuildingStorey') return a.elevation - b.elevation;
441
+ return 0;
442
+ });
443
+ if (options.order === 'top-down') groups.reverse();
444
+
445
+ return groups.map(g => g.entry);
446
+ }
447
+
448
+ function makeGroupEntry(
449
+ store: IfcDataStore,
450
+ containerId: number,
451
+ elementIds: number[],
452
+ fallbackPrefix: string,
453
+ ): GroupEntry {
454
+ const name = resolveName(store, containerId, `${fallbackPrefix} #${containerId}`);
455
+ const containerGlobalId = store.entities?.getGlobalId?.(containerId) ?? '';
456
+ const productGlobalIds: string[] = new Array(elementIds.length);
457
+ for (let i = 0; i < elementIds.length; i++) {
458
+ const gid = store.entities?.getGlobalId?.(elementIds[i]) ?? '';
459
+ productGlobalIds[i] = gid;
460
+ }
461
+ return {
462
+ name,
463
+ identification: undefined,
464
+ description: undefined,
465
+ productExpressIds: [...elementIds],
466
+ productGlobalIds,
467
+ // Always include the container's expressId so the seed is unique even
468
+ // if two storeys happen to report the same IFC GlobalId (seen in the
469
+ // wild with a malformed parser state — duplicates collapsed every
470
+ // storey to the same task globalId and cross-mapped products into the
471
+ // wrong task). expressId is authoritative per model; concatenating it
472
+ // with the GlobalId keeps the seed human-readable for debugging.
473
+ sourceGlobalId: `${containerGlobalId || fallbackPrefix}#${containerId}`,
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Collect groups by slicing the model's meshed elements into Z bands of
479
+ * `options.heightTolerance` metres. Optionally subdivides each band by
480
+ * element name / IFC class / type so schedules like "Floor 10 walls"
481
+ * and "Floor 10 slabs" are separate tasks.
482
+ *
483
+ * Why this exists: real IFC models frequently misattribute elements to the
484
+ * wrong storey in the spatial hierarchy (authoring tools pool everything
485
+ * under the ground-floor container). This path ignores the hierarchy
486
+ * entirely and partitions elements by their actual geometry — the only
487
+ * 100 %-reliable source of truth for where something physically is.
488
+ */
489
+ function collectZSliceContainers(
490
+ store: IfcDataStore,
491
+ modelContext: GenerateModelContext,
492
+ options: GenerateScheduleOptions,
493
+ ): GroupEntry[] {
494
+ const meshes = modelContext.meshes;
495
+ if (meshes.length === 0) return [];
496
+
497
+ const bandMetres = Math.max(0.1, options.heightTolerance);
498
+ const idOffset = modelContext.idOffset || 0;
499
+
500
+ // Scan every mesh once: compute min+max vertical, derive a centroid,
501
+ // keep the ifcType in hand. O(total vertex count) but cache-friendly
502
+ // because positions are a typed array.
503
+ //
504
+ // Vertical axis note: mesh positions are in WebGL Y-up space (the
505
+ // parser runs `convertZUpToYUp` on every mesh during collection, so
506
+ // IFC-native Z maps onto WebGL Y and what used to be IFC Y is now
507
+ // negated into WebGL Z). Reading the Y component (index 1) gives us
508
+ // the real "up" — reading Z here would bin by depth, not elevation,
509
+ // which looks like random noise at schedule time.
510
+ interface MeshMeta {
511
+ localId: number;
512
+ centroidY: number;
513
+ ifcType: string;
514
+ }
515
+ const meta: MeshMeta[] = [];
516
+ for (const mesh of meshes) {
517
+ if (!mesh.positions || mesh.positions.length < 3) continue;
518
+ let minY = Infinity;
519
+ let maxY = -Infinity;
520
+ // Vertical (Y in WebGL space) is every 3rd float starting at index 1.
521
+ for (let i = 1; i < mesh.positions.length; i += 3) {
522
+ const v = mesh.positions[i];
523
+ if (v < minY) minY = v;
524
+ if (v > maxY) maxY = v;
525
+ }
526
+ if (!Number.isFinite(minY) || !Number.isFinite(maxY)) continue;
527
+ meta.push({
528
+ localId: mesh.expressId - idOffset,
529
+ centroidY: (minY + maxY) * 0.5,
530
+ ifcType: mesh.ifcType ?? 'IfcElement',
531
+ });
532
+ }
533
+ if (meta.length === 0) return [];
534
+
535
+ // Bin by vertical coordinate. Bin indices are integers so two elements
536
+ // at identical height always land in the same bin regardless of
537
+ // floating-point drift.
538
+ const binOfY = (y: number) => Math.floor(y / bandMetres);
539
+
540
+ // Primary bin + optional sub-key → list of mesh metas.
541
+ // Bin key is "<bin><subkey>" so we can sort lexicographically
542
+ // after the bin portion is zero-padded to a fixed width.
543
+ const BIN_WIDTH = 8; // enough for 10^8 bins × 0.1 m = 10^7 m of range
544
+ const padBin = (b: number): string => {
545
+ const sign = b < 0 ? '-' : '+';
546
+ const abs = Math.abs(b).toString().padStart(BIN_WIDTH, '0');
547
+ return `${sign}${abs}`;
548
+ };
549
+
550
+ const subgroupKeyFor = (m: MeshMeta): string => {
551
+ switch (options.elementZSubgroup) {
552
+ case 'class':
553
+ return m.ifcType;
554
+ case 'type': {
555
+ const tn = store.entities?.getTypeName?.(m.localId) ?? '';
556
+ return tn || m.ifcType;
557
+ }
558
+ case 'name':
559
+ return store.entities?.getName?.(m.localId) ?? '';
560
+ default:
561
+ return '';
562
+ }
563
+ };
564
+
565
+ const groups = new Map<string, { bin: number; subkey: string; metas: MeshMeta[] }>();
566
+ for (const m of meta) {
567
+ const bin = binOfY(m.centroidY);
568
+ const subkey = subgroupKeyFor(m);
569
+ const key = `${padBin(bin)}${subkey}`;
570
+ let bucket = groups.get(key);
571
+ if (!bucket) {
572
+ bucket = { bin, subkey, metas: [] };
573
+ groups.set(key, bucket);
574
+ }
575
+ bucket.metas.push(m);
576
+ }
577
+
578
+ // Deterministic order: ascending bin, then lexicographic subkey. Reverse
579
+ // for top-down.
580
+ const sortedKeys = [...groups.keys()].sort();
581
+ if (options.order === 'top-down') sortedKeys.reverse();
582
+
583
+ const entries: GroupEntry[] = [];
584
+ for (const key of sortedKeys) {
585
+ const bucket = groups.get(key)!;
586
+ if (options.skipEmptyGroups && bucket.metas.length === 0) continue;
587
+
588
+ const localIds = bucket.metas.map(m => m.localId);
589
+ const productGlobalIds = localIds.map(id => store.entities?.getGlobalId?.(id) ?? '');
590
+
591
+ const zFrom = bucket.bin * bandMetres;
592
+ const zTo = zFrom + bandMetres;
593
+ const zLabel = `${formatZ(zFrom)} – ${formatZ(zTo)}`;
594
+ const displayName = bucket.subkey
595
+ ? `${bucket.subkey} · ${zLabel}`
596
+ : `Elements ${zLabel}`;
597
+
598
+ entries.push({
599
+ name: displayName,
600
+ identification: undefined,
601
+ description: `Elements with geometry Z in ${zLabel} (${localIds.length} item${localIds.length === 1 ? '' : 's'})`,
602
+ productExpressIds: localIds,
603
+ productGlobalIds,
604
+ // Seed for the deterministic task globalId — bin+subkey uniquely
605
+ // identifies the bucket across runs of the same model.
606
+ sourceGlobalId: `IfcElement#bin${padBin(bucket.bin)}|sub:${bucket.subkey}`,
607
+ });
608
+ }
609
+ return entries;
610
+ }
611
+
612
+ function formatZ(z: number): string {
613
+ // Two decimals is plenty for metre-scale bins; trims trailing zeros so
614
+ // "+3.00 m" reads as "+3 m" but "+3.25 m" keeps its precision.
615
+ const sign = z >= 0 ? '+' : '';
616
+ const rounded = Math.round(z * 100) / 100;
617
+ return `${sign}${rounded.toString()} m`; // NBSP to keep units together
618
+ }
619
+
620
+ function describeStrategy(options: GenerateScheduleOptions): string {
621
+ switch (options.strategy) {
622
+ case 'IfcBuildingStorey':
623
+ return 'Generated from building storeys';
624
+ case 'IfcBuilding':
625
+ return 'Generated from buildings';
626
+ case 'IfcElement': {
627
+ const subBy = options.elementZSubgroup === 'none'
628
+ ? ''
629
+ : `, grouped by ${options.elementZSubgroup}`;
630
+ return `Generated from element Z slices (${options.heightTolerance} m bands${subBy})`;
631
+ }
632
+ }
633
+ }
634
+
635
+ function emptyPreview(options: GenerateScheduleOptions): GeneratePreview {
636
+ return {
637
+ extraction: {
638
+ workSchedules: [],
639
+ tasks: [],
640
+ sequences: [],
641
+ hasSchedule: false,
642
+ },
643
+ groupCount: 0,
644
+ productCount: 0,
645
+ finishDate: options.startDate,
646
+ empty: true,
647
+ };
648
+ }