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