@ifc-lite/viewer 1.17.4 → 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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -0,0 +1,305 @@
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
+ * useGanttBarDrag — direct-manipulation for task bars.
7
+ *
8
+ * Three drag modes:
9
+ * • `shift` (body) — moves start+finish together, duration unchanged.
10
+ * • `resize-start` (left edge) — anchors finish, updates start (and
11
+ * therefore duration).
12
+ * • `resize-finish` (right edge) — anchors start, updates finish
13
+ * (and therefore duration).
14
+ *
15
+ * Snaps the live-dragged delta to a scale-appropriate unit (hour /
16
+ * day / week) unless the user holds Shift. Pointer capture keeps the
17
+ * drag alive outside the SVG. Esc aborts and restores the task to
18
+ * pre-drag state via `abortScheduleTransaction`.
19
+ *
20
+ * Transaction semantics: one `beginScheduleTransaction` at pointerdown
21
+ * means a 60-frame drag lands in the undo stack as ONE entry. End /
22
+ * abort close the window so the next edit opens a new entry.
23
+ *
24
+ * Playback: if the user was animating, we pause at drag start so the
25
+ * animator's hidden-id recompute doesn't fight the live `updateTaskTime`
26
+ * calls, then resume on successful release. Aborts don't resume (Esc
27
+ * is a rollback of everything the user did, including any implicit
28
+ * state changes).
29
+ */
30
+
31
+ import { useCallback, useRef, useState } from 'react';
32
+ import { useViewerStore } from '@/store';
33
+ import type { GanttTimeScale, ScheduleTimeRange } from '@/store';
34
+
35
+ export type BarDragMode = 'shift' | 'resize-start' | 'resize-finish';
36
+
37
+ /** Snap granularity per timeline scale, in milliseconds. */
38
+ const SNAP_MS_BY_SCALE: Record<GanttTimeScale, number> = {
39
+ hour: 15 * 60 * 1000, // 15 minutes
40
+ day: 60 * 60 * 1000, // 1 hour
41
+ week: 24 * 60 * 60 * 1000, // 1 day
42
+ month: 24 * 60 * 60 * 1000, // 1 day
43
+ year: 7 * 24 * 60 * 60 * 1000, // 1 week
44
+ };
45
+
46
+ /** Snap `ms` to the nearest multiple of `unit`. `ms` is a delta, not an epoch.
47
+ *
48
+ * Normalises negative zero to +0 — `Math.round(-0.4) === -0`, and -0 * X
49
+ * stays -0 in JS, which then surfaces as a weird `-PT0S` duration in
50
+ * downstream serialisers. The `|| 0` falls through for exact zero and
51
+ * is a no-op for any finite non-zero result. */
52
+ export function snapDeltaMs(deltaMs: number, unit: number): number {
53
+ if (unit <= 0) return deltaMs;
54
+ return (Math.round(deltaMs / unit) * unit) || 0;
55
+ }
56
+
57
+ /** Pixels per millisecond from the timeline's width and time span. */
58
+ export function pxPerMs(pixelWidth: number, range: ScheduleTimeRange): number {
59
+ const span = range.end - range.start;
60
+ if (span <= 0 || pixelWidth <= 0) return 0;
61
+ return pixelWidth / span;
62
+ }
63
+
64
+ /** Convert epoch ms → ISO-8601 UTC with seconds (no ms), matching the parser. */
65
+ function epochToIso(ms: number): string {
66
+ const d = new Date(ms);
67
+ const pad = (n: number) => n.toString().padStart(2, '0');
68
+ return (
69
+ `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T` +
70
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`
71
+ );
72
+ }
73
+
74
+ function parseIso(iso?: string): number | undefined {
75
+ if (!iso) return undefined;
76
+ const hasTz = /Z$|[+-]\d{2}:?\d{2}$/.test(iso);
77
+ const t = Date.parse(hasTz ? iso : `${iso}Z`);
78
+ return Number.isNaN(t) ? undefined : t;
79
+ }
80
+
81
+ /** Milliseconds → ISO-8601 duration (same shape the animator / exporter emit). */
82
+ function msToIsoDuration(ms: number): string {
83
+ const clamped = Math.max(0, Math.round(ms));
84
+ if (clamped === 0) return 'PT0S';
85
+ const days = Math.floor(clamped / 86_400_000);
86
+ const remAfterDays = clamped - days * 86_400_000;
87
+ const hours = Math.floor(remAfterDays / 3_600_000);
88
+ const remAfterHours = remAfterDays - hours * 3_600_000;
89
+ const mins = Math.floor(remAfterHours / 60_000);
90
+ let out = 'P';
91
+ if (days > 0) out += `${days}D`;
92
+ if (hours > 0 || mins > 0) {
93
+ out += 'T';
94
+ if (hours > 0) out += `${hours}H`;
95
+ if (mins > 0) out += `${mins}M`;
96
+ }
97
+ return out === 'P' ? 'P0D' : out;
98
+ }
99
+
100
+ interface DragSession {
101
+ taskGlobalId: string;
102
+ mode: BarDragMode;
103
+ /** Pointer screen-X where the drag started. */
104
+ startClientX: number;
105
+ /** Pixel-to-ms conversion captured at pointerdown — stays stable for the
106
+ * whole drag even if the viewport resizes mid-drag (user would expect
107
+ * the dragged bar to track the cursor, not re-scale). */
108
+ pxPerMs: number;
109
+ /** Original times so abort / resize anchors are authoritative. */
110
+ originalStartMs: number;
111
+ originalFinishMs: number;
112
+ /** Resume playback on release if it was on at begin. */
113
+ resumePlayback: boolean;
114
+ /** Live preview values so the floating tooltip can render without a
115
+ * store round-trip on every mousemove frame. */
116
+ liveStartMs: number;
117
+ liveFinishMs: number;
118
+ }
119
+
120
+ export interface BarDragLive {
121
+ /** globalId of the task currently being dragged, or null if none. */
122
+ taskGlobalId: string | null;
123
+ mode: BarDragMode | null;
124
+ liveStartMs: number;
125
+ liveFinishMs: number;
126
+ }
127
+
128
+ export interface UseGanttBarDragOptions {
129
+ range: ScheduleTimeRange | null;
130
+ pixelWidth: number;
131
+ scale: GanttTimeScale;
132
+ }
133
+
134
+ export interface UseGanttBarDragResult {
135
+ /** Call on pointerdown on a bar or its edge hit-zone. */
136
+ onPointerDown: (
137
+ e: React.PointerEvent<SVGElement>,
138
+ taskGlobalId: string,
139
+ mode: BarDragMode,
140
+ ) => void;
141
+ /** Live drag state — render the floating tooltip from this. */
142
+ live: BarDragLive;
143
+ }
144
+
145
+ export function useGanttBarDrag(opts: UseGanttBarDragOptions): UseGanttBarDragResult {
146
+ const { range, pixelWidth, scale } = opts;
147
+ const sessionRef = useRef<DragSession | null>(null);
148
+ const [live, setLive] = useState<BarDragLive>({
149
+ taskGlobalId: null, mode: null, liveStartMs: 0, liveFinishMs: 0,
150
+ });
151
+
152
+ const endDrag = useCallback((commit: boolean) => {
153
+ const sess = sessionRef.current;
154
+ if (!sess) return;
155
+ const store = useViewerStore.getState();
156
+ if (commit) {
157
+ store.endScheduleTransaction();
158
+ if (sess.resumePlayback) store.playSchedule();
159
+ } else {
160
+ store.abortScheduleTransaction();
161
+ // Abort is a full rollback; don't resume playback even if it was
162
+ // on at begin — user may have hit Esc specifically because the
163
+ // animation was running and they wanted to stop editing.
164
+ }
165
+ sessionRef.current = null;
166
+ setLive({ taskGlobalId: null, mode: null, liveStartMs: 0, liveFinishMs: 0 });
167
+ }, []);
168
+
169
+ // Global handlers — attached once per session on pointerdown,
170
+ // detached on up / cancel / esc. Using window-level handlers (not
171
+ // React's pointermove on the svg) means a drag that exits the
172
+ // timeline bounds still tracks the cursor.
173
+ const onPointerMove = useCallback((e: PointerEvent) => {
174
+ const sess = sessionRef.current;
175
+ if (!sess) return;
176
+ const rawDelta = (e.clientX - sess.startClientX) / (sess.pxPerMs || 1);
177
+ // Shift disables snap for precise placement.
178
+ const unit = e.shiftKey ? 1 : (SNAP_MS_BY_SCALE[scale] ?? 60_000);
179
+ const snapped = snapDeltaMs(rawDelta, unit);
180
+
181
+ let liveStart = sess.originalStartMs;
182
+ let liveFinish = sess.originalFinishMs;
183
+ switch (sess.mode) {
184
+ case 'shift':
185
+ liveStart = sess.originalStartMs + snapped;
186
+ liveFinish = sess.originalFinishMs + snapped;
187
+ break;
188
+ case 'resize-start':
189
+ liveStart = sess.originalStartMs + snapped;
190
+ // Guard: start can't cross finish. Clamp to finish - 1 snap unit.
191
+ if (liveStart >= sess.originalFinishMs) {
192
+ liveStart = sess.originalFinishMs - unit;
193
+ }
194
+ break;
195
+ case 'resize-finish':
196
+ liveFinish = sess.originalFinishMs + snapped;
197
+ if (liveFinish <= sess.originalStartMs) {
198
+ liveFinish = sess.originalStartMs + unit;
199
+ }
200
+ break;
201
+ }
202
+
203
+ sess.liveStartMs = liveStart;
204
+ sess.liveFinishMs = liveFinish;
205
+
206
+ // Commit to the store — the transaction open at begin means this
207
+ // fires 60 times a second but lands as ONE undo entry.
208
+ const store = useViewerStore.getState();
209
+ store.updateTaskTime(sess.taskGlobalId, {
210
+ scheduleStart: epochToIso(liveStart),
211
+ scheduleFinish: epochToIso(liveFinish),
212
+ scheduleDuration: msToIsoDuration(liveFinish - liveStart),
213
+ });
214
+
215
+ setLive({
216
+ taskGlobalId: sess.taskGlobalId,
217
+ mode: sess.mode,
218
+ liveStartMs: liveStart,
219
+ liveFinishMs: liveFinish,
220
+ });
221
+ }, [scale]);
222
+
223
+ const onPointerUp = useCallback(() => {
224
+ detach();
225
+ endDrag(true);
226
+ }, [endDrag]);
227
+
228
+ const onPointerCancel = useCallback(() => {
229
+ detach();
230
+ endDrag(false);
231
+ }, [endDrag]);
232
+
233
+ const onKeyDown = useCallback((e: KeyboardEvent) => {
234
+ if (e.key === 'Escape') {
235
+ e.preventDefault();
236
+ detach();
237
+ endDrag(false);
238
+ }
239
+ }, [endDrag]);
240
+
241
+ function detach() {
242
+ window.removeEventListener('pointermove', onPointerMove);
243
+ window.removeEventListener('pointerup', onPointerUp);
244
+ window.removeEventListener('pointercancel', onPointerCancel);
245
+ window.removeEventListener('keydown', onKeyDown);
246
+ }
247
+
248
+ const onPointerDown = useCallback((
249
+ e: React.PointerEvent<SVGElement>,
250
+ taskGlobalId: string,
251
+ mode: BarDragMode,
252
+ ) => {
253
+ if (!range || pixelWidth <= 0) return;
254
+ // Only primary button — avoid right-click hijack.
255
+ if (e.button !== 0) return;
256
+ const store = useViewerStore.getState();
257
+ const task = store.scheduleData?.tasks.find(t => t.globalId === taskGlobalId);
258
+ if (!task) return;
259
+ const origStart = parseIso(task.taskTime?.scheduleStart);
260
+ const origFinish = parseIso(task.taskTime?.scheduleFinish);
261
+ if (origStart === undefined || origFinish === undefined) return;
262
+ // Don't try to resize a zero-width bar: shift-only instead. Also
263
+ // milestones should never hit this path (caller gates them out) but
264
+ // we defend anyway.
265
+ if (origFinish <= origStart && mode !== 'shift') return;
266
+
267
+ e.stopPropagation();
268
+ e.preventDefault();
269
+
270
+ const ratio = pxPerMs(pixelWidth, range);
271
+ const resumePlayback = store.playbackIsPlaying;
272
+ if (resumePlayback) store.pauseSchedule();
273
+
274
+ // Open a single undo transaction for the whole gesture.
275
+ const label =
276
+ mode === 'shift' ? 'Drag task'
277
+ : mode === 'resize-start' ? 'Resize task start'
278
+ : 'Resize task finish';
279
+ store.beginScheduleTransaction(label);
280
+
281
+ sessionRef.current = {
282
+ taskGlobalId,
283
+ mode,
284
+ startClientX: e.clientX,
285
+ pxPerMs: ratio,
286
+ originalStartMs: origStart,
287
+ originalFinishMs: origFinish,
288
+ resumePlayback,
289
+ liveStartMs: origStart,
290
+ liveFinishMs: origFinish,
291
+ };
292
+
293
+ window.addEventListener('pointermove', onPointerMove);
294
+ window.addEventListener('pointerup', onPointerUp);
295
+ window.addEventListener('pointercancel', onPointerCancel);
296
+ window.addEventListener('keydown', onKeyDown);
297
+
298
+ setLive({
299
+ taskGlobalId, mode,
300
+ liveStartMs: origStart, liveFinishMs: origFinish,
301
+ });
302
+ }, [range, pixelWidth, onPointerMove, onPointerUp, onPointerCancel, onKeyDown]);
303
+
304
+ return { onPointerDown, live };
305
+ }
@@ -0,0 +1,152 @@
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
+ * useGanttSelection3DHighlight — selecting Gantt row(s) **highlights** their
7
+ * products in the 3D viewport. No isolation, no hiding, no color overlay —
8
+ * purely the renderer's existing selection-highlight channel
9
+ * (`selectedEntityIds`), which paints a blue fresnel on top of whatever the
10
+ * object already looks like and leaves visibility untouched.
11
+ *
12
+ * This means:
13
+ * • The 4D animator's `hiddenIds` / color overlays run completely
14
+ * undisturbed during playback — the highlight only applies to whatever
15
+ * is *currently visible* in that frame, because hidden entities aren't
16
+ * drawn at all.
17
+ * • Clearing the Gantt selection restores whatever viewport selection
18
+ * the user had before we wrote. Ownership is tracked in a ref so a
19
+ * user 3D-click in between is never clobbered on teardown.
20
+ * • Unmount (Gantt panel closed) and schedule reload also restore.
21
+ *
22
+ * One-way only: viewport → Gantt sync is intentionally NOT implemented
23
+ * here. Simpler mental model: "I clicked a Gantt row → I see my task in
24
+ * 3D." Nothing else.
25
+ */
26
+
27
+ import { useEffect, useRef } from 'react';
28
+ import { useViewerStore, toGlobalIdFromModels } from '@/store';
29
+ import { resolveScheduleSourceModelId } from '@/store/slices/schedule-edit-helpers';
30
+ import { collectProductLocalIdsForTasks } from './schedule-selection';
31
+
32
+ interface OwnedHighlight {
33
+ /** Global IDs we wrote as viewport selection. */
34
+ owned: Set<number>;
35
+ /** User's viewport selection before we took over. Restored on clear. */
36
+ prior: Set<number>;
37
+ priorPrimary: number | null;
38
+ }
39
+
40
+ export function useGanttSelection3DHighlight(): void {
41
+ const scheduleData = useViewerStore(s => s.scheduleData);
42
+ const selectedTaskGlobalIds = useViewerStore(s => s.selectedTaskGlobalIds);
43
+
44
+ /** What we last wrote + the user's prior selection. null = we don't own. */
45
+ const ownedRef = useRef<OwnedHighlight | null>(null);
46
+
47
+ useEffect(() => {
48
+ const store = useViewerStore.getState();
49
+
50
+ /**
51
+ * Returns true iff the current viewport selection is byte-for-byte the
52
+ * set we last wrote. If the user has clicked in 3D since, we lost
53
+ * ownership and must not clobber their choice on teardown.
54
+ */
55
+ const weStillOwn = (owned: Set<number>): boolean => {
56
+ const current = store.selectedEntityIds;
57
+ if (current.size !== owned.size) return false;
58
+ for (const id of owned) if (!current.has(id)) return false;
59
+ return true;
60
+ };
61
+
62
+ const restorePrior = () => {
63
+ const owned = ownedRef.current;
64
+ if (!owned) return;
65
+ if (weStillOwn(owned.owned)) {
66
+ // Restore the user's prior selection (often empty — the common case).
67
+ store.setSelectedEntityIds(Array.from(owned.prior));
68
+ if (owned.priorPrimary !== null) {
69
+ store.setSelectedEntityId(owned.priorPrimary);
70
+ }
71
+ }
72
+ ownedRef.current = null;
73
+ };
74
+
75
+ // ── Gantt selection empty → release ownership and restore ─────────
76
+ if (!scheduleData || selectedTaskGlobalIds.size === 0) {
77
+ restorePrior();
78
+ return;
79
+ }
80
+
81
+ // ── Compute global IDs for every descendant product ───────────────
82
+ const localIds = collectProductLocalIdsForTasks(scheduleData, selectedTaskGlobalIds);
83
+ if (localIds.size === 0) {
84
+ // Selected tasks own no products — treat as "nothing to highlight"
85
+ // and restore rather than stranding the user with a stale highlight.
86
+ restorePrior();
87
+ return;
88
+ }
89
+
90
+ const models = store.models;
91
+ const activeModelId = store.activeModelId;
92
+ const sourceModelId = resolveScheduleSourceModelId(models, activeModelId);
93
+
94
+ const globalIds = new Set<number>();
95
+ for (const local of localIds) {
96
+ globalIds.add(toGlobalIdFromModels(models, sourceModelId, local));
97
+ }
98
+
99
+ // ── First write: capture the user's prior selection so we can restore ──
100
+ if (ownedRef.current === null) {
101
+ ownedRef.current = {
102
+ owned: globalIds,
103
+ prior: new Set(store.selectedEntityIds),
104
+ priorPrimary: store.selectedEntityId,
105
+ };
106
+ store.setSelectedEntityIds(Array.from(globalIds));
107
+ return;
108
+ }
109
+
110
+ // ── Subsequent writes: update only if the set actually changed ────
111
+ const prevOwned = ownedRef.current.owned;
112
+ let same = prevOwned.size === globalIds.size;
113
+ if (same) {
114
+ for (const id of globalIds) if (!prevOwned.has(id)) { same = false; break; }
115
+ }
116
+ if (!same) {
117
+ // Only overwrite if we still own — otherwise a user 3D-click took
118
+ // priority and we shouldn't clobber it. If they then change the
119
+ // Gantt selection again, we simply take ownership from scratch.
120
+ if (weStillOwn(prevOwned)) {
121
+ ownedRef.current = { ...ownedRef.current, owned: globalIds };
122
+ store.setSelectedEntityIds(Array.from(globalIds));
123
+ } else {
124
+ ownedRef.current = {
125
+ owned: globalIds,
126
+ prior: new Set(store.selectedEntityIds),
127
+ priorPrimary: store.selectedEntityId,
128
+ };
129
+ store.setSelectedEntityIds(Array.from(globalIds));
130
+ }
131
+ }
132
+ }, [scheduleData, selectedTaskGlobalIds]);
133
+
134
+ // Unmount teardown — release if we still own the selection.
135
+ useEffect(() => {
136
+ return () => {
137
+ const owned = ownedRef.current;
138
+ if (!owned) return;
139
+ const store = useViewerStore.getState();
140
+ const current = store.selectedEntityIds;
141
+ const stillOwn = current.size === owned.owned.size
142
+ && [...owned.owned].every(id => current.has(id));
143
+ if (stillOwn) {
144
+ store.setSelectedEntityIds(Array.from(owned.prior));
145
+ if (owned.priorPrimary !== null) {
146
+ store.setSelectedEntityId(owned.priorPrimary);
147
+ }
148
+ }
149
+ ownedRef.current = null;
150
+ };
151
+ }, []);
152
+ }
@@ -0,0 +1,108 @@
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
+ * useOverlayCompositor — reconciles the registered overlay layers into the
7
+ * renderer's legacy channels (`hiddenEntities`, `pendingColorUpdates`).
8
+ *
9
+ * Responsibility split after P4:
10
+ * • Layer owners (animation, future: gantt-selection, lens, user-isolation)
11
+ * call `registerOverlayLayer(...)` / `removeOverlayLayer(id)` to declare
12
+ * their desired contribution. They do NOT write to the legacy channels.
13
+ * • This hook is the SINGLE writer to those legacy channels. It watches
14
+ * the overlay registry, computes the composite, and writes a delta.
15
+ *
16
+ * Ownership tracking lives here exactly once — previously duplicated as
17
+ * `contributedHiddenRef` / `contributedColorsRef` inside every consumer.
18
+ *
19
+ * This hook must be mounted high in the viewer tree so it runs throughout
20
+ * the session. Mount it alongside the root viewport or the Gantt panel.
21
+ */
22
+
23
+ import { useEffect, useRef } from 'react';
24
+ import { useViewerStore } from '@/store';
25
+
26
+ export function useOverlayCompositor(): void {
27
+ // Each entry is a GLOBAL id we hid; flag = "was already hidden by user
28
+ // when we took over". On restore we only un-hide ids where `false`.
29
+ const contributedHiddenRef = useRef<Map<number, boolean>>(new Map());
30
+ // Global ids we last wrote as colour overrides. Used to know when we
31
+ // need to issue a clear (`setPendingColorUpdates(new Map())`).
32
+ const contributedColorsRef = useRef<Set<number>>(new Set());
33
+
34
+ const overlayLayers = useViewerStore((s) => s.overlayLayers);
35
+
36
+ useEffect(() => {
37
+ const store = useViewerStore.getState();
38
+
39
+ // Build composite hiddenIds + colorOverrides from the current layers.
40
+ // `composeLayers` is a pure function — the hook decides when to rerun.
41
+ const { hiddenIds: nextHidden, colorOverrides: nextColors } = store.computeCompositeOverlay();
42
+
43
+ // ── Reconcile hidden set ──────────────────────────────────────────
44
+ //
45
+ // Compare the new hidden set against what we last wrote, not against
46
+ // the store's current `hiddenEntities` (which includes user-isolated
47
+ // ids we don't own). `contributedHiddenRef` maps each id we last hid
48
+ // to a boolean: "was the user already hiding this before we wrote?".
49
+ // On restore, only un-hide ids whose flag is false.
50
+ const prev = contributedHiddenRef.current;
51
+ const toShow: number[] = [];
52
+ for (const [id, wasHidden] of prev) {
53
+ if (!nextHidden.has(id) && wasHidden === false) toShow.push(id);
54
+ }
55
+ const toHide: number[] = [];
56
+ const nextHiddenMap = new Map<number, boolean>();
57
+ const currentlyHidden = store.hiddenEntities ?? new Set<number>();
58
+ for (const id of nextHidden) {
59
+ if (prev.has(id)) {
60
+ // We still want this id hidden AND we already wrote it — preserve
61
+ // the "was already hidden" bit so we un-hide correctly later.
62
+ nextHiddenMap.set(id, prev.get(id)!);
63
+ } else {
64
+ // First time we see this id. Capture whether the user already had
65
+ // it hidden so we won't unhide their choice on teardown.
66
+ const wasHidden = currentlyHidden.has(id);
67
+ nextHiddenMap.set(id, wasHidden);
68
+ if (!wasHidden) toHide.push(id);
69
+ }
70
+ }
71
+ if (toShow.length > 0) store.showEntities(toShow);
72
+ if (toHide.length > 0) store.hideEntities(toHide);
73
+ contributedHiddenRef.current = nextHiddenMap;
74
+
75
+ // ── Reconcile colour overrides ────────────────────────────────────
76
+ //
77
+ // Colour overrides are all-or-nothing per call: `setPendingColorUpdates`
78
+ // replaces the full map. When the composite is empty and we had
79
+ // contributions, signal a clear with `new Map()`.
80
+ if (nextColors.size > 0) {
81
+ store.setPendingColorUpdates(nextColors);
82
+ contributedColorsRef.current = new Set(nextColors.keys());
83
+ } else if (contributedColorsRef.current.size > 0) {
84
+ store.setPendingColorUpdates(new Map());
85
+ contributedColorsRef.current = new Set();
86
+ }
87
+ }, [overlayLayers]);
88
+
89
+ // Unmount cleanup — restore every id we own, clear our colour overrides.
90
+ // Happens once at app teardown in practice.
91
+ useEffect(() => {
92
+ return () => {
93
+ const store = useViewerStore.getState();
94
+ if (contributedHiddenRef.current.size > 0) {
95
+ const toShow: number[] = [];
96
+ for (const [id, wasHidden] of contributedHiddenRef.current) {
97
+ if (wasHidden === false) toShow.push(id);
98
+ }
99
+ if (toShow.length > 0) store.showEntities(toShow);
100
+ contributedHiddenRef.current = new Map();
101
+ }
102
+ if (contributedColorsRef.current.size > 0) {
103
+ store.setPendingColorUpdates(new Map());
104
+ contributedColorsRef.current = new Set();
105
+ }
106
+ };
107
+ }, []);
108
+ }