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:
co-authored by
Claude Opus 4.8
parent
6d02ad424e
commit
2666b07479
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export {
|
||||
snapWallDraftPoint,
|
||||
snapWallDraftPointDetailed,
|
||||
WALL_GRID_STEP,
|
||||
WALL_JOIN_SNAP_RADIUS,
|
||||
type WallDraftSnapKind,
|
||||
type WallDraftSnapResult,
|
||||
type WallPlanPoint,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user