* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * Fix spiral stair openings and fence handle arrows * Implement roof trim planes and ridge vent clipping * Fix mansard roof and ridge vent placement * Fix mansard merged roof cutouts * Fix Dutch roof gable overhang * Refactor roof segment, ridge vent, and surface geometry Remove Dutch ridge axis abstraction and rework roof edit system, ridge vent clipping geometry, and roof surface placement. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Simplify Dutch roof shape * Add Dutch roof gable top geometry controls * Fix Dutch roof slope material slots * Render dutch roof tops as double-sided faces * Add auto ridge vent toggle to roof segments Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so geometry changes only regenerate default vents when enabled, treating legacy segments with generated vents as auto-enabled for back-compat. Expose a panel toggle to opt in/out per segment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Snap new walls to the floor below Feed the walls of the level directly beneath the active one into the draft snap pipeline as extra references, so a new wall can align with the floor below. They share the same local XZ origin, and the list is kept separate from the current-level walls so the measurement HUD and wall splitting only act on the active level. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Set Dutch roof shape defaults on type switch Seed the Dutch shape parameters (waist width/height/length, top rake thickness/length) with sensible defaults whenever a segment is created as or switched to Dutch, so the gablet is well-formed regardless of leftover values from the previous roof type. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Use green accent for corner and endpoint snap markers Color the corner/endpoint snap markers and the vertical cursor pillar green across the 2D floorplan beacon, the 3D alignment guide dots, and the wall snap beacon so snap targets read as a consistent accent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add magnetic wall snapping to the roof tool Snap roof draft corners onto wall corners, midpoints, crossings, and bodies on the active level and the floor below, reusing the wall tool's snap pipeline so the beacon and coloring match. The cursor's ground dot/ring is hidden while a wall snap is active to avoid overlapping the beacon glyph. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Update auto-generated Next.js route types path Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Show cutaway outline while dragging roof trim Slice an untrimmed segment volume generated from the live node instead of the registry mesh, whose CSG rebuild lags a few frames behind the drag and may still hold placeholder geometry — so the section outline now renders deterministically. Use LineBasicNodeMaterial so the outline draws under the WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fill and clip roof trim cutaway, gate it to active drag Add a violet silhouette fill behind the cutaway outline, extend the section slicing to angled diagonal/corner trims via a generic vertical cut plane, and clip each slice to its footprint span so the infinite plane no longer sprouts stray lines across the rest of the roof. The cutaway now renders only while a trim handle is being dragged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Separate and extend Dutch roof end slopes Pull the Dutch hip end slopes out of the watertight shingle shell into their own slab wedge so they can be reshaped independently, and extend each end slope inward up its own hip plane until the top edge meets the gablet's inner triangle. Refactor roof-segment shape geometry into a shared roof-segment-shape module. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Render roof trim cutaway as a material-only section cut Replace the triangle-mesh slicer with a CSG intersection of a thin slab against the untrimmed roof shell, so the cutaway shows red only on real material (wall + deck bands) and leaves the hollow attic empty. Add an analytic surface-edge outline, style both solid red like a SketchUp section, and make the cutaway persist whenever a segment is trimmed. Keep the merged roof shell visible during trim editing (re-trimmed live from each segment's drag override) instead of swapping in the per-segment meshes, whose abutting end-cap faces showed as stray white planes the commit never had. Extend each slab past free cut-line ends only — trimmed ends clamp to the cut line — so the red section stays inside the trim box. Re-export INTERSECTION from the viewer CSG surface for the editor. * Outline roof cutaway by fill silhouette, restyle to destructive red Derive the section-cut outline from the fill geometry's edges (EdgesGeometry) so it traces the real cut shape — wall/deck band boundaries and the hollow-attic edge — instead of just the top surface line. Drop the fill to 85% opacity and recolor both fill and outline to the app's destructive red, matching the delete/destructive UI. * Include roof accessories in trim clipping and red cutaway Roof accessories (chimney, vents, skylight, dormer, gutter, downspout, solar-panel, cupola) now slice at the trim plane like the roof shell and appear in the red section-cut while dragging a trim handle: - Export clipGeometryBySegmentTrim from the viewer as a reusable segment-local trim-clip primitive. - Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh wrapper (nodes) that slice accessory geometry by the host segment's live trim override, so the cut tracks the drag. - Wire the clip into all 11 accessory renderers, including skylight glass panes and dormer window glass/frame/sill. - Feed every hosted accessory mesh into the editor's red cutaway, welding triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a cross-section. - Register skylight in the scene-graph tree-node map so it shows in the outliner when placed on a roof. Co-Authored-By: Claude <noreply@anthropic.com> * Add smooth spline fences with editable curve handles Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an optional `path` (+ per-point `tangents`), selectable in a Straight/Curved mode toggle. Selected spline fences expose draggable control-point dots (hexagon) and symmetric tangent handles (circle) joined by a violet line, editable in both 2D plan and 3D. Side-move arrows are dropped for splines. Co-Authored-By: Claude <noreply@anthropic.com> * Fix dutch roof ridge vent handling * Fix Dutch ridge vent placement and support * Fix Dutch roof trim artifacts * Fix Dutch roof trim preview geometry * Tag roof trim overlay meshes with EDITOR_LAYER Child meshes relied on a parent group's layer, which three.js does not propagate, so the trim section/rail/plane overlays rendered on the scene layer — getting inked/SSGI-darkened and leaking into thumbnail exports. Co-Authored-By: Claude <noreply@anthropic.com> * Apply Biome cleanup * fix(core): address Dutch roof review feedback * chore: apply biome check cleanup * fix(core): relax Dutch roof surface helper input * fix * Fix biome checks and dev verification * fixes * Remove unsupported Biome noShadow override * Improve roof interactions and fence editing * Fix fence drag and ridge vent default handling * editor: drop wall-snap debug log, gate curved-fence finish hint on draft start Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove hot path. Curved fences commit on a closing gesture (double-click / Enter) rather than per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a point has been placed and a curve is actually in flight. The draft point count is published from SplineFenceDraft into a small ephemeral editor store (useFenceCurveDraft) that the contextual helper reads, mirroring the existing useSegmentDraftChain pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Wassim SAMAD <wass08@gmail.com>
2103 lines
72 KiB
TypeScript
2103 lines
72 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
BuildingNode,
|
|
ColumnNode,
|
|
DEFAULT_WALL_HEIGHT,
|
|
DEFAULT_WALL_THICKNESS,
|
|
DoorNode,
|
|
LevelNode,
|
|
RoofNode,
|
|
SiteNode,
|
|
SlabNode,
|
|
StairNode,
|
|
WallNode,
|
|
WindowNode,
|
|
} from '@pascal-app/core'
|
|
import { customAlphabet } from 'nanoid'
|
|
import * as WebIFC from 'web-ifc'
|
|
import { type IfcConversionSimplificationOptions, simplifyConvertedSceneGraph } from './cleanup'
|
|
|
|
export type {
|
|
IfcConversionSimplificationOptions,
|
|
IfcConversionSimplificationStats,
|
|
} from './cleanup'
|
|
|
|
export type PascalNode = AnyNode
|
|
|
|
export interface PascalSceneGraph {
|
|
nodes: Record<AnyNodeId, AnyNode>
|
|
rootNodeIds: AnyNodeId[]
|
|
collections?: Record<string, unknown>
|
|
}
|
|
|
|
// Pascal's BaseNode.metadata is typed as `JSONType` (z.json()) — a loose
|
|
// JSON value. The converter writes a fixed shape; this typed accessor
|
|
// keeps dot-access ergonomics without spraying `as any` through the
|
|
// post-processing loops. Read-side only — writes still inline literals.
|
|
type ConverterMetadata = {
|
|
ifcType?: string
|
|
expressID?: number
|
|
globalId?: string
|
|
levelId?: string
|
|
material?: string
|
|
materialLayers?: { name: string; thickness?: number }[]
|
|
typeName?: string
|
|
properties?: Record<string, Record<string, unknown>>
|
|
[key: string]: unknown
|
|
}
|
|
|
|
function meta(node: { metadata?: unknown } | null | undefined): ConverterMetadata {
|
|
return (node?.metadata ?? {}) as ConverterMetadata
|
|
}
|
|
|
|
// Pascal's `BaseNode.metadata` is `z.json()` — a recursive JSON value
|
|
// type that doesn't accept `undefined` (JSON has `null`, not undefined).
|
|
// The converter pulls many fields from optional IFC properties that
|
|
// often return `undefined`; stripping them at the boundary keeps the
|
|
// schemas happy without spraying `?? null` through every assignment.
|
|
function buildMetadata(input: Record<string, unknown>): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {}
|
|
for (const [key, value] of Object.entries(input)) {
|
|
if (value === undefined) continue
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Wraps a Zod `.parse()` call and surfaces a single-line readable error
|
|
// instead of the framework's JSON-stringified issue list. The first
|
|
// issue is usually the actionable one; we mention the count if there
|
|
// are more so the user knows there's a deeper problem.
|
|
function tryParse<T>(schema: { parse: (input: unknown) => T }, kind: string, input: unknown): T {
|
|
try {
|
|
return schema.parse(input)
|
|
} catch (err) {
|
|
const issues = (err as { issues?: { path?: (string | number)[]; message?: string }[] }).issues
|
|
if (Array.isArray(issues) && issues.length > 0) {
|
|
const first = issues[0]
|
|
const path =
|
|
first && Array.isArray(first.path) && first.path.length > 0
|
|
? ` at "${first.path.join('.')}"`
|
|
: ''
|
|
const more = issues.length > 1 ? ` (+${issues.length - 1} more)` : ''
|
|
throw new Error(
|
|
`Could not build ${kind} node${path}: ${first?.message ?? 'schema mismatch'}${more}`,
|
|
)
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
|
|
|
|
function generateId<T extends string>(prefix: T): `${T}_${string}` {
|
|
return `${prefix}_${nanoid()}` as `${T}_${string}`
|
|
}
|
|
|
|
// --- Unit detection ---
|
|
|
|
function getLengthUnitFactor(ifcApi: WebIFC.IfcAPI, modelID: number): number {
|
|
const prefixFactors: Record<string, number> = {
|
|
EXA: 1e18,
|
|
PETA: 1e15,
|
|
TERA: 1e12,
|
|
GIGA: 1e9,
|
|
MEGA: 1e6,
|
|
KILO: 1e3,
|
|
HECTO: 1e2,
|
|
DECA: 1e1,
|
|
DECI: 1e-1,
|
|
CENTI: 1e-2,
|
|
MILLI: 1e-3,
|
|
MICRO: 1e-6,
|
|
NANO: 1e-9,
|
|
PICO: 1e-12,
|
|
FEMTO: 1e-15,
|
|
ATTO: 1e-18,
|
|
}
|
|
|
|
try {
|
|
const projects = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCPROJECT)
|
|
if (projects.size() === 0) return 1
|
|
|
|
const proj = ifcApi.GetLine(modelID, projects.get(0))
|
|
if (!proj.UnitsInContext?.value) return 1
|
|
|
|
const unitAssign = ifcApi.GetLine(modelID, proj.UnitsInContext.value)
|
|
if (!unitAssign.Units) return 1
|
|
|
|
for (const unitRef of unitAssign.Units) {
|
|
const unit = ifcApi.GetLine(modelID, unitRef.value)
|
|
if (unit.UnitType?.value !== 'LENGTHUNIT') continue
|
|
|
|
if (unit.Name?.value === 'METRE' || unit.Name?.value === 'METER') {
|
|
const prefix = unit.Prefix?.value as string | undefined
|
|
return prefix ? (prefixFactors[prefix] ?? 1) : 1
|
|
}
|
|
|
|
if (unit.Name?.value === 'FOOT' || unit.Name?.value === 'FEET') {
|
|
return 0.3048
|
|
}
|
|
if (unit.Name?.value === 'INCH') {
|
|
return 0.0254
|
|
}
|
|
|
|
// ConversionBasedUnit — read the conversion factor
|
|
if (unit.ConversionFactor?.value) {
|
|
const factor = ifcApi.GetLine(modelID, unit.ConversionFactor.value)
|
|
if (factor.ValueComponent?.value) {
|
|
return factor.ValueComponent.value
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
|
|
return 1
|
|
}
|
|
|
|
// --- 4x4 matrix math ---
|
|
|
|
type Mat4 = number[]
|
|
|
|
function identity(): Mat4 {
|
|
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
|
|
}
|
|
|
|
function multiply(a: Mat4, b: Mat4): Mat4 {
|
|
const r = new Array(16).fill(0)
|
|
for (let row = 0; row < 4; row++) {
|
|
for (let col = 0; col < 4; col++) {
|
|
for (let k = 0; k < 4; k++) {
|
|
r[row * 4 + col] += a[row * 4 + k] * b[k * 4 + col]
|
|
}
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
function transformPoint3(m: Mat4, p: number[]): number[] {
|
|
return [
|
|
m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3],
|
|
m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7],
|
|
m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11],
|
|
]
|
|
}
|
|
|
|
function buildAxis2Placement3DMatrix(
|
|
ifcApi: WebIFC.IfcAPI,
|
|
modelID: number,
|
|
axis2Id: number,
|
|
): Mat4 {
|
|
const axis2 = ifcApi.GetLine(modelID, axis2Id)
|
|
|
|
const num = (v: any): number | undefined => {
|
|
if (v == null) return undefined
|
|
if (typeof v === 'number') return v
|
|
if (v.value != null) return v.value
|
|
return undefined
|
|
}
|
|
|
|
let ox = 0,
|
|
oy = 0,
|
|
oz = 0
|
|
if (axis2.Location?.value) {
|
|
const loc = ifcApi.GetLine(modelID, axis2.Location.value)
|
|
const coords = loc.Coordinates.map((c: any) => num(c))
|
|
ox = coords[0] ?? 0
|
|
oy = coords[1] ?? 0
|
|
oz = coords[2] ?? 0
|
|
}
|
|
|
|
// Z-axis (Axis direction) — default (0,0,1)
|
|
let zx = 0,
|
|
zy = 0,
|
|
zz = 1
|
|
if (axis2.Axis?.value) {
|
|
const ax = ifcApi.GetLine(modelID, axis2.Axis.value)
|
|
const d = ax.DirectionRatios.map((c: any) => num(c))
|
|
if (d[0] != null) {
|
|
zx = d[0]
|
|
zy = d[1] ?? 0
|
|
zz = d[2] ?? 0
|
|
}
|
|
}
|
|
|
|
// X-axis (RefDirection) — default (1,0,0)
|
|
let xx = 1,
|
|
xy = 0,
|
|
xz = 0
|
|
if (axis2.RefDirection?.value) {
|
|
const rd = ifcApi.GetLine(modelID, axis2.RefDirection.value)
|
|
const d = rd.DirectionRatios.map((c: any) => num(c))
|
|
if (d[0] != null) {
|
|
xx = d[0]
|
|
xy = d[1] ?? 0
|
|
xz = d[2] ?? 0
|
|
}
|
|
}
|
|
|
|
// Normalize Z
|
|
const zLen = Math.sqrt(zx * zx + zy * zy + zz * zz) || 1
|
|
zx /= zLen
|
|
zy /= zLen
|
|
zz /= zLen
|
|
|
|
// Y = Z cross X, then normalize
|
|
let yx = zy * xz - zz * xy
|
|
let yy = zz * xx - zx * xz
|
|
let yz = zx * xy - zy * xx
|
|
const yLen = Math.sqrt(yx * yx + yy * yy + yz * yz) || 1
|
|
yx /= yLen
|
|
yy /= yLen
|
|
yz /= yLen
|
|
|
|
// Recompute X = Y cross Z to ensure orthonormal
|
|
xx = yy * zz - yz * zy
|
|
xy = yz * zx - yx * zz
|
|
xz = yx * zy - yy * zx
|
|
|
|
// Row-major 4x4
|
|
return [xx, yx, zx, ox, xy, yy, zy, oy, xz, yz, zz, oz, 0, 0, 0, 1]
|
|
}
|
|
|
|
// --- Placement chain resolver ---
|
|
|
|
function resolveWorldTransform(ifcApi: WebIFC.IfcAPI, modelID: number, placementId: number): Mat4 {
|
|
const chain: number[] = []
|
|
let current: number | null = placementId
|
|
|
|
while (current) {
|
|
const placement = ifcApi.GetLine(modelID, current)
|
|
if (placement.RelativePlacement?.value) {
|
|
chain.push(placement.RelativePlacement.value)
|
|
}
|
|
current = placement.PlacementRelTo?.value ?? null
|
|
}
|
|
|
|
// Multiply from root to leaf
|
|
let result = identity()
|
|
for (let i = chain.length - 1; i >= 0; i--) {
|
|
const mat = buildAxis2Placement3DMatrix(ifcApi, modelID, chain[i])
|
|
result = multiply(result, mat)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- Geometry extraction helpers ---
|
|
|
|
function getAxisPolyline(ifcApi: WebIFC.IfcAPI, modelID: number, element: any): number[][] | null {
|
|
try {
|
|
if (!element.Representation?.value) return null
|
|
const prodRep = ifcApi.GetLine(modelID, element.Representation.value)
|
|
for (const repRef of prodRep.Representations) {
|
|
const rep = ifcApi.GetLine(modelID, repRef.value)
|
|
if (rep.RepresentationIdentifier?.value !== 'Axis') continue
|
|
for (const itemRef of rep.Items) {
|
|
const item = ifcApi.GetLine(modelID, itemRef.value)
|
|
|
|
// IFCPOLYLINE — Points is an array of CartesianPoint references
|
|
if (Array.isArray(item.Points)) {
|
|
const points: number[][] = []
|
|
for (const ptRef of item.Points) {
|
|
const pt = ifcApi.GetLine(modelID, ptRef.value)
|
|
const coords = pt.Coordinates.map((c: any) =>
|
|
typeof c === 'number' ? c : (c?.value ?? 0),
|
|
)
|
|
points.push([coords[0] ?? 0, coords[1] ?? 0, coords[2] ?? 0])
|
|
}
|
|
if (points.length >= 2) return points
|
|
}
|
|
|
|
// IFCINDEXEDPOLYCURVE — Points is a reference to a point list entity
|
|
if (item.Points?.value && !Array.isArray(item.Points)) {
|
|
const ptList = ifcApi.GetLine(modelID, item.Points.value)
|
|
if (ptList.CoordList) {
|
|
const points: number[][] = []
|
|
for (const coords of ptList.CoordList) {
|
|
const c = Array.isArray(coords)
|
|
? coords.map((v: any) => (typeof v === 'number' ? v : (v?.value ?? 0)))
|
|
: []
|
|
points.push([c[0] ?? 0, c[1] ?? 0, c[2] ?? 0])
|
|
}
|
|
if (points.length >= 2) return points
|
|
}
|
|
}
|
|
|
|
// IFCGEOMETRICSET — unwrap to find an inner curve
|
|
if (item.Elements) {
|
|
for (const elemRef of item.Elements) {
|
|
const inner = ifcApi.GetLine(modelID, elemRef.value)
|
|
if (inner.Points?.value) {
|
|
const ptList = ifcApi.GetLine(modelID, inner.Points.value)
|
|
if (ptList.CoordList) {
|
|
const points: number[][] = []
|
|
for (const coords of ptList.CoordList) {
|
|
const c = Array.isArray(coords)
|
|
? coords.map((v: any) => (typeof v === 'number' ? v : (v?.value ?? 0)))
|
|
: []
|
|
points.push([c[0] ?? 0, c[1] ?? 0, c[2] ?? 0])
|
|
}
|
|
if (points.length >= 2) return points
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return null
|
|
}
|
|
|
|
type ExtrusionData = {
|
|
depth: number | null
|
|
xDim: number | null
|
|
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
|
|
radius: number | null
|
|
}
|
|
|
|
function extractFromExtrusionItem(
|
|
ifcApi: WebIFC.IfcAPI,
|
|
modelID: number,
|
|
item: any,
|
|
result: ExtrusionData,
|
|
): boolean {
|
|
// Unwrap BooleanClippingResult → follow FirstOperand chain to find extrusion
|
|
let current = item
|
|
for (let guard = 0; guard < 10; guard++) {
|
|
if (current.Depth?.value) break
|
|
if (current.FirstOperand?.value) {
|
|
current = ifcApi.GetLine(modelID, current.FirstOperand.value)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
if (current.Depth?.value) {
|
|
result.depth = current.Depth.value
|
|
}
|
|
|
|
if (current.SweptArea?.value) {
|
|
const profile = ifcApi.GetLine(modelID, current.SweptArea.value)
|
|
|
|
// Detect the profile shape by the fields present rather than the IFC
|
|
// type id — robust across web-ifc versions. IfcCircleProfileDef has a
|
|
// Radius; IfcRectangleProfileDef has XDim/YDim.
|
|
if (profile.Radius?.value != null) {
|
|
result.profileShape = 'round'
|
|
result.radius = profile.Radius.value
|
|
}
|
|
|
|
if (profile.XDim?.value) {
|
|
result.xDim = profile.XDim.value
|
|
result.yDim = profile.YDim?.value ?? null
|
|
if (result.profileShape === null) result.profileShape = 'rectangular'
|
|
}
|
|
|
|
// Extract profile points — OuterCurve for ArbitraryClosedProfileDef
|
|
const curveRef = profile.OuterCurve?.value
|
|
if (curveRef) {
|
|
const curve = ifcApi.GetLine(modelID, curveRef)
|
|
if (curve.Points) {
|
|
const pts: number[][] = []
|
|
// IFCPOLYLINE — Points is array of CartesianPoint refs
|
|
if (
|
|
Array.isArray(curve.Points) &&
|
|
curve.Points.length > 0 &&
|
|
curve.Points[0]?.value != null
|
|
) {
|
|
for (const ptRef of curve.Points) {
|
|
const pt = ifcApi.GetLine(modelID, ptRef.value)
|
|
const coords = pt.Coordinates.map((c: any) =>
|
|
typeof c === 'number' ? c : (c?.value ?? 0),
|
|
)
|
|
pts.push([coords[0] ?? 0, coords[1] ?? 0])
|
|
}
|
|
}
|
|
// IFCINDEXEDPOLYCURVE — Points is a reference to a point list
|
|
else if (curve.Points?.value) {
|
|
const ptList = ifcApi.GetLine(modelID, curve.Points.value)
|
|
if (ptList.CoordList) {
|
|
for (const coords of ptList.CoordList) {
|
|
const c = Array.isArray(coords)
|
|
? coords.map((v: any) => (typeof v === 'number' ? v : (v?.value ?? 0)))
|
|
: []
|
|
pts.push([c[0] ?? 0, c[1] ?? 0])
|
|
}
|
|
}
|
|
}
|
|
if (pts.length >= 3) result.profilePoints = pts
|
|
}
|
|
}
|
|
}
|
|
|
|
return result.depth !== null
|
|
}
|
|
|
|
function getBodyExtrusionData(ifcApi: WebIFC.IfcAPI, modelID: number, element: any): ExtrusionData {
|
|
const result: ExtrusionData = {
|
|
depth: null,
|
|
xDim: null,
|
|
yDim: null,
|
|
profilePoints: null,
|
|
profileShape: null,
|
|
radius: null,
|
|
}
|
|
try {
|
|
if (!element.Representation?.value) return result
|
|
const prodRep = ifcApi.GetLine(modelID, element.Representation.value)
|
|
for (const repRef of prodRep.Representations) {
|
|
const rep = ifcApi.GetLine(modelID, repRef.value)
|
|
if (rep.RepresentationIdentifier?.value !== 'Body') continue
|
|
|
|
for (const itemRef of rep.Items) {
|
|
const item = ifcApi.GetLine(modelID, itemRef.value)
|
|
|
|
// Direct extrusion or BooleanClippingResult
|
|
if (extractFromExtrusionItem(ifcApi, modelID, item, result)) return result
|
|
|
|
// MappedRepresentation → unwrap to inner items
|
|
if (item.MappingSource?.value) {
|
|
const src = ifcApi.GetLine(modelID, item.MappingSource.value)
|
|
if (src.MappedRepresentation?.value) {
|
|
const mapped = ifcApi.GetLine(modelID, src.MappedRepresentation.value)
|
|
if (mapped.Items) {
|
|
for (const mItemRef of mapped.Items) {
|
|
const mItem = ifcApi.GetLine(modelID, mItemRef.value)
|
|
if (extractFromExtrusionItem(ifcApi, modelID, mItem, result)) return result
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return result
|
|
}
|
|
|
|
function findExtrusionPosition(ifcApi: WebIFC.IfcAPI, modelID: number, item: any): Mat4 | null {
|
|
let current = item
|
|
for (let guard = 0; guard < 10; guard++) {
|
|
if (current.Position?.value) {
|
|
return buildAxis2Placement3DMatrix(ifcApi, modelID, current.Position.value)
|
|
}
|
|
if (current.FirstOperand?.value) {
|
|
current = ifcApi.GetLine(modelID, current.FirstOperand.value)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Mesh extents of an element measured in the WALL'S OWN frame: along the
|
|
// wall axis (length), horizontally perpendicular to it (thickness), and
|
|
// vertical (height). Used to recover height/thickness for plain IFCWALL
|
|
// whose Brep/mapped geometry getBodyExtrusionData can't read.
|
|
//
|
|
// Each vertex is transformed to world space (flatTransformation) and
|
|
// then projected onto the known wall-axis direction — NOT read as a raw
|
|
// world AABB. A world AABB conflates length and thickness for any wall
|
|
// the placement rotates (a 37°-rotated 0.2m wall would read ~1.9m
|
|
// thick); projecting onto the actual axis is rotation-invariant.
|
|
// (axisX, axisY) is the unit wall direction in the converter's
|
|
// horizontal frame, which is parallel to web-ifc world XY — both are IFC
|
|
// world coords, differing only by origin/scale, which cancel in extents.
|
|
// Returns extents in the geometry's native units (caller resolves
|
|
// scale), or null on any failure.
|
|
function measureWallLocalExtents(
|
|
ifcApi: WebIFC.IfcAPI,
|
|
modelID: number,
|
|
expressID: number,
|
|
axisX: number,
|
|
axisY: number,
|
|
): { along: number; across: number; vertical: number } | null {
|
|
const perpX = -axisY
|
|
const perpY = axisX
|
|
let mesh: { geometries: { size: () => number; get: (i: number) => unknown }; delete?: () => void }
|
|
try {
|
|
mesh = ifcApi.GetFlatMesh(modelID, expressID) as never
|
|
} catch {
|
|
return null
|
|
}
|
|
try {
|
|
const geoms = mesh.geometries
|
|
let minA = Number.POSITIVE_INFINITY
|
|
let maxA = Number.NEGATIVE_INFINITY
|
|
let minP = Number.POSITIVE_INFINITY
|
|
let maxP = Number.NEGATIVE_INFINITY
|
|
let minV = Number.POSITIVE_INFINITY
|
|
let maxV = Number.NEGATIVE_INFINITY
|
|
let any = false
|
|
for (let g = 0; g < geoms.size(); g++) {
|
|
const pg = geoms.get(g) as { flatTransformation: number[]; geometryExpressID: number }
|
|
const m = pg.flatTransformation
|
|
const geo = ifcApi.GetGeometry(modelID, pg.geometryExpressID)
|
|
try {
|
|
const verts = ifcApi.GetVertexArray(geo.GetVertexData(), geo.GetVertexDataSize())
|
|
// 6 floats per vertex: local position xyz + normal xyz.
|
|
for (let v = 0; v + 2 < verts.length; v += 6) {
|
|
const x = verts[v]
|
|
const y = verts[v + 1]
|
|
const z = verts[v + 2]
|
|
// flatTransformation is column-major 4x4 → world position.
|
|
const wx = m[0] * x + m[4] * y + m[8] * z + m[12]
|
|
const wy = m[1] * x + m[5] * y + m[9] * z + m[13]
|
|
const wz = m[2] * x + m[6] * y + m[10] * z + m[14]
|
|
const a = wx * axisX + wy * axisY
|
|
const p = wx * perpX + wy * perpY
|
|
if (a < minA) minA = a
|
|
if (a > maxA) maxA = a
|
|
if (p < minP) minP = p
|
|
if (p > maxP) maxP = p
|
|
if (wz < minV) minV = wz
|
|
if (wz > maxV) maxV = wz
|
|
any = true
|
|
}
|
|
} finally {
|
|
;(geo as unknown as { delete?: () => void }).delete?.()
|
|
}
|
|
}
|
|
if (!any) return null
|
|
return { along: maxA - minA, across: maxP - minP, vertical: maxV - minV }
|
|
} catch {
|
|
return null
|
|
} finally {
|
|
mesh.delete?.()
|
|
}
|
|
}
|
|
|
|
// Resolve wall height + thickness from the wall-frame extents. The along
|
|
// extent must match the wall's already-known length (within tolerance)
|
|
// for the measurement to be trusted — that gate confirms both the unit
|
|
// scale (tried raw and unit-scaled) and frame alignment. Height is the
|
|
// vertical extent (rotation-invariant); thickness is the perpendicular
|
|
// extent. Returns null (→ caller uses defaults) when the gate fails or
|
|
// the dims are implausible.
|
|
function wallHeightThicknessFromExtents(
|
|
extents: { along: number; across: number; vertical: number },
|
|
knownLengthMeters: number,
|
|
unitFactor: number,
|
|
): { height: number; thickness: number } | null {
|
|
if (knownLengthMeters <= 1e-6) return null
|
|
const tol = Math.max(0.3, 0.15 * knownLengthMeters)
|
|
for (const scale of [unitFactor, 1]) {
|
|
const along = extents.along * scale
|
|
if (Math.abs(along - knownLengthMeters) > tol) continue
|
|
const height = extents.vertical * scale
|
|
const thickness = extents.across * scale
|
|
if (height >= 0.2 && height <= 20 && thickness >= 0.02 && thickness <= 2) {
|
|
return { height, thickness }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function getExtrusionPosition(ifcApi: WebIFC.IfcAPI, modelID: number, element: any): Mat4 | null {
|
|
try {
|
|
if (!element.Representation?.value) return null
|
|
const prodRep = ifcApi.GetLine(modelID, element.Representation.value)
|
|
for (const repRef of prodRep.Representations) {
|
|
const rep = ifcApi.GetLine(modelID, repRef.value)
|
|
if (rep.RepresentationIdentifier?.value !== 'Body') continue
|
|
for (const itemRef of rep.Items) {
|
|
const item = ifcApi.GetLine(modelID, itemRef.value)
|
|
const pos = findExtrusionPosition(ifcApi, modelID, item)
|
|
if (pos) return pos
|
|
|
|
if (item.MappingSource?.value) {
|
|
const src = ifcApi.GetLine(modelID, item.MappingSource.value)
|
|
if (src.MappedRepresentation?.value) {
|
|
const mapped = ifcApi.GetLine(modelID, src.MappedRepresentation.value)
|
|
if (mapped.Items) {
|
|
for (const mItemRef of mapped.Items) {
|
|
const mItem = ifcApi.GetLine(modelID, mItemRef.value)
|
|
const mPos = findExtrusionPosition(ifcApi, modelID, mItem)
|
|
if (mPos) return mPos
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return null
|
|
}
|
|
|
|
// --- Main converter ---
|
|
|
|
export interface ConversionOptions {
|
|
swapYZ?: boolean
|
|
extrusionDepthIsHeight?: boolean
|
|
swapProfileDimensions?: boolean
|
|
simplify?: boolean | IfcConversionSimplificationOptions
|
|
label?: string
|
|
}
|
|
|
|
export const VARIANT_PRESETS: Record<string, ConversionOptions> = {
|
|
A: {
|
|
swapYZ: true,
|
|
extrusionDepthIsHeight: true,
|
|
swapProfileDimensions: false,
|
|
label: 'Default (Y-up, depth=height)',
|
|
},
|
|
B: {
|
|
swapYZ: false,
|
|
extrusionDepthIsHeight: true,
|
|
swapProfileDimensions: false,
|
|
label: 'Z-Up (no axis swap)',
|
|
},
|
|
}
|
|
|
|
export async function convertIfcToPascal(
|
|
ifcData: Uint8Array,
|
|
onProgress?: (message: string, percent: number) => void,
|
|
options?: ConversionOptions,
|
|
): Promise<PascalSceneGraph> {
|
|
const opts = {
|
|
swapYZ: options?.swapYZ ?? true,
|
|
extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true,
|
|
swapProfileDimensions: options?.swapProfileDimensions ?? false,
|
|
}
|
|
const simplificationOptions =
|
|
options?.simplify === false
|
|
? { enabled: false }
|
|
: typeof options?.simplify === 'object'
|
|
? options.simplify
|
|
: undefined
|
|
|
|
const progress = (msg: string, pct: number) => {
|
|
console.log(`[IFC→Pascal] ${msg} (${pct}%)`)
|
|
onProgress?.(msg, pct)
|
|
}
|
|
|
|
progress('Initializing IFC parser...', 0)
|
|
const ifcApi = new WebIFC.IfcAPI()
|
|
ifcApi.SetWasmPath('/', true)
|
|
|
|
await ifcApi.Init()
|
|
progress('Opening IFC model...', 10)
|
|
const modelID = ifcApi.OpenModel(ifcData)
|
|
|
|
console.log(
|
|
`[IFC→Pascal] Model opened, ID: ${modelID}, File size: ${(ifcData.length / 1024).toFixed(1)} KB`,
|
|
)
|
|
const nodes: Record<string, PascalNode> = {}
|
|
const rootNodeIds: string[] = []
|
|
|
|
// Maps to track relationships
|
|
const parentMap = new Map<number, number>()
|
|
const childrenMap = new Map<number, number[]>()
|
|
const expressIdToNodeId = new Map<number, string>()
|
|
|
|
progress('Analyzing spatial relationships...', 20)
|
|
|
|
// Detect length unit → meters conversion factor
|
|
const unitFactor = getLengthUnitFactor(ifcApi, modelID)
|
|
|
|
// Compute scene origin offset to center georeferenced models near (0,0,0)
|
|
let originOffset: number[] = [0, 0, 0]
|
|
try {
|
|
const siteIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE)
|
|
const anchorId = siteIds.size() > 0 ? siteIds.get(0) : null
|
|
if (anchorId) {
|
|
const anchor = ifcApi.GetLine(modelID, anchorId)
|
|
if (anchor.ObjectPlacement?.value) {
|
|
const mat = resolveWorldTransform(ifcApi, modelID, anchor.ObjectPlacement.value)
|
|
originOffset = transformPoint3(mat, [0, 0, 0])
|
|
}
|
|
}
|
|
} catch {
|
|
/* keep zero offset */
|
|
}
|
|
|
|
function worldToScene(worldPt: number[]): number[] {
|
|
return [
|
|
(worldPt[0] - originOffset[0]) * unitFactor,
|
|
(worldPt[1] - originOffset[1]) * unitFactor,
|
|
(worldPt[2] - originOffset[2]) * unitFactor,
|
|
]
|
|
}
|
|
|
|
// Collect storey expressIDs for level mapping
|
|
const storeyExpressIds = new Set<number>()
|
|
const storeyIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY)
|
|
for (let i = 0; i < storeyIds.size(); i++) {
|
|
storeyExpressIds.add(storeyIds.get(i))
|
|
}
|
|
|
|
// First pass: collect spatial hierarchy relationships
|
|
const relAggregates = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELAGGREGATES)
|
|
|
|
for (let i = 0; i < relAggregates.size(); i++) {
|
|
const relID = relAggregates.get(i)
|
|
const rel = ifcApi.GetLine(modelID, relID)
|
|
|
|
if (rel.RelatingObject && rel.RelatedObjects) {
|
|
const parentExpressID = rel.RelatingObject.value
|
|
const children = rel.RelatedObjects.map((obj: any) => obj.value)
|
|
|
|
children.forEach((childID: number) => {
|
|
parentMap.set(childID, parentExpressID)
|
|
})
|
|
|
|
if (!childrenMap.has(parentExpressID)) {
|
|
childrenMap.set(parentExpressID, [])
|
|
}
|
|
childrenMap.get(parentExpressID)?.push(...children)
|
|
}
|
|
}
|
|
|
|
// Second pass: collect spatial containment
|
|
const relContained = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELCONTAINEDINSPATIALSTRUCTURE)
|
|
|
|
for (let i = 0; i < relContained.size(); i++) {
|
|
const relID = relContained.get(i)
|
|
const rel = ifcApi.GetLine(modelID, relID)
|
|
|
|
if (rel.RelatingStructure && rel.RelatedElements) {
|
|
const parentExpressID = rel.RelatingStructure.value
|
|
const children = rel.RelatedElements.map((obj: any) => obj.value)
|
|
|
|
children.forEach((childID: number) => {
|
|
parentMap.set(childID, parentExpressID)
|
|
})
|
|
|
|
if (!childrenMap.has(parentExpressID)) {
|
|
childrenMap.set(parentExpressID, [])
|
|
}
|
|
childrenMap.get(parentExpressID)?.push(...children)
|
|
}
|
|
}
|
|
|
|
// Resolve containing storey for an element by walking the parent chain
|
|
function findStoreyForElement(expressId: number): number | null {
|
|
let current: number | undefined = expressId
|
|
for (let guard = 0; guard < 20 && current != null; guard++) {
|
|
if (storeyExpressIds.has(current)) return current
|
|
current = parentMap.get(current)
|
|
}
|
|
return null
|
|
}
|
|
|
|
progress('Processing sites...', 30)
|
|
// Process sites
|
|
const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE)
|
|
console.log(`[IFC→Pascal] Found ${sites.size()} sites`)
|
|
for (let i = 0; i < sites.size(); i++) {
|
|
const siteExpressID = sites.get(i)
|
|
const site = ifcApi.GetLine(modelID, siteExpressID)
|
|
|
|
const nodeId = generateId('site')
|
|
expressIdToNodeId.set(siteExpressID, nodeId)
|
|
rootNodeIds.push(nodeId)
|
|
|
|
const siteNode = tryParse(SiteNode, 'site', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'site',
|
|
name: site.Name?.value || site.LongName?.value || 'Site',
|
|
parentId: null,
|
|
visible: true,
|
|
polygon: {
|
|
// Pascal SiteNode requires a property-line polygon. The
|
|
// converter doesn't read IFC site geometry yet, so seed the
|
|
// editor's default 30x30 square here.
|
|
// TODO(ifc-fix): derive from IfcSite.SiteAddress or building footprints.
|
|
type: 'polygon',
|
|
points: [
|
|
[-15, -15],
|
|
[15, -15],
|
|
[15, 15],
|
|
[-15, 15],
|
|
],
|
|
},
|
|
children: [],
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCSITE',
|
|
expressID: siteExpressID,
|
|
globalId: site.GlobalId?.value,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = siteNode
|
|
}
|
|
|
|
progress('Processing buildings...', 40)
|
|
// Process buildings
|
|
const buildings = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDING)
|
|
console.log(`[IFC→Pascal] Found ${buildings.size()} buildings`)
|
|
for (let i = 0; i < buildings.size(); i++) {
|
|
const buildingExpressID = buildings.get(i)
|
|
const building = ifcApi.GetLine(modelID, buildingExpressID)
|
|
|
|
const nodeId = generateId('building')
|
|
expressIdToNodeId.set(buildingExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(buildingExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
const buildingNode = tryParse(BuildingNode, 'building', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'building',
|
|
name: building.Name?.value || building.LongName?.value || 'Building',
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
position: [0, 0, 0],
|
|
rotation: [0, 0, 0],
|
|
children: [],
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCBUILDING',
|
|
expressID: buildingExpressID,
|
|
globalId: building.GlobalId?.value,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = buildingNode
|
|
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
progress('Processing levels...', 50)
|
|
// Process building storeys (levels)
|
|
const storeys = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY)
|
|
console.log(`[IFC→Pascal] Found ${storeys.size()} levels`)
|
|
for (let i = 0; i < storeys.size(); i++) {
|
|
const storeyExpressID = storeys.get(i)
|
|
const storey = ifcApi.GetLine(modelID, storeyExpressID)
|
|
|
|
const nodeId = generateId('level')
|
|
expressIdToNodeId.set(storeyExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(storeyExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
// Resolve storey elevation from placement chain
|
|
let elevation = storey.Elevation?.value ?? 0
|
|
if (storey.ObjectPlacement?.value) {
|
|
try {
|
|
const worldMat = resolveWorldTransform(ifcApi, modelID, storey.ObjectPlacement.value)
|
|
const worldOrigin = transformPoint3(worldMat, [0, 0, 0])
|
|
elevation = worldToScene(worldOrigin)[2]
|
|
} catch {
|
|
elevation = (storey.Elevation?.value ?? 0) * unitFactor
|
|
}
|
|
} else {
|
|
elevation *= unitFactor
|
|
}
|
|
|
|
const levelNode = tryParse(LevelNode, 'level', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'level',
|
|
name: storey.Name?.value || storey.LongName?.value || `Level ${i}`,
|
|
level: i,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
children: [],
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCBUILDINGSTOREY',
|
|
expressID: storeyExpressID,
|
|
globalId: storey.GlobalId?.value,
|
|
elevation,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = levelNode
|
|
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
progress('Processing walls...', 60)
|
|
|
|
// Build void/fill relationship maps for doors and windows
|
|
// IFCRELVOIDSELEMENT: wall (RelatingBuildingElement) → opening (RelatedOpeningElement)
|
|
const wallToOpenings = new Map<number, number[]>()
|
|
const relVoids = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELVOIDSELEMENT)
|
|
for (let i = 0; i < relVoids.size(); i++) {
|
|
const rel = ifcApi.GetLine(modelID, relVoids.get(i))
|
|
const wallId = rel.RelatingBuildingElement?.value
|
|
const openingId = rel.RelatedOpeningElement?.value
|
|
if (wallId && openingId) {
|
|
if (!wallToOpenings.has(wallId)) wallToOpenings.set(wallId, [])
|
|
wallToOpenings.get(wallId)!.push(openingId)
|
|
}
|
|
}
|
|
|
|
// IFCRELFILLSELEMENT: opening (RelatingOpeningElement) → door/window (RelatedBuildingElement)
|
|
const openingToFill = new Map<number, number>()
|
|
const relFills = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELFILLSELEMENT)
|
|
for (let i = 0; i < relFills.size(); i++) {
|
|
const rel = ifcApi.GetLine(modelID, relFills.get(i))
|
|
const openingId = rel.RelatingOpeningElement?.value
|
|
const fillId = rel.RelatedBuildingElement?.value
|
|
if (openingId && fillId) {
|
|
openingToFill.set(openingId, fillId)
|
|
}
|
|
}
|
|
|
|
// Collect all door and window expressIDs for type checking
|
|
const doorExpressIds = new Set<number>()
|
|
const windowExpressIds = new Set<number>()
|
|
for (const doorType of [WebIFC.IFCDOOR, WebIFC.IFCDOORSTANDARDCASE]) {
|
|
const ids = ifcApi.GetLineIDsWithType(modelID, doorType)
|
|
for (let i = 0; i < ids.size(); i++) doorExpressIds.add(ids.get(i))
|
|
}
|
|
for (const winType of [WebIFC.IFCWINDOW, WebIFC.IFCWINDOWSTANDARDCASE]) {
|
|
const ids = ifcApi.GetLineIDsWithType(modelID, winType)
|
|
for (let i = 0; i < ids.size(); i++) windowExpressIds.add(ids.get(i))
|
|
}
|
|
|
|
// Process walls (both IFCWALL and IFCWALLSTANDARDCASE)
|
|
const wallTypes = [WebIFC.IFCWALL, WebIFC.IFCWALLSTANDARDCASE]
|
|
for (const wallType of wallTypes) {
|
|
const walls = ifcApi.GetLineIDsWithType(modelID, wallType)
|
|
for (let i = 0; i < walls.size(); i++) {
|
|
const wallExpressID = walls.get(i)
|
|
if (expressIdToNodeId.has(wallExpressID)) continue
|
|
|
|
const wall = ifcApi.GetLine(modelID, wallExpressID)
|
|
|
|
const nodeId = generateId('wall')
|
|
expressIdToNodeId.set(wallExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(wallExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let start: [number, number] = [0, 0]
|
|
let end: [number, number] | null = null
|
|
let thickness: number | undefined
|
|
let height: number | undefined
|
|
|
|
try {
|
|
// Resolve world placement
|
|
const worldMat = wall.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value)
|
|
: identity()
|
|
|
|
// Try to get axis polyline (local 2D line along wall)
|
|
const axisPts = getAxisPolyline(ifcApi, modelID, wall)
|
|
|
|
if (axisPts && axisPts.length >= 2) {
|
|
const s0 = worldToScene(transformPoint3(worldMat, axisPts[0]))
|
|
const s1 = worldToScene(transformPoint3(worldMat, axisPts[axisPts.length - 1]))
|
|
start = [s0[0], s0[1]]
|
|
end = [s1[0], s1[1]]
|
|
} else {
|
|
// Fallback: use placement origin + body XDim for length
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
start = [s[0], s[1]]
|
|
// Will try to get length from body below
|
|
}
|
|
|
|
// Get body extrusion data for thickness/height
|
|
const body = getBodyExtrusionData(ifcApi, modelID, wall)
|
|
|
|
if (body.depth) {
|
|
if (opts.extrusionDepthIsHeight) {
|
|
height = body.depth * unitFactor
|
|
} else {
|
|
thickness = body.depth * unitFactor
|
|
}
|
|
}
|
|
|
|
const dimForThickness = opts.swapProfileDimensions ? body.xDim : body.yDim
|
|
if (dimForThickness) {
|
|
thickness = dimForThickness * unitFactor
|
|
} else if (body.profilePoints && body.profilePoints.length >= 3) {
|
|
const ys = body.profilePoints.map((p) => p[1])
|
|
thickness = (Math.max(...ys) - Math.min(...ys)) * unitFactor
|
|
}
|
|
|
|
// If no axis polyline, derive wall length from profile or XDim
|
|
if (!axisPts) {
|
|
let wallLength = body.xDim
|
|
if (!wallLength && body.profilePoints && body.profilePoints.length >= 3) {
|
|
const xs = body.profilePoints.map((p) => p[0])
|
|
wallLength = Math.max(...xs) - Math.min(...xs)
|
|
}
|
|
if (wallLength) {
|
|
const se = worldToScene(transformPoint3(worldMat, [wallLength, 0, 0]))
|
|
end = [se[0], se[1]]
|
|
}
|
|
}
|
|
} catch {
|
|
// keep defaults
|
|
}
|
|
|
|
// Skip walls where we couldn't determine geometry
|
|
if (!end) continue
|
|
|
|
// Plain IFCWALL frequently carries Brep / mapped geometry rather
|
|
// than a clean IfcExtrudedAreaSolid, so getBodyExtrusionData can't
|
|
// read its height/thickness (only IFCWALLSTANDARDCASE reliably
|
|
// works). First try to recover real dims from the element's mesh
|
|
// bounding box; only then fall back to the editor's wall defaults
|
|
// so the wall renders at a sensible size instead of collapsing to
|
|
// a zero-height sliver (which also breaks the door/window CSG
|
|
// cutouts punched into it).
|
|
if (height === undefined || thickness === undefined) {
|
|
const wallLenM = Math.hypot(end[0] - start[0], end[1] - start[1])
|
|
if (wallLenM > 1e-6) {
|
|
// Project the wall mesh onto its own axis so length, thickness
|
|
// and height are read in the wall frame (rotation-invariant) —
|
|
// a world-space AABB conflates length and thickness on rotated
|
|
// walls. Axis is the normalised start→end direction in the
|
|
// IFC ground plane, which is also the mapping used for the
|
|
// wall's start/end above.
|
|
const axisX = (end[0] - start[0]) / wallLenM
|
|
const axisY = (end[1] - start[1]) / wallLenM
|
|
const extents = measureWallLocalExtents(ifcApi, modelID, wallExpressID, axisX, axisY)
|
|
const geom = extents
|
|
? wallHeightThicknessFromExtents(extents, wallLenM, unitFactor)
|
|
: null
|
|
if (geom) {
|
|
if (height === undefined) height = geom.height
|
|
if (thickness === undefined) thickness = geom.thickness
|
|
}
|
|
}
|
|
}
|
|
if (height === undefined) height = DEFAULT_WALL_HEIGHT
|
|
if (thickness === undefined) thickness = DEFAULT_WALL_THICKNESS
|
|
|
|
const wallNode = tryParse(WallNode, 'wall', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'wall',
|
|
name: wall.Name?.value || `Wall ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
start,
|
|
end,
|
|
thickness,
|
|
height,
|
|
frontSide: 'unknown',
|
|
backSide: 'unknown',
|
|
children: [],
|
|
metadata: buildMetadata({
|
|
ifcType: wallType === WebIFC.IFCWALL ? 'IFCWALL' : 'IFCWALLSTANDARDCASE',
|
|
expressID: wallExpressID,
|
|
globalId: wall.GlobalId?.value,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = wallNode
|
|
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process doors and windows via void/fill relationships
|
|
for (const [wallExpressID, openingIds] of wallToOpenings) {
|
|
const wallNodeId = expressIdToNodeId.get(wallExpressID)
|
|
if (!wallNodeId) continue
|
|
const wallNode = nodes[wallNodeId] as WallNode
|
|
if (wallNode?.type !== 'wall') continue
|
|
|
|
const wallDx = wallNode.end[0] - wallNode.start[0]
|
|
const wallDy = wallNode.end[1] - wallNode.start[1]
|
|
const wallLength = Math.sqrt(wallDx * wallDx + wallDy * wallDy)
|
|
|
|
// Wall world matrix for projecting opening positions
|
|
let wallWorldMat: Mat4 | null = null
|
|
try {
|
|
const wall = ifcApi.GetLine(modelID, wallExpressID)
|
|
if (wall.ObjectPlacement?.value) {
|
|
wallWorldMat = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value)
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
for (const openingId of openingIds) {
|
|
const fillId = openingToFill.get(openingId)
|
|
if (!fillId) continue
|
|
|
|
const isDoor = doorExpressIds.has(fillId)
|
|
const isWindow = windowExpressIds.has(fillId)
|
|
if (!isDoor && !isWindow) continue
|
|
|
|
try {
|
|
const element = ifcApi.GetLine(modelID, fillId)
|
|
|
|
// Get dimensions from OverallWidth/OverallHeight
|
|
let width: number | undefined
|
|
let height: number | undefined
|
|
if (element.OverallWidth?.value) width = element.OverallWidth.value * unitFactor
|
|
if (element.OverallHeight?.value) height = element.OverallHeight.value * unitFactor
|
|
|
|
// Compute position along wall from opening placement
|
|
let position: number | undefined
|
|
try {
|
|
const opening = ifcApi.GetLine(modelID, openingId)
|
|
if (opening.ObjectPlacement?.value) {
|
|
const openingWorldMat = resolveWorldTransform(
|
|
ifcApi,
|
|
modelID,
|
|
opening.ObjectPlacement.value,
|
|
)
|
|
const openingScene = worldToScene(transformPoint3(openingWorldMat, [0, 0, 0]))
|
|
const ox = openingScene[0] - wallNode.start[0]
|
|
const oy = openingScene[1] - wallNode.start[1]
|
|
if (wallLength > 1e-6) {
|
|
const dot = (ox * wallDx + oy * wallDy) / wallLength
|
|
// Clamp so the opening's [pos - w/2, pos + w/2] footprint
|
|
// stays inside the wall — an overflowing CSG cutout breaks
|
|
// the wall mesh.
|
|
const half = (width ?? 0) / 2
|
|
const lo = Math.min(half, wallLength / 2)
|
|
const hi = Math.max(wallLength - half, wallLength / 2)
|
|
position = Math.max(lo, Math.min(hi, dot))
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
// Get sill height for windows from opening placement Z relative to wall
|
|
let sillHeight: number | undefined
|
|
if (isWindow) {
|
|
try {
|
|
const opening = ifcApi.GetLine(modelID, openingId)
|
|
if (opening.ObjectPlacement?.value) {
|
|
const openingWorldMat = resolveWorldTransform(
|
|
ifcApi,
|
|
modelID,
|
|
opening.ObjectPlacement.value,
|
|
)
|
|
const openingScene = worldToScene(transformPoint3(openingWorldMat, [0, 0, 0]))
|
|
if (wallWorldMat) {
|
|
const wallScene = worldToScene(transformPoint3(wallWorldMat, [0, 0, 0]))
|
|
sillHeight = openingScene[2] - wallScene[2]
|
|
} else {
|
|
sillHeight = openingScene[2]
|
|
}
|
|
if (sillHeight !== undefined && sillHeight < 0.01) sillHeight = undefined
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
if (isDoor) {
|
|
const nodeId = generateId('door')
|
|
expressIdToNodeId.set(fillId, nodeId)
|
|
|
|
// Vertical centering is now handled: door center Y = height/2 so the
|
|
// opening sits at the correct position. Remaining caveat: door bottom
|
|
// is assumed at floor y=0 (i.e. the door starts at the wall's base).
|
|
// Defaults match @pascal-app/core door schema fallbacks.
|
|
const doorPosition: [number, number, number] = [position ?? 0, (height ?? 2.1) / 2, 0]
|
|
const doorNode = tryParse(DoorNode, 'door', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'door',
|
|
name: element.Name?.value || `Door`,
|
|
parentId: wallNodeId,
|
|
visible: true,
|
|
width: width ?? 0.9,
|
|
height: height ?? 2.1,
|
|
position: doorPosition,
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCDOOR',
|
|
expressID: fillId,
|
|
globalId: element.GlobalId?.value,
|
|
hostWallExpressID: wallExpressID,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = doorNode
|
|
wallNode.children.push(nodeId)
|
|
} else {
|
|
const nodeId = generateId('window')
|
|
expressIdToNodeId.set(fillId, nodeId)
|
|
|
|
// TODO(ifc-fix): same scalar-vs-tuple position issue as door above.
|
|
// sillHeight stays read-only metadata until we resolve the window
|
|
// schema (Pascal's WindowNode doesn't have sillHeight today —
|
|
// moved to metadata for now so we don't lose the value).
|
|
const windowPosition: [number, number, number] = [
|
|
position ?? 0,
|
|
(sillHeight ?? 0) + (height ?? 1.2) / 2,
|
|
0,
|
|
]
|
|
const windowNode = tryParse(WindowNode, 'window', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'window',
|
|
name: element.Name?.value || `Window`,
|
|
parentId: wallNodeId,
|
|
visible: true,
|
|
width: width ?? 1.0,
|
|
height: height ?? 1.2,
|
|
position: windowPosition,
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCWINDOW',
|
|
expressID: fillId,
|
|
globalId: element.GlobalId?.value,
|
|
hostWallExpressID: wallExpressID,
|
|
sillHeight,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = windowNode
|
|
wallNode.children.push(nodeId)
|
|
}
|
|
} catch {
|
|
// skip this opening
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process any doors/windows NOT linked via the void/fill chain. Some
|
|
// exporters (e.g. the Paris sample) carve openings with
|
|
// IFCRELVOIDSELEMENT but omit IFCRELFILLSELEMENT, so the
|
|
// door/window → wall link is only implicit in the element's world
|
|
// placement. We recover it by projecting the element's world position
|
|
// onto the nearest wall segment. Anything farther than
|
|
// HOST_WALL_MAX_DIST from every wall stays parented to its spatial
|
|
// container at the origin — we have no basis to place it on a wall.
|
|
const HOST_WALL_MAX_DIST = 1.0 // metres
|
|
|
|
type WallInfo = {
|
|
nodeId: string
|
|
start: [number, number]
|
|
end: [number, number]
|
|
length: number
|
|
baseY: number
|
|
}
|
|
const wallInfos: WallInfo[] = []
|
|
for (const [wallExpressId, wallNodeId] of expressIdToNodeId) {
|
|
const node = nodes[wallNodeId]
|
|
if (node?.type !== 'wall') continue
|
|
const w = node as WallNode
|
|
const length = Math.hypot(w.end[0] - w.start[0], w.end[1] - w.start[1])
|
|
if (length < 1e-6) continue
|
|
let baseY = 0
|
|
try {
|
|
const wall = ifcApi.GetLine(modelID, wallExpressId)
|
|
if (wall.ObjectPlacement?.value) {
|
|
const m = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value)
|
|
baseY = worldToScene(transformPoint3(m, [0, 0, 0]))[2]
|
|
}
|
|
} catch {
|
|
/* keep baseY = 0 */
|
|
}
|
|
wallInfos.push({ nodeId: wallNodeId, start: w.start, end: w.end, length, baseY })
|
|
}
|
|
|
|
// Pick the host wall for an opening at ground-plane point [x, y] with
|
|
// the given width. Among walls within HOST_WALL_MAX_DIST
|
|
// perpendicular, prefer one long enough to contain the opening — the
|
|
// castle's IFCWALL decomposition leaves tiny stub walls near corners,
|
|
// and a plain nearest-by-distance match would snap (say) a 0.7m door
|
|
// onto a 0.4m stub, so its CSG cutout overflows and breaks the wall.
|
|
// Returns the along-wall position already clamped so the opening
|
|
// footprint stays inside the wall.
|
|
const findHostWall = (x: number, y: number, width: number) => {
|
|
let best: { info: WallInfo; along: number; dist: number; fits: boolean } | null = null
|
|
for (const info of wallInfos) {
|
|
const dx = info.end[0] - info.start[0]
|
|
const dy = info.end[1] - info.start[1]
|
|
const lenSq = dx * dx + dy * dy
|
|
if (lenSq < 1e-9) continue
|
|
const t = Math.max(
|
|
0,
|
|
Math.min(1, ((x - info.start[0]) * dx + (y - info.start[1]) * dy) / lenSq),
|
|
)
|
|
const footX = info.start[0] + t * dx
|
|
const footY = info.start[1] + t * dy
|
|
const dist = Math.hypot(x - footX, y - footY)
|
|
if (dist > HOST_WALL_MAX_DIST) continue
|
|
// Keep the [along - width/2, along + width/2] span inside the wall.
|
|
const half = width / 2
|
|
const lo = Math.min(half, info.length / 2)
|
|
const hi = Math.max(info.length - half, info.length / 2)
|
|
const along = Math.max(lo, Math.min(hi, t * info.length))
|
|
const fits = info.length + 1e-6 >= width
|
|
const cand = { info, along, dist, fits }
|
|
if (!best) {
|
|
best = cand
|
|
} else if (cand.fits !== best.fits) {
|
|
if (cand.fits) best = cand
|
|
} else if (cand.dist < best.dist) {
|
|
best = cand
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
for (const fillId of [...doorExpressIds, ...windowExpressIds]) {
|
|
if (expressIdToNodeId.has(fillId)) continue
|
|
try {
|
|
const element = ifcApi.GetLine(modelID, fillId)
|
|
const isDoor = doorExpressIds.has(fillId)
|
|
|
|
let width: number | undefined
|
|
let height: number | undefined
|
|
if (element.OverallWidth?.value) width = element.OverallWidth.value * unitFactor
|
|
if (element.OverallHeight?.value) height = element.OverallHeight.value * unitFactor
|
|
|
|
// Element world placement → scene point. Ground plane is [x, y]
|
|
// (IFC X/Y, matching wall start/end); vertical is component [2].
|
|
let scene: number[] | null = null
|
|
try {
|
|
if (element.ObjectPlacement?.value) {
|
|
const m = resolveWorldTransform(ifcApi, modelID, element.ObjectPlacement.value)
|
|
scene = worldToScene(transformPoint3(m, [0, 0, 0]))
|
|
}
|
|
} catch {
|
|
/* no placement */
|
|
}
|
|
|
|
const effWidth = width ?? (isDoor ? 0.9 : 1.0)
|
|
const hosted = scene ? findHostWall(scene[0], scene[1], effWidth) : null
|
|
|
|
// When hosted, parent to (and live inside) the wall — same as the
|
|
// void/fill path. Otherwise fall back to the spatial container.
|
|
const containerExpressID = parentMap.get(fillId)
|
|
const containerNodeId = containerExpressID
|
|
? (expressIdToNodeId.get(containerExpressID) ?? null)
|
|
: null
|
|
const parentNodeId = hosted ? hosted.info.nodeId : containerNodeId
|
|
|
|
if (isDoor) {
|
|
const h = height ?? 2.1
|
|
const nodeId = generateId('door')
|
|
expressIdToNodeId.set(fillId, nodeId)
|
|
const doorNode = tryParse(DoorNode, 'door', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'door',
|
|
name: element.Name?.value || `Door`,
|
|
parentId: parentNodeId,
|
|
visible: true,
|
|
width: width ?? 0.9,
|
|
height: h,
|
|
// Placed by nearest-wall projection; [0,0,0] only when no wall
|
|
// is within range (then it sits on its spatial container).
|
|
position: hosted ? [hosted.along, h / 2, 0] : [0, 0, 0],
|
|
...(hosted ? { wallId: hosted.info.nodeId } : {}),
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCDOOR',
|
|
expressID: fillId,
|
|
globalId: element.GlobalId?.value,
|
|
}),
|
|
})
|
|
nodes[nodeId] = doorNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId)
|
|
}
|
|
} else {
|
|
const h = height ?? 1.2
|
|
const sill = hosted && scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0
|
|
const nodeId = generateId('window')
|
|
expressIdToNodeId.set(fillId, nodeId)
|
|
const windowNode = tryParse(WindowNode, 'window', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'window',
|
|
name: element.Name?.value || `Window`,
|
|
parentId: parentNodeId,
|
|
visible: true,
|
|
width: width ?? 1.0,
|
|
height: h,
|
|
position: hosted ? [hosted.along, sill + h / 2, 0] : [0, 0, 0],
|
|
...(hosted ? { wallId: hosted.info.nodeId } : {}),
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCWINDOW',
|
|
expressID: fillId,
|
|
globalId: element.GlobalId?.value,
|
|
...(hosted ? { sillHeight: sill } : {}),
|
|
}),
|
|
})
|
|
nodes[nodeId] = windowNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId)
|
|
}
|
|
}
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
|
|
// Process slabs
|
|
const slabs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSLAB)
|
|
console.log(`[IFC→Pascal] Found ${slabs.size()} slabs`)
|
|
for (let i = 0; i < slabs.size(); i++) {
|
|
const slabExpressID = slabs.get(i)
|
|
const slab = ifcApi.GetLine(modelID, slabExpressID)
|
|
|
|
const nodeId = generateId('slab')
|
|
expressIdToNodeId.set(slabExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(slabExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let polygon: [number, number][] | null = null
|
|
let elevation = 0
|
|
let thickness: number | undefined
|
|
|
|
try {
|
|
// Resolve world placement for the slab element
|
|
const worldMat = slab.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, slab.ObjectPlacement.value)
|
|
: identity()
|
|
|
|
// Get elevation from placement Z
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
elevation = s[2]
|
|
|
|
// Get body extrusion data
|
|
const body = getBodyExtrusionData(ifcApi, modelID, slab)
|
|
|
|
// Extrusion depth is slab thickness
|
|
if (body.depth) {
|
|
thickness = body.depth * unitFactor
|
|
}
|
|
|
|
// Extrusion Position provides an additional local offset for the profile
|
|
const extrusionMat = getExtrusionPosition(ifcApi, modelID, slab)
|
|
|
|
if (body.profilePoints && body.profilePoints.length >= 3) {
|
|
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
|
|
polygon = body.profilePoints.map((pt) => {
|
|
const sc = worldToScene(transformPoint3(combinedMat, [pt[0], pt[1], 0]))
|
|
return [sc[0], sc[1]] as [number, number]
|
|
})
|
|
const first = polygon[0]
|
|
const last = polygon[polygon.length - 1]
|
|
if (
|
|
polygon.length > 3 &&
|
|
Math.abs(first[0] - last[0]) < 1e-6 &&
|
|
Math.abs(first[1] - last[1]) < 1e-6
|
|
) {
|
|
polygon.pop()
|
|
}
|
|
} else if (body.xDim && body.yDim) {
|
|
const hw = body.xDim / 2
|
|
const hh = body.yDim / 2
|
|
const corners: number[][] = [
|
|
[-hw, -hh, 0],
|
|
[hw, -hh, 0],
|
|
[hw, hh, 0],
|
|
[-hw, hh, 0],
|
|
]
|
|
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
|
|
polygon = corners.map((c) => {
|
|
const sc = worldToScene(transformPoint3(combinedMat, c))
|
|
return [sc[0], sc[1]] as [number, number]
|
|
})
|
|
}
|
|
} catch {
|
|
// keep defaults
|
|
}
|
|
|
|
// Skip slabs where we couldn't extract a polygon
|
|
if (!polygon || polygon.length < 3) continue
|
|
|
|
const slabNode = tryParse(SlabNode, 'slab', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'slab',
|
|
name: slab.Name?.value || `Slab ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
polygon,
|
|
holes: [],
|
|
elevation,
|
|
// TODO(ifc-fix): Pascal SlabNode has no `thickness` field — moved
|
|
// to metadata so the IFC value isn't lost.
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCSLAB',
|
|
expressID: slabExpressID,
|
|
globalId: slab.GlobalId?.value,
|
|
thickness,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = slabNode
|
|
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
// Process stairs
|
|
const stairs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSTAIR)
|
|
for (let i = 0; i < stairs.size(); i++) {
|
|
const stairExpressID = stairs.get(i)
|
|
if (expressIdToNodeId.has(stairExpressID)) continue
|
|
|
|
const stair = ifcApi.GetLine(modelID, stairExpressID)
|
|
const nodeId = generateId('stair')
|
|
expressIdToNodeId.set(stairExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(stairExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let position: [number, number, number] = [0, 0, 0]
|
|
let boundingBox: [number, number, number] | undefined
|
|
|
|
try {
|
|
const worldMat = stair.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, stair.ObjectPlacement.value)
|
|
: identity()
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]]
|
|
|
|
// Try stair's own body first
|
|
const body = getBodyExtrusionData(ifcApi, modelID, stair)
|
|
if (body.xDim && body.yDim && body.depth) {
|
|
boundingBox = opts.swapYZ
|
|
? [body.xDim * unitFactor, body.depth * unitFactor, body.yDim * unitFactor]
|
|
: [body.xDim * unitFactor, body.yDim * unitFactor, body.depth * unitFactor]
|
|
}
|
|
|
|
// If no body, try to derive from stair flight children
|
|
if (!boundingBox) {
|
|
const stairChildren = childrenMap.get(stairExpressID) ?? []
|
|
for (const childId of stairChildren) {
|
|
try {
|
|
const child = ifcApi.GetLine(modelID, childId)
|
|
// Check for NumberOfRisers / RiserHeight / TreadLength
|
|
const nRisers = child.NumberOfRisers?.value ?? child.NumberOfRiser?.value
|
|
const riserHeight = child.RiserHeight?.value
|
|
const treadLength = child.TreadLength?.value
|
|
if (nRisers && riserHeight && treadLength) {
|
|
const totalHeight = nRisers * riserHeight * unitFactor
|
|
const totalRun = (nRisers - 1) * treadLength * unitFactor
|
|
const width = 1.0 // Default stair width
|
|
const flightBody = getBodyExtrusionData(ifcApi, modelID, child)
|
|
const stairWidth = flightBody.yDim ? flightBody.yDim * unitFactor : width
|
|
boundingBox = opts.swapYZ
|
|
? [totalRun || 1, totalHeight, stairWidth]
|
|
: [totalRun || 1, stairWidth, totalHeight]
|
|
break
|
|
}
|
|
// Fallback: try flight body extrusion
|
|
const flightBody = getBodyExtrusionData(ifcApi, modelID, child)
|
|
if (flightBody.xDim && flightBody.yDim && flightBody.depth) {
|
|
boundingBox = opts.swapYZ
|
|
? [
|
|
flightBody.xDim * unitFactor,
|
|
flightBody.depth * unitFactor,
|
|
flightBody.yDim * unitFactor,
|
|
]
|
|
: [
|
|
flightBody.xDim * unitFactor,
|
|
flightBody.yDim * unitFactor,
|
|
flightBody.depth * unitFactor,
|
|
]
|
|
break
|
|
}
|
|
} catch {
|
|
/* skip child */
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* keep defaults */
|
|
}
|
|
|
|
const stairNode = tryParse(StairNode, 'stair', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'stair',
|
|
name: stair.Name?.value || `Stair ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
position,
|
|
children: [],
|
|
// TODO(ifc-fix): Pascal StairNode is parametric (segments / treads /
|
|
// risers). The converter only knows the bounding box right now;
|
|
// keep it in metadata until we map IFC stairs onto the parametric
|
|
// shape (or extend StairNode with a raw-geometry escape hatch).
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCSTAIR',
|
|
expressID: stairExpressID,
|
|
globalId: stair.GlobalId?.value,
|
|
predefinedType: stair.PredefinedType?.value,
|
|
boundingBox,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = stairNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
// Process roofs
|
|
const roofs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCROOF)
|
|
for (let i = 0; i < roofs.size(); i++) {
|
|
const roofExpressID = roofs.get(i)
|
|
if (expressIdToNodeId.has(roofExpressID)) continue
|
|
|
|
const roof = ifcApi.GetLine(modelID, roofExpressID)
|
|
const nodeId = generateId('roof')
|
|
expressIdToNodeId.set(roofExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(roofExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let polygon: [number, number][] | undefined
|
|
let elevation: number | undefined
|
|
let height: number | undefined
|
|
|
|
try {
|
|
const worldMat = roof.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, roof.ObjectPlacement.value)
|
|
: identity()
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
elevation = s[2]
|
|
|
|
const body = getBodyExtrusionData(ifcApi, modelID, roof)
|
|
if (body.depth) height = body.depth * unitFactor
|
|
|
|
const extrusionMat = getExtrusionPosition(ifcApi, modelID, roof)
|
|
|
|
if (body.profilePoints && body.profilePoints.length >= 3) {
|
|
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
|
|
polygon = body.profilePoints.map((pt) => {
|
|
const sc = worldToScene(transformPoint3(combinedMat, [pt[0], pt[1], 0]))
|
|
return [sc[0], sc[1]] as [number, number]
|
|
})
|
|
const first = polygon[0]
|
|
const last = polygon[polygon.length - 1]
|
|
if (
|
|
polygon.length > 3 &&
|
|
Math.abs(first[0] - last[0]) < 1e-6 &&
|
|
Math.abs(first[1] - last[1]) < 1e-6
|
|
) {
|
|
polygon.pop()
|
|
}
|
|
} else if (body.xDim && body.yDim) {
|
|
const hw = body.xDim / 2
|
|
const hh = body.yDim / 2
|
|
const corners: number[][] = [
|
|
[-hw, -hh, 0],
|
|
[hw, -hh, 0],
|
|
[hw, hh, 0],
|
|
[-hw, hh, 0],
|
|
]
|
|
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
|
|
polygon = corners.map((c) => {
|
|
const sc = worldToScene(transformPoint3(combinedMat, c))
|
|
return [sc[0], sc[1]] as [number, number]
|
|
})
|
|
}
|
|
} catch {
|
|
/* keep defaults */
|
|
}
|
|
|
|
const roofNode = tryParse(RoofNode, 'roof', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'roof',
|
|
name: roof.Name?.value || `Roof ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
elevation,
|
|
// TODO(ifc-fix): Pascal RoofNode is composed of roof-segments. The
|
|
// converter only has the flat polygon + height; pass them through
|
|
// metadata until we map the IFC roof onto the segment-based shape.
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCROOF',
|
|
expressID: roofExpressID,
|
|
globalId: roof.GlobalId?.value,
|
|
predefinedType: roof.PredefinedType?.value,
|
|
polygon,
|
|
height,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = roofNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
// Process columns
|
|
const columnTypes = [WebIFC.IFCCOLUMN]
|
|
try {
|
|
columnTypes.push(WebIFC.IFCCOLUMNSTANDARDCASE)
|
|
} catch {
|
|
/* not in all versions */
|
|
}
|
|
for (const colType of columnTypes) {
|
|
let cols
|
|
try {
|
|
cols = ifcApi.GetLineIDsWithType(modelID, colType)
|
|
} catch {
|
|
continue
|
|
}
|
|
for (let i = 0; i < cols.size(); i++) {
|
|
const colExpressID = cols.get(i)
|
|
if (expressIdToNodeId.has(colExpressID)) continue
|
|
|
|
const col = ifcApi.GetLine(modelID, colExpressID)
|
|
const nodeId = generateId('column')
|
|
expressIdToNodeId.set(colExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(colExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let position: [number, number, number] = [0, 0, 0]
|
|
let width: number | undefined
|
|
let depth: number | undefined
|
|
let height: number | undefined
|
|
let profileShape: 'round' | 'rectangular' | null = null
|
|
let profileRadius: number | undefined
|
|
|
|
try {
|
|
const worldMat = col.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, col.ObjectPlacement.value)
|
|
: identity()
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]]
|
|
|
|
const body = getBodyExtrusionData(ifcApi, modelID, col)
|
|
if (body.depth) height = body.depth * unitFactor
|
|
if (opts.swapProfileDimensions) {
|
|
if (body.yDim) width = body.yDim * unitFactor
|
|
if (body.xDim) depth = body.xDim * unitFactor
|
|
} else {
|
|
if (body.xDim) width = body.xDim * unitFactor
|
|
if (body.yDim) depth = body.yDim * unitFactor
|
|
}
|
|
profileShape = body.profileShape
|
|
if (body.radius != null) profileRadius = body.radius * unitFactor
|
|
} catch {
|
|
/* keep defaults */
|
|
}
|
|
|
|
// Structural IFC columns are plain shafts. The ColumnNode defaults
|
|
// are decorative (round-rings base + simple capital + a necked
|
|
// shaft → a classical/Greek look), and the default round
|
|
// cross-section ignores width/depth in favour of `radius`. Strip
|
|
// the ornament, keep the shaft full-width, and size from the IFC
|
|
// profile: use the real swept-area profile type when known
|
|
// (IfcCircleProfileDef → round + radius, IfcRectangleProfileDef →
|
|
// rectangular + width/depth), falling back to the width/depth
|
|
// ratio when the profile type isn't recognised.
|
|
const isRect =
|
|
profileShape === 'rectangular' ||
|
|
(profileShape === null &&
|
|
width !== undefined &&
|
|
depth !== undefined &&
|
|
Math.abs(width - depth) > 0.15 * Math.max(width, depth))
|
|
const columnNode = tryParse(ColumnNode, 'column', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'column',
|
|
name: col.Name?.value || `Column ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
position,
|
|
width,
|
|
depth,
|
|
height,
|
|
crossSection: isRect ? 'rectangular' : 'round',
|
|
radius: profileRadius ?? Math.max(width ?? 0.44, depth ?? 0.44) / 2,
|
|
style: 'plain',
|
|
shaftProfile: 'straight',
|
|
shaftStartScale: 1,
|
|
shaftEndScale: 1,
|
|
shaftSegmentCount: 1,
|
|
baseStyle: 'none',
|
|
capitalStyle: 'none',
|
|
baseHeight: 0,
|
|
capitalHeight: 0,
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCCOLUMN',
|
|
expressID: colExpressID,
|
|
globalId: col.GlobalId?.value,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = columnNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Beams: skipped for now — Pascal has no `beam` node type yet. When it
|
|
// lands in @pascal-app/core, restore the IFCBEAM → BeamNode mapping
|
|
// (axis polyline → start/end [x,y,z], profile XDim/YDim → width/depth,
|
|
// extrusion depth → axis length). Reference implementation lives in
|
|
// git history of this file. We still walk the entities to log how
|
|
// many beams the IFC contained so the conversion summary is accurate.
|
|
let skippedBeamCount = 0
|
|
const beamTypes = [WebIFC.IFCBEAM]
|
|
try {
|
|
beamTypes.push(WebIFC.IFCBEAMSTANDARDCASE)
|
|
} catch {
|
|
/* not in all versions */
|
|
}
|
|
for (const beamType of beamTypes) {
|
|
try {
|
|
const beams = ifcApi.GetLineIDsWithType(modelID, beamType)
|
|
skippedBeamCount += beams.size()
|
|
} catch {
|
|
/* type not present in this file */
|
|
}
|
|
}
|
|
if (skippedBeamCount > 0) {
|
|
console.warn(
|
|
`[IFC→Pascal] Skipped ${skippedBeamCount} beam${skippedBeamCount === 1 ? '' : 's'} — Pascal has no beam node yet.`,
|
|
)
|
|
}
|
|
|
|
// Items: skipped for now — Pascal's ItemNode requires a full `asset`
|
|
// (catalog reference with id/src/dimensions/etc.) that the converter
|
|
// can't synthesise from raw IFC geometry. When the editor grows a
|
|
// raw-geometry escape hatch (or we add a placeholder-asset registry),
|
|
// restore the mapping from the pre-migration git history. We still
|
|
// walk the entities to log a count for diagnostics.
|
|
let skippedItemCount = 0
|
|
const itemTypeKeys = [
|
|
WebIFC.IFCFURNISHINGELEMENT,
|
|
WebIFC.IFCBUILDINGELEMENTPROXY,
|
|
WebIFC.IFCRAILING,
|
|
WebIFC.IFCCOVERING,
|
|
WebIFC.IFCCURTAINWALL,
|
|
WebIFC.IFCPLATE,
|
|
WebIFC.IFCMEMBER,
|
|
WebIFC.IFCFOOTING,
|
|
]
|
|
for (const itemType of itemTypeKeys) {
|
|
try {
|
|
const items = ifcApi.GetLineIDsWithType(modelID, itemType)
|
|
skippedItemCount += items.size()
|
|
} catch {
|
|
/* type not present in this file */
|
|
}
|
|
}
|
|
if (skippedItemCount > 0) {
|
|
console.warn(
|
|
`[IFC→Pascal] Skipped ${skippedItemCount} item${skippedItemCount === 1 ? '' : 's'} — Pascal items require a catalog asset the converter can't synthesise yet.`,
|
|
)
|
|
}
|
|
|
|
// Post-process: resolve levelId for all element nodes
|
|
for (const node of Object.values(nodes)) {
|
|
const m = meta(node)
|
|
if (!m.expressID) continue
|
|
const storeyExpId = findStoreyForElement(m.expressID)
|
|
if (storeyExpId != null) {
|
|
m.levelId = expressIdToNodeId.get(storeyExpId) ?? undefined
|
|
}
|
|
}
|
|
|
|
// Post-process: extract property sets and materials
|
|
const elementExpressIds = new Set<number>()
|
|
const expressIdToNode = new Map<number, PascalNode>()
|
|
for (const node of Object.values(nodes)) {
|
|
const m = meta(node)
|
|
if (m.expressID != null) {
|
|
elementExpressIds.add(m.expressID)
|
|
expressIdToNode.set(m.expressID, node)
|
|
}
|
|
}
|
|
|
|
// Property sets via IFCRELDEFINESBYPROPERTIES
|
|
try {
|
|
const relDefines = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYPROPERTIES)
|
|
for (let i = 0; i < relDefines.size(); i++) {
|
|
try {
|
|
const rel = ifcApi.GetLine(modelID, relDefines.get(i))
|
|
if (!rel.RelatedObjects || !rel.RelatingPropertyDefinition?.value) continue
|
|
const psetId = rel.RelatingPropertyDefinition.value
|
|
const pset = ifcApi.GetLine(modelID, psetId)
|
|
const psetName = pset.Name?.value ?? 'Properties'
|
|
|
|
const props: Record<string, string | number | boolean> = {}
|
|
if (pset.HasProperties) {
|
|
for (const propRef of pset.HasProperties) {
|
|
try {
|
|
const prop = ifcApi.GetLine(modelID, propRef.value)
|
|
const name = prop.Name?.value
|
|
const val = prop.NominalValue?.value
|
|
if (name != null && val != null) props[name] = val
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}
|
|
if (pset.Quantities) {
|
|
for (const qRef of pset.Quantities) {
|
|
try {
|
|
const q = ifcApi.GetLine(modelID, qRef.value)
|
|
const name = q.Name?.value
|
|
const val =
|
|
q.LengthValue?.value ??
|
|
q.AreaValue?.value ??
|
|
q.VolumeValue?.value ??
|
|
q.WeightValue?.value ??
|
|
q.CountValue?.value
|
|
if (name != null && val != null) props[name] = val
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(props).length === 0) continue
|
|
|
|
for (const objRef of rel.RelatedObjects) {
|
|
const node = expressIdToNode.get(objRef.value)
|
|
if (!node) continue
|
|
const m = meta(node)
|
|
if (!m.properties) m.properties = {}
|
|
m.properties[psetName] = props
|
|
}
|
|
} catch {
|
|
/* skip rel */
|
|
}
|
|
}
|
|
} catch {
|
|
/* no property rels */
|
|
}
|
|
|
|
// Materials via IFCRELASSOCIATESMATERIAL
|
|
try {
|
|
const relMat = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL)
|
|
for (let i = 0; i < relMat.size(); i++) {
|
|
try {
|
|
const rel = ifcApi.GetLine(modelID, relMat.get(i))
|
|
if (!rel.RelatedObjects || !rel.RelatingMaterial?.value) continue
|
|
const mat = ifcApi.GetLine(modelID, rel.RelatingMaterial.value)
|
|
|
|
let materialName: string | null = null
|
|
const layers: { name: string; thickness?: number }[] = []
|
|
|
|
const extractLayers = (layersArr: any[]) => {
|
|
for (const lRef of layersArr) {
|
|
try {
|
|
const layer = ifcApi.GetLine(modelID, lRef.value)
|
|
const layerMat = layer.Material?.value
|
|
? ifcApi.GetLine(modelID, layer.Material.value)
|
|
: null
|
|
layers.push({
|
|
name: layerMat?.Name?.value ?? 'Unknown',
|
|
thickness:
|
|
layer.LayerThickness?.value != null
|
|
? layer.LayerThickness.value * unitFactor
|
|
: undefined,
|
|
})
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mat.ForLayerSet?.value) {
|
|
const layerSet = ifcApi.GetLine(modelID, mat.ForLayerSet.value)
|
|
materialName = layerSet.LayerSetName?.value ?? null
|
|
if (layerSet.MaterialLayers) extractLayers(layerSet.MaterialLayers)
|
|
} else if (mat.MaterialLayers) {
|
|
materialName = mat.LayerSetName?.value ?? null
|
|
extractLayers(mat.MaterialLayers)
|
|
} else if (mat.Name?.value) {
|
|
materialName = mat.Name.value
|
|
}
|
|
|
|
if (!materialName && layers.length === 0) continue
|
|
|
|
for (const objRef of rel.RelatedObjects) {
|
|
const node = expressIdToNode.get(objRef.value)
|
|
if (!node) continue
|
|
const m = meta(node)
|
|
if (materialName) m.material = materialName
|
|
if (layers.length > 0) m.materialLayers = layers
|
|
}
|
|
} catch {
|
|
/* skip rel */
|
|
}
|
|
}
|
|
} catch {
|
|
/* no material rels */
|
|
}
|
|
|
|
// Type names via IFCRELDEFINESBYTYPE
|
|
try {
|
|
const relDefType = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYTYPE)
|
|
for (let i = 0; i < relDefType.size(); i++) {
|
|
try {
|
|
const rel = ifcApi.GetLine(modelID, relDefType.get(i))
|
|
if (!rel.RelatedObjects || !rel.RelatingType?.value) continue
|
|
const type = ifcApi.GetLine(modelID, rel.RelatingType.value)
|
|
const typeName = type.Name?.value
|
|
if (!typeName) continue
|
|
|
|
for (const objRef of rel.RelatedObjects) {
|
|
const node = expressIdToNode.get(objRef.value)
|
|
if (node) meta(node).typeName = typeName
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
} catch {
|
|
/* no type rels */
|
|
}
|
|
|
|
progress('Simplifying converted scene...', 94)
|
|
const simplificationStats = simplifyConvertedSceneGraph(nodes, simplificationOptions)
|
|
if (
|
|
simplificationStats.removedTinyWalls > 0 ||
|
|
simplificationStats.removedMergedWalls > 0 ||
|
|
simplificationStats.removedDuplicateOpenings > 0
|
|
) {
|
|
console.log('[IFC→Pascal] Simplification:', simplificationStats)
|
|
}
|
|
|
|
ifcApi.CloseModel(modelID)
|
|
|
|
progress('Building scene graph...', 95)
|
|
|
|
const totalNodes = Object.keys(nodes).length
|
|
console.log(`[IFC→Pascal] Conversion complete! Generated ${totalNodes} nodes`)
|
|
console.log(`[IFC→Pascal] Node breakdown:`, {
|
|
sites: Object.values(nodes).filter((n) => n.type === 'site').length,
|
|
buildings: Object.values(nodes).filter((n) => n.type === 'building').length,
|
|
levels: Object.values(nodes).filter((n) => n.type === 'level').length,
|
|
walls: Object.values(nodes).filter((n) => n.type === 'wall').length,
|
|
slabs: Object.values(nodes).filter((n) => n.type === 'slab').length,
|
|
doors: Object.values(nodes).filter((n) => n.type === 'door').length,
|
|
windows: Object.values(nodes).filter((n) => n.type === 'window').length,
|
|
stairs: Object.values(nodes).filter((n) => n.type === 'stair').length,
|
|
roofs: Object.values(nodes).filter((n) => n.type === 'roof').length,
|
|
columns: Object.values(nodes).filter((n) => n.type === 'column').length,
|
|
skippedBeams: skippedBeamCount,
|
|
skippedItems: skippedItemCount,
|
|
})
|
|
|
|
progress('Complete!', 100)
|
|
|
|
return {
|
|
nodes,
|
|
rootNodeIds: rootNodeIds as AnyNodeId[],
|
|
}
|
|
}
|