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