300 lines
11 KiB
TypeScript

import {
type AnyNode,
type HandleDescriptor,
type NodeDefinition,
type RoofSegmentNode,
SkylightNode as SkylightNodeSchema,
type SkylightNode as SkylightNodeType,
} from '@pascal-app/core'
import { buildSkylightFloorplan } from './floorplan'
import {
closeSkylightOpenState,
isOperableSkylightNode,
toggleSkylightOpenState,
} from './interaction'
import { skylightParametrics } from './parametrics'
import { buildSkylightRoofCut } from './roof-cut'
import { SkylightNode } from './schema'
// In-world handle constants. Offsets are measured from the skylight's
// edge to the arrow's CENTER, so they need to clear half the chevron's
// own length (~13 cm at default scale) plus the frame's outward depth
// before there's any visible gap. 0.35 m leaves ~15 cm of clear space
// between the chevron's tail and the skylight frame for a typical
// flat / opening skylight; rotate gizmo gets a matching cushion.
const SIDE_HANDLE_OFFSET = 0.35
const ROTATE_CORNER_OFFSET = 0.35
// Small +X bump so the rotate gizmo reads as sitting *beside* the corner
// instead of perched on its edge — visually separates it from the
// width chevron that shares the +X side.
const ROTATE_CORNER_X_OFFSET = 0.2
// Lift the rotate gizmo a little off the surface so it floats above the
// frame instead of sinking into the curb / shingles around the corner.
const ROTATE_CORNER_Y_OFFSET = 0.18
const ROTATE_RING_OFFSET = 0.06
// Curb arrow sits a small distance above the current curb top so it stays
// grabbable when the curb collapses to zero (flat / walk-on look) and
// doesn't sink into the frame for tall lantern curbs.
const CURB_HANDLE_OFFSET = 0.18
const MIN_SKYLIGHT_DIM = 0.2
const MIN_FRAME_THICKNESS = 0.005
// Width arrows live on ±X (left / right edges of the skylight footprint).
// Asymmetric resize: each arrow only moves its own edge, the opposite
// edge stays put — same pattern as the door and the roof-segment width
// handles. `position` is in segment-surface-tangent coords, so after
// changing width we recenter `position` by half the delta along the
// skylight's own +X axis (rotated by `node.rotation` since the inner
// rotation-y group puts skylight-local X at an angle relative to
// segment-local X).
function skylightWidthHandle(side: 'left' | 'right'): HandleDescriptor<SkylightNodeType> {
const sign = side === 'right' ? 1 : -1
return {
kind: 'linear-resize',
axis: 'x',
anchor: side === 'right' ? 'min' : 'max',
min: MIN_SKYLIGHT_DIM,
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), 0, 0],
rotationY: () => (side === 'right' ? 0 : Math.PI),
},
// Grandparent so handles render inside the roof (visible) instead of
// the segment mesh, which lives inside the roof renderer's
// `<group visible={false}>` segments wrapper. See skylight/renderer
// for the matching composed-transform group that backs this.
portal: 'grandparent',
}
}
// Height arrows on ±Z. Skylight `height` is the dimension along the
// roof's slope direction (X is across-slope). Same asymmetric pattern as
// width — anchored edge stays world-fixed, position recenters by half
// delta along the skylight's own +Z axis (rotated by `node.rotation`).
function skylightHeightHandle(side: 'top' | 'bottom'): HandleDescriptor<SkylightNodeType> {
const sign = side === 'top' ? 1 : -1
return {
kind: 'linear-resize',
axis: 'z',
anchor: side === 'top' ? 'min' : 'max',
min: MIN_SKYLIGHT_DIM,
currentValue: (n) => n.height,
apply: (initial, newHeight) => {
const rotY = initial.rotation ?? 0
// Skylight-local +Z projects onto segment-surface +X / +Z as
// (sin r, cos r) — orthogonal to the (cos r, -sin r) basis used
// for the +X axis above.
const armX = Math.sin(rotY)
const armZ = Math.cos(rotY)
const anchorX = initial.position[0] - sign * (initial.height / 2) * armX
const anchorZ = initial.position[2] - sign * (initial.height / 2) * armZ
const newCenterX = anchorX + sign * (newHeight / 2) * armX
const newCenterZ = anchorZ + sign * (newHeight / 2) * armZ
return {
height: newHeight,
position: [newCenterX, initial.position[1], newCenterZ],
}
},
placement: {
position: (n) => [0, 0, sign * (n.height / 2 + SIDE_HANDLE_OFFSET)],
// The +Z chevron points along +Z by default (axis 'z' auto-rotates
// -π/2 in LinearArrow). For the -Z handle, add +π so it flips.
rotationY: () => (side === 'top' ? 0 : Math.PI),
},
portal: 'grandparent',
}
}
// Rotate gizmo at the +X+Z corner of the footprint, with a ring guide
// traced around the corner-diagonal radius — same idiom as the roof-
// segment / column / elevator rotate gizmos. Negate the cursor delta to
// match three.js Y-rotation handedness.
function skylightRotateHandle(): HandleDescriptor<SkylightNodeType> {
return {
kind: 'arc-resize',
axis: 'angular',
shape: 'rotate',
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
placement: {
position: (n) => {
const halfX = n.width / 2
const halfZ = n.height / 2
return [
halfX + ROTATE_CORNER_X_OFFSET,
ROTATE_CORNER_Y_OFFSET,
halfZ + ROTATE_CORNER_OFFSET,
]
},
rotationY: () => -Math.PI / 4,
},
decoration: {
kind: 'ring',
radius: (n) => Math.hypot(n.width / 2, n.height / 2) + ROTATE_RING_OFFSET,
y: () => 0,
},
portal: 'grandparent',
}
}
// Curb-height arrow — vertical chevron above the skylight, dragged up
// to raise the curb. Auto-enables `curb: true` when the user grows from
// zero so the geometry actually has a curb to show; preserves `curb`
// flag once it's been set. Placement floats just above the curb top so
// it tracks the value as the user drags and stays above the frame for
// tall lantern curbs.
function skylightCurbHeightHandle(): HandleDescriptor<SkylightNodeType> {
return {
kind: 'linear-resize',
axis: 'y',
anchor: 'min',
min: 0,
currentValue: (n) => n.curbHeight ?? 0,
apply: (initial, newValue) => ({
curbHeight: newValue,
// Without this, dragging the arrow on a walk-on (curb: false)
// skylight would change `curbHeight` invisibly because the
// geometry path ignores it when `curb` is false.
curb: initial.curb || newValue > 0,
}),
placement: {
position: (n) => [0, (n.curbHeight ?? 0) + CURB_HANDLE_OFFSET, 0],
},
portal: 'grandparent',
}
}
// Frame-thickness arrow — diagonal chevron at the -X+Z (top-left) corner,
// pointing outward along the corner bisector. Lives on the opposite
// corner from the rotate gizmo so they don't compete. axis='z' (instead
// of 'x') so dragging outward toward +Z grows the value 1:1; LinearArrow
// auto-rotates a 'z'-axis chevron by -π/2 (so it points +Z), and the
// extra -π/4 here swings the chevron to -X+Z. The X+Z offsets match
// SIDE_HANDLE_OFFSET on each axis, so the arrow sits the same visual
// distance from the corner that the side chevrons sit from their edges.
function skylightFrameThicknessHandle(): HandleDescriptor<SkylightNodeType> {
return {
kind: 'linear-resize',
axis: 'z',
anchor: 'min',
min: MIN_FRAME_THICKNESS,
currentValue: (n) => n.frameThickness ?? 0.05,
apply: (_n, newValue) => ({ frameThickness: newValue }),
placement: {
position: (n) => [-(n.width / 2) - SIDE_HANDLE_OFFSET, 0, n.height / 2 + SIDE_HANDLE_OFFSET],
rotationY: () => -Math.PI / 4,
},
portal: 'grandparent',
}
}
const skylightHandles: HandleDescriptor<SkylightNodeType>[] = [
skylightWidthHandle('right'),
skylightWidthHandle('left'),
skylightHeightHandle('top'),
skylightHeightHandle('bottom'),
skylightCurbHeightHandle(),
skylightFrameThicknessHandle(),
skylightRotateHandle(),
]
/**
* Skylight — a framed glass opening hosted on a roof segment. All five
* type variants (flat / walk-on / lantern / opening / sliding) render
* with the archive's full geometry; the animation system advances
* `operationState` via `useInteractive.skylightAnimations`.
*/
export const skylightDefinition: NodeDefinition<typeof SkylightNode> = {
kind: 'skylight',
schemaVersion: 1,
schema: SkylightNode,
category: 'structure',
surfaceRole: 'glazing',
defaults: () => {
const stub = SkylightNodeSchema.parse({
id: 'skylight_default' as never,
type: 'skylight',
})
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
// Mounts on a roof segment via `roofSegmentId`. Dirty marks
// cascade to the host segment's parent roof so its merged shell
// re-CSGs with the new cut. `buildCut` returns the segment-local
// box that's subtracted from shin / deck / wall.
roofAccessory: {
buildCut: (node: AnyNode, hostSegment: AnyNode) =>
buildSkylightRoofCut(node as SkylightNodeType, hostSegment as RoofSegmentNode),
},
},
parametrics: skylightParametrics,
handles: skylightHandles,
floorplan: buildSkylightFloorplan,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 3,
},
tool: () => import('./tool'),
affordanceTools: {
move: () => import('./move-tool'),
},
toolHints: [
{ key: 'Left click', label: 'Place skylight on roof' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Skylight',
description: 'Framed glass opening on a roof segment.',
icon: { kind: 'url', src: '/icons/roof.webp' },
paletteSection: 'structure',
paletteOrder: 124,
},
mcp: {
description:
'A skylight on a roof segment. Five type variants (flat / walk-on / lantern / opening / sliding) — geometry beyond box stub coming later.',
},
// R toggles open ↔ closed on operable types (opening / sliding); T
// forces close. The animation runs through `useInteractive` and the
// skylight system; see `./interaction.ts`.
keyboardActions: {
r: {
appliesTo: (node: AnyNode) =>
node.type === 'skylight' && isOperableSkylightNode(node as SkylightNodeType),
run: (node: AnyNode) => toggleSkylightOpenState(node.id),
},
t: {
appliesTo: (node: AnyNode) =>
node.type === 'skylight' && isOperableSkylightNode(node as SkylightNodeType),
run: (node: AnyNode) => closeSkylightOpenState(node.id),
},
},
}