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 { 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 // `` 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 { 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 { 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 { 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 { 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[] = [ 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 = { 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), }, }, }