Files
editor/packages/nodes/src/wall/move-endpoint-tool.tsx
T
4fca38a3ef fix: make room walls and slabs join cleanly (#497)
* fix(editor): wall endpoint move — detach/attach modifier sync and zero-move drop

- restore linked walls to original positions the moment alt-detach engages
- re-run the endpoint preview on alt keydown/keyup so re-attach doesn't wait for a mousemove; preview, HUD badge, and commit share one alt source
- second click at an unmoved position cleanly drops the endpoint (no history entry) instead of leaving the interaction stuck
- 2D floorplan parity: clear stale linked overrides on detach, re-apply drag sessions on modifier changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): demote orphaned auto slabs/ceilings to manual instead of deleting

Deleting a wall that encloses a room no longer destroys the room's
slab/ceiling (paint, holes, elevation). Unmatched auto surfaces are only
deleted when >=60% of their footprint is still covered by a detected room
(rooms-merged case); otherwise they are demoted to manual nodes.

- demoted slab polygons are baked (inset by SLAB_OUTSET + AUTO_SLAB_INSET)
  so the rendered footprint doesn't jump between the auto and manual paths
- auto-creation suppression now also matches manual nodes by mutual
  footprint coverage, so re-closing the room doesn't stack a duplicate
  auto surface on the demoted one

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): hide alt-to-detach hint when the moving endpoint has no linked walls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): split host walls in every snap mode, stop wall chain on existing walls

Splitting a room with a wall now splits the walls it lands on and migrates
hosted doors/windows/items — matching what already happened to the slab and
ceiling. The split machinery existed but was gated on magnetic snapping
('lines' mode) while the wall tool defaults to 'grid'; split resolution now
always runs, with the join radius scoped to the active mode (0.35 magnetic,
0.05 connect snap otherwise), and the whole commit lands as one undo step.

The drawing chain now terminates when a committed segment ends on a wall
outside the current chain (T-junction), like the room auto-close — users
don't draw overlapping walls. Applied in 3D and 2D, and the 2D-only path
gains the previously missing wallClosesRoom parity check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): per-edge build-time slab offsets — slabs tile at wall centerlines

Replaces the stored-polygon render offsets (auto: centroid inset 0.02,
manual: flat outset 0.05) with one uniform per-edge rule computed at
geometry build time, for auto and manual slabs alike:

- edge shared with a sibling slab (collinear-overlap test, T-junction
  sub-segments included) → small relief inset; adjacent rooms tile
  exactly and can never overlap
- edge on a wall centerline with no slab neighbor → expand outward by
  that wall's thickness/2, flush with the facade
- free edge → rendered exactly as drawn

Slab demotion no longer bakes polygons (offsets never live in node
data); no stored-data migration. New slab system marks level slabs
dirty when wall geometry/thickness or sibling slab footprints change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): import KTX2Loader from its deep path, not the jsm aggregate

The Addons.js aggregate re-exports LottieLoader/TTFLoader whose CDN URL
imports (lottie-web, opentype.js) abort bun test in every package that
transitively imports the viewer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): split host wall when an endpoint move lands on its interior

Moving a wall endpoint onto another wall's mid-span now splits that wall
and migrates its doors/windows/items, matching the draw tool — previously
the room closed (detection planarizes internally) but the wall node stayed
whole. New resolveEndpointWallSplit reuses the draw path's split pipeline;
endpoint write + split commit as one undo step. Applied in 3D and 2D.
Corner drops still join without splitting; straddling openings skip the
split; alt-detached commits split the stationary former sibling correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): slab-wall adoption — absolute edge projection, band snapping, rendered-footprint wall elevation

Slab edges near a wall now render projected onto the wall's exact
geometry instead of getting relative offsets, healing legacy scenes
(face-aligned edges, old baked outsets, hand-drag slop) with no data
migration:

- adoption band: an edge collinear with a wall centerline within
  thickness/2 + 6cm (both sides) is wall-backed; nearest centerline
  wins, a sibling slab in the same band forces the interior seam
- wall-backed exterior edges project to the outer face; interior edges
  to the centerline minus the relief gap; free edges render as drawn
- slab edge resize: wall snapping is now edge-based, not cursor-based —
  fixes the drop landing short by the grab offset (0.34m arrow gap in
  3D, hit-stroke slop in 2D); the snap translates the edge onto the
  wall centerline (canonical stored position), beacon/preview/commit
  agree, full band in lines mode, 5cm stick otherwise; 2D shows a
  dashed stored-boundary skeleton when it diverges from the fill
- wall elevation tests the slab's rendered footprint instead of the
  stored polygon with a 0.1mm epsilon, so walls sit on legacy slabs
  and re-elevate when slabs are reshaped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): subcut slab edges per backing span

A slab edge backed differently along its length (offset rooms sharing a
wall over part of the span, a wall shorter than the edge, collinear
walls of different thickness) is now subdivided at the backing-span
breakpoints; each sub-span classifies and projects independently, with
a perpendicular step connector at intra-edge transitions that lands
inside the crossing wall's footprint. Breakpoints closer than 5cm merge
so no sliver geometry reaches the ring, and same-target spans re-fuse
(curved-wall sampling doesn't balloon vertex counts; whole-edge cases
render bit-identically to before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): wall elevation picks the slab that supports it, not the highest graze

A wall touching a raised slab only at an endpoint no longer lifts
entirely to that slab. Elevation selection is now coverage-based:
per-slab support is the wall's centerline+face length covered by the
slab's rendered footprint minus holes; slabs within 0.1mm of elevation
pool their support (party walls spanning two rooms still lift); the
wall sits on the highest elevation covering >=50% of its length, else
the best-covered group (ties prefer higher), else 0. Sub-5cm grazes
are ignored outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): interior slab edges meet exactly at the wall centerline

Removes the 2cm interior relief inset: both rooms' seam edges project
onto the same centerline (or the symmetric sibling midline when no wall
backs the seam), so adjacent slabs tile with a shared edge — the 4cm
slit under shared walls, visible at its open end on the facade, is
gone. Safe against z-fighting because slab side quads are single-sided
(FrontSide is enforced repo-wide for the MRT scene pass) and the
coincident seam faces have opposite normals; junction step pockets grow
to 5cm but remain strictly inside the intersecting wall footprints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): elevation-aware interior slab seams — close the vertical band slit

With unequal room elevations, both slabs stopping at the shared wall's
centerline left the lower room's half of the wall band open between the
lower slab top and the wall base (the wall seats on the higher slab).
Interior seams across a wall are now elevation-aware: equal elevations
keep the exact centerline seam; unequal elevations project BOTH slabs
to the wall face on the lower side — the higher slab runs through the
band under the wall, the lower butts the same plane. Wall-less unequal
seams keep the sibling midline (a visible step face is correct there).
Slab elevation joins the level dependency signature so height edits
rebuild sibling slabs live.

Verified against real local scenes: all wall bands solid where the
seam rule applies (remaining pockets are the known flat-wall-base
limitation, tracked separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): run wind displacement before the instance transform

The r184->r185 upgrade broke the plant wind: r185 fixed TSL's statement
emission order so a material's positionNode now runs *after* the
instancing transform (r184 emitted it before — verified in the compiled
WGSL of both versions). The wind nodes were tuned against the r184
order, reading positionLocal as geometry-local coordinates, so on r185
the displacement moved into level space: sway no longer scaled with the
per-instance scale (scaled-down trees thrashed like a storm, leaf cards
visibly detaching from branches), leaf phase followed world placement,
and STEM_BEND's height term read the floor elevation, so grass/flowers
on upper levels slid around rigidly.

Restore the r184 semantics explicitly: WindNodeMaterial assigns the wind
node to positionLocal inside setupPosition() before super applies the
instance transform, instead of using positionNode. The emitted WGSL is
statement-for-statement identical to r184's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): resolve slab joins across floor elevations

* fix(editor): keep slab resize arrows visible

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:28:29 -04:00

678 lines
24 KiB
TypeScript

'use client'
import {
type AnyNodeId,
collectAlignmentAnchors,
DEFAULT_WALL_HEIGHT,
emitter,
type GridEvent,
getWallCurveLength,
getWallThickness,
pauseSceneHistory,
resolveAlignment,
resumeSceneHistory,
runAsSingleSceneHistoryStep,
useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
CursorSphere,
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
isAlignmentGuideActive,
isAngleSnapActive,
isMagneticSnapActive,
isSegmentLongEnough,
MeasurementPill,
markToolCancelConsumed,
resolveEndpointWallSplit,
snapWallDraftPointDetailed,
triggerSFX,
useAlignmentGuides,
useInteractionScope,
useWallSnapIndicator,
WALL_CONNECT_SNAP_RADIUS,
type WallPlanPoint,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Wall endpoint move tool (kind-owned).
*
* Press-drag-release: the endpoint handle's pointerdown activates this
* tool; cursor movement updates the preview (snap → linked-wall cascade
* → Alt-detach); pointerup commits if the endpoint actually moved, else
* dismisses without committing.
*
* Mounted via `def.affordanceTools['move-endpoint']` from
* `wall/definition.ts`. Triggered by an `endpoint` reshape scope; ToolManager
* reconstructs this `target` from the reshaped node + the scope's endpoint.
*/
export type MovingWallEndpoint = {
wall: WallNode
endpoint: 'start' | 'end'
}
/** Figma-style alignment-snap threshold (meters), matching the item move /
* placement tools. 8 cm gives a magnetic pull without fighting grid snap. */
const ALIGNMENT_THRESHOLD_M = 0.08
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type WallSegmentLike = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
curveOffset?: number
}
type AngleLabelState = {
label: string
position: [number, number, number]
} | null
function getEndpointAngleLabel(args: {
preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
walls: WallSegmentLike[]
nodeId: WallNode['id']
}): AngleLabelState {
const { preview, walls, nodeId } = args
const endpoints = [{ point: preview.start }, { point: preview.end }]
const targetSegment: WallSegmentLike = {
id: nodeId,
start: preview.start,
end: preview.end,
curveOffset: preview.curveOffset,
}
for (const endpoint of endpoints) {
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
if (!targetReference) continue
const connectedWall = walls.find(
(wall) =>
wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
)
if (!connectedWall) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
if (angle === null) continue
return {
label: formatAngleRadians(angle),
position: [endpoint.point[0], 0.34, endpoint.point[1]],
}
}
return null
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
curveOffset?: number
}
function getLinkedWallSnapshots(args: {
wallId: WallNode['id']
wallParentId: string | null
originalStart: WallPlanPoint
originalEnd: WallPlanPoint
}) {
const { wallId, wallParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedWallSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'wall' && node.id !== wallId)) continue
if ((node.parentId ?? null) !== wallParentId) continue
if (
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
)
continue
snapshots.push({
id: node.id,
start: [...node.start] as WallPlanPoint,
end: [...node.end] as WallPlanPoint,
curveOffset: node.curveOffset,
})
}
return snapshots
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
) {
return linkedWalls.map((wall) => ({
id: wall.id,
curveOffset: wall.curveOffset,
start: samePoint(wall.start, originalStart)
? nextStart
: samePoint(wall.start, originalEnd)
? nextEnd
: wall.start,
end: samePoint(wall.end, originalStart)
? nextStart
: samePoint(wall.end, originalEnd)
? nextEnd
: wall.end,
}))
}
export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
const previousGridPosRef = useRef<WallPlanPoint | null>(null)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.wall.id)
const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
const originalEndRef = useRef<WallPlanPoint>([...target.wall.end] as WallPlanPoint)
const fixedPointRef = useRef<WallPlanPoint>(
target.endpoint === 'start'
? ([...target.wall.end] as WallPlanPoint)
: ([...target.wall.start] as WallPlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedWallSnapshots({
wallId: target.wall.id,
wallParentId: target.wall.parentId ?? null,
originalStart: target.wall.start,
originalEnd: target.wall.end,
}),
)
const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const unit = useViewer((s) => s.unit)
// Alt-detach only affects walls sharing the moving endpoint; walls linked
// solely to the fixed endpoint never move, so the hint would be noise.
const movingOriginal =
target.endpoint === 'start' ? originalStartRef.current : originalEndRef.current
const canDetachCorner = linkedOriginalsRef.current.some(
(wall) => samePoint(wall.start, movingOriginal) || samePoint(wall.end, movingOriginal),
)
const exitMoveMode = useCallback(() => {
useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'endpoint')
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const fixedPoint = fixedPointRef.current
const levelWalls = Object.values(useScene.getState().nodes).filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
)
// Alignment candidates — anchors of every OTHER alignable object (walls,
// fences, items, slabs, ceilings, columns), gathered once (the set is
// stable during the drag). Coords are building-local, the same frame as
// the cursor and the 3D guide layer, so the published guide lines up.
const wallAlignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, nodeId)
pauseSceneHistory(useScene)
let wasCommitted = false
// Last point handed to `applyPreview` — lets the Alt keydown/keyup
// handlers re-run the preview immediately on a modifier change instead of
// waiting for the next mousemove.
let lastMovedPoint: WallPlanPoint | null = null
// The first pointer-up is the *grab* of a click-to-move; later ones are
// drops. See the `!hasChanged` branch in `onPointerUp`.
let hasReleasedOnce = false
// Wall ids carrying a live position override during the drag. Mirrors the
// 3D/2D wall MOVE tools: preview via `useLiveNodeOverrides` (the wall
// system, wall panel, and 2D floor plan all merge it) instead of writing
// the scene store every tick. A per-tick `updateNodes` hands a fresh `nodes`
// reference to every `useScene(s => s.nodes)` subscriber (sidebar panels,
// contextual HUD, tooltips, floor plan) and rebuilds them all each frame.
// The store is written ONCE, atomically, on commit.
const touchedWallIds = new Set<AnyNodeId>()
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
) => {
const overrides = useLiveNodeOverrides.getState()
const sceneState = useScene.getState()
overrides.setMany(
updates.map(
(entry) =>
[entry.id, { start: entry.start, end: entry.end }] as [string, Record<string, unknown>],
),
)
for (const entry of updates) {
touchedWallIds.add(entry.id as AnyNodeId)
sceneState.markDirty(entry.id as AnyNodeId)
}
}
// Drop every live override (mesh + miters revert to the scene store, which
// was never mutated during the drag) and re-dirty so geometry rebuilds.
const clearPreviewOverrides = () => {
const overrides = useLiveNodeOverrides.getState()
const sceneState = useScene.getState()
for (const id of touchedWallIds) {
overrides.clear(id)
sceneState.markDirty(id)
}
touchedWallIds.clear()
}
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
lastMovedPoint = movingPoint
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedWalls
? []
: getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
if (detachLinkedWalls) {
// Attach→detach transition: `setMany` only writes the ids it is
// handed, so linked walls dragged on earlier attached ticks would keep
// their stale overrides. Drop them so their corners snap back to the
// scene originals (untouched during the drag).
const overrides = useLiveNodeOverrides.getState()
const sceneState = useScene.getState()
for (const linked of linkedOriginalsRef.current) {
if (touchedWallIds.delete(linked.id as AnyNodeId)) {
overrides.clear(linked.id)
sceneState.markDirty(linked.id as AnyNodeId)
}
}
}
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel(
getEndpointAngleLabel({
preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
walls: [
...levelWalls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...linkedUpdates,
],
nodeId,
}),
)
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
}
const restoreOriginal = (clearAngleLabel = true) => {
clearPreviewOverrides()
if (clearAngleLabel) {
setAngleLabel(null)
}
}
// Eat the click the browser fires right after the commit pointerup so it
// doesn't fall through to the wall body and arm the wall move tool.
const swallowNextClick = () => {
const swallow = (e: Event) => {
e.stopPropagation()
e.preventDefault()
}
window.addEventListener('click', swallow, { capture: true, once: true })
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
}
const onGridMove = (event: GridEvent) => {
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Endpoint move honours the active snapping mode (the HUD chip): grid →
// lattice; lines → magnetic corner/alignment snap; angles → lock the
// segment to 15° rays from the FIXED corner; off → raw. No Shift bypass —
// Shift cycles the mode now, and Off is the bypass.
const snapResult = snapWallDraftPointDetailed({
point: planPoint,
walls: levelWalls,
ignoreWallIds: [nodeId],
start: fixedPoint,
angleSnap: isAngleSnapActive(),
magnetic: isMagneticSnapActive(),
})
const snappedPoint = snapResult.point
// Figma-style alignment: nudge the dragged endpoint onto another wall /
// fence endpoint or midpoint axis when within threshold, and publish a
// guide. The resolver connects to the NEAREST real anchor of the
// candidate, so the dot always sits on an actual point (endpoint /
// midpoint), never an empty-space bbox corner. Layered on top of the
// grid + corner snap above; Alt is reserved for corner-detach here.
// Alignment lines are DISPLAYED in every mode except Off
// (isAlignmentGuideActive); the magnetic pull onto them is applied only in
// 'lines' mode (isMagneticSnapActive).
let alignedPoint = snappedPoint
if (isAlignmentGuideActive() && wallAlignmentCandidates.length > 0) {
const ar = resolveAlignment({
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
candidates: wallAlignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
const magnetic = isMagneticSnapActive()
if (ar.snap && magnetic) {
alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz]
}
// Non-magnetic modes don't pull onto a guide, so only surface guides
// whose anchor is within connect distance — a corner shouldn't magnetise
// the dot from farther than any other point on the wall. See wall draft.
useAlignmentGuides
.getState()
.set(
magnetic
? ar.guides
: ar.guides.filter(
(guide) =>
Math.hypot(
snappedPoint[0] - guide.anchor.x,
snappedPoint[1] - guide.anchor.z,
) <= WALL_CONNECT_SNAP_RADIUS,
),
)
} else {
useAlignmentGuides.getState().clear()
}
if (
previousGridPosRef.current &&
(alignedPoint[0] !== previousGridPosRef.current[0] ||
alignedPoint[1] !== previousGridPosRef.current[1])
) {
triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = alignedPoint
// Stand the magnetic beacon at the endpoint when it locked onto existing
// wall geometry (corner / midpoint / crossing / wall body).
useWallSnapIndicator
.getState()
.set(
snapResult.snap
? { x: alignedPoint[0], z: alignedPoint[1], kind: snapResult.snap }
: null,
)
// The keydown listener can't observe an Alt press that predates the
// tool mounting; the pointer event can. Sync the shared ref (single Alt
// source for preview, HUD badge, and commit) before applying.
if (event.nativeEvent.altKey !== altPressedRef.current) {
altPressedRef.current = event.nativeEvent.altKey
setAltPressed(event.nativeEvent.altKey)
}
applyPreview(alignedPoint, altPressedRef.current)
}
const onPointerUp = () => {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
// The handle sits on the wall body, so the browser fires a click on the
// wall after this release. Swallow it on EVERY endpoint-tool release (a
// no-drag tap dismisses, a drag commits) — otherwise that click falls
// through to the selection manager and arms the wall MOVE tool, a mode the
// user never asked for.
swallowNextClick()
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
// Endpoint still at its original spot. The FIRST release is the *grab*
// of a click-to-move (a tap on the handle, or a press that never
// dragged): stay armed so the endpoint keeps following the cursor — a
// press-drag and a click thus engage identically. Any LATER release at
// an unchanged position is a deliberate drop: end the interaction
// cleanly (previews restored, scope ended, no history entry) instead of
// leaving the user stuck until they move the mouse.
if (!hasChanged) {
if (!hasReleasedOnce) {
hasReleasedOnce = true
return
}
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
return
}
if (isSegmentLongEnough(preview.start, preview.end)) {
wasCommitted = true
const linkedUpdates = altPressedRef.current
? []
: getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
)
// Drop the live overrides; the store write below is the source of truth.
// The store sat at the pre-drag (original) values the whole drag — only
// overrides moved — so one resume+write records original→final as a
// single tracked change (one Ctrl-Z reverts to original). The split
// ops (create halves, migrate attachments, delete host) would each
// push their own entry, so the whole commit runs as one history step.
clearPreviewOverrides()
resumeSceneHistory(useScene)
runAsSingleSceneHistoryStep(useScene, () => {
// Dropping the endpoint on another wall's interior splits that host
// like the draw path does. Linked walls updated in this commit share
// the drop point as an endpoint (a corner join, not a split), so
// they're excluded along with the moved wall — in Alt-detach mode
// `linkedUpdates` is empty and a stationary former sibling can be
// split like any other host.
const movingPoint = target.endpoint === 'start' ? preview.start : preview.end
const resolved = resolveEndpointWallSplit({
point: movingPoint,
levelId: target.wall.parentId ?? null,
ignoreWallIds: [nodeId, ...linkedUpdates.map((u) => String(u.id))],
})
const finalPoint = resolved ?? movingPoint
useScene.getState().updateNodes([
{
id: nodeId as AnyNodeId,
data: {
start: target.endpoint === 'start' ? finalPoint : preview.start,
end: target.endpoint === 'end' ? finalPoint : preview.end,
},
},
...linkedUpdates.map((u) => ({
id: u.id as AnyNodeId,
data: {
start: samePoint(u.start, movingPoint) ? finalPoint : u.start,
end: samePoint(u.end, movingPoint) ? finalPoint : u.end,
},
})),
])
useScene.getState().markDirty(nodeId as AnyNodeId)
for (const u of linkedUpdates) {
useScene.getState().markDirty(u.id as AnyNodeId)
}
})
pauseSceneHistory(useScene)
triggerSFX('sfx:item-place')
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
setAngleLabel(null)
exitMoveMode()
}
const onCancel = () => {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
setAngleLabel(null)
markToolCancelConsumed()
exitMoveMode()
}
// Single Alt writer for keyboard transitions. Re-running the preview on
// the flip keeps geometry and the HUD badge in lockstep — detach reverts
// the linked walls instantly, re-attach snaps them onto the dragged point
// — without waiting for the next mousemove.
const setAltState = (pressed: boolean) => {
if (altPressedRef.current === pressed) return
altPressedRef.current = pressed
setAltPressed(pressed)
if (lastMovedPoint) {
applyPreview(lastMovedPoint, pressed)
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Alt') {
setAltState(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Alt') {
setAltState(false)
}
}
const onWindowBlur = () => {
setAltState(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
return () => {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
if (!wasCommitted) {
restoreOriginal(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)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
// Live segment dimensions for the floating pill. The moving endpoint is
// `cursorLocalPos`; the other end is fixed. Length tracks the drag (curve
// offset is unchanged by an endpoint move); height + thickness are static.
const movingPlanPoint: WallPlanPoint = [cursorLocalPos[0], cursorLocalPos[2]]
const fixedPlanPoint = fixedPointRef.current
const previewStart = target.endpoint === 'start' ? movingPlanPoint : fixedPlanPoint
const previewEnd = target.endpoint === 'end' ? movingPlanPoint : fixedPlanPoint
const liveLength = getWallCurveLength({
start: previewStart,
end: previewEnd,
curveOffset: target.wall.curveOffset,
})
const wallHeight = target.wall.height ?? DEFAULT_WALL_HEIGHT
const dimMidX = (previewStart[0] + previewEnd[0]) / 2
const dimMidZ = (previewStart[1] + previewEnd[1]) / 2
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
center
position={[dimMidX, wallHeight + 0.3, dimMidZ]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<MeasurementPill
height={wallHeight}
length={liveLength}
primary="length"
thickness={getWallThickness(target.wall)}
unit={unit}
/>
</Html>
{canDetachCorner && (
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground'
}`}
>
{altPressed ? 'Detaching corner' : 'Alt to detach'}
</div>
</div>
</Html>
)}
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group>
)
}
function EndpointAngleLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
export default MoveWallEndpointTool