/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import type { StoreApi } from './types.js'; import type { EntityRef, EntityData, PropertySetData, QuantitySetData, ExportBackendMethods } from '@ifc-lite/sdk'; import { EntityNode } from '@ifc-lite/query'; import { StepExporter, type StepExportOptions } from '@ifc-lite/export'; import { getModelForRef, LEGACY_MODEL_ID } from './model-compat.js'; import { applyAttributeMutationsToEntityData, getMutationViewForModel } from './mutation-view.js'; import { serializeScheduleToStep, type ScheduleExtraction, type IfcDataStore } from '@ifc-lite/parser'; import { spliceScheduleIntoExport } from './export-schedule-splice.js'; /** Options for CSV export */ interface CsvOptions { columns: string[]; separator?: string; filename?: string; } /** Options for IFC STEP export */ interface IfcExportOptions { schema?: 'IFC2X3' | 'IFC4' | 'IFC4X3'; filename?: string; includeMutations?: boolean; visibleOnly?: boolean; } /** Validate that a value is an IfcExportOptions object. */ function isIfcExportOptions(v: unknown): v is IfcExportOptions { if (v === null || typeof v !== 'object') return false; const options = v as IfcExportOptions; if (options.schema !== undefined && options.schema !== 'IFC2X3' && options.schema !== 'IFC4' && options.schema !== 'IFC4X3') return false; if (options.filename !== undefined && typeof options.filename !== 'string') return false; if (options.includeMutations !== undefined && typeof options.includeMutations !== 'boolean') return false; if (options.visibleOnly !== undefined && typeof options.visibleOnly !== 'boolean') return false; return true; } /** * Validate that a value is a CsvOptions object. */ function isCsvOptions(v: unknown): v is CsvOptions { if (v === null || typeof v !== 'object' || !('columns' in v)) return false; const columns = (v as CsvOptions).columns; if (!Array.isArray(columns)) return false; // Validate all column entries are strings return columns.every((c): c is string => typeof c === 'string'); } /** * Validate that a value is an array of EntityRef objects. */ function isEntityRefArray(v: unknown): v is EntityRef[] { if (!Array.isArray(v)) return false; if (v.length === 0) return true; const first = v[0] as Record; // Accept both raw EntityRef and entity proxy objects with .ref if ('modelId' in first && 'expressId' in first) { return typeof first.modelId === 'string' && typeof first.expressId === 'number'; } if ('ref' in first && first.ref !== null && typeof first.ref === 'object') { const ref = first.ref as Record; return typeof ref.modelId === 'string' && typeof ref.expressId === 'number'; } return false; } /** * Normalize entity refs — entities from the sandbox may be EntityData * objects with a .ref property, or raw EntityRef { modelId, expressId }. */ function normalizeRefs(raw: unknown[]): EntityRef[] { return raw.map((item) => { const r = item as Record; if (r.ref && typeof r.ref === 'object') { return r.ref as EntityRef; } return { modelId: r.modelId as string, expressId: r.expressId as number }; }); } export function resolveVisibilityFilterSets( state: StoreApi['getState'] extends () => infer T ? T : never, modelId: string, selectedExpressIds: Set, entityCount: number, ): { visibleOnly: boolean; hiddenEntityIds: Set; isolatedEntityIds: Set | null } { const shouldLimitToSelection = selectedExpressIds.size < entityCount; const isLegacyModel = state.models.size === 0 && (modelId === LEGACY_MODEL_ID || modelId === 'legacy'); const modelHidden = state.hiddenEntitiesByModel.get(modelId) ?? (isLegacyModel ? state.hiddenEntities : undefined); const modelIsolated = state.isolatedEntitiesByModel.get(modelId) ?? (isLegacyModel ? state.isolatedEntities : null); return { visibleOnly: shouldLimitToSelection, hiddenEntityIds: shouldLimitToSelection ? new Set() : new Set(modelHidden ?? []), isolatedEntityIds: shouldLimitToSelection ? selectedExpressIds : modelIsolated, }; } /** * Escape a CSV cell value — wrap in quotes if it contains the separator, * double-quotes, or newlines. */ function escapeCsv(value: string, sep: string): string { // Neutralize spreadsheet formula injection (CWE-1236): a leading // =, +, -, @, TAB or CR makes a cell execute as a formula in Excel/ // LibreOffice/Sheets. IFC values are attacker-controllable, so prefix // such cells with an apostrophe. if (/^[=+\-@\t\r]/.test(value)) value = `'${value}`; if (value.includes(sep) || value.includes('"') || value.includes('\n') || value.includes('\r')) { return `"${value.replace(/"/g, '""')}"`; } return value; } /** * Export adapter — implements CSV and JSON export directly. * * This adapter resolves entity data by dispatching to the query adapter * on the same LocalBackend, providing full export support for both * direct dispatch calls and SDK namespace usage. */ function toBlobPart(content: string | Uint8Array): BlobPart { if (typeof content === 'string') return content; const bytes = new Uint8Array(content.byteLength); bytes.set(content); return bytes; } export function createExportAdapter(store: StoreApi): ExportBackendMethods { /** Resolve entity data via the query subsystem */ function getEntityData(ref: EntityRef): EntityData | null { const state = store.getState(); const model = getModelForRef(state, ref.modelId); if (!model?.ifcDataStore) return null; const node = new EntityNode(model.ifcDataStore, ref.expressId); return applyAttributeMutationsToEntityData(store, ref.modelId, ref.expressId, { ref, globalId: node.globalId, name: node.name, type: node.type, description: node.description, objectType: node.objectType, }); } /** Resolve property sets for an entity */ function getProperties(ref: EntityRef): PropertySetData[] { const state = store.getState(); const model = getModelForRef(state, ref.modelId); if (!model?.ifcDataStore) return []; const node = new EntityNode(model.ifcDataStore, ref.expressId); return node.properties().map((pset) => ({ name: pset.name, globalId: pset.globalId, properties: pset.properties.map((p) => ({ name: p.name, type: p.type, value: p.value as string | number | boolean | null, })), })); } /** Resolve quantity sets for an entity */ function getQuantities(ref: EntityRef): QuantitySetData[] { const state = store.getState(); const model = getModelForRef(state, ref.modelId); if (!model?.ifcDataStore) return []; const node = new EntityNode(model.ifcDataStore, ref.expressId); return node.quantities().map((qset: { name: string; quantities: Array<{ name: string; type: number; value: number }> }) => ({ name: qset.name, quantities: qset.quantities.map((q: { name: string; type: number; value: number }) => ({ name: q.name, type: q.type, value: q.value, })), })); } /** Resolve a single column value from entity data + properties + quantities. * Accepts both IFC PascalCase (Name, GlobalId) and legacy camelCase (name, globalId). * Dot-path columns (e.g. "Pset_WallCommon.FireRating" or "Qto_WallBaseQuantities.GrossVolume") * resolve against property sets first, then quantity sets. */ function resolveColumnValue( data: EntityData, col: string, getProps: () => PropertySetData[], getQties: () => QuantitySetData[], ): string { // IFC schema attribute names (PascalCase) + legacy camelCase switch (col) { case 'Name': case 'name': return data.name; case 'Type': case 'type': return data.type; case 'GlobalId': case 'globalId': return data.globalId; case 'Description': case 'description': return data.description; case 'ObjectType': case 'objectType': return data.objectType; case 'modelId': return data.ref.modelId; case 'expressId': return String(data.ref.expressId); } // Property/Quantity path: "SetName.ValueName" const dotIdx = col.indexOf('.'); if (dotIdx > 0) { const setName = col.slice(0, dotIdx); const valueName = col.slice(dotIdx + 1); // Try property sets first const psets = getProps(); const pset = psets.find(p => p.name === setName); if (pset) { const prop = pset.properties.find(p => p.name === valueName); if (prop?.value != null) return String(prop.value); } // Fall back to quantity sets const qsets = getQties(); const qset = qsets.find(q => q.name === setName); if (qset) { const qty = qset.quantities.find(q => q.name === valueName); if (qty?.value != null) return String(qty.value); } return ''; } return ''; } return { csv(rawRefs: unknown, rawOptions: unknown) { if (!isEntityRefArray(rawRefs)) { throw new Error('export.csv: first argument must be an array of entity references'); } if (!isCsvOptions(rawOptions)) { throw new Error('export.csv: second argument must be { columns: string[], separator?: string }'); } const refs = normalizeRefs(rawRefs); const options = rawOptions; const sep = options.separator ?? ','; const rows: string[][] = []; // Header row rows.push(options.columns); // Data rows for (const ref of refs) { const data = getEntityData(ref); if (!data) continue; // Lazy-load properties/quantities only if a column needs them let cachedProps: PropertySetData[] | null = null; const getProps = (): PropertySetData[] => { if (!cachedProps) cachedProps = getProperties(ref); return cachedProps; }; let cachedQties: QuantitySetData[] | null = null; const getQties = (): QuantitySetData[] => { if (!cachedQties) cachedQties = getQuantities(ref); return cachedQties; }; const row = options.columns.map(col => resolveColumnValue(data, col, getProps, getQties)); rows.push(row); } const csvString = rows.map(r => r.map(cell => escapeCsv(cell, sep)).join(sep)).join('\n'); // If filename specified, trigger browser download if (options.filename) { triggerDownload(csvString, options.filename, 'text/csv;charset=utf-8;'); } return csvString; }, json(rawRefs: unknown, columns: unknown) { if (!isEntityRefArray(rawRefs)) { throw new Error('export.json: first argument must be an array of entity references'); } if (!Array.isArray(columns)) { throw new Error('export.json: second argument must be a string[] of column names'); } const refs = normalizeRefs(rawRefs); const result: Record[] = []; for (const ref of refs) { const data = getEntityData(ref); if (!data) continue; let cachedProps: PropertySetData[] | null = null; const getProps = (): PropertySetData[] => { if (!cachedProps) cachedProps = getProperties(ref); return cachedProps; }; let cachedQties: QuantitySetData[] | null = null; const getQties = (): QuantitySetData[] => { if (!cachedQties) cachedQties = getQuantities(ref); return cachedQties; }; const row: Record = {}; for (const col of columns as string[]) { const value = resolveColumnValue(data, col, getProps, getQties); // Try to parse numeric values const numVal = Number(value); row[col] = value === '' ? null : !isNaN(numVal) && value.trim() !== '' ? numVal : value; } result.push(row); } return result; }, ifc(rawRefs: unknown, rawOptions: unknown) { const candidateOptions = rawOptions ?? {}; if (!isEntityRefArray(rawRefs)) { throw new Error('export.ifc: first argument must be an array of entity references'); } if (!isIfcExportOptions(candidateOptions)) { throw new Error('export.ifc: second argument must be { schema?: IFC2X3|IFC4|IFC4X3, filename?: string, includeMutations?: boolean, visibleOnly?: boolean }'); } const refs = normalizeRefs(rawRefs); if (refs.length === 0) { throw new Error('export.ifc: expected at least one entity reference'); } const modelIds = new Set(refs.map(ref => ref.modelId)); if (modelIds.size !== 1) { throw new Error('export.ifc: all entity references must belong to the same model'); } const modelId = refs[0].modelId; const state = store.getState(); const model = getModelForRef(state, modelId); if (!model?.ifcDataStore) { throw new Error(`export.ifc: model '${modelId}' is not loaded`); } if (model.ifcDataStore.schemaVersion === 'IFC5') { throw new Error('export.ifc: IFC5 export is not supported by STEP exporter, use IFC2X3/IFC4/IFC4X3 models'); } const options = candidateOptions; const selectedExpressIds = new Set(refs.map(ref => ref.expressId)); const visibilityFilters = resolveVisibilityFilterSets( state, modelId, selectedExpressIds, model.ifcDataStore.entityCount, ); const visibleOnly = options.visibleOnly === true || visibilityFilters.visibleOnly; const hiddenEntityIds = visibleOnly ? visibilityFilters.hiddenEntityIds : new Set(); const isolatedEntityIds = visibleOnly ? visibilityFilters.isolatedEntityIds : null; const exporter = new StepExporter( model.ifcDataStore, options.includeMutations === false ? undefined : getMutationViewForModel(store, modelId) ?? undefined, ); // Include georeferencing mutations if present const georefMutations = options.includeMutations !== false ? state.georefMutations?.get(modelId) ?? undefined : undefined; const exportOptions: StepExportOptions = { schema: options.schema ?? model.ifcDataStore.schemaVersion, includeGeometry: true, includeProperties: true, includeQuantities: true, includeRelationships: true, applyMutations: options.includeMutations ?? true, visibleOnly, hiddenEntityIds, isolatedEntityIds, georefMutations, }; // Splice any in-memory schedule (parsed-and-cached, or generated // via the Gantt panel's "Generate from storeys" dialog) into the // STEP output via the shared splice helper. Keeps this adapter // in lockstep with the viewer's ExportDialog / ExportChangesButton // so bugs can't differ across surfaces. const exportResult = exporter.export(exportOptions); const spliced = spliceScheduleIntoExport(exportResult, modelId, model.ifcDataStore as IfcDataStore, { scheduleData: state.scheduleData ?? null, scheduleIsEdited: state.scheduleIsEdited === true, scheduleSourceModelId: state.scheduleSourceModelId ?? null, }); return spliced.content; }, download(content: string | Uint8Array, filename: string, mimeType?: string) { triggerDownload(content, filename, mimeType ?? 'text/plain'); return undefined; }, }; } /** * Splice an in-memory `ScheduleExtraction` into a STEP file's DATA section. * * Three cases: * 1. Schedule is purely parsed and untouched — leave the STEP alone. * 2. Schedule has generated-only tail (pre-existing behaviour) — append * the generated tasks + sequences + schedules just before ENDSEC. * 3. Schedule has been *edited* (rename / reschedule / reassign / delete * on ANY task, generated or parsed) — strip EVERY schedule entity * from the STEP body and re-emit the whole `scheduleData` fresh. * Dependent entities (`IfcTaskTime`, `IfcLagTime`, `IfcRel*`) cascade * cleanly on deletion because we serialize the whole block at once. * * We also use the source model's existing IfcOwnerHistory (when present) * for the inserted entities so they share ownership metadata. */ export interface InjectScheduleOptions { /** * When true, the caller has edited the in-memory schedule — enter * rewrite mode (case 3 above). The flag is the scheduleSlice's * `scheduleIsEdited` value; threading it here keeps injection logic * free of store knowledge. */ scheduleIsEdited?: boolean; } export function injectScheduleIntoStep( stepContent: string, scheduleData: ScheduleExtraction | null, ifcDataStore: IfcDataStore, options?: InjectScheduleOptions, ): string { if (!scheduleData || scheduleData.tasks.length === 0) { // No schedule in memory. If the caller flagged "edited", the user // deleted every task in what used to be a parsed schedule — we // still want to strip the stale entities from the STEP. if (options?.scheduleIsEdited) { return stripScheduleEntities(stepContent); } return stepContent; } const hasGenerated = scheduleData.tasks.some(t => !t.expressId || t.expressId <= 0); const edited = options?.scheduleIsEdited === true; if (!edited && !hasGenerated) return stepContent; // Shared resolution helpers for both injection paths. const resolveProduct = (gid: string): number | undefined => { if (!gid) return undefined; return ifcDataStore.entities?.getExpressIdByGlobalId?.(gid) ?? undefined; }; // ── Rewrite path: strip + re-emit the full schedule ───────────── if (edited) { const stripped = stripScheduleEntities(stepContent); const maxId = findMaxExpressId(stripped); const ownerHistoryId = findFirstOwnerHistoryId(stripped) ?? undefined; const result = serializeScheduleToStep(scheduleData, { nextId: maxId + 1, ownerHistoryId, resolveProductExpressId: resolveProduct, }); if (result.lines.length === 0) return stripped; return spliceBeforeEndSec(stripped, result.lines); } // ── Append-only path: only generated tasks (legacy behaviour) ─── const generatedTasks = scheduleData.tasks.filter(t => !t.expressId || t.expressId <= 0); const generatedTaskGids = new Set(generatedTasks.map(t => t.globalId)); const generatedSequences = scheduleData.sequences.filter( s => generatedTaskGids.has(s.relatingTaskGlobalId) && generatedTaskGids.has(s.relatedTaskGlobalId), ); const generatedWorkSchedules = scheduleData.workSchedules.filter(ws => !ws.expressId || ws.expressId <= 0); const partitioned: ScheduleExtraction = { hasSchedule: true, workSchedules: generatedWorkSchedules, tasks: generatedTasks, sequences: generatedSequences, }; const maxId = findMaxExpressId(stepContent); const ownerHistoryId = findFirstOwnerHistoryId(stepContent) ?? undefined; const result = serializeScheduleToStep(partitioned, { nextId: maxId + 1, ownerHistoryId, resolveProductExpressId: resolveProduct, }); if (result.lines.length === 0) return stepContent; return spliceBeforeEndSec(stepContent, result.lines); } /** * Splice fresh STEP lines just before the DATA-section's closing * `ENDSEC;`. Anchored on the LAST `ENDSEC;` because the header section * also ends with one — we want the data end. */ function spliceBeforeEndSec(stepContent: string, lines: string[]): string { const endSecIdx = stepContent.lastIndexOf('ENDSEC;'); if (endSecIdx < 0) { // Malformed STEP — surface the original file unchanged rather than // corrupting it. console.warn('[export] schedule injection: ENDSEC not found in STEP output'); return stepContent; } const head = stepContent.slice(0, endSecIdx); const tail = stepContent.slice(endSecIdx); return `${head}${lines.join('\n')}\n${tail}`; } /** * Remove every schedule-related entity declaration from the STEP body. * * Two-pass: * 1. Identify every express ID whose entity type is in the "always a * schedule entity" set (`IfcTask`, `IfcWorkSchedule`, `IfcWorkPlan`, * `IfcTaskTime`, `IfcLagTime`). * 2. Drop lines whose ID is in that set OR whose entity type is one of * the sometimes-schedule types (`IfcRelSequence`, `IfcRelAssignsTo- * Process`, `IfcRelAssignsToControl`) OR `IfcRelNests` lines that * reference any ID from step 1. * * The IfcRelNests check prevents us from stripping cost-item/resource * nests, which share the entity but aren't schedule-owned. */ const ALWAYS_SCHEDULE_TYPES: ReadonlySet = new Set([ 'IFCTASK', 'IFCWORKSCHEDULE', 'IFCWORKPLAN', 'IFCTASKTIME', 'IFCTASKTIMERECURRING', 'IFCLAGTIME', ]); const SOMETIMES_SCHEDULE_TYPES: ReadonlySet = new Set([ 'IFCRELSEQUENCE', 'IFCRELASSIGNSTOPROCESS', 'IFCRELASSIGNSTOCONTROL', ]); function stripScheduleEntities(stepContent: string): string { // Pass 1: collect schedule-entity IDs by tokenizing declarations. // // We walk the STEP content at the STATEMENT level (terminated by `;` // outside string literals), not line-by-line. Line-based splitting // breaks when a writer spans an entity across multiple lines — // valid STEP allows whitespace and newlines anywhere outside string // literals. Statement-based walking handles multi-line entities // transparently. const statements = tokenizeStepStatements(stepContent); const scheduleIds = new Set(); for (const stmt of statements) { if (stmt.kind !== 'entity') continue; if (ALWAYS_SCHEDULE_TYPES.has(stmt.typeUpper)) scheduleIds.add(stmt.id); } if (scheduleIds.size === 0) { // No "always" schedule entities. There can't be any schedule-related // relationship entities either; nothing to strip. return stepContent; } // Pass 2: walk statements and emit non-schedule text ranges. We keep // byte ranges (start/end offsets in `stepContent`) rather than // reassembling, so leading/trailing whitespace between statements // survives byte-identical when every statement is kept. const keptRanges: Array<{ start: number; end: number }> = []; let cursor = 0; for (const stmt of statements) { if (stmt.kind !== 'entity') { // Non-entity text (header, section markers, whitespace) — always keep. continue; } if (shouldStripStatement(stmt, scheduleIds)) { // Push the range from `cursor` up to the statement start, then // advance past the statement (including trailing whitespace / // newline so we don't leave a gap). if (stmt.start > cursor) keptRanges.push({ start: cursor, end: stmt.start }); cursor = stmt.end; // Also consume a trailing newline so we don't leave blank lines // scattered where schedule statements used to live. if (stepContent[cursor] === '\r') cursor++; if (stepContent[cursor] === '\n') cursor++; } } if (cursor < stepContent.length) { keptRanges.push({ start: cursor, end: stepContent.length }); } // Concatenate kept ranges. if (keptRanges.length === 1 && keptRanges[0].start === 0 && keptRanges[0].end === stepContent.length) { return stepContent; // No-op path — nothing was stripped. } let out = ''; for (const r of keptRanges) out += stepContent.slice(r.start, r.end); return out; } /** Per-statement classification: should we drop this record? */ function shouldStripStatement( stmt: { typeUpper: string; id: number; attributesText: string }, scheduleIds: ReadonlySet, ): boolean { if (scheduleIds.has(stmt.id)) return true; // Always-schedule entity itself. if (SOMETIMES_SCHEDULE_TYPES.has(stmt.typeUpper)) { // Relationship entity; strip only if it references a schedule id. return referencesAnyId(stmt.attributesText, scheduleIds); } if (stmt.typeUpper === 'IFCRELNESTS') { // Only strip when the referenced set includes a schedule id (the // nest ties a task to its children). False-positives (a nests that // mixes task + non-task in a single record) are vanishingly rare. return referencesAnyId(stmt.attributesText, scheduleIds); } return false; } interface StepEntityStatement { kind: 'entity'; /** Byte offset of the `#` in `#ID=…`. */ start: number; /** Byte offset just past the terminating `;`. */ end: number; id: number; typeUpper: string; /** The parenthesised attribute list text including the outer parens. */ attributesText: string; } /** * Tokenize `stepContent` into entity statements. Skips HEADER / DATA * section markers and whitespace; returns only `#ID=TYPE(…);` records. * Respects `'…'` string literals (STEP uses `''` to escape a quote). */ function tokenizeStepStatements(stepContent: string): StepEntityStatement[] { const out: StepEntityStatement[] = []; const len = stepContent.length; let i = 0; while (i < len) { // Skip whitespace. while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t' || stepContent[i] === '\n' || stepContent[i] === '\r')) i++; if (i >= len) break; // Only interested in `#N=…;` records. Anything else — header keywords, // section markers, end markers — gets scanned to the next `;` and // discarded as non-entity text. if (stepContent[i] !== '#') { // Scan to next `;` (STEP statements are `;`-terminated). i = scanToStatementEnd(stepContent, i); continue; } const declStart = i; i++; // past '#' // Read id digits. const idStart = i; while (i < len && stepContent.charCodeAt(i) >= 0x30 && stepContent.charCodeAt(i) <= 0x39) i++; if (i === idStart) { // `#` not followed by a digit — not an entity reference. Skip to `;`. i = scanToStatementEnd(stepContent, declStart + 1); continue; } const id = parseInt(stepContent.slice(idStart, i), 10); // Allow whitespace before `=`. while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t')) i++; if (stepContent[i] !== '=') { // `#N` without `=` — reference inside an attribute list; bail. i = scanToStatementEnd(stepContent, declStart + 1); continue; } i++; // past '=' while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t')) i++; // Type name: uppercase letters, digits, underscore. const typeStart = i; while (i < len) { const c = stepContent[i]; if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '_' || (c >= 'a' && c <= 'z')) i++; else break; } if (i === typeStart) { i = scanToStatementEnd(stepContent, declStart + 1); continue; } const typeUpper = stepContent.slice(typeStart, i).toUpperCase(); // Optional whitespace before attribute list. while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t' || stepContent[i] === '\n' || stepContent[i] === '\r')) i++; // Attribute list starts with `(`. Read until matching `)`, respecting // string literals and nested parens. const attrStart = i; if (stepContent[i] !== '(') { i = scanToStatementEnd(stepContent, declStart + 1); continue; } i++; // past '(' let depth = 1; let inString = false; while (i < len && depth > 0) { const c = stepContent[i]; if (inString) { if (c === "'") { // Peek for escape `''`. if (stepContent[i + 1] === "'") { i += 2; continue; } inString = false; i++; continue; } i++; continue; } if (c === "'") { inString = true; i++; continue; } if (c === '(') { depth++; i++; continue; } if (c === ')') { depth--; i++; continue; } i++; } const attrEnd = i; // Expect `;` terminator (optionally preceded by whitespace). while (i < len && (stepContent[i] === ' ' || stepContent[i] === '\t')) i++; if (stepContent[i] !== ';') { // Malformed — scan to next `;` and skip this record. i = scanToStatementEnd(stepContent, attrEnd); continue; } i++; // past ';' const end = i; out.push({ kind: 'entity', start: declStart, end, id, typeUpper, attributesText: stepContent.slice(attrStart, attrEnd), }); } return out; } /** Advance past the next `;` outside string literals. Never walks backwards. */ function scanToStatementEnd(s: string, from: number): number { const len = s.length; let i = from; let inString = false; while (i < len) { const c = s[i]; if (inString) { if (c === "'") { if (s[i + 1] === "'") { i += 2; continue; } inString = false; } i++; continue; } if (c === "'") { inString = true; i++; continue; } if (c === ';') return i + 1; i++; } return len; } /** True iff any `#N` token in `rest` has N in the given set. */ function referencesAnyId(rest: string, ids: ReadonlySet): boolean { const refRegex = /#(\d+)/g; let m: RegExpExecArray | null; while ((m = refRegex.exec(rest)) !== null) { const n = parseInt(m[1], 10); if (ids.has(n)) return true; } return false; } /** Scan the STEP body for the highest `#N=` declaration. Returns 0 when none. */ function findMaxExpressId(stepContent: string): number { let max = 0; // Pattern: line starts with `#NNN=` (newline-anchored to avoid matching // refs inside attribute lists). const regex = /(?:^|\n)\s*#(\d+)\s*=/g; let m: RegExpExecArray | null; while ((m = regex.exec(stepContent)) !== null) { const n = parseInt(m[1], 10); if (Number.isFinite(n) && n > max) max = n; } return max; } /** Find the first IfcOwnerHistory's express ID in the STEP file, if any. */ function findFirstOwnerHistoryId(stepContent: string): number | null { const m = stepContent.match(/(?:^|\n)\s*#(\d+)\s*=\s*IFCOWNERHISTORY\b/i); return m ? parseInt(m[1], 10) : null; } /** Trigger a browser file download */ function triggerDownload(content: string | Uint8Array, filename: string, mimeType: string): void { if (typeof document === 'undefined') { throw new Error('download() requires a browser environment (document is unavailable)'); } const blob = new Blob([toBlobPart(content)], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }