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
+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
}