/* 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/. */ /** * Wall-specific placement + representation edits. * * A rectangular-profile wall created by `addWallToStore` encodes its * start/end as four coupled entities — the placement origin, the * RefDirection (start→end), the profile XDim, and the profile * origin (centred at XDim/2). Resizing means touching all four * coherently, so this module owns that ensemble. * * Generic placement reads / writes live in `placement-core.ts`. */ import type { IfcDataStore } from '@ifc-lite/parser'; import type { MutablePropertyView, StoreEditor } from '@ifc-lite/mutations'; import { asExpressIdRef, readAttributes, resolvePlacementChain, resolveRotationState, } from './placement-core.js'; export interface WallEditChain { /** IfcLocalPlacement.RelativePlacement.Location — the wall's start point. */ startPointId: number; /** Current start coordinates (storey-local). */ startCoordinates: [number, number, number]; /** IfcAxis2Placement3D.RefDirection — wall direction (start→end). */ refDirectionId: number; /** Current RefDirection ratios. */ refDirection: [number, number, number]; /** IfcRectangleProfileDef.XDim — wall length along its local X. */ profileId: number; /** Current wall length. */ wallLength: number; /** IfcRectangleProfileDef.YDim — wall thickness. */ thickness: number; /** Profile origin IfcCartesianPoint with `[length/2, 0]`. */ profileOriginPointId: number; /** IfcExtrudedAreaSolid id — exposed so split readers don't re-walk. */ extrudedSolidId: number; /** Extrusion depth ( = wall height in metres ); `NaN` when the slot wasn't a number. */ height: number; } /** * Resolve the wall-edit chain for a wall created by * `@ifc-lite/create#addWallToStore` (or any source-buffer wall that * happens to follow the same `IfcRectangleProfileDef → * IfcExtrudedAreaSolid` shape). * * Returns null when the entity isn't a wall, doesn't have an explicit * RefDirection, or its representation isn't the expected rectangle- * profile / extruded-solid pair. Callers should treat null as * "endpoints not editable" and hide their drag handles rather than * crashing. */ export function resolveWallEditChain( dataStore: IfcDataStore, view: MutablePropertyView, editor: StoreEditor, expressId: number, ): WallEditChain | null { const wallAttrs = readAttributes(dataStore, view, editor, expressId); if (!wallAttrs) return null; // ObjectPlacement chain — reuse the standard walker to get the // start point. The "Position" attribute index varies by schema // (IfcRoot+) but ObjectPlacement is always #5 for an IfcProduct. const chain = resolvePlacementChain(dataStore, view, editor, expressId); if (!chain) return null; // RefDirection MUST be explicit for an addWallToStore-built wall — // the builder always emits it. Reject implicit defaults so we // don't have to materialise one mid-drag. const rot = resolveRotationState(dataStore, view, editor, expressId); if (!rot || rot.refDirectionId === null) return null; // Representation chain: wall.Representation (attrs[6]) → IfcProductDefinitionShape // → Representations[0] → IfcShapeRepresentation → Items[0] → IfcExtrudedAreaSolid // → SweptArea → IfcRectangleProfileDef → Position → Location (profile origin) const productShapeId = asExpressIdRef(wallAttrs[6]); if (productShapeId === null) return null; const productShapeAttrs = readAttributes(dataStore, view, editor, productShapeId); if (!productShapeAttrs) return null; // IfcProductDefinitionShape.Representations is index 2. const reps = productShapeAttrs[2]; if (!Array.isArray(reps) || reps.length === 0) return null; const shapeRepId = asExpressIdRef(reps[0]); if (shapeRepId === null) return null; const shapeRepAttrs = readAttributes(dataStore, view, editor, shapeRepId); if (!shapeRepAttrs) return null; // IfcShapeRepresentation.Items is index 3. const items = shapeRepAttrs[3]; if (!Array.isArray(items) || items.length === 0) return null; const solidId = asExpressIdRef(items[0]); if (solidId === null) return null; const solidAttrs = readAttributes(dataStore, view, editor, solidId); if (!solidAttrs) return null; // IfcExtrudedAreaSolid.SweptArea is index 0. const profileId = asExpressIdRef(solidAttrs[0]); if (profileId === null) return null; const profileAttrs = readAttributes(dataStore, view, editor, profileId); if (!profileAttrs) return null; // IfcRectangleProfileDef: // [0] ProfileType · [1] ProfileName · [2] Position · [3] XDim · [4] YDim const profilePosId = asExpressIdRef(profileAttrs[2]); const xdim = profileAttrs[3]; const ydim = profileAttrs[4]; if (profilePosId === null || typeof xdim !== 'number' || typeof ydim !== 'number') { // Non-rectangle profile — wall wasn't built by addWallToStore. return null; } const profilePosAttrs = readAttributes(dataStore, view, editor, profilePosId); if (!profilePosAttrs) return null; // IfcAxis2Placement2D.Location at index 0. const profileOriginPointId = asExpressIdRef(profilePosAttrs[0]); if (profileOriginPointId === null) return null; // Extrusion depth (= wall height) lives on the // IfcExtrudedAreaSolid (attribute index 3). Pull it now so split // / clone helpers don't need to re-walk the chain. const depth = solidAttrs[3]; const height = typeof depth === 'number' ? depth : NaN; return { startPointId: chain.cartesianPointId, startCoordinates: chain.coordinates, refDirectionId: rot.refDirectionId, refDirection: rot.refDirection, profileId, wallLength: xdim, thickness: ydim, profileOriginPointId, extrudedSolidId: solidId, height, }; } /** * Pure geometry math for splitting a rectangle-profile wall at * `distance` along its axis (measured from the start). Returns the * geometry of the two resulting walls in storey-local IFC space. * * The split is a straight perpendicular cut — both halves keep the * source wall's direction vector and thickness. The "left" half * starts at the source's start and runs `distance`; the "right" * half starts at the cut point and runs the remaining length. * * Returns `null` (with a reason) when: * - the wall chain doesn't resolve * - `distance` is outside `(MIN_SEGMENT_LENGTH, length - MIN_SEGMENT_LENGTH)` * - input is otherwise inconsistent * * Caller is responsible for actually building the two new walls and * tombstoning the source — this helper is pure and easy to unit-test * without a store editor. */ export const MIN_WALL_SEGMENT_LENGTH = 0.05; // metres export interface WallSplitGeometry { /** Geometry that `addWallToStore` would consume for the "before-cut" half. */ left: { Start: [number, number, number]; End: [number, number, number]; Thickness: number; /** Carried over from the source — caller may override. */ Height: number; }; right: { Start: [number, number, number]; End: [number, number, number]; Thickness: number; Height: number; }; /** Cut point in storey-local space (Start + dir·distance, Z = source Z). */ cutPoint: [number, number, number]; /** Source's length, so the caller can validate UX feedback. */ sourceLength: number; } export type WallSplitResult = | { ok: true; geometry: WallSplitGeometry } | { ok: false; reason: string }; /** * Compute the geometry for a split without mutating the store. Used * by the mutationSlice action to drive `addWallToStore` twice and by * tests that exercise the math in isolation. * * `sourceHeight` is passed in rather than re-read here because the * extrusion `Depth` lives on the `IfcExtrudedAreaSolid` (attribute 3), * not in the WallEditChain. The slice action reads it once and * forwards the value. */ export function computeWallSplitGeometry( chain: WallEditChain, distance: number, sourceHeight: number, ): WallSplitResult { if (!Number.isFinite(distance)) { return { ok: false, reason: 'Split distance must be a finite number' }; } if (!Number.isFinite(sourceHeight) || sourceHeight <= 0) { return { ok: false, reason: 'Wall has no readable extrusion height' }; } const len = chain.wallLength; if ( distance <= MIN_WALL_SEGMENT_LENGTH || distance >= len - MIN_WALL_SEGMENT_LENGTH ) { return { ok: false, reason: `Split must be at least ${MIN_WALL_SEGMENT_LENGTH} m from each end (wall is ${len.toFixed(2)} m)`, }; } const [dx, dy, dz] = chain.refDirection; const [sx, sy, sz] = chain.startCoordinates; const cut: [number, number, number] = [ sx + dx * distance, sy + dy * distance, sz + dz * distance, ]; const end: [number, number, number] = [ sx + dx * len, sy + dy * len, sz + dz * len, ]; return { ok: true, geometry: { left: { Start: [sx, sy, sz], End: cut, Thickness: chain.thickness, Height: sourceHeight, }, right: { Start: cut, End: end, Thickness: chain.thickness, Height: sourceHeight, }, cutPoint: cut, sourceLength: len, }, }; } /** * Project an arbitrary 3D point onto the wall's axis and return the * signed distance from the wall's start (storey-local). Useful for * the Split tool's hover preview: cursor lands anywhere near the * wall, we report where the cut would land. * * Clamps to `[0, length]` — callers decide whether to enforce the * min-segment guard. */ export function projectOntoWallAxis( chain: WallEditChain, pointStoreyLocal: [number, number, number], ): number { const [px, py] = pointStoreyLocal; const [sx, sy] = chain.startCoordinates; const [dx, dy] = chain.refDirection; // Project (p - start) onto unit-direction. RefDirection is unit- // length by IFC convention, but we don't trust it 100% — accept // tiny norm drift by dividing through. const ux = px - sx; const uy = py - sy; const denom = dx * dx + dy * dy; if (denom < 1e-9) return 0; const t = (ux * dx + uy * dy) / denom; return Math.max(0, Math.min(chain.wallLength, t)); } export type WallResizeResult = | { ok: true; newStart: [number, number, number]; newEnd: [number, number, number]; newLength: number; } | { ok: false; reason: string }; /** * Resize a rectangular-profile wall by setting new start AND end * points. Updates four entities atomically (from the caller's * perspective — the four writes still land as four mutations on * the undo stack today; a batched-mutation primitive is a planned * follow-up so a drag interaction collapses to one undo step). * * - wall placement origin (IfcCartesianPoint) * - RefDirection (IfcDirection) → new normalised (end-start) * - profile XDim (IfcRectangleProfileDef) → new length * - profile origin (IfcCartesianPoint) → [newLength/2, 0] */ export function resizeRectangleWall( dataStore: IfcDataStore, view: MutablePropertyView, editor: StoreEditor, expressId: number, newStart: [number, number, number], newEnd: [number, number, number], ): WallResizeResult { const chain = resolveWallEditChain(dataStore, view, editor, expressId); if (!chain) { return { ok: false, reason: 'Wall does not have a simple IfcRectangleProfileDef → IfcExtrudedAreaSolid representation', }; } const dx = newEnd[0] - newStart[0]; const dy = newEnd[1] - newStart[1]; const dz = newEnd[2] - newStart[2]; const length = Math.hypot(dx, dy); if (length < 1e-6) { return { ok: false, reason: 'Wall length must be greater than zero' }; } // Z mismatch would slope the wall — the builder rejects this, and // so do we to keep the geometry consistent with the rest of the IFC. if (Math.abs(dz) > Math.max(1e-6 * length, 1e-9)) { return { ok: false, reason: 'Start and end must lie on the same storey plane' }; } const dir: [number, number, number] = [dx / length, dy / length, 0]; editor.setPositionalAttribute(chain.startPointId, 0, newStart); editor.setPositionalAttribute(chain.refDirectionId, 0, dir); editor.setPositionalAttribute(chain.profileId, 3, length); editor.setPositionalAttribute(chain.profileOriginPointId, 0, [length / 2, 0]); return { ok: true, newStart, newEnd, newLength: length }; }