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:
Aymeric Rabot
2026-06-11 13:03:34 -04:00
committed by GitHub
parent 2d2dba5dba
commit aab48e053f
111 changed files with 2211 additions and 955 deletions
+1
View File
@@ -54,6 +54,7 @@ export {
DEFAULT_GRID_STEP, DEFAULT_GRID_STEP,
type SnapServices, type SnapServices,
snapAngleToList, snapAngleToList,
snapPointAlongAngleRay,
snapPointToAngle, snapPointToAngle,
snapPointToGrid, snapPointToGrid,
snapScalar, snapScalar,
+49
View File
@@ -3,6 +3,7 @@ import {
DEFAULT_ANGLE_STEP, DEFAULT_ANGLE_STEP,
DEFAULT_GRID_STEP, DEFAULT_GRID_STEP,
snapAngleToList, snapAngleToList,
snapPointAlongAngleRay,
snapPointToAngle, snapPointToAngle,
snapPointToGrid, snapPointToGrid,
snapScalar, snapScalar,
@@ -87,6 +88,54 @@ describe('snapPointToAngle', () => {
}) })
}) })
describe('snapPointAlongAngleRay', () => {
test('stays exactly on the 15° ray while distance-snapping to the grid step', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [2, 0.5] // ≈14° — snaps to 15°
const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.25)
expect(Math.atan2(snapped[1], snapped[0])).toBeCloseTo(Math.PI / 12, 10)
const distance = Math.hypot(snapped[0], snapped[1])
expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10)
})
test('grid-snapping after the angle projection would pull the point off the ray', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [2, 0.5]
const offRay = snapPointToAngle(from, cursor, Math.PI / 12, 0.25)
expect(Math.atan2(offRay[1], offRay[0])).not.toBeCloseTo(Math.PI / 12, 4)
})
test('45° back-compat: locks to the diagonal with grid-multiple distance', () => {
const from: Vec2 = [1, 1]
const cursor: Vec2 = [2.1, 1.9] // near 45° from `from`
const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 4, 0.25)
expect(Math.atan2(snapped[1] - 1, snapped[0] - 1)).toBeCloseTo(Math.PI / 4, 10)
const distance = Math.hypot(snapped[0] - 1, snapped[1] - 1)
expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10)
})
test('preserves the projected distance when no distanceStep is given', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1, 0.05] // near 0°
const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12)
expect(snapped[0]).toBeCloseTo(1, 10) // projection of (1, 0.05) onto 0° ray
expect(snapped[1]).toBeCloseTo(0, 10)
})
test('returns `from` for a zero-length segment', () => {
expect(snapPointAlongAngleRay([2, 3], [2, 3], Math.PI / 12, 0.25)).toEqual([2, 3])
})
test('is idempotent on its own output', () => {
const from: Vec2 = [0.5, -1]
const cursor: Vec2 = [3.2, 0.4]
const once = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.5)
const twice = snapPointAlongAngleRay(from, once, Math.PI / 12, 0.5)
expect(twice[0]).toBeCloseTo(once[0], 10)
expect(twice[1]).toBeCloseTo(once[1], 10)
})
})
describe('snapAngleToList', () => { describe('snapAngleToList', () => {
test('snaps to the nearest entry within tolerance', () => { test('snaps to the nearest entry within tolerance', () => {
const targets = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2] const targets = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]
+27 -1
View File
@@ -15,7 +15,7 @@ export type Vec3 = readonly [number, number, number]
/** Default planar grid spacing in meters. Matches the editor's wall tool. */ /** Default planar grid spacing in meters. Matches the editor's wall tool. */
export const DEFAULT_GRID_STEP = 0.25 export const DEFAULT_GRID_STEP = 0.25
/** Default angle-snap step — π/12 = 15°. Wall tools also use π/4 (45°). */ /** Default angle-snap step — π/12 = 15°. */
export const DEFAULT_ANGLE_STEP = Math.PI / 12 export const DEFAULT_ANGLE_STEP = Math.PI / 12
// ─── Grid snap ──────────────────────────────────────────────────────── // ─── Grid snap ────────────────────────────────────────────────────────
@@ -111,6 +111,32 @@ export function snapPointToAngle(
return gridStep == null ? projected : snapPointToGrid(projected, gridStep) return gridStep == null ? projected : snapPointToGrid(projected, gridStep)
} }
/**
* Snaps a cursor point onto the nearest angle ray from `from` (multiples of
* `angleStep`), projecting the cursor onto that ray, then snaps the distance
* ALONG the ray to `distanceStep`. Unlike `snapPointToAngle` with a
* `gridStep`, the result stays exactly on the snapped ray — grid-snapping
* after the angle projection pulls points off non-axis rays.
*/
export function snapPointAlongAngleRay(
from: Vec2,
cursor: Vec2,
angleStep: number = DEFAULT_ANGLE_STEP,
distanceStep?: number,
): Vec2 {
const dx = cursor[0] - from[0]
const dz = cursor[1] - from[1]
if (dx === 0 && dz === 0) return [from[0], from[1]]
const angle = Math.atan2(dz, dx)
const snappedAngle = angleStep > 0 ? Math.round(angle / angleStep) * angleStep : angle
const dirX = Math.cos(snappedAngle)
const dirZ = Math.sin(snappedAngle)
const projected = dx * dirX + dz * dirZ
const distance =
distanceStep != null && distanceStep > 0 ? snapScalar(projected, distanceStep) : projected
return [from[0] + dirX * distance, from[1] + dirZ * distance]
}
/** /**
* Snaps an angle (in radians) to the nearest entry in `snapAngles` (also in * Snaps an angle (in radians) to the nearest entry in `snapAngles` (also in
* radians). Returns the original angle if no entry is within `toleranceRad`. * radians). Returns the original angle if no entry is within `toleranceRad`.
@@ -428,7 +428,8 @@ export function FloorplanRegistryMoveOverlay() {
// 1) Grid snap baseline. Fresh catalog placement is absolute under // 1) Grid snap baseline. Fresh catalog placement is absolute under
// the cursor; existing moves preserve the cursor's grab offset. // the cursor; existing moves preserve the cursor's grab offset.
const gridStep = useEditor.getState().gridSnapStep 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({ const resolved = resolvePlanarCursorPosition({
cursor: [m[0], m[1]], cursor: [m[0], m[1]],
original: [originalPosition[0], originalPosition[2]], original: [originalPosition[0], originalPosition[2]],
@@ -442,11 +443,11 @@ export function FloorplanRegistryMoveOverlay() {
// 2) Alignment snap layered on top. Treat the grid-snapped point // 2) Alignment snap layered on top. Treat the grid-snapped point
// as the "proposed" position so alignment competes from a stable // as the "proposed" position so alignment competes from a stable
// base rather than the raw cursor jitter. Alt bypasses alignment // 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. // hint chip.
let finalX = gridX let finalX = gridX
let finalZ = gridZ 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 // Translate the cached local bbox to the proposed pos to get the
// moving anchors at that location. The entry's untransformed // moving anchors at that location. The entry's untransformed
// bbox is in world meters relative to the node's origin, so a // bbox is in world meters relative to the node's origin, so a
@@ -8,6 +8,7 @@ import {
type CeilingNode, type CeilingNode,
type ColumnNode, type ColumnNode,
calculateLevelMiters, calculateLevelMiters,
DEFAULT_ANGLE_STEP,
type DoorNode, type DoorNode,
type ElevatorNode, type ElevatorNode,
emitter, emitter,
@@ -38,6 +39,7 @@ import {
StairSegmentNode as StairSegmentNodeSchema, StairSegmentNode as StairSegmentNodeSchema,
sampleWallCenterline, sampleWallCenterline,
sceneRegistry, sceneRegistry,
snapPointAlongAngleRay,
useInteractive, useInteractive,
useLiveNodeOverrides, useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
@@ -47,7 +49,7 @@ import {
ZoneNode as ZoneNodeSchema, ZoneNode as ZoneNodeSchema,
type ZoneNode as ZoneNodeType, type ZoneNode as ZoneNodeType,
} from '@pascal-app/core' } 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 { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Command, Ruler } from 'lucide-react' import { Command, Ruler } from 'lucide-react'
import { import {
@@ -135,12 +137,10 @@ import {
DEFAULT_STAIR_WIDTH, DEFAULT_STAIR_WIDTH,
} from '../tools/stair/stair-defaults' } from '../tools/stair/stair-defaults'
import { import {
createWallOnCurrentLevel,
isSegmentLongEnough, isSegmentLongEnough,
snapWallDraftPoint, snapWallDraftPoint,
snapWallDraftPointDetailed, snapWallDraftPointDetailed,
snapPointToGrid as snapWallPointToGrid, snapPointToGrid as snapWallPointToGrid,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP, WALL_GRID_STEP,
type WallPlanPoint, type WallPlanPoint,
} from '../tools/wall/wall-drafting' } 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_OFFSET = 72
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92 const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48 const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48
const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45 const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 15
const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1
const FLOORPLAN_VIEW_ROTATION_DEG = 90 const FLOORPLAN_VIEW_ROTATION_DEG = 90
const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35 const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35
const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90 const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90
@@ -1277,7 +1276,7 @@ function buildGuideResizeDraft(
function buildGuideRotationDraft( function buildGuideRotationDraft(
interaction: GuideInteractionState, interaction: GuideInteractionState,
pointerSvg: SvgPoint, pointerSvg: SvgPoint,
useFineIncrement: boolean, bypassSnap: boolean,
): GuideTransformDraft { ): GuideTransformDraft {
const pointerVector = subtractSvgPoints(pointerSvg, interaction.centerSvg) const pointerVector = subtractSvgPoints(pointerSvg, interaction.centerSvg)
@@ -1292,12 +1291,9 @@ function buildGuideRotationDraft(
const rawRotationSvg = const rawRotationSvg =
Math.atan2(pointerVector[1], pointerVector[0]) - interaction.cornerBaseAngle Math.atan2(pointerVector[1], pointerVector[0]) - interaction.cornerBaseAngle
const snappedRotationSvg = snapAngleToIncrement( const snappedRotationSvg = bypassSnap
rawRotationSvg, ? rawRotationSvg
useFineIncrement : snapAngleToIncrement(rawRotationSvg, FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES)
? FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES
: FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES,
)
return { return {
guideId: interaction.guideId, 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 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({ function snapPolygonDraftPoint({
point, point,
start, start,
angleSnap, angleSnap,
bypassSnap,
}: { }: {
point: WallPlanPoint point: WallPlanPoint
start?: WallPlanPoint start?: WallPlanPoint
angleSnap: boolean angleSnap: boolean
bypassSnap?: boolean
}): WallPlanPoint { }): WallPlanPoint {
const snappedPoint: WallPlanPoint = [snapToHalf(point[0]), snapToHalf(point[1])] if (bypassSnap) return point
if (!(start && angleSnap)) { 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( function pointMatchesWallPlanPoint(
@@ -5508,16 +5484,18 @@ export function FloorplanPanel({
) )
const floorplanOpeningLocalY = useMemo(() => { const floorplanOpeningLocalY = useMemo(() => {
if (movingNode?.type === 'door' || movingNode?.type === 'window') { if (movingNode?.type === 'door' || movingNode?.type === 'window') {
return snapToHalf(movingNode.position[1]) return shiftPressed ? movingNode.position[1] : snapToHalf(movingNode.position[1])
} }
if (isWindowBuildActive) { if (isWindowBuildActive) {
// Floorplan is top-down, so new windows need an explicit wall-local height. // 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 return 0
}, [isWindowBuildActive, movingNode]) }, [isWindowBuildActive, movingNode, shiftPressed])
const isMarqueeSelectionToolActive = const isMarqueeSelectionToolActive =
mode === 'select' && mode === 'select' &&
floorplanSelectionTool === 'marquee' && floorplanSelectionTool === 'marquee' &&
@@ -7566,9 +7544,10 @@ export function FloorplanPanel({
return return
} }
const bypassSnap = shiftPressed || event.shiftKey
const nextDraft = const nextDraft =
guideInteraction.mode === 'rotate' guideInteraction.mode === 'rotate'
? buildGuideRotationDraft(guideInteraction, svgPoint, shiftPressed) ? buildGuideRotationDraft(guideInteraction, svgPoint, bypassSnap)
: guideInteraction.mode === 'translate' : guideInteraction.mode === 'translate'
? buildGuideTranslateDraft(guideInteraction, svgPoint) ? buildGuideTranslateDraft(guideInteraction, svgPoint)
: buildGuideResizeDraft(guideInteraction, svgPoint) : buildGuideResizeDraft(guideInteraction, svgPoint)
@@ -7625,15 +7604,14 @@ export function FloorplanPanel({
return return
} }
// Wall endpoint move: grid snap only (no 45° angle snap from the // Wall endpoint move: grid snap only. Shift bypasses all snap.
// fixed corner — that's draft-only behaviour). Shift switches const bypassSnap = shiftPressed || event.shiftKey
// to the fine grid step for precision.
const snapResult = snapWallDraftPointDetailed({ const snapResult = snapWallDraftPointDetailed({
point: planPoint, point: planPoint,
walls, walls,
ignoreWallIds: [dragState.wallId], ignoreWallIds: [dragState.wallId],
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, bypassSnap,
magnetic: useEditor.getState().magneticSnap, magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}) })
const snappedPoint = snapResult.point const snappedPoint = snapResult.point
// Magnetic beacon at the endpoint when it locked onto existing geometry. // Magnetic beacon at the endpoint when it locked onto existing geometry.
@@ -7674,6 +7652,7 @@ export function FloorplanPanel({
) )
if ( if (
!bypassSnap &&
!( !(
previousDraft && previousDraft &&
pointsEqual(previousDraft.start, nextDraft.start) && pointsEqual(previousDraft.start, nextDraft.start) &&
@@ -7702,7 +7681,8 @@ export function FloorplanPanel({
} }
const chord = getWallChordFrame(wall) const chord = getWallChordFrame(wall)
const snappedPoint: WallPlanPoint = shiftPressed const bypassSnap = shiftPressed || event.shiftKey
const snappedPoint: WallPlanPoint = bypassSnap
? planPoint ? planPoint
: [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
const rawCurveOffset = -( const rawCurveOffset = -(
@@ -7711,7 +7691,7 @@ export function FloorplanPanel({
) )
const nextCurveOffset = normalizeWallCurveOffset( const nextCurveOffset = normalizeWallCurveOffset(
wall, wall,
shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset), bypassSnap ? rawCurveOffset : snapToHalf(rawCurveOffset),
) )
if (curveDragState.currentCurveOffset === nextCurveOffset) { if (curveDragState.currentCurveOffset === nextCurveOffset) {
@@ -7721,8 +7701,10 @@ export function FloorplanPanel({
curveDragState.currentCurveOffset = nextCurveOffset curveDragState.currentCurveOffset = nextCurveOffset
setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset }) setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset })
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
if (!bypassSnap) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
}
const commitGuideInteraction = (event: PointerEvent) => { const commitGuideInteraction = (event: PointerEvent) => {
const interaction = guideInteractionRef.current const interaction = guideInteractionRef.current
@@ -7739,9 +7721,10 @@ export function FloorplanPanel({
} }
const svgPoint = getSvgPointFromClientPoint(event.clientX, event.clientY) const svgPoint = getSvgPointFromClientPoint(event.clientX, event.clientY)
const bypassSnap = shiftPressed || event.shiftKey
const nextDraft = svgPoint const nextDraft = svgPoint
? interaction.mode === 'rotate' ? interaction.mode === 'rotate'
? buildGuideRotationDraft(interaction, svgPoint, shiftPressed) ? buildGuideRotationDraft(interaction, svgPoint, bypassSnap)
: interaction.mode === 'translate' : interaction.mode === 'translate'
? buildGuideTranslateDraft(interaction, svgPoint) ? buildGuideTranslateDraft(interaction, svgPoint)
: buildGuideResizeDraft(interaction, svgPoint) : buildGuideResizeDraft(interaction, svgPoint)
@@ -7949,7 +7932,10 @@ export function FloorplanPanel({
return 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) setCursorPoint(snappedPoint)
const currentDraft = siteBoundaryDraftRef.current const currentDraft = siteBoundaryDraftRef.current
@@ -7962,7 +7948,9 @@ export function FloorplanPanel({
return return
} }
if (!bypassSnap) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
}
const nextPolygon = [...currentDraft.polygon] const nextPolygon = [...currentDraft.polygon]
nextPolygon[dragState.vertexIndex] = snappedPoint nextPolygon[dragState.vertexIndex] = snappedPoint
@@ -8037,6 +8025,7 @@ export function FloorplanPanel({
exitSiteEditingToSelect, exitSiteEditingToSelect,
getPlanPointFromClientPoint, getPlanPointFromClientPoint,
setSiteBoundaryLivePreview, setSiteBoundaryLivePreview,
shiftPressed,
site, site,
siteBoundaryWorldPolygon, siteBoundaryWorldPolygon,
siteVertexDragState, siteVertexDragState,
@@ -8176,25 +8165,26 @@ export function FloorplanPanel({
stopPropagation: () => {}, stopPropagation: () => {},
} as any) } 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( const emitFloorplanGridEvent = useCallback(
( (
eventType: 'move' | 'click' | 'double-click', eventType: 'move' | 'click' | 'double-click',
planPoint: WallPlanPoint, planPoint: WallPlanPoint,
nativeEvent: ReactMouseEvent<SVGSVGElement> | ReactPointerEvent<SVGSVGElement>, nativeEvent: ReactMouseEvent<SVGSVGElement> | ReactPointerEvent<SVGSVGElement>,
) => { ) => {
const snappedPoint = getSnappedFloorplanPoint(planPoint)
const cos = Math.cos(buildingRotationY) const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY) const sin = Math.sin(buildingRotationY)
const worldX = buildingPosition[0] + snappedPoint[0] * cos + snappedPoint[1] * sin const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin
const worldZ = buildingPosition[2] - snappedPoint[0] * sin + snappedPoint[1] * cos const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos
emitter.emit(`grid:${eventType}` as any, { emitter.emit(`grid:${eventType}` as any, {
nativeEvent: nativeEvent.nativeEvent as any, nativeEvent: nativeEvent.nativeEvent as any,
position: [worldX, floorplanGridWorldY, worldZ], position: [worldX, floorplanGridWorldY, worldZ],
localPosition: [snappedPoint[0], floorplanGridLocalY, snappedPoint[1]], localPosition: [planPoint[0], floorplanGridLocalY, planPoint[1]],
}) })
return snappedPoint
}, },
[buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY], [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY],
) )
@@ -8413,7 +8403,7 @@ export function FloorplanPanel({
} }
if (referenceScaleDraft) { if (referenceScaleDraft) {
emitFloorplanGridEvent('move', planPoint, event) emitFloorplanGridEvent('move', getSnappedFloorplanPoint(planPoint), event)
setCursorPoint((previousPoint) => setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint, previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint,
@@ -8430,21 +8420,24 @@ export function FloorplanPanel({
} }
if (isCeilingBuildActive) { if (isCeilingBuildActive) {
// Polygon vertex: grid (snapToHalf) + optional 45° angle snap from const bypassSnap = shiftPressed || event.shiftKey
// the previous vertex. Wall magnetic snap may still win, while // 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, // generic alignment runs only when angle snap is OFF (first vertex,
// or Shift held) so it does not pull a locked angle sideways. // 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({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1], start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap, angleSnap,
bypassSnap,
}) })
const snappedPoint = resolveCeilingPlanPointSnap({ const snappedPoint = resolveCeilingPlanPointSnap({
rawPoint: planPoint, rawPoint: planPoint,
fallbackPoint, fallbackPoint,
levelId, levelId,
altKey: event.altKey, altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap, align: !angleSnap,
}).point }).point
@@ -8456,8 +8449,11 @@ export function FloorplanPanel({
} }
if (isRoofBuildActive) { if (isRoofBuildActive) {
let snappedPoint = getSnappedFloorplanPoint(planPoint) const bypassSnap = shiftPressed || event.shiftKey
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) let snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint)
snappedPoint = alignFloorplanDraftPoint(snappedPoint, {
bypass: event.altKey || bypassSnap,
})
emitFloorplanGridEvent('move', snappedPoint, event) emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) => setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
@@ -8474,23 +8470,31 @@ export function FloorplanPanel({
} }
if (isFenceBuildActive) { if (isFenceBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
// Figma alignment — same endpoint-wins precedence as the wall branch. // Figma alignment — same endpoint-wins precedence as the wall branch.
// While a draft is open the segment locks to 15° rays from its start
// unless Shift is held; Shift bypasses grid, magnetic, angle, and
// alignment snap.
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
const fenceSnapped = snapFenceDraftPoint({ const fenceSnapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
fences, fences,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap,
bypassSnap,
}) })
const fenceGridBase = snapWallPointToGrid( const fenceGridBase = bypassSnap ? planPoint : snapWallPointToGrid(planPoint)
planPoint,
shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
)
const fenceLocked = const fenceLocked =
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] !bypassSnap &&
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
let snappedPoint = fenceSnapped let snappedPoint = fenceSnapped
if (fenceLocked) useAlignmentGuides.getState().clear() if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear()
else snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey }) else
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
bypass: event.altKey || bypassSnap,
})
emitFloorplanGridEvent('move', snappedPoint, event) emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) => setCursorPoint((previousPoint) =>
@@ -8511,11 +8515,13 @@ export function FloorplanPanel({
// the local polygon-draft state actually updates as the cursor // the local polygon-draft state actually updates as the cursor
// moves (the catch-all would otherwise swallow the move event). // moves (the catch-all would otherwise swallow the move event).
if (isPolygonBuildActive) { if (isPolygonBuildActive) {
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed const bypassSnap = shiftPressed || event.shiftKey
const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap
const fallbackPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap, angleSnap,
bypassSnap,
}) })
let snappedPoint = fallbackPoint let snappedPoint = fallbackPoint
if (isSlabBuildActive) { if (isSlabBuildActive) {
@@ -8524,12 +8530,15 @@ export function FloorplanPanel({
fallbackPoint, fallbackPoint,
levelId, levelId,
altKey: event.altKey, altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap, align: !angleSnap,
}).point }).point
} else if (angleSnap) { } else if (angleSnap) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} else { } 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 // Emit `grid:move` so the registry-driven slab tool also tracks
@@ -8538,7 +8547,7 @@ export function FloorplanPanel({
setCursorPoint((previousPoint) => { setCursorPoint((previousPoint) => {
const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint)) const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint))
if (hasChanged && activePolygonDraftPoints.length > 0) { if (!bypassSnap && hasChanged && activePolygonDraftPoints.length > 0) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
return snappedPoint return snappedPoint
@@ -8594,7 +8603,8 @@ export function FloorplanPanel({
// routing through `grid:move`, which would otherwise be processed // routing through `grid:move`, which would otherwise be processed
// by the floor strategy and drop the item to floor height. // by the floor strategy and drop the item to floor height.
if (isCeilingItemPlacementActive) { if (isCeilingItemPlacementActive) {
const snappedPoint = getSnappedFloorplanPoint(planPoint) const bypassSnap = shiftPressed || event.shiftKey
const snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint)
setCursorPoint((previousPoint) => setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
) )
@@ -8608,7 +8618,8 @@ export function FloorplanPanel({
// comment there). Wall build skips this so its own branch below // comment there). Wall build skips this so its own branch below
// updates local `draftEnd` state alongside the registry tool. // updates local `draftEnd` state alongside the registry tool.
if (!isWallBuildActive && isFloorplanGridInteractionActive) { if (!isWallBuildActive && isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event) const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) => setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
) )
@@ -8630,27 +8641,31 @@ export function FloorplanPanel({
return return
} }
// Wall draft: grid snap (orthogonal walls follow naturally from a // Wall draft: grid + magnetic snap, then Figma-style alignment.
// grid-aligned start; Shift = fine 0.05m step), then Figma-style // While a draft is open the segment locks to 15° rays from its
// alignment layered on top. An existing wall endpoint / join snap // start unless Shift is held. Shift bypasses grid, magnetic, angle,
// wins outright — never pull the cursor off a corner the user is // and alignment snap.
// closing onto — so alignment runs ONLY when the wall snap left the const bypassSnap = shiftPressed || event.shiftKey
// point on the plain grid. Alt bypasses alignment. const wallAngleSnap = draftStart !== null && !bypassSnap
const wallSnap = snapWallDraftPointDetailed({ const wallSnap = snapWallDraftPointDetailed({
point: planPoint, point: planPoint,
walls, walls,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, start: draftStart ?? undefined,
magnetic: useEditor.getState().magneticSnap, angleSnap: wallAngleSnap,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}) })
const wallSnapped = wallSnap.point const wallSnapped = wallSnap.point
// Locked onto existing geometry (corner / midpoint / crossing / edge) → // Locked onto existing geometry (corner / midpoint / crossing / edge) →
// that snap wins, so skip Figma alignment and stand the beacon there. // that snap wins, so skip Figma alignment and stand the beacon there.
const lockedToWall = wallSnap.snap !== null const lockedToWall = wallSnap.snap !== null
let snappedPoint = wallSnapped let snappedPoint = wallSnapped
if (lockedToWall) { if (lockedToWall || wallAngleSnap) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} else { } else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey }) snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
bypass: event.altKey || bypassSnap,
})
} }
useWallSnapIndicator useWallSnapIndicator
.getState() .getState()
@@ -8668,9 +8683,8 @@ export function FloorplanPanel({
setDraftEnd((previousEnd) => { setDraftEnd((previousEnd) => {
if ( if (
!previousEnd || !bypassSnap &&
previousEnd[0] !== snappedPoint[0] || (!previousEnd || previousEnd[0] !== snappedPoint[0] || previousEnd[1] !== snappedPoint[1])
previousEnd[1] !== snappedPoint[1]
) { ) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -8874,17 +8888,10 @@ export function FloorplanPanel({
// call. `emitFloorplanGridEvent('click', …)` in // call. `emitFloorplanGridEvent('click', …)` in
// `useFloorplanBackgroundPlacement` fires it synchronously // `useFloorplanBackgroundPlacement` fires it synchronously
// just before this callback runs, so by the time we get here // just before this callback runs, so by the time we get here
// the wall already exists in the scene. // the wall already exists in the scene. Committing here as
// // well used to double-create walls whenever the two snap
// We still attempt the create as a fallback in case the 3D // pipelines resolved endpoints ≥1e-6 apart (the duplicate
// tool isn't mounted (unusual — both views are always // check compares exact endpoints).
// 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)
// Alt commits a single wall: drop the draft so the next click // Alt commits a single wall: drop the draft so the next click
// starts a fresh segment instead of chaining off this endpoint. // starts a fresh segment instead of chaining off this endpoint.
@@ -8895,9 +8902,10 @@ export function FloorplanPanel({
return return
} }
const nextStart: WallPlanPoint = createdWall // Chain the next segment from the 3D tool's resolved commit
? [createdWall.end[0], createdWall.end[1]] // point (it may have corner-snapped or split-adjusted the
: point // endpoint) so both views draft from the same start.
const nextStart: WallPlanPoint = useSegmentDraftChain.getState().wall ?? point
setDraftStart(nextStart) setDraftStart(nextStart)
setDraftEnd(nextStart) setDraftEnd(nextStart)
setCursorPoint(nextStart) setCursorPoint(nextStart)
@@ -8930,6 +8938,7 @@ export function FloorplanPanel({
walls: WallNode[] walls: WallNode[]
start?: WallPlanPoint start?: WallPlanPoint
angleSnap?: boolean angleSnap?: boolean
bypassSnap?: boolean
step?: number step?: number
}) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }), }) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }),
[], [],
@@ -8939,6 +8948,7 @@ export function FloorplanPanel({
ceilingDraftPoints, ceilingDraftPoints,
clearFencePlacementDraft, clearFencePlacementDraft,
clearRoofPlacementDraft, clearRoofPlacementDraft,
clearWallPlacementDraft,
emitFloorplanGridEvent, emitFloorplanGridEvent,
fenceDraftStart, fenceDraftStart,
fences, fences,
@@ -8994,7 +9004,7 @@ export function FloorplanPanel({
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
emitFloorplanGridEvent('click', planPoint, event) emitFloorplanGridEvent('click', getSnappedFloorplanPoint(planPoint), event)
if (!referenceScaleDraft.start) { if (!referenceScaleDraft.start) {
setReferenceScaleDraft({ setReferenceScaleDraft({
@@ -9137,11 +9147,13 @@ export function FloorplanPanel({
return return
} }
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed const bypassSnap = shiftPressed || event.shiftKey
const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap
const fallbackPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap, angleSnap,
bypassSnap,
}) })
if (isCeilingBuildActive) { if (isCeilingBuildActive) {
@@ -9150,6 +9162,7 @@ export function FloorplanPanel({
fallbackPoint, fallbackPoint,
levelId, levelId,
altKey: event.altKey, altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap, align: !angleSnap,
}).point }).point
emitFloorplanGridEvent('double-click', snappedPoint, event) emitFloorplanGridEvent('double-click', snappedPoint, event)
@@ -9165,6 +9178,7 @@ export function FloorplanPanel({
fallbackPoint, fallbackPoint,
levelId, levelId,
altKey: event.altKey, altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap, align: !angleSnap,
}).point }).point
// Slab is registry-driven: forward the double-click so the 3D tool // Slab is registry-driven: forward the double-click so the 3D tool
@@ -3,6 +3,7 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
DEFAULT_ANGLE_STEP,
useLiveNodeOverrides, useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
@@ -40,8 +41,6 @@ import {
useInvisibleHitAreaMaterial, useInvisibleHitAreaMaterial,
} from './node-arrow-handles' } from './node-arrow-handles'
const ROTATE_SNAP = Math.PI / 12 // 15°
/** /**
* Group-rotate gizmo. When 2+ transformable nodes in the active level frame are * 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 * 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 let delta = angleOf(moveHit) - initialAngle
while (delta > Math.PI) delta -= 2 * Math.PI while (delta > Math.PI) delta -= 2 * Math.PI
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 // 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 // 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 { useViewer } from '@pascal-app/viewer'
import { type ThreeEvent, useThree } from '@react-three/fiber' import { type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' 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 { sfxEmitter } from '../../../lib/sfx-bus'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
@@ -27,9 +27,12 @@ type IntersectPlane = (
target: Vector3, target: Vector3,
) => Vector3 | null ) => Vector3 | null
type GetPointerRay = (clientX: number, clientY: number, target: Ray) => Ray
export type HandleDragStartContext = { export type HandleDragStartContext = {
event: ThreeEvent<PointerEvent> event: ThreeEvent<PointerEvent>
camera: Camera camera: Camera
getPointerRay: GetPointerRay
intersectPlane: IntersectPlane intersectPlane: IntersectPlane
initialNode: AnyNode initialNode: AnyNode
node: AnyNode node: AnyNode
@@ -40,6 +43,7 @@ export type HandleDragStartContext = {
export type HandleDragMoveContext = { export type HandleDragMoveContext = {
event: PointerEvent event: PointerEvent
getPointerRay: GetPointerRay
intersectPlane: IntersectPlane intersectPlane: IntersectPlane
} }
@@ -121,13 +125,20 @@ export function useHandleDrag(args: UseHandleDragArgs) {
rideObject.updateMatrixWorld() rideObject.updateMatrixWorld()
const ndc = new Vector2() const ndc = new Vector2()
const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => { const setPointerRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect() const rect = gl.domElement.getBoundingClientRect()
ndc.set( ndc.set(
((clientX - rect.left) / rect.width) * 2 - 1, ((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1, -((clientY - rect.top) / rect.height) * 2 + 1,
) )
raycaster.setFromCamera(ndc, camera) 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) return raycaster.ray.intersectPlane(plane, target)
} }
@@ -137,6 +148,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
const session = args.onStart({ const session = args.onStart({
event, event,
camera, camera,
getPointerRay,
intersectPlane, intersectPlane,
initialNode, initialNode,
node, node,
@@ -159,7 +171,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
let lastPatch: Partial<AnyNode> | null = null let lastPatch: Partial<AnyNode> | null = null
const onMove = (moveEvent: PointerEvent) => { const onMove = (moveEvent: PointerEvent) => {
const patch = session.move({ event: moveEvent, intersectPlane }) const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane })
if (!patch) return if (!patch) return
lastPatch = patch lastPatch = patch
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>) useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
@@ -6,6 +6,7 @@ import {
type ArcResizeHandle, type ArcResizeHandle,
type Cursor, type Cursor,
createSceneApi, createSceneApi,
DEFAULT_ANGLE_STEP,
type HandleDescriptor, type HandleDescriptor,
type HandlePortal, type HandlePortal,
type LinearResizeHandle, type LinearResizeHandle,
@@ -34,6 +35,7 @@ import {
OrthographicCamera, OrthographicCamera,
Plane, Plane,
Quaternion, Quaternion,
Ray,
RingGeometry, RingGeometry,
Vector3, Vector3,
} from 'three' } 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. // Pooled scratch for the handle rig's world-relative pose mapping.
const _rigRelative = new Matrix4() const _rigRelative = new Matrix4()
const _rigScratchScale = new Vector3() 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 { export {
ARROW_COLOR, ARROW_COLOR,
@@ -579,34 +618,31 @@ function LinearArrow({
rideObject, rideObject,
setIsDragging, setIsDragging,
onStart: ({ onStart: ({
camera: dragCamera,
event, event,
getPointerRay,
initialNode, initialNode,
intersectPlane,
nodeId, nodeId,
rideObject: dragRideObject, rideObject: dragRideObject,
sceneApi, sceneApi,
}) => { }) => {
const initialFrameInverse = new Matrix4().copy(dragRideObject.matrixWorld).invert() dragRideObject.matrixWorld.decompose(_resizePositionW, _resizeQuaternion, _resizeScale)
const worldOrigin = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld) _resizeOriginW.set(...position).applyMatrix4(dragRideObject.matrixWorld)
const planeNormal = new Vector3().subVectors(dragCamera.position, worldOrigin).setY(0) axisVector(descriptor.axis, _resizeAxisW).applyQuaternion(_resizeQuaternion).normalize()
if (planeNormal.lengthSq() === 0) return null const localToWorldScale = axisScale(descriptor.axis, _resizeScale)
planeNormal.normalize() if (Math.abs(localToWorldScale) < 1e-6 || _resizeAxisW.lengthSq() === 0) return null
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
const hitWorld = new Vector3() const initialPointer =
if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) { closestAxisParameterToRay(
return null _resizeOriginW,
} _resizeAxisW,
const hitLocal = hitWorld.clone().applyMatrix4(initialFrameInverse) getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay),
) / localToWorldScale
const overrideId = const overrideId =
(descriptor.kind === 'linear-resize' (descriptor.kind === 'linear-resize'
? descriptor.overrideTarget?.(initialNode as never, sceneApi) ? descriptor.overrideTarget?.(initialNode as never, sceneApi)
: undefined) ?? nodeId : undefined) ?? nodeId
const initialValue = descriptor.currentValue(initialNode) 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 minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi) const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
const gridSnapStep = const gridSnapStep =
@@ -634,22 +670,19 @@ function LinearArrow({
useEditor.getState().setActiveHandleDrag(null) useEditor.getState().setActiveHandleDrag(null)
} }
}, },
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
const intersection = new Vector3()
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, intersection)) {
return null
}
const intersectionLocal = intersection.clone().applyMatrix4(initialFrameInverse)
const currentPointer = const currentPointer =
descriptor.axis === 'x' closestAxisParameterToRay(
? intersectionLocal.x _resizeOriginW,
: descriptor.axis === 'y' _resizeAxisW,
? intersectionLocal.y getMovePointerRay(moveEvent.clientX, moveEvent.clientY, _resizeRay),
: intersectionLocal.z ) / localToWorldScale
const delta = currentPointer - initialPointer const delta = currentPointer - initialPointer
const rawNext = initialValue + delta * factor const rawNext = initialValue + delta * factor
const snappedNext = 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)) const next = Math.min(maxBound, Math.max(minBound, snappedNext))
return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode> 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
while (delta < -Math.PI) delta += 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI
if (moveEvent.shiftKey && descriptor.shape === 'rotate') { if (!moveEvent.shiftKey && descriptor.shape === 'rotate') {
const step = Math.PI / 12 delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
delta = Math.round(delta / step) * step
} }
if (isRotateShape && !isNodeNormalRot) { if (isRotateShape && !isNodeNormalRot) {
@@ -202,12 +202,14 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
restoreLevels = snapLevelsToTruePositions() restoreLevels = snapLevelsToTruePositions()
} }
// Hide scan and guide nodes directly so they are excluded from the // Hide scan, guide, and spawn nodes directly so they are excluded from
// thumbnail regardless of whether ScanSystem/GuideSystem listeners are // the thumbnail regardless of whether ScanSystem/GuideSystem listeners
// registered. Returns a function that restores the original visibility. // 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 restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>() 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]! const ids = sceneRegistry.byType[type]!
ids.forEach((id) => { ids.forEach((id) => {
const node = sceneRegistry.nodes.get(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 // Notify other systems (wall cutouts, selection manager) to restore
// their overrides before capture and re-apply them after. // their overrides before capture and re-apply them after.
try {
emitter.emit('thumbnail:before-capture', undefined) emitter.emit('thumbnail:before-capture', undefined)
;(renderer as any).setClearAlpha(0) ;(renderer as any).setClearAlpha(0)
renderer.setRenderTarget(rt) renderer.setRenderTarget(rt)
pipelineRef.current.render() 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) renderer.setRenderTarget(null)
emitter.emit('thumbnail:after-capture', undefined) emitter.emit('thumbnail:after-capture', undefined)
// Restore level positions, levelMode, and node visibility immediately after the
// render — before the async GPU readback.
restoreLevels() restoreLevels()
restoreLevelMode?.() restoreLevelMode?.()
restoreNodeVisibility() restoreNodeVisibility()
}
// Read pixels from the RT asynchronously. // Read pixels from the RT asynchronously.
// WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must // 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 } cameraData.resolution = { w: outW, h: outH }
} else { } else {
// Fallback: plain render directly to the canvas // Fallback: plain render directly to the canvas
try {
emitter.emit('thumbnail:before-capture', undefined) emitter.emit('thumbnail:before-capture', undefined)
gl.render(scene, thumbnailCamera) gl.render(scene, thumbnailCamera)
} finally {
emitter.emit('thumbnail:after-capture', undefined) emitter.emit('thumbnail:after-capture', undefined)
restoreLevels() restoreLevels()
restoreLevelMode?.() restoreLevelMode?.()
restoreNodeVisibility() restoreNodeVisibility()
}
let outW: number let outW: number
let outH: number let outH: number
@@ -5,23 +5,21 @@ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
WALL_FINE_GRID_STEP,
WALL_GRID_STEP,
type WallPlanPoint,
} from '../tools/wall/wall-drafting'
type UseFloorplanBackgroundPlacementArgs = { type UseFloorplanBackgroundPlacementArgs = {
activePolygonDraftPoints: WallPlanPoint[] activePolygonDraftPoints: WallPlanPoint[]
ceilingDraftPoints: WallPlanPoint[] ceilingDraftPoints: WallPlanPoint[]
clearFencePlacementDraft: () => void clearFencePlacementDraft: () => void
clearRoofPlacementDraft: () => void clearRoofPlacementDraft: () => void
clearWallPlacementDraft: () => void
emitFloorplanGridEvent: ( emitFloorplanGridEvent: (
type: 'click' | 'double-click' | 'move', type: 'click' | 'double-click' | 'move',
planPoint: WallPlanPoint, planPoint: WallPlanPoint,
event: ReactMouseEvent<SVGSVGElement>, event: ReactMouseEvent<SVGSVGElement>,
) => WallPlanPoint ) => void
fenceDraftStart: WallPlanPoint | null fenceDraftStart: WallPlanPoint | null
fences: FenceNode[] fences: FenceNode[]
findClosestWallPoint: ( findClosestWallPoint: (
@@ -67,6 +65,7 @@ type UseFloorplanBackgroundPlacementArgs = {
walls: WallNode[] walls: WallNode[]
start?: WallPlanPoint start?: WallPlanPoint
angleSnap?: boolean angleSnap?: boolean
bypassSnap?: boolean
step?: number step?: number
gridSnap?: (point: WallPlanPoint) => WallPlanPoint gridSnap?: (point: WallPlanPoint) => WallPlanPoint
}) => WallPlanPoint }) => WallPlanPoint
@@ -74,6 +73,7 @@ type UseFloorplanBackgroundPlacementArgs = {
point: WallPlanPoint point: WallPlanPoint
start?: WallPlanPoint start?: WallPlanPoint
angleSnap: boolean angleSnap: boolean
bypassSnap?: boolean
}) => WallPlanPoint }) => WallPlanPoint
toPoint2D: (point: WallPlanPoint) => { x: number; y: number } toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
walls: WallNode[] walls: WallNode[]
@@ -81,7 +81,7 @@ type UseFloorplanBackgroundPlacementArgs = {
* Snap a building-local plan point to the world XZ grid at `step`. * 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 * Injected so the hook doesn't have to know the building's rotation
* or position — used by wall / fence branches that snap at variable * or position — used by wall / fence branches that snap at variable
* step (Shift = fine). * step.
*/ */
worldGridSnap: (point: WallPlanPoint, step: number) => WallPlanPoint worldGridSnap: (point: WallPlanPoint, step: number) => WallPlanPoint
} }
@@ -91,6 +91,7 @@ export function useFloorplanBackgroundPlacement({
ceilingDraftPoints, ceilingDraftPoints,
clearFencePlacementDraft, clearFencePlacementDraft,
clearRoofPlacementDraft, clearRoofPlacementDraft,
clearWallPlacementDraft,
emitFloorplanGridEvent, emitFloorplanGridEvent,
fenceDraftStart, fenceDraftStart,
fences, fences,
@@ -154,21 +155,24 @@ export function useFloorplanBackgroundPlacement({
} }
if (isCeilingBuildActive) { if (isCeilingBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Align the committed vertex the same way the move-preview did, so // Align the committed vertex the same way the move-preview did, so
// the placed point matches what the user saw. Wall magnetic snap may // the placed point matches what the user saw. Wall magnetic snap may
// still win; generic alignment is skipped when angle snap owns the // still win; generic alignment is skipped when angle snap owns the
// vertex (matches the move branch). // vertex (matches the move branch).
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed const angleSnap = ceilingDraftPoints.length > 0 && !bypassSnap
const fallbackPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1], start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap, angleSnap,
bypassSnap,
}) })
const snappedPoint = resolveCeilingPlanPointSnap({ const snappedPoint = resolveCeilingPlanPointSnap({
rawPoint: planPoint, rawPoint: planPoint,
fallbackPoint, fallbackPoint,
levelId, levelId,
altKey: event.altKey, altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap, align: !angleSnap,
}).point }).point
@@ -178,9 +182,11 @@ export function useFloorplanBackgroundPlacement({
} }
if (isRoofBuildActive) { if (isRoofBuildActive) {
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { const bypassSnap = shiftPressed || event.shiftKey
bypass: event.altKey, const snappedPoint = alignFloorplanDraftPoint(
}) bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint),
{ bypass: event.altKey || bypassSnap },
)
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
@@ -194,35 +200,57 @@ export function useFloorplanBackgroundPlacement({
} }
if (isFenceBuildActive) { if (isFenceBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
// Figma alignment — endpoint snap wins (same precedence as move). // Figma alignment — endpoint snap wins (same precedence as move).
// `gridSnap` keeps the snap on the world XZ grid even when the // While a draft is open the segment locks to 15° rays from its
// building is rotated. // start unless Shift is held; Shift bypasses grid, magnetic,
const fenceStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP // 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({ const fenceSnapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
fences, fences,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap,
bypassSnap,
gridSnap: (p) => worldGridSnap(p, fenceStep), gridSnap: (p) => worldGridSnap(p, fenceStep),
}) })
const fenceGridBase = worldGridSnap(planPoint, fenceStep) const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep)
const fenceLocked = const fenceLocked =
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] !bypassSnap &&
const snappedPoint = fenceLocked (fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
const snappedPoint =
fenceLocked || fenceAngleSnap
? fenceSnapped ? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey }) : alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap })
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint) 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) { if (!fenceDraftStart) {
setFenceDraftStart(snappedPoint) setFenceDraftStart(snappedPoint)
setFenceDraftEnd(snappedPoint) setFenceDraftEnd(snappedPoint)
} else if ( } else if (
getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01 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 { } else {
setFenceDraftEnd(snappedPoint) setFenceDraftEnd(snappedPoint)
} }
@@ -235,11 +263,13 @@ export function useFloorplanBackgroundPlacement({
// swallow the click and skip local draft state updates — leaving // swallow the click and skip local draft state updates — leaving
// the 2D draft polygon invisible while the 3D tool builds fine). // the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) { if (isPolygonBuildActive) {
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed const bypassSnap = shiftPressed || event.shiftKey
const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap
const fallbackPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap, angleSnap,
bypassSnap,
}) })
let snappedPoint = fallbackPoint let snappedPoint = fallbackPoint
if (isSlabBuildActive) { if (isSlabBuildActive) {
@@ -248,10 +278,13 @@ export function useFloorplanBackgroundPlacement({
fallbackPoint, fallbackPoint,
levelId, levelId,
altKey: event.altKey, altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap, align: !angleSnap,
}).point }).point
} else if (!angleSnap) { } 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 // 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 // / draftEnd state in the floor plan would never update, leaving
// the dashed-line draft preview invisible. // the dashed-line draft preview invisible.
if (isWallBuildActive) { if (isWallBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Wall draft: grid snap (+ existing-wall endpoint/join snap), then // Wall draft: grid snap (+ existing-wall endpoint/join snap), then
// Figma alignment — endpoint/join snap wins (same precedence as the // Figma alignment — endpoint/join snap wins (same precedence as the
// move-preview branch), so committing onto a corner still works. // move-preview branch), so committing onto a corner still works.
// `gridSnap` keeps the snap on the world XZ grid even when the // While a draft is open the segment locks to 15° rays from its
// building is rotated. // start unless Shift is held; Shift bypasses grid, magnetic,
const wallStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP // 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({ const wallSnapped = snapWallDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, start: draftStart ?? undefined,
angleSnap: wallAngleSnap,
bypassSnap,
gridSnap: (p) => worldGridSnap(p, wallStep), gridSnap: (p) => worldGridSnap(p, wallStep),
}) })
const wallGridBase = worldGridSnap(planPoint, wallStep) const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep)
const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1] const wallLocked =
const snappedPoint = wallLocked !bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
const snappedPoint =
wallLocked || wallAngleSnap
? wallSnapped ? wallSnapped
: alignFloorplanDraftPoint(wallSnapped, { bypass: false }) : alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey || bypassSnap })
emitFloorplanGridEvent('click', snappedPoint, event) 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 }) handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey })
return true return true
} }
@@ -311,7 +363,8 @@ export function useFloorplanBackgroundPlacement({
// local floor-plan draft handler (column / spawn / shelf / etc.). // local floor-plan draft handler (column / spawn / shelf / etc.).
// The tool's `grid:click` subscriber owns the placement. // The tool's `grid:click` subscriber owns the placement.
if (isFloorplanGridInteractionActive) { if (isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event) const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
return true return true
} }
@@ -323,6 +376,7 @@ export function useFloorplanBackgroundPlacement({
ceilingDraftPoints, ceilingDraftPoints,
clearFencePlacementDraft, clearFencePlacementDraft,
clearRoofPlacementDraft, clearRoofPlacementDraft,
clearWallPlacementDraft,
emitFloorplanGridEvent, emitFloorplanGridEvent,
fenceDraftStart, fenceDraftStart,
fences, fences,
@@ -11,7 +11,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { useShallow } from 'zustand/react/shallow'
import { import {
clearCeilingSnapFeedback, clearCeilingSnapFeedback,
@@ -153,6 +153,7 @@ const CeilingSelectionAffordance = ({
const [draggedCornerIndex, setDraggedCornerIndex] = useState<number | null>(null) const [draggedCornerIndex, setDraggedCornerIndex] = useState<number | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null) const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const dragRef = useRef<CornerDragState | null>(null) const dragRef = useRef<CornerDragState | null>(null)
const bracketsRootRef = useRef<Group>(null)
const raycasterRef = useRef(new Raycaster()) const raycasterRef = useRef(new Raycaster())
const ndcRef = useRef(new Vector2()) const ndcRef = useRef(new Vector2())
const planeRef = useRef(new Plane()) const planeRef = useRef(new Plane())
@@ -293,7 +294,9 @@ const CeilingSelectionAffordance = ({
initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]), initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]),
initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]), initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]),
] ]
const gridNextPosition: [number, number] = [ const gridNextPosition: [number, number] = event.shiftKey
? rawNextPosition
: [
initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]), initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]),
initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]), initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
] ]
@@ -303,9 +306,11 @@ const CeilingSelectionAffordance = ({
levelId, levelId,
excludeId: drag.ceilingId, excludeId: drag.ceilingId,
altKey: event.altKey, altKey: event.altKey,
shiftKey: event.shiftKey,
}).point }).point
if ( if (
!event.shiftKey &&
drag.previousSnappedPosition && drag.previousSnappedPosition &&
(nextPosition[0] !== drag.previousSnappedPosition[0] || (nextPosition[0] !== drag.previousSnappedPosition[0] ||
nextPosition[1] !== drag.previousSnappedPosition[1]) nextPosition[1] !== drag.previousSnappedPosition[1])
@@ -372,6 +377,25 @@ const CeilingSelectionAffordance = ({
} }
}, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit]) }, [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(() => { useEffect(() => {
let frameId = 0 let frameId = 0
@@ -401,7 +425,10 @@ const CeilingSelectionAffordance = ({
if (!levelObject || corners.length === 0) return null if (!levelObject || corners.length === 0) return null
return createPortal( 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) => ( {corners.map((corner, index) => (
<CornerBracket <CornerBracket
ceiling={effectiveCeiling} ceiling={effectiveCeiling}
@@ -20,6 +20,7 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry {
g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
g.setAttribute('normal', 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('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 // Match the four material slots the roof-segment renderer's material
// array expects (0=top, 1=side, 2=interior, 3=shingle). Without these // array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
// groups, mesh.material is a single-material lookup that mismatches // 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 onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2, bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2, bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true || bypassSnap,
) )
const supportY = resolveElevatorSupportY({ const supportY = resolveElevatorSupportY({
buildingId: currentBuildingId, buildingId: currentBuildingId,
@@ -220,6 +221,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
}) })
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) { ) {
@@ -237,12 +239,13 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
}) })
if (!latestBuildingId) return if (!latestBuildingId) return
const bypassSnap = event.nativeEvent?.shiftKey === true
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2, bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2, bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true || bypassSnap,
) )
commitElevatorPlacement( commitElevatorPlacement(
latestBuildingId, latestBuildingId,
@@ -131,8 +131,9 @@ export function MoveElevatorTool({
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const rawX = Math.round(event.localPosition[0] * 2) / 2 const bypassSnap = event.nativeEvent?.shiftKey === true
const rawZ = Math.round(event.localPosition[2] * 2) / 2 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] const anchor = dragAnchorRef.current ?? [rawX, rawZ]
dragAnchorRef.current = anchor dragAnchorRef.current = anchor
const gridX = movingNode.position[0] + (rawX - anchor[0]) const gridX = movingNode.position[0] + (rawX - anchor[0])
@@ -145,6 +146,7 @@ export function MoveElevatorTool({
}) })
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) { ) {
@@ -1,8 +1,10 @@
import { import {
DEFAULT_ANGLE_STEP,
FenceNode, FenceNode,
getWallCurveFrameAt, getWallCurveFrameAt,
getWallCurveLength, getWallCurveLength,
isCurvedWall, isCurvedWall,
snapPointAlongAngleRay,
useScene, useScene,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -12,9 +14,7 @@ import useEditor from '../../../store/use-editor'
import { import {
findWallSnapTarget, findWallSnapTarget,
getSegmentGridStep, getSegmentGridStep,
getWallAngleSnapStep,
isSegmentLongEnough, isSegmentLongEnough,
snapPointTo45Degrees,
snapPointToGrid, snapPointToGrid,
type WallPlanPoint, type WallPlanPoint,
} from '../wall/wall-drafting' } from '../wall/wall-drafting'
@@ -132,7 +132,9 @@ export function snapFenceDraftPoint(args: {
start?: FencePlanPoint start?: FencePlanPoint
angleSnap?: boolean angleSnap?: boolean
ignoreFenceIds?: string[] 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 step?: number
/** /**
* Optional grid-snap function. When provided, replaces the default * Optional grid-snap function. When provided, replaces the default
@@ -142,17 +144,45 @@ export function snapFenceDraftPoint(args: {
*/ */
gridSnap?: (point: FencePlanPoint) => FencePlanPoint gridSnap?: (point: FencePlanPoint) => 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 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 start && angleSnap
? snapPointTo45Degrees(start, point, gridStep, angleStep, gridSnap) ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, gridStep)]
: gridSnap : gridSnap
? gridSnap(point) ? gridSnap(point)
: snapPointToGrid(point, gridStep) : 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 return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
} }
@@ -113,10 +113,14 @@ export const floorStrategy = {
// is rotated; then project the world point back into building-local // is rotated; then project the world point back into building-local
// for storage. Without this, a rotated building drags placement off // for storage. Without this, a rotated building drags placement off
// the world grid. // the world grid.
const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX) const bypassSnap = event.nativeEvent?.shiftKey === true
const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ) const [x, z] = bypassSnap
const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0) ? [event.localPosition[0], event.localPosition[2]]
const [x, z] = local : snapWorldXZForActiveBuilding(
snapToGrid(event.position[0], swapDims ? dimZ : dimX),
snapToGrid(event.position[2], swapDims ? dimX : dimZ),
0,
).local
const y = ctx.gridPosition.y const y = ctx.gridPosition.y
return { return {
@@ -197,9 +201,10 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const x = snapToHalf(event.localPosition[0]) const bypassSnap = event.nativeEvent?.shiftKey === true
const y = snapToHalf(event.localPosition[1]) const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
const z = snapToHalf(event.localPosition[2]) 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 // Get auto-adjusted Y position from validator
const rawDims = ctx.draftItem const rawDims = ctx.draftItem
@@ -231,7 +236,9 @@ export const wallStrategy = {
}, },
cursorRotationY: cursorRotation, cursorRotationY: cursorRotation,
gridPosition: [x, adjustedY, z], gridPosition: [x, adjustedY, z],
cursorPosition: [ cursorPosition: bypassSnap
? [event.position[0], event.position[1], event.position[2]]
: [
snapToHalf(event.position[0]), snapToHalf(event.position[0]),
snapToHalf(event.position[1]), snapToHalf(event.position[1]),
snapToHalf(event.position[2]), snapToHalf(event.position[2]),
@@ -258,9 +265,10 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const snappedX = snapToHalf(event.localPosition[0]) const bypassSnap = event.nativeEvent?.shiftKey === true
const snappedY = snapToHalf(event.localPosition[1]) const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
const snappedZ = snapToHalf(event.localPosition[2]) 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 // Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall( const validation = validators.canPlaceOnWall(
@@ -278,7 +286,9 @@ export const wallStrategy = {
return { return {
gridPosition: [snappedX, adjustedY, snappedZ], gridPosition: [snappedX, adjustedY, snappedZ],
cursorPosition: [ cursorPosition: bypassSnap
? [event.position[0], event.position[1], event.position[2]]
: [
snapToHalf(event.position[0]), snapToHalf(event.position[0]),
snapToHalf(event.position[1]), snapToHalf(event.position[1]),
snapToHalf(event.position[2]), snapToHalf(event.position[2]),
@@ -403,8 +413,8 @@ function resolveRoofWallTarget(
const dims = getGridAlignedDimensions(rawDims, attachTo) const dims = getGridAlignedDimensions(rawDims, attachTo)
const [width, height] = dims const [width, height] = dims
const u = snapToHalf(hit.u) const u = shiftFree ? hit.u : snapToHalf(hit.u)
const centerV = snapToHalf(hit.v) + height / 2 const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height) const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
if (!fitted && !shiftFree) return null if (!fitted && !shiftFree) return null
const finalU = fitted?.u ?? u const finalU = fitted?.u ?? u
@@ -604,8 +614,13 @@ export const ceilingStrategy = {
// Ceiling items are stored in ceiling-local coordinates, so snapping must // Ceiling items are stored in ceiling-local coordinates, so snapping must
// use the ceiling hit's local position rather than world position. // use the ceiling hit's local position rather than world position.
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) const bypassSnap = event.nativeEvent?.shiftKey === true
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) 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 // Recessed fixtures seat flush with the ceiling plane (body rising into the
// void above); everything else hangs its full height below the ceiling. // void above); everything else hangs its full height below the ceiling.
const seatY = ctx.asset.recessed ? 0 : -itemHeight const seatY = ctx.asset.recessed ? 0 : -itemHeight
@@ -638,8 +653,13 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem.rotation?.[1] ?? 0 const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9 const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) const bypassSnap = event.nativeEvent?.shiftKey === true
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) 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 // Recessed fixtures seat flush with the ceiling plane (body rising into the
// void above); everything else hangs its full height below the ceiling. // void above); everything else hangs its full height below the ceiling.
const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
@@ -750,8 +770,9 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null if (surfaceHeight === null) return null
const x = snapToGrid(localPos.x, ourDims[0]) const bypassSnap = event.nativeEvent?.shiftKey === true
const z = snapToGrid(localPos.z, ourDims[2]) const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -801,8 +822,9 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null if (surfaceHeight === null) return null
const x = snapToGrid(localPos.x, ourDims[0]) const bypassSnap = event.nativeEvent?.shiftKey === true
const z = snapToGrid(localPos.z, ourDims[2]) const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -901,8 +923,9 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null if (rowY === null) return null
const x = snapToGrid(localPos.x, ourDims[0]) const bypassSnap = event.nativeEvent?.shiftKey === true
const z = snapToGrid(localPos.z, ourDims[2]) 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)) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
@@ -945,8 +968,9 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null if (rowY === null) return null
const x = snapToGrid(localPos.x, ourDims[0]) const bypassSnap = event.nativeEvent?.shiftKey === true
const z = snapToGrid(localPos.z, ourDims[2]) 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)) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
return { return {
@@ -683,11 +683,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// item's edge, snap and publish a guide. The guide connects to the // 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 // 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 // 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 const draft = draftNode.current
let alignX = 0 let alignX = 0
let alignZ = 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) { if (!bypassAlign && draft) {
alignmentCandidates ??= collectAlignmentAnchors( alignmentCandidates ??= collectAlignmentAnchors(
useScene.getState().nodes, useScene.getState().nodes,
@@ -721,6 +722,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Play snap sound when grid position changes // Play snap sound when grid position changes
if ( if (
!bypassSnap &&
previousGridPos && previousGridPos &&
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2]) (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
) { ) {
@@ -866,7 +868,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
// Play snap sound when grid position changes // Play snap sound when grid position changes
if (posChanged) { if (event.nativeEvent?.shiftKey !== true && posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1035,7 +1037,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
if (posChanged) { if (!shiftFreeRef.current && posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1128,8 +1130,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.position[1], event.position[1],
event.position[2], event.position[2],
) )
const wx = Math.round(buildingLocalPoint.x * 2) / 2 const bypassSnap = event.nativeEvent?.shiftKey === true
const wz = Math.round(buildingLocalPoint.z * 2) / 2 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] const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { Object.assign(placementState.current, {
@@ -1429,7 +1432,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
if (posChanged) { if (event.nativeEvent?.shiftKey !== true && posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -40,8 +40,8 @@ const snapToGridStep = (value: number) => {
return Math.round(value / step) * step return Math.round(value / step) * step
} }
/** 90° steps, matching the GLB item placement rotation. */ /** 45° steps, matching the GLB item placement rotation. */
const ROTATION_STEP = Math.PI / 2 const ROTATION_STEP = Math.PI / 4
/** Figma-style alignment-snap threshold (meters), matching the 2D /** Figma-style alignment-snap threshold (meters), matching the 2D
* floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull * 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]], original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchorRef.current, anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative', mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
snap: snapToGridStep, snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
}) })
dragAnchorRef.current = resolved.anchor dragAnchorRef.current = resolved.anchor
let [x, z] = resolved.point 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, // 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 // snap and publish a guide. The guide connects to the nearest real
// corner of the candidate (resolver tie-break), so the dot always sits // corner of the candidate (resolver tie-break), so the dot always sits
// on an actual point. Alt bypasses. // on an actual point. Alt bypasses alignment; Shift bypasses all snap.
const bypass = event.nativeEvent?.altKey === true const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationRef.current), moving: movingFootprintAnchors(node, x, z, rotationRef.current),
@@ -338,7 +338,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
markMovedNodeDirty() markMovedNodeDirty()
const prev = previousSnapRef.current 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') sfxEmitter.emit('sfx:grid-snap')
previousSnapRef.current = [x, z] previousSnapRef.current = [x, z]
} }
@@ -457,7 +457,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
if (typeof direct === 'function') direct.call(event) 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 // item placement keys (and the "Rotate" hints the move HUD shows). Applied
// imperatively + mirrored to the live transform; committed on drop. // imperatively + mirrored to the live transform; committed on drop.
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
@@ -242,7 +242,10 @@ export const RoofTool: React.FC = () => {
// World-grid snap projected into building-local; rotated buildings // World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid. // used to drag every roof corner off the visible grid.
const snapped = snapWorldXZForActiveBuilding( const bypassSnap = event.nativeEvent?.shiftKey === true
const snapped: [number, number] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
@@ -252,7 +255,7 @@ export const RoofTool: React.FC = () => {
snapped[1], snapped[1],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true || bypassSnap,
) )
const y = event.localPosition[1] const y = event.localPosition[1]
@@ -262,6 +265,7 @@ export const RoofTool: React.FC = () => {
cursorRef.current.position.set(gridX, gridY, gridZ) cursorRef.current.position.set(gridX, gridY, gridZ)
if ( if (
!bypassSnap &&
corner1Ref.current && corner1Ref.current &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
@@ -287,7 +291,10 @@ export const RoofTool: React.FC = () => {
// World-grid snap projected into building-local; rotated buildings // World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid. // used to drag every roof corner off the visible grid.
const snapped = snapWorldXZForActiveBuilding( const bypassSnap = event.nativeEvent?.shiftKey === true
const snapped: [number, number] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
@@ -297,7 +304,7 @@ export const RoofTool: React.FC = () => {
snapped[1], snapped[1],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true || bypassSnap,
) )
const y = event.localPosition[1] const y = event.localPosition[1]
@@ -746,7 +746,10 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const point = levelNode ? event.localPosition : event.position const point = levelNode ? event.localPosition : event.position
const rawPoint: [number, number] = [point[0], point[2]] 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 = const newPosition =
dragState?.isDragging && resolvePlanPoint dragState?.isDragging && resolvePlanPoint
? 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 // Play snap sound when cursor moves to a new grid cell during drag
if ( if (
!bypassSnap &&
dragState?.isDragging && dragState?.isDragging &&
previousPositionRef.current && previousPositionRef.current &&
(newPosition[0] !== previousPositionRef.current[0] || (newPosition[0] !== previousPositionRef.current[0] ||
@@ -348,18 +348,20 @@ export const StairTool: React.FC = () => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2, bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2, bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true || bypassSnap,
) )
const position: [number, number, number] = [gridX, 0, gridZ] const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position lastCanonicalPositionRef.current = position
applyDraftPreview(position, rotationRef.current) applyDraftPreview(position, rotationRef.current)
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (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 getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
const bypassSnap = event.nativeEvent?.shiftKey === true
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2, bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2, bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true || bypassSnap,
) )
return [gridX, 0, gridZ] 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 { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
DEFAULT_ANGLE_STEP,
type DoorNode, type DoorNode,
getScaledDimensions, getScaledDimensions,
type ItemNode, type ItemNode,
snapPointAlongAngleRay,
useScene, useScene,
type WallNode, type WallNode,
WallNode as WallSchema, WallNode as WallSchema,
@@ -35,21 +37,16 @@ export {
} from './wall-snap-geometry' } from './wall-snap-geometry'
export const WALL_GRID_STEP = 0.5 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 export const WALL_MIN_LENGTH = 0.01
const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4 // An endpoint projecting within this distance of an existing wall's corner
// resolves to the corner without splitting — splitting there would mint a
const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = { // sliver segment a hair longer than `WALL_MIN_LENGTH` that no snap radius
0.5: Math.PI / 4, // can ever target again.
0.25: Math.PI / 8, const WALL_SPLIT_ENDPOINT_EPSILON = 0.02
0.1: Math.PI / 12,
0.05: Math.PI / 36,
}
type WallSplitIntersection = { type WallSplitIntersection = {
wallId: WallNode['id'] /** `null` = snap-only outcome: resolve to `point` but split no wall. */
wallId: WallNode['id'] | null
point: WallPlanPoint 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)] 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] { function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
const { id: _id, parentId: _parentId, children, ...rest } = wall const { id: _id, parentId: _parentId, children, ...rest } = wall
@@ -140,7 +108,14 @@ function findWallIntersection(
continue 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 bestDistanceSquared = candidateDistanceSquared
} }
@@ -292,6 +267,10 @@ function splitWallIfNeeded(
): { walls: WallNode[]; point: WallPlanPoint } | null { ): { walls: WallNode[]; point: WallPlanPoint } | null {
if (!intersection) return null if (!intersection) return null
if (!intersection.wallId) {
return { walls, point: intersection.point }
}
const wallToSplit = walls.find((wall) => wall.id === intersection.wallId) const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
if (!wallToSplit) { if (!wallToSplit) {
return { walls, point: intersection.point } return { walls, point: intersection.point }
@@ -331,7 +310,8 @@ type SnapWallDraftArgs = {
start?: WallPlanPoint start?: WallPlanPoint
angleSnap?: boolean angleSnap?: boolean
ignoreWallIds?: string[] ignoreWallIds?: string[]
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */ bypassSnap?: boolean
/** Override the grid step. */
step?: number step?: number
/** /**
* Magnetic snapping to existing wall geometry (corners, midpoints, * Magnetic snapping to existing wall geometry (corners, midpoints,
@@ -358,12 +338,15 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
start, start,
angleSnap = false, angleSnap = false,
ignoreWallIds, ignoreWallIds,
bypassSnap = false,
step: overrideStep, step: overrideStep,
magnetic = true, magnetic = true,
gridSnap, gridSnap,
snapRadii, snapRadii,
} = args } = args
if (bypassSnap) return { point, snap: null }
// Discrete special points (corner / midpoint / crossing) are taken from the // 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, // raw cursor so an interim grid snap can't mask them. A corner always wins,
// then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`. // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
@@ -373,10 +356,12 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
} }
const step = overrideStep ?? getSegmentGridStep() const step = overrideStep ?? getSegmentGridStep()
const angleStep = getWallAngleSnapStep(step) // The angle path snaps the distance ALONG the 15° ray — a scalar, the
const basePoint = // 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 start && angleSnap
? snapPointTo45Degrees(start, point, step, angleStep, gridSnap) ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, step)]
: gridSnap : gridSnap
? gridSnap(point) ? gridSnap(point)
: snapPointToGrid(point, step) : 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 { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' 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 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 * Creates a zone with the given polygon points
*/ */
@@ -93,6 +65,7 @@ export const ZoneTool: React.FC = () => {
const pointsRef = useRef<Array<[number, number]>>([]) const pointsRef = useRef<Array<[number, number]>>([])
const previousSnappedPointRef = useRef<[number, number] | null>(null) const previousSnappedPointRef = useRef<[number, number] | null>(null)
const levelYRef = useRef(0) // Track current level Y position const levelYRef = useRef(0) // Track current level Y position
const shiftPressed = useRef(false)
const currentLevelId = useViewer((state) => state.selection.levelId) const currentLevelId = useViewer((state) => state.selection.levelId)
const setTool = useEditor((state) => state.setTool) const setTool = useEditor((state) => state.setTool)
@@ -107,11 +80,30 @@ export const ZoneTool: React.FC = () => {
if (!currentLevelId) return if (!currentLevelId) return
let cursorPosition: [number, number] = [0, 0] let cursorPosition: [number, number] = [0, 0]
let rawCursorPosition: [number, number] = [0, 0]
// Initialize line geometries // Initialize line geometries
mainLineRef.current.geometry = new BufferGeometry() mainLineRef.current.geometry = new BufferGeometry()
closingLineRef.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 updateLines = () => {
const points = pointsRef.current const points = pointsRef.current
const y = levelYRef.current + Y_OFFSET const y = levelYRef.current + Y_OFFSET
@@ -128,7 +120,7 @@ export const ZoneTool: React.FC = () => {
// Add cursor point // Add cursor point
const lastPoint = points[points.length - 1] const lastPoint = points[points.length - 1]
if (lastPoint) { if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition) const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
if (isValidPoint(snapped)) { if (isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], y, snapped[1])) 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) // Update closing line (from cursor back to first point)
const firstPoint = points[0] const firstPoint = points[0]
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) { if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition) const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
if (isValidPoint(snapped)) { if (isValidPoint(snapped)) {
const closingPoints = [ const closingPoints = [
new Vector3(snapped[0], y, snapped[1]), new Vector3(snapped[0], y, snapped[1]),
@@ -167,7 +159,7 @@ export const ZoneTool: React.FC = () => {
let cursorPt: [number, number] | null = null let cursorPt: [number, number] | null = null
if (lastPoint) { if (lastPoint) {
cursorPt = calculateSnapPoint(lastPoint, cursorPosition) cursorPt = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
} else if (points.length === 0) { } else if (points.length === 0) {
cursorPt = cursorPosition cursorPt = cursorPosition
} }
@@ -181,22 +173,27 @@ export const ZoneTool: React.FC = () => {
// World-grid snap projected into building-local; rotated buildings // World-grid snap projected into building-local; rotated buildings
// used to pull the snap off the visible grid lines. // used to pull the snap off the visible grid lines.
const [gridX, gridZ] = snapWorldXZForActiveBuilding( const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const [gridX, gridZ] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
).local ).local
cursorPosition = [gridX, gridZ] cursorPosition = [gridX, gridZ]
rawCursorPosition = [event.localPosition[0], event.localPosition[2]]
levelYRef.current = event.localPosition[1] 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 lastPoint = pointsRef.current[pointsRef.current.length - 1]
const displayPoint = lastPoint const displayPoint = lastPoint
? calculateSnapPoint(lastPoint, cursorPosition) ? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
: cursorPosition : cursorPosition
// Play snap sound when the snapped position changes during drawing // Play snap sound when the snapped position changes during drawing
if ( if (
!bypassSnap &&
pointsRef.current.length > 0 && pointsRef.current.length > 0 &&
previousSnappedPointRef.current && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || (displayPoint[0] !== previousSnappedPointRef.current[0] ||
@@ -214,17 +211,23 @@ export const ZoneTool: React.FC = () => {
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return if (!currentLevelId) return
const [gridX, gridZ] = snapWorldXZForActiveBuilding( const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const [gridX, gridZ] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
).local ).local
let clickPoint: [number, number] = [gridX, gridZ] 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] const lastPoint = pointsRef.current[pointsRef.current.length - 1]
if (lastPoint) { 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 // 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 // Subscribe to events
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick) emitter.on('grid:double-click', onGridDoubleClick)
return () => { return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
@@ -80,8 +80,13 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
shortcuts: [ shortcuts: [
{ {
keys: ['Shift'], keys: ['Shift'],
action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings', action: 'Draw at any angle, bypassing the default 15° angle snap',
note: 'Hold while drawing.', 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.',
}, },
], ],
}, },
+4 -1
View File
@@ -19,7 +19,10 @@ import { useEffect, useRef } from 'react'
const sceneApi = createSceneApi(useScene) const sceneApi = createSceneApi(useScene)
function modifiersFromGridEvent(event: GridEvent): Modifiers { 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 { return {
shift: ne?.shiftKey ?? false, shift: ne?.shiftKey ?? false,
alt: ne?.altKey ?? false, alt: ne?.altKey ?? false,
+1 -1
View File
@@ -104,7 +104,6 @@ export {
snapScalarToGrid, snapScalarToGrid,
snapWallDraftPoint, snapWallDraftPoint,
snapWallDraftPointDetailed, snapWallDraftPointDetailed,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP, WALL_GRID_STEP,
type WallDraftSnapKind, type WallDraftSnapKind,
type WallDraftSnapResult, type WallDraftSnapResult,
@@ -299,6 +298,7 @@ export {
usePaletteViewRegistry, usePaletteViewRegistry,
} from './store/use-palette-view-registry' } from './store/use-palette-view-registry'
export { default as usePlacementPreview } from './store/use-placement-preview' 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 { useUploadStore } from './store/use-upload'
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts' export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
export { export {
@@ -43,6 +43,7 @@ export type SurfacePlanSnapInput = {
candidates?: readonly AlignmentAnchor[] candidates?: readonly AlignmentAnchor[]
threshold?: number threshold?: number
altKey?: boolean altKey?: boolean
shiftKey?: boolean
magnetic?: boolean magnetic?: boolean
align?: boolean align?: boolean
highlightWalls?: boolean highlightWalls?: boolean
@@ -171,6 +172,12 @@ export function clearSurfacePlanSnapFeedback() {
} }
export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult { 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 nodes = input.nodes ?? useScene.getState().nodes
const walls = getLevelWalls(nodes, input.levelId, input.walls) const walls = getLevelWalls(nodes, input.levelId, input.walls)
const fallbackPoint = input.fallbackPoint 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
+4 -1
View File
@@ -68,7 +68,10 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(target.localZ * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (
event.nativeEvent?.shiftKey !== true &&
(!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz)
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
+7 -5
View File
@@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
import { boxVentDefinition } from './definition' import { boxVentDefinition } from './definition'
import BoxVentPreview from './preview' import BoxVentPreview from './preview'
@@ -37,6 +37,7 @@ const BoxVentTool = () => {
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
const [previewYaw, setPreviewYaw] = useState(0) const [previewYaw, setPreviewYaw] = useState(0)
const [previewRotation, setPreviewRotation] = useState(0)
const lastSnapRef = useRef<[number, number] | null>(null) const lastSnapRef = useRef<[number, number] | null>(null)
// Default-shaped preview node — matches what the commit will create. // Default-shaped preview node — matches what the commit will create.
@@ -46,9 +47,9 @@ const BoxVentTool = () => {
...boxVentDefinition.defaults(), ...boxVentDefinition.defaults(),
name: 'Box Vent', name: 'Box Vent',
position: [0, 0, 0], position: [0, 0, 0],
rotation: 0, rotation: previewRotation,
}), }),
[], [previewRotation],
) )
useEffect(() => { useEffect(() => {
@@ -70,7 +71,7 @@ const BoxVentTool = () => {
const sx = Math.round(wx * 20) / 20 const sx = Math.round(wx * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(wz * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
@@ -81,6 +82,7 @@ const BoxVentTool = () => {
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
setPreviewPos(worldToBuildingLocal(wx, wy, wz)) setPreviewPos(worldToBuildingLocal(wx, wy, wz))
event.stopPropagation() event.stopPropagation()
} }
@@ -100,7 +102,7 @@ const BoxVentTool = () => {
name: 'Box Vent', name: 'Box Vent',
roofSegmentId: hit.segment.id, roofSegmentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ], position: [hit.localX, hit.localY, hit.localZ],
rotation: 0, rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment),
}) })
state.createNode(vent, hit.segment.id as AnyNodeId) state.createNode(vent, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId)
+5 -3
View File
@@ -93,7 +93,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
return return
} }
const ROTATION_STEP = Math.PI / 2 const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0 let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
@@ -121,14 +121,16 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const rawX = Math.round(event.position[0] * 2) / 2 const bypassSnap = event.nativeEvent?.shiftKey === true
const rawZ = Math.round(event.position[2] * 2) / 2 const rawX = bypassSnap ? event.position[0] : Math.round(event.position[0] * 2) / 2
const rawZ = bypassSnap ? event.position[2] : Math.round(event.position[2] * 2) / 2
const anchor = dragAnchorRef.current ?? [rawX, rawZ] const anchor = dragAnchorRef.current ?? [rawX, rawZ]
dragAnchorRef.current = anchor dragAnchorRef.current = anchor
const gridX = originalCenter[0] + (rawX - anchor[0]) const gridX = originalCenter[0] + (rawX - anchor[0])
const gridZ = originalCenter[1] + (rawZ - anchor[1]) const gridZ = originalCenter[1] + (rawZ - anchor[1])
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) { ) {
@@ -126,6 +126,7 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
levelId: ceilingLevelId, levelId: ceilingLevelId,
excludeId: ceilingId, excludeId: ceilingId,
altKey: context.nativeEvent?.altKey === true, altKey: context.nativeEvent?.altKey === true,
shiftKey: context.nativeEvent?.shiftKey === true,
}).point, }).point,
[ceilingId, ceilingLevelId], [ceilingId, ceilingLevelId],
) )
@@ -29,6 +29,7 @@ const ceilingSnapOptions = {
excludeId: node.id, excludeId: node.id,
nodes: sceneNodes, nodes: sceneNodes,
altKey: modifiers.altKey, altKey: modifiers.altKey,
shiftKey: modifiers.shiftKey,
}).point }).point
}, },
} }
+6 -4
View File
@@ -147,10 +147,12 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return if (isFloorplanSourcedEvent(event)) return
const localX = snap(event.localPosition[0]) const bypassSnap = event.nativeEvent?.shiftKey === true
const localZ = snap(event.localPosition[2]) const localX = bypassSnap ? event.localPosition[0] : snap(event.localPosition[0])
const localZ = bypassSnap ? event.localPosition[2] : snap(event.localPosition[2])
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) { ) {
@@ -166,8 +168,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
// Figma-style alignment snap: align the ceiling's translated polygon // Figma-style alignment snap: align the ceiling's translated polygon
// vertices to other objects' anchors; fold the snap into the delta and // vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alt bypasses. // publish a guide. Alt bypasses alignment; Shift bypasses all snap.
const bypass = event.nativeEvent?.altKey === true const bypass = event.nativeEvent?.altKey === true || bypassSnap
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)), moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)),
+30 -28
View File
@@ -1,6 +1,13 @@
'use client' 'use client'
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' import {
DEFAULT_ANGLE_STEP,
emitter,
type GridEvent,
type LevelNode,
snapPointAlongAngleRay,
useScene,
} from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
clearCeilingSnapFeedback, clearCeilingSnapFeedback,
@@ -22,34 +29,12 @@ import { CeilingNode } from './schema'
* Multi-click polygon drawing at the ceiling height (2.52m default) * Multi-click polygon drawing at the ceiling height (2.52m default)
* with a vertical TSL-gradient connector + ground-shadow lines so the * with a vertical TSL-gradient connector + ground-shadow lines so the
* draft is visible against both the ceiling plane and the floor. * draft is visible against both the ceiling plane and the floor.
* Shift defeats the axis/45° snap during drag. * Shift defeats the 15° angle snap during drag.
*/ */
const CEILING_HEIGHT = 2.52 const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02 const GRID_OFFSET = 0.02
function 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)
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 commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string { function commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string {
const { createNode, nodes } = useScene.getState() const { createNode, nodes } = useScene.getState()
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
@@ -107,26 +92,38 @@ export const CeilingTool: React.FC = () => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && gridCursorRef.current)) return if (!(cursorRef.current && gridCursorRef.current)) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const gridX = Math.round(rawPoint[0] * 2) / 2 const gridX = Math.round(rawPoint[0] * 2) / 2
const gridZ = Math.round(rawPoint[1] * 2) / 2 const gridZ = Math.round(rawPoint[1] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ] const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ]
setCursorPosition(gridPosition) setCursorPosition(gridPosition)
setLevelY(event.localPosition[1]) setLevelY(event.localPosition[1])
const ceilingY = event.localPosition[1] + CEILING_HEIGHT const ceilingY = event.localPosition[1] + CEILING_HEIGHT
const gridY = event.localPosition[1] + GRID_OFFSET const gridY = event.localPosition[1] + GRID_OFFSET
const lastPoint = points[points.length - 1] const lastPoint = points[points.length - 1]
const orthoPoint = // 15° angle snap from the raw cursor (matching the 2D floorplan
shiftPressed.current || !lastPoint // pipeline) with the distance snapped along the ray to the grid step.
const orthoPoint: [number, number] =
bypassSnap || !lastPoint
? gridPosition ? gridPosition
: calculateSnapPoint(lastPoint, gridPosition) : [
...snapPointAlongAngleRay(
lastPoint,
rawPoint,
DEFAULT_ANGLE_STEP,
useEditor.getState().gridSnapStep,
),
]
const displayPoint = resolveCeilingPlanPointSnap({ const displayPoint = resolveCeilingPlanPointSnap({
rawPoint, rawPoint,
fallbackPoint: orthoPoint, fallbackPoint: orthoPoint,
levelId: currentLevelId, levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true, altKey: event.nativeEvent?.altKey === true,
shiftKey: bypassSnap,
}).point }).point
setSnappedCursorPosition(displayPoint) setSnappedCursorPosition(displayPoint)
if ( if (
!bypassSnap &&
points.length > 0 && points.length > 0 &&
previousSnappedPointRef.current && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || (displayPoint[0] !== previousSnappedPointRef.current[0] ||
@@ -186,8 +183,12 @@ export const CeilingTool: React.FC = () => {
const onKeyUp = (e: KeyboardEvent) => { const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false if (e.key === 'Shift') shiftPressed.current = false
} }
const onWindowBlur = () => {
shiftPressed.current = false
}
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp) document.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
@@ -197,6 +198,7 @@ export const CeilingTool: React.FC = () => {
return () => { return () => {
document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp) document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
+1 -1
View File
@@ -97,7 +97,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(target.localZ * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
+1 -1
View File
@@ -88,7 +88,7 @@ const ChimneyTool = () => {
const sx = Math.round(wx * 20) / 20 const sx = Math.round(wx * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(wz * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
+3 -3
View File
@@ -63,7 +63,7 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
return Math.round(value / step) * step return Math.round(value / step) * step
} }
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
// Figma-style alignment layered on the grid snap (Alt bypasses). // Figma-style alignment layered on the grid snap (Alt bypasses alignment; Shift all snap).
const { point: snapped } = applyFloorplanAlignment( const { point: snapped } = applyFloorplanAlignment(
gridSnapped, gridSnapped,
movingFootprintAnchors( movingFootprintAnchors(
@@ -73,13 +73,13 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
rotationY, rotationY,
), ),
candidates, candidates,
{ bypass: modifiers.altKey }, { bypass: modifiers.altKey || modifiers.shiftKey },
) )
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
lastPosition = next lastPosition = next
const snapKey = `${snapped[0]},${snapped[1]}` const snapKey = `${snapped[0]},${snapped[1]}`
if (snapKey !== lastSnapKey) { if (!modifiers.shiftKey && snapKey !== lastSnapKey) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey lastSnapKey = snapKey
} }
+6 -6
View File
@@ -50,8 +50,8 @@ const snapToGridStep = (value: number) => {
return Math.round(value / step) * step return Math.round(value / step) * step
} }
/** 90° steps, matching the GLB item / shelf placement rotation. */ /** 45° steps, matching the generic move tool's R/T rotation. */
const ROTATION_STEP = Math.PI / 2 const ROTATION_STEP = Math.PI / 4
/** Figma-style alignment-snap threshold (meters), matching the other tools. */ /** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08 const ALIGNMENT_THRESHOLD_M = 0.08
@@ -124,15 +124,15 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
original: [node.position[0], node.position[2]], original: [node.position[0], node.position[2]],
anchor: dragAnchor, anchor: dragAnchor,
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative', mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
snap: snapToGridStep, snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
}) })
dragAnchor = resolved.anchor dragAnchor = resolved.anchor
let [x, z] = resolved.point let [x, z] = resolved.point
// Figma-style alignment snap on top of grid snap; Alt bypasses. The // Figma-style alignment snap on top of grid snap; Alt bypasses alignment; Shift all snap. The
// guide connects to the candidate's nearest real anchor (resolver // guide connects to the candidate's nearest real anchor (resolver
// tie-break), so the dot always sits on an actual point. // tie-break), so the dot always sits on an actual point.
const bypass = event.nativeEvent?.altKey === true const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationY), moving: movingFootprintAnchors(node, x, z, rotationY),
@@ -151,7 +151,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
applyPreview([x, 0, z]) applyPreview([x, 0, z])
} }
// R / T rotate the dragged column about Y in 90° steps (matches the move // R / T rotate the dragged column about Y in 45° steps (matches the move
// HUD's "Rotate" hints), committed on drop. // HUD's "Rotate" hints), committed on drop.
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return if (e.metaKey || e.ctrlKey || e.altKey) return
+12 -3
View File
@@ -87,7 +87,8 @@ const ColumnTool = () => {
rawZ: event.localPosition[2], rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep, gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypassAlignment: event.nativeEvent?.altKey === true, bypassAlignment: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassGrid: event.nativeEvent?.shiftKey === true,
}) })
useAlignmentGuides.getState().set(guides) useAlignmentGuides.getState().set(guides)
@@ -107,7 +108,10 @@ const ColumnTool = () => {
usePlacementPreview.getState().set({ ...previewNode, position }) usePlacementPreview.getState().set({ ...previewNode, position })
const prev = previousSnapRef.current const prev = previousSnapRef.current
if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { if (
event.nativeEvent?.shiftKey !== true &&
(!prev || prev[0] !== position[0] || prev[1] !== position[2])
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
previousSnapRef.current = [position[0], position[2]] previousSnapRef.current = [position[0], position[2]]
} }
@@ -116,7 +120,12 @@ const ColumnTool = () => {
const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => { const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
const position = const position =
lastCursorRef.current ?? lastCursorRef.current ??
getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep) getLevelLocalSnappedPosition(
activeLevelId,
event,
useEditor.getState().gridSnapStep,
event.nativeEvent?.shiftKey === true,
)
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
useScene.getState().createNode(column, activeLevelId) useScene.getState().createNode(column, activeLevelId)
+4 -1
View File
@@ -66,7 +66,10 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(target.localZ * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (
event.nativeEvent?.shiftKey !== true &&
(!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz)
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
+1 -1
View File
@@ -64,7 +64,7 @@ const CupolaTool = () => {
const sx = Math.round(wx * 20) / 20 const sx = Math.round(wx * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(wz * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
+3 -2
View File
@@ -83,8 +83,9 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
// Figma-style along-wall alignment first (edge-to-edge with other // Figma-style along-wall alignment first (edge-to-edge with other
// openings / wall ends); it competes with — and wins over — the 0.5m // openings / wall ends); it competes with — and wins over — the 0.5m
// grid snap. Falls back to the grid snap when nothing aligns. Alt // grid snap. Falls back to the grid snap when nothing aligns. Alt
// bypasses; Shift drops the grid snap for fine positioning. // bypasses alignment; Shift bypasses all snap.
const neighborX = modifiers.altKey const neighborX =
modifiers.altKey || modifiers.shiftKey
? null ? null
: snapLocalXToNeighbors({ : snapLocalXToNeighbors({
wall: hit.wall, wall: hit.wall,
+2 -1
View File
@@ -180,7 +180,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
rawLocalX: targetLocalX, rawLocalX: targetLocalX,
width: movingDoorNode.width, width: movingDoorNode.width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true, bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
}) })
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
event.node, event.node,
+6 -3
View File
@@ -123,7 +123,8 @@ const DoorTool: React.FC = () => {
rawLocalX: event.localPosition[0], rawLocalX: event.localPosition[0],
width, width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true, bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
}) })
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
@@ -176,7 +177,8 @@ const DoorTool: React.FC = () => {
rawLocalX: event.localPosition[0], rawLocalX: event.localPosition[0],
width, width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true, bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
}) })
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
@@ -268,7 +270,8 @@ const DoorTool: React.FC = () => {
rawLocalX: event.localPosition[0], rawLocalX: event.localPosition[0],
width: draftRef.current.width, width: draftRef.current.width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true, bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
}) })
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
event.node, event.node,
@@ -1,4 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { getRoofSegmentSurfaceY, type RoofSegmentNode } from '@pascal-app/core'
import { getDormerExposedFaces } from '../csg-geometry'
import { import {
buildDormerGhostGeometry, buildDormerGhostGeometry,
dormerSupportsArch, dormerSupportsArch,
@@ -41,3 +43,74 @@ describe('windowShape predicates', () => {
expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'rectangle' }))).toBe(false) expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'rectangle' }))).toBe(false)
}) })
}) })
const hostSegment = (overrides?: Partial<RoofSegmentNode>): RoofSegmentNode =>
({
object: 'node',
id: 'rseg_fixture',
type: 'roof-segment',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 40,
wallThickness: 0.1,
deckThickness: 0.1,
overhang: 0.3,
shingleThickness: 0.05,
...overrides,
}) as RoofSegmentNode
// Default-dims dormer resting on the host surface at (x, z) — mirrors
// `useDormerPlacement`, which anchors dormer-local Y=0 at the cursor's
// surface height.
const dormerAt = (segment: RoofSegmentNode, x: number, z: number, rotation = 0) =>
DormerNode.parse({ position: [x, getRoofSegmentSurfaceY(segment, x, z), z], rotation })
describe('getDormerExposedFaces', () => {
test('default dormer mid-slope on the default 40° gable shows the down-slope window', () => {
const seg = hostSegment()
expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5), seg)).toEqual({ front: true, back: false })
})
test('35° gable mid-slope stays exposed (centre datum, not window bottom)', () => {
const seg = hostSegment({ pitch: 35 })
expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5), seg).front).toBe(true)
})
test('eave band: face hanging past the structural eave keeps the window (no plateau)', () => {
const seg = hostSegment()
expect(getDormerExposedFaces(dormerAt(seg, 0, 2.8), seg).front).toBe(true)
})
test('on the Z slope the back face is the exposed one', () => {
const seg = hostSegment()
expect(getDormerExposedFaces(dormerAt(seg, 0, -1.5), seg)).toEqual({ front: false, back: true })
})
test('hip end-slope: face X feeds the max(fx, fz) profile', () => {
const seg = hostSegment({ roofType: 'hip' })
expect(getDormerExposedFaces(dormerAt(seg, 2.5, 0, Math.PI / 2), seg)).toEqual({
front: true,
back: false,
})
})
test('~10° pitch buries the window on both faces', () => {
const seg = hostSegment({ pitch: 10 })
expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5), seg)).toEqual({ front: false, back: false })
})
test('a π yaw swaps which face is down-slope', () => {
const seg = hostSegment()
expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5, Math.PI), seg)).toEqual({
front: false,
back: true,
})
})
})
+51 -55
View File
@@ -1,7 +1,7 @@
import { import {
type DormerNode, type DormerNode,
getActiveRoofHeight,
getPitchFromActiveRoofHeight, getPitchFromActiveRoofHeight,
getRoofSegmentSurfaceY,
ROOF_SHAPE_DEFAULTS, ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode, type RoofSegmentNode,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -191,73 +191,61 @@ function createDormerWindowCutGeometry(
return new THREE.BoxGeometry(w, h, depth) return new THREE.BoxGeometry(w, h, depth)
} }
// Exposure datum: a face shows its window when the window CENTER clears
// the host's structural surface line (≥ half the window visible).
// Gating on the window BOTTOM suppressed the default window on the
// default 40° roof (break-even ≈ 36.7° pitch) and across the whole
// lower-slope/overhang band. A partially buried window reads as a
// window meeting the roof line: the host shingle shell occludes the
// buried frame from outside (the dormer roof cut only clears the inner
// cavity, 5cm short of the gable face), and the glass panes span the
// full opening so the wall cut never reads as a see-through hole. The
// margin only absorbs float noise at the grazing boundary — suppress
// only when the window is truly unplaceable.
const WINDOW_CENTER_MIN_CLEARANCE = 0.01
/** /**
* Which gable faces of a dormer have a *fully visible window opening* * Which gable faces of a dormer have a visible window opening.
* (not clipped by the host roof slope). "front" = mesh-local +Z, * "front" = mesh-local +Z, "back" = mesh-local Z (after the +π/2 yaw
* "back" = mesh-local Z (after the +π/2 yaw bake for non-shed roofs). * bake for non-shed roofs).
* *
* The criterion is window-bottom-above-slope, not wall-top-above-slope: * Each face centre is lifted into segment-local X *and* Z (the yaw
* the dormer wall extends well below the window into the skirt that's * matters, and on hip hosts the end slopes fall along X) and compared
* buried inside the roof, so checking just "does any wall poke above * against the host's canonical per-type surface line via
* the slope" is far too lenient — a dormer whose eave barely clears * `getRoofSegmentSurfaceY`, which extrapolates past the structural
* the roof would pass even though the entire window (which sits inside * eave instead of plateauing at the wall top — a face hanging in free
* the skirt, well below the eave) is buried. Switching to the window * air past the eave keeps dropping. Gates both the CSG window-cut
* bottom collapses both the CSG window-cut decision (which calls into * decision (`generateDormerGeometry`) and the live render
* this function in `generateDormerGeometry`) and the live render gate * (window-assembly.tsx).
* (window-assembly.tsx) onto the right line: the window only renders
* where it's actually visible from outside.
*/ */
export function getDormerExposedFaces( export function getDormerExposedFaces(
dormer: DormerNode, dormer: DormerNode,
hostSegment: RoofSegmentNode, hostSegment: RoofSegmentNode,
): { front: boolean; back: boolean } { ): { front: boolean; back: boolean } {
const halfDepth = dormer.depth / 2 const halfDepth = dormer.depth / 2
const dormerZ = dormer.position[2] ?? 0 const dormerX = dormer.position[0] ?? 0
const dormerY = dormer.position[1] ?? 0 const dormerY = dormer.position[1] ?? 0
const dormerZ = dormer.position[2] ?? 0
const rot = dormer.rotation ?? 0 const rot = dormer.rotation ?? 0
// Gable-face centres in segment-local Z (accounts for dormer yaw). // Gable-face centres in segment-local X/Z (accounts for dormer yaw).
const frontZ = dormerZ + halfDepth * Math.cos(rot) const faceDX = halfDepth * Math.sin(rot)
const backZ = dormerZ - halfDepth * Math.cos(rot) const faceDZ = halfDepth * Math.cos(rot)
// Window bottom in dormer-local Y. Mirrors `getDormerSkirtWindowDims` // Window centre in segment-local Y. Mirrors `getDormerSkirtWindowDims`
// so both functions read the same window position. The window sits // so both functions read the same window position: dormer-local Y=0
// in the skirt below the eave (dormer-local Y=0), so `centerY` is // sits at `dormer.position[1]` and the window centre sits in the
// typically negative; subtracting half the window height lands us at // skirt at -(skirtH / 2) + windowOffsetY.
// the bottom edge.
const skirtH = dormerSkirtHeight(dormer) const skirtH = dormerSkirtHeight(dormer)
const winH = Math.max(0, dormer.windowHeight ?? 0) const windowCenterSegY = dormerY - skirtH / 2 + (dormer.windowOffsetY ?? 0)
const winOffsetY = dormer.windowOffsetY ?? 0
const windowCenterDormerY = -(skirtH / 2) + winOffsetY
const windowBottomDormerY = windowCenterDormerY - winH / 2
// Lift into segment-local Y: dormer-local Y=0 sits at `dormer.position[1]`.
const windowBottomSegY = dormerY + windowBottomDormerY
const hostWh = hostSegment.wallHeight ?? 0.5 const clears = (faceX: number, faceZ: number): boolean =>
const hostRh = getActiveRoofHeight(hostSegment) windowCenterSegY - getRoofSegmentSurfaceY(hostSegment, faceX, faceZ) >
const hostDepth = hostSegment.depth ?? 4 WINDOW_CENTER_MIN_CLEARANCE
const roofHeightAtZ = (segZ: number): number => {
const hostType = hostSegment.roofType ?? 'gable'
if (hostType === 'flat') return hostWh
if (hostType === 'shed') {
const t = Math.max(0, Math.min(1, (segZ + hostDepth / 2) / Math.max(hostDepth, 0.01)))
return hostWh + hostRh * (1 - t)
}
const halfD = Math.max(hostDepth / 2, 0.01)
const t = Math.max(0, Math.min(1, Math.abs(segZ) / halfD))
return hostWh + hostRh * (1 - t)
}
// A face is "exposed" only if the *window bottom* clears the host
// slope at that face's Z by a meaningful amount — borderline cases
// (slope grazing the window bottom) suppress the window so we don't
// render a partially-clipped frame poking out of the roof. 5cm
// matches the threshold the prior wall-top check used.
const minPokeOut = 0.05
return { return {
front: windowBottomSegY - roofHeightAtZ(frontZ) > minPokeOut, front: clears(dormerX + faceDX, dormerZ + faceDZ),
back: windowBottomSegY - roofHeightAtZ(backZ) > minPokeOut, back: clears(dormerX - faceDX, dormerZ - faceDZ),
} }
} }
@@ -352,12 +340,15 @@ export function generateDormerGeometry(
dormerBrushes.innerBrush, dormerBrushes.innerBrush,
SUBTRACTION, SUBTRACTION,
) as Brush ) as Brush
prepareBrushForCSG(hollowWall)
const shinDeck = csgEvaluator.evaluate( const shinDeck = csgEvaluator.evaluate(
dormerBrushes.shinSlab, dormerBrushes.shinSlab,
dormerBrushes.deckSlab, dormerBrushes.deckSlab,
ADDITION, ADDITION,
) as Brush ) as Brush
prepareBrushForCSG(shinDeck)
dormerSolid = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) as Brush dormerSolid = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) as Brush
prepareBrushForCSG(dormerSolid)
hollowWall.geometry.dispose() hollowWall.geometry.dispose()
shinDeck.geometry.dispose() shinDeck.geometry.dispose()
@@ -376,7 +367,9 @@ export function generateDormerGeometry(
hostBrushes.deckSlab, hostBrushes.deckSlab,
ADDITION, ADDITION,
) as Brush ) as Brush
prepareBrushForCSG(wallPlusDeck)
hostSolid = csgEvaluator.evaluate(wallPlusDeck, hostBrushes.shinSlab, ADDITION) as Brush hostSolid = csgEvaluator.evaluate(wallPlusDeck, hostBrushes.shinSlab, ADDITION) as Brush
prepareBrushForCSG(hostSolid)
wallPlusDeck.geometry.dispose() wallPlusDeck.geometry.dispose()
hostBrushes.deckSlab.geometry.dispose() hostBrushes.deckSlab.geometry.dispose()
hostBrushes.shinSlab.geometry.dispose() hostBrushes.shinSlab.geometry.dispose()
@@ -393,8 +386,9 @@ export function generateDormerGeometry(
groundBoxGeo.addGroup(0, indexCount, 0) groundBoxGeo.addGroup(0, indexCount, 0)
computeGeometryBoundsTree(groundBoxGeo) computeGeometryBoundsTree(groundBoxGeo)
const groundBrush = new Brush(groundBoxGeo, roofCsgDummyMats[0]) const groundBrush = new Brush(groundBoxGeo, roofCsgDummyMats[0])
groundBrush.updateMatrixWorld() prepareBrushForCSG(groundBrush)
const fullTrim = csgEvaluator.evaluate(hostSolid, groundBrush, ADDITION) as Brush const fullTrim = csgEvaluator.evaluate(hostSolid, groundBrush, ADDITION) as Brush
prepareBrushForCSG(fullTrim)
hostSolid.geometry.dispose() hostSolid.geometry.dispose()
groundBrush.geometry.dispose() groundBrush.geometry.dispose()
hostSolid = fullTrim hostSolid = fullTrim
@@ -416,6 +410,7 @@ export function generateDormerGeometry(
prepareBrushForCSG(hostSolid) prepareBrushForCSG(hostSolid)
const trimmed = csgEvaluator.evaluate(dormerSolid, hostSolid, SUBTRACTION) as Brush const trimmed = csgEvaluator.evaluate(dormerSolid, hostSolid, SUBTRACTION) as Brush
prepareBrushForCSG(trimmed)
dormerSolid.geometry.dispose() dormerSolid.geometry.dispose()
hostSolid.geometry.dispose() hostSolid.geometry.dispose()
hostSolid = null hostSolid = null
@@ -447,8 +442,9 @@ export function generateDormerGeometry(
cutGeo.addGroup(0, idxCount, 0) cutGeo.addGroup(0, idxCount, 0)
computeGeometryBoundsTree(cutGeo) computeGeometryBoundsTree(cutGeo)
const brush = new Brush(cutGeo, roofCsgDummyMats[0]) const brush = new Brush(cutGeo, roofCsgDummyMats[0])
brush.updateMatrixWorld() prepareBrushForCSG(brush)
const result = csgEvaluator.evaluate(dormerSolid!, brush, SUBTRACTION) as Brush const result = csgEvaluator.evaluate(dormerSolid!, brush, SUBTRACTION) as Brush
prepareBrushForCSG(result)
dormerSolid!.geometry.dispose() dormerSolid!.geometry.dispose()
brush.geometry.dispose() brush.geometry.dispose()
dormerSolid = result dormerSolid = result
@@ -557,7 +553,7 @@ export function buildDormerCutShape(
// ends up along mesh-(-Z) and the extrusion ends up along mesh-X. // ends up along mesh-(-Z) and the extrusion ends up along mesh-X.
// //
// `getRoofSegmentBrushes`'s shed slope puts the peak at z=-d/2 // `getRoofSegmentBrushes`'s shed slope puts the peak at z=-d/2
// and the eave at z=+d/2 (matching the `roofHeightAtZ` helper). // and the eave at z=+d/2 (matching `getRoofSegmentSurfaceY`).
// After the +π/2 rotation, shape-X=+hd → mesh-Z=-hd, so place the // After the +π/2 rotation, shape-X=+hd → mesh-Z=-hd, so place the
// PEAK at shape-X=+hd and the EAVE at shape-X=-hd to keep the cut // PEAK at shape-X=+hd and the EAVE at shape-X=-hd to keep the cut
// aligned with the dormer body's actual slope direction. // aligned with the dormer body's actual slope direction.
@@ -118,7 +118,7 @@ export function useDormerPlacement(opts: {
const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M
const sz = Math.round(wz / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M const sz = Math.round(wz / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
@@ -118,12 +118,10 @@ const DormerWindowAssembly = ({
// non-zero yaw needs to recompute exposure to know which gable // non-zero yaw needs to recompute exposure to know which gable
// is now poking above the slope. // is now poking above the slope.
node.rotation, node.rotation,
// Window position + height feed `getDormerExposedFaces` now that // The window's vertical placement feeds `getDormerExposedFaces`
// it's gating on window-bottom-above-slope (not wall-top-above- // (gates on the window CENTER clearing the host slope) — dragging
// slope) — dragging the window down via inspector or the new // the window down via inspector or the offset handle must
// window-height/offset handles must re-evaluate which gable // re-evaluate which gable still exposes the opening.
// still has a fully-visible opening.
node.windowHeight,
node.windowOffsetY, node.windowOffsetY,
node.wallSkirtHeight, node.wallSkirtHeight,
], ],
@@ -67,7 +67,10 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(target.localZ * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (
event.nativeEvent?.shiftKey !== true &&
(!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz)
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
+7 -5
View File
@@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
import { eyebrowVentDefinition } from './definition' import { eyebrowVentDefinition } from './definition'
import EyebrowVentPreview from './preview' import EyebrowVentPreview from './preview'
@@ -33,6 +33,7 @@ const EyebrowVentTool = () => {
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
const [previewYaw, setPreviewYaw] = useState(0) const [previewYaw, setPreviewYaw] = useState(0)
const [previewRotation, setPreviewRotation] = useState(0)
const lastSnapRef = useRef<[number, number] | null>(null) const lastSnapRef = useRef<[number, number] | null>(null)
const previewNode = useMemo( const previewNode = useMemo(
@@ -41,9 +42,9 @@ const EyebrowVentTool = () => {
...eyebrowVentDefinition.defaults(), ...eyebrowVentDefinition.defaults(),
name: 'Eyebrow Vent', name: 'Eyebrow Vent',
position: [0, 0, 0], position: [0, 0, 0],
rotation: 0, rotation: previewRotation,
}), }),
[], [previewRotation],
) )
useEffect(() => { useEffect(() => {
@@ -65,7 +66,7 @@ const EyebrowVentTool = () => {
const sx = Math.round(wx * 20) / 20 const sx = Math.round(wx * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(wz * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
@@ -76,6 +77,7 @@ const EyebrowVentTool = () => {
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
setPreviewPos(worldToBuildingLocal(wx, wy, wz)) setPreviewPos(worldToBuildingLocal(wx, wy, wz))
event.stopPropagation() event.stopPropagation()
} }
@@ -95,7 +97,7 @@ const EyebrowVentTool = () => {
name: 'Eyebrow Vent', name: 'Eyebrow Vent',
roofSegmentId: hit.segment.id, roofSegmentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ], position: [hit.localX, hit.localY, hit.localZ],
rotation: 0, rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment),
}) })
state.createNode(vent, hit.segment.id as AnyNodeId) state.createNode(vent, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId)
@@ -14,7 +14,6 @@ import {
isSegmentLongEnough, isSegmentLongEnough,
snapFenceDraftPoint, snapFenceDraftPoint,
useAlignmentGuides, useAlignmentGuides,
WALL_FINE_GRID_STEP,
} from '@pascal-app/editor' } from '@pascal-app/editor'
/** /**
@@ -165,14 +164,13 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
preview: (ctx, point, modifiers) => { preview: (ctx, point, modifiers) => {
const planPoint: FencePlanPoint = [point[0], point[1]] const planPoint: FencePlanPoint = [point[0], point[1]]
// Endpoint move = grid snap only; the 45°-from-start angle snap // Endpoint move = grid snap only; the 45°-from-start angle snap
// is draft-only. Shift switches to the fine grid step for // is draft-only. Shift is a hard snap bypass.
// precision, mirroring the wall convention.
const snapped = snapFenceDraftPoint({ const snapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls: ctx.levelWalls, walls: ctx.levelWalls,
fences: ctx.levelFences, fences: ctx.levelFences,
ignoreFenceIds: [ctx.fenceId as string], ignoreFenceIds: [ctx.fenceId as string],
step: modifiers.shift ? WALL_FINE_GRID_STEP : undefined, bypassSnap: modifiers.shift,
}) })
// Figma-style alignment: nudge the dragged endpoint onto another wall / // Figma-style alignment: nudge the dragged endpoint onto another wall /
@@ -180,7 +178,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
// guide. The resolver connects to the NEAREST real anchor, so the dot // guide. The resolver connects to the NEAREST real anchor, so the dot
// always sits on an actual point. Alt is reserved for detach. // always sits on an actual point. Alt is reserved for detach.
let aligned = snapped let aligned = snapped
if (ctx.alignCandidates.length > 0) { if (!modifiers.shift && ctx.alignCandidates.length > 0) {
const ar = resolveAlignment({ const ar = resolveAlignment({
moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }], moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }],
candidates: ctx.alignCandidates, candidates: ctx.alignCandidates,
@@ -190,6 +188,8 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz] aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz]
} }
useAlignmentGuides.getState().set(ar.guides) useAlignmentGuides.getState().set(ar.guides)
} else {
useAlignmentGuides.getState().clear()
} }
const nextStart = ctx.endpoint === 'start' ? aligned : ctx.fixedPoint const nextStart = ctx.endpoint === 'start' ? aligned : ctx.fixedPoint
+5 -3
View File
@@ -89,11 +89,12 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const snapStep = getSegmentGridStep() const snapStep = getSegmentGridStep()
const localX = shiftPressedRef.current const localX = bypassSnap
? event.localPosition[0] ? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep) : snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current const localZ = bypassSnap
? event.localPosition[2] ? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep) : snapScalarToGrid(event.localPosition[2], snapStep)
@@ -101,7 +102,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
(localX - chord.midpoint.x) * chord.normal.x + (localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y (localZ - chord.midpoint.y) * chord.normal.y
) )
const snappedOffset = shiftPressedRef.current const snappedOffset = bypassSnap
? offsetFromMidpoint ? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep) : snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset( const nextCurveOffset = normalizeWallCurveOffset(
@@ -110,6 +111,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
) )
if ( if (
!bypassSnap &&
previousCurveOffsetRef.current !== null && previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current nextCurveOffset !== previousCurveOffsetRef.current
) { ) {
+1 -1
View File
@@ -223,7 +223,7 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Set fence start / end' }, { key: 'Left click', label: 'Set fence start / end' },
{ key: 'Shift', label: 'Allow non-45° angles' }, { key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
@@ -20,7 +20,6 @@ import {
snapFenceDraftPoint, snapFenceDraftPoint,
snapScalarToGrid, snapScalarToGrid,
useAlignmentGuides, useAlignmentGuides,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP, WALL_GRID_STEP,
} from '@pascal-app/editor' } from '@pascal-app/editor'
@@ -159,16 +158,15 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
const sceneNodes = useScene.getState().nodes const sceneNodes = useScene.getState().nodes
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId) const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
// Endpoint move = grid snap only; the 45°-from-start angle // Endpoint move = grid snap only; the 45°-from-start angle
// snap is draft-only. Shift switches to the fine grid step for // snap is draft-only. Shift bypasses grid, magnetic, and alignment snap.
// precision, matching the 3D fence endpoint action.
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
const snapped = snapFenceDraftPoint({ const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint, point: planPoint as FencePlanPoint,
walls: nextWalls, walls: nextWalls,
fences: nextFences, fences: nextFences,
ignoreFenceIds: [node.id], ignoreFenceIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined, bypassSnap: modifiers.shiftKey,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep) as FencePlanPoint, magnetic: !modifiers.shiftKey,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
}) })
// Figma-style alignment on the dragged endpoint — snaps it onto // Figma-style alignment on the dragged endpoint — snaps it onto
// another object's edge / wall face and publishes a guide, matching // another object's edge / wall face and publishes a guide, matching
@@ -176,6 +174,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
// siblings (which cascade with the endpoint) are excluded from the // siblings (which cascade with the endpoint) are excluded from the
// candidate pool. Alt is reserved for detach here, NOT bypass. // candidate pool. Alt is reserved for detach here, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, { const aligned = alignFloorplanDraftPoint(snapped, {
bypass: modifiers.shiftKey,
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)], excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
}) as FencePlanPoint }) as FencePlanPoint
const nextStart = endpoint === 'start' ? aligned : fixedPoint const nextStart = endpoint === 'start' ? aligned : fixedPoint
@@ -1,6 +1,13 @@
'use client' 'use client'
import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core' import {
emitter,
type FenceNode,
type GridEvent,
getWallCurveLength,
useScene,
type WallNode,
} from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
type FencePlanPoint, type FencePlanPoint,
@@ -121,13 +128,43 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const movingPoint = endpoint === 'start' ? liveStart : liveEnd const movingPoint = endpoint === 'start' ? liveStart : liveEnd
// Ticker SFX on each grid-snap step, mirroring the wall endpoint tool. // Ticker SFX on each grid-snap step, mirroring the wall endpoint tool.
// The action snaps the point before writing to the scene, so `movingPoint` // First tick just seeds the ref (no sound on mount). The drag action receives
// only changes in discrete grid steps — the right cadence for the click. // the Shift modifier through grid events, so mirror that modifier here to
// First tick just seeds the ref (no sound on mount). // avoid playing grid ticks while snap is bypassed.
const previousGridPosRef = useRef<FencePlanPoint | null>(null) const previousGridPosRef = useRef<FencePlanPoint | null>(null)
const shiftPressedRef = useRef(false)
useEffect(() => {
const onGridMove = (event: GridEvent) => {
shiftPressedRef.current = event.nativeEvent?.shiftKey === true
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') shiftPressedRef.current = true
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') shiftPressedRef.current = false
}
const onBlur = () => {
shiftPressedRef.current = false
}
emitter.on('grid:move', onGridMove)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => {
emitter.off('grid:move', onGridMove)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
}
}, [])
useEffect(() => { useEffect(() => {
const prev = previousGridPosRef.current const prev = previousGridPosRef.current
if (prev && (prev[0] !== movingPoint[0] || prev[1] !== movingPoint[1])) { if (
!shiftPressedRef.current &&
prev &&
(prev[0] !== movingPoint[0] || prev[1] !== movingPoint[1])
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
} }
previousGridPosRef.current = movingPoint previousGridPosRef.current = movingPoint
+3
View File
@@ -193,14 +193,17 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true
const [localX, localZ] = snapFenceDraftPoint({ const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]], point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls, walls: levelWalls,
fences: levelFences, fences: levelFences,
ignoreFenceIds: [fenceId], ignoreFenceIds: [fenceId],
bypassSnap,
}) })
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) { ) {
+44 -13
View File
@@ -30,7 +30,7 @@ import {
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
WALL_FINE_GRID_STEP, useSegmentDraftChain,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
@@ -485,6 +485,7 @@ export const FenceTool: React.FC = () => {
buildingState.current = 0 buildingState.current = 0
previewRef.current.visible = false previewRef.current.visible = false
setDraftMeasurement(null) setDraftMeasurement(null)
useSegmentDraftChain.getState().clear('fence')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} }
@@ -492,20 +493,29 @@ export const FenceTool: React.FC = () => {
if (!(cursorRef.current && previewRef.current)) return if (!(cursorRef.current && previewRef.current)) return
const { walls, fences } = getCurrentLevelElements() const { walls, fences } = getCurrentLevelElements()
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
// Default = active grid step; Shift switches to the fine step // While drafting, the segment locks to 15° rays from its start
// (0.05m). No 45° angle snap — see `wall/tool.tsx` for rationale. // unless Shift is held. Shift also bypasses grid and magnetic snap.
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
if (buildingState.current === 1) { if (buildingState.current === 1) {
const angleLocked = !bypassSnap
const snappedLocal = alignPoint( const snappedLocal = alignPoint(
snapFenceDraftPoint({ point: localPoint, walls, fences, step }), snapFenceDraftPoint({
bypassAlign, point: localPoint,
walls,
fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
}),
bypassAlign || angleLocked,
) )
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current) cursorRef.current.position.copy(endingPoint.current)
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
if ( if (
!bypassSnap &&
previousFenceEnd && previousFenceEnd &&
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1]) (currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
) { ) {
@@ -532,7 +542,7 @@ export const FenceTool: React.FC = () => {
) )
} else { } else {
const snappedPoint = alignPoint( const snappedPoint = alignPoint(
snapFenceDraftPoint({ point: localPoint, walls, fences, step }), snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }),
bypassAlign, bypassAlign,
) )
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
@@ -548,12 +558,12 @@ export const FenceTool: React.FC = () => {
const { walls, fences } = getCurrentLevelElements() const { walls, fences } = getCurrentLevelElements()
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
if (buildingState.current === 0) { if (buildingState.current === 0) {
const snappedStart = alignPoint( const snappedStart = alignPoint(
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }), snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }),
bypassAlign, bypassAlign,
) )
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
@@ -563,9 +573,17 @@ export const FenceTool: React.FC = () => {
previewRef.current.visible = true previewRef.current.visible = true
setDraftMeasurement(null) setDraftMeasurement(null)
} else { } else {
const angleLocked = !bypassSnap
const snappedEnd = alignPoint( const snappedEnd = alignPoint(
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }), snapFenceDraftPoint({
bypassAlign, point: localClick,
walls,
fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
}),
bypassAlign || angleLocked,
) )
const dx = snappedEnd[0] - startingPoint.current.x const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z const dz = snappedEnd[1] - startingPoint.current.z
@@ -582,6 +600,10 @@ export const FenceTool: React.FC = () => {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
const nextStart = createdFence.end const nextStart = createdFence.end
// Publish the resolved chain start so the 2D floor-plan draft
// chains its next segment from the same point (its own snap
// pipeline can resolve a slightly different endpoint).
useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]])
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
cursorRef.current?.position.copy(startingPoint.current) cursorRef.current?.position.copy(startingPoint.current)
@@ -599,6 +621,12 @@ export const FenceTool: React.FC = () => {
if (e.key === 'Shift') shiftPressed.current = false if (e.key === 'Shift') shiftPressed.current = false
} }
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
// angle lock isn't stuck off when focus returns.
const onBlur = () => {
shiftPressed.current = false
}
const onCancel = () => { const onCancel = () => {
if (buildingState.current === 1) { if (buildingState.current === 1) {
markToolCancelConsumed() markToolCancelConsumed()
@@ -611,6 +639,7 @@ export const FenceTool: React.FC = () => {
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp) window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
@@ -618,6 +647,8 @@ export const FenceTool: React.FC = () => {
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
useSegmentDraftChain.getState().clear('fence')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} }
}, [unit]) }, [unit])
+1
View File
@@ -225,6 +225,7 @@ export function buildGutterGeometry(
const drillBrush = new Brush(drill) const drillBrush = new Brush(drill)
prepareBrushForCSG(drillBrush) prepareBrushForCSG(drillBrush)
const next = csgEvaluator.evaluate(workingBrush, drillBrush, SUBTRACTION) as Brush const next = csgEvaluator.evaluate(workingBrush, drillBrush, SUBTRACTION) as Brush
prepareBrushForCSG(next)
// Free the previous step's intermediate result (but not `merged`, // Free the previous step's intermediate result (but not `merged`,
// which is disposed once below). // which is disposed once below).
if (workingBrush.geometry !== merged) workingBrush.geometry.dispose() if (workingBrush.geometry !== merged) workingBrush.geometry.dispose()
+4 -1
View File
@@ -97,7 +97,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
const sx = Math.round(snap.eaveX * 20) / 20 const sx = Math.round(snap.eaveX * 20) / 20
const sz = Math.round(snap.eaveZ * 20) / 20 const sz = Math.round(snap.eaveZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (
event.nativeEvent?.shiftKey !== true &&
(!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz)
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
+1 -1
View File
@@ -83,7 +83,7 @@ const GutterTool = () => {
const sx = Math.round(snap.eaveX * 20) / 20 const sx = Math.round(snap.eaveX * 20) / 20
const sz = Math.round(snap.eaveZ * 20) / 20 const sz = Math.round(snap.eaveZ * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
+4 -4
View File
@@ -22,10 +22,10 @@ const ROTATE_RING_OFFSET = 0.06
// Whole-item rotation handle — the two-headed curved arrow. `arc-resize` // Whole-item rotation handle — the two-headed curved arrow. `arc-resize`
// does the angular drag math (raycasts a horizontal plane at the gizmo's // does the angular drag math (raycasts a horizontal plane at the gizmo's
// Y, measures cursor bearing around the item's local origin, returns the // Y, measures cursor bearing around the item's local origin, returns the
// delta). Holding Shift snaps to 15° increments (handled generically in // delta). Rotation snaps to 15° increments by default; holding Shift
// node-arrow-handles for any `shape: 'rotate'`), matching the R/T rotate // bypasses that snap (handled generically in node-arrow-handles for any
// step for placed items. Item rotation is stored as `[x, y, z]`; only the // `shape: 'rotate'`), matching the R/T rotate step for placed items. Item
// Y component turns. // rotation is stored as `[x, y, z]`; only the Y component turns.
function itemRotateHandle(): HandleDescriptor<ItemNodeType> { function itemRotateHandle(): HandleDescriptor<ItemNodeType> {
return { return {
kind: 'arc-resize', kind: 'arc-resize',
+4 -3
View File
@@ -211,8 +211,9 @@ function buildWallItemSession(
// Figma-style along-wall alignment (edge-to-edge with other openings / // Figma-style along-wall alignment (edge-to-edge with other openings /
// wall items / wall ends), winning over the 0.5m grid snap; falls back // wall items / wall ends), winning over the 0.5m grid snap; falls back
// to grid when nothing aligns. Alt bypasses; Shift drops the grid snap. // to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap.
const neighborX = modifiers.altKey const neighborX =
modifiers.altKey || modifiers.shiftKey
? null ? null
: snapLocalXToNeighbors({ : snapLocalXToNeighbors({
wall: hit.wall, wall: hit.wall,
@@ -286,7 +287,7 @@ function buildFloorItemSession(
rotationY, rotationY,
), ),
candidates, candidates,
{ bypass: modifiers.altKey }, { bypass: modifiers.altKey || modifiers.shiftKey },
) )
const sourceY = node.position[1] const sourceY = node.position[1]
+4 -1
View File
@@ -80,7 +80,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(target.localZ * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (
event.nativeEvent?.shiftKey !== true &&
(!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz)
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
+1 -1
View File
@@ -88,7 +88,7 @@ const RidgeVentTool = () => {
const sx = Math.round(ridgeWorld[0] * 20) / 20 const sx = Math.round(ridgeWorld[0] * 20) / 20
const sz = Math.round(ridgeWorld[2] * 20) / 20 const sz = Math.round(ridgeWorld[2] * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
@@ -79,11 +79,12 @@ export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> =
return { return {
affectedIds: [segmentId], affectedIds: [segmentId],
apply({ planPoint }) { apply({ planPoint, modifiers }) {
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1]) const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
const delta = (currentLocal - initialLocal) * side const delta = (currentLocal - initialLocal) * side
const rawValue = initialValue + 2 * delta const rawValue = initialValue + 2 * delta
const snappedValue = gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue const snappedValue =
!modifiers.shiftKey && gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue
const newValue = Math.max(MIN_ROOF_DIM, snappedValue) const newValue = Math.max(MIN_ROOF_DIM, snappedValue)
lastValue = newValue lastValue = newValue
useScene useScene
+10 -3
View File
@@ -36,6 +36,7 @@ type FloorPlacementAlignmentArgs = {
gridStep: number gridStep: number
candidates: Parameters<typeof resolveAlignment>[0]['candidates'] candidates: Parameters<typeof resolveAlignment>[0]['candidates']
bypassAlignment?: boolean bypassAlignment?: boolean
bypassGrid?: boolean
rotationY?: number rotationY?: number
} }
@@ -45,18 +46,23 @@ export function getLevelLocalSnappedPosition(
levelId: string, levelId: string,
event: FloorPlacementClickTriggerEvent, event: FloorPlacementClickTriggerEvent,
gridStep: number, gridStep: number,
bypassGrid = false,
): [number, number, number] { ): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId) const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) { if (!levelObject) {
const rawPoint = 'node' in event ? event.position : event.localPosition const rawPoint = 'node' in event ? event.position : event.localPosition
const [sx, sz] = snapPointToGrid([rawPoint[0], rawPoint[2]], gridStep) const [sx, sz] = bypassGrid
? [rawPoint[0], rawPoint[2]]
: snapPointToGrid([rawPoint[0], rawPoint[2]], gridStep)
return [sx, 0, sz] return [sx, 0, sz]
} }
worldVector.set(event.position[0], event.position[1], event.position[2]) worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false) levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector) levelObject.worldToLocal(worldVector)
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], gridStep) const [sx, sz] = bypassGrid
? [worldVector.x, worldVector.z]
: snapPointToGrid([worldVector.x, worldVector.z], gridStep)
return [sx, 0, sz] return [sx, 0, sz]
} }
@@ -67,9 +73,10 @@ export function resolveAlignedFloorPlacement({
gridStep, gridStep,
candidates, candidates,
bypassAlignment = false, bypassAlignment = false,
bypassGrid = false,
rotationY = 0, rotationY = 0,
}: FloorPlacementAlignmentArgs) { }: FloorPlacementAlignmentArgs) {
const [sx, sz] = snapPointToGrid([rawX, rawZ], gridStep) const [sx, sz] = bypassGrid ? [rawX, rawZ] : snapPointToGrid([rawX, rawZ], gridStep)
let ax = sx let ax = sx
let az = sz let az = sz
+7 -1
View File
@@ -293,6 +293,7 @@ export const MoveRoofTool: React.FC<{
point: [event.localPosition[0], event.localPosition[2]], point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls, walls: levelWalls,
fences: levelFences, fences: levelFences,
bypassSnap: event.nativeEvent?.shiftKey === true,
}) })
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y) const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
const [rawLocalX, rawLocalZ] = computeLocal( const [rawLocalX, rawLocalZ] = computeLocal(
@@ -312,12 +313,17 @@ export const MoveRoofTool: React.FC<{
let [localX, localZ] = resolved.point let [localX, localZ] = resolved.point
if (alignTopLevel) { if (alignTopLevel) {
const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true) const aligned = alignLocalPoint(
localX,
localZ,
event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
)
localX = aligned[0] localX = aligned[0]
localZ = aligned[1] localZ = aligned[1]
} }
if ( if (
event.nativeEvent?.shiftKey !== true &&
previousGridPosRef.current && previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) { ) {
@@ -24,6 +24,7 @@ export function createPlaceholderGeometry(groupCount = 0): BufferGeometry {
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3)) geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3))
geometry.setAttribute('normal', new Float32BufferAttribute(new Float32Array(9), 3)) geometry.setAttribute('normal', new Float32BufferAttribute(new Float32Array(9), 3))
geometry.setAttribute('uv', new Float32BufferAttribute(new Float32Array(6), 2)) geometry.setAttribute('uv', new Float32BufferAttribute(new Float32Array(6), 2))
geometry.setAttribute('uv2', new Float32BufferAttribute(new Float32Array(6), 2))
for (let group = 0; group < groupCount; group++) { for (let group = 0; group < groupCount; group++) {
geometry.addGroup(0, 0, group) geometry.addGroup(0, 0, group)
} }
@@ -104,7 +104,7 @@ export function createPolygonCentroidMoveTarget(args: {
let dx = target[0] - originalCenter[0] let dx = target[0] - originalCenter[0]
let dz = target[1] - originalCenter[1] let dz = target[1] - originalCenter[1]
if (!modifiers.altKey && candidates.length > 0) { if (!(modifiers.altKey || modifiers.shiftKey) && candidates.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
moving: polygonAnchors(id, translatePolygon(originalPolygon, dx, dz)), moving: polygonAnchors(id, translatePolygon(originalPolygon, dx, dz)),
candidates, candidates,
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'bun:test'
import type { RoofSegmentNode } from '@pascal-app/core'
import { getDownSlopeYaw } from './roof-surface'
const fixtureSegment = (overrides?: Partial<RoofSegmentNode>): RoofSegmentNode =>
({
object: 'node',
id: 'rseg_fixture',
type: 'roof-segment',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 2.5,
pitch: (Math.atan2(2, 3) * 180) / Math.PI,
wallThickness: 0.1,
deckThickness: 0.1,
overhang: 0.3,
shingleThickness: 0.05,
...overrides,
}) as RoofSegmentNode
describe('getDownSlopeYaw', () => {
test('gable +z face: local +z already points down-slope (yaw 0)', () => {
expect(getDownSlopeYaw(0, 1, fixtureSegment())).toBeCloseTo(0)
})
test('gable z face: half-turn so +z faces the z eave (yaw π)', () => {
expect(getDownSlopeYaw(0, -1, fixtureSegment())).toBeCloseTo(Math.PI)
})
test('hip +x face yaws +π/2', () => {
expect(getDownSlopeYaw(2, 0, fixtureSegment({ roofType: 'hip' }))).toBeCloseTo(Math.PI / 2)
})
test('hip x face yaws −π/2', () => {
expect(getDownSlopeYaw(-2, 0, fixtureSegment({ roofType: 'hip' }))).toBeCloseTo(-Math.PI / 2)
})
test('flat segment has no down-slope direction (yaw 0)', () => {
expect(getDownSlopeYaw(0, 0, fixtureSegment({ roofType: 'flat' }))).toBe(0)
})
})
+12
View File
@@ -137,3 +137,15 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni
const m = new THREE.Matrix4().makeBasis(right, normal, forward) const m = new THREE.Matrix4().makeBasis(right, normal, forward)
return out.setFromRotationMatrix(m) return out.setFromRotationMatrix(m)
} }
// Yaw (about the surface normal, composed AFTER `surfaceQuatFromNormal`)
// that points the node's local +Z down the slope. The analytical normals
// are axis-aligned (n.x or n.z is 0), and in the +X-projected basis above
// the down-slope direction decomposes to atan2(n.x · n.y, n.z): +Z face
// → 0, Z → π, +X → +π/2, X → −π/2. Kept next to `surfaceQuatFromNormal`
// so the two stay in lockstep — the formula is only valid for its basis.
export function getDownSlopeYaw(lx: number, lz: number, seg: RoofSegmentNode): number {
const n = getAnalyticalNormal(lx, lz, seg)
if (n.x === 0 && n.z === 0) return 0
return Math.atan2(n.x * n.y, n.z)
}
@@ -1,26 +1,22 @@
import type { RoofSegmentNode, RoofWallFaceId } from '@pascal-app/core' import type { DoorNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'
import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core' import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core'
import { buildOpeningCutoutGeometry, hasFlatOpeningCutoutBottom } from '@pascal-app/viewer'
import * as THREE from 'three' import * as THREE from 'three'
type RoofWallOpening = {
roofSegmentId?: string
roofFace?: RoofWallFaceId
position: [number, number, number]
width: number
height: number
}
/** /**
* CSG cut for a door / window hosted on a roof-segment wall face * CSG cut for a door / window hosted on a roof-segment wall face
* (`capabilities.roofAccessory.buildCut`). A box through the wall * (`capabilities.roofAccessory.buildCut`). The cut goes through the wall
* mid-plane, derived from the CURRENT host geometry (the opening stores * mid-plane, derived from the CURRENT host geometry (the opening stores
* face-local coords), so the hole follows segment resizes for free. * face-local coords), so the hole follows segment resizes for free.
* Plain rectangles cut a box; shaped openings (arch / rounded /
* frameless `opening` kind) reuse the wall pipeline's cutout profile so
* roof-hosted holes match wall-hosted ones.
* *
* Returns null for wall-hosted openings: their cut is handled by the * Returns null for wall-hosted openings: their cut is handled by the
* wall system's own cutout pipeline. * wall system's own cutout pipeline.
*/ */
export function buildRoofWallOpeningCut( export function buildRoofWallOpeningCut(
node: RoofWallOpening, node: DoorNode | WindowNode,
hostSegment: RoofSegmentNode, hostSegment: RoofSegmentNode,
): THREE.BufferGeometry | null { ): THREE.BufferGeometry | null {
if (!node.roofSegmentId || !node.roofFace) return null if (!node.roofSegmentId || !node.roofFace) return null
@@ -32,8 +28,10 @@ export function buildRoofWallOpeningCut(
// A door's cut bottom is coplanar with the wall brush base — extend it // A door's cut bottom is coplanar with the wall brush base — extend it
// slightly downward so three-bvh-csg never has to clip coplanar faces. // slightly downward so three-bvh-csg never has to clip coplanar faces.
// Only a flat bottom chord may extend; a rounded bottom is never
// coplanar and shifting it would distort the profile.
const bottom = node.position[1] - node.height / 2 const bottom = node.position[1] - node.height / 2
const bottomPad = bottom < 0.005 ? 0.02 : 0 const bottomPad = bottom < 0.005 && hasFlatOpeningCutoutBottom(node) ? 0.02 : 0
const center = roofFacePointToSegment(hostSegment, node.roofFace, [ const center = roofFacePointToSegment(hostSegment, node.roofFace, [
node.position[0], node.position[0],
@@ -42,9 +40,35 @@ export function buildRoofWallOpeningCut(
]) ])
const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace) const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace)
const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth) const geo = buildCutGeometry(node, wallThickness, depth, bottomPad)
geo.translate(0, -bottomPad / 2, 0)
geo.rotateY(yaw) geo.rotateY(yaw)
geo.translate(center[0], center[1], center[2]) geo.translate(center[0], center[1], center[2])
return geo return geo
} }
function buildCutGeometry(
node: DoorNode | WindowNode,
wallThickness: number,
depth: number,
bottomPad: number,
): THREE.BufferGeometry {
const shaped =
node.openingKind === 'opening' ||
node.openingShape === 'arch' ||
node.openingShape === 'rounded'
if (!shaped) {
const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth)
geo.translate(0, -bottomPad / 2, 0)
return geo
}
const halfWidth = node.width / 2
const halfHeight = node.height / 2
return buildOpeningCutoutGeometry(
node,
{ left: -halfWidth, right: halfWidth, bottom: -halfHeight - bottomPad, top: halfHeight },
depth,
wallThickness,
)
}
@@ -21,7 +21,8 @@ const MIN_AXIS_COMPONENT = 0.5
* runs along and map it to the along-wall coordinate that lands the opening on * runs along and map it to the along-wall coordinate that lands the opening on
* it. Falls back to the half-metre snap when nothing aligns, and clears the * it. Falls back to the half-metre snap when nothing aligns, and clears the
* guide on bypass / no-match. Returns the localX to use (X-clamped to the wall * guide on bypass / no-match. Returns the localX to use (X-clamped to the wall
* given `width`). `bypass` (Alt) disables alignment. * given `width`). `bypass` disables alignment; `bypassSnap` also skips the
* half-metre fallback.
*/ */
export function resolveWallSlideAlignment(args: { export function resolveWallSlideAlignment(args: {
wallNode: WallNode wallNode: WallNode
@@ -29,9 +30,10 @@ export function resolveWallSlideAlignment(args: {
width: number width: number
candidates: readonly AlignmentAnchor[] candidates: readonly AlignmentAnchor[]
bypass: boolean bypass: boolean
bypassSnap?: boolean
}): number { }): number {
const { wallNode, rawLocalX, width, candidates, bypass } = args const { wallNode, rawLocalX, width, candidates, bypass, bypassSnap = false } = args
const base = snapToHalf(rawLocalX) const base = bypassSnap ? rawLocalX : snapToHalf(rawLocalX)
if (bypass || candidates.length === 0) { if (bypass || candidates.length === 0) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return base return base
+3 -3
View File
@@ -66,7 +66,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
// Figma-style alignment layered on the grid snap — the shelf footprint // Figma-style alignment layered on the grid snap — the shelf footprint
// edges snap to neighbours / wall faces and a guide is published. Alt // edges snap to neighbours / wall faces and a guide is published. Alt
// bypasses (matches placement tools' "No snap"). // bypasses alignment; Shift bypasses all snap.
const { point: snapped } = applyFloorplanAlignment( const { point: snapped } = applyFloorplanAlignment(
gridSnapped, gridSnapped,
movingFootprintAnchors( movingFootprintAnchors(
@@ -76,7 +76,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
originalRotationY, originalRotationY,
), ),
candidates, candidates,
{ bypass: modifiers.altKey }, { bypass: modifiers.altKey || modifiers.shiftKey },
) )
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
lastPosition = next lastPosition = next
@@ -85,7 +85,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
// and the placement coordinators. Item / slab / wall flows fire // and the placement coordinators. Item / slab / wall flows fire
// the same cue, so the shelf following along is the expected UX. // the same cue, so the shelf following along is the expected UX.
const snapKey = `${snapped[0]},${snapped[1]}` const snapKey = `${snapped[0]},${snapped[1]}`
if (snapKey !== lastSnapKey) { if (!modifiers.shiftKey && snapKey !== lastSnapKey) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey lastSnapKey = snapKey
} }
+12 -3
View File
@@ -83,7 +83,8 @@ const ShelfTool = () => {
rawZ: event.localPosition[2], rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep, gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypassAlignment: event.nativeEvent?.altKey === true, bypassAlignment: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassGrid: event.nativeEvent?.shiftKey === true,
}) })
useAlignmentGuides.getState().set(guides) useAlignmentGuides.getState().set(guides)
@@ -97,7 +98,10 @@ const ShelfTool = () => {
lastCursorRef.current = position lastCursorRef.current = position
const prev = previousSnapRef.current const prev = previousSnapRef.current
if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { if (
event.nativeEvent?.shiftKey !== true &&
(!prev || prev[0] !== position[0] || prev[1] !== position[2])
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
previousSnapRef.current = [position[0], position[2]] previousSnapRef.current = [position[0], position[2]]
} }
@@ -110,7 +114,12 @@ const ShelfTool = () => {
// first). Both paths apply the same grid snap. // first). Both paths apply the same grid snap.
const position = const position =
lastCursorRef.current ?? lastCursorRef.current ??
getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep) getLevelLocalSnappedPosition(
activeLevelId,
event,
useEditor.getState().gridSnapStep,
event.nativeEvent?.shiftKey === true,
)
const shelf = ShelfNode.parse({ const shelf = ShelfNode.parse({
...shelfDefinition.defaults(), ...shelfDefinition.defaults(),
name: 'Shelf', name: 'Shelf',
-9
View File
@@ -67,14 +67,5 @@ export function buildFrameGeometry({
frameGeo.translate(0, -totalDepth / 2 + curbH, 0) frameGeo.translate(0, -totalDepth / 2 + curbH, 0)
// WebGPU node renderer requests `uv2` on every geometry for lightmap support.
// CSG output only carries position + normal + uv. Copy uv → uv2 so the
// AttributeNode lookup doesn't fail and invalidate the render pipeline.
// Mirrors `ensureUv2Attribute` in packages/viewer/src/systems/roof/roof-system.tsx.
const uv = frameGeo.getAttribute('uv')
if (uv) {
frameGeo.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
return frameGeo return frameGeo
} }
+1 -1
View File
@@ -98,7 +98,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
const onRoofMove = (event: RoofEvent) => { const onRoofMove = (event: RoofEvent) => {
const sx = Math.round(event.position[0] * 20) / 20 const sx = Math.round(event.position[0] * 20) / 20
const sz = Math.round(event.position[2] * 20) / 20 const sz = Math.round(event.position[2] * 20) / 20
if (sx !== lastSnapX || sz !== lastSnapZ) { if (event.nativeEvent?.shiftKey !== true && (sx !== lastSnapX || sz !== lastSnapZ)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapX = sx lastSnapX = sx
lastSnapZ = sz lastSnapZ = sz
+1 -3
View File
@@ -628,8 +628,7 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
const glassMaterial = useMemo(() => { const glassMaterial = useMemo(() => {
// Untextured glass (and textures-off mode) takes the themed 'glazing' // Untextured glass (and textures-off mode) takes the themed 'glazing'
// role material — already DoubleSide + semi-transparent, and shared // role material from the shared cache, so it must not be mutated.
// from the cache, so it must not be mutated.
if (!textures || (!node.glassMaterial && !node.glassMaterialPreset)) { if (!textures || (!node.glassMaterial && !node.glassMaterialPreset)) {
return createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme) return createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme)
} }
@@ -638,7 +637,6 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
: (createMaterialFromPresetRef(node.glassMaterialPreset, shading) ?? : (createMaterialFromPresetRef(node.glassMaterialPreset, shading) ??
defaultGlassMaterial.clone()) defaultGlassMaterial.clone())
if (mat && typeof mat === 'object') { if (mat && typeof mat === 'object') {
;(mat as THREE.Material).side = THREE.DoubleSide
if (mat instanceof THREE.MeshPhysicalMaterial) { if (mat instanceof THREE.MeshPhysicalMaterial) {
mat.thickness = glassThickness mat.thickness = glassThickness
} }
+1 -1
View File
@@ -59,7 +59,7 @@ const SkylightTool = () => {
const sx = Math.round(wx * 20) / 20 const sx = Math.round(wx * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(wz * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
@@ -73,6 +73,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
levelId: slabLevelId, levelId: slabLevelId,
excludeId: slabId, excludeId: slabId,
altKey: context.nativeEvent?.altKey === true, altKey: context.nativeEvent?.altKey === true,
shiftKey: context.nativeEvent?.shiftKey === true,
}).point, }).point,
[slabId, slabLevelId], [slabId, slabLevelId],
) )
+1 -1
View File
@@ -166,7 +166,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
handles: slabHandles, handles: slabHandles,
// Stage D: kind-owned placement tool. Multi-click polygon drawing // Stage D: kind-owned placement tool. Multi-click polygon drawing
// with axis/45° snap (Shift to defeat). // with 15° angle snap (Shift to defeat).
tool: () => import('./tool'), tool: () => import('./tool'),
// Stage D — all four slab drag-affordances live in this folder. // Stage D — all four slab drag-affordances live in this folder.
@@ -37,6 +37,7 @@ const slabSnapOptions = {
excludeId: node.id, excludeId: node.id,
nodes: sceneNodes, nodes: sceneNodes,
altKey: modifiers.altKey, altKey: modifiers.altKey,
shiftKey: modifiers.shiftKey,
}).point }).point
}, },
} }
+5 -2
View File
@@ -167,14 +167,17 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return if (isFloorplanSourcedEvent(event)) return
const gridStep = getSegmentGridStep() const gridStep = getSegmentGridStep()
const bypassSnap = event.nativeEvent?.shiftKey === true
const [localX, localZ] = snapFenceDraftPoint({ const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]], point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls, walls: levelWalls,
fences: levelFences, fences: levelFences,
bypassSnap,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
}) })
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) { ) {
@@ -190,8 +193,8 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
// Figma-style alignment snap: align the slab's translated polygon // Figma-style alignment snap: align the slab's translated polygon
// vertices to other objects' anchors; fold the snap into the delta and // vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alt bypasses. // publish a guide. Alt bypasses alignment; Shift bypasses all snap.
const bypass = event.nativeEvent?.altKey === true const bypass = event.nativeEvent?.altKey === true || bypassSnap
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignmentForActiveBuilding({ const result = resolveAlignmentForActiveBuilding({
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)), moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
+30 -28
View File
@@ -1,6 +1,13 @@
'use client' 'use client'
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' import {
DEFAULT_ANGLE_STEP,
emitter,
type GridEvent,
type LevelNode,
snapPointAlongAngleRay,
useScene,
} from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
clearSlabSnapFeedback, clearSlabSnapFeedback,
@@ -20,7 +27,7 @@ import { SlabNode } from './schema'
* *
* Multi-click polygon drawing: each click adds a vertex; clicking near * Multi-click polygon drawing: each click adds a vertex; clicking near
* the first vertex (or double-clicking) closes the polygon and creates * the first vertex (or double-clicking) closes the polygon and creates
* the slab. Shift-modifier defeats the axis/45° snap during drag. * the slab. Shift-modifier defeats the 15° angle snap during drag.
* *
* Not a `DragAction` — same reasoning as `tool.tsx` for fence: this is * Not a `DragAction` — same reasoning as `tool.tsx` for fence: this is
* a stateful sequence of grid:click events with preview state, not a * a stateful sequence of grid:click events with preview state, not a
@@ -29,28 +36,6 @@ import { SlabNode } from './schema'
const Y_OFFSET = 0.02 const Y_OFFSET = 0.02
function 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)
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 commitSlabDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string { function commitSlabDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string {
const { createNode, nodes } = useScene.getState() const { createNode, nodes } = useScene.getState()
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
@@ -90,24 +75,36 @@ export const SlabTool: React.FC = () => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return if (!cursorRef.current) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const gridX = Math.round(rawPoint[0] * 2) / 2 const gridX = Math.round(rawPoint[0] * 2) / 2
const gridZ = Math.round(rawPoint[1] * 2) / 2 const gridZ = Math.round(rawPoint[1] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ] const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ]
setCursorPosition(gridPosition) setCursorPosition(gridPosition)
setLevelY(event.localPosition[1]) setLevelY(event.localPosition[1])
const lastPoint = points[points.length - 1] const lastPoint = points[points.length - 1]
const orthoPoint = // 15° angle snap from the raw cursor (matching the 2D floorplan
shiftPressed.current || !lastPoint // pipeline) with the distance snapped along the ray to the grid step.
const orthoPoint: [number, number] =
bypassSnap || !lastPoint
? gridPosition ? gridPosition
: calculateSnapPoint(lastPoint, gridPosition) : [
...snapPointAlongAngleRay(
lastPoint,
rawPoint,
DEFAULT_ANGLE_STEP,
useEditor.getState().gridSnapStep,
),
]
const displayPoint = resolveSlabPlanPointSnap({ const displayPoint = resolveSlabPlanPointSnap({
rawPoint, rawPoint,
fallbackPoint: orthoPoint, fallbackPoint: orthoPoint,
levelId: currentLevelId, levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true, altKey: event.nativeEvent?.altKey === true,
shiftKey: bypassSnap,
}).point }).point
setSnappedCursorPosition(displayPoint) setSnappedCursorPosition(displayPoint)
if ( if (
!bypassSnap &&
points.length > 0 && points.length > 0 &&
previousSnappedPointRef.current && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || (displayPoint[0] !== previousSnappedPointRef.current[0] ||
@@ -163,8 +160,12 @@ export const SlabTool: React.FC = () => {
const onKeyUp = (e: KeyboardEvent) => { const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false if (e.key === 'Shift') shiftPressed.current = false
} }
const onWindowBlur = () => {
shiftPressed.current = false
}
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp) document.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
@@ -174,6 +175,7 @@ export const SlabTool: React.FC = () => {
return () => { return () => {
document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp) document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
+1 -1
View File
@@ -100,7 +100,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(target.localZ * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (sx !== lastSnapX || sz !== lastSnapZ) { if (event.nativeEvent?.shiftKey !== true && (sx !== lastSnapX || sz !== lastSnapZ)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapX = sx lastSnapX = sx
lastSnapZ = sz lastSnapZ = sz
+1 -1
View File
@@ -72,7 +72,7 @@ const SolarPanelTool = () => {
const sx = Math.round(wx * 20) / 20 const sx = Math.round(wx * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(wz * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
+16 -7
View File
@@ -22,15 +22,23 @@ function getExistingSpawnIds() {
.sort() .sort()
} }
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] { function getLevelLocalPosition(
levelId: string,
event: GridEvent,
bypassSnap: boolean,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId) const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) { if (!levelObject) {
return [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])] return bypassSnap
? [event.localPosition[0], 0, event.localPosition[2]]
: [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]
} }
worldVector.set(event.position[0], event.position[1], event.position[2]) worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false) levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector) levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)] return bypassSnap
? [worldVector.x, 0, worldVector.z]
: [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)]
} }
/** /**
@@ -52,8 +60,9 @@ const SpawnTool = () => {
// Cursor lives in the ToolManager's building-local group. Use // Cursor lives in the ToolManager's building-local group. Use
// event.localPosition directly (already building-local) with the // event.localPosition directly (already building-local) with the
// same half-meter snap the legacy tool uses. // same half-meter snap the legacy tool uses.
const nextX = roundToHalf(event.localPosition[0]) const bypassSnap = event.nativeEvent?.shiftKey === true
const nextZ = roundToHalf(event.localPosition[2]) const nextX = bypassSnap ? event.localPosition[0] : roundToHalf(event.localPosition[0])
const nextZ = bypassSnap ? event.localPosition[2] : roundToHalf(event.localPosition[2])
const position: [number, number, number] = [nextX, 0, nextZ] const position: [number, number, number] = [nextX, 0, nextZ]
const previewNode = SpawnNode.parse({ const previewNode = SpawnNode.parse({
name: 'Spawn Point', name: 'Spawn Point',
@@ -72,14 +81,14 @@ const SpawnTool = () => {
// not every frame the mouse moves within the same cell. Matches the // not every frame the mouse moves within the same cell. Matches the
// wall / slab / curve tools. // wall / slab / curve tools.
const prev = previousSnapRef.current const prev = previousSnapRef.current
if (!prev || prev[0] !== nextX || prev[1] !== nextZ) { if (!bypassSnap && (!prev || prev[0] !== nextX || prev[1] !== nextZ)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
previousSnapRef.current = [nextX, nextZ] previousSnapRef.current = [nextX, nextZ]
} }
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
const next = getLevelLocalPosition(activeLevelId, event) const next = getLevelLocalPosition(activeLevelId, event, event.nativeEvent?.shiftKey === true)
const [existingSpawnId, ...duplicates] = getExistingSpawnIds() const [existingSpawnId, ...duplicates] = getExistingSpawnIds()
let placedId: SpawnNode['id'] let placedId: SpawnNode['id']
+2 -2
View File
@@ -43,7 +43,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
const step = getSegmentGridStep() const step = getSegmentGridStep()
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step)) const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
const [gx, gz] = resolveCursor(planPoint, { snap }) const [gx, gz] = resolveCursor(planPoint, { snap })
// Figma alignment on the actual stair footprint (Alt bypasses), // Figma alignment on the actual stair footprint (Alt bypasses alignment; Shift all snap),
// matching the 3D move tool. Publishes guides via `useAlignmentGuides`. // matching the 3D move tool. Publishes guides via `useAlignmentGuides`.
const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0) const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0)
const { point: aligned } = applyFloorplanAlignment( const { point: aligned } = applyFloorplanAlignment(
@@ -52,7 +52,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
? movingAnchors ? movingAnchors
: [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }], : [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
candidates, candidates,
{ bypass: modifiers.altKey }, { bypass: modifiers.altKey || modifiers.shiftKey },
) )
const sx = aligned[0] const sx = aligned[0]
const sz = aligned[1] const sz = aligned[1]
@@ -67,7 +67,10 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
const sx = Math.round(target.localX * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(target.localZ * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (
event.nativeEvent?.shiftKey !== true &&
(!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz)
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
+7 -5
View File
@@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
import { turbineVentDefinition } from './definition' import { turbineVentDefinition } from './definition'
import TurbineVentPreview from './preview' import TurbineVentPreview from './preview'
@@ -33,6 +33,7 @@ const TurbineVentTool = () => {
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
const [previewYaw, setPreviewYaw] = useState(0) const [previewYaw, setPreviewYaw] = useState(0)
const [previewRotation, setPreviewRotation] = useState(0)
const lastSnapRef = useRef<[number, number] | null>(null) const lastSnapRef = useRef<[number, number] | null>(null)
const previewNode = useMemo( const previewNode = useMemo(
@@ -41,9 +42,9 @@ const TurbineVentTool = () => {
...turbineVentDefinition.defaults(), ...turbineVentDefinition.defaults(),
name: 'Turbine Vent', name: 'Turbine Vent',
position: [0, 0, 0], position: [0, 0, 0],
rotation: 0, rotation: previewRotation,
}), }),
[], [previewRotation],
) )
useEffect(() => { useEffect(() => {
@@ -65,7 +66,7 @@ const TurbineVentTool = () => {
const sx = Math.round(wx * 20) / 20 const sx = Math.round(wx * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(wz * 20) / 20
const prev = lastSnapRef.current const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
@@ -76,6 +77,7 @@ const TurbineVentTool = () => {
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
setPreviewPos(worldToBuildingLocal(wx, wy, wz)) setPreviewPos(worldToBuildingLocal(wx, wy, wz))
event.stopPropagation() event.stopPropagation()
} }
@@ -95,7 +97,7 @@ const TurbineVentTool = () => {
name: 'Turbine Vent', name: 'Turbine Vent',
roofSegmentId: hit.segment.id, roofSegmentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ], position: [hit.localX, hit.localY, hit.localZ],
rotation: 0, rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment),
}) })
state.createNode(vent, hit.segment.id as AnyNodeId) state.createNode(vent, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId)
+4 -2
View File
@@ -85,11 +85,12 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const snapStep = getSegmentGridStep() const snapStep = getSegmentGridStep()
// Snap the cursor on the WORLD XZ grid (still in building-local // Snap the cursor on the WORLD XZ grid (still in building-local
// coords for the rest of the math) so a rotated building doesn't // coords for the rest of the math) so a rotated building doesn't
// pull the curve handle off the visible grid lines. // pull the curve handle off the visible grid lines.
const [snappedLocalX, snappedLocalZ] = shiftPressedRef.current const [snappedLocalX, snappedLocalZ] = bypassSnap
? [event.localPosition[0], event.localPosition[2]] ? [event.localPosition[0], event.localPosition[2]]
: snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep) : snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep)
const localX = snappedLocalX const localX = snappedLocalX
@@ -99,7 +100,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
(localX - chord.midpoint.x) * chord.normal.x + (localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y (localZ - chord.midpoint.y) * chord.normal.y
) )
const snappedOffset = shiftPressedRef.current const snappedOffset = bypassSnap
? offsetFromMidpoint ? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep) : snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset( const nextCurveOffset = normalizeWallCurveOffset(
@@ -108,6 +109,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
) )
if ( if (
!bypassSnap &&
previousCurveOffsetRef.current !== null && previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current nextCurveOffset !== previousCurveOffsetRef.current
) { ) {
+1 -1
View File
@@ -108,7 +108,7 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Set wall start / end' }, { key: 'Left click', label: 'Set wall start / end' },
{ key: 'Shift', label: 'Allow non-45° angles' }, { key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
@@ -18,7 +18,6 @@ import {
snapScalarToGrid, snapScalarToGrid,
snapWallDraftPoint, snapWallDraftPoint,
useAlignmentGuides, useAlignmentGuides,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP, WALL_GRID_STEP,
type WallPlanPoint, type WallPlanPoint,
} from '@pascal-app/editor' } from '@pascal-app/editor'
@@ -187,23 +186,22 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
// the legacy flow. // the legacy flow.
const sceneNodes = useScene.getState().nodes const sceneNodes = useScene.getState().nodes
const walls = collectLevelWalls(sceneNodes, node.id) const walls = collectLevelWalls(sceneNodes, node.id)
// Endpoint move = grid snap, never 45° from the fixed corner // Endpoint move = grid snap, never 45° from the fixed corner.
// the angle snap is for initial draft only. Shift switches to // Shift bypasses grid, magnetic, and alignment snap.
// the fine grid step for precision, matching the 3D
// `MoveWallEndpointTool`.
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
const snapped = snapWallDraftPoint({ const snapped = snapWallDraftPoint({
point: planPoint as WallPlanPoint, point: planPoint as WallPlanPoint,
walls, walls,
ignoreWallIds: [node.id], ignoreWallIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined, bypassSnap: modifiers.shiftKey,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep), magnetic: !modifiers.shiftKey,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
}) })
// Figma-style alignment on the dragged corner — snaps it onto another // Figma-style alignment on the dragged corner — snaps it onto another
// object's edge / wall face and publishes a guide. The dragged wall // object's edge / wall face and publishes a guide. The dragged wall
// and its linked siblings (which cascade with the corner) are excluded // and its linked siblings (which cascade with the corner) are excluded
// from the candidate pool. Alt is reserved for detach, NOT bypass. // from the candidate pool. Alt is reserved for detach, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, { const aligned = alignFloorplanDraftPoint(snapped, {
bypass: modifiers.shiftKey,
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)], excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
}) as WallPlanPoint }) as WallPlanPoint
@@ -28,7 +28,6 @@ import {
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
useWallSnapIndicator, useWallSnapIndicator,
WALL_FINE_GRID_STEP,
type WallPlanPoint, type WallPlanPoint,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -288,16 +287,15 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// drag by warping the endpoint onto the nearest 45° line from // drag by warping the endpoint onto the nearest 45° line from
// the fixed corner. // the fixed corner.
// //
// Shift switches to the *fine* grid step (`WALL_FINE_GRID_STEP`) // Shift is a hard snap bypass: raw endpoint position, no grid,
// for precision placement, so it can land on positions the // no magnetic wall snap, and no alignment guide snap.
// active grid would skip (e.g. 0.05m increments when the active const bypassSnap = shiftPressedRef.current || event.nativeEvent.shiftKey
// grid is 0.5m). It does NOT bypass snap.
const snapResult = snapWallDraftPointDetailed({ const snapResult = snapWallDraftPointDetailed({
point: planPoint, point: planPoint,
walls: levelWalls, walls: levelWalls,
ignoreWallIds: [nodeId], ignoreWallIds: [nodeId],
step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined, bypassSnap,
magnetic: useEditor.getState().magneticSnap, magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}) })
const snappedPoint = snapResult.point const snappedPoint = snapResult.point
@@ -308,7 +306,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// midpoint), never an empty-space bbox corner. Layered on top of the // midpoint), never an empty-space bbox corner. Layered on top of the
// grid + corner snap above; Alt is reserved for corner-detach here. // grid + corner snap above; Alt is reserved for corner-detach here.
let alignedPoint = snappedPoint let alignedPoint = snappedPoint
if (wallAlignmentCandidates.length > 0) { if (!bypassSnap && wallAlignmentCandidates.length > 0) {
const ar = resolveAlignment({ const ar = resolveAlignment({
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }], moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
candidates: wallAlignmentCandidates, candidates: wallAlignmentCandidates,
@@ -318,9 +316,12 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz] alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz]
} }
useAlignmentGuides.getState().set(ar.guides) useAlignmentGuides.getState().set(ar.guides)
} else {
useAlignmentGuides.getState().clear()
} }
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(alignedPoint[0] !== previousGridPosRef.current[0] || (alignedPoint[0] !== previousGridPosRef.current[0] ||
alignedPoint[1] !== previousGridPosRef.current[1]) alignedPoint[1] !== previousGridPosRef.current[1])
+4 -2
View File
@@ -437,6 +437,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const rawX = event.localPosition[0] const rawX = event.localPosition[0]
const rawZ = event.localPosition[2] const rawZ = event.localPosition[2]
const snapStep = getSegmentGridStep() const snapStep = getSegmentGridStep()
@@ -467,11 +468,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
if (axis) { if (axis) {
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1] const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1] const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1]
const snappedProj = shiftPressedRef.current ? rawProj : snapScalarToGrid(rawProj, snapStep) const snappedProj = bypassSnap ? rawProj : snapScalarToGrid(rawProj, snapStep)
const perpDelta = snappedProj - originalProj const perpDelta = snappedProj - originalProj
deltaX = axis[0] * perpDelta deltaX = axis[0] * perpDelta
deltaZ = axis[1] * perpDelta deltaZ = axis[1] * perpDelta
} else if (shiftPressedRef.current) { } else if (bypassSnap) {
deltaX = rawDeltaX deltaX = rawDeltaX
deltaZ = rawDeltaZ deltaZ = rawDeltaZ
} else { } else {
@@ -491,6 +492,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ] const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(constrainedGridPos[0] !== previousGridPosRef.current[0] || (constrainedGridPos[0] !== previousGridPosRef.current[0] ||
constrainedGridPos[1] !== previousGridPosRef.current[1]) constrainedGridPos[1] !== previousGridPosRef.current[1])
+41 -18
View File
@@ -26,8 +26,8 @@ import {
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
useSegmentDraftChain,
useWallSnapIndicator, useWallSnapIndicator,
WALL_FINE_GRID_STEP,
type WallPlanPoint, type WallPlanPoint,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { getSceneTheme, useViewer } from '@pascal-app/viewer'
@@ -532,6 +532,7 @@ export const WallTool: React.FC = () => {
setAxisGuide(null) setAxisGuide(null)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear() useWallSnapIndicator.getState().clear()
useSegmentDraftChain.getState().clear('wall')
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
@@ -539,20 +540,21 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls() const walls = getCurrentLevelWalls()
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Default to the active grid step; Shift switches to the fine // Default path: grid + magnetic snap, with 15° angle lock while
// step (0.05m) for precision. No 45° angle snap — we want the // drafting. Shift is a hard snap bypass: no grid, magnetic, angle,
// cursor to track grid lines in every direction. Orthogonal // or alignment snap.
// walls fall out of grid snap naturally when the start sits on const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
// a grid intersection. const angleLocked = buildingState.current === 1 && !bypassSnap
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
const bypassAlign = event.nativeEvent?.altKey === true
const snapResult = snapWallDraftPointDetailed({ const snapResult = snapWallDraftPointDetailed({
point: localPoint, point: localPoint,
walls, walls,
step, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
magnetic: useEditor.getState().magneticSnap, angleSnap: angleLocked,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}) })
gridPosition = alignPoint(snapResult.point, bypassAlign) gridPosition = alignPoint(snapResult.point, bypassAlign || angleLocked)
// Stand the magnetic beacon at the endpoint when it locked onto an // Stand the magnetic beacon at the endpoint when it locked onto an
// existing wall corner / wall point; clear it for plain grid/angle moves. // existing wall corner / wall point; clear it for plain grid/angle moves.
useWallSnapIndicator useWallSnapIndicator
@@ -579,6 +581,7 @@ export const WallTool: React.FC = () => {
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if ( if (
!bypassSnap &&
previousWallEnd && previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1]) (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
) { ) {
@@ -611,6 +614,8 @@ export const WallTool: React.FC = () => {
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (!wallPreviewRef.current) return
if (buildingState.current === 1 && event.nativeEvent.detail >= 2) { if (buildingState.current === 1 && event.nativeEvent.detail >= 2) {
stopDrafting() stopDrafting()
return return
@@ -619,16 +624,16 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls() const walls = getCurrentLevelWalls()
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
if (buildingState.current === 0) { if (buildingState.current === 0) {
const snappedStart = alignPoint( const snappedStart = alignPoint(
snapWallDraftPointDetailed({ snapWallDraftPointDetailed({
point: localClick, point: localClick,
walls, walls,
step: clickStep, bypassSnap,
magnetic: useEditor.getState().magneticSnap, magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point, }).point,
bypassAlign, bypassAlign,
) )
@@ -651,14 +656,17 @@ export const WallTool: React.FC = () => {
// `onGridMove` writes a real BoxGeometry skips that frame. // `onGridMove` writes a real BoxGeometry skips that frame.
setDraftMeasurement(null) setDraftMeasurement(null)
} else if (buildingState.current === 1) { } else if (buildingState.current === 1) {
const angleLocked = !bypassSnap
const snappedEnd = alignPoint( const snappedEnd = alignPoint(
snapWallDraftPointDetailed({ snapWallDraftPointDetailed({
point: localClick, point: localClick,
walls, walls,
step: clickStep, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
magnetic: useEditor.getState().magneticSnap, angleSnap: angleLocked,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point, }).point,
bypassAlign, bypassAlign || angleLocked,
) )
const dx = snappedEnd[0] - startingPoint.current.x const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z const dz = snappedEnd[1] - startingPoint.current.z
@@ -684,6 +692,10 @@ export const WallTool: React.FC = () => {
} }
const nextStart = createdWall.end const nextStart = createdWall.end
// Publish the resolved chain start so the 2D floor-plan draft
// chains its next segment from the same point (its own snap
// pipeline can resolve a slightly different endpoint).
useSegmentDraftChain.getState().setChainStart('wall', [nextStart[0], nextStart[1]])
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
cursorRef.current?.position.copy(startingPoint.current) cursorRef.current?.position.copy(startingPoint.current)
@@ -698,7 +710,9 @@ export const WallTool: React.FC = () => {
// BoxGeometry stays visible for a frame on top of the // BoxGeometry stays visible for a frame on top of the
// freshly-committed real wall, producing a brief // freshly-committed real wall, producing a brief
// double-paint at the new wall's position. // double-paint at the new wall's position.
if (wallPreviewRef.current) {
wallPreviewRef.current.visible = false wallPreviewRef.current.visible = false
}
setDraftMeasurement(null) setDraftMeasurement(null)
} }
} }
@@ -711,6 +725,12 @@ export const WallTool: React.FC = () => {
if (e.key === 'Shift') shiftPressed.current = false if (e.key === 'Shift') shiftPressed.current = false
} }
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
// angle lock isn't stuck off when focus returns.
const onBlur = () => {
shiftPressed.current = false
}
const onCancel = () => { const onCancel = () => {
if (buildingState.current === 1) { if (buildingState.current === 1) {
markToolCancelConsumed() markToolCancelConsumed()
@@ -723,6 +743,7 @@ export const WallTool: React.FC = () => {
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp) window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
@@ -730,8 +751,10 @@ export const WallTool: React.FC = () => {
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear() useWallSnapIndicator.getState().clear()
useSegmentDraftChain.getState().clear('wall')
} }
}, [unit]) }, [unit])
+3 -2
View File
@@ -79,8 +79,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
// Figma-style along-wall alignment first (edge-to-edge with other // Figma-style along-wall alignment first (edge-to-edge with other
// openings / wall ends), winning over the 0.5m grid snap; falls back // openings / wall ends), winning over the 0.5m grid snap; falls back
// to grid when nothing aligns. Alt bypasses; Shift drops the grid snap. // to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap.
const neighborX = modifiers.altKey const neighborX =
modifiers.altKey || modifiers.shiftKey
? null ? null
: snapLocalXToNeighbors({ : snapLocalXToNeighbors({
wall: hit.wall, wall: hit.wall,
+15 -4
View File
@@ -187,23 +187,31 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const rawLocalX = event.localPosition[0] const rawLocalX = event.localPosition[0]
const rawLocalY = event.localPosition[1] const rawLocalY = event.localPosition[1]
if (!dragAnchor || dragAnchor.wallId !== event.node.id) { if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
const bypassSnap = event.nativeEvent?.shiftKey === true
dragAnchor = { dragAnchor = {
wallId: event.node.id, wallId: event.node.id,
rawX: rawLocalX, rawX: rawLocalX,
rawY: rawLocalY, rawY: rawLocalY,
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX, startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
startY: startY:
event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY), event.node.id === original.parentId
? original.position[1]
: bypassSnap
? rawLocalY
: snapToHalf(rawLocalY),
} }
} }
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
const targetLocalY = snapToHalf(dragAnchor.startY + (rawLocalY - dragAnchor.rawY)) const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY)
const targetLocalY =
event.nativeEvent?.shiftKey === true ? targetRawLocalY : snapToHalf(targetRawLocalY)
const localX = resolveWallSlideAlignment({ const localX = resolveWallSlideAlignment({
wallNode: event.node, wallNode: event.node,
rawLocalX: targetLocalX, rawLocalX: targetLocalX,
width: movingWindowNode.width, width: movingWindowNode.width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true, bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
}) })
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
event.node, event.node,
@@ -409,7 +417,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
width: movingWindowNode.width, width: movingWindowNode.width,
height: movingWindowNode.height, height: movingWindowNode.height,
ignoreId: movingWindowNode.id, ignoreId: movingWindowNode.id,
vertical: { kind: 'free', snap: snapToHalf }, vertical: {
kind: 'free',
snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf,
},
}) })
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {

Some files were not shown because too many files have changed in this diff Show More