/* 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 { describe, it } from 'node:test'; import assert from 'node:assert'; import { resolvePlacementChain, translateProduct, setProductPosition, resolveRotationState, rotateProductYaw, resolveWallEditChain, resizeRectangleWall, computeWallSplitGeometry, projectOntoWallAxis, MIN_WALL_SEGMENT_LENGTH, } from './placement-edit.js'; /** * Fixture mirrors what `@ifc-lite/create`'s `addColumnToStore` * produces — an IfcColumn with placement chain * #100 IfcColumn ─► #99 IfcLocalPlacement * └─► #98 IfcAxis2Placement3D * └─► #97 IfcCartesianPoint([1, 2, 3]) * * Stubs live in `__test__/stubs.ts` — shared with the other * lib/ helper tests. */ import { StubStoreEditor, StubView, makeStubDataStore, type OverlayEntity } from './__test__/stubs.js'; function makeFixture() { // IfcCartesianPoint at #97 with Coordinates = [1, 2, 3] const point: OverlayEntity = { expressId: 97, type: 'IFCCARTESIANPOINT', attributes: [[1, 2, 3]], }; // IfcAxis2Placement3D at #98: Location=#97, Axis=null, RefDirection=null const axis: OverlayEntity = { expressId: 98, type: 'IFCAXIS2PLACEMENT3D', attributes: [97, null, null], }; // IfcLocalPlacement at #99: PlacementRelTo=null, RelativePlacement=#98 const local: OverlayEntity = { expressId: 99, type: 'IFCLOCALPLACEMENT', attributes: [null, 98], }; // IfcColumn at #100: [GlobalId, OwnerHistory, Name, Description, // ObjectType, ObjectPlacement=#99, Representation, Tag] const column: OverlayEntity = { expressId: 100, type: 'IFCCOLUMN', attributes: ['guid', null, 'Column-1', null, null, 99, null, null], }; return { point, axis, local, column }; } const dataStoreStub = makeStubDataStore() as unknown as Parameters[0]; describe('placement-edit', () => { it('resolves the full chain for an overlay column', () => { const { point, axis, local, column } = makeFixture(); const editor = new StubStoreEditor([point, axis, local, column]) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const chain = resolvePlacementChain(dataStoreStub, view, editor, 100); assert.ok(chain); assert.strictEqual(chain.localPlacementId, 99); assert.strictEqual(chain.axisPlacementId, 98); assert.strictEqual(chain.cartesianPointId, 97); assert.deepStrictEqual(chain.coordinates, [1, 2, 3]); }); it('returns null when ObjectPlacement is missing', () => { const { point, axis, local } = makeFixture(); const broken: OverlayEntity = { expressId: 200, type: 'IFCCOLUMN', attributes: ['guid', null, 'Broken', null, null, null, null, null], // no placement }; const editor = new StubStoreEditor([point, axis, local, broken]) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; assert.strictEqual(resolvePlacementChain(dataStoreStub, view, editor, 200), null); }); it('translateProduct writes the new coordinates', () => { const { point, axis, local, column } = makeFixture(); const editor = new StubStoreEditor([point, axis, local, column]); const view = new StubView(); const result = translateProduct( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], 100, [0.5, -1, 2], ); assert.ok(result.ok); assert.deepStrictEqual(result.newCoordinates, [1.5, 1, 5]); // Read back via a fresh resolve to confirm the overlay reflects the write. const chain = resolvePlacementChain( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], 100, ); assert.ok(chain); assert.deepStrictEqual(chain.coordinates, [1.5, 1, 5]); }); it('translateProduct accumulates over multiple calls', () => { const { point, axis, local, column } = makeFixture(); const editor = new StubStoreEditor([point, axis, local, column]); const view = new StubView(); translateProduct( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], 100, [1, 0, 0], ); const second = translateProduct( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], 100, [0, 1, 0], ); assert.ok(second.ok); assert.deepStrictEqual(second.newCoordinates, [2, 3, 3]); }); it('setProductPosition replaces rather than adds', () => { const { point, axis, local, column } = makeFixture(); const editor = new StubStoreEditor([point, axis, local, column]); const view = new StubView(); const result = setProductPosition( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], 100, [10, 20, 30], ); assert.ok(result.ok); assert.deepStrictEqual(result.newCoordinates, [10, 20, 30]); }); it('honours positional mutation overrides on the IfcCartesianPoint', () => { const { point, axis, local, column } = makeFixture(); const editor = new StubStoreEditor([point, axis, local, column]) as unknown as Parameters[2]; const view = new StubView(); // Pre-mutate the point's Coordinates to simulate a prior edit. view.setPositionalForTest(97, 0, [9, 9, 9]); const chain = resolvePlacementChain( dataStoreStub, view as unknown as Parameters[1], editor, 100, ); assert.ok(chain); assert.deepStrictEqual(chain.coordinates, [9, 9, 9]); }); it('reads rotation state with explicit RefDirection', () => { const { point, local, column } = makeFixture(); // Override axis: give it an explicit RefDirection of [0, 1, 0] = 90° yaw. const dir: OverlayEntity = { expressId: 96, type: 'IFCDIRECTION', attributes: [[0, 1, 0]], }; const axis: OverlayEntity = { expressId: 98, type: 'IFCAXIS2PLACEMENT3D', attributes: [97, null, 96], }; const editor = new StubStoreEditor([point, dir, axis, local, column]) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const state = resolveRotationState(dataStoreStub, view, editor, 100); assert.ok(state); assert.strictEqual(state.refDirectionId, 96); assert.deepStrictEqual(state.refDirection, [0, 1, 0]); assert.ok(Math.abs(state.yawZ - Math.PI / 2) < 1e-9); }); it('reports implicit [1,0,0] when RefDirection is null', () => { const { point, axis, local, column } = makeFixture(); const editor = new StubStoreEditor([point, axis, local, column]) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const state = resolveRotationState(dataStoreStub, view, editor, 100); assert.ok(state); assert.strictEqual(state.refDirectionId, null); assert.deepStrictEqual(state.refDirection, [1, 0, 0]); assert.strictEqual(state.yawZ, 0); }); it('rotateProductYaw updates existing RefDirection in place', () => { const { point, local, column } = makeFixture(); const dir: OverlayEntity = { expressId: 96, type: 'IFCDIRECTION', attributes: [[1, 0, 0]], }; const axis: OverlayEntity = { expressId: 98, type: 'IFCAXIS2PLACEMENT3D', attributes: [97, null, 96], }; const editor = new StubStoreEditor([point, dir, axis, local, column]); const view = new StubView(); const result = rotateProductYaw( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], 100, Math.PI / 2, ); assert.ok(result.ok); assert.ok(Math.abs(result.newYawZ - Math.PI / 2) < 1e-9); // Read back via resolve to confirm. const state = resolveRotationState( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], 100, ); assert.ok(state); assert.ok(Math.abs(state.refDirection[0]) < 1e-9); assert.ok(Math.abs(state.refDirection[1] - 1) < 1e-9); }); it('rotateProductYaw refuses to act when RefDirection is implicit', () => { // Avoids the orphan-on-undo problem: creating a fresh // IfcDirection here without a batched-mutation primitive would // leave it behind after undo. Refusing is the safer default // until we have multi-entity atomic undo. const { point, axis, local, column } = makeFixture(); const editor = new StubStoreEditor([point, axis, local, column]); const view = new StubView(); const result = rotateProductYaw( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], 100, Math.PI / 4, ); assert.strictEqual(result.ok, false); }); /** * Wall edit chain — fixture mirrors `addWallToStore`'s output: * #100 IfcWall * ├── attrs[5] ObjectPlacement → #99 IfcLocalPlacement * │ attrs[1] RelativePlacement → #98 IfcAxis2Placement3D * │ attrs[0] Location → #97 IfcCartesianPoint([0, 0, 0]) * │ attrs[2] RefDirection → #96 IfcDirection([1, 0, 0]) * └── attrs[6] Representation → #95 IfcProductDefinitionShape * attrs[2] Representations[0] → #94 IfcShapeRepresentation * attrs[3] Items[0] → #93 IfcExtrudedAreaSolid * attrs[0] SweptArea → #92 IfcRectangleProfileDef * attrs[2] Position → #91 IfcAxis2Placement2D * attrs[0] Location → #90 IfcCartesianPoint([2.5, 0]) * attrs[3] XDim = 5 · attrs[4] YDim = 0.2 */ function makeWallFixture() { const startPoint: OverlayEntity = { expressId: 97, type: 'IFCCARTESIANPOINT', attributes: [[0, 0, 0]], }; const refDir: OverlayEntity = { expressId: 96, type: 'IFCDIRECTION', attributes: [[1, 0, 0]], }; const axis: OverlayEntity = { expressId: 98, type: 'IFCAXIS2PLACEMENT3D', attributes: [97, null, 96], }; const local: OverlayEntity = { expressId: 99, type: 'IFCLOCALPLACEMENT', attributes: [null, 98], }; const profileOrigin: OverlayEntity = { expressId: 90, type: 'IFCCARTESIANPOINT', attributes: [[2.5, 0]], }; const profilePos: OverlayEntity = { expressId: 91, type: 'IFCAXIS2PLACEMENT2D', attributes: [90, null], }; const profile: OverlayEntity = { expressId: 92, type: 'IFCRECTANGLEPROFILEDEF', attributes: ['.AREA.', null, 91, 5, 0.2], }; const solid: OverlayEntity = { expressId: 93, type: 'IFCEXTRUDEDAREASOLID', attributes: [92, null, null, 3], }; const shapeRep: OverlayEntity = { expressId: 94, type: 'IFCSHAPEREPRESENTATION', attributes: [null, 'Body', 'SweptSolid', [93]], }; const productShape: OverlayEntity = { expressId: 95, type: 'IFCPRODUCTDEFINITIONSHAPE', attributes: [null, null, [94]], }; const wall: OverlayEntity = { expressId: 100, type: 'IFCWALL', attributes: ['guid', null, 'Wall-1', null, null, 99, 95, null], }; return { entities: [startPoint, refDir, axis, local, profileOrigin, profilePos, profile, solid, shapeRep, productShape, wall], ids: { startPoint: 97, refDir: 96, profile: 92, profileOrigin: 90, wall: 100 }, }; } it('resolveWallEditChain walks placement + representation', () => { const fx = makeWallFixture(); const editor = new StubStoreEditor(fx.entities) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const chain = resolveWallEditChain(dataStoreStub, view, editor, fx.ids.wall); assert.ok(chain); assert.strictEqual(chain.startPointId, fx.ids.startPoint); assert.strictEqual(chain.refDirectionId, fx.ids.refDir); assert.strictEqual(chain.profileId, fx.ids.profile); assert.strictEqual(chain.profileOriginPointId, fx.ids.profileOrigin); assert.strictEqual(chain.wallLength, 5); assert.strictEqual(chain.thickness, 0.2); }); it('resizeRectangleWall updates all four entities atomically', () => { const fx = makeWallFixture(); const editor = new StubStoreEditor(fx.entities); const view = new StubView(); const result = resizeRectangleWall( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], fx.ids.wall, [1, 2, 0], [4, 6, 0], ); assert.ok(result.ok); const newLen = Math.hypot(3, 4); assert.ok(Math.abs(result.newLength - newLen) < 1e-9); // Re-resolve to confirm the writes landed. const chain = resolveWallEditChain( dataStoreStub, view as unknown as Parameters[1], editor as unknown as Parameters[2], fx.ids.wall, ); assert.ok(chain); assert.deepStrictEqual(chain.startCoordinates, [1, 2, 0]); assert.ok(Math.abs(chain.wallLength - newLen) < 1e-9); assert.ok(Math.abs(chain.refDirection[0] - 3 / newLen) < 1e-9); assert.ok(Math.abs(chain.refDirection[1] - 4 / newLen) < 1e-9); }); it('resizeRectangleWall rejects a zero-length resize', () => { const fx = makeWallFixture(); const editor = new StubStoreEditor(fx.entities) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const result = resizeRectangleWall(dataStoreStub, view, editor, fx.ids.wall, [0, 0, 0], [0, 0, 0]); assert.strictEqual(result.ok, false); }); it('computeWallSplitGeometry produces two coherent halves', () => { const fx = makeWallFixture(); const editor = new StubStoreEditor(fx.entities) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const chain = resolveWallEditChain(dataStoreStub, view, editor, fx.ids.wall); assert.ok(chain); const result = computeWallSplitGeometry(chain, 2, 3); // height = 3 assert.ok(result.ok); // Source: start (0,0,0) → (5,0,0); split at 2. assert.deepStrictEqual(result.geometry.left.Start, [0, 0, 0]); assert.deepStrictEqual(result.geometry.left.End, [2, 0, 0]); assert.deepStrictEqual(result.geometry.right.Start, [2, 0, 0]); assert.deepStrictEqual(result.geometry.right.End, [5, 0, 0]); assert.strictEqual(result.geometry.left.Thickness, 0.2); assert.strictEqual(result.geometry.right.Thickness, 0.2); assert.strictEqual(result.geometry.left.Height, 3); assert.strictEqual(result.geometry.right.Height, 3); assert.deepStrictEqual(result.geometry.cutPoint, [2, 0, 0]); assert.strictEqual(result.geometry.sourceLength, 5); }); it('computeWallSplitGeometry rejects splits too close to the start', () => { const fx = makeWallFixture(); const editor = new StubStoreEditor(fx.entities) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const chain = resolveWallEditChain(dataStoreStub, view, editor, fx.ids.wall); assert.ok(chain); const result = computeWallSplitGeometry(chain, MIN_WALL_SEGMENT_LENGTH / 2, 3); assert.strictEqual(result.ok, false); }); it('computeWallSplitGeometry rejects splits too close to the end', () => { const fx = makeWallFixture(); const editor = new StubStoreEditor(fx.entities) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const chain = resolveWallEditChain(dataStoreStub, view, editor, fx.ids.wall); assert.ok(chain); const result = computeWallSplitGeometry(chain, chain.wallLength - MIN_WALL_SEGMENT_LENGTH / 2, 3); assert.strictEqual(result.ok, false); }); it('projectOntoWallAxis clamps to wall length and reports midpoint correctly', () => { const fx = makeWallFixture(); const editor = new StubStoreEditor(fx.entities) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const chain = resolveWallEditChain(dataStoreStub, view, editor, fx.ids.wall); assert.ok(chain); // Cursor directly on the midpoint. assert.strictEqual(projectOntoWallAxis(chain, [2.5, 0, 0]), 2.5); // Cursor off-axis but in front of midpoint — projects to 2.5. assert.strictEqual(projectOntoWallAxis(chain, [2.5, 1, 0]), 2.5); // Cursor past the end — clamps. assert.strictEqual(projectOntoWallAxis(chain, [10, 0, 0]), chain.wallLength); // Cursor before the start — clamps. assert.strictEqual(projectOntoWallAxis(chain, [-3, 0, 0]), 0); }); it('treats 2D coordinates as having Z=0', () => { const point: OverlayEntity = { expressId: 50, type: 'IFCCARTESIANPOINT', attributes: [[5, 10]], }; const axis: OverlayEntity = { expressId: 51, type: 'IFCAXIS2PLACEMENT3D', attributes: [50, null, null], }; const local: OverlayEntity = { expressId: 52, type: 'IFCLOCALPLACEMENT', attributes: [null, 51], }; const wall: OverlayEntity = { expressId: 53, type: 'IFCWALL', attributes: ['guid', null, 'Wall', null, null, 52, null, null], }; const editor = new StubStoreEditor([point, axis, local, wall]) as unknown as Parameters[2]; const view = new StubView() as unknown as Parameters[1]; const chain = resolvePlacementChain(dataStoreStub, view, editor, 53); assert.ok(chain); assert.deepStrictEqual(chain.coordinates, [5, 10, 0]); }); });