@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,488 @@
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
+ * Schedule animator — compute per-product RGBA overrides for the current
7
+ * playback time.
8
+ *
9
+ * Professional 4D tools (Synchro / Navisworks Timeliner / Fuzor) classify
10
+ * each product into a *lifecycle phase* relative to its controlling task and
11
+ * paint it according to (a) the task's PredefinedType and (b) the phase:
12
+ *
13
+ * upcoming — task hasn't started yet (optionally outside a
14
+ * "look-ahead" window: hidden. Inside the window: ghost)
15
+ * preparation — within `preparationDays` before start: ghost-blue
16
+ * @ ~25 % opacity; signals imminent work
17
+ * ramp-in — first `rampInFraction` of the task window: opacity
18
+ * animates 0 → 1 (ease-out), painted in type-colour
19
+ * active — middle of the task: solid task-type colour
20
+ * settling — last `fadeOutFraction` of the window: type-colour
21
+ * fades toward full transparency of *the override* (so
22
+ * the real material shows through), not the product
23
+ * complete — task finished: no override (material default)
24
+ *
25
+ * Demolition-like tasks (DEMOLITION / DISMANTLE / REMOVAL / DISPOSAL) invert
26
+ * the lifecycle — the product exists normally before the task, fades out
27
+ * with a red tint during the window, and is hidden afterwards.
28
+ *
29
+ * This module is pure. It reads a `ScheduleExtraction` + a time + settings
30
+ * and returns a Map<expressId, RGBA> plus a hidden set. The caller wires the
31
+ * Map into `pendingColorUpdates` and the hidden set into the visibility
32
+ * layer.
33
+ */
34
+
35
+ import type { ScheduleExtraction, ScheduleTaskInfo } from '@ifc-lite/parser';
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────
38
+ // Types
39
+ // ─────────────────────────────────────────────────────────────────────────
40
+
41
+ export type RGBA = [number, number, number, number];
42
+
43
+ /**
44
+ * All IfcTaskTypeEnum values (IFC4) plus an extra `PREPARATION` slot that
45
+ * applies to every task during its look-ahead window, regardless of type.
46
+ */
47
+ export type TaskPaletteKey =
48
+ | 'ATTENDANCE' | 'CONSTRUCTION' | 'DEMOLITION' | 'DISMANTLE'
49
+ | 'DISPOSAL' | 'INSTALLATION' | 'LOGISTIC' | 'MAINTENANCE'
50
+ | 'MOVE' | 'OPERATION' | 'REMOVAL' | 'RENOVATION'
51
+ | 'USERDEFINED' | 'NOTDEFINED'
52
+ | 'PREPARATION'
53
+ | 'COMPLETED';
54
+
55
+ export type TaskPalette = Record<TaskPaletteKey, RGBA>;
56
+
57
+ /**
58
+ * Task-type palette aligned with Synchro's default conventions.
59
+ *
60
+ * RGB channels are chosen for decent contrast on both light and dark
61
+ * viewport backgrounds; **alpha is 1.0 for task-type entries** because the
62
+ * per-frame `paletteIntensity` setting (not the palette) is how users dial
63
+ * how strongly the override paints over the real material.
64
+ *
65
+ * PREPARATION is treated separately — it's a ghost outline, not an active
66
+ * paint, so it keeps its own alpha baked in.
67
+ */
68
+ export const DEFAULT_PALETTE: TaskPalette = {
69
+ CONSTRUCTION: [0.34, 0.76, 0.39, 1.0], // emerald green
70
+ INSTALLATION: [0.40, 0.60, 0.95, 1.0], // royal blue
71
+ DEMOLITION: [0.88, 0.27, 0.27, 1.0], // red
72
+ DISMANTLE: [0.97, 0.55, 0.16, 1.0], // orange
73
+ REMOVAL: [0.86, 0.40, 0.25, 1.0], // red-orange
74
+ DISPOSAL: [0.55, 0.35, 0.18, 1.0], // brown
75
+ RENOVATION: [0.62, 0.42, 0.88, 1.0], // purple
76
+ MAINTENANCE: [0.92, 0.80, 0.22, 1.0], // amber
77
+ LOGISTIC: [0.32, 0.78, 0.85, 1.0], // cyan
78
+ MOVE: [0.58, 0.66, 0.90, 1.0], // lavender
79
+ OPERATION: [0.45, 0.80, 0.58, 1.0], // teal-green
80
+ ATTENDANCE: [0.75, 0.75, 0.80, 1.0], // cool grey
81
+ USERDEFINED: [0.55, 0.72, 0.86, 1.0], // soft blue
82
+ NOTDEFINED: [0.70, 0.70, 0.70, 1.0], // neutral grey
83
+ // The preparation *ghost* is a dark NEUTRAL colour at moderate alpha.
84
+ // Now that the overlay pipeline has real src-alpha blending AND skips the
85
+ // glass-fresnel path (flags.x bit 1), a 0.55-alpha dark overlay composites
86
+ // as a clear dim silhouette on both light and dark viewport themes — you
87
+ // see the underlying material through it but it reads as pending/ghosted.
88
+ PREPARATION: [0.15, 0.17, 0.22, 0.55],
89
+ // "Completed" is an opt-in tint for built-but-static products so users can
90
+ // visually separate "already built" from "never-touched / untaskd". A
91
+ // muted cool grey at low alpha doesn't recolour the material heavily but
92
+ // clearly distinguishes it from material-default renders. Off by default.
93
+ COMPLETED: [0.55, 0.58, 0.62, 0.25],
94
+ };
95
+
96
+ /**
97
+ * Enum values that represent "removing" work — products should be visible
98
+ * before the task and faded/hidden after. IFC4's IfcTaskTypeEnum identifies
99
+ * these explicitly; MOVE/RENOVATION are *not* removal by themselves.
100
+ */
101
+ const REMOVAL_TASK_TYPES: ReadonlySet<string> = new Set([
102
+ 'DEMOLITION', 'DISMANTLE', 'REMOVAL', 'DISPOSAL',
103
+ ]);
104
+
105
+ /**
106
+ * Distinguishable lifecycle phase names. Callers don't need to branch on
107
+ * these directly; they're exposed so UI can report a count per phase.
108
+ */
109
+ export type LifecyclePhase =
110
+ | 'upcoming-far' // not within look-ahead window
111
+ | 'upcoming-preparation' // within look-ahead window
112
+ | 'active-ramp-in'
113
+ | 'active'
114
+ | 'active-settling'
115
+ | 'complete'
116
+ | 'removal-active' // fading out during a demolition task
117
+ | 'removal-complete'; // already demolished
118
+
119
+ export interface AnimationSettings {
120
+ /**
121
+ * Whether any colour overlays paint over the base material during
122
+ * playback. Derived at the animator: true when ANY of
123
+ * `colorizeByTaskType` / `showPreparationGhost` / `showCompletedTint`
124
+ * is on. The Popover's "Minimal" / "Phased" tiles read + write the
125
+ * underlying flags as presets; there is no separate `style` mode.
126
+ *
127
+ * Earlier revisions stored `style: 'minimal' | 'phased'` alongside
128
+ * these flags — two ways to say the same thing invited drift. Now
129
+ * the flags are the source of truth.
130
+ */
131
+ /** Days before task start that the preparation ghost is shown. */
132
+ preparationDays: number;
133
+ /** Fraction of the task window used for the ramp-in (0..0.5). */
134
+ rampInFraction: number;
135
+ /** Fraction of the task window used for the settling fade (0..0.5). */
136
+ fadeOutFraction: number;
137
+ /** Show ghost-blue preparation outline during the look-ahead window. */
138
+ showPreparationGhost: boolean;
139
+ /** Apply task-type colour during active phase. */
140
+ colorizeByTaskType: boolean;
141
+ /**
142
+ * 0 = no palette override (pure visibility-timing mode even inside
143
+ * `'phased'` style); 1 = full palette alpha. Multiplies the emitted
144
+ * RGBA alpha for every active / ramp-in / settling / removal phase so
145
+ * users can dial "subtle tint" vs. "saturated highlight" without
146
+ * editing the palette itself. Does not affect the PREPARATION ghost,
147
+ * which keeps its own baked alpha.
148
+ */
149
+ paletteIntensity: number;
150
+ /** Animate DEMOLITION / DISMANTLE / REMOVAL / DISPOSAL as inverted fade. */
151
+ animateDemolition: boolean;
152
+ /**
153
+ * Hide products whose task hasn't started and isn't in the preparation
154
+ * window. When false, they render with the material default — useful if
155
+ * the user wants to see the whole model at once with colour-coding only.
156
+ */
157
+ hideBeforePreparation: boolean;
158
+ /**
159
+ * Hide products that have NO controlling task in the active schedule.
160
+ *
161
+ * Without this, partial schedule coverage (e.g. an LLM-generated sequence
162
+ * that only covers the bottom floors, or a schedule whose tasks don't
163
+ * reach every product) leaves untaskd products rendering as their
164
+ * material default — which on bare concrete IFC reads as pure white and
165
+ * dominates the viewport. On by default because "untaskd" during an
166
+ * active construction animation effectively means "not yet built": the
167
+ * animation looks cleanest when those products simply don't exist yet.
168
+ *
169
+ * Turn off to show the whole model with only the scheduled portion
170
+ * highlighted (useful during schedule authoring so the user can still
171
+ * see everything they haven't taskd).
172
+ */
173
+ hideUntaskedProducts: boolean;
174
+ /**
175
+ * Apply the COMPLETED palette tint to products whose task has finished.
176
+ * Off by default — completed products render as material-default, which
177
+ * is the Synchro/Navisworks convention ("done = the real model"). Turn
178
+ * on when you want a persistent visual distinction between built and
179
+ * untaskd products.
180
+ */
181
+ showCompletedTint: boolean;
182
+ /** RGBA palette indexed by TaskPaletteKey. Defaults to DEFAULT_PALETTE. */
183
+ palette: TaskPalette;
184
+ }
185
+
186
+ export const DEFAULT_ANIMATION_SETTINGS: AnimationSettings = {
187
+ // Defaults to pure visibility-timing with zero colour overlays — the
188
+ // "Minimal" preset from the popover. Users who load a model should
189
+ // see it animate cleanly by default without any palette cast on top
190
+ // of their authoring. Every colour flag is OFF; the "Phased" preset
191
+ // in the popover flips `colorizeByTaskType` + raises
192
+ // `paletteIntensity`.
193
+ preparationDays: 2,
194
+ rampInFraction: 0.08,
195
+ fadeOutFraction: 0.10,
196
+ showPreparationGhost: false,
197
+ colorizeByTaskType: false,
198
+ // 0 = no palette tint even if `colorizeByTaskType` is flipped later
199
+ // by other code; the "Phased" preset bumps this to 0.6 (middle-ground)
200
+ // when the user selects it.
201
+ paletteIntensity: 0,
202
+ animateDemolition: true,
203
+ hideBeforePreparation: true,
204
+ hideUntaskedProducts: true,
205
+ showCompletedTint: false,
206
+ palette: DEFAULT_PALETTE,
207
+ };
208
+
209
+ export interface AnimationFrame {
210
+ /** Per-expressId RGBA overrides for `scene.setColorOverrides`. */
211
+ colorOverrides: Map<number, RGBA>;
212
+ /** Products that should be *fully hidden* for this frame (upcoming-far). */
213
+ hiddenIds: Set<number>;
214
+ /** Human-readable per-phase counts for a debug / UI readout. */
215
+ stats: Record<LifecyclePhase, number>;
216
+ }
217
+
218
+ // ─────────────────────────────────────────────────────────────────────────
219
+ // Helpers
220
+ // ─────────────────────────────────────────────────────────────────────────
221
+
222
+ /** Classic cubic ease-out, same shape used by the camera animator. */
223
+ function easeOutCubic(t: number): number {
224
+ const clamped = Math.min(1, Math.max(0, t));
225
+ return 1 - Math.pow(1 - clamped, 3);
226
+ }
227
+
228
+ /** Mirror — used by the settling-phase fade so the override dissolves. */
229
+ function easeInCubic(t: number): number {
230
+ const clamped = Math.min(1, Math.max(0, t));
231
+ return clamped * clamped * clamped;
232
+ }
233
+
234
+ /**
235
+ * Parse an ISO 8601 datetime → epoch ms. Identical to
236
+ * `scheduleSlice.parseIsoDate`. We keep a local copy rather than importing
237
+ * from `@/store` because `scheduleSlice` already imports from this module
238
+ * for `AnimationSettings` / `DEFAULT_ANIMATION_SETTINGS` — sharing the
239
+ * helper through `@/store` closes the loop and breaks ESM initialisation
240
+ * order ("Cannot access X before initialization"). Any fix to TZ-less
241
+ * normalization must land in both copies; linked via comment.
242
+ */
243
+ function parseEpoch(value: string | undefined): number | undefined {
244
+ if (!value) return undefined;
245
+ const hasTz = /Z$|[+-]\d{2}:?\d{2}$/.test(value);
246
+ const t = Date.parse(hasTz ? value : `${value}Z`);
247
+ return Number.isNaN(t) ? undefined : t;
248
+ }
249
+
250
+ function taskWindow(task: ScheduleTaskInfo): { start: number; finish: number } | null {
251
+ const start = parseEpoch(task.taskTime?.scheduleStart ?? task.taskTime?.actualStart);
252
+ const finish = parseEpoch(task.taskTime?.scheduleFinish ?? task.taskTime?.actualFinish);
253
+ if (start === undefined || finish === undefined || finish < start) return null;
254
+ return { start, finish };
255
+ }
256
+
257
+ function resolvePaletteKey(predefinedType: string | undefined): TaskPaletteKey {
258
+ if (!predefinedType) return 'NOTDEFINED';
259
+ const upper = predefinedType.toUpperCase();
260
+ if (upper in DEFAULT_PALETTE) return upper as TaskPaletteKey;
261
+ return 'USERDEFINED';
262
+ }
263
+
264
+ function isRemovalTask(task: ScheduleTaskInfo): boolean {
265
+ return REMOVAL_TASK_TYPES.has((task.predefinedType ?? '').toUpperCase());
266
+ }
267
+
268
+ /** Merge RGBA b over a using the `a.alpha` and `b.alpha` terms — standard
269
+ * "src-over" compositing. Only used for emptyPhase initialization. */
270
+ function withAlpha(rgba: RGBA, alpha: number): RGBA {
271
+ return [rgba[0], rgba[1], rgba[2], Math.min(1, Math.max(0, alpha))];
272
+ }
273
+
274
+ const MS_PER_DAY = 86_400_000;
275
+
276
+ // ─────────────────────────────────────────────────────────────────────────
277
+ // Per-task phase computation
278
+ // ─────────────────────────────────────────────────────────────────────────
279
+
280
+ interface PhaseResult {
281
+ phase: LifecyclePhase;
282
+ /** RGBA to apply (undefined = no override = render material default). */
283
+ color?: RGBA;
284
+ /** True if the product should be hidden instead of coloured. */
285
+ hide: boolean;
286
+ }
287
+
288
+ function computeTaskPhase(
289
+ task: ScheduleTaskInfo,
290
+ playbackTime: number,
291
+ settings: AnimationSettings,
292
+ ): PhaseResult | null {
293
+ const win = taskWindow(task);
294
+ if (!win) return null;
295
+ const { start, finish } = win;
296
+ const duration = Math.max(1, finish - start);
297
+ const prepStart = start - settings.preparationDays * MS_PER_DAY;
298
+ const typeKey = resolvePaletteKey(task.predefinedType);
299
+ const typeColor = settings.palette[typeKey] ?? settings.palette.NOTDEFINED;
300
+ const prepColor = settings.palette.PREPARATION;
301
+ // Clamp once; 0 disables task-type painting entirely even in 'phased'.
302
+ const intensity = Math.min(1, Math.max(0, settings.paletteIntensity));
303
+ // Helper: palette alpha × user intensity. Kept inline rather than lifted
304
+ // to the module scope because it closes over `typeColor[3]`.
305
+ const paint = (alpha: number): RGBA => [
306
+ typeColor[0], typeColor[1], typeColor[2],
307
+ Math.min(1, Math.max(0, alpha * typeColor[3] * intensity)),
308
+ ];
309
+
310
+ // ── Removal-like tasks invert the lifecycle ──────────────────────────
311
+ if (settings.animateDemolition && isRemovalTask(task)) {
312
+ if (playbackTime < start) {
313
+ // Before demolition starts — product exists normally.
314
+ return { phase: 'upcoming-far', hide: false };
315
+ }
316
+ if (playbackTime > finish) {
317
+ return { phase: 'removal-complete', hide: true };
318
+ }
319
+ const p = (playbackTime - start) / duration;
320
+ // Fade red tint in and overall override alpha out. Intensity modulates
321
+ // the peak — `intensity=0` means the tint is never visible (the product
322
+ // simply disappears at `finish`).
323
+ return { phase: 'removal-active', hide: false, color: paint(1 - easeInCubic(p)) };
324
+ }
325
+
326
+ // ── Standard construction lifecycle ──────────────────────────────────
327
+ if (playbackTime < prepStart) {
328
+ return {
329
+ phase: 'upcoming-far',
330
+ hide: settings.hideBeforePreparation,
331
+ };
332
+ }
333
+
334
+ if (playbackTime < start) {
335
+ if (!settings.showPreparationGhost) {
336
+ return { phase: 'upcoming-preparation', hide: settings.hideBeforePreparation };
337
+ }
338
+ return { phase: 'upcoming-preparation', hide: false, color: prepColor };
339
+ }
340
+
341
+ if (playbackTime <= finish) {
342
+ // colorizeByTaskType=false → phase is tracked for stats but no override;
343
+ // paletteIntensity=0 is the same effect without changing the toggle.
344
+ if (!settings.colorizeByTaskType || intensity === 0) {
345
+ return { phase: 'active', hide: false };
346
+ }
347
+ const p = (playbackTime - start) / duration;
348
+ if (p < settings.rampInFraction) {
349
+ const t = p / Math.max(0.001, settings.rampInFraction);
350
+ return {
351
+ phase: 'active-ramp-in',
352
+ hide: false,
353
+ color: paint(easeOutCubic(t)),
354
+ };
355
+ }
356
+ if (p > 1 - settings.fadeOutFraction) {
357
+ const t = (p - (1 - settings.fadeOutFraction)) / Math.max(0.001, settings.fadeOutFraction);
358
+ return {
359
+ phase: 'active-settling',
360
+ hide: false,
361
+ color: paint(1 - easeInCubic(t)),
362
+ };
363
+ }
364
+ return { phase: 'active', hide: false, color: paint(1) };
365
+ }
366
+
367
+ // After finish — material default, or an explicit "completed" tint when
368
+ // the user wants built products visually distinguished from untaskd ones.
369
+ if (settings.showCompletedTint) {
370
+ const completedColor = settings.palette.COMPLETED;
371
+ return { phase: 'complete', hide: false, color: completedColor };
372
+ }
373
+ return { phase: 'complete', hide: false };
374
+ }
375
+
376
+ // ─────────────────────────────────────────────────────────────────────────
377
+ // Public — computeAnimationFrame
378
+ // ─────────────────────────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Compute a frame of animation for the given playback time.
382
+ *
383
+ * Products with multiple controlling tasks take the *most-relevant* one:
384
+ * an active/ramp/settling task wins over preparation, which wins over
385
+ * upcoming-far, which wins over complete. Removal tasks are always
386
+ * preferred over construction tasks (their span dominates the product's
387
+ * visual state).
388
+ */
389
+ export function computeAnimationFrame(
390
+ data: ScheduleExtraction | null,
391
+ playbackTime: number,
392
+ settings: AnimationSettings,
393
+ scheduleGlobalId?: string | null,
394
+ /**
395
+ * Universe of product expressIds in the model(s). When
396
+ * `settings.hideUntaskedProducts` is true, every id in this iterable that
397
+ * isn't covered by any in-scope task is added to `hiddenIds` so the caller
398
+ * doesn't render material-default for coverage gaps. Leave undefined when
399
+ * the caller already handles untasked products separately — the animator
400
+ * falls back to task-only hiding.
401
+ */
402
+ allProductIds?: Iterable<number>,
403
+ ): AnimationFrame {
404
+ const colorOverrides = new Map<number, RGBA>();
405
+ const hiddenIds = new Set<number>();
406
+ const stats: Record<LifecyclePhase, number> = {
407
+ 'upcoming-far': 0,
408
+ 'upcoming-preparation': 0,
409
+ 'active-ramp-in': 0,
410
+ 'active': 0,
411
+ 'active-settling': 0,
412
+ 'complete': 0,
413
+ 'removal-active': 0,
414
+ 'removal-complete': 0,
415
+ };
416
+
417
+ if (!data || data.tasks.length === 0) {
418
+ return { colorOverrides, hiddenIds, stats };
419
+ }
420
+
421
+ /** Phase priority used to pick the "winning" task for a product. */
422
+ const phasePriority: Record<LifecyclePhase, number> = {
423
+ 'removal-active': 90,
424
+ 'removal-complete': 80,
425
+ 'active-ramp-in': 70,
426
+ 'active-settling': 65,
427
+ 'active': 60,
428
+ 'upcoming-preparation': 40,
429
+ 'upcoming-far': 20,
430
+ 'complete': 10,
431
+ };
432
+
433
+ // Two-layer separation: *timing* (hiddenIds) is always emitted so
434
+ // minimal-mode still removes demolished products and hides upcoming
435
+ // ones. *Colour overlays* only emit when at least one colour-overlay
436
+ // feature is enabled — the "minimal" preset in the popover simply
437
+ // leaves all three flags off.
438
+ const emitColours =
439
+ settings.colorizeByTaskType
440
+ || settings.showPreparationGhost
441
+ || settings.showCompletedTint;
442
+
443
+ /** Per-product chosen phase so we resolve multi-task conflicts. */
444
+ const chosenByProduct = new Map<number, PhaseResult>();
445
+
446
+ for (const task of data.tasks) {
447
+ if (scheduleGlobalId
448
+ && task.controllingScheduleGlobalIds.length > 0
449
+ && !task.controllingScheduleGlobalIds.includes(scheduleGlobalId)) {
450
+ continue;
451
+ }
452
+ if (task.productExpressIds.length === 0) continue;
453
+ const phase = computeTaskPhase(task, playbackTime, settings);
454
+ if (!phase) continue;
455
+
456
+ for (const id of task.productExpressIds) {
457
+ const existing = chosenByProduct.get(id);
458
+ if (!existing || phasePriority[phase.phase] > phasePriority[existing.phase]) {
459
+ chosenByProduct.set(id, phase);
460
+ }
461
+ }
462
+ }
463
+
464
+ for (const [id, result] of chosenByProduct) {
465
+ stats[result.phase] += 1;
466
+ if (result.hide) {
467
+ hiddenIds.add(id);
468
+ continue;
469
+ }
470
+ if (emitColours && result.color) {
471
+ colorOverrides.set(id, result.color);
472
+ }
473
+ }
474
+
475
+ // Untasked-product hide: anything in the model universe that no in-scope
476
+ // task touched is treated as "not yet built" during animation. Without
477
+ // this, partial schedule coverage leaves the unscheduled portion of the
478
+ // model rendering as material default — which for bare concrete IFC reads
479
+ // as pure white and dominates the viewport. `chosenByProduct` is our
480
+ // tasked-set for the current filter; everything else gets hidden.
481
+ if (settings.hideUntaskedProducts && allProductIds) {
482
+ for (const id of allProductIds) {
483
+ if (!chosenByProduct.has(id)) hiddenIds.add(id);
484
+ }
485
+ }
486
+
487
+ return { colorOverrides, hiddenIds, stats };
488
+ }
@@ -0,0 +1,148 @@
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
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import type { ScheduleExtraction, ScheduleTaskInfo } from '@ifc-lite/parser';
8
+ import {
9
+ collectProductLocalIdsForTasks,
10
+ findTaskForProductGlobalId,
11
+ findTaskForProductGlobalIdWithLocal,
12
+ } from './schedule-selection.js';
13
+
14
+ function task(over: Partial<ScheduleTaskInfo>): ScheduleTaskInfo {
15
+ return {
16
+ expressId: 0,
17
+ globalId: 'T',
18
+ name: 'Task',
19
+ isMilestone: false,
20
+ childGlobalIds: [],
21
+ productExpressIds: [],
22
+ productGlobalIds: [],
23
+ controllingScheduleGlobalIds: [],
24
+ ...over,
25
+ };
26
+ }
27
+
28
+ function schedule(tasks: ScheduleTaskInfo[]): ScheduleExtraction {
29
+ return { hasSchedule: true, workSchedules: [], sequences: [], tasks };
30
+ }
31
+
32
+ describe('collectProductLocalIdsForTasks', () => {
33
+ it('returns empty set for null data', () => {
34
+ const out = collectProductLocalIdsForTasks(null, ['x']);
35
+ assert.equal(out.size, 0);
36
+ });
37
+
38
+ it('returns the task own products for a leaf selection', () => {
39
+ const data = schedule([task({ globalId: 'leaf', productExpressIds: [1, 2, 3] })]);
40
+ const out = collectProductLocalIdsForTasks(data, ['leaf']);
41
+ assert.deepEqual(Array.from(out).sort((a, b) => a - b), [1, 2, 3]);
42
+ });
43
+
44
+ it('unions descendant products when a parent is selected', () => {
45
+ const data = schedule([
46
+ task({ globalId: 'parent', childGlobalIds: ['childA', 'childB'], productExpressIds: [10] }),
47
+ task({ globalId: 'childA', productExpressIds: [20, 21] }),
48
+ task({ globalId: 'childB', productExpressIds: [30], childGlobalIds: ['leaf'] }),
49
+ task({ globalId: 'leaf', productExpressIds: [40] }),
50
+ ]);
51
+ const out = collectProductLocalIdsForTasks(data, ['parent']);
52
+ assert.deepEqual(Array.from(out).sort((a, b) => a - b), [10, 20, 21, 30, 40]);
53
+ });
54
+
55
+ it('is idempotent when multiple selected tasks overlap via children', () => {
56
+ const data = schedule([
57
+ task({ globalId: 'A', childGlobalIds: ['shared'], productExpressIds: [1] }),
58
+ task({ globalId: 'B', childGlobalIds: ['shared'], productExpressIds: [2] }),
59
+ task({ globalId: 'shared', productExpressIds: [99] }),
60
+ ]);
61
+ const out = collectProductLocalIdsForTasks(data, ['A', 'B']);
62
+ assert.deepEqual(Array.from(out).sort((a, b) => a - b), [1, 2, 99]);
63
+ });
64
+
65
+ it('defends against cyclic childGlobalIds', () => {
66
+ const data = schedule([
67
+ task({ globalId: 'A', childGlobalIds: ['B'], productExpressIds: [1] }),
68
+ task({ globalId: 'B', childGlobalIds: ['A'], productExpressIds: [2] }),
69
+ ]);
70
+ const out = collectProductLocalIdsForTasks(data, ['A']);
71
+ // Cycle must not hang and must visit each task exactly once.
72
+ assert.deepEqual(Array.from(out).sort((a, b) => a - b), [1, 2]);
73
+ });
74
+
75
+ it('skips unknown globalIds without throwing', () => {
76
+ const data = schedule([task({ globalId: 'A', productExpressIds: [1] })]);
77
+ const out = collectProductLocalIdsForTasks(data, ['A', 'does-not-exist']);
78
+ assert.deepEqual(Array.from(out), [1]);
79
+ });
80
+ });
81
+
82
+ describe('findTaskForProductGlobalId', () => {
83
+ const data = schedule([
84
+ task({ globalId: 'root', childGlobalIds: ['parent'] }),
85
+ task({
86
+ globalId: 'parent',
87
+ parentGlobalId: 'root',
88
+ childGlobalIds: ['hit', 'other'],
89
+ }),
90
+ task({
91
+ globalId: 'hit',
92
+ parentGlobalId: 'parent',
93
+ productExpressIds: [42],
94
+ productGlobalIds: ['42', '43'],
95
+ }),
96
+ task({
97
+ globalId: 'other',
98
+ parentGlobalId: 'parent',
99
+ productExpressIds: [99],
100
+ productGlobalIds: ['99'],
101
+ }),
102
+ ]);
103
+
104
+ it('returns the owning task and its ancestor chain (fast path via productGlobalIds)', () => {
105
+ const result = findTaskForProductGlobalId(data, 42);
106
+ assert.ok(result);
107
+ assert.equal(result.taskGlobalId, 'hit');
108
+ assert.deepEqual(result.ancestorGlobalIds, ['root', 'parent']);
109
+ });
110
+
111
+ it('returns null when no task owns the product', () => {
112
+ const result = findTaskForProductGlobalId(data, 777);
113
+ assert.equal(result, null);
114
+ });
115
+
116
+ it('returns null for null schedule data', () => {
117
+ const result = findTaskForProductGlobalId(null, 42);
118
+ assert.equal(result, null);
119
+ });
120
+ });
121
+
122
+ describe('findTaskForProductGlobalIdWithLocal', () => {
123
+ // Extracted (non-generated) schedules have empty productGlobalIds — we
124
+ // must be able to translate a global → local and still find the owner.
125
+ const data = schedule([
126
+ task({ globalId: 'only', productExpressIds: [42], productGlobalIds: [] }),
127
+ ]);
128
+
129
+ it('falls back to the local-id scan when productGlobalIds is empty', () => {
130
+ const result = findTaskForProductGlobalIdWithLocal(
131
+ data,
132
+ 1042, // global
133
+ (g) => g - 1000, // idOffset = 1000
134
+ );
135
+ assert.ok(result);
136
+ assert.equal(result.taskGlobalId, 'only');
137
+ assert.deepEqual(result.ancestorGlobalIds, []);
138
+ });
139
+
140
+ it('returns null when the local translator yields nothing or the scan misses', () => {
141
+ const result = findTaskForProductGlobalIdWithLocal(
142
+ data,
143
+ 9999,
144
+ () => undefined,
145
+ );
146
+ assert.equal(result, null);
147
+ });
148
+ });