feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf
Implements plans/editor-placement-interaction-overhaul.md: an authoritative interaction-scope state machine plus the catalogued placement/interaction fixes, and split-view floor-plan performance. - Interaction-scope spine (lib/interaction/* + store/use-interaction-scope), driven from central useEditor setters; overlay scoping (zone labels, context badges, floating action menu) reads resolveOverlayPolicy. - Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity, auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset, per-frame jank. - Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier model (Shift=cycle, Alt=free place, Ctrl=grid step). - Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle rig hides during a whole-node move; rotate gizmo advertises Shift=free rotation in the HUD and hides the move cross while rotating. - Floor-plan perf: pause live reactivity while in 3D-only view; per-node geometry cache so only changed nodes rebuild on a drag; hoist wall miters to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b2f1a8432e
commit
f773e6b8c5
@@ -223,6 +223,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
|
||||
// direction + perpendicular for the cutout footprint.
|
||||
floorplan: buildDoorFloorplan,
|
||||
floorplanDependsOnSiblings: true,
|
||||
// Stage D — placement (`def.tool`) + move-on-wall (`def.
|
||||
// affordanceTools.move`). Both ports of the legacy tools at
|
||||
// `editor/components/tools/door/`, relocated into the kind folder and
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
type FencePlanPoint,
|
||||
isMagneticSnapActive,
|
||||
isSegmentLongEnough,
|
||||
snapFenceDraftPoint,
|
||||
useAlignmentGuides,
|
||||
@@ -171,6 +172,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
fences: ctx.levelFences,
|
||||
ignoreFenceIds: [ctx.fenceId as string],
|
||||
bypassSnap: modifiers.shift,
|
||||
magnetic: !modifiers.shift && isMagneticSnapActive(),
|
||||
})
|
||||
|
||||
// Figma-style alignment: nudge the dragged endpoint onto another wall /
|
||||
|
||||
@@ -229,7 +229,6 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Set fence start / end' },
|
||||
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
alignFloorplanDraftPoint,
|
||||
type FencePlanPoint,
|
||||
getSegmentGridStep,
|
||||
isMagneticSnapActive,
|
||||
isSegmentLongEnough,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
snapFenceDraftPoint,
|
||||
@@ -165,7 +166,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
fences: nextFences,
|
||||
ignoreFenceIds: [node.id],
|
||||
bypassSnap: modifiers.shiftKey,
|
||||
magnetic: !modifiers.shiftKey,
|
||||
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
|
||||
})
|
||||
// Figma-style alignment on the dragged endpoint — snaps it onto
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
CursorSphere,
|
||||
consumePlacementDragRelease,
|
||||
isMagneticSnapActive,
|
||||
markToolCancelConsumed,
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
@@ -201,6 +202,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
fences: levelFences,
|
||||
ignoreFenceIds: [fenceId],
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||
})
|
||||
|
||||
if (
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
getAngleArcToSegmentReference,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
isAngleSnapActive,
|
||||
isMagneticSnapActive,
|
||||
markToolCancelConsumed,
|
||||
type SegmentAngleReference,
|
||||
snapFenceDraftPoint,
|
||||
@@ -445,7 +447,6 @@ export const FenceTool: React.FC = () => {
|
||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const buildingState = useRef(0)
|
||||
const shiftPressed = useRef(false)
|
||||
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||
const measurementColor = isDark ? '#ffffff' : '#111111'
|
||||
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
|
||||
@@ -466,10 +467,13 @@ export const FenceTool: React.FC = () => {
|
||||
}
|
||||
|
||||
// Align the drafted point onto another object's nearest real anchor and
|
||||
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
|
||||
// snapping. Returns the possibly snapped point.
|
||||
// publish the guide. Alt bypasses alignment. Returns the possibly snapped
|
||||
// point.
|
||||
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
||||
if (bypass || alignmentCandidates.length === 0) {
|
||||
// Figma alignment pulls the endpoint onto existing corners / edges, so it
|
||||
// is a line snap — suppress it whenever magnetic snap is off (`'off'` /
|
||||
// `'angles'`), matching the fence-geometry snap.
|
||||
if (bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return point
|
||||
}
|
||||
@@ -494,13 +498,13 @@ export const FenceTool: React.FC = () => {
|
||||
if (!(cursorRef.current && previewRef.current)) return
|
||||
const { walls, fences } = getCurrentLevelElements()
|
||||
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
// While drafting, the segment locks to 15° rays from its start
|
||||
// unless Shift is held. Shift also bypasses grid and magnetic snap.
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
||||
// While drafting, the segment locks to 15° rays from its start.
|
||||
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||
// there is no Shift hold-to-bypass. Alt still bypasses alignment guides.
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
const angleLocked = !bypassSnap
|
||||
const angleLocked = isAngleSnapActive()
|
||||
const snappedLocal = alignPoint(
|
||||
snapFenceDraftPoint({
|
||||
point: localPoint,
|
||||
@@ -508,7 +512,7 @@ export const FenceTool: React.FC = () => {
|
||||
fences,
|
||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||
angleSnap: angleLocked,
|
||||
bypassSnap,
|
||||
magnetic: isMagneticSnapActive(),
|
||||
}),
|
||||
bypassAlign || angleLocked,
|
||||
)
|
||||
@@ -516,7 +520,6 @@ export const FenceTool: React.FC = () => {
|
||||
cursorRef.current.position.copy(endingPoint.current)
|
||||
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousFenceEnd &&
|
||||
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
|
||||
) {
|
||||
@@ -543,7 +546,12 @@ export const FenceTool: React.FC = () => {
|
||||
)
|
||||
} else {
|
||||
const snappedPoint = alignPoint(
|
||||
snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }),
|
||||
snapFenceDraftPoint({
|
||||
point: localPoint,
|
||||
walls,
|
||||
fences,
|
||||
magnetic: isMagneticSnapActive(),
|
||||
}),
|
||||
bypassAlign,
|
||||
)
|
||||
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
||||
@@ -559,12 +567,16 @@ export const FenceTool: React.FC = () => {
|
||||
|
||||
const { walls, fences } = getCurrentLevelElements()
|
||||
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = alignPoint(
|
||||
snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }),
|
||||
snapFenceDraftPoint({
|
||||
point: localClick,
|
||||
walls,
|
||||
fences,
|
||||
magnetic: isMagneticSnapActive(),
|
||||
}),
|
||||
bypassAlign,
|
||||
)
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
@@ -574,7 +586,7 @@ export const FenceTool: React.FC = () => {
|
||||
previewRef.current.visible = true
|
||||
setDraftMeasurement(null)
|
||||
} else {
|
||||
const angleLocked = !bypassSnap
|
||||
const angleLocked = isAngleSnapActive()
|
||||
const snappedEnd = alignPoint(
|
||||
snapFenceDraftPoint({
|
||||
point: localClick,
|
||||
@@ -582,7 +594,7 @@ export const FenceTool: React.FC = () => {
|
||||
fences,
|
||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||
angleSnap: angleLocked,
|
||||
bypassSnap,
|
||||
magnetic: isMagneticSnapActive(),
|
||||
}),
|
||||
bypassAlign || angleLocked,
|
||||
)
|
||||
@@ -614,20 +626,6 @@ export const FenceTool: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = true
|
||||
}
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = false
|
||||
}
|
||||
|
||||
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
|
||||
// angle lock isn't stuck off when focus returns.
|
||||
const onBlur = () => {
|
||||
shiftPressed.current = false
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (buildingState.current === 1) {
|
||||
markToolCancelConsumed()
|
||||
@@ -638,17 +636,11 @@ export const FenceTool: React.FC = () => {
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onBlur)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
useSegmentDraftChain.getState().clear('fence')
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ export const gutterDefinition: NodeDefinition<typeof GutterNode> = {
|
||||
parametrics: gutterParametrics,
|
||||
handles: gutterHandles,
|
||||
floorplan: buildGutterFloorplan,
|
||||
floorplanDependsOnSiblings: true,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
|
||||
@@ -315,7 +315,8 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
{ key: 'Left click', label: 'Place item' },
|
||||
{ key: 'R', label: 'Rotate counterclockwise' },
|
||||
{ key: 'T', label: 'Rotate clockwise' },
|
||||
{ key: 'Shift', label: 'Free place' },
|
||||
{ key: 'Shift', label: 'Cycle snapping mode' },
|
||||
{ key: 'Alt', label: 'Free place (no snap)' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
|
||||
})
|
||||
|
||||
const isSelected = ctx.viewState?.selected ?? false
|
||||
const isMoving = ctx.viewState?.moving ?? false
|
||||
const floorPlanUrl = node.asset.floorPlanUrl
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
@@ -214,8 +215,10 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
|
||||
rotation: transform.rotation,
|
||||
})
|
||||
}
|
||||
// Move handle — orange dot at the item center. Only when selected.
|
||||
if (isSelected) {
|
||||
// Move handle — orange dot at the item center. Only when selected and not
|
||||
// already moving: during a move the dot sits under the cursor, so a release
|
||||
// over it would re-arm the move (and re-enter edit) instead of committing.
|
||||
if (isSelected && !isMoving) {
|
||||
children.push({
|
||||
kind: 'move-handle',
|
||||
point: [cx, cy],
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
consumePlacementDragRelease,
|
||||
DragBoundingBox,
|
||||
getFloorStackPreviewPosition,
|
||||
isMagneticSnapActive,
|
||||
resolvePlanarCursorPosition,
|
||||
snapFenceDraftPoint,
|
||||
stripPlacementMetadataFlags,
|
||||
@@ -290,11 +291,13 @@ export const MoveRoofTool: React.FC<{
|
||||
|
||||
const y = event.position[1]
|
||||
|
||||
const roofBypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const snappedLocal = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: roofBypassSnap,
|
||||
magnetic: !roofBypassSnap && isMagneticSnapActive(),
|
||||
})
|
||||
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
|
||||
const [rawLocalX, rawLocalZ] = computeLocal(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
CursorSphere,
|
||||
consumePlacementDragRelease,
|
||||
getSegmentGridStep,
|
||||
isMagneticSnapActive,
|
||||
markToolCancelConsumed,
|
||||
resolveAlignmentForActiveBuilding,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
@@ -174,6 +175,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildWallFloorplan } from './floorplan'
|
||||
import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
|
||||
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { wallFloorplanMoveTarget } from './floorplan-move'
|
||||
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
|
||||
@@ -18,7 +18,8 @@ import { wallSlots } from './slots'
|
||||
* `renderer` + `system` keep wrap-exporting legacy WallRenderer +
|
||||
* WallSystem + WallCutout.
|
||||
* Stage C: `def.floorplan` builder produces the mitered plan footprint
|
||||
* polygon using `ctx.siblings` to assemble miter context.
|
||||
* polygon from shared floor-plan level data, with `ctx.siblings` as the
|
||||
* direct-caller fallback.
|
||||
* floorplan-panel.tsx's `wallPolygons` short-circuits to [] when
|
||||
* wall is registered.
|
||||
*/
|
||||
@@ -98,9 +99,11 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
// Priority 4 mirrors the legacy WallSystem's useFrame priority.
|
||||
priority: 4,
|
||||
},
|
||||
// Stage C: floor-plan rendering. ctx.siblings provides other walls in
|
||||
// the level so `calculateLevelMiters` can compute correct corner joins.
|
||||
// Stage C: floor-plan rendering. Precomputes the level miter graph once
|
||||
// per render pass, then the builder reads its own junctions by wall id.
|
||||
computeFloorplanLevelData: computeWallFloorplanLevelData,
|
||||
floorplan: buildWallFloorplan,
|
||||
floorplanDependsOnSiblings: true,
|
||||
// 2D drag affordances triggered by `endpoint-handle` primitives in
|
||||
// `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the
|
||||
// same legacy `MoveWallEndpointTool` flow, reachable from both the
|
||||
@@ -114,7 +117,6 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Set wall start / end' },
|
||||
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
alignFloorplanDraftPoint,
|
||||
getSegmentGridStep,
|
||||
isMagneticSnapActive,
|
||||
isSegmentLongEnough,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
snapScalarToGrid,
|
||||
@@ -193,7 +194,7 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
|
||||
walls,
|
||||
ignoreWallIds: [node.id],
|
||||
bypassSnap: modifiers.shiftKey,
|
||||
magnetic: !modifiers.shiftKey,
|
||||
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
|
||||
})
|
||||
// Figma-style alignment on the dragged corner — snaps it onto another
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getWallMidpointHandlePoint,
|
||||
getWallPlanFootprint,
|
||||
isCurvedWall,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
@@ -35,6 +36,15 @@ function formatLengthMetric(meters: number): string {
|
||||
return `${Number.parseFloat(meters.toFixed(2))}m`
|
||||
}
|
||||
|
||||
export function computeWallFloorplanLevelData({
|
||||
siblings,
|
||||
}: {
|
||||
siblings: ReadonlyArray<WallNode>
|
||||
nodes: Record<string, AnyNode>
|
||||
}): WallMiterData {
|
||||
return calculateLevelMiters(siblings.map(exaggerateWallThickness))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for wall — emits the full chrome stack the
|
||||
* legacy `floorplan-panel.tsx` rendered inline:
|
||||
@@ -47,21 +57,25 @@ function formatLengthMetric(meters: number): string {
|
||||
* layer hosts the 5-circle stack + hover transitions + 2D drag.
|
||||
* 5. A small dimension label at the midpoint when selected.
|
||||
*
|
||||
* `ctx.siblings` provides other walls in the level so
|
||||
* `calculateLevelMiters` computes correct corner joins.
|
||||
*
|
||||
* Performance note: this recomputes level miter data per wall (O(N²)
|
||||
* across N walls in the level). For < 100 walls per level this is
|
||||
* sub-millisecond. If a real perf hotspot surfaces, the
|
||||
* `ctx.levelData?.miters` extension flagged in the plan moves the batch
|
||||
* computation to the dispatcher.
|
||||
* `ctx.levelData` provides the shared level miter graph when the floor-plan
|
||||
* dispatcher precomputes it; `ctx.siblings` remains the fallback path for
|
||||
* direct builder callers.
|
||||
*/
|
||||
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||
const siblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall')
|
||||
const all = [node, ...siblings].map(exaggerateWallThickness)
|
||||
const miters = calculateLevelMiters(all)
|
||||
const self = all.find((w) => w.id === node.id)
|
||||
if (!self) return null
|
||||
const self = exaggerateWallThickness(node)
|
||||
// Prefer the level-batch miter graph the floor-plan dispatcher precomputes
|
||||
// once per pass (`computeWallFloorplanLevelData`). Only the fallback path —
|
||||
// a direct builder caller with no shared data — pays the O(N) exaggerate +
|
||||
// level-wide miter calc per wall; the dispatcher path is O(1) here, which is
|
||||
// what keeps a wall drag from being O(N²) across the level.
|
||||
const miters =
|
||||
(ctx.levelData as WallMiterData | undefined) ??
|
||||
calculateLevelMiters([
|
||||
self,
|
||||
...ctx.siblings
|
||||
.filter((s): s is AnyNode & WallNode => s.type === 'wall')
|
||||
.map(exaggerateWallThickness),
|
||||
])
|
||||
|
||||
const polygon = getWallPlanFootprint(self, miters)
|
||||
if (!polygon || polygon.length < 3) return null
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
formatAngleRadians,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
isMagneticSnapActive,
|
||||
isSegmentLongEnough,
|
||||
MeasurementPill,
|
||||
type MovingWallEndpoint,
|
||||
@@ -295,7 +296,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
walls: levelWalls,
|
||||
ignoreWallIds: [nodeId],
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||
})
|
||||
const snappedPoint = snapResult.point
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
getAngleArcToSegmentReference,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
isAngleSnapActive,
|
||||
isMagneticSnapActive,
|
||||
markToolCancelConsumed,
|
||||
type SegmentAngleReference,
|
||||
snapWallDraftPointDetailed,
|
||||
@@ -40,8 +42,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
|
||||
*
|
||||
* 1:1 port of the legacy `WallTool`. Two-click flow: click 1 sets the
|
||||
* start, click 2 creates the wall. Between clicks a vertical preview
|
||||
* rectangle + length/angle measurement HUD follow the pointer. Shift
|
||||
* bypasses the angle snap; Esc cancels.
|
||||
* rectangle + length/angle measurement HUD follow the pointer. Snapping is
|
||||
* governed by the global snapping mode (`'off'` is the bypass); Esc cancels.
|
||||
*
|
||||
* Not a `DragAction` — same reasoning as fence/slab/ceiling placement:
|
||||
* stateful sequence of grid:click events, not a single drag-up.
|
||||
@@ -486,7 +488,6 @@ export const WallTool: React.FC = () => {
|
||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const buildingState = useRef(0)
|
||||
const shiftPressed = useRef(false)
|
||||
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
|
||||
const measurementColor = isDark ? '#ffffff' : '#111111'
|
||||
@@ -508,13 +509,16 @@ export const WallTool: React.FC = () => {
|
||||
}
|
||||
|
||||
// Align the drafted point onto another object's nearest real anchor and
|
||||
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
|
||||
// snapping. Returns the possibly snapped point.
|
||||
// publish the guide. Alt bypasses alignment. Returns the possibly snapped
|
||||
// point.
|
||||
const alignPoint = (
|
||||
point: WallPlanPoint,
|
||||
options: { applySnap?: boolean; bypass?: boolean },
|
||||
): WallPlanPoint => {
|
||||
if (options.bypass || alignmentCandidates.length === 0) {
|
||||
// Figma alignment pulls the endpoint onto existing wall corners / edges,
|
||||
// so it is a line snap — suppress it whenever magnetic snap is off
|
||||
// (`'off'` / `'angles'`), matching the wall-geometry snap above.
|
||||
if (options.bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return point
|
||||
}
|
||||
@@ -546,19 +550,17 @@ export const WallTool: React.FC = () => {
|
||||
|
||||
const walls = getCurrentLevelWalls()
|
||||
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
// Default path: grid + magnetic snap, with 15° angle lock while
|
||||
// drafting. Shift is a hard snap bypass: no grid, magnetic, angle,
|
||||
// or alignment snap.
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const angleLocked = buildingState.current === 1 && !bypassSnap
|
||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
||||
// Snapping is governed entirely by the snapping mode (grid / lines /
|
||||
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
|
||||
// Alt still bypasses Figma-style alignment guides independently.
|
||||
const angleLocked = buildingState.current === 1 && isAngleSnapActive()
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
const snapResult = snapWallDraftPointDetailed({
|
||||
point: localPoint,
|
||||
walls,
|
||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||
angleSnap: angleLocked,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
magnetic: isMagneticSnapActive(),
|
||||
})
|
||||
gridPosition = alignPoint(snapResult.point, {
|
||||
applySnap: !angleLocked,
|
||||
@@ -590,7 +592,6 @@ export const WallTool: React.FC = () => {
|
||||
|
||||
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousWallEnd &&
|
||||
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
||||
) {
|
||||
@@ -633,16 +634,14 @@ export const WallTool: React.FC = () => {
|
||||
const walls = getCurrentLevelWalls()
|
||||
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = alignPoint(
|
||||
snapWallDraftPointDetailed({
|
||||
point: localClick,
|
||||
walls,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
magnetic: isMagneticSnapActive(),
|
||||
}).point,
|
||||
{ bypass: bypassAlign },
|
||||
)
|
||||
@@ -665,15 +664,14 @@ export const WallTool: React.FC = () => {
|
||||
// `onGridMove` writes a real BoxGeometry skips that frame.
|
||||
setDraftMeasurement(null)
|
||||
} else if (buildingState.current === 1) {
|
||||
const angleLocked = !bypassSnap
|
||||
const angleLocked = isAngleSnapActive()
|
||||
const snappedEnd = alignPoint(
|
||||
snapWallDraftPointDetailed({
|
||||
point: localClick,
|
||||
walls,
|
||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||
angleSnap: angleLocked,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
magnetic: isMagneticSnapActive(),
|
||||
}).point,
|
||||
{
|
||||
applySnap: !angleLocked,
|
||||
@@ -729,20 +727,6 @@ export const WallTool: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = true
|
||||
}
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = false
|
||||
}
|
||||
|
||||
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
|
||||
// angle lock isn't stuck off when focus returns.
|
||||
const onBlur = () => {
|
||||
shiftPressed.current = false
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (buildingState.current === 1) {
|
||||
markToolCancelConsumed()
|
||||
@@ -753,17 +737,11 @@ export const WallTool: React.FC = () => {
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onBlur)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
useSegmentDraftChain.getState().clear('wall')
|
||||
|
||||
@@ -209,6 +209,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
// Stage C: floor-plan polygon. ctx.parent gives the wall for direction
|
||||
// + thickness — same shape as door.
|
||||
floorplan: buildWindowFloorplan,
|
||||
floorplanDependsOnSiblings: true,
|
||||
// Stage D — placement + move-on-wall. Same recipe as door. See
|
||||
// `nodes/src/window/{tool,move-tool,window-math}.ts`.
|
||||
tool: () => import('./tool'),
|
||||
|
||||
Reference in New Issue
Block a user