From 2666b07479fb37020570998d156d75e3c33f850c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 26 Jun 2026 11:27:12 -0400 Subject: [PATCH] feat(editor): wall room/single + fence continuous/single chain toggles Replace the legacy held-Alt mechanism on wall and fence drafting with a mode toggle, mirroring the snapping-mode chip: - Wall: `wallChainMode` room (auto-close on loop) / single. Room mode finishes automatically when the new endpoint lands within the join-snap radius of the chain's first vertex; single commits one wall per click. - Fence: `fenceChainMode` continuous (chain until double-click/Esc) / single. Fences are linear barriers, so continuous has no auto-close. - Both: Alt-tap cycles the active drafting tool's chain mode (clean-tap, scoped to wall/fence drafting); a clickable HUD chip shows the mode. Persisted + migrated in `useEditor`. Migrate wall and fence off held-Alt-bypass-alignment to the unified convention: alignment now follows the magnetic snap mode, which frees Alt for the toggle. 2D floorplan parity kept in sync with the 3D tools. Co-Authored-By: Claude Opus 4.8 --- .../src/components/editor/floorplan-panel.tsx | 47 +++++--- .../use-floorplan-background-placement.ts | 21 ++-- .../ui/helpers/contextual-helper-panel.tsx | 111 +++++++++++++++++- .../components/ui/helpers/helper-manager.tsx | 2 + .../ui/helpers/registered-tool-helper.tsx | 9 +- packages/editor/src/hooks/use-keyboard.ts | 53 +++++++-- packages/editor/src/index.tsx | 1 + packages/editor/src/store/use-editor.tsx | 38 ++++++ packages/nodes/src/fence/tool.tsx | 17 ++- packages/nodes/src/wall/tool.tsx | 28 ++++- 10 files changed, 281 insertions(+), 46 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index ca9c9ab5..cfe8e991 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -167,6 +167,7 @@ import { snapWallDraftPointDetailed, snapPointToGrid as snapWallPointToGrid, WALL_GRID_STEP, + WALL_JOIN_SNAP_RADIUS, type WallPlanPoint, } from '../tools/wall/wall-drafting' @@ -2459,6 +2460,13 @@ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean { return a[0] === b[0] && a[1] === b[1] } +function isWithinWallJoinSnapRadius(point: WallPlanPoint, firstVertex: WallPlanPoint): boolean { + const dx = point[0] - firstVertex[0] + const dz = point[1] - firstVertex[1] + + return dx * dx + dz * dz <= WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS +} + function haveSameIds(currentIds: string[], nextIds: string[]): boolean { return ( currentIds.length === nextIds.length && @@ -5119,6 +5127,7 @@ export function FloorplanPanel({ // `grid:move` re-renders only `FloorplanLinearDraftLayer`, not this panel. // Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged. const [draftStart, setDraftStart] = useState(null) + const [wallChainFirstVertex, setWallChainFirstVertex] = useState(null) const setDraftEnd = useCallback( (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => { const store = useFloorplanDraftPreview.getState() @@ -7477,7 +7486,9 @@ export function FloorplanPanel({ const clearWallPlacementDraft = useCallback(() => { setDraftStart(null) + setWallChainFirstVertex(null) setDraftEnd(null) + useSegmentDraftChain.getState().clear('wall') }, []) const clearFencePlacementDraft = useCallback(() => { setFenceDraftStart(null) @@ -8859,7 +8870,8 @@ export function FloorplanPanel({ // Figma alignment — same endpoint-wins precedence as the wall branch. // While a draft is open 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 Figma alignment. + // there is no Shift hold-to-bypass. Alignment follows the magnetic snap + // mode, not Alt (Alt-tap toggles continuous/single chaining). const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive() const fenceSnapped = snapFenceDraftPoint({ point: planPoint, @@ -8878,7 +8890,7 @@ export function FloorplanPanel({ snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { // Alignment is a line snap (pulls onto existing corners/edges) — // suppress it whenever magnetic snap is off (`'off'` / `'angles'`). - bypass: event.altKey || !isMagneticSnapActive(), + bypass: !isMagneticSnapActive(), }) emitFloorplanGridEvent('move', snappedPoint, event) @@ -9059,7 +9071,7 @@ export function FloorplanPanel({ // Wall draft: grid + magnetic snap, then Figma-style alignment. // While a draft is open 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 Figma alignment. + // there is no Shift hold-to-bypass. const wallAngleSnap = draftStart !== null && isAngleSnapActive() const wallSnap = snapWallDraftPointDetailed({ point: planPoint, @@ -9080,7 +9092,7 @@ export function FloorplanPanel({ applySnap: !wallAngleSnap, // Alignment is a line snap (pulls onto existing corners/edges) — // suppress it whenever magnetic snap is off (`'off'` / `'angles'`). - bypass: event.altKey || !isMagneticSnapActive(), + bypass: !isMagneticSnapActive(), }) } useWallSnapIndicator @@ -9309,9 +9321,10 @@ export function FloorplanPanel({ ) const handleWallPlacementPoint = useCallback( - (point: WallPlanPoint, options?: { singleWall?: boolean }) => { + (point: WallPlanPoint) => { if (!draftStart) { setDraftStart(point) + setWallChainFirstVertex(point) setDraftEnd(point) setCursorPoint(point) return @@ -9338,27 +9351,29 @@ export function FloorplanPanel({ const createdWall = useEditor.getState().viewMode === '2d' ? createWallOnCurrentLevel(draftStart, point) : null - // Alt commits a single wall: drop the draft so the next click - // starts a fresh segment instead of chaining off this endpoint. - if (options?.singleWall) { - setDraftStart(null) - setDraftEnd(null) - setCursorPoint(null) - return - } - // Chain the next segment from the resolved commit endpoint (it may // have corner-snapped or split-adjusted): the wall we just made in // 2D-only, otherwise the 3D tool's published chain start. Both views // then draft from the same start. + const publishedNextStart = useSegmentDraftChain.getState().wall const nextStart: WallPlanPoint = createdWall ? (createdWall.end as WallPlanPoint) - : (useSegmentDraftChain.getState().wall ?? point) + : (publishedNextStart ?? point) + + if ( + useEditor.getState().wallChainMode === 'single' || + (wallChainFirstVertex && isWithinWallJoinSnapRadius(nextStart, wallChainFirstVertex)) + ) { + clearWallPlacementDraft() + setCursorPoint(null) + return + } + setDraftStart(nextStart) setDraftEnd(nextStart) setCursorPoint(nextStart) }, - [draftStart], + [clearWallPlacementDraft, draftStart, wallChainFirstVertex], ) const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ ceilingPolygons: displayCeilingPolygons, diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index a7e326ad..22e76842 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -6,7 +6,7 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import useAlignmentGuides from '../../store/use-alignment-guides' -import { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor' +import useEditor, { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor' import usePlacementPreview from '../../store/use-placement-preview' import useSegmentDraftChain from '../../store/use-segment-draft-chain' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' @@ -43,7 +43,7 @@ type UseFloorplanBackgroundPlacementArgs = { ) => boolean handleCeilingPlacementPoint: (point: WallPlanPoint) => void handleSlabPlacementPoint: (point: WallPlanPoint) => void - handleWallPlacementPoint: (point: WallPlanPoint, options?: { singleWall?: boolean }) => void + handleWallPlacementPoint: (point: WallPlanPoint) => void handleZonePlacementPoint: (point: WallPlanPoint) => void isCeilingBuildActive: boolean isCeilingItemPlacementActive: boolean @@ -203,7 +203,7 @@ export function useFloorplanBackgroundPlacement({ // Fence draft: mode-driven (matches the chip), same as the move // preview. `grid` snaps to the world XZ grid (rotation-safe via the // `gridSnap` callback), `angles` locks 15° rays from the start, `lines` - // pulls onto walls / fences / alignment, `off` is free. Alt forces. + // pulls onto walls / fences / alignment, `off` is free. const fenceStep = getSegmentGridStep() const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive() const fenceSnapped = snapFenceDraftPoint({ @@ -222,7 +222,7 @@ export function useFloorplanBackgroundPlacement({ fenceLocked || fenceAngleSnap ? fenceSnapped : alignFloorplanDraftPoint(fenceSnapped, { - bypass: event.altKey || !isMagneticSnapActive(), + bypass: !isMagneticSnapActive(), }) emitFloorplanGridEvent('click', snappedPoint, event) @@ -242,6 +242,14 @@ export function useFloorplanBackgroundPlacement({ } else if ( getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01 ) { + // Single mode commits one segment per click: the same emit above + // already made the 3D fence tool stopDrafting, so close the 2D + // draft too instead of chaining. + if (useEditor.getState().fenceChainMode === 'single') { + clearFencePlacementDraft() + setCursorPoint(snappedPoint) + return true + } // The 3D fence tool owns creation and keeps chaining from the // committed fence's resolved end — chain the 2D draft from the // same published point so both views draft the next segment @@ -307,7 +315,6 @@ export function useFloorplanBackgroundPlacement({ // `grid` snaps to the world XZ grid (rotation-safe via `gridSnap`), // `angles` locks 15° rays from the start, `lines` pulls the endpoint // onto existing wall corners / edges + alignment, `off` is free. - // (Alt = commit a single wall, handled below — not a snap modifier.) const wallStep = getSegmentGridStep() const wallAngleSnap = draftStart !== null && isAngleSnapActive() const wallSnapped = snapWallDraftPoint({ @@ -328,7 +335,7 @@ export function useFloorplanBackgroundPlacement({ // 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. - bypass: event.altKey || !isMagneticSnapActive(), + bypass: !isMagneticSnapActive(), }) } @@ -344,7 +351,7 @@ export function useFloorplanBackgroundPlacement({ return true } - handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey }) + handleWallPlacementPoint(snappedPoint) return true } diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx index ab93bd7e..0318e8a8 100644 --- a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx +++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx @@ -8,7 +8,11 @@ import { type SnapContext, } from '../../../lib/snapping-mode' import { cn } from '../../../lib/utils' -import useEditor, { type GridSnapStep } from '../../../store/use-editor' +import useEditor, { + type FenceChainMode, + type GridSnapStep, + type WallChainMode, +} from '../../../store/use-editor' import { ShortcutToken } from '../primitives/shortcut-token' import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip' @@ -44,6 +48,26 @@ const SNAPPING_MODE_LABELS = { off: 'Off', } as const +const WALL_CHAIN_MODE_ICONS: Record = { + room: 'lucide:square', + single: 'lucide:minus', +} + +const WALL_CHAIN_MODE_LABELS: Record = { + room: 'Room (auto-close)', + single: 'Single wall', +} + +const FENCE_CHAIN_MODE_ICONS: Record = { + continuous: 'lucide:waypoints', + single: 'lucide:minus', +} + +const FENCE_CHAIN_MODE_LABELS: Record = { + continuous: 'Continuous', + single: 'Single fence', +} + const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] function nextGridSnapStep(step: GridSnapStep): GridSnapStep { @@ -110,6 +134,76 @@ function SnappingChips({ context }: { context: SnapContext }) { ) } +function nextWallChainMode(mode: WallChainMode): WallChainMode { + return mode === 'room' ? 'single' : 'room' +} + +function WallChainModeChip() { + const wallChainMode = useEditor((s) => s.wallChainMode) + const setWallChainMode = useEditor((s) => s.setWallChainMode) + const label = WALL_CHAIN_MODE_LABELS[wallChainMode] + + return ( + + + + + Wall drafting mode - click or tap Alt to cycle + + ) +} + +function nextFenceChainMode(mode: FenceChainMode): FenceChainMode { + return mode === 'continuous' ? 'single' : 'continuous' +} + +function FenceChainModeChip() { + const fenceChainMode = useEditor((s) => s.fenceChainMode) + const setFenceChainMode = useEditor((s) => s.setFenceChainMode) + const label = FENCE_CHAIN_MODE_LABELS[fenceChainMode] + + return ( + + + + + Fence drafting mode - click or tap Alt to cycle + + ) +} + const PAINT_SCOPE_ICONS: Record = { single: 'lucide:square', object: 'lucide:box', @@ -198,18 +292,31 @@ export function ContextualHelperPanel({ hints, snapContext = null, showPaintScope = false, + showWallChainMode = false, + showFenceChainMode = false, }: { hints: ContextualShortcutHint[] // The active snapping context drives the snapping chips (which mode set). Null // → no snapping chips for this interaction. snapContext?: SnapContext | null showPaintScope?: boolean + showWallChainMode?: boolean + showFenceChainMode?: boolean }) { - if (hints.length === 0 && !snapContext && !showPaintScope) return null + if ( + hints.length === 0 && + !snapContext && + !showPaintScope && + !showWallChainMode && + !showFenceChainMode + ) + return null return (
{snapContext ? : null} + {showWallChainMode ? : null} + {showFenceChainMode ? : null} {showPaintScope ? : null} {hints.map((hint) => (
diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index 1b9eb0c7..7db8158f 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -16,10 +16,14 @@ export function RegisteredToolHelper({ hints, shiftPressed = false, snapContext = null, + showWallChainMode = false, + showFenceChainMode = false, }: { hints: ToolHint[] shiftPressed?: boolean snapContext?: SnapContext | null + showWallChainMode?: boolean + showFenceChainMode?: boolean }) { // Live vertex count of an in-progress polygon draft, so hints gated on a // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. @@ -32,7 +36,8 @@ export function RegisteredToolHelper({ !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), ) - if (visible.length === 0 && !snapContext) return null + if (visible.length === 0 && !snapContext && !showWallChainMode && !showFenceChainMode) + return null return ( { @@ -46,6 +51,8 @@ export function RegisteredToolHelper({ } })} snapContext={snapContext} + showWallChainMode={showWallChainMode} + showFenceChainMode={showFenceChainMode} /> ) } diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index eb2373d3..912b4625 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -48,8 +48,19 @@ export const useKeyboard = ({ // shows a snapping chip. That single source covers wall/fence/item drafting, // every node move (including wall-hosted items + door/window openings, which // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys - // never silently stop working. (Force-place lives on Alt for all of them.) + // never silently stop working. (Force-place lives on Alt outside wall drafting.) const isSnappingCycleContext = () => getActiveSnapContext() != null + const isWallDraftingActive = () => { + const ed = useEditor.getState() + return ed.mode === 'build' && ed.tool === 'wall' + } + const isFenceDraftingActive = () => { + const ed = useEditor.getState() + return ed.mode === 'build' && ed.tool === 'fence' + } + // Alt-tap cycles the active drafting tool's chain mode (wall room/single, + // fence continuous/single). Only one of these is ever active at a time. + const isChainModeContext = () => isWallDraftingActive() || isFenceDraftingActive() // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in // between) cycles the grid step — same context as the Shift snapping-mode @@ -57,16 +68,22 @@ export const useKeyboard = ({ // and is cleared the instant any other key fires, so chords like Ctrl+Z / // Ctrl+C never cycle. let ctrlTapClean = false + let altTapClean = false const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Control' || e.key === 'Meta') { // Only a fresh, modifier-free press starts a clean-tap candidate; // ignore key-repeat and presses already part of a combo. ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey + altTapClean = false + } else if (e.key === 'Alt') { + altTapClean = !e.repeat && !e.shiftKey && !e.ctrlKey && !e.metaKey && isChainModeContext() + ctrlTapClean = false } else { // Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks // the clean tap. ctrlTapClean = false + altTapClean = false } // Don't handle shortcuts if user is typing in an input @@ -413,18 +430,36 @@ export const useKeyboard = ({ } } const handleKeyUp = (e: KeyboardEvent) => { - if (e.key !== 'Control' && e.key !== 'Meta') return - const wasClean = ctrlTapClean - ctrlTapClean = false + if (e.key === 'Control' || e.key === 'Meta') { + const wasClean = ctrlTapClean + ctrlTapClean = false + if (!wasClean) return + // Same scope as the Shift snapping-mode cycle: wall / fence build only, + // and never while typing in an input. + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + if (!isSnappingCycleContext()) return + // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05). + useEditor.getState().cycleGridSnapStep() + sfxEmitter.emit('sfx:grid-snap') + return + } + + if (e.key !== 'Alt') return + const wasClean = altTapClean + altTapClean = false if (!wasClean) return - // Same scope as the Shift snapping-mode cycle: wall / fence build only, - // and never while typing in an input. if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return } - if (!isSnappingCycleContext()) return - // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05). - useEditor.getState().cycleGridSnapStep() + if (isWallDraftingActive()) { + useEditor.getState().cycleWallChainMode() + } else if (isFenceDraftingActive()) { + useEditor.getState().cycleFenceChainMode() + } else { + return + } sfxEmitter.emit('sfx:grid-snap') } diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index a837cd1b..65049822 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -134,6 +134,7 @@ export { snapWallDraftPoint, snapWallDraftPointDetailed, WALL_GRID_STEP, + WALL_JOIN_SNAP_RADIUS, type WallDraftSnapKind, type WallDraftSnapResult, type WallPlanPoint, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index f547f9da..008019f1 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -152,6 +152,8 @@ export type StructureLayer = 'zones' | 'elements' export type FloorplanSelectionTool = 'click' | 'marquee' export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05 +export type WallChainMode = 'room' | 'single' +export type FenceChainMode = 'continuous' | 'single' export type NavigationSyncSource = '2d' | '3d' @@ -373,6 +375,12 @@ type EditorState = { setSnappingMode: (context: SnapContext, mode: SnappingMode) => void // Cycle the *active* context's mode within its own set; returns the new value. cycleSnappingMode: () => SnappingMode + wallChainMode: WallChainMode + setWallChainMode: (mode: WallChainMode) => void + cycleWallChainMode: () => WallChainMode + fenceChainMode: FenceChainMode + setFenceChainMode: (mode: FenceChainMode) => void + cycleFenceChainMode: () => FenceChainMode showReferenceFloor: boolean toggleReferenceFloor: () => void setShowReferenceFloor: (show: boolean) => void @@ -422,6 +430,8 @@ type PersistedEditorLayoutState = Pick< | 'gridSnapStep' | 'magneticSnap' | 'snappingModeByContext' + | 'wallChainMode' + | 'fenceChainMode' | 'showReferenceFloor' | 'referenceFloorOffset' | 'referenceFloorOpacity' @@ -450,6 +460,8 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = item: defaultSnappingModeFor('item'), polygon: defaultSnappingModeFor('polygon'), }, + wallChainMode: 'room', + fenceChainMode: 'continuous', showReferenceFloor: false, referenceFloorOffset: 1, referenceFloorOpacity: 0.35, @@ -560,6 +572,14 @@ function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode : defaultSnappingModeFor(context) } +function migrateWallChainMode(value: unknown): WallChainMode { + return value === 'single' || value === 'room' ? value : 'room' +} + +function migrateFenceChainMode(value: unknown): FenceChainMode { + return value === 'single' || value === 'continuous' ? value : 'continuous' +} + function normalizePersistedEditorLayoutState( state: Partial | null | undefined, ): PersistedEditorLayoutState { @@ -581,6 +601,8 @@ function normalizePersistedEditorLayoutState( item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'), polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'), }, + wallChainMode: migrateWallChainMode(state?.wallChainMode), + fenceChainMode: migrateFenceChainMode(state?.fenceChainMode), showReferenceFloor: state?.showReferenceFloor === true, referenceFloorOffset: typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 @@ -1048,6 +1070,20 @@ const useEditor = create()( })) return next }, + wallChainMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.wallChainMode, + setWallChainMode: (mode) => set({ wallChainMode: mode }), + cycleWallChainMode: () => { + const next = get().wallChainMode === 'room' ? 'single' : 'room' + set({ wallChainMode: next }) + return next + }, + fenceChainMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.fenceChainMode, + setFenceChainMode: (mode) => set({ fenceChainMode: mode }), + cycleFenceChainMode: () => { + const next = get().fenceChainMode === 'continuous' ? 'single' : 'continuous' + set({ fenceChainMode: next }) + return next + }, showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, toggleReferenceFloor: () => set((state) => ({ showReferenceFloor: !state.showReferenceFloor })), @@ -1141,6 +1177,8 @@ const useEditor = create()( gridSnapStep: state.gridSnapStep, magneticSnap: state.magneticSnap, snappingModeByContext: state.snappingModeByContext, + wallChainMode: state.wallChainMode, + fenceChainMode: state.fenceChainMode, showReferenceFloor: state.showReferenceFloor, referenceFloorOffset: state.referenceFloorOffset, referenceFloorOpacity: state.referenceFloorOpacity, diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 297a7077..a6051540 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -467,8 +467,7 @@ export const FenceTool: React.FC = () => { } // Align the drafted point onto another object's nearest real anchor and - // publish the guide. Alt bypasses alignment. Returns the possibly snapped - // point. + // publish the guide. Returns the possibly snapped point. const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => { // Figma alignment pulls the endpoint onto existing corners / edges, so it // is a line snap — suppress it whenever magnetic snap is off (`'off'` / @@ -500,8 +499,9 @@ export const FenceTool: React.FC = () => { const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] // 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 + // there is no Shift hold-to-bypass. Alignment follows the magnetic snap + // mode, not Alt (Alt-tap toggles continuous/single chaining). + const bypassAlign = !isMagneticSnapActive() if (buildingState.current === 1) { const angleLocked = isAngleSnapActive() @@ -567,7 +567,7 @@ export const FenceTool: React.FC = () => { const { walls, fences } = getCurrentLevelElements() const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] - const bypassAlign = event.nativeEvent?.altKey === true + const bypassAlign = !isMagneticSnapActive() if (buildingState.current === 0) { const snappedStart = alignPoint( @@ -612,6 +612,13 @@ export const FenceTool: React.FC = () => { refreshAlignmentCandidates() useAlignmentGuides.getState().clear() + // Single mode commits one segment per click: stop drafting so the next + // click starts a fresh segment instead of chaining off this endpoint. + if (useEditor.getState().fenceChainMode === 'single') { + stopDrafting() + return + } + const nextStart = createdFence.end // Publish the resolved chain start so the 2D floor-plan draft // chains its next segment from the same point (its own snap diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 7b3b00bf..b202cf82 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -30,6 +30,7 @@ import { useEditor, useSegmentDraftChain, useWallSnapIndicator, + WALL_JOIN_SNAP_RADIUS, type WallPlanPoint, } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' @@ -142,6 +143,13 @@ function pointMatches(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-5) { return distanceSquared(a, b) <= tolerance * tolerance } +function isWithinWallJoinSnapRadius(point: WallPlanPoint, vertex: Vector3) { + const dx = point[0] - vertex.x + const dz = point[1] - vertex.z + + return dx * dx + dz * dz <= WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS +} + function getNearestAxisAngleLabel( start: WallPlanPoint, end: WallPlanPoint, @@ -487,6 +495,7 @@ export const WallTool: React.FC = () => { const wallPreviewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) + const chainFirstVertex = useRef(null) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) @@ -509,8 +518,7 @@ export const WallTool: React.FC = () => { } // Align the drafted point onto another object's nearest real anchor and - // publish the guide. Alt bypasses alignment. Returns the possibly snapped - // point. + // publish the guide. Returns the possibly snapped point. const alignPoint = ( point: WallPlanPoint, options: { applySnap?: boolean; bypass?: boolean }, @@ -535,6 +543,7 @@ export const WallTool: React.FC = () => { const stopDrafting = () => { buildingState.current = 0 + chainFirstVertex.current = null if (wallPreviewRef.current) { wallPreviewRef.current.visible = false } @@ -552,7 +561,6 @@ export const WallTool: React.FC = () => { const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] // 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() // Alignment guides follow the snapping mode (lines = magnetic on), not Alt. const bypassAlign = !isMagneticSnapActive() @@ -649,6 +657,7 @@ export const WallTool: React.FC = () => { ) gridPosition = snappedStart startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) + chainFirstVertex.current = startingPoint.current.clone() endingPoint.current.copy(startingPoint.current) buildingState.current = 1 setAxisGuide({ @@ -696,9 +705,16 @@ export const WallTool: React.FC = () => { useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() - // Alt commits a single wall — stop drafting instead of chaining - // so the next click starts a fresh start point. - if (event.nativeEvent?.altKey === true) { + const wallChainMode = useEditor.getState().wallChainMode + if (wallChainMode === 'single') { + stopDrafting() + return + } + + if ( + chainFirstVertex.current && + isWithinWallJoinSnapRadius(createdWall.end, chainFirstVertex.current) + ) { stopDrafting() return }