diff --git a/packages/core/src/agent-operations/add-column.test.ts b/packages/core/src/agent-operations/add-column.test.ts new file mode 100644 index 0000000000..d938da4403 --- /dev/null +++ b/packages/core/src/agent-operations/add-column.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from 'bun:test' +import { z } from 'zod' +import { addColumnTool } from '../agent-tools/columns' +import { type ColumnNode, LevelNode } from '../schema' +import { addColumn } from './add-column' + +const level = LevelNode.parse({ id: 'level_columns' }) +const nodes = { [level.id]: level } +const context = { activeLevelId: level.id } +const input = z.object(addColumnTool.input) + +function created(outcome: ReturnType) { + return outcome.changes?.create?.[0]?.node as ColumnNode +} + +test('add_column creates an i-beam column', () => { + const column = created( + addColumn( + nodes, + input.parse({ x: 1, z: 2, crossSection: 'i-beam', width: 0.3, depth: 0.4 }), + context, + ), + ) + expect(column.crossSection).toBe('i-beam') + expect(column.width).toBe(0.3) + expect(column.depth).toBe(0.4) + expect(column.position).toEqual([1, 0, 2]) +}) + +test('add_column takes tilt in degrees and stores radians', () => { + const column = created( + addColumn(nodes, input.parse({ x: 0, z: 0, tiltX: 10, tiltZ: '-5°' }), context), + ) + expect(column.tiltX).toBeCloseTo((10 * Math.PI) / 180, 12) + expect(column.tiltZ).toBeCloseTo((-5 * Math.PI) / 180, 12) + expect(() => input.parse({ x: 0, z: 0, tiltX: 50 })).toThrow() + + const upright = created(addColumn(nodes, input.parse({ x: 0, z: 0 }), context)) + expect('tiltX' in upright || 'tiltZ' in upright).toBe(false) +}) diff --git a/packages/core/src/agent-operations/add-column.ts b/packages/core/src/agent-operations/add-column.ts index 5550a64bb1..ba9f17f987 100644 --- a/packages/core/src/agent-operations/add-column.ts +++ b/packages/core/src/agent-operations/add-column.ts @@ -73,6 +73,8 @@ export const addColumn: AgentOperation = (nodes, input, context) y, z, rotation, + tiltX, + tiltZ, ...fields } = input const node = ColumnNode.parse({ @@ -90,6 +92,8 @@ export const addColumn: AgentOperation = (nodes, input, context) z ?? previous?.position[2] ?? 0, ], rotation: rotation === undefined ? previous?.rotation : (rotation * Math.PI) / 180, + ...(tiltX === undefined ? {} : { tiltX: (tiltX * Math.PI) / 180 }), + ...(tiltZ === undefined ? {} : { tiltZ: (tiltZ * Math.PI) / 180 }), ...(previous?.source && !compiled ? { source: withSourceMeta(previous.source, input) } : {}), ...(compiled && dimensions ? { diff --git a/packages/core/src/agent-operations/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts index 4f6d3ced9c..0c8d99746e 100644 --- a/packages/core/src/agent-operations/scene-view.ts +++ b/packages/core/src/agent-operations/scene-view.ts @@ -1,5 +1,6 @@ import { refuse } from '../agent-tools/refusal' import type { VIEW_SIDES } from '../agent-tools/view-scene' +import { columnPlanLeanOffset } from '../lib/column-geometry' import type { AnyNode, AnyNodeId, @@ -230,7 +231,11 @@ function siteBox(node: AnyNode, baseY: number): SceneViewBox | null { const half = column.crossSection === 'round' ? column.radius : Math.max(column.width, column.depth) / 2 const [x, , z] = column.position - return { min: [x - half, baseY, z - half], max: [x + half, baseY + column.height, z + half] } + const [dx, dz] = columnPlanLeanOffset(column, column.height) + return { + min: [Math.min(x, x + dx) - half, baseY, Math.min(z, z + dz) - half], + max: [Math.max(x, x + dx) + half, baseY + column.height, Math.max(z, z + dz) + half], + } } if (node.type === 'fence') { const fence = node as FenceNode diff --git a/packages/core/src/agent-tools/columns.ts b/packages/core/src/agent-tools/columns.ts index 038293c65b..9e9e56b648 100644 --- a/packages/core/src/agent-tools/columns.ts +++ b/packages/core/src/agent-tools/columns.ts @@ -22,15 +22,29 @@ export const addColumnTool = { }).optional(), width: measurement('length', 'm', { positive: true, - description: 'Width when crossSection is "square" / "rectangular" (default: 0.44).', + description: + 'Width when crossSection is "square" / "rectangular" / "i-beam" (default: 0.44).', }).optional(), depth: measurement('length', 'm', { positive: true, - description: 'Depth when crossSection is "square" / "rectangular" (default: 0.44).', + description: + 'Depth when crossSection is "square" / "rectangular" / "i-beam" (default: 0.44).', }).optional(), rotation: measurement('angle', 'deg', { description: 'Y-axis rotation (default: 0).', }).optional(), + tiltX: measurement('angle', 'deg', { + min: -45, + max: 45, + description: + 'Lean about the column X axis, tipping the top toward its +Z (default: 0). The base stays planted and the top stays level.', + }).optional(), + tiltZ: measurement('angle', 'deg', { + min: -45, + max: 45, + description: + 'Lean about the column Z axis, tipping the top toward its -X (default: 0). The base stays planted and the top stays level.', + }).optional(), style: z .enum(['plain', 'faceted', 'fluted', 'lathe-turned', 'dravidian-carved', 'cluster']) .optional() @@ -38,10 +52,10 @@ export const addColumnTool = { "Visual style: 'plain' (default smooth pillar), 'faceted' (sharp prism faces), 'fluted' (vertical channels — classical), 'lathe-turned' (turned bands — porch / interior), 'dravidian-carved' (heavy carving — temple), 'cluster' (bundled colonnette — gothic).", ), crossSection: z - .enum(['round', 'square', 'rectangular', 'octagonal', 'sixteen-sided']) + .enum(['round', 'square', 'rectangular', 'octagonal', 'sixteen-sided', 'i-beam']) .optional() .describe( - "Plan shape: 'round' (default), 'square' (equal width/depth), 'rectangular' (independent width/depth), 'octagonal', 'sixteen-sided'.", + "Plan shape: 'round' (default), 'square' (equal width/depth), 'rectangular' (independent width/depth), 'octagonal', 'sixteen-sided', 'i-beam' (steel H/I section: flanges span width, web runs along depth).", ), level: z .string() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd584944b6..e9151be58d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -129,6 +129,13 @@ export { computeCeilingSurfaceCells, parseCeilingRegionRole, } from './lib/ceiling-surface' +export { + columnIBeamOutline, + columnIBeamSection, + columnLean, + columnPlanLeanOffset, + sweptPlanFootprint, +} from './lib/column-geometry' export { isCutterName, resolveCutterHost } from './lib/cutter-host' export { clampDoorOperationState, diff --git a/packages/core/src/lib/column-geometry.test.ts b/packages/core/src/lib/column-geometry.test.ts new file mode 100644 index 0000000000..dd7303599e --- /dev/null +++ b/packages/core/src/lib/column-geometry.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from 'bun:test' +import { + columnIBeamOutline, + columnIBeamSection, + columnLean, + columnPlanLeanOffset, + sweptPlanFootprint, +} from './column-geometry' + +test('i-beam plates scale with the section and keep a minimum thickness', () => { + expect(columnIBeamSection(0.3, 0.4)).toEqual({ + flangeThickness: expect.closeTo(0.04, 9), + webThickness: expect.closeTo(0.03, 9), + }) + expect(columnIBeamSection(0.05, 0.05)).toEqual({ flangeThickness: 0.01, webThickness: 0.01 }) +}) + +test('i-beam outline spans the section with a web narrower than the flanges', () => { + const outline = columnIBeamOutline(0.3, 0.4) + expect(outline).toHaveLength(12) + const xs = outline.map(([x]) => x) + const zs = outline.map(([, z]) => z) + expect(Math.min(...xs)).toBeCloseTo(-0.15) + expect(Math.max(...xs)).toBeCloseTo(0.15) + expect(Math.min(...zs)).toBeCloseTo(-0.2) + expect(Math.max(...zs)).toBeCloseTo(0.2) + // The web edge sits half a web thickness off the axis. + expect(outline[3]).toEqual([expect.closeTo(0.015, 9), expect.closeTo(-0.16, 9)]) + // Shoelace area: two flanges plus the web. + const area = + Math.abs( + outline.reduce((sum, [x, z], i) => { + const [nx, nz] = outline[(i + 1) % outline.length]! + return sum + x * nz - nx * z + }, 0), + ) / 2 + expect(area).toBeCloseTo(2 * 0.3 * 0.04 + 0.03 * 0.32, 9) +}) + +test('a leaning column shears its section by height, in the yawed plan frame', () => { + const tilt = Math.PI / 8 + expect(columnLean({ tiltX: tilt })).toEqual([0, expect.closeTo(Math.tan(tilt), 12)]) + expect(columnLean({ tiltZ: tilt })).toEqual([expect.closeTo(-Math.tan(tilt), 12), 0]) + expect(columnPlanLeanOffset({ tiltX: tilt, rotation: 0 }, 0)).toEqual([0, 0]) + const [x, z] = columnPlanLeanOffset({ tiltX: tilt, rotation: Math.PI / 2 }, 2) + // Local +Z turns to plan +X under a quarter turn of yaw. + expect(x).toBeCloseTo(2 * Math.tan(tilt), 12) + expect(z).toBeCloseTo(0, 12) +}) + +test('a swept footprint covers the base, the top and the band between', () => { + const square: [number, number][] = [ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + ] + expect(sweptPlanFootprint(square, [0, 0])).toEqual(square) + const swept = sweptPlanFootprint(square, [2, 0]) + const xs = swept.map(([px]) => px) + expect(Math.min(...xs)).toBeCloseTo(-0.5) + expect(Math.max(...xs)).toBeCloseTo(2.5) + const diagonal = sweptPlanFootprint(square, [1, 1]) + // A square swept along its diagonal is a hexagon. + expect(diagonal).toHaveLength(6) +}) diff --git a/packages/core/src/lib/column-geometry.ts b/packages/core/src/lib/column-geometry.ts new file mode 100644 index 0000000000..bfced62aab --- /dev/null +++ b/packages/core/src/lib/column-geometry.ts @@ -0,0 +1,77 @@ +import type { ColumnNode } from '../schema/nodes/column' +import { union } from './polygon-boolean' + +/** + * Plate thicknesses of an i-beam column. The flanges span the width (local X) + * at the front and back faces; the web joins them along the depth (local Z). + */ +export function columnIBeamSection( + width: number, + depth: number, +): { flangeThickness: number; webThickness: number } { + return { + flangeThickness: Math.max(0.01, depth * 0.1), + webThickness: Math.max(0.01, width * 0.1), + } +} + +/** Plan outline of an i-beam section centred on the column axis, in local XZ. */ +export function columnIBeamOutline(width: number, depth: number): [number, number][] { + const { flangeThickness, webThickness } = columnIBeamSection(width, depth) + const x = width / 2 + const z = depth / 2 + const webX = webThickness / 2 + const innerZ = Math.max(0, z - flangeThickness) + return [ + [-x, -z], + [x, -z], + [x, -innerZ], + [webX, -innerZ], + [webX, innerZ], + [x, innerZ], + [x, z], + [-x, z], + [-x, innerZ], + [-webX, innerZ], + [-webX, -innerZ], + [-x, -innerZ], + ] +} + +/** + * How far a leaning column's section moves per metre of height, in its local + * XZ. A column leans by shearing, not rotating: tiltX tips the top toward +Z + * and tiltZ toward −X (the senses of right-handed rotations about those axes), + * while the base stays planted and every horizontal section stays level. + */ +export function columnLean(column: Pick): [number, number] { + return [-Math.tan(column.tiltZ ?? 0) || 0, Math.tan(column.tiltX ?? 0)] +} + +/** Level-plan offset of a leaning column's section at height `y`, after its yaw. */ +export function columnPlanLeanOffset( + column: Pick, + y: number, +): [number, number] { + const [x, z] = columnLean(column) + const cos = Math.cos(column.rotation) + const sin = Math.sin(column.rotation) + return [(x * cos + z * sin) * y, (-x * sin + z * cos) * y] +} + +/** + * Plan area a footprint covers while sliding by `offset`: the footprint, its + * shifted copy and the band each edge sweeps. Returned unchanged when nothing moves. + */ +export function sweptPlanFootprint( + points: readonly [number, number][], + [dx, dz]: readonly [number, number], +): [number, number][] { + if (Math.hypot(dx, dz) < 1e-9 || points.length < 3) return [...points] + const shifted = points.map(([x, z]): [number, number] => [x + dx, z + dz]) + const bands = points.map((point, i): [number, number][] => { + const next = (i + 1) % points.length + return [point, points[next]!, shifted[next]!, shifted[i]!] + }) + return union([[...points], shifted, ...bands])[0]?.outer ?? [...points] +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 81a914576f..0aa8cc0950 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -98,6 +98,7 @@ export { export { CeilingNode } from './nodes/ceiling' export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' export { + COLUMN_MAX_TILT, COLUMN_PRESETS, ColumnBaseStyle, ColumnCapitalStyle, diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index fb1144a22b..4b63c2d692 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -4,6 +4,9 @@ import { BaseNode, nodeType, objectId } from '../base' import { GeometryScriptSource } from '../geometry-source' import { MaterialSchema } from '../material' +/** Steepest lean a column takes about either plan axis (45°). */ +export const COLUMN_MAX_TILT = Math.PI / 4 + export const ColumnStyle = z.enum([ 'plain', 'faceted', @@ -19,6 +22,7 @@ export const ColumnCrossSection = z.enum([ 'rectangular', 'octagonal', 'sixteen-sided', + 'i-beam', ]) export const ColumnShaftProfile = z.enum(['straight', 'tapered', 'bulged', 'baluster', 'hourglass']) @@ -88,6 +92,9 @@ export const ColumnNode = BaseNode.extend({ children: z.array(z.string()).default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Lean from vertical in radians, about the column's local X and Z axes. Absent = upright. + tiltX: z.number().min(-COLUMN_MAX_TILT).max(COLUMN_MAX_TILT).optional(), + tiltZ: z.number().min(-COLUMN_MAX_TILT).max(COLUMN_MAX_TILT).optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), source: GeometryScriptSource.optional().describe( @@ -172,8 +179,10 @@ export const ColumnNode = BaseNode.extend({ }).describe(dedent` Column node - used to represent structural or decorative pillars/columns. - style: visual approach such as plain, lathe-turned, carved, or cluster - - crossSection: plan shape used by the procedural renderer + - crossSection: plan shape used by the procedural renderer; 'i-beam' is a steel H/I section whose flanges span the width at the front and back faces - height/radius/width/depth: primary dimensions in meters + - tiltX/tiltZ: lean in radians (±π/4) — tiltX tips the top toward local +Z, tiltZ toward local −X; + the column shears, so its base stays planted and its top stays level - edgeSoftness: bevel radius for square/plinth/block edges - shaftProfile/shaftDetail: profile and surface treatment of the shaft - shaftTwistStep: per-segment shaft rotation in degrees diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index eaeabcb7be..197fd31081 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -9,6 +9,8 @@ import { CeilingNode as CeilingNodeSchema, type ColumnNode, calculateLevelMiters, + columnIBeamOutline, + columnPlanLeanOffset, createSizedStairFlight, DEFAULT_ANGLE_STEP, type DoorNode, @@ -45,6 +47,7 @@ import { sceneRegistry, snapPointAlongAngleRay, spatialGridManager, + sweptPlanFootprint, terrainSupportLift, useInteractive, useLiveNodeOverrides, @@ -2023,6 +2026,17 @@ function getColumnPlanFootprint(column: ColumnNode): Point2D[] { return getRotatedRectanglePolygon(center, width, depth, column.rotation) } + if ( + column.crossSection === 'i-beam' && + column.baseStyle === 'none' && + column.capitalStyle === 'none' + ) { + return columnIBeamOutline(column.width, column.depth).map(([localX, localY]) => { + const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation) + return { x: center.x + offsetX, y: center.y + offsetY } + }) + } + const shaftWidth = column.crossSection === 'round' || column.crossSection === 'octagonal' || @@ -2046,7 +2060,11 @@ function getColumnPlanFootprint(column: ColumnNode): Point2D[] { column.depth * column.capitalDepthScale, ) - if (column.crossSection === 'square' || column.crossSection === 'rectangular') { + if ( + column.crossSection === 'square' || + column.crossSection === 'rectangular' || + column.crossSection === 'i-beam' + ) { return getRotatedRectanglePolygon(center, width, depth, column.rotation) } @@ -5621,7 +5639,10 @@ export function FloorplanPanel({ }) const columnEntries = referenceColumns.flatMap((column) => { - const polygon = getColumnPlanFootprint(column) + const polygon = sweptPlanFootprint( + getColumnPlanFootprint(column).map(({ x, y }): [number, number] => [x, y]), + columnPlanLeanOffset(column, column.height), + ).map(([x, y]) => ({ x, y })) if (polygon.length < 3) { return [] } diff --git a/packages/ifc-converter/src/export/ifc-model.ts b/packages/ifc-converter/src/export/ifc-model.ts index 821ff6483a..095a29498b 100644 --- a/packages/ifc-converter/src/export/ifc-model.ts +++ b/packages/ifc-converter/src/export/ifc-model.ts @@ -151,18 +151,49 @@ export class IfcModel { return this.step.add('IFCRECTANGLEPROFILEDEF', enumValue('AREA'), null, position, xDim, yDim) } + iShapeProfile( + overallWidth: number, + overallDepth: number, + webThickness: number, + flangeThickness: number, + ): StepRef { + const position = this.step.add('IFCAXIS2PLACEMENT2D', this.point2([0, 0]), null) + return this.step.add( + 'IFCISHAPEPROFILEDEF', + enumValue('AREA'), + null, + position, + overallWidth, + overallDepth, + webThickness, + flangeThickness, + null, + null, + null, + ) + } + circleProfile(radius: number): StepRef { const position = this.step.add('IFCAXIS2PLACEMENT2D', this.point2([0, 0]), null) return this.step.add('IFCCIRCLEPROFILEDEF', enumValue('AREA'), null, position, radius) } - /** Vertical prism of `profile`, from local z = `bottom` upwards by `depth`. */ - extrusion(profile: StepRef, bottom: number, depth: number, color?: IfcColor): StepRef { + /** + * Prism of `profile` from local z = `bottom`, `depth` along `direction`: + * upright by default, oblique for a leaning element. + */ + extrusion( + profile: StepRef, + bottom: number, + depth: number, + color?: IfcColor, + direction: Vec3 = [0, 0, 1], + ): StepRef { const solid = this.step.add( 'IFCEXTRUDEDAREASOLID', profile, this.axis2Placement3D({ origin: [0, 0, bottom], angle: 0 }), - this.direction([0, 0, 1]), + this.direction(direction), depth, ) if (color) this.styleItem(solid, color) diff --git a/packages/ifc-converter/src/export/scene-to-ifc.ts b/packages/ifc-converter/src/export/scene-to-ifc.ts index 069d169d27..ca75576f8c 100644 --- a/packages/ifc-converter/src/export/scene-to-ifc.ts +++ b/packages/ifc-converter/src/export/scene-to-ifc.ts @@ -6,6 +6,8 @@ import { type Collection, type ColumnNode, calculateLevelMiters, + columnIBeamSection, + columnLean, DEFAULT_WALL_THICKNESS, type DoorNode, difference, @@ -1279,8 +1281,11 @@ export function buildIfcExport(input: IfcExportInput): IfcExportResult { } } const [x, y] = planToIfc(column.position[0], column.position[2]) - const profile = - column.crossSection === 'rectangular' || column.crossSection === 'square' + const [leanX, leanZ] = columnLean(column) + const iBeam = column.crossSection === 'i-beam' && columnIBeamSection(column.width, column.depth) + const profile = iBeam + ? model.iShapeProfile(column.width, column.depth, iBeam.webThickness, iBeam.flangeThickness) + : column.crossSection === 'rectangular' || column.crossSection === 'square' ? model.rectangleProfile( column.width, column.crossSection === 'square' ? column.width : column.depth, @@ -1296,7 +1301,16 @@ export function buildIfcExport(input: IfcExportInput): IfcExportResult { angle: column.rotation ?? 0, }), model.shape([ - model.bodyRepresentation('SweptSolid', [model.extrusion(profile, 0, column.height)]), + model.bodyRepresentation('SweptSolid', [ + // A leaning column is the same section swept along its lean. + model.extrusion( + profile, + 0, + column.height * Math.hypot(leanX, leanZ, 1), + undefined, + leanX || leanZ ? [leanX, -leanZ, 1] : undefined, + ), + ]), ]), ) context.contained.push(ref) @@ -1644,7 +1658,7 @@ function isPlainColumn(column: ColumnNode): boolean { column.baseStyle === 'none' && column.capitalStyle === 'none' && (column.supportStyle ?? 'vertical') === 'vertical' && - ['round', 'square', 'rectangular'].includes(column.crossSection) + ['round', 'square', 'rectangular', 'i-beam'].includes(column.crossSection) ) } diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index cea14ce9b8..b33e6e0e91 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -4,6 +4,7 @@ import { BlockNode, BuildingNode, CeilingNode, + COLUMN_MAX_TILT, type Collection, ColumnNode, containsPoint, @@ -368,8 +369,8 @@ type ExtrusionData = { yDim: number | null profilePoints: number[][] | null // Detected swept-area profile shape (model units, pre-unitFactor). - // 'round' carries `radius`; 'rectangular' carries xDim/yDim. - profileShape: 'round' | 'rectangular' | null + // 'round' carries `radius`; 'rectangular' and 'i-beam' carry xDim/yDim. + profileShape: 'round' | 'rectangular' | 'i-beam' | null radius: number | null // IfcArbitraryProfileDefWithVoids inner rings, same frame as profilePoints. innerCurves: number[][][] @@ -462,6 +463,13 @@ function extractFromExtrusionItem( if (result.profileShape === null) result.profileShape = 'rectangular' } + // IfcIShapeProfileDef: OverallWidth spans the flanges, OverallDepth the web. + if (profile.OverallWidth?.value && profile.WebThickness?.value) { + result.xDim = profile.OverallWidth.value + result.yDim = profile.OverallDepth?.value ?? null + result.profileShape = 'i-beam' + } + // Extract profile points — OuterCurve for ArbitraryClosedProfileDef const curveRef = profile.OuterCurve?.value if (curveRef) { @@ -2341,8 +2349,9 @@ export async function convertIfcToPascal( let width: number | undefined let depth: number | undefined let height: number | undefined - let profileShape: 'round' | 'rectangular' | null = null + let profileShape: 'round' | 'rectangular' | 'i-beam' | null = null let profileRadius: number | undefined + let tilt: { tiltX: number; tiltZ: number } | undefined try { const worldMat = col.ObjectPlacement?.value @@ -2353,6 +2362,14 @@ export async function convertIfcToPascal( const body = getBodyExtrusionData(ifcApi, modelID, col) if (body.depth) height = body.depth * unitFactor + // An oblique extrusion is a leaning column: its depth runs along the lean. + const [dx = 0, dy = 0, dz = 1] = body.direction ?? [] + if (height !== undefined && dz > 0 && (dx || dy)) { + height *= dz / Math.hypot(dx, dy, dz) + const lean = (ratio: number) => + Math.max(-COLUMN_MAX_TILT, Math.min(COLUMN_MAX_TILT, Math.atan(ratio))) + tilt = { tiltX: lean(-dy / dz), tiltZ: lean(-dx / dz) } + } if (opts.swapProfileDimensions) { if (body.yDim) width = body.yDim * unitFactor if (body.xDim) depth = body.xDim * unitFactor @@ -2392,7 +2409,8 @@ export async function convertIfcToPascal( width, depth, height, - crossSection: isRect ? 'rectangular' : 'round', + crossSection: profileShape === 'i-beam' ? 'i-beam' : isRect ? 'rectangular' : 'round', + ...tilt, radius: profileRadius ?? Math.max(width ?? 0.44, depth ?? 0.44) / 2, style: 'plain', shaftProfile: 'straight', diff --git a/packages/ifc-converter/tests/export-roundtrip.test.ts b/packages/ifc-converter/tests/export-roundtrip.test.ts index 8c68d047ff..4d61b8fb55 100644 --- a/packages/ifc-converter/tests/export-roundtrip.test.ts +++ b/packages/ifc-converter/tests/export-roundtrip.test.ts @@ -5,10 +5,11 @@ import { fileURLToPath } from 'node:url' import { type AnyNode, type Collection, - type ColumnNode, + ColumnNode, type DoorNode, getLevelElevations, ItemNode, + LevelNode, type SlabNode, type WallNode, type WindowNode, @@ -402,6 +403,78 @@ describe('IFC export — columns', () => { }) }) +describe('IFC export — i-beam columns', () => { + const level = LevelNode.parse({ id: 'level_steel', name: 'Steel', height: 3 }) + const beam = ColumnNode.parse({ + id: 'column_ibeam', + name: 'Steel column', + parentId: level.id, + position: [1, 0, 2], + rotation: Math.PI / 2, + crossSection: 'i-beam', + width: 0.3, + depth: 0.4, + height: 2.8, + style: 'plain', + baseStyle: 'none', + capitalStyle: 'none', + shaftProfile: 'straight', + }) + const ifc = exportSceneToIfc({ + nodes: { [level.id]: { ...level, children: [beam.id] } as AnyNode, [beam.id]: beam }, + timestamp: EPOCH, + }) + + test('exports an IfcIShapeProfileDef extrusion', () => { + const entities = expectWellFormedStep(ifc) + const profile = [...entities.values()].find((entity) => entity.type === 'IFCISHAPEPROFILEDEF') + expect(profile).toBeDefined() + expect(ifc).toMatch(/IFCISHAPEPROFILEDEF\(\.AREA\.,\$,#\d+,0\.3,0\.4,0\.03,0\.04,\$,\$,\$\)/) + }) + + test('re-imports the section as an i-beam', async () => { + const [column] = nodesOf(await reimport(ifc), 'column') + expect(column?.crossSection).toBe('i-beam') + expect(column?.width).toBeCloseTo(0.3, MM) + expect(column?.depth).toBeCloseTo(0.4, MM) + expect(column?.height).toBeCloseTo(2.8, MM) + expect(column?.position[0]).toBeCloseTo(1, MM) + expect(column?.position[2]).toBeCloseTo(2, MM) + }) +}) + +describe('IFC export — leaning columns', () => { + const level = LevelNode.parse({ id: 'level_lean', name: 'Lean', height: 3 }) + const leaning = ColumnNode.parse({ + id: 'column_lean', + name: 'Leaning column', + parentId: level.id, + position: [0, 0, 0], + crossSection: 'rectangular', + width: 0.3, + depth: 0.3, + height: 2.5, + tiltX: 0.2, + tiltZ: -0.1, + style: 'plain', + baseStyle: 'none', + capitalStyle: 'none', + shaftProfile: 'straight', + }) + const ifc = exportSceneToIfc({ + nodes: { [level.id]: { ...level, children: [leaning.id] } as AnyNode, [leaning.id]: leaning }, + timestamp: EPOCH, + }) + + test('exports the lean as an oblique extrusion and reads it back', async () => { + expectWellFormedStep(ifc) + const [column] = nodesOf(await reimport(ifc), 'column') + expect(column?.height).toBeCloseTo(2.5, MM) + expect(column?.tiltX).toBeCloseTo(0.2, MM) + expect(column?.tiltZ).toBeCloseTo(-0.1, MM) + }) +}) + describe('IFC export — sample house round trip', () => { let first: PascalSceneGraph let second: PascalSceneGraph diff --git a/packages/ifc-converter/tests/export-step-check.ts b/packages/ifc-converter/tests/export-step-check.ts index 4650b814da..840903521c 100644 --- a/packages/ifc-converter/tests/export-step-check.ts +++ b/packages/ifc-converter/tests/export-step-check.ts @@ -38,6 +38,7 @@ const ATTRIBUTE_COUNTS: Record = { IFCARBITRARYPROFILEDEFWITHVOIDS: 4, IFCRECTANGLEPROFILEDEF: 5, IFCCIRCLEPROFILEDEF: 4, + IFCISHAPEPROFILEDEF: 10, IFCEXTRUDEDAREASOLID: 4, IFCPRODUCTDEFINITIONSHAPE: 3, IFCWALL: 9, diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index f1d4864ff4..7f34ee9569 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -409,6 +409,8 @@ export const columnDefinition: NodeDefinition = { // square → width, rectangular → width/depth, plus brace spread) so the // box, slab-overlap, and collision all track the real column size rather // than the raw width/depth (stale for a round column resized by radius). + // A tilted column stands on its base: the lean above it neither lifts the + // column onto a slab it overhangs nor blocks floor placement under it. floorPlaced: { footprint: (node) => { const column = node as ColumnNodeType diff --git a/packages/nodes/src/column/floorplan.ts b/packages/nodes/src/column/floorplan.ts index 548ebc97b2..55896f541e 100644 --- a/packages/nodes/src/column/floorplan.ts +++ b/packages/nodes/src/column/floorplan.ts @@ -1,11 +1,14 @@ import { type AnyNode, type ColumnNode, + columnIBeamOutline, + columnPlanLeanOffset, type FloorplanGeometry, type FloorplanPoint, type GeometryContext, type StructuralGridNode, scriptImages, + sweptPlanFootprint, } from '@pascal-app/core' import { floorplanGeometryMetadata } from '@pascal-app/editor' import { @@ -60,7 +63,9 @@ export function buildColumnFloorplan( node: ColumnNode, ctx: GeometryContext, ): FloorplanGeometry | null { - const points = getColumnFloorplanFootprint(node) + const base = getColumnBaseFootprint(node) + const lean = columnPlanLeanOffset(node, node.height) + const points = sweptPlanFootprint(base, lean) if (points.length < 3) return null const view = ctx.viewState @@ -100,7 +105,7 @@ export function buildColumnFloorplan( : [body] if (floorPlanUrl && node.source) { const { min, max } = node.source.manifest.bounds - const [cx, cz] = points + const [cx, cz] = base .reduce((sum, [x, z]) => [sum[0] + x, sum[1] + z], [0, 0]) .map((total) => total / points.length) as [number, number] children.push({ @@ -116,6 +121,19 @@ export function buildColumnFloorplan( if (showSelectedChrome) children.push({ ...body, fill: 'none', pointerEvents: 'none', metadata: undefined }) } + // A leaning column's top, drawn overhead (dashed) where it ends up. + if (lean[0] !== 0 || lean[1] !== 0) { + children.push({ + kind: 'polygon', + points: base.map(([x, z]) => [x + lean[0], z + lean[1]]), + fill: 'none', + stroke, + strokeWidth: 1, + strokeDasharray: '4 3', + vectorEffect: 'non-scaling-stroke', + pointerEvents: 'none', + }) + } const { halfX, halfZ } = columnPlanHalfExtents(node) const centerMarkHalf = Math.min(0.09, Math.max(0.035, Math.min(halfX, halfZ) * 0.45)) const centerX = node.position[0] @@ -275,7 +293,12 @@ export function buildColumnFloorplan( return { kind: 'group', children } } +/** Plan area the column covers: its base swept along its lean up to the top. */ export function getColumnFloorplanFootprint(node: ColumnNode): FloorplanPoint[] { + return sweptPlanFootprint(getColumnBaseFootprint(node), columnPlanLeanOffset(node, node.height)) +} + +function getColumnBaseFootprint(node: ColumnNode): [number, number][] { if (node.source) { const cos = Math.cos(node.rotation), sin = Math.sin(node.rotation) @@ -411,6 +434,18 @@ function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] { return getRotatedRectanglePolygon(center, width, depth, -column.rotation) } + // A bare i-beam (no base or capital plates) reads as its true section. + if ( + column.crossSection === 'i-beam' && + column.baseStyle === 'none' && + column.capitalStyle === 'none' + ) { + return columnIBeamOutline(column.width, column.depth).map(([x, z]) => { + const [offsetX, offsetY] = rotatePlanVector(x, z, -column.rotation) + return { x: center.x + offsetX, y: center.y + offsetY } + }) + } + // Standalone column: shaft profile expanded for base + capital. const isRound = column.crossSection === 'round' || @@ -429,7 +464,11 @@ function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] { column.depth * column.capitalDepthScale, ) - if (column.crossSection === 'square' || column.crossSection === 'rectangular') { + if ( + column.crossSection === 'square' || + column.crossSection === 'rectangular' || + column.crossSection === 'i-beam' + ) { return getRotatedRectanglePolygon(center, width, depth, -column.rotation) } diff --git a/packages/nodes/src/column/hosted-resize.ts b/packages/nodes/src/column/hosted-resize.ts index ecefc05c4c..5b74f3764a 100644 --- a/packages/nodes/src/column/hosted-resize.ts +++ b/packages/nodes/src/column/hosted-resize.ts @@ -4,6 +4,7 @@ import { planHostedEdit } from '../shared/hosted-resize' export const columnHostedPolicy = { host: (node: AnyNode) => node.type === 'column', child: (_node: AnyNode) => true, + followsSurfacePlan: true, } export function planColumnEdit(id: AnyNodeId, patch: Partial) { diff --git a/packages/nodes/src/column/i-beam.test.tsx b/packages/nodes/src/column/i-beam.test.tsx new file mode 100644 index 0000000000..be1d89aa81 --- /dev/null +++ b/packages/nodes/src/column/i-beam.test.tsx @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test' +import { ColumnNode } from '@pascal-app/core' +import { create } from '@react-three/test-renderer' +import { Box3, type Mesh } from 'three' +import { getColumnFloorplanFootprint } from './floorplan' +import { ColumnPreview } from './renderer' +import { columnCapitalBlocks, columnShaftLayout } from './shape' +import { columnTopSurfaces } from './surface' + +const bare = { + crossSection: 'i-beam', + width: 0.3, + depth: 0.4, + height: 3, + baseStyle: 'none', + capitalStyle: 'none', +} as const + +describe('i-beam column', () => { + test('renders two flanges and a web spanning the section', async () => { + const renderer = await create() + const meshes: Mesh[] = [] + renderer.scene.instance.updateMatrixWorld(true) + renderer.scene.instance.traverse((object) => { + if ((object as Mesh).isMesh) meshes.push(object as Mesh) + }) + expect(meshes).toHaveLength(3) + const bounds = new Box3() + for (const mesh of meshes) bounds.expandByObject(mesh) + expect(bounds.min.x).toBeCloseTo(-0.15) + expect(bounds.max.x).toBeCloseTo(0.15) + expect(bounds.min.z).toBeCloseTo(-0.2) + expect(bounds.max.z).toBeCloseTo(0.2) + expect(bounds.max.y).toBeCloseTo(3) + const web = meshes.find((mesh) => { + const box = new Box3().setFromObject(mesh) + return box.max.x - box.min.x < 0.1 + }) + expect(web).toBeDefined() + await renderer.unmount() + }) + + test('a bare i-beam draws its true section in plan', () => { + const column = ColumnNode.parse({ ...bare, position: [2, 0, 1] }) + const footprint = getColumnFloorplanFootprint(column) + expect(footprint).toHaveLength(12) + expect(Math.min(...footprint.map(([x]) => x))).toBeCloseTo(1.85) + expect(Math.max(...footprint.map(([, z]) => z))).toBeCloseTo(1.2) + }) + + test('an i-beam with plates draws the plate rectangle in plan', () => { + const column = ColumnNode.parse({ ...bare, baseStyle: 'simple-square' }) + expect(getColumnFloorplanFootprint(column)).toHaveLength(4) + }) + + test('caps with box plates and supports objects on the section', () => { + const capped = ColumnNode.parse({ ...bare, capitalStyle: 'simple-slab' }) + const layout = columnShaftLayout(capped) + expect( + columnCapitalBlocks(capped, layout.shaftY + layout.shaftHeight, layout.capitalHeight), + ).toEqual([expect.objectContaining({ kind: 'box' })]) + + const [top] = columnTopSurfaces(ColumnNode.parse(bare)) + expect(top?.position).toEqual([0, 3, 0]) + expect(top?.region).toMatchObject({ kind: 'polygon' }) + expect(top?.region.kind === 'polygon' && top.region.points).toHaveLength(12) + }) +}) diff --git a/packages/nodes/src/column/panel.tsx b/packages/nodes/src/column/panel.tsx index 3c93a902bc..2872dea888 100644 --- a/packages/nodes/src/column/panel.tsx +++ b/packages/nodes/src/column/panel.tsx @@ -2,6 +2,7 @@ import { type AnyNode, + COLUMN_MAX_TILT, COLUMN_PRESETS, type ColumnNode, type ColumnPresetId, @@ -40,6 +41,8 @@ const MANAGED_LEAN_TO_LAYOUT_FIELDS = new Set([ 'baseWidthScale', 'baseDepthScale', 'slots', + 'tiltX', + 'tiltZ', ]) const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({ @@ -443,7 +446,7 @@ export default function ColumnPanel() { ) : ( <> -
+
{( [ { @@ -513,6 +516,26 @@ export default function ColumnPanel() { ), }, + { + value: 'i-beam', + label: 'I-Beam', + icon: ( + + ), + }, ] as { value: ColumnNode['crossSection'] label: string @@ -690,7 +713,9 @@ export default function ColumnPanel() { handleUpdate({ width: value, radius: value / 2, - ...(node.crossSection === 'rectangular' ? {} : { depth: value }), + ...(node.crossSection === 'rectangular' || node.crossSection === 'i-beam' + ? {} + : { depth: value }), }) } precision={2} @@ -698,7 +723,7 @@ export default function ColumnPanel() { unit="m" value={node.width} /> - {node.crossSection === 'rectangular' && ( + {(node.crossSection === 'rectangular' || node.crossSection === 'i-beam') && ( - {!isBraceSupport && ( + {!isBraceSupport && node.crossSection !== 'i-beam' && (