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 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-26 11:27:12 -04:00
co-authored by Claude Opus 4.8
parent 6d02ad424e
commit 2666b07479
10 changed files with 281 additions and 46 deletions
@@ -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<WallPlanPoint | null>(null)
const [wallChainFirstVertex, setWallChainFirstVertex] = useState<WallPlanPoint | null>(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,
@@ -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
}
@@ -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<WallChainMode, string> = {
room: 'lucide:square',
single: 'lucide:minus',
}
const WALL_CHAIN_MODE_LABELS: Record<WallChainMode, string> = {
room: 'Room (auto-close)',
single: 'Single wall',
}
const FENCE_CHAIN_MODE_ICONS: Record<FenceChainMode, string> = {
continuous: 'lucide:waypoints',
single: 'lucide:minus',
}
const FENCE_CHAIN_MODE_LABELS: Record<FenceChainMode, string> = {
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 (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`Wall drafting: ${label}`}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
onClick={() => setWallChainMode(nextWallChainMode(wallChainMode))}
type="button"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
<Icon
className="shrink-0"
height={13}
icon={WALL_CHAIN_MODE_ICONS[wallChainMode]}
width={13}
/>
<span className="truncate">{label}</span>
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Alt" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Wall drafting mode - click or tap Alt to cycle</TooltipContent>
</Tooltip>
)
}
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 (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`Fence drafting: ${label}`}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
onClick={() => setFenceChainMode(nextFenceChainMode(fenceChainMode))}
type="button"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
<Icon
className="shrink-0"
height={13}
icon={FENCE_CHAIN_MODE_ICONS[fenceChainMode]}
width={13}
/>
<span className="truncate">{label}</span>
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Alt" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Fence drafting mode - click or tap Alt to cycle</TooltipContent>
</Tooltip>
)
}
const PAINT_SCOPE_ICONS: Record<PaintScope, string> = {
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 (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col items-end gap-2">
{snapContext ? <SnappingChips context={snapContext} /> : null}
{showWallChainMode ? <WallChainModeChip /> : null}
{showFenceChainMode ? <FenceChainModeChip /> : null}
{showPaintScope ? <PaintScopeChip /> : null}
{hints.map((hint) => (
<div
@@ -177,6 +177,8 @@ export function HelperManager() {
return (
<RegisteredToolHelper
hints={def.toolHints}
showFenceChainMode={mode === 'build' && tool === 'fence'}
showWallChainMode={mode === 'build' && tool === 'wall'}
shiftPressed={modifiers.shift}
snapContext={snapContext}
/>
@@ -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 (
<ContextualHelperPanel
hints={visible.map((hint) => {
@@ -46,6 +51,8 @@ export function RegisteredToolHelper({
}
})}
snapContext={snapContext}
showWallChainMode={showWallChainMode}
showFenceChainMode={showFenceChainMode}
/>
)
}
+44 -9
View File
@@ -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')
}
+1
View File
@@ -134,6 +134,7 @@ export {
snapWallDraftPoint,
snapWallDraftPointDetailed,
WALL_GRID_STEP,
WALL_JOIN_SNAP_RADIUS,
type WallDraftSnapKind,
type WallDraftSnapResult,
type WallPlanPoint,
+38
View File
@@ -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<PersistedEditorLayoutState> | 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<EditorState>()(
}))
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<EditorState>()(
gridSnapStep: state.gridSnapStep,
magneticSnap: state.magneticSnap,
snappingModeByContext: state.snappingModeByContext,
wallChainMode: state.wallChainMode,
fenceChainMode: state.fenceChainMode,
showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity,
+12 -5
View File
@@ -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
+22 -6
View File
@@ -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<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const chainFirstVertex = useRef<Vector3 | null>(null)
const buildingState = useRef(0)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(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
}