* feat(editor): shelf placement validity box + sync tool for positioned presets Give the generic move tool a green/red footprint box for shelf placement, matching the GLB item cursor, and drop the vertical-arrow CursorSphere for shelves. Box colour comes from canPlaceOnFloor; an invalid (red) drop is refused unless Shift forces it, and R/T play the rotate sfx. Re-sync the box transform on node change so a re-armed clone isn't left at the previous rotation/position. Extract the box wireframe geometry helpers into a shared placement-box-geometry module reused by the item coordinator and the new declarative PlacementBox component. Export the Tool type so host apps can set the active tool for a positioned preset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: apply biome formatting + ignore generated next-env.d.ts Format files that predate the current biome config (lineWidth 100, single quotes, semicolons as-needed) so they stop showing as dirty on every checkout. Formatting only — no behavior change. Also exclude **/next-env.d.ts from biome: Next regenerates it (double quotes + semicolon) on every build, so biome kept reformatting it into a perpetual dirty diff. Ignoring it lets Next own the file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
194 lines
6.9 KiB
TypeScript
194 lines
6.9 KiB
TypeScript
import {
|
||
GutterNode as GutterNodeSchema,
|
||
type GutterNode as GutterNodeType,
|
||
type HandleDescriptor,
|
||
type NodeDefinition,
|
||
} from '@pascal-app/core'
|
||
import { buildGutterFloorplan } from './floorplan'
|
||
import { snapLengthToCorner } from './length-snap'
|
||
import { gutterParametrics } from './parametrics'
|
||
import { GutterNode } from './schema'
|
||
|
||
// Edge-to-arrow-center offset, matching the box-vent / ridge-vent
|
||
// cadence so a roof's worth of accessories all read at the same scale.
|
||
const SIDE_HANDLE_OFFSET = 0.2
|
||
// Gutter chevrons sit BELOW the gutter (the trough hangs below the
|
||
// eave); the Y handle places its arrow under the cross-section apex.
|
||
const SIZE_HANDLE_OFFSET = 0.15
|
||
// Minimums — well below the inspector defaults (2.0 m length, 0.13 m
|
||
// profile) so users can shrink freely without locking.
|
||
const MIN_LENGTH = 0.2
|
||
const MIN_SIZE = 0.05
|
||
|
||
// Centre of the gutter cross-section in vertical (Y) terms. The gutter
|
||
// hangs from the eave (Y=0 in vent-mesh-local) down to Y=-size; chevrons
|
||
// that want to read "beside the body" sit at -size/2.
|
||
function getBodyMidY(n: GutterNodeType): number {
|
||
return -Math.max(MIN_SIZE, n.size) / 2
|
||
}
|
||
|
||
// Outward Z midpoint — the gutter's back wall sits at Z=0 and the rim
|
||
// hangs out to Z≈+size (k-style) / +size (half-round / box). Side
|
||
// handles place at Z=0 so they sit ABOVE the eave's fascia line.
|
||
function getRimZ(n: GutterNodeType): number {
|
||
return Math.max(MIN_SIZE, n.size) / 2
|
||
}
|
||
|
||
// Length arrow on ±X (the eave direction). Asymmetric resize: drag one
|
||
// end outward while the opposite end stays world-fixed by recentering
|
||
// `position` along the gutter's own +X arm in segment frame. Same
|
||
// yaw-aware projection as the box-vent / ridge-vent / chimney width
|
||
// handles.
|
||
//
|
||
// Corner snap: when the dragged endpoint nears the geometric corner it
|
||
// would form with another gutter (the crossing of their length axes),
|
||
// `snapLengthToCorner` overrides the raw newLength so the endpoint lands
|
||
// EXACTLY on that corner — the corner-mitre detector then fires reliably
|
||
// without pixel-perfect dragging. Only this gutter's length changes.
|
||
function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor<GutterNodeType> {
|
||
const sign = side === 'right' ? 1 : -1
|
||
return {
|
||
kind: 'linear-resize',
|
||
axis: 'x',
|
||
anchor: side === 'right' ? 'min' : 'max',
|
||
min: MIN_LENGTH,
|
||
currentValue: (n) => n.length,
|
||
apply: (initial, newLength, sceneApi) => {
|
||
const rotY = initial.rotation ?? 0
|
||
const armX = Math.cos(rotY)
|
||
const armZ = -Math.sin(rotY)
|
||
const anchorX = initial.position[0] - sign * (initial.length / 2) * armX
|
||
const anchorZ = initial.position[2] - sign * (initial.length / 2) * armZ
|
||
const snap = snapLengthToCorner(
|
||
initial,
|
||
newLength,
|
||
sign,
|
||
anchorX,
|
||
anchorZ,
|
||
armX,
|
||
armZ,
|
||
MIN_LENGTH,
|
||
sceneApi,
|
||
)
|
||
// Only the dragged gutter's own length is snapped — `snapLengthToCorner`
|
||
// never moves the corner-mate, so dragging one gutter can't reset
|
||
// another the user placed deliberately.
|
||
const newCenterX = anchorX + sign * (snap.length / 2) * armX
|
||
const newCenterZ = anchorZ + sign * (snap.length / 2) * armZ
|
||
return {
|
||
length: snap.length,
|
||
position: [newCenterX, initial.position[1], newCenterZ],
|
||
}
|
||
},
|
||
placement: {
|
||
position: (n) => [sign * (n.length / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), getRimZ(n)],
|
||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||
},
|
||
}
|
||
}
|
||
|
||
// Profile-size arrow below the rim. axis='y', anchor='max' pins the
|
||
// top of the trough (Y=0, the eave line) and grows the bottom edge
|
||
// downward as the user drags toward -Y. Plain chevron — at typical
|
||
// sizes (5″–6″) a dashed tracker would clutter the eave line.
|
||
function gutterSizeHandle(): HandleDescriptor<GutterNodeType> {
|
||
return {
|
||
kind: 'linear-resize',
|
||
axis: 'y',
|
||
// 'max' = +Y edge anchored (top of the trough stays at Y=0); drag
|
||
// pulls the bottom edge further down. The base linear-resize
|
||
// factor for `max` is -1, which flips the cursor delta so dragging
|
||
// downward grows the value 1:1.
|
||
anchor: 'max',
|
||
min: MIN_SIZE,
|
||
currentValue: (n) => n.size,
|
||
apply: (_n, newValue) => ({ size: Math.max(MIN_SIZE, newValue) }),
|
||
placement: {
|
||
// Sit at the bottom of the trough, pushed a bit further down so
|
||
// the chevron clears the rim and reads as a downward indicator.
|
||
position: (n) => [0, -Math.max(n.size, MIN_SIZE) - SIZE_HANDLE_OFFSET, getRimZ(n)],
|
||
},
|
||
}
|
||
}
|
||
|
||
const gutterHandles: HandleDescriptor<GutterNodeType>[] = [
|
||
gutterLengthHandle('right'),
|
||
gutterLengthHandle('left'),
|
||
gutterSizeHandle(),
|
||
]
|
||
|
||
/**
|
||
* Gutter — a rain-water channel running along the eave of a roof
|
||
* segment. Parented to a `roof-segment`; position is segment-local.
|
||
*
|
||
* Three-checkbox model — same shape as box-vent / ridge-vent: custom
|
||
* `def.renderer` for the parent-segment transform lookup + live
|
||
* override merge, pure geometry builder in `./geometry` shared with
|
||
* the placement preview, no per-frame system (no animation, no
|
||
* cross-kind cascades).
|
||
*
|
||
* Placement tool snaps to the eave line (segment-local
|
||
* `Z = +depth/2, Y = wallHeight`) wherever the cursor lands on a
|
||
* segment. After commit, the length L/R handles cover trimming and
|
||
* the inspector covers profile + size adjustments.
|
||
*/
|
||
export const gutterDefinition: NodeDefinition<typeof GutterNode> = {
|
||
kind: 'gutter',
|
||
schemaVersion: 1,
|
||
schema: GutterNode,
|
||
category: 'structure',
|
||
surfaceRole: 'roof',
|
||
|
||
defaults: () => {
|
||
const stub = GutterNodeSchema.parse({
|
||
id: 'gutter_default' as never,
|
||
type: 'gutter',
|
||
})
|
||
const { id: _id, type: _type, ...rest } = stub
|
||
return rest
|
||
},
|
||
|
||
capabilities: {
|
||
selectable: { hitVolume: 'bbox' },
|
||
duplicable: true,
|
||
deletable: true,
|
||
// Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the
|
||
// eave fascia — no `buildCut`, just the dirty cascade so the
|
||
// parent roof's merged shell rebuilds when the gutter moves /
|
||
// resizes.
|
||
roofAccessory: {},
|
||
},
|
||
|
||
parametrics: gutterParametrics,
|
||
handles: gutterHandles,
|
||
floorplan: buildGutterFloorplan,
|
||
|
||
renderer: {
|
||
kind: 'parametric',
|
||
module: () => import('./renderer'),
|
||
},
|
||
|
||
preview: () => import('./preview'),
|
||
tool: () => import('./tool'),
|
||
affordanceTools: {
|
||
move: () => import('./move-tool'),
|
||
},
|
||
toolHints: [
|
||
{ key: 'Left click', label: 'Place gutter on roof eave' },
|
||
{ key: 'Esc', label: 'Cancel' },
|
||
],
|
||
|
||
presentation: {
|
||
label: 'Gutter',
|
||
description: 'Rain-water channel running along the eave of a roof segment.',
|
||
icon: { kind: 'url', src: '/icons/roof.png' },
|
||
paletteSection: 'structure',
|
||
paletteOrder: 122,
|
||
},
|
||
|
||
mcp: {
|
||
description:
|
||
'A gutter strip running along the eave of a roof segment. Three profiles (k-style ogee fascia, half-round, square box), length / size / thickness parametric.',
|
||
},
|
||
}
|