* 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>
678 lines
24 KiB
TypeScript
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
|