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:
@@ -68,7 +68,10 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
|
||||
const sx = Math.round(target.localX * 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')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
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 BoxVentPreview from './preview'
|
||||
|
||||
@@ -37,6 +37,7 @@ const BoxVentTool = () => {
|
||||
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
|
||||
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
|
||||
const [previewYaw, setPreviewYaw] = useState(0)
|
||||
const [previewRotation, setPreviewRotation] = useState(0)
|
||||
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||
|
||||
// Default-shaped preview node — matches what the commit will create.
|
||||
@@ -46,9 +47,9 @@ const BoxVentTool = () => {
|
||||
...boxVentDefinition.defaults(),
|
||||
name: 'Box Vent',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
rotation: previewRotation,
|
||||
}),
|
||||
[],
|
||||
[previewRotation],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,7 +71,7 @@ const BoxVentTool = () => {
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
@@ -81,6 +82,7 @@ const BoxVentTool = () => {
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -100,7 +102,7 @@ const BoxVentTool = () => {
|
||||
name: 'Box Vent',
|
||||
roofSegmentId: hit.segment.id,
|
||||
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.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
|
||||
@@ -93,7 +93,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
return
|
||||
}
|
||||
|
||||
const ROTATION_STEP = Math.PI / 2
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
let rotationDelta = 0
|
||||
if (event.key === 'r' || event.key === 'R') 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 rawX = Math.round(event.position[0] * 2) / 2
|
||||
const rawZ = Math.round(event.position[2] * 2) / 2
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
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]
|
||||
dragAnchorRef.current = anchor
|
||||
const gridX = originalCenter[0] + (rawX - anchor[0])
|
||||
const gridZ = originalCenter[1] + (rawZ - anchor[1])
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
|
||||
@@ -126,6 +126,7 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
levelId: ceilingLevelId,
|
||||
excludeId: ceilingId,
|
||||
altKey: context.nativeEvent?.altKey === true,
|
||||
shiftKey: context.nativeEvent?.shiftKey === true,
|
||||
}).point,
|
||||
[ceilingId, ceilingLevelId],
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ const ceilingSnapOptions = {
|
||||
excludeId: node.id,
|
||||
nodes: sceneNodes,
|
||||
altKey: modifiers.altKey,
|
||||
shiftKey: modifiers.shiftKey,
|
||||
}).point
|
||||
},
|
||||
}
|
||||
|
||||
@@ -147,10 +147,12 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (isFloorplanSourcedEvent(event)) return
|
||||
const localX = snap(event.localPosition[0])
|
||||
const localZ = snap(event.localPosition[2])
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const localX = bypassSnap ? event.localPosition[0] : snap(event.localPosition[0])
|
||||
const localZ = bypassSnap ? event.localPosition[2] : snap(event.localPosition[2])
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPosRef.current &&
|
||||
(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
|
||||
// vertices to other objects' anchors; fold the snap into the delta and
|
||||
// publish a guide. Alt bypasses.
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
// publish a guide. Alt bypasses alignment; Shift bypasses all snap.
|
||||
const bypass = event.nativeEvent?.altKey === true || bypassSnap
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)),
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'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 {
|
||||
CursorSphere,
|
||||
clearCeilingSnapFeedback,
|
||||
@@ -22,34 +29,12 @@ import { CeilingNode } from './schema'
|
||||
* Multi-click polygon drawing at the ceiling height (2.52m default)
|
||||
* with a vertical TSL-gradient connector + ground-shadow lines so the
|
||||
* 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 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 {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
|
||||
@@ -107,26 +92,38 @@ export const CeilingTool: React.FC = () => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||
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 gridZ = Math.round(rawPoint[1] * 2) / 2
|
||||
const gridPosition: [number, number] = [gridX, gridZ]
|
||||
const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ]
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
|
||||
const gridY = event.localPosition[1] + GRID_OFFSET
|
||||
const lastPoint = points[points.length - 1]
|
||||
const orthoPoint =
|
||||
shiftPressed.current || !lastPoint
|
||||
// 15° angle snap from the raw cursor (matching the 2D floorplan
|
||||
// pipeline) with the distance snapped along the ray to the grid step.
|
||||
const orthoPoint: [number, number] =
|
||||
bypassSnap || !lastPoint
|
||||
? gridPosition
|
||||
: calculateSnapPoint(lastPoint, gridPosition)
|
||||
: [
|
||||
...snapPointAlongAngleRay(
|
||||
lastPoint,
|
||||
rawPoint,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
useEditor.getState().gridSnapStep,
|
||||
),
|
||||
]
|
||||
const displayPoint = resolveCeilingPlanPointSnap({
|
||||
rawPoint,
|
||||
fallbackPoint: orthoPoint,
|
||||
levelId: currentLevelId,
|
||||
altKey: event.nativeEvent?.altKey === true,
|
||||
shiftKey: bypassSnap,
|
||||
}).point
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
if (
|
||||
!bypassSnap &&
|
||||
points.length > 0 &&
|
||||
previousSnappedPointRef.current &&
|
||||
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
||||
@@ -186,8 +183,12 @@ export const CeilingTool: React.FC = () => {
|
||||
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)
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
@@ -197,6 +198,7 @@ export const CeilingTool: React.FC = () => {
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('grid:double-click', onGridDoubleClick)
|
||||
|
||||
@@ -97,7 +97,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||
const sx = Math.round(target.localX * 20) / 20
|
||||
const sz = Math.round(target.localZ * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ const ChimneyTool = () => {
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
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(
|
||||
gridSnapped,
|
||||
movingFootprintAnchors(
|
||||
@@ -73,13 +73,13 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
|
||||
rotationY,
|
||||
),
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey },
|
||||
{ bypass: modifiers.altKey || modifiers.shiftKey },
|
||||
)
|
||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||
lastPosition = next
|
||||
|
||||
const snapKey = `${snapped[0]},${snapped[1]}`
|
||||
if (snapKey !== lastSnapKey) {
|
||||
if (!modifiers.shiftKey && snapKey !== lastSnapKey) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ const snapToGridStep = (value: number) => {
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
/** 90° steps, matching the GLB item / shelf placement rotation. */
|
||||
const ROTATION_STEP = Math.PI / 2
|
||||
/** 45° steps, matching the generic move tool's R/T rotation. */
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
@@ -124,15 +124,15 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
original: [node.position[0], node.position[2]],
|
||||
anchor: dragAnchor,
|
||||
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
|
||||
snap: snapToGridStep,
|
||||
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
|
||||
})
|
||||
dragAnchor = resolved.anchor
|
||||
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
|
||||
// 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) {
|
||||
const result = resolveAlignment({
|
||||
moving: movingFootprintAnchors(node, x, z, rotationY),
|
||||
@@ -151,7 +151,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
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.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||
|
||||
@@ -87,7 +87,8 @@ const ColumnTool = () => {
|
||||
rawZ: event.localPosition[2],
|
||||
gridStep: useEditor.getState().gridSnapStep,
|
||||
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)
|
||||
|
||||
@@ -107,7 +108,10 @@ const ColumnTool = () => {
|
||||
usePlacementPreview.getState().set({ ...previewNode, position })
|
||||
|
||||
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')
|
||||
previousSnapRef.current = [position[0], position[2]]
|
||||
}
|
||||
@@ -116,7 +120,12 @@ const ColumnTool = () => {
|
||||
const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
|
||||
const position =
|
||||
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)
|
||||
useScene.getState().createNode(column, activeLevelId)
|
||||
|
||||
@@ -66,7 +66,10 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
|
||||
const sx = Math.round(target.localX * 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')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const CupolaTool = () => {
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -83,16 +83,17 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||
// openings / wall ends); it competes with — and wins over — the 0.5m
|
||||
// grid snap. Falls back to the grid snap when nothing aligns. Alt
|
||||
// bypasses; Shift drops the grid snap for fine positioning.
|
||||
const neighborX = modifiers.altKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width: node.width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
// bypasses alignment; Shift bypasses all snap.
|
||||
const neighborX =
|
||||
modifiers.altKey || modifiers.shiftKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width: node.width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
|
||||
|
||||
|
||||
@@ -180,7 +180,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
rawLocalX: targetLocalX,
|
||||
width: movingDoorNode.width,
|
||||
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,
|
||||
|
||||
@@ -123,7 +123,8 @@ const DoorTool: React.FC = () => {
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
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)
|
||||
@@ -176,7 +177,8 @@ const DoorTool: React.FC = () => {
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
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)
|
||||
@@ -268,7 +270,8 @@ const DoorTool: React.FC = () => {
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: draftRef.current.width,
|
||||
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,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getRoofSegmentSurfaceY, type RoofSegmentNode } from '@pascal-app/core'
|
||||
import { getDormerExposedFaces } from '../csg-geometry'
|
||||
import {
|
||||
buildDormerGhostGeometry,
|
||||
dormerSupportsArch,
|
||||
@@ -41,3 +43,74 @@ describe('windowShape predicates', () => {
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type DormerNode,
|
||||
getActiveRoofHeight,
|
||||
getPitchFromActiveRoofHeight,
|
||||
getRoofSegmentSurfaceY,
|
||||
ROOF_SHAPE_DEFAULTS,
|
||||
type RoofSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -191,73 +191,61 @@ function createDormerWindowCutGeometry(
|
||||
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*
|
||||
* (not clipped by the host roof slope). "front" = mesh-local +Z,
|
||||
* "back" = mesh-local −Z (after the +π/2 yaw bake for non-shed roofs).
|
||||
* Which gable faces of a dormer have a visible window opening.
|
||||
* "front" = mesh-local +Z, "back" = mesh-local −Z (after the +π/2 yaw
|
||||
* bake for non-shed roofs).
|
||||
*
|
||||
* The criterion is window-bottom-above-slope, not wall-top-above-slope:
|
||||
* the dormer wall extends well below the window into the skirt that's
|
||||
* buried inside the roof, so checking just "does any wall poke above
|
||||
* the slope" is far too lenient — a dormer whose eave barely clears
|
||||
* the roof would pass even though the entire window (which sits inside
|
||||
* the skirt, well below the eave) is buried. Switching to the window
|
||||
* bottom collapses both the CSG window-cut decision (which calls into
|
||||
* this function in `generateDormerGeometry`) and the live render gate
|
||||
* (window-assembly.tsx) onto the right line: the window only renders
|
||||
* where it's actually visible from outside.
|
||||
* Each face centre is lifted into segment-local X *and* Z (the yaw
|
||||
* matters, and on hip hosts the end slopes fall along X) and compared
|
||||
* against the host's canonical per-type surface line via
|
||||
* `getRoofSegmentSurfaceY`, which extrapolates past the structural
|
||||
* eave instead of plateauing at the wall top — a face hanging in free
|
||||
* air past the eave keeps dropping. Gates both the CSG window-cut
|
||||
* decision (`generateDormerGeometry`) and the live render
|
||||
* (window-assembly.tsx).
|
||||
*/
|
||||
export function getDormerExposedFaces(
|
||||
dormer: DormerNode,
|
||||
hostSegment: RoofSegmentNode,
|
||||
): { front: boolean; back: boolean } {
|
||||
const halfDepth = dormer.depth / 2
|
||||
const dormerZ = dormer.position[2] ?? 0
|
||||
const dormerX = dormer.position[0] ?? 0
|
||||
const dormerY = dormer.position[1] ?? 0
|
||||
const dormerZ = dormer.position[2] ?? 0
|
||||
const rot = dormer.rotation ?? 0
|
||||
|
||||
// Gable-face centres in segment-local Z (accounts for dormer yaw).
|
||||
const frontZ = dormerZ + halfDepth * Math.cos(rot)
|
||||
const backZ = dormerZ - halfDepth * Math.cos(rot)
|
||||
// Gable-face centres in segment-local X/Z (accounts for dormer yaw).
|
||||
const faceDX = halfDepth * Math.sin(rot)
|
||||
const faceDZ = halfDepth * Math.cos(rot)
|
||||
|
||||
// Window bottom in dormer-local Y. Mirrors `getDormerSkirtWindowDims`
|
||||
// so both functions read the same window position. The window sits
|
||||
// in the skirt below the eave (dormer-local Y=0), so `centerY` is
|
||||
// typically negative; subtracting half the window height lands us at
|
||||
// the bottom edge.
|
||||
// Window centre in segment-local Y. Mirrors `getDormerSkirtWindowDims`
|
||||
// so both functions read the same window position: dormer-local Y=0
|
||||
// sits at `dormer.position[1]` and the window centre sits in the
|
||||
// skirt at -(skirtH / 2) + windowOffsetY.
|
||||
const skirtH = dormerSkirtHeight(dormer)
|
||||
const winH = Math.max(0, dormer.windowHeight ?? 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 windowCenterSegY = dormerY - skirtH / 2 + (dormer.windowOffsetY ?? 0)
|
||||
|
||||
const hostWh = hostSegment.wallHeight ?? 0.5
|
||||
const hostRh = getActiveRoofHeight(hostSegment)
|
||||
const hostDepth = hostSegment.depth ?? 4
|
||||
const clears = (faceX: number, faceZ: number): boolean =>
|
||||
windowCenterSegY - getRoofSegmentSurfaceY(hostSegment, faceX, faceZ) >
|
||||
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 {
|
||||
front: windowBottomSegY - roofHeightAtZ(frontZ) > minPokeOut,
|
||||
back: windowBottomSegY - roofHeightAtZ(backZ) > minPokeOut,
|
||||
front: clears(dormerX + faceDX, dormerZ + faceDZ),
|
||||
back: clears(dormerX - faceDX, dormerZ - faceDZ),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,12 +340,15 @@ export function generateDormerGeometry(
|
||||
dormerBrushes.innerBrush,
|
||||
SUBTRACTION,
|
||||
) as Brush
|
||||
prepareBrushForCSG(hollowWall)
|
||||
const shinDeck = csgEvaluator.evaluate(
|
||||
dormerBrushes.shinSlab,
|
||||
dormerBrushes.deckSlab,
|
||||
ADDITION,
|
||||
) as Brush
|
||||
prepareBrushForCSG(shinDeck)
|
||||
dormerSolid = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) as Brush
|
||||
prepareBrushForCSG(dormerSolid)
|
||||
hollowWall.geometry.dispose()
|
||||
shinDeck.geometry.dispose()
|
||||
|
||||
@@ -376,7 +367,9 @@ export function generateDormerGeometry(
|
||||
hostBrushes.deckSlab,
|
||||
ADDITION,
|
||||
) as Brush
|
||||
prepareBrushForCSG(wallPlusDeck)
|
||||
hostSolid = csgEvaluator.evaluate(wallPlusDeck, hostBrushes.shinSlab, ADDITION) as Brush
|
||||
prepareBrushForCSG(hostSolid)
|
||||
wallPlusDeck.geometry.dispose()
|
||||
hostBrushes.deckSlab.geometry.dispose()
|
||||
hostBrushes.shinSlab.geometry.dispose()
|
||||
@@ -393,8 +386,9 @@ export function generateDormerGeometry(
|
||||
groundBoxGeo.addGroup(0, indexCount, 0)
|
||||
computeGeometryBoundsTree(groundBoxGeo)
|
||||
const groundBrush = new Brush(groundBoxGeo, roofCsgDummyMats[0])
|
||||
groundBrush.updateMatrixWorld()
|
||||
prepareBrushForCSG(groundBrush)
|
||||
const fullTrim = csgEvaluator.evaluate(hostSolid, groundBrush, ADDITION) as Brush
|
||||
prepareBrushForCSG(fullTrim)
|
||||
hostSolid.geometry.dispose()
|
||||
groundBrush.geometry.dispose()
|
||||
hostSolid = fullTrim
|
||||
@@ -416,6 +410,7 @@ export function generateDormerGeometry(
|
||||
prepareBrushForCSG(hostSolid)
|
||||
|
||||
const trimmed = csgEvaluator.evaluate(dormerSolid, hostSolid, SUBTRACTION) as Brush
|
||||
prepareBrushForCSG(trimmed)
|
||||
dormerSolid.geometry.dispose()
|
||||
hostSolid.geometry.dispose()
|
||||
hostSolid = null
|
||||
@@ -447,8 +442,9 @@ export function generateDormerGeometry(
|
||||
cutGeo.addGroup(0, idxCount, 0)
|
||||
computeGeometryBoundsTree(cutGeo)
|
||||
const brush = new Brush(cutGeo, roofCsgDummyMats[0])
|
||||
brush.updateMatrixWorld()
|
||||
prepareBrushForCSG(brush)
|
||||
const result = csgEvaluator.evaluate(dormerSolid!, brush, SUBTRACTION) as Brush
|
||||
prepareBrushForCSG(result)
|
||||
dormerSolid!.geometry.dispose()
|
||||
brush.geometry.dispose()
|
||||
dormerSolid = result
|
||||
@@ -557,7 +553,7 @@ export function buildDormerCutShape(
|
||||
// ends up along mesh-(-Z) and the extrusion ends up along mesh-X.
|
||||
//
|
||||
// `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
|
||||
// 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.
|
||||
|
||||
@@ -118,7 +118,7 @@ export function useDormerPlacement(opts: {
|
||||
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 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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -118,12 +118,10 @@ const DormerWindowAssembly = ({
|
||||
// non-zero yaw needs to recompute exposure to know which gable
|
||||
// is now poking above the slope.
|
||||
node.rotation,
|
||||
// Window position + height feed `getDormerExposedFaces` now that
|
||||
// it's gating on window-bottom-above-slope (not wall-top-above-
|
||||
// slope) — dragging the window down via inspector or the new
|
||||
// window-height/offset handles must re-evaluate which gable
|
||||
// still has a fully-visible opening.
|
||||
node.windowHeight,
|
||||
// The window's vertical placement feeds `getDormerExposedFaces`
|
||||
// (gates on the window CENTER clearing the host slope) — dragging
|
||||
// the window down via inspector or the offset handle must
|
||||
// re-evaluate which gable still exposes the opening.
|
||||
node.windowOffsetY,
|
||||
node.wallSkirtHeight,
|
||||
],
|
||||
|
||||
@@ -67,7 +67,10 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
||||
|
||||
const sx = Math.round(target.localX * 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')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
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 EyebrowVentPreview from './preview'
|
||||
|
||||
@@ -33,6 +33,7 @@ const EyebrowVentTool = () => {
|
||||
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
|
||||
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
|
||||
const [previewYaw, setPreviewYaw] = useState(0)
|
||||
const [previewRotation, setPreviewRotation] = useState(0)
|
||||
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||
|
||||
const previewNode = useMemo(
|
||||
@@ -41,9 +42,9 @@ const EyebrowVentTool = () => {
|
||||
...eyebrowVentDefinition.defaults(),
|
||||
name: 'Eyebrow Vent',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
rotation: previewRotation,
|
||||
}),
|
||||
[],
|
||||
[previewRotation],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,7 +66,7 @@ const EyebrowVentTool = () => {
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
@@ -76,6 +77,7 @@ const EyebrowVentTool = () => {
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -95,7 +97,7 @@ const EyebrowVentTool = () => {
|
||||
name: 'Eyebrow Vent',
|
||||
roofSegmentId: hit.segment.id,
|
||||
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.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
isSegmentLongEnough,
|
||||
snapFenceDraftPoint,
|
||||
useAlignmentGuides,
|
||||
WALL_FINE_GRID_STEP,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
@@ -165,14 +164,13 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
preview: (ctx, point, modifiers) => {
|
||||
const planPoint: FencePlanPoint = [point[0], point[1]]
|
||||
// Endpoint move = grid snap only; the 45°-from-start angle snap
|
||||
// is draft-only. Shift switches to the fine grid step for
|
||||
// precision, mirroring the wall convention.
|
||||
// is draft-only. Shift is a hard snap bypass.
|
||||
const snapped = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls: ctx.levelWalls,
|
||||
fences: ctx.levelFences,
|
||||
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 /
|
||||
@@ -180,7 +178,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
// guide. The resolver connects to the NEAREST real anchor, so the dot
|
||||
// always sits on an actual point. Alt is reserved for detach.
|
||||
let aligned = snapped
|
||||
if (ctx.alignCandidates.length > 0) {
|
||||
if (!modifiers.shift && ctx.alignCandidates.length > 0) {
|
||||
const ar = resolveAlignment({
|
||||
moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }],
|
||||
candidates: ctx.alignCandidates,
|
||||
@@ -190,6 +188,8 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz]
|
||||
}
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const nextStart = ctx.endpoint === 'start' ? aligned : ctx.fixedPoint
|
||||
|
||||
@@ -89,11 +89,12 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
|
||||
const snapStep = getSegmentGridStep()
|
||||
const localX = shiftPressedRef.current
|
||||
const localX = bypassSnap
|
||||
? event.localPosition[0]
|
||||
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||
const localZ = shiftPressedRef.current
|
||||
const localZ = bypassSnap
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
|
||||
@@ -101,7 +102,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const snappedOffset = shiftPressedRef.current
|
||||
const snappedOffset = bypassSnap
|
||||
? offsetFromMidpoint
|
||||
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
@@ -110,6 +111,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
)
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousCurveOffsetRef.current !== null &&
|
||||
nextCurveOffset !== previousCurveOffsetRef.current
|
||||
) {
|
||||
|
||||
@@ -223,7 +223,7 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
||||
|
||||
toolHints: [
|
||||
{ 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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
snapFenceDraftPoint,
|
||||
snapScalarToGrid,
|
||||
useAlignmentGuides,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -159,16 +158,15 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
|
||||
// Endpoint move = grid snap only; the 45°-from-start angle
|
||||
// snap is draft-only. Shift switches to the fine grid step for
|
||||
// precision, matching the 3D fence endpoint action.
|
||||
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
|
||||
// snap is draft-only. Shift bypasses grid, magnetic, and alignment snap.
|
||||
const snapped = snapFenceDraftPoint({
|
||||
point: planPoint as FencePlanPoint,
|
||||
walls: nextWalls,
|
||||
fences: nextFences,
|
||||
ignoreFenceIds: [node.id],
|
||||
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep) as FencePlanPoint,
|
||||
bypassSnap: modifiers.shiftKey,
|
||||
magnetic: !modifiers.shiftKey,
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
|
||||
})
|
||||
// Figma-style alignment on the dragged endpoint — snaps it onto
|
||||
// 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
|
||||
// candidate pool. Alt is reserved for detach here, NOT bypass.
|
||||
const aligned = alignFloorplanDraftPoint(snapped, {
|
||||
bypass: modifiers.shiftKey,
|
||||
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
|
||||
}) as FencePlanPoint
|
||||
const nextStart = endpoint === 'start' ? aligned : fixedPoint
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'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 {
|
||||
CursorSphere,
|
||||
type FencePlanPoint,
|
||||
@@ -121,13 +128,43 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
const movingPoint = endpoint === 'start' ? liveStart : liveEnd
|
||||
|
||||
// Ticker SFX on each grid-snap step, mirroring the wall endpoint tool.
|
||||
// The action snaps the point before writing to the scene, so `movingPoint`
|
||||
// only changes in discrete grid steps — the right cadence for the click.
|
||||
// First tick just seeds the ref (no sound on mount).
|
||||
// First tick just seeds the ref (no sound on mount). The drag action receives
|
||||
// the Shift modifier through grid events, so mirror that modifier here to
|
||||
// avoid playing grid ticks while snap is bypassed.
|
||||
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(() => {
|
||||
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')
|
||||
}
|
||||
previousGridPosRef.current = movingPoint
|
||||
|
||||
@@ -193,14 +193,17 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const [localX, localZ] = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
ignoreFenceIds: [fenceId],
|
||||
bypassSnap,
|
||||
})
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPosRef.current &&
|
||||
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
WALL_FINE_GRID_STEP,
|
||||
useSegmentDraftChain,
|
||||
} from '@pascal-app/editor'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -485,6 +485,7 @@ export const FenceTool: React.FC = () => {
|
||||
buildingState.current = 0
|
||||
previewRef.current.visible = false
|
||||
setDraftMeasurement(null)
|
||||
useSegmentDraftChain.getState().clear('fence')
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
@@ -492,20 +493,29 @@ export const FenceTool: React.FC = () => {
|
||||
if (!(cursorRef.current && previewRef.current)) return
|
||||
const { walls, fences } = getCurrentLevelElements()
|
||||
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
// Default = active grid step; Shift switches to the fine step
|
||||
// (0.05m). No 45° angle snap — see `wall/tool.tsx` for rationale.
|
||||
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
// While drafting, the segment locks to 15° rays from its start
|
||||
// unless Shift is held. Shift also bypasses grid and magnetic snap.
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
const angleLocked = !bypassSnap
|
||||
const snappedLocal = alignPoint(
|
||||
snapFenceDraftPoint({ point: localPoint, walls, fences, step }),
|
||||
bypassAlign,
|
||||
snapFenceDraftPoint({
|
||||
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])
|
||||
cursorRef.current.position.copy(endingPoint.current)
|
||||
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousFenceEnd &&
|
||||
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
|
||||
) {
|
||||
@@ -532,7 +542,7 @@ export const FenceTool: React.FC = () => {
|
||||
)
|
||||
} else {
|
||||
const snappedPoint = alignPoint(
|
||||
snapFenceDraftPoint({ point: localPoint, walls, fences, step }),
|
||||
snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }),
|
||||
bypassAlign,
|
||||
)
|
||||
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 localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = alignPoint(
|
||||
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }),
|
||||
snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }),
|
||||
bypassAlign,
|
||||
)
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
@@ -563,9 +573,17 @@ export const FenceTool: React.FC = () => {
|
||||
previewRef.current.visible = true
|
||||
setDraftMeasurement(null)
|
||||
} else {
|
||||
const angleLocked = !bypassSnap
|
||||
const snappedEnd = alignPoint(
|
||||
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }),
|
||||
bypassAlign,
|
||||
snapFenceDraftPoint({
|
||||
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 dz = snappedEnd[1] - startingPoint.current.z
|
||||
@@ -582,6 +600,10 @@ export const FenceTool: React.FC = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
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])
|
||||
endingPoint.current.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
|
||||
}
|
||||
|
||||
// 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 = () => {
|
||||
if (buildingState.current === 1) {
|
||||
markToolCancelConsumed()
|
||||
@@ -611,6 +639,7 @@ export const FenceTool: React.FC = () => {
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onBlur)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
@@ -618,6 +647,8 @@ export const FenceTool: React.FC = () => {
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
useSegmentDraftChain.getState().clear('fence')
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
@@ -225,6 +225,7 @@ export function buildGutterGeometry(
|
||||
const drillBrush = new Brush(drill)
|
||||
prepareBrushForCSG(drillBrush)
|
||||
const next = csgEvaluator.evaluate(workingBrush, drillBrush, SUBTRACTION) as Brush
|
||||
prepareBrushForCSG(next)
|
||||
// Free the previous step's intermediate result (but not `merged`,
|
||||
// which is disposed once below).
|
||||
if (workingBrush.geometry !== merged) workingBrush.geometry.dispose()
|
||||
|
||||
@@ -97,7 +97,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
|
||||
const sx = Math.round(snap.eaveX * 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')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ const GutterTool = () => {
|
||||
const sx = Math.round(snap.eaveX * 20) / 20
|
||||
const sz = Math.round(snap.eaveZ * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ const ROTATE_RING_OFFSET = 0.06
|
||||
// Whole-item rotation handle — the two-headed curved arrow. `arc-resize`
|
||||
// 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
|
||||
// delta). Holding Shift snaps to 15° increments (handled generically in
|
||||
// node-arrow-handles for any `shape: 'rotate'`), matching the R/T rotate
|
||||
// step for placed items. Item rotation is stored as `[x, y, z]`; only the
|
||||
// Y component turns.
|
||||
// delta). Rotation snaps to 15° increments by default; holding Shift
|
||||
// bypasses that snap (handled generically in node-arrow-handles for any
|
||||
// `shape: 'rotate'`), matching the R/T rotate step for placed items. Item
|
||||
// rotation is stored as `[x, y, z]`; only the Y component turns.
|
||||
function itemRotateHandle(): HandleDescriptor<ItemNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
|
||||
@@ -211,16 +211,17 @@ function buildWallItemSession(
|
||||
|
||||
// Figma-style along-wall alignment (edge-to-edge with other openings /
|
||||
// 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.
|
||||
const neighborX = modifiers.altKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
// to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap.
|
||||
const neighborX =
|
||||
modifiers.altKey || modifiers.shiftKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
const snappedLocalX =
|
||||
neighborX ?? (modifiers.shiftKey ? hit.localX : Math.round(hit.localX / step) * step)
|
||||
@@ -286,7 +287,7 @@ function buildFloorItemSession(
|
||||
rotationY,
|
||||
),
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey },
|
||||
{ bypass: modifiers.altKey || modifiers.shiftKey },
|
||||
)
|
||||
|
||||
const sourceY = node.position[1]
|
||||
|
||||
@@ -80,7 +80,10 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
|
||||
const sx = Math.round(target.localX * 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')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ const RidgeVentTool = () => {
|
||||
const sx = Math.round(ridgeWorld[0] * 20) / 20
|
||||
const sz = Math.round(ridgeWorld[2] * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -79,11 +79,12 @@ export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> =
|
||||
|
||||
return {
|
||||
affectedIds: [segmentId],
|
||||
apply({ planPoint }) {
|
||||
apply({ planPoint, modifiers }) {
|
||||
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
|
||||
const delta = (currentLocal - initialLocal) * side
|
||||
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)
|
||||
lastValue = newValue
|
||||
useScene
|
||||
|
||||
@@ -36,6 +36,7 @@ type FloorPlacementAlignmentArgs = {
|
||||
gridStep: number
|
||||
candidates: Parameters<typeof resolveAlignment>[0]['candidates']
|
||||
bypassAlignment?: boolean
|
||||
bypassGrid?: boolean
|
||||
rotationY?: number
|
||||
}
|
||||
|
||||
@@ -45,18 +46,23 @@ export function getLevelLocalSnappedPosition(
|
||||
levelId: string,
|
||||
event: FloorPlacementClickTriggerEvent,
|
||||
gridStep: number,
|
||||
bypassGrid = false,
|
||||
): [number, number, number] {
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
if (!levelObject) {
|
||||
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]
|
||||
}
|
||||
|
||||
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -67,9 +73,10 @@ export function resolveAlignedFloorPlacement({
|
||||
gridStep,
|
||||
candidates,
|
||||
bypassAlignment = false,
|
||||
bypassGrid = false,
|
||||
rotationY = 0,
|
||||
}: FloorPlacementAlignmentArgs) {
|
||||
const [sx, sz] = snapPointToGrid([rawX, rawZ], gridStep)
|
||||
const [sx, sz] = bypassGrid ? [rawX, rawZ] : snapPointToGrid([rawX, rawZ], gridStep)
|
||||
let ax = sx
|
||||
let az = sz
|
||||
|
||||
|
||||
@@ -293,6 +293,7 @@ export const MoveRoofTool: React.FC<{
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
|
||||
const [rawLocalX, rawLocalZ] = computeLocal(
|
||||
@@ -312,12 +313,17 @@ export const MoveRoofTool: React.FC<{
|
||||
let [localX, localZ] = resolved.point
|
||||
|
||||
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]
|
||||
localZ = aligned[1]
|
||||
}
|
||||
|
||||
if (
|
||||
event.nativeEvent?.shiftKey !== true &&
|
||||
previousGridPosRef.current &&
|
||||
(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('normal', new Float32BufferAttribute(new Float32Array(9), 3))
|
||||
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++) {
|
||||
geometry.addGroup(0, 0, group)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function createPolygonCentroidMoveTarget(args: {
|
||||
let dx = target[0] - originalCenter[0]
|
||||
let dz = target[1] - originalCenter[1]
|
||||
|
||||
if (!modifiers.altKey && candidates.length > 0) {
|
||||
if (!(modifiers.altKey || modifiers.shiftKey) && candidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: polygonAnchors(id, translatePolygon(originalPolygon, dx, dz)),
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -137,3 +137,15 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni
|
||||
const m = new THREE.Matrix4().makeBasis(right, normal, forward)
|
||||
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 { buildOpeningCutoutGeometry, hasFlatOpeningCutoutBottom } from '@pascal-app/viewer'
|
||||
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
|
||||
* (`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
|
||||
* 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
|
||||
* wall system's own cutout pipeline.
|
||||
*/
|
||||
export function buildRoofWallOpeningCut(
|
||||
node: RoofWallOpening,
|
||||
node: DoorNode | WindowNode,
|
||||
hostSegment: RoofSegmentNode,
|
||||
): THREE.BufferGeometry | 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
|
||||
// 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 bottomPad = bottom < 0.005 ? 0.02 : 0
|
||||
const bottomPad = bottom < 0.005 && hasFlatOpeningCutoutBottom(node) ? 0.02 : 0
|
||||
|
||||
const center = roofFacePointToSegment(hostSegment, node.roofFace, [
|
||||
node.position[0],
|
||||
@@ -42,9 +40,35 @@ export function buildRoofWallOpeningCut(
|
||||
])
|
||||
const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace)
|
||||
|
||||
const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth)
|
||||
geo.translate(0, -bottomPad / 2, 0)
|
||||
const geo = buildCutGeometry(node, wallThickness, depth, bottomPad)
|
||||
geo.rotateY(yaw)
|
||||
geo.translate(center[0], center[1], center[2])
|
||||
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
|
||||
* 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
|
||||
* given `width`). `bypass` (Alt) disables alignment.
|
||||
* given `width`). `bypass` disables alignment; `bypassSnap` also skips the
|
||||
* half-metre fallback.
|
||||
*/
|
||||
export function resolveWallSlideAlignment(args: {
|
||||
wallNode: WallNode
|
||||
@@ -29,9 +30,10 @@ export function resolveWallSlideAlignment(args: {
|
||||
width: number
|
||||
candidates: readonly AlignmentAnchor[]
|
||||
bypass: boolean
|
||||
bypassSnap?: boolean
|
||||
}): number {
|
||||
const { wallNode, rawLocalX, width, candidates, bypass } = args
|
||||
const base = snapToHalf(rawLocalX)
|
||||
const { wallNode, rawLocalX, width, candidates, bypass, bypassSnap = false } = args
|
||||
const base = bypassSnap ? rawLocalX : snapToHalf(rawLocalX)
|
||||
if (bypass || candidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return base
|
||||
|
||||
@@ -66,7 +66,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
|
||||
// Figma-style alignment layered on the grid snap — the shelf footprint
|
||||
// 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(
|
||||
gridSnapped,
|
||||
movingFootprintAnchors(
|
||||
@@ -76,7 +76,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
originalRotationY,
|
||||
),
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey },
|
||||
{ bypass: modifiers.altKey || modifiers.shiftKey },
|
||||
)
|
||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||
lastPosition = next
|
||||
@@ -85,7 +85,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
// and the placement coordinators. Item / slab / wall flows fire
|
||||
// the same cue, so the shelf following along is the expected UX.
|
||||
const snapKey = `${snapped[0]},${snapped[1]}`
|
||||
if (snapKey !== lastSnapKey) {
|
||||
if (!modifiers.shiftKey && snapKey !== lastSnapKey) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ const ShelfTool = () => {
|
||||
rawZ: event.localPosition[2],
|
||||
gridStep: useEditor.getState().gridSnapStep,
|
||||
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)
|
||||
|
||||
@@ -97,7 +98,10 @@ const ShelfTool = () => {
|
||||
lastCursorRef.current = position
|
||||
|
||||
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')
|
||||
previousSnapRef.current = [position[0], position[2]]
|
||||
}
|
||||
@@ -110,7 +114,12 @@ const ShelfTool = () => {
|
||||
// first). Both paths apply the same grid snap.
|
||||
const position =
|
||||
lastCursorRef.current ??
|
||||
getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep)
|
||||
getLevelLocalSnappedPosition(
|
||||
activeLevelId,
|
||||
event,
|
||||
useEditor.getState().gridSnapStep,
|
||||
event.nativeEvent?.shiftKey === true,
|
||||
)
|
||||
const shelf = ShelfNode.parse({
|
||||
...shelfDefinition.defaults(),
|
||||
name: 'Shelf',
|
||||
|
||||
@@ -67,14 +67,5 @@ export function buildFrameGeometry({
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
const onRoofMove = (event: RoofEvent) => {
|
||||
const sx = Math.round(event.position[0] * 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')
|
||||
lastSnapX = sx
|
||||
lastSnapZ = sz
|
||||
|
||||
@@ -628,8 +628,7 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
|
||||
|
||||
const glassMaterial = useMemo(() => {
|
||||
// Untextured glass (and textures-off mode) takes the themed 'glazing'
|
||||
// role material — already DoubleSide + semi-transparent, and shared
|
||||
// from the cache, so it must not be mutated.
|
||||
// role material from the shared cache, so it must not be mutated.
|
||||
if (!textures || (!node.glassMaterial && !node.glassMaterialPreset)) {
|
||||
return createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme)
|
||||
}
|
||||
@@ -638,7 +637,6 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
|
||||
: (createMaterialFromPresetRef(node.glassMaterialPreset, shading) ??
|
||||
defaultGlassMaterial.clone())
|
||||
if (mat && typeof mat === 'object') {
|
||||
;(mat as THREE.Material).side = THREE.DoubleSide
|
||||
if (mat instanceof THREE.MeshPhysicalMaterial) {
|
||||
mat.thickness = glassThickness
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ const SkylightTool = () => {
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
|
||||
levelId: slabLevelId,
|
||||
excludeId: slabId,
|
||||
altKey: context.nativeEvent?.altKey === true,
|
||||
shiftKey: context.nativeEvent?.shiftKey === true,
|
||||
}).point,
|
||||
[slabId, slabLevelId],
|
||||
)
|
||||
|
||||
@@ -166,7 +166,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
handles: slabHandles,
|
||||
|
||||
// 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'),
|
||||
|
||||
// Stage D — all four slab drag-affordances live in this folder.
|
||||
|
||||
@@ -37,6 +37,7 @@ const slabSnapOptions = {
|
||||
excludeId: node.id,
|
||||
nodes: sceneNodes,
|
||||
altKey: modifiers.altKey,
|
||||
shiftKey: modifiers.shiftKey,
|
||||
}).point
|
||||
},
|
||||
}
|
||||
|
||||
@@ -167,14 +167,17 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (isFloorplanSourcedEvent(event)) return
|
||||
const gridStep = getSegmentGridStep()
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const [localX, localZ] = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
bypassSnap,
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
|
||||
})
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPosRef.current &&
|
||||
(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
|
||||
// vertices to other objects' anchors; fold the snap into the delta and
|
||||
// publish a guide. Alt bypasses.
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
// publish a guide. Alt bypasses alignment; Shift bypasses all snap.
|
||||
const bypass = event.nativeEvent?.altKey === true || bypassSnap
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignmentForActiveBuilding({
|
||||
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'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 {
|
||||
CursorSphere,
|
||||
clearSlabSnapFeedback,
|
||||
@@ -20,7 +27,7 @@ import { SlabNode } from './schema'
|
||||
*
|
||||
* Multi-click polygon drawing: each click adds a vertex; clicking near
|
||||
* 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
|
||||
* 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
|
||||
|
||||
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 {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
|
||||
@@ -90,24 +75,36 @@ export const SlabTool: React.FC = () => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
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 gridZ = Math.round(rawPoint[1] * 2) / 2
|
||||
const gridPosition: [number, number] = [gridX, gridZ]
|
||||
const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ]
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
const lastPoint = points[points.length - 1]
|
||||
const orthoPoint =
|
||||
shiftPressed.current || !lastPoint
|
||||
// 15° angle snap from the raw cursor (matching the 2D floorplan
|
||||
// pipeline) with the distance snapped along the ray to the grid step.
|
||||
const orthoPoint: [number, number] =
|
||||
bypassSnap || !lastPoint
|
||||
? gridPosition
|
||||
: calculateSnapPoint(lastPoint, gridPosition)
|
||||
: [
|
||||
...snapPointAlongAngleRay(
|
||||
lastPoint,
|
||||
rawPoint,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
useEditor.getState().gridSnapStep,
|
||||
),
|
||||
]
|
||||
const displayPoint = resolveSlabPlanPointSnap({
|
||||
rawPoint,
|
||||
fallbackPoint: orthoPoint,
|
||||
levelId: currentLevelId,
|
||||
altKey: event.nativeEvent?.altKey === true,
|
||||
shiftKey: bypassSnap,
|
||||
}).point
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
if (
|
||||
!bypassSnap &&
|
||||
points.length > 0 &&
|
||||
previousSnappedPointRef.current &&
|
||||
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
||||
@@ -163,8 +160,12 @@ export const SlabTool: React.FC = () => {
|
||||
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)
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
@@ -174,6 +175,7 @@ export const SlabTool: React.FC = () => {
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('grid:double-click', onGridDoubleClick)
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
|
||||
const sx = Math.round(target.localX * 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')
|
||||
lastSnapX = sx
|
||||
lastSnapZ = sz
|
||||
|
||||
@@ -72,7 +72,7 @@ const SolarPanelTool = () => {
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -22,15 +22,23 @@ function getExistingSpawnIds() {
|
||||
.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)
|
||||
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])
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
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
|
||||
// event.localPosition directly (already building-local) with the
|
||||
// same half-meter snap the legacy tool uses.
|
||||
const nextX = roundToHalf(event.localPosition[0])
|
||||
const nextZ = roundToHalf(event.localPosition[2])
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
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 previewNode = SpawnNode.parse({
|
||||
name: 'Spawn Point',
|
||||
@@ -72,14 +81,14 @@ const SpawnTool = () => {
|
||||
// not every frame the mouse moves within the same cell. Matches the
|
||||
// wall / slab / curve tools.
|
||||
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')
|
||||
previousSnapRef.current = [nextX, nextZ]
|
||||
}
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const next = getLevelLocalPosition(activeLevelId, event)
|
||||
const next = getLevelLocalPosition(activeLevelId, event, event.nativeEvent?.shiftKey === true)
|
||||
const [existingSpawnId, ...duplicates] = getExistingSpawnIds()
|
||||
let placedId: SpawnNode['id']
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
|
||||
const step = getSegmentGridStep()
|
||||
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
|
||||
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`.
|
||||
const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0)
|
||||
const { point: aligned } = applyFloorplanAlignment(
|
||||
@@ -52,7 +52,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
|
||||
? movingAnchors
|
||||
: [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey },
|
||||
{ bypass: modifiers.altKey || modifiers.shiftKey },
|
||||
)
|
||||
const sx = aligned[0]
|
||||
const sz = aligned[1]
|
||||
|
||||
@@ -67,7 +67,10 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
||||
|
||||
const sx = Math.round(target.localX * 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')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
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 TurbineVentPreview from './preview'
|
||||
|
||||
@@ -33,6 +33,7 @@ const TurbineVentTool = () => {
|
||||
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
|
||||
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
|
||||
const [previewYaw, setPreviewYaw] = useState(0)
|
||||
const [previewRotation, setPreviewRotation] = useState(0)
|
||||
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||
|
||||
const previewNode = useMemo(
|
||||
@@ -41,9 +42,9 @@ const TurbineVentTool = () => {
|
||||
...turbineVentDefinition.defaults(),
|
||||
name: 'Turbine Vent',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
rotation: previewRotation,
|
||||
}),
|
||||
[],
|
||||
[previewRotation],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,7 +66,7 @@ const TurbineVentTool = () => {
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
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')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
@@ -76,6 +77,7 @@ const TurbineVentTool = () => {
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -95,7 +97,7 @@ const TurbineVentTool = () => {
|
||||
name: 'Turbine Vent',
|
||||
roofSegmentId: hit.segment.id,
|
||||
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.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
|
||||
@@ -85,11 +85,12 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
|
||||
const snapStep = getSegmentGridStep()
|
||||
// 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
|
||||
// pull the curve handle off the visible grid lines.
|
||||
const [snappedLocalX, snappedLocalZ] = shiftPressedRef.current
|
||||
const [snappedLocalX, snappedLocalZ] = bypassSnap
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep)
|
||||
const localX = snappedLocalX
|
||||
@@ -99,7 +100,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const snappedOffset = shiftPressedRef.current
|
||||
const snappedOffset = bypassSnap
|
||||
? offsetFromMidpoint
|
||||
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
@@ -108,6 +109,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
)
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousCurveOffsetRef.current !== null &&
|
||||
nextCurveOffset !== previousCurveOffsetRef.current
|
||||
) {
|
||||
|
||||
@@ -108,7 +108,7 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
|
||||
toolHints: [
|
||||
{ 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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
snapScalarToGrid,
|
||||
snapWallDraftPoint,
|
||||
useAlignmentGuides,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
@@ -187,23 +186,22 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
|
||||
// the legacy flow.
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const walls = collectLevelWalls(sceneNodes, node.id)
|
||||
// Endpoint move = grid snap, never 45° from the fixed corner —
|
||||
// the angle snap is for initial draft only. Shift switches to
|
||||
// the fine grid step for precision, matching the 3D
|
||||
// `MoveWallEndpointTool`.
|
||||
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
|
||||
// Endpoint move = grid snap, never 45° from the fixed corner.
|
||||
// Shift bypasses grid, magnetic, and alignment snap.
|
||||
const snapped = snapWallDraftPoint({
|
||||
point: planPoint as WallPlanPoint,
|
||||
walls,
|
||||
ignoreWallIds: [node.id],
|
||||
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep),
|
||||
bypassSnap: modifiers.shiftKey,
|
||||
magnetic: !modifiers.shiftKey,
|
||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
|
||||
})
|
||||
// Figma-style alignment on the dragged corner — snaps it onto another
|
||||
// object's edge / wall face and publishes a guide. The dragged wall
|
||||
// and its linked siblings (which cascade with the corner) are excluded
|
||||
// from the candidate pool. Alt is reserved for detach, NOT bypass.
|
||||
const aligned = alignFloorplanDraftPoint(snapped, {
|
||||
bypass: modifiers.shiftKey,
|
||||
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
|
||||
}) as WallPlanPoint
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useWallSnapIndicator,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
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
|
||||
// the fixed corner.
|
||||
//
|
||||
// Shift switches to the *fine* grid step (`WALL_FINE_GRID_STEP`)
|
||||
// for precision placement, so it can land on positions the
|
||||
// active grid would skip (e.g. 0.05m increments when the active
|
||||
// grid is 0.5m). It does NOT bypass snap.
|
||||
// Shift is a hard snap bypass: raw endpoint position, no grid,
|
||||
// no magnetic wall snap, and no alignment guide snap.
|
||||
const bypassSnap = shiftPressedRef.current || event.nativeEvent.shiftKey
|
||||
const snapResult = snapWallDraftPointDetailed({
|
||||
point: planPoint,
|
||||
walls: levelWalls,
|
||||
ignoreWallIds: [nodeId],
|
||||
step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
})
|
||||
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
|
||||
// grid + corner snap above; Alt is reserved for corner-detach here.
|
||||
let alignedPoint = snappedPoint
|
||||
if (wallAlignmentCandidates.length > 0) {
|
||||
if (!bypassSnap && wallAlignmentCandidates.length > 0) {
|
||||
const ar = resolveAlignment({
|
||||
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
|
||||
candidates: wallAlignmentCandidates,
|
||||
@@ -318,9 +316,12 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz]
|
||||
}
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPosRef.current &&
|
||||
(alignedPoint[0] !== previousGridPosRef.current[0] ||
|
||||
alignedPoint[1] !== previousGridPosRef.current[1])
|
||||
|
||||
@@ -437,6 +437,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
const snapStep = getSegmentGridStep()
|
||||
@@ -467,11 +468,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
if (axis) {
|
||||
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * 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
|
||||
deltaX = axis[0] * perpDelta
|
||||
deltaZ = axis[1] * perpDelta
|
||||
} else if (shiftPressedRef.current) {
|
||||
} else if (bypassSnap) {
|
||||
deltaX = rawDeltaX
|
||||
deltaZ = rawDeltaZ
|
||||
} else {
|
||||
@@ -491,6 +492,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousGridPosRef.current &&
|
||||
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
|
||||
constrainedGridPos[1] !== previousGridPosRef.current[1])
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useSegmentDraftChain,
|
||||
useWallSnapIndicator,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
@@ -532,6 +532,7 @@ export const WallTool: React.FC = () => {
|
||||
setAxisGuide(null)
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
useSegmentDraftChain.getState().clear('wall')
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
@@ -539,20 +540,21 @@ export const WallTool: React.FC = () => {
|
||||
|
||||
const walls = getCurrentLevelWalls()
|
||||
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
// Default to the active grid step; Shift switches to the fine
|
||||
// step (0.05m) for precision. No 45° angle snap — we want the
|
||||
// cursor to track grid lines in every direction. Orthogonal
|
||||
// walls fall out of grid snap naturally when the start sits on
|
||||
// a grid intersection.
|
||||
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
// Default path: grid + magnetic snap, with 15° angle lock while
|
||||
// drafting. Shift is a hard snap bypass: no grid, magnetic, angle,
|
||||
// or alignment snap.
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const angleLocked = buildingState.current === 1 && !bypassSnap
|
||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
||||
const snapResult = snapWallDraftPointDetailed({
|
||||
point: localPoint,
|
||||
walls,
|
||||
step,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||
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
|
||||
// existing wall corner / wall point; clear it for plain grid/angle moves.
|
||||
useWallSnapIndicator
|
||||
@@ -579,6 +581,7 @@ export const WallTool: React.FC = () => {
|
||||
|
||||
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
||||
if (
|
||||
!bypassSnap &&
|
||||
previousWallEnd &&
|
||||
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
||||
) {
|
||||
@@ -611,6 +614,8 @@ export const WallTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!wallPreviewRef.current) return
|
||||
|
||||
if (buildingState.current === 1 && event.nativeEvent.detail >= 2) {
|
||||
stopDrafting()
|
||||
return
|
||||
@@ -619,16 +624,16 @@ export const WallTool: React.FC = () => {
|
||||
const walls = getCurrentLevelWalls()
|
||||
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
|
||||
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = alignPoint(
|
||||
snapWallDraftPointDetailed({
|
||||
point: localClick,
|
||||
walls,
|
||||
step: clickStep,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
}).point,
|
||||
bypassAlign,
|
||||
)
|
||||
@@ -651,14 +656,17 @@ export const WallTool: React.FC = () => {
|
||||
// `onGridMove` writes a real BoxGeometry skips that frame.
|
||||
setDraftMeasurement(null)
|
||||
} else if (buildingState.current === 1) {
|
||||
const angleLocked = !bypassSnap
|
||||
const snappedEnd = alignPoint(
|
||||
snapWallDraftPointDetailed({
|
||||
point: localClick,
|
||||
walls,
|
||||
step: clickStep,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||
angleSnap: angleLocked,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
}).point,
|
||||
bypassAlign,
|
||||
bypassAlign || angleLocked,
|
||||
)
|
||||
const dx = snappedEnd[0] - startingPoint.current.x
|
||||
const dz = snappedEnd[1] - startingPoint.current.z
|
||||
@@ -684,6 +692,10 @@ export const WallTool: React.FC = () => {
|
||||
}
|
||||
|
||||
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])
|
||||
endingPoint.current.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
|
||||
// freshly-committed real wall, producing a brief
|
||||
// double-paint at the new wall's position.
|
||||
wallPreviewRef.current.visible = false
|
||||
if (wallPreviewRef.current) {
|
||||
wallPreviewRef.current.visible = false
|
||||
}
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
}
|
||||
@@ -711,6 +725,12 @@ export const WallTool: React.FC = () => {
|
||||
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 = () => {
|
||||
if (buildingState.current === 1) {
|
||||
markToolCancelConsumed()
|
||||
@@ -723,6 +743,7 @@ export const WallTool: React.FC = () => {
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onBlur)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
@@ -730,8 +751,10 @@ export const WallTool: React.FC = () => {
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
useSegmentDraftChain.getState().clear('wall')
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
|
||||
@@ -79,16 +79,17 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
|
||||
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||
// openings / wall ends), winning over the 0.5m grid snap; falls back
|
||||
// to grid when nothing aligns. Alt bypasses; Shift drops the grid snap.
|
||||
const neighborX = modifiers.altKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width: node.width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
// to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap.
|
||||
const neighborX =
|
||||
modifiers.altKey || modifiers.shiftKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width: node.width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
hit.wall,
|
||||
|
||||
@@ -187,23 +187,31 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const rawLocalX = event.localPosition[0]
|
||||
const rawLocalY = event.localPosition[1]
|
||||
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
dragAnchor = {
|
||||
wallId: event.node.id,
|
||||
rawX: rawLocalX,
|
||||
rawY: rawLocalY,
|
||||
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
|
||||
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 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({
|
||||
wallNode: event.node,
|
||||
rawLocalX: targetLocalX,
|
||||
width: movingWindowNode.width,
|
||||
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,
|
||||
@@ -409,7 +417,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
width: movingWindowNode.width,
|
||||
height: movingWindowNode.height,
|
||||
ignoreId: movingWindowNode.id,
|
||||
vertical: { kind: 'free', snap: snapToHalf },
|
||||
vertical: {
|
||||
kind: 'free',
|
||||
snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf,
|
||||
},
|
||||
})
|
||||
|
||||
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
|
||||
|
||||
@@ -128,9 +128,13 @@ const WindowTool: React.FC = () => {
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const localY =
|
||||
event.nativeEvent?.shiftKey === true
|
||||
? event.localPosition[1]
|
||||
: snapToHalf(event.localPosition[1])
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
@@ -183,9 +187,13 @@ const WindowTool: React.FC = () => {
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const localY =
|
||||
event.nativeEvent?.shiftKey === true
|
||||
? event.localPosition[1]
|
||||
: snapToHalf(event.localPosition[1])
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
@@ -277,9 +285,13 @@ const WindowTool: React.FC = () => {
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: draftRef.current.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const localY =
|
||||
event.nativeEvent?.shiftKey === true
|
||||
? event.localPosition[1]
|
||||
: snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
@@ -367,7 +379,10 @@ const WindowTool: React.FC = () => {
|
||||
width: draftRef.current?.width ?? 1.5,
|
||||
height: draftRef.current?.height ?? 1.5,
|
||||
ignoreId: draftRef.current?.id,
|
||||
vertical: { kind: 'free', snap: snapToHalf },
|
||||
vertical: {
|
||||
kind: 'free',
|
||||
snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf,
|
||||
},
|
||||
})
|
||||
|
||||
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
|
||||
|
||||
Reference in New Issue
Block a user