editor: building rotation, alt-click single wall, world-grid alignment, tap-to-engage move (#388)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* Snapshot: ridge vent locks to segment ridge during placement, free sliders after

Placement tool clamps the cursor to the segment's ridge line for all roof
types (gable, hip, shed, gambrel, dutch, mansard; flat rejected). Renderer
re-derives Y from the live surface plus a small lift, and treats stored
position[1] / position[2] as user offsets so the inspector sliders move
the vent after placement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(roof): contribute outer silhouette to alignment-guide candidates

Roof has no centred-box footprint (it's the union of its `roof-segment`
children) so the capability bridge needs the resolved AABB directly. We
build it from the children's corners in roof-local space, then transform
to world coords. Roofs only contribute as static candidates — the
move-roof tool drives them by origin, so the relocatable-box path never
applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(floorplan): upright text geometry that stays horizontal under scene rotation

Floor-plan text labels were rotating with the 90° default scene rotation,
making zone names read sideways. Add an `upright` flag to text geometry:
when set, the registry layer counter-rotates the label by sceneRotationDeg
around its anchor so it reads horizontally on screen. Zone labels opt in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): redesign material-paint cursor badge and gate floor-plan paint mode

Paint cursor swaps the inline icon+label chip for a stacked badge — a
glowing accent stem pointing down to the hit point with the paint icon
above — so the cue reads as "pointer" rather than "tooltip". Switches
the accent to indigo. The 2D floor-plan now also shows the paint-icon
overlay and routes pointer interactions to the painter when in
material-paint mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): 90° rotate action on the floating building menu

Adds a Rotate button to the building action menu (and a generic `onRotate`
slot to NodeActionMenu). Each click rotates the building 90° CW around
its world-bbox center, mirroring the offset compensation
`MoveBuildingContent` uses during R/T-rotate drags — so the building
spins in place instead of orbiting its (often off-centre) origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(column,shelf): show cursor sphere during placement preview

Matches the placement affordance other floor-placed tools already have
so users see the snap point as a discrete dot alongside the ghost mesh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ifc-converter): update next-env types path to .next/dev/types

Next.js moved generated route types under `.next/dev/types/` — regenerate
the reference so the type-check resolves them again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ridge-vent): track parent segment's live overrides for real-time follow

While dragging a roof-segment resize handle (width / depth / pitch /
wallHeight / rotation), the new dimensions stream through
useLiveNodeOverrides and only flush to the store on release — so the
ridge vent was snapping to the new ridge only after the drag ended.
Merge segment overrides into the renderer's segment view so the vent
rides the resize in real time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): step building rotation in 15° increments

Cuts the floating building menu's rotate-step from 90° to 15° so each
click nudges the building rather than slamming it to the next cardinal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): world-frame alignment guides and grid snap under building rotation

Brings the 2D floor-plan alignment + grid behaviour in line with 3D so
guides and snap follow the world XZ grid regardless of how the active
building is rotated.

Core: adds resolveAlignmentInBuildingWorld + snapWorldXZToBuildingLocal,
plus a shared BuildingPose type, so callers can resolve in world space
while staying in building-local coords downstream.

Editor: introduces packages/editor/src/lib/world-grid-snap.ts as the
single entry point for tools that need world-frame alignment / grid
snap (resolveAlignmentForActiveBuilding, getActiveBuildingPose,
snapWorldXZForActiveBuilding, snapBuildingLocalToWorldGrid). Honours
useLiveTransforms and resolves the active building via level.parentId
first so the helper stays in sync with the floor-plan panel.

Alignment store unification: applyFloorplanAlignment and the registry
move overlay now publish guides in world XZ — the same frame the 3D
tools already use — so the shared useAlignmentGuides store carries one
consistent coordinate system. The 2D layer renders world XZ via the
fixed world → SVG transform (a 90° rotation around the building's
world position; independent of building rotation), so guides come out
parallel to the world-axis-aligned floor-plan grid in every case.

Drafting + move sessions across walls, fences, slabs, ceilings,
columns, items, shelves, roofs, stairs, zones and elevators are
re-routed through the new helpers so cursor placement, grid snap,
draft endpoints, and move commits all land on the world XZ grid
even when the building has been rotated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(editor): drop building drag-rotate and grid building-XZ chase

Two behaviours added recently were causing the world grid to slide
around during building edits. Reverts both, keeping the rest of the
world-frame alignment work intact.

- floating-building-action-menu / node-action-menu: roll the rotate
  button back to a single click that increments rotation by 15°
  around the bbox center (state at 3eb02fde). The drag-rotate from
  b50c0d5a streamed live position updates per frame, which the
  floor plan and grid both followed.
- grid: stop chasing the active building's world XZ each frame
  (pre-c00a2469 behaviour). The grid stays anchored at world (0, 0)
  so it doesn't visibly sweep when the building origin moves —
  during a drag, an R/T rotation in the move tool, or a click
  rotate that re-anchors the origin to the bbox-pivot. Y still
  lerps to the active level's floor height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): consistent building rotation pivot + 2D position sync

Several connected fixes to building rotate/move so the 3D mesh, the
2D floor plan, and alignment guides all stay visually consistent
across rotations and drags.

Rotation-invariant pivot
- New `getBuildingLocalBboxCenter` helper walks descendant mesh
  geometry bounds in the building's LOCAL frame, returning a
  centroid that doesn't depend on the building's current world
  rotation. The previous approach derived the pivot from the world
  AABB at the current rotation, which made the local pivot subtly
  drift each time the building was rotated.
- Floating menu click-rotate and move-tool R/T rotation now seed
  their pivot from this helper, so successive rotations stay
  pinned at the same world point.

Move-tool stability
- R/T rotation always pivots around the current world bbox center,
  even before the cursor has moved onto the grid (previously it
  fell back to rotating around the origin in that case).
- Commit (`onGridClick`) now writes the mesh's actual on-screen
  pose instead of recomputing it from the click event's grid
  coords, removing a one-cell snap on release.

Alignment resolver
- Tie-break is now perp-first, primary-second. The previous order
  was reliable when wall faces shared an exact axis-aligned coord
  (pre-rotation), but post-rotation float deltas meant primary
  never tied and the resolver could lock onto the far corner of a
  candidate. Perp-first picks the visually closest point.

2D ↔ 3D sync
- The floor-plan SVG scene group now applies a live `translate(...)`
  derived from `(committedBuildingPosition → live buildingPosition)`,
  in addition to the existing live rotation. Building-local content
  slides in lockstep with the 3D mesh during a drag.
- The alignment-guide layer projects from world XZ using the
  COMMITTED building position, so guides stay locked to their world
  coordinates while the building slides past them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Merge remote-tracking branch 'origin/main' into fix/fir-5-june

* feat(wall): alt-click commits a single wall instead of chaining

Holding Alt on the click that completes a segment now stops drafting
instead of starting a new segment from the just-placed endpoint. Same
behaviour wired in both the 3D wall tool and the 2D floor-plan
placement hook (which already passed `singleWall: event.altKey` —
the 2D handler was ignoring the flag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(editor): drop 15° rotate from floating building action menu

Removes the rotate button on the floating building menu and the
`getBuildingLocalBboxCenter` pivot helper that only existed to support
it. The selected-group rotate handle remains the way to rotate a
building.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ifc-converter): point next-env at .next/types instead of .next/dev/types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): unify 3D move grip with floating-menu Move via tap-to-engage

The translate gizmo on the 3D bounding box now hands the node to the
registry move tool (same path as the floating action menu's Move
button) instead of running its own plane-drag. One move flow — green
bounding box, cursor follow, alignment guides, R/T rotation,
click-to-commit — drives both entry points.

Adds a `dragBounds` capability on the node registry so kinds whose
rendered mesh contains extras (per-level landing assemblies on an
elevator, etc.) can declare the box the user thinks of as "the thing
being dragged." Elevator and stair opt in; everything else still
auto-measures. `MoveRoofTool` now shows the green box for whole-stair
and whole-roof moves, and aligns roofs by their footprint corners
(previously only stairs did). Slab floor-plan moves clear
`autoFromWalls` on commit so space-detection doesn't snap the slab
back, via a new `extraCommitFields` hook on the polygon-centroid
move target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): 2D alignment guides in building-local frame + world-grid wall snap

The 2D floor-plan alignment layer now mounts inside the rotated scene
<g> and reads from an editor-local `useAlignmentGuides` store —
guides are stored in building-local meters and follow the SVG
transform that already carries the rest of the floor-plan geometry.
Pill labels are counter-rotated by the scene rotation so they stay
upright under building rotation. The 3D pipeline keeps its own
world-frame store; the two surfaces no longer share frames.

Wall drafting picks up an optional `gridSnap` override so the 2D
floor-plan can snap drafts onto the world-XZ grid even when the
active building is rotated — without it, a rotated building's
drafted wall would chase the local grid and miss the visible lines.
`snapPointTo45Degrees` and `snapWallDraftPointDetailed` route through
the override when supplied; otherwise the prior local-axis grid snap
is unchanged. The floor-plan panel wires `snapBuildingLocalToWorldGrid`
through the placement hook and exports `WALL_GRID_STEP` to support it.

Also fixes the minor/major grid stroke palette swap so minor lines
render with the minor palette and majors with the major palette.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(editor): drop dead onRotate slot, memoize dragBounds snapshot

Removes the unreachable `onRotate` prop, `RotateCw` import, and
rotate button from `NodeActionMenu` — its only caller was the
floating building menu's 15° rotate, which was removed in d65fd5fe.

Memoizes `MoveRegistryNodeTool`'s `dragBounds` lookup so it captures
the scene snapshot once per node instead of re-reading
`useScene.getState().nodes` on every render. Bounds depend only on
`node` (locked for the tool's lifetime) and start-time sibling state
(elevator shaft height from the level set), so a one-shot snapshot is
the right semantics — and it avoids implying a live subscription that
isn't there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: biome check --write (import order, formatting, unused import)

Auto-fixes from `bun check:fix`:
- import-order normalization in editor/nodes/core
- unused `snapToGrid` / `useLayoutEffect` imports dropped
- minor whitespace/wrapping tweaks

No behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-06-09 11:05:27 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent b620c9d502
commit 555cf06c12
43 changed files with 948 additions and 295 deletions
+25 -9
View File
@@ -137,21 +137,20 @@ function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
function elevatorMoveHandle(): HandleDescriptor<ElevatorNodeType> {
return {
kind: 'translate',
// Tap-to-engage: hand the elevator to its move tool (same path the
// floating action menu's Move button takes via `setMovingNode`) so the
// 3D grip and the floating-UI button share one move flow — green
// bounding box, alignment guides, R/T rotation, click-to-commit.
kind: 'tap-action',
shape: 'move-cross',
cursor: 'move',
onActivate: (node, _scene, editor) => editor.engageMove(node),
placement: {
position: (n) => {
const { halfZ } = elevatorOuterHalfExtents(n)
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const { halfX, halfZ } = elevatorOuterHalfExtents(n)
const dimX = Math.max(halfX * 2, MIN_ELEVATOR_DIM)
const dimZ = Math.max(halfZ * 2, MIN_ELEVATOR_DIM)
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
},
}
}
@@ -205,6 +204,23 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
rotation: [0, e.rotation ?? 0, 0],
}
},
// Drag box wraps just the OUTER SHAFT × full shaft height — same footprint
// alignment uses, same height the rendered shell occupies. Without this
// override, `DragBoundingBox` would measure the whole mesh tree (per-level
// landing assemblies, cab interior, buttons) and the box would feel
// vertically off-centre when the elevator's lowest served level isn't the
// building origin.
dragBounds: (node, nodes) => {
const e = node as ElevatorNodeType
const { halfX, halfZ } = elevatorOuterHalfExtents(e)
const { shaftBaseY, totalHeight } = resolveElevatorLevels(e, nodes ?? {})
const cabHeight = Math.max(e.cabHeight, 1.4)
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
return {
size: [halfX * 2, shaftHeight, halfZ * 2],
centerY: shaftBaseY + shaftHeight / 2,
}
},
duplicable: true,
deletable: true,
},
@@ -16,10 +16,12 @@ import {
type FencePlanPoint,
getSegmentGridStep,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
snapFenceDraftPoint,
snapScalarToGrid,
useAlignmentGuides,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP,
} from '@pascal-app/editor'
/**
@@ -159,12 +161,14 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
// Endpoint move = grid snap only; the 45°-from-start angle
// snap is draft-only. Shift switches to the fine grid step for
// precision, matching the 3D fence endpoint action.
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint,
walls: nextWalls,
fences: nextFences,
ignoreFenceIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep) as FencePlanPoint,
})
// Figma-style alignment on the dragged endpoint — snaps it onto
// another object's edge / wall face and publishes a guide, matching
+2 -6
View File
@@ -3,7 +3,6 @@
import {
type AnyNode,
type AnyNodeId,
getActiveRoofHeight,
RidgeVentNode as RidgeVentSchema,
type RoofSegmentNode,
useScene,
@@ -217,11 +216,8 @@ export default function RidgeVentPanel() {
/>
<SliderControl
label="Y"
max={Math.max(
(segment?.wallHeight ?? 3) + (segment ? getActiveRoofHeight(segment) : 3) + 2,
(node.position[1] ?? 0) + 0.1,
)}
min={Math.min(0, (node.position[1] ?? 0) - 0.5)}
max={2}
min={-2}
onChange={(v) =>
handleUpdate({
position: [node.position[0] ?? 0, v, node.position[2] ?? 0],
+33 -9
View File
@@ -18,6 +18,7 @@ import {
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap'
import { getSurfaceY } from '../shared/roof-surface'
import { buildRidgeVentGeometry } from './geometry'
@@ -66,11 +67,26 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
? ({ ...storeNode, ...overrides } as RidgeVentNode)
: storeNode
const segment = useScene((state) =>
const segmentStore = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
// Subscribe to the segment's live overrides too — when the user drags a
// segment handle (width / depth / wallHeight / pitch / rotation), the
// dimensions stream through `useLiveNodeOverrides` and don't hit the
// store until release. Merging them lets the ridge ride the segment in
// real time instead of snapping into place on commit.
const segmentOverrides = useLiveNodeOverrides((s) =>
node.roofSegmentId
? (s.get(node.roofSegmentId as AnyNodeId) as Partial<RoofSegmentNode> | undefined)
: undefined,
)
const segment: RoofSegmentNode | undefined = segmentStore
? segmentOverrides
? ({ ...segmentStore, ...segmentOverrides } as RoofSegmentNode)
: segmentStore
: undefined
const geometry = useMemo(
() => buildRidgeVentGeometry(node),
@@ -109,18 +125,26 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
const segPos = segment.position ?? [0, 0, 0]
const segRotY = segment.rotation ?? 0
// Seat the vent on the ridge by DERIVING its Y from the segment's current
// surface rather than the stored `position[1]`. The ridge height comes from
// the segment's pitch (`getActiveRoofHeight`), so when the roof is lowered
// the segment updates, this renderer re-runs, and the vent rides the ridge
// down automatically — no stale floating cap. X/Z stay as authored (the vent
// straddles the ridge line at localZ≈0).
const ridgeY = getSurfaceY(node.position[0] ?? 0, node.position[2] ?? 0, segment)
// Lock the BASE position to the ridge so the vent always starts on the
// slope top; treat `position[1]` and `position[2]` as user-tunable OFFSETS
// off that base (Y above ridge lift, Z away from ridge centerline). So
// after placement the inspector's Y / Z sliders nudge the vent off the
// locked ridge without losing the slope-tracking base. X is the position
// along the ridge — the snap re-clamps it to the segment's ridge span.
const snap = resolveRidgeSnap(segment, node.position[0] ?? 0, 0)
const ridgeX = snap ? snap.localX : (node.position[0] ?? 0)
const baseZ = snap ? snap.localZ : 0
const baseY = getSurfaceY(ridgeX, baseZ, segment) + RIDGE_LIFT
// Clamp legacy stored Y (absolute peak height from earlier versions) so the
// vent doesn't fly off when the field was an absolute Y instead of offset.
const yOffset = Math.max(-2, Math.min(2, node.position[1] ?? 0))
const ridgeY = baseY + yOffset
const ridgeZ = baseZ + (node.position[2] ?? 0)
return (
<group position={segPos} rotation-y={segRotY}>
<group
position={[node.position[0] ?? 0, ridgeY, node.position[2] ?? 0]}
position={[ridgeX, ridgeY, ridgeZ]}
ref={ref}
rotation-y={node.rotation ?? 0}
visible={node.visible}
+13 -5
View File
@@ -13,6 +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 { resolveRidgeSnap } from '../shared/ridge-snap'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { ridgeVentDefinition } from './definition'
import RidgeVentPreview from './preview'
@@ -65,12 +66,18 @@ const RidgeVentTool = () => {
)
if (!hit) return
// Snap the cursor to the ridge by zeroing localZ via the
// segment's local frame, then convert back through the building.
// Project the cursor onto the segment's ridge line (clamped to the
// segment's ridge span). The preview then moves ALONG the ridge as the
// cursor moves — never off it. Flat segments have no ridge: hide.
const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
if (!snap) {
setPreviewPos(null)
return
}
const segObj = sceneRegistry.nodes.get(hit.segment.id)
let ridgeWorld: [number, number, number]
if (segObj) {
const ridgeLocal = new THREE.Vector3(hit.localX, hit.localY, 0)
const ridgeLocal = new THREE.Vector3(snap.localX, hit.localY, snap.localZ)
segObj.updateWorldMatrix(true, false)
ridgeLocal.applyMatrix4(segObj.matrixWorld)
ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z]
@@ -99,14 +106,15 @@ const RidgeVentTool = () => {
event.position[2],
)
if (!hit) return
const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
if (!snap) return
const state = useScene.getState()
const vent = RidgeVentNode.parse({
...ridgeVentDefinition.defaults(),
name: 'Ridge Vent',
roofSegmentId: hit.segment.id,
// Snap Z to 0 — ridge vents straddle the ridge line.
position: [hit.localX, hit.localY, 0],
position: [snap.localX, 0, snap.localZ],
rotation: 0,
})
state.createNode(vent, hit.segment.id as AnyNodeId)
+45
View File
@@ -108,6 +108,51 @@ export const roofDefinition: NodeDefinition<typeof RoofNode> = {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
// Contribute a plan AABB to the alignment-guide candidate pool so a roof
// (and any moving sibling) snaps against the roof's outer silhouette.
// Roof has no centred-box footprint — it's the union of its
// `roof-segment` children — so we hand the bridge a resolved `aabb`
// directly. The roof moves by its origin via `move-roof-tool`, so it
// only ever contributes static candidates; the relocatable-box path
// never needs to apply to roofs.
alignmentFootprint: (node, nodes) => {
const roof = node as RoofNodeType
if (!nodes) return null
const cos = Math.cos(roof.rotation ?? 0)
const sin = Math.sin(roof.rotation ?? 0)
let minX = Number.POSITIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
let any = false
for (const childId of roof.children ?? []) {
const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
if (segment?.type !== 'roof-segment') continue
const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2
const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2
const sCos = Math.cos(segment.rotation ?? 0)
const sSin = Math.sin(segment.rotation ?? 0)
for (const [cx, cz] of [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
] as const) {
// Segment corner → roof-local.
const rx = segment.position[0] + cx * sCos + cz * sSin
const rz = segment.position[2] - cx * sSin + cz * sCos
// Roof-local → world (apply roof rotation, then position).
const wx = roof.position[0] + rx * cos + rz * sin
const wz = roof.position[2] - rx * sin + rz * cos
if (wx < minX) minX = wx
if (wx > maxX) maxX = wx
if (wz < minZ) minZ = wz
if (wz > maxZ) maxZ = wz
any = true
}
}
return any ? { shape: 'aabb', minX, minZ, maxX, maxZ } : null
},
},
// Bespoke free-floating move (drag-to-place with R/T rotation and
+18 -3
View File
@@ -20,6 +20,7 @@ import {
import {
CursorSphere,
commitFreshPlacementSubtree,
DragBoundingBox,
getFloorStackPreviewPosition,
resolvePlanarCursorPosition,
snapFenceDraftPoint,
@@ -56,6 +57,7 @@ export const MoveRoofTool: React.FC<{
const previousGridPosRef = useRef<[number, number] | null>(null)
const dragAnchorRef = useRef<[number, number] | null>(null)
const [previewRotation, setPreviewRotation] = useState<number>(movingNode.rotation as number)
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(movingNode.id)
if (obj) {
@@ -187,8 +189,8 @@ export const MoveRoofTool: React.FC<{
// Alignment for top-level stair / roof only. Segments live in parent-local
// space (a different frame from the building-local candidate pool / guide
// layer), so we leave them on the plain grid+corner snap. Stairs align by
// their footprint edges; roofs keep the origin-point behavior.
// layer), so we leave them on the plain grid+corner snap. Both stair and
// roof align by their footprint bounding-box corners.
const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof'
const alignmentCandidates = alignTopLevel
? collectAlignmentAnchors(
@@ -203,7 +205,7 @@ export const MoveRoofTool: React.FC<{
return [lx, lz]
}
const moving =
movingNode.type === 'stair'
movingNode.type === 'stair' || movingNode.type === 'roof'
? movingAlignmentAnchors(movingNode, useScene.getState().nodes, lx, lz, pendingRotation)
: []
const ar = resolveAlignment({
@@ -418,6 +420,7 @@ export const MoveRoofTool: React.FC<{
triggerSFX('sfx:item-rotate')
pendingRotation += rotationDelta
setPreviewRotation(pendingRotation)
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
@@ -479,9 +482,21 @@ export const MoveRoofTool: React.FC<{
}
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
// Green footprint box during whole-stair / whole-roof moves. Skipped for
// segments — their cursor lives in parent-local space and the bounding box
// would render in the wrong frame.
const showBoundingBox = movingNode.type === 'stair' || movingNode.type === 'roof'
return (
<group visible={cursorVisible}>
<CursorSphere position={cursorWorldPos} showTooltip={false} />
{showBoundingBox && (
<DragBoundingBox
nodeId={movingNode.id}
position={cursorWorldPos}
rotationY={previewRotation}
/>
)}
</group>
)
}
@@ -67,8 +67,15 @@ export function createPolygonCentroidMoveTarget(args: {
nodes: Record<AnyNodeId, AnyNode>
/** 3D mesh Y the kind's system parks the group at on rebuild. */
meshY: number
/**
* Extra fields merged into the commit payload. Use for kind-specific flags
* that a manual drag should clear — e.g. slab `autoFromWalls: false`, so the
* space-detection sync stops re-deriving the polygon from walls and
* snapping the slab back to its original position.
*/
extraCommitData?: Record<string, unknown>
}): FloorplanMoveTargetSession {
const { node, nodes, meshY } = args
const { node, nodes, meshY, extraCommitData } = args
const id = node.id as AnyNodeId
const typeGuard = node.type
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
@@ -129,12 +136,19 @@ export function createPolygonCentroidMoveTarget(args: {
// so the React render and the kind's geometry rebuild land in the same
// paint (no original-position blink). Only write `holes` for kinds that
// have them (zone has none).
const data: { polygon: Array<[number, number]>; holes?: Array<Array<[number, number]>> } = {
const data: {
polygon: Array<[number, number]>
holes?: Array<Array<[number, number]>>
[key: string]: unknown
} = {
polygon: translatePolygon(originalPolygon, dx, dz),
}
if (hasHoles) {
data.holes = originalHoles.map((h) => translatePolygon(h, dx, dz))
}
if (extraCommitData) {
Object.assign(data, extraCommitData)
}
useScene.getState().updateNodes([{ id, data }])
useScene.getState().markDirty(id)
useLiveTransforms.getState().clear(id)
+48
View File
@@ -0,0 +1,48 @@
import type { RoofSegmentNode } from '@pascal-app/core'
/**
* Shared ridge-line snap math for ridge-vent placement + move tools.
*
* Ridge vents must sit centered on the segment's ridge — off-ridge the
* cap's far half dips into the higher part of the slope ("goes inside"
* the roof). So the placement tools clamp the cursor onto the ridge:
* closest-point projection along the segment's local X axis, with the X
* span clipped to where a real ridge actually exists for that roof type.
*
* Per roof type (the segment's ridge runs along the segment's local X):
* - gable / gambrel / dutch / mansard: ridge spans the full width.
* - hip: ridge is shortened by the hipped ends — spans width depth.
* A square hip (width ≤ depth) collapses to a single apex point.
* - shed: no true ridge — snap to the high eave (z = -depth/2).
* - flat: no ridge at all → return null.
*/
// Standard lift above the analytical slope surface so the cap reads as
// sitting on the shingle course rather than clipping into it. Shared
// with the renderer so live ridge-Y derivation matches placement.
export const RIDGE_LIFT = 0.12
export type RidgeSnap = {
/** Segment-local X of the snapped ridge position. */
localX: number
/** Segment-local Z of the snapped ridge position (0 for peaked roofs). */
localZ: number
}
export function resolveRidgeSnap(
segment: RoofSegmentNode,
cursorLocalX: number,
_cursorLocalZ: number,
): RidgeSnap | null {
const roofType = segment.roofType ?? 'gable'
if (roofType === 'flat') return null
const halfW = (segment.width ?? 0) / 2
const halfD = (segment.depth ?? 0) / 2
const ridgeZ = roofType === 'shed' ? -halfD : 0
const ridgeHalfLength = roofType === 'hip' ? Math.max(0, halfW - halfD) : halfW
const localX = Math.max(-ridgeHalfLength, Math.min(ridgeHalfLength, cursorLocalX))
return { localX, localZ: ridgeZ }
}
+9 -1
View File
@@ -11,4 +11,12 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move
* `meshY = 0`: `GeometrySystem` parks the slab group at y=0 on rebuild.
*/
export const slabFloorplanMoveTarget: FloorplanMoveTarget<SlabNode> = ({ node, nodes }) =>
createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 })
createPolygonCentroidMoveTarget({
node,
nodes,
meshY: 0,
// A user-dragged slab is a manual edit. Clear `autoFromWalls` so the
// space-detection sync doesn't recompute the polygon from the wall loop
// and snap the slab back to its original position.
extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined,
})
+6 -2
View File
@@ -8,7 +8,6 @@ import {
type GridEvent,
type LevelNode,
polygonAnchors,
resolveAlignment,
type SlabNode,
sceneRegistry,
useLiveTransforms,
@@ -17,7 +16,10 @@ import {
} from '@pascal-app/core'
import {
CursorSphere,
getSegmentGridStep,
markToolCancelConsumed,
resolveAlignmentForActiveBuilding,
snapBuildingLocalToWorldGrid,
snapFenceDraftPoint,
triggerSFX,
useAlignmentGuides,
@@ -164,10 +166,12 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return
const gridStep = getSegmentGridStep()
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
})
if (
@@ -189,7 +193,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
// publish a guide. Alt bypasses.
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
const result = resolveAlignmentForActiveBuilding({
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
+8 -9
View File
@@ -352,7 +352,14 @@ function stairRotateHandle(): HandleDescriptor<StairNodeType> {
function stairMoveHandle(): HandleDescriptor<StairNodeType> {
return {
kind: 'translate',
// Tap-to-engage: hand the stair to its `MoveRoofTool` (same path the
// floating action menu's Move button takes via `setMovingNode`) so the
// 3D grip and the floating-UI button share one move flow — green
// bounding box, alignment guides, R/T rotation, click-to-commit.
kind: 'tap-action',
shape: 'move-cross',
cursor: 'move',
onActivate: (node, _scene, editor) => editor.engageMove(node),
placement: {
// Low to the floor at the front edge (matches the item move grip) so it
// reads as a floor-move grip and stays clear of the body resize / rotate
@@ -362,14 +369,6 @@ function stairMoveHandle(): HandleDescriptor<StairNodeType> {
return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + STAIR_MOVE_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n, sceneApi) => {
const bounds = readStairMoveBounds(n, sceneApi)
const dimX = Math.max(bounds.maxX - bounds.minX, MIN_CURVED_WIDTH)
const dimZ = Math.max(bounds.maxZ - bounds.minZ, MIN_CURVED_WIDTH)
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
},
}
}
+9 -6
View File
@@ -16,6 +16,7 @@ import {
CursorSphere,
getSegmentGridStep,
markToolCancelConsumed,
snapBuildingLocalToWorldGrid,
snapScalarToGrid,
triggerSFX,
useEditor,
@@ -85,12 +86,14 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => {
const snapStep = getSegmentGridStep()
const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
// Snap the cursor on the WORLD XZ grid (still in building-local
// coords for the rest of the math) so a rotated building doesn't
// pull the curve handle off the visible grid lines.
const [snappedLocalX, snappedLocalZ] = shiftPressedRef.current
? [event.localPosition[0], event.localPosition[2]]
: snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep)
const localX = snappedLocalX
const localZ = snappedLocalZ
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
@@ -14,10 +14,12 @@ import {
alignFloorplanDraftPoint,
getSegmentGridStep,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
snapScalarToGrid,
snapWallDraftPoint,
useAlignmentGuides,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -111,8 +113,11 @@ export const wallCurveAffordance: FloorplanAffordance<WallNode> = {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapStep = getSegmentGridStep()
const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep)
const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep)
// World-grid snap so a rotated building doesn't drag the curve
// handle off the visible grid.
const [x, y] = modifiers.shiftKey
? [planPoint[0], planPoint[1]]
: snapBuildingLocalToWorldGrid([planPoint[0], planPoint[1]], snapStep)
// Signed projection of (snappedPoint - chord midpoint) onto the
// chord normal. Legacy negates because the SVG y-axis flips
@@ -186,11 +191,13 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
// the angle snap is for initial draft only. Shift switches to
// the fine grid step for precision, matching the 3D
// `MoveWallEndpointTool`.
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
const snapped = snapWallDraftPoint({
point: planPoint as WallPlanPoint,
walls,
ignoreWallIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep),
})
// Figma-style alignment on the dragged corner — snaps it onto another
// object's edge / wall face and publishes a guide. The dragged wall
+15 -2
View File
@@ -29,6 +29,7 @@ import {
getSegmentGridStep,
isSegmentLongEnough,
markToolCancelConsumed,
snapBuildingLocalToWorldGrid,
snapScalarToGrid,
triggerSFX,
useEditor,
@@ -470,9 +471,21 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const perpDelta = snappedProj - originalProj
deltaX = axis[0] * perpDelta
deltaZ = axis[1] * perpDelta
} else if (shiftPressedRef.current) {
deltaX = rawDeltaX
deltaZ = rawDeltaZ
} else {
deltaX = shiftPressedRef.current ? rawDeltaX : snapScalarToGrid(rawDeltaX, snapStep)
deltaZ = shiftPressedRef.current ? rawDeltaZ : snapScalarToGrid(rawDeltaZ, snapStep)
// Snap the resulting wall center to the WORLD XZ grid (projected
// back into building-local), then express the result as a delta
// from the original centre. Without this, a rotated building
// dragged the wall along its local axes instead of world ones.
const targetLocal: [number, number] = [
originalCenter[0] + rawDeltaX,
originalCenter[1] + rawDeltaZ,
]
const snappedLocal = snapBuildingLocalToWorldGrid(targetLocal, snapStep)
deltaX = snappedLocal[0] - originalCenter[0]
deltaZ = snappedLocal[1] - originalCenter[1]
}
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
+7
View File
@@ -686,6 +686,13 @@ export const WallTool: React.FC = () => {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
// Alt commits a single wall — stop drafting instead of chaining
// so the next click starts a fresh start point.
if (event.nativeEvent?.altKey === true) {
stopDrafting()
return
}
const nextStart = createdWall.end
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
endingPoint.current.copy(startingPoint.current)
+1
View File
@@ -105,6 +105,7 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
textAnchor: 'middle',
dominantBaseline: 'central',
opacity: showSelectedChrome ? 1 : 0.92,
upright: true,
})
}