@ifc-lite/viewer 1.17.6 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.turbo/turbo-build.log +20 -15
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +949 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
  5. package/dist/assets/decode-worker-Collf_X_.js +1320 -0
  6. package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
  7. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
  8. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  9. package/dist/assets/index-0XpVr_S5.css +1 -0
  10. package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
  11. package/dist/assets/index-XwKzDuw6.js +22 -0
  12. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
  13. package/dist/assets/sandbox-Baez7n-t.js +9682 -0
  14. package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
  15. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
  16. package/dist/index.html +6 -6
  17. package/package.json +11 -10
  18. package/src/apache-arrow.d.ts +30 -0
  19. package/src/components/viewer/AddElementPanel.tsx +758 -0
  20. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  21. package/src/components/viewer/ChatPanel.tsx +64 -2
  22. package/src/components/viewer/CommandPalette.tsx +56 -7
  23. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  24. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  25. package/src/components/viewer/ExportDialog.tsx +19 -1
  26. package/src/components/viewer/MainToolbar.tsx +73 -12
  27. package/src/components/viewer/PointCloudPanel.tsx +174 -0
  28. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  29. package/src/components/viewer/SearchInline.tsx +669 -0
  30. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  31. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  32. package/src/components/viewer/SearchModal.text.tsx +388 -0
  33. package/src/components/viewer/SearchModal.tsx +235 -0
  34. package/src/components/viewer/ToolOverlays.tsx +5 -0
  35. package/src/components/viewer/ViewerLayout.tsx +24 -4
  36. package/src/components/viewer/Viewport.tsx +29 -2
  37. package/src/components/viewer/ViewportContainer.tsx +45 -5
  38. package/src/components/viewer/ViewportOverlays.tsx +13 -2
  39. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  40. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  41. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  42. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  43. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  44. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  45. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  46. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  47. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  48. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  49. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  50. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  51. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  52. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  53. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  54. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  55. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  56. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  57. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  58. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  59. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  60. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  61. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  62. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  63. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  64. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  65. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  66. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  67. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  68. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  69. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  70. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  71. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  72. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  73. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  74. package/src/components/viewer/selectionHandlers.ts +446 -0
  75. package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
  76. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  77. package/src/components/viewer/useMouseControls.ts +9 -1
  78. package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
  79. package/src/components/viewer/usePointCloudSync.ts +98 -0
  80. package/src/hooks/ingest/pointCloudIngest.ts +391 -0
  81. package/src/hooks/ingest/viewerModelIngest.ts +32 -3
  82. package/src/hooks/useIfcFederation.ts +72 -3
  83. package/src/hooks/useIfcLoader.ts +89 -13
  84. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  85. package/src/hooks/useSandbox.ts +1 -1
  86. package/src/hooks/useSearchIndex.ts +125 -0
  87. package/src/index.css +66 -0
  88. package/src/lib/llm/system-prompt.test.ts +14 -0
  89. package/src/lib/llm/system-prompt.ts +102 -1
  90. package/src/lib/llm/types.ts +6 -0
  91. package/src/lib/recent-files.ts +38 -4
  92. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  93. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  94. package/src/lib/scripts/templates.ts +7 -0
  95. package/src/lib/search/common-ifc-types.ts +36 -0
  96. package/src/lib/search/filter-evaluate.test.ts +537 -0
  97. package/src/lib/search/filter-evaluate.ts +610 -0
  98. package/src/lib/search/filter-rules.test.ts +119 -0
  99. package/src/lib/search/filter-rules.ts +198 -0
  100. package/src/lib/search/filter-schema.test.ts +233 -0
  101. package/src/lib/search/filter-schema.ts +146 -0
  102. package/src/lib/search/recent-searches.test.ts +116 -0
  103. package/src/lib/search/recent-searches.ts +93 -0
  104. package/src/lib/search/result-export.test.ts +101 -0
  105. package/src/lib/search/result-export.ts +104 -0
  106. package/src/lib/search/saved-filters.test.ts +118 -0
  107. package/src/lib/search/saved-filters.ts +154 -0
  108. package/src/lib/search/tier0-scan.test.ts +196 -0
  109. package/src/lib/search/tier0-scan.ts +237 -0
  110. package/src/lib/search/tier1-index.test.ts +242 -0
  111. package/src/lib/search/tier1-index.ts +448 -0
  112. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  113. package/src/sdk/adapters/export-adapter.ts +404 -1
  114. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  115. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  116. package/src/sdk/adapters/model-compat.ts +8 -2
  117. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  118. package/src/sdk/adapters/store-adapter.ts +201 -0
  119. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  120. package/src/sdk/local-backend.ts +16 -8
  121. package/src/services/desktop-export.ts +3 -1
  122. package/src/services/desktop-native-metadata.ts +41 -18
  123. package/src/services/file-dialog.ts +8 -3
  124. package/src/services/tauri-modules.d.ts +25 -0
  125. package/src/store/basketVisibleSet.ts +3 -0
  126. package/src/store/globalId.ts +4 -1
  127. package/src/store/index.ts +79 -1
  128. package/src/store/slices/addElementMeshes.ts +365 -0
  129. package/src/store/slices/addElementSlice.ts +275 -0
  130. package/src/store/slices/annotationsSlice.test.ts +133 -0
  131. package/src/store/slices/annotationsSlice.ts +251 -0
  132. package/src/store/slices/dataSlice.test.ts +23 -4
  133. package/src/store/slices/dataSlice.ts +1 -1
  134. package/src/store/slices/modelSlice.test.ts +67 -9
  135. package/src/store/slices/modelSlice.ts +39 -7
  136. package/src/store/slices/mutationSlice.ts +964 -3
  137. package/src/store/slices/overlayCompositor.test.ts +164 -0
  138. package/src/store/slices/overlaySlice.test.ts +93 -0
  139. package/src/store/slices/overlaySlice.ts +151 -0
  140. package/src/store/slices/pinboardSlice.test.ts +6 -1
  141. package/src/store/slices/playbackSlice.ts +128 -0
  142. package/src/store/slices/pointCloudSlice.ts +102 -0
  143. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  144. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  145. package/src/store/slices/scheduleSlice.test.ts +694 -0
  146. package/src/store/slices/scheduleSlice.ts +1330 -0
  147. package/src/store/slices/searchSlice.test.ts +342 -0
  148. package/src/store/slices/searchSlice.ts +341 -0
  149. package/src/store/slices/selectionSlice.test.ts +46 -0
  150. package/src/store/slices/selectionSlice.ts +20 -0
  151. package/src/store/types.ts +7 -0
  152. package/src/store.ts +14 -0
  153. package/vite.config.ts +1 -0
  154. package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
  155. package/dist/assets/index-_bfZsDCC.css +0 -1
  156. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -4,8 +4,9 @@
4
4
 
5
5
  import test from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
- import { resolveVisibilityFilterSets } from './export-adapter.js';
7
+ import { resolveVisibilityFilterSets, injectScheduleIntoStep } from './export-adapter.js';
8
8
  import { LEGACY_MODEL_ID } from './model-compat.js';
9
+ import type { ScheduleExtraction, IfcDataStore } from '@ifc-lite/parser';
9
10
 
10
11
  test('resolveVisibilityFilterSets honors legacy single-model hidden and isolated state', () => {
11
12
  const state = {
@@ -22,3 +23,435 @@ test('resolveVisibilityFilterSets honors legacy single-model hidden and isolated
22
23
  assert.deepEqual([...result.hiddenEntityIds], [11, 12]);
23
24
  assert.deepEqual(result.isolatedEntityIds ? [...result.isolatedEntityIds] : null, [21, 22]);
24
25
  });
26
+
27
+ // ─── injectScheduleIntoStep ─────────────────────────────────────────────
28
+
29
+ const STUB_STORE: IfcDataStore = {
30
+ entities: {
31
+ getExpressIdByGlobalId: (gid: string) => {
32
+ const map: Record<string, number> = { 'wall-A': 11, 'wall-B': 12 };
33
+ return map[gid] ?? -1;
34
+ },
35
+ } as unknown as IfcDataStore['entities'],
36
+ } as unknown as IfcDataStore;
37
+
38
+ const SAMPLE_STEP = `ISO-10303-21;
39
+ HEADER;
40
+ FILE_DESCRIPTION(('test'),'2;1');
41
+ FILE_NAME('','',(''),(''),'','','');
42
+ FILE_SCHEMA(('IFC4'));
43
+ ENDSEC;
44
+ DATA;
45
+ #1=IFCPROJECT('proj-gid',$,'P',$,$,$,$,(#2),#3);
46
+ #10=IFCOWNERHISTORY($,$,$,.NOCHANGE.,$,$,$,0);
47
+ #11=IFCWALL('wall-A-gid',#10,'A',$,$,$,$,$,$);
48
+ #12=IFCWALL('wall-B-gid',#10,'B',$,$,$,$,$,$);
49
+ ENDSEC;
50
+ END-ISO-10303-21;
51
+ `;
52
+
53
+ function makeGeneratedSchedule(): ScheduleExtraction {
54
+ return {
55
+ hasSchedule: true,
56
+ workSchedules: [{
57
+ expressId: 0, globalId: 'sched-gid', kind: 'WorkSchedule',
58
+ name: 'Generated', startTime: '2024-05-01T08:00:00',
59
+ finishTime: '2024-05-30T17:00:00', predefinedType: 'PLANNED',
60
+ taskGlobalIds: ['task-1'],
61
+ }],
62
+ tasks: [{
63
+ expressId: 0, globalId: 'task-1', name: 'Install walls',
64
+ isMilestone: false, predefinedType: 'INSTALLATION',
65
+ childGlobalIds: [],
66
+ productExpressIds: [0, 0],
67
+ productGlobalIds: ['wall-A', 'wall-B'],
68
+ controllingScheduleGlobalIds: ['sched-gid'],
69
+ taskTime: {
70
+ scheduleStart: '2024-05-01T08:00:00',
71
+ scheduleFinish: '2024-05-06T17:00:00',
72
+ scheduleDuration: 'P5D',
73
+ },
74
+ }],
75
+ sequences: [],
76
+ };
77
+ }
78
+
79
+ test('injectScheduleIntoStep is a no-op when scheduleData is null', () => {
80
+ const out = injectScheduleIntoStep(SAMPLE_STEP, null, STUB_STORE);
81
+ assert.equal(out, SAMPLE_STEP);
82
+ });
83
+
84
+ test('injectScheduleIntoStep is a no-op when every task has a positive expressId (parsed schedule already in STEP)', () => {
85
+ const parsed: ScheduleExtraction = {
86
+ hasSchedule: true, workSchedules: [], sequences: [],
87
+ tasks: [{
88
+ expressId: 999, globalId: 'task-x', name: 'Already in file',
89
+ isMilestone: false, childGlobalIds: [],
90
+ productExpressIds: [], productGlobalIds: [],
91
+ controllingScheduleGlobalIds: [],
92
+ }],
93
+ };
94
+ const out = injectScheduleIntoStep(SAMPLE_STEP, parsed, STUB_STORE);
95
+ assert.equal(out, SAMPLE_STEP);
96
+ });
97
+
98
+ test('injectScheduleIntoStep splices generated schedule entities before the DATA section ENDSEC', () => {
99
+ const out = injectScheduleIntoStep(SAMPLE_STEP, makeGeneratedSchedule(), STUB_STORE);
100
+ // The new entities must appear in the file.
101
+ assert.match(out, /=IFCWORKSCHEDULE\(/);
102
+ assert.match(out, /=IFCTASK\(/);
103
+ assert.match(out, /=IFCTASKTIME\(/);
104
+ assert.match(out, /=IFCRELASSIGNSTOCONTROL\(/);
105
+ assert.match(out, /=IFCRELASSIGNSTOPROCESS\(/);
106
+ // Trailer must still be intact and well-formed.
107
+ assert.ok(out.endsWith('END-ISO-10303-21;\n'));
108
+ // Splice location must be strictly INSIDE the DATA section, not just
109
+ // before `END-ISO-10303-21;` — entities outside the DATA block are
110
+ // invalid STEP placement.
111
+ const dataStartIdx = out.indexOf('DATA;');
112
+ const dataEndIdx = out.indexOf('ENDSEC;', dataStartIdx);
113
+ const wsIdx = out.indexOf('=IFCWORKSCHEDULE(');
114
+ assert.ok(dataStartIdx >= 0, 'DATA; section header present');
115
+ assert.ok(dataEndIdx > dataStartIdx, 'DATA ENDSEC comes after DATA;');
116
+ assert.ok(wsIdx > dataStartIdx && wsIdx < dataEndIdx,
117
+ `IfcWorkSchedule splice (${wsIdx}) must land inside DATA..ENDSEC (${dataStartIdx}..${dataEndIdx})`);
118
+ });
119
+
120
+ test('injectScheduleIntoStep partitions mixed schedules — only generated tasks are emitted', () => {
121
+ const mixed: ScheduleExtraction = {
122
+ hasSchedule: true,
123
+ workSchedules: [{
124
+ expressId: 0, globalId: 'gen-sched', kind: 'WorkSchedule',
125
+ name: 'Gen', startTime: '2024-05-01T08:00:00',
126
+ taskGlobalIds: ['gen-task'],
127
+ }],
128
+ tasks: [
129
+ {
130
+ // Parsed — must NOT be re-emitted (already in source STEP).
131
+ expressId: 99, globalId: 'parsed-task', name: 'Already-in-file',
132
+ isMilestone: false, childGlobalIds: [],
133
+ productExpressIds: [], productGlobalIds: [],
134
+ controllingScheduleGlobalIds: [],
135
+ },
136
+ {
137
+ // Generated — must be emitted.
138
+ expressId: 0, globalId: 'gen-task', name: 'Fresh',
139
+ isMilestone: false, childGlobalIds: [],
140
+ productExpressIds: [0], productGlobalIds: ['wall-A'],
141
+ controllingScheduleGlobalIds: ['gen-sched'],
142
+ taskTime: { scheduleStart: '2024-05-01T08:00:00', scheduleFinish: '2024-05-05T17:00:00' },
143
+ },
144
+ ],
145
+ sequences: [],
146
+ };
147
+ const out = injectScheduleIntoStep(SAMPLE_STEP, mixed, STUB_STORE);
148
+ // The generated task should be emitted…
149
+ assert.match(out, /IFCTASK\('[^']+',[^)]*'Fresh'/);
150
+ // …but the parsed task name must not appear a second time.
151
+ assert.ok(!/Already-in-file/.test(out), 'parsed task is not re-emitted');
152
+ });
153
+
154
+ test('injectScheduleIntoStep allocates IDs above the existing maximum', () => {
155
+ const out = injectScheduleIntoStep(SAMPLE_STEP, makeGeneratedSchedule(), STUB_STORE);
156
+ // Existing max in SAMPLE_STEP is 12; first new entity must be #13 or higher.
157
+ const firstNewId = out.match(/(?<=\n)#(\d+)=IFCWORKSCHEDULE\(/);
158
+ assert.ok(firstNewId);
159
+ assert.ok(parseInt(firstNewId![1], 10) > 12);
160
+ });
161
+
162
+ test('injectScheduleIntoStep references the existing IfcOwnerHistory', () => {
163
+ const out = injectScheduleIntoStep(SAMPLE_STEP, makeGeneratedSchedule(), STUB_STORE);
164
+ // Entities should reference #10 (the stub IfcOwnerHistory) for ownership.
165
+ const ws = out.split('\n').find(l => l.includes('=IFCWORKSCHEDULE('));
166
+ assert.ok(ws);
167
+ assert.match(ws!, /=IFCWORKSCHEDULE\('[^']+',#10/);
168
+ });
169
+
170
+ test('injectScheduleIntoStep resolves product GlobalIds via the data store', () => {
171
+ const out = injectScheduleIntoStep(SAMPLE_STEP, makeGeneratedSchedule(), STUB_STORE);
172
+ const proc = out.split('\n').find(l => l.includes('=IFCRELASSIGNSTOPROCESS('));
173
+ assert.ok(proc);
174
+ // wall-A → 11, wall-B → 12 per STUB_STORE's resolver.
175
+ assert.match(proc!, /\(#11,#12\)/);
176
+ });
177
+
178
+ // ─── rewrite mode (P1: schedule-as-unit export) ───────────────────────
179
+
180
+ /**
181
+ * STEP fixture with an existing parsed schedule block — exercises the
182
+ * rewrite path that strips all schedule entities and re-emits fresh.
183
+ */
184
+ const SAMPLE_STEP_WITH_SCHEDULE = `ISO-10303-21;
185
+ HEADER;
186
+ FILE_DESCRIPTION(('test'),'2;1');
187
+ FILE_NAME('','',(''),(''),'','','');
188
+ FILE_SCHEMA(('IFC4'));
189
+ ENDSEC;
190
+ DATA;
191
+ #1=IFCPROJECT('proj-gid',$,'P',$,$,$,$,(#2),#3);
192
+ #10=IFCOWNERHISTORY($,$,$,.NOCHANGE.,$,$,$,0);
193
+ #11=IFCWALL('wall-A-gid',#10,'A',$,$,$,$,$,$);
194
+ #12=IFCWALL('wall-B-gid',#10,'B',$,$,$,$,$,$);
195
+ #20=IFCWORKSCHEDULE('orig-sched-gid',#10,'Original',$,$,$,$,$,$,$,$,$,$,$,.PLANNED.);
196
+ #21=IFCTASKTIME($,$,$,.WORKTIME.,'P3D','2024-01-01T08:00:00','2024-01-04T08:00:00',$,$,$,$,$,$,$,$,$,$,$,$,$);
197
+ #22=IFCTASK('orig-task-gid',#10,'Original task',$,$,$,$,$,$,.F.,$,#21,.CONSTRUCTION.);
198
+ #23=IFCRELASSIGNSTOCONTROL('rel-ctl',#10,$,$,(#22),$,#20);
199
+ #24=IFCRELASSIGNSTOPROCESS('rel-proc',#10,$,$,(#11,#12),$,#22);
200
+ ENDSEC;
201
+ END-ISO-10303-21;
202
+ `;
203
+
204
+ test('injectScheduleIntoStep rewrite mode strips the original schedule block', () => {
205
+ // No in-memory schedule + edited flag → user deleted every task.
206
+ const out = injectScheduleIntoStep(
207
+ SAMPLE_STEP_WITH_SCHEDULE,
208
+ null,
209
+ STUB_STORE,
210
+ { scheduleIsEdited: true },
211
+ );
212
+ assert.ok(!out.includes('IFCWORKSCHEDULE'), 'original workschedule removed');
213
+ assert.ok(!out.includes('IFCTASK('), 'original task removed');
214
+ assert.ok(!out.includes('IFCTASKTIME'), 'original task time removed');
215
+ assert.ok(!out.includes('IFCRELASSIGNSTOCONTROL'), 'rel-assigns-to-control removed');
216
+ assert.ok(!out.includes('IFCRELASSIGNSTOPROCESS'), 'rel-assigns-to-process removed');
217
+ // Non-schedule entities must remain intact.
218
+ assert.ok(out.includes('IFCWALL'), 'walls preserved');
219
+ assert.ok(out.includes('IFCOWNERHISTORY'), 'owner history preserved');
220
+ assert.ok(out.includes('IFCPROJECT'), 'project preserved');
221
+ });
222
+
223
+ test('injectScheduleIntoStep rewrite mode replaces the original schedule with the edited one', () => {
224
+ const edited: ScheduleExtraction = {
225
+ hasSchedule: true,
226
+ workSchedules: [{
227
+ expressId: 20, globalId: 'orig-sched-gid', kind: 'WorkSchedule',
228
+ name: 'Renamed schedule',
229
+ startTime: '2024-05-01T08:00:00',
230
+ finishTime: '2024-05-10T17:00:00',
231
+ predefinedType: 'PLANNED',
232
+ taskGlobalIds: ['orig-task-gid'],
233
+ }],
234
+ tasks: [{
235
+ expressId: 22, globalId: 'orig-task-gid', name: 'Renamed task',
236
+ isMilestone: false, predefinedType: 'CONSTRUCTION',
237
+ childGlobalIds: [],
238
+ productExpressIds: [11],
239
+ productGlobalIds: ['wall-A'],
240
+ controllingScheduleGlobalIds: ['orig-sched-gid'],
241
+ taskTime: {
242
+ scheduleStart: '2024-05-01T08:00:00',
243
+ scheduleFinish: '2024-05-05T17:00:00',
244
+ scheduleDuration: 'P5D',
245
+ },
246
+ }],
247
+ sequences: [],
248
+ };
249
+ const out = injectScheduleIntoStep(
250
+ SAMPLE_STEP_WITH_SCHEDULE,
251
+ edited,
252
+ STUB_STORE,
253
+ { scheduleIsEdited: true },
254
+ );
255
+
256
+ // Old names/timestamps gone.
257
+ assert.ok(!out.includes("'Original'"), 'original schedule name stripped');
258
+ assert.ok(!out.includes("'Original task'"), 'original task name stripped');
259
+ assert.ok(!out.includes('P3D'), 'original duration stripped');
260
+ assert.ok(!out.includes('2024-01-01T08:00:00'), 'original date stripped');
261
+
262
+ // New names/timestamps present.
263
+ assert.ok(out.includes("'Renamed schedule'"), 'new schedule name present');
264
+ assert.ok(out.includes("'Renamed task'"), 'new task name present');
265
+ assert.ok(out.includes('P5D'), 'new duration present');
266
+ assert.ok(out.includes('2024-05-01T08:00:00'), 'new start date present');
267
+
268
+ // Globalids must be preserved (same identity).
269
+ assert.ok(out.includes("'orig-sched-gid'"), 'work-schedule globalId preserved');
270
+ assert.ok(out.includes("'orig-task-gid'"), 'task globalId preserved');
271
+
272
+ // No duplicate emission.
273
+ const workScheduleCount = (out.match(/=IFCWORKSCHEDULE\(/g) ?? []).length;
274
+ const taskCount = (out.match(/=IFCTASK\(/g) ?? []).length;
275
+ assert.equal(workScheduleCount, 1, 'exactly one work schedule in output');
276
+ assert.equal(taskCount, 1, 'exactly one task in output');
277
+ });
278
+
279
+ test('injectScheduleIntoStep rewrite mode leaves non-schedule entities byte-identical', () => {
280
+ // Input has project, owner history, two walls, plus a schedule block.
281
+ // After rewrite with empty schedule, the non-schedule lines should be
282
+ // intact (aside from re-ordering they don't do).
283
+ const out = injectScheduleIntoStep(
284
+ SAMPLE_STEP_WITH_SCHEDULE,
285
+ null,
286
+ STUB_STORE,
287
+ { scheduleIsEdited: true },
288
+ );
289
+ // Each non-schedule line from the input must appear in the output.
290
+ for (const line of [
291
+ "#1=IFCPROJECT('proj-gid',$,'P',$,$,$,$,(#2),#3);",
292
+ '#10=IFCOWNERHISTORY($,$,$,.NOCHANGE.,$,$,$,0);',
293
+ "#11=IFCWALL('wall-A-gid',#10,'A',$,$,$,$,$,$);",
294
+ "#12=IFCWALL('wall-B-gid',#10,'B',$,$,$,$,$,$);",
295
+ ]) {
296
+ assert.ok(out.includes(line), `preserved line: ${line}`);
297
+ }
298
+ });
299
+
300
+ test('injectScheduleIntoStep without scheduleIsEdited preserves append-only legacy behaviour', () => {
301
+ // Mixed schedule (one parsed, one generated) without the edit flag →
302
+ // only the generated tail is emitted, original parsed task stays intact.
303
+ const mixed: ScheduleExtraction = {
304
+ hasSchedule: true,
305
+ workSchedules: [],
306
+ tasks: [
307
+ {
308
+ expressId: 99, globalId: 'parsed', name: 'Parsed task', isMilestone: false,
309
+ childGlobalIds: [], productExpressIds: [], productGlobalIds: [],
310
+ controllingScheduleGlobalIds: [],
311
+ },
312
+ {
313
+ expressId: 0, globalId: 'fresh', name: 'Fresh', isMilestone: false,
314
+ childGlobalIds: [], productExpressIds: [0], productGlobalIds: ['wall-A'],
315
+ controllingScheduleGlobalIds: [],
316
+ taskTime: { scheduleStart: '2024-05-01T08:00:00', scheduleFinish: '2024-05-05T17:00:00' },
317
+ },
318
+ ],
319
+ sequences: [],
320
+ };
321
+ // Legacy (no options): only generated should splice in, parsed name
322
+ // must not re-appear.
323
+ const legacy = injectScheduleIntoStep(SAMPLE_STEP_WITH_SCHEDULE, mixed, STUB_STORE);
324
+ assert.match(legacy, /'Fresh'/);
325
+ // The original schedule block stays untouched because we're in append mode.
326
+ assert.ok(legacy.includes("'Original'"), 'original schedule block preserved in append mode');
327
+ });
328
+
329
+ // ─── edge cases flagged in the retrospective ────────────────────────
330
+
331
+ test('stripScheduleEntities preserves IFCTASK as substring inside non-task strings', () => {
332
+ // Defensive: an IFC file could (unusually) contain a string attribute
333
+ // whose text literally spells "IFCTASK" or "IFCWORKSCHEDULE". The
334
+ // line-regex is anchored to leading whitespace + `#N=TYPE` so it
335
+ // shouldn't match substrings, but let's verify.
336
+ const stepWithTrickyString = `ISO-10303-21;
337
+ HEADER;
338
+ FILE_DESCRIPTION(('test'),'2;1');
339
+ FILE_NAME('','',(''),(''),'','','');
340
+ FILE_SCHEMA(('IFC4'));
341
+ ENDSEC;
342
+ DATA;
343
+ #1=IFCPROJECT('proj',$,'P',$,$,$,$,(#2),#3);
344
+ #10=IFCOWNERHISTORY($,$,$,.NOCHANGE.,$,$,$,0);
345
+ #11=IFCWALL('wall-A',#10,'Description mentions IFCTASK for context',$,$,$,$,$,$);
346
+ #20=IFCWORKSCHEDULE('real-ws',#10,'Real',$,$,$,$,$,$,$,$,$,$,.PLANNED.);
347
+ #22=IFCTASK('real-task',#10,'Real task',$,$,$,$,$,$,.F.,$,$,.CONSTRUCTION.);
348
+ ENDSEC;
349
+ END-ISO-10303-21;
350
+ `;
351
+ const out = injectScheduleIntoStep(stepWithTrickyString, null, STUB_STORE, {
352
+ scheduleIsEdited: true,
353
+ });
354
+ // Real schedule entities stripped…
355
+ assert.ok(!out.includes("'real-ws'"), 'real workschedule stripped');
356
+ assert.ok(!out.includes("'real-task'"), 'real task stripped');
357
+ // …but the wall with "IFCTASK" in its description must survive.
358
+ assert.ok(
359
+ out.includes('Description mentions IFCTASK for context'),
360
+ 'wall with IFCTASK substring in string attribute preserved',
361
+ );
362
+ });
363
+
364
+ test('stripScheduleEntities handles \\r\\n line endings', () => {
365
+ // Some STEP writers emit CRLF; node tests typically run with LF.
366
+ // Verify the splitter treats \r\n correctly — each line ends up with
367
+ // its trailing \r, gets the same regex treatment, and rejoins.
368
+ const crlf = [
369
+ 'ISO-10303-21;',
370
+ 'HEADER;',
371
+ "FILE_DESCRIPTION(('test'),'2;1');",
372
+ "FILE_NAME('','',(''),(''),'','','');",
373
+ "FILE_SCHEMA(('IFC4'));",
374
+ 'ENDSEC;',
375
+ 'DATA;',
376
+ "#1=IFCPROJECT('proj',$,'P',$,$,$,$,(#2),#3);",
377
+ "#10=IFCOWNERHISTORY($,$,$,.NOCHANGE.,$,$,$,0);",
378
+ "#11=IFCWALL('wall-A',#10,'A',$,$,$,$,$,$);",
379
+ "#20=IFCWORKSCHEDULE('ws',#10,'WS',$,$,$,$,$,$,$,$,$,$,.PLANNED.);",
380
+ 'ENDSEC;',
381
+ 'END-ISO-10303-21;',
382
+ '',
383
+ ].join('\r\n');
384
+ const out = injectScheduleIntoStep(crlf, null, STUB_STORE, { scheduleIsEdited: true });
385
+ assert.ok(!out.includes('IFCWORKSCHEDULE'), 'CRLF workschedule stripped');
386
+ assert.ok(out.includes('IFCWALL'), 'CRLF non-schedule line preserved');
387
+ // \r\n preservation not strictly required; as long as each kept line
388
+ // survives with its content, we pass.
389
+ });
390
+
391
+ test('stripScheduleEntities handles multi-line STEP entities', () => {
392
+ // Valid STEP allows entities to span multiple lines (whitespace is
393
+ // tolerated anywhere outside string literals). The old line-by-line
394
+ // regex would have stripped only the first line of a multi-line
395
+ // IFCTASK, leaving its attribute-list continuation as orphan garbage.
396
+ // The statement-level tokenizer handles this correctly.
397
+ const multiLine = [
398
+ 'ISO-10303-21;',
399
+ 'HEADER;',
400
+ "FILE_DESCRIPTION(('test'),'2;1');",
401
+ "FILE_NAME('','',(''),(''),'','','');",
402
+ "FILE_SCHEMA(('IFC4'));",
403
+ 'ENDSEC;',
404
+ 'DATA;',
405
+ "#1=IFCPROJECT('proj',$,'P',$,$,$,$,(#2),#3);",
406
+ "#10=IFCOWNERHISTORY($,$,$,.NOCHANGE.,$,$,$,0);",
407
+ "#11=IFCWALL('wall-A',#10,'A',$,$,$,$,$,$);",
408
+ // Split the IFCTASK across 3 lines. Valid STEP.
409
+ "#20=IFCTASK(",
410
+ " 'multi-task',#10,'Multi-line task',",
411
+ " $,$,$,$,$,$,.F.,$,$,.CONSTRUCTION.);",
412
+ // Multi-line workschedule too.
413
+ "#30=IFCWORKSCHEDULE('ws-multi',",
414
+ " #10,'WS multi',$,$,$,$,$,$,$,$,$,$,.PLANNED.);",
415
+ 'ENDSEC;',
416
+ 'END-ISO-10303-21;',
417
+ '',
418
+ ].join('\n');
419
+ const out = injectScheduleIntoStep(multiLine, null, STUB_STORE, { scheduleIsEdited: true });
420
+ // Every schedule artifact must be gone — no orphan attribute lines
421
+ // left behind.
422
+ assert.ok(!out.includes("'multi-task'"), 'multi-line task stripped');
423
+ assert.ok(!out.includes("'ws-multi'"), 'multi-line workschedule stripped');
424
+ assert.ok(!out.includes('Multi-line task'), 'orphan attribute line not left behind');
425
+ assert.ok(!out.includes('WS multi'), 'orphan workschedule continuation not left behind');
426
+ // Non-schedule entities stay.
427
+ assert.ok(out.includes("'wall-A'"), 'wall survives multi-line strip');
428
+ assert.ok(out.includes('IFCPROJECT'), 'project survives multi-line strip');
429
+ });
430
+
431
+ test("stripScheduleEntities respects ';' inside string literals", () => {
432
+ // A string attribute that literally contains `;` must not confuse
433
+ // the statement tokenizer — STEP terminates statements with `;`
434
+ // outside string literals only.
435
+ const trickySemicolon = [
436
+ 'ISO-10303-21;',
437
+ 'HEADER;',
438
+ "FILE_DESCRIPTION(('test'),'2;1');",
439
+ "FILE_NAME('','',(''),(''),'','','');",
440
+ "FILE_SCHEMA(('IFC4'));",
441
+ 'ENDSEC;',
442
+ 'DATA;',
443
+ "#1=IFCPROJECT('proj',$,'P',$,$,$,$,(#2),#3);",
444
+ "#10=IFCOWNERHISTORY($,$,$,.NOCHANGE.,$,$,$,0);",
445
+ // IFC string-escape: `''` means a single quote. `;` inside a
446
+ // string literal is NOT a statement terminator.
447
+ "#11=IFCWALL('w;all','B','A;B;C',$,$,$,$,$,$);",
448
+ "#20=IFCWORKSCHEDULE('ws',#10,'note;with;semis',$,$,$,$,$,$,$,$,$,$,.PLANNED.);",
449
+ 'ENDSEC;',
450
+ 'END-ISO-10303-21;',
451
+ '',
452
+ ].join('\n');
453
+ const out = injectScheduleIntoStep(trickySemicolon, null, STUB_STORE, { scheduleIsEdited: true });
454
+ assert.ok(!out.includes("'ws'"), 'workschedule stripped despite semicolons in string');
455
+ assert.ok(out.includes("'w;all'"), 'wall with semicolon in name preserved');
456
+ assert.ok(out.includes('A;B;C'), 'wall attribute with semicolons preserved');
457
+ });