@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
@@ -0,0 +1,164 @@
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
+ * Compositor reconciliation logic — unit-tested in isolation from React.
7
+ *
8
+ * The `useOverlayCompositor` hook's reconciliation loop (what-we-wrote vs.
9
+ * what-the-layers-want + user-isolation preservation) is extracted from
10
+ * the React shape so we can assert the delta math without a renderer.
11
+ */
12
+
13
+ import { describe, it } from 'node:test';
14
+ import assert from 'node:assert';
15
+ import { composeLayers, type OverlayLayer, type RGBA } from './overlaySlice.js';
16
+
17
+ /** Same logic as `useOverlayCompositor`, without React. */
18
+ function reconcile(args: {
19
+ prevContributedHidden: Map<number, boolean>;
20
+ prevContributedColors: Set<number>;
21
+ layers: Map<string, OverlayLayer>;
22
+ currentlyHidden: Set<number>;
23
+ }): {
24
+ hideDelta: number[];
25
+ showDelta: number[];
26
+ nextColors: Map<number, RGBA> | 'clear' | 'unchanged';
27
+ nextContributedHidden: Map<number, boolean>;
28
+ nextContributedColors: Set<number>;
29
+ } {
30
+ const { hiddenIds: nextHidden, colorOverrides: nextColors } = composeLayers(args.layers);
31
+
32
+ // Hidden delta — unhide only ids whose "was already hidden by user"
33
+ // bit is false (we own them).
34
+ const showDelta: number[] = [];
35
+ for (const [id, wasHidden] of args.prevContributedHidden) {
36
+ if (!nextHidden.has(id) && wasHidden === false) showDelta.push(id);
37
+ }
38
+ const hideDelta: number[] = [];
39
+ const nextContributedHidden = new Map<number, boolean>();
40
+ for (const id of nextHidden) {
41
+ if (args.prevContributedHidden.has(id)) {
42
+ nextContributedHidden.set(id, args.prevContributedHidden.get(id)!);
43
+ } else {
44
+ const wasHidden = args.currentlyHidden.has(id);
45
+ nextContributedHidden.set(id, wasHidden);
46
+ if (!wasHidden) hideDelta.push(id);
47
+ }
48
+ }
49
+
50
+ // Colours are all-or-nothing.
51
+ let nextColorsResult: Map<number, RGBA> | 'clear' | 'unchanged';
52
+ let nextContributedColors: Set<number>;
53
+ if (nextColors.size > 0) {
54
+ nextColorsResult = nextColors;
55
+ nextContributedColors = new Set(nextColors.keys());
56
+ } else if (args.prevContributedColors.size > 0) {
57
+ nextColorsResult = 'clear';
58
+ nextContributedColors = new Set();
59
+ } else {
60
+ nextColorsResult = 'unchanged';
61
+ nextContributedColors = new Set();
62
+ }
63
+
64
+ return {
65
+ hideDelta,
66
+ showDelta,
67
+ nextColors: nextColorsResult,
68
+ nextContributedHidden,
69
+ nextContributedColors,
70
+ };
71
+ }
72
+
73
+ const RED: RGBA = [1, 0, 0, 1];
74
+
75
+ function mkLayer(id: string, priority: number, opts: {
76
+ hide?: Iterable<number>;
77
+ colour?: Iterable<[number, RGBA]>;
78
+ } = {}): OverlayLayer {
79
+ return {
80
+ id,
81
+ priority,
82
+ hiddenIds: opts.hide ? new Set(opts.hide) : null,
83
+ colorOverrides: opts.colour ? new Map(opts.colour) : null,
84
+ };
85
+ }
86
+
87
+ describe('overlay compositor — reconciliation', () => {
88
+ it("preserves user's prior isolation — doesn't unhide ids the user had hidden first", () => {
89
+ // User had id 5 already hidden (via class filter, say). Animation
90
+ // layer then registers it too.
91
+ const r1 = reconcile({
92
+ prevContributedHidden: new Map(),
93
+ prevContributedColors: new Set(),
94
+ layers: new Map([['animation', mkLayer('animation', 100, { hide: [5] })]]),
95
+ currentlyHidden: new Set([5]), // user already hid this
96
+ });
97
+ // We don't re-hide (hideEntities would be a no-op anyway, but we
98
+ // track "was already hidden" = true so we don't unhide on teardown).
99
+ assert.deepStrictEqual(r1.hideDelta, []);
100
+ assert.strictEqual(r1.nextContributedHidden.get(5), true);
101
+
102
+ // Layer goes away — we must NOT unhide 5, the user still wants it hidden.
103
+ const r2 = reconcile({
104
+ prevContributedHidden: r1.nextContributedHidden,
105
+ prevContributedColors: new Set(),
106
+ layers: new Map(),
107
+ currentlyHidden: new Set([5]),
108
+ });
109
+ assert.deepStrictEqual(r2.showDelta, []);
110
+ });
111
+
112
+ it('colour overrides are full-replace — empty layers signal a clear exactly once', () => {
113
+ // Layer writes a colour; next tick layer is gone — we issue clear.
114
+ const r1 = reconcile({
115
+ prevContributedHidden: new Map(),
116
+ prevContributedColors: new Set(),
117
+ layers: new Map([['animation', mkLayer('animation', 100, { colour: [[5, RED]] })]]),
118
+ currentlyHidden: new Set(),
119
+ });
120
+ assert.notStrictEqual(r1.nextColors, 'clear');
121
+ assert.notStrictEqual(r1.nextColors, 'unchanged');
122
+
123
+ const r2 = reconcile({
124
+ prevContributedHidden: r1.nextContributedHidden,
125
+ prevContributedColors: r1.nextContributedColors,
126
+ layers: new Map(),
127
+ currentlyHidden: new Set(),
128
+ });
129
+ assert.strictEqual(r2.nextColors, 'clear');
130
+
131
+ // Subsequent reconcile with still-empty colours → 'unchanged' (no
132
+ // redundant clear).
133
+ const r3 = reconcile({
134
+ prevContributedHidden: r2.nextContributedHidden,
135
+ prevContributedColors: r2.nextContributedColors,
136
+ layers: new Map(),
137
+ currentlyHidden: new Set(),
138
+ });
139
+ assert.strictEqual(r3.nextColors, 'unchanged');
140
+ });
141
+
142
+ it('layer swap — adds new ids, removes dropped ids (owned only)', () => {
143
+ // Start: animation hides {1, 2}; user had 2 pre-hidden.
144
+ const r1 = reconcile({
145
+ prevContributedHidden: new Map(),
146
+ prevContributedColors: new Set(),
147
+ layers: new Map([['animation', mkLayer('animation', 100, { hide: [1, 2] })]]),
148
+ currentlyHidden: new Set([2]),
149
+ });
150
+ // Tick: layer now hides {2, 3}. Id 1 should be unhidden (we owned it).
151
+ // Id 2 stays hidden (user owns). Id 3 hides (new).
152
+ const r2 = reconcile({
153
+ prevContributedHidden: r1.nextContributedHidden,
154
+ prevContributedColors: new Set(),
155
+ layers: new Map([['animation', mkLayer('animation', 100, { hide: [2, 3] })]]),
156
+ currentlyHidden: new Set([1, 2]), // store state after r1's writes
157
+ });
158
+ assert.deepStrictEqual(r2.showDelta.sort((a, b) => a - b), [1]);
159
+ assert.deepStrictEqual(r2.hideDelta.sort((a, b) => a - b), [3]);
160
+ // After swap: id 2's ownership bit still says "user owns".
161
+ assert.strictEqual(r2.nextContributedHidden.get(2), true);
162
+ assert.strictEqual(r2.nextContributedHidden.get(3), false);
163
+ });
164
+ });
@@ -0,0 +1,93 @@
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';
7
+ import { create } from 'zustand';
8
+ import {
9
+ createOverlaySlice,
10
+ composeLayers,
11
+ type OverlaySlice,
12
+ type OverlayLayer,
13
+ type RGBA,
14
+ } from './overlaySlice.js';
15
+
16
+ const RED: RGBA = [1, 0, 0, 1];
17
+ const GREEN: RGBA = [0, 1, 0, 1];
18
+ const BLUE: RGBA = [0, 0, 1, 1];
19
+
20
+ function mkLayer(id: string, priority: number, opts: {
21
+ hide?: Iterable<number>;
22
+ colour?: Iterable<[number, RGBA]>;
23
+ } = {}): OverlayLayer {
24
+ return {
25
+ id,
26
+ priority,
27
+ hiddenIds: opts.hide ? new Set(opts.hide) : null,
28
+ colorOverrides: opts.colour ? new Map(opts.colour) : null,
29
+ };
30
+ }
31
+
32
+ describe('overlaySlice — composition', () => {
33
+ it('unions hiddenIds; higher-priority layer wins on colour collisions', () => {
34
+ // The two non-trivial algorithmic properties of the compositor in one
35
+ // test: union for visibility, priority-ordered overwrite for colour.
36
+ const layers = new Map<string, OverlayLayer>([
37
+ ['lens', mkLayer('lens', 50, { hide: [1], colour: [[5, RED]] })],
38
+ ['animation', mkLayer('animation', 100, { hide: [2, 3], colour: [[5, GREEN]] })],
39
+ ]);
40
+ const { hiddenIds, colorOverrides } = composeLayers(layers);
41
+ assert.deepStrictEqual(Array.from(hiddenIds).sort((a, b) => a - b), [1, 2, 3]);
42
+ assert.deepStrictEqual(colorOverrides.get(5), GREEN);
43
+ });
44
+
45
+ it('result is independent of Map insertion order', () => {
46
+ // Property test — we sort by priority internally, so whichever order
47
+ // the caller inserts layers the answer must match.
48
+ const ab = composeLayers(new Map<string, OverlayLayer>([
49
+ ['a', mkLayer('a', 100, { colour: [[1, RED]] })],
50
+ ['b', mkLayer('b', 200, { colour: [[1, BLUE]] })],
51
+ ]));
52
+ const ba = composeLayers(new Map<string, OverlayLayer>([
53
+ ['b', mkLayer('b', 200, { colour: [[1, BLUE]] })],
54
+ ['a', mkLayer('a', 100, { colour: [[1, RED]] })],
55
+ ]));
56
+ assert.deepStrictEqual(ab.colorOverrides.get(1), ba.colorOverrides.get(1));
57
+ assert.deepStrictEqual(ab.colorOverrides.get(1), BLUE);
58
+ });
59
+ });
60
+
61
+ describe('overlaySlice — store wiring', () => {
62
+ function bootOverlayStore() {
63
+ return create<OverlaySlice>()((...args) => ({
64
+ ...createOverlaySlice(...args),
65
+ }));
66
+ }
67
+
68
+ it('registerOverlayLayer upserts; removeOverlayLayer is idempotent', () => {
69
+ const store = bootOverlayStore();
70
+ store.getState().registerOverlayLayer(mkLayer('animation', 100, { hide: [1] }));
71
+ store.getState().registerOverlayLayer(mkLayer('animation', 100, { hide: [2] }));
72
+ assert.strictEqual(store.getState().overlayLayers.size, 1);
73
+ assert.deepStrictEqual(Array.from(store.getState().overlayLayers.get('animation')!.hiddenIds!), [2]);
74
+ store.getState().removeOverlayLayer('animation');
75
+ store.getState().removeOverlayLayer('animation'); // idempotent
76
+ store.getState().removeOverlayLayer('never-registered');
77
+ assert.strictEqual(store.getState().overlayLayers.size, 0);
78
+ });
79
+
80
+ it('Map identity changes on every mutation so shallow-compare subscribers fire', () => {
81
+ // Zustand default equality is Object.is — Maps compare by identity.
82
+ // If we mutated in place, Gantt / renderer subscribers would miss
83
+ // layer updates and the viewport would stop refreshing.
84
+ const store = bootOverlayStore();
85
+ const ref1 = store.getState().overlayLayers;
86
+ store.getState().registerOverlayLayer(mkLayer('animation', 100, { hide: [1] }));
87
+ const ref2 = store.getState().overlayLayers;
88
+ store.getState().removeOverlayLayer('animation');
89
+ const ref3 = store.getState().overlayLayers;
90
+ assert.notStrictEqual(ref1, ref2);
91
+ assert.notStrictEqual(ref2, ref3);
92
+ });
93
+ });
@@ -0,0 +1,151 @@
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
+ * Overlay layer registry — P4 of the 4D refactor plan.
7
+ *
8
+ * Problem this solves: multiple subsystems (4D animation, Gantt selection,
9
+ * lens colouring, user isolation) all want to contribute visibility +
10
+ * colour overrides to the viewport. Before this slice each owner wrote
11
+ * directly into `visibilitySlice.hiddenEntities` / `dataSlice.pendingColorUpdates`
12
+ * and tracked "what I added" in a local ref so it could restore on unmount.
13
+ * That pattern was copy-pasted four times with subtly different ownership
14
+ * semantics — a ticking time bomb when a user 3D-click lands between two
15
+ * owners' writes and nobody knows who owns what.
16
+ *
17
+ * Now: each owner registers a named `OverlayLayer` with a priority. The
18
+ * slice composes all active layers (higher priority wins on collisions)
19
+ * and exposes the composite via selectors. Consumers subscribe to the
20
+ * composite and apply it to the renderer in one place.
21
+ *
22
+ * This PR migrates the animation owner. Gantt-selection, lens, and user-
23
+ * isolation continue to write directly for now — they'll move in a follow-up
24
+ * once the animation migration proves the contract.
25
+ */
26
+
27
+ import type { StateCreator } from 'zustand';
28
+
29
+ /**
30
+ * RGBA tuple — `[r, g, b, a]` in 0..1 floats. Matches the shape of
31
+ * `pendingColorUpdates` values in `dataSlice` and the animator palette
32
+ * so layers can pass colour values straight through without conversion.
33
+ */
34
+ export type RGBA = [number, number, number, number];
35
+
36
+ export interface OverlayLayer {
37
+ /** Stable id used to register / update / remove. e.g. 'animation'. */
38
+ id: string;
39
+ /**
40
+ * Higher priority wins on colour collisions and determines layering
41
+ * order. Convention (0-1000):
42
+ * 50 — lens (background colouring)
43
+ * 100 — animation
44
+ * 200 — gantt selection
45
+ * 300 — user isolation (visibility wins)
46
+ */
47
+ priority: number;
48
+ /**
49
+ * Local-space expressIds this layer wants hidden. `null` = no
50
+ * visibility contribution.
51
+ */
52
+ hiddenIds: Set<number> | null;
53
+ /**
54
+ * Per-expressId colour override. `null` or empty Map = no colour
55
+ * contribution. Keys are local expressIds (federation translation is
56
+ * the consumer's responsibility at the compose boundary).
57
+ */
58
+ colorOverrides: Map<number, RGBA> | null;
59
+ }
60
+
61
+ export interface OverlaySlice {
62
+ /**
63
+ * Active overlay layers keyed by id. Set-identity is replaced on
64
+ * every update so Zustand's shallow-compare fires for subscribers.
65
+ */
66
+ overlayLayers: Map<string, OverlayLayer>;
67
+
68
+ /**
69
+ * Register or replace a layer. Same-id calls overwrite — callers can
70
+ * treat this as the "upsert" primitive: write your layer's current
71
+ * desired state every render, the slice handles the rest.
72
+ */
73
+ registerOverlayLayer: (layer: OverlayLayer) => void;
74
+
75
+ /**
76
+ * Remove a layer by id. Idempotent — no-op when the id is unknown.
77
+ * Use on hook cleanup / feature toggle-off.
78
+ */
79
+ removeOverlayLayer: (id: string) => void;
80
+
81
+ /**
82
+ * Composite the current layers into flat `hiddenIds` + `colorOverrides`
83
+ * maps. `hiddenIds`: union of every layer's hiddenIds. `colorOverrides`:
84
+ * per-id the highest-priority layer's colour wins.
85
+ *
86
+ * Selector form so callers can memoize via `useViewerStore(computeComposite)`.
87
+ */
88
+ computeCompositeOverlay: () => {
89
+ hiddenIds: Set<number>;
90
+ colorOverrides: Map<number, RGBA>;
91
+ };
92
+ }
93
+
94
+ export const createOverlaySlice: StateCreator<OverlaySlice, [], [], OverlaySlice> = (set, get) => ({
95
+ overlayLayers: new Map(),
96
+
97
+ registerOverlayLayer: (layer) => {
98
+ set((s) => {
99
+ const next = new Map(s.overlayLayers);
100
+ next.set(layer.id, layer);
101
+ return { overlayLayers: next };
102
+ });
103
+ },
104
+
105
+ removeOverlayLayer: (id) => {
106
+ set((s) => {
107
+ if (!s.overlayLayers.has(id)) return {};
108
+ const next = new Map(s.overlayLayers);
109
+ next.delete(id);
110
+ return { overlayLayers: next };
111
+ });
112
+ },
113
+
114
+ computeCompositeOverlay: () => composeLayers(get().overlayLayers),
115
+ });
116
+
117
+ /**
118
+ * Pure compositor — exported so it can be unit-tested in isolation and
119
+ * consumed from places that don't have the store handle (e.g. an adapter
120
+ * that already has the raw layers Map).
121
+ *
122
+ * Algorithm:
123
+ * 1. `hiddenIds`: union of every layer's hiddenIds. No priority needed —
124
+ * any layer saying "hide this" wins over any layer saying "show it"
125
+ * (there's no such thing as an explicit "show" in this model; layers
126
+ * contribute only hide + colour).
127
+ * 2. `colorOverrides`: sort layers by ascending priority and apply each
128
+ * layer's colour map on top. The final pass's value wins on
129
+ * collisions, so the highest-priority layer dictates the colour.
130
+ */
131
+ export function composeLayers(layers: Map<string, OverlayLayer>): {
132
+ hiddenIds: Set<number>;
133
+ colorOverrides: Map<number, RGBA>;
134
+ } {
135
+ const hiddenIds = new Set<number>();
136
+ const colorOverrides = new Map<number, RGBA>();
137
+ if (layers.size === 0) return { hiddenIds, colorOverrides };
138
+
139
+ // Ascending priority order — later layers overwrite earlier ones on
140
+ // colour collisions, so the higher-priority layer wins.
141
+ const sorted = Array.from(layers.values()).sort((a, b) => a.priority - b.priority);
142
+ for (const layer of sorted) {
143
+ if (layer.hiddenIds) {
144
+ for (const id of layer.hiddenIds) hiddenIds.add(id);
145
+ }
146
+ if (layer.colorOverrides) {
147
+ for (const [id, rgba] of layer.colorOverrides) colorOverrides.set(id, rgba);
148
+ }
149
+ }
150
+ return { hiddenIds, colorOverrides };
151
+ }
@@ -41,7 +41,12 @@ describe('PinboardSlice', () => {
41
41
 
42
42
  state = {
43
43
  ...cross,
44
- ...createPinboardSlice(setState, () => state, cross as any),
44
+ // The test mock's cross-slice shape is slightly looser than the
45
+ // real PinboardCrossSliceState (e.g. cameraCallbacks.getViewpoint
46
+ // returns null only), so the typed StateCreator can't accept the
47
+ // mock setState directly. Cast at the boundary — runtime shape
48
+ // is correct, just structurally narrower than the prod type.
49
+ ...createPinboardSlice(setState as any, (() => state) as any, cross as any),
45
50
  };
46
51
  });
47
52
 
@@ -0,0 +1,128 @@
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
+ * Playback state slice — drives the 4D / Gantt animation clock.
7
+ *
8
+ * Owns:
9
+ * • the animation master toggle + play-state + cursor time
10
+ * • playback rate + loop setting
11
+ * • `animationSettings` (palette, style flags, ghosting, tinting)
12
+ *
13
+ * Extracted from the schedule slice so its ~70-lines worth of state +
14
+ * mutators don't crowd the schedule-domain logic. Reads
15
+ * `scheduleRange` from `scheduleSlice` via the combined store shape
16
+ * in `advancePlaybackBy`, but every other mutator is self-contained —
17
+ * so this slice can safely be subscribed-to in isolation by the
18
+ * render-tick rAF loop without pulling the full schedule data into
19
+ * a Zustand shallow-compare.
20
+ */
21
+
22
+ import type { StateCreator } from 'zustand';
23
+ import type { AnimationSettings } from '@/components/viewer/schedule/schedule-animator';
24
+ import { DEFAULT_ANIMATION_SETTINGS } from '@/components/viewer/schedule/schedule-animator';
25
+ import type { ScheduleTimeRange } from './scheduleSlice.js';
26
+
27
+ export interface PlaybackSlice {
28
+ /** Animation master toggle — when false the viewer renders normally. */
29
+ animationEnabled: boolean;
30
+ /** Is the playback currently advancing? */
31
+ playbackIsPlaying: boolean;
32
+ /** Current playback time, epoch ms. */
33
+ playbackTime: number;
34
+ /** Playback rate in simulated-days-per-real-second. */
35
+ playbackSpeed: number;
36
+ /** When true, looping from end → start. */
37
+ playbackLoop: boolean;
38
+ /**
39
+ * Animation style + palette settings. See `schedule-animator.ts` for the
40
+ * phase / colour model. `minimal` keeps the original visibility-only
41
+ * behaviour; `phased` lights up the type-colour lifecycle.
42
+ */
43
+ animationSettings: AnimationSettings;
44
+
45
+ setAnimationEnabled: (enabled: boolean) => void;
46
+ /** Replace the full animation-settings object. */
47
+ setAnimationSettings: (settings: AnimationSettings) => void;
48
+ /** Shallow-merge patch — convenient for toolbar toggles. */
49
+ patchAnimationSettings: (patch: Partial<AnimationSettings>) => void;
50
+ /** Restore the built-in Synchro-style defaults. */
51
+ resetAnimationSettings: () => void;
52
+ playSchedule: () => void;
53
+ pauseSchedule: () => void;
54
+ togglePlaySchedule: () => void;
55
+ seekSchedule: (time: number) => void;
56
+ setPlaybackSpeed: (speed: number) => void;
57
+ setPlaybackLoop: (loop: boolean) => void;
58
+ advancePlaybackBy: (deltaMs: number) => void;
59
+ }
60
+
61
+ /**
62
+ * Cross-slice reads needed by the playback slice — the rAF advance
63
+ * loop clamps against the current `scheduleRange.end`. Declared
64
+ * explicitly rather than as a cast so the combined store keeps this
65
+ * field accessible at compile time too.
66
+ */
67
+ interface PlaybackCrossSliceReads {
68
+ scheduleRange?: ScheduleTimeRange | null;
69
+ }
70
+
71
+ export const createPlaybackSlice: StateCreator<
72
+ PlaybackSlice & PlaybackCrossSliceReads,
73
+ [],
74
+ [],
75
+ PlaybackSlice
76
+ > = (set, get) => ({
77
+ animationEnabled: false,
78
+ playbackIsPlaying: false,
79
+ playbackTime: 0,
80
+ playbackSpeed: 7, // 7 simulated days per real second by default
81
+ playbackLoop: true,
82
+ animationSettings: DEFAULT_ANIMATION_SETTINGS,
83
+
84
+ setAnimationEnabled: (animationEnabled) => set({ animationEnabled }),
85
+ setAnimationSettings: (animationSettings) => set({ animationSettings }),
86
+ patchAnimationSettings: (patch) => set((s) => ({
87
+ animationSettings: { ...s.animationSettings, ...patch },
88
+ })),
89
+ resetAnimationSettings: () => set({ animationSettings: DEFAULT_ANIMATION_SETTINGS }),
90
+ playSchedule: () => set({ playbackIsPlaying: true, animationEnabled: true }),
91
+ pauseSchedule: () => set({ playbackIsPlaying: false }),
92
+ togglePlaySchedule: () => set((s) => {
93
+ const next = !s.playbackIsPlaying;
94
+ return {
95
+ playbackIsPlaying: next,
96
+ animationEnabled: next ? true : s.animationEnabled,
97
+ };
98
+ }),
99
+ seekSchedule: (time) => set({ playbackTime: time }),
100
+ setPlaybackSpeed: (playbackSpeed) => set({ playbackSpeed }),
101
+ setPlaybackLoop: (playbackLoop) => set({ playbackLoop }),
102
+
103
+ advancePlaybackBy: (deltaMs) => {
104
+ const s = get();
105
+ if (!s.playbackIsPlaying || !s.scheduleRange) return;
106
+ // Clamp the wall-clock delta before scaling. rAF pauses when the tab is
107
+ // hidden, OS sleeps, or a breakpoint fires; the next frame fires with a
108
+ // multi-second delta. At the default 7 days/sec that would skip weeks of
109
+ // schedule in one step, either missing animation states or overshooting
110
+ // the end of non-looping playback.
111
+ const MAX_DELTA_MS = 100;
112
+ const clamped = Math.min(Math.max(deltaMs, 0), MAX_DELTA_MS);
113
+ // speed = simulated days / real second
114
+ // → simulated ms = (deltaMs / 1000) * speed * 86_400_000
115
+ // = deltaMs * speed * 86_400
116
+ const simulated = clamped * s.playbackSpeed * 86_400;
117
+ let next = s.playbackTime + simulated;
118
+ if (next > s.scheduleRange.end) {
119
+ if (s.playbackLoop) {
120
+ next = s.scheduleRange.start;
121
+ } else {
122
+ set({ playbackTime: s.scheduleRange.end, playbackIsPlaying: false });
123
+ return;
124
+ }
125
+ }
126
+ set({ playbackTime: next });
127
+ },
128
+ });
@@ -0,0 +1,102 @@
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
+ * Point cloud rendering preferences.
7
+ *
8
+ * The renderer reads these via `usePointCloudSync`; UI components write
9
+ * them via the actions below. EDL is opt-in (default on) — costs ~5
10
+ * extra texture taps per pixel.
11
+ */
12
+
13
+ import type { StateCreator } from 'zustand';
14
+
15
+ export type PointColorModeUi = 'rgb' | 'classification' | 'intensity' | 'height' | 'fixed';
16
+ export type PointSizeModeUi = 'fixed-px' | 'adaptive-world' | 'attenuated';
17
+
18
+ export interface PointCloudSlice {
19
+ pointCloudColorMode: PointColorModeUi;
20
+ pointCloudFixedColor: [number, number, number, number];
21
+ /** Splat sizing strategy. Default: 'fixed-px' (sized by the px slider). */
22
+ pointCloudSizeMode: PointSizeModeUi;
23
+ /** Splat size in pixels (fixed/attenuated) or upper cap (attenuated). 1..20. */
24
+ pointCloudPointSize: number;
25
+ /** World-space splat radius in metres for adaptive/attenuated modes.
26
+ * Typical scans: 0.005–0.05. Default 0.02. */
27
+ pointCloudWorldRadius: number;
28
+ /** Render splats as discs vs squares. Default true. */
29
+ pointCloudRoundShape: boolean;
30
+ /** Enable Eye-Dome Lighting post-pass. Default true. */
31
+ pointCloudEdlEnabled: boolean;
32
+ /** EDL strength multiplier. 0..3, default 1. */
33
+ pointCloudEdlStrength: number;
34
+ /**
35
+ * Best-effort count of point cloud assets currently uploaded to the
36
+ * renderer. Updated by ingest paths; UI uses it to show/hide the
37
+ * controls panel and the EDL post-pass.
38
+ */
39
+ pointCloudAssetCount: number;
40
+ setPointCloudColorMode: (mode: PointColorModeUi) => void;
41
+ setPointCloudFixedColor: (rgba: [number, number, number, number]) => void;
42
+ setPointCloudSizeMode: (mode: PointSizeModeUi) => void;
43
+ setPointCloudPointSize: (px: number) => void;
44
+ setPointCloudWorldRadius: (m: number) => void;
45
+ setPointCloudRoundShape: (enabled: boolean) => void;
46
+ setPointCloudEdlEnabled: (enabled: boolean) => void;
47
+ setPointCloudEdlStrength: (strength: number) => void;
48
+ setPointCloudAssetCount: (count: number) => void;
49
+ incrementPointCloudAssetCount: (n?: number) => void;
50
+ }
51
+
52
+ /**
53
+ * Single source of truth for the slice's runtime field defaults.
54
+ * Both the slice initializer and `resetViewerState` consume this so
55
+ * the two paths can't drift.
56
+ */
57
+ export const POINT_CLOUD_DEFAULTS = {
58
+ // Fixed-px is the default so the size slider feels responsive on first
59
+ // contact. `attenuated` is nicer at extreme zooms but its "slider =
60
+ // upper cap" semantic confuses users at typical wide views because the
61
+ // projected world radius sits well below the cap.
62
+ pointCloudColorMode: 'rgb' as PointColorModeUi,
63
+ pointCloudFixedColor: [1, 1, 1, 1] as [number, number, number, number],
64
+ pointCloudSizeMode: 'fixed-px' as PointSizeModeUi,
65
+ pointCloudPointSize: 4,
66
+ pointCloudWorldRadius: 0.02,
67
+ pointCloudRoundShape: true,
68
+ pointCloudEdlEnabled: true,
69
+ pointCloudEdlStrength: 1,
70
+ pointCloudAssetCount: 0,
71
+ } as const;
72
+
73
+ export const createPointCloudSlice: StateCreator<PointCloudSlice, [], [], PointCloudSlice> = (set) => ({
74
+ ...POINT_CLOUD_DEFAULTS,
75
+ // Re-spread typed-array fields so consumers get fresh references
76
+ // instead of the readonly literal in POINT_CLOUD_DEFAULTS.
77
+ pointCloudFixedColor: [...POINT_CLOUD_DEFAULTS.pointCloudFixedColor] as [number, number, number, number],
78
+ setPointCloudColorMode: (mode) => set({ pointCloudColorMode: mode }),
79
+ setPointCloudFixedColor: (rgba) => set({ pointCloudFixedColor: rgba }),
80
+ setPointCloudSizeMode: (mode) => set({ pointCloudSizeMode: mode }),
81
+ // NaN/Infinity slip past Math.max+min unchanged ((NaN < x) === false),
82
+ // so guard with isFinite to keep invalid values out of GPU uniforms.
83
+ setPointCloudPointSize: (px) => set({
84
+ pointCloudPointSize: Number.isFinite(px) ? Math.max(1, Math.min(20, px)) : 4,
85
+ }),
86
+ setPointCloudWorldRadius: (m) => set({
87
+ pointCloudWorldRadius: Number.isFinite(m) ? Math.max(1e-4, m) : 0.02,
88
+ }),
89
+ setPointCloudRoundShape: (enabled) => set({ pointCloudRoundShape: enabled }),
90
+ setPointCloudEdlEnabled: (enabled) => set({ pointCloudEdlEnabled: enabled }),
91
+ setPointCloudEdlStrength: (strength) => set({
92
+ pointCloudEdlStrength: Number.isFinite(strength) ? Math.max(0, Math.min(3, strength)) : 1,
93
+ }),
94
+ setPointCloudAssetCount: (count) => set({
95
+ pointCloudAssetCount: Number.isFinite(count) ? Math.max(0, count) : 0,
96
+ }),
97
+ incrementPointCloudAssetCount: (n = 1) => set((s) => ({
98
+ pointCloudAssetCount: Number.isFinite(n)
99
+ ? Math.max(0, s.pointCloudAssetCount + n)
100
+ : s.pointCloudAssetCount,
101
+ })),
102
+ });