diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 1e92b7ca1..e514be6f5 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -9,9 +9,11 @@ import type { ColumnNode, DoorNode, DormerNode, + DownspoutNode, ElevatorNode, FenceNode, GuideNode, + GutterNode, ItemNode, LevelNode, RidgeVentNode, @@ -63,6 +65,11 @@ export interface NodeEvent { object: Object3D stopPropagation: () => void nativeEvent: ThreeEvent + // Set when the click originated from a dedicated selection affordance + // (e.g. a ceiling corner handle) rather than the node's own surface + // mesh. Lets selection logic accept handle clicks while ignoring clicks + // on the body so they fall through to whatever sits below. + viaHandle?: boolean } export type WallEvent = NodeEvent @@ -88,10 +95,12 @@ export type ScanEvent = NodeEvent export type GuideEvent = NodeEvent export type BoxVentEvent = NodeEvent export type RidgeVentEvent = NodeEvent +export type GutterEvent = NodeEvent export type ChimneyEvent = NodeEvent export type SolarPanelEvent = NodeEvent export type SkylightEvent = NodeEvent export type DormerEvent = NodeEvent +export type DownspoutEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -229,10 +238,12 @@ type EditorEvents = GridEvents & NodeEvents<'guide', GuideEvent> & NodeEvents<'box-vent', BoxVentEvent> & NodeEvents<'ridge-vent', RidgeVentEvent> & + NodeEvents<'gutter', GutterEvent> & NodeEvents<'chimney', ChimneyEvent> & NodeEvents<'solar-panel', SolarPanelEvent> & NodeEvents<'skylight', SkylightEvent> & NodeEvents<'dormer', DormerEvent> & + NodeEvents<'downspout', DownspoutEvent> & CameraControlEvents & ToolEvents & GuideEvents & diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index b9708753f..49d8a354c 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,6 +1,8 @@ import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import useScene from '../../store/use-scene' +import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve' +import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -284,21 +286,82 @@ function segmentsCollinearAndOverlap( return a1OnB && a2OnB } +type WallOverlapInput = { + start: [number, number] + end: [number, number] + curveOffset?: number + thickness?: number +} + /** * Test if a wall segment overlaps with a polygon. * A wall is considered to overlap if: * - Its midpoint is inside the polygon (wall crosses through) * - At least one endpoint is inside (wall partially or fully in slab) * - It's collinear with and overlaps a polygon edge (wall on slab boundary) + * - (curved walls) any sample along the centerline is inside * * Note: A wall with just one endpoint touching the edge but the rest outside * is NOT considered overlapping (adjacent only). */ export function wallOverlapsPolygon( - start: [number, number], - end: [number, number], - polygon: Array<[number, number]>, + startOrWall: [number, number] | WallOverlapInput, + endOrPolygon: [number, number] | Array<[number, number]>, + polygonArg?: Array<[number, number]>, ): boolean { + // Two call shapes: + // wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware + // wallOverlapsPolygon(start, end, polygon) — legacy chord-only + let start: [number, number] + let end: [number, number] + let polygon: Array<[number, number]> + let curveOffset = 0 + let thickness = DEFAULT_WALL_THICKNESS + if (Array.isArray(startOrWall)) { + start = startOrWall as [number, number] + end = endOrPolygon as [number, number] + polygon = polygonArg as Array<[number, number]> + } else { + start = startOrWall.start + end = startOrWall.end + curveOffset = startOrWall.curveOffset ?? 0 + thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS + polygon = endOrPolygon as Array<[number, number]> + } + const halfThickness = Math.max(thickness / 2, 0) + + // Curved walls: sample the centerline. The chord-based checks below miss + // walls that bow into/out of the slab — e.g. endpoints on the slab + // boundary with the curve arcing inward through the slab interior. Without + // this, `getSlabElevationForWall` returns 0 (wall drops to floor) and + // `markNodesOverlappingSlab` never re-dirties the wall when the slab Y + // moves. + if (curveOffset !== 0) { + const wallLike = { start, end, curveOffset } + if (isCurvedWall(wallLike)) { + const samples = sampleWallCenterline(wallLike, 16) + for (let i = 0; i < samples.length; i++) { + const point = samples[i]! + if (pointInPolygon(point.x, point.y, polygon)) return true + // Also test ±halfThickness perpendicular at each sample so a curve + // skirting the slab edge with its centerline just outside still + // registers — its body sits inside the slab. + if (halfThickness > 0 && i > 0) { + const prev = samples[i - 1]! + const sx = point.x - prev.x + const sz = point.y - prev.y + const sl = Math.sqrt(sx * sx + sz * sz) + if (sl > 1e-10) { + const tnx = (-sz / sl) * halfThickness + const tnz = (sx / sl) * halfThickness + if (pointInPolygon(point.x + tnx, point.y + tnz, polygon)) return true + if (pointInPolygon(point.x - tnx, point.y - tnz, polygon)) return true + } + } + } + } + } + const dx = end[0] - start[0] const dz = end[1] - start[1] const len = Math.sqrt(dx * dx + dz * dz) @@ -331,6 +394,26 @@ export function wallOverlapsPolygon( if (pointInPolygon(bx + pnx, bz + pnz, polygon)) return true if (pointInPolygon(bx - pnx, bz - pnz, polygon)) return true } + + // Wall-thickness perpendicular test. Walls aren't infinitely thin lines; + // a wall whose centerline sits just outside the slab boundary still has + // half its body inside the slab and should follow the slab elevation. + // Without this, the perimeter walls of a room often miss the slab-overlap + // detection (the slab polygon is the room's interior, the wall centerline + // sits on or just outside its edge) and stay at Y=0 while the slab moves + // up. + if (halfThickness > 0) { + const ux = dx / len + const uz = dz / len + const tnx = -uz * halfThickness + const tnz = ux * halfThickness + for (const t of [0, 0.25, 0.5, 0.75, 1]) { + const bx = start[0] + dx * t + const bz = start[1] + dz * t + if (pointInPolygon(bx + tnx, bz + tnz, polygon)) return true + if (pointInPolygon(bx - tnx, bz - tnz, polygon)) return true + } + } } // Check if midpoint is inside (catches walls crossing through) @@ -762,15 +845,33 @@ export class SpatialGridManager { * Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes). * Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments). * Returns the highest slab elevation found, or 0 if none. + * + * Accepts an optional `curveOffset` so curved walls evaluate overlap + * against their actual centerline samples, not just the chord. */ - getSlabElevationForWall(levelId: string, start: [number, number], end: [number, number]): number { + getSlabElevationForWall( + levelId: string, + start: [number, number], + end: [number, number], + curveOffset = 0, + thickness = DEFAULT_WALL_THICKNESS, + ): number { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) return 0 + const wallLike: WallOverlapInput = { start, end, curveOffset, thickness } + const isCurved = curveOffset !== 0 && isCurvedWall(wallLike) + const holeSamplePoints: Array<{ x: number; y: number }> = isCurved + ? sampleWallCenterline(wallLike, 8) + : [0, 0.25, 0.5, 0.75, 1].map((t) => ({ + x: start[0] + (end[0] - start[0]) * t, + y: start[1] + (end[1] - start[1]) * t, + })) + let maxElevation = Number.NEGATIVE_INFINITY for (const slab of slabMap.values()) { if (slab.polygon.length < 3) continue - if (!wallOverlapsPolygon(start, end, slab.polygon)) continue + if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue const holes = slab.holes || [] if (holes.length === 0) { @@ -783,15 +884,11 @@ export class SpatialGridManager { // Sample multiple points along the wall to check whether any portion lies on // solid slab (not inside any hole). Checking only the midpoint fails when the // midpoint falls in a staircase hole but the wall's endpoints are on solid slab. - const dx = end[0] - start[0] - const dz = end[1] - start[1] let hasValidPoint = false - for (const t of [0, 0.25, 0.5, 0.75, 1]) { - const px = start[0] + dx * t - const pz = start[1] + dz * t + for (const sample of holeSamplePoints) { let inHole = false for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(px, pz, hole)) { + if (hole.length >= 3 && pointInPolygon(sample.x, sample.y, hole)) { inHole = true break } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 5825bcb0a..ba669c218 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -166,7 +166,17 @@ function markNodesOverlappingSlab( if (node.type === 'wall') { const wall = node as WallNode if (resolveLevelId(node, nodes) !== slabLevelId) continue - if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) { + if ( + wallOverlapsPolygon( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset ?? 0, + thickness: wall.thickness, + }, + slab.polygon, + ) + ) { markDirty(node.id) } continue diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 028d449e8..080387d16 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export type { FenceEvent, GridEvent, GuideEvent, + GutterEvent, ItemEvent, LevelEvent, NodeEvent, @@ -105,6 +106,7 @@ export { getEffectiveNode, type LiveNodeOverrides, } from './store/use-live-node-overrides' +export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index c664a19d0..9db7cabd2 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -22,7 +22,7 @@ // - endpoint-move : wall / fence endpoint drag (snapping is bespoke, // so it delegates to a kind-supplied callback) -import type { AnyNode } from '../schema/types' +import type { AnyNode, AnyNodeId } from '../schema/types' import type { SceneApi } from './types' /** @@ -110,9 +110,30 @@ export type LinearResizeHandle = { anchor: HandleAnchor currentValue: (node: N) => number apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** + * Cross-node redirect. By default the drag's live override + the + * committed write both land on the SELECTED node. When this returns + * another node's id, the editor publishes the override to / commits on + * THAT node instead (and `apply` should return that node's patch). + * Used when a node's handle edits a value owned by a sibling — e.g. a + * downspout's side-move arrows slide its outlet, which lives on the + * host gutter (`gutter.outlets[].offset`). The selected node is still + * what `currentValue` / `apply` receive, so the descriptor can read + * the downspout to find its gutter + outlet. + */ + overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined min?: number | ((node: N, sceneApi: SceneApi) => number) max?: number | ((node: N, sceneApi: SceneApi) => number) placement: HandlePlacement + /** + * Dimension this handle steers (e.g. `'height'`). When set, the editor + * publishes it to `activeHandleDrag.label` for the duration of the drag + * so out-of-band overlays (the floating dimension pill) can react, and + * the handle's own in-world value chip is suppressed to avoid showing + * the same number twice. Leave unset for handles that keep their inline + * chip and don't drive any external overlay. + */ + measureLabel?: string /** * Defaults to 'self' (arrow lives in the selected node's own mesh). * 'parent' uses the parent mesh — used by doors/windows whose handles @@ -122,6 +143,29 @@ export type LinearResizeHandle = { cursor?: Cursor /** Optional visual guide shown while the arrow is hovered or dragging. */ decoration?: HandleDecoration + /** + * Visual override. Defaults to the standard chevron arrow. + * + * `'tracker'` swaps the chevron for a dashed vertical leader + a small + * cube at `placement.position`. The leader runs from the floor (local + * y=0) up to the cube; the cube is the drag target and reuses the same + * linear-resize drag pipeline as the chevron. Intended for vertical + * height handles where the dashed leader makes the "this is the wall + * top" relationship readable at a glance — mirrors the `corner-picker` + * shape on `tap-action` handles but with a draggable cube instead of a + * one-tap hex disc. Use with `axis: 'y'`; horizontal axes will render + * the leader vertically and look wrong. + */ + shape?: 'arrow' | 'tracker' + /** + * Optional override for the bottom Y of the tracker leader. Defaults + * to 0 (floor of the rideObject's local frame). Use when the value + * being tracked spans a region that doesn't start at the floor — e.g. + * a chimney's body height runs from the roof deck up to the body top, + * so the leader should start at the deck plane and not climb through + * the roof shell below it. Only consulted when `shape === 'tracker'`. + */ + trackerBaseY?: (node: N, sceneApi: SceneApi) => number } /** @@ -176,6 +220,28 @@ export type ArcResizeHandle = { * arrow icon, intended for whole-node rotation handles. */ shape?: 'chevron' | 'rotate' + /** + * Plane the angular drag is measured in: + * - 'horizontal' (default): cursor bearing around +Y — whole-node yaw + * (floor items, elevator, stair, roof-segment). + * - 'node-normal': cursor bearing around the node's local +Z axis, in + * the plane perpendicular to it — spins a wall-mounted item flat + * against its wall. The descriptor's `apply` writes the roll + * component (rotation[2]). The gizmo icon stands up into that plane. + */ + rotationPlane?: 'horizontal' | 'node-normal' + /** + * Pivot point for the angular drag, in the rideObject's local space. + * The renderer measures cursor angle (atan2 on the drag plane) around + * this point — descriptors that write `rotation` should anchor it to + * the node's visual center. Defaults to the rideObject's own origin, + * which is correct for nodes whose mesh origin coincides with the + * field they're rotating (roof-segment, elevator). Use this when the + * node's pose is baked into its geometry (chimney) so the mesh origin + * sits at the parent frame's origin rather than the rotating shape's + * center. + */ + rotationCenter?: (node: N, sceneApi: SceneApi) => readonly [number, number, number] } /** @@ -227,12 +293,54 @@ export type TapActionHandle = { cursor?: Cursor } +/** + * Free ground-plane move. Drag the handle and the node slides across the + * horizontal plane at its base — the renderer raycasts that plane, converts + * the hit into the node's parent-local frame, and reports the new local XZ + * (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release + * with the same live-override → commit-on-release flow as the resize / rotate + * handles. Rendered as a 4-way cross of double-headed arrows. + */ +export type TranslateHandle = { + kind: 'translate' + placement: HandlePlacement + /** + * Plane the drag is constrained to (through the node origin): + * - 'horizontal' (default): the ground plane (world-up normal) — slide + * across the floor. The free axes are parent-local X / Z. + * - 'node-normal': the plane perpendicular to the node's local +Z axis + * (its facing direction) — slide across a wall face. The free axes are + * parent-local X / Y; depth (Z) stays pinned to the surface. + */ + plane?: 'horizontal' | 'node-normal' + /** + * `localPos` is the dragged-to position in the node's PARENT-local frame, + * with the two in-plane axes already grid-snapped (if `snapExtents` is set) + * and the off-plane axis pinned to its drag-start value. Return the patch + * that writes it to the node's position field. + */ + apply: ( + initialNode: N, + localPos: readonly [number, number, number], + sceneApi: SceneApi, + ) => Partial + /** + * Optional grid-snap footprint for the two in-plane axes, in order + * `[alongX, alongOther]` — `alongOther` is Z for the 'horizontal' plane and + * Y for 'node-normal'. Used to align the node's edges to the grid (rotation- + * aware: swap the pair at 90°). Omit / return null for free movement. + */ + snapExtents?: (node: N) => readonly [number, number] | null + portal?: HandlePortal +} + export type HandleDescriptor = | LinearResizeHandle | RadialResizeHandle | ArcResizeHandle | EndpointMoveHandle | TapActionHandle + | TranslateHandle /** * Static array, or a function for shape-dependent cases (column diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index b3226b31e..896208661 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -12,6 +12,7 @@ export type { LinearResizeHandle, RadialResizeHandle, TapActionHandle, + TranslateHandle, } from './handles' export { discoverPlugins, @@ -82,6 +83,7 @@ export type { PaintPatchArgs, PaintPreviewArgs, PaintResolveArgs, + ParamAction, ParametricDescriptor, ParamField, ParamGroup, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 5492da86d..b498bd9fd 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -410,6 +410,14 @@ export type FloorplanGeometry = angle: number affordance: string payload?: unknown + /** + * Rotation pivot (plan coords) this handle turns the node around. + * When present, the floor-plan layer draws a live angle wedge + degree + * readout swept from grab to the current pointer bearing during the + * drag — the 2D twin of the 3D rotate gizmo's readout. Emitters that + * already compute the pivot to place the handle should pass it through. + */ + pivot?: FloorplanPoint } /** * Centered length / distance label. Renders as a small rounded @@ -1238,6 +1246,31 @@ export type ParametricDescriptor = { invariants?: ReadonlyArray<(n: N) => Issue[]> derive?: (n: N) => Partial customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> + /** + * Extra buttons rendered in the inspector's Actions section + * (below Move/Delete). Lets a kind declare "do this thing to the + * current node" affordances without escaping to a full custom + * panel. Buttons whose `enabledIf` returns false stay disabled. + */ + actions?: ParamAction[] + /** + * Lazy-loaded React subsection rendered AFTER the auto-derived + * groups and BEFORE the Actions section. Used by kinds that want + * to list their child nodes inline — e.g. the gutter's downspout + * list with an "Add Downspout" button at the bottom, same shape as + * the roof panel's gutter / vent lists. Kind owns the layout; the + * inspector just slots it in. + */ + trailingSection?: () => Promise<{ default: ComponentType<{ node: N }> }> +} + +export type ParamAction = { + label: string + /** Optional asset URL for a leading icon — same shape as palette icons. */ + iconSrc?: string + enabledIf?: (n: N) => boolean + /** Click handler. Receives the current node value at click time. */ + onClick: (n: N) => void } export type ParamGroup = { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 8d7732929..2a104da61 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -57,6 +57,7 @@ export { type DormerSurfaceMaterialSpec, getEffectiveDormerSurfaceMaterial, } from './nodes/dormer' +export { DownspoutNode } from './nodes/downspout' export { ElevatorDoorPanelStyle, ElevatorDoorStyle, @@ -65,6 +66,7 @@ export { } from './nodes/elevator' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' +export { GutterNode, GutterOutlet } from './nodes/gutter' export type { AnimationEffect, Asset, diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 90e864f46..b7f5910fa 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -57,6 +57,7 @@ export const MaterialTarget = z.enum([ 'dormer', 'box-vent', 'ridge-vent', + 'gutter', ]) export type MaterialTarget = z.infer diff --git a/packages/core/src/schema/nodes/downspout.ts b/packages/core/src/schema/nodes/downspout.ts new file mode 100644 index 000000000..e3c1de8f1 --- /dev/null +++ b/packages/core/src/schema/nodes/downspout.ts @@ -0,0 +1,74 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const DownspoutNode = BaseNode.extend({ + id: objectId('downspout'), + type: nodeType('downspout'), + + material: MaterialSchema.optional(), + // Match the gutter family default — paint inspector reads "White" + // instead of "no material" on a freshly placed downspout. + materialPreset: z.string().default('preset-white'), + + // Logical attachment: the gutter this downspout drains. Scene-graph + // parent is the same roof-segment that hosts the gutter, so the + // renderer can be reached through the segment's children list (like + // every other roof accessory). gutterId then drives the LOOKUP of + // outlet X/Z/diameter — the downspout's actual mount position is + // derived from the gutter, not stored. + gutterId: z.string().optional(), + // Which of the host gutter's `outlets` this downspout drains, by the + // outlet's `id`. The mount position, bore, and cross-section shape are + // looked up from that outlet — so several downspouts on one gutter no + // longer stack on a single drop. + outletId: z.string().optional(), + + // Length the pipe extends DOWN from the gutter outlet, in metres. + // Default 2.5 m covers a typical residential storey; the placement + // tool can default to the gutter's eave-Y minus building floor on + // commit so the user doesn't have to set it on every drop. + length: z.number().default(2.5), + // Bore diameter, default 0.07 m ≈ 3″ to match the gutter outlet + // default. Larger downspouts are common on commercial gutters. + diameter: z.number().default(0.07), + // Gap between the pipe's wall-facing surface and the wall face, in + // metres. The downspout's offset elbows step it back from the eave + // overhang to the wall; this controls how far proud of the wall it + // sits (real downspouts mount on standoff brackets ~1–2 cm off the + // wall). Larger values pull the run back OUT toward the eave — the + // escape hatch when the auto-routed wall position doesn't match the + // actual wall (overshoots into it). Default 0.02 m. + standoff: z.number().default(0.02), + + // Cross-section shape. 'auto' follows the host gutter's profile (round + // on half-round, rectangular on k-style / box); 'round' / 'rect' + // override it for a mixed look. + shape: z.enum(['auto', 'round', 'rect']).default('auto'), + + // Wall straps clamping the run to the wall. 'band' renders periodic + // bands; 'none' hides them. Spacing is metres between bands. + strapStyle: z.enum(['band', 'none']).default('band'), + strapSpacing: z.number().default(1.8), + + // What happens at the bottom of the run: + // - 'splash': kickout elbow + a splash block on the ground (default) + // - 'kickout': kickout elbow only (e.g. into a drain pipe) + // - 'straight': no kick — runs straight down (into a grade drain) + terminal: z.enum(['splash', 'kickout', 'straight']).default('splash'), +}).describe( + dedent` + Downspout — a vertical pipe that takes water from a gutter outlet + down to ground level. Parented to a roof-segment (scene-graph), + linked to a specific gutter via gutterId for outlet position. + - length: vertical pipe length below the gutter outlet + - diameter: bore diameter; should match the host gutter's outletDiameter + - standoff: gap the pipe sits proud of the wall (pulls the run out toward the eave) + - shape: cross-section (auto follows the gutter profile, or force round / rect) + - strapStyle / strapSpacing: wall straps clamping the run + - terminal: bottom treatment (splash block, kickout only, or straight down) + `, +) + +export type DownspoutNode = z.infer diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts new file mode 100644 index 000000000..d9fbc7e44 --- /dev/null +++ b/packages/core/src/schema/nodes/gutter.ts @@ -0,0 +1,90 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +// A single drop outlet drilled in the gutter floor. A gutter can carry +// several so a long run can split between multiple downspouts (each +// downspout links to one outlet via its `outletId`). +export const GutterOutlet = z.object({ + // Stable id the downspout references. Generated with `generateId('outlet')`. + id: z.string(), + // Position along the gutter length (gutter-local +X), signed from the + // CENTER. The geometry clamps it inside the end caps at build time, so + // a stored value that no longer fits just rides the nearest bound. + offset: z.number().default(0), + // Bore diameter of this drop. Default 0.07 m ≈ 3″. The cross-section + // SHAPE (round vs rectangular) follows the gutter's profile, not this. + diameter: z.number().default(0.07), +}) +export type GutterOutlet = z.infer + +export const GutterNode = BaseNode.extend({ + id: objectId('gutter'), + type: nodeType('gutter'), + + material: MaterialSchema.optional(), + // White preset by default — matches the rest of the roof accessory + // family (box-vent / ridge-vent) so the paint inspector reads as + // "White" instead of "no material" on a freshly-placed gutter. + materialPreset: z.string().default('preset-white'), + + roofSegmentId: z.string().optional(), + // Segment-local. The placement tool snaps to the eave line (Z = + // +depth/2, Y = wallHeight) of the segment under the cursor; X is + // wherever the user clicked. After placement the inspector + length + // handles can shift X along the eave. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around the gutter's own local Y. Kept at 0 by default + // because the gutter's length axis is constrained to the eave + // direction (segment-local +X) — but exposed in case the user wants + // to tilt for a custom run. + rotation: z.number().default(0), + + // Length along the eave (gutter-local +X). + length: z.number().default(2.0), + // Profile size — the vertical drop of the U-channel below the eave + // line. 5″ (0.127 m) is the most common residential gutter size; 6″ + // (0.152 m) is the common commercial / heavy-duty size. Default + // rounds the residential value to 0.13 m. + size: z.number().default(0.13), + // Wall thickness of the U-channel. Visible on the rim from above; too + // thin reads as a paper strip, too thick reads as a curb. + thickness: z.number().default(0.006), + + profile: z.enum(['k-style', 'half-round', 'box']).default('k-style'), + + // End caps close the open ends of the U-channel so water can't run + // out the sides. Independent per-end because a downspout typically + // joins the gutter at one end while the other stays capped. Default + // true on both — matches a freshly-installed residential gutter. + endCapLeft: z.boolean().default(true), + endCapRight: z.boolean().default(true), + + // Hangers are the metal straps that hold the gutter onto the + // fascia. 'strap' renders periodic bars across the rim; 'none' + // hides them (some plastic gutters use hidden clips). Spacing is + // metres between hanger centers; real residential code is roughly + // 0.6 m for snow-load areas, 0.75 m elsewhere. + hangerStyle: z.enum(['strap', 'none']).default('strap'), + hangerSpacing: z.number().default(0.6), + + // Downspout outlets — short drop tubes descending from the gutter + // floor where downspouts connect. Empty by default so existing + // gutters don't sprout outlets on schema upgrade. Each is drilled + // through the trough floor via CSG; a downspout links to one by id. + outlets: z.array(GutterOutlet).default([]), +}).describe( + dedent` + Gutter — a rain-water channel running along the eave of a roof + segment. Parented to a roof-segment; position is segment-local. + - length: span along the eave (gutter-local +X) + - size: profile drop below the eave line (vertical extent) + - profile: k-style (ogee fascia), half-round, or square box + - endCapLeft / endCapRight: close the trough at gutter-local -X / +X + - hangerStyle / hangerSpacing: visible metal straps across the rim + - outlets: drop-tube outlets (id + along-length offset + bore diameter) + `, +) + +export type GutterNode = z.infer diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 22f9f6c1c..d6e8f56b4 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -97,7 +97,7 @@ export const RoofSegmentNode = BaseNode.extend({ .max(0.9) .default(ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio), // Hosted accessories — chimney, dormer, skylight, box-vent, - // ridge-vent, solar-panel. Each accessory's `parentId` points back + // ridge-vent, solar-panel, gutter. Each accessory's `parentId` points back // here; the segment renderer mounts them recursively via // `` so they inherit the segment's transform stack. // Required for `createNode(child, segmentId)` to append the child diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 1c27dd07a..e514d11a0 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -6,9 +6,11 @@ import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' import { DoorNode } from './nodes/door' import { DormerNode } from './nodes/dormer' +import { DownspoutNode } from './nodes/downspout' import { ElevatorNode } from './nodes/elevator' import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' +import { GutterNode } from './nodes/gutter' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' import { RidgeVentNode } from './nodes/ridge-vent' @@ -51,10 +53,12 @@ export const AnyNode = z.discriminatedUnion('type', [ DoorNode, BoxVentNode, RidgeVentNode, + GutterNode, ChimneyNode, SolarPanelNode, SkylightNode, DormerNode, + DownspoutNode, ]) export type AnyNode = z.infer diff --git a/packages/core/src/services/alignment.test.ts b/packages/core/src/services/alignment.test.ts new file mode 100644 index 000000000..3fa8aaaa6 --- /dev/null +++ b/packages/core/src/services/alignment.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test' +import { type AlignmentAnchor, bboxAnchors, resolveAlignment } from './alignment' + +function center(nodeId: string, x: number, z: number): AlignmentAnchor { + return { nodeId, kind: 'center', x, z } +} + +describe('resolveAlignment', () => { + test('returns empty when no candidates within threshold', () => { + const result = resolveAlignment({ + moving: [center('m', 0, 0)], + candidates: [center('a', 1, 1)], + threshold: 0.5, + }) + expect(result.guides).toEqual([]) + expect(result.snap).toBeNull() + }) + + test('snaps moving anchor onto candidate X when within threshold', () => { + const result = resolveAlignment({ + moving: [center('m', 0.03, 5)], + candidates: [center('a', 0, 2)], + threshold: 0.1, + }) + expect(result.snap).toEqual({ dx: -0.03, dz: 0 }) + expect(result.guides).toHaveLength(1) + expect(result.guides[0]!.axis).toBe('x') + expect(result.guides[0]!.coord).toBe(0) + expect(result.guides[0]!.from.z).toBe(2) + expect(result.guides[0]!.to.z).toBe(5) + }) + + test('snaps both axes when both match', () => { + const result = resolveAlignment({ + moving: [center('m', 0.03, 0.04)], + candidates: [center('a', 0, 0)], + threshold: 0.1, + }) + expect(result.snap).toEqual({ dx: -0.03, dz: -0.04 }) + expect(result.guides).toHaveLength(2) + }) + + test('picks closest candidate per axis', () => { + const result = resolveAlignment({ + moving: [center('m', 0.08, 0)], + candidates: [center('a', 0, 5), center('b', 0.1, 5), center('c', 0.05, 10)], + threshold: 0.1, + }) + // |0.1 - 0.08| = 0.02 wins over |0.05 - 0.08| = 0.03 and |0 - 0.08| = 0.08 + expect(result.snap?.dx).toBeCloseTo(0.02, 10) + expect(result.guides[0]!.candidateNodeId).toBe('b') + }) + + test('threshold = 0 disables alignment', () => { + const result = resolveAlignment({ + moving: [center('m', 0, 0)], + candidates: [center('a', 0, 0)], + threshold: 0, + }) + expect(result.guides).toEqual([]) + expect(result.snap).toBeNull() + }) + + test('distance is the perpendicular gap to the matched axis', () => { + const result = resolveAlignment({ + moving: [center('m', 0.02, 3)], + candidates: [center('a', 0, 0)], + threshold: 0.1, + }) + // After snap: moving at (0, 3). X guide runs along x=0 from z=0 to z=3. + expect(result.guides[0]!.distance).toBeCloseTo(3, 10) + }) +}) + +describe('bboxAnchors', () => { + test('returns 9 anchors with correct kinds and positions', () => { + const anchors = bboxAnchors('node', 0, 0, 2, 4) + expect(anchors).toHaveLength(9) + const corners = anchors.filter((a) => a.kind === 'corner') + const edges = anchors.filter((a) => a.kind === 'edge-mid') + const centers = anchors.filter((a) => a.kind === 'center') + expect(corners).toHaveLength(4) + expect(edges).toHaveLength(4) + expect(centers).toHaveLength(1) + expect(centers[0]).toEqual({ nodeId: 'node', kind: 'center', x: 1, z: 2 }) + }) +}) diff --git a/packages/core/src/services/alignment.ts b/packages/core/src/services/alignment.ts new file mode 100644 index 000000000..ce4f8ba66 --- /dev/null +++ b/packages/core/src/services/alignment.ts @@ -0,0 +1,176 @@ +/** + * Pure alignment-guide resolver — no React, no DOM, no scene access. + * + * Given a moving object's anchor points at its proposed position and a + * pool of candidate anchors from nearby static objects, returns: + * - the best per-axis matches as `Guide` rendering primitives, and + * - an optional `{ dx, dz }` snap delta the caller can apply. + * + * Anchors are 2D points on the floor plane (XZ, in world meters). The + * resolver picks at most one match per axis: the smallest |Δx| match + * snaps X; the smallest |Δz| match snaps Z. This mirrors Figma's + * behaviour — guides appear along the matched axes, regardless of how + * many neighbours could have matched. + * + * Two guides max per call keeps the visual signal sharp at the cost of + * not surfacing every possible alignment at once. Multi-guide ("this + * lines up with three things") is intentionally out of scope for v1. + */ + +export type AnchorKind = 'corner' | 'edge-mid' | 'center' + +export type AlignmentAnchor = { + /** Owning node id — informational; resolver does not use it. */ + nodeId: string + kind: AnchorKind + x: number + z: number +} + +export type AlignmentGuideAxis = 'x' | 'z' + +/** + * Rendering primitive — a guide line on the floor plane. + * + * `axis === 'x'`: vertical guide. Both endpoints share `coord` as their X. + * `axis === 'z'`: horizontal guide. Both endpoints share `coord` as their Z. + * + * The line spans from the matched candidate anchor to the moving anchor + * after snap. Renderers extend visually beyond the endpoints if they want + * Figma-style "infinite line" feel. + */ +export type AlignmentGuide = { + axis: AlignmentGuideAxis + coord: number + from: { x: number; z: number } + to: { x: number; z: number } + movingAnchorKind: AnchorKind + candidateAnchorKind: AnchorKind + candidateNodeId: string + /** Perpendicular distance between the two anchors (used by the distance pill). */ + distance: number +} + +export type ResolveAlignmentInput = { + /** Anchors of the moving node, positioned at the proposed (pre-snap) location. */ + moving: readonly AlignmentAnchor[] + /** Anchors from every other candidate node the caller has already filtered. */ + candidates: readonly AlignmentAnchor[] + /** + * Max |Δ| (meters) for an anchor pair to count as a match. Typically + * derived from a screen-pixel budget × current units-per-pixel so the + * snap feel is zoom-invariant. + */ + threshold: number +} + +export type ResolveAlignmentResult = { + guides: AlignmentGuide[] + /** + * Delta the caller should add to the moving node's planar position so + * its anchors land on the matched axes. `null` when no axis matched. + */ + snap: { dx: number; dz: number } | null +} + +const EMPTY: ResolveAlignmentResult = { guides: [], snap: null } + +export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignmentResult { + const { moving, candidates, threshold } = input + if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return EMPTY + + // Best match per axis: smallest |Δ| across all (moving, candidate) pairs. + // Tie-break by candidate anchor kind priority (center > edge-mid > corner) + // so visually meaningful matches win when |Δ| is equal. + let bestX: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null + let bestZ: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null + + for (const m of moving) { + for (const c of candidates) { + const dx = c.x - m.x + const dz = c.z - m.z + const adx = Math.abs(dx) + const adz = Math.abs(dz) + if (adx <= threshold && (bestX === null || adx < Math.abs(bestX.delta))) { + bestX = { delta: dx, m, c } + } + if (adz <= threshold && (bestZ === null || adz < Math.abs(bestZ.delta))) { + bestZ = { delta: dz, m, c } + } + } + } + + if (!bestX && !bestZ) return EMPTY + + const dxSnap = bestX?.delta ?? 0 + const dzSnap = bestZ?.delta ?? 0 + const guides: AlignmentGuide[] = [] + + if (bestX) { + // X-axis match: vertical guide at x = bestX.c.x. The moving anchor + // ends up at (c.x, m.z + dzSnap). Span the line between them. + const snappedMz = bestX.m.z + dzSnap + const z1 = Math.min(bestX.c.z, snappedMz) + const z2 = Math.max(bestX.c.z, snappedMz) + guides.push({ + axis: 'x', + coord: bestX.c.x, + from: { x: bestX.c.x, z: z1 }, + to: { x: bestX.c.x, z: z2 }, + movingAnchorKind: bestX.m.kind, + candidateAnchorKind: bestX.c.kind, + candidateNodeId: bestX.c.nodeId, + distance: Math.abs(snappedMz - bestX.c.z), + }) + } + + if (bestZ) { + const snappedMx = bestZ.m.x + dxSnap + const x1 = Math.min(bestZ.c.x, snappedMx) + const x2 = Math.max(bestZ.c.x, snappedMx) + guides.push({ + axis: 'z', + coord: bestZ.c.z, + from: { x: x1, z: bestZ.c.z }, + to: { x: x2, z: bestZ.c.z }, + movingAnchorKind: bestZ.m.kind, + candidateAnchorKind: bestZ.c.kind, + candidateNodeId: bestZ.c.nodeId, + distance: Math.abs(snappedMx - bestZ.c.x), + }) + } + + return { guides, snap: { dx: dxSnap, dz: dzSnap } } +} + +// ─── Anchor extractors (pure) ───────────────────────────────────────── + +/** + * Produces the 9 standard anchors for an axis-aligned bounding box on the + * floor plane: 4 corners, 4 edge midpoints, 1 center. Suitable for any + * floor-plan entity whose footprint can be expressed as a bbox. + * + * Caller is responsible for computing the bbox — the resolver doesn't + * care how (per-kind dimensions, SVG getBBox(), etc.). + */ +export function bboxAnchors( + nodeId: string, + minX: number, + minZ: number, + maxX: number, + maxZ: number, +): AlignmentAnchor[] { + const cx = (minX + maxX) / 2 + const cz = (minZ + maxZ) / 2 + return [ + { nodeId, kind: 'corner', x: minX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: maxZ }, + { nodeId, kind: 'corner', x: minX, z: maxZ }, + { nodeId, kind: 'edge-mid', x: cx, z: minZ }, + { nodeId, kind: 'edge-mid', x: maxX, z: cz }, + { nodeId, kind: 'edge-mid', x: cx, z: maxZ }, + { nodeId, kind: 'edge-mid', x: minX, z: cz }, + { nodeId, kind: 'center', x: cx, z: cz }, + ] +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index ac4826f80..bb0cffe89 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -1,3 +1,13 @@ +export { + type AlignmentAnchor, + type AlignmentGuide, + type AlignmentGuideAxis, + type AnchorKind, + bboxAnchors, + resolveAlignment, + type ResolveAlignmentInput, + type ResolveAlignmentResult, +} from './alignment' export { createDragSession, type DragSession, diff --git a/packages/core/src/store/use-alignment-guides.ts b/packages/core/src/store/use-alignment-guides.ts new file mode 100644 index 000000000..ae4991fb0 --- /dev/null +++ b/packages/core/src/store/use-alignment-guides.ts @@ -0,0 +1,21 @@ +// Ephemeral store for Figma-style alignment guides published during a +// move / placement drag. The producer (a tool or move overlay) writes +// guides on pointermove; the renderer (a 2D / 3D guide layer) subscribes +// and draws them. Both sides clear on commit, cancel, and unmount. + +import { create } from 'zustand' +import type { AlignmentGuide } from '../services/alignment' + +type AlignmentGuidesState = { + guides: AlignmentGuide[] + set(guides: AlignmentGuide[]): void + clear(): void +} + +const useAlignmentGuides = create((set) => ({ + guides: [], + set: (guides) => set({ guides }), + clear: () => set({ guides: [] }), +})) + +export default useAlignmentGuides diff --git a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx new file mode 100644 index 000000000..5244e9f4e --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx @@ -0,0 +1,137 @@ +'use client' + +import { useAlignmentGuides } from '@pascal-app/core' +import { memo } from 'react' +import { useFloorplanRender } from './floorplan-render-context' + +/** + * Figma-style alignment guides for the 2D floor plan. + * + * Subscribes to `useAlignmentGuides` — populated by + * `FloorplanRegistryMoveOverlay` (Path 2) during a generic free-translate + * drag. Each guide renders as a red line between the moving and matched + * candidate anchors with small `×` end-caps. A distance pill is drawn at + * the line's midpoint when the perpendicular gap is non-zero. + * + * Stroke widths and handle radii are scaled by `unitsPerPixel` so they + * stay a constant size on screen no matter the zoom. Text labels are + * counter-rotated by `sceneRotationDeg` so they read upright even when + * the building rotation rotates the scene ``. + * + * Mounted inside the `data-floorplan-scene` group so coordinates match + * world meters 1:1 with the rest of the floor plan. + */ +export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() { + const guides = useAlignmentGuides((s) => s.guides) + const ctx = useFloorplanRender() + + if (guides.length === 0) return null + + const upp = ctx?.unitsPerPixel ?? 0.01 + const sceneRot = ctx?.sceneRotationDeg ?? 0 + + // Pixel-budgeted sizes converted to world meters so visuals stay + // constant across zoom. Numbers picked to mirror Figma's snap chrome. + const stroke = 1 * upp + const xCapSize = 4 * upp + const pillFontSize = 11 * upp + const pillPadX = 5 * upp + const pillPadY = 3 * upp + const pillRadius = 3 * upp + const pillOffset = 8 * upp + + const color = '#ef4444' // tailwind red-500 — matches Figma's snap red + + return ( + + {guides.map((guide, i) => { + const { from, to, axis } = guide + const midX = (from.x + to.x) / 2 + const midZ = (from.z + to.z) / 2 + const distMeters = guide.distance + + // Pill placed offset perpendicular to the guide's axis so it + // doesn't sit on top of the line itself. For an X-axis guide + // (vertical line) we offset along Z; for a Z-axis guide we + // offset along X. + const pillX = axis === 'x' ? midX + pillOffset : midX + const pillZ = axis === 'z' ? midZ + pillOffset : midZ + const distLabel = formatMeters(distMeters) + const charWidth = pillFontSize * 0.55 + const pillWidth = distLabel.length * charWidth + pillPadX * 2 + const pillHeight = pillFontSize + pillPadY * 2 + + return ( + + + + + {distMeters > 1e-4 && ( + // Counter-rotate the pill so it stays upright when the + // scene `` is rotated by building rotation. SVG's + // `transform` runs in the local coord system, so the + // rotation pivots around the pill's center. + + + + {distLabel} + + + )} + + ) + })} + + ) +}) + +function XCap({ + color, + size, + stroke, + x, + y, +}: { + color: string + size: number + stroke: number + x: number + y: number +}) { + return ( + + + + + ) +} + +function formatMeters(meters: number): string { + // Sub-centimetre = "0". Otherwise show with up to 2 decimals, trimmed. + if (meters < 0.005) return '0' + const fixed = meters.toFixed(2) + return `${fixed.replace(/\.?0+$/, '')}m` +} diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 433534708..67da92ef6 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -1,13 +1,17 @@ 'use client' import { + type AlignmentAnchor, type AnyNode, type AnyNodeId, + bboxAnchors, type FloorplanMoveTargetSession, nodeRegistry, pauseSceneHistory, + resolveAlignment, resumeSceneHistory, snapPointToGrid, + useAlignmentGuides, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -20,6 +24,12 @@ import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts' const GRID_STEP = 0.5 +// Figma-style alignment snap threshold. Meters in world space; 8cm gives +// a comfortable "magnetic" pull at default zoom without fighting the +// grid snap. Held fixed for v1 — a future revision can scale this with +// the SVG's units-per-pixel so the feel stays constant across zoom. +const ALIGNMENT_THRESHOLD_M = 0.08 + /** * Cursor-driven placement for registered kinds in the floor plan. * @@ -374,6 +384,24 @@ export function FloorplanRegistryMoveOverlay() { } ).position ?? [0, 0, 0]) as [number, number, number] + // SVG units in this floorplan map 1:1 to world meters, and the + // `` entry has no transform of its own when at rest, + // so its untransformed bbox IS the world-space footprint. Cache the + // moving entry's local bbox once (relative to originalPosition) and + // derive anchors at any proposed (sx, sz) by translating it. + const movingLocalBBox = entry.getBBox() + const candidateAnchors: AlignmentAnchor[] = [] + const allEntries = scene.querySelectorAll('[data-node-id]') + for (const el of Array.from(allEntries)) { + const otherId = el.getAttribute('data-node-id') + if (!otherId || otherId === movingNode.id) continue + const b = (el as SVGGraphicsElement).getBBox() + if (b.width <= 0 || b.height <= 0) continue + candidateAnchors.push( + ...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height), + ) + } + let lastSnapped: [number, number] | null = null const onMove = (event: PointerEvent) => { @@ -384,11 +412,49 @@ export function FloorplanRegistryMoveOverlay() { if (!target?.closest('[data-floorplan-scene]')) return const m = toMeters(event.clientX, event.clientY) if (!m) return - const [sx, sz] = snapPointToGrid([m[0], m[1]], GRID_STEP) - const dx = sx - originalPosition[0] - const dz = sz - originalPosition[2] + + // 1) Grid snap baseline (unchanged behaviour with Alt held). + const [gridX, gridZ] = snapPointToGrid([m[0], m[1]], GRID_STEP) + + // 2) Alignment snap layered on top. Treat the grid-snapped point + // as the "proposed" position so alignment competes from a stable + // base rather than the raw cursor jitter. Alt bypasses alignment + // entirely — same affordance Path 1 advertises in its "No Snap" + // hint chip. + let finalX = gridX + let finalZ = gridZ + if (!event.altKey && candidateAnchors.length > 0) { + // Translate the cached local bbox to the proposed pos to get the + // moving anchors at that location. The entry's untransformed + // bbox is in world meters relative to the node's origin, so a + // simple translate suffices. + const dxProposed = gridX - originalPosition[0] + const dzProposed = gridZ - originalPosition[2] + const movingAnchors = bboxAnchors( + movingNode.id, + movingLocalBBox.x + dxProposed, + movingLocalBBox.y + dzProposed, + movingLocalBBox.x + movingLocalBBox.width + dxProposed, + movingLocalBBox.y + movingLocalBBox.height + dzProposed, + ) + const result = resolveAlignment({ + moving: movingAnchors, + candidates: candidateAnchors, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + finalX += result.snap.dx + finalZ += result.snap.dz + } + useAlignmentGuides.getState().set(result.guides) + } else { + useAlignmentGuides.getState().clear() + } + + const dx = finalX - originalPosition[0] + const dz = finalZ - originalPosition[2] entry.setAttribute('transform', `translate(${dx} ${dz})`) - lastSnapped = [sx, sz] + lastSnapped = [finalX, finalZ] } const onPointerUp = (event: PointerEvent) => { @@ -414,12 +480,14 @@ export function FloorplanRegistryMoveOverlay() { } } entry.removeAttribute('transform') + useAlignmentGuides.getState().clear() setMovingNode(null) } const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { entry.removeAttribute('transform') + useAlignmentGuides.getState().clear() setMovingNode(null) } } @@ -432,6 +500,7 @@ export function FloorplanRegistryMoveOverlay() { window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKey) entry.removeAttribute('transform') + useAlignmentGuides.getState().clear() } }, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def]) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 45b9267d2..04d53d3d8 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -7,6 +7,7 @@ import { type FloorplanAffordanceSession, type FloorplanGeometry, type FloorplanPalette, + type FloorplanPoint, type GeometryContext, kindsWithFloorplanScope, nodeRegistry, @@ -85,6 +86,26 @@ type ActiveDrag = { session: FloorplanAffordanceSession snapshots: NodeSnapshot[] historyPaused: boolean + /** + * Set only for rotate-arrow drags (handles that carry a `pivot`). Drives + * the live angle wedge + degree readout — the 2D twin of the 3D rotate + * gizmo's readout. The bearing sweep is measured the same way every + * rotate affordance measures it: `atan2(pointer − pivot)`. + */ + rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number } +} + +/** + * Transient live-rotation readout state. Rebuilt each pointer-move while a + * rotate-arrow is dragged and cleared on release. World-plan coords. + */ +type RotationOverlayState = { + pivot: FloorplanPoint + startAngle: number + endAngle: number + radius: number + /** Swept magnitude in radians, for the degree chip. */ + sweep: number } function snapshotNode(node: AnyNode): NodeSnapshot { @@ -193,6 +214,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const dragRef = useRef(null) const [hoveredHandleId, setHoveredHandleId] = useState(null) const [activeDragId, setActiveDragId] = useState(null) + const [rotationOverlay, setRotationOverlay] = useState(null) const handleSelect = useCallback( (id: AnyNodeId, event: React.PointerEvent) => { @@ -436,6 +458,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { affordance: string, payload: unknown, event: ReactPointerEvent, + // Present only for rotate-arrow handles — the pivot the node turns + // around, used to drive the live angle wedge + degree readout. + rotationPivot?: FloorplanPoint, ) => { if (event.button !== 0) return if (movingNode) return @@ -469,12 +494,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { pauseSceneHistory(useScene) + // Rotation readout setup. The wedge radius tracks the grab distance + // from the pivot (≈ the handle's orbit), nudged inward so the swept + // fill reads as the handle swinging round rather than overlapping it, + // and floored so a tight footprint still shows a legible wedge. + let rotation: ActiveDrag['rotation'] + if (rotationPivot) { + const dx = initialPlanPoint[0] - rotationPivot[0] + const dz = initialPlanPoint[1] - rotationPivot[1] + rotation = { + pivot: rotationPivot, + initialAngle: Math.atan2(dz, dx), + radius: Math.max(Math.hypot(dx, dz) * 0.72, 0.25), + } + } + dragRef.current = { pointerId: event.pointerId, handleId, session, snapshots, historyPaused: true, + rotation, } setActiveDragId(handleId) setSelection({ selectedIds: [nodeId] }) @@ -500,6 +541,30 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { metaKey: event.metaKey, }, }) + + // Live rotation readout. Sweep from the bearing at grab to the + // current pointer bearing around the pivot — the same measurement + // every rotate affordance applies — and surface it as a wedge + + // degree chip. Suppressed below ~0.5° so a fresh grab doesn't flash + // a zero-width sliver. + const rot = drag.rotation + if (rot) { + const current = Math.atan2(planPoint[1] - rot.pivot[1], planPoint[0] - rot.pivot[0]) + let delta = current - rot.initialAngle + while (delta > Math.PI) delta -= 2 * Math.PI + while (delta < -Math.PI) delta += 2 * Math.PI + if (Math.abs(delta) < 0.0087) { + setRotationOverlay(null) + } else { + setRotationOverlay({ + pivot: rot.pivot, + startAngle: rot.initialAngle, + endAngle: rot.initialAngle + delta, + radius: rot.radius, + sweep: Math.abs(delta), + }) + } + } } const onPointerUp = (event: PointerEvent) => { @@ -524,6 +589,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { sfxEmitter.emit('sfx:structure-build') dragRef.current = null setActiveDragId(null) + setRotationOverlay(null) return } @@ -574,6 +640,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { dragRef.current = null setActiveDragId(null) + setRotationOverlay(null) } const onPointerCancel = (event: PointerEvent) => { @@ -595,6 +662,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { dragRef.current = null setActiveDragId(null) + setRotationOverlay(null) } window.addEventListener('pointermove', onPointerMove) @@ -656,8 +724,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { hoveredHandleId={hoveredHandleId} nodeId={id} onHandleHoverChange={setHoveredHandleId} - onHandlePointerDown={(affordance, payload, event) => - startAffordanceDrag(id, makeHandleId(id, payload), affordance, payload, event) + onHandlePointerDown={(affordance, payload, event, rotationPivot) => + startAffordanceDrag( + id, + makeHandleId(id, payload), + affordance, + payload, + event, + rotationPivot, + ) } onMoveHandlePointerDown={(event) => { if (event.button !== 0) return @@ -715,6 +790,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { overlay ? renderEntry(id, overlay, `overlay-${id}`) : null, )} + {/* Transient live-rotation readout — drawn last so the wedge + degree + chip sit above all handle chrome while a rotate-arrow is dragged. */} + {rotationOverlay && palette ? ( + + ) : null} ) }) @@ -747,6 +832,9 @@ function InteractiveGeometry({ affordance: string, payload: unknown, event: ReactPointerEvent, + // Forwarded only by rotate-arrow handles — the pivot the drag turns + // the node around, used to drive the live angle wedge + degree chip. + rotationPivot?: FloorplanPoint, ) => void onMoveHandlePointerDown: (event: ReactPointerEvent) => void }): React.ReactElement { @@ -1010,6 +1098,7 @@ function InteractiveGeometry({ const angleDeg = (g.angle * 180) / Math.PI const affordance = g.affordance const payload = g.payload + const pivot = g.pivot return ( - onHandlePointerDown(affordance, payload, e as ReactPointerEvent) + onHandlePointerDown( + affordance, + payload, + e as ReactPointerEvent, + pivot, + ) } onPointerEnter={() => onHandleHoverChange(handleId)} onPointerLeave={() => onHandleHoverChange(null)} @@ -1047,7 +1141,12 @@ function InteractiveGeometry({ d={`${head1} ${head2}`} fill="transparent" onPointerDown={(e) => - onHandlePointerDown(affordance, payload, e as ReactPointerEvent) + onHandlePointerDown( + affordance, + payload, + e as ReactPointerEvent, + pivot, + ) } onPointerEnter={() => onHandleHoverChange(handleId)} onPointerLeave={() => onHandleHoverChange(null)} @@ -1370,9 +1469,17 @@ function InteractiveGeometry({ const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf] const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf] + // Keep the label parallel to the dimension line, but decide the + // 180° flip from the on-SCREEN angle, not the local one. The parent + // `` is rotated by `sceneRotationDeg` (default 90° in the floor + // plan), so a label kept upright in local coords still renders + // upside down for half of the wall orientations. Same fix as the + // `dimension-label` case above. let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI - if (labelDeg > 90) labelDeg -= 180 - else if (labelDeg <= -90) labelDeg += 180 + let screenDeg = labelDeg + sceneRotationDeg + screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180 + if (screenDeg > 90) labelDeg -= 180 + else if (screenDeg <= -90) labelDeg += 180 return ( @@ -1651,6 +1758,95 @@ function deepEqual(a: unknown, b: unknown): boolean { return false } +const ROTATION_WEDGE_COLOR = '#8381ed' +const ROTATION_WEDGE_SEGMENTS = 48 + +/** + * Live rotation readout for the floor plan — the 2D twin of the 3D rotate + * gizmo's wedge. Draws a filled sector + outline swept from the pointer's + * bearing at grab (`startAngle`) to its current bearing (`endAngle`) around + * the pivot, plus an upright degree chip at the wedge midpoint. All geometry + * is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads + * horizontally regardless of the building's on-screen orientation. + */ +function RotationAngleOverlay({ + overlay, + palette, + unitsPerPixel, + sceneRotationDeg, +}: { + overlay: RotationOverlayState + palette: FloorplanPalette + unitsPerPixel: number + sceneRotationDeg: number +}): React.ReactElement { + const { pivot, startAngle, endAngle, radius, sweep } = overlay + const span = endAngle - startAngle + const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS)) + let d = `M ${pivot[0]} ${pivot[1]}` + for (let i = 0; i <= count; i++) { + const a = startAngle + (span * i) / count + d += ` L ${pivot[0] + Math.cos(a) * radius} ${pivot[1] + Math.sin(a) * radius}` + } + d += ' Z' + + const midAngle = startAngle + span / 2 + const labelDist = radius + unitsPerPixel * 14 + const lx = pivot[0] + Math.cos(midAngle) * labelDist + const ly = pivot[1] + Math.sin(midAngle) * labelDist + + const text = `${Math.round((sweep * 180) / Math.PI)}°` + const padX = unitsPerPixel * 6 + const padY = unitsPerPixel * 3 + const fontSize = Math.max(unitsPerPixel * 10, 0.08) + const textWidth = text.length * unitsPerPixel * 6.2 + const plateW = textWidth + padX * 2 + const plateH = fontSize + padY * 2 + + return ( + + + + {/* Counter-rotate the scene transform so the chip stays horizontal. */} + + + + {text} + + + + ) +} + function formatGroupTransform(t?: { translate?: readonly [number, number] rotate?: number diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 543a0c4bd..38091ba7d 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -545,12 +545,12 @@ export const CustomCameraControls = () => { } // Preset capture mode frames a single subtree (often a 0.3–2m preset), - // so the default 10m minDistance prevents the user from getting close + // so the default 6m minDistance prevents the user from getting close // enough to compose a good thumbnail. Relax the clamp to 0.5m while // capturing presets; reset on exit so general editing keeps the looser // navigation guardrails. const isPresetCapture = captureMode.mode === 'preset' - const minDistance = isPresetCapture ? 0.5 : 10 + const minDistance = isPresetCapture ? 0.5 : 6 return ( entry.visible) : material.visible } +// Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a +// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every +// merged geometry to share the same typed-array constructor for matching attributes, so +// imported item GLBs using KHR_mesh_quantization or interleaved buffers must be coerced +// to Float32 to match wall/slab geometry. +function toFloat32Attribute(source: THREE.BufferAttribute | THREE.InterleavedBufferAttribute) { + const itemSize = source.itemSize + const array = new Float32Array(source.count * itemSize) + for (let i = 0; i < source.count; i++) { + const offset = i * itemSize + array[offset] = source.getX(i) + if (itemSize > 1) array[offset + 1] = source.getY(i) + if (itemSize > 2) array[offset + 2] = source.getZ(i) + if (itemSize > 3) array[offset + 3] = source.getW(i) + } + return new THREE.BufferAttribute(array, itemSize) +} + function cloneWorldGeometry(mesh: THREE.Mesh) { const sourceGeometry = mesh.geometry const position = sourceGeometry.getAttribute('position') @@ -65,11 +83,11 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() const cleanGeometry = new THREE.BufferGeometry() - cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) + cleanGeometry.setAttribute('position', toFloat32Attribute(workingGeometry.getAttribute('position'))) const normal = workingGeometry.getAttribute('normal') if (normal) { - cleanGeometry.setAttribute('normal', normal.clone()) + cleanGeometry.setAttribute('normal', toFloat32Attribute(normal)) } else { cleanGeometry.computeVertexNormals() } diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index efd5691c2..d1de7aaf5 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -5,10 +5,13 @@ import { type AnyNodeId, type CeilingNode, ColumnNode, + DEFAULT_WALL_HEIGHT, DoorNode, ElevatorNode, FenceNode, generateId, + getWallCurveLength, + getWallThickness, ItemNode, isRegistryMovable, isRegistrySelectable, @@ -19,6 +22,7 @@ import { StairNode, StairSegmentNode, sceneRegistry, + useLiveNodeOverrides, useScene, WallNode, WindowNode, @@ -32,6 +36,7 @@ import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' import useEditor from '../../store/use-editor' +import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' const ALLOWED_TYPES = [ @@ -76,9 +81,10 @@ const MENU_Y_OFFSETS: Record = { door: 0.6, window: 0.6, column: 0.6, - // Fence: clears the height-resize arrow (sits at fence.height + 0.45) - // plus the chevron's own visual size, so the menu floats just above it. - fence: 1.05, + // Fence: still clears the height-resize arrow (sits at fence.height + + // 0.45) plus the chevron's visual size, but kept low so the menu sits + // close to the fence rather than floating well above it. + fence: 0.7, // Elevator: clears the cab-height arrow which sits above the SHAFT // top (resolved through level entries), so the menu floats above it. elevator: 0.9, @@ -105,6 +111,32 @@ function getMenuYOffset(node: AnyNode | null): number { return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT } +// Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the +// pill reads sensibly before an explicit height / thickness is set. +const FENCE_DEFAULT_HEIGHT = 1.8 +const FENCE_DEFAULT_THICKNESS = 0.08 + +// Dimensions for the height-drag pill. Walls and fences both carry +// start/end/curveOffset, so getWallCurveLength covers length for either. +function getHeightPillDimensions(node: WallNode | FenceNode): { + height: number + length: number + thickness: number +} { + if (node.type === 'wall') { + return { + height: node.height ?? DEFAULT_WALL_HEIGHT, + length: getWallCurveLength(node), + thickness: getWallThickness(node), + } + } + return { + height: node.height ?? FENCE_DEFAULT_HEIGHT, + length: getWallCurveLength(node), + thickness: node.thickness ?? FENCE_DEFAULT_THICKNESS, + } +} + export function FloatingActionMenu() { const selectedIds = useViewer((s) => s.selection.selectedIds) const updateNode = useScene((s) => s.updateNode) @@ -118,9 +150,15 @@ export function FloatingActionMenu() { const setCurvingFence = useEditor((s) => s.setCurvingFence) const setSelection = useViewer((s) => s.setSelection) const setEditingHole = useEditor((s) => s.setEditingHole) + const unit = useViewer((s) => s.unit) + // Drives the height-drag dimension pill below the menu. `activeHandleDrag` + // flips only at drag start / end, so subscribing here is cheap — the live + // height value is written imperatively in the useFrame below. + const activeHandleDrag = useEditor((s) => s.activeHandleDrag) const groupRef = useRef(null) const menuScaleRef = useRef(null) + const pillHeightRef = useRef(null) // Only show for single selection of specific types const selectedId = selectedIds.length === 1 ? selectedIds[0] : null @@ -135,6 +173,17 @@ export function FloatingActionMenu() { ? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type) : false + // Height-drag pill: shown just above the menu only while the selected + // wall/fence height arrow is being dragged. Length + thickness are fixed + // during a height drag, so they're computed here; the live height value + // is updated imperatively in the useFrame (same pattern as the scale). + const pillNode = node?.type === 'wall' || node?.type === 'fence' ? node : null + const isHeightDragPill = + pillNode !== null && + activeHandleDrag?.nodeId === selectedId && + activeHandleDrag?.label === 'height' + const pillDims = pillNode ? getHeightPillDimensions(pillNode) : null + // Boolean selector, only re-renders when curving availability actually flips. const canCurveSelectedWall = useScene((s) => { if (!selectedId) return false @@ -169,6 +218,23 @@ export function FloatingActionMenu() { menuScaleRef.current.style.transform = `scale(${scale})` } + // Live height readout for the drag pill. The dragged height lands in + // `useLiveNodeOverrides` (not the scene store) each frame, so read it + // imperatively here instead of forcing a per-frame React re-render. + if ( + pillHeightRef.current && + (node?.type === 'wall' || node?.type === 'fence') && + activeHandleDrag?.nodeId === selectedId && + activeHandleDrag?.label === 'height' + ) { + const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as + | { height?: number } + | undefined + const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT + const liveHeight = override?.height ?? node.height ?? fallbackHeight + pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}` + } + const obj = sceneRegistry.nodes.get(selectedId) if (obj) { // Calculate bounding box in world space @@ -313,15 +379,22 @@ export function FloatingActionMenu() { } // Duplicate children for stair nodes - } else if (duplicate.type === 'chimney' || duplicate.type === 'dormer') { - // Chimney & dormer use pure drag-to-place: NO node is - // inserted into the scene until the user clicks a roof - // segment. The `setMovingNode` call below hands the clone - // (with `metadata.isNew = true` + no id) to - // `MoveChimneyTool` / `MoveDormerTool`, which call - // `createNode` on the click that commits the placement. - // Skipping the auto-create avoids the "duplicate appears at - // +1 offset before drag" UX the other registry kinds use. + } else if ( + duplicate.type === 'item' || + duplicate.type === 'chimney' || + duplicate.type === 'dormer' + ) { + // Items, chimneys & dormers use pure drag-to-place: NO node is + // inserted into the scene until the user clicks to commit. The + // `setMovingNode` call below hands the clone (with + // `metadata.isNew = true` + no id) to its move tool — + // `MoveItemTool` / `MoveChimneyTool` / `MoveDormerTool` — which + // create a draft and call `createNode` on the commit click. + // Pre-creating here would drop a second copy into the scene + // before any click — the furnish-tab "duplicate auto-places an + // item without clicking" bug. (Item has its own + // draft-committing move tool, so it must skip the generic + // registry auto-create branch below.) } else if (nodeRegistry.has(duplicate.type)) { // Registry-driven kinds: offset the position slightly so the // duplicate doesn't overlap exactly, then create + hand to the @@ -435,7 +508,7 @@ export function FloatingActionMenu() { }} zIndexRange={[100, 0]} > -
+
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> + {/* Height-drag dimension pill. Absolutely positioned just above + the menu (away from the height arrow below it) so it rides the + same scale transform + anchor, never overlaps the menu, and + needs no menu lift — which is what caused the click flicker. + Non-interactive. */} + {isHeightDragPill && pillDims ? ( +
+ +
+ ) : null}
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 965e77afd..1abbd6dd6 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -59,6 +59,7 @@ import { useState, } from 'react' import { createPortal } from 'react-dom' +import { Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' import { buildFloorplanItemEntry, @@ -73,6 +74,7 @@ import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' import type { GuideUiState } from '../../store/use-editor' import useEditor from '../../store/use-editor' +import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers' import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu' @@ -4516,6 +4518,21 @@ export function FloorplanPanel() { const displaySlabPolygons = useMemo(() => [], []) const ceilingPolygons = useMemo(() => [], []) const displayCeilingPolygons = useMemo(() => [], []) + // Ceilings on the active level, projected to 2D polygons for hit-testing + // ceiling-item placement clicks/moves. The legacy `ceilingPolygons` above + // is intentionally empty (ceilings render via the registry layer); this + // memo is the placement-side counterpart, separate from rendering. + const ceilingHitEntries = useMemo( + () => + ceilings.map((ceiling) => ({ + ceiling, + polygon: toFloorplanPolygon(ceiling.polygon), + holes: (ceiling.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3), + })), + [ceilings], + ) // Zone fully registry-driven via `def.floorplan`. const zonePolygons = useMemo(() => [], []) const displayZonePolygons = useMemo(() => [], []) @@ -4799,6 +4816,18 @@ export function FloorplanPanel() { (mode === 'build' && tool === 'item') || movingNode?.type === 'item' const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo + // Ceiling-attached items (lights, fans). The 3D viewer drives these via + // `ceiling:enter/move/click` raycast events on the ceiling mesh; the 2D + // floor plan has no such mesh, so we synthesise the same events when the + // cursor is over a ceiling polygon. Without this the placement system + // never transitions out of `surface: 'floor'` and the draft sits at floor + // height while the 2D cursor moves freely — what the user perceived as + // "2D and 3D positions are out of sync". + const isCeilingItemBuildActive = + mode === 'build' && tool === 'item' && selectedItem?.attachTo === 'ceiling' + const isCeilingItemMoveActive = + movingNode?.type === 'item' && movingNode.asset.attachTo === 'ceiling' + const isCeilingItemPlacementActive = isCeilingItemBuildActive || isCeilingItemMoveActive // Any registry-driven kind whose tool is currently active. Lets the floor // plan emit `grid:click` / `grid:move` events to that kind's placement tool // (shelf today; future Phase 5 kinds the moment they register a `tool`). @@ -7119,6 +7148,7 @@ export function FloorplanPanel() { }, []) const hoveredWallIdRef = useRef(null) + const hoveredCeilingIdRef = useRef(null) const floorplanGridLocalY = useMemo(() => { if (movingNode?.type === 'item' || movingNode?.type === 'spawn') { return movingNode.position[1] @@ -7171,6 +7201,156 @@ export function FloorplanPanel() { [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY], ) + // Build a synthetic `CeilingEvent` from a 2D plan point so the placement + // coordinator's existing ceiling handlers (which expect the same payload + // shape the 3D raycaster produces) can drive a ceiling-attached item + // placement from the floor plan. Returns null if the ceiling mesh isn't + // registered yet — the placement strategy needs `event.object` for the + // ceiling-local↔world transform. + const buildFloorplanCeilingEventPayload = useCallback( + ( + ceiling: CeilingNode, + planPoint: WallPlanPoint, + nativeEvent?: ReactMouseEvent | ReactPointerEvent, + ) => { + const ceilingMesh = sceneRegistry.nodes.get(ceiling.id as AnyNodeId) + if (!ceilingMesh) return null + + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin + const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos + const worldY = ceilingMesh.getWorldPosition(new Vector3()).y + const localVec = ceilingMesh.worldToLocal(new Vector3(worldX, worldY, worldZ)) + + return { + node: ceiling, + position: [worldX, worldY, worldZ] as [number, number, number], + localPosition: [localVec.x, localVec.y, localVec.z] as [number, number, number], + normal: [0, -1, 0] as [number, number, number], + object: ceilingMesh, + stopPropagation: () => {}, + nativeEvent: nativeEvent?.nativeEvent, + } + }, + [buildingPosition, buildingRotationY], + ) + + const emitFloorplanCeilingLeave = useCallback((ceilingId: string | null) => { + if (!ceilingId) return + const ceilingNode = useScene.getState().nodes[ceilingId as AnyNodeId] + if (!ceilingNode || ceilingNode.type !== 'ceiling') return + + emitter.emit('ceiling:leave', { + node: ceilingNode, + position: [0, 0, 0], + localPosition: [0, 0, 0], + normal: [0, -1, 0], + stopPropagation: () => {}, + } as any) + }, []) + + // Clear the hovered-ceiling tracker whenever placement mode ends, so we + // don't carry a stale ceiling id into the next placement session. + useEffect(() => { + if (!isCeilingItemPlacementActive && hoveredCeilingIdRef.current) { + hoveredCeilingIdRef.current = null + } + }, [isCeilingItemPlacementActive]) + + const findCeilingAtPlanPoint = useCallback( + (planPoint: WallPlanPoint): CeilingNode | null => { + if (ceilingHitEntries.length === 0) return null + const point: Point2D = { x: planPoint[0], y: planPoint[1] } + for (const entry of ceilingHitEntries) { + if (isPointInsidePolygonWithHoles(point, entry.polygon, entry.holes)) { + return entry.ceiling + } + } + return null + }, + [ceilingHitEntries], + ) + + // Route a 2D plan point through ceiling enter/move/leave events when a + // ceiling-attached item is being placed. Returns true when the panel + // should stop further pointer-move processing (we either emitted a + // ceiling event or are between ceilings). + const handleCeilingItemPlacementMove = useCallback( + ( + planPoint: WallPlanPoint, + nativeEvent: ReactPointerEvent, + ): boolean => { + if (!isCeilingItemPlacementActive) return false + + const ceiling = findCeilingAtPlanPoint(planPoint) + if (!ceiling) { + if (hoveredCeilingIdRef.current) { + emitFloorplanCeilingLeave(hoveredCeilingIdRef.current) + hoveredCeilingIdRef.current = null + } + return true + } + + const payload = buildFloorplanCeilingEventPayload(ceiling, planPoint, nativeEvent) + if (!payload) return true + + if (hoveredCeilingIdRef.current !== ceiling.id) { + if (hoveredCeilingIdRef.current) { + emitFloorplanCeilingLeave(hoveredCeilingIdRef.current) + } + hoveredCeilingIdRef.current = ceiling.id + emitter.emit('ceiling:enter', payload as any) + } else { + emitter.emit('ceiling:move', payload as any) + } + return true + }, + [ + buildFloorplanCeilingEventPayload, + emitFloorplanCeilingLeave, + findCeilingAtPlanPoint, + isCeilingItemPlacementActive, + ], + ) + + // Click counterpart — used by the background placement click handler. + // Returns true if the click was a valid ceiling-item placement. + const handleCeilingItemPlacementClick = useCallback( + ( + planPoint: WallPlanPoint, + nativeEvent: ReactMouseEvent, + ): boolean => { + if (!isCeilingItemPlacementActive) return false + const ceiling = findCeilingAtPlanPoint(planPoint) + if (!ceiling) return true + + // Ensure the placement state has entered the ceiling surface; the + // user can click without a prior move (e.g. fresh placement after + // selecting the asset from the catalog). + if (hoveredCeilingIdRef.current !== ceiling.id) { + const enterPayload = buildFloorplanCeilingEventPayload(ceiling, planPoint, nativeEvent) + if (!enterPayload) return true + if (hoveredCeilingIdRef.current) { + emitFloorplanCeilingLeave(hoveredCeilingIdRef.current) + } + hoveredCeilingIdRef.current = ceiling.id + emitter.emit('ceiling:enter', enterPayload as any) + } + + const clickPayload = buildFloorplanCeilingEventPayload(ceiling, planPoint, nativeEvent) + if (!clickPayload) return true + emitter.emit('ceiling:click', clickPayload as any) + return true + }, + [ + buildFloorplanCeilingEventPayload, + emitFloorplanCeilingLeave, + findCeilingAtPlanPoint, + isCeilingItemPlacementActive, + ], + ) + const handlePointerMove = useCallback( (event: ReactPointerEvent) => { if (panStateRef.current?.pointerId === event.pointerId) { @@ -7357,6 +7537,20 @@ export function FloorplanPanel() { return } + // Ceiling-attached item placement. Same shape as the opening branch + // above: synthesise the surface events the placement coordinator + // already listens for (ceiling:enter / move / leave) instead of + // routing through `grid:move`, which would otherwise be processed + // by the floor strategy and drop the item to floor height. + if (isCeilingItemPlacementActive) { + const snappedPoint = getSnappedFloorplanPoint(planPoint) + setCursorPoint((previousPoint) => + previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, + ) + handleCeilingItemPlacementMove(snappedPoint, event) + return + } + // Registry-driven catch-all for kinds without bespoke 2D handling // (shelf, etc.). Must run AFTER the opening branch above (door / // window are also registered kinds, but need wall events — see @@ -7427,7 +7621,9 @@ export function FloorplanPanel() { fittedViewport, getPlanPointFromClientPoint, activePolygonDraftPoints, + handleCeilingItemPlacementMove, isCeilingBuildActive, + isCeilingItemPlacementActive, isFenceBuildActive, isFloorplanGridInteractionActive, isMarqueeSelectionToolActive, @@ -7650,11 +7846,13 @@ export function FloorplanPanel() { findClosestWallPoint, floorplanOpeningLocalY, getSnappedFloorplanPoint, + handleCeilingItemPlacementClick, handleCeilingPlacementPoint, handleSlabPlacementPoint, handleWallPlacementPoint, handleZonePlacementPoint, isCeilingBuildActive, + isCeilingItemPlacementActive, isFenceBuildActive, isFloorplanGridInteractionActive, isOpeningPlacementActive, @@ -8937,6 +9135,12 @@ export function FloorplanPanel() { attribute below); see floorplan-registry-move-overlay.tsx. */} + {/* Figma-style alignment guides published by the move + overlay during a free-translate drag. Sits above the + registry layer so the red lines and distance pills + paint on top of node geometry. */} + + } {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } + {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } diff --git a/packages/editor/src/components/editor/measurement-pill.tsx b/packages/editor/src/components/editor/measurement-pill.tsx new file mode 100644 index 000000000..892f5d011 --- /dev/null +++ b/packages/editor/src/components/editor/measurement-pill.tsx @@ -0,0 +1,75 @@ +'use client' + +import { type ForwardedRef, Fragment, forwardRef } from 'react' + +// Canonical in-world dimension formatter — metric metres or imperial +// feet/inches. Shared by every measurement readout so they read the same. +export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): string { + if (unit === 'imperial') { + const feet = value * 3.280_84 + const wholeFeet = Math.floor(feet) + const inches = Math.round((feet - wholeFeet) * 12) + if (inches === 12) return `${wholeFeet + 1}'0"` + return `${wholeFeet}'${inches}"` + } + return `${Number.parseFloat(value.toFixed(2))}m` +} + +type MeasurePart = 'height' | 'length' | 'thickness' + +const PART_ORDER: { key: MeasurePart; prefix: string }[] = [ + { key: 'height', prefix: 'H' }, + { key: 'length', prefix: 'L' }, + { key: 'thickness', prefix: 'T' }, +] + +/** + * Floating dimension pill shown during wall / fence drags: `H · L · T` with + * the actively-dragged dimension emphasised. Styled to match the top-center + * floating info bar (rounded-full, design-token colours) so it tracks the + * app theme. + * + * The forwarded ref points at the `primary` value's `` so a caller + * driving a per-frame drag (the height arrow) can rewrite its text + * imperatively without a React re-render. Callers that re-render naturally + * (the endpoint tools) ignore the ref and just pass live values as props. + */ +export const MeasurementPill = forwardRef(function MeasurementPill( + { + height, + length, + thickness, + unit, + primary, + }: { + height: number + length: number + thickness: number + unit: 'metric' | 'imperial' + primary: MeasurePart + }, + primaryRef: ForwardedRef, +) { + const values: Record = { height, length, thickness } + return ( +
+ {PART_ORDER.map((part, index) => ( + + {index > 0 ? ( + + · + + ) : null} + + {`${part.prefix} ${formatMeasurement(values[part.key], unit)}`} + + + ))} +
+ ) +}) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 81074b777..2f2f8aa82 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -13,6 +13,7 @@ import { type RadialResizeHandle, sceneRegistry, type TapActionHandle, + type TranslateHandle, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -21,11 +22,13 @@ import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { - type BufferGeometry, + BoxGeometry, + BufferGeometry, Color, CylinderGeometry, DoubleSide, ExtrudeGeometry, + Float32BufferAttribute, type Group, Matrix4, type Object3D, @@ -43,9 +46,15 @@ import { EDITOR_LAYER } from '../../lib/constants' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { snapToGrid } from '../tools/item/placement-math' +import { formatAngleRadians } from '../tools/shared/segment-angle' const ARROW_SCALE = 0.65 const ARROW_COLOR = '#8381ed' +// How far a DOWNWARD tracker's dashed leader pokes past its cube so the +// dashes visibly thread through it (the cube sits ON the line, not at +// its end). Upward trackers — wall / chimney height — stop at the cube. +const TRACKER_THROUGH = 0.12 // Mirrors the formatter used by wall / fence measurement labels so all // in-world dimension chips read consistently. @@ -207,6 +216,65 @@ function createArrowHandleGeometry() { return geometry } +// Double-headed straight arrow silhouette, drawn in 2D pointing along ±X. +// A thin ribbon between two arrowheads. Two of these (one rotated 90°) +// merge into the 4-way move cross. +function createDoubleArrowShape(): Shape { + const L = 0.36 // half-length to each tip + const rw = 0.03 // ribbon half-width + const hw = 0.12 // arrowhead half-width + // Long inner ribbon so opposing arrowheads sit well apart rather than + // meeting in a cramped knot at the centre. + const hx = 0.2 // where each arrowhead meets the ribbon + const shape = new Shape() + shape.moveTo(L, 0) // right tip + shape.lineTo(hx, hw) + shape.lineTo(hx, rw) + shape.lineTo(-hx, rw) + shape.lineTo(-hx, hw) + shape.lineTo(-L, 0) // left tip + shape.lineTo(-hx, -hw) + shape.lineTo(-hx, -rw) + shape.lineTo(hx, -rw) + shape.lineTo(hx, -hw) + shape.closePath() + return shape +} + +// 4-way move cross: two double-headed arrows (±X and ±Z) lying flat in the +// XZ plane. Drawn on top (depthTest off, shared arrow material) so it reads +// as a floor-move grip centred on the item. +function createMoveCrossHandleGeometry() { + const shape = createDoubleArrowShape() + const extrudeOpts = { + depth: 0.06, + bevelEnabled: true, + bevelThickness: 0.018, + bevelSize: 0.012, + bevelOffset: 0, + bevelSegments: 6, + curveSegments: 8, + steps: 1, + } + const armX = new ExtrudeGeometry(shape, extrudeOpts) + armX.translate(0, 0, -0.03) + armX.rotateX(-Math.PI / 2) // lay flat → points along ±X in XZ + const armZ = armX.clone() + armZ.rotateY(Math.PI / 2) // second arm → points along ±Z + const merged = mergeGeometries([armX, armZ], false) + if (!merged) { + armZ.dispose() + armX.computeVertexNormals() + armX.computeBoundingSphere() + return armX + } + armX.dispose() + armZ.dispose() + merged.computeVertexNormals() + merged.computeBoundingSphere() + return merged +} + function swallowNextClick() { const swallow = (clickEvent: Event) => { clickEvent.stopPropagation() @@ -223,6 +291,14 @@ export function NodeArrowHandles() { const mode = useEditor((state) => state.mode) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const movingNode = useEditor((state) => state.movingNode) + // Endpoint / curve drags reshape the selected wall or fence; hide its + // resize arrows for the duration so they don't clutter (or get blocked + // by) the drag's own cursor + dimension overlays. Mirrors the same guard + // on the legacy wall handles (`WallMoveSideHandles`). + const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) + const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) + const curvingWall = useEditor((state) => state.curvingWall) + const curvingFence = useEditor((state) => state.curvingFence) const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const rawNode = useScene((state) => @@ -250,10 +326,24 @@ export function NodeArrowHandles() { }, [node, def]) const shouldRender = - Boolean(node && descriptors?.length) && !isFloorplanHovered && mode !== 'delete' && !movingNode + Boolean(node && descriptors?.length) && + !isFloorplanHovered && + mode !== 'delete' && + !movingNode && + !movingWallEndpoint && + !movingFenceEndpoint && + !curvingWall && + !curvingFence if (!shouldRender || !node || !descriptors) return null - return + // Key by the selected node id so switching selection REMOUNTS the rig. + // The portal target + ride-mesh refs are seeded from the scene registry + // in `useState` initializers; without a remount they'd persist from the + // previous selection and the arrows would ride the old node's world pose + // (right local placements, wrong frame) until the resolve effect happened + // to catch up. Remounting re-resolves both refs synchronously for the new + // node, so the arrows land in the right place the instant it's selected. + return } // Resolves the portal target + ride mesh chain. Descriptor-level `portal` @@ -338,16 +428,14 @@ function NodeArrowHandlesForNode({ const outerRef = useRef(null) const innerRef = useRef(null) - // Tag all arrow objects for EDITOR_LAYER so the ThumbnailGenerator camera - // (which calls cam.layers.disable(EDITOR_LAYER)) excludes them from captures. - // descriptors is used as a trigger dep: when handles change, new mesh objects - // are created and need their layers set even though it isn't read in the body. - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional trigger dep - useEffect(() => { - outerRef.current?.traverse((obj) => { - obj.layers.set(EDITOR_LAYER) - }) - }, [descriptors]) + // Keep arrow objects on SCENE_LAYER so the post-processing scenePass + // captures them in the depth/normal MRT — that's what feeds the ink-edge + // shader, and it's the reason the wall height arrow (which also stays on + // SCENE_LAYER) reads as a proper 3D plate with outlined edges. Putting + // them on EDITOR_LAYER hides them from scenePass and the chevron renders + // flat. Arrows are only mounted while a node is selected, so thumbnail + // captures (which never have selection) don't need the layer-based + // exclusion the wall arrow also goes without. useFrame(() => { if (outerRef.current && outerRide) { @@ -360,6 +448,34 @@ function NodeArrowHandlesForNode({ } }) + // Active-drag tracking. When a handle starts dragging, it claims its + // descriptor index here and snapshots the store node at drag-start. + // Non-active arrows re-render against the snapshot + a freeze offset + // that undoes the mesh's `position` drift in node-local frame — so + // asymmetric resize (width L/R, length L/R) doesn't visually slide the + // depth / height / rotate chevrons. They stay anchored at their + // pre-drag world positions for the duration of the drag. + // + // Hooks must sit ABOVE the early-return guard below — the registry- + // resolve `useEffect` flips `portalObject` from null → object after + // the first frame, so a guard between two hooks would change the + // hook count between renders and trip React's rules-of-hooks check. + const [activeIndex, setActiveIndex] = useState(null) + const [preDragNode, setPreDragNode] = useState(null) + const dragControls = useMemo( + () => ({ + onStart: (index: number, snapshot: AnyNode) => { + setActiveIndex(index) + setPreDragNode(snapshot) + }, + onEnd: () => { + setActiveIndex(null) + setPreDragNode(null) + }, + }), + [], + ) + if (!portalObject || !outerRide || (innerRideId !== null && !innerRide)) return null // `arrowFrame` is the Object3D used as the spatial reference for the @@ -370,14 +486,25 @@ function NodeArrowHandlesForNode({ // wall-riding outer wrapper. const arrowFrame = innerRide ?? outerRide + // A translate drag moves `position`, so the whole handle rig should travel + // with the mesh — the freeze-at-pre-drag mechanism (built for asymmetric + // resize that re-centres the mesh) must NOT fire for the non-active arrows + // here, or they'd lag behind the moving item. + const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate' + const arrows = descriptors.map((descriptor, index) => ( )) @@ -389,23 +516,120 @@ function NodeArrowHandlesForNode({ ) } +type DragControls = { + onStart: (index: number, snapshot: AnyNode) => void + onEnd: () => void +} + +// Offset, in node-local frame, that compensates for `position` drift on +// the mesh during an asymmetric resize. Width/length L+R recompute +// `position` so the anchored edge stays world-fixed — the renderer +// follows that override, the ride object moves, and every arrow under +// it would drift along with the mesh center. Subtracting this offset +// from a non-active arrow's local placement undoes that drift so it +// stays at its pre-drag world position. +// +// Rotation drags don't change `position`, so the offset collapses to +// zero and non-active arrows naturally rotate with the mesh — which is +// the desired behaviour (the whole rig rotates as a unit). +function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, number, number] { + // Not every node in the union carries a `position` field (sites are the + // notable holdout — they don't have handles anyway, but TypeScript still + // requires us to discriminate). Guarded access keeps the freeze logic + // safe for the few node kinds that lack the field. + const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0] + const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [ + 0, 0, 0, + ] + const deltaWorldX = liveP[0] - preP[0] + const deltaWorldY = liveP[1] - preP[1] + const deltaWorldZ = liveP[2] - preP[2] + const rotY = (preDragNode as { rotation?: number }).rotation ?? 0 + // World → node-local for Y-axis rotation by rotY (THREE.Object3D + // rotation-y convention): inverse is rotation by -rotY around +Y. + const cosR = Math.cos(rotY) + const sinR = Math.sin(rotY) + const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ + const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ + return [deltaLocalX, deltaWorldY, deltaLocalZ] +} + function ArrowHandle({ descriptor, - node, + liveNode, + preDragNode, + activeIndex, + handleIndex, + dragControls, rideObject, + suppressFreeze, }: { descriptor: HandleDescriptor - node: AnyNode + liveNode: AnyNode + preDragNode: AnyNode | null + activeIndex: number | null + handleIndex: number + dragControls: DragControls rideObject: Object3D + /** When the active drag is a translate, non-active arrows ride the moving + * mesh instead of freezing at their pre-drag world position. */ + suppressFreeze?: boolean }) { + // During a drag, non-active arrows render against the pre-drag store + // snapshot. The active arrow always uses the live (override-merged) + // node so it tracks the cursor. + const isOtherActive = activeIndex !== null && activeIndex !== handleIndex && preDragNode !== null + const placementNode = isOtherActive ? (preDragNode as AnyNode) : liveNode + const freezeOffset = + isOtherActive && preDragNode && !suppressFreeze + ? computeFreezeOffset(liveNode, preDragNode) + : null + if (descriptor.kind === 'linear-resize' || descriptor.kind === 'radial-resize') { - return + return ( + + ) } if (descriptor.kind === 'arc-resize') { - return + return ( + + ) + } + if (descriptor.kind === 'translate') { + return ( + + ) } if (descriptor.kind === 'tap-action') { - return + // Tap-action handles (fence side-move arrows, corner pickers) aren't + // resize handles, so the freeze-at-pre-drag mechanism — which only + // exists to stop arrows sliding when an asymmetric width/length resize + // re-centers the mesh — doesn't apply to them. Track the live node so + // their height-dependent placement (side arrows ride the top, corner + // leaders span the full height) follows a height drag in real time. + return } // endpoint-move not yet implemented. return null @@ -417,8 +641,16 @@ function useArrowMaterial(): MeshBasicNodeMaterial { new MeshBasicNodeMaterial({ color: new Color(ARROW_COLOR), side: DoubleSide, + // `depthTest: false` keeps the chevron drawing on top of any + // geometry under it; `depthWrite: true` puts the chevron's depth + // into the scenePass buffer so the ink-edge shader's depth + // Laplacian fires on its silhouette from every angle. Without + // depthWrite, only the normal-discontinuity branch can detect + // the chevron, and that signal collapses when the arrow's faces + // happen to align with whatever sits behind them in screen space + // — which is why the lines used to drop out depending on the view. depthTest: false, - depthWrite: false, + depthWrite: true, transparent: true, opacity: 1, }), @@ -447,10 +679,21 @@ function resolveBound( function LinearArrow({ descriptor, node, + liveNode, + freezeOffset, + handleIndex, + dragControls, rideObject, }: { descriptor: LinearResizeHandle | RadialResizeHandle + /** Effective node for placement (preDrag snapshot when another arrow is active). */ node: AnyNode + /** Always the live (override-merged) node — used inside drag handlers. */ + liveNode: AnyNode + /** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */ + freezeOffset: [number, number, number] | null + handleIndex: number + dragControls: DragControls rideObject: Object3D }) { const [isHovered, setIsHovered] = useState(false) @@ -470,9 +713,33 @@ function LinearArrow({ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) useEffect(() => () => dragCleanupRef.current?.(), []) + // Suppress "declared but unused" for `liveNode` — LinearArrow's apply + // operates on `initialNode` (snapshot inside activate) and reads value + // updates back via `useLiveNodeOverrides`. The prop is required for + // uniformity with ArrowHandle's variant dispatch but isn't consumed in + // this variant's render path. + void liveNode + const cursor = pickCursor(descriptor) + // When a handle declares `measureLabel`, its readout is routed to the + // floating dimension pill (via `activeHandleDrag`) and its own in-world + // chip is suppressed — matches the wall height handle. + const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined const placementSceneApi = useMemo(() => createSceneApi(useScene), []) - const position = descriptor.placement.position(node, placementSceneApi) + const basePosition = descriptor.placement.position(node, placementSceneApi) + // `freezeOffset` (in node-local frame) cancels the mesh's `position` + // drift while another arrow is being dragged — `basePosition` is + // computed against the pre-drag snapshot, then we subtract the offset + // so the arrow's WORLD location matches its pre-drag world location. + // Active arrows + idle state have `freezeOffset === null`, so the + // position passes through unchanged. + const position: [number, number, number] = freezeOffset + ? [ + basePosition[0] - freezeOffset[0], + basePosition[1] - freezeOffset[1], + basePosition[2] - freezeOffset[2], + ] + : [basePosition[0], basePosition[1], basePosition[2]] const baseRotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 // Default chevron points +X. Rotate around Y to face the chosen axis. const axisRotationY = descriptor.axis === 'z' ? -Math.PI / 2 : 0 @@ -522,6 +789,13 @@ function LinearArrow({ const nodeId = node.id as AnyNodeId const sceneApi = createSceneApi(useScene) const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode + // Cross-node handles (a downspout sliding its gutter's outlet) redirect + // the live override + commit to another node; defaults to the selected + // node. `currentValue` / `apply` still see the selected node. + const overrideId = + (descriptor.kind === 'linear-resize' + ? descriptor.overrideTarget?.(initialNode as never, sceneApi) + : undefined) ?? nodeId const initialValue = descriptor.currentValue(initialNode) const initialPointer = descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z @@ -548,6 +822,16 @@ function LinearArrow({ useViewer.getState().setInputDragging(true) useScene.temporal.getState().pause() setIsDragging(true) + // Claim active-drag status — `NodeArrowHandlesForNode` will pass the + // snapshot to every OTHER arrow so they render at their pre-drag + // world positions while this drag runs. The snapshot must be the + // pre-override store node (not the merged `liveNode`) so subsequent + // re-renders don't pollute it with this drag's own patch. + dragControls.onStart(handleIndex, initialNode) + // Publish the dimension being steered so the floating pill can show it. + if (measureLabel) { + useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel }) + } let lastPatch: Partial | null = null @@ -576,8 +860,8 @@ function LinearArrow({ // pre-drag geometry (door-width re-centers on the opposite edge). const patch = descriptor.apply(initialNode as never, next, sceneApi) lastPatch = patch as Partial - useLiveNodeOverrides.getState().set(nodeId, patch as Record) - useScene.getState().markDirty(nodeId) + useLiveNodeOverrides.getState().set(overrideId, patch as Record) + useScene.getState().markDirty(overrideId) } const cleanup = () => { @@ -590,6 +874,12 @@ function LinearArrow({ useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) setIsDragging(false) + if (measureLabel) { + useEditor.getState().setActiveHandleDrag(null) + } + // Release the active-drag claim so non-active arrows return to + // live-tracking (and so the next drag can claim its own snapshot). + dragControls.onEnd() dragCleanupRef.current = null } const onUp = () => { @@ -598,17 +888,17 @@ function LinearArrow({ // Commit: one tracked write to the scene store, then drop the // override so subscribers read from scene again. if (lastPatch) { - sceneApi.update(nodeId, lastPatch) + sceneApi.update(overrideId, lastPatch) } - useLiveNodeOverrides.getState().clear(nodeId) - useScene.getState().markDirty(nodeId) + useLiveNodeOverrides.getState().clear(overrideId) + useScene.getState().markDirty(overrideId) cleanup() } const onCancel = () => { // Revert: drop the override + mark dirty so the system rebuilds // against the original scene values. - useLiveNodeOverrides.getState().clear(nodeId) - useScene.getState().markDirty(nodeId) + useLiveNodeOverrides.getState().clear(overrideId) + useScene.getState().markDirty(overrideId) cleanup() } dragCleanupRef.current = cleanup @@ -637,9 +927,74 @@ function LinearArrow({ // is already the effective (override-merged) node, so currentValue // returns the in-flight value during a drag and the label tracks // smoothly without an extra subscription. - const showLabel = isHovered || isDragging + // `measureLabel` handles route their readout to the floating dimension + // pill, so suppress the inline chip here to avoid showing it twice. + const showLabel = (isHovered || isDragging) && !measureLabel const labelText = showLabel ? formatDimension(descriptor.currentValue(node), unit) : '' + // `tracker` shape on a linear-resize handle: render a dashed vertical + // leader from the floor up to a small cube at `placement.position`. The + // cube is the drag target and reuses the same `activate` pointer handler + // as the chevron path, so all the override/commit plumbing is unchanged. + // Only valid for axis='y' resize handles — the leader is rendered at + // (0,0,0)→(0,position.y,0) in the same group as the cube, so for x/z + // axes the leader would still climb vertically and look wrong. + const shape = + descriptor.kind === 'linear-resize' && descriptor.shape === 'tracker' ? 'tracker' : 'arrow' + + if (shape === 'tracker') { + // Descriptors can pin the leader's bottom Y above the floor — e.g. + // chimney body height starts at the deck plane, not at y=0, so the + // dashed leader spans only the body's visible extent. + const trackerDescriptor = descriptor as LinearResizeHandle + const baseY = trackerDescriptor.trackerBaseY?.(node as never, placementSceneApi) ?? 0 + // Leader spans base ↔ cube either direction. Upward (cube above base: + // wall / chimney height) it stops at the cube as before. Downward + // (cube below base: a downspout's length cube under the gutter + // outlet) it pokes `TRACKER_THROUGH` past the cube so the dashes + // thread through it instead of the leader collapsing to nothing. + const cubeY = position[1] + const cubeBelowBase = cubeY < baseY + const leaderBottomY = Math.min(baseY, cubeY) - (cubeBelowBase ? TRACKER_THROUGH : 0) + const leaderHeight = Math.max(Math.max(baseY, cubeY) - leaderBottomY, 0) + return ( + <> + {showDecoration && decoration ? ( + + ) : null} + {showLabel ? ( + + ) : null} + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = cursor + }} + onLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === cursor) { + document.body.style.cursor = '' + } + }} + zoom={zoom} + /> + + ) + } + return ( <> {showDecoration && decoration ? ( @@ -714,6 +1069,95 @@ function GuideRing({ radius, y }: { radius: number; y: number }) { ) } +const ROTATION_GUIDE_COLOR = ARROW_COLOR +const ROTATION_GUIDE_SEGMENTS = 48 +const NO_RAYCAST = () => null + +// Live rotation readout shown while a whole-node rotate gizmo is dragged. +// Mirrors the wall-draft angle arc: a filled wedge + outline swept from the +// pointer's bearing at grab (`startAngle`) to its current bearing +// (`endAngle`) around the rotation pivot, plus a degree chip at the wedge's +// midpoint. All coordinates are world-space — the guide is portalled to the +// scene root so it stays fixed while the node mesh rotates underneath it. +type RotationGuideData = { + center: [number, number, number] + startAngle: number + endAngle: number + radius: number + labelPos: [number, number, number] + /** Swept magnitude in radians, for the degree chip. */ + sweep: number +} + +function RotationGuide({ data }: { data: RotationGuideData }) { + const { center, startAngle, endAngle, radius, labelPos, sweep } = data + const { outline, fill } = useMemo(() => { + const span = endAngle - startAngle + const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS)) + const arc = Array.from({ length: count + 1 }, (_, index) => { + const angle = startAngle + (span * index) / count + return new Vector3( + center[0] + Math.cos(angle) * radius, + center[1], + center[2] + Math.sin(angle) * radius, + ) + }) + const centerV = new Vector3(center[0], center[1], center[2]) + const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV]) + const positions: number[] = [] + for (let i = 0; i < arc.length - 1; i++) { + const a = arc[i] + const b = arc[i + 1] + if (!a || !b) continue + positions.push(centerV.x, centerV.y, centerV.z, a.x, a.y, a.z, b.x, b.y, b.z) + } + const fillGeo = new BufferGeometry() + fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3)) + return { outline: outlineGeo, fill: fillGeo } + }, [center, startAngle, endAngle, radius]) + useEffect(() => () => outline.dispose(), [outline]) + useEffect(() => () => fill.dispose(), [fill]) + + return ( + <> + + + + + + + ) +} + +function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) { + return ( + // @ts-expect-error - R3F accepts Three line primitives, matching the wall draft arc. + + + + ) +} + // Angular drag: project pointer to a horizontal plane at the arrow's Y // and measure the signed angle around the node's local origin (in world // XZ). Pass the normalised delta to `apply` — the descriptor owns the @@ -722,10 +1166,21 @@ function GuideRing({ radius, y }: { radius: number; y: number }) { function ArcArrow({ descriptor, node, + liveNode, + freezeOffset, + handleIndex, + dragControls, rideObject, }: { descriptor: ArcResizeHandle + /** Effective node for placement (preDrag snapshot when another arrow is active). */ node: AnyNode + /** Always the live (override-merged) node — used inside drag handlers. */ + liveNode: AnyNode + /** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */ + freezeOffset: [number, number, number] | null + handleIndex: number + dragControls: DragControls rideObject: Object3D }) { const [isHovered, setIsHovered] = useState(false) @@ -734,18 +1189,26 @@ function ArcArrow({ // corner) render a two-headed curved arrow; everything else (stair // sweep, etc.) keeps the chevron. const isRotateShape = descriptor.shape === 'rotate' + // 'node-normal' spins the node about its local +Z (a wall item flat against + // its wall) instead of yaw about world-Y. The drag plane and the icon both + // tilt into that plane, and the horizontal-only wedge/ring readout is + // suppressed. + const isNodeNormalRot = descriptor.rotationPlane === 'node-normal' const arrowGeometry = useMemo( () => (isRotateShape ? createRotateArrowHandleGeometry() : createArrowHandleGeometry()), [isRotateShape], ) const arrowMaterial = useArrowMaterial() - const { camera, raycaster, gl } = useThree() + const { camera, raycaster, gl, scene } = useThree() const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 // The rotate icon is denser than the chevron; pump scale a touch so the // ribbon reads at the same on-screen size as the other handles. const arrowScale = isRotateShape ? ARROW_SCALE * 1.05 : ARROW_SCALE const scale = (isHovered ? 1.12 : 1) * zoom * arrowScale const dragCleanupRef = useRef<(() => void) | null>(null) + // Live rotation readout (wedge + degree chip) — only populated while a + // `shape: 'rotate'` gizmo is mid-drag. See RotationGuide. + const [rotationGuide, setRotationGuide] = useState(null) useEffect(() => { arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) @@ -755,7 +1218,19 @@ function ArcArrow({ useEffect(() => () => dragCleanupRef.current?.(), []) const placementSceneApi = useMemo(() => createSceneApi(useScene), []) - const position = descriptor.placement.position(node, placementSceneApi) + const basePosition = descriptor.placement.position(node, placementSceneApi) + // See the LinearArrow note on freezeOffset — for rotation drags the + // delta collapses to zero (position doesn't change), so the rotate + // gizmo naturally rotates with the mesh while another arrow is being + // dragged. The offset only kicks in for asymmetric resize drags that + // recompute `position` to anchor the opposite edge. + const position: [number, number, number] = freezeOffset + ? [ + basePosition[0] - freezeOffset[0], + basePosition[1] - freezeOffset[1], + basePosition[2] - freezeOffset[2], + ] + : [basePosition[0], basePosition[1], basePosition[2]] const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 // Rotation gizmo: hover signals "grabbable", active drag signals // "grabbed". `ew-resize` was wrong — it implies linear width drag. @@ -772,14 +1247,65 @@ function ArcArrow({ event.stopPropagation() // Horizontal drag plane at the arrow's world Y. Atan2 around the - // node's local origin (= rideObject world center) gives the cursor's - // bearing — delta between samples is the angular drag. + // rotation pivot gives the cursor's bearing — delta between samples + // is the angular drag. + // + // Default pivot is the rideObject's world origin (= node-local + // origin) which is correct when the mesh origin coincides with the + // shape being rotated (roof-segment, elevator). Nodes that bake + // pose into their geometry (chimney) can override via + // `descriptor.rotationCenter`, which we apply through the + // rideObject's matrixWorld so the descriptor stays in node-local + // coordinates. rideObject.updateMatrixWorld() - const centerWorld = new Vector3() - rideObject.getWorldPosition(centerWorld) + const centerWorld = + descriptor.rotationCenter !== undefined + ? new Vector3( + ...descriptor.rotationCenter(node as never, createSceneApi(useScene)), + ).applyMatrix4(rideObject.matrixWorld) + : new Vector3().setFromMatrixPosition(rideObject.matrixWorld) const arrowWorld = new Vector3(...position).applyMatrix4(rideObject.matrixWorld) const planeY = arrowWorld.y - const plane = new Plane(new Vector3(0, 1, 0), -planeY) + + // Rotation axis + drag plane. 'horizontal' spins about world-Y on a flat + // plane; 'node-normal' spins about the node's local +Z (the wall normal) + // on the plane perpendicular to it. The 2D basis (u, v) lets us measure a + // consistent bearing in either plane: for horizontal it collapses to the + // original atan2(z, x). + const axis = isNodeNormalRot + ? new Vector3().setFromMatrixColumn(rideObject.matrixWorld, 2).normalize() + : new Vector3(0, 1, 0) + const plane = isNodeNormalRot + ? new Plane().setFromNormalAndCoplanarPoint(axis, centerWorld) + : new Plane(new Vector3(0, 1, 0), -planeY) + let basisU: Vector3 + if (isNodeNormalRot) { + // In-plane reference: world-up projected onto the plane (falls back to + // world-X if the axis is near-vertical, e.g. a ceiling item). + const up = new Vector3(0, 1, 0) + basisU = up.clone().addScaledVector(axis, -up.dot(axis)) + if (basisU.lengthSq() < 1e-6) { + const x = new Vector3(1, 0, 0) + basisU = x.addScaledVector(axis, -x.dot(axis)) + } + basisU.normalize() + } else { + basisU = new Vector3(1, 0, 0) + } + const basisV = isNodeNormalRot + ? new Vector3().crossVectors(axis, basisU).normalize() + : new Vector3(0, 0, 1) + const angleOf = (p: Vector3) => { + const d = new Vector3().subVectors(p, centerWorld) + return Math.atan2(d.dot(basisV), d.dot(basisU)) + } + + // Wedge radius tracks the handle's orbit (its horizontal distance from + // the pivot), nudged inward so the swept fill reads as "the handle swung + // around" rather than overlapping the gizmo itself. Clamped so tiny + // footprints (column) and large ones (roof segment) both stay legible. + const handleRadius = Math.hypot(arrowWorld.x - centerWorld.x, arrowWorld.z - centerWorld.z) + const guideRadius = Math.min(Math.max(handleRadius * 0.72, 0.3), 1.6) const ndc = new Vector2() const setNDC = (clientX: number, clientY: number) => { @@ -795,7 +1321,7 @@ function ArcArrow({ const hitWorld = new Vector3() if (!raycaster.ray.intersectPlane(plane, hitWorld)) return - const initialAngle = Math.atan2(hitWorld.z - centerWorld.z, hitWorld.x - centerWorld.x) + const initialAngle = angleOf(hitWorld) const nodeId = node.id as AnyNodeId const sceneApi = createSceneApi(useScene) const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode @@ -805,6 +1331,8 @@ function ArcArrow({ useViewer.getState().setInputDragging(true) useScene.temporal.getState().pause() setIsDragging(true) + // Claim active-drag status — see LinearArrow's onStart note. + dragControls.onStart(handleIndex, initialNode) let lastPatch: Partial | null = null @@ -818,16 +1346,51 @@ function ArcArrow({ raycaster.setFromCamera(ndc, camera) const hit = new Vector3() if (!raycaster.ray.intersectPlane(plane, hit)) return - const currentAngle = Math.atan2(hit.z - centerWorld.z, hit.x - centerWorld.x) + const currentAngle = angleOf(hit) // Normalise so a drag that crosses ±π doesn't flip sign mid-gesture. let delta = currentAngle - initialAngle while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI + // Shift snaps whole-node rotation gizmos (stair, elevator, column…) to + // 15° increments. Scoped to `shape: 'rotate'` so curved-stair sweep + // handles keep their continuous feel. + if (e.shiftKey && descriptor.shape === 'rotate') { + const step = Math.PI / 12 + delta = Math.round(delta / step) * step + } + const patch = descriptor.apply(initialNode as never, delta, sceneApi) lastPatch = patch as Partial useLiveNodeOverrides.getState().set(nodeId, patch as Record) useScene.getState().markDirty(nodeId) + + // Whole-node rotate gizmos report how far the node has turned since + // grab. The wedge sweeps `delta` (the snapped amount, so it tracks the + // 15° steps under Shift), and the chip sits at the wedge midpoint just + // past its rim. Suppressed below ~0.5° so a fresh grab doesn't flash a + // zero-width sliver. The wedge is drawn in the horizontal plane, so it + // only applies to horizontal-axis rotation (not the wall-normal spin). + if (isRotateShape && !isNodeNormalRot) { + if (Math.abs(delta) < 0.0087) { + setRotationGuide(null) + } else { + const midAngle = initialAngle + delta / 2 + const labelRadius = guideRadius + 0.22 + setRotationGuide({ + center: [centerWorld.x, planeY, centerWorld.z], + startAngle: initialAngle, + endAngle: initialAngle + delta, + radius: guideRadius, + labelPos: [ + centerWorld.x + Math.cos(midAngle) * labelRadius, + planeY + 0.02, + centerWorld.z + Math.sin(midAngle) * labelRadius, + ], + sweep: Math.abs(delta), + }) + } + } } const cleanup = () => { @@ -840,6 +1403,9 @@ function ArcArrow({ useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) setIsDragging(false) + setRotationGuide(null) + // Release the active-drag claim — see LinearArrow's onEnd note. + dragControls.onEnd() dragCleanupRef.current = null } const onUp = () => { @@ -868,6 +1434,13 @@ function ArcArrow({ window.addEventListener('pointercancel', onCancel) } + // Suppress "declared but unused" for `liveNode` — ArcArrow's apply + // operates entirely on `initialNode` (snapshot taken inside activate) + // and `delta` (live cursor angle), so the live store node doesn't + // appear in the rotation pipeline. The prop is still required because + // ArrowHandle passes it uniformly to every variant. + void liveNode + return ( <> {showDecoration && decoration ? ( @@ -876,7 +1449,16 @@ function ArcArrow({ y={decoration.y?.(node as never) ?? 0} /> ) : null} - + {/* World-space rotation readout, portalled to the scene root so it + stays fixed while this gizmo's frame rotates with the node mesh. */} + {rotationGuide ? createPortal(, scene) : null} + + node: AnyNode + handleIndex: number + dragControls: DragControls + rideObject: Object3D +}) { + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), []) + const arrowMaterial = useArrowMaterial() + const { camera, raycaster, gl } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE + const dragCleanupRef = useRef<(() => void) | null>(null) + + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + useEffect(() => () => dragCleanupRef.current?.(), []) + + const placementSceneApi = useMemo(() => createSceneApi(useScene), []) + const position = descriptor.placement.position(node, placementSceneApi) + const cursor: Cursor = 'move' + // 'node-normal' constrains the drag to the wall face (plane ⟂ the node's + // local +Z). Its cross icon stands up into that plane (tilt about X). + const isWallPlane = descriptor.plane === 'node-normal' + + const activate = (event: ThreeEvent) => { + event.stopPropagation() + + // Drag plane through the node origin. 'horizontal' uses the world-up + // normal (slide on the floor); 'node-normal' uses the node's facing + // direction (its local +Z in world) so the item slides on the wall face. + // Hits map into the parent frame so the delta composes with `position` + // (which lives in parent-local space). + rideObject.updateMatrixWorld() + const worldOrigin = new Vector3().setFromMatrixPosition(rideObject.matrixWorld) + const planeNormal = isWallPlane + ? new Vector3().setFromMatrixColumn(rideObject.matrixWorld, 2).normalize() + : new Vector3(0, 1, 0) + const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin) + const parent = rideObject.parent + const parentInverse = new Matrix4() + if (parent) { + parent.updateMatrixWorld() + parentInverse.copy(parent.matrixWorld).invert() + } + + const ndc = new Vector2() + const setNDC = (clientX: number, clientY: number) => { + const rect = gl.domElement.getBoundingClientRect() + ndc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + } + + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) + const hitWorld = new Vector3() + if (!raycaster.ray.intersectPlane(plane, hitWorld)) return + const startLocal = hitWorld.clone().applyMatrix4(parentInverse) + + const nodeId = node.id as AnyNodeId + const sceneApi = createSceneApi(useScene) + const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode + const initialPos = (initialNode as { position?: readonly [number, number, number] }) + .position ?? [0, 0, 0] + + document.body.style.cursor = cursor + sfxEmitter.emit('sfx:item-pick') + useViewer.getState().setInputDragging(true) + useScene.temporal.getState().pause() + setIsDragging(true) + dragControls.onStart(handleIndex, initialNode) + + let lastPatch: Partial | null = null + + const onMove = (e: PointerEvent) => { + setNDC(e.clientX, e.clientY) + raycaster.setFromCamera(ndc, camera) + const hit = new Vector3() + if (!raycaster.ray.intersectPlane(plane, hit)) return + const curLocal = hit.applyMatrix4(parentInverse) + // Add the in-plane delta to the drag-start position; the off-plane axis + // (Y on the floor, Z/depth on a wall) keeps its value. Snap / clamp is + // the descriptor's job in `apply`. + const newPos: [number, number, number] = [initialPos[0], initialPos[1], initialPos[2]] + newPos[0] += curLocal.x - startLocal.x + if (isWallPlane) { + newPos[1] += curLocal.y - startLocal.y + } else { + newPos[2] += curLocal.z - startLocal.z + } + // Grid-snap the two in-plane axes (X + the plane's other free axis). + const extents = descriptor.snapExtents?.(initialNode as never) + if (extents) { + newPos[0] = snapToGrid(newPos[0], extents[0]) + if (isWallPlane) { + newPos[1] = snapToGrid(newPos[1], extents[1]) + } else { + newPos[2] = snapToGrid(newPos[2], extents[1]) + } + } + const patch = descriptor.apply(initialNode as never, newPos, sceneApi) + lastPatch = patch as Partial + useLiveNodeOverrides.getState().set(nodeId, patch as Record) + useScene.getState().markDirty(nodeId) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + if (document.body.style.cursor === cursor) { + document.body.style.cursor = '' + } + useScene.temporal.getState().resume() + useViewer.getState().setInputDragging(false) + setIsDragging(false) + dragControls.onEnd() + dragCleanupRef.current = null + } + const onUp = () => { + swallowNextClick() + sfxEmitter.emit('sfx:item-place') + if (lastPatch) { + sceneApi.update(nodeId, lastPatch) + } + useLiveNodeOverrides.getState().clear(nodeId) + useScene.getState().markDirty(nodeId) + cleanup() + } + const onCancel = () => { + useLiveNodeOverrides.getState().clear(nodeId) + useScene.getState().markDirty(nodeId) + cleanup() + } + dragCleanupRef.current = cleanup + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } + + const onEnter = (event: ThreeEvent) => { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = cursor + } + const onLeave = (event: ThreeEvent) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === cursor) document.body.style.cursor = '' + } + + // Suppress the unused `isDragging` lint — it only drives the React re-render + // that keeps hover/drag cursor state in sync. + void isDragging + + // The cross is built flat in the XZ plane. On a wall, tilt it up about X so + // it lies in the item-local XY plane (= the wall face). + const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0] + + return ( + + + + ) +} + // Click-to-engage affordance — no drag plumbing, just a click target. The // descriptor's `onActivate` receives sceneApi + editorApi so it can engage // move tools, endpoint drags, or any other editor-state transition without @@ -1161,3 +1938,109 @@ function CornerPickerShape({ ) } + +// Tracker visual for the `linear-resize` handle's `shape: 'tracker'` option. +// Mirrors the corner picker (dashed vertical leader from the floor) but caps +// the leader with a small draggable cube instead of a hex disc, and the cube +// sits at the TOP of the leader rather than the floor — the visual reads as +// "this cube is the wall top; drag it to raise/lower." All interactivity +// (pointer-down → linear-resize drag) is wired by the parent `LinearArrow`. +const TRACKER_CUBE_SIZE = 0.16 + +function TrackerShape({ + basePosition, + cubePosition, + leaderHeight, + zoom, + isHovered, + onActivate, + onEnter, + onLeave, +}: { + basePosition: readonly [number, number, number] + cubePosition: readonly [number, number, number] + leaderHeight: number + zoom: number + isHovered: boolean + onActivate: (event: ThreeEvent) => void + onEnter: (event: ThreeEvent) => void + onLeave: (event: ThreeEvent) => void +}) { + // `leaderHeight === 0` (wallHeight collapsed to floor) would make the + // dashed builder return an empty geometry — skip the mesh entirely in + // that case so the cube still renders by itself. + const hasLeader = leaderHeight > 0.0001 + const dashedGeometry = useMemo( + () => (hasLeader ? buildDashedVerticalGeometry(leaderHeight) : null), + [hasLeader, leaderHeight], + ) + useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry]) + + const cubeGeometry = useMemo( + () => new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE), + [], + ) + useEffect(() => () => cubeGeometry.dispose(), [cubeGeometry]) + + const dashMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + transparent: true, + opacity: 0.85, + depthTest: false, + depthWrite: false, + }), + [], + ) + const cubeMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + transparent: true, + opacity: 1, + // depthTest off keeps the cube visible through any geometry sitting + // between camera and wall top; depthWrite on so the ink-edge pass + // catches the cube silhouette from every angle (same reasoning as + // the chevron — without it the lines fade in/out by view angle). + depthTest: false, + depthWrite: true, + }), + [], + ) + useEffect(() => { + const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR + dashMaterial.color.set(next) + cubeMaterial.color.set(next) + }, [dashMaterial, cubeMaterial, isHovered]) + useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) + useEffect(() => () => cubeMaterial.dispose(), [cubeMaterial]) + + const cubeScale = (isHovered ? 1.25 : 1) * zoom + + return ( + <> + {dashedGeometry ? ( + + ) : null} + + + ) +} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 1b1bcd0be..e73bb27ba 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1214,6 +1214,16 @@ export const SelectionManager = () => { if (boxSelectHandled) return const node = event.node + + // A ceiling is selectable only through its corner handles, never via + // the `ceiling-grid` body mesh. When the grid is revealed (ceiling + // selected, or an item placed beneath it) a top-down click hits the + // grid first; selecting the ceiling there both re-selects it as a + // no-op and stops propagation, blocking the hosted item below. By + // ignoring non-handle ceiling clicks (without stopping propagation) + // the click falls through to the item underneath. + if (node.type === 'ceiling' && !event.viaHandle) return + let currentPhase = useEditor.getState().phase let currentStructureLayer = useEditor.getState().structureLayer diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 126b3ebe4..be09c7483 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -30,11 +30,16 @@ type UseFloorplanBackgroundPlacementArgs = { } | null floorplanOpeningLocalY: number getSnappedFloorplanPoint: (point: WallPlanPoint) => WallPlanPoint + handleCeilingItemPlacementClick: ( + planPoint: WallPlanPoint, + nativeEvent: ReactMouseEvent, + ) => boolean handleCeilingPlacementPoint: (point: WallPlanPoint) => void handleSlabPlacementPoint: (point: WallPlanPoint) => void handleWallPlacementPoint: (point: WallPlanPoint) => void handleZonePlacementPoint: (point: WallPlanPoint) => void isCeilingBuildActive: boolean + isCeilingItemPlacementActive: boolean isFenceBuildActive: boolean isFloorplanGridInteractionActive: boolean isOpeningPlacementActive: boolean @@ -76,11 +81,13 @@ export function useFloorplanBackgroundPlacement({ findClosestWallPoint, floorplanOpeningLocalY, getSnappedFloorplanPoint, + handleCeilingItemPlacementClick, handleCeilingPlacementPoint, handleSlabPlacementPoint, handleWallPlacementPoint, handleZonePlacementPoint, isCeilingBuildActive, + isCeilingItemPlacementActive, isFenceBuildActive, isFloorplanGridInteractionActive, isOpeningPlacementActive, @@ -225,6 +232,15 @@ export function useFloorplanBackgroundPlacement({ return true } + // Ceiling-attached item placement (lights, fans). Routes the click + // through `ceiling:click` instead of `grid:click` so the placement + // strategy parents the new item to the ceiling at the correct + // height — mirrors the pointer-move handler in `floorplan-panel`. + if (isCeilingItemPlacementActive) { + handleCeilingItemPlacementClick(planPoint, event) + return true + } + // Generic catch-all — registry-driven tool whose kind has no // local floor-plan draft handler (column / spawn / shelf / etc.). // The tool's `grid:click` subscriber owns the placement. @@ -247,10 +263,12 @@ export function useFloorplanBackgroundPlacement({ findClosestWallPoint, floorplanOpeningLocalY, getSnappedFloorplanPoint, + handleCeilingItemPlacementClick, handleCeilingPlacementPoint, handleSlabPlacementPoint, handleZonePlacementPoint, isCeilingBuildActive, + isCeilingItemPlacementActive, isFenceBuildActive, isFloorplanGridInteractionActive, isOpeningPlacementActive, diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 5630badbd..3274dde33 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -143,6 +143,17 @@ export function WallMoveSideHandles() { } function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { + // Merge the in-flight drag override so every handle (side-move arrows, + // height arrow, corner leaders) tracks the live height in real time + // during a height drag — the scene store stays at the pre-drag value + // until commit, so reading `wall` alone would freeze them. Same pattern + // as node-arrow-handles. + const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id)) + const effectiveWall = useMemo( + () => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall), + [wall, liveOverride], + ) + const [levelObject, setLevelObject] = useState(() => wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null, ) @@ -175,18 +186,18 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { } }, [wall.parentId]) - const handles = useMemo(() => getWallMoveHandles(wall), [wall]) + const handles = useMemo(() => getWallMoveHandles(effectiveWall), [effectiveWall]) if (!levelObject || handles.length === 0) return null return createPortal( {handles.map((handle) => ( - + ))} - - - + + + , levelObject, ) @@ -395,6 +406,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0] const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1] const wallAngle = Math.atan2(-dirZ, dirX) + // `wall` is the override-merged effective wall (see + // WallMoveSideHandlesForWall), so this height is already live during a drag. const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT const handleY = wallHeight + HEIGHT_HANDLE_OFFSET diff --git a/packages/editor/src/components/editor/wall-opening-highlights.tsx b/packages/editor/src/components/editor/wall-opening-highlights.tsx new file mode 100644 index 000000000..e93aca5cf --- /dev/null +++ b/packages/editor/src/components/editor/wall-opening-highlights.tsx @@ -0,0 +1,158 @@ +'use client' + +import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createPortal, useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef } from 'react' +import { + BoxGeometry, + type BufferGeometry, + DoubleSide, + EdgesGeometry, + type Group, + PlaneGeometry, + Vector3, +} from 'three' +import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' + +// How far the outline sits outside the opening's own extents, so it reads as +// a frame *around* the opening rather than coinciding with its edges. +const PAD = 0.05 + +const ACCENT = 0x83_81_ed + +const NO_RAYCAST = () => null +const scratchScale = new Vector3() + +// Indigo accent matching the resize-arrow handles — deliberately distinct +// from the white selection outline so the highlight reads as "editable child +// here", not "this is selected". `depthTest: false` keeps both layers drawn +// on top of the wall so frameless openings (which have no visible geometry) +// are still located. Shared across every box; never disposed. +const outlineMaterial = new LineBasicNodeMaterial({ + color: ACCENT, + depthTest: false, + depthWrite: false, +}) + +// Translucent pane that fills the opening so it reads as a highlighted +// region. Sits in the opening's plane (XY, facing the wall normal) and is +// double-sided so it shows from either side of the wall. +const fillMaterial = new MeshBasicNodeMaterial({ + color: ACCENT, + transparent: true, + opacity: 0.22, + side: DoubleSide, + depthTest: false, + depthWrite: false, +}) + +function makeOutlineGeometry(width: number, height: number, depth: number): BufferGeometry { + const box = new BoxGeometry(width + PAD, height + PAD, depth + PAD) + const edges = new EdgesGeometry(box) + box.dispose() + return edges +} + +/** + * When a wall is selected, draws a translucent indigo highlight (filled pane + * + outline) over each door / window it hosts. Openings whose `openingKind` + * is `'opening'` have no visible geometry, so without this affordance the + * user can't tell an editable cutout lives there — the fill marks it (and + * stays out of the way of clicking the opening itself, which selects it). + * + * Highlights are portalled to the scene root so they sit outside the wall's + * selection-outline subtree and keep their own accent colour. + */ +export function WallOpeningHighlights() { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const { scene } = useThree() + + if (selectedIds.length === 0) return null + + return createPortal( + <> + {selectedIds.map((id) => ( + + ))} + , + scene, + ) +} + +function WallOpenings({ wallId }: { wallId: string }) { + const wall = useScene((state) => state.nodes[wallId as AnyNodeId]) + + if (!wall || wall.type !== 'wall') return null + + const depth = wall.thickness ?? 0.1 + return ( + <> + {(wall.children ?? []).map((childId) => ( + + ))} + + ) +} + +function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) { + const node = useScene((state) => state.nodes[openingId as AnyNodeId]) + const groupRef = useRef(null) + + const isOpening = node?.type === 'door' || node?.type === 'window' + const width = isOpening ? node.width : 0 + const height = isOpening ? node.height : 0 + + const outlineGeometry = useMemo( + () => (isOpening ? makeOutlineGeometry(width, height, depth) : null), + [isOpening, width, height, depth], + ) + const fillGeometry = useMemo( + () => (isOpening ? new PlaneGeometry(width, height) : null), + [isOpening, width, height], + ) + useEffect(() => () => outlineGeometry?.dispose(), [outlineGeometry]) + useEffect(() => () => fillGeometry?.dispose(), [fillGeometry]) + + // The opening's mesh (registered by its renderer) already carries the wall + // transform + its own local pose baked into `matrixWorld`. Copy that + // straight onto the highlight each frame so it tracks moves, resizes, and + // wall rotation without any wall-local maths here. + useFrame(() => { + const group = groupRef.current + if (!group) return + const obj = sceneRegistry.nodes.get(openingId as AnyNodeId) + if (!obj) { + group.visible = false + return + } + group.visible = true + obj.matrixWorld.decompose(group.position, group.quaternion, scratchScale) + }) + + if (!isOpening || !outlineGeometry || !fillGeometry) return null + + return ( + + + + + ) +} + +export default WallOpeningHighlights diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index ee0df9859..c61b86651 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -18,6 +18,10 @@ const BRACKET_THICKNESS = 0.04 const BRACKET_HEIGHT = 0.04 const BRACKET_Y_OFFSET = 0.035 const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28] +// Draw the corner handles after everything else and with depth testing +// off (see materials below) so they stay visible — and clickable — even +// when a wall, roof, or the ceiling itself would otherwise occlude them. +const CORNER_RENDER_ORDER = 1000 type CornerBracketData = { corner: [number, number] @@ -149,6 +153,7 @@ const CornerBracket = ({ localPosition: [0, 0, 0], position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]], stopPropagation: () => e.stopPropagation(), + viaHandle: true, }) } @@ -179,9 +184,16 @@ const CornerBracket = ({ e.stopPropagation() setIsHovered(false) }} + renderOrder={CORNER_RENDER_ORDER} > - + ) @@ -208,9 +220,20 @@ const BracketLeg = ({ ] return ( - + - + ) } diff --git a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx index 71917ba44..f557b3fdf 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx @@ -25,15 +25,19 @@ export const CeilingSystem = () => { } for (const id of selectedIds) { - let currentId: string | null = id - let isCeilingRelated = false - let levelId: string | null = null + // Only treat a directly-selected ceiling as "reveal the grid"; a + // selected descendant (e.g. a freshly-placed ceiling light) used to + // count too, which left the opaque grid overlay covering the room + // even after the user moved on. With the grid still in front, every + // subsequent click in 3D hit the grid mesh (its `useNodeEvents` + // handlers re-selected the ceiling) instead of the items below. + const selectedNode = nodes[id as AnyNodeId] + if (selectedNode?.type !== 'ceiling') continue + let currentId: string | null = selectedNode.parentId as string | null + let levelId: string | null = null while (currentId && nodes[currentId as AnyNodeId]) { const node = nodes[currentId as AnyNodeId] - if (node?.type === 'ceiling') { - isCeilingRelated = true - } if (node?.type === 'level') { levelId = node.id break @@ -41,7 +45,7 @@ export const CeilingSystem = () => { currentId = node?.parentId as string | null } - if (isCeilingRelated && levelId) { + if (levelId) { levelsToShowCeilings.add(levelId) } } diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx index e0055dd09..8708b7e4f 100644 --- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx +++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx @@ -1,41 +1,92 @@ import { type AnyNodeId, type RoofNode, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' +import * as THREE from 'three' + +// Empty placeholder geometry used when we reveal segments-wrapper for +// accessory editing. The roof's CSG-merged shell is the only thing +// that should render the roof surface in this mode — the per-segment +// CSG geometry (if any was left over from a prior edit) would visually +// double the cut shape, so we strip each segment mesh back to nothing. +// `RoofSystem` rebuilds CSG on demand if the user later selects a +// segment, so destroying the cached geometry here only costs one +// recomputation per segment when the user actually wants it back. +function makeEmptySegmentGeometry(): THREE.BufferGeometry { + const g = new THREE.BufferGeometry() + g.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + // Match the four material slots the roof-segment renderer's material + // array expects (0=top, 1=side, 2=interior, 3=shingle). Without these + // groups, mesh.material is a single-material lookup that mismatches + // the array — same crash mode the BoxGeometry workaround in + // `roof-system.tsx:144` guards against. + g.addGroup(0, 0, 0) + g.addGroup(0, 0, 1) + g.addGroup(0, 0, 2) + g.addGroup(0, 0, 3) + return g +} /** * Imperatively toggles the Three.js visibility of roof objects based on the * editor selection — without causing React re-renders in RoofRenderer. * - * When a roof (or one of its segments) is selected: + * Full edit-mode (segment selected): * - merged-roof mesh is hidden * - segments-wrapper group is shown (individual segments visible for editing) * - all children are marked dirty so RoofSystem rebuilds their geometry * - * When deselected: - * - merged-roof mesh is shown - * - segments-wrapper group is hidden + * Accessory-reveal mode (a dormer/chimney/etc. hosted on a segment is selected): + * - merged-roof mesh stays visible (we don't want the appearance to jump) + * - segments-wrapper group is shown ANYWAY so anything portaled into a + * segment's registered mesh (e.g. dormer in-world handle arrows that + * don't use `portal: 'grandparent'`) is no longer inheriting the + * wrapper's hidden flag + * - segment placeholder geometry is empty, so revealing the wrapper has + * no visible cost beyond letting the handle arrows render + * + * When deselected: merged-roof shown, segments-wrapper hidden. */ export const RoofEditSystem = () => { const selectedIds = useViewer((s) => s.selection.selectedIds) const prevActiveRoofIds = useRef(new Set()) + const prevRevealRoofIds = useRef(new Set()) useEffect(() => { const nodes = useScene.getState().nodes - // Collect which roof nodes should be in "edit mode". - // Selecting the roof itself should keep the merged visual intact so - // material appearance does not jump between merged and per-segment meshes. + // Roofs where a segment itself is selected -> full edit mode (hide + // merged, show wrapper). const activeRoofIds = new Set() + // Roofs where an accessory (dormer/chimney/etc.) is selected -> only + // reveal the wrapper so handle portals into the segment mesh become + // visible. Merged stays on. + const revealRoofIds = new Set() + for (const id of selectedIds) { const node = nodes[id as AnyNodeId] if (!node) continue if (node.type === 'roof-segment' && node.parentId) { activeRoofIds.add(node.parentId) + continue + } + // Walk up one level: if the parent is a roof-segment, this is a + // hosted accessory and we want to reveal its grandparent roof's + // wrapper. Two-step lookup keeps it scoped to roof children + // without enumerating all accessory kinds. + if (!node.parentId) continue + const parent = nodes[node.parentId as AnyNodeId] + if (parent?.type === 'roof-segment' && parent.parentId) { + revealRoofIds.add(parent.parentId) } } - // Update all roofs that are currently active OR were previously active - const roofIdsToUpdate = new Set([...activeRoofIds, ...prevActiveRoofIds.current]) + // Union of roofs that need ANY state change this tick. + const roofIdsToUpdate = new Set([ + ...activeRoofIds, + ...revealRoofIds, + ...prevActiveRoofIds.current, + ...prevRevealRoofIds.current, + ]) for (const roofId of roofIdsToUpdate) { const group = sceneRegistry.nodes.get(roofId) @@ -44,25 +95,44 @@ export const RoofEditSystem = () => { const mergedMesh = group.getObjectByName('merged-roof') const segmentsWrapper = group.getObjectByName('segments-wrapper') const isActive = activeRoofIds.has(roofId) + const isReveal = revealRoofIds.has(roofId) if (mergedMesh) mergedMesh.visible = !isActive - if (segmentsWrapper) segmentsWrapper.visible = isActive + if (segmentsWrapper) segmentsWrapper.visible = isActive || isReveal const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined if (roofNode?.children?.length) { const wasActive = prevActiveRoofIds.current.has(roofId) + const wasReveal = prevRevealRoofIds.current.has(roofId) if (isActive !== wasActive) { - // Entering edit mode: rebuild individual segment geometries - // Exiting edit mode: sync transforms + rebuild merged mesh + // Entering / exiting full edit mode: rebuild segment / merged + // geometries. Accessory-reveal doesn't need this — segments + // keep their placeholder; only their visibility flips. const { markDirty } = useScene.getState() for (const childId of roofNode.children) { markDirty(childId as AnyNodeId) } } + // Entering reveal mode (and NOT also full-edit, which already + // owns its own rebuild path): strip each segment mesh back to + // an empty placeholder so the wrapper-now-visible doesn't + // re-show stale CSG geometry from a previous segment edit. + // Without this, the host segment's CSG cut renders ON TOP of + // the merged-roof, doubling the dormer's cut shape and + // bleeding the host wall material through the dormer body. + if (isReveal && !isActive && !wasReveal && segmentsWrapper) { + for (const child of segmentsWrapper.children) { + const mesh = child as THREE.Mesh + if (!mesh.isMesh) continue + mesh.geometry?.dispose() + mesh.geometry = makeEmptySegmentGeometry() + } + } } } prevActiveRoofIds.current = activeRoofIds + prevRevealRoofIds.current = revealRoofIds }, [selectedIds]) return null diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index d7c86be96..5ba49e38a 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,20 +1,9 @@ -import type { - AnyNodeId, - BuildingNode, - ElevatorNode, - RoofNode, - RoofSegmentNode, - SpawnNode, - StairNode, - StairSegmentNode, -} from '@pascal-app/core' +import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core' import { nodeRegistry } from '@pascal-app/core' import { Suspense } from 'react' import useEditor from '../../../store/use-editor' -import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' -import { MoveRoofTool } from '../roof/move-roof-tool' import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' /** @@ -23,14 +12,13 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' * 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that * declare `capabilities.movable` (shelf, spawn, item-with-floor-attach, * …). - * 2. `def.affordanceTools.move` — kind-owned move component - * (slab / ceiling / wall / fence / column / item / door / window). - * Lazy-loaded via `getRegistryAffordanceTool`. - * 3. The narrow set of kinds that still have legacy movers because no - * registry equivalent has been written yet (building / elevator / - * roof / stair). Each of these has bespoke move semantics that - * don't fit the generic mover and are not yet ported to a - * kind-owned affordance. + * 2. `def.affordanceTools.move` — kind-owned move component, lazy-loaded + * via `getRegistryAffordanceTool`. Covers both generic movers + * (slab / ceiling / wall / fence / column / item / door / window) and + * the bespoke roof / roof-segment / stair / stair-segment / building + * movers ported into `@pascal-app/nodes`. + * 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft + * mover hasn't been ported to a kind-owned affordance yet. */ export const MoveTool: React.FC<{ onNodeMoved?: (nodeId: AnyNodeId) => void @@ -54,13 +42,7 @@ export const MoveTool: React.FC<{ ) } - if (movingNode.type === 'building') - return if (movingNode.type === 'elevator') return - if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') - return - if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') - return return null } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 8edc1207d..872446408 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1168,9 +1168,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const mesh = sceneRegistry.nodes.get(draft.id) if (mesh) mesh.position.copy(gridPosition.current) - // Publish live transform for 2D floorplan + // Publish live transform for 2D floorplan. The item override in + // `floorplan-registry-layer` treats `live.position` as building-local + // plan coords (parentId forced to null so the resolver renders it + // directly), so publish the building-local cursor — not the + // world-space `result.cursorPosition`, which otherwise lands the 2D + // visual off the cursor whenever the building isn't at the origin + // with zero rotation. useLiveTransforms.getState().set(draft.id, { - position: result.cursorPosition, + position: [cc.x, cc.y, cc.z], rotation: cursorGroupRef.current.rotation.y, }) } @@ -1324,7 +1330,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Keyboard rotation ---- - const ROTATION_STEP = Math.PI / 2 + // 45° increments — matches the R-key rotation step for already-placed + // items (use-keyboard.ts) so the ghost/duplicate rotates the same way. + const ROTATION_STEP = Math.PI / 4 const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Shift') { shiftFreeRef.current = true diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index edf8e71da..80a10b755 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -609,10 +609,17 @@ export const PolygonEditor: React.FC = ({ {/* Per-side resize arrow — points outward from the edge. Dragging it pulls (or pushes) only this edge's two vertices along the outward normal; the opposite side - of the polygon stays put. */} + of the polygon stays put. + + Stays on SCENE_LAYER (no `layers={EDITOR_LAYER}`) so the + post-processing scenePass picks it up in the depth/normal + MRT and the ink-edge shader paints dark outlines on the + chevron — same treatment as the wall and registry height + arrows. The surrounding line/vertex/edge-box handles stay + on EDITOR_LAYER because they're not chevrons and reading + as flat overlays is the intended look there. */} { if (e.button !== 0) return e.stopPropagation() @@ -638,8 +645,12 @@ export const PolygonEditor: React.FC = ({ color={ isDragging ? '#22c55e' : isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR } + // depthTest off → still drawn on top of underlying surface. + // depthWrite on → silhouette enters the depth buffer so the + // ink-edge shader paints it from every angle, like all the + // other registry chevrons. depthTest={false} - depthWrite={false} + depthWrite={true} transparent /> diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index 783d45c92..78227c346 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type IconRef, nodeRegistry, + type ParamAction, type ParamField, useScene, } from '@pascal-app/core' @@ -104,6 +105,10 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = { const canMove = !!def.capabilities.movable const canDelete = def.capabilities.deletable !== false + const TrailingSection = parametrics.trailingSection + ? resolveCustomPanel(parametrics.trailingSection) + : null + return ( {parametrics.groups.map((group, gi) => ( @@ -118,12 +123,20 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = { ))} ))} - {(canMove || canDelete) && ( + {TrailingSection && ( + + + + )} + {(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && ( {canMove && ( } label="Move" onClick={handleMove} /> )} + {parametrics.actions?.map((action, i) => ( + + ))} {canDelete && ( ; nodeId: AnyNodeId }) { + const disabled = useScene((s) => { + if (!action.enabledIf) return false + const n = s.nodes[nodeId] + return n ? !action.enabledIf(n as AnyNode) : false + }) + return ( + + ) : undefined + } + label={action.label} + onClick={() => { + const live = useScene.getState().nodes[nodeId] + if (live) action.onClick(live as AnyNode) + }} + /> + ) +} + function renderIcon(ref: IconRef | undefined): React.ReactNode | undefined { if (!ref) return undefined if (ref.kind === 'url') { diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx new file mode 100644 index 000000000..289c4480b --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx @@ -0,0 +1,83 @@ +import { type AnyNodeId, type GutterNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import Image from 'next/image' +import { memo, useCallback, useState } from 'react' +import useEditor from './../../../../../store/use-editor' +import { InlineRenameInput } from './inline-rename-input' +import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' +import { TreeNodeActions } from './tree-node-actions' + +interface GutterTreeNodeProps { + nodeId: AnyNodeId + depth: number + isLast?: boolean +} + +export const GutterTreeNode = memo(function GutterTreeNode({ + nodeId, + depth, + isLast, +}: GutterTreeNodeProps) { + const [isEditing, setIsEditing] = useState(false) + const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) + const node = useScene((s) => s.nodes[nodeId] as GutterNode | undefined) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const handled = handleTreeSelection( + e, + nodeId, + useViewer.getState().selection.selectedIds, + setSelection, + ) + if (!handled && useEditor.getState().phase === 'furnish') { + useEditor.getState().setPhase('structure') + } + }, + [nodeId, setSelection], + ) + + const defaultName = node?.name || 'Gutter' + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 6835a62dd..ce476de1c 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -63,6 +63,7 @@ import { DoorTreeNode } from './door-tree-node' import { DormerTreeNode } from './dormer-tree-node' import { ElevatorTreeNode } from './elevator-tree-node' import { FenceTreeNode } from './fence-tree-node' +import { GutterTreeNode } from './gutter-tree-node' import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' import { RidgeVentTreeNode } from './ridge-vent-tree-node' @@ -123,6 +124,7 @@ const treeNodeByType: Record< }>, wall: WallTreeNode, fence: FenceTreeNode, + gutter: GutterTreeNode, 'ridge-vent': RidgeVentTreeNode, roof: RoofTreeNode, stair: StairTreeNode, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index c5718baf1..5023ce55e 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -12,6 +12,7 @@ export { default as Editor } from './components/editor' // surface uses the shorter, shell-friendly names from the unified // preset-system spec. export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' +export { formatMeasurement, MeasurementPill } from './components/editor/measurement-pill' export { type SnapshotCameraData, ThumbnailGenerator, @@ -191,7 +192,7 @@ export { getActivePaintMaterialLabel, hasActivePaintMaterial, } from './lib/material-paint' -export { duplicateRoofSubtree } from './lib/roof-duplication' +export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 232d3532e..a1be2baae 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -90,6 +90,8 @@ export type StructureTool = | 'solar-panel' | 'skylight' | 'dormer' + | 'gutter' + | 'downspout' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index e0d61abd5..2b6ce03db 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -1,7 +1,135 @@ -import { BoxVentNode as BoxVentNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { + type BoxVentNode as BoxVentNodeType, + BoxVentNode as BoxVentNodeSchema, + type HandleDescriptor, + type NodeDefinition, +} from '@pascal-app/core' +import { buildBoxVentFloorplan } from './floorplan' import { boxVentParametrics } from './parametrics' import { BoxVentNode } from './schema' +// Edge-to-arrow-center offset, matching the chimney / dormer cadence. +const SIDE_HANDLE_OFFSET = 0.25 +const HEIGHT_HANDLE_OFFSET = 0.2 +const ROTATE_CORNER_OFFSET = 0.25 +// Min sizes — vents are small (default 0.4 × 0.4 × 0.15), so the floor +// is well below the default values to allow shrinking without locking. +const MIN_DIM = 0.1 +const MIN_HEIGHT = 0.05 + +// Mid-Y of the vent body in vent-mesh-local. The vent sits with its base +// at y=0 on the slope, so mid is half the height. Side / depth / rotate +// chevrons all place their handle at this Y to read as "this dimension +// is the vent body". +function getBodyMidY(n: BoxVentNodeType): number { + return Math.max(0.001, n.height) / 2 +} + +// Width arrow on the +X (right) or -X (left) side of the vent body. +// Asymmetric resize — anchored edge stays world-fixed by recentering +// `position` along the vent's own +X arm in segment frame (matches the +// chimney / dormer width handle math). The slope tilt rotates around +// the vent's base point, so segment-local XZ of the anchored edge stays +// the same regardless of tilt; only the yaw matters for the projection. +function boxVentWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_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), getBodyMidY(n), 0], + // Flip the left chevron so it points outward toward -X. The + // generic LinearArrow auto-orients for axis 'z'; +X / -X facing + // is up to the descriptor. + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Depth arrow on the +Z side. Symmetric (anchor 'center') matches the +// chimney / dormer handle count budget — splitting into asymmetric front / +// back chevrons here would push the vent over the same TSL/MRT pipeline +// threshold those nodes already document. Single symmetric chevron grows +// the depth from the centre. +function boxVentDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_DIM, + currentValue: (n) => n.depth, + apply: (_n, newValue) => ({ depth: newValue }), + placement: { + position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET], + }, + } +} + +// Height arrow above the top of the vent. anchor='min' so the base stays +// pinned to the slope at vent-local y=0 and the top edge follows the +// pointer. Plain chevron (not tracker) — at default sizes (~0.15 m) a +// dashed leader from base to top reads as visual noise rather than a +// dimension cue. +function boxVentHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_HEIGHT, + currentValue: (n) => n.height, + apply: (_n, newValue) => ({ height: Math.max(MIN_HEIGHT, newValue) }), + placement: { + position: (n) => [0, Math.max(n.height, MIN_HEIGHT) + HEIGHT_HANDLE_OFFSET, 0], + }, + } +} + +// Whole-vent rotation gizmo at the +X/+Z corner of the body footprint. +// The registered group already centres on the vent and applies its +// composed slope+yaw quaternion, so the default rotation pivot is +// correct — no `rotationCenter` override needed. +function boxVentRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n) => [ + n.width / 2 + ROTATE_CORNER_OFFSET, + getBodyMidY(n), + n.depth / 2 + ROTATE_CORNER_OFFSET, + ], + // Aim the two-headed icon along the +X+Z corner bisector. + rotationY: () => -Math.PI / 4, + }, + } +} + +const boxVentHandles: HandleDescriptor[] = [ + boxVentWidthHandle('right'), + boxVentWidthHandle('left'), + boxVentDepthHandle(), + boxVentHeightHandle(), + boxVentRotateHandle(), +] + /** * Box vent — a small louvered ventilation box that sits on a roof * slope. Parented to a `roof-segment`; position is segment-local; @@ -52,6 +180,8 @@ export const boxVentDefinition: NodeDefinition = { }, parametrics: boxVentParametrics, + handles: boxVentHandles, + floorplan: buildBoxVentFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/box-vent/floorplan.ts b/packages/nodes/src/box-vent/floorplan.ts new file mode 100644 index 000000000..5287e3f05 --- /dev/null +++ b/packages/nodes/src/box-vent/floorplan.ts @@ -0,0 +1,195 @@ +import type { + AnyNodeId, + BoxVentNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' + +/** + * Floor-plan builder for a box vent — a small attic-exhaust vent on a roof + * slope. Seen from above it reads as its footprint per style: `box` is a + * cover with an inset riser, `cap` flares to a flange past the body, and + * `dome` is a flush ellipse. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → vent), same as the chimney builder. `position` + * is segment-local (X = width, Z = depth; Y ignored — anchored to the + * slope). `rotation` is yaw. Rotations negated for the floor plan's y-down + * convention. + */ +export function buildBoxVentFloorplan( + node: BoxVentNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Painted-metal vent — cool grey, accent on select, light blue on hover. + const baseInk = '#475569' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#dbe1e8' + const fillOpacity = showSelectedChrome ? 0.55 : 0.7 + const lineWidth = showSelectedChrome ? 0.03 : 0.02 + + const hw = Math.max(node.width, 0.05) / 2 + const hd = Math.max(node.depth, 0.05) / 2 + const style = node.style ?? 'cap' + + const rect = (halfX: number, halfZ: number): FloorplanPoint[] => [ + toPlan(-halfX, -halfZ), + toPlan(halfX, -halfZ), + toPlan(halfX, halfZ), + toPlan(-halfX, halfZ), + ] + const ellipse = (halfX: number, halfZ: number): FloorplanPoint[] => { + const pts: FloorplanPoint[] = [] + const N = 28 + for (let i = 0; i < N; i++) { + const a = (i / N) * Math.PI * 2 + pts.push(toPlan(halfX * Math.cos(a), halfZ * Math.sin(a))) + } + return pts + } + + const children: FloorplanGeometry[] = [] + + if (style === 'dome') { + const outer = ellipse(hw, hd) + children.push({ + kind: 'polygon', + points: outer, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + children.push({ + kind: 'polygon', + points: outer, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + pointerEvents: 'none', + }) + // Inner ring suggests the dome bulge. + children.push({ + kind: 'polygon', + points: ellipse(hw * 0.55, hd * 0.55), + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.6, + pointerEvents: 'none', + }) + } else if (style === 'cap') { + // Flange flares past the body by `hoodOverhang` on all sides. + const ovh = Math.max(0, node.hoodOverhang ?? 0.04) + const outer = rect(hw + ovh, hd + ovh) + children.push({ + kind: 'polygon', + points: outer, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + children.push({ + kind: 'polygon', + points: outer, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + // Body footprint inside the flange. + children.push({ + kind: 'polygon', + points: rect(hw, hd), + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.7, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } else { + // box: cover footprint + inset riser. + const outer = rect(hw, hd) + children.push({ + kind: 'polygon', + points: outer, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + children.push({ + kind: 'polygon', + points: outer, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + const inset = Math.max(0, Math.min(node.baseInset ?? 0.06, Math.min(hw, hd) - 0.01)) + if (inset > 0.001) { + children.push({ + kind: 'polygon', + points: rect(hw - inset, hd - inset), + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.7, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index abc315985..312e36438 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -14,8 +14,8 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import BoxVentPreview from './preview' /** diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index a373ed818..98be50e59 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -18,14 +18,13 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { buildBoxVentGeometry } from './geometry' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, roughness: 0.85, metalness: 0.1, - side: THREE.DoubleSide, }) /** @@ -108,26 +107,32 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { }, [segment, node.position[0], node.position[2]]) // Paint surface: explicit material wins, then preset, then the cached - // default. Mirrors the slab / stair / wall pattern. Preset materials - // come from the shared cache with `side: FrontSide`; clone + force - // DoubleSide locally so back faces of the vent body / hood don't drop - // out when the camera looks up at the eaves. + // default. FrontSide everywhere — DoubleSide on the role material's + // NodeMaterial poisons the MRT scene pass (see `materials.ts` line 77 / + // glazing fix 9400f1c5). Earlier this path forced DoubleSide so back + // faces of the vent body / hood wouldn't drop out when looking up at the + // eaves; that's now a known visual tradeoff — a closed-solid extrude in + // `geometry.ts` is the right fix if undersides become noticeable. const material = useMemo(() => { - // Untextured box vent (and textures-off mode) takes the themed 'roof' - // role colour. Request DoubleSide directly so the cached role material - // is the right side — no clone/mutation of a shared material. if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme) + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } - const base = node.material + return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - if (base.side === THREE.DoubleSide) return base - const cloned = base.clone() - cloned.side = THREE.DoubleSide - return cloned }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + // Compose slope tilt + yaw onto a single quaternion so the registered + // ref's local frame is vent-mesh-local. `NodeArrowHandles` reads this + // frame to place its chevrons; collapsing the nested-group stack onto + // the registered group lets handles use vent-local coords directly, + // without per-arrow tilt compensation. Mirrors solar-panel's renderer. + const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), []) + const composedQuat = useMemo(() => { + const yawQuat = new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0) + return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat) + }, [surfaceQuat, node.rotation, yAxis]) + if (!segment) return null // `node.position` is segment-local (the placement + move tools resolve @@ -146,21 +151,18 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { - - - - - + ) diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index cb004c14d..814434db0 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -13,8 +13,8 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { boxVentDefinition } from './definition' import BoxVentPreview from './preview' diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts index 207733a14..ee63d60f7 100644 --- a/packages/nodes/src/building/definition.ts +++ b/packages/nodes/src/building/definition.ts @@ -31,6 +31,13 @@ export const buildingDefinition: NodeDefinition = { presettable: false, }, + // Building-wide drag (whole-building translate + R/T rotation). Routed + // through `MoveTool`'s registry-affordance lookup rather than a + // hardcoded dispatcher arm. + affordanceTools: { + move: () => import('./move-tool'), + }, + parametrics: buildingParametrics, renderer: { diff --git a/packages/editor/src/components/tools/building/move-building-tool.tsx b/packages/nodes/src/building/move-tool.tsx similarity index 95% rename from packages/editor/src/components/tools/building/move-building-tool.tsx rename to packages/nodes/src/building/move-tool.tsx index 0917abb28..d3cc99b35 100644 --- a/packages/editor/src/components/tools/building/move-building-tool.tsx +++ b/packages/nodes/src/building/move-tool.tsx @@ -8,13 +8,10 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' +import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' const Y_AXIS = new THREE.Vector3(0, 1, 0) @@ -96,7 +93,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { if (rotationDelta !== 0) { event.preventDefault() - sfxEmitter.emit('sfx:item-rotate') + triggerSFX('sfx:item-rotate') pendingRotationRef.current += rotationDelta const mesh = sceneRegistry.nodes.get(nodeId) @@ -124,7 +121,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) ) { - sfxEmitter.emit('sfx:grid-snap') + triggerSFX('sfx:grid-snap') } previousGridPosRef.current = [gridX, gridZ] @@ -154,7 +151,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { }) useScene.temporal.getState().pause() - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] }) exitMoveMode() event.nativeEvent?.stopPropagation?.() @@ -207,3 +204,5 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { ) } + +export default MoveBuildingContent diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 1b743ef81..82a985e7a 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -9,6 +9,7 @@ import { import { createSurfaceRoleMaterial, NodeRenderer, + resolveSurfaceColor, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -86,8 +87,17 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { // 'ceiling' role colour; only an explicit preset/material keeps a texture. const hasExplicit = Boolean(node.materialPreset || node.material) if (!textures || !hasExplicit) { + // Bottom (seen from inside the room, looking up) stays opaque so the + // ceiling reads as a solid surface. Top uses the transparent + // grid-pattern material so the ceiling stays see-through whenever + // the editor reveals the `ceiling-grid` overlay (placing a + // ceiling-hosted item, or selecting one of its children — e.g. + // after committing a placement). Without this the top mesh shipped + // an opaque surface-role material, so a top-down camera lost view + // of everything under the ceiling once the overlay turned on. + const ceilingColor = resolveSurfaceColor('ceiling', colorPreset, sceneTheme) return { - topMaterial: createSurfaceRoleMaterial('ceiling', colorPreset, FrontSide, sceneTheme), + topMaterial: getCeilingMaterials(ceilingColor).topMaterial, bottomMaterial: createSurfaceRoleMaterial('ceiling', colorPreset, BackSide, sceneTheme), } } diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index 4cb0d276c..c68c3f339 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -1,8 +1,327 @@ -import { ChimneyNode as ChimneyNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { + type AnyNodeId, + type ChimneyNode as ChimneyNodeType, + ChimneyNode as ChimneyNodeSchema, + getActiveRoofHeight, + type HandleDescriptor, + type NodeDefinition, + type RoofSegmentNode as RoofSegmentNodeType, + type SceneApi, +} from '@pascal-app/core' +import { buildChimneyFloorplan } from './floorplan' import { chimneyPaint } from './paint' import { chimneyParametrics } from './parametrics' import { ChimneyNode } from './schema' +// Side handle offsets in metres. Match the roof-segment values so a +// segment + chimney selected back-to-back use the same visual rhythm. +const SIDE_HANDLE_OFFSET = 0.25 +const HEIGHT_HANDLE_OFFSET = 0.25 +const ROTATE_CORNER_OFFSET = 0.35 +const MIN_BODY_DIM = 0.3 +const MIN_HEIGHT_ABOVE_RIDGE = 0.05 +const MIN_FLUE_HEIGHT = 0.05 +const MAX_FLUE_HEIGHT = 1.5 +const MIN_CAP_THICKNESS = 0.02 +const MAX_CAP_THICKNESS = 0.5 +const MIN_CAP_OVERHANG = 0 +const MAX_CAP_OVERHANG = 0.3 +// Cap-reveal gap between body top and cap base — mirrors the +// `CAP_REVEAL` constant in `geometry.ts`. Local copy because handle +// placements need to know the cap's Y range and the geometry's +// constant isn't exported. If you change it here, change it there. +const CAP_REVEAL = 0.003 +// Fallback Y when the host segment can't be resolved (shouldn't happen +// for a placed chimney, but `placement.position` runs synchronously and +// must always return a vector). +const FALLBACK_BODY_MID_Y = 1.5 + +// Resolve the segment that hosts this chimney. Returns undefined if the +// chimney is unparented or the parent isn't in the scene yet. +function resolveHostSegment( + node: ChimneyNodeType, + sceneApi: SceneApi, +): RoofSegmentNodeType | undefined { + if (!node.roofSegmentId) return undefined + return sceneApi.get(node.roofSegmentId as AnyNodeId) +} + +// Mid-Y of the visible chimney body in the host segment's local frame. +// Matches the geometry builder: body runs from `baseY = max(0, wallHeight +// - 0.2)` up to `peakY + heightAboveRidge`. The handle Y picks the +// midpoint of the *visible* portion (deck plane → top) so chevrons sit +// next to the body, not buried inside the roof deck or floating over +// the eave. +function getBodyMidY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number { + const peakY = segment.wallHeight + getActiveRoofHeight(segment) + const topY = peakY + node.heightAboveRidge + return (segment.wallHeight + topY) / 2 +} + +// Top of the chimney body (where the cap reveal gap begins). Tracker +// handle and cap-thickness handle both reference this Y. +function getBodyTopY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number { + return segment.wallHeight + getActiveRoofHeight(segment) + node.heightAboveRidge +} + +// Cap base Y — the bottom of the cap slab. Sits just above the body +// top with a small reveal gap so a shadow line separates them. +function getCapBaseY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number { + return getBodyTopY(node, segment) + CAP_REVEAL +} + +// Cap top Y — the top of the cap slab. Falls back to body top when no +// cap is rendered (flues mount on whichever is the upper surface). +function getCapTopY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number { + if (!node.cap || node.capShape === 'none') return getBodyTopY(node, segment) + return getCapBaseY(node, segment) + node.capThickness +} + +// Width arrow on the +X (right) or -X (left) side. Asymmetric resize: +// dragging one arrow grows the chimney outward from its own edge while +// the opposite edge stays world-fixed. Handles live in the chimney's +// registered ref frame (the nested inner group in the renderer that +// applies `node.position` / `node.rotation`), so placements are in +// chimney-local coordinates — no per-arrow rotation/translation +// compensation. `apply` keeps the world-fixed edge anchored even when +// the chimney is rotated by recentering `position` along the chimney's +// own +X arm in segment frame. +function chimneyWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + // Portal into the roof (grandparent), not the segment (parent). Unpainted + // roof segments live inside a `visible={false}` wrapper, which would + // hide the handles. The roof itself is always visible. Skylight does + // the same. + portal: 'grandparent', + min: MIN_BODY_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, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + const y = segment ? getBodyMidY(n, segment) : FALLBACK_BODY_MID_Y + return [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), y, 0] + }, + // Chevron faces along the chimney's own ±X; the left arrow flips + // 180°. No node.rotation here — the registered inner group is + // already rotated by `node.rotation`, so chimney-local +X is the + // chevron's natural direction. + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Depth arrow — symmetric on the +Z side. Only meaningful for square +// bodies; round chimneys are circular (depth field is ignored by the +// geometry builder, so a depth handle would just resize an invisible +// field). The chimneys factory below omits this descriptor for round +// bodies. +function chimneyDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_BODY_DIM, + currentValue: (n) => n.depth, + apply: (_n, newValue) => ({ depth: newValue }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + const y = segment ? getBodyMidY(n, segment) : FALLBACK_BODY_MID_Y + return [0, y, n.depth / 2 + SIDE_HANDLE_OFFSET] + }, + }, + } +} + +// Height-above-ridge tracker. Dashed leader spans the chimney body's +// visible extent — from the roof deck plane up to the body top — and +// terminates in a draggable cube at the body top. Cap, flues, cricket +// and bands sit ABOVE the body and are explicitly excluded from the +// leader so the height affordance reads as "this is the body height", +// not "this is the whole stack height". Dragging the cube vertically +// adjusts `heightAboveRidge` 1:1. +function chimneyHeightAboveRidgeHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + shape: 'tracker', + min: MIN_HEIGHT_ABOVE_RIDGE, + currentValue: (n) => n.heightAboveRidge, + apply: (initial, newValue) => ({ + heightAboveRidge: Math.max(MIN_HEIGHT_ABOVE_RIDGE, newValue), + }), + placement: { + // Cube sits AT the body top (no offset) so the leader terminates + // exactly at the body's top edge — visually the "ceiling" of the + // body, before the cap reveal gap. + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + const y = segment ? getBodyTopY(n, segment) : FALLBACK_BODY_MID_Y + return [0, y, 0] + }, + }, + // Leader bottom = deck plane (segment.wallHeight). The chimney body + // geometry actually extends a touch below the deck so the bottom + // doesn't show above the eave on low-slope roofs (`baseY = max(0, + // wallHeight - 0.2)`), but the visible portion starts at the deck — + // and starting the leader there is what reads as "body height" to + // the user. + trackerBaseY: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + return segment?.wallHeight ?? 0 + }, + } +} + +// Whole-chimney rotation gizmo at the +X/+Z corner of the body +// footprint. The registered inner group already centers on the chimney +// and applies its yaw, so the default rotation pivot (rideObject origin) +// is correct — no `rotationCenter` override needed. +function chimneyRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + const y = segment ? getBodyMidY(n, segment) : FALLBACK_BODY_MID_Y + const isRound = n.bodyShape === 'round' + const halfX = n.width / 2 + ROTATE_CORNER_OFFSET + const halfZ = (isRound ? n.width : n.depth) / 2 + ROTATE_CORNER_OFFSET + return [halfX, y, halfZ] + }, + // The two-headed icon's natural bias points along +X; aim it + // toward the corner (45° outward from the chimney's local frame). + rotationY: () => -Math.PI / 4, + }, + } +} + +// Flue-height chevron at the center of the cap top, pointing upward. +// Drag adjusts `flueHeight` for ALL flues uniformly — the schema only +// carries a single scalar. Placed at chimney center (X=Z=0) so the +// handle stays valid regardless of `flueCount` / `flueSpacing`. Anchor +// is 'min' so the flue base stays pinned to the cap and the top edge +// follows the pointer. +function chimneyFlueHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + portal: 'grandparent', + min: MIN_FLUE_HEIGHT, + max: MAX_FLUE_HEIGHT, + currentValue: (n) => n.flueHeight, + apply: (_n, newValue) => ({ flueHeight: newValue }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + // Sit the chevron at the flue top so it visually attaches to + // the thing being dragged. Fallback Y mirrors the body-top + // fallback above. + const baseY = segment ? getCapTopY(n, segment) : FALLBACK_BODY_MID_Y + return [0, baseY + n.flueHeight, 0] + }, + }, + } +} + +// Cap-thickness chevron above the cap top, pointing upward. Anchor is +// 'min' so the cap base stays at body-top + reveal and the top edge +// follows the pointer. +function chimneyCapThicknessHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + portal: 'grandparent', + min: MIN_CAP_THICKNESS, + max: MAX_CAP_THICKNESS, + currentValue: (n) => n.capThickness, + apply: (_n, newValue) => ({ capThickness: newValue }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + // Offset toward +Z (away from chimney center on the depth axis) + // so the cap-thickness chevron doesn't overlap the flue-height + // chevron at X=0,Z=0. Pick the cap edge minus a small margin so + // it sits on top of the cap, not floating off to the side. + const isRound = n.bodyShape === 'round' + const halfZ = (isRound ? n.width : n.depth) / 2 + const z = halfZ * 0.35 + const y = segment ? getCapTopY(n, segment) : FALLBACK_BODY_MID_Y + return [0, y, z] + }, + }, + } +} + +// Cap-overhang radial chevron on the +X edge of the cap. Outward 1:1 +// drag grows the overhang; the cap's half-extent is `width/2 + overhang`. +function chimneyCapOverhangHandle(): HandleDescriptor { + return { + kind: 'radial-resize', + axis: 'x', + portal: 'grandparent', + min: MIN_CAP_OVERHANG, + max: MAX_CAP_OVERHANG, + currentValue: (n) => n.capOverhang, + apply: (_n, newValue) => ({ capOverhang: newValue }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + // Cap mid-height in the chimney-local frame so the chevron sits + // on the cap edge, not above or below it. + const capBaseY = segment ? getCapBaseY(n, segment) : FALLBACK_BODY_MID_Y + const y = capBaseY + n.capThickness / 2 + return [n.width / 2 + n.capOverhang + SIDE_HANDLE_OFFSET, y, 0] + }, + }, + } +} + +const chimneyHandles = (node: ChimneyNodeType): HandleDescriptor[] => { + const descriptors: HandleDescriptor[] = [ + chimneyWidthHandle('right'), + chimneyWidthHandle('left'), + ] + if (node.bodyShape !== 'round') descriptors.push(chimneyDepthHandle()) + descriptors.push(chimneyHeightAboveRidgeHandle(), chimneyRotateHandle()) + // Conditional flue/cap handles are temporarily disabled — they fired + // a "Color target has no corresponding fragment stage output" WebGPU + // validation error that the original four handles don't trigger. The + // descriptor shapes (linear-resize y / radial-resize x) match other + // working handles in the codebase, so the cause is likely a TSL/MRT + // pipeline interaction we haven't pinned down. Re-enable one at a + // time after isolating the trigger; the factory + helpers are kept so + // we can flip them back on without re-deriving the placement math. + // if (node.cap && node.capShape !== 'none') { + // descriptors.push(chimneyCapThicknessHandle(), chimneyCapOverhangHandle()) + // } + // if (node.flueCount > 0) descriptors.push(chimneyFlueHeightHandle()) + return descriptors +} + // Every fresh chimney starts as plain white (body + top). The paint // flow / material picker writes preset refs or full `MaterialSchema` // objects on top of this; until then both roles render `#ffffff`. @@ -73,6 +392,8 @@ export const chimneyDefinition: NodeDefinition = { }, parametrics: chimneyParametrics, + handles: chimneyHandles, + floorplan: buildChimneyFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/chimney/floorplan.ts b/packages/nodes/src/chimney/floorplan.ts new file mode 100644 index 000000000..989c68b8c --- /dev/null +++ b/packages/nodes/src/chimney/floorplan.ts @@ -0,0 +1,215 @@ +import type { + AnyNodeId, + ChimneyNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' +import { flueXPositions } from './geometry' + +/** + * Floor-plan builder for a chimney. A chimney is a masonry stack hosted on + * a roof segment. Seen from above it reads as its crown/cap footprint with + * the body shaft nested inside (the cap overhangs the body) and the flue + * openings poking out the top. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → chimney). The chimney's `position` is + * segment-local (X = width axis, Z = depth axis; Y is ignored — the 3D + * renderer anchors it to the slope). `rotation` is yaw. We compose with + * the floor-plan's negated-rotation convention (see + * `buildRoofSegmentFloorplan`). Unlike the gutter there's no eave/overhang + * offset — a chimney sits at its own footprint, not on the drip edge. + */ +export function buildChimneyFloorplan( + node: ChimneyNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + // Compose roof → segment → chimney in plan coords. Each rotation is + // negated so SVG's y-down CW matches Three.js' top-down CCW. + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Masonry — warm stone grey, accent on select, light blue on hover. + const baseInk = '#44403c' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#d6d3d1' + const fillOpacity = showSelectedChrome ? 0.55 : 0.6 + const lineWidth = showSelectedChrome ? 0.03 : 0.022 + + const isRound = node.bodyShape === 'round' + const halfW = Math.max(node.width, 0.05) / 2 + // Round bodies use `width` as the diameter and ignore `depth`. + const halfD = isRound ? halfW : Math.max(node.depth, 0.05) / 2 + const hasCap = node.cap && node.capShape !== 'none' + const overhang = hasCap ? Math.max(node.capOverhang, 0) : 0 + const capHalfW = halfW + overhang + const capHalfD = halfD + overhang + const showBodyInset = hasCap && overhang > 0.001 + + const children: FloorplanGeometry[] = [] + + if (isRound) { + const c = toPlan(0, 0) + // Transparent hit-target across the crown footprint. + children.push({ + kind: 'circle', + cx: c[0], + cy: c[1], + r: capHalfW, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + // Crown / outer body footprint, filled. + children.push({ + kind: 'circle', + cx: c[0], + cy: c[1], + r: capHalfW, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + pointerEvents: 'none', + }) + // Body shaft inside the cap overhang. + if (showBodyInset) { + children.push({ + kind: 'circle', + cx: c[0], + cy: c[1], + r: halfW, + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.7, + pointerEvents: 'none', + }) + } + } else { + const capCorners: FloorplanPoint[] = [ + toPlan(-capHalfW, -capHalfD), + toPlan(capHalfW, -capHalfD), + toPlan(capHalfW, capHalfD), + toPlan(-capHalfW, capHalfD), + ] + children.push({ + kind: 'polygon', + points: capCorners, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + children.push({ + kind: 'polygon', + points: capCorners, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + if (showBodyInset) { + children.push({ + kind: 'polygon', + points: [ + toPlan(-halfW, -halfD), + toPlan(halfW, -halfD), + toPlan(halfW, halfD), + toPlan(-halfW, halfD), + ], + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.7, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } + } + + // Flue openings poking out the crown — drawn along the chimney's local X + // at z = 0, matching `flueXPositions` (the same layout the 3D pots use). + // Round or square per `flueShape`. Hollow so they read as openings. + const flueCount = Math.max(0, Math.min(4, node.flueCount)) + if (flueCount > 0) { + const d = Math.max(0.02, node.flueDiameter) + const r = d / 2 + const xs = flueXPositions(flueCount, node.width, d, node.flueSpacing) + const flueStroke = showSelectedChrome && palette ? palette.selectedStroke : '#292524' + for (const fx of xs) { + if (node.flueShape === 'square') { + children.push({ + kind: 'polygon', + points: [ + toPlan(fx - r, -r), + toPlan(fx + r, -r), + toPlan(fx + r, r), + toPlan(fx - r, r), + ], + fill: 'none', + stroke: flueStroke, + strokeWidth: lineWidth * 0.8, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } else { + const c = toPlan(fx, 0) + children.push({ + kind: 'circle', + cx: c[0], + cy: c[1], + r, + fill: 'none', + stroke: flueStroke, + strokeWidth: lineWidth * 0.8, + pointerEvents: 'none', + }) + } + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/chimney/geometry.ts b/packages/nodes/src/chimney/geometry.ts index 1cf71eb33..540b8d680 100644 --- a/packages/nodes/src/chimney/geometry.ts +++ b/packages/nodes/src/chimney/geometry.ts @@ -138,7 +138,6 @@ function buildBodyGeometry(node: ChimneyNode, baseY: number, topY: number): THRE parts.push(buildSmoothCylinder(baseY + sh, topY, r, r)) } const merged = mergeAndDispose(parts) - applyNodeTransform(merged, node) return merged } @@ -213,7 +212,6 @@ function buildCapGeometry(node: ChimneyNode, capBaseY: number): THREE.BufferGeom break } const merged = mergeAndDispose(parts) - applyNodeTransform(merged, node) return merged } @@ -450,7 +448,6 @@ function buildBandsGeometry( } if (parts.length === 0) return null const merged = mergeAndDispose(parts) - applyNodeTransform(merged, node) return merged } @@ -484,10 +481,14 @@ function buildBandsGeometry( // ─── Helpers ───────────────────────────────────────────────────────── -function applyNodeTransform(geo: THREE.BufferGeometry, node: ChimneyNode) { - if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation) - geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0) -} +// Each builder returns geometry in chimney-local frame (chimney center +// at X/Z origin, Y absolute in the host segment's frame). The renderer +// applies `node.position` / `node.rotation` via a nested registered +// group, which lets `NodeArrowHandles` read a chimney-local mesh frame +// when placing the resize / rotation arrows. Kept as a no-op shim so +// the existing call sites don't need to be touched if a future refactor +// re-introduces per-builder baking. +function applyNodeTransform(_geo: THREE.BufferGeometry, _node: ChimneyNode) {} function buildBufferGeometry(positions: number[], uvs: number[]): THREE.BufferGeometry { const geo = new THREE.BufferGeometry() diff --git a/packages/nodes/src/chimney/holes.ts b/packages/nodes/src/chimney/holes.ts index e8fef23f8..a260f06fd 100644 --- a/packages/nodes/src/chimney/holes.ts +++ b/packages/nodes/src/chimney/holes.ts @@ -270,8 +270,9 @@ function buildPanelCutters(node: ChimneyNode, topY: number): Brush[] { for (const f of faces) { const geo = new THREE.BoxGeometry(f.sizeX, h, f.sizeZ) geo.translate(f.cx, midY, f.cz) - if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation) - geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0) + // Body geometry is in chimney-local frame (node.position/rotation are + // applied by the renderer's nested ref'd group, not baked into the + // buffer geometry), so cutters need to stay in chimney-local too. const idx = geo.getIndex()?.count ?? 0 geo.clearGroups() @@ -298,10 +299,9 @@ function buildCutter( ? new THREE.CylinderGeometry(spec.sizeX / 2, spec.sizeX / 2, h, 24, 1, false) : new THREE.BoxGeometry(spec.sizeX, h, spec.sizeZ) geo.translate(spec.xCenter, midY, 0) - // Match the same node-local transform that `geometry.ts:applyNodeTransform` - // bakes into the body/cap/flue vertices. - if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation) - geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0) + // Cutter stays in chimney-local frame to match the body/cap/flue + // geometry (node.position/rotation are applied via the renderer's + // nested ref'd group, not baked into the buffer geometry). const idx = geo.getIndex()?.count ?? 0 geo.clearGroups() diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index 113b90197..ca57e0a9c 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -15,7 +15,7 @@ import { triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import ChimneyPreview from './preview' const tmpMatrix = new THREE.Matrix4() diff --git a/packages/nodes/src/chimney/renderer.tsx b/packages/nodes/src/chimney/renderer.tsx index 508475dd6..16aaa2f65 100644 --- a/packages/nodes/src/chimney/renderer.tsx +++ b/packages/nodes/src/chimney/renderer.tsx @@ -223,67 +223,71 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => { if (!segment || !geo) return null - // The chimney's geometry bakes its baseY using segment.wallHeight inside - // the builder, so the outer group only needs the segment-local X/Z - // offset. Y stays at 0 here. - // Chimneys are mounted inside `RoofRenderer`'s `roof-elements` group, // which sits at the ROOF's origin — not inside the host segment's - // transform. Apply the segment's own position/rotation here so a - // chimney parented to segment N lands on segment N (and not on the - // first segment) once the chimney's segment-local `node.position[0/2]` - // is layered in by `geometry.ts`. Mirrors skylight's renderer. + // transform. Apply the segment's pose on the outer group, then nest a + // ref'd inner group at the chimney's segment-local position + + // rotation so the registered Object3D's local frame is *chimney-local* + // — that's what `NodeArrowHandles` reads to place its arrows. + // Mirrors skylight's renderer; geometry comes from `geometry.ts` in + // chimney-local frame (no transform baking) and lands in the right + // world spot via the two-group stack below. return ( - - {geo.cap && ( + - )} - {geo.flues && ( - - )} - {geo.cricket && ( - - )} - {geo.bands && ( - - )} + {geo.cap && ( + + )} + {geo.flues && ( + + )} + {geo.cricket && ( + + )} + {geo.bands && ( + + )} + ) } diff --git a/packages/nodes/src/chimney/roof-trim.ts b/packages/nodes/src/chimney/roof-trim.ts index 6fc76e23d..225b0c96d 100644 --- a/packages/nodes/src/chimney/roof-trim.ts +++ b/packages/nodes/src/chimney/roof-trim.ts @@ -40,12 +40,21 @@ export function trimChimneyBodyAgainstRoof( ): THREE.BufferGeometry { const { shinSlab, wallBrush } = segBrushes - // Wrap the chimney body in a Brush. The body has `node.position` / - // `node.rotation` baked into its vertices via `applyNodeTransform` - // in `geometry.ts`, so it's already in segment-local space — the - // same frame as the roof brushes from `getRoofSegmentBrushes`. + // The body comes in chimney-local frame — `node.position` / + // `node.rotation` are applied by the renderer's nested ref'd group + // rather than baked into the geometry. Segment brushes from + // `getRoofSegmentBrushes` are in segment-local frame, so we move the + // chimney brush into segment-local space for the CSG pass, then strip + // the same transform back off the result before returning, so the + // mesh stays in chimney-local for the renderer to position via the + // inner ref group. const indexed = mergeVertices(body, 1e-4) if (!indexed.getAttribute('normal')) indexed.computeVertexNormals() + const hasRotation = Math.abs(node.rotation) > 1e-4 + const posX = node.position[0] ?? 0 + const posZ = node.position[2] ?? 0 + if (hasRotation) indexed.rotateY(node.rotation) + indexed.translate(posX, 0, posZ) const indexCount = indexed.getIndex()?.count ?? 0 indexed.clearGroups() if (indexCount > 0) indexed.addGroup(0, indexCount, 0) @@ -70,6 +79,11 @@ export function trimChimneyBodyAgainstRoof( const step2 = csgEvaluator.evaluate(step1, shinSlab, SUBTRACTION) as Brush const out = csgGeometry(step2).clone() + // Strip the same node transform we baked onto the input so the + // returned geometry is back in chimney-local frame (the renderer's + // inner ref'd group applies `node.position` / `node.rotation`). + out.translate(-posX, 0, -posZ) + if (hasRotation) out.rotateY(-node.rotation) const ic = out.getIndex()?.count ?? 0 out.clearGroups() if (ic > 0) out.addGroup(0, ic, 0) diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index d9b582aae..522caf28a 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -14,7 +14,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { chimneyDefinition } from './definition' import ChimneyPreview from './preview' diff --git a/packages/nodes/src/column/floorplan.ts b/packages/nodes/src/column/floorplan.ts index ea6c44225..6f5d229da 100644 --- a/packages/nodes/src/column/floorplan.ts +++ b/packages/nodes/src/column/floorplan.ts @@ -156,6 +156,7 @@ export function buildColumnFloorplan( point: [cx + cornerWorldX, cz + cornerWorldZ], angle: Math.atan2(radialZ, radialX), affordance: 'column-rotate', + pivot: [cx, cz], }) } diff --git a/packages/nodes/src/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index 4141e5af3..27ebc1621 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -57,11 +57,16 @@ export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeo const isFlat = dormer.roofType === 'flat' || roofH === 0 // Body box: foot at y = -skirt, top at y = wallH. - const body = new THREE.BoxGeometry(w, wallH + skirt, d) - body.translate(0, (wallH - skirt) / 2, 0) - const bIdx = body.getIndex()?.count ?? 0 + // BoxGeometry is indexed; ExtrudeGeometry below is not. mergeGeometries + // refuses mixed input ("index attribute exists among all geometries, + // or in none of them") — drop the body's index so both inputs match. + const indexedBody = new THREE.BoxGeometry(w, wallH + skirt, d) + indexedBody.translate(0, (wallH - skirt) / 2, 0) + const body = indexedBody.toNonIndexed() + indexedBody.dispose() + const bVtx = body.getAttribute('position').count body.clearGroups() - body.addGroup(0, bIdx, 0) + body.addGroup(0, bVtx, 0) if (isFlat) { if (!body.getAttribute('normal')) body.computeVertexNormals() @@ -78,9 +83,9 @@ export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeo const roof = new THREE.ExtrudeGeometry(roofShape, { depth: d, bevelEnabled: false }) roof.translate(0, wallH, -d / 2) - const rIdx = roof.getIndex()?.count ?? 0 + const rVtx = roof.getAttribute('position').count roof.clearGroups() - roof.addGroup(0, rIdx, 3) + roof.addGroup(0, rVtx, 3) const merged = mergeGeometries([body, roof], true) ?? body body.dispose() @@ -187,11 +192,20 @@ function createDormerWindowCutGeometry( } /** - * Which faces of a dormer are exposed (not fully buried in the host - * roof). "front" = mesh-local +Z, "back" = mesh-local −Z (after the - * +π/2 yaw bake for non-shed roofs). A face is exposed when the - * dormer's total wall top exceeds the host roof surface at that face's - * Z position. + * Which gable faces of a dormer have a *fully visible window opening* + * (not clipped by the host roof slope). "front" = mesh-local +Z, + * "back" = mesh-local −Z (after the +π/2 yaw bake for non-shed roofs). + * + * The criterion is window-bottom-above-slope, not wall-top-above-slope: + * the dormer wall extends well below the window into the skirt that's + * buried inside the roof, so checking just "does any wall poke above + * the slope" is far too lenient — a dormer whose eave barely clears + * the roof would pass even though the entire window (which sits inside + * the skirt, well below the eave) is buried. Switching to the window + * bottom collapses both the CSG window-cut decision (which calls into + * this function in `generateDormerGeometry`) and the live render gate + * (window-assembly.tsx) onto the right line: the window only renders + * where it's actually visible from outside. */ export function getDormerExposedFaces( dormer: DormerNode, @@ -206,7 +220,18 @@ export function getDormerExposedFaces( const frontZ = dormerZ + halfDepth * Math.cos(rot) const backZ = dormerZ - halfDepth * Math.cos(rot) - const dormerWallTop = dormerY + dormer.height + // Window bottom in dormer-local Y. Mirrors `getDormerSkirtWindowDims` + // so both functions read the same window position. The window sits + // in the skirt below the eave (dormer-local Y=0), so `centerY` is + // typically negative; subtracting half the window height lands us at + // the bottom edge. + const skirtH = dormerSkirtHeight(dormer) + const winH = Math.max(0, dormer.windowHeight ?? 0) + const winOffsetY = dormer.windowOffsetY ?? 0 + const windowCenterDormerY = -(skirtH / 2) + winOffsetY + const windowBottomDormerY = windowCenterDormerY - winH / 2 + // Lift into segment-local Y: dormer-local Y=0 sits at `dormer.position[1]`. + const windowBottomSegY = dormerY + windowBottomDormerY const hostWh = hostSegment.wallHeight ?? 0.5 const hostRh = getActiveRoofHeight(hostSegment) @@ -224,15 +249,15 @@ export function getDormerExposedFaces( return hostWh + hostRh * (1 - t) } - // A face is "exposed" only if the dormer's wall actually pokes above - // the host roof there by a meaningful amount — otherwise the wall is - // CSG-buried and any window we render at that face will hover with - // no wall behind it. A 5cm threshold suppresses the borderline-cases - // where the wall top is essentially level with the slope. + // A face is "exposed" only if the *window bottom* clears the host + // slope at that face's Z by a meaningful amount — borderline cases + // (slope grazing the window bottom) suppress the window so we don't + // render a partially-clipped frame poking out of the roof. 5cm + // matches the threshold the prior wall-top check used. const minPokeOut = 0.05 return { - front: dormerWallTop - roofHeightAtZ(frontZ) > minPokeOut, - back: dormerWallTop - roofHeightAtZ(backZ) > minPokeOut, + front: windowBottomSegY - roofHeightAtZ(frontZ) > minPokeOut, + back: windowBottomSegY - roofHeightAtZ(backZ) > minPokeOut, } } diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 30ba96cd8..ce929a785 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -1,14 +1,379 @@ import { type AnyNode, + type AnyNodeId, DormerNode as DormerNodeSchema, type DormerNode as DormerNodeType, + type HandleDescriptor, type NodeDefinition, + type RoofSegmentNode as RoofSegmentNodeType, + type SceneApi, } from '@pascal-app/core' -import { buildDormerRoofCut } from './csg-geometry' +import { buildDormerRoofCut, getDormerExposedFaces } from './csg-geometry' +import { buildDormerFloorplan } from './floorplan' import { dormerPaint } from './paint' import { dormerParametrics } from './parametrics' import { DormerNode } from './schema' +const SIDE_HANDLE_OFFSET = 0.25 +const HEIGHT_HANDLE_OFFSET = 0.25 +const ROTATE_CORNER_OFFSET = 0.35 +const ROTATE_RING_OFFSET = 0.08 +// Schema/parametrics ranges — keep these aligned with `dormerParametrics` +// so the in-world drag and the inspector slider clamp identically. +const MIN_DIM = 0.5 +const MIN_HEIGHT = 0 +const MIN_ROOF_HEIGHT = 0 +const MAX_ROOF_HEIGHT = 2 +const MIN_SKIRT = 0.2 +const MAX_SKIRT = 6 +// Window-handle constants. The window opening is parametric geometry +// on the dormer's +Z gable face; chevrons sit just outside its rim +// with a small forward Z offset so they pop in front of the wall plane +// instead of z-fighting with the frame bars. +const WINDOW_SIDE_HANDLE_OFFSET = 0.15 +const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15 +const WINDOW_FACE_Z_OFFSET = 0.05 +// Lower clamp for window dims matches the geometry's internal clamp +// in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the +// dormer dimensions and are resolved per-handle via the function form +// of `max`. +const MIN_WINDOW_DIM = 0.1 +// Clamp used for handle Y placement so side chevrons stay reachable on +// dormers whose wall is flat (`height ≈ 0`). The dormer body is +// `height + roofHeight` tall; if that collapses too, the side arrows +// would bury into the deck — this floor keeps them visible. +const MIN_BODY_DISPLAY = 0.3 + +// Mid-Y of the dormer body in dormer-local frame. Y=0 is the eave +// (where wall meets skirt); body extends up to `height + roofHeight`. +// Side chevrons sit at the body midpoint so they read as "this is the +// dormer's footprint" rather than floating at the apex or the eave. +function getBodyMidY(n: DormerNodeType): number { + return Math.max(n.height + n.roofHeight, MIN_BODY_DISPLAY) / 2 +} + +// Width arrow on the +X (right) or -X (left) side. Asymmetric resize: +// dragging one arrow grows the dormer outward from its own edge while +// the opposite edge stays world-fixed in segment frame. The dormer's +// registered ref frame is dormer-local (renderer applies position + +// rotation on the registered group), so placements are in dormer-local +// coords — no per-arrow rotation/translation compensation here. +// +// `apply` recomputes `position` so the anchored edge stays at the same +// segment-local point even when the dormer is Y-rotated: project the +// dormer's local +X onto segment frame via (cos r, -sin r), find the +// anchored edge's segment-local XZ from the pre-drag node, then place +// the new center half a new-width away from that anchor in the same +// direction. Mirrors chimney + roof-segment width handle math. +function dormerWidthHandle(side: 'left' | 'right'): HandleDescriptor { + 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', + // Default 'parent' portal (no `'grandparent'` escape). Arrows + // portal into the host roof segment's registered mesh, which lives + // inside the roof renderer's ``. + // That wrapper is `visible={false}` by default; `RoofEditSystem` + // imperatively flips it to `visible={true}` whenever any accessory + // hosted on a segment of this roof is selected, so the portaled + // arrows become visible during selection without us reaching for + // `portal: 'grandparent'` — which trips the same "Color target has + // no corresponding fragment stage output" WebGPU pipeline error + // chimney already documents (likely an MRT interaction with the + // window-assembly's transparent glazing meshes). + min: MIN_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), getBodyMidY(n), 0], + // Flip the left chevron so it points outward toward -X. The + // generic LinearArrow only auto-orients for axis 'z'; +X / -X + // facing is up to the descriptor. + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Depth arrow on the +Z side. Symmetric (anchor 'center') to match +// chimney's known-working handle count — splitting depth into asymmetric +// front + back chevrons puts the dormer over the per-node MRT/TSL +// budget that chimney already documents (see `chimneyHandles` factory). +// Re-evaluate the split once that pipeline issue is pinned down. +function dormerDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_DIM, + currentValue: (n) => n.depth, + apply: (_n, newValue) => ({ depth: newValue }), + placement: { + position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET], + }, + } +} + +// Wall-height tracker — dashed vertical leader from the eave (y=0) up +// to a draggable cube at the wall top (y=height), centred on the +// footprint. Reads as "the dormer wall is THIS tall" without claiming +// the roof apex. Same `linear-resize axis='y'` pipeline as every other +// height handle; `shape: 'tracker'` only swaps the visual. +function dormerWallHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + shape: 'tracker', + min: MIN_HEIGHT, + currentValue: (n) => n.height, + apply: (_n, newValue) => ({ height: newValue }), + placement: { + position: (n) => [0, Math.max(n.height, 0.001), 0], + }, + trackerBaseY: () => 0, + } +} + +// Wall-skirt chevron — sits BELOW the eave, at the bottom of the +// hung-wall skirt that extends down into the host roof. Drag pulls the +// skirt's bottom edge further down (or up) to grow / shrink +// `wallSkirtHeight`. Anchor 'max' keeps the eave (y=0) fixed; the +// linear-resize factor (-1 for anchor 'max') flips the drag sign so +// dragging the chevron downward increases the value 1:1. +// +// Plain arrow (not tracker) because tracker only renders an upward +// leader; the dashed line would point the wrong way for a downward +// span. The auto-orient logic in `ArrowHandle` flips the chevron to +// point -Y when placement.y < 0, so the arrow visibly points down. +function dormerWallSkirtHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'max', + min: MIN_SKIRT, + max: MAX_SKIRT, + currentValue: (n) => n.wallSkirtHeight, + apply: (_n, newValue) => ({ wallSkirtHeight: newValue }), + placement: { + position: (n) => [0, -(n.wallSkirtHeight + HEIGHT_HANDLE_OFFSET), 0], + }, + } +} + +// Roof-height chevron at the dormer's peak. Drag adjusts `roofHeight` +// directly — unlike roof-segment there's no pitch back-solve because +// dormer stores roof height as a literal scalar, not a pitch angle. +// Placed slightly above the apex (height + roofHeight) so the chevron +// visually attaches to the ridge. +function dormerRoofHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_ROOF_HEIGHT, + max: MAX_ROOF_HEIGHT, + currentValue: (n) => n.roofHeight, + apply: (_n, newValue) => ({ roofHeight: newValue }), + placement: { + position: (n) => [0, n.height + n.roofHeight + HEIGHT_HANDLE_OFFSET, 0], + }, + } +} + +// Whole-dormer rotation gizmo at the +X / +Z corner of the footprint, +// guide ring traces the corner-diagonal radius on hover / drag. +// Dormer-local frame is the registered group (renderer applies +// node.position + node.rotation there), so the default rotation pivot +// (rideObject origin = dormer center) is correct — no +// `rotationCenter` override needed. +function dormerRotateHandle(): HandleDescriptor { + 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 + ROTATE_CORNER_OFFSET + const halfZ = n.depth / 2 + ROTATE_CORNER_OFFSET + return [halfX, getBodyMidY(n), halfZ] + }, + // The two-headed icon's natural bias points along +X; aim it at + // the corner (45° outward from the dormer's local frame). + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: (n) => Math.hypot(n.width / 2, n.depth / 2) + ROTATE_RING_OFFSET, + y: (n) => getBodyMidY(n), + }, + } +} + +// Window-center Y in dormer-local frame. The schema stores +// `windowOffsetY` as the bottom-relative offset of the window center +// from the bottom of the skirt; the geometry then maps it to +// `centerY = -(skirtH / 2) + offsetY`. We mirror that here so handle +// placements line up with what the inspector + window-assembly use. +function getWindowCenterY(n: DormerNodeType): number { + return -(n.wallSkirtHeight / 2) + n.windowOffsetY +} + +// Sign of the dormer-local Z direction where the visible window face +// sits. The dormer renders the window on both +Z (front) and -Z (back) +// gable faces, but only whichever face actually pokes above the host +// roof slope is exposed — `getDormerExposedFaces` is the source of +// truth there. The in-world handles need to attach to that exposed +// face so the user is editing the window they can see; as the dormer +// drags across the ridge, the exposed face flips and the chevrons +// follow. +// +// Preference order when both faces are exposed (e.g. a tall gable that +// pokes above the roof on both ends): keep handles on +Z so the +// affordance stays put visually instead of flipping when the slope +// math grazes the threshold from the other side. When neither face is +// exposed (degenerate — wall buried on both sides), fall back to +Z so +// the placement still produces a valid vector; the chevrons are just +// not useful there. +function getExposedFaceZSign(n: DormerNodeType, sceneApi: SceneApi): 1 | -1 { + if (!n.roofSegmentId) return 1 + const segment = sceneApi.get(n.roofSegmentId as AnyNodeId) + if (!segment) return 1 + const exposed = getDormerExposedFaces(n, segment) + if (exposed.front) return 1 + if (exposed.back) return -1 + return 1 +} + +// Window-width chevron on the +X (right) or -X (left) edge of the +// opening. Asymmetric: dragging one arrow grows the window outward +// from its own edge while the opposite edge stays put. The framework +// only knows about the scalar `windowWidth`; we re-emit `windowOffsetX` +// in `apply` so the anchored edge stays at the same X in dormer-local. +// Placement sits on the dormer's +Z gable face, where the window opens. +function dormerWindowWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_WINDOW_DIM, + // Cap at the dormer's window field — keep a 0.1m gap on each side + // to match the geometry's interior clamp (`maxW = width - 0.1`). + max: (n) => Math.max(MIN_WINDOW_DIM, n.width - 0.1), + currentValue: (n) => n.windowWidth, + apply: (initial, newWidth) => { + // Anchored edge stays fixed: anchor X = initial.windowOffsetX - + // sign * initial.windowWidth/2. New center = anchor + sign * + // newWidth/2 → new windowOffsetX. + const anchorX = initial.windowOffsetX - sign * (initial.windowWidth / 2) + const newOffsetX = anchorX + sign * (newWidth / 2) + return { + windowWidth: newWidth, + windowOffsetX: newOffsetX, + } + }, + placement: { + position: (n, sceneApi) => { + const faceSign = getExposedFaceZSign(n, sceneApi) + return [ + n.windowOffsetX + sign * (n.windowWidth / 2 + WINDOW_SIDE_HANDLE_OFFSET), + getWindowCenterY(n), + faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET), + ] + }, + // Left chevron points -X; right points +X. LinearArrow doesn't + // auto-orient axis 'x' — descriptor handles the flip. + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Window-height chevron on the +Y (top) or -Y (bottom) edge of the +// opening. Same asymmetric pattern as the width handle, projected onto +// the Y axis. The schema stores the window's vertical position as +// `windowOffsetY` (distance from the BOTTOM of the skirt to the window +// CENTER), not as a centerY in dormer-local — so `apply` translates +// back through that mapping when it re-emits the offset. +function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor { + const sign = side === 'top' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'y', + // 'min' = bottom edge anchored (top arrow grows the top edge up). + // 'max' = top edge anchored (bottom arrow drops the bottom edge). + anchor: side === 'top' ? 'min' : 'max', + min: MIN_WINDOW_DIM, + // Cap at the skirt with a 0.1m interior margin — matches + // `maxH = skirtH - 0.1` from `getDormerSkirtWindowDims`. + max: (n) => Math.max(MIN_WINDOW_DIM, n.wallSkirtHeight - 0.1), + currentValue: (n) => n.windowHeight, + apply: (initial, newHeight) => { + // Compute the anchored edge in dormer-local Y, derive the new + // centerY, then map back to schema-form `windowOffsetY`. + const initialCenterY = -(initial.wallSkirtHeight / 2) + initial.windowOffsetY + const anchorY = initialCenterY - sign * (initial.windowHeight / 2) + const newCenterY = anchorY + sign * (newHeight / 2) + const newOffsetY = newCenterY + initial.wallSkirtHeight / 2 + return { + windowHeight: newHeight, + windowOffsetY: newOffsetY, + } + }, + placement: { + position: (n, sceneApi) => { + const faceSign = getExposedFaceZSign(n, sceneApi) + return [ + n.windowOffsetX, + getWindowCenterY(n) + sign * (n.windowHeight / 2 + WINDOW_HEIGHT_HANDLE_OFFSET), + faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET), + ] + }, + }, + } +} + +const dormerHandles: HandleDescriptor[] = [ + dormerWidthHandle('right'), + dormerWidthHandle('left'), + dormerDepthHandle(), + dormerWallHeightHandle(), + dormerRotateHandle(), + dormerWindowWidthHandle('right'), + dormerWindowWidthHandle('left'), + dormerWindowHeightHandle('top'), + dormerWindowHeightHandle('bottom'), + // The wall-skirt (downward chevron), roof-height (peak chevron), and + // the asymmetric front/back depth split stay out for now. Re-adding + // any of them previously fired the "Color target has no + // corresponding fragment stage output" WebGPU pipeline error chimney + // already documented for its flue / cap-thickness / cap-overhang + // extras — only reproducible while `portal: 'grandparent'` was set, + // which we no longer rely on (RoofEditSystem reveals the wrapper + // instead). The shapes themselves are valid; if the count budget + // turns out to also be sensitive without grandparent portal, drop + // the window handles first since the inspector covers them too. + // dormerWallSkirtHandle(), + // dormerRoofHeightHandle(), +] + /** * Dormer — a small house-shaped protrusion sitting on top of a roof * segment. The window opening is inlined into the dormer's schema @@ -69,6 +434,8 @@ export const dormerDefinition: NodeDefinition = { }, parametrics: dormerParametrics, + handles: dormerHandles, + floorplan: buildDormerFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/dormer/floorplan.ts b/packages/nodes/src/dormer/floorplan.ts new file mode 100644 index 000000000..8305c46d9 --- /dev/null +++ b/packages/nodes/src/dormer/floorplan.ts @@ -0,0 +1,213 @@ +import type { + AnyNodeId, + DormerNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' + +/** + * Floor-plan builder for a dormer — a small house-shaped structure that + * projects from a roof slope, with its own little roof and a window on the + * front face. Seen from above it reads as a `width × depth` footprint plus + * its roof's ridge/hip linework, and a line marking the window on the + * down-slope (+Z) front face. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → dormer), same as the chimney builder. The + * dormer's `position` is segment-local: X = width axis (along the eave), + * Z = depth axis (projecting down-slope; +Z is the front/window face). + * `rotation` is yaw. Rotations are negated for the floor plan's y-down + * convention (see `buildRoofSegmentFloorplan`). + * + * Per-type roof linework follows the dormer's own roof geometry + * (`buildDormerCutShape` in csg-geometry.ts): gable ridge runs along Z, + * shed slopes high-at-back (−Z) to low-at-front (+Z), hip ridges along the + * longer axis. Gambrel falls back to gable; dutch/mansard to hip — the + * same fallbacks the 3D cut uses. + */ +export function buildDormerFloorplan( + node: DormerNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + // Compose roof → segment → dormer in plan coords. Each rotation negated + // so SVG's y-down CW matches Three.js' top-down CCW. + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Reads as a small structure on the roof — neutral grey, accent on + // select, light blue on hover. + const baseInk = '#52525b' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#e4e4e7' + const fillOpacity = showSelectedChrome ? 0.55 : 0.6 + const lineWidth = showSelectedChrome ? 0.03 : 0.022 + const ridgeWidth = showSelectedChrome ? 0.04 : 0.03 + + const hw = Math.max(node.width, 0.1) / 2 + const hd = Math.max(node.depth, 0.1) / 2 + + const corners: FloorplanPoint[] = [ + toPlan(-hw, -hd), + toPlan(hw, -hd), + toPlan(hw, hd), + toPlan(-hw, hd), + ] + + const children: FloorplanGeometry[] = [ + // Transparent hit-target across the footprint. + { + kind: 'polygon', + points: corners, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + // Body footprint, filled. + { + kind: 'polygon', + points: corners, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }, + ] + + const line = (a: readonly [number, number], b: readonly [number, number], w: number) => { + const pa = toPlan(a[0], a[1]) + const pb = toPlan(b[0], b[1]) + children.push({ + kind: 'line', + x1: pa[0], + y1: pa[1], + x2: pb[0], + y2: pb[1], + stroke, + strokeWidth: w, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + } + + // Roof linework per dormer roof type (skipped for flat / zero-height). + const type = node.roofType + if (node.roofHeight > 0 && type !== 'flat') { + if (type === 'shed') { + // Slopes from the high back (−Z) down to the low front (+Z); show a + // downslope arrow pointing toward the front. + const tail = toPlan(0, -hd * 0.55) + const head = toPlan(0, hd * 0.55) + const dx = head[0] - tail[0] + const dy = head[1] - tail[1] + const len = Math.hypot(dx, dy) || 1 + const ux = dx / len + const uy = dy / len + const headLen = Math.min(0.25, len * 0.4) + const wing = headLen * 0.6 + children.push({ + kind: 'line', + x1: tail[0], + y1: tail[1], + x2: head[0], + y2: head[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + children.push({ + kind: 'polyline', + points: [ + [head[0] - headLen * ux - wing * uy, head[1] - headLen * uy + wing * ux], + [head[0], head[1]], + [head[0] - headLen * ux + wing * uy, head[1] - headLen * uy - wing * ux], + ], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + strokeLinejoin: 'round', + pointerEvents: 'none', + }) + } else if (type === 'hip' || type === 'dutch' || type === 'mansard') { + // Ridge along the longer axis + four hips from the corners (a single + // apex when square). Mirrors the dormer cut's pyramid/hip. + if (Math.abs(hw - hd) < 0.01) { + line([-hw, -hd], [0, 0], lineWidth) + line([hw, -hd], [0, 0], lineWidth) + line([hw, hd], [0, 0], lineWidth) + line([-hw, hd], [0, 0], lineWidth) + } else if (hd >= hw) { + const rl = hd - hw // ridge along Z + line([0, -rl], [0, rl], ridgeWidth) + line([-hw, hd], [0, rl], lineWidth) + line([hw, hd], [0, rl], lineWidth) + line([-hw, -hd], [0, -rl], lineWidth) + line([hw, -hd], [0, -rl], lineWidth) + } else { + const rl = hw - hd // ridge along X + line([-rl, 0], [rl, 0], ridgeWidth) + line([-hw, -hd], [-rl, 0], lineWidth) + line([-hw, hd], [-rl, 0], lineWidth) + line([hw, -hd], [rl, 0], lineWidth) + line([hw, hd], [rl, 0], lineWidth) + } + } else { + // Gable (and gambrel fallback): ridge runs front-to-back along Z. + line([0, -hd], [0, hd], ridgeWidth) + } + } + + // Window on the +Z (front) face — a line just inside the front edge, + // spanning the window width centred at its X offset. Marks the glazing + // and which way the dormer faces. + const ww = node.windowWidth ?? 0 + if (ww > 0.01) { + const halfWin = Math.min(ww, node.width) / 2 + const center = Math.max(-hw + halfWin, Math.min(hw - halfWin, node.windowOffsetX ?? 0)) + const inset = Math.min(hd * 0.2, 0.08) + line([center - halfWin, hd - inset], [center + halfWin, hd - inset], lineWidth) + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/dormer/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index 5c7fe580b..01f8ab0f4 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -152,29 +152,36 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { // dormer-mesh-local with `dormer.position` + `dormer.rotation` // already accounted for by `segToMesh`, so we layer them as group // transforms here too. + // + // The registered ref sits on the inner group that applies the + // dormer's own position + rotation so the registered Object3D's + // local frame is *dormer-local* — that's what `NodeArrowHandles` + // reads to place its chevrons. Mirrors chimney's structure. return ( - - - - - + + + ) diff --git a/packages/nodes/src/dormer/use-dormer-placement.ts b/packages/nodes/src/dormer/use-dormer-placement.ts index e524342dd..97b31012a 100644 --- a/packages/nodes/src/dormer/use-dormer-placement.ts +++ b/packages/nodes/src/dormer/use-dormer-placement.ts @@ -10,7 +10,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry' const tmpMatrix = new THREE.Matrix4() diff --git a/packages/nodes/src/dormer/window-assembly.tsx b/packages/nodes/src/dormer/window-assembly.tsx index f35825ded..5237b4eb7 100644 --- a/packages/nodes/src/dormer/window-assembly.tsx +++ b/packages/nodes/src/dormer/window-assembly.tsx @@ -113,6 +113,19 @@ const DormerWindowAssembly = ({ node.position[0], node.position[1], node.position[2], + // Rotation flips which dormer-local face projects to which Z in + // segment frame, so dragging the dormer across the ridge with a + // non-zero yaw needs to recompute exposure to know which gable + // is now poking above the slope. + node.rotation, + // Window position + height feed `getDormerExposedFaces` now that + // it's gating on window-bottom-above-slope (not wall-top-above- + // slope) — dragging the window down via inspector or the new + // window-height/offset handles must re-evaluate which gable + // still has a fully-visible opening. + node.windowHeight, + node.windowOffsetY, + node.wallSkirtHeight, ], ) @@ -120,8 +133,18 @@ const DormerWindowAssembly = ({ const winX = skirtWin.offsetX const winY = skirtWin.centerY - const renderFace = (zPos: number, outDir: number, keyPrefix: string) => ( - + // The glazing role material is FrontSide (DoubleSide on a NodeMaterial + // poisons the MRT scene pass — see `createSurfaceRoleMaterial`). The + // back gable face therefore renders inside a Y-rotated group so its + // FrontSide points outward (-Z in segment frame). With the rotation, + // the sill always extrudes along the group's local +Z, so its position + // no longer needs to flip per-face. + const renderFace = (zPos: number, yRot: number, keyPrefix: string) => ( + {winGeo.glassPanes.map((pane, i) => ( )} @@ -158,8 +181,8 @@ const DormerWindowAssembly = ({ return ( <> - {exposed.front && renderFace(gableHalfZ, +1, 'front')} - {exposed.back && renderFace(-gableHalfZ, -1, 'back')} + {exposed.front && renderFace(gableHalfZ, 0, 'front')} + {exposed.back && renderFace(-gableHalfZ, Math.PI, 'back')} ) } diff --git a/packages/nodes/src/downspout/definition.ts b/packages/nodes/src/downspout/definition.ts new file mode 100644 index 000000000..e5fee0366 --- /dev/null +++ b/packages/nodes/src/downspout/definition.ts @@ -0,0 +1,209 @@ +import { + type AnyNodeId, + DownspoutNode as DownspoutNodeSchema, + type DownspoutNode as DownspoutNodeType, + type GutterNode, + type GutterOutlet, + type HandleDescriptor, + type NodeDefinition, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { downspoutParametrics } from './parametrics' +import { + computeDownspoutPath, + downspoutPipeDims, + effectiveWallJog, + resolveDownspoutRouting, +} from './routing' +import { DownspoutNode } from './schema' + +// Mirrors the parametric `min`s so handle drags can't shrink the pipe +// past what the inspector would accept. +const MIN_LENGTH = 0.1 +// The length cube + dashed leader ride the straight WALL RUN, offset +// outward (+Z, over the eave) past the pipe surface so they float clear +// of the pipe instead of touching it. +const LENGTH_HANDLE_PAD = 0.12 +// Lift the length cube a little up the run from the very bottom so it +// reads as a height grip rather than sitting at the pipe's end. Clamped +// to the run top so it never climbs above the straight section. +const CUBE_LIFT = 0.18 +// Side-move arrows: how far ±X (along the eave) they sit from the pipe, +// and how far below the gutter floor — near the top so they read as +// "grab and slide along the eave". +const SIDE_MOVE_OFFSET = 0.22 +const SIDE_MOVE_Y = -0.12 + +/** + * Length tracker — a dashed vertical leader from the outlet (Y = 0, + * the gutter floor) down to a small cube near the bottom of the + * straight wall run, `anchor: 'max'` + `axis: 'y'` so dragging the + * cube down extends the pipe 1:1. + * + * Both the cube and the leader sit on the wall-run line but offset + * outward (away from the pipe) by `radius + LENGTH_HANDLE_PAD`, so the + * whole dimension floats clear of the pipe — it reads as "change the + * height" rather than a box jammed onto the kicked-out mouth. The cube + * rides the run BOTTOM (above the kickout), not the mouth, so the + * dimension stays on the straight part. + */ +function downspoutLengthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'max', + shape: 'tracker', + min: MIN_LENGTH, + currentValue: (n) => n.length, + apply: (_n, newValue) => ({ length: Math.max(MIN_LENGTH, newValue) }), + placement: { + position: (n, scene) => { + const routing = resolveDownspoutRouting(n, scene) + const path = computeDownspoutPath( + n.length, + effectiveWallJog(n, routing), + (n.terminal ?? 'splash') !== 'straight', + ) + const halfZ = downspoutPipeDims(n, routing).halfZ + const y = Math.min(path.wallRunTopY, path.wallRunBottomY + CUBE_LIFT) + return [0, y, path.wallRunZ + halfZ + LENGTH_HANDLE_PAD] + }, + }, + // Leader starts at Y = 0 (outlet / gutter floor) and runs DOWN past + // the cube — same tracker the wall / chimney height fields use. + trackerBaseY: () => 0, + } +} + +// Usable half-span — keep the outlet a hair inside each end so the +// collar never lands on a cap (the geometry clamps too; this bounds the +// drag). Reads the host gutter's length. +function moveBound(n: DownspoutNodeType, gutter: GutterNode | undefined): number { + return Math.max(0.05, Math.max(0.05, gutter?.length ?? 2) / 2 - 0.1) +} + +// Effective outlet offset for `currentValue` — reads the gutter's live +// override first (so the dragged value tracks) then the store. +function readOutletOffset(n: DownspoutNodeType): number { + if (!n.gutterId) return 0 + const id = n.gutterId as AnyNodeId + const override = useLiveNodeOverrides.getState().get(id) as Partial | undefined + const gutter = useScene.getState().nodes[id] as GutterNode | undefined + const outlets = (override?.outlets as GutterOutlet[] | undefined) ?? gutter?.outlets ?? [] + return outlets.find((o) => o.id === n.outletId)?.offset ?? 0 +} + +/** + * Side-move arrow — one of a ±X pair that slides the downspout along the + * eave. The position lives on the host gutter's outlet + * (`gutter.outlets[].offset`), not on the downspout, so `overrideTarget` + * redirects the drag's live override + commit to the gutter and `apply` + * returns the gutter's patch. The arrows sit near the top of the pipe + * and ride its group, which moves with the outlet — so they track the + * cursor 1:1 (`anchor: 'min'` → factor +1). + */ +function downspoutMoveHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: 'min', + cursor: 'ew-resize', + overrideTarget: (n) => (n.gutterId ? (n.gutterId as AnyNodeId) : undefined), + currentValue: (n) => readOutletOffset(n), + apply: (n, newOffset, scene) => { + const gutter = n.gutterId ? scene.get(n.gutterId as AnyNodeId) : undefined + if (!gutter) return {} + const outlets = (gutter.outlets ?? []).map((o) => + o.id === n.outletId ? { ...o, offset: newOffset } : o, + ) + // Patch targets the GUTTER (overrideTarget), not the downspout. + return { outlets } as unknown as Partial + }, + min: (n, scene) => + -moveBound(n, n.gutterId ? scene.get(n.gutterId as AnyNodeId) : undefined), + max: (n, scene) => + moveBound(n, n.gutterId ? scene.get(n.gutterId as AnyNodeId) : undefined), + placement: { + // Static ±X beside the top of the pipe; the group it rides moves + // with the outlet, so the arrow stays under the cursor as it slides. + position: () => [sign * SIDE_MOVE_OFFSET, SIDE_MOVE_Y, 0], + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +const downspoutHandles: HandleDescriptor[] = [ + downspoutLengthHandle(), + downspoutMoveHandle('left'), + downspoutMoveHandle('right'), +] + +/** + * Downspout — vertical drop pipe taking water from a gutter outlet to + * the ground. Scene-graph parent is the same roof-segment the host + * gutter sits on (so it renders under `roof-elements` like every + * other accessory); the logical link to the gutter is via the + * `gutterId` field, which the renderer uses to look up the outlet + * position. + * + * No `handles` yet — the downspout's geometry is anchored to the + * gutter's outlet. Length (tracker cube at the routed mouth) and + * diameter (chevron on the wall run) are draggable arrows; the wall + * standoff lives in the inspector. + */ +export const downspoutDefinition: NodeDefinition = { + kind: 'downspout', + schemaVersion: 1, + schema: DownspoutNode, + category: 'structure', + surfaceRole: 'roof', + + defaults: () => { + const stub = DownspoutNodeSchema.parse({ + id: 'downspout_default' as never, + type: 'downspout', + }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + // Logically a roof accessory — registers under the segment, has + // no buildCut, just the standard dirty cascade. + roofAccessory: {}, + }, + + parametrics: downspoutParametrics, + handles: downspoutHandles, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + + preview: () => import('./preview'), + tool: () => import('./tool'), + toolHints: [ + { key: 'Hover gutter', label: 'Highlight outlet' }, + { key: 'Left click', label: 'Drop downspout from outlet' }, + { key: 'Esc', label: 'Cancel' }, + ], + + presentation: { + label: 'Downspout', + description: 'Vertical drop pipe from a gutter outlet to the ground.', + icon: { kind: 'url', src: '/icons/roof.png' }, + paletteSection: 'structure', + paletteOrder: 123, + }, + + mcp: { + description: + 'A downspout — drop pipe from a gutter outlet that elbows back to the wall, runs down the wall face, and kicks out at the bottom. length / diameter / standoff parametric.', + }, +} diff --git a/packages/nodes/src/downspout/geometry.ts b/packages/nodes/src/downspout/geometry.ts new file mode 100644 index 000000000..aa5f56329 --- /dev/null +++ b/packages/nodes/src/downspout/geometry.ts @@ -0,0 +1,241 @@ +import type { DownspoutNode } from '@pascal-app/core' +import * as THREE from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import type { OutletDims } from '../gutter/profile-geometry' +import { + computeDownspoutPath, + type DownspoutPath, + type DownspoutRouting, + downspoutPipeDims, + effectiveWallJog, +} from './routing' + +/** + * Downspout pipe builder. The pipe follows a real downspout's path — + * a short DROP out of the collar, an OFFSET ELBOW back to the wall, the + * VERTICAL RUN down the wall, and a bottom KICKOUT — plus the hardware + * that makes it read real: WALL STRAPS clamping the run to the wall, + * an open (hollow) mouth at the kickout, and a SPLASH BLOCK on the + * ground under the mouth. + * + * Mesh frame is centred on the outlet: local Y = 0 is the gutter floor, + * −Y is down, −Z is toward the wall (+Z is outward over the eave). The + * path lives in the local Y/Z plane; X is the gutter-length axis. + * + * Cross-section follows the host gutter's profile: round on half-round, + * rectangular on k-style / box. Straight legs are solid cylinders / + * boxes welded at the corners with a small joint; the kickout leg is a + * hollow tube so the open mouth reads through. + * + * Pure: no React, no scene access. + */ +const RADIAL_SEGMENTS = 16 +const JOINT_SEGMENTS = 12 +const FWD = new THREE.Vector3(0, 0, 1) +const UP = new THREE.Vector3(0, 1, 0) + +// Pipe wall thickness for the hollow (open-mouth) kickout leg. +const PIPE_WALL = 0.004 +// Wall straps — a thin band clamps the run to the wall, set in a margin +// from each end (spacing comes from the node). +const STRAP_END_MARGIN = 0.3 +const STRAP_THICKNESS = 0.022 +const STRAP_OVERHANG = 0.014 +// Splash block — a tilted slab on the ground under the mouth that +// carries water away from the foundation. +const SPLASH_WIDTH = 0.22 +const SPLASH_LENGTH = 0.34 +const SPLASH_THICKNESS = 0.05 +const SPLASH_TILT = 0.1 + +export function buildDownspoutGeometry( + node: DownspoutNode, + routing?: DownspoutRouting | null, +): THREE.BufferGeometry { + const dims = downspoutPipeDims(node, routing) + const terminal = node.terminal ?? 'splash' + // 'straight' runs the pipe to grade with no kickout leg. + const pathData = computeDownspoutPath( + node.length, + effectiveWallJog(node, routing), + terminal !== 'straight', + ) + + // Drop consecutive duplicates (jog == 0 collapses the elbow; no kick + // collapses the bottom two) so we never build a zero-length segment. + const path: THREE.Vector3[] = [] + for (const [x, y, z] of pathData.points) { + const p = new THREE.Vector3(x, y, z) + const last = path.at(-1) + if (!last || last.distanceTo(p) > 1e-4) path.push(p) + } + + const pieces: THREE.BufferGeometry[] = [] + const lastLeg = path.length - 2 + for (let i = 0; i < path.length - 1; i++) { + // The final leg (the kickout mouth) is a hollow tube so you can see + // up the open end; the rest stay solid (their outer surface reads + // identically, and they're capped by the collar / joints anyway). + pieces.push( + i === lastLeg + ? ringTube(path[i]!, path[i + 1]!, dims) + : segmentBetween(path[i]!, path[i + 1]!, dims), + ) + if (i > 0) pieces.push(jointAt(path[i]!, path[i - 1]!, path[i + 1]!, dims)) + } + + if ((node.strapStyle ?? 'band') !== 'none') { + for (const strap of buildStraps(pathData, dims, node.strapSpacing ?? 1.8)) pieces.push(strap) + } + if (terminal === 'splash') { + const splash = buildSplash(pathData) + if (splash) pieces.push(splash) + } + + const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!) + if (merged !== pieces[0]) { + for (const p of pieces) p.dispose() + } + merged.computeVertexNormals() + return merged +} + +/** + * Solid segment spanning two points. Round → a cylinder; rect → a box + * (2·halfX wide along the gutter length, 2·halfZ deep outward). The + * orient-onto-direction rotation is purely about X for our planar path, + * so the box's width stays aligned with the gutter length axis. + */ +function segmentBetween( + a: THREE.Vector3, + b: THREE.Vector3, + dims: OutletDims, +): THREE.BufferGeometry { + const dir = new THREE.Vector3().subVectors(b, a) + const len = dir.length() + const geo = + dims.shape === 'round' + ? new THREE.CylinderGeometry(dims.halfX, dims.halfX, len, RADIAL_SEGMENTS).toNonIndexed() + : new THREE.BoxGeometry(2 * dims.halfX, len, 2 * dims.halfZ).toNonIndexed() + // The primitive runs along +Y centred at origin; rotate +Y onto the + // segment direction, then drop it on the midpoint. + geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, dir.normalize())) + geo.translate((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2) + return geo +} + +/** + * Hollow tube spanning two points — a ring (round) / rectangular-ring + * cross-section extruded along the leg, so both ends are open and the + * bore reads through. Used for the kickout mouth. + */ +function ringTube(a: THREE.Vector3, b: THREE.Vector3, dims: OutletDims): THREE.BufferGeometry { + const dir = new THREE.Vector3().subVectors(b, a) + const len = dir.length() + const shape = new THREE.Shape() + const hole = new THREE.Path() + if (dims.shape === 'round') { + shape.absarc(0, 0, dims.halfX, 0, Math.PI * 2, false) + hole.absarc(0, 0, Math.max(0.002, dims.halfX - PIPE_WALL), 0, Math.PI * 2, true) + } else { + const ox = dims.halfX + const oz = dims.halfZ + const ix = Math.max(0.002, ox - PIPE_WALL) + const iz = Math.max(0.002, oz - PIPE_WALL) + shape.moveTo(-ox, -oz) + shape.lineTo(ox, -oz) + shape.lineTo(ox, oz) + shape.lineTo(-ox, oz) + shape.closePath() + hole.moveTo(-ix, -iz) + hole.lineTo(-ix, iz) + hole.lineTo(ix, iz) + hole.lineTo(ix, -iz) + hole.closePath() + } + shape.holes.push(hole) + // ExtrudeGeometry runs the shape (in XY) along +Z from 0 to depth; + // orient +Z onto the leg direction, then move the z=0 end to `a`. + // ExtrudeGeometry is already non-indexed, matching the merge set. + const geo = new THREE.ExtrudeGeometry(shape, { + depth: len, + bevelEnabled: false, + steps: 1, + curveSegments: RADIAL_SEGMENTS, + }) + geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(FWD, dir.normalize())) + geo.translate(a.x, a.y, a.z) + return geo +} + +/** + * Corner joint at `p` between the segments (prev→p) and (p→next). Round + * → a sphere; rect → a box aligned to the bend bisector so it bridges + * the wedge the two box ends leave open at the outer corner. + */ +function jointAt( + p: THREE.Vector3, + prev: THREE.Vector3, + next: THREE.Vector3, + dims: OutletDims, +): THREE.BufferGeometry { + if (dims.shape === 'round') { + const geo = new THREE.SphereGeometry(dims.halfX, JOINT_SEGMENTS, JOINT_SEGMENTS).toNonIndexed() + geo.translate(p.x, p.y, p.z) + return geo + } + const dirIn = new THREE.Vector3().subVectors(p, prev).normalize() + const dirOut = new THREE.Vector3().subVectors(next, p).normalize() + const bis = new THREE.Vector3().addVectors(dirIn, dirOut) + if (bis.lengthSq() < 1e-8) bis.copy(dirOut) // straight-through; degenerate + bis.normalize() + const geo = new THREE.BoxGeometry(2 * dims.halfX, 2 * dims.halfZ, 2 * dims.halfZ).toNonIndexed() + geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, bis)) + geo.translate(p.x, p.y, p.z) + return geo +} + +/** + * Thin bands clamping the wall run to the wall, ~`STRAP_SPACING` apart + * and set in from each end. Each is a flat box a touch proud of the + * pipe so it reads as a strap wrapping the run. + */ +function buildStraps( + path: DownspoutPath, + dims: OutletDims, + spacing: number, +): THREE.BufferGeometry[] { + const top = path.wallRunTopY + const bottom = path.wallRunBottomY + const z = path.wallRunZ + const runLen = top - bottom + if (runLen < STRAP_END_MARGIN * 2 + 0.05) return [] + + const usable = runLen - STRAP_END_MARGIN * 2 + const count = Math.max(1, Math.floor(usable / Math.max(0.2, spacing)) + 1) + const stride = count > 1 ? usable / (count - 1) : 0 + const w = 2 * dims.halfX + 2 * STRAP_OVERHANG + const d = 2 * dims.halfZ + 2 * STRAP_OVERHANG + + const straps: THREE.BufferGeometry[] = [] + for (let i = 0; i < count; i++) { + const y = count > 1 ? top - STRAP_END_MARGIN - i * stride : (top + bottom) / 2 + const band = new THREE.BoxGeometry(w, STRAP_THICKNESS, d).toNonIndexed() + band.translate(0, y, z) + straps.push(band) + } + return straps +} + +/** + * Tilted slab on the ground under the mouth, extending outward (+Z, + * away from the wall) so it carries water off from the foundation. + */ +function buildSplash(path: DownspoutPath): THREE.BufferGeometry | null { + const [bx, by, bz] = path.bottom + const slab = new THREE.BoxGeometry(SPLASH_WIDTH, SPLASH_THICKNESS, SPLASH_LENGTH).toNonIndexed() + // Tilt the far (+Z) end down so it slopes away from the wall. + slab.rotateX(SPLASH_TILT) + slab.translate(bx, by - SPLASH_THICKNESS / 2, bz + SPLASH_LENGTH / 2) + return slab +} diff --git a/packages/nodes/src/downspout/index.ts b/packages/nodes/src/downspout/index.ts new file mode 100644 index 000000000..ffe441894 --- /dev/null +++ b/packages/nodes/src/downspout/index.ts @@ -0,0 +1,3 @@ +export { downspoutDefinition } from './definition' +export { buildDownspoutGeometry } from './geometry' +export { DownspoutNode } from './schema' diff --git a/packages/nodes/src/downspout/inspector-editors.tsx b/packages/nodes/src/downspout/inspector-editors.tsx new file mode 100644 index 000000000..69fdf06bc --- /dev/null +++ b/packages/nodes/src/downspout/inspector-editors.tsx @@ -0,0 +1,90 @@ +'use client' + +import { + type AnyNodeId, + type DownspoutNode, + type GutterNode, + type GutterOutlet, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { SliderControl } from '@pascal-app/editor' + +/** + * Position-along-the-eave editor for a downspout. The downspout's spot + * is owned by its outlet on the host gutter (`gutter.outlets[].offset`), + * not by the downspout itself — so this slider reads + writes that + * outlet on the gutter rather than patching the downspout. + * + * Mesh-first commit (same shape as the in-world handle drags): + * - `onChange` (live, every drag tick) publishes the new outlets to the + * gutter's `useLiveNodeOverrides`. The gutter renderer rebuilds its + * mesh from that override and the downspout renderer re-reads its + * outlet — both move immediately, with NO write to the scene store / + * history. The slider also reads the override back so its number + * tracks during the drag. + * - `onCommit` (on release) writes the final outlets to the store once + * (the single undoable change — `SliderControl` resumes history + * first) and drops the override so the renderers read the store again. + * + * Wired via `parametrics.fields[].kind: 'custom'`; hidden when the + * downspout isn't linked to an outlet. + */ +export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) { + const gutter = useScene((s) => + node.gutterId ? (s.nodes[node.gutterId as AnyNodeId] as GutterNode | undefined) : undefined, + ) + // Live override on the gutter, so the readout tracks the in-flight drag. + const override = useLiveNodeOverrides((s) => + node.gutterId + ? (s.get(node.gutterId as AnyNodeId) as Partial | undefined) + : undefined, + ) + + if (!gutter || gutter.type !== 'gutter') return null + const storeOutlets = gutter.outlets ?? [] + const effectiveOutlets = (override?.outlets as GutterOutlet[] | undefined) ?? storeOutlets + const outlet = effectiveOutlets.find((o) => o.id === node.outletId) + if (!outlet) return null + + // Usable half-span — keep the outlet a hair inside each end so the + // collar never lands on a cap. The geometry clamps too; this just + // keeps the slider honest. + const bound = Math.max(0.05, Math.max(0.05, gutter.length) / 2 - 0.1) + const gutterId = gutter.id as AnyNodeId + + // Set the dragged outlet's offset on a copy of the STORE outlets (the + // canonical base — the slider hands an absolute value each tick). + const withOffset = (offset: number): GutterOutlet[] => + storeOutlets.map((o) => (o.id === node.outletId ? { ...o, offset } : o)) + + const handleChange = (offset: number) => { + // Mesh-first: publish to the gutter override, no store write. + useLiveNodeOverrides.getState().set(gutterId, { outlets: withOffset(offset) }) + } + + const handleCommit = (offset: number) => { + // Commit once to the store, then drop the override. + const state = useScene.getState() + state.updateNode(gutterId, { outlets: withOffset(offset) }) + useLiveNodeOverrides.getState().clear(gutterId) + state.markDirty(gutterId) + } + + return ( + + ) +} diff --git a/packages/nodes/src/downspout/parametrics.ts b/packages/nodes/src/downspout/parametrics.ts new file mode 100644 index 000000000..d87752950 --- /dev/null +++ b/packages/nodes/src/downspout/parametrics.ts @@ -0,0 +1,69 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import { DownspoutPositionEditor } from './inspector-editors' +import type { DownspoutNode } from './schema' + +export const downspoutParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'length', kind: 'number', unit: 'm', min: 0.1, max: 8, step: 0.05 }, + { key: 'diameter', kind: 'number', unit: 'm', min: 0.02, max: 0.15, step: 0.005 }, + // Cross-section: follow the gutter profile, or force round / rect. + { + key: 'shape', + kind: 'enum', + options: ['auto', 'round', 'rect'], + display: 'segmented', + }, + ], + }, + { + label: 'Hardware', + fields: [ + // Wall straps clamping the run, like the gutter's hangers. + { + key: 'strapStyle', + kind: 'enum', + options: ['band', 'none'], + display: 'segmented', + }, + { + key: 'strapSpacing', + kind: 'number', + unit: 'm', + min: 0.3, + max: 3, + step: 0.1, + visibleIf: (n) => (n.strapStyle ?? 'band') !== 'none', + }, + // Bottom treatment: splash block, kickout only, or straight to grade. + { + key: 'terminal', + kind: 'enum', + options: ['splash', 'kickout', 'straight'], + display: 'segmented', + }, + ], + }, + { + label: 'Placement', + fields: [ + // Slide the outlet (and so this downspout) along the eave. Edits + // the linked outlet's offset on the host gutter — the only way to + // reposition a drop after placing it. Hidden when unlinked. + { + key: 'outletPosition', + kind: 'custom', + component: DownspoutPositionEditor, + visibleIf: (n) => Boolean(n.outletId), + }, + // How far proud of the wall the pipe sits. Crank it up if the + // auto-routed run buries into the wall (the wall isn't where the + // roof overhang implies); 0 puts the pipe surface on the wall + // face; large values pull the run back out toward the eave. + { key: 'standoff', kind: 'number', unit: 'm', min: 0, max: 0.6, step: 0.01 }, + ], + }, + ], +} diff --git a/packages/nodes/src/downspout/preview.tsx b/packages/nodes/src/downspout/preview.tsx new file mode 100644 index 000000000..596ebecbd --- /dev/null +++ b/packages/nodes/src/downspout/preview.tsx @@ -0,0 +1,77 @@ +'use client' + +import { useEffect, useMemo } from 'react' +import * as THREE from 'three' +import { buildDownspoutGeometry } from './geometry' +import type { DownspoutRouting } from './routing' +import type { DownspoutNode } from './schema' + +/** + * Translucent ghost of a downspout — same geometry as the committed + * pipe so the placement ghost matches what lands on click. No + * internal transform wrapper; the placement tool nests this under + * the gutter / outlet chain so the position math stays in one place. + * + * `routing` mirrors the renderer's — when the tool resolves the host + * gutter it feeds the same wall-jog so the ghost already shows the + * elbowed path, not a straight drop. + */ +const DownspoutPreview = ({ + node, + routing, +}: { + node: DownspoutNode + routing?: DownspoutRouting | null +}) => { + const geometry = useMemo( + () => buildDownspoutGeometry(node, routing), + [ + node.length, + node.diameter, + node.standoff, + node.shape, + node.strapStyle, + node.strapSpacing, + node.terminal, + routing, + ], + ) + + const material = useMemo( + () => + new THREE.MeshStandardMaterial({ + color: 0xff_ff_ff, + emissive: 0xff_ff_ff, + emissiveIntensity: 0.12, + roughness: 0.7, + metalness: 0.2, + transparent: true, + opacity: 0.55, + depthWrite: false, + side: THREE.FrontSide, + }), + [], + ) + + const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) + + useEffect( + () => () => { + geometry.dispose() + edgesGeometry.dispose() + material.dispose() + }, + [geometry, edgesGeometry, material], + ) + + return ( + <> + {}} /> + + + + + ) +} + +export default DownspoutPreview diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx new file mode 100644 index 000000000..cc0cb6e63 --- /dev/null +++ b/packages/nodes/src/downspout/renderer.tsx @@ -0,0 +1,189 @@ +'use client' + +import { + type AnyNodeId, + type DownspoutNode, + type GutterNode, + type RoofSegmentNode, + useLiveNodeOverrides, + useRegistry, + useScene, +} from '@pascal-app/core' +import { + type ColorPreset, + createMaterial, + createMaterialFromPresetRef, + createSurfaceRoleMaterial, + useNodeEvents, + useViewer, +} from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' +import { computeEaveY } from '../gutter/eave-snap' +import { resolveGutterOutletById } from '../gutter/outlet-lookup' +import { buildDownspoutGeometry } from './geometry' +import { computeDownspoutRouting } from './routing' + +const defaultMaterial = new THREE.MeshStandardMaterial({ + color: 0xff_ff_ff, + roughness: 0.7, + metalness: 0.25, +}) + +/** + * Downspout renderer. Mount chain mirrors the gutter's, then nests + * one level deeper into the outlet position in gutter-mesh-local: + * + * segment.position → segment.rotation (Y) + * → [gutter.position[0], computeEaveY(segment), gutter.position[2]] + * → gutter.rotation (Y) + * → [outlet.x, outlet.y, outlet.z] + * → mesh (pipe descends from Y = 0) + * + * Pulling the gutter's eave Y from `computeEaveY(effectiveSegment)` + * means the downspout follows wallHeight / overhang / pitch changes + * live, on the same frame as the gutter. The gutter and segment also + * subscribe to `useLiveNodeOverrides` so drag-in-flight changes flow + * through too. + */ +const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { + const ref = useRef(null!) + useRegistry(storeNode.id, 'downspout', ref) + const handlers = useNodeEvents(storeNode, 'downspout') + const shading = useViewer((s) => s.shading) + const textures = useViewer((s) => s.textures) + const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) + const sceneTheme = useViewer((s) => s.sceneTheme) + + const overrides = useLiveNodeOverrides( + (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, + ) + const node: DownspoutNode = overrides + ? ({ ...storeNode, ...overrides } as DownspoutNode) + : storeNode + + // Host gutter — both scene + live overrides so drag-in-flight gutter + // moves (length / position) reposition the downspout immediately. + const gutter = useScene((s) => + node.gutterId ? (s.nodes[node.gutterId as AnyNodeId] as GutterNode | undefined) : undefined, + ) + const gutterOverrides = useLiveNodeOverrides((s) => + node.gutterId + ? (s.get(node.gutterId as AnyNodeId) as Partial | undefined) + : undefined, + ) + const effectiveGutter: GutterNode | undefined = gutter + ? gutterOverrides + ? ({ ...gutter, ...gutterOverrides } as GutterNode) + : gutter + : undefined + + // Segment of the host gutter (the downspout's own scene-graph parent + // is the same segment — same as roof accessories — so the chain + // segment → gutter-mesh-local is what we need to reach the outlet). + const segment = useScene((s) => + effectiveGutter?.roofSegmentId + ? (s.nodes[effectiveGutter.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined, + ) + const segmentOverrides = useLiveNodeOverrides((s) => + effectiveGutter?.roofSegmentId + ? (s.get(effectiveGutter.roofSegmentId as AnyNodeId) as Partial | undefined) + : undefined, + ) + const effectiveSegment: RoofSegmentNode | undefined = segment + ? segmentOverrides + ? ({ ...segment, ...segmentOverrides } as RoofSegmentNode) + : segment + : undefined + + // Routing back to the wall — memoised on the gutter/segment values + // that actually move the jog or the collar bore, so the pipe geometry + // only rebuilds when one of those changes (not on every override-merge + // render). Resolves to null when the gutter has no outlet. + const routing = useMemo( + () => + effectiveGutter && effectiveSegment + ? computeDownspoutRouting(effectiveGutter, effectiveSegment, node.outletId) + : null, + [ + effectiveGutter?.profile, + effectiveGutter?.size, + // The outlets array — its referenced entry's diameter / offset + // drives the collar bore + nesting. + effectiveGutter ? JSON.stringify(effectiveGutter.outlets) : undefined, + effectiveSegment?.overhang, + node.outletId, + ], + ) + + const geometry = useMemo( + () => buildDownspoutGeometry(node, routing), + [ + node.length, + node.diameter, + node.standoff, + node.shape, + node.strapStyle, + node.strapSpacing, + node.terminal, + routing, + ], + ) + useEffect(() => () => geometry.dispose(), [geometry]) + + const material = useMemo(() => { + if (!textures || (!node.material && !node.materialPreset)) { + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + } + return node.material + ? createMaterial(node.material, shading) + : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) + }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + + if (!effectiveGutter || !effectiveSegment) return null + const outlet = resolveGutterOutletById(effectiveGutter, node.outletId) + if (!outlet) return null + + const segPos = effectiveSegment.position ?? [0, 0, 0] + const segRotY = effectiveSegment.rotation ?? 0 + const liveEaveY = computeEaveY(effectiveSegment) + const gutterRotY = effectiveGutter.rotation ?? 0 + + // Bake the gutter's position + Y-rotation into the registered ref so it + // sits as a DIRECT child of the segment-transform group — its local + // pose is the outlet's full segment-local placement. `NodeArrowHandles` + // copies the registered object's LOCAL transform into the segment's + // object (it assumes a flat node → scene-parent chain); with the old + // nested segment → gutter → outlet groups it only saw the innermost + // `[outlet.x …]` offset and the handles landed at the roof centre. + const gutterX = effectiveGutter.position[0] ?? 0 + const gutterZ = effectiveGutter.position[2] ?? 0 + const cos = Math.cos(gutterRotY) + const sin = Math.sin(gutterRotY) + const outletSegX = gutterX + (outlet.x * cos + outlet.z * sin) + const outletSegZ = gutterZ + (-outlet.x * sin + outlet.z * cos) + const outletSegY = liveEaveY + outlet.y + + return ( + + + + + + ) +} + +export default DownspoutRenderer diff --git a/packages/nodes/src/downspout/routing.ts b/packages/nodes/src/downspout/routing.ts new file mode 100644 index 000000000..c37cb4bf5 --- /dev/null +++ b/packages/nodes/src/downspout/routing.ts @@ -0,0 +1,207 @@ +import type { + AnyNodeId, + DownspoutNode, + GutterNode, + RoofSegmentNode, + SceneApi, +} from '@pascal-app/core' +import { EAVE_TUCK_INWARD } from '../gutter/eave-snap' +import { resolveGutterOutletById } from '../gutter/outlet-lookup' +import { + type OutletDims, + type OutletShape, + outletDims, + profileFloorMidZ, +} from '../gutter/profile-geometry' + +/** + * Routing parameters that turn the downspout from a straight drop into + * a pipe that actually returns to the wall. Derived from the host + * gutter + its roof segment — the geometry builder stays pure (takes + * this as data) so the renderer, the placement preview, and the handle + * descriptors can all feed it the same numbers. + */ +export type DownspoutRouting = { + /** + * Distance the pipe must travel toward the wall (downspout-local −Z) + * to leave the eave overhang and sit flat against the fascia. The + * outlet hangs `overhang − tuck` outboard of the wall face, plus the + * `floorMidZ` offset of the outlet within the trough — so the offset + * elbow at the top steps the pipe back by exactly this much. + */ + wallJog: number + /** Cross-section the pipe takes — round on half-round, rect on k-style / box. */ + shape: OutletShape + /** + * Inner half-extents of the gutter's drilled collar (along-length X, + * outward Z). The pipe's cross-section is clamped just under these so + * it slip-fits up inside the collar instead of sharing a coincident + * surface with it (the old pipe sat at exactly the bore and z-fought + * the collar wall). + */ + collarHalfX: number + collarHalfZ: number +} + +/** + * Pure routing from resolved nodes — used by the renderer / preview / + * tool, which already hold the effective gutter + segment. + */ +export function computeDownspoutRouting( + gutter: GutterNode, + segment: Pick, + outletId: string | undefined, +): DownspoutRouting | null { + const outlet = resolveGutterOutletById(gutter, outletId) + if (!outlet) return null + + const overhang = segment.overhang ?? 0 + const floorMidZ = profileFloorMidZ(gutter.profile ?? 'k-style', Math.max(0.04, gutter.size)) + // The gutter rim is tucked `EAVE_TUCK_INWARD` back from the very tip + // of the overhang, so the real outboard distance is `overhang − tuck` + // (never negative — a flush eave still leaves the floorMidZ offset). + const wallJog = Math.max(0, overhang - EAVE_TUCK_INWARD) + floorMidZ + + return { + wallJog, + shape: outlet.shape, + collarHalfX: outlet.innerHalfX, + collarHalfZ: outlet.innerHalfZ, + } +} + +/** + * Routing for the handle descriptors, which only get `(node, sceneApi)`. + * Walks downspout → gutter → segment through the scene snapshot. + */ +export function resolveDownspoutRouting( + node: DownspoutNode, + sceneApi: SceneApi, +): DownspoutRouting | null { + if (!node.gutterId) return null + const gutter = sceneApi.get(node.gutterId as AnyNodeId) + if (!gutter || gutter.type !== 'gutter') return null + const segment = gutter.roofSegmentId + ? sceneApi.get(gutter.roofSegmentId as AnyNodeId) + : undefined + return computeDownspoutRouting(gutter, segment ?? { overhang: 0 }, node.outletId) +} + +// ─── Pipe cross-section + effective jog ────────────────────────────── + +// Slip-fit clearance — when the pipe lands within this of the collar +// bore (the placement default, where pipe == hole), nudge it just inside +// so it doesn't share a coincident wall (the old z-fighting). +const NEAR_BORE = 0.002 +const SLIP_CLEARANCE = 0.0005 + +function nestUnder(half: number, collar: number | undefined): number { + if (collar !== undefined && Math.abs(half - collar) < NEAR_BORE) { + return Math.max(0.005, collar - SLIP_CLEARANCE) + } + return half +} + +/** + * Rendered pipe cross-section — round (halfX = halfZ = radius) or rect, + * following the host gutter's profile. Defaults to `diameter`-sized, but + * each half-extent that lands within a hair of the collar's matching + * bore is nudged just inside so the pipe slip-fits the collar instead of + * sharing a coincident wall. A deliberately larger / smaller pipe is + * left alone, so the diameter field stays honest. + */ +export function downspoutPipeDims( + node: Pick, + routing?: DownspoutRouting | null, +): OutletDims { + // 'auto' follows the gutter profile; 'round' / 'rect' override it. + const shape: OutletShape = + node.shape && node.shape !== 'auto' ? node.shape : (routing?.shape ?? 'round') + const dims = outletDims(shape, node.diameter) + if (!routing) return dims + return { + shape, + halfX: nestUnder(dims.halfX, routing.collarHalfX), + halfZ: nestUnder(dims.halfZ, routing.collarHalfZ), + } +} + +/** + * The −Z distance the wall run actually sits at. The raw `wallJog` + * reaches the wall *face*; we pull back by the pipe's outward half-depth + * (so the pipe's surface — not its centerline — meets the wall) plus the + * `standoff` (bracket gap / overshoot escape hatch), so the pipe sits + * proud of the wall instead of burying into it. + */ +export function effectiveWallJog( + node: Pick, + routing?: DownspoutRouting | null, +): number { + if (!routing) return 0 + const dims = downspoutPipeDims(node, routing) + return Math.max(0, routing.wallJog - dims.halfZ - (node.standoff ?? 0)) +} + +// ─── Centerline path ───────────────────────────────────────────────── + +// Vertical drop straight out of the collar before the offset elbow. +const TOP_DROP = 0.05 +// Bottom kickout: how far the mouth throws outward (+Z) and how tall +// the kicked section is. Skipped when the pipe is too short to fit it. +const KICK_OUT = 0.08 +const KICK_RISE = 0.1 + +export type DownspoutPath = { + /** Centerline points, top → bottom, in downspout-local space. */ + points: [number, number, number][] + /** Bottom mouth — the kicked-out pipe end. */ + bottom: [number, number, number] + /** Z of the vertical wall run (downspout-local; −jog). */ + wallRunZ: number + /** Y at the top of the straight wall run (just below the offset elbow). */ + wallRunTopY: number + /** Y at the bottom of the straight wall run (just above the kickout). */ + wallRunBottomY: number +} + +/** + * Pure centerline of the routed pipe. Shared by the geometry builder + * (sweeps a cylinder along it) and the handle descriptors (place the + * length cube at `bottom`), so the dimension chrome can never drift + * from the mesh. + * + * Local frame: Y = 0 at the gutter floor, −Y down, −Z toward the wall. + * The four legs are the vertical drop out of the collar, the offset + * elbow stepping back to the wall, the wall run, and the kickout. + */ +export function computeDownspoutPath(length: number, jog: number, allowKick = true): DownspoutPath { + const len = Math.max(0.1, length) + const j = Math.max(0, jog) + const drop = Math.min(TOP_DROP, len * 0.15) + // Offset elbow runs at ~45° (vertical travel == horizontal jog) but + // never eats more than what's left after the drop + a minimum run. + const elbowVert = Math.min(j, Math.max(0, len - drop - 0.1)) + const afterElbow = len - drop - elbowVert + // `allowKick` off (terminal 'straight') runs the pipe straight to the + // bottom — no kickout leg. + const kick = allowKick && afterElbow > KICK_RISE * 1.5 + const kickRise = kick ? Math.min(KICK_RISE, afterElbow * 0.3) : 0 + const kickOut = kick ? KICK_OUT : 0 + + const wallRunTopY = -drop - elbowVert + const wallRunBottomY = -len + kickRise + const bottom: [number, number, number] = [0, -len, -j + kickOut] + return { + points: [ + [0, 0, 0], // collar mouth + [0, -drop, 0], // bottom of the first drop + [0, wallRunTopY, -j], // offset elbow, now at the wall + [0, wallRunBottomY, -j], // bottom of the wall run + bottom, // kicked mouth + ], + bottom, + wallRunZ: -j, + wallRunTopY, + wallRunBottomY, + } +} diff --git a/packages/nodes/src/downspout/schema.ts b/packages/nodes/src/downspout/schema.ts new file mode 100644 index 000000000..d4fded13a --- /dev/null +++ b/packages/nodes/src/downspout/schema.ts @@ -0,0 +1,3 @@ +// Schema lives in core (referenced by the AnyNode union). Re-export so +// every downspout-related import stays inside @pascal-app/nodes/downspout. +export { DownspoutNode } from '@pascal-app/core' diff --git a/packages/nodes/src/downspout/tool.tsx b/packages/nodes/src/downspout/tool.tsx new file mode 100644 index 000000000..1f92affcd --- /dev/null +++ b/packages/nodes/src/downspout/tool.tsx @@ -0,0 +1,197 @@ +'use client' + +import { + type AnyNodeId, + DownspoutNode, + emitter, + type GutterEvent, + type GutterNode, + generateId, + type RoofSegmentNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' +import { Vector3 } from 'three' +import { computeEaveY } from '../gutter/eave-snap' +import { resolveGutterOutletById } from '../gutter/outlet-lookup' +import { downspoutDefinition } from './definition' +import DownspoutPreview from './preview' +import { computeDownspoutRouting, type DownspoutRouting } from './routing' + +const DEFAULT_OUTLET_DIAMETER = 0.07 + +type PreviewTarget = { + segment: { position: [number, number, number]; rotation: number; eaveY: number } + gutter: { position: [number, number, number]; rotation: number } + outlet: { x: number; y: number; z: number; bore: number } + routing: DownspoutRouting | null +} + +/** + * Downspout placement tool. Hovering a gutter previews a downspout at + * the cursor's position ALONG the gutter; clicking drills a NEW outlet + * there (appended to the gutter's `outlets`) and drops a downspout + * linked to it. So multiple downspouts on one gutter land where you + * click instead of stacking on a single outlet. + * + * The cursor's along-length offset is read by projecting the world hit + * into the gutter's registered mesh frame (worldToLocal → local X). A + * throwaway gutter with a single `preview` outlet feeds the same outlet + * lookup + routing the committed pipe uses, so the ghost matches. + */ +const _hit = new Vector3() + +const DownspoutTool = () => { + const activeBuildingId = useViewer((s) => s.selection.buildingId) + const setSelection = useViewer((s) => s.setSelection) + + const [target, setTarget] = useState(null) + + const previewNode = useMemo( + () => + DownspoutNode.parse({ + ...downspoutDefinition.defaults(), + name: 'Downspout', + }), + [], + ) + + useEffect(() => { + if (!activeBuildingId) return + + // Cursor's offset along the gutter length, from the world hit. + const cursorOffset = (gutter: GutterNode, world: [number, number, number]): number | null => { + const obj = sceneRegistry.nodes.get(gutter.id as AnyNodeId) + if (!obj) return null + obj.updateWorldMatrix(true, false) + return obj.worldToLocal(_hit.set(world[0], world[1], world[2])).x + } + + const computeTarget = (event: GutterEvent): PreviewTarget | null => { + const gutter = event.node + const segmentId = gutter.roofSegmentId as AnyNodeId | undefined + if (!segmentId) return null + const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined + if (!segment) return null + const offset = cursorOffset(gutter, event.position) + if (offset === null) return null + + // Throwaway single-outlet gutter at the cursor so the lookup + + // routing produce the exact pose the commit will store. + const ghost: GutterNode = { + ...gutter, + outlets: [{ id: 'preview', offset, diameter: DEFAULT_OUTLET_DIAMETER }], + } + const outlet = resolveGutterOutletById(ghost, 'preview') + if (!outlet) return null + + return { + segment: { + position: (segment.position ?? [0, 0, 0]) as [number, number, number], + rotation: segment.rotation ?? 0, + eaveY: computeEaveY(segment), + }, + gutter: { + position: (gutter.position ?? [0, 0, 0]) as [number, number, number], + rotation: gutter.rotation ?? 0, + }, + outlet, + routing: computeDownspoutRouting(ghost, segment, 'preview'), + } + } + + const updatePreview = (event: GutterEvent) => { + const next = computeTarget(event) + if (next) { + setTarget(next) + event.stopPropagation() + } + } + + const onClick = (event: GutterEvent) => { + const gutter = event.node + const segmentId = gutter.roofSegmentId as AnyNodeId | undefined + if (!segmentId) return + const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined + if (!segment) return + const offset = cursorOffset(gutter, event.position) + if (offset === null) return + + // Drill a new outlet at the clicked offset, then drop a downspout + // linked to it. Both land in one undoable step. + const outletId = generateId('outlet') + const outlets = [ + ...(gutter.outlets ?? []), + { id: outletId, offset, diameter: DEFAULT_OUTLET_DIAMETER }, + ] + const state = useScene.getState() + state.updateNode(gutter.id as AnyNodeId, { outlets }) + state.dirtyNodes.add(gutter.id as AnyNodeId) + + const outlet = resolveGutterOutletById({ ...gutter, outlets }, outletId) + if (!outlet) return + // Drop from the gutter outlet (at eaveY − size) down to segment Y = 0. + const dropLength = Math.max(0.1, computeEaveY(segment) + outlet.y) + + const downspout = DownspoutNode.parse({ + ...downspoutDefinition.defaults(), + name: 'Downspout', + gutterId: gutter.id, + outletId, + length: dropLength, + diameter: outlet.bore * 2, + }) + state.createNode(downspout, segmentId) + state.dirtyNodes.add(segmentId) + setSelection({ selectedIds: [downspout.id] }) + triggerSFX('sfx:item-place') + event.stopPropagation() + } + + emitter.on('gutter:move', updatePreview) + emitter.on('gutter:enter', updatePreview) + emitter.on('gutter:click', onClick) + + return () => { + emitter.off('gutter:move', updatePreview) + emitter.off('gutter:enter', updatePreview) + emitter.off('gutter:click', onClick) + } + }, [activeBuildingId, setSelection]) + + if (!activeBuildingId || !target) return null + + return ( + + + + + + + + ) +} + +function previewNodeWithDefaults( + base: ReturnType, + target: PreviewTarget, +): typeof base { + // Snap preview to the same dimensions a commit would use — bore + // diameter from the gutter, drop length to the segment Y=0 plane. + return { + ...base, + diameter: target.outlet.bore * 2, + length: Math.max(0.1, target.segment.eaveY + target.outlet.y), + } as typeof base +} + +export default DownspoutTool diff --git a/packages/nodes/src/elevator/floorplan.ts b/packages/nodes/src/elevator/floorplan.ts index 1a026d2a9..e6e328562 100644 --- a/packages/nodes/src/elevator/floorplan.ts +++ b/packages/nodes/src/elevator/floorplan.ts @@ -357,6 +357,7 @@ export function buildElevatorFloorplan( point: [cx + cornerX, cz + cornerZ], angle: Math.atan2(radialZ, radialX), affordance: 'elevator-rotate', + pivot: [cx, cz], }) } diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index dc6e5fbd0..36fe97bcf 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -71,6 +71,9 @@ function fenceHeightHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_FENCE_HEIGHT, + // Drives the floating dimension pill (H · L · T) and suppresses the + // arrow's own inline chip, matching the wall height handle. + measureLabel: 'height', currentValue: (n) => n.height ?? 1.8, apply: (_n, newHeight) => ({ height: newHeight }), placement: { diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index 16c09e98a..e13101f06 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -1,12 +1,13 @@ 'use client' -import { type FenceNode, useScene, type WallNode } from '@pascal-app/core' +import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core' import { CursorSphere, type FencePlanPoint, formatAngleRadians, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + MeasurementPill, type MovingFenceEndpoint, triggerSFX, useDragAction, @@ -90,6 +91,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = : [target.fence.end[0], target.fence.end[1]] const [altPressed, setAltPressed] = useState(false) + const unit = useViewer((s) => s.unit) const exitMoveMode = (committed: boolean) => { if (committed) triggerSFX('sfx:item-place') @@ -174,9 +176,34 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const cursorPos: [number, number, number] = [movingPoint[0], 0, movingPoint[1]] + // Live segment dimensions for the floating pill. Length tracks the drag; + // height + thickness are static during an endpoint move. + const liveLength = getWallCurveLength({ + start: liveStart, + end: liveEnd, + curveOffset: liveFence?.curveOffset ?? target.fence.curveOffset, + }) + const fenceHeight = target.fence.height ?? 1.8 + const dimMidX = (liveStart[0] + liveEnd[0]) / 2 + const dimMidZ = (liveStart[1] + liveEnd[1]) / 2 + return ( + + + +} + +export type Endpoint = { + pos: readonly [number, number, number] + /** Length-axis direction in segment frame, pointing from this end toward the other end. */ + awayDir: readonly [number, number] + /** Outward normal (gutter-local +Z, "away from the building") in this frame. */ + outDir: readonly [number, number] +} + +function gutterEndpoints(g: GutterNode): { plus: Endpoint; minus: Endpoint } { + const [px, py, pz] = g.position + const r = g.rotation ?? 0 + // Gutter-local +X (length axis) rotated by `r` around Y. THREE's + // rotation-y convention: local (1, 0, 0) → (cos r, 0, −sin r). + const dirX = Math.cos(r) + const dirZ = -Math.sin(r) + // Gutter-local +Z (outward) rotated by `r`: (0, 0, 1) → (sin r, 0, cos r). + const outX = Math.sin(r) + const outZ = Math.cos(r) + const half = g.length / 2 + return { + plus: { + pos: [px + dirX * half, py, pz + dirZ * half], + // From the +X endpoint, the rest of the gutter extends back + // toward the −X end — so "away from this end" is −dir. + awayDir: [-dirX, -dirZ], + outDir: [outX, outZ], + }, + minus: { + pos: [px - dirX * half, py, pz - dirZ * half], + awayDir: [dirX, dirZ], + outDir: [outX, outZ], + }, + } +} + +/** + * Gutter endpoints lifted from segment-local into the shared ROOF frame + * by applying the host segment's position + Y-rotation. Two gutters on + * different segments can then be compared in one frame. THREE's + * rotation-y convention: a point/dir (x, z) rotates to + * (x·cos + z·sin, −x·sin + z·cos). + */ +export function gutterEndpointsInFrame( + g: GutterNode, + segment: Pick, +): { plus: Endpoint; minus: Endpoint } { + const local = gutterEndpoints(g) + const sx = segment.position?.[0] ?? 0 + const sz = segment.position?.[2] ?? 0 + const sr = segment.rotation ?? 0 + const c = Math.cos(sr) + const s = Math.sin(sr) + const liftPos = (p: readonly [number, number, number]): [number, number, number] => [ + sx + (p[0] * c + p[2] * s), + p[1], + sz + (-p[0] * s + p[2] * c), + ] + const liftDir = (d: readonly [number, number]): [number, number] => [ + d[0] * c + d[1] * s, + -d[0] * s + d[1] * c, + ] + return { + plus: { + pos: liftPos(local.plus.pos), + awayDir: liftDir(local.plus.awayDir), + outDir: liftDir(local.plus.outDir), + }, + minus: { + pos: liftPos(local.minus.pos), + awayDir: liftDir(local.minus.awayDir), + outDir: liftDir(local.minus.outDir), + }, + } +} + +// Plan-space (X/Z) distance only — deliberately ignores Y. Gutters are +// pinned to the eave line so they're coplanar in eave-Y, AND the +// renderer draws them at the LIVE `computeEaveY(segment)`, not the +// stored `position[1]` (which goes stale if the segment's wallHeight / +// pitch changed after placement). Folding Y in would reject a real +// corner whenever two gutters' stored Ys drifted apart even though they +// visibly meet. The length-snap that feeds this also works purely in +// plan, so the match must too. +export function planDistSq( + a: readonly [number, number, number], + b: readonly [number, number, number], +): number { + const dx = a[0] - b[0] + const dz = a[2] - b[2] + return dx * dx + dz * dz +} + +function mitreBetween(a: Endpoint, b: Endpoint): number { + // Both `awayDir`s point from the corner toward the FAR end of their + // gutter. The interior angle of the joint is the angle between them. + // Mitre = half the supplementary angle (standard carpenter formula). + const dot = a.awayDir[0] * b.awayDir[0] + a.awayDir[1] * b.awayDir[1] + const clamped = Math.max(-1, Math.min(1, dot)) + const interior = Math.acos(clamped) + const mitre = (Math.PI - interior) / 2 + // Aligned-or-nearly so → straight run, no mitre needed. + if (mitre < 1e-3) return 0 + + // Convex vs concave. `a.outDir` is THIS gutter's outward normal; `b`'s + // body runs from the corner along `b.awayDir`. On a CONVEX (outer) + // corner the neighbour's body sits on the INWARD side of our rim, so + // `b.awayDir · a.outDir < 0` (it heads away from our outward face) — + // the rim must EXTEND (positive). On a CONCAVE (inner) corner the + // neighbour's body sits on our OUTWARD side (`· > 0`) and the rim must + // RETRACT (negative) to the inner intersection. + const concave = b.awayDir[0] * a.outDir[0] + b.awayDir[1] * a.outDir[1] > 0 + const signed = concave ? -mitre : mitre + return Math.max(-MAX_MITRE, Math.min(MAX_MITRE, signed)) +} + +// Intersection (in plan X/Z) of two infinite length-axis lines, each +// given as a point + run direction. Returns null when the runs are +// parallel (no single crossing — a straight collinear run, not a +// corner). Mirrors the `length-snap.ts` corner solve. +function axisIntersectionXZ( + aPos: readonly [number, number, number], + aDir: readonly [number, number], + bPos: readonly [number, number, number], + bDir: readonly [number, number], +): readonly [number, number, number] | null { + const cross = aDir[0] * bDir[1] - aDir[1] * bDir[0] + if (Math.abs(cross) < AXIS_PARALLEL_EPSILON) return null + const dx = bPos[0] - aPos[0] + const dz = bPos[2] - aPos[2] + const t = (dx * bDir[1] - dz * bDir[0]) / cross + return [aPos[0] + t * aDir[0], 0, aPos[2] + t * aDir[1]] +} + +/** + * Compute mitres for `subject` against every other gutter under the + * same parent. + * + * A corner is the INTERSECTION of the two gutters' length-axis lines — + * not the proximity of their endpoints. This is what makes inner + * (concave) corners work: there the two eave drip-lines meet out in the + * notch, a full overhang away from where either gutter naturally ends, + * so an endpoint-to-endpoint test never fired. Keying off the axis + * crossing treats convex and concave identically. The length-snap pulls + * both ends out to that shared point, so by the time we mitre the + * subject's end AND the sibling's end both sit on the intersection — we + * require both to be within `CORNER_EPSILON` of it, which also rejects + * runs that merely cross in the middle (a T, not an L). + * + * First match per end wins; siblings order is the caller's, so the + * result is deterministic. + */ +export function computeGutterMitres( + subject: GutterNode, + subjectSegment: Pick, + siblings: readonly GutterWithSegment[], +): GutterMitres { + if (siblings.length === 0) return NO_MITRES + + const subj = gutterEndpointsInFrame(subject, subjectSegment) + let leftMitre = 0 + let rightMitre = 0 + + for (const sib of siblings) { + if (sib.gutter.id === subject.id) continue + const other = gutterEndpointsInFrame(sib.gutter, sib.segment) + + // `minus.awayDir` runs from the −X end toward +X, i.e. along the + // length — so it's a valid direction for either gutter's axis line. + const corner = axisIntersectionXZ( + subj.minus.pos, + subj.minus.awayDir, + other.minus.pos, + other.minus.awayDir, + ) + if (!corner) continue + + // The sibling end that sits on the corner is the one we mitre against. + const otherPlusAtCorner = planDistSq(other.plus.pos, corner) <= CORNER_EPSILON_SQ + const otherMinusAtCorner = planDistSq(other.minus.pos, corner) <= CORNER_EPSILON_SQ + if (!otherPlusAtCorner && !otherMinusAtCorner) continue + const otherEnd = otherPlusAtCorner ? other.plus : other.minus + + if (leftMitre === 0 && planDistSq(subj.minus.pos, corner) <= CORNER_EPSILON_SQ) { + leftMitre = mitreBetween(subj.minus, otherEnd) + } + if (rightMitre === 0 && planDistSq(subj.plus.pos, corner) <= CORNER_EPSILON_SQ) { + rightMitre = mitreBetween(subj.plus, otherEnd) + } + if (leftMitre !== 0 && rightMitre !== 0) break + } + + return { left: leftMitre, right: rightMitre } +} diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts new file mode 100644 index 000000000..5818f9a2e --- /dev/null +++ b/packages/nodes/src/gutter/definition.ts @@ -0,0 +1,197 @@ +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 { + 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 { + 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[] = [ + 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 = { + 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.', + }, +} diff --git a/packages/nodes/src/gutter/downspouts-panel.tsx b/packages/nodes/src/gutter/downspouts-panel.tsx new file mode 100644 index 000000000..06cbafd02 --- /dev/null +++ b/packages/nodes/src/gutter/downspouts-panel.tsx @@ -0,0 +1,161 @@ +'use client' + +import { + type AnyNodeId, + DownspoutNode, + type DownspoutNode as DownspoutNodeType, + type GutterNode, + generateId, + type RoofSegmentNode, + useScene, +} from '@pascal-app/core' +import { ActionButton, ActionGroup, PanelSection, triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useShallow } from 'zustand/react/shallow' +import { computeEaveY } from './eave-snap' +import { resolveGutterOutletById } from './outlet-lookup' + +const DEFAULT_OUTLET_DIAMETER = 0.07 + +/** + * Pick an along-length offset for a new outlet that doesn't land on an + * existing one: the first goes near the right end, the rest drop into + * the midpoint of the widest gap (including the gaps to each end). So + * "Add Downspout" repeatedly spreads outlets along the run instead of + * stacking them. + */ +function nextOutletOffset(gutter: GutterNode): number { + const len = Math.max(0.05, gutter.length) + const margin = 0.12 + const lo = -len / 2 + margin + const hi = len / 2 - margin + if (hi <= lo) return 0 + + const existing = (gutter.outlets ?? []) + .map((o) => Math.max(lo, Math.min(hi, o.offset ?? 0))) + .sort((a, b) => a - b) + if (existing.length === 0) return hi + + const bounds = [lo, ...existing, hi] + let bestMid = (lo + hi) / 2 + let bestGap = -1 + for (let i = 0; i < bounds.length - 1; i++) { + const gap = bounds[i + 1]! - bounds[i]! + if (gap > bestGap) { + bestGap = gap + bestMid = (bounds[i]! + bounds[i + 1]!) / 2 + } + } + return bestMid +} + +/** + * Downspouts subsection at the bottom of the gutter inspector. Lists the + * downspouts attached to this gutter (one per outlet); "Add Downspout" + * drills a fresh outlet at a spread-out position and drops a downspout + * on it, and each row's ✕ removes both the downspout and its outlet. + */ +export default function DownspoutsPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined + const setSelection = useViewer((s) => s.setSelection) + + const gutter = useScene((s) => + selectedId ? (s.nodes[selectedId] as GutterNode | undefined) : undefined, + ) + + const downspouts = useScene( + useShallow((s) => { + if (!selectedId) return [] as DownspoutNodeType[] + const out: DownspoutNodeType[] = [] + for (const n of Object.values(s.nodes)) { + if (n?.type === 'downspout' && n.gutterId === selectedId) { + out.push(n as DownspoutNodeType) + } + } + return out + }), + ) + + if (!gutter || gutter.type !== 'gutter') return null + + const handleSelectDownspout = (id: AnyNodeId) => { + setSelection({ selectedIds: [id] }) + } + + const handleAddDownspout = () => { + const segmentId = gutter.roofSegmentId as AnyNodeId | undefined + if (!segmentId) return + const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined + if (!segment) return + + const outletId = generateId('outlet') + const outlets = [ + ...(gutter.outlets ?? []), + { id: outletId, offset: nextOutletOffset(gutter), diameter: DEFAULT_OUTLET_DIAMETER }, + ] + const state = useScene.getState() + state.updateNode(gutter.id as AnyNodeId, { outlets }) + state.dirtyNodes.add(gutter.id as AnyNodeId) + + const outlet = resolveGutterOutletById({ ...gutter, outlets }, outletId) + const dropLength = Math.max(0.1, computeEaveY(segment) + (outlet?.y ?? -gutter.size)) + + const downspout = DownspoutNode.parse({ + name: 'Downspout', + gutterId: gutter.id, + outletId, + length: dropLength, + diameter: (outlet?.bore ?? DEFAULT_OUTLET_DIAMETER / 2) * 2, + }) + state.createNode(downspout, segmentId) + state.dirtyNodes.add(segmentId) + setSelection({ selectedIds: [downspout.id] }) + triggerSFX('sfx:item-place') + } + + const handleRemove = (downspout: DownspoutNodeType) => { + const state = useScene.getState() + // Drop the outlet this downspout drained so its hole closes up. + if (downspout.outletId) { + state.updateNode(gutter.id as AnyNodeId, { + outlets: (gutter.outlets ?? []).filter((o) => o.id !== downspout.outletId), + }) + state.dirtyNodes.add(gutter.id as AnyNodeId) + } + state.deleteNode(downspout.id as AnyNodeId) + if (gutter.roofSegmentId) state.dirtyNodes.add(gutter.roofSegmentId as AnyNodeId) + setSelection({ selectedIds: [gutter.id as AnyNodeId] }) + } + + return ( + +
+ {downspouts.map((d, i) => ( +
+ + +
+ ))} + + + +
+
+ ) +} diff --git a/packages/nodes/src/gutter/eave-align.ts b/packages/nodes/src/gutter/eave-align.ts new file mode 100644 index 000000000..7ca20cbd7 --- /dev/null +++ b/packages/nodes/src/gutter/eave-align.ts @@ -0,0 +1,97 @@ +import type { GutterNode, RoofSegmentNode } from '@pascal-app/core' +import { CORNER_EPSILON_SQ, gutterEndpointsInFrame, planDistSq } from './corner-mitre' +import { computeEaveY } from './eave-snap' + +/** + * Shared eave-Y for a connected run of gutters. + * + * Each gutter normally derives its mount height independently from its + * own host segment via `computeEaveY` (wallHeight − overhang·tan(pitch) + * + tuck, or wallHeight on a flat deck). Two segments that read as the + * same height in the inspector can still land at different eave Ys when + * their pitch / overhang / roofType differ — so gutters that meet at a + * corner inherit two heights and the run visibly steps at the joint. + * + * This walks the connected component of gutters that meet at corners + * (same plan-space endpoint test the mitre detector uses — Y is + * deliberately ignored, so the grouping survives the very height drift + * we're correcting) and returns ONE height for the whole run: the + * HIGHEST member eave. Aligning up means no gutter ever sinks into a + * roof surface; a lower roof gets a small fascia gap, which reads + * cleaner than a gutter clipping through its slope. + * + * Deterministic + symmetric: every gutter in the run computes the same + * component and the same max, so they all converge on the identical Y + * without any shared coordinator or store write. Isolated gutters (no + * corner neighbour) get their own eave Y unchanged. + * + * Pure: no React, no scene access, no store mutation. + */ + +/** A sibling gutter paired with its FULL host segment (needs the eave-Y inputs). */ +export type GutterWithSegment = { + gutter: GutterNode + segment: RoofSegmentNode +} + +function guttersMeet( + a: GutterNode, + aSeg: RoofSegmentNode, + b: GutterNode, + bSeg: RoofSegmentNode, +): boolean { + const ea = gutterEndpointsInFrame(a, aSeg) + const eb = gutterEndpointsInFrame(b, bSeg) + return ( + planDistSq(ea.minus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ || + planDistSq(ea.minus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ || + planDistSq(ea.plus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ || + planDistSq(ea.plus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ + ) +} + +// Each gutter mounts at `segment.position[1] + computeEaveY(segment)` in +// the roof frame (the renderer adds the segment-local eave Y under the +// segment's group). Segments can sit at different Y offsets, so the run +// has to be compared — and the answer returned — in the SHARED roof +// frame, not raw segment-local eave Ys. +function worldEaveY(segment: RoofSegmentNode): number { + return (segment.position?.[1] ?? 0) + computeEaveY(segment) +} + +export function computeSharedEaveY( + subject: GutterNode, + subjectSegment: RoofSegmentNode, + siblings: readonly GutterWithSegment[], +): number { + const subjectBaseY = subjectSegment.position?.[1] ?? 0 + if (siblings.length === 0) return computeEaveY(subjectSegment) + + // Index 0 is the subject; the rest are candidates. BFS the corner + // graph from the subject and keep the tallest eave in its component. + const nodes: GutterWithSegment[] = [{ gutter: subject, segment: subjectSegment }, ...siblings] + const visited = new Array(nodes.length).fill(false) + visited[0] = true + const queue = [0] + let maxWorldEaveY = worldEaveY(subjectSegment) + + while (queue.length > 0) { + const i = queue.pop()! + const cur = nodes[i]! + for (let j = 0; j < nodes.length; j++) { + if (visited[j]) continue + const other = nodes[j]! + if (guttersMeet(cur.gutter, cur.segment, other.gutter, other.segment)) { + visited[j] = true + queue.push(j) + const eaveY = worldEaveY(other.segment) + if (eaveY > maxWorldEaveY) maxWorldEaveY = eaveY + } + } + } + + // Back to the SUBJECT's segment-local frame — the renderer applies the + // returned value under the subject segment's group, which already adds + // `subjectBaseY`. + return maxWorldEaveY - subjectBaseY +} diff --git a/packages/nodes/src/gutter/eave-snap.ts b/packages/nodes/src/gutter/eave-snap.ts new file mode 100644 index 000000000..a0daabbb1 --- /dev/null +++ b/packages/nodes/src/gutter/eave-snap.ts @@ -0,0 +1,167 @@ +import type { RoofSegmentNode, RoofType } from '@pascal-app/core' + +/** + * Shared eave-snap math for the gutter's placement + move tools. + * + * `resolveEaveSnap` finds the drip edge of the eave closest to a + * cursor hit in segment-local coords. It supports every roof type the + * segment renderer can produce; the difference vs the original + * `±Z`-only resolver is hip/flat awareness (4-way eave instead of 2) + * and shed's single low eave. + * + * Why this lives outside the tools: the two tool files used to inline + * an identical copy of the resolver + the tuck constants, with a + * "keep in sync" comment that becomes a landmine as soon as the + * resolver grows non-trivial. Hip's 4-way picker pushed it past that + * threshold. + */ + +// Real gutters mount on the fascia (slightly inside the drip edge), +// with the rim at the deck-top line rather than the slope-surface-at- +// drip-edge. These tuck the snap so the gutter reads as "attached to +// the fascia" rather than "floating at the very tip of the overhang". +// Tuned by feel — bump them up if the gutter looks too low / outboard. +export const EAVE_TUCK_INWARD = 0.04 +export const EAVE_TUCK_UP = 0.04 + +export type EaveSide = '+X' | '-X' | '+Z' | '-Z' + +export type EaveSnap = { + /** Segment-local X of the snapped gutter position. */ + eaveX: number + /** Segment-local Y of the snapped gutter position (drip-edge Y). */ + eaveY: number + /** Segment-local Z of the snapped gutter position. */ + eaveZ: number + /** + * Gutter's segment-local Y rotation: orients gutter's outward axis + * (+Z local) toward the side picked. Length axis (+X local) falls + * out along the eave direction (±X or ±Z depending on the side). + */ + rotation: number + /** Which side of the segment the snap landed on. */ + side: EaveSide +} + +/** + * Live eave Y from a segment's wallHeight + overhang + pitch. Pulled + * out as a shared helper because the renderer derives Y from this same + * formula on every frame (the gutter tracks the segment's height + * instead of trusting `node.position[1]` from placement time), and + * `resolveEaveSnap` uses the same formula at placement. + */ +export function computeEaveY( + segment: Pick, +): number { + const wallHeight = segment.wallHeight ?? 0 + // Flat roofs have no slope drop and no slope-surface-vs-deck-top + // offset — the deck top IS the eave line. EAVE_TUCK_UP is a + // correction that lifts a SLOPED gutter from the slope-surface up to + // the deck-top line; applying it to a flat deck floats the gutter + // above the roof and leaves a visible gap between the edge and the + // gutter. So mount flat gutters right at the deck top. + if ((segment.roofType ?? 'gable') === 'flat') return wallHeight + const overhang = segment.overhang ?? 0 + const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 + return wallHeight - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP +} + +/** + * Pick which of the segment's eaves is closest to the cursor. + * + * - `shed`: low side only. The segment-hit's analytical surface for + * a shed has `t = (lz + depth/2)/depth`, so the eave is at +Z + * regardless of which side the cursor is on — clicking on the high + * side still rolls the gutter down to the low eave. + * + * - `hip` / `flat` / `dutch`: 4-way. The slope the user is standing + * on is determined by whichever of `|lx|/halfW` or `|lz|/halfD` is + * larger — same `max(fx, fz)` discriminator the segment-hit's + * `analyticalSurfaceY` uses for hip. Sign of the dominant axis + * picks +/-. Dutch is a hip base with a gablet on top, so its + * lower run has all four eaves at the eave line — it gets the same + * 4-way snap as hip. + * + * - `gable` / `gambrel` / `mansard`: 2-way `±Z`. Mansard has real + * 4-side eaves in plan, but the segment-hit formula approximates it + * as 2-slope (depth-only), so we stay consistent here — the user + * can re-place the gutter manually on a side eave if mansard + * becomes important. + */ +function pickEaveSide( + roofType: RoofType, + localX: number, + localZ: number, + halfW: number, + halfD: number, +): EaveSide { + if (roofType === 'shed') return '+Z' + + if (roofType === 'hip' || roofType === 'flat' || roofType === 'dutch') { + const fx = halfW > 0 ? Math.abs(localX) / halfW : 0 + const fz = halfD > 0 ? Math.abs(localZ) / halfD : 0 + if (fx > fz) return localX < 0 ? '-X' : '+X' + return localZ < 0 ? '-Z' : '+Z' + } + + return localZ < 0 ? '-Z' : '+Z' +} + +export function resolveEaveSnap( + segment: RoofSegmentNode, + localX: number, + localZ: number, +): EaveSnap { + const halfW = (segment.width ?? 0) / 2 + const halfD = (segment.depth ?? 0) / 2 + const overhang = segment.overhang ?? 0 + + // The slope keeps descending past the wall edge by the overhang + // span; same drop on every eave (pitch is the segment-wide primary + // slope). EAVE_TUCK_UP raises the rim back toward the deck-top line. + // Shared formula with the renderer so placement and live tracking + // agree exactly. + const eaveY = computeEaveY(segment) + + const side = pickEaveSide(segment.roofType ?? 'gable', localX, localZ, halfW, halfD) + + // For `±Z` eaves the eave runs along ±X so the parallel axis stays + // free (snapped to cursor's X), and Z pins to the drip edge. `±X` + // eaves swap which axis is free vs pinned. Rotation aligns the + // gutter's outward (+Z local) with the picked side; length (+X + // local) then falls along the eave. + switch (side) { + case '+Z': + return { + eaveX: localX, + eaveY, + eaveZ: Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD), + rotation: 0, + side, + } + case '-Z': + return { + eaveX: localX, + eaveY, + eaveZ: -Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD), + rotation: Math.PI, + side, + } + case '+X': + return { + eaveX: Math.max(halfW, halfW + overhang - EAVE_TUCK_INWARD), + eaveY, + eaveZ: localZ, + rotation: Math.PI / 2, + side, + } + case '-X': + return { + eaveX: -Math.max(halfW, halfW + overhang - EAVE_TUCK_INWARD), + eaveY, + eaveZ: localZ, + rotation: -Math.PI / 2, + side, + } + } +} diff --git a/packages/nodes/src/gutter/floorplan.ts b/packages/nodes/src/gutter/floorplan.ts new file mode 100644 index 000000000..e292a906c --- /dev/null +++ b/packages/nodes/src/gutter/floorplan.ts @@ -0,0 +1,300 @@ +import type { + AnyNodeId, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + GutterNode, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' +import { computeGutterMitres, type GutterWithSegment } from './corner-mitre' +import { EAVE_TUCK_INWARD } from './eave-snap' +import { outletDims, outletShapeForProfile, profileFloorMidZ } from './profile-geometry' + +/** + * Floor-plan builder for a gutter. A gutter is a thin rain-water channel + * hosted on a roof segment, running along an eave. In plan it reads as a + * narrow metal strip just outboard of the eave line: the trough (two long + * edges), end caps where the trough is closed, hanger straps across the + * run, and a downspout outlet symbol where one is fitted. + * + * The coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → gutter). The gutter's `position` is + * segment-local and the segment's is roof-local, so we compose + * world = roof.pos + R(roof) · (seg.pos + R(seg) · gutter.pos) + * using the floor-plan's negated-rotation convention (see + * `buildRoofSegmentFloorplan`). Gutter-local +X is the run (along the + * eave); +Z hangs outward, away from the building. + */ +export function buildGutterFloorplan( + node: GutterNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + // Compose roof → segment → gutter in plan coords. Each rotation is + // negated so SVG's y-down CW matches Three.js' top-down CCW — the same + // convention the roof-segment builder establishes. + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const gutterRot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(gutterRot) + const sin = Math.sin(gutterRot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const halfLen = Math.max(node.length, 0.1) / 2 + const width = Math.max(node.size, 0.05) // outward extent of the trough + + // The gutter's stored position is the eave drip edge — `halfD + overhang` + // out from the segment centre (resolveEaveSnap). The roof floor plan + // draws only the structural footprint (no overhang), so to seat the + // trough on the drawn roof edge we shift it inward by that overhang + // excess. Local Z then reads: `backZ` = back/fascia edge (on the roof + // edge), `rimZ` = outward lip. + // + // A plain inward shift would break mitred corners — the two meeting + // gutters lie on perpendicular eaves, so each shifts a different way and + // their ends part (they cross). We compensate in the corner math below + // by also retracting each MITRED end along the run by the same inset: + // the net move at a corner is then identical for both gutters, so they + // still meet — now at the structural corner. Exact for right-angle + // (hip / rectangular) corners. + const eaveInset = Math.max(0, (segment.overhang ?? 0) - EAVE_TUCK_INWARD) + const backZ = -eaveInset + const rimZ = width - eaveInset + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Gutters are a metal accessory — read them in a cooler grey than the + // roof's black structural ink, accent on select, light blue on hover. + const baseInk = '#475569' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#cbd5e1' + const fillOpacity = showSelectedChrome ? 0.5 : 0.4 + const lineWidth = showSelectedChrome ? 0.03 : 0.022 + + // Corner mitres — when a sibling gutter meets this one at a roof + // corner, that shared end is open: the back wall stays at the corner + // while the rim extends outward to the mitre, and the cap is + // suppressed. Mirrors the 3D builder's rule (`endCap* && mitre === 0`). + // `left` = −X end, `right` = +X end. + // + // Cross-segment: collect every other gutter on the roof paired with its + // host segment (mirrors the renderer's `mitreNodes` walk) so gutters + // meeting where two segments join still mitre, not just same-segment. + const mitreSiblings: GutterWithSegment[] = [] + for (const segId of roof.children ?? []) { + const sib = ctx.resolve(segId as AnyNodeId) as RoofSegmentNode | undefined + if (!sib || sib.type !== 'roof-segment') continue + for (const gid of sib.children ?? []) { + const g = ctx.resolve(gid as AnyNodeId) as GutterNode | undefined + if (g && g.type === 'gutter' && g.id !== node.id) { + mitreSiblings.push({ gutter: g, segment: sib }) + } + } + } + const mitres = computeGutterMitres(node, segment, mitreSiblings) + const capRight = node.endCapRight && mitres.right === 0 + const capLeft = node.endCapLeft && mitres.left === 0 + + // Footprint corners. Back edge sits on the roof edge (lz = backZ); the + // rim hangs outward (lz = rimZ). A mitred end (a) retracts along the run + // by `eaveInset` so its back corner lands on the structural corner, then + // (b) skews its rim corner by the SIGNED mitre (`Math.tan` carries the + // sign: convex extends, concave retracts) so adjacent gutters' rims meet + // at the corner. Non-mitred ends (mitre === 0) keep the full run. + const backRightX = halfLen - (mitres.right !== 0 ? eaveInset : 0) + const backLeftX = -(halfLen - (mitres.left !== 0 ? eaveInset : 0)) + const backLeft = toPlan(backLeftX, backZ) + const backRight = toPlan(backRightX, backZ) + const rimRight = toPlan(backRightX + width * Math.tan(mitres.right), rimZ) + const rimLeft = toPlan(backLeftX - width * Math.tan(mitres.left), rimZ) + + const children: FloorplanGeometry[] = [ + // Transparent hit-target across the whole channel so the thin strip + // is easy to click-select in plan. + { + kind: 'polygon', + points: [backLeft, backRight, rimRight, rimLeft], + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + // Channel fill. + { + kind: 'polygon', + points: [backLeft, backRight, rimRight, rimLeft], + fill, + fillOpacity, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'none', + }, + // Long edges — the back (fascia) line and the front lip. These two + // parallel lines are the gutter's signature read in plan. + { + kind: 'line', + x1: backLeft[0], + y1: backLeft[1], + x2: backRight[0], + y2: backRight[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }, + { + kind: 'line', + x1: rimLeft[0], + y1: rimLeft[1], + x2: rimRight[0], + y2: rimRight[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }, + ] + + // End edges. A capped end gets a square closure line; a mitred end gets + // the slanted mitre seam (so the joint shows in plan); an open, uncapped + // end gets nothing. `cap*` already excludes mitred ends, so an end never + // draws both a cap and a seam. + if (capLeft || mitres.left !== 0) { + children.push({ + kind: 'line', + x1: backLeft[0], + y1: backLeft[1], + x2: rimLeft[0], + y2: rimLeft[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + } + if (capRight || mitres.right !== 0) { + children.push({ + kind: 'line', + x1: backRight[0], + y1: backRight[1], + x2: rimRight[0], + y2: rimRight[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + } + + // Hanger straps — short ticks across the trough at the real hanger + // spacing, so the strip reads as a gutter rather than a thin wall. + if (node.hangerStyle === 'strap') { + const spacing = Math.max(node.hangerSpacing, 0.2) + const inset = width * 0.15 + // Span the (possibly retracted) run between the two end corners. + const mid = (backLeftX + backRightX) / 2 + const runLen = backRightX - backLeftX + const count = Math.max(1, Math.floor(runLen / spacing)) + const span = count * spacing + for (let i = 0; i < count; i++) { + const x = mid - span / 2 + spacing / 2 + i * spacing + if (x <= backLeftX + 0.02 || x >= backRightX - 0.02) continue + const a = toPlan(x, backZ + inset) + const b = toPlan(x, rimZ - inset) + children.push({ + kind: 'line', + x1: a[0], + y1: a[1], + x2: b[0], + y2: b[1], + stroke, + strokeWidth: lineWidth * 0.7, + strokeLinecap: 'round', + opacity: 0.6, + pointerEvents: 'none', + }) + } + } + + // Downspout outlets — a leader symbol per outlet (round for half-round + // gutters, rectangular for k-style / box, following the profile) at each + // outlet's along-run position. The strongest "this is a gutter" cue in a + // roof plan. `offset` is signed from the gutter centre along +X. + // `outlets` is a recent schema addition — gutters persisted before it + // existed deserialize without the field (the schema default only fills + // on a fresh parse), so guard against `undefined`. + const outlets = node.outlets ?? [] + if (outlets.length > 0) { + const floorZ = Math.min( + Math.max(profileFloorMidZ(node.profile, width), width * 0.25), + width * 0.85, + ) + const outletZ = backZ + floorZ + const shape = outletShapeForProfile(node.profile) + const outStroke = showSelectedChrome && palette ? palette.selectedStroke : '#1e293b' + for (const outlet of outlets) { + // Clamp inside the run so the symbol never rides out onto a cap line + // (the 3D builder clamps the drill the same way). + const outletX = Math.max(-halfLen * 0.9, Math.min(halfLen * 0.9, outlet.offset)) + const dims = outletDims(shape, outlet.diameter) + if (shape === 'round') { + const center = toPlan(outletX, outletZ) + children.push({ + kind: 'circle', + cx: center[0], + cy: center[1], + r: Math.max(dims.halfX, 0.03), + fill: 'none', + stroke: outStroke, + strokeWidth: lineWidth, + pointerEvents: 'none', + }) + } else { + children.push({ + kind: 'polygon', + points: [ + toPlan(outletX - dims.halfX, outletZ - dims.halfZ), + toPlan(outletX + dims.halfX, outletZ - dims.halfZ), + toPlan(outletX + dims.halfX, outletZ + dims.halfZ), + toPlan(outletX - dims.halfX, outletZ + dims.halfZ), + ], + fill: 'none', + stroke: outStroke, + strokeWidth: lineWidth, + pointerEvents: 'none', + }) + } + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts new file mode 100644 index 000000000..f030cbbcf --- /dev/null +++ b/packages/nodes/src/gutter/geometry.ts @@ -0,0 +1,634 @@ +import type { GutterNode } from '@pascal-app/core' +import { + Brush, + csgEvaluator, + csgGeometry, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' +import * as THREE from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { type GutterMitres, NO_MITRES } from './corner-mitre' +import { + OUTLET_STUB_LENGTH, + OUTLET_WALL_THICKNESS, + type OutletDims, + type OutletShape, + outletDims, + outletShapeForProfile, + profileFloorMidZ, +} from './profile-geometry' + +/** + * Pure builder for the gutter mesh. The gutter is a hollow trough that + * runs along the eave; we build its cross-section as a closed 2D Shape + * in the (Z, Y) plane with the channel cavity carved out as a Path + * hole, then extrude along the gutter's local +X (length direction). + * + * Three profiles share the same outer-outline-minus-cavity recipe; they + * differ only in the OUTLINE shape: + * + * - `k-style`: flat back + flat bottom + ogee (S-curve) fascia. + * Most common modern residential profile. + * - `half-round`: half-cylinder (semicircle cross-section). + * - `box`: square / rectangular u-channel; reads as commercial. + * + * The gutter mounts at the eave line (gutter-local Y=0) and drops + * downward (negative Y) by `size`. +Z is "away from the building" — + * positive Z is the outer face that hangs over the eave. + * + * End caps: when `endCapLeft` / `endCapRight` is true, the matching + * end gets a thin SOLID slice (depth = wall thickness) instead of the + * hollow U-channel. The solid slice's end face closes the trough so + * water can't run out the side. Caps subtract from the user-set + * `length` so the gutter's total span stays constant — capping doesn't + * silently grow the geometry past what the inspector reads. + * + * Corner mitres: when a sibling gutter meets this gutter at a roof + * corner, the corner-mitre detector passes a mitre angle for the + * affected end. The end-face vertices are skewed (back wall held in + * place, front rim extended outward) so two perpendicular gutters' + * front rims meet at the outer eave intersection. A mitred end's cap + * is force-suppressed — capping a corner would wall off the L. + * + * Pure: no React, no scene access, no store mutation. + */ +export function buildGutterGeometry( + node: GutterNode, + mitres: GutterMitres = NO_MITRES, +): THREE.BufferGeometry { + const len = Math.max(0.05, node.length) + const size = Math.max(0.04, node.size) + const t = Math.min(Math.max(0.001, node.thickness), size * 0.4) + + const capLeft = (node.endCapLeft ?? true) && mitres.left === 0 + const capRight = (node.endCapRight ?? true) && mitres.right === 0 + + // Reserve cap slices at each capped end. Each cap is `t` thick + // (matches the wall thickness — a real end cap is a stamped plate + // welded onto the gutter). Clamp so a tiny gutter doesn't end up + // all-cap-no-channel. + const reserved = (capLeft ? t : 0) + (capRight ? t : 0) + const channelLen = Math.max(len * 0.1, len - reserved) + const totalCap = len - channelLen + const capLeftLen = capLeft ? (capRight ? totalCap / 2 : totalCap) : 0 + const capRightLen = capRight ? (capLeft ? totalCap / 2 : totalCap) : 0 + + let channelCross: THREE.Shape + let capCross: THREE.Shape + if (node.profile === 'half-round') { + channelCross = buildHalfRoundCross(size, t) + capCross = buildHalfRoundOuterOnly(size) + } else if (node.profile === 'box') { + channelCross = buildBoxCross(size, t) + capCross = buildBoxOuterOnly(size) + } else { + channelCross = buildKStyleCross(size, t) + capCross = buildKStyleOuterOnly(size) + } + + // Each extrude below uses the same orient-and-recenter recipe: + // ExtrudeGeometry produces (mesh-X = cross-X, mesh-Y = cross-Y, + // mesh-Z = extrusion axis); we rotateY(-π/2) so the LENGTH lands + // along mesh-+X and the OUTWARD direction lands along mesh-+Z, then + // translate so the piece sits in its slot of the gutter's overall + // [-len/2, +len/2] span. Z_cs = 0 maps to mesh-+X (right end); + // Z_cs = depth maps to mesh--X (left end). + const pieces: THREE.BufferGeometry[] = [] + + let channel: THREE.BufferGeometry = new THREE.ExtrudeGeometry(channelCross, { + depth: channelLen, + bevelEnabled: false, + curveSegments: 16, + steps: 1, + }) + // Apply the corner-mitre skew while we're still in the source frame. + // Source axes (pre-rotation): X_cs = outward, Y_cs = vertical, + // Z_cs = length (0 at right end of mesh, `channelLen` at left end). + // After rotateY(-π/2): mesh-X = -Z_cs, mesh-Z = X_cs. + // + // Corner mitre rule: the back wall (X_cs = 0) stays at the original + // end (mesh-X = ±len/2); the front rim (X_cs = +outward) is SKEWED + // along the gutter's length to reach the eave intersection of the L. + // The mitre is SIGNED — positive (convex / outer corner) extends the + // rim past the end; negative (concave / inner corner) retracts it to + // the inner intersection. `Math.tan` carries the sign, so the same + // formula handles both. In mesh coords: + // right end: Δmesh-X = +mesh-Z · tan(mitreRight) + // left end: Δmesh-X = −mesh-Z · tan(mitreLeft) + // Mapped back to source coords (Δmesh-X = −ΔZ_cs, mesh-Z = X_cs): + // right end (Z_cs = 0): new Z_cs = −X_cs · tan(mitreRight) + // left end (Z_cs = channelLen): new Z_cs = channelLen + X_cs · tan(mitreLeft) + if (mitres.right !== 0 || mitres.left !== 0) { + // Drop the cross-section CAP at each mitred end first. Both gutters + // at a corner skew their end faces onto the SAME mitre plane, so the + // thin metal-section caps land coincident and z-fight into the stray + // seam lines visible at the joint (and the ink-edge pass outlines + // them). With the caps gone the open troughs flow into each other and + // the side walls carry the corner — a clean seam. Done before the + // skew so the end planes are still the clean z=0 / z=channelLen. + const stripped = stripEndCaps(channel, channelLen, mitres.right !== 0, mitres.left !== 0) + channel.dispose() + channel = stripped + + const tanRight = Math.tan(mitres.right) + const tanLeft = Math.tan(mitres.left) + const eps = 1e-5 + const pos = channel.attributes.position! + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i) + const z = pos.getZ(i) + if (mitres.right !== 0 && Math.abs(z) < eps) { + pos.setZ(i, -x * tanRight) + } else if (mitres.left !== 0 && Math.abs(z - channelLen) < eps) { + pos.setZ(i, channelLen + x * tanLeft) + } + } + pos.needsUpdate = true + channel.computeVertexNormals() + } + channel.rotateY(-Math.PI / 2) + // Channel spans [-len/2 + capLeftLen, +len/2 - capRightLen]: shift + // the recentered extrude so its right end butts against the right cap. + channel.translate(len / 2 - capRightLen, 0, 0) + pieces.push(channel) + + if (capLeft) { + const leftCap = new THREE.ExtrudeGeometry(capCross, { + depth: capLeftLen, + bevelEnabled: false, + curveSegments: 16, + steps: 1, + }) + leftCap.rotateY(-Math.PI / 2) + // Left cap spans [-len/2, -len/2 + capLeftLen]: translate by + // -len/2 + capLeftLen so Z_cs=0 (mesh-+X end of the cap slice) + // sits at -len/2 + capLeftLen and Z_cs=depth at -len/2. + leftCap.translate(-len / 2 + capLeftLen, 0, 0) + pieces.push(leftCap) + } + + if (capRight) { + const rightCap = new THREE.ExtrudeGeometry(capCross, { + depth: capRightLen, + bevelEnabled: false, + curveSegments: 16, + steps: 1, + }) + rightCap.rotateY(-Math.PI / 2) + // Right cap spans [+len/2 - capRightLen, +len/2]. + rightCap.translate(len / 2, 0, 0) + pieces.push(rightCap) + } + + // Hangers: thin metal straps spanning the rim from the back wall to + // the front rim, repeated along the length. Each strap is a small + // box centered on Y=0 (the eave line, where the rim sits) with its + // top ~3mm above the rim — so it reads as a clip resting on the + // gutter rather than buried in it. + if ((node.hangerStyle ?? 'strap') !== 'none') { + for (const hanger of buildHangers(node, len, size, capLeftLen, capRightLen, mitres)) { + pieces.push(hanger) + } + } + + // Downspout outlets: short drop-tube collars hanging off the gutter + // floor at each outlet's position. Each collar is solid at the outer + // (bore + wall) cross-section; the bore is then drilled through both + // the floor and the collar via CSG, so the result is a real hole in + // the trough floor and a hollow tube descending from it. CSG only + // runs when at least one outlet exists — an empty `outlets` keeps the + // fast merge path. + const placements = resolveOutletPlacements(node, len, size, capLeftLen, capRightLen) + for (const p of placements) { + pieces.push(buildOutletStub(p, size)) + // Flared funnel lip at the floor so the drop reads as a real drop + // outlet rather than a bare tube; the same bore drill cuts it open. + pieces.push(buildOutletFunnel(p, size)) + } + + const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!) + // Free the intermediate pieces when merge returned a new geometry. + if (merged !== pieces[0]) { + for (const p of pieces) p.dispose() + } + merged.computeVertexNormals() + + // CSG drill — punches each bore through the merged geometry. Runs + // last so the floor + collars are already in one mesh; each drill + // cuts both at once, subtracted sequentially. + if (placements.length > 0) { + let workingBrush = new Brush(merged) + prepareBrushForCSG(workingBrush) + for (const p of placements) { + const drill = buildOutletDrill(p, size) + const drillBrush = new Brush(drill) + prepareBrushForCSG(drillBrush) + const next = csgEvaluator.evaluate(workingBrush, drillBrush, SUBTRACTION) as Brush + // Free the previous step's intermediate result (but not `merged`, + // which is disposed once below). + if (workingBrush.geometry !== merged) workingBrush.geometry.dispose() + workingBrush = next + drill.dispose() + } + const cutGeometry = csgGeometry(workingBrush) + merged.dispose() + return cutGeometry + } + + return merged +} + +// Remove the extrude's cross-section CAP triangles at a mitred end so two +// gutters meeting at a corner don't leave coincident, z-fighting end +// faces (the seam lines at the joint). A cap triangle is one whose three +// vertices ALL lie on an end plane — z≈0 (right end) or z≈channelLen +// (left end). Wall triangles always span the two planes, so this cleanly +// separates caps from walls without depending on ExtrudeGeometry's +// internal vertex order. Input is non-indexed (ExtrudeGeometry), so every +// three positions is one triangle; we copy through the survivors and let +// the caller recompute normals. +function stripEndCaps( + geo: THREE.BufferGeometry, + channelLen: number, + removeRight: boolean, + removeLeft: boolean, +): THREE.BufferGeometry { + const src = geo.index ? geo.toNonIndexed() : geo + const pos = src.attributes.position! + const uv = src.attributes.uv + const eps = 1e-4 + const keepPos: number[] = [] + const keepUv: number[] | null = uv ? [] : null + const triCount = Math.floor(pos.count / 3) + for (let t = 0; t < triCount; t++) { + const a = t * 3 + const za = pos.getZ(a) + const zb = pos.getZ(a + 1) + const zc = pos.getZ(a + 2) + const allRight = Math.abs(za) < eps && Math.abs(zb) < eps && Math.abs(zc) < eps + const allLeft = + Math.abs(za - channelLen) < eps && + Math.abs(zb - channelLen) < eps && + Math.abs(zc - channelLen) < eps + if ((removeRight && allRight) || (removeLeft && allLeft)) continue + for (let k = 0; k < 3; k++) { + const idx = a + k + keepPos.push(pos.getX(idx), pos.getY(idx), pos.getZ(idx)) + if (keepUv && uv) keepUv.push(uv.getX(idx), uv.getY(idx)) + } + } + if (src !== geo) src.dispose() + const out = new THREE.BufferGeometry() + out.setAttribute('position', new THREE.Float32BufferAttribute(keepPos, 3)) + if (keepUv) out.setAttribute('uv', new THREE.Float32BufferAttribute(keepUv, 2)) + return out +} + +// Each cross-section below is authored as a single closed polygon that +// traces the U-channel's MATERIAL — outer wall down → bottom → outer +// wall up → rim across → inner wall down → inner bottom → inner wall +// up → closing rim. The interior of the U (where rainwater sits) is +// empty space, not a hole — so the extrude has an OPEN TOP, which is +// what makes the geometry read as a gutter rather than a sealed box +// with a tunnel through it. +// +// Cross-section authoring frame: X is the gutter's outward direction +// (X=0 against the fascia, X=+w hanging outward); Y is vertical (Y=0 +// at the eave line, Y=-size at the bottom of the trough). After the +// rotateY(-π/2) in the parent builder, +X maps to segment-outward (+Z) +// and the extrude axis (length) maps to segment-+X. + +function buildKStyleCross(size: number, t: number): THREE.Shape { + const wBot = size * 0.8 // bottom outer width — narrower than the rim + const wTop = size * 0.95 + const ogeeY = -size * 0.65 // S-curve inflection on the fascia + + const shape = new THREE.Shape() + // Outer trace — top-back → down the back → across the bottom → up + // the ogee fascia. + shape.moveTo(0, 0) + shape.lineTo(0, -size) + shape.lineTo(wBot, -size) + shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0) + // Front rim (thin top of the front wall): step inward by `t`. + shape.lineTo(wTop - t, 0) + // Inner trace — back down the ogee, across the inner bottom, up the + // inner back wall. Same bezier control points pushed inward by `t`. + shape.bezierCurveTo( + wTop - size * 0.15 - t, + ogeeY * 0.4, + wBot + size * 0.15 - t, + ogeeY, + wBot - t, + -size + t, + ) + shape.lineTo(t, -size + t) + shape.lineTo(t, 0) + // closePath draws the back rim (t, 0) → (0, 0) — the thin top of + // the back wall, sealing the cross-section. + shape.closePath() + return shape +} + +// Half-round trough — a semicircular cross-section with a smaller +// concentric semicircle carved out. Single closed trace: outer half +// from (0,0) sweeping down and back up to (2r, 0), front rim across by +// `t`, inner half from (2r-t, 0) sweeping back to (t, 0), back rim +// closes the loop. +function buildHalfRoundCross(size: number, t: number): THREE.Shape { + const r = size // radius == size: half-circle drops `size` below the eave + const ri = r - t // inner radius + const segs = 24 + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + // Outer semicircle, lower half (angle π → 2π). At i=0 we'd be at + // (0,0) — already there from moveTo — so start at i=1. + for (let i = 1; i <= segs; i++) { + const angle = Math.PI + (Math.PI * i) / segs + shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle)) + } + // Front rim — step inward by `t` to start the inner trace. + shape.lineTo(2 * r - t, 0) + // Inner semicircle, traced BACK toward the back wall (angle 2π → π). + for (let i = 1; i <= segs; i++) { + const angle = 2 * Math.PI - (Math.PI * i) / segs + shape.lineTo(r + ri * Math.cos(angle), ri * Math.sin(angle)) + } + // closePath draws (t, 0) → (0, 0) — back rim. + shape.closePath() + return shape +} + +// Square / rectangular box U-channel. Width equals size (deep-and- +// narrow ratio reads as commercial). Traced as outer rect → front rim +// → inner rect (reverse) → back rim. +function buildBoxCross(size: number, t: number): THREE.Shape { + const w = size + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + shape.lineTo(0, -size) + shape.lineTo(w, -size) + shape.lineTo(w, 0) + // Front rim. + shape.lineTo(w - t, 0) + // Inner rect, reversed so the polygon doesn't self-intersect. + shape.lineTo(w - t, -size + t) + shape.lineTo(t, -size + t) + shape.lineTo(t, 0) + // closePath draws the back rim (t, 0) → (0, 0). + shape.closePath() + return shape +} + +// Solid-outer outlines used for the end-cap slices: same outer +// boundary as the channel cross-sections above but without the inner +// trough carved out, so the extruded slice is a solid plug that closes +// the open end of the trough. + +function buildKStyleOuterOnly(size: number): THREE.Shape { + const wBot = size * 0.8 + const wTop = size * 0.95 + const ogeeY = -size * 0.65 + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + shape.lineTo(0, -size) + shape.lineTo(wBot, -size) + shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0) + // closePath draws (wTop, 0) → (0, 0) — the cap's rim line across + // the top of the gutter cross-section. + shape.closePath() + return shape +} + +function buildHalfRoundOuterOnly(size: number): THREE.Shape { + const r = size + const segs = 24 + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + for (let i = 1; i <= segs; i++) { + const angle = Math.PI + (Math.PI * i) / segs + shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle)) + } + shape.closePath() + return shape +} + +function buildBoxOuterOnly(size: number): THREE.Shape { + const w = size + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + shape.lineTo(0, -size) + shape.lineTo(w, -size) + shape.lineTo(w, 0) + shape.closePath() + return shape +} + +// ─── Hangers ─────────────────────────────────────────────────────── + +// Strap dimensions — a residential hidden hanger reads as a flat band +// roughly 25mm wide along the gutter, 3mm thick, sitting on the rim. +const HANGER_BAR_LEN = 0.025 +const HANGER_BAR_THICKNESS = 0.003 +// Extra spread past the rim's outward extent — so the strap looks like +// it "wraps over" both edges rather than ending flush. +const HANGER_OVERHANG = 0.005 +// Distance from each gutter end where a strap is allowed to sit; keeps +// straps from clashing with end caps and from looking pinned to the +// very edge. +const HANGER_END_MARGIN = 0.05 + +/** Outward Z extent of each profile, used to size the strap. */ +function profileRimWidth(profile: GutterNode['profile'], size: number): number { + if (profile === 'half-round') return 2 * size + if (profile === 'box') return size + return size * 0.95 // k-style wTop +} + +function buildHangers( + node: GutterNode, + len: number, + size: number, + capLeftLen: number, + capRightLen: number, + mitres: GutterMitres, +): THREE.BufferGeometry[] { + const spacing = Math.max(0.2, node.hangerSpacing ?? 0.6) + const profile = node.profile ?? 'k-style' + const rimWidth = profileRimWidth(profile, size) + const strapDepth = rimWidth + HANGER_OVERHANG * 2 + + // At an INNER (concave) corner the two gutters' troughs fold into the + // same notch, so a strap sitting near that end overlaps the neighbour + // gutter's end strap — the two perpendicular straps read as an X across + // the corner. Pull the run's bound back by a full strap depth at a + // concave end so the nearest strap clears the joint. OUTER (convex) + // corners diverge outward and never overlap, so they keep the tight + // margin (`mitre > 0` and non-mitred ends → no extra inset). + const concaveInset = (mitre: number) => (mitre < 0 ? strapDepth : 0) + + // Inset by margin AND any cap so straps don't punch into the cap slab, + // plus the concave-corner clearance above. + const leftBound = -len / 2 + capLeftLen + HANGER_END_MARGIN + concaveInset(mitres.left) + const rightBound = len / 2 - capRightLen - HANGER_END_MARGIN - concaveInset(mitres.right) + const usable = rightBound - leftBound + if (usable <= 0) return [] + + // Span the usable run with straps at `spacing` between centers, plus + // one at each end. Symmetric layout for any length, including very + // short gutters where two straps land at the bounds. + const count = Math.max(1, Math.floor(usable / spacing) + 1) + const stride = count > 1 ? usable / (count - 1) : 0 + + const pieces: THREE.BufferGeometry[] = [] + for (let i = 0; i < count; i++) { + const x = count > 1 ? leftBound + i * stride : (leftBound + rightBound) / 2 + // BoxGeometry is indexed; the channel + cap ExtrudeGeometries are + // not. `mergeGeometries` rejects mixed-index sets, so flatten the + // box to non-indexed before pushing. + const bar = new THREE.BoxGeometry( + HANGER_BAR_LEN, + HANGER_BAR_THICKNESS, + strapDepth, + ).toNonIndexed() + // Center the bar at X = position, Y just above the rim line, Z + // straddling 0 so the strap covers the full back-to-front span. + bar.translate(x, HANGER_BAR_THICKNESS / 2 + 0.001, rimWidth / 2) + pieces.push(bar) + } + return pieces +} + +// ─── Outlet ──────────────────────────────────────────────────────── + +// Radial subdivisions — 24 reads as smooth at typical outlet +// diameters; lower and the 3″ tube starts looking faceted from below. +const OUTLET_RADIAL_SEGMENTS = 24 +// Drill overshoot past the floor and past the stub bottom — keeps the +// CSG cut planes from coinciding with merged-geometry surfaces +// (coplanar cuts produce degenerate output in three-bvh-csg). +const OUTLET_DRILL_OVERSHOOT = 0.01 +// Funnel lip at the drop outlet — flares this much wider than the collar +// over this height, just below the trough floor. The bore drill cuts it +// open along with the collar. +const OUTLET_FLARE_SCALE = 1.6 +const OUTLET_FLARE_HEIGHT = 0.02 + +type OutletPlacement = { + x: number + z: number + shape: OutletShape + /** Bore cross-section (the drilled hole). */ + inner: OutletDims + /** Collar cross-section (bore + wall) — the solid stub body. */ + outer: OutletDims +} + +/** + * One placement per `node.outlets` entry that fits between the caps. The + * shape follows the gutter profile (`outletShapeForProfile`): round + * leader on half-round, rectangular on k-style / box. Each outlet's + * `offset` (signed from center) is clamped inside the trough-floor span + * using the OUTER along-length half-extent so the collar can't poke into + * a cap. Outlets that can't fit at all are dropped. + */ +function resolveOutletPlacements( + node: GutterNode, + len: number, + size: number, + capLeftLen: number, + capRightLen: number, +): OutletPlacement[] { + const outlets = node.outlets ?? [] + if (outlets.length === 0) return [] + + const shape = outletShapeForProfile(node.profile) + const z = profileFloorMidZ(node.profile ?? 'k-style', size) + const placements: OutletPlacement[] = [] + for (const outlet of outlets) { + const inner = outletDims(shape, outlet.diameter ?? 0.07) + const outer: OutletDims = { + shape, + halfX: inner.halfX + OUTLET_WALL_THICKNESS, + halfZ: inner.halfZ + OUTLET_WALL_THICKNESS, + } + const minX = -len / 2 + capLeftLen + outer.halfX + const maxX = len / 2 - capRightLen - outer.halfX + if (maxX <= minX) continue + const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0)) + placements.push({ x, z, shape, inner, outer }) + } + return placements +} + +/** Cylinder (round) or box (rect) sized to `dims`, height `h` along Y. */ +function outletSolid(dims: OutletDims, h: number): THREE.BufferGeometry { + if (dims.shape === 'round') { + return new THREE.CylinderGeometry( + dims.halfX, + dims.halfX, + h, + OUTLET_RADIAL_SEGMENTS, + ).toNonIndexed() + } + return new THREE.BoxGeometry(2 * dims.halfX, h, 2 * dims.halfZ).toNonIndexed() +} + +/** + * Solid collar at the OUTER cross-section; the CSG drill hollows out the + * bore and leaves a tube wall. Top sits flush with the gutter floor + * (Y = −size). Flattened to match the ExtrudeGeometries. + */ +function buildOutletStub(p: OutletPlacement, size: number): THREE.BufferGeometry { + const stub = outletSolid(p.outer, OUTLET_STUB_LENGTH) + stub.translate(p.x, -size - OUTLET_STUB_LENGTH / 2, p.z) + return stub +} + +/** + * Flared funnel lip just below the trough floor — wide at the floor, + * tapering to the collar below (round → a cone; rect → a stepped wider + * lip). Sits in the bore drill's span so it gets cut open too. + */ +function buildOutletFunnel(p: OutletPlacement, size: number): THREE.BufferGeometry { + const centerY = -size - OUTLET_FLARE_HEIGHT / 2 + let funnel: THREE.BufferGeometry + if (p.shape === 'round') { + funnel = new THREE.CylinderGeometry( + p.outer.halfX * OUTLET_FLARE_SCALE, + p.outer.halfX, + OUTLET_FLARE_HEIGHT, + OUTLET_RADIAL_SEGMENTS, + ).toNonIndexed() + } else { + funnel = new THREE.BoxGeometry( + 2 * p.outer.halfX * OUTLET_FLARE_SCALE, + OUTLET_FLARE_HEIGHT, + 2 * p.outer.halfZ * OUTLET_FLARE_SCALE, + ).toNonIndexed() + } + funnel.translate(p.x, centerY, p.z) + return funnel +} + +/** + * Bore drill — spans from slightly above the trough floor to slightly + * below the collar's bottom; the overshoots keep CSG cut planes from + * sitting coplanar with merged-geometry faces. + */ +function buildOutletDrill(p: OutletPlacement, size: number): THREE.BufferGeometry { + const top = -size + OUTLET_DRILL_OVERSHOOT + const bottom = -size - OUTLET_STUB_LENGTH - OUTLET_DRILL_OVERSHOOT + const height = top - bottom + const centerY = (top + bottom) / 2 + + const drill = outletSolid(p.inner, height) + drill.translate(p.x, centerY, p.z) + return drill +} diff --git a/packages/nodes/src/gutter/index.ts b/packages/nodes/src/gutter/index.ts new file mode 100644 index 000000000..cf870cf3d --- /dev/null +++ b/packages/nodes/src/gutter/index.ts @@ -0,0 +1,3 @@ +export { gutterDefinition } from './definition' +export { buildGutterGeometry } from './geometry' +export { GutterNode } from './schema' diff --git a/packages/nodes/src/gutter/length-snap.ts b/packages/nodes/src/gutter/length-snap.ts new file mode 100644 index 000000000..95177a8d7 --- /dev/null +++ b/packages/nodes/src/gutter/length-snap.ts @@ -0,0 +1,217 @@ +import type { AnyNodeId, GutterNode, RoofSegmentNode, SceneApi } from '@pascal-app/core' + +/** + * Length-handle snap. When the user drags a gutter's ±X length handle + * and the proposed endpoint lands within `SNAP_RADIUS` of the geometric + * CORNER it would form with another gutter — the intersection of their + * two length-axis lines — the dragged gutter's length is pulled so its + * end lands exactly on that corner. The corner-mitre detector's match + * window then fires reliably without a pixel-perfect drag. + * + * ONLY the dragged gutter moves. The corner is the axis crossing — a + * fixed point in plan, independent of where the other gutter currently + * ends — so each gutter snaps onto the SAME shared corner on its own as + * it's dragged in, and the L meets there without ever reaching over to + * reposition a gutter the user placed deliberately. (An earlier version + * adjusted both gutters at once; that yanked an already-placed gutter + * whenever its corner-mate was dragged, which is the bug this avoids.) + * + * Cross-segment: the search covers every gutter under the SAME ROOF + * (not just the dragged gutter's segment-mates), and all the geometry + * runs in the shared ROOF frame — each gutter's endpoints are lifted + * out of its own segment-local frame by that segment's position + + * Y-rotation. So an L-shaped plan whose two eaves live on different + * roof segments snaps + mitres at the corner where the segments meet, + * exactly like a same-segment hip corner (which is the degenerate case + * where both gutters share one segment transform). + * + * Pure: no React, no THREE. Reads through SceneApi; returns the snapped + * length for the caller to apply. + */ + +// 10 cm catch radius — wide enough that the user doesn't need pixel- +// perfect dragging, narrow enough that unrelated gutters on the +// opposite eave don't accidentally bind. +const SNAP_RADIUS = 0.1 +const SNAP_RADIUS_SQ = SNAP_RADIUS * SNAP_RADIUS + +// Cross product below this counts as parallel axes — no intersection, +// fall back to snapping A onto B's current endpoint without modifying B. +const AXIS_PARALLEL_EPSILON = 1e-3 + +// How far a corner-mate's own nearer endpoint may sit from the corner +// and still bind the dragged gutter to it. The corner is the crossing +// of the two axis LINES, which can lie well beyond where a gutter ends — +// at an inner/concave corner the mate's end is a full eave overhang +// short of the crossing, so the bound has to clear a generous overhang. +// It also stops a far perpendicular gutter, whose infinite axis happens +// to cross near the dragged end, from binding by coincidence: a real +// corner-mate is within reach, an unrelated run is metres away. +const CORNER_MATE_REACH = 1.5 +const CORNER_MATE_REACH_SQ = CORNER_MATE_REACH * CORNER_MATE_REACH + +export type GutterLengthSnap = { + /** Length to apply to the dragged gutter. */ + length: number +} + +/** + * @param initial gutter at drag start (rotation, length, position) + * @param proposedLength length the linear-resize pipeline computed + * @param sign +1 for the gutter-local +X end being dragged, −1 for −X + * @param anchorX,anchorZ the held-fixed endpoint (opposite of `sign`) + * @param armX,armZ gutter +X direction in segment frame (cos r, −sin r) + * @param minLength floor — typically the descriptor's `min` value + * @param sceneApi scene access for corner-mate lookup + */ +export function snapLengthToCorner( + initial: GutterNode, + proposedLength: number, + sign: 1 | -1, + anchorX: number, + anchorZ: number, + armX: number, + armZ: number, + minLength: number, + sceneApi: SceneApi, +): GutterLengthSnap { + const segmentId = initial.roofSegmentId as AnyNodeId | undefined + if (!segmentId) return { length: proposedLength } + const seg = sceneApi.get(segmentId) + if (!seg) return { length: proposedLength } + + // Everything runs in the ROOF frame so gutters on different segments + // can meet. The dragged gutter's anchor/arm come in segment-local + // (the caller computes them from `initial`); lift them into the roof + // frame with the dragged segment's transform. + const selfTf = segmentTransform(seg) + const anchorR = applyTf(selfTf, anchorX, anchorZ) + const armR = applyTfDir(selfTf, armX, armZ) + const aAnchorX = anchorR.x + const aAnchorZ = anchorR.z + const aArmX = armR.x + const aArmZ = armR.z + + const proposedEndX = aAnchorX + sign * proposedLength * aArmX + const proposedEndZ = aAnchorZ + sign * proposedLength * aArmZ + + // Candidate gutters: every gutter under the SAME ROOF, each carrying + // its own segment's roof-frame transform. + type Cand = { gutter: GutterNode; tf: SegmentTransform } + const candidates: Cand[] = [] + const roofId = seg.parentId as AnyNodeId | undefined + const roof = roofId ? sceneApi.get(roofId) : undefined + const roofChildren = (roof as { children?: readonly string[] } | undefined)?.children + for (const sid of roofChildren ?? []) { + const s = sceneApi.get(sid as AnyNodeId) + if (!s || s.type !== 'roof-segment') continue + const tf = segmentTransform(s) + for (const gid of s.children ?? []) { + const g = sceneApi.get(gid as AnyNodeId) + if (g?.type === 'gutter' && g.id !== initial.id) { + candidates.push({ gutter: g as GutterNode, tf }) + } + } + } + + // Find the corner-mate whose CORNER with the dragged gutter lands + // closest to the proposed dragged endpoint. The corner is the + // intersection of the two length-axis LINES (roof frame) — NOT the + // proximity of the two endpoints. That distinction is what unlocks + // inner/concave corners: there the two eave drip-lines meet out in the + // notch, a full overhang away from where either gutter naturally ends, + // so the old endpoint-to-endpoint catch never fired. Keying off the + // axis crossing treats convex and concave identically. Parallel axes + // (a straight collinear run) have no crossing, so there we fall back to + // the mate's nearer endpoint (flush join). Only the dragged gutter's + // own length is snapped to the corner — the mate is never moved. + let bestTargetX = 0 + let bestTargetZ = 0 + let bestDistSq = SNAP_RADIUS_SQ + let found = false + + for (const { gutter: mateG, tf } of candidates) { + const mateRot = mateG.rotation ?? 0 + const arm = applyTfDir(tf, Math.cos(mateRot), -Math.sin(mateRot)) + const mateHalf = mateG.length / 2 + const center = applyTf(tf, mateG.position[0], mateG.position[2]) + const plusX = center.x + arm.x * mateHalf + const plusZ = center.z + arm.z * mateHalf + const minusX = center.x - arm.x * mateHalf + const minusZ = center.z - arm.z * mateHalf + + // Corner target T: axis intersection when the runs cross, else the + // mate's endpoint nearest the dragged end (collinear extension). + const crossDirs = aArmX * arm.z - aArmZ * arm.x + let targetX: number + let targetZ: number + if (Math.abs(crossDirs) < AXIS_PARALLEL_EPSILON) { + const dPlus = (plusX - proposedEndX) ** 2 + (plusZ - proposedEndZ) ** 2 + const dMinus = (minusX - proposedEndX) ** 2 + (minusZ - proposedEndZ) ** 2 + if (dPlus <= dMinus) { + targetX = plusX + targetZ = plusZ + } else { + targetX = minusX + targetZ = minusZ + } + } else { + const dx = center.x - aAnchorX + const dz = center.z - aAnchorZ + const t = (dx * arm.z - dz * arm.x) / crossDirs + targetX = aAnchorX + t * aArmX + targetZ = aAnchorZ + t * aArmZ + } + + // Reject a mate whose own ends are nowhere near the crossing — its + // infinite axis lines up by coincidence, it's not a real corner-mate. + const dPlusT = (plusX - targetX) ** 2 + (plusZ - targetZ) ** 2 + const dMinusT = (minusX - targetX) ** 2 + (minusZ - targetZ) ** 2 + if (Math.min(dPlusT, dMinusT) > CORNER_MATE_REACH_SQ) continue + + const score = (targetX - proposedEndX) ** 2 + (targetZ - proposedEndZ) ** 2 + if (score < bestDistSq) { + bestDistSq = score + bestTargetX = targetX + bestTargetZ = targetZ + found = true + } + } + + if (!found) return { length: proposedLength } + + // Snap the dragged gutter's own end onto the corner: project + // (corner − anchor) onto its roof-frame axis. Length is frame-invariant + // (a scalar along the run), so the projection is the same in roof or + // segment frame — no need to map back. The mate is left untouched. + const projected = sign * ((bestTargetX - aAnchorX) * aArmX + (bestTargetZ - aAnchorZ) * aArmZ) + return { length: Math.max(minLength, projected) } +} + +// ─── Segment-frame ↔ roof-frame transform ──────────────────────────── +// +// A segment places its children at `seg.position` rotated by +// `seg.rotation` about +Y. THREE's rotation-y convention: a point +// (x, z) maps to (x·cos + z·sin, −x·sin + z·cos). These helpers lift a +// gutter's segment-local X/Z into the shared roof frame and back so two +// gutters on different segments can be compared in one frame. + +type SegmentTransform = { x: number; z: number; cos: number; sin: number } + +function segmentTransform(seg: Pick): SegmentTransform { + const r = seg.rotation ?? 0 + return { + x: seg.position?.[0] ?? 0, + z: seg.position?.[2] ?? 0, + cos: Math.cos(r), + sin: Math.sin(r), + } +} + +function applyTf(tf: SegmentTransform, x: number, z: number): { x: number; z: number } { + return { x: tf.x + (x * tf.cos + z * tf.sin), z: tf.z + (-x * tf.sin + z * tf.cos) } +} + +function applyTfDir(tf: SegmentTransform, x: number, z: number): { x: number; z: number } { + return { x: x * tf.cos + z * tf.sin, z: -x * tf.sin + z * tf.cos } +} diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx new file mode 100644 index 000000000..a2813fc4c --- /dev/null +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -0,0 +1,225 @@ +'use client' + +import { + type AnyNodeId, + emitter, + type GutterNode, + type RoofEvent, + type RoofNode, + type RoofSegmentNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { useCallback, useEffect, useState } from 'react' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { type EaveSnap, resolveEaveSnap } from './eave-snap' +import GutterPreview from './preview' + +type PreviewTarget = { + roof: { position: [number, number, number]; rotation: number } + segment: { position: [number, number, number]; rotation: number } + snap: EaveSnap +} + +/** + * Gutter move tool. Mirrors the ridge-vent move flow — ghost follows + * the cursor over any roof segment, click commits the new position + + * parent segment in one undoable step. The eave-snap math from the + * placement tool runs again on the new segment so the gutter lands on + * the correct side of the new ridge. + * + * On commit the gutter rotation may flip from 0 ↔ π if the user moves + * it from the front eave to the back eave (or vice versa). The + * pre-drag rotation is restored on cancel. + * + * Ghost transform: mirrors the GutterRenderer chain (roof → segment → + * snap), so the cursor preview lands at the exact world coords the + * commit will store. GutterPreview applies no internal rotation, so + * the gutter's CURRENT `rotation` doesn't bleed into the new snap. + */ +export default function MoveGutterTool({ node }: { node: GutterNode }) { + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + const [target, setTarget] = useState(null) + + useEffect(() => { + useScene.temporal.getState().pause() + + const original = { + position: [...node.position] as [number, number, number], + rotation: node.rotation ?? 0, + roofSegmentId: node.roofSegmentId, + parentId: node.parentId, + metadata: node.metadata, + } + const meta = + typeof node.metadata === 'object' && node.metadata !== null + ? (node.metadata as Record) + : {} + const isNew = !!meta.isNew + + const gutterObj = sceneRegistry.nodes.get(node.id) + if (gutterObj) gutterObj.visible = false + + let lastSnap: [number, number] | null = null + + const updatePreview = (event: RoofEvent) => { + const roof = event.node as RoofNode + const hit = resolveRoofSegmentHit( + roof, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return + + // Same snap math as the placement tool — picking-up and putting- + // down round-trip identically. roofType-aware: hip/flat picks + // ±X or ±Z based on which slope the cursor is on; shed always + // snaps to its low (+Z) eave; gable / gambrel / mansard / dutch + // stay on ±Z. + const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + + const sx = Math.round(snap.eaveX * 20) / 20 + const sz = Math.round(snap.eaveZ * 20) / 20 + if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { + triggerSFX('sfx:grid-snap') + lastSnap = [sx, sz] + } + + setTarget({ + roof: { + position: (roof.position ?? [0, 0, 0]) as [number, number, number], + rotation: roof.rotation ?? 0, + }, + segment: { + position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number], + rotation: hit.segment.rotation ?? 0, + }, + snap, + }) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return + const targetSegmentId = hit.segment.id as AnyNodeId + const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + const st = useScene.getState() + + const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined + if (prevSegmentId && prevSegmentId !== targetSegmentId) { + const oldSeg = st.nodes[prevSegmentId] as RoofSegmentNode | undefined + if (oldSeg) { + st.updateNode(prevSegmentId, { + children: (oldSeg.children ?? []).filter((id) => id !== node.id), + }) + } + const newSeg = st.nodes[targetSegmentId] as RoofSegmentNode | undefined + if (newSeg && !(newSeg.children ?? []).includes(node.id)) { + st.updateNode(targetSegmentId, { + children: [...(newSeg.children ?? []), node.id], + }) + } + st.dirtyNodes.add(prevSegmentId) + } + + useScene.temporal.getState().resume() + st.updateNode(node.id as AnyNodeId, { + roofSegmentId: targetSegmentId, + parentId: targetSegmentId, + position: [snap.eaveX, snap.eaveY, snap.eaveZ], + rotation: snap.rotation, + visible: true, + metadata: {}, + }) + useScene.temporal.getState().pause() + + st.dirtyNodes.add(targetSegmentId) + st.dirtyNodes.add(node.id as AnyNodeId) + + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + + triggerSFX('sfx:item-place') + exitMoveMode() + event.stopPropagation() + } + + const onCancel = () => { + if (isNew) { + const parentId = original.roofSegmentId as AnyNodeId | undefined + if (parentId) { + const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined + if (parent) { + useScene.getState().updateNode(parentId, { + children: (parent.children ?? []).filter((id) => id !== node.id), + }) + } + } + useScene.getState().deleteNode(node.id as AnyNodeId) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + return + } + + useScene.getState().updateNode(node.id as AnyNodeId, { + position: original.position, + rotation: original.rotation, + roofSegmentId: original.roofSegmentId as AnyNodeId | undefined, + parentId: original.parentId as AnyNodeId | undefined, + metadata: original.metadata, + }) + if (original.roofSegmentId) { + useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId) + } + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('roof:move', updatePreview) + emitter.on('roof:enter', updatePreview) + emitter.on('roof:click', onRoofClick) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('roof:move', updatePreview) + emitter.off('roof:enter', updatePreview) + emitter.off('roof:click', onRoofClick) + emitter.off('tool:cancel', onCancel) + + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + useScene.temporal.getState().resume() + } + }, [exitMoveMode, node]) + + if (!target) return null + + return ( + + + + + + + + ) +} diff --git a/packages/nodes/src/gutter/outlet-lookup.ts b/packages/nodes/src/gutter/outlet-lookup.ts new file mode 100644 index 000000000..e2ea72437 --- /dev/null +++ b/packages/nodes/src/gutter/outlet-lookup.ts @@ -0,0 +1,107 @@ +import type { GutterNode, GutterOutlet } from '@pascal-app/core' +import { + OUTLET_WALL_THICKNESS, + type OutletShape, + outletDims, + outletShapeForProfile, + profileFloorMidZ, +} from './profile-geometry' + +/** + * Outlet position lookup — used by the downspout (renderer / tool / + * routing) to mount a pipe at one of the gutter's outlets without + * walking the gutter's geometry pipeline. + * + * Returns the outlet's center in GUTTER-MESH-LOCAL frame (i.e. after + * the gutter's own `position` + `rotation` have already been applied + * by the renderer chain): X is along the gutter length, Y is the + * gutter's vertical extent (−size, the trough floor), Z is outward + * (the profile-dependent floor midpoint). + * + * The clamp mirrors the geometry's `resolveOutletPlacements` so the + * lookup and the drilled hole agree on X. Ignores mitres — when a + * gutter end is mitred its cap collapses, which shifts the clamp bound + * by ≤ 6 mm; the drift is below what reads visually, and the gutter's + * own CSG drill still cuts in the exact spot since it sees the full + * mitre context. + */ + +export type GutterOutletPlacement = { + /** Gutter-mesh-local X — along the length axis, signed from center. */ + x: number + /** Gutter-mesh-local Y — the trough floor at −size. */ + y: number + /** Gutter-mesh-local Z — profile-dependent floor midpoint. */ + z: number + /** Nominal bore radius (= halfX); `bore * 2` is the outlet diameter. */ + bore: number + /** Outlet cross-section — round on half-round, rect on k-style / box. */ + shape: OutletShape + /** Bore half-extent along the gutter length (X) — the pipe nests just inside this. */ + innerHalfX: number + /** Bore half-extent outward (Z) — the pipe nests just inside this. */ + innerHalfZ: number +} + +function placeOutlet( + gutter: GutterNode, + outlet: GutterOutlet, + len: number, + size: number, + t: number, +): GutterOutletPlacement | null { + const shape = outletShapeForProfile(gutter.profile) + const inner = outletDims(shape, outlet.diameter ?? 0.07) + const outerHalfX = inner.halfX + OUTLET_WALL_THICKNESS + + // Default-cap reservation — no mitre awareness here; see header note. + const capLeftLen = (gutter.endCapLeft ?? true) ? t : 0 + const capRightLen = (gutter.endCapRight ?? true) ? t : 0 + + const minX = -len / 2 + capLeftLen + outerHalfX + const maxX = len / 2 - capRightLen - outerHalfX + if (maxX <= minX) return null + const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0)) + + return { + x, + y: -size, + z: profileFloorMidZ(gutter.profile ?? 'k-style', size), + bore: inner.halfX, + shape, + innerHalfX: inner.halfX, + innerHalfZ: inner.halfZ, + } +} + +function gutterDims(gutter: GutterNode): { len: number; size: number; t: number } { + const len = Math.max(0.05, gutter.length) + const size = Math.max(0.04, gutter.size) + const t = Math.min(Math.max(0.001, gutter.thickness), size * 0.4) + return { len, size, t } +} + +/** Placement of the gutter's outlet with the given id, or null if absent / doesn't fit. */ +export function resolveGutterOutletById( + gutter: GutterNode, + outletId: string | undefined, +): GutterOutletPlacement | null { + if (!outletId) return null + const outlet = (gutter.outlets ?? []).find((o) => o.id === outletId) + if (!outlet) return null + const { len, size, t } = gutterDims(gutter) + return placeOutlet(gutter, outlet, len, size, t) +} + +/** Placements for every fitting outlet, tagged with its id. */ +export function resolveGutterOutlets( + gutter: GutterNode, +): Array { + const { len, size, t } = gutterDims(gutter) + const out: Array = [] + for (const outlet of gutter.outlets ?? []) { + const p = placeOutlet(gutter, outlet, len, size, t) + if (p) out.push({ ...p, id: outlet.id }) + } + return out +} diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts new file mode 100644 index 000000000..6ecc6cdeb --- /dev/null +++ b/packages/nodes/src/gutter/parametrics.ts @@ -0,0 +1,65 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { GutterNode } from './schema' + +export const gutterParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Profile', + fields: [ + { + key: 'profile', + kind: 'enum', + options: ['k-style', 'half-round', 'box'], + display: 'segmented', + }, + ], + }, + { + label: 'Dimensions', + fields: [ + { key: 'length', kind: 'number', unit: 'm', min: 0.2, max: 12, step: 0.05 }, + { key: 'size', kind: 'number', unit: 'm', min: 0.05, max: 0.3, step: 0.005 }, + { + key: 'thickness', + kind: 'number', + unit: 'm', + min: 0.001, + max: 0.02, + step: 0.001, + }, + ], + }, + { + label: 'End caps', + fields: [ + { key: 'endCapLeft', kind: 'boolean' }, + { key: 'endCapRight', kind: 'boolean' }, + ], + }, + { + label: 'Hangers', + fields: [ + { + key: 'hangerStyle', + kind: 'enum', + options: ['strap', 'none'], + display: 'segmented', + }, + { + key: 'hangerSpacing', + kind: 'number', + unit: 'm', + min: 0.2, + max: 2.0, + step: 0.05, + visibleIf: (n) => (n.hangerStyle ?? 'strap') !== 'none', + }, + ], + }, + ], + // Lazy-loaded section that lists every downspout attached to this + // gutter and offers an Add button at the bottom. Outlets are created + // and removed through this panel (and the downspout placement tool) — + // one outlet per downspout — so there's no separate outlet field group. + trailingSection: () => import('./downspouts-panel'), +} diff --git a/packages/nodes/src/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx new file mode 100644 index 000000000..60496d504 --- /dev/null +++ b/packages/nodes/src/gutter/preview.tsx @@ -0,0 +1,82 @@ +'use client' + +import { useEffect, useMemo } from 'react' +import * as THREE from 'three' +import { buildGutterGeometry } from './geometry' +import type { GutterNode } from './schema' + +/** + * Translucent ghost of a gutter — built from the same `buildGutterGeometry` + * the renderer commits, so the shape on screen during placement is the + * shape that lands on click. + * + * No internal transform wrapper. Callers (placement tool + move tool) + * mirror the GutterRenderer's transform chain around this component + * (roof → segment → snap), so the ghost shares one bulletproof chain + * with the committed mesh instead of a flattened-yaw shortcut that can + * drift in edge cases. + * + * FrontSide matches the renderer; DoubleSide would render the inside + * of the trough walls and visually thicken the ghost relative to the + * placed gutter. + */ +const GutterPreview = ({ node }: { node: GutterNode }) => { + const geometry = useMemo( + () => buildGutterGeometry(node), + [ + node.length, + node.size, + node.thickness, + node.profile, + node.endCapLeft, + node.endCapRight, + node.hangerStyle, + node.hangerSpacing, + JSON.stringify(node.outlets), + ], + ) + + const material = useMemo( + () => + new THREE.MeshStandardMaterial({ + color: 0xff_ff_ff, + emissive: 0xff_ff_ff, + emissiveIntensity: 0.12, + roughness: 0.7, + metalness: 0.2, + transparent: true, + opacity: 0.55, + depthWrite: false, + side: THREE.FrontSide, + }), + [], + ) + + const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) + + useEffect( + () => () => { + geometry.dispose() + edgesGeometry.dispose() + material.dispose() + }, + [geometry, edgesGeometry, material], + ) + + return ( + <> + {}} + /> + + + + + ) +} + +export default GutterPreview diff --git a/packages/nodes/src/gutter/profile-geometry.ts b/packages/nodes/src/gutter/profile-geometry.ts new file mode 100644 index 000000000..f2a81b567 --- /dev/null +++ b/packages/nodes/src/gutter/profile-geometry.ts @@ -0,0 +1,73 @@ +import type { GutterNode } from '@pascal-app/core' + +/** + * Shared outlet/profile geometry constants + math used by the gutter + * mesh builder, the outlet lookup the downspout mounts against, and the + * downspout's own routing. Kept in one place so the trough-floor probe + * and the collar dimensions can't drift between the three call sites + * (they did before this file — `profileFloorMidZ` was copied verbatim + * into both `geometry.ts` and `outlet-lookup.ts`). + */ + +// Wall thickness of the drop-tube collar — 3 mm matches typical +// residential gauge. After the CSG drill the stub becomes a tube with +// outer radius = bore + wall and inner radius = bore. +export const OUTLET_WALL_THICKNESS = 0.003 + +// Collar length — how far the drop-tube stub hangs below the trough +// floor. 6 cm reads as "drop outlet" without poking conspicuously far +// below the eave; the downspout slip-fits up into this collar. +export const OUTLET_STUB_LENGTH = 0.06 + +/** + * Z (outward) coordinate of the trough floor's midpoint per profile — + * where a drop outlet drills through. k-style bottom is `wBot = 0.8 · + * size` wide so its midpoint sits at `0.4 · size`; box bottom is `size` + * wide → `size / 2`; half-round's lowest point is the centre of the + * semicircle at Z = r = size. + */ +export function profileFloorMidZ(profile: GutterNode['profile'], size: number): number { + if (profile === 'half-round') return size + if (profile === 'box') return size / 2 + return size * 0.4 +} + +// ─── Outlet cross-section shape ────────────────────────────────────── + +export type OutletShape = 'round' | 'rect' + +/** + * Which cross-section a gutter's drop outlet (and the downspout that + * plugs into it) takes. Half-round gutters use a round leader; the + * flat-bottomed profiles (k-style, box) use a rectangular one — matching + * real residential hardware (round leaders on half-round, 2×3 / 3×4 + * rectangular leaders on k-style / commercial box). + */ +export function outletShapeForProfile(profile: GutterNode['profile']): OutletShape { + return (profile ?? 'k-style') === 'half-round' ? 'round' : 'rect' +} + +// Outward (Z) depth of a rectangular outlet as a fraction of its +// along-length (X) width — a 2×3 leader is ~0.66; 0.7 reads cleanly and +// still fits inside the k-style trough floor. +export const RECT_OUTLET_DEPTH_RATIO = 0.7 + +export type OutletDims = { + shape: OutletShape + /** Half-extent along the gutter length (X). Round: = radius. */ + halfX: number + /** Half-extent outward (Z). Round: = radius. */ + halfZ: number +} + +/** + * Cross-section half-extents for a `nominalDiameter`-sized outlet of the + * given shape. Round → a circle of that diameter (halfX = halfZ = + * radius); rect → that diameter wide along the run, `RECT_OUTLET_DEPTH_ + * RATIO` as deep outward. + */ +export function outletDims(shape: OutletShape, nominalDiameter: number): OutletDims { + const half = Math.max(0.01, nominalDiameter / 2) + if (shape === 'round') return { shape, halfX: half, halfZ: half } + return { shape, halfX: half, halfZ: half * RECT_OUTLET_DEPTH_RATIO } +} diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx new file mode 100644 index 000000000..76827e528 --- /dev/null +++ b/packages/nodes/src/gutter/renderer.tsx @@ -0,0 +1,244 @@ +'use client' + +import { + type AnyNodeId, + type GutterNode, + type RoofSegmentNode, + useLiveNodeOverrides, + useRegistry, + useScene, +} from '@pascal-app/core' +import { + type ColorPreset, + createMaterial, + createMaterialFromPresetRef, + createSurfaceRoleMaterial, + useNodeEvents, + useViewer, +} from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' +import { useShallow } from 'zustand/react/shallow' +import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner-mitre' +import { computeSharedEaveY } from './eave-align' +import { computeEaveY } from './eave-snap' +import { buildGutterGeometry } from './geometry' + +const defaultMaterial = new THREE.MeshStandardMaterial({ + color: 0xff_ff_ff, + roughness: 0.7, + metalness: 0.25, +}) + +/** + * Gutter renderer. Mounts at the eave of the host roof-segment — the + * gutter hangs level off the eave line (gravity wins; no slope tilt). + * Transform stack: + * + * segment.position → segment.rotation (Y) → gutter.position + * → gutter.rotation (Y) → mesh + * + * The registered ref sits on the inner group that applies position + + * rotation, so `NodeArrowHandles` reads gutter-mesh-local coords for + * its chevron placements (same pattern as ridge-vent). + * + * `useLiveNodeOverrides` merges in-flight handle drags onto the store + * node so the mesh tracks the drag without flushing zustand each + * frame. + */ +const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { + const ref = useRef(null!) + useRegistry(storeNode.id, 'gutter', ref) + const handlers = useNodeEvents(storeNode, 'gutter') + const shading = useViewer((s) => s.shading) + const textures = useViewer((s) => s.textures) + const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) + const sceneTheme = useViewer((s) => s.sceneTheme) + + const overrides = useLiveNodeOverrides( + (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, + ) + const node: GutterNode = overrides ? ({ ...storeNode, ...overrides } as GutterNode) : storeNode + + const segment = useScene((state) => + node.roofSegmentId + ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined, + ) + + // While the user is dragging the segment's wall-height / overhang / + // pitch handle, the drag pipeline writes to useLiveNodeOverrides + // instead of the scene store — the scene entry above stays at the + // pre-drag value until pointer-up. Subscribing to the segment's live + // overrides too lets the gutter's `computeEaveY` see the in-flight + // height and slide up/down on every frame of the drag. + const segmentOverrides = useLiveNodeOverrides((s) => + node.roofSegmentId + ? (s.get(node.roofSegmentId as AnyNodeId) as Partial | undefined) + : undefined, + ) + const effectiveSegment: RoofSegmentNode | undefined = segment + ? segmentOverrides + ? ({ ...segment, ...segmentOverrides } as RoofSegmentNode) + : segment + : undefined + + // Corner-mitre inputs: every other gutter under the SAME ROOF, plus + // the roof's segments (for the frame lift). A flat array of node refs + // keeps `useShallow` stable — it only re-renders when one of those + // nodes actually changes. Cross-segment so gutters on different + // segments mitre where their segments meet (the mitres useMemo pairs + // each gutter back to its segment). + const mitreNodes = useScene( + useShallow((state) => { + const segmentId = node.roofSegmentId as AnyNodeId | undefined + const seg = segmentId ? (state.nodes[segmentId] as RoofSegmentNode | undefined) : undefined + const roofId = seg?.parentId as AnyNodeId | undefined + const roof = roofId + ? (state.nodes[roofId] as { children?: readonly string[] } | undefined) + : undefined + if (!roof) return [] as (GutterNode | RoofSegmentNode)[] + const out: (GutterNode | RoofSegmentNode)[] = [] + for (const sid of roof.children ?? []) { + const s = state.nodes[sid as AnyNodeId] + if (s?.type !== 'roof-segment') continue + out.push(s as RoofSegmentNode) + for (const gid of (s as RoofSegmentNode).children ?? []) { + const g = state.nodes[gid as AnyNodeId] + if (g?.type === 'gutter' && g.id !== storeNode.id) out.push(g as GutterNode) + } + } + return out + }), + ) + + // Mitres AND the run's shared eave height come from the same sibling + // walk: both key off which gutters meet at corners. `siblings` carries + // the FULL host segment (the alignment needs wallHeight / overhang / + // pitch / roofType to derive each eave Y), which is a superset of what + // the mitre detector reads — so one list feeds both. + const { mitres, sharedEaveY } = useMemo(() => { + if (!effectiveSegment) return { mitres: NO_MITRES, sharedEaveY: undefined } + const segById = new Map() + for (const n of mitreNodes) { + if (n.type === 'roof-segment') segById.set(n.id, n as RoofSegmentNode) + } + const siblings: GutterWithSegment[] = [] + for (const n of mitreNodes) { + if (n.type !== 'gutter') continue + const g = n as GutterNode + const seg = g.roofSegmentId ? segById.get(g.roofSegmentId) : undefined + if (seg) siblings.push({ gutter: g, segment: seg }) + } + return { + mitres: computeGutterMitres(node, effectiveSegment, siblings), + // `siblings` is typed for the mitre detector (position/rotation), + // but the segment objects are the full RoofSegmentNodes from + // `mitreNodes`, so `computeSharedEaveY` gets the eave-Y inputs it + // needs at runtime. + sharedEaveY: computeSharedEaveY( + node, + effectiveSegment, + siblings as unknown as Parameters[2], + ), + } + }, [ + node.position[0], + node.position[1], + node.position[2], + node.rotation, + node.length, + effectiveSegment?.position?.[0], + effectiveSegment?.position?.[2], + effectiveSegment?.rotation, + effectiveSegment?.wallHeight, + effectiveSegment?.overhang, + effectiveSegment?.pitch, + effectiveSegment?.roofType, + mitreNodes, + ]) + + const geometry = useMemo( + () => buildGutterGeometry(node, mitres), + [ + node.length, + node.size, + node.thickness, + node.profile, + node.endCapLeft, + node.endCapRight, + node.hangerStyle, + node.hangerSpacing, + // Value-compare the outlets array so the CSG drills only rebuild + // when an outlet's offset / diameter changes or one is added. + JSON.stringify(node.outlets), + mitres.left, + mitres.right, + ], + ) + useEffect(() => () => geometry.dispose(), [geometry]) + + // Paint surface: explicit material wins, then preset, then the cached + // default. FrontSide everywhere — DoubleSide on any NodeMaterial inside + // the MRT scenePass compiles a back-face shader variant that doesn't + // declare outputs for every MRT target and poisons the render context + // (see `materials.ts` line 77, and the glazing FrontSide fix in + // 9400f1c5). The U-channel cross-section in `geometry.ts` is traced as + // a single closed polygon around the material — both the exterior shell + // and the interior trough walls are part of the same outward-wound + // boundary, so ExtrudeGeometry produces outward-facing normals on every + // visible face. FrontSide is therefore sufficient and DoubleSide is not + // needed. + const material = useMemo(() => { + if (!textures || (!node.material && !node.materialPreset)) { + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + } + return node.material + ? createMaterial(node.material, shading) + : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) + }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + + if (!segment || !effectiveSegment) return null + + // `node.position` is segment-local — the placement tool resolves the + // eave click via `segObj.worldToLocal`. The renderer mounts under + // `roof-elements` (only the roof transform inherited), so we + // re-apply the segment's roof-local transform here. Mirrors the + // ridge-vent / box-vent pattern; without this gutters on rotated + // segments would land on the first segment instead. + // + // Y is derived live from `effectiveSegment` (scene + drag overrides) + // instead of trusting `node.position[1]` — so changing wallHeight, + // overhang, or pitch on the parent segment moves the gutter on the + // very next frame, including while a segment-height handle is + // mid-drag. Matches the chimney/box-vent pattern of pulling host- + // segment geometry at draw time rather than caching it at placement. + const segPos = segment.position ?? [0, 0, 0] + const segRotY = segment.rotation ?? 0 + // Prefer the connected run's shared height (aligns gutters meeting at + // a corner whose segments derive different eave Ys); fall back to this + // segment's own eave Y for an isolated gutter. + const liveEaveY = sharedEaveY ?? computeEaveY(effectiveSegment) + + return ( + + + + + + ) +} + +export default GutterRenderer diff --git a/packages/nodes/src/gutter/schema.ts b/packages/nodes/src/gutter/schema.ts new file mode 100644 index 000000000..537926e76 --- /dev/null +++ b/packages/nodes/src/gutter/schema.ts @@ -0,0 +1,3 @@ +// Schema lives in core (referenced by the AnyNode union). Re-export so +// every gutter-related import stays inside @pascal-app/nodes/gutter. +export { GutterNode } from '@pascal-app/core' diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx new file mode 100644 index 000000000..f2066e090 --- /dev/null +++ b/packages/nodes/src/gutter/tool.tsx @@ -0,0 +1,161 @@ +'use client' + +import { + type AnyNodeId, + emitter, + GutterNode, + type RoofEvent, + type RoofNode, + useScene, +} from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef, useState } from 'react' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { gutterDefinition } from './definition' +import { type EaveSnap, resolveEaveSnap } from './eave-snap' +import GutterPreview from './preview' + +type PreviewTarget = { + roof: { position: [number, number, number]; rotation: number } + segment: { position: [number, number, number]; rotation: number } + snap: EaveSnap +} + +/** + * Gutter placement tool. Cursor preview snaps to the OUTER eave — the + * drip edge of the roof, NOT the wall line. The eave sits at + * `Z = ±(depth/2 + overhang)` in segment-local frame; the gutter + * mounts against the fascia there, hanging outward from the building. + * + * Which eave: `eave-snap.ts` picks the side closest to the cursor + * (roof-type aware — 4-way for hip/flat, low side only for shed, ±Z + * for the rest) and returns the segment-local snap pose. The same + * snap drives the ghost AND the commit, so picking-up + putting-down + * land at identical world coordinates. + * + * Ghost transform: we mount the GutterPreview under the exact same + * chain the GutterRenderer applies — roof.position + roof.rotation → + * segment.position + segment.rotation → snap.eave + snap.rotation. + * No `worldToBuildingLocal` + `previewYaw`-sum shortcut: that + * collapses three Y rotations into one scalar and converts world + * coords back into building-local, which is mathematically + * equivalent for pure-Y stacks but drifts under any future non-Y + * roof/segment transform. Sharing the renderer's chain means the + * ghost and the placed mesh are guaranteed pixel-identical. + */ +const GutterTool = () => { + const activeBuildingId = useViewer((s) => s.selection.buildingId) + const setSelection = useViewer((s) => s.setSelection) + + const [target, setTarget] = useState(null) + const lastSnapRef = useRef<[number, number] | null>(null) + + const previewNode = useMemo( + () => + GutterNode.parse({ + ...gutterDefinition.defaults(), + name: 'Gutter', + position: [0, 0, 0], + rotation: 0, + }), + [], + ) + + useEffect(() => { + if (!activeBuildingId) return + + const updatePreview = (event: RoofEvent) => { + const roof = event.node as RoofNode + const hit = resolveRoofSegmentHit( + roof, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return + + const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + + // Grid-snap chime fires when the segment-local snap moves to a + // new 5 cm cell along the eave — keeps SFX in lockstep with what + // the commit will actually store. + const sx = Math.round(snap.eaveX * 20) / 20 + const sz = Math.round(snap.eaveZ * 20) / 20 + const prev = lastSnapRef.current + if (!prev || prev[0] !== sx || prev[1] !== sz) { + triggerSFX('sfx:grid-snap') + lastSnapRef.current = [sx, sz] + } + + setTarget({ + roof: { + position: (roof.position ?? [0, 0, 0]) as [number, number, number], + rotation: roof.rotation ?? 0, + }, + segment: { + position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number], + rotation: hit.segment.rotation ?? 0, + }, + snap, + }) + event.stopPropagation() + } + + const onClick = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return + const state = useScene.getState() + const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + + const gutter = GutterNode.parse({ + ...gutterDefinition.defaults(), + name: 'Gutter', + roofSegmentId: hit.segment.id, + // (X, Y, Z) all come from the eave snap — on ±Z eaves X stays + // free along the cursor; on ±X eaves Z stays free instead. + // Rotation orients the gutter's outward axis away from the + // building on whichever side the click landed. + position: [snap.eaveX, snap.eaveY, snap.eaveZ], + rotation: snap.rotation, + }) + state.createNode(gutter, hit.segment.id as AnyNodeId) + state.dirtyNodes.add(hit.segment.id as AnyNodeId) + setSelection({ selectedIds: [gutter.id] }) + triggerSFX('sfx:item-place') + event.stopPropagation() + } + + emitter.on('roof:move', updatePreview) + emitter.on('roof:enter', updatePreview) + emitter.on('roof:click', onClick) + + return () => { + emitter.off('roof:move', updatePreview) + emitter.off('roof:enter', updatePreview) + emitter.off('roof:click', onClick) + } + }, [activeBuildingId, setSelection]) + + if (!activeBuildingId || !target) return null + + return ( + + + + + + + + ) +} + +export default GutterTool diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index d3c7745e1..c9620014c 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -6,9 +6,11 @@ import { chimneyDefinition } from './chimney' import { columnDefinition } from './column' import { doorDefinition } from './door' import { dormerDefinition } from './dormer' +import { downspoutDefinition } from './downspout' import { elevatorDefinition } from './elevator' import { fenceDefinition } from './fence' import { guideDefinition } from './guide' +import { gutterDefinition } from './gutter' import { itemDefinition } from './item' import { levelDefinition } from './level' import { ridgeVentDefinition } from './ridge-vent' @@ -78,6 +80,8 @@ export const builtinPlugin: Plugin = { solarPanelDefinition as unknown as AnyNodeDefinition, skylightDefinition as unknown as AnyNodeDefinition, dormerDefinition as unknown as AnyNodeDefinition, + gutterDefinition as unknown as AnyNodeDefinition, + downspoutDefinition as unknown as AnyNodeDefinition, ], } @@ -88,9 +92,11 @@ export { chimneyDefinition } from './chimney' export { columnDefinition } from './column' export { doorDefinition } from './door' export { dormerDefinition } from './dormer' +export { downspoutDefinition } from './downspout' export { elevatorDefinition } from './elevator' export { fenceDefinition } from './fence' export { guideDefinition } from './guide' +export { gutterDefinition } from './gutter' export { itemDefinition } from './item' export { levelDefinition } from './level' export { ridgeVentDefinition } from './ridge-vent' diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 7ca83f1cf..eb5d18d51 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -1,5 +1,6 @@ import { getScaledDimensions, + type HandleDescriptor, type ItemNode as ItemNodeType, type NodeDefinition, } from '@pascal-app/core' @@ -8,6 +9,134 @@ import { itemFloorplanMoveTarget } from './floorplan-move' import { itemParametrics } from './parametrics' import { ItemNode } from './schema' +// Gizmo sits just past the front-right footprint corner; the guide ring +// traces a circle slightly outside the footprint's bounding circle. +const ROTATE_CORNER_OFFSET = 0.25 +const ROTATE_RING_OFFSET = 0.06 +// How far past the item's front edge the move cross floats. +const MOVE_FRONT_OFFSET = 0.35 + +// Whole-item rotation handle — the two-headed curved arrow. `arc-resize` +// does the angular drag math (raycasts a horizontal plane at the gizmo's +// Y, measures cursor bearing around the item's local origin, returns the +// delta). Holding Shift snaps to 15° increments (handled generically in +// node-arrow-handles for any `shape: 'rotate'`), matching the R/T rotate +// step for placed items. Item rotation is stored as `[x, y, z]`; only the +// Y component turns. +function itemRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + // Negate the cursor delta to match three.js Y-rotation handedness + // (positive Ry takes +X → −Z, while atan2(z, x) increases +X → +Z). + apply: (initial, delta) => { + const [rx, ry, rz] = initial.rotation ?? [0, 0, 0] + return { rotation: [rx, ry - delta, rz] } + }, + placement: { + // Front-right corner of the footprint at mid-height. The registered + // item mesh carries position + rotation only (scale lives on an + // inner mesh), so the scaled footprint maps straight to world. + position: (n) => { + const [w, h, d] = getScaledDimensions(n) + return [w / 2, h / 2, d / 2 + ROTATE_CORNER_OFFSET] + }, + // Fixed −45° tilt leans the curve toward the item's front face. + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: (n) => { + const [w, , d] = getScaledDimensions(n) + return Math.hypot(w / 2, d / 2) + ROTATE_RING_OFFSET + }, + y: (n) => getScaledDimensions(n)[1] / 2, + }, + } +} + +// Free ground-plane move gizmo — the 4-way cross just outside the front edge. +// Press-drag-release slides the item across the floor (live preview, commit +// on release). `snapExtents` aligns the item's edges to the grid the same +// way placement does, swapping width / depth at 90° turns. +function itemMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + // Sit just outside the item's front edge (centred in X, clear of the + // model), low to the floor so it reads as a floor-move grip. + position: (n) => { + const [, , d] = getScaledDimensions(n) + return [0, 0.02, d / 2 + MOVE_FRONT_OFFSET] + }, + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: (n) => { + const [dimX, , dimZ] = getScaledDimensions(n) + const swap = Math.abs(Math.sin(n.rotation[1] ?? 0)) > 0.9 + return [swap ? dimZ : dimX, swap ? dimX : dimZ] + }, + } +} + +// ---- Wall-mounted items (attachTo 'wall' / 'wall-side') ---- +// These live in the wall's local frame: position is [along-wall, up, depth] +// and the item faces along the wall normal (its local +Z). Both gizmos use +// `portal: 'grandparent'` so they render in the wall frame like door / window +// handles, and sit a little off the wall surface (+Z) so they're grabbable. + +// How far off the wall surface (along the normal) the wall gizmos float, and +// how far to either side of the item they sit. +const WALL_GIZMO_LIFT = 0.12 +const WALL_SIDE_OFFSET = 0.3 + +// Spin the item flat against the wall — rotation about its local +Z (the wall +// normal), written to rotation[2]. Sits just past the item's right edge. +function itemWallRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + rotationPlane: 'node-normal', + portal: 'grandparent', + apply: (initial, delta) => { + const [rx, ry, rz] = initial.rotation ?? [0, 0, 0] + return { rotation: [rx, ry, rz + delta] } + }, + placement: { + position: (n) => { + const [w] = getScaledDimensions(n) + return [w / 2 + WALL_SIDE_OFFSET, 0, WALL_GIZMO_LIFT] + }, + }, + } +} + +// Slide the item across the wall face — constrained to the wall plane (along +// the wall + up/down), depth pinned. Sits just past the item's left edge. +function itemWallMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + plane: 'node-normal', + portal: 'grandparent', + placement: { + position: (n) => { + const [w] = getScaledDimensions(n) + return [-(w / 2 + WALL_SIDE_OFFSET), 0, WALL_GIZMO_LIFT] + }, + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: (n) => { + const [dimX, dimY] = getScaledDimensions(n) + // A 90° roll about the normal swaps the item's along-wall + vertical + // footprint. + const swap = Math.abs(Math.sin(n.rotation[2] ?? 0)) > 0.9 + return [swap ? dimY : dimX, swap ? dimX : dimY] + }, + } +} + /** * Item — Phase 5 batch kind. Catalog-backed, GLB-rendered, multi-host. * @@ -99,6 +228,20 @@ export const itemDefinition: NodeDefinition = { parametrics: itemParametrics, + // In-world rotate + move gizmos for selected items. + // - Floor items: world-Y rotate + free floor-plane move cross. + // - Wall items: wall-normal rotate (spin flat against the wall) + a move + // cross constrained to the wall face. Both ride the wall frame. + // - Ceiling items: no gizmos yet (move via the move tool). + handles: (node) => { + const attachTo = (node as ItemNodeType).asset.attachTo + if (attachTo === 'wall' || attachTo === 'wall-side') { + return [itemWallRotateHandle(), itemWallMoveHandle()] + } + if (attachTo) return [] + return [itemRotateHandle(), itemMoveHandle()] + }, + renderer: { kind: 'parametric', module: () => import('./renderer'), diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 0c1149d6a..37abb3607 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -7,6 +7,7 @@ import { type ItemNode, type LightEffect, useInteractive, + useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' @@ -75,10 +76,21 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => { ) } -export const ItemRenderer = ({ node }: { node: ItemNode }) => { +export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => { const ref = useRef(null!) - useRegistry(node.id, node.type, ref) + useRegistry(storeNode.id, storeNode.type, ref) + + // Merge live drag overrides so the mesh transforms in real time during a + // drag (e.g. the in-world rotate gizmo). The handle writes the in-flight + // rotation to `useLiveNodeOverrides` on every pointer move and commits to + // the store only on release — without this merge the item would stay put + // until commit. + const liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id as AnyNodeId)) + const node = useMemo( + () => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as ItemNode) : storeNode), + [storeNode, liveOverrides], + ) return ( diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index a0ff7c559..8fbbe96e1 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -1,7 +1,135 @@ -import { type NodeDefinition, RidgeVentNode as RidgeVentNodeSchema } from '@pascal-app/core' +import { + type HandleDescriptor, + type NodeDefinition, + type RidgeVentNode as RidgeVentNodeType, + RidgeVentNode as RidgeVentNodeSchema, +} from '@pascal-app/core' +import { buildRidgeVentFloorplan } from './floorplan' import { ridgeVentParametrics } from './parametrics' import { RidgeVentNode } from './schema' +// Edge-to-arrow-center offset, matching the box-vent / chimney cadence. +const SIDE_HANDLE_OFFSET = 0.25 +const HEIGHT_HANDLE_OFFSET = 0.15 +const ROTATE_CORNER_OFFSET = 0.25 +// Ridge vents are long but thin — minimums let users shrink without +// collapsing the geometry past the point where the cross-section +// degenerates. Default length is 2.0, default width 0.3, default +// height 0.08, so these are well below the defaults. +const MIN_LENGTH = 0.2 +const MIN_WIDTH = 0.1 +const MIN_HEIGHT = 0.02 + +// Mid-Y of the vent body in vent-mesh-local frame. The base sits at the +// ridge line (Y=0) and the cap peaks at Y=height — so side / rotate +// chevrons place at half-height to read as "beside the body". +function getBodyMidY(n: RidgeVentNodeType): number { + return Math.max(MIN_HEIGHT, n.height) / 2 +} + +// Length arrow on ±X (the ridge direction). Asymmetric: drag one end +// outward and the opposite end stays world-fixed by recentering +// `position` along the vent's own +X arm in segment frame (yaw-aware +// math, matches box-vent / chimney). The ridge vent typically straddles +// a portion of the ridge, so dragging one end is the natural extend / +// shorten gesture. +function ridgeVentLengthHandle(side: 'left' | 'right'): HandleDescriptor { + 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) => { + 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 newCenterX = anchorX + sign * (newLength / 2) * armX + const newCenterZ = anchorZ + sign * (newLength / 2) * armZ + return { + length: newLength, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, + placement: { + position: (n) => [sign * (n.length / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), 0], + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Width arrow on +Z (across the ridge). Symmetric — the vent geometry +// straddles the ridge line (Z=0) so growing the width pushes both edges +// outward by the same amount. A single chevron on +Z reads as "this is +// the width dimension"; keeping it symmetric also stays inside the same +// handle-count budget the chimney / dormer / box-vent already document. +function ridgeVentWidthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_WIDTH, + currentValue: (n) => n.width, + apply: (_n, newValue) => ({ width: newValue }), + placement: { + position: (n) => [0, getBodyMidY(n), n.width / 2 + SIDE_HANDLE_OFFSET], + }, + } +} + +// Height arrow above the cap peak. anchor='min' so the base stays +// pinned to the ridge line (Y=0) and the peak follows the cursor. Plain +// chevron — at default 0.08 m a dashed tracker leader would be visual +// noise rather than a dimension cue. +function ridgeVentHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_HEIGHT, + currentValue: (n) => n.height, + apply: (_n, newValue) => ({ height: Math.max(MIN_HEIGHT, newValue) }), + placement: { + position: (n) => [0, Math.max(n.height, MIN_HEIGHT) + HEIGHT_HANDLE_OFFSET, 0], + }, + } +} + +// Whole-vent rotation gizmo at the +X+Z corner of the body footprint. +// Negate the cursor delta to match three.js Y-rotation handedness. The +// registered group already centres on the vent and applies its yaw, +// so the default rotation pivot is correct. +function ridgeVentRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n) => [ + n.length / 2 + ROTATE_CORNER_OFFSET, + getBodyMidY(n), + n.width / 2 + ROTATE_CORNER_OFFSET, + ], + // Two-headed icon's natural bias is along +X; aim along the + // +X+Z corner bisector so it sits visually flush with the + // rotate gesture's swing direction. + rotationY: () => -Math.PI / 4, + }, + } +} + +const ridgeVentHandles: HandleDescriptor[] = [ + ridgeVentLengthHandle('right'), + ridgeVentLengthHandle('left'), + ridgeVentWidthHandle(), + ridgeVentHeightHandle(), + ridgeVentRotateHandle(), +] + /** * Ridge vent — a ventilation strip running along the ridge of a roof * segment. Parented to a `roof-segment`; position is segment-local. @@ -41,6 +169,8 @@ export const ridgeVentDefinition: NodeDefinition = { }, parametrics: ridgeVentParametrics, + handles: ridgeVentHandles, + floorplan: buildRidgeVentFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/ridge-vent/floorplan.ts b/packages/nodes/src/ridge-vent/floorplan.ts new file mode 100644 index 000000000..793db2217 --- /dev/null +++ b/packages/nodes/src/ridge-vent/floorplan.ts @@ -0,0 +1,152 @@ +import type { + AnyNodeId, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RidgeVentNode, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' + +// Tab pitch for the shingled style — matches SHINGLED_TAB_SIZE in +// geometry.ts so the plan's divider spacing reads like the 3D ridge cap. +const SHINGLED_TAB_SIZE = 0.3 + +/** + * Floor-plan builder for a ridge vent — a ventilation strip running along + * a roof ridge. Seen from above it's a long thin band straddling the + * ridge crest, with a centre crest line, end caps where closed, and tab + * dividers for the shingled style. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → vent), same as the chimney builder. `position` + * is segment-local; the run (`length`) is along local +X and the small + * cross-`width` straddles the ridge along local Z (centred at Z = 0). + * `rotation` is yaw, negated for the floor plan's y-down convention. + */ +export function buildRidgeVentFloorplan( + node: RidgeVentNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + const baseInk = '#475569' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#dbe1e8' + const fillOpacity = showSelectedChrome ? 0.55 : 0.6 + const lineWidth = showSelectedChrome ? 0.03 : 0.02 + + const halfLen = Math.max(node.length, 0.1) / 2 + const halfW = Math.max(node.width, 0.04) / 2 + + const corners: FloorplanPoint[] = [ + toPlan(-halfLen, -halfW), + toPlan(halfLen, -halfW), + toPlan(halfLen, halfW), + toPlan(-halfLen, halfW), + ] + + const children: FloorplanGeometry[] = [ + // Transparent hit-target over the whole strip. + { + kind: 'polygon', + points: corners, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + // Strip fill. + { + kind: 'polygon', + points: corners, + fill, + fillOpacity, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'none', + }, + ] + + const seg = ( + a: readonly [number, number], + b: readonly [number, number], + w: number, + opacity?: number, + ) => { + const pa = toPlan(a[0], a[1]) + const pb = toPlan(b[0], b[1]) + children.push({ + kind: 'line', + x1: pa[0], + y1: pa[1], + x2: pb[0], + y2: pb[1], + stroke, + strokeWidth: w, + strokeLinecap: 'round', + opacity, + pointerEvents: 'none', + }) + } + + // Long edges along the run (always) + end caps (only when closed). + seg([-halfLen, -halfW], [halfLen, -halfW], lineWidth) + seg([-halfLen, halfW], [halfLen, halfW], lineWidth) + if (node.endCaps !== false) { + seg([-halfLen, -halfW], [-halfLen, halfW], lineWidth) + seg([halfLen, -halfW], [halfLen, halfW], lineWidth) + } + + // Ridge crest line down the centre. + seg([-halfLen, 0], [halfLen, 0], lineWidth * 0.8, 0.7) + + // Shingled style: tab dividers across the width at the cap pitch. + if (node.style === 'shingled') { + const total = halfLen * 2 + const count = Math.max(2, Math.round(total / SHINGLED_TAB_SIZE)) + const step = total / count + for (let i = 1; i < count; i++) { + const x = -halfLen + i * step + seg([x, -halfW], [x, halfW], lineWidth * 0.6, 0.5) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index 2cc215d30..f7890d6f8 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -14,7 +14,7 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import RidgeVentPreview from './preview' /** diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 505c4b2c8..f1699ad6b 100644 --- a/packages/nodes/src/ridge-vent/renderer.tsx +++ b/packages/nodes/src/ridge-vent/renderer.tsx @@ -4,6 +4,7 @@ import { type AnyNodeId, type RidgeVentNode, type RoofSegmentNode, + useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' @@ -28,7 +29,6 @@ const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, roughness: 0.85, metalness: 0.1, - side: THREE.DoubleSide, }) /** @@ -45,15 +45,26 @@ const defaultMaterial = new THREE.MeshStandardMaterial({ * family (matte standard / shingled grey / brushed metal) before the * user opens the paint tray. */ -const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => { +const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { const ref = useRef(null!) - useRegistry(node.id, 'ridge-vent', ref) - const handlers = useNodeEvents(node, 'ridge-vent') + useRegistry(storeNode.id, 'ridge-vent', ref) + const handlers = useNodeEvents(storeNode, 'ridge-vent') const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + // Merge live drag overrides on top of the store node so handle drags + // update the mesh in-flight without flushing to zustand on every frame. + // Same pattern as box-vent / chimney / dormer — the override is set by + // `NodeArrowHandles`' drag handler and cleared on commit. + const overrides = useLiveNodeOverrides( + (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, + ) + const node: RidgeVentNode = overrides + ? ({ ...storeNode, ...overrides } as RidgeVentNode) + : storeNode + const segment = useScene((state) => node.roofSegmentId ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) @@ -67,25 +78,20 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => { useEffect(() => () => geometry.dispose(), [geometry]) - // The preset cache returns materials with `side: FrontSide` (that's - // what the preset payload encodes). For a thin extruded ridge cap that - // makes the underside disappear when the camera dips below the eaves - // — so clone the resolved material and force `DoubleSide` locally - // without mutating the shared cache entry. + // Paint surface: FrontSide everywhere — DoubleSide on the role + // material's NodeMaterial poisons the MRT scene pass (see `materials.ts` + // line 77 / glazing fix 9400f1c5). Earlier this path forced DoubleSide + // so the underside of the thin extruded ridge cap stayed visible from + // below; that's now a known visual tradeoff — building the cap as a + // closed solid in `geometry.ts` is the right fix if the underside-view + // becomes noticeable. const material = useMemo(() => { - // Untextured ridge vent (and textures-off mode) takes the themed - // 'roof' role colour. Request DoubleSide directly so the cached role - // material is the right side — no clone/mutation of a shared material. if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme) + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } - const base = node.material + return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - if (base.side === THREE.DoubleSide) return base - const cloned = base.clone() - cloned.side = THREE.DoubleSide - return cloned }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) if (!segment) return null diff --git a/packages/nodes/src/ridge-vent/tool.tsx b/packages/nodes/src/ridge-vent/tool.tsx index 878a7b876..a1bf7d383 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -13,7 +13,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { ridgeVentDefinition } from './definition' import RidgeVentPreview from './preview' diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 673f9cd49..976466bf2 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -35,64 +35,139 @@ function getPeakHeight(n: RoofSegmentNodeType): number { return n.wallHeight + getActiveRoofHeight(n) } -// Width arrow — anchor='center' so dragging the +X side grows the full -// footprint symmetrically (both edges move ±delta). Same idiom as the -// elevator / column / shelf width arrow. -function roofSegmentWidthHandle(): HandleDescriptor { +// 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 { + const sign = side === 'right' ? 1 : -1 return { kind: 'linear-resize', axis: 'x', - anchor: 'center', + // '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, currentValue: (n) => n.width, - apply: (_n, newValue) => ({ width: newValue }), + 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) => [ - n.width / 2 + SIDE_HANDLE_OFFSET, + 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 — symmetric on the +Z side. -function roofSegmentDepthHandle(): HandleDescriptor { +// 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 { + const sign = side === 'front' ? 1 : -1 return { kind: 'linear-resize', axis: 'z', - anchor: 'center', + anchor: side === 'front' ? 'min' : 'max', min: MIN_ROOF_DIM, currentValue: (n) => n.depth, - apply: (_n, newValue) => ({ depth: newValue }), + 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, - n.depth / 2 + SIDE_HANDLE_OFFSET, + 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 arrow — `anchor: 'min'` keeps the base on the floor and -// grows the wall upward. Placed on the -X side at the wall's top edge -// so it doesn't stack on the centered pitch arrow when wallHeight ≈ 0 -// (flat roof / no walls). +// 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 { 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) => [ - -(n.width / 2 + SIDE_HANDLE_OFFSET), - Math.max(n.wallHeight, MIN_WALL_DISPLAY), - 0, - ], + position: (n) => [0, Math.max(n.wallHeight, MIN_WALL_DISPLAY), 0], }, } } @@ -169,8 +244,10 @@ function roofSegmentRotateHandle(): HandleDescriptor { } const roofSegmentHandles: HandleDescriptor[] = [ - roofSegmentWidthHandle(), - roofSegmentDepthHandle(), + roofSegmentWidthHandle('right'), + roofSegmentWidthHandle('left'), + roofSegmentDepthHandle('front'), + roofSegmentDepthHandle('back'), roofSegmentWallHeightHandle(), roofSegmentPitchHandle(), roofSegmentRotateHandle(), @@ -204,6 +281,13 @@ export const roofSegmentDefinition: NodeDefinition = { 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, diff --git a/packages/nodes/src/roof-segment/floorplan.ts b/packages/nodes/src/roof-segment/floorplan.ts index fec976b14..e91bce9cb 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -7,13 +7,16 @@ import type { } from '@pascal-app/core' /** - * Stage C floor-plan builder for roof segment. Renders the segment's - * footprint as a rotated rectangle in world coords (parent roof's - * position + rotation composed with the segment's own). + * Stage C floor-plan builder for roof segment. Renders the segment as a + * proper architectural roof plan: the footprint outline plus the + * ridge / hip / break linework (and a downslope arrow for sheds) that + * makes each roof shape — hip, gable, shed, gambrel, dutch, mansard, + * flat — read distinctly, rather than as a bare rectangle. * - * Inlined from `getRoofSegmentPolygon` / `getRoofSegmentCenter` in - * `floorplan-panel.tsx`. Ridge line not yet rendered — adds a follow-up - * for full visual parity. + * All linework is derived in segment-local space, mirroring the faces the + * 3D builder (`getModuleFaces` in the roof system) generates per type, so + * the plan and the model agree. Everything is composed into world coords + * via the parent roof's position + rotation and the segment's own. */ export function buildRoofSegmentFloorplan( node: RoofSegmentNode, @@ -41,16 +44,21 @@ export function buildRoofSegmentFloorplan( const halfWidth = node.width / 2 const halfDepth = node.depth / 2 + // Map a segment-local point (lx = width axis, lz = depth axis) into + // world plan coords — the same rotation + translation the footprint + // corners use. Shared by the per-type ridge/hip linework below. + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + const corners: Array<[number, number]> = [ [-halfWidth, -halfDepth], [halfWidth, -halfDepth], [halfWidth, halfDepth], [-halfWidth, halfDepth], ] - const points: FloorplanPoint[] = corners.map(([x, y]) => [ - cx + x * cos - y * sin, - cz + x * sin + y * cos, - ]) + const points: FloorplanPoint[] = corners.map(([x, y]) => toPlan(x, y)) const view = ctx.viewState const palette = view?.palette @@ -77,46 +85,34 @@ export function buildRoofSegmentFloorplan( strokeWidth: 0, pointerEvents: 'all', }, - // Visible outline. - { - kind: 'polygon', - points, - fill: showSelectedChrome ? '#fed7aa' : 'none', - fillOpacity: showSelectedChrome ? 0.55 : 0, - stroke, - strokeWidth: showSelectedChrome ? 0.035 : 0.025, - strokeLinejoin: 'miter', - }, ] - // Ridge line — only for pitched segments, not flat roofs. Dashed - // black so it reads as the ridge (axis of the pitch) without - // competing with the perimeter outline. - if (node.roofType !== 'flat') { - const ridgeAxis = - node.roofType === 'gable' || node.roofType === 'gambrel' - ? 'x' - : node.roofType === 'dutch' - ? node.width >= node.depth - ? 'x' - : 'z' - : 'z' - const axisAngle = ridgeAxis === 'x' ? rotation : rotation + Math.PI / 2 - const halfSpan = ridgeAxis === 'x' ? node.width / 2 : node.depth / 2 + // The segment's own rectangle outline + fill render ONLY while it's + // selected / highlighted — that highlights which sub-plane is active + // (including its interior edges shared with neighbours). When unselected + // the eaves come from the parent roof's merged outline + // (`buildRoofFloorplan`), so overlapping segments read as one combined + // shape instead of stacked rectangles. Ridges/hips below always draw. + if (showSelectedChrome) { children.push({ - kind: 'line', - x1: cx - halfSpan * Math.cos(axisAngle), - y1: cz - halfSpan * Math.sin(axisAngle), - x2: cx + halfSpan * Math.cos(axisAngle), - y2: cz + halfSpan * Math.sin(axisAngle), + kind: 'polygon', + points, + fill: '#fed7aa', + fillOpacity: 0.55, stroke, - strokeWidth: 0.02, - strokeDasharray: '0.1 0.08', - strokeLinecap: 'butt', - opacity: 0.85, + strokeWidth: 0.035, + strokeLinejoin: 'miter', }) } + // NOTE: the ridge / hip / break / slope linework is NOT drawn here — the + // parent roof's builder (`buildRoofFloorplan`) draws it for every segment, + // clipped against the merged-roof valleys so a segment's ridge stops at + // the junction instead of running on into a neighbour it overlaps. This + // builder owns only the per-segment interaction chrome below. The shape + // math lives in `getRoofSegmentPlanLinework` (exported for the roof + // builder to consume). + // Selection chrome — orange move-handle dot at the centre, four // perpendicular side resize-arrows (width on X, depth on Z), and a // rotate-arrow at the +X/+Z corner. Sister to the 3D handles in @@ -173,8 +169,149 @@ export function buildRoofSegmentFloorplan( point: [cx + cornerX, cz + cornerZ], angle: Math.atan2(radialZ, radialX), affordance: 'roof-segment-rotate', + pivot: [cx, cz], }) } return { kind: 'group', children } } + +export type PlanPt = readonly [number, number] +export type PlanSeg = readonly [PlanPt, PlanPt] + +/** + * Ridge / hip / break linework for a roof segment in segment-local space + * (lx = width axis, lz = depth axis), mirroring the faces the 3D builder + * (`getModuleFaces`) generates for each roof type. The floor-plan builder + * maps these to world coords. `slope`, when set, is a shed roof's downhill + * fall direction (tail = high eave, head = low eave). + * + * - ridge: peak line(s) where opposite slopes meet + * - hip: diagonal from an eave corner up to a ridge end / peak + * - break: horizontal fold where the slope angle changes (gambrel kink, + * mansard/dutch waist) + * + * Exported so the roof-level builder can reuse it to terminate the valley + * diagonals it draws at merged-roof junctions against the segments' ridges. + */ +export function getRoofSegmentPlanLinework(node: RoofSegmentNode): { + ridges: PlanSeg[] + hips: PlanSeg[] + breaks: PlanSeg[] + slope: { tail: PlanPt; head: PlanPt } | null +} { + const hw = node.width / 2 + const hd = node.depth / 2 + const ridges: PlanSeg[] = [] + const hips: PlanSeg[] = [] + const breaks: PlanSeg[] = [] + let slope: { tail: PlanPt; head: PlanPt } | null = null + + // Eave corners, matching e1..e4 in the 3D `getModuleFaces` builder. + const e1: PlanPt = [-hw, hd] + const e2: PlanPt = [hw, hd] + const e3: PlanPt = [hw, -hd] + const e4: PlanPt = [-hw, -hd] + + // Hip linework shared by `hip` and the collapsed-waist mansard/dutch + // fallbacks: ridge along the longer axis, four hips from the eave + // corners to the nearer ridge end — or a single peak when square. + const pushHip = () => { + if (Math.abs(node.width - node.depth) < 0.01) { + const peak: PlanPt = [0, 0] + hips.push([e1, peak], [e2, peak], [e3, peak], [e4, peak]) + } else if (node.width >= node.depth) { + const r1: PlanPt = [-hw + hd, 0] + const r2: PlanPt = [hw - hd, 0] + ridges.push([r1, r2]) + hips.push([e1, r1], [e4, r1], [e2, r2], [e3, r2]) + } else { + const r1: PlanPt = [0, hd - hw] + const r2: PlanPt = [0, -hd + hw] + ridges.push([r1, r2]) + hips.push([e1, r1], [e2, r1], [e3, r2], [e4, r2]) + } + } + + switch (node.roofType) { + case 'flat': + break + case 'gable': + // Single ridge down the middle along the width axis. + ridges.push([ + [-hw, 0], + [hw, 0], + ]) + break + case 'shed': + // 3D builder slopes from the high eave (lz = -hd) down to lz = +hd. + slope = { tail: [0, -hd * 0.55], head: [0, hd * 0.55] } + break + case 'hip': + pushHip() + break + case 'gambrel': { + // Ridge + two kink lines parallel to it. + const mz = hd * node.gambrelLowerWidthRatio + ridges.push([ + [-hw, 0], + [hw, 0], + ]) + breaks.push( + [ + [-hw, mz], + [hw, mz], + ], + [ + [-hw, -mz], + [hw, -mz], + ], + ) + break + } + case 'mansard': { + // Inner waist rectangle + four corner hips from the eaves to it. + const i = Math.min(node.width, node.depth) * node.mansardSteepWidthRatio + if (hw - i > 0.02 && hd - i > 0.02) { + const w1: PlanPt = [-hw + i, hd - i] + const w2: PlanPt = [hw - i, hd - i] + const w3: PlanPt = [hw - i, -hd + i] + const w4: PlanPt = [-hw + i, -hd + i] + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + } else { + pushHip() + } + break + } + case 'dutch': { + // Hipped lower skirt (eave corners → waist corners) + the gablet + // fold, then a gable-style ridge on top of the waist. + const i = Math.min(node.width, node.depth) * node.dutchHipWidthRatio + if (hw - i > 0.02 && hd - i > 0.02) { + const w1: PlanPt = [-hw + i, hd - i] + const w2: PlanPt = [hw - i, hd - i] + const w3: PlanPt = [hw - i, -hd + i] + const w4: PlanPt = [-hw + i, -hd + i] + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + if (node.width >= node.depth) { + const r1: PlanPt = [-hw + i, 0] + const r2: PlanPt = [hw - i, 0] + ridges.push([r1, r2]) + hips.push([w1, r1], [w4, r1], [w2, r2], [w3, r2]) + } else { + const r1: PlanPt = [0, hd - i] + const r2: PlanPt = [0, -hd + i] + ridges.push([r1, r2]) + hips.push([w1, r1], [w2, r1], [w3, r2], [w4, r2]) + } + } else { + pushHip() + } + break + } + } + + return { ridges, hips, breaks, slope } +} diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index d18eb13a1..8f3663b4e 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -1,11 +1,15 @@ import { type NodeDefinition, RoofNode as RoofNodeSchema } from '@pascal-app/core' +import { buildRoofFloorplan } from './floorplan' import { roofParametrics } from './parametrics' import { RoofNode } from './schema' /** * Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer` * + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` + - * CSG). Inspector / move / floorplan stay legacy until Stage B-E. + * 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; @@ -30,7 +34,16 @@ export const roofDefinition: NodeDefinition = { deletable: true, }, + // 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, + floorplan: buildRoofFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/roof/floorplan.ts b/packages/nodes/src/roof/floorplan.ts new file mode 100644 index 000000000..ca04284ee --- /dev/null +++ b/packages/nodes/src/roof/floorplan.ts @@ -0,0 +1,292 @@ +import type { + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' +import { unionPolygons } from '@pascal-app/viewer' +import { getRoofSegmentPlanLinework } from '../roof-segment/floorplan' + +type Pt = [number, number] +type Seg = [Pt, Pt] + +function signedArea(ring: readonly Pt[]): number { + let a = 0 + const n = ring.length + for (let i = 0; i < n; i++) { + const p = ring[i] as Pt + const q = ring[(i + 1) % n] as Pt + a += p[0] * q[1] - q[0] * p[1] + } + return a / 2 +} + +/** Distance `t >= 0` from `V` along unit dir `(dx,dz)` to where the ray first + * meets segment `A→B`, or null. (Used to terminate valleys at ridges.) */ +function rayHitT( + vx: number, + vz: number, + dx: number, + dz: number, + ax: number, + az: number, + bx: number, + bz: number, +): number | null { + const ex = bx - ax + const ez = bz - az + const denom = dx * ez - dz * ex + if (Math.abs(denom) < 1e-9) return null + const wx = ax - vx + const wz = az - vz + const t = (wx * ez - wz * ex) / denom + const s = (wx * dz - wz * dx) / denom + if (t < 0) return null + if (s < -1e-6 || s > 1 + 1e-6) return null + return t +} + +function pointInPolygon(px: number, pz: number, poly: readonly Pt[]): boolean { + let inside = false + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const pi = poly[i] as Pt + const pj = poly[j] as Pt + if ( + pi[1] > pz !== pj[1] > pz && + px < ((pj[0] - pi[0]) * (pz - pi[1])) / (pj[1] - pi[1]) + pi[0] + ) { + inside = !inside + } + } + return inside +} + +/** Parametric `t` in (0,1) along `p1→p2` where it crosses segment `a→b`, else null. */ +function segCrossT(p1: Pt, p2: Pt, a: Pt, b: Pt): number | null { + const rx = p2[0] - p1[0] + const rz = p2[1] - p1[1] + const ex = b[0] - a[0] + const ez = b[1] - a[1] + const denom = rx * ez - rz * ex + if (Math.abs(denom) < 1e-12) return null + const wx = a[0] - p1[0] + const wz = a[1] - p1[1] + const t = (wx * ez - wz * ex) / denom + const s = (wx * rz - wz * rx) / denom + if (t <= 1e-4 || t >= 1 - 1e-9) return null + if (s < -1e-6 || s > 1 + 1e-6) return null + return t +} + +type SegPlan = { + footprint: Pt[] + ridges: Seg[] + hips: Seg[] + breaks: Seg[] + slope: { tail: Pt; head: Pt } | null +} + +/** A segment's footprint + ridge/hip/break/slope linework, in world plan coords. */ +function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { + const cosRoof = Math.cos(-roof.rotation) + const sinRoof = Math.sin(-roof.rotation) + const segCx = roof.position[0] + seg.position[0] * cosRoof - seg.position[2] * sinRoof + const segCz = roof.position[2] + seg.position[0] * sinRoof + seg.position[2] * cosRoof + const rot = -(roof.rotation + seg.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const tp = (lx: number, lz: number): Pt => [segCx + lx * cos - lz * sin, segCz + lx * sin + lz * cos] + const hw = Math.max(seg.width, 0.01) / 2 + const hd = Math.max(seg.depth, 0.01) / 2 + const lw = getRoofSegmentPlanLinework(seg) + const mapSeg = (s: readonly [readonly [number, number], readonly [number, number]]): Seg => [ + tp(s[0][0], s[0][1]), + tp(s[1][0], s[1][1]), + ] + return { + footprint: [tp(-hw, -hd), tp(hw, -hd), tp(hw, hd), tp(-hw, hd)], + ridges: lw.ridges.map(mapSeg), + hips: lw.hips.map(mapSeg), + breaks: lw.breaks.map(mapSeg), + slope: lw.slope + ? { tail: tp(lw.slope.tail[0], lw.slope.tail[1]), head: tp(lw.slope.head[0], lw.slope.head[1]) } + : null, + } +} + +/** + * Roof-level floor-plan builder. Draws the whole merged-roof plan: the + * unioned silhouette, the valley diagonals at concave junctions, and every + * segment's ridge/hip/break linework — clipped so a line stops at the valley + * where its segment overlaps a neighbour, instead of running on at the + * segment's full length into the cut-away part. + * + * Drawing all the linework here (rather than per-segment) is what lets the + * clip work: the valleys and the neighbouring footprints are all in hand, so + * each line can be trimmed to the actual merged geometry. The segment + * builder keeps only its hit-target / selection chrome. + * + * Composition uses the floor plan's negated-rotation convention + * (segment-local → roof-local → plan). `unionPolygons` returns one ring per + * disjoint group, so non-touching segments each keep their own outline. The + * group is decorative (`pointerEvents: 'none'`) — clicks fall through to the + * segment hit-targets. + */ +export function buildRoofFloorplan( + node: RoofNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segments = ctx.children.filter( + (c): c is RoofSegmentNode => c.type === 'roof-segment', + ) + if (segments.length === 0) return null + + const plans = segments.map((s) => buildSegPlan(node, s)) + const rings = unionPolygons(plans.map((p) => p.footprint)) as Pt[][] + if (rings.length === 0) return null + + // Valleys at concave (reflex) corners of the merged outline. Each runs + // along the interior angle bisector and terminates at the nearest segment + // ridge — the diagonal where two merged slopes meet. + const allRidges: Seg[] = plans.flatMap((p) => p.ridges) + const valleys: Seg[] = [] + for (const ring of rings) { + const n = ring.length + if (n < 3) continue + const orient = signedArea(ring) > 0 ? 1 : -1 + for (let i = 0; i < n; i++) { + const prev = ring[(i - 1 + n) % n] as Pt + const V = ring[i] as Pt + const next = ring[(i + 1) % n] as Pt + const ax = prev[0] - V[0] + const az = prev[1] - V[1] + const bx = next[0] - V[0] + const bz = next[1] - V[1] + if ((ax * bz - az * bx) * orient <= 0) continue // not reflex + const la = Math.hypot(ax, az) || 1 + const lb = Math.hypot(bx, bz) || 1 + let dx = -(ax / la + bx / lb) + let dz = -(az / la + bz / lb) + const dl = Math.hypot(dx, dz) + if (dl < 1e-6) continue + dx /= dl + dz /= dl + let bestT = Number.POSITIVE_INFINITY + for (const [A, B] of allRidges) { + const t = rayHitT(V[0], V[1], dx, dz, A[0], A[1], B[0], B[1]) + if (t !== null && t > 1e-4 && t < bestT) bestT = t + } + if (!Number.isFinite(bestT)) continue + valleys.push([ + [V[0], V[1]], + [V[0] + dx * bestT, V[1] + dz * bestT], + ]) + } + } + + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected ?? false) || (view?.highlighted ?? false) + const ink = showSelectedChrome && palette ? palette.selectedStroke : '#111111' + const eaveWidth = showSelectedChrome ? 0.04 : 0.03 + const ridgeWidth = showSelectedChrome ? 0.05 : 0.038 + const hipWidth = showSelectedChrome ? 0.04 : 0.026 + + const children: FloorplanGeometry[] = [] + const pushLine = (a: Pt, b: Pt, width: number) => { + children.push({ + kind: 'line', + x1: a[0], + y1: a[1], + x2: b[0], + y2: b[1], + stroke: ink, + strokeWidth: width, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + } + + // Merged outline (eaves). + for (const ring of rings) { + if (ring.length < 3) continue + children.push({ + kind: 'polygon', + points: ring.map(([x, z]) => [x, z] as FloorplanPoint), + fill: 'none', + stroke: ink, + strokeWidth: eaveWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } + + // Valley diagonals. + for (const v of valleys) pushLine(v[0], v[1], hipWidth) + + // Per-segment ridge / hip / break linework, clipped to the merged geometry: + // an endpoint that overshoots into another segment is pulled back to the + // valley it crosses (the junction), so a ridge stops at the diagonal. + const footprints = plans.map((p) => p.footprint) + const clipEnd = (pt: Pt, other: Pt, ownIndex: number): Pt => { + let inOther = false + for (let i = 0; i < footprints.length; i++) { + if (i === ownIndex) continue + if (pointInPolygon(pt[0], pt[1], footprints[i] as Pt[])) { + inOther = true + break + } + } + if (!inOther) return pt + let bestT = Number.POSITIVE_INFINITY // nearest valley crossing to the overshoot + for (const v of valleys) { + const t = segCrossT(pt, other, v[0], v[1]) + if (t !== null && t < bestT) bestT = t + } + if (!Number.isFinite(bestT)) return pt // overshoots but no valley to stop at + return [pt[0] + (other[0] - pt[0]) * bestT, pt[1] + (other[1] - pt[1]) * bestT] + } + const clipPush = (line: Seg, width: number, ownIndex: number) => { + const a = clipEnd(line[0], line[1], ownIndex) + const b = clipEnd(line[1], a, ownIndex) + const dx = a[0] - b[0] + const dz = a[1] - b[1] + if (dx * dx + dz * dz < 1e-8) return + pushLine(a, b, width) + } + + plans.forEach((p, idx) => { + for (const s of p.breaks) clipPush(s, hipWidth, idx) + for (const s of p.hips) clipPush(s, hipWidth, idx) + for (const s of p.ridges) clipPush(s, ridgeWidth, idx) + + // Shed downslope arrow (no overshoot to clip). + if (p.slope) { + const { tail, head } = p.slope + const dx = head[0] - tail[0] + const dz = head[1] - tail[1] + const len = Math.hypot(dx, dz) || 1 + const ux = dx / len + const uz = dz / len + const headLen = Math.min(0.22, len * 0.4) + const wing = headLen * 0.6 + pushLine(tail, head, hipWidth) + children.push({ + kind: 'polyline', + points: [ + [head[0] - headLen * ux - wing * uz, head[1] - headLen * uz + wing * ux], + [head[0], head[1]], + [head[0] - headLen * ux + wing * uz, head[1] - headLen * uz - wing * ux], + ], + stroke: ink, + strokeWidth: hipWidth, + strokeLinecap: 'round', + strokeLinejoin: 'round', + pointerEvents: 'none', + }) + } + }) + + return children.length > 0 ? { kind: 'group', children } : null +} diff --git a/packages/nodes/src/roof/index.ts b/packages/nodes/src/roof/index.ts index de387ee09..79202f432 100644 --- a/packages/nodes/src/roof/index.ts +++ b/packages/nodes/src/roof/index.ts @@ -1,2 +1,2 @@ +export { type RoofSegmentHit, resolveRoofSegmentHit } from '../shared/roof-segment-hit' export { roofDefinition } from './definition' -export { type RoofSegmentHit, resolveRoofSegmentHit } from './segment-hit' diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index 9dadc262b..1c1813751 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -6,6 +6,7 @@ import { type BoxVentNode, type ChimneyNode, type DormerNode, + type GutterNode, type RidgeVentNode, type RoofNode, type RoofSegmentNode, @@ -112,6 +113,19 @@ export default function RoofPanel() { }), ) + const gutters = useScene( + useShallow((s) => { + if (segmentIdSet.size === 0) return [] + const out: GutterNode[] = [] + for (const n of Object.values(s.nodes)) { + if (n?.type === 'gutter' && n.roofSegmentId && segmentIdSet.has(n.roofSegmentId)) { + out.push(n as GutterNode) + } + } + return out + }), + ) + // Box vents and ridge vents share the "Vents" UI group — same list, // type shown as the right-side label, and an `Add Vent` button with // a Box/Ridge segmented picker. @@ -207,7 +221,17 @@ export default function RoofPanel() { // Same code path as the top palette — see `tool-manager.tsx:28`'s // `nodeRegistry.get(tool)?.tool` dispatch. const activateTool = useCallback( - (kind: 'box-vent' | 'ridge-vent' | 'chimney' | 'solar-panel' | 'skylight' | 'dormer') => { + ( + kind: + | 'box-vent' + | 'ridge-vent' + | 'chimney' + | 'solar-panel' + | 'skylight' + | 'dormer' + | 'gutter' + | 'downspout', + ) => { triggerSFX('sfx:item-pick') useEditor.getState().setTool(kind) if (useEditor.getState().mode !== 'build') { @@ -441,6 +465,27 @@ export default function RoofPanel() { />
+ +
+ {gutters.map((gutter, i) => ( + + ))} + + } + label="Add Gutter" + onClick={() => activateTool('gutter')} + /> + +
diff --git a/packages/editor/src/components/tools/roof/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx similarity index 96% rename from packages/editor/src/components/tools/roof/move-roof-tool.tsx rename to packages/nodes/src/shared/move-roof-tool.tsx index be05f382a..5b4c2ccc2 100644 --- a/packages/editor/src/components/tools/roof/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -13,15 +13,17 @@ import { useScene, type WallNode, } from '@pascal-app/core' +import { + CursorSphere, + clearRoofDuplicateMetadata, + snapFenceDraftPoint, + triggerSFX, + useEditor, + type WallPlanPoint, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' -import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { snapFenceDraftPoint } from '../fence/fence-drafting' -import { CursorSphere } from '../shared/cursor-sphere' -import type { WallPlanPoint } from '../wall/wall-drafting' export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode @@ -217,7 +219,7 @@ export const MoveRoofTool: React.FC<{ previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) ) { - sfxEmitter.emit('sfx:grid-snap') + triggerSFX('sfx:grid-snap') } previousGridPosRef.current = [gridX, gridZ] @@ -274,7 +276,7 @@ export const MoveRoofTool: React.FC<{ useScene.temporal.getState().pause() - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [movingNode.id] }) useLiveTransforms.getState().clear(movingNode.id) exitMoveMode() @@ -309,7 +311,7 @@ export const MoveRoofTool: React.FC<{ if (rotationDelta !== 0) { event.preventDefault() - sfxEmitter.emit('sfx:item-rotate') + triggerSFX('sfx:item-rotate') pendingRotation += rotationDelta @@ -371,3 +373,5 @@ export const MoveRoofTool: React.FC<{ ) } + +export default MoveRoofTool diff --git a/packages/nodes/src/roof/segment-hit.ts b/packages/nodes/src/shared/roof-segment-hit.ts similarity index 98% rename from packages/nodes/src/roof/segment-hit.ts rename to packages/nodes/src/shared/roof-segment-hit.ts index 1dada83c0..82b41790e 100644 --- a/packages/nodes/src/roof/segment-hit.ts +++ b/packages/nodes/src/shared/roof-segment-hit.ts @@ -19,7 +19,7 @@ export type RoofSegmentHit = { /** * Analytical surface Y for `seg` at segment-local (lx, lz). Mirrors - * the per-roof-type slope math in `solar-panel/geometry.ts` so the + * the per-roof-type slope math in `shared/roof-surface.ts` so the * disambiguator below stays free of cross-kind imports. Returns the * roof's local surface height; the value is only used to compare * candidates, never written to the scene. diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts new file mode 100644 index 000000000..7e539308d --- /dev/null +++ b/packages/nodes/src/shared/roof-surface.ts @@ -0,0 +1,158 @@ +import { + getActiveRoofHeight, + getSegmentSlopeFrame, + ROOF_SHAPE_DEFAULTS, + type RoofSegmentNode, +} from '@pascal-app/core' +import * as THREE from 'three' + +// ─── Roof-surface helpers ──────────────────────────────────────────── +// Analytical slope geometry for a roof segment, shared by every roof +// accessory that seats itself on the slope (solar-panel, skylight, +// box-vent). Lives here rather than inside any one kind's folder so the +// accessories don't reach across into a sibling kind for it. + +export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number { + const { roofType, wallHeight, depth, width } = seg + const rh = getActiveRoofHeight(seg) + const peakY = wallHeight + rh + if (rh === 0) return wallHeight + + if (roofType === 'gable') { + const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 + return peakY - t * rh + } + if (roofType === 'shed') { + const t = (lz + depth / 2) / (depth || 1) + return peakY - t * rh + } + if (roofType === 'hip') { + const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0 + const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 + return peakY - Math.max(fx, fz) * rh + } + const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 + return peakY - t * rh +} + +// Outward normal for a roof surface tilting at angle θ in the horizontal +// direction (dx, dz). Derivation: the surface tangent vectors are the +// ridge axis (perpendicular to the fall line, horizontal) and the +// down-slope direction (cos θ horizontal + −sin θ vertical). Crossing +// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz), +// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised. +function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 { + return new THREE.Vector3(dx * tan, 1, dz * tan).normalize() +} + +export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 { + const { roofType, depth, width } = seg + const slope = getSegmentSlopeFrame(seg) + if (slope.activeRh === 0 || slope.tanTheta === 0) { + return new THREE.Vector3(0, 1, 0) + } + const primaryTan = slope.tanTheta + const halfW = width / 2 + const halfD = depth / 2 + + // Ridge runs along X — slope falls in ±Z. Gambrel shares the gable + // dispatch (its kink-to-eave/lower tier is the primary slope frame). + if (roofType === 'gable' || roofType === 'gambrel') { + if (roofType === 'gambrel') { + // Tier-aware: the upper (shallower) face spans |z| < mz; the + // lower (steep) face spans mz < |z| ≤ halfD. Using primaryTan on + // the upper tier would tilt the ghost too steeply near the ridge. + const lowerWidthRatio = + seg.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio + const lowerHeightRatio = + seg.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio + const mz = halfD * lowerWidthRatio + if (Math.abs(lz) <= mz) { + const upperRise = slope.activeRh * (1 - lowerHeightRatio) + const upperRun = mz + const upperTan = upperRun > 0 ? upperRise / upperRun : 0 + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan) + } + } + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) + } + + // Single slope falling toward +Z (ridge at -Z, eave at +Z). + if (roofType === 'shed') { + return buildSlopeNormal(0, 1, primaryTan) + } + + // 4-sided slopes: the dominant axis chooses which face the point sits + // on. Hip is uniform across all four faces. Mansard has a steep outer + // band (primaryTan) and a shallow top inside the waist. Dutch has hip + // ends and gable sides — both share the same primaryTan from the + // slope frame, so directional dispatch is enough. + if (roofType === 'hip') { + const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 + const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 + if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) + } + + if (roofType === 'mansard') { + const widthRatio = seg.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio + const heightRatio = seg.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio + const inset = Math.min(width, depth) * widthRatio + const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 + const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 + const onZ = fz >= fx + const inSteepBand = onZ ? Math.abs(lz) > halfD - inset : Math.abs(lx) > halfW - inset + + let tan = primaryTan + if (!inSteepBand) { + // Top hip (shallow) above the waist — rises from the waist + // rectangle at fraction `heightRatio` of activeRh up to the peak. + const topRise = slope.activeRh * (1 - heightRatio) + const topRun = Math.max(0, Math.min(halfW, halfD) - inset) + tan = topRun > 0 ? topRise / topRun : 0 + } + if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan) + } + + if (roofType === 'dutch') { + // Hip on the short-axis ends, gable on the long-axis sides. Both + // share the primary pitch on their primary (eave-band) face, so the + // approximation collapses to "pick the dominant axis." + const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 + const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 + if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) + } + + return new THREE.Vector3(0, 1, 0) +} + +// ─── Quaternion helper ─────────────────────────────────────────────── +// Given a normal in the panel's parent frame, build a rotation that +// aligns the panel's local +Y to that normal. Lifted out so the +// renderer and the placement preview share one source of truth. + +export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaternion) { + // Build `right` by projecting world +X onto the surface plane instead of + // using `up × normal`. The cross-product version flips sign when the + // normal's Z component flips (e.g. the two slopes of a gable roof), so + // the resulting basis has its +X axis reversed on one slope — which + // makes hosted children's local +X point in opposite world directions + // depending on which slope they sit on, and registry chevrons end up + // anchored to the wrong edge. Projecting +X keeps the basis stable + // across slope-flips that share the same X axis. + const wx = new THREE.Vector3(1, 0, 0) + const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal))) + if (right.lengthSq() < 1e-6) { + // Degenerate: normal is parallel to ±X. Fall back to +Z so the basis + // is still well-defined; this is the wall-like edge case (vertical + // surface facing along X) where any in-plane convention is OK. + right.set(0, 0, 1) + } else { + right.normalize() + } + const forward = new THREE.Vector3().crossVectors(right, normal).normalize() + const m = new THREE.Matrix4().makeBasis(right, normal, forward) + return out.setFromRotationMatrix(m) +} diff --git a/packages/nodes/src/shelf/floorplan.ts b/packages/nodes/src/shelf/floorplan.ts index 4578dbeff..3230bd0e3 100644 --- a/packages/nodes/src/shelf/floorplan.ts +++ b/packages/nodes/src/shelf/floorplan.ts @@ -134,6 +134,7 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo point: [px + cornerPlanX, pz + cornerPlanY], angle: Math.atan2(radialPlanY, radialPlanX), affordance: 'shelf-rotate', + pivot: [px, pz], }) return { kind: 'group', children } diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index 719cdeae7..ab4bcc36c 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -1,5 +1,6 @@ import { type AnyNode, + type HandleDescriptor, type NodeDefinition, type RoofSegmentNode, SkylightNode as SkylightNodeSchema, @@ -10,10 +11,209 @@ import { isOperableSkylightNode, toggleSkylightOpenState, } from './interaction' +import { buildSkylightFloorplan } from './floorplan' 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 @@ -51,6 +251,8 @@ export const skylightDefinition: NodeDefinition = { }, parametrics: skylightParametrics, + handles: skylightHandles, + floorplan: buildSkylightFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/skylight/floorplan.ts b/packages/nodes/src/skylight/floorplan.ts new file mode 100644 index 000000000..2c4bf4cc3 --- /dev/null +++ b/packages/nodes/src/skylight/floorplan.ts @@ -0,0 +1,154 @@ +import type { + AnyNodeId, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, + SkylightNode, +} from '@pascal-app/core' + +/** + * Floor-plan builder for a skylight — a glazed opening set into the roof + * slope. Seen from above it's the classic skylight symbol: an outer frame + * rectangle, an inset glass pane, and a diagonal cross over the glass. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → skylight), same as the chimney builder. + * `position` is segment-local (X = width across slope, Z = height down + * slope; Y ignored — anchored to the slope). `rotation` is yaw. Rotations + * negated for the floor plan's y-down convention. The frame extends + * outward by `frameThickness` past the `width × height` glass opening + * (matching `frame-csg.ts`). + */ +export function buildSkylightFloorplan( + node: SkylightNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + const baseInk = '#475569' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const frameFill = showSelectedChrome ? '#fed7aa' : '#e2e8f0' + const glassFill = showSelectedChrome ? '#fed7aa' : '#dbeafe' + const frameFillOpacity = showSelectedChrome ? 0.55 : 0.7 + const glassFillOpacity = showSelectedChrome ? 0.45 : 0.55 + const lineWidth = showSelectedChrome ? 0.03 : 0.02 + + const hw = Math.max(node.width, 0.1) / 2 + const hh = Math.max(node.height, 0.1) / 2 + const ft = Math.max(0, node.frameThickness ?? 0.05) + const outerHX = hw + ft + const outerHZ = hh + ft + + const rect = (halfX: number, halfZ: number): FloorplanPoint[] => [ + toPlan(-halfX, -halfZ), + toPlan(halfX, -halfZ), + toPlan(halfX, halfZ), + toPlan(-halfX, halfZ), + ] + + const children: FloorplanGeometry[] = [ + // Transparent hit-target over the outer frame. + { + kind: 'polygon', + points: rect(outerHX, outerHZ), + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + // Outer frame / curb. + { + kind: 'polygon', + points: rect(outerHX, outerHZ), + fill: frameFill, + fillOpacity: frameFillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }, + // Inset glass pane. + { + kind: 'polygon', + points: rect(hw, hh), + fill: glassFill, + fillOpacity: glassFillOpacity, + stroke, + strokeWidth: lineWidth * 0.8, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }, + ] + + // Diagonal glazing cross over the pane — the standard skylight symbol. + const tl = toPlan(-hw, -hh) + const tr = toPlan(hw, -hh) + const bl = toPlan(-hw, hh) + const br = toPlan(hw, hh) + children.push( + { + kind: 'line', + x1: tl[0], + y1: tl[1], + x2: br[0], + y2: br[1], + stroke, + strokeWidth: lineWidth * 0.7, + strokeOpacity: 0.7, + strokeLinecap: 'round', + pointerEvents: 'none', + }, + { + kind: 'line', + x1: tr[0], + y1: tr[1], + x2: bl[0], + y2: bl[1], + stroke, + strokeWidth: lineWidth * 0.7, + strokeOpacity: 0.7, + strokeLinecap: 'round', + pointerEvents: 'none', + }, + ) + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/skylight/geometry.ts b/packages/nodes/src/skylight/geometry.ts index a4e3b802b..72dc04b97 100644 --- a/packages/nodes/src/skylight/geometry.ts +++ b/packages/nodes/src/skylight/geometry.ts @@ -1,5 +1,5 @@ import * as THREE from 'three' -import { getAnalyticalNormal, getSurfaceY } from '../solar-panel/geometry' +import { getAnalyticalNormal, getSurfaceY } from '../shared/roof-surface' export { getAnalyticalNormal, getSurfaceY } diff --git a/packages/nodes/src/skylight/move-tool.tsx b/packages/nodes/src/skylight/move-tool.tsx index d498a87b9..07a1e9939 100644 --- a/packages/nodes/src/skylight/move-tool.tsx +++ b/packages/nodes/src/skylight/move-tool.tsx @@ -14,6 +14,8 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import SkylightPreview from './preview' function resolveSegmentFromWorldPoint( @@ -45,7 +47,15 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { const previewRef = useRef(null!) const [previewPos, setPreviewPos] = useState<[number, number, number]>([0, 0, 0]) - const [previewQuat, setPreviewQuat] = useState<[number, number, number, number]>([0, 0, 0, 1]) + // Mirror the placement tool's transform stack so the ghost reads the + // same in both flows: outer rotation-y aligns the segment's world yaw + // (roof + segment), inner quaternion tilts to the segment surface in + // segment-local space (analytical, not raycast — raycast normals can + // be flipped or in the wrong frame depending on hit-object state). + // The skylight's own `rotation` is applied on a deeper group so it + // stays editable on top of the surface alignment. + const [previewYaw, setPreviewYaw] = useState(0) + const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) const [hasHit, setHasHit] = useState(false) useEffect(() => { @@ -85,20 +95,30 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { let lastSnapX = 0 let lastSnapZ = 0 - const captureNormal = (event: RoofEvent) => { - if (!event.normal) return - const n = new THREE.Vector3(event.normal[0], event.normal[1], event.normal[2]) - const nm = new THREE.Matrix3().getNormalMatrix(event.object.matrixWorld) - n.applyMatrix3(nm).normalize() - - const up = new THREE.Vector3(0, 1, 0) - const right = new THREE.Vector3().crossVectors(up, n) - if (right.lengthSq() < 1e-6) right.set(1, 0, 0) - else right.normalize() - const forward = new THREE.Vector3().crossVectors(right, n).normalize() - const m = new THREE.Matrix4().makeBasis(right, n, forward) - const q = new THREE.Quaternion().setFromRotationMatrix(m) - setPreviewQuat([q.x, q.y, q.z, q.w]) + // Resolve which segment the cursor is over, then derive the same + // preview transform stack the placement tool uses (`skylight/tool.tsx`): + // analytical surface normal in segment-local frame → outer yaw = + // roof + segment rotation. Falls back to leaving the preview hidden + // if the cursor is between segments — the placement tool does the + // same via its `if (!hit) return` guard. + const updateFromHit = (event: RoofEvent) => { + const roof = event.node as RoofNode + const hit = resolveRoofSegmentHit( + roof, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) { + setHasHit(false) + return false + } + const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) + setPreviewYaw((roof.rotation ?? 0) + (hit.segment.rotation ?? 0)) + setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2])) + setHasHit(true) + return true } const onRoofMove = (event: RoofEvent) => { @@ -109,16 +129,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { lastSnapX = sx lastSnapZ = sz } - captureNormal(event) - setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2])) - setHasHit(true) + updateFromHit(event) event.stopPropagation() } const onRoofEnter = (event: RoofEvent) => { - captureNormal(event) - setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2])) - setHasHit(true) + updateFromHit(event) event.stopPropagation() } @@ -147,7 +163,6 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { }) useScene.temporal.getState().resume() - captureNormal(event) st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, @@ -241,10 +256,16 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { } }, [exitMoveMode, node]) + if (!previewSurfaceQuat) return null + return ( - - - + + + + + + + ) diff --git a/packages/nodes/src/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index 8ef51fc99..47bc6dd51 100644 --- a/packages/nodes/src/skylight/renderer.tsx +++ b/packages/nodes/src/skylight/renderer.tsx @@ -22,10 +22,12 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { surfaceQuatFromNormal } from '../solar-panel/geometry' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { buildFrameGeometry } from './frame-csg' import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry' +const yAxis = new THREE.Vector3(0, 1, 0) + const defaultFrameMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, roughness: 0.3, @@ -662,6 +664,19 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { [surfaceFrame.normal], ) + // Compose the surface tilt with the skylight's own yaw so the + // registered ref group below carries the complete "skylight pose in + // segment frame" as a single local position+quaternion. Registry + // handles (`portal: 'grandparent'`) read this Object3D's *local* + // pose, so splitting the tilt and the yaw across nested groups would + // leave the registered group with just the yaw and put the handles + // at the wrong spot. + const composedQuat = useMemo(() => { + const q = new THREE.Quaternion().copy(surfaceQuat) + q.multiply(new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0)) + return q + }, [surfaceQuat, node.rotation]) + const hasCurb = node.curb ?? false const curbH = hasCurb ? Math.max(0, node.curbHeight ?? 0.1) : 0 @@ -672,61 +687,70 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { return ( - - - - - {activeType === 'lantern' && ( - - )} - {activeType === 'sliding' && ( - - )} - {activeType === 'opening' && ( - - )} - {(activeType === 'flat' || activeType === 'walk-on') && ( - - )} - - + {/* + Single registered transform group carries the full skylight pose + in segment frame: translation = (skylight.x, surfaceY, skylight.z), + quaternion = surfaceQuat · Y(node.rotation). Used to be three + nested groups; collapsed because registry handles read this + Object3D's *local* position/quaternion (grandparent portal mode), + and a split tree would only expose the bottom group's local pose + (the yaw) — handles would land at the segment origin on the roof + base instead of on the skylight. + */} + + + {activeType === 'lantern' && ( + + )} + {activeType === 'sliding' && ( + + )} + {activeType === 'opening' && ( + + )} + {(activeType === 'flat' || activeType === 'walk-on') && ( + + )} ) diff --git a/packages/nodes/src/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index 59879287b..cbaadcb48 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -13,8 +13,8 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { skylightDefinition } from './definition' import SkylightPreview from './preview' diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 32ae0631b..dd1a68080 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -9,7 +9,7 @@ import { generateSlabGeometry, type RenderShading, } from '@pascal-app/viewer' -import { DoubleSide, Group, type Material, Mesh, type Texture } from 'three' +import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' /** * Stage B builder for slab. Reuses `generateSlabGeometry` (pure @@ -40,8 +40,12 @@ function getSlabMaterial( // Untextured slabs (and everything in textures-off mode) take the themed // 'floor' role colour. createSurfaceRoleMaterial returns a shared cached // material, so it is returned as-is without the mutation below. + // FrontSide — DoubleSide on the role material's NodeMaterial poisons the + // MRT scene pass (see `materials.ts` line 77 / glazing fix 9400f1c5). + // Slab side faces still render correctly because `generateSlabGeometry` + // produces outward-facing normals on the top, bottom, and perimeter. if (!textures || (!node.materialPreset && !node.material)) { - return createSurfaceRoleMaterial('floor', colorPreset, DoubleSide, sceneTheme) + return createSurfaceRoleMaterial('floor', colorPreset, FrontSide, sceneTheme) } const cacheKey = JSON.stringify({ @@ -67,7 +71,10 @@ function getSlabMaterial( slabMaterial.transparent = false slabMaterial.opacity = 1 slabMaterial.alphaMap = null - slabMaterial.side = DoubleSide + // FrontSide — user-supplied materials may be NodeMaterials, and DoubleSide + // on any NodeMaterial in the MRT scene pass poisons the render context + // (see `materials.ts` line 77 / glazing fix 9400f1c5). + slabMaterial.side = FrontSide slabMaterial.depthWrite = true slabMaterial.needsUpdate = true diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index 3525519ef..0c129b78a 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -1,7 +1,219 @@ -import { type NodeDefinition, SolarPanelNode as SolarPanelNodeSchema } from '@pascal-app/core' +import { + type HandleDescriptor, + type NodeDefinition, + SolarPanelNode as SolarPanelNodeSchema, + type SolarPanelNode as SolarPanelNodeType, +} from '@pascal-app/core' +import { buildSolarPanelFloorplan } from './floorplan' import { solarPanelParametrics } from './parametrics' import { SolarPanelNode } from './schema' +// Handle constants — same scheme as the skylight: edge-to-arrow-center +// offsets that need to clear half the chevron's body (~13 cm at default +// scale) plus the frame thickness before the gap reads. 0.35 m matches +// the skylight's visual cadence; rotate gizmo gets a matching offset + +// a small +X bump so it sits beside (not on) the +X corner. +const SIDE_HANDLE_OFFSET = 0.35 +const ROTATE_CORNER_OFFSET = 0.35 +const ROTATE_CORNER_X_OFFSET = 0.2 +const ROTATE_CORNER_Y_OFFSET = 0.18 +const ROTATE_RING_OFFSET = 0.06 +const MIN_PANEL_DIM = 0.1 +const MIN_FRAME_THICKNESS = 0.005 +const MIN_FRAME_DEPTH = 0.005 +// Small gap above the current frame top so the chevron stays grabbable +// when frameDepth collapses near zero and floats above the frame for +// thicker frames. +const FRAME_DEPTH_HANDLE_OFFSET = 0.15 + +// Total array footprint (meters). `panelWidth` / `panelHeight` are +// per-cell dimensions; arrays multiply by columns/rows and add the +// inter-cell gaps. Handles operate on the TOTAL footprint so the +// dimension label and drag distance feel intuitive (drag the right edge +// by 1 m → array grows 1 m on that side, not 1 m per cell). +function totalArrayWidth(n: SolarPanelNodeType): number { + return n.columns * n.panelWidth + Math.max(0, n.columns - 1) * (n.gapX ?? 0) +} + +function totalArrayHeight(n: SolarPanelNodeType): number { + return n.rows * n.panelHeight + Math.max(0, n.rows - 1) * (n.gapY ?? 0) +} + +// Width arrows on ±X (left / right edges of the array). Asymmetric +// resize — same pattern as the skylight: anchored edge stays world-fixed, +// per-cell `panelWidth` is back-solved from the new total width, and +// `position` shifts by half the actual change along the panel's local +// +X axis (projected to segment-local via the panel's yaw). Clears +// `panelTypePreset` since the dimensions no longer match a saved preset. +function solarPanelWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_PANEL_DIM, + currentValue: (n) => totalArrayWidth(n), + apply: (initial, newTotalWidth) => { + const cols = initial.columns + const gapTotal = Math.max(0, cols - 1) * (initial.gapX ?? 0) + const newPanelWidth = Math.max(MIN_PANEL_DIM, (newTotalWidth - gapTotal) / cols) + const actualNewTotal = cols * newPanelWidth + gapTotal + const initialTotal = totalArrayWidth(initial) + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initialTotal / 2) * armX + const anchorZ = initial.position[2] - sign * (initialTotal / 2) * armZ + const newCenterX = anchorX + sign * (actualNewTotal / 2) * armX + const newCenterZ = anchorZ + sign * (actualNewTotal / 2) * armZ + return { + panelWidth: newPanelWidth, + position: [newCenterX, initial.position[1], newCenterZ], + panelTypePreset: undefined, + } + }, + placement: { + position: (n) => [sign * (totalArrayWidth(n) / 2 + SIDE_HANDLE_OFFSET), 0, 0], + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + portal: 'grandparent', + } +} + +// Height arrows on ±Z. Same shape as width but acts on `panelHeight` +// (back-solved from new total height) and projects onto +Z instead of +// +X. The skylight-axis convention applies: +Z is the dimension along +// the roof's slope direction. +function solarPanelHeightHandle(side: 'top' | 'bottom'): HandleDescriptor { + const sign = side === 'top' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'z', + anchor: side === 'top' ? 'min' : 'max', + min: MIN_PANEL_DIM, + currentValue: (n) => totalArrayHeight(n), + apply: (initial, newTotalHeight) => { + const rws = initial.rows + const gapTotal = Math.max(0, rws - 1) * (initial.gapY ?? 0) + const newPanelHeight = Math.max(MIN_PANEL_DIM, (newTotalHeight - gapTotal) / rws) + const actualNewTotal = rws * newPanelHeight + gapTotal + const initialTotal = totalArrayHeight(initial) + const rotY = initial.rotation ?? 0 + // Panel-local +Z projects to segment-local (sin r, cos r) — + // orthogonal to the panel-local +X basis used for width. + const armX = Math.sin(rotY) + const armZ = Math.cos(rotY) + const anchorX = initial.position[0] - sign * (initialTotal / 2) * armX + const anchorZ = initial.position[2] - sign * (initialTotal / 2) * armZ + const newCenterX = anchorX + sign * (actualNewTotal / 2) * armX + const newCenterZ = anchorZ + sign * (actualNewTotal / 2) * armZ + return { + panelHeight: newPanelHeight, + position: [newCenterX, initial.position[1], newCenterZ], + panelTypePreset: undefined, + } + }, + placement: { + position: (n) => [0, 0, sign * (totalArrayHeight(n) / 2 + SIDE_HANDLE_OFFSET)], + rotationY: () => (side === 'top' ? 0 : Math.PI), + }, + portal: 'grandparent', + } +} + +// Rotate gizmo at the +X+Z corner of the total array footprint, lifted +// slightly off the surface so it doesn't sink into the frame. Negate +// the cursor delta to match three.js Y-rotation handedness. +function solarPanelRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n) => { + const halfX = totalArrayWidth(n) / 2 + const halfZ = totalArrayHeight(n) / 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(totalArrayWidth(n) / 2, totalArrayHeight(n) / 2) + ROTATE_RING_OFFSET, + y: () => 0, + }, + portal: 'grandparent', + } +} + +// Frame-depth arrow — vertical chevron above the array, centred on the +// panel surface. Drag up to grow `frameDepth` (how far the frame sticks +// out from the surface). axis='y' / anchor='min' so the bottom of the +// frame stays pinned at the surface (Y=0 in panel-local) and the top +// follows the cursor. Clears `panelTypePreset` since dimensions no +// longer match a saved preset. +function solarPanelFrameDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_FRAME_DEPTH, + currentValue: (n) => n.frameDepth ?? 0.04, + apply: (_n, newValue) => ({ + frameDepth: newValue, + panelTypePreset: undefined, + }), + placement: { + position: (n) => [0, (n.frameDepth ?? 0.04) + FRAME_DEPTH_HANDLE_OFFSET, 0], + }, + portal: 'grandparent', + } +} + +// Frame-thickness arrow — diagonal chevron at the -X+Z (top-left) corner, +// mirroring the skylight handle. axis='z' so dragging outward toward +Z +// grows the value; rotationY = -π/4 swings the auto-rotated +Z chevron +// to point along the -X+Z corner bisector. Clears `panelTypePreset` +// since the frame dimensions no longer match a saved preset. +function solarPanelFrameThicknessHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'min', + min: MIN_FRAME_THICKNESS, + currentValue: (n) => n.frameThickness ?? 0.04, + apply: (_n, newValue) => ({ + frameThickness: newValue, + panelTypePreset: undefined, + }), + placement: { + position: (n) => [ + -(totalArrayWidth(n) / 2) - SIDE_HANDLE_OFFSET, + 0, + totalArrayHeight(n) / 2 + SIDE_HANDLE_OFFSET, + ], + rotationY: () => -Math.PI / 4, + }, + portal: 'grandparent', + } +} + +const solarPanelHandles: HandleDescriptor[] = [ + solarPanelWidthHandle('right'), + solarPanelWidthHandle('left'), + solarPanelHeightHandle('top'), + solarPanelHeightHandle('bottom'), + solarPanelFrameDepthHandle(), + solarPanelRotateHandle(), + solarPanelFrameThicknessHandle(), +] + /** * Solar panel array — a grid of photovoltaic panels mounted on a roof * segment. Position is segment-local; the surface normal stored on @@ -40,6 +252,8 @@ export const solarPanelDefinition: NodeDefinition = { }, parametrics: solarPanelParametrics, + handles: solarPanelHandles, + floorplan: buildSolarPanelFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/solar-panel/floorplan.ts b/packages/nodes/src/solar-panel/floorplan.ts new file mode 100644 index 000000000..dd305cf90 --- /dev/null +++ b/packages/nodes/src/solar-panel/floorplan.ts @@ -0,0 +1,142 @@ +import type { + AnyNodeId, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, + SolarPanelNode, +} from '@pascal-app/core' + +/** + * Floor-plan builder for a solar-panel array — a grid of photovoltaic + * modules mounted on a roof segment. Seen from above it reads as its + * `rows × columns` grid of dark module rectangles with gaps between them. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → panel), same as the chimney builder. The array's + * `position` is segment-local (X = width axis, Z = depth axis; Y is + * ignored — the 3D renderer anchors it to the slope). `rotation` is yaw. + * Rotations are negated for the floor plan's y-down convention (see + * `buildRoofSegmentFloorplan`). + * + * The per-module layout matches `buildSolarPanelGeometry` exactly: the + * array is centred at the origin, modules step by `panel + gap` along each + * axis (columns along X, rows along Z). A tilted array foreshortens + * slightly in 3D, but the plan shows its full mounting footprint — that's + * what matters for roof layout. + */ +export function buildSolarPanelFloorplan( + node: SolarPanelNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + // Compose roof → segment → panel in plan coords. Each rotation is + // negated so SVG's y-down CW matches Three.js' top-down CCW. + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Photovoltaic modules — dark glass with a thin frame. Accent on select, + // light blue on hover. + const baseInk = '#1e293b' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const moduleFill = showSelectedChrome ? '#fed7aa' : '#334155' + const moduleFillOpacity = showSelectedChrome ? 0.5 : 0.85 + const lineWidth = showSelectedChrome ? 0.028 : 0.016 + + // Grid params — guarded + clamped to the schema's ranges so a malformed + // or un-migrated node can't NaN the layout or blow up the module loop. + const rows = Math.max(1, Math.min(20, Math.floor(node.rows ?? 1))) + const columns = Math.max(1, Math.min(20, Math.floor(node.columns ?? 1))) + const panelW = Math.max(0.05, node.panelWidth ?? 1) + const panelH = Math.max(0.05, node.panelHeight ?? 1) + const gapX = Math.max(0, node.gapX ?? 0) + const gapY = Math.max(0, node.gapY ?? 0) + + const totalW = columns * panelW + (columns - 1) * gapX + const totalH = rows * panelH + (rows - 1) * gapY + const originX = -totalW / 2 + const originZ = -totalH / 2 + const halfW = totalW / 2 + const halfH = totalH / 2 + + const children: FloorplanGeometry[] = [ + // One transparent hit-target across the whole array so clicking a gap + // (or any module) selects the array. + { + kind: 'polygon', + points: [ + toPlan(-halfW, -halfH), + toPlan(halfW, -halfH), + toPlan(halfW, halfH), + toPlan(-halfW, halfH), + ], + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + ] + + // One filled rectangle per module — same loop as `buildSolarPanelGeometry` + // (columns along X, rows along Z), so the plan grid matches the 3D array. + const hw = panelW / 2 + const hh = panelH / 2 + for (let r = 0; r < rows; r++) { + for (let c = 0; c < columns; c++) { + const mcx = originX + c * (panelW + gapX) + panelW / 2 + const mcz = originZ + r * (panelH + gapY) + panelH / 2 + children.push({ + kind: 'polygon', + points: [ + toPlan(mcx - hw, mcz - hh), + toPlan(mcx + hw, mcz - hh), + toPlan(mcx + hw, mcz + hh), + toPlan(mcx - hw, mcz + hh), + ], + fill: moduleFill, + fillOpacity: moduleFillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/solar-panel/geometry.ts b/packages/nodes/src/solar-panel/geometry.ts index b11e7a7a8..b0fd944a4 100644 --- a/packages/nodes/src/solar-panel/geometry.ts +++ b/packages/nodes/src/solar-panel/geometry.ts @@ -1,10 +1,4 @@ -import { - getActiveRoofHeight, - getSegmentSlopeFrame, - ROOF_SHAPE_DEFAULTS, - type RoofSegmentNode, - type SolarPanelNode, -} from '@pascal-app/core' +import type { RoofSegmentNode, SolarPanelNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshStandardNodeMaterial } from 'three/webgpu' @@ -196,141 +190,6 @@ export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeome return frameMerged } -// ─── Roof-surface helpers ──────────────────────────────────────────── -// Used to drop the panel onto the slope when the schema's -// `surfaceNormal` is absent (legacy data or simplified placement). - -export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number { - const { roofType, wallHeight, depth, width } = seg - const rh = getActiveRoofHeight(seg) - const peakY = wallHeight + rh - if (rh === 0) return wallHeight - - if (roofType === 'gable') { - const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - t * rh - } - if (roofType === 'shed') { - const t = (lz + depth / 2) / (depth || 1) - return peakY - t * rh - } - if (roofType === 'hip') { - const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0 - const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - Math.max(fx, fz) * rh - } - const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - t * rh -} - -// Outward normal for a roof surface tilting at angle θ in the horizontal -// direction (dx, dz). Derivation: the surface tangent vectors are the -// ridge axis (perpendicular to the fall line, horizontal) and the -// down-slope direction (cos θ horizontal + −sin θ vertical). Crossing -// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz), -// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised. -function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 { - return new THREE.Vector3(dx * tan, 1, dz * tan).normalize() -} - -export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 { - const { roofType, depth, width } = seg - const slope = getSegmentSlopeFrame(seg) - if (slope.activeRh === 0 || slope.tanTheta === 0) { - return new THREE.Vector3(0, 1, 0) - } - const primaryTan = slope.tanTheta - const halfW = width / 2 - const halfD = depth / 2 - - // Ridge runs along X — slope falls in ±Z. Gambrel shares the gable - // dispatch (its kink-to-eave/lower tier is the primary slope frame). - if (roofType === 'gable' || roofType === 'gambrel') { - if (roofType === 'gambrel') { - // Tier-aware: the upper (shallower) face spans |z| < mz; the - // lower (steep) face spans mz < |z| ≤ halfD. Using primaryTan on - // the upper tier would tilt the ghost too steeply near the ridge. - const lowerWidthRatio = - seg.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio - const lowerHeightRatio = - seg.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio - const mz = halfD * lowerWidthRatio - if (Math.abs(lz) <= mz) { - const upperRise = slope.activeRh * (1 - lowerHeightRatio) - const upperRun = mz - const upperTan = upperRun > 0 ? upperRise / upperRun : 0 - return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan) - } - } - return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - } - - // Single slope falling toward +Z (ridge at -Z, eave at +Z). - if (roofType === 'shed') { - return buildSlopeNormal(0, 1, primaryTan) - } - - // 4-sided slopes: the dominant axis chooses which face the point sits - // on. Hip is uniform across all four faces. Mansard has a steep outer - // band (primaryTan) and a shallow top inside the waist. Dutch has hip - // ends and gable sides — both share the same primaryTan from the - // slope frame, so directional dispatch is enough. - if (roofType === 'hip') { - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) - } - - if (roofType === 'mansard') { - const widthRatio = seg.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio - const heightRatio = seg.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio - const inset = Math.min(width, depth) * widthRatio - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - const onZ = fz >= fx - const inSteepBand = onZ ? Math.abs(lz) > halfD - inset : Math.abs(lx) > halfW - inset - - let tan = primaryTan - if (!inSteepBand) { - // Top hip (shallow) above the waist — rises from the waist - // rectangle at fraction `heightRatio` of activeRh up to the peak. - const topRise = slope.activeRh * (1 - heightRatio) - const topRun = Math.max(0, Math.min(halfW, halfD) - inset) - tan = topRun > 0 ? topRise / topRun : 0 - } - if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan) - } - - if (roofType === 'dutch') { - // Hip on the short-axis ends, gable on the long-axis sides. Both - // share the primary pitch on their primary (eave-band) face, so the - // approximation collapses to "pick the dominant axis." - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) - } - - return new THREE.Vector3(0, 1, 0) -} - -// ─── Quaternion helper ─────────────────────────────────────────────── -// Given a normal in the panel's parent frame, build a rotation that -// aligns the panel's local +Y to that normal. Lifted out so the -// renderer and the placement preview share one source of truth. - -export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaternion) { - const up = new THREE.Vector3(0, 1, 0) - const right = new THREE.Vector3().crossVectors(up, normal) - if (right.lengthSq() < 1e-6) right.set(1, 0, 0) - else right.normalize() - const forward = new THREE.Vector3().crossVectors(right, normal).normalize() - const m = new THREE.Matrix4().makeBasis(right, normal, forward) - return out.setFromRotationMatrix(m) -} - // ─── Layout helpers (used by the inspector / placement tool) ───────── function getSlopeDepthBounds( diff --git a/packages/nodes/src/solar-panel/index.ts b/packages/nodes/src/solar-panel/index.ts index d446166c4..c0f9399f2 100644 --- a/packages/nodes/src/solar-panel/index.ts +++ b/packages/nodes/src/solar-panel/index.ts @@ -1,10 +1,4 @@ +export { getAnalyticalNormal, getSurfaceY, surfaceQuatFromNormal } from '../shared/roof-surface' export { solarPanelDefinition } from './definition' -export { - buildSolarPanelGeometry, - computeAutoFit, - flippedPanelDims, - getAnalyticalNormal, - getSurfaceY, - surfaceQuatFromNormal, -} from './geometry' +export { buildSolarPanelGeometry, computeAutoFit, flippedPanelDims } from './geometry' export { SolarPanelNode } from './schema' diff --git a/packages/nodes/src/solar-panel/move-tool.tsx b/packages/nodes/src/solar-panel/move-tool.tsx index 1394cde7b..b181c6106 100644 --- a/packages/nodes/src/solar-panel/move-tool.tsx +++ b/packages/nodes/src/solar-panel/move-tool.tsx @@ -14,8 +14,8 @@ import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pa import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from './geometry' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' // MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding // fragment stage output / writeMask not zero" error that fires when diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index 088938690..663a5608f 100644 --- a/packages/nodes/src/solar-panel/renderer.tsx +++ b/packages/nodes/src/solar-panel/renderer.tsx @@ -13,19 +13,23 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + getRoofOuterSurfaceFrameAtPoint, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { MeshStandardNodeMaterial } from 'three/webgpu' -import { - buildSolarPanelGeometry, - getAnalyticalNormal, - getDefaultPanelMaterial, - getSurfaceY, - surfaceQuatFromNormal, -} from './geometry' +import { surfaceQuatFromNormal } from '../shared/roof-surface' +import { buildSolarPanelGeometry, getDefaultPanelMaterial } from './geometry' + +// Module-scope scratch vectors and quaternions for composing the panel's +// local orientation each render — surfaceQuat · Y(rotation) · X(tilt). +// Reused so we don't allocate four objects per frame. +const yAxis = new THREE.Vector3(0, 1, 0) +const xAxis = new THREE.Vector3(1, 0, 0) +const panelYawQuat = new THREE.Quaternion() +const panelTiltQuat = new THREE.Quaternion() // MeshStandardNodeMaterial: WebGPU-native so it integrates correctly with // the MRT pass (normal + roughness attachments). The legacy WebGL @@ -39,9 +43,17 @@ const defaultFrameMaterial = new MeshStandardNodeMaterial({ }) /** - * Solar panel renderer. Reads the parent roof-segment so the panel's - * Y can fall back to the analytical surface height when the schema's - * `surfaceNormal` is absent (legacy nodes / simplified placement). + * Solar panel renderer. The panel's Y and surface tilt are derived + * live from the parent roof-segment's finished (deck + shingle) surface + * on every render via `getRoofOuterSurfaceFrameAtPoint` — the same + * authoritative helper skylights use. Deriving rather than reading the + * stored `position[1]`/`surfaceNormal` snapshot is what lets the panel + * re-seat and re-tilt automatically when the roof's wall height or + * pitch changes; a cached snapshot would leave it floating or buried. + * + * The segment's live overrides are merged too, so the panel tracks the + * surface continuously during a wall-height/pitch drag, not just after + * the value commits to the scene store. * * The surface orientation is applied as a quaternion on an inner * group computed once per render (not per frame). This matches the @@ -72,6 +84,20 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { : undefined, ) + // Merge the segment's live overrides (written by wall-height / pitch + // slider drags before they commit) so the panel re-seats and re-tilts + // in real time as the roof changes, mirroring the gutter's eave-snap. + const segmentOverrides = useLiveNodeOverrides((s) => + node.roofSegmentId + ? (s.get(node.roofSegmentId as AnyNodeId) as Partial | undefined) + : undefined, + ) + const effectiveSegment: RoofSegmentNode | undefined = segment + ? segmentOverrides + ? ({ ...segment, ...segmentOverrides } as RoofSegmentNode) + : segment + : undefined + const geometry = useMemo( () => buildSolarPanelGeometry(node), [ @@ -107,49 +133,67 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { ) }, [shading, node.panelMaterial, node.panelMaterialPreset]) - const surfaceQuat = useMemo(() => { - if (!segment) return new THREE.Quaternion() - const normal = node.surfaceNormal - ? new THREE.Vector3(...node.surfaceNormal).normalize() - : getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment) - return surfaceQuatFromNormal(normal, new THREE.Quaternion()) - }, [segment, node.surfaceNormal, node.position[0], node.position[2]]) + // Finished-surface frame (deck + shingle top) at the panel's local + // X/Z, recomputed from the live segment each render — both the Y and + // the tilt normal flow from here, so a wall-height or pitch change + // re-seats and re-orients the panel automatically. `segmentOverrides` + // is in the deps so a live drag re-derives the frame mid-drag. + const surfaceFrame = useMemo(() => { + if (!effectiveSegment) { + return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) } + } + return getRoofOuterSurfaceFrameAtPoint( + effectiveSegment, + node.position[0] ?? 0, + node.position[2] ?? 0, + ) + }, [segment, segmentOverrides, node.position[0], node.position[2]]) + + const surfaceQuat = useMemo( + () => surfaceQuatFromNormal(surfaceFrame.normal, new THREE.Quaternion()), + [surfaceFrame.normal], + ) - if (!segment || !geometry) return null + if (!effectiveSegment || !geometry) return null - const surfaceY = - (node.position[1] ?? 0) !== 0 - ? node.position[1] - : getSurfaceY(node.position[0] ?? 0, node.position[2] ?? 0, segment) + const surfaceY = surfaceFrame.point.y const tiltRad = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0 + // Compose surfaceQuat · Y(rotation) · X(tilt) into a single quaternion + // so the registered group below carries the panel's complete local pose + // (position + orientation) as its own *local* matrix. Registry handles + // (`portal: 'grandparent'`) read this Object3D's local position + + // quaternion to ride the panel; splitting the rotation across nested + // groups would leave the registered group with only the position and + // an identity quaternion, so the arrows would land on the segment-flat + // axes instead of on the tilted panel. + const composedQuat = new THREE.Quaternion() + .copy(surfaceQuat) + .multiply(panelYawQuat.setFromAxisAngle(yAxis, node.rotation ?? 0)) + .multiply(panelTiltQuat.setFromAxisAngle(xAxis, tiltRad)) + // Roof accessories are mounted under `` // in the roof renderer — that group has NO transform, so the segment // frame is NOT inherited from the React tree. Apply segment.position - // and segment.rotation here, then the panel's segment-local offset, - // then surface quat / yaw / tilt. + // and segment.rotation here, then the panel's segment-local offset + + // composed orientation on a single registered group. return ( - + - - - - - - - + ) diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index afed1a46a..9289e230b 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -13,9 +13,9 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { solarPanelDefinition } from './definition' -import { getAnalyticalNormal, surfaceQuatFromNormal } from './geometry' import SolarPanelPreview from './preview' const worldPoint = new THREE.Vector3() diff --git a/packages/nodes/src/stair-segment/definition.ts b/packages/nodes/src/stair-segment/definition.ts index 125788855..63333a619 100644 --- a/packages/nodes/src/stair-segment/definition.ts +++ b/packages/nodes/src/stair-segment/definition.ts @@ -122,6 +122,13 @@ export const stairSegmentDefinition: NodeDefinition = { deletable: true, }, + // Bespoke move shared with roof / roof-segment / stair 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: stairSegmentParametrics, handles: stairSegmentHandles, diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index f5db51432..d5ff25a8f 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -322,6 +322,13 @@ export const stairDefinition: NodeDefinition = { deletable: true, }, + // Bespoke move shared with roof / roof-segment / 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: stairParametrics, handles: stairHandles, diff --git a/packages/nodes/src/stair/floorplan.ts b/packages/nodes/src/stair/floorplan.ts index ead2f758e..eeabc2d34 100644 --- a/packages/nodes/src/stair/floorplan.ts +++ b/packages/nodes/src/stair/floorplan.ts @@ -464,6 +464,7 @@ export function buildStairFloorplan( point: [planX, planY], angle: radialAngle, affordance: 'stair-rotate', + pivot: [cx, cz], }) } diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx index 136781c20..903ad3ed5 100644 --- a/packages/nodes/src/stair/renderer.tsx +++ b/packages/nodes/src/stair/renderer.tsx @@ -155,16 +155,26 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { function StairRailings({ stair, material }: { stair: StairNode; material: THREE.Material }) { const nodes = useScene((state) => state.nodes) + // Stair segments' width/length/height arrow handles publish drag values to + // `useLiveNodeOverrides` and only commit to zustand on release. Subscribing + // here and merging each child segment's override means the railing tracks + // the drag in real time instead of freezing at the pre-drag values. + const overrides = useLiveNodeOverrides((s) => s.overrides) const segments = useMemo( () => (stair.children ?? []) - .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) + .map((childId) => { + const base = nodes[childId as AnyNodeId] as StairSegmentNode | undefined + if (!base) return undefined + const override = overrides.get(childId as AnyNodeId) + return (override ? { ...base, ...override } : base) as StairSegmentNode + }) .filter( (node): node is StairSegmentNode => node?.type === 'stair-segment' && node.visible !== false, ), - [nodes, stair.children], + [nodes, overrides, stair.children], ) const railPaths = useMemo( diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index 019017f25..eb36b1d20 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -2,8 +2,11 @@ import { type AnyNodeId, + DEFAULT_WALL_HEIGHT, emitter, type GridEvent, + getWallCurveLength, + getWallThickness, pauseSceneHistory, resumeSceneHistory, useScene, @@ -15,6 +18,7 @@ import { getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, isSegmentLongEnough, + MeasurementPill, type MovingWallEndpoint, markToolCancelConsumed, snapWallDraftPoint, @@ -187,6 +191,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ return [point[0], 0, point[1]] }) const [altPressed, setAltPressed] = useState(false) + const unit = useViewer((s) => s.unit) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingWallEndpoint(null) @@ -398,9 +403,39 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } }, [exitMoveMode, target]) + // Live segment dimensions for the floating pill. The moving endpoint is + // `cursorLocalPos`; the other end is fixed. Length tracks the drag (curve + // offset is unchanged by an endpoint move); height + thickness are static. + const movingPlanPoint: WallPlanPoint = [cursorLocalPos[0], cursorLocalPos[2]] + const fixedPlanPoint = fixedPointRef.current + const previewStart = target.endpoint === 'start' ? movingPlanPoint : fixedPlanPoint + const previewEnd = target.endpoint === 'end' ? movingPlanPoint : fixedPlanPoint + const liveLength = getWallCurveLength({ + start: previewStart, + end: previewEnd, + curveOffset: target.wall.curveOffset, + }) + const wallHeight = target.wall.height ?? DEFAULT_WALL_HEIGHT + const dimMidX = (previewStart[0] + previewEnd[0]) / 2 + const dimMidZ = (previewStart[1] + previewEnd[1]) / 2 + return ( + + +