feat: add ceiling wall snap feedback
This commit is contained in:
@@ -64,6 +64,7 @@ import {
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Vector3 } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
||||
import {
|
||||
alignFloorplanDraftPoint,
|
||||
buildFloorplanItemEntry,
|
||||
@@ -8425,17 +8426,22 @@ export function FloorplanPanel() {
|
||||
|
||||
if (isCeilingBuildActive) {
|
||||
// Polygon vertex: grid (snapToHalf) + optional 45° angle snap from
|
||||
// the previous vertex. Alignment runs only when angle snap is OFF
|
||||
// (first vertex, or Shift held) — when the angle is being locked,
|
||||
// pulling the vertex sideways would break it.
|
||||
// the previous vertex. Wall magnetic snap may still win, while
|
||||
// generic alignment runs only when angle snap is OFF (first vertex,
|
||||
// or Shift held) so it does not pull a locked angle sideways.
|
||||
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
|
||||
let snappedPoint = snapPolygonDraftPoint({
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
})
|
||||
if (angleSnap) useAlignmentGuides.getState().clear()
|
||||
else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
|
||||
const snappedPoint = resolveCeilingPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
@@ -8676,6 +8682,7 @@ export function FloorplanPanel() {
|
||||
isPolygonBuildActive,
|
||||
isRoofBuildActive,
|
||||
isWallBuildActive,
|
||||
levelId,
|
||||
publishFloorplanNavigationPose,
|
||||
smoothFloorplanNavigationView,
|
||||
referenceScaleDraft,
|
||||
@@ -8934,6 +8941,7 @@ export function FloorplanPanel() {
|
||||
isRoofBuildActive,
|
||||
isWallBuildActive,
|
||||
isZoneBuildActive,
|
||||
levelId,
|
||||
roofDraftStart,
|
||||
setCursorPoint,
|
||||
setFenceDraftEnd,
|
||||
@@ -9110,25 +9118,33 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
const snappedPoint = snapPolygonDraftPoint({
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
|
||||
angleSnap,
|
||||
})
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if (isZoneBuildActive) {
|
||||
handleZonePlacementConfirm(snappedPoint)
|
||||
handleZonePlacementConfirm(fallbackPoint)
|
||||
} else {
|
||||
// Slab is registry-driven: forward the double-click so the 3D tool
|
||||
// commits the node (zone has no registry tool, so it commits locally).
|
||||
emitFloorplanGridEvent('double-click', planPoint, event)
|
||||
handleSlabPlacementConfirm(snappedPoint)
|
||||
handleSlabPlacementConfirm(fallbackPoint)
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -9142,6 +9158,7 @@ export function FloorplanPanel() {
|
||||
isPolygonDraftBuildActive,
|
||||
isRoofBuildActive,
|
||||
isZoneBuildActive,
|
||||
levelId,
|
||||
shiftPressed,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
|
||||
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
|
||||
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
isRoofBuildActive: boolean
|
||||
isWallBuildActive: boolean
|
||||
isZoneBuildActive: boolean
|
||||
levelId: string | null
|
||||
roofDraftStart: WallPlanPoint | null
|
||||
setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
||||
setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
||||
@@ -107,6 +109,7 @@ export function useFloorplanBackgroundPlacement({
|
||||
isRoofBuildActive,
|
||||
isWallBuildActive,
|
||||
isZoneBuildActive,
|
||||
levelId,
|
||||
roofDraftStart,
|
||||
setCursorPoint,
|
||||
setFenceDraftEnd,
|
||||
@@ -149,17 +152,22 @@ export function useFloorplanBackgroundPlacement({
|
||||
|
||||
if (isCeilingBuildActive) {
|
||||
// Align the committed vertex the same way the move-preview did, so
|
||||
// the placed point matches what the user saw. Skip when angle snap
|
||||
// owns the vertex (matches the move branch).
|
||||
// the placed point matches what the user saw. Wall magnetic snap may
|
||||
// still win; generic alignment is skipped when angle snap owns the
|
||||
// vertex (matches the move branch).
|
||||
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
|
||||
let snappedPoint = snapPolygonDraftPoint({
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
})
|
||||
if (!angleSnap) {
|
||||
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
|
||||
}
|
||||
const snappedPoint = resolveCeilingPlanPointSnap({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
handleCeilingPlacementPoint(snappedPoint)
|
||||
@@ -322,6 +330,7 @@ export function useFloorplanBackgroundPlacement({
|
||||
isRoofBuildActive,
|
||||
isWallBuildActive,
|
||||
isZoneBuildActive,
|
||||
levelId,
|
||||
roofDraftStart,
|
||||
setCursorPoint,
|
||||
setFenceDraftEnd,
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
'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 { useViewer } from '@pascal-app/viewer'
|
||||
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 { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
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 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 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
|
||||
// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
|
||||
@@ -45,17 +66,41 @@ const beaconMaterial = new MeshBasicNodeMaterial({
|
||||
transparent: true,
|
||||
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)
|
||||
// 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.
|
||||
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 CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
|
||||
|
||||
export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
|
||||
const point = useWallSnapIndicator((s) => s.point)
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
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
|
||||
// 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
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{highlightedWalls.map((wall) => (
|
||||
<WallTopHighlight key={wall.id} nodes={nodes} wall={wall} />
|
||||
))}
|
||||
<mesh
|
||||
geometry={PILLAR_GEOMETRY}
|
||||
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. */
|
||||
function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
|
||||
const y = FLOOR_LIFT
|
||||
|
||||
+18
-2
@@ -13,6 +13,10 @@ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
clearCeilingSnapFeedback,
|
||||
resolveCeilingPlanPointSnap,
|
||||
} from '../../../lib/ceiling-plan-snap'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { snapToHalf } from '../../tools/item/placement-math'
|
||||
@@ -84,6 +88,7 @@ function clearCornerDragPreview(drag: CornerDragState) {
|
||||
if (drag.inputDraggingSet) {
|
||||
useViewer.getState().setInputDragging(drag.previousInputDragging)
|
||||
}
|
||||
clearCeilingSnapFeedback()
|
||||
}
|
||||
|
||||
export const CeilingSelectionAffordanceSystem = () => {
|
||||
@@ -284,10 +289,21 @@ const CeilingSelectionAffordance = ({
|
||||
const initialCorner = drag.initialPolygon[drag.cornerIndex]
|
||||
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[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
|
||||
]
|
||||
const nextPosition = resolveCeilingPlanPointSnap({
|
||||
rawPoint: rawNextPosition,
|
||||
fallbackPoint: gridNextPosition,
|
||||
levelId,
|
||||
excludeId: drag.ceilingId,
|
||||
altKey: event.altKey,
|
||||
}).point
|
||||
|
||||
if (
|
||||
drag.previousSnappedPosition &&
|
||||
@@ -354,7 +370,7 @@ const CeilingSelectionAffordance = ({
|
||||
dragRef.current = null
|
||||
clearCornerDragPreview(drag)
|
||||
}
|
||||
}, [effectiveCeiling.id, getHandlePlanePoint, selectCeilingForEdit])
|
||||
}, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit])
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0
|
||||
|
||||
@@ -79,6 +79,17 @@ type DragState = {
|
||||
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 {
|
||||
polygon: Array<[number, number]>
|
||||
color?: string
|
||||
@@ -120,6 +131,8 @@ export interface PolygonEditorProps {
|
||||
showMidpointHandles?: boolean
|
||||
/** Whether hovering a handle should also tint its connected edges and endpoint handles. */
|
||||
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. */
|
||||
renderVertexHandle?: PolygonVertexHandleRenderer
|
||||
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */
|
||||
@@ -397,6 +410,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
showBorderLine = true,
|
||||
showMidpointHandles = true,
|
||||
highlightConnectedHandles = false,
|
||||
resolvePlanPoint,
|
||||
renderMidpointHandle,
|
||||
renderVertexHandle,
|
||||
}) => {
|
||||
@@ -731,9 +745,21 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
useEffect(() => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const point = levelNode ? event.localPosition : event.position
|
||||
const gridX = snapToHalf(point[0])
|
||||
const gridZ = snapToHalf(point[2])
|
||||
const newPosition: [number, number] = [gridX, gridZ]
|
||||
const rawPoint: [number, number] = [point[0], point[2]]
|
||||
const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
|
||||
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
|
||||
if (
|
||||
@@ -788,7 +814,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
}
|
||||
}, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon])
|
||||
}, [dragState, handleVertexDrag, levelNode, resolvePlanPoint, updatePreviewPolygon])
|
||||
|
||||
// Set up pointer up listener for ending drag
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
WALL_JOIN_SNAP_RADIUS,
|
||||
type WallDraftSnapResult,
|
||||
type WallPlanPoint,
|
||||
type WallSnapRadii,
|
||||
} from './wall-snap-geometry'
|
||||
|
||||
// The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
|
||||
@@ -30,6 +31,7 @@ export {
|
||||
type WallDraftSnapKind,
|
||||
type WallDraftSnapResult,
|
||||
type WallPlanPoint,
|
||||
type WallSnapRadii,
|
||||
} from './wall-snap-geometry'
|
||||
|
||||
export const WALL_GRID_STEP = 0.5
|
||||
@@ -345,6 +347,8 @@ type SnapWallDraftArgs = {
|
||||
* local-axis grid at `step`.
|
||||
*/
|
||||
gridSnap?: (point: WallPlanPoint) => WallPlanPoint
|
||||
/** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */
|
||||
snapRadii?: WallSnapRadii
|
||||
}
|
||||
|
||||
export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
|
||||
@@ -357,13 +361,14 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
|
||||
step: overrideStep,
|
||||
magnetic = true,
|
||||
gridSnap,
|
||||
snapRadii,
|
||||
} = args
|
||||
|
||||
// Discrete special points (corner / midpoint / crossing) are taken from the
|
||||
// raw cursor so an interim grid snap can't mask them. A corner always wins,
|
||||
// then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
|
||||
if (magnetic) {
|
||||
const special = findWallSpecialPointSnap(point, walls, ignoreWallIds)
|
||||
const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii)
|
||||
if (special) return special
|
||||
}
|
||||
|
||||
@@ -377,7 +382,10 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
|
||||
: snapPointToGrid(point, step)
|
||||
|
||||
if (magnetic) {
|
||||
const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
|
||||
const wallSnap = findWallSnapTarget(basePoint, walls, {
|
||||
ignoreWallIds,
|
||||
radius: snapRadii?.wall,
|
||||
})
|
||||
if (wallSnap) return { point: wallSnap, snap: 'wall' }
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,13 @@ describe('findWallSpecialPointSnap', () => {
|
||||
// handled separately by findWallSnapTarget, not a special point.
|
||||
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)', () => {
|
||||
@@ -76,4 +83,10 @@ describe('findWallSnapTarget (edge / along-wall)', () => {
|
||||
const walls = [makeWall([0, 0], [4, 0])]
|
||||
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. */
|
||||
export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
|
||||
|
||||
export type WallSnapRadii = Partial<Record<WallDraftSnapKind, number>>
|
||||
|
||||
export type WallDraftSnapResult = {
|
||||
point: WallPlanPoint
|
||||
/**
|
||||
@@ -119,9 +121,10 @@ export function findWallEndpointFromRaw(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
radius = WALL_ENDPOINT_SNAP_RADIUS,
|
||||
): WallPlanPoint | null {
|
||||
const ignored = new Set(ignoreWallIds ?? [])
|
||||
const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
|
||||
const radiusSquared = radius ** 2
|
||||
let best: WallPlanPoint | null = null
|
||||
let bestDistSq = Number.POSITIVE_INFINITY
|
||||
|
||||
@@ -152,9 +155,10 @@ export function findWallMidpointFromRaw(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
radius = WALL_MIDPOINT_SNAP_RADIUS,
|
||||
): WallPlanPoint | null {
|
||||
const ignored = new Set(ignoreWallIds ?? [])
|
||||
const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2
|
||||
const radiusSquared = radius ** 2
|
||||
let best: WallPlanPoint | null = null
|
||||
let bestDistSq = Number.POSITIVE_INFINITY
|
||||
|
||||
@@ -202,10 +206,11 @@ export function findWallIntersectionFromRaw(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
radius = WALL_INTERSECTION_SNAP_RADIUS,
|
||||
): WallPlanPoint | null {
|
||||
const ignored = new Set(ignoreWallIds ?? [])
|
||||
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 bestDistSq = Number.POSITIVE_INFINITY
|
||||
|
||||
@@ -257,12 +262,13 @@ export function findWallSpecialPointSnap(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
radii?: WallSnapRadii,
|
||||
): WallDraftSnapResult | null {
|
||||
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds)
|
||||
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds, radii?.endpoint)
|
||||
if (endpoint) return { point: endpoint, snap: 'endpoint' }
|
||||
|
||||
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds)
|
||||
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds)
|
||||
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds, radii?.midpoint)
|
||||
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds, radii?.intersection)
|
||||
return nearestCandidate(point, [
|
||||
midpoint && { point: midpoint, snap: 'midpoint' },
|
||||
intersection && { point: intersection, snap: 'intersection' },
|
||||
|
||||
@@ -65,6 +65,7 @@ export { useFreshPlacementVisibility } from './components/tools/shared/fresh-pla
|
||||
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
|
||||
export {
|
||||
PolygonEditor,
|
||||
type PolygonEditorPlanPointSnapContext,
|
||||
type PolygonEditorProps,
|
||||
} from './components/tools/shared/polygon-editor'
|
||||
export {
|
||||
@@ -108,6 +109,7 @@ export {
|
||||
type WallDraftSnapKind,
|
||||
type WallDraftSnapResult,
|
||||
type WallPlanPoint,
|
||||
type WallSnapRadii,
|
||||
} from './components/tools/wall/wall-drafting'
|
||||
// `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for 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.).
|
||||
export { markToolCancelConsumed } from './hooks/use-keyboard'
|
||||
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'
|
||||
// Helper libs used by the kind-owned roof / stair / elevator panels.
|
||||
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
|
||||
z: number
|
||||
kind: WallSnapKind
|
||||
/** Optional wall ids whose geometry produced this snap. */
|
||||
wallIds?: string[]
|
||||
}
|
||||
|
||||
type WallSnapIndicatorState = {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client'
|
||||
|
||||
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 { 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],
|
||||
)
|
||||
const ceilingLevelId = effectiveCeiling
|
||||
? resolveLevelId(effectiveCeiling, useScene.getState().nodes)
|
||||
: null
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
clearCeilingSnapFeedback()
|
||||
updateNode(ceilingId, { polygon: newPolygon })
|
||||
setSelection({ selectedIds: [ceilingId] })
|
||||
},
|
||||
@@ -87,12 +97,18 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
(isDragging: boolean) => {
|
||||
if (!isDragging) {
|
||||
ownsPolygonPreviewRef.current = false
|
||||
clearCeilingSnapFeedback()
|
||||
}
|
||||
setCeilingHandleHover(isDragging)
|
||||
},
|
||||
[setCeilingHandleHover],
|
||||
)
|
||||
|
||||
const handlePolygonEditorDragCommit = useCallback(() => {
|
||||
triggerSFX('sfx:item-place')
|
||||
clearCeilingSnapFeedback()
|
||||
}, [])
|
||||
|
||||
const handlePolygonEditorDragStart = useCallback(() => {
|
||||
ownsPolygonPreviewRef.current = true
|
||||
triggerSFX('sfx:item-pick')
|
||||
@@ -102,8 +118,21 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
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(() => {
|
||||
return () => {
|
||||
clearCeilingSnapFeedback()
|
||||
useLiveNodeOverrides.getState().clear(ceilingId)
|
||||
useScene.getState().markDirty(ceilingId)
|
||||
ownsPolygonPreviewRef.current = false
|
||||
@@ -121,18 +150,19 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
allowEdgeMove
|
||||
color="#d4d4d4"
|
||||
highlightConnectedHandles
|
||||
levelId={resolveLevelId(effectiveCeiling, useScene.getState().nodes)}
|
||||
levelId={ceilingLevelId ?? undefined}
|
||||
minVertices={3}
|
||||
onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag}
|
||||
onDragStateChange={handleDragStateChange}
|
||||
onDragCommit={() => triggerSFX('sfx:item-place')}
|
||||
onDragCommit={handlePolygonEditorDragCommit}
|
||||
onDragStart={handlePolygonEditorDragStart}
|
||||
onDragStateChange={handleDragStateChange}
|
||||
onEdgeHoverChange={handleHandleHoverChange}
|
||||
onMidpointHoverChange={handleHandleHoverChange}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
onPolygonPreview={handlePolygonPreview}
|
||||
onVertexHoverChange={handleHandleHoverChange}
|
||||
polygon={effectiveCeiling.polygon}
|
||||
resolvePlanPoint={resolvePolygonEditorPlanPoint}
|
||||
surfaceHeight={effectiveCeiling.height ?? 2.5}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
resolveAlignment,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
clearCeilingSnapFeedback,
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
resolveCeilingPlanPointSnap,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
@@ -33,8 +27,6 @@ import { CeilingNode } from './schema'
|
||||
|
||||
const CEILING_HEIGHT = 2.52
|
||||
const GRID_OFFSET = 0.02
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function calculateSnapPoint(
|
||||
lastPoint: [number, number],
|
||||
@@ -93,10 +85,7 @@ export const CeilingTool: React.FC = () => {
|
||||
// draw isn't built with a stale preset's parameters. Unmount-only.
|
||||
useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), [])
|
||||
|
||||
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
|
||||
// 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(), [])
|
||||
useEffect(() => () => clearCeilingSnapFeedback(), [])
|
||||
|
||||
const verticalGeo = useMemo(
|
||||
() =>
|
||||
@@ -115,44 +104,6 @@ export const CeilingTool: React.FC = () => {
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||
@@ -168,7 +119,12 @@ export const CeilingTool: React.FC = () => {
|
||||
shiftPressed.current || !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)
|
||||
if (
|
||||
points.length > 0 &&
|
||||
@@ -199,7 +155,7 @@ export const CeilingTool: React.FC = () => {
|
||||
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [ceilingId] })
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearCeilingSnapFeedback()
|
||||
} else {
|
||||
// Every non-closing vertex is a "start" tick; the closing click above
|
||||
// fires the structure-build (end) cue.
|
||||
@@ -214,14 +170,14 @@ export const CeilingTool: React.FC = () => {
|
||||
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [ceilingId] })
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearCeilingSnapFeedback()
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (points.length > 0) markToolCancelConsumed()
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearCeilingSnapFeedback()
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
Reference in New Issue
Block a user