Files
editor/packages/nodes/src/roof-segment/definition.ts
T

325 lines
13 KiB
TypeScript

import {
getActiveRoofHeight,
getPitchFromActiveRoofHeight,
type HandleDescriptor,
type NodeDefinition,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofSegmentNode as RoofSegmentNodeType,
} from '@pascal-app/core'
import { buildRoofSegmentFloorplan } from './floorplan'
import {
roofSegmentMoveTarget,
roofSegmentResizeAffordance,
roofSegmentRotateAffordance,
} from './floorplan-affordances'
import { roofSegmentParametrics } from './parametrics'
import { RoofSegmentNode } from './schema'
const SIDE_HANDLE_OFFSET = 0.3
const HEIGHT_HANDLE_OFFSET = 0.3
const ROTATE_CORNER_OFFSET = 0.4
const ROTATE_RING_OFFSET = 0.08
const MIN_ROOF_DIM = 1
const MIN_WALL_HEIGHT = 0
// Clamp used for handle Y placement so arrows stay visible on flat /
// wall-less segments where `wallHeight ≈ 0` would put them on the floor.
const MIN_WALL_DISPLAY = 0.3
// Pitch is stored in degrees on the schema; same clamp the panel applies.
const MIN_PITCH = 0
const MAX_PITCH = 85
// Floor-to-peak height of the assembled segment. Pitch drag drives this
// value directly and back-solves the pitch angle via the slope-frame
// math in core.
function getPeakHeight(n: RoofSegmentNodeType): number {
return n.wallHeight + getActiveRoofHeight(n)
}
// Width arrow on the +X (right) or -X (left) side. Asymmetric resize:
// dragging one arrow grows the segment outward from its own edge while
// the opposite edge stays world-fixed — the same pattern doors use
// (`door/definition.ts:35-73`). The arrow's chevron points outward
// (`rotationY: Math.PI` flips the left arrow's chevron to face -X) so
// you read "this edge is what moves" at a glance.
//
// `apply` recomputes `position` so the anchored edge stays at the same
// world point even when the segment is Y-rotated: project the segment's
// local +X onto world via (cos r, -sin r), find the anchored edge's
// world XZ from the pre-drag node, then place the new center half a
// new-width away from that anchor in the same direction.
function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor<RoofSegmentNodeType> {
const sign = side === 'right' ? 1 : -1
return {
kind: 'linear-resize',
axis: 'x',
// 'min' = -X edge anchored (right arrow grows the +X edge outward).
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
anchor: side === 'right' ? 'min' : 'max',
min: MIN_ROOF_DIM,
gridSnap: true,
currentValue: (n) => n.width,
apply: (initial, newWidth) => {
const rotY = initial.rotation ?? 0
const armX = Math.cos(rotY)
const armZ = -Math.sin(rotY)
const anchorX = initial.position[0] - sign * (initial.width / 2) * armX
const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ
const newCenterX = anchorX + sign * (newWidth / 2) * armX
const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ
return {
width: newWidth,
position: [newCenterX, initial.position[1], newCenterZ],
}
},
placement: {
position: (n) => [
sign * (n.width / 2 + SIDE_HANDLE_OFFSET),
Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
0,
],
// Flip the left chevron so it points outward toward -X. The
// generic LinearArrow only auto-orients for axis 'z' (rotates the
// chevron 90° to face +Z); +X / -X facing is up to the descriptor.
rotationY: () => (side === 'right' ? 0 : Math.PI),
},
}
}
// Depth arrow on the +Z (front) or -Z (back) side. Asymmetric: the
// dragged edge follows the pointer, the opposite edge stays world-fixed
// — mirrors the width-handle pattern (`roofSegmentWidthHandle`). Because
// segment depth feeds the pitch math via `getActiveRoofHeight`, growing
// depth at constant pitch ramps the peak up too, which reads as
// scaling. We hold the peak height constant by back-solving a new pitch
// for the new depth (same recipe the pitch handle uses, run in
// reverse). MIN/MAX_PITCH clamps cover degenerate cases where the new
// depth would demand a negative or beyond-vertical pitch.
function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor<RoofSegmentNodeType> {
const sign = side === 'front' ? 1 : -1
return {
kind: 'linear-resize',
axis: 'z',
anchor: side === 'front' ? 'min' : 'max',
min: MIN_ROOF_DIM,
gridSnap: true,
currentValue: (n) => n.depth,
apply: (initial, newDepth) => {
// Recenter so the anchored Z edge stays at the same world point.
// Same math as the width handle but along the Z arm: yaw maps
// segment-local +Z to (sin r, cos r) in world.
const rotY = initial.rotation ?? 0
const armX = Math.sin(rotY)
const armZ = Math.cos(rotY)
const anchorX = initial.position[0] - sign * (initial.depth / 2) * armX
const anchorZ = initial.position[2] - sign * (initial.depth / 2) * armZ
const newCenterX = anchorX + sign * (newDepth / 2) * armX
const newCenterZ = anchorZ + sign * (newDepth / 2) * armZ
// Preserve peak height — back-solve pitch for the new depth so
// the assembled roof height matches what it was before the drag.
const originalRoofHeight = getActiveRoofHeight(initial)
const newPitch = getPitchFromActiveRoofHeight({
roofType: initial.roofType,
width: initial.width,
depth: newDepth,
roofHeight: originalRoofHeight,
gambrelLowerWidthRatio: initial.gambrelLowerWidthRatio,
gambrelLowerHeightRatio: initial.gambrelLowerHeightRatio,
mansardSteepWidthRatio: initial.mansardSteepWidthRatio,
mansardSteepHeightRatio: initial.mansardSteepHeightRatio,
dutchHipWidthRatio: initial.dutchHipWidthRatio,
dutchHipHeightRatio: initial.dutchHipHeightRatio,
})
return {
depth: newDepth,
position: [newCenterX, initial.position[1], newCenterZ],
pitch: Math.max(MIN_PITCH, Math.min(MAX_PITCH, newPitch)),
}
},
placement: {
position: (n) => [
0,
Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
sign * (n.depth / 2 + SIDE_HANDLE_OFFSET),
],
// For axis 'z', `LinearArrow` adds -π/2 around Y so the chevron
// points +Z by default. Flip the back arrow by π so it points -Z.
rotationY: () => (side === 'front' ? 0 : Math.PI),
},
}
}
// Wall-height tracker — dashed vertical leader from the floor up to a
// draggable cube at the wall top, centred on the footprint. Replaces
// the old -X-side chevron so the wall-top control reads as "the wall is
// THIS tall" instead of "there's an arrow on the side." Drag math is
// unchanged: same linear-resize axis='y' / anchor='min' pipeline as
// every other height handle; the `shape: 'tracker'` flag only swaps the
// visual. Wall-height clamps to MIN_WALL_DISPLAY for placement so the
// cube stays grabbable on flat / wall-less segments where the real
// `wallHeight` is ~0 and the leader would collapse to nothing.
function roofSegmentWallHeightHandle(): HandleDescriptor<RoofSegmentNodeType> {
return {
kind: 'linear-resize',
axis: 'y',
anchor: 'min',
shape: 'tracker',
min: MIN_WALL_HEIGHT,
currentValue: (n) => n.wallHeight,
apply: (_n, newValue) => ({ wallHeight: newValue }),
placement: {
position: (n) => [0, Math.max(n.wallHeight, MIN_WALL_DISPLAY), 0],
},
}
}
// Pitch arrow — drag the peak vertically to steepen / flatten the roof.
// The handle exposes the floor-to-peak height as its currentValue so the
// drag delta is a meters value the user can read in the dimension chip;
// `apply` inverts the slope-frame math (run = primary-slope footprint
// span, rise fraction depends on roofType) to recover the pitch degrees
// the new peak corresponds to. Clamped to the schema range [0, 85].
//
// Placed at the peak's center so it visually attaches to the ridge for
// gable / hip / dutch / mansard / gambrel; on shed roofs the geometric
// peak sits at one edge, so the arrow floats slightly inboard of the
// ridge — acceptable as a "peak-height" affordance and matches the
// floorplan-center origin every other handle uses.
function roofSegmentPitchHandle(): HandleDescriptor<RoofSegmentNodeType> {
return {
kind: 'linear-resize',
axis: 'y',
anchor: 'min',
min: (n) => n.wallHeight,
currentValue: (n) => getPeakHeight(n),
apply: (initial, newPeakHeight) => {
const roofHeight = Math.max(0, newPeakHeight - initial.wallHeight)
const pitch = getPitchFromActiveRoofHeight({
roofType: initial.roofType,
width: initial.width,
depth: initial.depth,
roofHeight,
gambrelLowerWidthRatio: initial.gambrelLowerWidthRatio,
gambrelLowerHeightRatio: initial.gambrelLowerHeightRatio,
mansardSteepWidthRatio: initial.mansardSteepWidthRatio,
mansardSteepHeightRatio: initial.mansardSteepHeightRatio,
dutchHipWidthRatio: initial.dutchHipWidthRatio,
dutchHipHeightRatio: initial.dutchHipHeightRatio,
})
return { pitch: Math.max(MIN_PITCH, Math.min(MAX_PITCH, pitch)) }
},
placement: {
position: (n) => [0, getPeakHeight(n) + HEIGHT_HANDLE_OFFSET, 0],
},
}
}
// Whole-segment rotation gizmo — curved two-headed arrow at the +X / +Z
// corner of the footprint, guide ring traces the corner-diagonal radius
// on hover / drag. Same pattern as the elevator / column rotate gizmo;
// roof-segment stores rotation as a scalar (radians) so the apply patch
// just writes back the new scalar.
function roofSegmentRotateHandle(): HandleDescriptor<RoofSegmentNodeType> {
return {
kind: 'arc-resize',
axis: 'angular',
shape: 'rotate',
// Negate the cursor delta to match three.js Y-rotation handedness
// (cursor atan2 ticks opposite-handed from `rotation-y`).
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
placement: {
position: (n) => {
const halfX = n.width / 2
const halfZ = n.depth / 2
const yMid = Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2
return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET]
},
rotationY: () => -Math.PI / 4,
},
decoration: {
kind: 'ring',
radius: (n) => Math.hypot(n.width / 2, n.depth / 2) + ROTATE_RING_OFFSET,
y: (n) => Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
},
}
}
const roofSegmentHandles: HandleDescriptor<RoofSegmentNodeType>[] = [
roofSegmentWidthHandle('right'),
roofSegmentWidthHandle('left'),
roofSegmentDepthHandle('front'),
roofSegmentDepthHandle('back'),
roofSegmentWallHeightHandle(),
roofSegmentPitchHandle(),
roofSegmentRotateHandle(),
]
/**
* Roof segment — Stage A. Child of a roof node, owns the per-segment
* polygon + pitch. Geometry is generated by `RoofSystem` (registered
* under the parent roof's `def.system`), so the segment kind itself
* only needs a renderer wrap.
*/
export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
kind: 'roof-segment',
schemaVersion: 1,
schema: RoofSegmentNode,
category: 'structure',
surfaceRole: 'roof',
defaults: () => {
const stub = RoofSegmentNodeSchema.parse({
id: 'roof-segment_default' as never,
type: 'roof-segment',
})
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
// Bespoke move shared with roof / stair / stair-segment via
// `shared/move-roof-tool` — routed through `MoveTool`'s registry-
// affordance lookup rather than a hardcoded dispatcher arm.
affordanceTools: {
move: () => import('../shared/move-roof-tool'),
},
parametrics: roofSegmentParametrics,
handles: roofSegmentHandles,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
floorplan: buildRoofSegmentFloorplan,
// Body-move target. The generic Path 2 fallback writes plan coords
// directly to `position`, which is wrong here because the segment's
// position is roof-local. `roofSegmentMoveTarget` inverts the parent
// roof's transform so the segment lands at the world-plan cursor.
floorplanMoveTarget: roofSegmentMoveTarget,
// 2D drag affordances for the side resize arrows + corner rotate
// arrow emitted by `buildRoofSegmentFloorplan`.
floorplanAffordances: {
'roof-segment-resize': roofSegmentResizeAffordance,
'roof-segment-rotate': roofSegmentRotateAffordance,
},
presentation: {
label: 'Roof Segment',
description: 'A single pitched plane of a parent roof.',
icon: { kind: 'url', src: '/icons/roof.png' },
paletteSection: 'structure',
paletteOrder: 101,
},
mcp: {
description: 'A single roof segment with polygon footprint + pitch.',
},
}