fix(editor): harden editor interactions and WebGPU rendering
Fix editor bug sweep regressions, WebGPU CSG/material crashes, Shift snap bypass behavior, arrow handle drag projection, and the wall preview null guard covered by the Sentry follow-up PRs.
This commit is contained in:
@@ -428,7 +428,8 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
// 1) Grid snap baseline. Fresh catalog placement is absolute under
|
||||
// the cursor; existing moves preserve the cursor's grab offset.
|
||||
const gridStep = useEditor.getState().gridSnapStep
|
||||
const snap = (value: number) => Math.round(value / gridStep) * gridStep
|
||||
const snap = (value: number) =>
|
||||
event.shiftKey ? value : Math.round(value / gridStep) * gridStep
|
||||
const resolved = resolvePlanarCursorPosition({
|
||||
cursor: [m[0], m[1]],
|
||||
original: [originalPosition[0], originalPosition[2]],
|
||||
@@ -442,11 +443,11 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
// 2) Alignment snap layered on top. Treat the grid-snapped point
|
||||
// as the "proposed" position so alignment competes from a stable
|
||||
// base rather than the raw cursor jitter. Alt bypasses alignment
|
||||
// entirely — same affordance Path 1 advertises in its "No Snap"
|
||||
// entirely; Shift bypasses both grid and alignment
|
||||
// hint chip.
|
||||
let finalX = gridX
|
||||
let finalZ = gridZ
|
||||
if (!event.altKey && candidateAnchors.length > 0) {
|
||||
if (!(event.altKey || event.shiftKey) && candidateAnchors.length > 0) {
|
||||
// Translate the cached local bbox to the proposed pos to get the
|
||||
// moving anchors at that location. The entry's untransformed
|
||||
// bbox is in world meters relative to the node's origin, so a
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
calculateLevelMiters,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
type DoorNode,
|
||||
type ElevatorNode,
|
||||
emitter,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
sampleWallCenterline,
|
||||
sceneRegistry,
|
||||
snapPointAlongAngleRay,
|
||||
useInteractive,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
@@ -47,7 +49,7 @@ import {
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
type ZoneNode as ZoneNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides, useWallSnapIndicator } from '@pascal-app/editor'
|
||||
import { useAlignmentGuides, useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Command, Ruler } from 'lucide-react'
|
||||
import {
|
||||
@@ -135,12 +137,10 @@ import {
|
||||
DEFAULT_STAIR_WIDTH,
|
||||
} from '../tools/stair/stair-defaults'
|
||||
import {
|
||||
createWallOnCurrentLevel,
|
||||
isSegmentLongEnough,
|
||||
snapWallDraftPoint,
|
||||
snapWallDraftPointDetailed,
|
||||
snapPointToGrid as snapWallPointToGrid,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '../tools/wall/wall-drafting'
|
||||
@@ -220,8 +220,7 @@ const FLOORPLAN_GUIDE_SELECTION_STROKE_WIDTH = 0.05
|
||||
const FLOORPLAN_GUIDE_HANDLE_HINT_OFFSET = 72
|
||||
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92
|
||||
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48
|
||||
const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45
|
||||
const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1
|
||||
const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 15
|
||||
const FLOORPLAN_VIEW_ROTATION_DEG = 90
|
||||
const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35
|
||||
const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90
|
||||
@@ -1277,7 +1276,7 @@ function buildGuideResizeDraft(
|
||||
function buildGuideRotationDraft(
|
||||
interaction: GuideInteractionState,
|
||||
pointerSvg: SvgPoint,
|
||||
useFineIncrement: boolean,
|
||||
bypassSnap: boolean,
|
||||
): GuideTransformDraft {
|
||||
const pointerVector = subtractSvgPoints(pointerSvg, interaction.centerSvg)
|
||||
|
||||
@@ -1292,12 +1291,9 @@ function buildGuideRotationDraft(
|
||||
|
||||
const rawRotationSvg =
|
||||
Math.atan2(pointerVector[1], pointerVector[0]) - interaction.cornerBaseAngle
|
||||
const snappedRotationSvg = snapAngleToIncrement(
|
||||
rawRotationSvg,
|
||||
useFineIncrement
|
||||
? FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES
|
||||
: FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES,
|
||||
)
|
||||
const snappedRotationSvg = bypassSnap
|
||||
? rawRotationSvg
|
||||
: snapAngleToIncrement(rawRotationSvg, FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES)
|
||||
|
||||
return {
|
||||
guideId: interaction.guideId,
|
||||
@@ -2174,49 +2170,29 @@ function isPointNearPlanPoint(a: WallPlanPoint, b: WallPlanPoint, threshold = 0.
|
||||
return Math.abs(a[0] - b[0]) < threshold && Math.abs(a[1] - b[1]) < threshold
|
||||
}
|
||||
|
||||
function calculatePolygonSnapPoint(
|
||||
lastPoint: WallPlanPoint,
|
||||
currentPoint: WallPlanPoint,
|
||||
): WallPlanPoint {
|
||||
const [x1, y1] = lastPoint
|
||||
const [x, y] = currentPoint
|
||||
const dx = x - x1
|
||||
const dy = y - y1
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
const horizontalDist = absDy
|
||||
const verticalDist = absDx
|
||||
const diagonalDist = Math.abs(absDx - absDy)
|
||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
||||
|
||||
if (minDist === diagonalDist) {
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
}
|
||||
|
||||
if (minDist === horizontalDist) {
|
||||
return [x, y1]
|
||||
}
|
||||
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
function snapPolygonDraftPoint({
|
||||
point,
|
||||
start,
|
||||
angleSnap,
|
||||
bypassSnap,
|
||||
}: {
|
||||
point: WallPlanPoint
|
||||
start?: WallPlanPoint
|
||||
angleSnap: boolean
|
||||
bypassSnap?: boolean
|
||||
}): WallPlanPoint {
|
||||
const snappedPoint: WallPlanPoint = [snapToHalf(point[0]), snapToHalf(point[1])]
|
||||
if (bypassSnap) return point
|
||||
|
||||
if (!(start && angleSnap)) {
|
||||
return snappedPoint
|
||||
return [snapToHalf(point[0]), snapToHalf(point[1])]
|
||||
}
|
||||
|
||||
return calculatePolygonSnapPoint(start, snappedPoint)
|
||||
// 15° angle snap from the raw point, with the distance snapped along the
|
||||
// ray to the grid step — grid-snapping the point itself would pull the
|
||||
// vertex off non-axis rays (and matches the 3D slab / ceiling tools).
|
||||
return [
|
||||
...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, useEditor.getState().gridSnapStep),
|
||||
]
|
||||
}
|
||||
|
||||
function pointMatchesWallPlanPoint(
|
||||
@@ -5508,16 +5484,18 @@ export function FloorplanPanel({
|
||||
)
|
||||
const floorplanOpeningLocalY = useMemo(() => {
|
||||
if (movingNode?.type === 'door' || movingNode?.type === 'window') {
|
||||
return snapToHalf(movingNode.position[1])
|
||||
return shiftPressed ? movingNode.position[1] : snapToHalf(movingNode.position[1])
|
||||
}
|
||||
|
||||
if (isWindowBuildActive) {
|
||||
// Floorplan is top-down, so new windows need an explicit wall-local height.
|
||||
return snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y)
|
||||
return shiftPressed
|
||||
? FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y
|
||||
: snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y)
|
||||
}
|
||||
|
||||
return 0
|
||||
}, [isWindowBuildActive, movingNode])
|
||||
}, [isWindowBuildActive, movingNode, shiftPressed])
|
||||
const isMarqueeSelectionToolActive =
|
||||
mode === 'select' &&
|
||||
floorplanSelectionTool === 'marquee' &&
|
||||
@@ -7566,9 +7544,10 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const nextDraft =
|
||||
guideInteraction.mode === 'rotate'
|
||||
? buildGuideRotationDraft(guideInteraction, svgPoint, shiftPressed)
|
||||
? buildGuideRotationDraft(guideInteraction, svgPoint, bypassSnap)
|
||||
: guideInteraction.mode === 'translate'
|
||||
? buildGuideTranslateDraft(guideInteraction, svgPoint)
|
||||
: buildGuideResizeDraft(guideInteraction, svgPoint)
|
||||
@@ -7625,15 +7604,14 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
// Wall endpoint move: grid snap only (no 45° angle snap from the
|
||||
// fixed corner — that's draft-only behaviour). Shift switches
|
||||
// to the fine grid step for precision.
|
||||
// Wall endpoint move: grid snap only. Shift bypasses all snap.
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const snapResult = snapWallDraftPointDetailed({
|
||||
point: planPoint,
|
||||
walls,
|
||||
ignoreWallIds: [dragState.wallId],
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
})
|
||||
const snappedPoint = snapResult.point
|
||||
// Magnetic beacon at the endpoint when it locked onto existing geometry.
|
||||
@@ -7674,6 +7652,7 @@ export function FloorplanPanel({
|
||||
)
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
!(
|
||||
previousDraft &&
|
||||
pointsEqual(previousDraft.start, nextDraft.start) &&
|
||||
@@ -7702,7 +7681,8 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
const chord = getWallChordFrame(wall)
|
||||
const snappedPoint: WallPlanPoint = shiftPressed
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const snappedPoint: WallPlanPoint = bypassSnap
|
||||
? planPoint
|
||||
: [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
||||
const rawCurveOffset = -(
|
||||
@@ -7711,7 +7691,7 @@ export function FloorplanPanel({
|
||||
)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
wall,
|
||||
shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset),
|
||||
bypassSnap ? rawCurveOffset : snapToHalf(rawCurveOffset),
|
||||
)
|
||||
|
||||
if (curveDragState.currentCurveOffset === nextCurveOffset) {
|
||||
@@ -7721,7 +7701,9 @@ export function FloorplanPanel({
|
||||
curveDragState.currentCurveOffset = nextCurveOffset
|
||||
setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset })
|
||||
setCursorPoint(snappedPoint)
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
if (!bypassSnap) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
}
|
||||
|
||||
const commitGuideInteraction = (event: PointerEvent) => {
|
||||
@@ -7739,9 +7721,10 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
const svgPoint = getSvgPointFromClientPoint(event.clientX, event.clientY)
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const nextDraft = svgPoint
|
||||
? interaction.mode === 'rotate'
|
||||
? buildGuideRotationDraft(interaction, svgPoint, shiftPressed)
|
||||
? buildGuideRotationDraft(interaction, svgPoint, bypassSnap)
|
||||
: interaction.mode === 'translate'
|
||||
? buildGuideTranslateDraft(interaction, svgPoint)
|
||||
: buildGuideResizeDraft(interaction, svgPoint)
|
||||
@@ -7949,7 +7932,10 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const snappedPoint: WallPlanPoint = bypassSnap
|
||||
? planPoint
|
||||
: [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
||||
setCursorPoint(snappedPoint)
|
||||
|
||||
const currentDraft = siteBoundaryDraftRef.current
|
||||
@@ -7962,7 +7948,9 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
if (!bypassSnap) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
const nextPolygon = [...currentDraft.polygon]
|
||||
nextPolygon[dragState.vertexIndex] = snappedPoint
|
||||
@@ -8037,6 +8025,7 @@ export function FloorplanPanel({
|
||||
exitSiteEditingToSelect,
|
||||
getPlanPointFromClientPoint,
|
||||
setSiteBoundaryLivePreview,
|
||||
shiftPressed,
|
||||
site,
|
||||
siteBoundaryWorldPolygon,
|
||||
siteVertexDragState,
|
||||
@@ -8176,25 +8165,26 @@ export function FloorplanPanel({
|
||||
stopPropagation: () => {},
|
||||
} as any)
|
||||
}, [])
|
||||
// Emits `planPoint` unchanged — callers own snapping. Re-quantizing here
|
||||
// (the old behaviour) destroyed magnetic corner / midpoint snaps the draft
|
||||
// branches had already resolved, desyncing the 2D pipeline from the 3D
|
||||
// tools that subscribe to these grid events.
|
||||
const emitFloorplanGridEvent = useCallback(
|
||||
(
|
||||
eventType: 'move' | 'click' | 'double-click',
|
||||
planPoint: WallPlanPoint,
|
||||
nativeEvent: ReactMouseEvent<SVGSVGElement> | ReactPointerEvent<SVGSVGElement>,
|
||||
) => {
|
||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||
const cos = Math.cos(buildingRotationY)
|
||||
const sin = Math.sin(buildingRotationY)
|
||||
const worldX = buildingPosition[0] + snappedPoint[0] * cos + snappedPoint[1] * sin
|
||||
const worldZ = buildingPosition[2] - snappedPoint[0] * sin + snappedPoint[1] * cos
|
||||
const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin
|
||||
const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos
|
||||
|
||||
emitter.emit(`grid:${eventType}` as any, {
|
||||
nativeEvent: nativeEvent.nativeEvent as any,
|
||||
position: [worldX, floorplanGridWorldY, worldZ],
|
||||
localPosition: [snappedPoint[0], floorplanGridLocalY, snappedPoint[1]],
|
||||
localPosition: [planPoint[0], floorplanGridLocalY, planPoint[1]],
|
||||
})
|
||||
|
||||
return snappedPoint
|
||||
},
|
||||
[buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY],
|
||||
)
|
||||
@@ -8413,7 +8403,7 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
if (referenceScaleDraft) {
|
||||
emitFloorplanGridEvent('move', planPoint, event)
|
||||
emitFloorplanGridEvent('move', getSnappedFloorplanPoint(planPoint), event)
|
||||
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint,
|
||||
@@ -8430,21 +8420,24 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
if (isCeilingBuildActive) {
|
||||
// Polygon vertex: grid (snapToHalf) + optional 45° angle snap from
|
||||
// the previous vertex. Wall magnetic snap may still win, while
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
// Polygon vertex: grid (snapToHalf) or 15° angle snap from the
|
||||
// previous vertex. Wall magnetic snap may still win, while
|
||||
// generic alignment runs only when angle snap is OFF (first vertex,
|
||||
// or Shift held) so it does not pull a locked angle sideways.
|
||||
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
|
||||
const angleSnap = ceilingDraftPoints.length > 0 && !bypassSnap
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
bypassSnap,
|
||||
})
|
||||
const snappedPoint = resolveCeilingPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
shiftKey: bypassSnap,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
|
||||
@@ -8456,8 +8449,11 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
if (isRoofBuildActive) {
|
||||
let snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
let snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||
snappedPoint = alignFloorplanDraftPoint(snappedPoint, {
|
||||
bypass: event.altKey || bypassSnap,
|
||||
})
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
@@ -8474,23 +8470,31 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
if (isFenceBuildActive) {
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
|
||||
// Figma alignment — same endpoint-wins precedence as the wall branch.
|
||||
// While a draft is open the segment locks to 15° rays from its start
|
||||
// unless Shift is held; Shift bypasses grid, magnetic, angle, and
|
||||
// alignment snap.
|
||||
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
|
||||
const fenceSnapped = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
fences,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
start: fenceDraftStart ?? undefined,
|
||||
angleSnap: fenceAngleSnap,
|
||||
bypassSnap,
|
||||
})
|
||||
const fenceGridBase = snapWallPointToGrid(
|
||||
planPoint,
|
||||
shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
|
||||
)
|
||||
const fenceGridBase = bypassSnap ? planPoint : snapWallPointToGrid(planPoint)
|
||||
const fenceLocked =
|
||||
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
||||
!bypassSnap &&
|
||||
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
|
||||
let snappedPoint = fenceSnapped
|
||||
if (fenceLocked) useAlignmentGuides.getState().clear()
|
||||
else snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey })
|
||||
if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear()
|
||||
else
|
||||
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
|
||||
bypass: event.altKey || bypassSnap,
|
||||
})
|
||||
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
@@ -8511,11 +8515,13 @@ export function FloorplanPanel({
|
||||
// the local polygon-draft state actually updates as the cursor
|
||||
// moves (the catch-all would otherwise swallow the move event).
|
||||
if (isPolygonBuildActive) {
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
bypassSnap,
|
||||
})
|
||||
let snappedPoint = fallbackPoint
|
||||
if (isSlabBuildActive) {
|
||||
@@ -8524,12 +8530,15 @@ export function FloorplanPanel({
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
shiftKey: bypassSnap,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
} else if (angleSnap) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey })
|
||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
||||
bypass: event.altKey || bypassSnap,
|
||||
})
|
||||
}
|
||||
|
||||
// Emit `grid:move` so the registry-driven slab tool also tracks
|
||||
@@ -8538,7 +8547,7 @@ export function FloorplanPanel({
|
||||
|
||||
setCursorPoint((previousPoint) => {
|
||||
const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint))
|
||||
if (hasChanged && activePolygonDraftPoints.length > 0) {
|
||||
if (!bypassSnap && hasChanged && activePolygonDraftPoints.length > 0) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
return snappedPoint
|
||||
@@ -8594,7 +8603,8 @@ export function FloorplanPanel({
|
||||
// routing through `grid:move`, which would otherwise be processed
|
||||
// by the floor strategy and drop the item to floor height.
|
||||
if (isCeilingItemPlacementActive) {
|
||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
)
|
||||
@@ -8608,7 +8618,8 @@ export function FloorplanPanel({
|
||||
// comment there). Wall build skips this so its own branch below
|
||||
// updates local `draftEnd` state alongside the registry tool.
|
||||
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event)
|
||||
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
)
|
||||
@@ -8630,27 +8641,31 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
// Wall draft: grid snap (orthogonal walls follow naturally from a
|
||||
// grid-aligned start; Shift = fine 0.05m step), then Figma-style
|
||||
// alignment layered on top. An existing wall endpoint / join snap
|
||||
// wins outright — never pull the cursor off a corner the user is
|
||||
// closing onto — so alignment runs ONLY when the wall snap left the
|
||||
// point on the plain grid. Alt bypasses alignment.
|
||||
// Wall draft: grid + magnetic snap, then Figma-style alignment.
|
||||
// While a draft is open the segment locks to 15° rays from its
|
||||
// start unless Shift is held. Shift bypasses grid, magnetic, angle,
|
||||
// and alignment snap.
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const wallAngleSnap = draftStart !== null && !bypassSnap
|
||||
const wallSnap = snapWallDraftPointDetailed({
|
||||
point: planPoint,
|
||||
walls,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
start: draftStart ?? undefined,
|
||||
angleSnap: wallAngleSnap,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
})
|
||||
const wallSnapped = wallSnap.point
|
||||
// Locked onto existing geometry (corner / midpoint / crossing / edge) →
|
||||
// that snap wins, so skip Figma alignment and stand the beacon there.
|
||||
const lockedToWall = wallSnap.snap !== null
|
||||
let snappedPoint = wallSnapped
|
||||
if (lockedToWall) {
|
||||
if (lockedToWall || wallAngleSnap) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
snappedPoint = alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey })
|
||||
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
||||
bypass: event.altKey || bypassSnap,
|
||||
})
|
||||
}
|
||||
useWallSnapIndicator
|
||||
.getState()
|
||||
@@ -8668,9 +8683,8 @@ export function FloorplanPanel({
|
||||
|
||||
setDraftEnd((previousEnd) => {
|
||||
if (
|
||||
!previousEnd ||
|
||||
previousEnd[0] !== snappedPoint[0] ||
|
||||
previousEnd[1] !== snappedPoint[1]
|
||||
!bypassSnap &&
|
||||
(!previousEnd || previousEnd[0] !== snappedPoint[0] || previousEnd[1] !== snappedPoint[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
@@ -8874,17 +8888,10 @@ export function FloorplanPanel({
|
||||
// call. `emitFloorplanGridEvent('click', …)` in
|
||||
// `useFloorplanBackgroundPlacement` fires it synchronously
|
||||
// just before this callback runs, so by the time we get here
|
||||
// the wall already exists in the scene.
|
||||
//
|
||||
// We still attempt the create as a fallback in case the 3D
|
||||
// tool isn't mounted (unusual — both views are always
|
||||
// mounted today, but defensive). When the wall already
|
||||
// exists `createWallOnCurrentLevel` returns null via its
|
||||
// duplicate-detection branch; we treat that as "the 3D side
|
||||
// committed" and chain the draft state forward instead of
|
||||
// clearing it (the previous behaviour caused the 2nd-segment
|
||||
// draft to silently break after click 2).
|
||||
const createdWall = createWallOnCurrentLevel(draftStart, point)
|
||||
// the wall already exists in the scene. Committing here as
|
||||
// well used to double-create walls whenever the two snap
|
||||
// pipelines resolved endpoints ≥1e-6 apart (the duplicate
|
||||
// check compares exact endpoints).
|
||||
|
||||
// Alt commits a single wall: drop the draft so the next click
|
||||
// starts a fresh segment instead of chaining off this endpoint.
|
||||
@@ -8895,9 +8902,10 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
const nextStart: WallPlanPoint = createdWall
|
||||
? [createdWall.end[0], createdWall.end[1]]
|
||||
: point
|
||||
// Chain the next segment from the 3D tool's resolved commit
|
||||
// point (it may have corner-snapped or split-adjusted the
|
||||
// endpoint) so both views draft from the same start.
|
||||
const nextStart: WallPlanPoint = useSegmentDraftChain.getState().wall ?? point
|
||||
setDraftStart(nextStart)
|
||||
setDraftEnd(nextStart)
|
||||
setCursorPoint(nextStart)
|
||||
@@ -8930,6 +8938,7 @@ export function FloorplanPanel({
|
||||
walls: WallNode[]
|
||||
start?: WallPlanPoint
|
||||
angleSnap?: boolean
|
||||
bypassSnap?: boolean
|
||||
step?: number
|
||||
}) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }),
|
||||
[],
|
||||
@@ -8939,6 +8948,7 @@ export function FloorplanPanel({
|
||||
ceilingDraftPoints,
|
||||
clearFencePlacementDraft,
|
||||
clearRoofPlacementDraft,
|
||||
clearWallPlacementDraft,
|
||||
emitFloorplanGridEvent,
|
||||
fenceDraftStart,
|
||||
fences,
|
||||
@@ -8994,7 +9004,7 @@ export function FloorplanPanel({
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
emitFloorplanGridEvent('click', planPoint, event)
|
||||
emitFloorplanGridEvent('click', getSnappedFloorplanPoint(planPoint), event)
|
||||
|
||||
if (!referenceScaleDraft.start) {
|
||||
setReferenceScaleDraft({
|
||||
@@ -9137,11 +9147,13 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
bypassSnap,
|
||||
})
|
||||
|
||||
if (isCeilingBuildActive) {
|
||||
@@ -9150,6 +9162,7 @@ export function FloorplanPanel({
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
shiftKey: bypassSnap,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
emitFloorplanGridEvent('double-click', snappedPoint, event)
|
||||
@@ -9165,6 +9178,7 @@ export function FloorplanPanel({
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
shiftKey: bypassSnap,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
// Slab is registry-driven: forward the double-click so the 3D tool
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
@@ -40,8 +41,6 @@ import {
|
||||
useInvisibleHitAreaMaterial,
|
||||
} from './node-arrow-handles'
|
||||
|
||||
const ROTATE_SNAP = Math.PI / 12 // 15°
|
||||
|
||||
/**
|
||||
* Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
|
||||
* selected, a single rotation handle appears at the selection's bounding-box
|
||||
@@ -201,7 +200,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
let delta = angleOf(moveHit) - initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
if (e.shiftKey) delta = Math.round(delta / ROTATE_SNAP) * ROTATE_SNAP
|
||||
if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
||||
|
||||
// Orbit each node's anchor point(s) CCW by `delta` (atan2 x→z sense) and
|
||||
// turn its yaw by `-delta` to match three.js Y-rotation handedness (same
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { type ThreeEvent, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { type Camera, type Object3D, type Plane, Vector2, type Vector3 } from 'three'
|
||||
import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
||||
|
||||
@@ -27,9 +27,12 @@ type IntersectPlane = (
|
||||
target: Vector3,
|
||||
) => Vector3 | null
|
||||
|
||||
type GetPointerRay = (clientX: number, clientY: number, target: Ray) => Ray
|
||||
|
||||
export type HandleDragStartContext = {
|
||||
event: ThreeEvent<PointerEvent>
|
||||
camera: Camera
|
||||
getPointerRay: GetPointerRay
|
||||
intersectPlane: IntersectPlane
|
||||
initialNode: AnyNode
|
||||
node: AnyNode
|
||||
@@ -40,6 +43,7 @@ export type HandleDragStartContext = {
|
||||
|
||||
export type HandleDragMoveContext = {
|
||||
event: PointerEvent
|
||||
getPointerRay: GetPointerRay
|
||||
intersectPlane: IntersectPlane
|
||||
}
|
||||
|
||||
@@ -121,13 +125,20 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
||||
rideObject.updateMatrixWorld()
|
||||
|
||||
const ndc = new Vector2()
|
||||
const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => {
|
||||
const setPointerRay = (clientX: number, clientY: number) => {
|
||||
const rect = gl.domElement.getBoundingClientRect()
|
||||
ndc.set(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
}
|
||||
const getPointerRay: GetPointerRay = (clientX, clientY, target) => {
|
||||
setPointerRay(clientX, clientY)
|
||||
return target.copy(raycaster.ray)
|
||||
}
|
||||
const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => {
|
||||
setPointerRay(clientX, clientY)
|
||||
return raycaster.ray.intersectPlane(plane, target)
|
||||
}
|
||||
|
||||
@@ -137,6 +148,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
||||
const session = args.onStart({
|
||||
event,
|
||||
camera,
|
||||
getPointerRay,
|
||||
intersectPlane,
|
||||
initialNode,
|
||||
node,
|
||||
@@ -159,7 +171,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
||||
let lastPatch: Partial<AnyNode> | null = null
|
||||
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
const patch = session.move({ event: moveEvent, intersectPlane })
|
||||
const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane })
|
||||
if (!patch) return
|
||||
lastPatch = patch
|
||||
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ArcResizeHandle,
|
||||
type Cursor,
|
||||
createSceneApi,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
type HandleDescriptor,
|
||||
type HandlePortal,
|
||||
type LinearResizeHandle,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
OrthographicCamera,
|
||||
Plane,
|
||||
Quaternion,
|
||||
Ray,
|
||||
RingGeometry,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
@@ -59,6 +61,43 @@ import { type HandleDragControls, useHandleDrag } from './handles/use-handle-dra
|
||||
// Pooled scratch for the handle rig's world-relative pose mapping.
|
||||
const _rigRelative = new Matrix4()
|
||||
const _rigScratchScale = new Vector3()
|
||||
const _resizeAxisW = new Vector3()
|
||||
const _resizeScale = new Vector3()
|
||||
const _resizeQuaternion = new Quaternion()
|
||||
const _resizeOriginW = new Vector3()
|
||||
const _resizePositionW = new Vector3()
|
||||
const _resizeRay = new Ray()
|
||||
const _resizeRayW = new Vector3()
|
||||
|
||||
function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) {
|
||||
target.set(0, 0, 0)
|
||||
if (axis === 'x') target.x = 1
|
||||
else if (axis === 'y') target.y = 1
|
||||
else target.z = 1
|
||||
return target
|
||||
}
|
||||
|
||||
function axisScale(axis: 'x' | 'y' | 'z', scale: Vector3) {
|
||||
return axis === 'x' ? scale.x : axis === 'y' ? scale.y : scale.z
|
||||
}
|
||||
|
||||
function closestAxisParameterToRay(axisOrigin: Vector3, axisDirection: Vector3, ray: Ray) {
|
||||
_resizeRayW.subVectors(axisOrigin, ray.origin)
|
||||
const b = axisDirection.dot(ray.direction)
|
||||
const d = axisDirection.dot(_resizeRayW)
|
||||
const e = ray.direction.dot(_resizeRayW)
|
||||
const denominator = 1 - b * b
|
||||
if (Math.abs(denominator) < 1e-6) {
|
||||
return -d
|
||||
}
|
||||
|
||||
const axisParameter = (b * e - d) / denominator
|
||||
const rayParameter = e + b * axisParameter
|
||||
if (rayParameter < 0) {
|
||||
return -d
|
||||
}
|
||||
return axisParameter
|
||||
}
|
||||
|
||||
export {
|
||||
ARROW_COLOR,
|
||||
@@ -579,34 +618,31 @@ function LinearArrow({
|
||||
rideObject,
|
||||
setIsDragging,
|
||||
onStart: ({
|
||||
camera: dragCamera,
|
||||
event,
|
||||
getPointerRay,
|
||||
initialNode,
|
||||
intersectPlane,
|
||||
nodeId,
|
||||
rideObject: dragRideObject,
|
||||
sceneApi,
|
||||
}) => {
|
||||
const initialFrameInverse = new Matrix4().copy(dragRideObject.matrixWorld).invert()
|
||||
const worldOrigin = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld)
|
||||
const planeNormal = new Vector3().subVectors(dragCamera.position, worldOrigin).setY(0)
|
||||
if (planeNormal.lengthSq() === 0) return null
|
||||
planeNormal.normalize()
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
|
||||
dragRideObject.matrixWorld.decompose(_resizePositionW, _resizeQuaternion, _resizeScale)
|
||||
_resizeOriginW.set(...position).applyMatrix4(dragRideObject.matrixWorld)
|
||||
axisVector(descriptor.axis, _resizeAxisW).applyQuaternion(_resizeQuaternion).normalize()
|
||||
const localToWorldScale = axisScale(descriptor.axis, _resizeScale)
|
||||
if (Math.abs(localToWorldScale) < 1e-6 || _resizeAxisW.lengthSq() === 0) return null
|
||||
|
||||
const hitWorld = new Vector3()
|
||||
if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) {
|
||||
return null
|
||||
}
|
||||
const hitLocal = hitWorld.clone().applyMatrix4(initialFrameInverse)
|
||||
const initialPointer =
|
||||
closestAxisParameterToRay(
|
||||
_resizeOriginW,
|
||||
_resizeAxisW,
|
||||
getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay),
|
||||
) / localToWorldScale
|
||||
|
||||
const overrideId =
|
||||
(descriptor.kind === 'linear-resize'
|
||||
? descriptor.overrideTarget?.(initialNode as never, sceneApi)
|
||||
: undefined) ?? nodeId
|
||||
const initialValue = descriptor.currentValue(initialNode)
|
||||
const initialPointer =
|
||||
descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
|
||||
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
|
||||
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
|
||||
const gridSnapStep =
|
||||
@@ -634,22 +670,19 @@ function LinearArrow({
|
||||
useEditor.getState().setActiveHandleDrag(null)
|
||||
}
|
||||
},
|
||||
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
||||
const intersection = new Vector3()
|
||||
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, intersection)) {
|
||||
return null
|
||||
}
|
||||
const intersectionLocal = intersection.clone().applyMatrix4(initialFrameInverse)
|
||||
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
|
||||
const currentPointer =
|
||||
descriptor.axis === 'x'
|
||||
? intersectionLocal.x
|
||||
: descriptor.axis === 'y'
|
||||
? intersectionLocal.y
|
||||
: intersectionLocal.z
|
||||
closestAxisParameterToRay(
|
||||
_resizeOriginW,
|
||||
_resizeAxisW,
|
||||
getMovePointerRay(moveEvent.clientX, moveEvent.clientY, _resizeRay),
|
||||
) / localToWorldScale
|
||||
const delta = currentPointer - initialPointer
|
||||
const rawNext = initialValue + delta * factor
|
||||
const snappedNext =
|
||||
gridSnapStep && gridSnapStep > 0 ? snapScalar(rawNext, gridSnapStep) : rawNext
|
||||
!moveEvent.shiftKey && gridSnapStep && gridSnapStep > 0
|
||||
? snapScalar(rawNext, gridSnapStep)
|
||||
: rawNext
|
||||
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
|
||||
return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
||||
},
|
||||
@@ -1096,9 +1129,8 @@ function ArcArrow({
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
|
||||
if (moveEvent.shiftKey && descriptor.shape === 'rotate') {
|
||||
const step = Math.PI / 12
|
||||
delta = Math.round(delta / step) * step
|
||||
if (!moveEvent.shiftKey && descriptor.shape === 'rotate') {
|
||||
delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
||||
}
|
||||
|
||||
if (isRotateShape && !isNodeNormalRot) {
|
||||
|
||||
@@ -202,12 +202,14 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
restoreLevels = snapLevelsToTruePositions()
|
||||
}
|
||||
|
||||
// Hide scan and guide nodes directly so they are excluded from the
|
||||
// thumbnail regardless of whether ScanSystem/GuideSystem listeners are
|
||||
// registered. Returns a function that restores the original visibility.
|
||||
// Hide scan, guide, and spawn nodes directly so they are excluded from
|
||||
// the thumbnail regardless of whether ScanSystem/GuideSystem listeners
|
||||
// are registered. Spawn renders on SCENE_LAYER for occlusion, so the
|
||||
// thumbnail camera's layer mask can't filter it either. Returns a
|
||||
// function that restores the original visibility.
|
||||
const restoreNodeVisibility = (() => {
|
||||
const saved = new Map<THREE.Object3D, boolean>()
|
||||
for (const type of ['scan', 'guide'] as const) {
|
||||
for (const type of ['scan', 'guide', 'spawn'] as const) {
|
||||
const ids = sceneRegistry.byType[type]!
|
||||
ids.forEach((id) => {
|
||||
const node = sceneRegistry.nodes.get(id)
|
||||
@@ -238,18 +240,21 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
|
||||
// Notify other systems (wall cutouts, selection manager) to restore
|
||||
// their overrides before capture and re-apply them after.
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
;(renderer as any).setClearAlpha(0)
|
||||
renderer.setRenderTarget(rt)
|
||||
pipelineRef.current.render()
|
||||
renderer.setRenderTarget(null)
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
|
||||
// Restore level positions, levelMode, and node visibility immediately after the
|
||||
// render — before the async GPU readback.
|
||||
restoreLevels()
|
||||
restoreLevelMode?.()
|
||||
restoreNodeVisibility()
|
||||
try {
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
;(renderer as any).setClearAlpha(0)
|
||||
renderer.setRenderTarget(rt)
|
||||
pipelineRef.current.render()
|
||||
} finally {
|
||||
// Restore level positions, levelMode, and node visibility immediately
|
||||
// after the render — before the async GPU readback. Runs in `finally`
|
||||
// so a render failure can't leave helpers permanently hidden.
|
||||
renderer.setRenderTarget(null)
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
restoreLevels()
|
||||
restoreLevelMode?.()
|
||||
restoreNodeVisibility()
|
||||
}
|
||||
|
||||
// Read pixels from the RT asynchronously.
|
||||
// WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
|
||||
@@ -364,12 +369,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
cameraData.resolution = { w: outW, h: outH }
|
||||
} else {
|
||||
// Fallback: plain render directly to the canvas
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
gl.render(scene, thumbnailCamera)
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
restoreLevels()
|
||||
restoreLevelMode?.()
|
||||
restoreNodeVisibility()
|
||||
try {
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
gl.render(scene, thumbnailCamera)
|
||||
} finally {
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
restoreLevels()
|
||||
restoreLevelMode?.()
|
||||
restoreNodeVisibility()
|
||||
}
|
||||
|
||||
let outW: number
|
||||
let outH: number
|
||||
|
||||
@@ -5,23 +5,21 @@ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
|
||||
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import {
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '../tools/wall/wall-drafting'
|
||||
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||
|
||||
type UseFloorplanBackgroundPlacementArgs = {
|
||||
activePolygonDraftPoints: WallPlanPoint[]
|
||||
ceilingDraftPoints: WallPlanPoint[]
|
||||
clearFencePlacementDraft: () => void
|
||||
clearRoofPlacementDraft: () => void
|
||||
clearWallPlacementDraft: () => void
|
||||
emitFloorplanGridEvent: (
|
||||
type: 'click' | 'double-click' | 'move',
|
||||
planPoint: WallPlanPoint,
|
||||
event: ReactMouseEvent<SVGSVGElement>,
|
||||
) => WallPlanPoint
|
||||
) => void
|
||||
fenceDraftStart: WallPlanPoint | null
|
||||
fences: FenceNode[]
|
||||
findClosestWallPoint: (
|
||||
@@ -67,6 +65,7 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
walls: WallNode[]
|
||||
start?: WallPlanPoint
|
||||
angleSnap?: boolean
|
||||
bypassSnap?: boolean
|
||||
step?: number
|
||||
gridSnap?: (point: WallPlanPoint) => WallPlanPoint
|
||||
}) => WallPlanPoint
|
||||
@@ -74,6 +73,7 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
point: WallPlanPoint
|
||||
start?: WallPlanPoint
|
||||
angleSnap: boolean
|
||||
bypassSnap?: boolean
|
||||
}) => WallPlanPoint
|
||||
toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
|
||||
walls: WallNode[]
|
||||
@@ -81,7 +81,7 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
* Snap a building-local plan point to the world XZ grid at `step`.
|
||||
* Injected so the hook doesn't have to know the building's rotation
|
||||
* or position — used by wall / fence branches that snap at variable
|
||||
* step (Shift = fine).
|
||||
* step.
|
||||
*/
|
||||
worldGridSnap: (point: WallPlanPoint, step: number) => WallPlanPoint
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export function useFloorplanBackgroundPlacement({
|
||||
ceilingDraftPoints,
|
||||
clearFencePlacementDraft,
|
||||
clearRoofPlacementDraft,
|
||||
clearWallPlacementDraft,
|
||||
emitFloorplanGridEvent,
|
||||
fenceDraftStart,
|
||||
fences,
|
||||
@@ -154,21 +155,24 @@ export function useFloorplanBackgroundPlacement({
|
||||
}
|
||||
|
||||
if (isCeilingBuildActive) {
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
// Align the committed vertex the same way the move-preview did, so
|
||||
// the placed point matches what the user saw. Wall magnetic snap may
|
||||
// still win; generic alignment is skipped when angle snap owns the
|
||||
// vertex (matches the move branch).
|
||||
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
|
||||
const angleSnap = ceilingDraftPoints.length > 0 && !bypassSnap
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
bypassSnap,
|
||||
})
|
||||
const snappedPoint = resolveCeilingPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
shiftKey: bypassSnap,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
|
||||
@@ -178,9 +182,11 @@ export function useFloorplanBackgroundPlacement({
|
||||
}
|
||||
|
||||
if (isRoofBuildActive) {
|
||||
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
|
||||
bypass: event.altKey,
|
||||
})
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const snappedPoint = alignFloorplanDraftPoint(
|
||||
bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint),
|
||||
{ bypass: event.altKey || bypassSnap },
|
||||
)
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
setCursorPoint(snappedPoint)
|
||||
|
||||
@@ -194,35 +200,57 @@ export function useFloorplanBackgroundPlacement({
|
||||
}
|
||||
|
||||
if (isFenceBuildActive) {
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
|
||||
// Figma alignment — endpoint snap wins (same precedence as move).
|
||||
// `gridSnap` keeps the snap on the world XZ grid even when the
|
||||
// building is rotated.
|
||||
const fenceStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
|
||||
// While a draft is open the segment locks to 15° rays from its
|
||||
// start unless Shift is held; Shift bypasses grid, magnetic,
|
||||
// angle, and alignment snap. `gridSnap` keeps the regular snap
|
||||
// on the world XZ grid even when the building is rotated.
|
||||
const fenceStep = WALL_GRID_STEP
|
||||
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
|
||||
const fenceSnapped = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
fences,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
start: fenceDraftStart ?? undefined,
|
||||
angleSnap: fenceAngleSnap,
|
||||
bypassSnap,
|
||||
gridSnap: (p) => worldGridSnap(p, fenceStep),
|
||||
})
|
||||
const fenceGridBase = worldGridSnap(planPoint, fenceStep)
|
||||
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep)
|
||||
const fenceLocked =
|
||||
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
||||
const snappedPoint = fenceLocked
|
||||
? fenceSnapped
|
||||
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey })
|
||||
!bypassSnap &&
|
||||
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
|
||||
const snappedPoint =
|
||||
fenceLocked || fenceAngleSnap
|
||||
? fenceSnapped
|
||||
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap })
|
||||
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
setCursorPoint(snappedPoint)
|
||||
|
||||
// Double-click finishes the chain. The emit above already made the
|
||||
// 3D fence tool stopDrafting (its detail >= 2 guard), so close the
|
||||
// 2D draft too — leaving it open desyncs the two views.
|
||||
if (fenceDraftStart && event.detail >= 2) {
|
||||
clearFencePlacementDraft()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!fenceDraftStart) {
|
||||
setFenceDraftStart(snappedPoint)
|
||||
setFenceDraftEnd(snappedPoint)
|
||||
} else if (
|
||||
getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01
|
||||
) {
|
||||
clearFencePlacementDraft()
|
||||
// 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
|
||||
// from the same start.
|
||||
const nextStart = useSegmentDraftChain.getState().fence ?? snappedPoint
|
||||
setFenceDraftStart(nextStart)
|
||||
setFenceDraftEnd(nextStart)
|
||||
} else {
|
||||
setFenceDraftEnd(snappedPoint)
|
||||
}
|
||||
@@ -235,11 +263,13 @@ export function useFloorplanBackgroundPlacement({
|
||||
// swallow the click and skip local draft state updates — leaving
|
||||
// the 2D draft polygon invisible while the 3D tool builds fine).
|
||||
if (isPolygonBuildActive) {
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
bypassSnap,
|
||||
})
|
||||
let snappedPoint = fallbackPoint
|
||||
if (isSlabBuildActive) {
|
||||
@@ -248,10 +278,13 @@ export function useFloorplanBackgroundPlacement({
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
shiftKey: bypassSnap,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
} else if (!angleSnap) {
|
||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey })
|
||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
||||
bypass: event.altKey || bypassSnap,
|
||||
})
|
||||
}
|
||||
|
||||
// Emit the grid event so the registry-driven slab tool also
|
||||
@@ -275,25 +308,44 @@ export function useFloorplanBackgroundPlacement({
|
||||
// / draftEnd state in the floor plan would never update, leaving
|
||||
// the dashed-line draft preview invisible.
|
||||
if (isWallBuildActive) {
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
// Wall draft: grid snap (+ existing-wall endpoint/join snap), then
|
||||
// Figma alignment — endpoint/join snap wins (same precedence as the
|
||||
// move-preview branch), so committing onto a corner still works.
|
||||
// `gridSnap` keeps the snap on the world XZ grid even when the
|
||||
// building is rotated.
|
||||
const wallStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
|
||||
// While a draft is open the segment locks to 15° rays from its
|
||||
// start unless Shift is held; Shift bypasses grid, magnetic,
|
||||
// angle, and alignment snap. `gridSnap` keeps the regular snap
|
||||
// on the world XZ grid even when the building is rotated.
|
||||
const wallStep = WALL_GRID_STEP
|
||||
const wallAngleSnap = draftStart !== null && !bypassSnap
|
||||
const wallSnapped = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
start: draftStart ?? undefined,
|
||||
angleSnap: wallAngleSnap,
|
||||
bypassSnap,
|
||||
gridSnap: (p) => worldGridSnap(p, wallStep),
|
||||
})
|
||||
const wallGridBase = worldGridSnap(planPoint, wallStep)
|
||||
const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]
|
||||
const snappedPoint = wallLocked
|
||||
? wallSnapped
|
||||
: alignFloorplanDraftPoint(wallSnapped, { bypass: false })
|
||||
const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep)
|
||||
const wallLocked =
|
||||
!bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
|
||||
const snappedPoint =
|
||||
wallLocked || wallAngleSnap
|
||||
? wallSnapped
|
||||
: alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey || bypassSnap })
|
||||
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
|
||||
// Double-click finishes the chain. The emit above already made the
|
||||
// 3D wall tool stopDrafting (its detail >= 2 guard), so close the
|
||||
// 2D draft too — otherwise it stays open against a closed 3D tool
|
||||
// and the next previewed segment is silently never created.
|
||||
if (draftStart && event.detail >= 2) {
|
||||
clearWallPlacementDraft()
|
||||
setCursorPoint(snappedPoint)
|
||||
return true
|
||||
}
|
||||
|
||||
handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey })
|
||||
return true
|
||||
}
|
||||
@@ -311,7 +363,8 @@ export function useFloorplanBackgroundPlacement({
|
||||
// local floor-plan draft handler (column / spawn / shelf / etc.).
|
||||
// The tool's `grid:click` subscriber owns the placement.
|
||||
if (isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
|
||||
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
setCursorPoint(snappedPoint)
|
||||
return true
|
||||
}
|
||||
@@ -323,6 +376,7 @@ export function useFloorplanBackgroundPlacement({
|
||||
ceilingDraftPoints,
|
||||
clearFencePlacementDraft,
|
||||
clearRoofPlacementDraft,
|
||||
clearWallPlacementDraft,
|
||||
emitFloorplanGridEvent,
|
||||
fenceDraftStart,
|
||||
fences,
|
||||
|
||||
+33
-6
@@ -11,7 +11,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import { BoxGeometry, type Group, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
clearCeilingSnapFeedback,
|
||||
@@ -153,6 +153,7 @@ const CeilingSelectionAffordance = ({
|
||||
const [draggedCornerIndex, setDraggedCornerIndex] = useState<number | null>(null)
|
||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
||||
const dragRef = useRef<CornerDragState | null>(null)
|
||||
const bracketsRootRef = useRef<Group>(null)
|
||||
const raycasterRef = useRef(new Raycaster())
|
||||
const ndcRef = useRef(new Vector2())
|
||||
const planeRef = useRef(new Plane())
|
||||
@@ -293,19 +294,23 @@ const CeilingSelectionAffordance = ({
|
||||
initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]),
|
||||
initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]),
|
||||
]
|
||||
const gridNextPosition: [number, number] = [
|
||||
initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]),
|
||||
initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
|
||||
]
|
||||
const gridNextPosition: [number, number] = event.shiftKey
|
||||
? rawNextPosition
|
||||
: [
|
||||
initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]),
|
||||
initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
|
||||
]
|
||||
const nextPosition = resolveCeilingPlanPointSnap({
|
||||
rawPoint: rawNextPosition,
|
||||
fallbackPoint: gridNextPosition,
|
||||
levelId,
|
||||
excludeId: drag.ceilingId,
|
||||
altKey: event.altKey,
|
||||
shiftKey: event.shiftKey,
|
||||
}).point
|
||||
|
||||
if (
|
||||
!event.shiftKey &&
|
||||
drag.previousSnappedPosition &&
|
||||
(nextPosition[0] !== drag.previousSnappedPosition[0] ||
|
||||
nextPosition[1] !== drag.previousSnappedPosition[1])
|
||||
@@ -372,6 +377,25 @@ const CeilingSelectionAffordance = ({
|
||||
}
|
||||
}, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit])
|
||||
|
||||
// The brackets render on SCENE_LAYER (scene-depth occlusion), so unlike
|
||||
// EDITOR_LAYER affordances the thumbnail camera can't filter them — hide
|
||||
// them around captures via synchronous Object3D.visible mutation (the
|
||||
// capture renders right after the emit), same as `site-boundary-editor.tsx`.
|
||||
useEffect(() => {
|
||||
const hideForCapture = () => {
|
||||
if (bracketsRootRef.current) bracketsRootRef.current.visible = false
|
||||
}
|
||||
const restoreAfterCapture = () => {
|
||||
if (bracketsRootRef.current) bracketsRootRef.current.visible = true
|
||||
}
|
||||
emitter.on('thumbnail:before-capture', hideForCapture)
|
||||
emitter.on('thumbnail:after-capture', restoreAfterCapture)
|
||||
return () => {
|
||||
emitter.off('thumbnail:before-capture', hideForCapture)
|
||||
emitter.off('thumbnail:after-capture', restoreAfterCapture)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0
|
||||
|
||||
@@ -401,7 +425,10 @@ const CeilingSelectionAffordance = ({
|
||||
if (!levelObject || corners.length === 0) return null
|
||||
|
||||
return createPortal(
|
||||
<group position={[0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
||||
<group
|
||||
position={[0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}
|
||||
ref={bracketsRootRef}
|
||||
>
|
||||
{corners.map((corner, index) => (
|
||||
<CornerBracket
|
||||
ceiling={effectiveCeiling}
|
||||
|
||||
@@ -20,6 +20,7 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry {
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
g.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
g.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
g.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
// Match the four material slots the roof-segment renderer's material
|
||||
// array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
|
||||
// groups, mesh.material is a single-material lookup that mismatches
|
||||
|
||||
@@ -195,12 +195,13 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
Math.round(event.localPosition[0] * 2) / 2,
|
||||
Math.round(event.localPosition[2] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true,
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
)
|
||||
const supportY = resolveElevatorSupportY({
|
||||
buildingId: currentBuildingId,
|
||||
@@ -220,6 +221,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
||||
})
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
@@ -237,12 +239,13 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
||||
})
|
||||
if (!latestBuildingId) return
|
||||
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
Math.round(event.localPosition[0] * 2) / 2,
|
||||
Math.round(event.localPosition[2] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true,
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
)
|
||||
commitElevatorPlacement(
|
||||
latestBuildingId,
|
||||
|
||||
@@ -131,8 +131,9 @@ export function MoveElevatorTool({
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const rawX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const rawZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const rawX = bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2
|
||||
const rawZ = bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2
|
||||
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
|
||||
dragAnchorRef.current = anchor
|
||||
const gridX = movingNode.position[0] + (rawX - anchor[0])
|
||||
@@ -145,6 +146,7 @@ export function MoveElevatorTool({
|
||||
})
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
DEFAULT_ANGLE_STEP,
|
||||
FenceNode,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
isCurvedWall,
|
||||
snapPointAlongAngleRay,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -12,9 +14,7 @@ import useEditor from '../../../store/use-editor'
|
||||
import {
|
||||
findWallSnapTarget,
|
||||
getSegmentGridStep,
|
||||
getWallAngleSnapStep,
|
||||
isSegmentLongEnough,
|
||||
snapPointTo45Degrees,
|
||||
snapPointToGrid,
|
||||
type WallPlanPoint,
|
||||
} from '../wall/wall-drafting'
|
||||
@@ -132,7 +132,9 @@ export function snapFenceDraftPoint(args: {
|
||||
start?: FencePlanPoint
|
||||
angleSnap?: boolean
|
||||
ignoreFenceIds?: string[]
|
||||
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
|
||||
bypassSnap?: boolean
|
||||
magnetic?: boolean
|
||||
/** Override the grid step. */
|
||||
step?: number
|
||||
/**
|
||||
* Optional grid-snap function. When provided, replaces the default
|
||||
@@ -142,17 +144,45 @@ export function snapFenceDraftPoint(args: {
|
||||
*/
|
||||
gridSnap?: (point: FencePlanPoint) => FencePlanPoint
|
||||
}): FencePlanPoint {
|
||||
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step, gridSnap } = args
|
||||
const {
|
||||
point,
|
||||
walls,
|
||||
fences,
|
||||
start,
|
||||
angleSnap = false,
|
||||
ignoreFenceIds,
|
||||
bypassSnap = false,
|
||||
magnetic = true,
|
||||
step,
|
||||
gridSnap,
|
||||
} = args
|
||||
if (bypassSnap) return point
|
||||
|
||||
const gridStep = step ?? getSegmentGridStep()
|
||||
const angleStep = getWallAngleSnapStep(gridStep)
|
||||
const basePoint =
|
||||
|
||||
// Magnetic endpoint snap must beat the angle lock, and the lock can pull
|
||||
// the cursor far enough off an endpoint that probing the locked point
|
||||
// would never engage — so under the lock, probe from the RAW cursor
|
||||
// first (mirrors `snapWallDraftPointDetailed`'s special-point pre-pass).
|
||||
if (start && angleSnap) {
|
||||
const rawTarget =
|
||||
magnetic &&
|
||||
(findFenceSnapTarget(point, fences, ignoreFenceIds) ?? findWallSnapTarget(point, walls))
|
||||
if (rawTarget) return rawTarget
|
||||
}
|
||||
|
||||
// The angle path snaps the distance ALONG the 15° ray — a scalar, the
|
||||
// same in world and local frames — so the `gridSnap` world-grid override
|
||||
// only applies when the angle lock is off.
|
||||
const basePoint: FencePlanPoint =
|
||||
start && angleSnap
|
||||
? snapPointTo45Degrees(start, point, gridStep, angleStep, gridSnap)
|
||||
? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, gridStep)]
|
||||
: gridSnap
|
||||
? gridSnap(point)
|
||||
: snapPointToGrid(point, gridStep)
|
||||
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
||||
if (!magnetic) return basePoint
|
||||
|
||||
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
||||
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
||||
}
|
||||
|
||||
|
||||
@@ -113,10 +113,14 @@ export const floorStrategy = {
|
||||
// is rotated; then project the world point back into building-local
|
||||
// for storage. Without this, a rotated building drags placement off
|
||||
// the world grid.
|
||||
const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
|
||||
const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
|
||||
const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0)
|
||||
const [x, z] = local
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const [x, z] = bypassSnap
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapWorldXZForActiveBuilding(
|
||||
snapToGrid(event.position[0], swapDims ? dimZ : dimX),
|
||||
snapToGrid(event.position[2], swapDims ? dimX : dimZ),
|
||||
0,
|
||||
).local
|
||||
const y = ctx.gridPosition.y
|
||||
|
||||
return {
|
||||
@@ -197,9 +201,10 @@ export const wallStrategy = {
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const x = snapToHalf(event.localPosition[0])
|
||||
const y = snapToHalf(event.localPosition[1])
|
||||
const z = snapToHalf(event.localPosition[2])
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
|
||||
const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
|
||||
const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
|
||||
|
||||
// Get auto-adjusted Y position from validator
|
||||
const rawDims = ctx.draftItem
|
||||
@@ -231,11 +236,13 @@ export const wallStrategy = {
|
||||
},
|
||||
cursorRotationY: cursorRotation,
|
||||
gridPosition: [x, adjustedY, z],
|
||||
cursorPosition: [
|
||||
snapToHalf(event.position[0]),
|
||||
snapToHalf(event.position[1]),
|
||||
snapToHalf(event.position[2]),
|
||||
],
|
||||
cursorPosition: bypassSnap
|
||||
? [event.position[0], event.position[1], event.position[2]]
|
||||
: [
|
||||
snapToHalf(event.position[0]),
|
||||
snapToHalf(event.position[1]),
|
||||
snapToHalf(event.position[2]),
|
||||
],
|
||||
stopPropagation: true,
|
||||
}
|
||||
},
|
||||
@@ -258,9 +265,10 @@ export const wallStrategy = {
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const snappedX = snapToHalf(event.localPosition[0])
|
||||
const snappedY = snapToHalf(event.localPosition[1])
|
||||
const snappedZ = snapToHalf(event.localPosition[2])
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
|
||||
const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
|
||||
const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
|
||||
|
||||
// Get auto-adjusted Y position from validator
|
||||
const validation = validators.canPlaceOnWall(
|
||||
@@ -278,11 +286,13 @@ export const wallStrategy = {
|
||||
|
||||
return {
|
||||
gridPosition: [snappedX, adjustedY, snappedZ],
|
||||
cursorPosition: [
|
||||
snapToHalf(event.position[0]),
|
||||
snapToHalf(event.position[1]),
|
||||
snapToHalf(event.position[2]),
|
||||
],
|
||||
cursorPosition: bypassSnap
|
||||
? [event.position[0], event.position[1], event.position[2]]
|
||||
: [
|
||||
snapToHalf(event.position[0]),
|
||||
snapToHalf(event.position[1]),
|
||||
snapToHalf(event.position[2]),
|
||||
],
|
||||
cursorRotationY: cursorRotation,
|
||||
nodeUpdate: {
|
||||
position: [snappedX, adjustedY, snappedZ],
|
||||
@@ -403,8 +413,8 @@ function resolveRoofWallTarget(
|
||||
const dims = getGridAlignedDimensions(rawDims, attachTo)
|
||||
const [width, height] = dims
|
||||
|
||||
const u = snapToHalf(hit.u)
|
||||
const centerV = snapToHalf(hit.v) + height / 2
|
||||
const u = shiftFree ? hit.u : snapToHalf(hit.u)
|
||||
const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2
|
||||
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
|
||||
if (!fitted && !shiftFree) return null
|
||||
const finalU = fitted?.u ?? u
|
||||
@@ -604,8 +614,13 @@ export const ceilingStrategy = {
|
||||
|
||||
// Ceiling items are stored in ceiling-local coordinates, so snapping must
|
||||
// use the ceiling hit's local position rather than world position.
|
||||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const x = bypassSnap
|
||||
? event.localPosition[0]
|
||||
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
const z = bypassSnap
|
||||
? event.localPosition[2]
|
||||
: snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||||
// Recessed fixtures seat flush with the ceiling plane (body rising into the
|
||||
// void above); everything else hangs its full height below the ceiling.
|
||||
const seatY = ctx.asset.recessed ? 0 : -itemHeight
|
||||
@@ -638,8 +653,13 @@ export const ceilingStrategy = {
|
||||
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
||||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||||
|
||||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const x = bypassSnap
|
||||
? event.localPosition[0]
|
||||
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
const z = bypassSnap
|
||||
? event.localPosition[2]
|
||||
: snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||||
// Recessed fixtures seat flush with the ceiling plane (body rising into the
|
||||
// void above); everything else hangs its full height below the ceiling.
|
||||
const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
|
||||
@@ -750,8 +770,9 @@ export const itemSurfaceStrategy = {
|
||||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||||
if (surfaceHeight === null) return null
|
||||
|
||||
const x = snapToGrid(localPos.x, ourDims[0])
|
||||
const z = snapToGrid(localPos.z, ourDims[2])
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||
const y = surfaceHeight
|
||||
|
||||
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||
@@ -801,8 +822,9 @@ export const itemSurfaceStrategy = {
|
||||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||||
if (surfaceHeight === null) return null
|
||||
|
||||
const x = snapToGrid(localPos.x, ourDims[0])
|
||||
const z = snapToGrid(localPos.z, ourDims[2])
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||
const y = surfaceHeight
|
||||
|
||||
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||
@@ -901,8 +923,9 @@ export const shelfSurfaceStrategy = {
|
||||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||||
if (rowY === null) return null
|
||||
|
||||
const x = snapToGrid(localPos.x, ourDims[0])
|
||||
const z = snapToGrid(localPos.z, ourDims[2])
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||
|
||||
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
||||
|
||||
@@ -945,8 +968,9 @@ export const shelfSurfaceStrategy = {
|
||||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||||
if (rowY === null) return null
|
||||
|
||||
const x = snapToGrid(localPos.x, ourDims[0])
|
||||
const z = snapToGrid(localPos.z, ourDims[2])
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
||||
|
||||
return {
|
||||
|
||||
@@ -683,11 +683,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// item's edge, snap and publish a guide. The guide connects to the
|
||||
// nearest real corner of the candidate (resolver tie-break), so the dot
|
||||
// always sits on an actual point. The delta is applied to BOTH the grid
|
||||
// and cursor positions below. Alt bypasses.
|
||||
// and cursor positions below. Alt bypasses alignment; Shift bypasses all snap.
|
||||
const draft = draftNode.current
|
||||
let alignX = 0
|
||||
let alignZ = 0
|
||||
const bypassAlign = floorEvent.nativeEvent?.altKey === true
|
||||
const bypassSnap = floorEvent.nativeEvent?.shiftKey === true
|
||||
const bypassAlign = floorEvent.nativeEvent?.altKey === true || bypassSnap
|
||||
if (!bypassAlign && draft) {
|
||||
alignmentCandidates ??= collectAlignmentAnchors(
|
||||
useScene.getState().nodes,
|
||||
@@ -721,6 +722,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
// Play snap sound when grid position changes
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPos &&
|
||||
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
|
||||
) {
|
||||
@@ -866,7 +868,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
gridPosition.current.z !== result.gridPosition[2]
|
||||
|
||||
// Play snap sound when grid position changes
|
||||
if (posChanged) {
|
||||
if (event.nativeEvent?.shiftKey !== true && posChanged) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
@@ -1035,7 +1037,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
gridPosition.current.y !== result.gridPosition[1] ||
|
||||
gridPosition.current.z !== result.gridPosition[2]
|
||||
|
||||
if (posChanged) {
|
||||
if (!shiftFreeRef.current && posChanged) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
@@ -1128,8 +1130,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
const wx = Math.round(buildingLocalPoint.x * 2) / 2
|
||||
const wz = Math.round(buildingLocalPoint.z * 2) / 2
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2
|
||||
const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2
|
||||
const floorPos: [number, number, number] = [wx, 0, wz]
|
||||
|
||||
Object.assign(placementState.current, {
|
||||
@@ -1429,7 +1432,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
gridPosition.current.y !== result.gridPosition[1] ||
|
||||
gridPosition.current.z !== result.gridPosition[2]
|
||||
|
||||
if (posChanged) {
|
||||
if (event.nativeEvent?.shiftKey !== true && posChanged) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ const snapToGridStep = (value: number) => {
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
/** 90° steps, matching the GLB item placement rotation. */
|
||||
const ROTATION_STEP = Math.PI / 2
|
||||
/** 45° steps, matching the GLB item placement rotation. */
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the 2D
|
||||
* floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
|
||||
@@ -286,7 +286,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
original: [originalPosition[0], originalPosition[2]],
|
||||
anchor: dragAnchorRef.current,
|
||||
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
|
||||
snap: snapToGridStep,
|
||||
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
|
||||
})
|
||||
dragAnchorRef.current = resolved.anchor
|
||||
let [x, z] = resolved.point
|
||||
@@ -295,8 +295,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// moving item's edge lines up (on X or Z) with another item's edge,
|
||||
// snap and publish a guide. The guide connects to the nearest real
|
||||
// corner of the candidate (resolver tie-break), so the dot always sits
|
||||
// on an actual point. Alt bypasses.
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
// on an actual point. Alt bypasses alignment; Shift bypasses all snap.
|
||||
const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
|
||||
@@ -338,7 +338,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
markMovedNodeDirty()
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (!prev || prev[0] !== x || prev[1] !== z) {
|
||||
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
previousSnapRef.current = [x, z]
|
||||
}
|
||||
@@ -457,7 +457,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
if (typeof direct === 'function') direct.call(event)
|
||||
}
|
||||
|
||||
// R / T rotate the dragged node about Y in 90° steps — matching the GLB
|
||||
// R / T rotate the dragged node about Y in 45° steps — matching the GLB
|
||||
// item placement keys (and the "Rotate" hints the move HUD shows). Applied
|
||||
// imperatively + mirrored to the live transform; committed on drop.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -242,17 +242,20 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to drag every roof corner off the visible grid.
|
||||
const snapped = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const snapped: [number, number] = bypassSnap
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
snapped[0],
|
||||
snapped[1],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true,
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
)
|
||||
const y = event.localPosition[1]
|
||||
|
||||
@@ -262,6 +265,7 @@ export const RoofTool: React.FC = () => {
|
||||
cursorRef.current.position.set(gridX, gridY, gridZ)
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
corner1Ref.current &&
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
@@ -287,17 +291,20 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to drag every roof corner off the visible grid.
|
||||
const snapped = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const snapped: [number, number] = bypassSnap
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
snapped[0],
|
||||
snapped[1],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true,
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
)
|
||||
const y = event.localPosition[1]
|
||||
|
||||
|
||||
@@ -746,7 +746,10 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const point = levelNode ? event.localPosition : event.position
|
||||
const rawPoint: [number, number] = [point[0], point[2]]
|
||||
const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
|
||||
const bypassSnap = event.nativeEvent.shiftKey === true
|
||||
const gridPoint: [number, number] = bypassSnap
|
||||
? rawPoint
|
||||
: [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
|
||||
const newPosition =
|
||||
dragState?.isDragging && resolvePlanPoint
|
||||
? resolvePlanPoint({
|
||||
@@ -763,6 +766,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
// Play snap sound when cursor moves to a new grid cell during drag
|
||||
if (
|
||||
!bypassSnap &&
|
||||
dragState?.isDragging &&
|
||||
previousPositionRef.current &&
|
||||
(newPosition[0] !== previousPositionRef.current[0] ||
|
||||
|
||||
@@ -348,18 +348,20 @@ export const StairTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
Math.round(event.localPosition[0] * 2) / 2,
|
||||
Math.round(event.localPosition[2] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true,
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
)
|
||||
const position: [number, number, number] = [gridX, 0, gridZ]
|
||||
lastCanonicalPositionRef.current = position
|
||||
applyDraftPreview(position, rotationRef.current)
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
@@ -370,12 +372,13 @@ export const StairTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
Math.round(event.localPosition[0] * 2) / 2,
|
||||
Math.round(event.localPosition[2] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true,
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
)
|
||||
return [gridX, 0, gridZ]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
useScene,
|
||||
type WallNode,
|
||||
WallNode as WallSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createWallOnCurrentLevel, snapWallDraftPointDetailed } from './wall-drafting'
|
||||
import type { WallPlanPoint } from './wall-snap-geometry'
|
||||
|
||||
const LEVEL_ID = 'level_test' as AnyNodeId
|
||||
|
||||
function makeWall(start: WallPlanPoint, end: WallPlanPoint, id: string): WallNode {
|
||||
return {
|
||||
...WallSchema.parse({ start, end, name: id }),
|
||||
id: id as WallNode['id'],
|
||||
parentId: LEVEL_ID,
|
||||
}
|
||||
}
|
||||
|
||||
function seedLevel(walls: WallNode[]) {
|
||||
useScene.setState({
|
||||
nodes: Object.fromEntries([
|
||||
[
|
||||
LEVEL_ID,
|
||||
{
|
||||
id: LEVEL_ID,
|
||||
type: 'level',
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: walls.map((wall) => wall.id),
|
||||
level: 0,
|
||||
} as AnyNode,
|
||||
],
|
||||
...walls.map((wall) => [wall.id, wall] as const),
|
||||
]),
|
||||
rootNodeIds: [LEVEL_ID],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
} as never)
|
||||
}
|
||||
|
||||
function levelWalls(): WallNode[] {
|
||||
return Object.values(useScene.getState().nodes).filter(
|
||||
(node): node is WallNode => node?.type === 'wall',
|
||||
)
|
||||
}
|
||||
|
||||
describe('createWallOnCurrentLevel', () => {
|
||||
beforeEach(() => {
|
||||
useViewer.setState({
|
||||
selection: {
|
||||
buildingId: 'building_test',
|
||||
levelId: LEVEL_ID,
|
||||
zoneId: null,
|
||||
selectedIds: [],
|
||||
},
|
||||
} as never)
|
||||
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
|
||||
})
|
||||
|
||||
test('endpoint near an existing corner attaches to the corner instead of splitting', () => {
|
||||
const created = createWallOnCurrentLevel([2, 2], [3.99, 0])
|
||||
|
||||
expect(created?.end).toEqual([4, 0])
|
||||
const hostWall = useScene.getState().nodes['wall_a' as AnyNodeId] as WallNode | undefined
|
||||
expect(hostWall?.start).toEqual([0, 0])
|
||||
expect(hostWall?.end).toEqual([4, 0])
|
||||
expect(levelWalls()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('endpoint near the host start corner snaps there without splitting', () => {
|
||||
const created = createWallOnCurrentLevel([2, 2], [0.015, 0])
|
||||
|
||||
expect(created?.end).toEqual([0, 0])
|
||||
expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeDefined()
|
||||
expect(levelWalls()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('genuine mid-wall endpoint still splits the host (T junction)', () => {
|
||||
const created = createWallOnCurrentLevel([2, 2], [2, 0])
|
||||
|
||||
expect(created?.end).toEqual([2, 0])
|
||||
expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeUndefined()
|
||||
const walls = levelWalls()
|
||||
expect(walls).toHaveLength(3)
|
||||
expect(
|
||||
walls.some((wall) => wall.start[0] === 0 && wall.end[0] === 2 && wall.end[1] === 0),
|
||||
).toBe(true)
|
||||
expect(
|
||||
walls.some((wall) => wall.start[0] === 2 && wall.start[1] === 0 && wall.end[0] === 4),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('exact duplicate segment is rejected', () => {
|
||||
expect(createWallOnCurrentLevel([0, 0], [4, 0])).toBeNull()
|
||||
expect(levelWalls()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapWallDraftPointDetailed', () => {
|
||||
test('bypassSnap returns the raw point without endpoint or angle snap', () => {
|
||||
const wall = makeWall([0, 0], [4, 0], 'wall_a')
|
||||
const result = snapWallDraftPointDetailed({
|
||||
point: [3.99, 0.03],
|
||||
walls: [wall],
|
||||
start: [2, 2],
|
||||
angleSnap: true,
|
||||
bypassSnap: true,
|
||||
})
|
||||
|
||||
expect(result.point).toEqual([3.99, 0.03])
|
||||
expect(result.snap).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
snapPointAlongAngleRay,
|
||||
useScene,
|
||||
type WallNode,
|
||||
WallNode as WallSchema,
|
||||
@@ -35,21 +37,16 @@ export {
|
||||
} from './wall-snap-geometry'
|
||||
|
||||
export const WALL_GRID_STEP = 0.5
|
||||
// Smallest available grid snap. Used as a precision-mode step (Shift +
|
||||
// drag) so a drag can land on values the regular grid skips.
|
||||
export const WALL_FINE_GRID_STEP = 0.05
|
||||
export const WALL_MIN_LENGTH = 0.01
|
||||
const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
|
||||
|
||||
const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = {
|
||||
0.5: Math.PI / 4,
|
||||
0.25: Math.PI / 8,
|
||||
0.1: Math.PI / 12,
|
||||
0.05: Math.PI / 36,
|
||||
}
|
||||
// An endpoint projecting within this distance of an existing wall's corner
|
||||
// resolves to the corner without splitting — splitting there would mint a
|
||||
// sliver segment a hair longer than `WALL_MIN_LENGTH` that no snap radius
|
||||
// can ever target again.
|
||||
const WALL_SPLIT_ENDPOINT_EPSILON = 0.02
|
||||
|
||||
type WallSplitIntersection = {
|
||||
wallId: WallNode['id']
|
||||
/** `null` = snap-only outcome: resolve to `point` but split no wall. */
|
||||
wallId: WallNode['id'] | null
|
||||
point: WallPlanPoint
|
||||
}
|
||||
|
||||
@@ -65,35 +62,6 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
|
||||
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
|
||||
}
|
||||
|
||||
export function snapPointTo45Degrees(
|
||||
start: WallPlanPoint,
|
||||
cursor: WallPlanPoint,
|
||||
step = WALL_GRID_STEP,
|
||||
angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
|
||||
/**
|
||||
* Optional grid-snap callback. Lets the caller route the final
|
||||
* snap through a world-XZ grid (or any other axis system) instead
|
||||
* of the local-axis grid `snapPointToGrid` uses. When omitted,
|
||||
* falls back to the local-axis snap at `step`.
|
||||
*/
|
||||
gridSnap?: (point: WallPlanPoint) => WallPlanPoint,
|
||||
): WallPlanPoint {
|
||||
const dx = cursor[0] - start[0]
|
||||
const dz = cursor[1] - start[1]
|
||||
const angle = Math.atan2(dz, dx)
|
||||
const snappedAngle = Math.round(angle / angleStep) * angleStep
|
||||
const distance = Math.sqrt(dx * dx + dz * dz)
|
||||
const point: WallPlanPoint = [
|
||||
start[0] + Math.cos(snappedAngle) * distance,
|
||||
start[1] + Math.sin(snappedAngle) * distance,
|
||||
]
|
||||
return gridSnap ? gridSnap(point) : snapPointToGrid(point, step)
|
||||
}
|
||||
|
||||
export function getWallAngleSnapStep(step = getSegmentGridStep()): number {
|
||||
return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
|
||||
}
|
||||
|
||||
function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
|
||||
const { id: _id, parentId: _parentId, children, ...rest } = wall
|
||||
|
||||
@@ -140,7 +108,14 @@ function findWallIntersection(
|
||||
continue
|
||||
}
|
||||
|
||||
best = { wallId: wall.id, point: projected }
|
||||
const nearCorner = ([wall.start, wall.end] as WallPlanPoint[]).find(
|
||||
(corner) =>
|
||||
distanceSquared(projected, corner) <=
|
||||
WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON,
|
||||
)
|
||||
best = nearCorner
|
||||
? { wallId: null, point: [nearCorner[0], nearCorner[1]] }
|
||||
: { wallId: wall.id, point: projected }
|
||||
bestDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
|
||||
@@ -292,6 +267,10 @@ function splitWallIfNeeded(
|
||||
): { walls: WallNode[]; point: WallPlanPoint } | null {
|
||||
if (!intersection) return null
|
||||
|
||||
if (!intersection.wallId) {
|
||||
return { walls, point: intersection.point }
|
||||
}
|
||||
|
||||
const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
|
||||
if (!wallToSplit) {
|
||||
return { walls, point: intersection.point }
|
||||
@@ -331,7 +310,8 @@ type SnapWallDraftArgs = {
|
||||
start?: WallPlanPoint
|
||||
angleSnap?: boolean
|
||||
ignoreWallIds?: string[]
|
||||
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
|
||||
bypassSnap?: boolean
|
||||
/** Override the grid step. */
|
||||
step?: number
|
||||
/**
|
||||
* Magnetic snapping to existing wall geometry (corners, midpoints,
|
||||
@@ -358,12 +338,15 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
|
||||
start,
|
||||
angleSnap = false,
|
||||
ignoreWallIds,
|
||||
bypassSnap = false,
|
||||
step: overrideStep,
|
||||
magnetic = true,
|
||||
gridSnap,
|
||||
snapRadii,
|
||||
} = args
|
||||
|
||||
if (bypassSnap) return { point, snap: null }
|
||||
|
||||
// Discrete special points (corner / midpoint / crossing) are taken from the
|
||||
// raw cursor so an interim grid snap can't mask them. A corner always wins,
|
||||
// then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
|
||||
@@ -373,10 +356,12 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
|
||||
}
|
||||
|
||||
const step = overrideStep ?? getSegmentGridStep()
|
||||
const angleStep = getWallAngleSnapStep(step)
|
||||
const basePoint =
|
||||
// The angle path snaps the distance ALONG the 15° ray — a scalar, the
|
||||
// same in world and local frames — so the `gridSnap` world-grid override
|
||||
// only applies when the angle lock is off.
|
||||
const basePoint: WallPlanPoint =
|
||||
start && angleSnap
|
||||
? snapPointTo45Degrees(start, point, step, angleStep, gridSnap)
|
||||
? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, step)]
|
||||
: gridSnap
|
||||
? gridSnap(point)
|
||||
: snapPointToGrid(point, step)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pascal-app/core'
|
||||
import {
|
||||
DEFAULT_ANGLE_STEP,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
snapPointAlongAngleRay,
|
||||
useScene,
|
||||
ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
@@ -10,42 +18,6 @@ import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const Y_OFFSET = 0.02
|
||||
|
||||
/**
|
||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||
*/
|
||||
const calculateSnapPoint = (
|
||||
lastPoint: [number, number],
|
||||
currentPoint: [number, number],
|
||||
): [number, number] => {
|
||||
const [x1, y1] = lastPoint
|
||||
const [x, y] = currentPoint
|
||||
|
||||
const dx = x - x1
|
||||
const dy = y - y1
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
|
||||
// Calculate distances to horizontal, vertical, and diagonal lines
|
||||
const horizontalDist = absDy
|
||||
const verticalDist = absDx
|
||||
const diagonalDist = Math.abs(absDx - absDy)
|
||||
|
||||
// Find the minimum distance to determine which axis to snap to
|
||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
||||
|
||||
if (minDist === diagonalDist) {
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
}
|
||||
if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1]
|
||||
}
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a zone with the given polygon points
|
||||
*/
|
||||
@@ -93,6 +65,7 @@ export const ZoneTool: React.FC = () => {
|
||||
const pointsRef = useRef<Array<[number, number]>>([])
|
||||
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||
const levelYRef = useRef(0) // Track current level Y position
|
||||
const shiftPressed = useRef(false)
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
|
||||
@@ -107,11 +80,30 @@ export const ZoneTool: React.FC = () => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
let cursorPosition: [number, number] = [0, 0]
|
||||
let rawCursorPosition: [number, number] = [0, 0]
|
||||
|
||||
// Initialize line geometries
|
||||
mainLineRef.current.geometry = new BufferGeometry()
|
||||
closingLineRef.current.geometry = new BufferGeometry()
|
||||
|
||||
// 15° angle snap from the last vertex by default. Shift bypasses all snap.
|
||||
// Distance snaps along the ray so the vertex lands on
|
||||
// grid-multiple lengths without leaving the ray.
|
||||
const snapDraftPoint = (
|
||||
lastPoint: [number, number],
|
||||
gridPoint: [number, number],
|
||||
rawPoint: [number, number],
|
||||
): [number, number] => {
|
||||
if (shiftPressed.current) return rawPoint
|
||||
const [x, z] = snapPointAlongAngleRay(
|
||||
lastPoint,
|
||||
rawPoint,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
useEditor.getState().gridSnapStep,
|
||||
)
|
||||
return [x, z]
|
||||
}
|
||||
|
||||
const updateLines = () => {
|
||||
const points = pointsRef.current
|
||||
const y = levelYRef.current + Y_OFFSET
|
||||
@@ -128,7 +120,7 @@ export const ZoneTool: React.FC = () => {
|
||||
// Add cursor point
|
||||
const lastPoint = points[points.length - 1]
|
||||
if (lastPoint) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
||||
if (isValidPoint(snapped)) {
|
||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
|
||||
}
|
||||
@@ -146,7 +138,7 @@ export const ZoneTool: React.FC = () => {
|
||||
// Update closing line (from cursor back to first point)
|
||||
const firstPoint = points[0]
|
||||
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
||||
if (isValidPoint(snapped)) {
|
||||
const closingPoints = [
|
||||
new Vector3(snapped[0], y, snapped[1]),
|
||||
@@ -167,7 +159,7 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
let cursorPt: [number, number] | null = null
|
||||
if (lastPoint) {
|
||||
cursorPt = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
cursorPt = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
||||
} else if (points.length === 0) {
|
||||
cursorPt = cursorPosition
|
||||
}
|
||||
@@ -181,22 +173,27 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to pull the snap off the visible grid lines.
|
||||
const [gridX, gridZ] = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const [gridX, gridZ] = bypassSnap
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
cursorPosition = [gridX, gridZ]
|
||||
rawCursorPosition = [event.localPosition[0], event.localPosition[2]]
|
||||
levelYRef.current = event.localPosition[1]
|
||||
|
||||
// If we have points, snap to axis from last point
|
||||
// If we have points, snap to the 15° ray from the last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||
const displayPoint = lastPoint
|
||||
? calculateSnapPoint(lastPoint, cursorPosition)
|
||||
? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
|
||||
: cursorPosition
|
||||
|
||||
// Play snap sound when the snapped position changes during drawing
|
||||
if (
|
||||
!bypassSnap &&
|
||||
pointsRef.current.length > 0 &&
|
||||
previousSnappedPointRef.current &&
|
||||
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
||||
@@ -214,17 +211,23 @@ export const ZoneTool: React.FC = () => {
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
const [gridX, gridZ] = snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const [gridX, gridZ] = bypassSnap
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
let clickPoint: [number, number] = [gridX, gridZ]
|
||||
|
||||
// Snap to axis from last point
|
||||
// Snap to the 15° ray from the last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||
if (lastPoint) {
|
||||
clickPoint = calculateSnapPoint(lastPoint, clickPoint)
|
||||
clickPoint = snapDraftPoint(lastPoint, clickPoint, [
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
])
|
||||
}
|
||||
|
||||
// Check if clicking on the first point to close the shape
|
||||
@@ -267,12 +270,28 @@ export const ZoneTool: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = true
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = false
|
||||
}
|
||||
const onWindowBlur = () => {
|
||||
shiftPressed.current = false
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
|
||||
// Subscribe to events
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('grid:double-click', onGridDoubleClick)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('grid:double-click', onGridDoubleClick)
|
||||
|
||||
+7
-2
@@ -80,8 +80,13 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ['Shift'],
|
||||
action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings',
|
||||
note: 'Hold while drawing.',
|
||||
action: 'Draw at any angle, bypassing the default 15° angle snap',
|
||||
note: 'Hold while drawing walls, fences, slabs, ceilings, and zones.',
|
||||
},
|
||||
{
|
||||
keys: ['Shift'],
|
||||
action: 'Rotate freely, bypassing the default 15° rotation snap',
|
||||
note: 'Hold while dragging a rotate handle.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -19,7 +19,10 @@ import { useEffect, useRef } from 'react'
|
||||
const sceneApi = createSceneApi(useScene)
|
||||
|
||||
function modifiersFromGridEvent(event: GridEvent): Modifiers {
|
||||
const ne = event.nativeEvent?.nativeEvent as Partial<KeyboardEvent> | undefined
|
||||
// Both grid-event emit paths (use-grid-events.ts and the floorplan panel)
|
||||
// store the raw DOM event directly at `.nativeEvent` — there is no second
|
||||
// `.nativeEvent` hop.
|
||||
const ne = event.nativeEvent as Partial<MouseEvent> | undefined
|
||||
return {
|
||||
shift: ne?.shiftKey ?? false,
|
||||
alt: ne?.altKey ?? false,
|
||||
|
||||
@@ -104,7 +104,6 @@ export {
|
||||
snapScalarToGrid,
|
||||
snapWallDraftPoint,
|
||||
snapWallDraftPointDetailed,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
type WallDraftSnapKind,
|
||||
type WallDraftSnapResult,
|
||||
@@ -299,6 +298,7 @@ export {
|
||||
usePaletteViewRegistry,
|
||||
} from './store/use-palette-view-registry'
|
||||
export { default as usePlacementPreview } from './store/use-placement-preview'
|
||||
export { default as useSegmentDraftChain } from './store/use-segment-draft-chain'
|
||||
export { useUploadStore } from './store/use-upload'
|
||||
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
|
||||
export {
|
||||
|
||||
@@ -43,6 +43,7 @@ export type SurfacePlanSnapInput = {
|
||||
candidates?: readonly AlignmentAnchor[]
|
||||
threshold?: number
|
||||
altKey?: boolean
|
||||
shiftKey?: boolean
|
||||
magnetic?: boolean
|
||||
align?: boolean
|
||||
highlightWalls?: boolean
|
||||
@@ -171,6 +172,12 @@ export function clearSurfacePlanSnapFeedback() {
|
||||
}
|
||||
|
||||
export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult {
|
||||
if (input.shiftKey) {
|
||||
useWallSnapIndicator.getState().clear()
|
||||
useAlignmentGuides.getState().clear()
|
||||
return { point: input.rawPoint, wallSnap: null, guides: [], wallIds: [] }
|
||||
}
|
||||
|
||||
const nodes = input.nodes ?? useScene.getState().nodes
|
||||
const walls = getLevelWalls(nodes, input.levelId, input.walls)
|
||||
const fallbackPoint = input.fallbackPoint
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Ephemeral store for the wall / fence tools' click-chaining start points.
|
||||
// The 3D tools (`@pascal-app/nodes` wall/tool.tsx, fence/tool.tsx) own node
|
||||
// creation for both views; after each chained commit they publish the
|
||||
// created segment's resolved end here so the 2D floor-plan draft chains its
|
||||
// next segment from the same point instead of re-deriving it through a
|
||||
// different snap pipeline. Cleared on cancel, single-segment commit, and
|
||||
// unmount — never persisted, never in undo history.
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { WallPlanPoint } from '../components/tools/wall/wall-snap-geometry'
|
||||
|
||||
type SegmentKind = 'wall' | 'fence'
|
||||
|
||||
type SegmentDraftChainState = {
|
||||
wall: WallPlanPoint | null
|
||||
fence: WallPlanPoint | null
|
||||
setChainStart(kind: SegmentKind, point: WallPlanPoint | null): void
|
||||
clear(kind: SegmentKind): void
|
||||
}
|
||||
|
||||
const useSegmentDraftChain = create<SegmentDraftChainState>((set) => ({
|
||||
wall: null,
|
||||
fence: null,
|
||||
setChainStart: (kind, point) => set({ [kind]: point }),
|
||||
clear: (kind) => set({ [kind]: null }),
|
||||
}))
|
||||
|
||||
export default useSegmentDraftChain
|
||||
Reference in New Issue
Block a user