@ifc-lite/viewer 1.17.4 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -8,6 +8,8 @@ import { EntityNode } from '@ifc-lite/query';
8
8
  import { StepExporter, type StepExportOptions } from '@ifc-lite/export';
9
9
  import { getModelForRef, LEGACY_MODEL_ID } from './model-compat.js';
10
10
  import { applyAttributeMutationsToEntityData, getMutationViewForModel } from './mutation-view.js';
11
+ import { serializeScheduleToStep, type ScheduleExtraction, type IfcDataStore } from '@ifc-lite/parser';
12
+ import { spliceScheduleIntoExport } from './export-schedule-splice.js';
11
13
 
12
14
  /** Options for CSV export */
13
15
  interface CsvOptions {
@@ -379,7 +381,18 @@ export function createExportAdapter(store: StoreApi): ExportBackendMethods {
379
381
  georefMutations,
380
382
  };
381
383
 
382
- return exporter.export(exportOptions).content;
384
+ // Splice any in-memory schedule (parsed-and-cached, or generated
385
+ // via the Gantt panel's "Generate from storeys" dialog) into the
386
+ // STEP output via the shared splice helper. Keeps this adapter
387
+ // in lockstep with the viewer's ExportDialog / ExportChangesButton
388
+ // so bugs can't differ across surfaces.
389
+ const exportResult = exporter.export(exportOptions);
390
+ const spliced = spliceScheduleIntoExport(exportResult, modelId, model.ifcDataStore as IfcDataStore, {
391
+ scheduleData: state.scheduleData ?? null,
392
+ scheduleIsEdited: state.scheduleIsEdited === true,
393
+ scheduleSourceModelId: state.scheduleSourceModelId ?? null,
394
+ });
395
+ return spliced.content;
383
396
  },
384
397
 
385
398
  download(content: string | Uint8Array, filename: string, mimeType?: string) {
@@ -389,6 +402,396 @@ export function createExportAdapter(store: StoreApi): ExportBackendMethods {
389
402
  };
390
403
  }
391
404
 
405
+ /**
406
+ * Splice an in-memory `ScheduleExtraction` into a STEP file's DATA section.
407
+ *
408
+ * Three cases:
409
+ * 1. Schedule is purely parsed and untouched — leave the STEP alone.
410
+ * 2. Schedule has generated-only tail (pre-existing behaviour) — append
411
+ * the generated tasks + sequences + schedules just before ENDSEC.
412
+ * 3. Schedule has been *edited* (rename / reschedule / reassign / delete
413
+ * on ANY task, generated or parsed) — strip EVERY schedule entity
414
+ * from the STEP body and re-emit the whole `scheduleData` fresh.
415
+ * Dependent entities (`IfcTaskTime`, `IfcLagTime`, `IfcRel*`) cascade
416
+ * cleanly on deletion because we serialize the whole block at once.
417
+ *
418
+ * We also use the source model's existing IfcOwnerHistory (when present)
419
+ * for the inserted entities so they share ownership metadata.
420
+ */
421
+ export interface InjectScheduleOptions {
422
+ /**
423
+ * When true, the caller has edited the in-memory schedule — enter
424
+ * rewrite mode (case 3 above). The flag is the scheduleSlice's
425
+ * `scheduleIsEdited` value; threading it here keeps injection logic
426
+ * free of store knowledge.
427
+ */
428
+ scheduleIsEdited?: boolean;
429
+ }
430
+
431
+ export function injectScheduleIntoStep(
432
+ stepContent: string,
433
+ scheduleData: ScheduleExtraction | null,
434
+ ifcDataStore: IfcDataStore,
435
+ options?: InjectScheduleOptions,
436
+ ): string {
437
+ if (!scheduleData || scheduleData.tasks.length === 0) {
438
+ // No schedule in memory. If the caller flagged "edited", the user
439
+ // deleted every task in what used to be a parsed schedule — we
440
+ // still want to strip the stale entities from the STEP.
441
+ if (options?.scheduleIsEdited) {
442
+ return stripScheduleEntities(stepContent);
443
+ }
444
+ return stepContent;
445
+ }
446
+
447
+ const hasGenerated = scheduleData.tasks.some(t => !t.expressId || t.expressId <= 0);
448
+ const edited = options?.scheduleIsEdited === true;
449
+
450
+ if (!edited && !hasGenerated) return stepContent;
451
+
452
+ // Shared resolution helpers for both injection paths.
453
+ const resolveProduct = (gid: string): number | undefined => {
454
+ if (!gid) return undefined;
455
+ return ifcDataStore.entities?.getExpressIdByGlobalId?.(gid) ?? undefined;
456
+ };
457
+
458
+ // ── Rewrite path: strip + re-emit the full schedule ─────────────
459
+ if (edited) {
460
+ const stripped = stripScheduleEntities(stepContent);
461
+ const maxId = findMaxExpressId(stripped);
462
+ const ownerHistoryId = findFirstOwnerHistoryId(stripped) ?? undefined;
463
+
464
+ const result = serializeScheduleToStep(scheduleData, {
465
+ nextId: maxId + 1,
466
+ ownerHistoryId,
467
+ resolveProductExpressId: resolveProduct,
468
+ });
469
+ if (result.lines.length === 0) return stripped;
470
+ return spliceBeforeEndSec(stripped, result.lines);
471
+ }
472
+
473
+ // ── Append-only path: only generated tasks (legacy behaviour) ───
474
+ const generatedTasks = scheduleData.tasks.filter(t => !t.expressId || t.expressId <= 0);
475
+ const generatedTaskGids = new Set(generatedTasks.map(t => t.globalId));
476
+ const generatedSequences = scheduleData.sequences.filter(
477
+ s => generatedTaskGids.has(s.relatingTaskGlobalId) && generatedTaskGids.has(s.relatedTaskGlobalId),
478
+ );
479
+ const generatedWorkSchedules = scheduleData.workSchedules.filter(ws => !ws.expressId || ws.expressId <= 0);
480
+
481
+ const partitioned: ScheduleExtraction = {
482
+ hasSchedule: true,
483
+ workSchedules: generatedWorkSchedules,
484
+ tasks: generatedTasks,
485
+ sequences: generatedSequences,
486
+ };
487
+
488
+ const maxId = findMaxExpressId(stepContent);
489
+ const ownerHistoryId = findFirstOwnerHistoryId(stepContent) ?? undefined;
490
+
491
+ const result = serializeScheduleToStep(partitioned, {
492
+ nextId: maxId + 1,
493
+ ownerHistoryId,
494
+ resolveProductExpressId: resolveProduct,
495
+ });
496
+ if (result.lines.length === 0) return stepContent;
497
+ return spliceBeforeEndSec(stepContent, result.lines);
498
+ }
499
+
500
+ /**
501
+ * Splice fresh STEP lines just before the DATA-section's closing
502
+ * `ENDSEC;`. Anchored on the LAST `ENDSEC;` because the header section
503
+ * also ends with one — we want the data end.
504
+ */
505
+ function spliceBeforeEndSec(stepContent: string, lines: string[]): string {
506
+ const endSecIdx = stepContent.lastIndexOf('ENDSEC;');
507
+ if (endSecIdx < 0) {
508
+ // Malformed STEP — surface the original file unchanged rather than
509
+ // corrupting it.
510
+ console.warn('[export] schedule injection: ENDSEC not found in STEP output');
511
+ return stepContent;
512
+ }
513
+ const head = stepContent.slice(0, endSecIdx);
514
+ const tail = stepContent.slice(endSecIdx);
515
+ return `${head}${lines.join('\n')}\n${tail}`;
516
+ }
517
+
518
+ /**
519
+ * Remove every schedule-related entity declaration from the STEP body.
520
+ *
521
+ * Two-pass:
522
+ * 1. Identify every express ID whose entity type is in the "always a
523
+ * schedule entity" set (`IfcTask`, `IfcWorkSchedule`, `IfcWorkPlan`,
524
+ * `IfcTaskTime`, `IfcLagTime`).
525
+ * 2. Drop lines whose ID is in that set OR whose entity type is one of
526
+ * the sometimes-schedule types (`IfcRelSequence`, `IfcRelAssignsTo-
527
+ * Process`, `IfcRelAssignsToControl`) OR `IfcRelNests` lines that
528
+ * reference any ID from step 1.
529
+ *
530
+ * The IfcRelNests check prevents us from stripping cost-item/resource
531
+ * nests, which share the entity but aren't schedule-owned.
532
+ */
533
+ const ALWAYS_SCHEDULE_TYPES: ReadonlySet<string> = new Set([
534
+ 'IFCTASK',
535
+ 'IFCWORKSCHEDULE',
536
+ 'IFCWORKPLAN',
537
+ 'IFCTASKTIME',
538
+ 'IFCTASKTIMERECURRING',
539
+ 'IFCLAGTIME',
540
+ ]);
541
+
542
+ const SOMETIMES_SCHEDULE_TYPES: ReadonlySet<string> = new Set([
543
+ 'IFCRELSEQUENCE',
544
+ 'IFCRELASSIGNSTOPROCESS',
545
+ 'IFCRELASSIGNSTOCONTROL',
546
+ ]);
547
+
548
+ function stripScheduleEntities(stepContent: string): string {
549
+ // Pass 1: collect schedule-entity IDs by tokenizing declarations.
550
+ //
551
+ // We walk the STEP content at the STATEMENT level (terminated by `;`
552
+ // outside string literals), not line-by-line. Line-based splitting
553
+ // breaks when a writer spans an entity across multiple lines —
554
+ // valid STEP allows whitespace and newlines anywhere outside string
555
+ // literals. Statement-based walking handles multi-line entities
556
+ // transparently.
557
+ const statements = tokenizeStepStatements(stepContent);
558
+ const scheduleIds = new Set<number>();
559
+ for (const stmt of statements) {
560
+ if (stmt.kind !== 'entity') continue;
561
+ if (ALWAYS_SCHEDULE_TYPES.has(stmt.typeUpper)) scheduleIds.add(stmt.id);
562
+ }
563
+
564
+ if (scheduleIds.size === 0) {
565
+ // No "always" schedule entities. There can't be any schedule-related
566
+ // relationship entities either; nothing to strip.
567
+ return stepContent;
568
+ }
569
+
570
+ // Pass 2: walk statements and emit non-schedule text ranges. We keep
571
+ // byte ranges (start/end offsets in `stepContent`) rather than
572
+ // reassembling, so leading/trailing whitespace between statements
573
+ // survives byte-identical when every statement is kept.
574
+ const keptRanges: Array<{ start: number; end: number }> = [];
575
+ let cursor = 0;
576
+ for (const stmt of statements) {
577
+ if (stmt.kind !== 'entity') {
578
+ // Non-entity text (header, section markers, whitespace) — always keep.
579
+ continue;
580
+ }
581
+ if (shouldStripStatement(stmt, scheduleIds)) {
582
+ // Push the range from `cursor` up to the statement start, then
583
+ // advance past the statement (including trailing whitespace /
584
+ // newline so we don't leave a gap).
585
+ if (stmt.start > cursor) keptRanges.push({ start: cursor, end: stmt.start });
586
+ cursor = stmt.end;
587
+ // Also consume a trailing newline so we don't leave blank lines
588
+ // scattered where schedule statements used to live.
589
+ if (stepContent[cursor] === '\r') cursor++;
590
+ if (stepContent[cursor] === '\n') cursor++;
591
+ }
592
+ }
593
+ if (cursor < stepContent.length) {
594
+ keptRanges.push({ start: cursor, end: stepContent.length });
595
+ }
596
+
597
+ // Concatenate kept ranges.
598
+ if (keptRanges.length === 1 && keptRanges[0].start === 0 && keptRanges[0].end === stepContent.length) {
599
+ return stepContent; // No-op path — nothing was stripped.
600
+ }
601
+ let out = '';
602
+ for (const r of keptRanges) out += stepContent.slice(r.start, r.end);
603
+ return out;
604
+ }
605
+
606
+ /** Per-statement classification: should we drop this record? */
607
+ function shouldStripStatement(
608
+ stmt: { typeUpper: string; id: number; attributesText: string },
609
+ scheduleIds: ReadonlySet<number>,
610
+ ): boolean {
611
+ if (scheduleIds.has(stmt.id)) return true; // Always-schedule entity itself.
612
+ if (SOMETIMES_SCHEDULE_TYPES.has(stmt.typeUpper)) {
613
+ // Relationship entity; strip only if it references a schedule id.
614
+ return referencesAnyId(stmt.attributesText, scheduleIds);
615
+ }
616
+ if (stmt.typeUpper === 'IFCRELNESTS') {
617
+ // Only strip when the referenced set includes a schedule id (the
618
+ // nest ties a task to its children). False-positives (a nests that
619
+ // mixes task + non-task in a single record) are vanishingly rare.
620
+ return referencesAnyId(stmt.attributesText, scheduleIds);
621
+ }
622
+ return false;
623
+ }
624
+
625
+ interface StepEntityStatement {
626
+ kind: 'entity';
627
+ /** Byte offset of the `#` in `#ID=…`. */
628
+ start: number;
629
+ /** Byte offset just past the terminating `;`. */
630
+ end: number;
631
+ id: number;
632
+ typeUpper: string;
633
+ /** The parenthesised attribute list text including the outer parens. */
634
+ attributesText: string;
635
+ }
636
+
637
+ /**
638
+ * Tokenize `stepContent` into entity statements. Skips HEADER / DATA
639
+ * section markers and whitespace; returns only `#ID=TYPE(…);` records.
640
+ * Respects `'…'` string literals (STEP uses `''` to escape a quote).
641
+ */
642
+ function tokenizeStepStatements(stepContent: string): StepEntityStatement[] {
643
+ const out: StepEntityStatement[] = [];
644
+ const len = stepContent.length;
645
+ let i = 0;
646
+ while (i < len) {
647
+ // Skip whitespace.
648
+ while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t' || stepContent[i] === '\n' || stepContent[i] === '\r')) i++;
649
+ if (i >= len) break;
650
+ // Only interested in `#N=…;` records. Anything else — header keywords,
651
+ // section markers, end markers — gets scanned to the next `;` and
652
+ // discarded as non-entity text.
653
+ if (stepContent[i] !== '#') {
654
+ // Scan to next `;` (STEP statements are `;`-terminated).
655
+ i = scanToStatementEnd(stepContent, i);
656
+ continue;
657
+ }
658
+ const declStart = i;
659
+ i++; // past '#'
660
+ // Read id digits.
661
+ const idStart = i;
662
+ while (i < len && stepContent.charCodeAt(i) >= 0x30 && stepContent.charCodeAt(i) <= 0x39) i++;
663
+ if (i === idStart) {
664
+ // `#` not followed by a digit — not an entity reference. Skip to `;`.
665
+ i = scanToStatementEnd(stepContent, declStart + 1);
666
+ continue;
667
+ }
668
+ const id = parseInt(stepContent.slice(idStart, i), 10);
669
+ // Allow whitespace before `=`.
670
+ while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t')) i++;
671
+ if (stepContent[i] !== '=') {
672
+ // `#N` without `=` — reference inside an attribute list; bail.
673
+ i = scanToStatementEnd(stepContent, declStart + 1);
674
+ continue;
675
+ }
676
+ i++; // past '='
677
+ while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t')) i++;
678
+ // Type name: uppercase letters, digits, underscore.
679
+ const typeStart = i;
680
+ while (i < len) {
681
+ const c = stepContent[i];
682
+ if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '_' || (c >= 'a' && c <= 'z')) i++;
683
+ else break;
684
+ }
685
+ if (i === typeStart) {
686
+ i = scanToStatementEnd(stepContent, declStart + 1);
687
+ continue;
688
+ }
689
+ const typeUpper = stepContent.slice(typeStart, i).toUpperCase();
690
+ // Optional whitespace before attribute list.
691
+ while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t' || stepContent[i] === '\n' || stepContent[i] === '\r')) i++;
692
+ // Attribute list starts with `(`. Read until matching `)`, respecting
693
+ // string literals and nested parens.
694
+ const attrStart = i;
695
+ if (stepContent[i] !== '(') {
696
+ i = scanToStatementEnd(stepContent, declStart + 1);
697
+ continue;
698
+ }
699
+ i++; // past '('
700
+ let depth = 1;
701
+ let inString = false;
702
+ while (i < len && depth > 0) {
703
+ const c = stepContent[i];
704
+ if (inString) {
705
+ if (c === "'") {
706
+ // Peek for escape `''`.
707
+ if (stepContent[i + 1] === "'") { i += 2; continue; }
708
+ inString = false;
709
+ i++;
710
+ continue;
711
+ }
712
+ i++;
713
+ continue;
714
+ }
715
+ if (c === "'") { inString = true; i++; continue; }
716
+ if (c === '(') { depth++; i++; continue; }
717
+ if (c === ')') { depth--; i++; continue; }
718
+ i++;
719
+ }
720
+ const attrEnd = i;
721
+ // Expect `;` terminator (optionally preceded by whitespace).
722
+ while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t')) i++;
723
+ if (stepContent[i] !== ';') {
724
+ // Malformed — scan to next `;` and skip this record.
725
+ i = scanToStatementEnd(stepContent, attrEnd);
726
+ continue;
727
+ }
728
+ i++; // past ';'
729
+ const end = i;
730
+ out.push({
731
+ kind: 'entity',
732
+ start: declStart,
733
+ end,
734
+ id,
735
+ typeUpper,
736
+ attributesText: stepContent.slice(attrStart, attrEnd),
737
+ });
738
+ }
739
+ return out;
740
+ }
741
+
742
+ /** Advance past the next `;` outside string literals. Never walks backwards. */
743
+ function scanToStatementEnd(s: string, from: number): number {
744
+ const len = s.length;
745
+ let i = from;
746
+ let inString = false;
747
+ while (i < len) {
748
+ const c = s[i];
749
+ if (inString) {
750
+ if (c === "'") {
751
+ if (s[i + 1] === "'") { i += 2; continue; }
752
+ inString = false;
753
+ }
754
+ i++;
755
+ continue;
756
+ }
757
+ if (c === "'") { inString = true; i++; continue; }
758
+ if (c === ';') return i + 1;
759
+ i++;
760
+ }
761
+ return len;
762
+ }
763
+
764
+ /** True iff any `#N` token in `rest` has N in the given set. */
765
+ function referencesAnyId(rest: string, ids: ReadonlySet<number>): boolean {
766
+ const refRegex = /#(\d+)/g;
767
+ let m: RegExpExecArray | null;
768
+ while ((m = refRegex.exec(rest)) !== null) {
769
+ const n = parseInt(m[1], 10);
770
+ if (ids.has(n)) return true;
771
+ }
772
+ return false;
773
+ }
774
+
775
+ /** Scan the STEP body for the highest `#N=` declaration. Returns 0 when none. */
776
+ function findMaxExpressId(stepContent: string): number {
777
+ let max = 0;
778
+ // Pattern: line starts with `#NNN=` (newline-anchored to avoid matching
779
+ // refs inside attribute lists).
780
+ const regex = /(?:^|\n)\s*#(\d+)\s*=/g;
781
+ let m: RegExpExecArray | null;
782
+ while ((m = regex.exec(stepContent)) !== null) {
783
+ const n = parseInt(m[1], 10);
784
+ if (Number.isFinite(n) && n > max) max = n;
785
+ }
786
+ return max;
787
+ }
788
+
789
+ /** Find the first IfcOwnerHistory's express ID in the STEP file, if any. */
790
+ function findFirstOwnerHistoryId(stepContent: string): number | null {
791
+ const m = stepContent.match(/(?:^|\n)\s*#(\d+)\s*=\s*IFCOWNERHISTORY\b/i);
792
+ return m ? parseInt(m[1], 10) : null;
793
+ }
794
+
392
795
  /** Trigger a browser file download */
393
796
  function triggerDownload(content: string | Uint8Array, filename: string, mimeType: string): void {
394
797
  if (typeof document === 'undefined') {
@@ -0,0 +1,127 @@
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
+ * Integration tests for `spliceScheduleIntoExport` — the single entry
7
+ * point every export surface routes through.
8
+ *
9
+ * Guards the bug classes that hit production:
10
+ * • Uint8Array content path silently skipping injection
11
+ * • `scheduleSourceModelId === null` single-model sessions not
12
+ * matching the model id
13
+ * • Edited parsed schedules not triggering the rewrite branch
14
+ */
15
+
16
+ import test from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { spliceScheduleIntoExport } from './export-schedule-splice.js';
19
+ import type { ScheduleExtraction, IfcDataStore } from '@ifc-lite/parser';
20
+
21
+ const SAMPLE_STEP = `ISO-10303-21;
22
+ HEADER;
23
+ FILE_DESCRIPTION(('test'),'2;1');
24
+ FILE_NAME('','',(''),(''),'','','');
25
+ FILE_SCHEMA(('IFC4'));
26
+ ENDSEC;
27
+ DATA;
28
+ #1=IFCPROJECT('proj',$,'P',$,$,$,$,(#2),#3);
29
+ #10=IFCOWNERHISTORY($,$,$,.NOCHANGE.,$,$,$,0);
30
+ #11=IFCWALL('wall-A',#10,'A',$,$,$,$,$,$);
31
+ ENDSEC;
32
+ END-ISO-10303-21;
33
+ `;
34
+
35
+ const STUB_STORE: IfcDataStore = {
36
+ entities: {
37
+ getExpressIdByGlobalId: (gid: string) => (gid === 'wall-A' ? 11 : -1),
38
+ } as unknown as IfcDataStore['entities'],
39
+ } as unknown as IfcDataStore;
40
+
41
+ function genSchedule(): ScheduleExtraction {
42
+ return {
43
+ hasSchedule: true,
44
+ workSchedules: [{
45
+ expressId: 0, globalId: 'ws', kind: 'WorkSchedule',
46
+ name: 'Gen', startTime: '2024-05-01T08:00:00',
47
+ taskGlobalIds: ['t1'],
48
+ }],
49
+ tasks: [{
50
+ expressId: 0, globalId: 't1', name: 'Install',
51
+ isMilestone: false, predefinedType: 'INSTALLATION',
52
+ childGlobalIds: [],
53
+ productExpressIds: [0],
54
+ productGlobalIds: ['wall-A'],
55
+ controllingScheduleGlobalIds: ['ws'],
56
+ taskTime: {
57
+ scheduleStart: '2024-05-01T08:00:00',
58
+ scheduleFinish: '2024-05-05T17:00:00',
59
+ },
60
+ }],
61
+ sequences: [],
62
+ };
63
+ }
64
+
65
+ // ─── the bug we actually shipped ──────────────────────────────────────
66
+
67
+ test('splices into Uint8Array content (the regression that shipped)', () => {
68
+ // StepExporter sometimes returns bytes; every string-only short
69
+ // circuit we shipped silently dropped the splice on this path.
70
+ const bytes = new TextEncoder().encode(SAMPLE_STEP);
71
+ const result = { content: bytes as string | Uint8Array };
72
+ const out = spliceScheduleIntoExport(result, 'modelA', STUB_STORE, {
73
+ scheduleData: genSchedule(),
74
+ scheduleIsEdited: false,
75
+ scheduleSourceModelId: 'modelA',
76
+ });
77
+ // Output must remain bytes (contract: preserve caller's content type)…
78
+ assert.ok(out.content instanceof Uint8Array, 'bytes in → bytes out');
79
+ // …and the schedule lines must be present in the decoded text.
80
+ const decoded = new TextDecoder('utf-8').decode(out.content as Uint8Array);
81
+ assert.match(decoded, /=IFCWORKSCHEDULE\(/);
82
+ assert.match(decoded, /=IFCTASK\(/);
83
+ });
84
+
85
+ test('preserves string content type when exporter returns text', () => {
86
+ const result = { content: SAMPLE_STEP as string | Uint8Array };
87
+ const out = spliceScheduleIntoExport(result, 'modelA', STUB_STORE, {
88
+ scheduleData: genSchedule(),
89
+ scheduleIsEdited: false,
90
+ scheduleSourceModelId: 'modelA',
91
+ });
92
+ assert.strictEqual(typeof out.content, 'string', 'string in → string out');
93
+ assert.match(out.content as string, /=IFCWORKSCHEDULE\(/);
94
+ });
95
+
96
+ test('null sourceModelId + in-memory schedule still splices (single-model fallback)', () => {
97
+ // Regression: single-model sessions generated with the Generate
98
+ // dialog left sourceModelId as null. Strict equality against the
99
+ // export target's model id missed every time.
100
+ const result = { content: SAMPLE_STEP as string | Uint8Array };
101
+ const out = spliceScheduleIntoExport(result, '__legacy__', STUB_STORE, {
102
+ scheduleData: genSchedule(),
103
+ scheduleIsEdited: false,
104
+ scheduleSourceModelId: null,
105
+ });
106
+ assert.match(out.content as string, /=IFCTASK\(/);
107
+ });
108
+
109
+ test('sourceModelId mismatch skips the splice (federation safety)', () => {
110
+ const result = { content: SAMPLE_STEP as string | Uint8Array };
111
+ const out = spliceScheduleIntoExport(result, 'modelB', STUB_STORE, {
112
+ scheduleData: genSchedule(),
113
+ scheduleIsEdited: false,
114
+ scheduleSourceModelId: 'modelA', // different model!
115
+ });
116
+ assert.strictEqual(out.content, SAMPLE_STEP, 'foreign schedule is not spliced');
117
+ });
118
+
119
+ test('no schedule + no edit flag is a no-op', () => {
120
+ const result = { content: SAMPLE_STEP as string | Uint8Array };
121
+ const out = spliceScheduleIntoExport(result, 'modelA', STUB_STORE, {
122
+ scheduleData: null,
123
+ scheduleIsEdited: false,
124
+ scheduleSourceModelId: null,
125
+ });
126
+ assert.strictEqual(out.content, SAMPLE_STEP);
127
+ });
@@ -0,0 +1,87 @@
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
+ * Shared helper for splicing an in-memory schedule into a STEP export.
7
+ *
8
+ * Three export surfaces (ExportChangesButton, ExportDialog, SDK
9
+ * adapter) previously re-implemented the same decode-inject-re-encode
10
+ * dance. Two of the three shipped a bug at different times:
11
+ * • gate on `typeof result.content === 'string'` silently dropped
12
+ * the splice when `StepExporter.export()` returned `Uint8Array`
13
+ * • gate on `scheduleSourceModelId === selectedModelId` missed
14
+ * single-model sessions where `sourceModelId` was `null`
15
+ *
16
+ * Centralising into one function kills both bug classes — the three
17
+ * surfaces become one-liners over the same contract.
18
+ */
19
+
20
+ import type { IfcDataStore, ScheduleExtraction } from '@ifc-lite/parser';
21
+ import { injectScheduleIntoStep } from './export-adapter.js';
22
+
23
+ export interface ExportScheduleState {
24
+ /** In-memory schedule — generated tasks, edits, or parsed+untouched. */
25
+ scheduleData: ScheduleExtraction | null;
26
+ /** True when the user has edited the schedule since load / generation. */
27
+ scheduleIsEdited: boolean;
28
+ /**
29
+ * Model the schedule is attributed to. Null for purely-parsed schedules
30
+ * that haven't been touched (or for single-model sessions where the
31
+ * generate dialog didn't explicitly attribute one).
32
+ */
33
+ scheduleSourceModelId: string | null;
34
+ }
35
+
36
+ export interface ExportResultLike {
37
+ content: string | Uint8Array;
38
+ }
39
+
40
+ /**
41
+ * Splice the pending schedule into the exporter's output.
42
+ *
43
+ * Input: the raw `result.content` from `StepExporter.export()` (which
44
+ * may be text or bytes) + the model + the current schedule state.
45
+ *
46
+ * Output: the same `content` shape (string → string, Uint8Array →
47
+ * Uint8Array) with the schedule either (a) left alone — no pending
48
+ * schedule, or source-model doesn't match the export target, (b)
49
+ * appended for a generated schedule, or (c) strip-and-rewritten for an
50
+ * edited schedule.
51
+ *
52
+ * Gate logic:
53
+ * • match if `scheduleSourceModelId === modelId` (federated session)
54
+ * • match if `scheduleSourceModelId === null` AND we have tasks
55
+ * (single-model session where the generate dialog didn't attribute)
56
+ */
57
+ export function spliceScheduleIntoExport(
58
+ result: ExportResultLike,
59
+ modelId: string,
60
+ dataStore: IfcDataStore,
61
+ state: ExportScheduleState,
62
+ ): ExportResultLike {
63
+ const taskCount = state.scheduleData?.tasks.length ?? 0;
64
+ const sourceMatches = state.scheduleSourceModelId === modelId;
65
+ const singleModelFallback = state.scheduleSourceModelId === null && taskCount > 0;
66
+ const shouldInject = sourceMatches || singleModelFallback;
67
+ if (!shouldInject) return result;
68
+
69
+ // STEP is textual by spec but the underlying exporter sometimes
70
+ // returns Uint8Array (pre-encoded bytes). Decode on the way in,
71
+ // splice, re-encode on the way out so the caller's content-type
72
+ // contract is preserved.
73
+ const raw = result.content;
74
+ const stepText = typeof raw === 'string'
75
+ ? raw
76
+ : new TextDecoder('utf-8', { fatal: false }).decode(raw);
77
+ const injected = injectScheduleIntoStep(
78
+ stepText,
79
+ state.scheduleData ?? null,
80
+ dataStore,
81
+ { scheduleIsEdited: state.scheduleIsEdited === true },
82
+ );
83
+ return {
84
+ ...result,
85
+ content: typeof raw === 'string' ? injected : new TextEncoder().encode(injected),
86
+ };
87
+ }
@@ -21,11 +21,17 @@ import type { ViewerState } from '../../store/index.js';
21
21
  /** Sentinel model ID used for the legacy single-model path */
22
22
  export const LEGACY_MODEL_ID = 'default';
23
23
 
24
- /** Minimal model shape needed by the SDK adapters */
24
+ /** Minimal model shape needed by the SDK adapters.
25
+ *
26
+ * `ifcDataStore` is nullable because the federated model store also
27
+ * carries native-metadata-only entries (loaded through Tauri without a
28
+ * full STEP parse). Adapters that need the data store check for null
29
+ * before using it.
30
+ */
25
31
  export interface ModelLike {
26
32
  id: string;
27
33
  name: string;
28
- ifcDataStore: IfcDataStore;
34
+ ifcDataStore: IfcDataStore | null;
29
35
  schemaVersion: SchemaVersion;
30
36
  fileSize: number;
31
37
  loadedAt: number;