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}