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,
|
snapWallDraftPointDetailed,
|
||||||
snapPointToGrid as snapWallPointToGrid,
|
snapPointToGrid as snapWallPointToGrid,
|
||||||
WALL_GRID_STEP,
|
WALL_GRID_STEP,
|
||||||
|
WALL_JOIN_SNAP_RADIUS,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
} from '../tools/wall/wall-drafting'
|
} 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]
|
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 {
|
function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
|
||||||
return (
|
return (
|
||||||
currentIds.length === nextIds.length &&
|
currentIds.length === nextIds.length &&
|
||||||
@@ -5119,6 +5127,7 @@ export function FloorplanPanel({
|
|||||||
// `grid:move` re-renders only `FloorplanLinearDraftLayer`, not this panel.
|
// `grid:move` re-renders only `FloorplanLinearDraftLayer`, not this panel.
|
||||||
// Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged.
|
// Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged.
|
||||||
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
|
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
|
||||||
|
const [wallChainFirstVertex, setWallChainFirstVertex] = useState<WallPlanPoint | null>(null)
|
||||||
const setDraftEnd = useCallback(
|
const setDraftEnd = useCallback(
|
||||||
(next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
|
(next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
|
||||||
const store = useFloorplanDraftPreview.getState()
|
const store = useFloorplanDraftPreview.getState()
|
||||||
@@ -7477,7 +7486,9 @@ export function FloorplanPanel({
|
|||||||
|
|
||||||
const clearWallPlacementDraft = useCallback(() => {
|
const clearWallPlacementDraft = useCallback(() => {
|
||||||
setDraftStart(null)
|
setDraftStart(null)
|
||||||
|
setWallChainFirstVertex(null)
|
||||||
setDraftEnd(null)
|
setDraftEnd(null)
|
||||||
|
useSegmentDraftChain.getState().clear('wall')
|
||||||
}, [])
|
}, [])
|
||||||
const clearFencePlacementDraft = useCallback(() => {
|
const clearFencePlacementDraft = useCallback(() => {
|
||||||
setFenceDraftStart(null)
|
setFenceDraftStart(null)
|
||||||
@@ -8859,7 +8870,8 @@ export function FloorplanPanel({
|
|||||||
// Figma alignment — same endpoint-wins precedence as the wall branch.
|
// Figma alignment — same endpoint-wins precedence as the wall branch.
|
||||||
// While a draft is open the segment locks to 15° rays from its start.
|
// 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);
|
// 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 fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
|
||||||
const fenceSnapped = snapFenceDraftPoint({
|
const fenceSnapped = snapFenceDraftPoint({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
@@ -8878,7 +8890,7 @@ export function FloorplanPanel({
|
|||||||
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
|
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
|
||||||
// Alignment is a line snap (pulls onto existing corners/edges) —
|
// Alignment is a line snap (pulls onto existing corners/edges) —
|
||||||
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
||||||
bypass: event.altKey || !isMagneticSnapActive(),
|
bypass: !isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
|
|
||||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||||
@@ -9059,7 +9071,7 @@ export function FloorplanPanel({
|
|||||||
// Wall draft: grid + magnetic snap, then Figma-style alignment.
|
// Wall draft: grid + magnetic snap, then Figma-style alignment.
|
||||||
// While a draft is open the segment locks to 15° rays from its start.
|
// 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);
|
// 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 wallAngleSnap = draftStart !== null && isAngleSnapActive()
|
||||||
const wallSnap = snapWallDraftPointDetailed({
|
const wallSnap = snapWallDraftPointDetailed({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
@@ -9080,7 +9092,7 @@ export function FloorplanPanel({
|
|||||||
applySnap: !wallAngleSnap,
|
applySnap: !wallAngleSnap,
|
||||||
// Alignment is a line snap (pulls onto existing corners/edges) —
|
// Alignment is a line snap (pulls onto existing corners/edges) —
|
||||||
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
||||||
bypass: event.altKey || !isMagneticSnapActive(),
|
bypass: !isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
useWallSnapIndicator
|
useWallSnapIndicator
|
||||||
@@ -9309,9 +9321,10 @@ export function FloorplanPanel({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleWallPlacementPoint = useCallback(
|
const handleWallPlacementPoint = useCallback(
|
||||||
(point: WallPlanPoint, options?: { singleWall?: boolean }) => {
|
(point: WallPlanPoint) => {
|
||||||
if (!draftStart) {
|
if (!draftStart) {
|
||||||
setDraftStart(point)
|
setDraftStart(point)
|
||||||
|
setWallChainFirstVertex(point)
|
||||||
setDraftEnd(point)
|
setDraftEnd(point)
|
||||||
setCursorPoint(point)
|
setCursorPoint(point)
|
||||||
return
|
return
|
||||||
@@ -9338,27 +9351,29 @@ export function FloorplanPanel({
|
|||||||
const createdWall =
|
const createdWall =
|
||||||
useEditor.getState().viewMode === '2d' ? createWallOnCurrentLevel(draftStart, point) : null
|
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
|
// Chain the next segment from the resolved commit endpoint (it may
|
||||||
// have corner-snapped or split-adjusted): the wall we just made in
|
// have corner-snapped or split-adjusted): the wall we just made in
|
||||||
// 2D-only, otherwise the 3D tool's published chain start. Both views
|
// 2D-only, otherwise the 3D tool's published chain start. Both views
|
||||||
// then draft from the same start.
|
// then draft from the same start.
|
||||||
|
const publishedNextStart = useSegmentDraftChain.getState().wall
|
||||||
const nextStart: WallPlanPoint = createdWall
|
const nextStart: WallPlanPoint = createdWall
|
||||||
? (createdWall.end as WallPlanPoint)
|
? (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)
|
setDraftStart(nextStart)
|
||||||
setDraftEnd(nextStart)
|
setDraftEnd(nextStart)
|
||||||
setCursorPoint(nextStart)
|
setCursorPoint(nextStart)
|
||||||
},
|
},
|
||||||
[draftStart],
|
[clearWallPlacementDraft, draftStart, wallChainFirstVertex],
|
||||||
)
|
)
|
||||||
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
|
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
|
||||||
ceilingPolygons: displayCeilingPolygons,
|
ceilingPolygons: displayCeilingPolygons,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
|||||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
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 usePlacementPreview from '../../store/use-placement-preview'
|
||||||
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
||||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||||
@@ -43,7 +43,7 @@ type UseFloorplanBackgroundPlacementArgs = {
|
|||||||
) => boolean
|
) => boolean
|
||||||
handleCeilingPlacementPoint: (point: WallPlanPoint) => void
|
handleCeilingPlacementPoint: (point: WallPlanPoint) => void
|
||||||
handleSlabPlacementPoint: (point: WallPlanPoint) => void
|
handleSlabPlacementPoint: (point: WallPlanPoint) => void
|
||||||
handleWallPlacementPoint: (point: WallPlanPoint, options?: { singleWall?: boolean }) => void
|
handleWallPlacementPoint: (point: WallPlanPoint) => void
|
||||||
handleZonePlacementPoint: (point: WallPlanPoint) => void
|
handleZonePlacementPoint: (point: WallPlanPoint) => void
|
||||||
isCeilingBuildActive: boolean
|
isCeilingBuildActive: boolean
|
||||||
isCeilingItemPlacementActive: boolean
|
isCeilingItemPlacementActive: boolean
|
||||||
@@ -203,7 +203,7 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
// Fence draft: mode-driven (matches the chip), same as the move
|
// Fence draft: mode-driven (matches the chip), same as the move
|
||||||
// preview. `grid` snaps to the world XZ grid (rotation-safe via the
|
// preview. `grid` snaps to the world XZ grid (rotation-safe via the
|
||||||
// `gridSnap` callback), `angles` locks 15° rays from the start, `lines`
|
// `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 fenceStep = getSegmentGridStep()
|
||||||
const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
|
const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
|
||||||
const fenceSnapped = snapFenceDraftPoint({
|
const fenceSnapped = snapFenceDraftPoint({
|
||||||
@@ -222,7 +222,7 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
fenceLocked || fenceAngleSnap
|
fenceLocked || fenceAngleSnap
|
||||||
? fenceSnapped
|
? fenceSnapped
|
||||||
: alignFloorplanDraftPoint(fenceSnapped, {
|
: alignFloorplanDraftPoint(fenceSnapped, {
|
||||||
bypass: event.altKey || !isMagneticSnapActive(),
|
bypass: !isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
|
|
||||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||||
@@ -242,6 +242,14 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
} else if (
|
} else if (
|
||||||
getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01
|
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
|
// The 3D fence tool owns creation and keeps chaining from the
|
||||||
// committed fence's resolved end — chain the 2D draft from the
|
// committed fence's resolved end — chain the 2D draft from the
|
||||||
// same published point so both views draft the next segment
|
// 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`),
|
// `grid` snaps to the world XZ grid (rotation-safe via `gridSnap`),
|
||||||
// `angles` locks 15° rays from the start, `lines` pulls the endpoint
|
// `angles` locks 15° rays from the start, `lines` pulls the endpoint
|
||||||
// onto existing wall corners / edges + alignment, `off` is free.
|
// onto existing wall corners / edges + alignment, `off` is free.
|
||||||
// (Alt = commit a single wall, handled below — not a snap modifier.)
|
|
||||||
const wallStep = getSegmentGridStep()
|
const wallStep = getSegmentGridStep()
|
||||||
const wallAngleSnap = draftStart !== null && isAngleSnapActive()
|
const wallAngleSnap = draftStart !== null && isAngleSnapActive()
|
||||||
const wallSnapped = snapWallDraftPoint({
|
const wallSnapped = snapWallDraftPoint({
|
||||||
@@ -328,7 +335,7 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
// Figma alignment pulls the endpoint onto existing wall corners /
|
// Figma alignment pulls the endpoint onto existing wall corners /
|
||||||
// edges, so it is a line snap — suppress it whenever magnetic snap
|
// edges, so it is a line snap — suppress it whenever magnetic snap
|
||||||
// is off (`'off'` / `'angles'`), matching the wall-geometry 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey })
|
handleWallPlacementPoint(snappedPoint)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
type SnapContext,
|
type SnapContext,
|
||||||
} from '../../../lib/snapping-mode'
|
} from '../../../lib/snapping-mode'
|
||||||
import { cn } from '../../../lib/utils'
|
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 { ShortcutToken } from '../primitives/shortcut-token'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
||||||
|
|
||||||
@@ -44,6 +48,26 @@ const SNAPPING_MODE_LABELS = {
|
|||||||
off: 'Off',
|
off: 'Off',
|
||||||
} as const
|
} 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]
|
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
||||||
|
|
||||||
function nextGridSnapStep(step: GridSnapStep): GridSnapStep {
|
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> = {
|
const PAINT_SCOPE_ICONS: Record<PaintScope, string> = {
|
||||||
single: 'lucide:square',
|
single: 'lucide:square',
|
||||||
object: 'lucide:box',
|
object: 'lucide:box',
|
||||||
@@ -198,18 +292,31 @@ export function ContextualHelperPanel({
|
|||||||
hints,
|
hints,
|
||||||
snapContext = null,
|
snapContext = null,
|
||||||
showPaintScope = false,
|
showPaintScope = false,
|
||||||
|
showWallChainMode = false,
|
||||||
|
showFenceChainMode = false,
|
||||||
}: {
|
}: {
|
||||||
hints: ContextualShortcutHint[]
|
hints: ContextualShortcutHint[]
|
||||||
// The active snapping context drives the snapping chips (which mode set). Null
|
// The active snapping context drives the snapping chips (which mode set). Null
|
||||||
// → no snapping chips for this interaction.
|
// → no snapping chips for this interaction.
|
||||||
snapContext?: SnapContext | null
|
snapContext?: SnapContext | null
|
||||||
showPaintScope?: boolean
|
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 (
|
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">
|
<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}
|
{snapContext ? <SnappingChips context={snapContext} /> : null}
|
||||||
|
{showWallChainMode ? <WallChainModeChip /> : null}
|
||||||
|
{showFenceChainMode ? <FenceChainModeChip /> : null}
|
||||||
{showPaintScope ? <PaintScopeChip /> : null}
|
{showPaintScope ? <PaintScopeChip /> : null}
|
||||||
{hints.map((hint) => (
|
{hints.map((hint) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ export function HelperManager() {
|
|||||||
return (
|
return (
|
||||||
<RegisteredToolHelper
|
<RegisteredToolHelper
|
||||||
hints={def.toolHints}
|
hints={def.toolHints}
|
||||||
|
showFenceChainMode={mode === 'build' && tool === 'fence'}
|
||||||
|
showWallChainMode={mode === 'build' && tool === 'wall'}
|
||||||
shiftPressed={modifiers.shift}
|
shiftPressed={modifiers.shift}
|
||||||
snapContext={snapContext}
|
snapContext={snapContext}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ export function RegisteredToolHelper({
|
|||||||
hints,
|
hints,
|
||||||
shiftPressed = false,
|
shiftPressed = false,
|
||||||
snapContext = null,
|
snapContext = null,
|
||||||
|
showWallChainMode = false,
|
||||||
|
showFenceChainMode = false,
|
||||||
}: {
|
}: {
|
||||||
hints: ToolHint[]
|
hints: ToolHint[]
|
||||||
shiftPressed?: boolean
|
shiftPressed?: boolean
|
||||||
snapContext?: SnapContext | null
|
snapContext?: SnapContext | null
|
||||||
|
showWallChainMode?: boolean
|
||||||
|
showFenceChainMode?: boolean
|
||||||
}) {
|
}) {
|
||||||
// Live vertex count of an in-progress polygon draft, so hints gated on a
|
// 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.
|
// 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.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
|
||||||
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
|
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
|
||||||
)
|
)
|
||||||
if (visible.length === 0 && !snapContext) return null
|
if (visible.length === 0 && !snapContext && !showWallChainMode && !showFenceChainMode)
|
||||||
|
return null
|
||||||
return (
|
return (
|
||||||
<ContextualHelperPanel
|
<ContextualHelperPanel
|
||||||
hints={visible.map((hint) => {
|
hints={visible.map((hint) => {
|
||||||
@@ -46,6 +51,8 @@ export function RegisteredToolHelper({
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
snapContext={snapContext}
|
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,
|
// shows a snapping chip. That single source covers wall/fence/item drafting,
|
||||||
// every node move (including wall-hosted items + door/window openings, which
|
// every node move (including wall-hosted items + door/window openings, which
|
||||||
// now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
|
// 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 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
|
// 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
|
// 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 /
|
// and is cleared the instant any other key fires, so chords like Ctrl+Z /
|
||||||
// Ctrl+C never cycle.
|
// Ctrl+C never cycle.
|
||||||
let ctrlTapClean = false
|
let ctrlTapClean = false
|
||||||
|
let altTapClean = false
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Control' || e.key === 'Meta') {
|
if (e.key === 'Control' || e.key === 'Meta') {
|
||||||
// Only a fresh, modifier-free press starts a clean-tap candidate;
|
// Only a fresh, modifier-free press starts a clean-tap candidate;
|
||||||
// ignore key-repeat and presses already part of a combo.
|
// ignore key-repeat and presses already part of a combo.
|
||||||
ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey
|
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 {
|
} else {
|
||||||
// Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
|
// Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
|
||||||
// the clean tap.
|
// the clean tap.
|
||||||
ctrlTapClean = false
|
ctrlTapClean = false
|
||||||
|
altTapClean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't handle shortcuts if user is typing in an input
|
// Don't handle shortcuts if user is typing in an input
|
||||||
@@ -413,18 +430,36 @@ export const useKeyboard = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Control' && e.key !== 'Meta') return
|
if (e.key === 'Control' || e.key === 'Meta') {
|
||||||
const wasClean = ctrlTapClean
|
const wasClean = ctrlTapClean
|
||||||
ctrlTapClean = false
|
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
|
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) {
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isSnappingCycleContext()) return
|
if (isWallDraftingActive()) {
|
||||||
// Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
|
useEditor.getState().cycleWallChainMode()
|
||||||
useEditor.getState().cycleGridSnapStep()
|
} else if (isFenceDraftingActive()) {
|
||||||
|
useEditor.getState().cycleFenceChainMode()
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
sfxEmitter.emit('sfx:grid-snap')
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export {
|
|||||||
snapWallDraftPoint,
|
snapWallDraftPoint,
|
||||||
snapWallDraftPointDetailed,
|
snapWallDraftPointDetailed,
|
||||||
WALL_GRID_STEP,
|
WALL_GRID_STEP,
|
||||||
|
WALL_JOIN_SNAP_RADIUS,
|
||||||
type WallDraftSnapKind,
|
type WallDraftSnapKind,
|
||||||
type WallDraftSnapResult,
|
type WallDraftSnapResult,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export type StructureLayer = 'zones' | 'elements'
|
|||||||
|
|
||||||
export type FloorplanSelectionTool = 'click' | 'marquee'
|
export type FloorplanSelectionTool = 'click' | 'marquee'
|
||||||
export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
|
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'
|
export type NavigationSyncSource = '2d' | '3d'
|
||||||
|
|
||||||
@@ -373,6 +375,12 @@ type EditorState = {
|
|||||||
setSnappingMode: (context: SnapContext, mode: SnappingMode) => void
|
setSnappingMode: (context: SnapContext, mode: SnappingMode) => void
|
||||||
// Cycle the *active* context's mode within its own set; returns the new value.
|
// Cycle the *active* context's mode within its own set; returns the new value.
|
||||||
cycleSnappingMode: () => SnappingMode
|
cycleSnappingMode: () => SnappingMode
|
||||||
|
wallChainMode: WallChainMode
|
||||||
|
setWallChainMode: (mode: WallChainMode) => void
|
||||||
|
cycleWallChainMode: () => WallChainMode
|
||||||
|
fenceChainMode: FenceChainMode
|
||||||
|
setFenceChainMode: (mode: FenceChainMode) => void
|
||||||
|
cycleFenceChainMode: () => FenceChainMode
|
||||||
showReferenceFloor: boolean
|
showReferenceFloor: boolean
|
||||||
toggleReferenceFloor: () => void
|
toggleReferenceFloor: () => void
|
||||||
setShowReferenceFloor: (show: boolean) => void
|
setShowReferenceFloor: (show: boolean) => void
|
||||||
@@ -422,6 +430,8 @@ type PersistedEditorLayoutState = Pick<
|
|||||||
| 'gridSnapStep'
|
| 'gridSnapStep'
|
||||||
| 'magneticSnap'
|
| 'magneticSnap'
|
||||||
| 'snappingModeByContext'
|
| 'snappingModeByContext'
|
||||||
|
| 'wallChainMode'
|
||||||
|
| 'fenceChainMode'
|
||||||
| 'showReferenceFloor'
|
| 'showReferenceFloor'
|
||||||
| 'referenceFloorOffset'
|
| 'referenceFloorOffset'
|
||||||
| 'referenceFloorOpacity'
|
| 'referenceFloorOpacity'
|
||||||
@@ -450,6 +460,8 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
|
|||||||
item: defaultSnappingModeFor('item'),
|
item: defaultSnappingModeFor('item'),
|
||||||
polygon: defaultSnappingModeFor('polygon'),
|
polygon: defaultSnappingModeFor('polygon'),
|
||||||
},
|
},
|
||||||
|
wallChainMode: 'room',
|
||||||
|
fenceChainMode: 'continuous',
|
||||||
showReferenceFloor: false,
|
showReferenceFloor: false,
|
||||||
referenceFloorOffset: 1,
|
referenceFloorOffset: 1,
|
||||||
referenceFloorOpacity: 0.35,
|
referenceFloorOpacity: 0.35,
|
||||||
@@ -560,6 +572,14 @@ function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode
|
|||||||
: defaultSnappingModeFor(context)
|
: 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(
|
function normalizePersistedEditorLayoutState(
|
||||||
state: Partial<PersistedEditorLayoutState> | null | undefined,
|
state: Partial<PersistedEditorLayoutState> | null | undefined,
|
||||||
): PersistedEditorLayoutState {
|
): PersistedEditorLayoutState {
|
||||||
@@ -581,6 +601,8 @@ function normalizePersistedEditorLayoutState(
|
|||||||
item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
|
item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
|
||||||
polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
|
polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
|
||||||
},
|
},
|
||||||
|
wallChainMode: migrateWallChainMode(state?.wallChainMode),
|
||||||
|
fenceChainMode: migrateFenceChainMode(state?.fenceChainMode),
|
||||||
showReferenceFloor: state?.showReferenceFloor === true,
|
showReferenceFloor: state?.showReferenceFloor === true,
|
||||||
referenceFloorOffset:
|
referenceFloorOffset:
|
||||||
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
|
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
|
||||||
@@ -1048,6 +1070,20 @@ const useEditor = create<EditorState>()(
|
|||||||
}))
|
}))
|
||||||
return next
|
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,
|
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
|
||||||
toggleReferenceFloor: () =>
|
toggleReferenceFloor: () =>
|
||||||
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
|
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
|
||||||
@@ -1141,6 +1177,8 @@ const useEditor = create<EditorState>()(
|
|||||||
gridSnapStep: state.gridSnapStep,
|
gridSnapStep: state.gridSnapStep,
|
||||||
magneticSnap: state.magneticSnap,
|
magneticSnap: state.magneticSnap,
|
||||||
snappingModeByContext: state.snappingModeByContext,
|
snappingModeByContext: state.snappingModeByContext,
|
||||||
|
wallChainMode: state.wallChainMode,
|
||||||
|
fenceChainMode: state.fenceChainMode,
|
||||||
showReferenceFloor: state.showReferenceFloor,
|
showReferenceFloor: state.showReferenceFloor,
|
||||||
referenceFloorOffset: state.referenceFloorOffset,
|
referenceFloorOffset: state.referenceFloorOffset,
|
||||||
referenceFloorOpacity: state.referenceFloorOpacity,
|
referenceFloorOpacity: state.referenceFloorOpacity,
|
||||||
|
|||||||
@@ -467,8 +467,7 @@ export const FenceTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Align the drafted point onto another object's nearest real anchor and
|
// Align the drafted point onto another object's nearest real anchor and
|
||||||
// publish the guide. Alt bypasses alignment. Returns the possibly snapped
|
// publish the guide. Returns the possibly snapped point.
|
||||||
// point.
|
|
||||||
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
||||||
// Figma alignment pulls the endpoint onto existing corners / edges, so it
|
// Figma alignment pulls the endpoint onto existing corners / edges, so it
|
||||||
// is a line snap — suppress it whenever magnetic snap is off (`'off'` /
|
// 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]]
|
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
// While drafting, the segment locks to 15° rays from its start.
|
// While drafting, the segment locks to 15° rays from its start.
|
||||||
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||||
// there is no Shift hold-to-bypass. Alt still bypasses alignment guides.
|
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap
|
||||||
const bypassAlign = event.nativeEvent?.altKey === true
|
// mode, not Alt (Alt-tap toggles continuous/single chaining).
|
||||||
|
const bypassAlign = !isMagneticSnapActive()
|
||||||
|
|
||||||
if (buildingState.current === 1) {
|
if (buildingState.current === 1) {
|
||||||
const angleLocked = isAngleSnapActive()
|
const angleLocked = isAngleSnapActive()
|
||||||
@@ -567,7 +567,7 @@ export const FenceTool: React.FC = () => {
|
|||||||
|
|
||||||
const { walls, fences } = getCurrentLevelElements()
|
const { walls, fences } = getCurrentLevelElements()
|
||||||
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
const bypassAlign = event.nativeEvent?.altKey === true
|
const bypassAlign = !isMagneticSnapActive()
|
||||||
|
|
||||||
if (buildingState.current === 0) {
|
if (buildingState.current === 0) {
|
||||||
const snappedStart = alignPoint(
|
const snappedStart = alignPoint(
|
||||||
@@ -612,6 +612,13 @@ export const FenceTool: React.FC = () => {
|
|||||||
refreshAlignmentCandidates()
|
refreshAlignmentCandidates()
|
||||||
useAlignmentGuides.getState().clear()
|
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
|
const nextStart = createdFence.end
|
||||||
// Publish the resolved chain start so the 2D floor-plan draft
|
// Publish the resolved chain start so the 2D floor-plan draft
|
||||||
// chains its next segment from the same point (its own snap
|
// chains its next segment from the same point (its own snap
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
useEditor,
|
useEditor,
|
||||||
useSegmentDraftChain,
|
useSegmentDraftChain,
|
||||||
useWallSnapIndicator,
|
useWallSnapIndicator,
|
||||||
|
WALL_JOIN_SNAP_RADIUS,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
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
|
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(
|
function getNearestAxisAngleLabel(
|
||||||
start: WallPlanPoint,
|
start: WallPlanPoint,
|
||||||
end: WallPlanPoint,
|
end: WallPlanPoint,
|
||||||
@@ -487,6 +495,7 @@ export const WallTool: React.FC = () => {
|
|||||||
const wallPreviewRef = useRef<Mesh>(null!)
|
const wallPreviewRef = useRef<Mesh>(null!)
|
||||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const endingPoint = 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 buildingState = useRef(0)
|
||||||
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||||
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(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
|
// Align the drafted point onto another object's nearest real anchor and
|
||||||
// publish the guide. Alt bypasses alignment. Returns the possibly snapped
|
// publish the guide. Returns the possibly snapped point.
|
||||||
// point.
|
|
||||||
const alignPoint = (
|
const alignPoint = (
|
||||||
point: WallPlanPoint,
|
point: WallPlanPoint,
|
||||||
options: { applySnap?: boolean; bypass?: boolean },
|
options: { applySnap?: boolean; bypass?: boolean },
|
||||||
@@ -535,6 +543,7 @@ export const WallTool: React.FC = () => {
|
|||||||
|
|
||||||
const stopDrafting = () => {
|
const stopDrafting = () => {
|
||||||
buildingState.current = 0
|
buildingState.current = 0
|
||||||
|
chainFirstVertex.current = null
|
||||||
if (wallPreviewRef.current) {
|
if (wallPreviewRef.current) {
|
||||||
wallPreviewRef.current.visible = false
|
wallPreviewRef.current.visible = false
|
||||||
}
|
}
|
||||||
@@ -552,7 +561,6 @@ export const WallTool: React.FC = () => {
|
|||||||
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
// Snapping is governed entirely by the snapping mode (grid / lines /
|
// Snapping is governed entirely by the snapping mode (grid / lines /
|
||||||
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
|
// 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 angleLocked = buildingState.current === 1 && isAngleSnapActive()
|
||||||
// Alignment guides follow the snapping mode (lines = magnetic on), not Alt.
|
// Alignment guides follow the snapping mode (lines = magnetic on), not Alt.
|
||||||
const bypassAlign = !isMagneticSnapActive()
|
const bypassAlign = !isMagneticSnapActive()
|
||||||
@@ -649,6 +657,7 @@ export const WallTool: React.FC = () => {
|
|||||||
)
|
)
|
||||||
gridPosition = snappedStart
|
gridPosition = snappedStart
|
||||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||||
|
chainFirstVertex.current = startingPoint.current.clone()
|
||||||
endingPoint.current.copy(startingPoint.current)
|
endingPoint.current.copy(startingPoint.current)
|
||||||
buildingState.current = 1
|
buildingState.current = 1
|
||||||
setAxisGuide({
|
setAxisGuide({
|
||||||
@@ -696,9 +705,16 @@ export const WallTool: React.FC = () => {
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
useWallSnapIndicator.getState().clear()
|
useWallSnapIndicator.getState().clear()
|
||||||
|
|
||||||
// Alt commits a single wall — stop drafting instead of chaining
|
const wallChainMode = useEditor.getState().wallChainMode
|
||||||
// so the next click starts a fresh start point.
|
if (wallChainMode === 'single') {
|
||||||
if (event.nativeEvent?.altKey === true) {
|
stopDrafting()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
chainFirstVertex.current &&
|
||||||
|
isWithinWallJoinSnapRadius(createdWall.end, chainFirstVertex.current)
|
||||||
|
) {
|
||||||
stopDrafting()
|
stopDrafting()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user