Files
editor/packages/nodes/src/roof/definition.ts
T
Wassim SAMADandClaude Opus 4.8 267dac7b1a perf(editor): convert UI icons from PNG to WebP
The editor's /icons assets were ~10MB of oversized PNGs (a single
toolbar icon up to 1.4MB). Convert every non-PWA icon to WebP (quality
92) and repoint all /icons/*.png references to .webp across
packages/editor, packages/nodes, and apps/editor. PWA/platform icons
(apple-touch-icon, icon-192, icon-512) stay PNG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:41:50 -04:00

191 lines
6.6 KiB
TypeScript

import {
type AnyNodeId,
type HandleDescriptor,
type NodeDefinition,
RoofNode as RoofNodeSchema,
type RoofNode as RoofNodeType,
type RoofSegmentNode,
type SceneApi,
} from '@pascal-app/core'
import { buildRoofFloorplan } from './floorplan'
import { roofParametrics } from './parametrics'
import { RoofNode } from './schema'
const MOVE_FRONT_OFFSET = 0.35
const MIN_ROOF_FOOTPRINT = 1
type RoofFootprintBounds = {
maxX: number
maxZ: number
minX: number
minZ: number
}
function getRoofFootprintBounds(node: RoofNodeType, sceneApi: SceneApi): RoofFootprintBounds {
let bounds: RoofFootprintBounds | null = null
for (const childId of node.children ?? []) {
const segment = sceneApi.get<RoofSegmentNode>(childId as AnyNodeId)
if (segment?.type !== 'roof-segment') continue
const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2
const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2
const cos = Math.cos(segment.rotation ?? 0)
const sin = Math.sin(segment.rotation ?? 0)
const corners = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
] as const
for (const [x, z] of corners) {
const localX = segment.position[0] + x * cos + z * sin
const localZ = segment.position[2] - x * sin + z * cos
bounds =
bounds === null
? { maxX: localX, maxZ: localZ, minX: localX, minZ: localZ }
: {
maxX: Math.max(bounds.maxX, localX),
maxZ: Math.max(bounds.maxZ, localZ),
minX: Math.min(bounds.minX, localX),
minZ: Math.min(bounds.minZ, localZ),
}
}
}
return bounds ?? { maxX: 0.5, maxZ: 0.5, minX: -0.5, minZ: -0.5 }
}
function roofMoveHandle(): HandleDescriptor<RoofNodeType> {
return {
kind: 'translate',
placement: {
position: (node, sceneApi) => {
const bounds = getRoofFootprintBounds(node, sceneApi)
return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + MOVE_FRONT_OFFSET]
},
},
apply: (_node, position) => ({ position: [position[0], position[1], position[2]] }),
snapExtents: (node, sceneApi) => {
const bounds = getRoofFootprintBounds(node, sceneApi)
const width = Math.max(bounds.maxX - bounds.minX, MIN_ROOF_FOOTPRINT)
const depth = Math.max(bounds.maxZ - bounds.minZ, MIN_ROOF_FOOTPRINT)
const swap = Math.abs(Math.sin(node.rotation ?? 0)) > 0.9
return [swap ? depth : width, swap ? width : depth]
},
}
}
const roofHandles: HandleDescriptor<RoofNodeType>[] = [roofMoveHandle()]
/**
* Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
* + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` +
* CSG). Inspector / move stay legacy until Stage B-E. `floorplan` draws
* the merged silhouette (union of the child segments' footprints), so a
* multi-segment roof reads as one combined shape rather than stacked
* rectangles.
*
* Roof is a "composite" node — it has `roof-segment` children that
* own per-segment geometry. The parent roof handles overall framing;
* each segment is its own registered kind (see `roof-segment`).
*/
export const roofDefinition: NodeDefinition<typeof RoofNode> = {
kind: 'roof',
schemaVersion: 1,
schema: RoofNode,
category: 'structure',
surfaceRole: 'roof',
defaults: () => {
const stub = RoofNodeSchema.parse({ id: 'roof_default' as never, type: 'roof' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
// Contribute a plan AABB to the alignment-guide candidate pool so a roof
// (and any moving sibling) snaps against the roof's outer silhouette.
// Roof has no centred-box footprint — it's the union of its
// `roof-segment` children — so we hand the bridge a resolved `aabb`
// directly. The roof moves by its origin via `move-roof-tool`, so it
// only ever contributes static candidates; the relocatable-box path
// never needs to apply to roofs.
alignmentFootprint: (node, nodes) => {
const roof = node as RoofNodeType
if (!nodes) return null
const cos = Math.cos(roof.rotation ?? 0)
const sin = Math.sin(roof.rotation ?? 0)
let minX = Number.POSITIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
let any = false
for (const childId of roof.children ?? []) {
const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
if (segment?.type !== 'roof-segment') continue
const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2
const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2
const sCos = Math.cos(segment.rotation ?? 0)
const sSin = Math.sin(segment.rotation ?? 0)
for (const [cx, cz] of [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
] as const) {
// Segment corner → roof-local.
const rx = segment.position[0] + cx * sCos + cz * sSin
const rz = segment.position[2] - cx * sSin + cz * sCos
// Roof-local → world (apply roof rotation, then position).
const wx = roof.position[0] + rx * cos + rz * sin
const wz = roof.position[2] - rx * sin + rz * cos
if (wx < minX) minX = wx
if (wx > maxX) maxX = wx
if (wz < minZ) minZ = wz
if (wz > maxZ) maxZ = wz
any = true
}
}
return any ? { shape: 'aabb', minX, minZ, maxX, maxZ } : null
},
},
// Bespoke free-floating move (drag-to-place with R/T rotation and
// wall/fence snapping). Routes through `MoveTool`'s registry-affordance
// lookup — no hardcoded dispatcher arm. Shared with roof-segment / stair
// / stair-segment via `shared/move-roof-tool`.
affordanceTools: {
move: () => import('../shared/move-roof-tool'),
},
parametrics: roofParametrics,
handles: roofHandles,
floorplan: buildRoofFloorplan,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 3,
},
presentation: {
label: 'Roof',
description: 'A pitched / hip / gable roof composed of one or more segments.',
icon: { kind: 'url', src: '/icons/roof.webp' },
paletteSection: 'structure',
paletteOrder: 100,
},
mcp: {
description: 'A roof composed of segmented planes (gable / hip / shed).',
},
}