Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/events/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
GuideNode,
GutterNode,
HvacEquipmentNode,
ImportedMeshNode,
ItemNode,
LevelNode,
LinesetNode,
Expand Down Expand Up @@ -93,6 +94,7 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
export type WallEvent = NodeEvent<WallNode>
export type FenceEvent = NodeEvent<FenceNode>
export type ItemEvent = NodeEvent<ItemNode>
export type ImportedMeshEvent = NodeEvent<ImportedMeshNode>
export type SiteEvent = NodeEvent<SiteNode>
export type BuildingEvent = NodeEvent<BuildingNode>
export type CabinetEvent = NodeEvent<CabinetNode>
Expand Down Expand Up @@ -296,6 +298,7 @@ type EditorEvents = GridEvents &
NodeEvents<'cabinet', CabinetEvent> &
NodeEvents<'cabinet-module', CabinetModuleEvent> &
NodeEvents<'item', ItemEvent> &
NodeEvents<'imported-mesh', ImportedMeshEvent> &
NodeEvents<'site', SiteEvent> &
NodeEvents<'building', BuildingEvent> &
NodeEvents<'elevator', ElevatorEvent> &
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type {
GridEvent,
GuideEvent,
GutterEvent,
ImportedMeshEvent,
ItemEvent,
LevelEvent,
MeasurementEvent,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode, GuideScaleReference } from './nodes/guide'
export { GutterNode, GutterOutlet } from './nodes/gutter'
export { HvacEquipmentNode } from './nodes/hvac-equipment'
export {
ImportedMeshNode,
ImportedMeshPrimitive,
type ImportedMeshPrimitive as ImportedMeshPrimitiveValue,
} from './nodes/imported-mesh'
export type {
AnimationEffect,
Asset,
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/schema/nodes/imported-mesh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'

const finiteNumber = z.number().finite()
const vector3Array = z
.array(finiteNumber)
.refine((values) => values.length % 3 === 0, 'Expected XYZ triples')

export const ImportedMeshPrimitive = z.object({
positions: vector3Array,
normals: vector3Array.optional(),
indices: z.array(z.number().int().nonnegative()).default([]),
color: z.string().default('#94a3b8'),
opacity: z.number().min(0).max(1).default(1),
})

export type ImportedMeshPrimitive = z.infer<typeof ImportedMeshPrimitive>

/** Triangle geometry retained when an import has no native Pascal shape. */
export const ImportedMeshNode = BaseNode.extend({
id: objectId('imesh'),
type: nodeType('imported-mesh'),
position: z.tuple([finiteNumber, finiteNumber, finiteNumber]).default([0, 0, 0]),
rotation: z.tuple([finiteNumber, finiteNumber, finiteNumber]).default([0, 0, 0]),
primitives: z.array(ImportedMeshPrimitive).default([]),
}).describe(
dedent`
Imported mesh node - triangle geometry preserved from an external model
- position / rotation: transform in the parent coordinate system
- primitives: indexed triangle buffers in meters with source display colors
- metadata: source-format identity and properties
`,
)

export type ImportedMeshNode = z.infer<typeof ImportedMeshNode>
2 changes: 2 additions & 0 deletions packages/core/src/schema/nodes/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { DuctTerminalNode } from './duct-terminal'
import type { FenceNode } from './fence'
import type { GuideNode } from './guide'
import type { HvacEquipmentNode } from './hvac-equipment'
import type { ImportedMeshNode } from './imported-mesh'
import type { ItemNode } from './item'
import type { LinesetNode } from './lineset'
import type { LiquidLineNode } from './liquid-line'
Expand All @@ -34,6 +35,7 @@ type CoreLevelChildId =
| ConstructionDimensionNode['id']
| StructuralGridNode['id']
| ItemNode['id']
| ImportedMeshNode['id']
| ZoneNode['id']
| SlabNode['id']
| CeilingNode['id']
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { FenceNode } from './nodes/fence'
import { GuideNode } from './nodes/guide'
import { GutterNode } from './nodes/gutter'
import { HvacEquipmentNode } from './nodes/hvac-equipment'
import { ImportedMeshNode } from './nodes/imported-mesh'
import { ItemNode } from './nodes/item'
import { LevelNode } from './nodes/level'
import { LinesetNode } from './nodes/lineset'
Expand Down Expand Up @@ -59,6 +60,7 @@ export const AnyNode = z.discriminatedUnion('type', [
CabinetNode,
CabinetModuleNode,
ItemNode,
ImportedMeshNode,
ZoneNode,
SlabNode,
CeilingNode,
Expand Down
15 changes: 15 additions & 0 deletions packages/ifc-converter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,18 @@ IFC bytes, returns `{ nodes, rootNodeIds, stats }` shaped against
`@pascal-app/core` schemas.

No DOM, no React. The UI lives in `apps/ifc-converter`.

Native Pascal nodes are produced for sites, buildings, levels, walls, doors,
windows, slabs, stairs, roofs, columns, and IFC spaces (as room zones). Beams,
railings, coverings, furnishings, proxies, curtain walls, plates, members,
footings, and native wall/slab shapes that cannot be parameterized are retained
as selectable `imported-mesh` nodes using their IFC triangle geometry and color.
Imported meshes are import-only and do not appear as empty objects in the editor
palette.

Door families are derived from `IfcDoor.OperationType`. Glazing is applied from
the standardized `Pset_DoorCommon.GlazingAreaFraction` property rather than
from element names or project-specific conventions.

Browser callers use the default WebIFC WASM path (`/`). Node callers can pass
`{ wasmPath: '/absolute/path/to/web-ifc/' }` in `ConversionOptions`.
16 changes: 15 additions & 1 deletion packages/ifc-converter/src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,24 @@ function toWallSegment(wall: WallNode): WallSegment | null {
}

function wallLineTolerance(a: WallSegment, b: WallSegment) {
return Math.max(0.06, Math.min(0.14, Math.max(a.thickness, b.thickness) * 0.5))
// Wall fragments must share essentially the same centerline. A tolerance based
// on half the wall thickness can collapse adjacent, parallel walls whose faces
// happen to meet (for example, a 4-inch exterior wall and an interior return
// offset by 2 inches). Keep a small allowance for IFC modelling noise instead.
return Math.max(0.005, Math.min(0.025, Math.max(a.thickness, b.thickness) * 0.1))
}

function wallHeightCompatible(a: WallSegment, b: WallSegment) {
return Math.abs(a.height - b.height) <= WALL_HEIGHT_TOLERANCE
}

function wallMaterialCompatible(a: WallSegment, b: WallSegment) {
const materialA = (a.wall.metadata as { material?: unknown } | undefined)?.material
const materialB = (b.wall.metadata as { material?: unknown } | undefined)?.material
if (typeof materialA !== 'string' || typeof materialB !== 'string') return true
return materialA === materialB

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing material allows wall merge

Medium Severity

wallMaterialCompatible treats a missing metadata.material on either wall as compatible and allows merge. Collinear exterior and interior fragments can combine when only one side received an IFC material association, undoing the intent of the new material guard.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3449f30. Configure here.

}

function wallIntervalsCompatible(a: WallSegment, b: WallSegment, maxJoinGap: number) {
const gap = Math.max(a.t0, b.t0) - Math.min(a.t1, b.t1)
if (gap <= maxJoinGap) return true
Expand All @@ -220,6 +231,9 @@ function wallsCanMerge(a: WallSegment, b: WallSegment, maxJoinGap: number) {
if (Math.abs(a.angleBucket - b.angleBucket) > 1) return false
if (Math.abs(a.offset - b.offset) > wallLineTolerance(a, b)) return false
if (!wallHeightCompatible(a, b)) return false
// Collinear walls can still represent different assemblies. Merging across
// that semantic boundary can bridge a real doorway or open connection.
if (!wallMaterialCompatible(a, b)) return false
return wallIntervalsCompatible(a, b, maxJoinGap)
}

Expand Down
65 changes: 65 additions & 0 deletions packages/ifc-converter/src/door-semantics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { DoorNode } from '@pascal-app/core'

type DoorStyle = Partial<DoorNode>

const glazedSegments: DoorNode['segments'] = [
{
type: 'glass',
heightRatio: 1,
columnRatios: [1],
dividerThickness: 0.025,
panelDepth: 0.008,
panelInset: 0.035,
},
]

/** Map standardized IfcDoor operation values to Pascal door families. */
export function doorStyleFromIfcOperation(operationType: unknown): DoorStyle {
const operation = String(operationType ?? '').toUpperCase()
const leafCount = operation.startsWith('DOUBLE_DOOR') ? 2 : 1

if (operation.includes('SLIDING')) {
return {
doorType: 'sliding',
leafCount,
slideDirection: operation.includes('RIGHT') ? 'right' : 'left',
trackStyle: 'visible',
threshold: false,
}
}

if (operation.includes('FOLDING')) {
return {
doorType: 'folding',
leafCount: 2,
}
}

if (operation === 'ROLLINGUP') {
return {
doorCategory: 'garage',
doorType: 'garage-rollup',
trackStyle: 'overhead',
}
}

if (operation.startsWith('DOUBLE_DOOR')) {
return { doorType: 'double', leafCount: 2 }
}

if (operation.includes('SWING_RIGHT')) return { hingesSide: 'right' }
if (operation.includes('SWING_LEFT')) return { hingesSide: 'left' }
return {}
}

/** Apply the standardized Pset_DoorCommon glazing fraction after psets load. */
export function doorGlazingStyle(door: DoorNode, glazingAreaFraction: unknown): DoorStyle {
const fraction = Number(glazingAreaFraction)
if (!Number.isFinite(fraction) || fraction <= 0) return {}

return {
doorType: door.doorType === 'double' ? 'french' : door.doorType,
segments: glazedSegments,
contentPadding: [0.04, 0.05],
}
}
Loading