@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.
- package/.turbo/turbo-build.log +17 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +513 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +10 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +69 -10
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +11 -1
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/hooks/useIfcLoader.ts +22 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +70 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store.ts +14 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -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
|
+
}
|