feat: add ceiling wall snap feedback

This commit is contained in:
Aymeric Rabot
2026-06-09 17:50:01 -04:00
parent f3c1407cae
commit 92078741cd
13 changed files with 563 additions and 94 deletions
@@ -64,6 +64,7 @@ import {
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { import {
alignFloorplanDraftPoint, alignFloorplanDraftPoint,
buildFloorplanItemEntry, buildFloorplanItemEntry,
@@ -8425,17 +8426,22 @@ export function FloorplanPanel() {
if (isCeilingBuildActive) { if (isCeilingBuildActive) {
// Polygon vertex: grid (snapToHalf) + optional 45° angle snap from // Polygon vertex: grid (snapToHalf) + optional 45° angle snap from
// the previous vertex. Alignment runs only when angle snap is OFF // the previous vertex. Wall magnetic snap may still win, while
// (first vertex, or Shift held) — when the angle is being locked, // generic alignment runs only when angle snap is OFF (first vertex,
// pulling the vertex sideways would break it. // or Shift held) so it does not pull a locked angle sideways.
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1], start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap, angleSnap,
}) })
if (angleSnap) useAlignmentGuides.getState().clear() const snappedPoint = resolveCeilingPlanPointSnap({
else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
emitFloorplanGridEvent('move', snappedPoint, event) emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) => setCursorPoint((previousPoint) =>
@@ -8676,6 +8682,7 @@ export function FloorplanPanel() {
isPolygonBuildActive, isPolygonBuildActive,
isRoofBuildActive, isRoofBuildActive,
isWallBuildActive, isWallBuildActive,
levelId,
publishFloorplanNavigationPose, publishFloorplanNavigationPose,
smoothFloorplanNavigationView, smoothFloorplanNavigationView,
referenceScaleDraft, referenceScaleDraft,
@@ -8934,6 +8941,7 @@ export function FloorplanPanel() {
isRoofBuildActive, isRoofBuildActive,
isWallBuildActive, isWallBuildActive,
isZoneBuildActive, isZoneBuildActive,
levelId,
roofDraftStart, roofDraftStart,
setCursorPoint, setCursorPoint,
setFenceDraftEnd, setFenceDraftEnd,
@@ -9110,25 +9118,33 @@ export function FloorplanPanel() {
return return
} }
const snappedPoint = snapPolygonDraftPoint({ const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, angleSnap,
}) })
if (isCeilingBuildActive) { if (isCeilingBuildActive) {
emitFloorplanGridEvent('double-click', planPoint, event) const snappedPoint = resolveCeilingPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
emitFloorplanGridEvent('double-click', snappedPoint, event)
handleCeilingPlacementConfirm(snappedPoint) handleCeilingPlacementConfirm(snappedPoint)
return return
} }
if (isZoneBuildActive) { if (isZoneBuildActive) {
handleZonePlacementConfirm(snappedPoint) handleZonePlacementConfirm(fallbackPoint)
} else { } else {
// Slab is registry-driven: forward the double-click so the 3D tool // Slab is registry-driven: forward the double-click so the 3D tool
// commits the node (zone has no registry tool, so it commits locally). // commits the node (zone has no registry tool, so it commits locally).
emitFloorplanGridEvent('double-click', planPoint, event) emitFloorplanGridEvent('double-click', planPoint, event)
handleSlabPlacementConfirm(snappedPoint) handleSlabPlacementConfirm(fallbackPoint)
} }
}, },
[ [
@@ -9142,6 +9158,7 @@ export function FloorplanPanel() {
isPolygonDraftBuildActive, isPolygonDraftBuildActive,
isRoofBuildActive, isRoofBuildActive,
isZoneBuildActive, isZoneBuildActive,
levelId,
shiftPressed, shiftPressed,
], ],
) )
@@ -2,6 +2,7 @@
import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core' import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react' import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { import {
@@ -51,6 +52,7 @@ type UseFloorplanBackgroundPlacementArgs = {
isRoofBuildActive: boolean isRoofBuildActive: boolean
isWallBuildActive: boolean isWallBuildActive: boolean
isZoneBuildActive: boolean isZoneBuildActive: boolean
levelId: string | null
roofDraftStart: WallPlanPoint | null roofDraftStart: WallPlanPoint | null
setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>> setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>> setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
@@ -107,6 +109,7 @@ export function useFloorplanBackgroundPlacement({
isRoofBuildActive, isRoofBuildActive,
isWallBuildActive, isWallBuildActive,
isZoneBuildActive, isZoneBuildActive,
levelId,
roofDraftStart, roofDraftStart,
setCursorPoint, setCursorPoint,
setFenceDraftEnd, setFenceDraftEnd,
@@ -149,17 +152,22 @@ export function useFloorplanBackgroundPlacement({
if (isCeilingBuildActive) { if (isCeilingBuildActive) {
// 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. Skip when angle snap // the placed point matches what the user saw. Wall magnetic snap may
// owns the vertex (matches the move branch). // still win; generic alignment is skipped when angle snap owns the
// vertex (matches the move branch).
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1], start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap, angleSnap,
}) })
if (!angleSnap) { const snappedPoint = resolveCeilingPlanPointSnap({
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) rawPoint: planPoint,
} fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
handleCeilingPlacementPoint(snappedPoint) handleCeilingPlacementPoint(snappedPoint)
@@ -322,6 +330,7 @@ export function useFloorplanBackgroundPlacement({
isRoofBuildActive, isRoofBuildActive,
isWallBuildActive, isWallBuildActive,
isZoneBuildActive, isZoneBuildActive,
levelId,
roofDraftStart, roofDraftStart,
setCursorPoint, setCursorPoint,
setFenceDraftEnd, setFenceDraftEnd,
@@ -1,10 +1,23 @@
'use client' 'use client'
import { sceneRegistry } from '@pascal-app/core' import {
type AnyNode,
type AnyNodeId,
DEFAULT_WALL_HEIGHT,
getWallCurveFrameAt,
getWallCurveLength,
getWallThickness,
isCurvedWall,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor' import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { memo, useRef } from 'react' import { memo, useMemo, useRef } from 'react'
import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three' import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
@@ -34,6 +47,14 @@ const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
const BEACON_RADIUS = 0.018 // world-meter radius of the pillar const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
const MARKER = 0.13 // world-meter base size of the floor glyph const MARKER = 0.13 // world-meter base size of the floor glyph
const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid
const WALL_TOP_HIGHLIGHT_LIFT = 0.035
const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018
const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14
const WALL_TOP_GLOW_HEIGHT = 0.026
const WALL_TOP_GLOW_OVERHANG = 0.36
const WALL_TOP_END_OVERHANG = 0.08
const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45
const NO_RAYCAST = () => null
// Shared resources — one material + unit geometries, so snap churn during a // Shared resources — one material + unit geometries, so snap churn during a
// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer). // drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
@@ -45,17 +66,41 @@ const beaconMaterial = new MeshBasicNodeMaterial({
transparent: true, transparent: true,
opacity: 0.9, opacity: 0.9,
}) })
const wallTopHighlightMaterial = new MeshBasicNodeMaterial({
color: BEACON_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
opacity: 0.88,
})
const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({
color: BEACON_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
opacity: 0.26,
})
const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8) const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8)
// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as // Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as
// a flat plate; circles/triangles lie flat via an X rotation at the mesh. // a flat plate; circles/triangles lie flat via an X rotation at the mesh.
const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1) const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1)
const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1)
const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3) const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3)
const CIRCLE_GEOMETRY = new CircleGeometry(1, 28) const CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
const point = useWallSnapIndicator((s) => s.point) const point = useWallSnapIndicator((s) => s.point)
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
const nodes = useScene((s) => s.nodes)
const groupRef = useRef<Group>(null) const groupRef = useRef<Group>(null)
const highlightedWalls = useMemo(() => {
if (!point?.wallIds?.length) return []
return point.wallIds
.map((wallId) => nodes[wallId as AnyNodeId])
.filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false)
}, [nodes, point?.wallIds])
// Track the active level's building-local Y each frame so the beacon stands // Track the active level's building-local Y each frame so the beacon stands
// on the floor being edited, not the building base — same source the // on the floor being edited, not the building base — same source the
@@ -70,6 +115,9 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
if (!point) return null if (!point) return null
return ( return (
<group ref={groupRef}> <group ref={groupRef}>
{highlightedWalls.map((wall) => (
<WallTopHighlight key={wall.id} nodes={nodes} wall={wall} />
))}
<mesh <mesh
geometry={PILLAR_GEOMETRY} geometry={PILLAR_GEOMETRY}
layers={EDITOR_LAYER} layers={EDITOR_LAYER}
@@ -82,6 +130,103 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
) )
}) })
type WallTopHighlightSegment = {
angle: number
center: [number, number]
length: number
}
function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
const levelId = resolveLevelId(wall, nodes as Record<string, AnyNode>)
const slabElevation = spatialGridManager.getSlabElevationForWall(
levelId,
wall.start,
wall.end,
wall.curveOffset ?? 0,
wall.thickness,
)
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT
}
function buildHighlightSegment(start: [number, number], end: [number, number]) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 1e-6) return null
return {
angle: -Math.atan2(dz, dx),
center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number],
length,
}
}
function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] {
if (!isCurvedWall(wall)) {
const segment = buildHighlightSegment(wall.start, wall.end)
return segment ? [segment] : []
}
const sampleCount = Math.max(
8,
Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH),
)
const segments: WallTopHighlightSegment[] = []
let previous = getWallCurveFrameAt(wall, 0).point
for (let index = 1; index <= sampleCount; index += 1) {
const current = getWallCurveFrameAt(wall, index / sampleCount).point
const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y])
if (segment) segments.push(segment)
previous = current
}
return segments
}
function WallTopHighlight({
nodes,
wall,
}: {
nodes: Readonly<Record<string, AnyNode>>
wall: WallNode
}) {
const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall])
const y = getWallTopY(wall, nodes)
const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24)
const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42)
return (
<>
{segments.map((segment, index) => (
<group key={`${wall.id}:${index}`}>
<mesh
frustumCulled={false}
geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
layers={EDITOR_LAYER}
material={wallTopHighlightGlowMaterial}
position={[segment.center[0], y - 0.003, segment.center[1]]}
raycast={NO_RAYCAST}
renderOrder={1003}
rotation={[0, segment.angle, 0]}
scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_GLOW_HEIGHT, glowWidth]}
/>
<mesh
frustumCulled={false}
geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
layers={EDITOR_LAYER}
material={wallTopHighlightMaterial}
position={[segment.center[0], y + 0.002, segment.center[1]]}
raycast={NO_RAYCAST}
renderOrder={1004}
rotation={[0, segment.angle, 0]}
scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_HIGHLIGHT_HEIGHT, width]}
/>
</group>
))}
</>
)
}
/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */ /** Floor glyph whose shape encodes which kind of geometry the point snapped to. */
function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) { function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
const y = FLOOR_LIFT const y = FLOOR_LIFT
@@ -13,6 +13,10 @@ 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 Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import {
clearCeilingSnapFeedback,
resolveCeilingPlanPointSnap,
} from '../../../lib/ceiling-plan-snap'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { snapToHalf } from '../../tools/item/placement-math' import { snapToHalf } from '../../tools/item/placement-math'
@@ -84,6 +88,7 @@ function clearCornerDragPreview(drag: CornerDragState) {
if (drag.inputDraggingSet) { if (drag.inputDraggingSet) {
useViewer.getState().setInputDragging(drag.previousInputDragging) useViewer.getState().setInputDragging(drag.previousInputDragging)
} }
clearCeilingSnapFeedback()
} }
export const CeilingSelectionAffordanceSystem = () => { export const CeilingSelectionAffordanceSystem = () => {
@@ -284,10 +289,21 @@ const CeilingSelectionAffordance = ({
const initialCorner = drag.initialPolygon[drag.cornerIndex] const initialCorner = drag.initialPolygon[drag.cornerIndex]
if (!initialCorner) return if (!initialCorner) return
const nextPosition: [number, number] = [ const rawNextPosition: [number, number] = [
initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]),
initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]),
]
const gridNextPosition: [number, number] = [
initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]), initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]),
initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]), initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
] ]
const nextPosition = resolveCeilingPlanPointSnap({
rawPoint: rawNextPosition,
fallbackPoint: gridNextPosition,
levelId,
excludeId: drag.ceilingId,
altKey: event.altKey,
}).point
if ( if (
drag.previousSnappedPosition && drag.previousSnappedPosition &&
@@ -354,7 +370,7 @@ const CeilingSelectionAffordance = ({
dragRef.current = null dragRef.current = null
clearCornerDragPreview(drag) clearCornerDragPreview(drag)
} }
}, [effectiveCeiling.id, getHandlePlanePoint, selectCeilingForEdit]) }, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit])
useEffect(() => { useEffect(() => {
let frameId = 0 let frameId = 0
@@ -79,6 +79,17 @@ type DragState = {
pointerId: number pointerId: number
} }
export type PolygonEditorPlanPointSnapContext = {
rawPoint: [number, number]
gridPoint: [number, number]
mode: DragState['mode']
vertexIndex: number | null
edgeIndex?: number
initialPosition: [number, number]
initialPolygon: Array<[number, number]>
nativeEvent?: GridEvent['nativeEvent']
}
export interface PolygonEditorProps { export interface PolygonEditorProps {
polygon: Array<[number, number]> polygon: Array<[number, number]>
color?: string color?: string
@@ -120,6 +131,8 @@ export interface PolygonEditorProps {
showMidpointHandles?: boolean showMidpointHandles?: boolean
/** Whether hovering a handle should also tint its connected edges and endpoint handles. */ /** Whether hovering a handle should also tint its connected edges and endpoint handles. */
highlightConnectedHandles?: boolean highlightConnectedHandles?: boolean
/** Optional host-owned point snapper. Defaults to the existing half-grid snap. */
resolvePlanPoint?: (context: PolygonEditorPlanPointSnapContext) => [number, number]
/** Optional vertex handle renderer for host-specific affordances. */ /** Optional vertex handle renderer for host-specific affordances. */
renderVertexHandle?: PolygonVertexHandleRenderer renderVertexHandle?: PolygonVertexHandleRenderer
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */ /** Optional midpoint handle renderer for host-specific add-vertex affordances. */
@@ -397,6 +410,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
showBorderLine = true, showBorderLine = true,
showMidpointHandles = true, showMidpointHandles = true,
highlightConnectedHandles = false, highlightConnectedHandles = false,
resolvePlanPoint,
renderMidpointHandle, renderMidpointHandle,
renderVertexHandle, renderVertexHandle,
}) => { }) => {
@@ -731,9 +745,21 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
useEffect(() => { useEffect(() => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const point = levelNode ? event.localPosition : event.position const point = levelNode ? event.localPosition : event.position
const gridX = snapToHalf(point[0]) const rawPoint: [number, number] = [point[0], point[2]]
const gridZ = snapToHalf(point[2]) const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
const newPosition: [number, number] = [gridX, gridZ] const newPosition =
dragState?.isDragging && resolvePlanPoint
? resolvePlanPoint({
rawPoint,
gridPoint,
mode: dragState.mode,
vertexIndex: dragState.vertexIndex,
edgeIndex: dragState.edgeIndex,
initialPosition: dragState.initialPosition,
initialPolygon: dragState.initialPolygon,
nativeEvent: event.nativeEvent,
})
: gridPoint
// 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 (
@@ -788,7 +814,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
} }
}, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon]) }, [dragState, handleVertexDrag, levelNode, resolvePlanPoint, updatePreviewPolygon])
// Set up pointer up listener for ending drag // Set up pointer up listener for ending drag
useEffect(() => { useEffect(() => {
@@ -20,6 +20,7 @@ import {
WALL_JOIN_SNAP_RADIUS, WALL_JOIN_SNAP_RADIUS,
type WallDraftSnapResult, type WallDraftSnapResult,
type WallPlanPoint, type WallPlanPoint,
type WallSnapRadii,
} from './wall-snap-geometry' } from './wall-snap-geometry'
// The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
@@ -30,6 +31,7 @@ export {
type WallDraftSnapKind, type WallDraftSnapKind,
type WallDraftSnapResult, type WallDraftSnapResult,
type WallPlanPoint, type WallPlanPoint,
type WallSnapRadii,
} from './wall-snap-geometry' } from './wall-snap-geometry'
export const WALL_GRID_STEP = 0.5 export const WALL_GRID_STEP = 0.5
@@ -345,6 +347,8 @@ type SnapWallDraftArgs = {
* local-axis grid at `step`. * local-axis grid at `step`.
*/ */
gridSnap?: (point: WallPlanPoint) => WallPlanPoint gridSnap?: (point: WallPlanPoint) => WallPlanPoint
/** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */
snapRadii?: WallSnapRadii
} }
export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult { export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
@@ -357,13 +361,14 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
step: overrideStep, step: overrideStep,
magnetic = true, magnetic = true,
gridSnap, gridSnap,
snapRadii,
} = args } = args
// 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`.
if (magnetic) { if (magnetic) {
const special = findWallSpecialPointSnap(point, walls, ignoreWallIds) const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii)
if (special) return special if (special) return special
} }
@@ -377,7 +382,10 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
: snapPointToGrid(point, step) : snapPointToGrid(point, step)
if (magnetic) { if (magnetic) {
const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds }) const wallSnap = findWallSnapTarget(basePoint, walls, {
ignoreWallIds,
radius: snapRadii?.wall,
})
if (wallSnap) return { point: wallSnap, snap: 'wall' } if (wallSnap) return { point: wallSnap, snap: 'wall' }
} }
@@ -62,6 +62,13 @@ describe('findWallSpecialPointSnap', () => {
// handled separately by findWallSnapTarget, not a special point. // handled separately by findWallSnapTarget, not a special point.
expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull() expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
}) })
test('honors tighter per-call radii without changing defaults', () => {
const walls = [makeWall([0, 0], [4, 0])]
expect(findWallSpecialPointSnap([0.34, 0], walls)?.snap).toBe('endpoint')
expect(findWallSpecialPointSnap([0.34, 0], walls, undefined, { endpoint: 0.3 })).toBeNull()
})
}) })
describe('findWallSnapTarget (edge / along-wall)', () => { describe('findWallSnapTarget (edge / along-wall)', () => {
@@ -76,4 +83,10 @@ describe('findWallSnapTarget (edge / along-wall)', () => {
const walls = [makeWall([0, 0], [4, 0])] const walls = [makeWall([0, 0], [4, 0])]
expect(findWallSnapTarget([1.2, 2], walls)).toBeNull() expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
}) })
test('honors a tighter wall-body radius', () => {
const walls = [makeWall([0, 0], [4, 0])]
expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull()
})
}) })
@@ -15,6 +15,8 @@ export type WallPlanPoint = [number, number]
/** Which kind of existing-geometry snap produced a drafted point. */ /** Which kind of existing-geometry snap produced a drafted point. */
export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall' export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
export type WallSnapRadii = Partial<Record<WallDraftSnapKind, number>>
export type WallDraftSnapResult = { export type WallDraftSnapResult = {
point: WallPlanPoint point: WallPlanPoint
/** /**
@@ -119,9 +121,10 @@ export function findWallEndpointFromRaw(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
ignoreWallIds?: string[], ignoreWallIds?: string[],
radius = WALL_ENDPOINT_SNAP_RADIUS,
): WallPlanPoint | null { ): WallPlanPoint | null {
const ignored = new Set(ignoreWallIds ?? []) const ignored = new Set(ignoreWallIds ?? [])
const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2 const radiusSquared = radius ** 2
let best: WallPlanPoint | null = null let best: WallPlanPoint | null = null
let bestDistSq = Number.POSITIVE_INFINITY let bestDistSq = Number.POSITIVE_INFINITY
@@ -152,9 +155,10 @@ export function findWallMidpointFromRaw(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
ignoreWallIds?: string[], ignoreWallIds?: string[],
radius = WALL_MIDPOINT_SNAP_RADIUS,
): WallPlanPoint | null { ): WallPlanPoint | null {
const ignored = new Set(ignoreWallIds ?? []) const ignored = new Set(ignoreWallIds ?? [])
const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2 const radiusSquared = radius ** 2
let best: WallPlanPoint | null = null let best: WallPlanPoint | null = null
let bestDistSq = Number.POSITIVE_INFINITY let bestDistSq = Number.POSITIVE_INFINITY
@@ -202,10 +206,11 @@ export function findWallIntersectionFromRaw(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
ignoreWallIds?: string[], ignoreWallIds?: string[],
radius = WALL_INTERSECTION_SNAP_RADIUS,
): WallPlanPoint | null { ): WallPlanPoint | null {
const ignored = new Set(ignoreWallIds ?? []) const ignored = new Set(ignoreWallIds ?? [])
const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall)) const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall))
const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2 const radiusSquared = radius ** 2
let best: WallPlanPoint | null = null let best: WallPlanPoint | null = null
let bestDistSq = Number.POSITIVE_INFINITY let bestDistSq = Number.POSITIVE_INFINITY
@@ -257,12 +262,13 @@ export function findWallSpecialPointSnap(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
ignoreWallIds?: string[], ignoreWallIds?: string[],
radii?: WallSnapRadii,
): WallDraftSnapResult | null { ): WallDraftSnapResult | null {
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds) const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds, radii?.endpoint)
if (endpoint) return { point: endpoint, snap: 'endpoint' } if (endpoint) return { point: endpoint, snap: 'endpoint' }
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds) const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds, radii?.midpoint)
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds) const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds, radii?.intersection)
return nearestCandidate(point, [ return nearestCandidate(point, [
midpoint && { point: midpoint, snap: 'midpoint' }, midpoint && { point: midpoint, snap: 'midpoint' },
intersection && { point: intersection, snap: 'intersection' }, intersection && { point: intersection, snap: 'intersection' },
+9
View File
@@ -65,6 +65,7 @@ export { useFreshPlacementVisibility } from './components/tools/shared/fresh-pla
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export { export {
PolygonEditor, PolygonEditor,
type PolygonEditorPlanPointSnapContext,
type PolygonEditorProps, type PolygonEditorProps,
} from './components/tools/shared/polygon-editor' } from './components/tools/shared/polygon-editor'
export { export {
@@ -108,6 +109,7 @@ export {
type WallDraftSnapKind, type WallDraftSnapKind,
type WallDraftSnapResult, type WallDraftSnapResult,
type WallPlanPoint, type WallPlanPoint,
type WallSnapRadii,
} from './components/tools/wall/wall-drafting' } from './components/tools/wall/wall-drafting'
// `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the // `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the
// existing `ViewerToolbarLeft` / `ViewerToolbarRight` exports — the // existing `ViewerToolbarLeft` / `ViewerToolbarRight` exports — the
@@ -172,6 +174,13 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard' export { markToolCancelConsumed } from './hooks/use-keyboard'
export { type Selection, useSelection } from './hooks/use-selection' export { type Selection, useSelection } from './hooks/use-selection'
export {
CEILING_ALIGNMENT_THRESHOLD_M,
type CeilingPlanSnapInput,
type CeilingPlanSnapResult,
clearCeilingSnapFeedback,
resolveCeilingPlanPointSnap,
} from './lib/ceiling-plan-snap'
export { EDITOR_LAYER } from './lib/constants' export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels. // Helper libs used by the kind-owned roof / stair / elevator panels.
export { export {
@@ -0,0 +1,232 @@
import {
type AlignmentAnchor,
type AlignmentGuide,
type AnyNode,
collectAlignmentAnchors,
getWallCurveFrameAt,
getWallCurveLength,
isCurvedWall,
resolveAlignment,
resolveLevelId,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
getSegmentGridStep,
snapWallDraftPointDetailed,
type WallDraftSnapKind,
type WallPlanPoint,
type WallSnapRadii,
} from '../components/tools/wall/wall-drafting'
import useAlignmentGuides from '../store/use-alignment-guides'
import useEditor from '../store/use-editor'
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
const CEILING_SNAP_MOVING_ID = '__ceiling_snap__'
export const CEILING_ALIGNMENT_THRESHOLD_M = 0.08
const CEILING_WALL_SNAP_RADII = {
endpoint: 0.38,
midpoint: 0.28,
intersection: 0.28,
wall: 0.18,
} satisfies WallSnapRadii
const WALL_SOURCE_MATCH_EPSILON = 0.035
export type CeilingPlanSnapInput = {
rawPoint: WallPlanPoint
fallbackPoint?: WallPlanPoint
levelId?: string | null
excludeId?: string | null
movingId?: string
nodes?: Readonly<Record<string, AnyNode>>
walls?: readonly WallNode[]
candidates?: readonly AlignmentAnchor[]
threshold?: number
altKey?: boolean
magnetic?: boolean
align?: boolean
step?: number
snapRadii?: WallSnapRadii
}
export type CeilingPlanSnapResult = {
point: WallPlanPoint
wallSnap: WallDraftSnapKind | null
guides: AlignmentGuide[]
wallIds: string[]
}
function getLevelWalls(
nodes: Readonly<Record<string, AnyNode>>,
levelId: string | null | undefined,
walls?: readonly WallNode[],
): WallNode[] {
const source =
walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall')
if (!levelId) return source.filter((wall) => wall.visible !== false)
return source.filter(
(wall) =>
wall.visible !== false && resolveLevelId(wall, nodes as Record<string, AnyNode>) === levelId,
)
}
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz
}
function wallMidpoint(wall: WallNode): WallPlanPoint {
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, 0.5)
return [frame.point.x, frame.point.y]
}
return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
}
function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-9) return distanceSquared(point, start)
const t = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
)
const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t]
return distanceSquared(point, projected)
}
function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) {
if (!isCurvedWall(wall)) {
return distanceToSegmentSquared(point, wall.start, wall.end)
}
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
let bestDistanceSquared = Number.POSITIVE_INFINITY
let previous = getWallCurveFrameAt(wall, 0).point
for (let index = 1; index <= sampleCount; index += 1) {
const current = getWallCurveFrameAt(wall, index / sampleCount).point
const distance = distanceToSegmentSquared(
point,
[previous.x, previous.y],
[current.x, current.y],
)
bestDistanceSquared = Math.min(bestDistanceSquared, distance)
previous = current
}
return bestDistanceSquared
}
function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) {
return walls
.map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) }))
.sort((a, b) => a.distance - b.distance)
.slice(0, count)
.map(({ id }) => id)
}
function findSnapSourceWallIds(
point: WallPlanPoint,
kind: WallDraftSnapKind,
walls: readonly WallNode[],
): string[] {
const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2
if (kind === 'endpoint') {
const endpointMatches = walls.filter(
(wall) =>
distanceSquared(point, wall.start) <= epsilonSquared ||
distanceSquared(point, wall.end) <= epsilonSquared,
)
if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id)
return closestWallIds(point, walls, 1)
}
if (kind === 'midpoint') {
const midpointMatches = walls.filter(
(wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared,
)
if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id)
return closestWallIds(point, walls, 1)
}
if (kind === 'intersection') {
const crossingMatches = walls.filter(
(wall) => distanceToWallSquared(point, wall) <= epsilonSquared,
)
if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2)
return closestWallIds(point, walls, 2)
}
return closestWallIds(point, walls, 1)
}
export function clearCeilingSnapFeedback() {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
}
export function resolveCeilingPlanPointSnap(input: CeilingPlanSnapInput): CeilingPlanSnapResult {
const nodes = input.nodes ?? useScene.getState().nodes
const walls = getLevelWalls(nodes, input.levelId, input.walls)
const fallbackPoint = input.fallbackPoint
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap
const wallSnap = snapWallDraftPointDetailed({
point: input.rawPoint,
walls,
step: input.step ?? getSegmentGridStep(),
magnetic,
snapRadii: input.snapRadii ?? CEILING_WALL_SNAP_RADII,
gridSnap: fallbackPoint ? () => fallbackPoint : undefined,
})
if (wallSnap.snap) {
const wallIds = findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls)
useWallSnapIndicator
.getState()
.set({ x: wallSnap.point[0], z: wallSnap.point[1], kind: wallSnap.snap, wallIds })
useAlignmentGuides.getState().clear()
return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds }
}
useWallSnapIndicator.getState().clear()
const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || input.altKey) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const movingId = input.movingId ?? CEILING_SNAP_MOVING_ID
const candidates =
input.candidates ??
collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null)
if (candidates.length === 0) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const alignment = resolveAlignment({
moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }],
candidates,
threshold: input.threshold ?? CEILING_ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(alignment.guides)
if (!alignment.snap) {
return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] }
}
return {
point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz],
wallSnap: null,
guides: alignment.guides,
wallIds: [],
}
}
@@ -15,6 +15,8 @@ export type WallSnapPoint = {
x: number x: number
z: number z: number
kind: WallSnapKind kind: WallSnapKind
/** Optional wall ids whose geometry produced this snap. */
wallIds?: string[]
} }
type WallSnapIndicatorState = { type WallSnapIndicatorState = {
+34 -4
View File
@@ -1,7 +1,13 @@
'use client' 'use client'
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { PolygonEditor, triggerSFX } from '@pascal-app/editor' import {
clearCeilingSnapFeedback,
PolygonEditor,
type PolygonEditorPlanPointSnapContext,
resolveCeilingPlanPointSnap,
triggerSFX,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -36,9 +42,13 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
() => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), () => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
[ceiling, liveOverride], [ceiling, liveOverride],
) )
const ceilingLevelId = effectiveCeiling
? resolveLevelId(effectiveCeiling, useScene.getState().nodes)
: null
const handlePolygonChange = useCallback( const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => { (newPolygon: Array<[number, number]>) => {
clearCeilingSnapFeedback()
updateNode(ceilingId, { polygon: newPolygon }) updateNode(ceilingId, { polygon: newPolygon })
setSelection({ selectedIds: [ceilingId] }) setSelection({ selectedIds: [ceilingId] })
}, },
@@ -87,12 +97,18 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
(isDragging: boolean) => { (isDragging: boolean) => {
if (!isDragging) { if (!isDragging) {
ownsPolygonPreviewRef.current = false ownsPolygonPreviewRef.current = false
clearCeilingSnapFeedback()
} }
setCeilingHandleHover(isDragging) setCeilingHandleHover(isDragging)
}, },
[setCeilingHandleHover], [setCeilingHandleHover],
) )
const handlePolygonEditorDragCommit = useCallback(() => {
triggerSFX('sfx:item-place')
clearCeilingSnapFeedback()
}, [])
const handlePolygonEditorDragStart = useCallback(() => { const handlePolygonEditorDragStart = useCallback(() => {
ownsPolygonPreviewRef.current = true ownsPolygonPreviewRef.current = true
triggerSFX('sfx:item-pick') triggerSFX('sfx:item-pick')
@@ -102,8 +118,21 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
ownsPolygonPreviewRef.current = true ownsPolygonPreviewRef.current = true
}, []) }, [])
const resolvePolygonEditorPlanPoint = useCallback(
(context: PolygonEditorPlanPointSnapContext) =>
resolveCeilingPlanPointSnap({
rawPoint: context.rawPoint,
fallbackPoint: context.gridPoint,
levelId: ceilingLevelId,
excludeId: ceilingId,
altKey: context.nativeEvent?.altKey === true,
}).point,
[ceilingId, ceilingLevelId],
)
useEffect(() => { useEffect(() => {
return () => { return () => {
clearCeilingSnapFeedback()
useLiveNodeOverrides.getState().clear(ceilingId) useLiveNodeOverrides.getState().clear(ceilingId)
useScene.getState().markDirty(ceilingId) useScene.getState().markDirty(ceilingId)
ownsPolygonPreviewRef.current = false ownsPolygonPreviewRef.current = false
@@ -121,18 +150,19 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
allowEdgeMove allowEdgeMove
color="#d4d4d4" color="#d4d4d4"
highlightConnectedHandles highlightConnectedHandles
levelId={resolveLevelId(effectiveCeiling, useScene.getState().nodes)} levelId={ceilingLevelId ?? undefined}
minVertices={3} minVertices={3}
onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag} onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag}
onDragStateChange={handleDragStateChange} onDragCommit={handlePolygonEditorDragCommit}
onDragCommit={() => triggerSFX('sfx:item-place')}
onDragStart={handlePolygonEditorDragStart} onDragStart={handlePolygonEditorDragStart}
onDragStateChange={handleDragStateChange}
onEdgeHoverChange={handleHandleHoverChange} onEdgeHoverChange={handleHandleHoverChange}
onMidpointHoverChange={handleHandleHoverChange} onMidpointHoverChange={handleHandleHoverChange}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview} onPolygonPreview={handlePolygonPreview}
onVertexHoverChange={handleHandleHoverChange} onVertexHoverChange={handleHandleHoverChange}
polygon={effectiveCeiling.polygon} polygon={effectiveCeiling.polygon}
resolvePlanPoint={resolvePolygonEditorPlanPoint}
surfaceHeight={effectiveCeiling.height ?? 2.5} surfaceHeight={effectiveCeiling.height ?? 2.5}
/> />
) )
+13 -57
View File
@@ -1,19 +1,13 @@
'use client' 'use client'
import { import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
collectAlignmentAnchors,
emitter,
type GridEvent,
type LevelNode,
resolveAlignment,
useScene,
} from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
clearCeilingSnapFeedback,
EDITOR_LAYER, EDITOR_LAYER,
markToolCancelConsumed, markToolCancelConsumed,
resolveCeilingPlanPointSnap,
triggerSFX, triggerSFX,
useAlignmentGuides,
useEditor, useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -33,8 +27,6 @@ import { CeilingNode } from './schema'
const CEILING_HEIGHT = 2.52 const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02 const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function calculateSnapPoint( function calculateSnapPoint(
lastPoint: [number, number], lastPoint: [number, number],
@@ -93,10 +85,7 @@ export const CeilingTool: React.FC = () => {
// draw isn't built with a stale preset's parameters. Unmount-only. // draw isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), []) useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), [])
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs useEffect(() => () => clearCeilingSnapFeedback(), [])
// on every cursor move (cursorPosition is in its deps), so clearing guides
// in its cleanup would wipe the guide the instant after each move sets it.
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
const verticalGeo = useMemo( const verticalGeo = useMemo(
() => () =>
@@ -115,44 +104,6 @@ export const CeilingTool: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!currentLevelId) return if (!currentLevelId) return
// Alignment candidates — anchors of every OTHER alignable object. The
// ceiling's own in-progress vertices are intentionally excluded (no
// self-alignment while drawing).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// Snap the drafted vertex onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid/ortho snap. Alt
// bypasses.
const alignPoint = (
fallback: [number, number],
raw: [number, number],
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
const ar = resolveAlignment({
moving: [{ nodeId: '__ceiling-draft__', kind: 'corner', x: raw[0], z: raw[1] }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
useAlignmentGuides.getState().set(ar.guides)
let [x, z] = fallback
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
}
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]]
@@ -168,7 +119,12 @@ export const CeilingTool: React.FC = () => {
shiftPressed.current || !lastPoint shiftPressed.current || !lastPoint
? gridPosition ? gridPosition
: calculateSnapPoint(lastPoint, gridPosition) : calculateSnapPoint(lastPoint, gridPosition)
const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true) const displayPoint = resolveCeilingPlanPointSnap({
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true,
}).point
setSnappedCursorPosition(displayPoint) setSnappedCursorPosition(displayPoint)
if ( if (
points.length > 0 && points.length > 0 &&
@@ -199,7 +155,7 @@ export const CeilingTool: React.FC = () => {
const ceilingId = commitCeilingDrawing(currentLevelId, points) const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] }) setSelection({ selectedIds: [ceilingId] })
setPoints([]) setPoints([])
useAlignmentGuides.getState().clear() clearCeilingSnapFeedback()
} else { } else {
// Every non-closing vertex is a "start" tick; the closing click above // Every non-closing vertex is a "start" tick; the closing click above
// fires the structure-build (end) cue. // fires the structure-build (end) cue.
@@ -214,14 +170,14 @@ export const CeilingTool: React.FC = () => {
const ceilingId = commitCeilingDrawing(currentLevelId, points) const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] }) setSelection({ selectedIds: [ceilingId] })
setPoints([]) setPoints([])
useAlignmentGuides.getState().clear() clearCeilingSnapFeedback()
} }
} }
const onCancel = () => { const onCancel = () => {
if (points.length > 0) markToolCancelConsumed() if (points.length > 0) markToolCancelConsumed()
setPoints([]) setPoints([])
useAlignmentGuides.getState().clear() clearCeilingSnapFeedback()
} }
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {