/* 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/. */ /** * Raw STEP tab content — lists every positional argument on the * selected entity with an inline editor for each scalar value, and * lets the user drill into `#N` references to chase the graph * (auto-skipping trivial single-ref wrappers along the way). The * entry point for `bim.store.setPositionalAttribute` from the UI. * * This is intentionally close-to-the-metal: STEP literals are shown * verbatim, no friendly transforms, and the help line at the bottom * documents the convention so a power user with `IfcRectangleProfileDef` * open can edit `XDim` without consulting the script panel. */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { ArrowLeft, ChevronRight, FileBox, Info, Sparkles } from 'lucide-react'; import { getAttributeNames } from '@ifc-lite/parser'; import type { EntityRef } from '@ifc-lite/parser'; import type { IfcDataStore } from '@ifc-lite/parser'; import type { IfcAttributeValue } from '@ifc-lite/mutations'; import { useViewerStore } from '@/store'; import { RawStepRow } from './RawStepRow'; import { extractRawStepTokens, serializeStepToken } from './raw-step-format'; /** Max wrappers to skip when auto-following a `#N` click. Caps the * loop in case of cyclic STEP graphs (shouldn't happen in valid * IFC, but stay defensive). */ const AUTO_FOLLOW_DEPTH = 16; /** * Apply per-index overlay overrides on top of the base STEP tokens. * Returns a fresh array so React detects the change. Out-of-range * indices are ignored — the StoreEditor refuses them on write, but * stay defensive in case the override map outlives the entity. */ function applyOverlayTokens( base: string[], overlay: Map | null, ): string[] { if (!overlay || overlay.size === 0) return base; const merged = base.slice(); for (const [index, value] of overlay) { if (index >= 0 && index < merged.length) { merged[index] = serializeStepToken(value); } } return merged; } /** * Read raw STEP tokens for an entity by id. Returns null if the entity * is overlay-only or the source bytes can't be parsed. */ function readSourceTokens(dataStore: IfcDataStore | null, expressId: number): string[] | null { if (!dataStore?.source) return null; const ref: EntityRef | undefined = dataStore.entityIndex.byId.get(expressId); if (!ref || ref.byteLength <= 0) return null; return extractRawStepTokens(dataStore.source, ref.byteOffset, ref.byteLength); } /** * If the target entity is a single-positional-arg wrapper whose only * arg is itself a `#N` reference, follow that chain. Returns the * deepest "meaningful" expressId — the first one whose body has more * than one arg, or whose single arg isn't a reference. Caps recursion * at AUTO_FOLLOW_DEPTH and bails on tombstoned entities. */ function autoFollowWrappers( startId: number, dataStore: IfcDataStore | null, isDeleted: (id: number) => boolean, ): number { let current = startId; for (let i = 0; i < AUTO_FOLLOW_DEPTH; i++) { if (isDeleted(current)) return current; const tokens = readSourceTokens(dataStore, current); if (!tokens || tokens.length !== 1) return current; const m = tokens[0].match(/^#(\d+)$/); if (!m) return current; const next = Number.parseInt(m[1], 10); if (!Number.isFinite(next) || next === current) return current; current = next; } return current; } interface RawStepCardProps { modelId: string; entityId: number; entityType: string; /** The active model's data store — needed to read the source bytes. */ dataStore: IfcDataStore | null; /** Edit affordances are gated on edit mode (matches Properties tab). */ enableEditing: boolean; } export function RawStepCard({ modelId, entityId, entityType, dataStore, enableEditing, }: RawStepCardProps) { // Subscribe to the mutation version so overlay overrides re-render // here exactly when they would in the Properties tab. const mutationVersion = useViewerStore((s) => s.mutationVersion); const getMutationView = useViewerStore((s) => s.getMutationView); // Drill-through navigation. The stack holds expressIds the user has // clicked into; an empty stack means "show the 3D-selected entity". // Reset whenever the root selection changes — drilling stays scoped // to a single 3D click, otherwise the UI feels haunted. const [navStack, setNavStack] = useState([]); useEffect(() => { setNavStack([]); }, [modelId, entityId]); const currentId = navStack.length > 0 ? navStack[navStack.length - 1] : entityId; const isAtRoot = navStack.length === 0; // Resolve the current entity's type for the header. Lookup order: // 1. The 3D-selected root carries its type via `entityType`. // 2. The parsed data store knows source-buffer entities by id. // 3. Overlay-only entities (drill-create / duplicate) live in the // mutation view's `newEntities` map — without this fallback // drilled overlay entities would lose schema-aware attribute // labels and render as `#`. const currentType = useMemo(() => { if (currentId === entityId) return entityType; const t = dataStore?.entities.getTypeName(currentId); if (t) return t; const view = getMutationView(modelId); const overlay = view?.getNewEntity(currentId); if (overlay) return overlay.type; return `#${currentId}`; // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentId, entityId, entityType, dataStore, modelId, getMutationView, mutationVersion]); // Resolve display tokens for the *current* entity. Tokenize the raw // STEP body when source bytes exist, fall back to overlay-only // NewEntity records otherwise. Per-index overrides land on top. const { tokens, isOverlayOnly, overlayMap } = useMemo(() => { const view = getMutationView(modelId); const overlay = view?.getPositionalMutationsForEntity(currentId) ?? null; const sourceTokens = readSourceTokens(dataStore, currentId); if (sourceTokens) { return { tokens: applyOverlayTokens(sourceTokens, overlay), isOverlayOnly: false, overlayMap: overlay, }; } if (view) { const overlayEntity = view.getNewEntity(currentId); if (overlayEntity) { const baseTokens = (overlayEntity.attributes as IfcAttributeValue[]).map(serializeStepToken); return { tokens: applyOverlayTokens(baseTokens, overlay), isOverlayOnly: true, overlayMap: overlay, }; } } return { tokens: null as string[] | null, isOverlayOnly: false, overlayMap: overlay }; // mutationVersion forces this hook to re-run when any overlay // (positional or overlay-entity) changes — overlay maps are // mutated in place, so identity-based memoization isn't enough. // eslint-disable-next-line react-hooks/exhaustive-deps }, [dataStore, currentId, modelId, getMutationView, mutationVersion]); // Schema attribute names for the current type. Falls back to // "Arg N" for entities the generated registry doesn't know. const attributeNames = useMemo(() => getAttributeNames(currentType) ?? [], [currentType]); // Per-row mutation indicator — drives the purple dot. const mutatedIndices = useMemo(() => { if (!overlayMap) return new Set(); return new Set(overlayMap.keys()); }, [overlayMap]); // Drill into a `#N` reference, auto-skipping trivial wrappers so a // single click takes the user from `OwnerHistory → IfcOwnerHistory` // (one hop) without three intermediate stops on identity-only // wrapper entities. const handleNavigate = useCallback( (refId: number) => { const view = getMutationView(modelId); const isDeleted = (id: number) => view?.isDeleted?.(id) ?? false; const target = autoFollowWrappers(refId, dataStore, isDeleted); setNavStack((prev) => { // No-op if the user is already viewing the target — refs that // self-loop or land on the current node would otherwise grow // the breadcrumb forever. const tail = prev.length > 0 ? prev[prev.length - 1] : entityId; if (target === tail) return prev; return [...prev, target]; }); }, [dataStore, modelId, entityId, getMutationView], ); const handleBack = useCallback(() => { setNavStack((prev) => prev.slice(0, -1)); }, []); const handleResetToRoot = useCallback(() => { setNavStack([]); }, []); if (!tokens || tokens.length === 0) { return (

{dataStore ? `Entity #${currentId} has no positional STEP arguments` : 'Raw STEP is unavailable for this model'}

{!isAtRoot && ( )}
); } return (
{/* Breadcrumb (only when drilled in) */} {!isAtRoot && (
{navStack.map((id, i) => ( ))}
)} {/* Header */}
{currentType} #{currentId}
{isOverlayOnly && ( New )}
{/* Rows */}
{tokens.map((token, idx) => { // Fallback name uses the 1-based position so it stays aligned with // the bracketed index shown in each row (which is also 1-based). const name = attributeNames[idx] || `Arg ${idx + 1}`; return ( ); })}
{/* Help footer */}

STEP literals: numbers, $ for null,{' '} .T./ .F. for booleans,{' '} #42 for refs (click to drill),{' '} .AREA. for enums. Edits land on the export overlay — undo/redo via the toolbar.

); }