* 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>
698 lines
26 KiB
TypeScript
698 lines
26 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNodeId,
|
|
type AutoCeilingSyncPlan,
|
|
type AutoSlabSyncPlan,
|
|
type CeilingNode,
|
|
detectSpacesForLevel,
|
|
emitter,
|
|
type GridEvent,
|
|
getPerpendicularWallMoveAxis,
|
|
getPlannedLinkedWallUpdates,
|
|
pauseSceneHistory,
|
|
planAutoCeilingsForLevel,
|
|
planAutoSlabsForLevel,
|
|
planWallMoveJunctions,
|
|
projectAutoSlabsForPlan,
|
|
resumeSceneHistory,
|
|
type SlabNode,
|
|
useLiveNodeOverrides,
|
|
useScene,
|
|
type WallMoveAxis,
|
|
type WallMoveJunctionPlan,
|
|
type WallNode,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
CursorSphere,
|
|
EDITOR_LAYER,
|
|
getSegmentGridStep,
|
|
isSegmentLongEnough,
|
|
markToolCancelConsumed,
|
|
snapBuildingLocalToWorldGrid,
|
|
snapScalarToGrid,
|
|
triggerSFX,
|
|
useEditor,
|
|
} from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
|
|
import {
|
|
buildBridgeWallCreates,
|
|
buildBridgeWallPreviews,
|
|
type GhostWallPreview,
|
|
getLinkedWallSnapshots,
|
|
getWallsAfterUpdates,
|
|
type LinkedWallSnapshot,
|
|
stripWallIsNewMetadata,
|
|
} from './move-shared'
|
|
|
|
/**
|
|
* Phase 5 Stage D — wall whole-move tool (kind-owned).
|
|
*
|
|
* 1:1 port of the legacy `MoveWallTool` (804 LoC, the most complex
|
|
* single tool in the editor). Preserves every behavior:
|
|
*
|
|
* - **Center-drag with axis lock** — wall stays perpendicular to its
|
|
* move axis unless rotated via R/T (45° steps).
|
|
* - **Linked-wall corner cascade** — neighbours sharing endpoints
|
|
* move with the dragged wall via `planWallMoveJunctions`.
|
|
* - **Bridge wall ghost previews** — when a corner separates, a
|
|
* translucent ghost shows the new wall that would be inserted.
|
|
* - **Auto-slab live preview** — `planAutoSlabsForLevel` runs every
|
|
* tick so room slabs adapt to the new wall layout in real time.
|
|
* - **Single-undo dance** — paused history during drag, restore +
|
|
* resume + reapply on commit so one Ctrl-Z rolls back the whole
|
|
* operation.
|
|
* - **`isNew` metadata strip** — first commit after a fresh wall
|
|
* placement clears the placement marker.
|
|
* - **Activation grace** (150ms) + Shift to bypass grid snap.
|
|
*
|
|
* Mounted via `def.affordanceTools.move` from `wall/definition.ts`.
|
|
*/
|
|
function rotateVector([x, z]: [number, number], angle: number): [number, number] {
|
|
const cos = Math.cos(angle)
|
|
const sin = Math.sin(angle)
|
|
return [x * cos - z * sin, x * sin + z * cos]
|
|
}
|
|
|
|
function getLevelSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
|
return Object.values(nodes).filter(
|
|
(entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
|
|
)
|
|
}
|
|
|
|
function getLevelCeilings(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
|
return Object.values(nodes).filter(
|
|
(entry): entry is CeilingNode =>
|
|
entry?.type === 'ceiling' && (entry.parentId ?? null) === levelId,
|
|
)
|
|
}
|
|
|
|
function setPreviewGeometryAttributes(
|
|
geometry: BufferGeometry,
|
|
positions: number[],
|
|
normals: number[],
|
|
uvs: number[],
|
|
) {
|
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
|
|
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
|
|
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
|
|
}
|
|
|
|
function createWallPreviewGeometry(length: number, height: number) {
|
|
const geometry = new BufferGeometry()
|
|
setPreviewGeometryAttributes(
|
|
geometry,
|
|
[0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0],
|
|
[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
|
[0, 0, 1, 0, 1, 1, 0, 1],
|
|
)
|
|
geometry.setIndex([0, 1, 2, 0, 2, 3])
|
|
geometry.computeBoundingSphere()
|
|
return geometry
|
|
}
|
|
|
|
function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
|
|
const dx = preview.end[0] - preview.start[0]
|
|
const dz = preview.end[1] - preview.start[1]
|
|
const length = Math.hypot(dx, dz)
|
|
const angle = -Math.atan2(dz, dx)
|
|
const geometry = useMemo(() => {
|
|
return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height)
|
|
}, [length, preview.height])
|
|
|
|
useEffect(() => () => geometry?.dispose(), [geometry])
|
|
|
|
if (!geometry) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<group position={[preview.start[0], 0.02, preview.start[1]]} rotation={[0, angle, 0]}>
|
|
<mesh
|
|
// Pass geometry as a prop so the mesh never renders with R3F's
|
|
// default empty `BufferGeometry`. With `frustumCulled={false}`,
|
|
// the `<primitive attach="geometry">` path emits one frame of
|
|
// `Draw(0, 1, 0, 0)` against an empty buffer and WebGPU flags it
|
|
// (see wall-move-side-handles.tsx).
|
|
frustumCulled={false}
|
|
geometry={geometry}
|
|
layers={EDITOR_LAYER}
|
|
renderOrder={2}
|
|
>
|
|
<meshBasicMaterial
|
|
color={preview.color}
|
|
depthTest={false}
|
|
depthWrite={false}
|
|
opacity={0.32}
|
|
side={DoubleSide}
|
|
transparent
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|
const meta =
|
|
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
|
? (node.metadata as Record<string, unknown>)
|
|
: {}
|
|
const isNew = !!meta.isNew
|
|
const hasDraggedRef = useRef(false)
|
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
|
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
|
const originalCenterRef = useRef<[number, number]>([
|
|
(node.start[0] + node.end[0]) / 2,
|
|
(node.start[1] + node.end[1]) / 2,
|
|
])
|
|
const originalHalfVectorRef = useRef<[number, number]>([
|
|
(node.end[0] - node.start[0]) / 2,
|
|
(node.end[1] - node.start[1]) / 2,
|
|
])
|
|
const moveAxisRef = useRef<WallMoveAxis | null>(
|
|
getPerpendicularWallMoveAxis(node.start, node.end),
|
|
)
|
|
const linkedOriginalsRef = useRef<LinkedWallSnapshot[]>(
|
|
isNew
|
|
? []
|
|
: getLinkedWallSnapshots({
|
|
wallId: node.id,
|
|
wallParentId: node.parentId ?? null,
|
|
originalStart: node.start,
|
|
originalEnd: node.end,
|
|
}),
|
|
)
|
|
const dragAnchorRef = useRef<[number, number] | null>(null)
|
|
const nodeIdRef = useRef(node.id)
|
|
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
|
|
const pendingRotationRef = useRef(0)
|
|
const shiftPressedRef = useRef(false)
|
|
|
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
const centerX = (node.start[0] + node.end[0]) / 2
|
|
const centerZ = (node.start[1] + node.end[1]) / 2
|
|
return [centerX, 0, centerZ]
|
|
})
|
|
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
|
|
|
|
const exitMoveMode = useCallback(() => {
|
|
useEditor.getState().setMovingNode(null)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const nodeId = nodeIdRef.current
|
|
const originalStart = originalStartRef.current
|
|
const originalEnd = originalEndRef.current
|
|
const originalCenter = originalCenterRef.current
|
|
const originalHalfVector = originalHalfVectorRef.current
|
|
const levelId = node.parentId ?? null
|
|
pauseSceneHistory(useScene)
|
|
let shouldRestoreOnCleanup = true
|
|
|
|
const applyNodePreview = (
|
|
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
|
|
) => {
|
|
useScene.getState().updateNodes(
|
|
updates.map((entry) => ({
|
|
id: entry.id as AnyNodeId,
|
|
data: { start: entry.start, end: entry.end },
|
|
})),
|
|
)
|
|
for (const entry of updates) {
|
|
useScene.getState().markDirty(entry.id as AnyNodeId)
|
|
}
|
|
}
|
|
|
|
// Live auto-slab / auto-ceiling preview. Calculates the new
|
|
// surfaces from the moving wall configuration each tick, then
|
|
// sends polygon overrides to `useLiveNodeOverrides` (and marks
|
|
// the slab / ceiling dirty) so `GeometrySystem` / `CeilingSystem`
|
|
// rebuild the mesh through the existing `getEffectiveNode` merge
|
|
// path. *Nothing* is written to the scene store during the drag —
|
|
// the store stays at pre-drag values; commit writes the final
|
|
// plan in one atomic `applyNodeChanges`. Mirrors the wall's own
|
|
// 2D drag pattern (`useLiveNodeOverrides` → mesh, store on
|
|
// commit).
|
|
//
|
|
// Creates / deletes from the plan are *deferred* to commit. We
|
|
// can't represent a "node that doesn't exist yet" through
|
|
// overrides, so newly-detected rooms only get their slab/ceiling
|
|
// materialised on release; rooms whose closing wall pulls away
|
|
// keep their slab/ceiling visible (at the original polygon) until
|
|
// commit clears them. UX-wise this trades the previous "ghost
|
|
// appears/disappears mid-drag" feedback for atomic, undoable
|
|
// commits — which is what the user asked for.
|
|
//
|
|
// `latestSurfacePlans` holds the most recent plan from the last
|
|
// live tick. `commitSurfacesToStore` flushes it (creates,
|
|
// updates, deletes) into the scene as part of the commit-time
|
|
// atomic write, then `clearSurfaceOverrides` drops the override
|
|
// map so the system reads from the now-committed store.
|
|
let latestSurfacePlans: { slabs: AutoSlabSyncPlan; ceilings: AutoCeilingSyncPlan } | null = null
|
|
const touchedSlabIds = new Set<AnyNodeId>()
|
|
const touchedCeilingIds = new Set<AnyNodeId>()
|
|
|
|
const publishLiveSurfaceOverrides = (walls: WallNode[]) => {
|
|
if (!levelId) return
|
|
|
|
const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId)
|
|
const sceneState = useScene.getState()
|
|
const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls)
|
|
|
|
// Plan against the pre-drag scene — `getLevelSlabs/Ceilings`
|
|
// reads from the unchanged store, so the matcher's "remap
|
|
// existing room → existing slab" logic sees stable IDs across
|
|
// ticks. Without this anchor, IDs would drift as overrides
|
|
// re-flowed through the planner.
|
|
const existingSlabs = getLevelSlabs(levelId, sceneState.nodes)
|
|
const slabPlan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
|
|
const ceilingPlan = planAutoCeilingsForLevel(
|
|
roomPolygons,
|
|
getLevelCeilings(levelId, sceneState.nodes),
|
|
{
|
|
walls: levelWalls,
|
|
slabs: projectAutoSlabsForPlan(existingSlabs, slabPlan),
|
|
},
|
|
)
|
|
|
|
latestSurfacePlans = { slabs: slabPlan, ceilings: ceilingPlan }
|
|
|
|
const overrideEntries: Array<[string, Record<string, unknown>]> = []
|
|
for (const update of slabPlan.update) {
|
|
if (update.data.polygon === undefined) continue
|
|
overrideEntries.push([update.id, { polygon: update.data.polygon }])
|
|
touchedSlabIds.add(update.id as AnyNodeId)
|
|
}
|
|
for (const update of ceilingPlan.update) {
|
|
if (update.data.polygon === undefined && update.data.height === undefined) continue
|
|
overrideEntries.push([update.id, update.data as Record<string, unknown>])
|
|
touchedCeilingIds.add(update.id as AnyNodeId)
|
|
}
|
|
|
|
if (overrideEntries.length > 0) {
|
|
useLiveNodeOverrides.getState().setMany(overrideEntries)
|
|
for (const [id] of overrideEntries) {
|
|
sceneState.markDirty(id as AnyNodeId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Commit-time: flush the last live plan into the scene store as
|
|
// one atomic write, then drop overrides so the renderer reads
|
|
// from the now-current store state. Called from `commitPreview`
|
|
// *while history is resumed*, so the entire surface delta is one
|
|
// undoable step alongside the wall update.
|
|
const commitSurfacesToStore = () => {
|
|
if (!levelId || !latestSurfacePlans) return
|
|
|
|
const { slabs, ceilings } = latestSurfacePlans
|
|
const update = [
|
|
...slabs.update.map((entry) => ({
|
|
id: entry.id as AnyNodeId,
|
|
data: entry.data,
|
|
})),
|
|
...ceilings.update.map((entry) => ({
|
|
id: entry.id as AnyNodeId,
|
|
data: entry.data,
|
|
})),
|
|
]
|
|
const create = [
|
|
...slabs.create.map((slab) => ({
|
|
node: slab,
|
|
parentId: levelId as AnyNodeId,
|
|
})),
|
|
...ceilings.create.map((ceiling) => ({
|
|
node: ceiling,
|
|
parentId: levelId as AnyNodeId,
|
|
})),
|
|
]
|
|
const deleteIds = [
|
|
...slabs.delete.map((id) => id as AnyNodeId),
|
|
...ceilings.delete.map((id) => id as AnyNodeId),
|
|
]
|
|
|
|
if (update.length === 0 && create.length === 0 && deleteIds.length === 0) return
|
|
|
|
useScene.getState().applyNodeChanges({
|
|
update,
|
|
create,
|
|
delete: deleteIds,
|
|
})
|
|
}
|
|
|
|
const clearSurfaceOverrides = () => {
|
|
const overrides = useLiveNodeOverrides.getState()
|
|
for (const id of touchedSlabIds) overrides.clear(id)
|
|
for (const id of touchedCeilingIds) overrides.clear(id)
|
|
touchedSlabIds.clear()
|
|
touchedCeilingIds.clear()
|
|
latestSurfacePlans = null
|
|
}
|
|
|
|
const buildWallFromCenter = (center: [number, number]) => {
|
|
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
|
|
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
|
|
const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]]
|
|
return { start: nextStart, end: nextEnd }
|
|
}
|
|
|
|
const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) =>
|
|
planWallMoveJunctions(
|
|
linkedOriginalsRef.current,
|
|
originalStart,
|
|
originalEnd,
|
|
nextStart,
|
|
nextEnd,
|
|
)
|
|
|
|
const getLinkedPreviewUpdates = (
|
|
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
|
|
nextStart: [number, number],
|
|
nextEnd: [number, number],
|
|
) => {
|
|
const movedUpdates = getPlannedLinkedWallUpdates(
|
|
plan,
|
|
originalStart,
|
|
originalEnd,
|
|
nextStart,
|
|
nextEnd,
|
|
)
|
|
const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry]))
|
|
|
|
return linkedOriginalsRef.current.map(
|
|
(wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end },
|
|
)
|
|
}
|
|
|
|
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
|
|
previewRef.current = { start: nextStart, end: nextEnd }
|
|
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
|
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
|
setCursorLocalPos([centerX, 0, centerZ])
|
|
const previewPlan = getMovePlan(nextStart, nextEnd)
|
|
const previewUpdates = [
|
|
{ id: nodeId, start: nextStart, end: nextEnd },
|
|
...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd),
|
|
]
|
|
const previewCollapsedWallIds = new Set([
|
|
...previewUpdates
|
|
.filter((entry) => entry.id !== nodeId && !isSegmentLongEnough(entry.start, entry.end))
|
|
.map((entry) => entry.id as AnyNodeId),
|
|
...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
|
])
|
|
const previewSceneWalls = getWallsAfterUpdates(
|
|
useScene.getState().nodes,
|
|
previewUpdates.map((entry) => ({
|
|
id: entry.id as AnyNodeId,
|
|
data: { start: entry.start, end: entry.end },
|
|
})),
|
|
).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId))
|
|
const bridgePreviews = buildBridgeWallPreviews({
|
|
bridgePlans: previewPlan.bridgePlans,
|
|
nextStart,
|
|
nextEnd,
|
|
existingWalls: previewSceneWalls,
|
|
})
|
|
const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost)
|
|
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
|
|
setGhostWallPreviews(nextGhostWalls)
|
|
applyNodePreview(previewUpdates)
|
|
publishLiveSurfaceOverrides([...previewSceneWalls, ...virtualBridgeWalls])
|
|
}
|
|
|
|
const restoreOriginal = () => {
|
|
setGhostWallPreviews([])
|
|
applyNodePreview([
|
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
...linkedOriginalsRef.current,
|
|
])
|
|
// No scene rollback for surfaces — nothing was written. Just
|
|
// clear the live overrides so the renderer falls back to the
|
|
// (pre-drag, unchanged) store state.
|
|
clearSurfaceOverrides()
|
|
}
|
|
|
|
const onGridMove = (event: GridEvent) => {
|
|
const rawX = event.localPosition[0]
|
|
const rawZ = event.localPosition[2]
|
|
const snapStep = getSegmentGridStep()
|
|
|
|
// Anchor at the raw cursor so the displacement is measured in
|
|
// continuous space.
|
|
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
|
|
dragAnchorRef.current = anchor
|
|
|
|
const rawDeltaX = rawX - anchor[0]
|
|
const rawDeltaZ = rawZ - anchor[1]
|
|
|
|
// When the move is axis-locked (side-handle drag), snap the wall
|
|
// center's absolute perpendicular offset to a multiple of
|
|
// `snapStep`. For axis-aligned walls this puts the wall on grid
|
|
// lines along its normal regardless of the wall's starting
|
|
// position; for diagonal walls the wall steps in exact
|
|
// `snapStep` increments along its normal.
|
|
//
|
|
// Why absolute (perp · centre) rather than `delta` snap: snapping
|
|
// the displacement alone preserves any pre-existing off-grid
|
|
// offset, so the wall could only ever sit at `originalCentre ±
|
|
// k·snapStep` — never on actual grid lines. Snapping the wall's
|
|
// own perpendicular coordinate fixes that.
|
|
const axis = moveAxisRef.current
|
|
let deltaX: number
|
|
let deltaZ: number
|
|
if (axis) {
|
|
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
|
|
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1]
|
|
const snappedProj = shiftPressedRef.current ? rawProj : snapScalarToGrid(rawProj, snapStep)
|
|
const perpDelta = snappedProj - originalProj
|
|
deltaX = axis[0] * perpDelta
|
|
deltaZ = axis[1] * perpDelta
|
|
} else if (shiftPressedRef.current) {
|
|
deltaX = rawDeltaX
|
|
deltaZ = rawDeltaZ
|
|
} else {
|
|
// 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]
|
|
|
|
if (
|
|
previousGridPosRef.current &&
|
|
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
|
|
constrainedGridPos[1] !== previousGridPosRef.current[1])
|
|
) {
|
|
triggerSFX('sfx:grid-snap')
|
|
}
|
|
previousGridPosRef.current = constrainedGridPos
|
|
|
|
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
|
|
const nextWall = buildWallFromCenter(nextCenter)
|
|
hasDraggedRef.current = true
|
|
applyPreview(nextWall.start, nextWall.end)
|
|
}
|
|
|
|
const commitPreview = () => {
|
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
|
|
shouldRestoreOnCleanup = false
|
|
|
|
// Restore original baseline while paused so the next resume+update
|
|
// registers as a single tracked change (undo reverts to original).
|
|
// Surfaces stayed in the store the whole drag (override-driven
|
|
// mesh preview), so there's nothing to restore for them.
|
|
setGhostWallPreviews([])
|
|
applyNodePreview([
|
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
...linkedOriginalsRef.current,
|
|
])
|
|
|
|
resumeSceneHistory(useScene)
|
|
const commitPlan = getMovePlan(preview.start, preview.end)
|
|
const linkedWallUpdates = getPlannedLinkedWallUpdates(
|
|
commitPlan,
|
|
originalStart,
|
|
originalEnd,
|
|
preview.start,
|
|
preview.end,
|
|
)
|
|
const collapsedLinkedWallIds = new Set([
|
|
...linkedWallUpdates
|
|
.filter((entry) => !isSegmentLongEnough(entry.start, entry.end))
|
|
.map((entry) => entry.id as AnyNodeId),
|
|
...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
|
])
|
|
|
|
const commitUpdates = [
|
|
{
|
|
id: nodeId as AnyNodeId,
|
|
data: isNew
|
|
? {
|
|
start: preview.start,
|
|
end: preview.end,
|
|
metadata: stripWallIsNewMetadata(node.metadata),
|
|
}
|
|
: { start: preview.start, end: preview.end },
|
|
},
|
|
...linkedWallUpdates
|
|
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
|
|
.map((entry) => ({
|
|
id: entry.id as AnyNodeId,
|
|
data: { start: entry.start, end: entry.end },
|
|
})),
|
|
]
|
|
const sceneState = useScene.getState()
|
|
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
|
|
(wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
|
|
)
|
|
const bridgeCreates = buildBridgeWallCreates({
|
|
bridgePlans: commitPlan.bridgePlans,
|
|
nextStart: preview.start,
|
|
nextEnd: preview.end,
|
|
existingWalls,
|
|
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
|
|
})
|
|
sceneState.applyNodeChanges({
|
|
update: commitUpdates,
|
|
create: bridgeCreates,
|
|
delete: Array.from(collapsedLinkedWallIds),
|
|
})
|
|
|
|
// Flush the last live surface plan (slab + ceiling creates,
|
|
// updates, deletes) into the store while history is still
|
|
// resumed, so the surface delta joins the wall change as one
|
|
// undoable step. Then drop the live overrides — the renderer
|
|
// now reads the committed polygons directly.
|
|
commitSurfacesToStore()
|
|
clearSurfaceOverrides()
|
|
|
|
pauseSceneHistory(useScene)
|
|
|
|
// Claim teardown ownership so the 2D overlay's cleanup skips its
|
|
// own revert when split-view has both mounted — see
|
|
// `movingNodeOrigin` in `use-editor.tsx`.
|
|
useEditor.getState().setMovingNodeOrigin('3d')
|
|
|
|
triggerSFX('sfx:item-place')
|
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
exitMoveMode()
|
|
}
|
|
|
|
const onPointerUp = () => {
|
|
// Press-release without drag: dismiss the tool without committing.
|
|
// This is the same UX as MoveWallEndpointTool / WallHeightArrowHandle
|
|
// — pointer-down on the affordance starts the move, drag updates the
|
|
// preview, release commits if the cursor actually moved.
|
|
if (!hasDraggedRef.current) {
|
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
exitMoveMode()
|
|
return
|
|
}
|
|
commitPreview()
|
|
}
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Shift') {
|
|
shiftPressedRef.current = true
|
|
return
|
|
}
|
|
|
|
const ROTATION_STEP = Math.PI / 4
|
|
let rotationDelta = 0
|
|
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
|
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
|
|
|
if (rotationDelta === 0) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
pendingRotationRef.current += rotationDelta
|
|
triggerSFX('sfx:item-rotate')
|
|
|
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
const currentCenter: [number, number] = [
|
|
(preview.start[0] + preview.end[0]) / 2,
|
|
(preview.start[1] + preview.end[1]) / 2,
|
|
]
|
|
const nextWall = buildWallFromCenter(currentCenter)
|
|
moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end)
|
|
applyPreview(nextWall.start, nextWall.end)
|
|
}
|
|
|
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
if (event.key === 'Shift') {
|
|
shiftPressedRef.current = false
|
|
}
|
|
}
|
|
|
|
const onCancel = () => {
|
|
shouldRestoreOnCleanup = false
|
|
restoreOriginal()
|
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
resumeSceneHistory(useScene)
|
|
markToolCancelConsumed()
|
|
// Claim teardown ownership so the 2D overlay doesn't redundantly
|
|
// revert the same baseline on its own cleanup.
|
|
useEditor.getState().setMovingNodeOrigin('3d')
|
|
exitMoveMode()
|
|
}
|
|
|
|
emitter.on('grid:move', onGridMove)
|
|
emitter.on('tool:cancel', onCancel)
|
|
window.addEventListener('pointerup', onPointerUp)
|
|
window.addEventListener('keydown', onKeyDown)
|
|
window.addEventListener('keyup', onKeyUp)
|
|
|
|
return () => {
|
|
if (shouldRestoreOnCleanup) {
|
|
// `shouldRestoreOnCleanup` is only true if neither `onPointerUp`
|
|
// (commit branch) nor `onCancel` ran — i.e., the unmount came
|
|
// from outside (typically the 2D overlay finalising in split
|
|
// view). The origin flag tells us whether the 2D side committed
|
|
// (skip restore — its write is the live state) or the unmount
|
|
// has no claimed owner (restore to baseline).
|
|
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
|
if (!finalisedBy2D) {
|
|
restoreOriginal()
|
|
}
|
|
}
|
|
shiftPressedRef.current = false
|
|
resumeSceneHistory(useScene)
|
|
emitter.off('grid:move', onGridMove)
|
|
emitter.off('tool:cancel', onCancel)
|
|
window.removeEventListener('pointerup', onPointerUp)
|
|
window.removeEventListener('keydown', onKeyDown)
|
|
window.removeEventListener('keyup', onKeyUp)
|
|
}
|
|
}, [exitMoveMode, isNew, node.metadata, node.parentId])
|
|
|
|
return (
|
|
<group>
|
|
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
|
{ghostWallPreviews.map((preview) => (
|
|
<GhostWallPreviewMesh key={preview.id} preview={preview} />
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export default MoveWallTool
|