@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.
- package/.turbo/turbo-build.log +20 -17
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +630 -0
- package/DESKTOP_CONTRACT_VERSION +1 -1
- package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
- package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
- package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/ids-DQ5jY0E8.js +1 -0
- package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
- package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +8 -7
- package/index.html +1 -0
- package/package.json +13 -13
- package/src/App.tsx +16 -2
- 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/CesiumOverlay.tsx +62 -19
- package/src/components/viewer/ChatPanel.tsx +259 -93
- 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 +73 -13
- package/src/components/viewer/PropertiesPanel.tsx +237 -23
- 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/SettingsPage.tsx +252 -101
- package/src/components/viewer/ThemeSwitch.tsx +63 -7
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +25 -4
- package/src/components/viewer/Viewport.tsx +25 -3
- package/src/components/viewer/ViewportContainer.tsx +51 -64
- package/src/components/viewer/ViewportOverlays.tsx +5 -2
- 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 +4 -4
- package/src/components/viewer/chat/ModelSelector.tsx +90 -54
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
- package/src/components/viewer/properties/LocationMap.tsx +9 -7
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
- 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/tools/SectionCapControls.tsx +237 -0
- package/src/components/viewer/tools/SectionPanel.tsx +39 -18
- package/src/components/viewer/useAnimationLoop.ts +9 -1
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/useRenderUpdates.ts +1 -1
- package/src/hooks/ids/idsDataAccessor.ts +60 -24
- package/src/hooks/ingest/viewerModelIngest.ts +7 -2
- package/src/hooks/useIfcFederation.ts +326 -71
- package/src/hooks/useIfcLoader.ts +23 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/hooks/useViewControls.ts +13 -5
- package/src/index.css +550 -10
- package/src/lib/desktop-entitlement.ts +2 -4
- package/src/lib/geo/cesium-bridge.ts +15 -7
- package/src/lib/geo/effective-georef.test.ts +73 -0
- package/src/lib/geo/effective-georef.ts +111 -0
- package/src/lib/geo/reproject.ts +105 -19
- package/src/lib/llm/byok-guard.test.ts +77 -0
- package/src/lib/llm/byok-guard.ts +39 -0
- package/src/lib/llm/free-models.test.ts +0 -6
- package/src/lib/llm/models.ts +104 -42
- package/src/lib/llm/stream-client.ts +74 -110
- package/src/lib/llm/stream-direct.test.ts +130 -0
- package/src/lib/llm/stream-direct.ts +316 -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 +20 -2
- 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/main.tsx +1 -10
- 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/api-keys.ts +73 -0
- 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/constants.ts +20 -2
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +82 -6
- 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/cesiumSlice.ts +5 -0
- package/src/store/slices/chatSlice.test.ts +6 -76
- package/src/store/slices/chatSlice.ts +17 -58
- 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/sectionSlice.test.ts +87 -7
- package/src/store/slices/sectionSlice.ts +151 -5
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/slices/uiSlice.ts +28 -5
- package/src/store/types.ts +26 -0
- package/src/store.ts +14 -0
- package/src/utils/nativeSpatialDataStore.ts +4 -1
- package/src/utils/viewportUtils.ts +7 -2
- package/src/vite-env.d.ts +0 -4
- package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
- package/dist/assets/ids-B4jTqB1O.js +0 -1
- package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
- package/dist/assets/index-DckuDqlv.css +0 -1
- package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
- package/src/components/viewer/UpgradePage.tsx +0 -71
- package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
- package/src/lib/llm/ClerkChatSync.tsx +0 -74
- 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
|
-
|
|
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;
|