Improve editor manipulation flows

This commit is contained in:
Aymeric Rabot
2026-06-08 01:07:53 -04:00
parent ab271df9b6
commit 8dc602caa9
57 changed files with 3442 additions and 735 deletions
@@ -6,12 +6,12 @@ import {
type AnyNodeId,
bboxAnchors,
bboxCornerAnchors,
emitter,
type FloorplanMoveTargetSession,
nodeRegistry,
pauseSceneHistory,
resolveAlignment,
resumeSceneHistory,
snapPointToGrid,
useAlignmentGuides,
useLiveNodeOverrides,
useLiveTransforms,
@@ -19,12 +19,13 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
const GRID_STEP = 0.5
// Figma-style alignment snap threshold. Meters in world space; 8cm gives
// a comfortable "magnetic" pull at default zoom without fighting the
// grid snap. Held fixed for v1 — a future revision can scale this with
@@ -78,6 +79,21 @@ export function FloorplanRegistryMoveOverlay() {
return [m.x, m.y]
}
const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
// The scene's `<g>` only covers painted SVG elements, so hovers over
// empty grid background often target the parent SVG. Bounds keep the
// cursor active anywhere inside the floor-plan viewport.
const svg = scene.ownerSVGElement
if (!svg) return false
const rect = svg.getBoundingClientRect()
return (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
)
}
// ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
if (hasMoveTarget && def?.floorplanMoveTarget) {
const sceneNodes = useScene.getState().nodes
@@ -109,26 +125,6 @@ export function FloorplanRegistryMoveOverlay() {
// all entries use the action menu now.
let hasMovedSinceStart = false
const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
// We can't just check `target.closest('[data-floorplan-scene]')`
// because the scene's `<g>` only covers painted SVG elements —
// hovering empty grid background returns the parent SVG element
// as target (no ancestor with the marker), so the closest check
// fails. Compare the pointer position against the scene's
// bounding rect instead: any cursor inside the SVG viewport
// counts as "over the floor plan", regardless of whether the
// exact pixel paints a node or just blank surface.
const svg = scene.ownerSVGElement
if (!svg) return false
const rect = svg.getBoundingClientRect()
return (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
)
}
const onMove = (event: PointerEvent) => {
// Skip 3D-canvas / other-UI cursor moves so the overlay only
// tracks pointer events that actually correspond to a floor-plan
@@ -175,6 +171,7 @@ export function FloorplanRegistryMoveOverlay() {
}
session.commit()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
return
}
@@ -195,6 +192,24 @@ export function FloorplanRegistryMoveOverlay() {
if (changed) finalUpdates.push({ id: snap.id, data })
}
for (const snap of snapshots) {
const current = sceneState[snap.id]
if (!current || !isFreshPlacementMetadata((current as { metadata?: unknown }).metadata)) {
continue
}
const existing = finalUpdates.find((update) => update.id === snap.id)
const metadata = stripPlacementMetadataFlags((current as { metadata?: unknown }).metadata)
if (existing) {
existing.data.metadata = metadata
existing.data.visible = true
} else {
finalUpdates.push({
id: snap.id,
data: { metadata, visible: true },
})
}
}
if (commitValid && finalUpdates.length > 0) {
// Single-undo dance:
// 1. Revert to baseline while history is still paused.
@@ -206,24 +221,6 @@ export function FloorplanRegistryMoveOverlay() {
historyPaused = false
}
useScene.getState().updateNodes(finalUpdates)
// Strip the isNew metadata once committed (matches the legacy
// 3D move-tool that demotes duplicated nodes from "new" status
// on first successful drop).
for (const snap of snapshots) {
const current = useScene.getState().nodes[snap.id]
const meta =
current && typeof (current as { metadata?: unknown }).metadata === 'object'
? ((current as { metadata?: Record<string, unknown> }).metadata ?? {})
: {}
if (meta.isNew) {
useScene.getState().updateNodes([
{
id: snap.id,
data: { metadata: { ...meta, isNew: false } } as Record<string, unknown>,
},
])
}
}
sfxEmitter.emit('sfx:item-place')
// Re-select the moved node(s) — mirrors the legacy 3D move
// tool. The action menu cleared selection on Move click so
@@ -246,6 +243,7 @@ export function FloorplanRegistryMoveOverlay() {
// reason as `onMove`: commits should land for any pointer-up
// inside the SVG viewport, including empty grid background.
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
if (!hasMovedSinceStart) return
// Commit using the LAST pointermove's state — no re-apply at
// pointer-up coords. A previous version re-applied here to
@@ -281,17 +279,7 @@ export function FloorplanRegistryMoveOverlay() {
// the following click are separate DOM events, so we listen on
// window in the capture phase to intercept the click before any
// bubble-phase handler (the floor-plan SVG) sees it.
const swallowClick = (e: MouseEvent) => {
e.stopPropagation()
e.preventDefault()
window.removeEventListener('click', swallowClick, true)
}
window.addEventListener('click', swallowClick, true)
// Safety net: if no click fires (e.g. user dragged enough to
// suppress it), drop the listener on the next tick.
setTimeout(() => {
window.removeEventListener('click', swallowClick, true)
}, 0)
swallowNextClick()
}
const onKey = (event: KeyboardEvent) => {
@@ -300,6 +288,23 @@ export function FloorplanRegistryMoveOverlay() {
// its own restore — without this, both sides would race to
// write the same baseline, harmless but wasteful.
setMovingNodeOrigin('2d')
if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
emitter.emit('tool:cancel')
useScene.getState().deleteNode(movingNode.id as AnyNodeId)
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
const liveTransforms = useLiveTransforms.getState()
const liveOverrides = useLiveNodeOverrides.getState()
for (const id of session.affectedIds) {
liveTransforms.clear(id)
liveOverrides.clear(id)
}
useAlignmentGuides.getState().clear()
setMovingNode(null)
return
}
// Revert untracked, then resume — no history entry.
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) {
@@ -389,6 +394,9 @@ export function FloorplanRegistryMoveOverlay() {
position?: [number, number, number]
}
).position ?? [0, 0, 0]) as [number, number, number]
const isFreshPlacement = isFreshPlacementMetadata(
(movingNode as { metadata?: unknown }).metadata,
)
// SVG units in this floorplan map 1:1 to world meters, and the
// `<g data-node-id>` entry has no transform of its own when at rest,
@@ -407,18 +415,29 @@ export function FloorplanRegistryMoveOverlay() {
}
let lastSnapped: [number, number] | null = null
let dragAnchor: [number, number] | null = null
const onMove = (event: PointerEvent) => {
// Same target guard as Path 1 — pointer must be over the floor
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
// plan coords.
const target = event.target as Element | null
if (!target?.closest('[data-floorplan-scene]')) return
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
const m = toMeters(event.clientX, event.clientY)
if (!m) return
// 1) Grid snap baseline (unchanged behaviour with Alt held).
const [gridX, gridZ] = snapPointToGrid([m[0], m[1]], GRID_STEP)
// 1) Grid snap baseline. Fresh catalog placement is absolute under
// the cursor; existing moves preserve the cursor's grab offset.
const gridStep = useEditor.getState().gridSnapStep
const snap = (value: number) => Math.round(value / gridStep) * gridStep
const resolved = resolvePlanarCursorPosition({
cursor: [m[0], m[1]],
original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchor,
mode: isFreshPlacement ? 'absolute' : 'relative',
snap,
})
dragAnchor = resolved.anchor
const [gridX, gridZ] = resolved.point
// 2) Alignment snap layered on top. Treat the grid-snapped point
// as the "proposed" position so alignment competes from a stable
@@ -467,33 +486,52 @@ export function FloorplanRegistryMoveOverlay() {
const onPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return
const target = event.target as Element | null
if (!target?.closest('[data-floorplan-scene]')) return
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
const snapped = lastSnapped
if (snapped) {
const [sx, sz] = snapped
const [, oldY] = originalPosition
useScene
.getState()
.updateNode(movingNode.id as AnyNodeId, { position: [sx, oldY, sz] } as Partial<AnyNode>)
const meta = (movingNode as unknown as { metadata?: Record<string, unknown> }).metadata
if (meta?.isNew) {
useScene.getState().updateNode(
if (!snapped) return
const [sx, sz] = snapped
const [, oldY] = originalPosition
setMovingNodeOrigin('2d')
let selectedId = movingNode.id as AnyNodeId
if (isFreshPlacement) {
selectedId =
commitFreshPlacementSubtree(
movingNode.id as AnyNodeId,
{
metadata: { ...meta, isNew: false },
position: [sx, oldY, sz],
metadata: stripPlacementMetadataFlags(
(movingNode as { metadata?: unknown }).metadata,
),
visible: true,
} as Partial<AnyNode>,
)
}
) ?? selectedId
} else {
useScene.getState().updateNode(
movingNode.id as AnyNodeId,
{
position: [sx, oldY, sz],
} as Partial<AnyNode>,
)
}
useViewer.getState().setSelection({ selectedIds: [selectedId] })
entry.removeAttribute('transform')
useAlignmentGuides.getState().clear()
setMovingNode(null)
swallowNextClick()
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setMovingNodeOrigin('2d')
if (isFreshPlacement) {
emitter.emit('tool:cancel')
const temporal = useScene.temporal.getState()
const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
if (wasTracking) temporal.pause()
useScene.getState().deleteNode(movingNode.id as AnyNodeId)
if (wasTracking) temporal.resume()
}
entry.removeAttribute('transform')
useAlignmentGuides.getState().clear()
setMovingNode(null)
@@ -557,3 +595,17 @@ function deepEqual(a: unknown, b: unknown): boolean {
}
return false
}
function swallowNextClick() {
const swallowClick = (e: MouseEvent) => {
e.stopPropagation()
e.preventDefault()
window.removeEventListener('click', swallowClick, true)
}
window.addEventListener('click', swallowClick, true)
// Safety net: if no click fires (e.g. user dragged enough to suppress it),
// drop the listener on the next tick.
setTimeout(() => {
window.removeEventListener('click', swallowClick, true)
}, 0)
}
@@ -23,13 +23,18 @@ import { memo, useEffect, useState } from 'react'
*/
export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
geometry,
pointerEventsOverride,
}: {
geometry: FloorplanGeometry
pointerEventsOverride?: string
}) {
return renderNode(geometry, 0)
return renderNode(geometry, 0, pointerEventsOverride)
})
function styleAttrs(g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> }) {
function styleAttrs(
g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
pointerEventsOverride?: string,
) {
// Shared SVG attribute mapping for any styled primitive. Keeps the per-
// primitive switch arms terse and ensures new style fields land
// everywhere at once. `as any` avoids re-asserting every variant
@@ -60,21 +65,37 @@ function styleAttrs(g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['ki
strokeOpacity: s.strokeOpacity,
opacity: s.opacity,
vectorEffect: s.vectorEffect,
pointerEvents: s.pointerEvents,
pointerEvents: pointerEventsOverride ?? s.pointerEvents,
style: s.cursor ? { cursor: s.cursor } : undefined,
}
}
function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | null {
function renderNode(
g: FloorplanGeometry,
keyHint: number,
pointerEventsOverride?: string,
): React.ReactElement | null {
switch (g.kind) {
case 'path':
return <path d={g.d} key={keyHint} {...styleAttrs(g)} />
return <path d={g.d} key={keyHint} {...styleAttrs(g, pointerEventsOverride)} />
case 'polygon':
return <polygon key={keyHint} points={pointsToAttr(g.points)} {...styleAttrs(g)} />
return (
<polygon
key={keyHint}
points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)}
/>
)
case 'polyline':
return <polyline key={keyHint} points={pointsToAttr(g.points)} {...styleAttrs(g)} />
return (
<polyline
key={keyHint}
points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)}
/>
)
case 'rect':
return (
@@ -86,15 +107,32 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement |
width={g.width}
x={g.x}
y={g.y}
{...styleAttrs(g)}
{...styleAttrs(g, pointerEventsOverride)}
/>
)
case 'circle':
return <circle cx={g.cx} cy={g.cy} key={keyHint} r={g.r} {...styleAttrs(g)} />
return (
<circle
cx={g.cx}
cy={g.cy}
key={keyHint}
r={g.r}
{...styleAttrs(g, pointerEventsOverride)}
/>
)
case 'line':
return <line key={keyHint} x1={g.x1} x2={g.x2} y1={g.y1} y2={g.y2} {...styleAttrs(g)} />
return (
<line
key={keyHint}
x1={g.x1}
x2={g.x2}
y1={g.y1}
y2={g.y2}
{...styleAttrs(g, pointerEventsOverride)}
/>
)
case 'text':
return (
@@ -112,6 +150,7 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement |
strokeLinejoin={g.stroke ? 'round' : undefined}
strokeWidth={g.strokeWidth}
textAnchor={g.textAnchor ?? 'start'}
pointerEvents={pointerEventsOverride}
x={g.x}
y={g.y}
>
@@ -137,7 +176,7 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement |
const transform = formatTransform(g.transform)
return (
<g key={keyHint} transform={transform}>
{g.children.map((child, i) => renderNode(child, i))}
{g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))}
</g>
)
}
@@ -187,11 +187,20 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const editorPhase = useEditor((s) => s.phase)
const editorMode = useEditor((s) => s.mode)
const editorTool = useEditor((s) => s.tool)
const structureLayer = useEditor((s) => s.structureLayer)
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const isOpeningPlacementActive =
(editorPhase === 'structure' &&
editorMode === 'build' &&
(editorTool === 'door' || editorTool === 'window')) ||
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
const isMarqueeSelectionActive =
editorMode === 'select' &&
floorplanSelectionTool === 'marquee' &&
structureLayer !== 'zones' &&
!movingNode &&
!movingFenceEndpoint
// Subscribe to the live-transforms map ref so the layer re-renders
// whenever a 3D mover publishes a per-frame position (see
// `usePlacementCoordinator`). Without this the 2D floor plan only
@@ -260,6 +269,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// tree the builder returns. Builders don't need to know about the
// partition.
const entries = useMemo(() => {
// Some builders read elevator runtime state imperatively; this keeps the memo subscribed.
void interactiveElevators
if (!levelId) return []
const out: {
id: AnyNodeId
@@ -273,6 +285,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const visit = (id: AnyNodeId) => {
const node = nodes[id]
if (!node) return
if ((node as { visible?: boolean }).visible === false) return
const def = nodeRegistry.get(node.type)
const builder = def?.floorplan
if (builder) {
@@ -373,6 +386,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const buildingScopedKindSet = new Set(buildingScopedKinds)
for (const [id, node] of Object.entries(nodes)) {
if (!node || !buildingScopedKindSet.has(node.type)) continue
if ((node as { visible?: boolean }).visible === false) continue
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
if (parentId !== activeBuildingId) continue
const cid = id as AnyNodeId
@@ -383,8 +397,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const highlighted = highlightedIdSet.has(cid)
const hovered = hoveredId === cid
const moving = movingNode?.id === cid
const live = liveTransforms.get(cid)
const hasPosition = Array.isArray((node as { position?: unknown }).position)
let effectiveNode: AnyNode =
live && hasPosition ? applyPositionLiveTransform(node, live) : node
const contextNodes = def?.floorplanSiblingOverrides
? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides })
: nodes
if (contextNodes !== nodes) {
const merged = contextNodes[cid]
if (merged) {
effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged
}
}
const ctx: GeometryContext = {
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined => nodes[rid] as N | undefined,
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
contextNodes[rid] as N | undefined,
children: [],
siblings: [],
parent: activeLevelNode,
@@ -399,12 +427,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
: undefined,
}
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
node,
effectiveNode,
ctx,
)
if (geometry) {
const { base, overlay } = splitFloorplanOverlay(geometry)
out.push({ id: cid, node, base, overlay, selected, highlighted })
out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted })
}
}
}
@@ -693,8 +721,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
className="floorplan-registry-entry"
data-node-id={id}
key={key}
onClick={isOpeningPlacementActive ? undefined : handleClickStop}
onPointerDown={isOpeningPlacementActive ? undefined : (e) => handleSelect(id, e)}
onClick={isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handleClickStop}
onPointerDown={
isOpeningPlacementActive || isMarqueeSelectionActive
? undefined
: (e) => handleSelect(id, e)
}
// Mirror the sidebar tree nodes' hover wiring — `useViewer.
// hoveredId` drives the highlight halo in 3D as well as the
// wall / fence floor-plan hover stroke. Setting it on
@@ -716,6 +748,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
geometry={geometry}
hatchPatternId={renderCtx?.hatchPatternId}
hoveredHandleId={hoveredHandleId}
isMarqueeSelectionActive={isMarqueeSelectionActive}
nodeId={id}
onHandleHoverChange={setHoveredHandleId}
onHandlePointerDown={(affordance, payload, event, rotationPivot) =>
@@ -807,6 +840,7 @@ function InteractiveGeometry({
hatchPatternId,
hoveredHandleId,
activeDragId,
isMarqueeSelectionActive,
nodeId,
sceneRotationDeg,
onHandleHoverChange,
@@ -819,6 +853,7 @@ function InteractiveGeometry({
hatchPatternId: string | undefined
hoveredHandleId: string | null
activeDragId: string | null
isMarqueeSelectionActive: boolean
nodeId: AnyNodeId
sceneRotationDeg: number
onHandleHoverChange: (id: string | null) => void
@@ -860,7 +895,7 @@ function InteractiveGeometry({
return (
<line
key={keyHint}
pointerEvents={g.pointerEvents ?? 'stroke'}
pointerEvents={isMarqueeSelectionActive ? 'none' : (g.pointerEvents ?? 'stroke')}
stroke="transparent"
strokeLinecap="round"
strokeWidth={g.strokeWidthPx * unitsPerPixel}
@@ -1562,7 +1597,13 @@ function InteractiveGeometry({
)
}
default:
return <FloorplanGeometryRenderer geometry={g} key={keyHint} />
return (
<FloorplanGeometryRenderer
geometry={g}
key={keyHint}
pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
/>
)
}
}
}
@@ -12,7 +12,14 @@ import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Box3, Vector3 } from 'three'
import {
Box3,
type Camera,
type OrthographicCamera,
type PerspectiveCamera,
Spherical,
Vector3,
} from 'three'
import { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor'
@@ -23,8 +30,13 @@ const tempDelta = new Vector3()
const tempPosition = new Vector3()
const tempSize = new Vector3()
const tempTarget = new Vector3()
const syncTarget = new Vector3()
const syncSpherical = new Spherical()
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001
type CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
type CameraPoseSnapshot = {
mode: CameraMode
@@ -64,6 +76,86 @@ function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot
)
}
function isEditableKeyboardTarget(target: EventTarget | null) {
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
)
}
type CameraViewportSize = {
width: number
height: number
}
function isPerspectiveCamera(camera: Camera): camera is PerspectiveCamera {
return (camera as PerspectiveCamera).isPerspectiveCamera === true
}
function isOrthographicCamera(camera: Camera): camera is OrthographicCamera {
return (camera as OrthographicCamera).isOrthographicCamera === true
}
function getCameraViewAspect(size: CameraViewportSize) {
return Math.max(size.width, 1) / Math.max(size.height, 1)
}
function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewportSize) {
if (isPerspectiveCamera(camera)) {
const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180
return Math.max(0.001, 2 * distance * Math.tan(fovRadians / 2) * getCameraViewAspect(size))
}
if (isOrthographicCamera(camera)) {
return Math.max(0.001, (camera.right - camera.left) / camera.zoom)
}
return Math.max(0.001, distance)
}
function getCameraDistanceForViewWidth(
camera: Camera,
viewWidth: number,
size: CameraViewportSize,
) {
if (!isPerspectiveCamera(camera)) {
return null
}
const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180
const denominator = 2 * Math.tan(fovRadians / 2) * getCameraViewAspect(size)
return denominator > 0 ? Math.max(0.001, viewWidth / denominator) : null
}
function getCameraZoomForViewWidth(camera: Camera, viewWidth: number) {
if (!isOrthographicCamera(camera)) {
return null
}
return viewWidth > 0 ? Math.max(0.001, (camera.right - camera.left) / viewWidth) : null
}
function applyCameraViewWidth(
control: CameraControlsImpl,
camera: Camera,
viewWidth: number,
size: CameraViewportSize,
) {
const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size)
if (nextDistance !== null) {
control.dollyTo(nextDistance, true)
return
}
const nextZoom = getCameraZoomForViewWidth(camera, viewWidth)
if (nextZoom !== null) {
control.zoomTo(nextZoom, true)
}
}
function useFirstPersonCameraPoseRestore(
controls: { current: CameraControlsImpl | null },
isFirstPersonMode: boolean,
@@ -131,6 +223,7 @@ export const CustomCameraControls = () => {
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
const selection = useViewer((s) => s.selection)
const cameraMode = useViewer((state) => state.cameraMode)
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
@@ -140,11 +233,19 @@ export const CustomCameraControls = () => {
)
const currentLevelId = selection.levelId
const firstLoad = useRef(true)
const lastPublishedNavigationSync = useRef<{
target: [number, number, number]
azimuth: number
viewWidth: number
} | null>(null)
const lastApplied2dNavigationRevision = useRef(0)
const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
const camera = useThree((state) => state.camera)
const gl = useThree((state) => state.gl)
const raycaster = useThree((state) => state.raycaster)
const viewportSize = useThree((state) => state.size)
useEffect(() => {
camera.layers.enable(EDITOR_LAYER)
camera.layers.enable(GRID_LAYER)
@@ -209,6 +310,73 @@ export const CustomCameraControls = () => {
[isPreviewMode, isFirstPersonMode],
)
useEffect(() => {
if (isFirstPersonMode) return
return useEditor.subscribe((state) => {
const pose = state.navigationSyncPose
if (
!pose ||
pose.source !== '2d' ||
pose.revision === lastApplied2dNavigationRevision.current
)
return
const control = controls.current
if (!control) return
lastApplied2dNavigationRevision.current = pose.revision
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
control.rotateTo(pose.azimuth, control.polarAngle, true)
applyCameraViewWidth(control, camera, pose.viewWidth, viewportSize)
})
}, [camera, isFirstPersonMode, viewportSize])
const publishCurrentNavigationPose = useCallback(() => {
if (isFirstPersonMode || !controls.current) return
controls.current.getTarget(syncTarget, false)
controls.current.getSpherical(syncSpherical, false)
const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize)
const previous = lastPublishedNavigationSync.current
if (
previous &&
Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(previous.azimuth - syncSpherical.theta) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
) {
return
}
lastPublishedNavigationSync.current = {
target: [syncTarget.x, syncTarget.y, syncTarget.z],
azimuth: syncSpherical.theta,
viewWidth,
}
useEditor.getState().publishNavigationSyncPose({
source: '3d',
target: [syncTarget.x, syncTarget.y, syncTarget.z],
azimuth: syncSpherical.theta,
viewWidth,
})
}, [camera, isFirstPersonMode, viewportSize])
useEffect(() => {
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
const frame = requestAnimationFrame(() => {
lastPublishedNavigationSync.current = null
publishCurrentNavigationPose()
})
return () => {
cancelAnimationFrame(frame)
}
}, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
// Configure mouse buttons based on control mode and camera mode
const mouseButtons = useMemo(() => {
// Use ZOOM for orthographic camera, DOLLY for perspective camera
@@ -284,6 +452,45 @@ export const CustomCameraControls = () => {
controlLeft: false,
space: false,
}
let ownsNavigationCursor = false
let panPointerId: number | null = null
let panPointerButton: number | null = null
const setNavigationCursor = (cursor: 'grab' | 'grabbing') => {
document.body.style.cursor = cursor
gl.domElement.style.cursor = cursor
ownsNavigationCursor = true
}
const clearNavigationCursor = () => {
if (
ownsNavigationCursor &&
(document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing')
) {
document.body.style.cursor = ''
}
if (ownsNavigationCursor && gl.domElement.style.cursor === 'grab') {
gl.domElement.style.cursor = ''
}
if (ownsNavigationCursor && gl.domElement.style.cursor === 'grabbing') {
gl.domElement.style.cursor = ''
}
ownsNavigationCursor = false
}
const updateNavigationCursor = () => {
if (panPointerId !== null) {
setNavigationCursor('grabbing')
return
}
if (keyState.space) {
setNavigationCursor('grab')
return
}
clearNavigationCursor()
}
const updateConfig = () => {
if (!controls.current) return
@@ -311,8 +518,10 @@ export const CustomCameraControls = () => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.code === 'Space') {
if (isEditableKeyboardTarget(event.target)) return
event.preventDefault()
keyState.space = true
document.body.style.cursor = 'grab'
updateNavigationCursor()
}
if (event.code === 'ShiftRight') {
keyState.shiftRight = true
@@ -332,7 +541,11 @@ export const CustomCameraControls = () => {
const onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Space') {
keyState.space = false
document.body.style.cursor = ''
if (panPointerButton === 0) {
panPointerId = null
panPointerButton = null
}
updateNavigationCursor()
}
if (event.code === 'ShiftRight') {
keyState.shiftRight = false
@@ -349,16 +562,51 @@ export const CustomCameraControls = () => {
updateConfig()
}
const onPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
panPointerId = event.pointerId
panPointerButton = event.button
updateNavigationCursor()
}
const onPointerUp = (event: PointerEvent) => {
if (panPointerId === null) return
if (event.type !== 'pointercancel' && event.pointerId !== panPointerId) return
if (event.type !== 'pointercancel' && event.button !== panPointerButton) return
panPointerId = null
panPointerButton = null
updateNavigationCursor()
}
const onBlur = () => {
keyState.space = false
panPointerId = null
panPointerButton = null
clearNavigationCursor()
updateConfig()
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
window.addEventListener('pointerdown', onPointerDown, true)
window.addEventListener('pointerup', onPointerUp, true)
window.addEventListener('pointercancel', onPointerUp, true)
window.addEventListener('blur', onBlur)
updateConfig()
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
document.body.style.cursor = ''
window.removeEventListener('pointerdown', onPointerDown, true)
window.removeEventListener('pointerup', onPointerUp, true)
window.removeEventListener('pointercancel', onPointerUp, true)
window.removeEventListener('blur', onBlur)
clearNavigationCursor()
}
}, [cameraMode, isPreviewMode, isFirstPersonMode])
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
// Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode
@@ -669,6 +917,7 @@ export const CustomCameraControls = () => {
minDistance={minDistance}
minPolarAngle={0}
mouseButtons={mouseButtons}
onUpdate={publishCurrentNavigationPose}
onRest={onRest}
onSleep={onRest}
onTransitionStart={onTransitionStart}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,19 @@
'use client'
import { type AnyNode, type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import {
CORNER_OFFSET,
classifyParticipant,
@@ -41,7 +48,10 @@ export function GroupMoveHandle() {
const nodes = useScene((s) => s.nodes)
const participantIds = useMemo(
() => selectedIds.filter((id) => classifyParticipant(nodes[id as AnyNodeId], levelId) !== null),
() =>
selectedIds.filter(
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
),
[selectedIds, levelId, nodes],
)
@@ -103,6 +113,7 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
frozenCorner.current = rest.corner.clone()
const planeY = rest.baseY
@@ -159,18 +170,28 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
lastSnap = [dx, dz]
}
const overrides = useLiveNodeOverrides.getState()
const overrideEntries: Array<readonly [string, Record<string, unknown>]> = []
const liveTransforms = useLiveTransforms.getState()
for (const s of starts) {
if (s.kind === 'endpoint') {
overrides.set(s.id, {
start: [s.start[0] + dx, s.start[1] + dz],
end: [s.end[0] + dx, s.end[1] + dz],
})
overrideEntries.push([
s.id,
{
start: [s.start[0] + dx, s.start[1] + dz],
end: [s.end[0] + dx, s.end[1] + dz],
},
])
} else {
// Slide on the floor: XZ shift, Y and rotation untouched.
overrides.set(s.id, {
position: [s.position[0] + dx, s.position[1], s.position[2] + dz],
})
const position: [number, number, number] = [
s.position[0] + dx,
s.position[1],
s.position[2] + dz,
]
overrideEntries.push([s.id, { position }])
if (s.kind === 'scalar') {
liveTransforms.set(s.id, { position, rotation: s.rotation })
}
}
useScene.getState().markDirty(s.id)
}
@@ -178,16 +199,31 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
// Shared endpoints of connected neighbours follow by the same delta so
// the junction stays welded; the far end stays put.
for (const l of links) {
overrides.set(l.id, {
start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
})
overrideEntries.push([
l.id,
{
start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
},
])
useScene.getState().markDirty(l.id)
}
useLiveNodeOverrides.getState().setMany(overrideEntries)
setLiveDelta([dx, dz])
}
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
const clearLivePreviews = () => {
const overrides = useLiveNodeOverrides.getState()
const liveTransforms = useLiveTransforms.getState()
for (const id of affectedIds) {
overrides.clear(id)
liveTransforms.clear(id)
useScene.getState().markDirty(id)
}
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
@@ -201,8 +237,6 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
dragCleanupRef.current = null
}
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
const commitFromOverrides = () => {
const overrides = useLiveNodeOverrides.getState()
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
@@ -223,22 +257,22 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
// tracked set — collapsing the whole group move into one undo.
useScene.temporal.getState().resume()
if (updates.length > 0) useScene.getState().updateNodes(updates)
for (const id of affectedIds) {
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
clearLivePreviews()
cleanup()
}
const onCancel = () => {
for (const id of affectedIds) {
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
clearLivePreviews()
cleanup()
}
dragCleanupRef.current = cleanup
dragCleanupRef.current = () => {
clearLivePreviews()
cleanup()
}
for (const id of affectedIds) {
useLiveTransforms.getState().clear(id)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel)
@@ -1,12 +1,19 @@
'use client'
import { type AnyNode, type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import {
CORNER_OFFSET,
classifyParticipant,
@@ -35,11 +42,11 @@ import {
const ROTATE_SNAP = Math.PI / 12 // 15°
/**
* Group-rotate gizmo. When 2+ "movable" nodes (position + rotation, sitting
* directly on the active level) are selected, a single rotation handle appears
* at the selection's bounding-box center. Dragging it spins every selected node
* rigidly around that shared center — orbiting each node's position AND turning
* its yaw by the same delta, so the group rotates as one piece.
* Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
* selected, a single rotation handle appears at the selection's bounding-box
* center. Dragging it spins every selected node rigidly around that shared
* center — orbiting each node's position AND turning its yaw by the same delta,
* so the group rotates as one piece.
*
* The single-selection case is handled by `NodeArrowHandles`; a full-level
* box-select promotes to a building selection, so neither reaches this gizmo.
@@ -55,7 +62,10 @@ export function GroupRotateHandle() {
const nodes = useScene((s) => s.nodes)
const participantIds = useMemo(
() => selectedIds.filter((id) => classifyParticipant(nodes[id as AnyNodeId], levelId) !== null),
() =>
selectedIds.filter(
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
),
[selectedIds, levelId, nodes],
)
@@ -123,6 +133,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
const center = rest.pivot.clone()
@@ -200,10 +211,14 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const dz = z - center.z
return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
}
const overrides = useLiveNodeOverrides.getState()
const overrideEntries: Array<readonly [string, Record<string, unknown>]> = []
const liveTransforms = useLiveTransforms.getState()
for (const s of starts) {
if (s.kind === 'endpoint') {
overrides.set(s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) })
overrideEntries.push([
s.id,
{ start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) },
])
} else {
const [px, pz] = rot(s.position[0], s.position[2])
const position: Vec3 = [px, s.position[1], pz]
@@ -211,7 +226,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
s.kind === 'vec3'
? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
: s.rotation - delta
overrides.set(s.id, { position, rotation })
overrideEntries.push([s.id, { position, rotation }])
if (s.kind === 'scalar') {
liveTransforms.set(s.id, { position, rotation: s.rotation - delta })
}
}
useScene.getState().markDirty(s.id)
}
@@ -220,12 +238,16 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
// (rot is deterministic, so it lands exactly on the selected wall's
// rotated endpoint), keeping the junction welded; the far end stays put.
for (const l of links) {
overrides.set(l.id, {
start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
})
overrideEntries.push([
l.id,
{
start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
},
])
useScene.getState().markDirty(l.id)
}
useLiveNodeOverrides.getState().setMany(overrideEntries)
if (Math.abs(delta) < 0.0087) {
setGuide(null)
@@ -247,6 +269,17 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
}
}
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
const clearLivePreviews = () => {
const overrides = useLiveNodeOverrides.getState()
const liveTransforms = useLiveTransforms.getState()
for (const id of affectedIds) {
overrides.clear(id)
liveTransforms.clear(id)
useScene.getState().markDirty(id)
}
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
@@ -260,8 +293,6 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
dragCleanupRef.current = null
}
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
const commitFromOverrides = () => {
const overrides = useLiveNodeOverrides.getState()
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
@@ -282,23 +313,23 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
// one tracked set — collapsing the whole group rotation into one undo.
useScene.temporal.getState().resume()
if (updates.length > 0) useScene.getState().updateNodes(updates)
for (const id of affectedIds) {
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
clearLivePreviews()
cleanup()
}
const onCancel = () => {
// Revert: drop overrides + mark dirty so renderers rebuild from the store.
for (const id of affectedIds) {
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
clearLivePreviews()
cleanup()
}
dragCleanupRef.current = cleanup
dragCleanupRef.current = () => {
clearLivePreviews()
cleanup()
}
for (const id of affectedIds) {
useLiveTransforms.getState().clear(id)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel)
@@ -0,0 +1,205 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
import { z } from 'zod'
import { classifyParticipant, collectParticipants } from './group-transform-shared'
const BUILDING_SCOPED_KIND = 'group-transform-building-scoped-test'
function registerBuildingScopedTestKind() {
if (nodeRegistry.has(BUILDING_SCOPED_KIND)) return
registerNode({
kind: BUILDING_SCOPED_KIND,
schemaVersion: 1,
schema: z.object({ type: z.literal(BUILDING_SCOPED_KIND) }) as never,
category: 'structure',
defaults: () => ({}),
capabilities: {},
floorplanScope: 'building',
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
} as AnyNodeDefinition)
}
function registerElevatorTestKind() {
if (nodeRegistry.has('elevator')) return
registerNode({
kind: 'elevator',
schemaVersion: 1,
schema: z.object({ type: z.literal('elevator') }) as never,
category: 'structure',
defaults: () => ({}),
capabilities: { selectable: {} },
floorplanScope: 'building',
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
} as AnyNodeDefinition)
}
describe('group transform participants', () => {
beforeAll(() => {
registerBuildingScopedTestKind()
registerElevatorTestKind()
})
test('includes building-scoped positioned nodes for the active level building', () => {
const nodes = {
building_test: {
id: 'building_test',
type: 'building',
children: ['level_test', 'elevator_test'],
},
level_test: {
id: 'level_test',
type: 'level',
parentId: 'building_test',
children: [],
},
elevator_test: {
id: 'elevator_test',
type: BUILDING_SCOPED_KIND,
parentId: 'building_test',
position: [1, 0, 2],
rotation: 0,
},
} as unknown as Record<string, AnyNode>
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
const participants = collectParticipants(['elevator_test'], nodes, 'level_test')
expect(participants.starts).toEqual([
{
id: 'elevator_test',
kind: 'scalar',
position: [1, 0, 2],
rotation: 0,
},
])
})
test('excludes building-scoped positioned nodes from other buildings', () => {
const nodes = {
building_active: {
id: 'building_active',
type: 'building',
children: ['level_test'],
},
building_other: {
id: 'building_other',
type: 'building',
children: ['elevator_test'],
},
level_test: {
id: 'level_test',
type: 'level',
parentId: 'building_active',
children: [],
},
elevator_test: {
id: 'elevator_test',
type: BUILDING_SCOPED_KIND,
parentId: 'building_other',
position: [1, 0, 2],
rotation: 0,
},
} as unknown as Record<string, AnyNode>
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBeNull()
expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([])
})
test('uses current elevator defaults for legacy elevators with no saved rotation', () => {
const nodes = {
building_test: {
id: 'building_test',
type: 'building',
children: ['level_test', 'elevator_test'],
},
level_test: {
id: 'level_test',
type: 'level',
parentId: 'building_test',
children: [],
},
elevator_test: {
id: 'elevator_test',
type: 'elevator',
parentId: 'building_test',
position: [3, 0, 4],
},
} as unknown as Record<string, AnyNode>
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([
{
id: 'elevator_test',
kind: 'scalar',
position: [3, 0, 4],
rotation: 0,
},
])
})
test('resolves building-scoped elevators when legacy level parentId is missing', () => {
const nodes = {
building_test: {
id: 'building_test',
type: 'building',
children: ['level_test', 'elevator_test'],
},
level_test: {
id: 'level_test',
type: 'level',
parentId: null,
children: [],
},
elevator_test: {
id: 'elevator_test',
type: 'elevator',
parentId: 'building_test',
position: [7, 0, 8],
},
} as unknown as Record<string, AnyNode>
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([
{
id: 'elevator_test',
kind: 'scalar',
position: [7, 0, 8],
rotation: 0,
},
])
})
test('supports legacy level-parented elevators already loaded in the editor', () => {
const nodes = {
building_test: {
id: 'building_test',
type: 'building',
children: ['level_test'],
},
level_test: {
id: 'level_test',
type: 'level',
parentId: 'building_test',
children: ['elevator_test'],
},
elevator_test: {
id: 'elevator_test',
type: 'elevator',
parentId: 'level_test',
position: [5, 0, 6],
},
} as unknown as Record<string, AnyNode>
expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar')
expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([
{
id: 'elevator_test',
kind: 'scalar',
position: [5, 0, 6],
rotation: 0,
},
])
})
})
@@ -1,4 +1,10 @@
import { type AnyNode, type AnyNodeId, sceneRegistry } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
nodeRegistry,
resolveBuildingForLevel,
sceneRegistry,
} from '@pascal-app/core'
import { Box3 } from 'three'
// Shared plumbing for the group transform gizmos (rotate + move). Both operate
@@ -26,20 +32,61 @@ const isVec2 = (v: unknown): v is Vec2 =>
// - 'endpoint' start/end tuples (walls, fences)
export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint'
// A selected node qualifies when it sits directly on the active level and its
// placement is one of the transformable shapes. Doors/windows parent to their
// wall (not the level), so they're excluded here and ride their wall.
// A selected node qualifies when it belongs to the active level's horizontal
// frame: either parented to that level, or declared building-scoped and parented
// to the active level's building. Doors/windows parent to their wall, so they're
// excluded here and ride their wall.
function isInGroupTransformScope(
node: AnyNode | undefined,
levelId: string | null,
sceneNodes: Record<string, AnyNode | undefined>,
): boolean {
if (!node || !levelId) return false
if (node.parentId === levelId) return true
if (nodeRegistry.get(node.type)?.floorplanScope !== 'building') {
return false
}
const buildingId = resolveBuildingForLevel(
levelId as AnyNodeId,
sceneNodes as Record<AnyNodeId, AnyNode>,
)
return Boolean(buildingId && node.parentId === buildingId)
}
function getLegacyScenePosition(node: AnyNode): Vec3 | null {
if (node.type !== 'elevator') return null
const object = sceneRegistry.nodes.get(node.id)
if (!object) return [0, 0, 0]
return [object.position.x, object.position.y, object.position.z]
}
function getParticipantPosition(node: AnyNode): Vec3 | null {
const p = (node as { position?: unknown }).position
if (isVec3(p)) return p
return getLegacyScenePosition(node)
}
function getParticipantScalarRotation(node: AnyNode): number | null {
const r = (node as { rotation?: unknown }).rotation
if (typeof r === 'number' && Number.isFinite(r)) return r
if (node.type !== 'elevator') return null
return sceneRegistry.nodes.get(node.id)?.rotation.y ?? 0
}
export function classifyParticipant(
node: AnyNode | undefined,
levelId: string | null,
sceneNodes: Record<string, AnyNode | undefined>,
): ParticipantKind | null {
if (!node || node.parentId !== levelId) return null
const p = (node as { position?: unknown }).position
if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null
const p = getParticipantPosition(node)
const r = (node as { rotation?: unknown }).rotation
const start = (node as { start?: unknown }).start
const end = (node as { end?: unknown }).end
if (isVec3(p) && isVec3(r)) return 'vec3'
if (isVec3(p) && typeof r === 'number') return 'scalar'
if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar'
if (isVec2(start) && isVec2(end)) return 'endpoint'
return null
}
@@ -74,23 +121,27 @@ export function collectParticipants(
const starts: ParticipantStart[] = []
for (const id of ids) {
const node = sceneNodes[id]
const kind = classifyParticipant(node, levelId)
const kind = classifyParticipant(node, levelId, sceneNodes)
if (!node || !kind) continue
if (kind === 'vec3') {
const n = node as AnyNode & { position: Vec3; rotation: Vec3 }
const position = getParticipantPosition(node)
if (!position) continue
starts.push({
id: id as AnyNodeId,
kind,
position: [n.position[0], n.position[1], n.position[2]],
position: [position[0], position[1], position[2]],
rotation: [n.rotation[0], n.rotation[1], n.rotation[2]],
})
} else if (kind === 'scalar') {
const n = node as AnyNode & { position: Vec3; rotation: number }
const position = getParticipantPosition(node)
const rotation = getParticipantScalarRotation(node)
if (!(position && rotation !== null)) continue
starts.push({
id: id as AnyNodeId,
kind,
position: [n.position[0], n.position[1], n.position[2]],
rotation: n.rotation,
position: [position[0], position[1], position[2]],
rotation,
})
} else {
const n = node as AnyNode & { start: Vec2; end: Vec2 }
@@ -112,7 +163,7 @@ export function collectParticipants(
const selected = new Set(starts.map((s) => s.id))
for (const [nid, node] of Object.entries(sceneNodes)) {
if (selected.has(nid as AnyNodeId)) continue
if (classifyParticipant(node, levelId) !== 'endpoint') continue
if (classifyParticipant(node, levelId, sceneNodes) !== 'endpoint') continue
const n = node as AnyNode & { start: Vec2; end: Vec2 }
const start: Vec2 = [n.start[0], n.start[1]]
const end: Vec2 = [n.end[0], n.end[1]]
@@ -138,7 +189,7 @@ export function expandToComponent(
): string[] {
const endpoints: { id: string; start: Vec2; end: Vec2 }[] = []
for (const [id, node] of Object.entries(sceneNodes)) {
if (classifyParticipant(node, levelId) === 'endpoint') {
if (classifyParticipant(node, levelId, sceneNodes) === 'endpoint') {
const n = node as AnyNode & { start: Vec2; end: Vec2 }
endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] })
}
@@ -13,6 +13,7 @@ import { type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { type Camera, type Object3D, type Plane, Vector2, type Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
export type HandleDragControls = {
onStart: (index: number, snapshot: AnyNode) => void
@@ -77,6 +78,26 @@ export function swallowNextClick() {
}, 300)
}
function suppressInputDraggingUntilPointerRelease(pointerId: number) {
const previousInputDragging = useViewer.getState().inputDragging
useViewer.getState().setInputDragging(true)
function restore(event?: PointerEvent) {
if (event && event.pointerId !== pointerId) return
useViewer.getState().setInputDragging(previousInputDragging)
window.removeEventListener('pointerup', restore)
window.removeEventListener('pointercancel', restore)
window.removeEventListener('blur', onBlur)
}
function onBlur() {
restore()
}
window.addEventListener('pointerup', restore)
window.addEventListener('pointercancel', restore)
window.addEventListener('blur', onBlur)
}
export function useHandleDrag(args: UseHandleDragArgs) {
const { camera, raycaster, gl } = useThree()
const dragCleanupRef = useRef<(() => void) | null>(null)
@@ -85,8 +106,11 @@ export function useHandleDrag(args: UseHandleDragArgs) {
return (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
if (args.kind === 'tap') {
suppressInputDraggingUntilPointerRelease(event.nativeEvent.pointerId)
swallowNextClick()
sfxEmitter.emit('sfx:item-pick')
document.body.style.cursor = ''
args.onTap(event)
@@ -341,6 +341,7 @@ const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
{
action: 'Pan',
keys: [{ value: 'Space' }, { value: 'Left click' }],
alternativeKeys: [{ value: 'Middle click' }],
},
{ action: 'Rotate', keys: [{ value: 'Right click' }] },
{ action: 'Zoom', keys: [{ value: 'Scroll' }] },
@@ -125,6 +125,7 @@ export function NodeArrowHandles() {
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const placementDragMode = useEditor((state) => state.placementDragMode)
// Endpoint / curve drags reshape the selected wall or fence; hide its
// resize arrows for the duration so they don't clutter (or get blocked
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
@@ -150,6 +151,8 @@ export function NodeArrowHandles() {
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
[rawNode, liveOverride],
)
const isOwnPressDragMove =
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
const def = node ? nodeRegistry.get(node.type) : null
const descriptors = useMemo(() => {
@@ -163,7 +166,7 @@ export function NodeArrowHandles() {
Boolean(node && descriptors?.length) &&
!isFloorplanHovered &&
mode !== 'delete' &&
!movingNode &&
(!movingNode || isOwnPressDragMove) &&
!movingWallEndpoint &&
!movingFenceEndpoint &&
!curvingWall &&
@@ -26,6 +26,7 @@ import {
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { swallowNextClick } from './handles/use-handle-drag'
const ACCENT = 0x83_81_ed
@@ -199,6 +200,7 @@ function resetPointerCursor() {
function stopPointerPropagation(event: ThreeEvent<PointerEvent>) {
event.stopPropagation()
suppressBoxSelectForPointer(event)
event.nativeEvent.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
}
@@ -34,6 +34,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import {
createArrowHitAreaGeometry,
createEndpointHitAreaGeometry,
@@ -329,6 +330,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick')
document.body.style.cursor = 'grabbing'
useEditor.getState().setMovingWallEndpoint({ wall, endpoint })
@@ -432,6 +434,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
if (!levelObject) return
@@ -603,6 +606,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick')
@@ -693,6 +697,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick')
@@ -22,10 +22,14 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
import { PlacementBox } from '../shared/placement-box'
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
@@ -155,6 +159,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
)
const [valid, setValid] = useState(true)
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
useFreshPlacementVisibility({ node })
// Mirrors of `valid` / Shift for the event handlers inside the effect, which
// can't read React state without stale closures.
const validRef = useRef(true)
@@ -180,6 +186,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
setCursorRotationY(originalRotationY)
lastCursorRef.current = originalPosition
let committed = false
const isNew = isFreshPlacement
const baseRotation = (node as { rotation?: unknown }).rotation
const toCommitRotation = (y: number): number | [number, number, number] =>
@@ -271,11 +278,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const onGridMove = (event: GridEvent) => {
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
dragAnchorRef.current = anchor
revealFreshPlacement()
let x = originalPosition[0] + snapToGridStep(rawX - anchor[0])
let z = originalPosition[2] + snapToGridStep(rawZ - anchor[1])
const resolved = resolvePlanarCursorPosition({
cursor: [rawX, rawZ],
original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
snap: snapToGridStep,
})
dragAnchorRef.current = resolved.anchor
let [x, z] = resolved.point
// Figma-style alignment snap layered on top of grid snap: when the
// moving item's edge lines up (on X or Z) with another item's edge,
@@ -358,12 +371,32 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const rotation = toCommitRotation(rotationRef.current)
const visualPosition = getVisualPosition(position)
let committedId = node.id as AnyNodeId
if (useScene.getState().nodes[node.id]) {
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, { position, rotation } as Partial<AnyNode>)
useScene.temporal.getState().pause()
committed = true
const data = {
position,
rotation,
...(isNew
? {
metadata: stripPlacementMetadataFlags(node.metadata),
visible: true,
}
: null),
} as Partial<AnyNode>
if (isNew) {
const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data)
if (finalId) {
committed = true
committedId = finalId
}
} else {
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, data)
useScene.temporal.getState().pause()
committed = true
}
} else if (node.parentId) {
// Orphan re-create path: re-parse via the registry's schema.
const def = nodeRegistry.get(node.type)
@@ -393,8 +426,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
}
useAlignmentGuides.getState().clear()
if (isNew && committed) {
useViewer.getState().setSelection({ selectedIds: [committedId] })
}
sfxEmitter.emit('sfx:item-place')
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode()
// Stop further propagation so other listeners (e.g. a selection
@@ -470,13 +507,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(...getVisualPosition(originalPosition, originalRotationY))
m.rotation.y = originalRotationY
if (isNew) {
useScene.getState().deleteNode(node.id as AnyNodeId)
} else {
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(...getVisualPosition(originalPosition, originalRotationY))
m.rotation.y = originalRotationY
}
markMovedNodeDirty()
}
useAlignmentGuides.getState().clear()
markMovedNodeDirty()
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
@@ -499,16 +540,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// Drop any alignment guides this drag published — covers Esc / mid-drag
// unmount / commit paths uniformly.
useAlignmentGuides.getState().clear()
if (!committed) {
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
if (!(committed || isNew || finalisedBy2D)) {
useLiveTransforms.getState().clear(node.id)
sceneRegistry.nodes
.get(node.id)
?.position.set(...getVisualPosition(originalPosition, originalRotationY))
markMovedNodeDirty()
useScene.temporal.getState().resume()
}
useScene.temporal.getState().resume()
}
}, [boxDimensions, exitMoveMode, node, originalPosition, originalRotationY])
}, [
boxDimensions,
exitMoveMode,
isFreshPlacement,
node,
originalPosition,
originalRotationY,
revealFreshPlacement,
useAbsoluteCursorPlacement,
])
if (!previewVisible) return null
if (boxDimensions) {
return (
@@ -1,6 +1,21 @@
export let boxSelectHandled = false
let resetTimeout: ReturnType<typeof setTimeout> | null = null
const suppressedPointerIds = new Set<number>()
const suppressionCleanups = new Map<number, () => void>()
type PointerEventLike = {
pointerId?: number
nativeEvent?: PointerEvent | PointerEventLike
}
function pointerIdFor(event: PointerEvent | PointerEventLike): number | null {
if ('pointerId' in event && typeof event.pointerId === 'number') {
return event.pointerId
}
const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : undefined
return nativeEvent ? pointerIdFor(nativeEvent) : null
}
export function markBoxSelectHandled() {
boxSelectHandled = true
@@ -13,10 +28,50 @@ export function markBoxSelectHandled() {
}, 50)
}
export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLike) {
markBoxSelectHandled()
const pointerId = pointerIdFor(event)
if (pointerId === null || suppressedPointerIds.has(pointerId)) return
suppressedPointerIds.add(pointerId)
const clear = (releaseEvent?: PointerEvent) => {
if (releaseEvent && releaseEvent.pointerId !== pointerId) return
markBoxSelectHandled()
suppressedPointerIds.delete(pointerId)
const cleanup = suppressionCleanups.get(pointerId)
suppressionCleanups.delete(pointerId)
cleanup?.()
}
const onPointerUp = (releaseEvent: PointerEvent) => clear(releaseEvent)
const onPointerCancel = (releaseEvent: PointerEvent) => clear(releaseEvent)
const onBlur = () => clear()
const cleanup = () => {
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel)
window.removeEventListener('blur', onBlur)
}
suppressionCleanups.set(pointerId, cleanup)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerCancel)
window.addEventListener('blur', onBlur)
}
export function isBoxSelectPointerSuppressed(event: PointerEvent | PointerEventLike) {
const pointerId = pointerIdFor(event)
return pointerId !== null && suppressedPointerIds.has(pointerId)
}
export function clearBoxSelectHandled() {
if (resetTimeout) {
clearTimeout(resetTimeout)
resetTimeout = null
}
boxSelectHandled = false
for (const cleanup of suppressionCleanups.values()) cleanup()
suppressionCleanups.clear()
suppressedPointerIds.clear()
}
@@ -4,17 +4,25 @@ import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import { Box3, type Camera, type Object3D, Vector3 } from 'three'
import useEditor from '../../../store/use-editor'
import { clearBoxSelectHandled, markBoxSelectHandled } from './box-select-state'
import {
clearBoxSelectHandled,
isBoxSelectPointerSuppressed,
markBoxSelectHandled,
} from './box-select-state'
import { PlaneBoxSelectTool } from './plane-box-select-tool'
import {
createScreenRectangleSelectionElement,
hideScreenRectangleSelectionElement,
intersectScreenRects,
normalizeScreenRect,
SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX,
type ScreenRect,
screenRectFromDomRect,
screenRectsIntersect,
updateScreenRectangleSelectionElement,
} from './screen-rectangle-selection'
import { collectSelectableCandidateIds } from './select-candidates'
type ScreenRect = { minX: number; minY: number; maxX: number; maxY: number }
const BOX_SELECT_FILL_COLOR = 'rgba(129, 140, 248, 0.14)'
const BOX_SELECT_BORDER_COLOR = 'rgba(129, 140, 248, 0.9)'
const BOX_SELECT_SHADOW_COLOR = 'rgba(129, 140, 248, 0.28)'
const DRAG_THRESHOLD_PX = 4
const tempBox = new Box3()
const tempWorldPoint = new Vector3()
const tempScreenPoint = new Vector3()
@@ -36,76 +44,6 @@ function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
)
}
function createSelectionElement(): HTMLDivElement {
const element = document.createElement('div')
element.style.position = 'fixed'
element.style.display = 'none'
element.style.pointerEvents = 'none'
element.style.zIndex = '2147483647'
element.style.border = `1px solid ${BOX_SELECT_BORDER_COLOR}`
element.style.background = BOX_SELECT_FILL_COLOR
element.style.boxShadow = `0 0 0 1px ${BOX_SELECT_SHADOW_COLOR} inset`
element.style.contain = 'layout paint style'
return element
}
function normalizeScreenRect(
startX: number,
startY: number,
endX: number,
endY: number,
): ScreenRect {
return {
minX: Math.min(startX, endX),
minY: Math.min(startY, endY),
maxX: Math.max(startX, endX),
maxY: Math.max(startY, endY),
}
}
function updateSelectionElement(element: HTMLDivElement, rect: ScreenRect) {
element.style.display = 'block'
element.style.left = `${rect.minX}px`
element.style.top = `${rect.minY}px`
element.style.width = `${Math.max(0, rect.maxX - rect.minX)}px`
element.style.height = `${Math.max(0, rect.maxY - rect.minY)}px`
}
function hideSelectionElement(element: HTMLDivElement | null) {
if (!element) return
element.style.display = 'none'
element.style.width = '0px'
element.style.height = '0px'
}
function screenRectsIntersect(a: ScreenRect, b: ScreenRect): boolean {
return !(b.maxX < a.minX || b.minX > a.maxX || b.maxY < a.minY || b.minY > a.maxY)
}
function screenRectFromDomRect(rect: DOMRect): ScreenRect {
return {
minX: rect.left,
minY: rect.top,
maxX: rect.right,
maxY: rect.bottom,
}
}
function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | null {
const rect = {
minX: Math.max(a.minX, b.minX),
minY: Math.max(a.minY, b.minY),
maxX: Math.min(a.maxX, b.maxX),
maxY: Math.min(a.maxY, b.maxY),
}
if (rect.maxX <= rect.minX || rect.maxY <= rect.minY) {
return null
}
return rect
}
function projectWorldPointToScreen(
point: Vector3,
camera: Camera,
@@ -268,7 +206,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
pointerDownRef.current = false
isDraggingRef.current = false
pointerIdRef.current = null
hideSelectionElement(elementRef.current)
hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([])
if (ownsInputDraggingRef.current) {
@@ -278,7 +216,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
}, [syncPreviewSelectedIds])
useEffect(() => {
const element = createSelectionElement()
const element = createScreenRectangleSelectionElement()
document.body.appendChild(element)
elementRef.current = element
@@ -331,6 +269,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
const viewer = useViewer.getState()
if (
isBoxSelectPointerSuppressed(event) ||
spaceDownRef.current ||
viewer.cameraDragging ||
(viewer.inputDragging && !ownsInputDraggingRef.current)
@@ -348,7 +287,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
currentClientYRef.current - startClientYRef.current,
)
if (!isDraggingRef.current && dragDistance >= DRAG_THRESHOLD_PX) {
if (!isDraggingRef.current && dragDistance >= SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX) {
isDraggingRef.current = true
ownsInputDraggingRef.current = true
useViewer.getState().setInputDragging(true)
@@ -372,12 +311,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
screenRectFromDomRect(canvas.getBoundingClientRect()),
)
if (!clampedRect) {
hideSelectionElement(elementRef.current)
hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([])
return
}
updateSelectionElement(elementRef.current!, clampedRect)
updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas))
}
@@ -385,7 +324,10 @@ const ScreenRectangleSelectTool: React.FC = () => {
if (!pointerDownRef.current) return
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
if (useViewer.getState().inputDragging && !ownsInputDraggingRef.current) {
if (
isBoxSelectPointerSuppressed(event) ||
(useViewer.getState().inputDragging && !ownsInputDraggingRef.current)
) {
markBoxSelectHandled()
resetDrag()
return
@@ -420,6 +362,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
const onCanvasPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (spaceDownRef.current) return
if (isBoxSelectPointerSuppressed(event)) return
const viewer = useViewer.getState()
if (viewer.cameraDragging || viewer.inputDragging) return
@@ -31,7 +31,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { markBoxSelectHandled } from './box-select-state'
import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state'
import { collectSelectableCandidateIds } from './select-candidates'
declare module 'react/jsx-runtime' {
@@ -407,6 +407,7 @@ export const PlaneBoxSelectTool: React.FC = () => {
const onCanvasPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (spaceDownRef.current) return
if (isBoxSelectPointerSuppressed(event)) return
if (useViewer.getState().cameraDragging) return
if (useViewer.getState().inputDragging) return
@@ -426,7 +427,8 @@ export const PlaneBoxSelectTool: React.FC = () => {
const onCanvasPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return
if (useViewer.getState().inputDragging) {
if (isBoxSelectPointerSuppressed(event) || useViewer.getState().inputDragging) {
markBoxSelectHandled()
resetDrag()
return
}
@@ -494,7 +496,16 @@ export const PlaneBoxSelectTool: React.FC = () => {
}
if (!pointerDown.current) return
if (spaceDownRef.current || useViewer.getState().inputDragging) return
if (isBoxSelectPointerSuppressed(event.nativeEvent)) {
markBoxSelectHandled()
resetDrag()
return
}
if (spaceDownRef.current || useViewer.getState().inputDragging) {
markBoxSelectHandled()
resetDrag()
return
}
currentPoint.current.set(snappedX, event.position[1], snappedZ)
@@ -538,7 +549,7 @@ export const PlaneBoxSelectTool: React.FC = () => {
return () => {
emitter.off('grid:move', onMove)
}
}, [syncPreviewSelectedIds])
}, [resetDrag, syncPreviewSelectedIds])
return (
<group>
@@ -0,0 +1,84 @@
export type ScreenRect = {
minX: number
minY: number
maxX: number
maxY: number
}
export const SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX = 4
const SCREEN_RECTANGLE_SELECTION_FILL_COLOR = 'rgba(129, 140, 248, 0.14)'
const SCREEN_RECTANGLE_SELECTION_BORDER_COLOR = 'rgba(129, 140, 248, 0.9)'
const SCREEN_RECTANGLE_SELECTION_SHADOW_COLOR = 'rgba(129, 140, 248, 0.28)'
export function createScreenRectangleSelectionElement(): HTMLDivElement {
const element = document.createElement('div')
element.style.position = 'fixed'
element.style.display = 'none'
element.style.pointerEvents = 'none'
element.style.zIndex = '2147483647'
element.style.border = `1px solid ${SCREEN_RECTANGLE_SELECTION_BORDER_COLOR}`
element.style.background = SCREEN_RECTANGLE_SELECTION_FILL_COLOR
element.style.boxShadow = `0 0 0 1px ${SCREEN_RECTANGLE_SELECTION_SHADOW_COLOR} inset`
element.style.contain = 'layout paint style'
return element
}
export function normalizeScreenRect(
startX: number,
startY: number,
endX: number,
endY: number,
): ScreenRect {
return {
minX: Math.min(startX, endX),
minY: Math.min(startY, endY),
maxX: Math.max(startX, endX),
maxY: Math.max(startY, endY),
}
}
export function screenRectFromDomRect(rect: DOMRect | DOMRectReadOnly): ScreenRect {
return {
minX: rect.left,
minY: rect.top,
maxX: rect.right,
maxY: rect.bottom,
}
}
export function screenRectsIntersect(a: ScreenRect, b: ScreenRect): boolean {
return !(b.maxX < a.minX || b.minX > a.maxX || b.maxY < a.minY || b.minY > a.maxY)
}
export function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | null {
const rect = {
minX: Math.max(a.minX, b.minX),
minY: Math.max(a.minY, b.minY),
maxX: Math.min(a.maxX, b.maxX),
maxY: Math.min(a.maxY, b.maxY),
}
if (rect.maxX <= rect.minX || rect.maxY <= rect.minY) {
return null
}
return rect
}
export function updateScreenRectangleSelectionElement(element: HTMLDivElement, rect: ScreenRect) {
element.style.display = 'block'
element.style.left = `${rect.minX}px`
element.style.top = `${rect.minY}px`
element.style.width = `${Math.max(0, rect.maxX - rect.minX)}px`
element.style.height = `${Math.max(0, rect.maxY - rect.minY)}px`
}
export function hideScreenRectangleSelectionElement(element: HTMLDivElement | null) {
if (!element) {
return
}
element.style.display = 'none'
element.style.width = '0px'
element.style.height = '0px'
}
@@ -0,0 +1,109 @@
import { beforeAll, beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeDefinition,
nodeRegistry,
registerNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { z } from 'zod'
import useEditor from '../../../store/use-editor'
import { collectSelectableCandidateIds } from './select-candidates'
function registerSelectableElevatorTestKind() {
if (nodeRegistry.has('elevator')) return
registerNode({
kind: 'elevator',
schemaVersion: 1,
schema: z.object({ type: z.literal('elevator') }) as never,
category: 'structure',
defaults: () => ({}),
capabilities: { selectable: {} },
floorplanScope: 'building',
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
} as AnyNodeDefinition)
}
describe('selectable candidates', () => {
beforeAll(() => {
registerSelectableElevatorTestKind()
})
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useViewer.setState({
selection: {
buildingId: 'building_test',
levelId: 'level_test',
zoneId: null,
selectedIds: [],
},
previewSelectedIds: [],
})
useEditor.setState({
phase: 'structure',
structureLayer: 'elements',
})
})
test('includes building-scoped elevators for the active level building', () => {
useScene.setState({
nodes: {
building_test: {
id: 'building_test',
type: 'building',
children: ['level_test', 'elevator_test'],
},
level_test: {
id: 'level_test',
type: 'level',
parentId: 'building_test',
children: [],
},
elevator_test: {
id: 'elevator_test',
type: 'elevator',
parentId: 'building_test',
position: [1, 0, 2],
rotation: 0,
},
} as unknown as Record<string, AnyNode>,
} as never)
expect(collectSelectableCandidateIds()).toContain('elevator_test')
})
test('includes legacy level-parented elevators already loaded in the editor', () => {
useScene.setState({
nodes: {
building_test: {
id: 'building_test',
type: 'building',
children: ['level_test'],
},
level_test: {
id: 'level_test',
type: 'level',
parentId: 'building_test',
children: ['elevator_test'],
},
elevator_test: {
id: 'elevator_test',
type: 'elevator',
parentId: 'level_test',
position: [1, 0, 2],
rotation: 0,
},
} as unknown as Record<string, AnyNode>,
} as never)
expect(collectSelectableCandidateIds()).toContain('elevator_test')
})
})
@@ -5,43 +5,15 @@ import {
type LevelNode,
nodeRegistry,
resolveBuildingForLevel,
resolveLevelId,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../../store/use-editor'
export function isFurnishSelectableCandidate(node: AnyNode): boolean {
if (node.type === 'item') {
return node.asset.category !== 'door' && node.asset.category !== 'window'
}
const def = nodeRegistry.get(node.type)
return Boolean(def?.category === 'furnish' && def.capabilities.selectable)
}
export function isStructureSelectableCandidate(node: AnyNode): boolean {
if (
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'elevator' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
node.type === 'stair' ||
node.type === 'spawn' ||
node.type === 'window' ||
node.type === 'door'
) {
return true
}
if (node.type === 'item') {
return node.asset.category === 'door' || node.asset.category === 'window'
}
const def = nodeRegistry.get(node.type)
return Boolean(def && def.category !== 'furnish' && def.capabilities.selectable)
function isVisibleSelectableNode(node: AnyNode): boolean {
if ((node as { visible?: boolean }).visible === false) return false
return isRegistrySelectable(node.type)
}
export function collectSelectableCandidateIds(): string[] {
@@ -51,10 +23,23 @@ export function collectSelectableCandidateIds(): string[] {
const result: string[] = []
const seen = new Set<string>()
const addNode = (node: AnyNode | undefined) => {
if (!node || seen.has(node.id)) return
if (!node || seen.has(node.id) || (node as { visible?: boolean }).visible === false) return
seen.add(node.id)
result.push(node.id)
}
const visitLevelDescendant = (id: AnyNodeId) => {
const node = nodes[id]
if (!node || seen.has(node.id) || (node as { visible?: boolean }).visible === false) return
if (isRegistrySelectable(node.type)) {
addNode(node)
}
const children = 'children' in node && Array.isArray(node.children) ? node.children : []
for (const childId of children) {
visitLevelDescendant(childId as AnyNodeId)
}
}
if (phase === 'site') {
for (const node of Object.values(nodes)) {
@@ -76,49 +61,22 @@ export function collectSelectableCandidateIds(): string[] {
}
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node) continue
if (phase === 'furnish') {
if (isFurnishSelectableCandidate(node)) addNode(node)
continue
}
if (node.type === 'wall' || node.type === 'fence') {
addNode(node)
const hostedChildren = 'children' in node && Array.isArray(node.children) ? node.children : []
for (const hostedChildId of hostedChildren) {
const child = nodes[hostedChildId as AnyNodeId]
if (!child) continue
if (
child.type === 'window' ||
child.type === 'door' ||
(child.type === 'item' &&
(child.asset.category === 'door' || child.asset.category === 'window'))
) {
addNode(child)
}
}
continue
}
if (isStructureSelectableCandidate(node)) {
addNode(node)
}
visitLevelDescendant(childId as AnyNodeId)
}
const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes)
const buildingNode = buildingId ? nodes[buildingId] : undefined
const buildingChildren =
buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children)
? (buildingNode.children as AnyNodeId[])
: []
for (const childId of buildingChildren) {
const node = nodes[childId]
if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue
if (phase === 'furnish') {
if (isFurnishSelectableCandidate(node)) addNode(node)
} else if (isStructureSelectableCandidate(node)) {
for (const node of Object.values(nodes)) {
if (!node || node.type === 'level' || !isVisibleSelectableNode(node)) continue
const def = nodeRegistry.get(node.type)
const isBuildingScoped = def?.floorplanScope === 'building'
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
if (isBuildingScoped && buildingId && parentId === buildingId) {
addNode(node)
continue
}
if (!isBuildingScoped && resolveLevelId(node, nodes) === levelId) {
addNode(node)
}
}
@@ -0,0 +1,61 @@
'use client'
import { type AnyNode, type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { useCallback, useRef, useState } from 'react'
import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
import useEditor from '../../../store/use-editor'
type FreshPlacementNode = Pick<AnyNode, 'id' | 'metadata'>
type FreshPlacementVisibilityArgs = {
node: FreshPlacementNode
enabled?: boolean
}
export function useFreshPlacementVisibility({
node,
enabled = true,
}: FreshPlacementVisibilityArgs) {
const isFreshPlacement = enabled && isFreshPlacementMetadata(node.metadata)
const useAbsoluteCursorPlacement = isFreshPlacement && !useEditor.getState().placementDragMode
const shouldStartHidden = useAbsoluteCursorPlacement
const [visibility, setVisibility] = useState(() => ({
nodeId: node.id,
visible: !shouldStartHidden,
}))
const visibilityRef = useRef(visibility)
const previewVisible = visibility.nodeId === node.id ? visibility.visible : !shouldStartHidden
const setPreviewVisibleForNode = useCallback(
(visible: boolean) => {
const current = visibilityRef.current
if (current.nodeId === node.id && current.visible === visible) return
const next = { nodeId: node.id, visible }
visibilityRef.current = next
setVisibility(next)
},
[node.id],
)
const revealFreshPlacement = useCallback(() => {
if (!isFreshPlacement) return
setPreviewVisibleForNode(true)
sceneRegistry.nodes.get(node.id)?.traverse((child) => {
child.visible = true
})
const liveNode = useScene.getState().nodes[node.id as AnyNodeId]
if (liveNode?.visible === false) {
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true } as Partial<AnyNode>)
}
}, [isFreshPlacement, node.id, setPreviewVisibleForNode])
return {
isFreshPlacement,
previewVisible,
revealFreshPlacement,
useAbsoluteCursorPlacement,
}
}
@@ -6,6 +6,7 @@ import {
emitter,
type GridEvent,
type LevelNode,
movingAlignmentAnchors,
type NodeEvent,
resolveAlignment,
StairNode,
@@ -295,14 +296,30 @@ export const StairTool: React.FC = () => {
}
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. The stair aligns by its ORIGIN point.
// after each placement. The moving stair aligns by its footprint edges so
// users can snap the run side against walls, slabs, elevators, or another
// stair instead of only lining up the invisible origin point.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
// Snap the stair origin 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 snap. Alt bypasses.
const resolveStairFootprintAlignment = (
x: number,
z: number,
rotation: number,
): ReturnType<typeof resolveAlignment> | null => {
const preview = buildPreviewScene([x, 0, z], rotation)
const moving = preview
? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation)
: []
if (moving.length === 0) return null
return resolveAlignment({
moving,
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
}
// The probe is the RAW cursor, not the grid-snapped point: resolving
// against the grid point would only catch anchors that happen to sit near
// a grid line. Matched axes use the raw probe + snap delta; unmatched axes
// keep the normal grid snap. Alt bypasses.
const alignPoint = (
gridX: number,
gridZ: number,
@@ -314,22 +331,19 @@ export const StairTool: React.FC = () => {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
const ar = resolveAlignment({
moving: [{ nodeId: '__stair-draft__', kind: 'corner', x: rawX, z: rawZ }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
const ar = resolveStairFootprintAlignment(rawX, rawZ, rotationRef.current)
if (!ar || ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
useAlignmentGuides.getState().set(ar.guides)
let x = gridX
let z = gridZ
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
if (ar.snap) {
if (ar.guides.some((guide) => guide.axis === 'x')) x = rawX + ar.snap.dx
if (ar.guides.some((guide) => guide.axis === 'z')) z = rawZ + ar.snap.dz
}
const finalAlignment = resolveStairFootprintAlignment(x, z, rotationRef.current)
useAlignmentGuides.getState().set(finalAlignment?.guides ?? ar.guides)
return [x, z]
}
+13
View File
@@ -61,6 +61,7 @@ export {
export { CursorSphere } from './components/tools/shared/cursor-sphere'
export { DragBoundingBox } from './components/tools/shared/drag-bounding-box'
export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview'
export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility'
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export {
PolygonEditor,
@@ -195,6 +196,7 @@ export {
type FloorplanStairSegmentEntry,
getFloorplanWallThickness,
} from './lib/floorplan'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export {
buildResetSurfaceMaterialUpdates,
buildRoofSurfaceMaterialPatch,
@@ -204,6 +206,17 @@ export {
getActivePaintMaterialLabel,
hasActivePaintMaterial,
} from './lib/material-paint'
export {
addFreshPlacementMetadata,
getPlacementMetadataRecord,
isFreshPlacementMetadata,
stripPlacementMetadataFlags,
} from './lib/placement-metadata'
export {
type PlanarCursorPlacementMode,
type PlanarPoint,
resolvePlanarCursorPosition,
} from './lib/planar-cursor-placement'
export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication'
export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { useScene } from '@pascal-app/core'
import { commitFreshPlacementSubtree } from './fresh-planar-placement'
type RafFn = (cb: (time: number) => void) => number
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
cb: (time: number) => void,
) => {
cb(0)
return 0
}) as RafFn
;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
const LEVEL_ID = 'level_test' as AnyNodeId
const SHELF_ID = 'shelf_draft' as AnyNodeId
function level(children: AnyNodeId[]): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children,
level: 0,
} as AnyNode
}
function shelf(): AnyNode {
return {
id: SHELF_ID,
type: 'shelf',
object: 'node',
parentId: LEVEL_ID,
visible: false,
metadata: { isNew: true, label: 'draft' },
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
width: 1.2,
depth: 0.3,
thickness: 0.04,
height: 0.9,
style: 'wall-shelf',
rows: 1,
columns: 1,
withBack: false,
withSides: true,
withBottom: false,
bracketStyle: 'minimal',
} as AnyNode
}
describe('commitFreshPlacementSubtree', () => {
beforeEach(() => {
useScene.setState({
nodes: {
[LEVEL_ID]: level([SHELF_ID]),
[SHELF_ID]: shelf(),
},
rootNodeIds: [LEVEL_ID],
collections: {},
dirtyNodes: new Set(),
} as never)
useScene.temporal.getState().clear()
useScene.temporal.getState().resume()
})
test('commits a fresh draft as one undoable clean subtree', () => {
useScene.temporal.getState().pause()
const committedId = commitFreshPlacementSubtree(SHELF_ID, {
position: [2, 0, 3],
visible: true,
} as Partial<AnyNode>)
expect(committedId).toBeTruthy()
expect(committedId).not.toBe(SHELF_ID)
const finalId = committedId as AnyNodeId
expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
const committed = useScene.getState().nodes[finalId] as
| (AnyNode & { position: [number, number, number]; metadata?: Record<string, unknown> })
| undefined
expect(committed?.position).toEqual([2, 0, 3])
expect(committed?.visible).toBe(true)
expect(committed?.metadata?.isNew).toBeUndefined()
expect(committed?.metadata?.label).toBe('draft')
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([
finalId,
])
useScene.temporal.getState().resume()
useScene.temporal.getState().undo()
expect(useScene.getState().nodes[finalId]).toBeUndefined()
expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
})
})
@@ -0,0 +1,63 @@
import {
type AnyNode,
type AnyNodeId,
cloneNodesInto,
collectSubtree,
useScene,
} from '@pascal-app/core'
import { stripPlacementMetadataFlags } from './placement-metadata'
function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
return {
...node,
metadata: stripPlacementMetadataFlags(node.metadata),
} as N
}
function parentIdOf(node: AnyNode): AnyNodeId | undefined {
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
return parentId ?? undefined
}
/**
* Finalises a fresh catalog/duplicate draft as a single undoable creation.
*
* Fresh drafts already exist in the scene so renderers and move tools can
* preview real geometry. On commit we delete that draft while history is
* paused, then create a clean clone at the final cursor position with history
* resumed. Undo therefore removes the placed node instead of resurrecting the
* hidden draft at its origin.
*/
export function commitFreshPlacementSubtree(
rootId: AnyNodeId,
rootPatch: Partial<AnyNode>,
): AnyNodeId | null {
const scene = useScene.getState()
const subtree = collectSubtree(scene.nodes, rootId)
if (!subtree) return null
const root = cleanPlacementMetadata({
...subtree.root,
...rootPatch,
} as AnyNode)
const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
const parentId = parentIdOf(root)
const cloned = cloneNodesInto([root, ...descendants], {
rootId,
parentId,
})
const temporal = useScene.temporal.getState()
const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
if (wasTracking) temporal.pause()
useScene.getState().deleteNode(rootId)
temporal.resume()
useScene
.getState()
.createNodes(
cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
)
if (!wasTracking) temporal.pause()
return cloned.rootId
}
@@ -0,0 +1,29 @@
export function getPlacementMetadataRecord(metadata: unknown): Record<string, unknown> {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return {}
}
return metadata as Record<string, unknown>
}
export function addFreshPlacementMetadata(metadata: unknown): Record<string, unknown> {
return {
...getPlacementMetadataRecord(metadata),
isNew: true,
}
}
export function isFreshPlacementMetadata(metadata: unknown): boolean {
return getPlacementMetadataRecord(metadata).isNew === true
}
export function stripPlacementMetadataFlags(metadata: unknown): unknown {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
const nextMeta = { ...(metadata as Record<string, unknown>) }
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta
}
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'bun:test'
import { resolvePlanarCursorPosition } from './planar-cursor-placement'
const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5
describe('resolvePlanarCursorPosition', () => {
test('absolute mode places the point directly at the snapped cursor', () => {
const result = resolvePlanarCursorPosition({
cursor: [1.24, -2.26],
original: [10, 10],
anchor: null,
mode: 'absolute',
snap: snapHalf,
})
expect(result.point).toEqual([1, -2.5])
expect(result.anchor).toBeNull()
})
test('relative mode preserves the original grab offset from the first cursor sample', () => {
const start = resolvePlanarCursorPosition({
cursor: [4.1, 6.1],
original: [10, 20],
anchor: null,
mode: 'relative',
snap: snapHalf,
})
expect(start.point).toEqual([10, 20])
expect(start.anchor).toEqual([4.1, 6.1])
const moved = resolvePlanarCursorPosition({
cursor: [4.9, 5.2],
original: [10, 20],
anchor: start.anchor,
mode: 'relative',
snap: snapHalf,
})
expect(moved.point).toEqual([11, 19])
expect(moved.anchor).toEqual([4.1, 6.1])
})
})
@@ -0,0 +1,42 @@
export type PlanarPoint = [number, number]
export type PlanarCursorPlacementMode = 'absolute' | 'relative'
type ResolvePlanarCursorPositionArgs = {
cursor: PlanarPoint
original: PlanarPoint
anchor: PlanarPoint | null
mode: PlanarCursorPlacementMode
snap?: (value: number) => number
}
type ResolvePlanarCursorPositionResult = {
point: PlanarPoint
anchor: PlanarPoint | null
}
const identity = (value: number) => value
export function resolvePlanarCursorPosition({
cursor,
original,
anchor,
mode,
snap = identity,
}: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult {
if (mode === 'absolute') {
return {
point: [snap(cursor[0]), snap(cursor[1])],
anchor,
}
}
const resolvedAnchor = anchor ?? cursor
return {
point: [
original[0] + snap(cursor[0] - resolvedAnchor[0]),
original[1] + snap(cursor[1] - resolvedAnchor[1]),
],
anchor: resolvedAnchor,
}
}
+1 -1
View File
@@ -175,7 +175,7 @@ export function duplicateRoofSubtree(
export function clearRoofDuplicateMetadata(
roofId: AnyNodeId,
updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata'>> = {},
updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata' | 'visible'>> = {},
) {
const scene = useScene.getState()
const roofNode = scene.nodes[roofId]
+6 -2
View File
@@ -1,6 +1,6 @@
'use client'
import { resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
import { nodeRegistry, resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor, {
hasCustomPersistedEditorUiState,
@@ -220,7 +220,11 @@ function getValidatedSelectionForScene(
const selectedIds = selection.selectedIds.filter((id) => {
const node = sceneNodes[id]
return Boolean(node) && resolveLevelId(node, sceneNodes) === levelId
if (!node) return false
if (resolveLevelId(node, sceneNodes) === levelId) return true
const def = nodeRegistry.get(node.type)
return def?.floorplanScope === 'building' && node.parentId === buildingId
})
return {
+22
View File
@@ -118,6 +118,18 @@ export type StructureLayer = 'zones' | 'elements'
export type FloorplanSelectionTool = 'click' | 'marquee'
export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
export type NavigationSyncSource = '2d' | '3d'
export type NavigationSyncPose = {
source: NavigationSyncSource
revision: number
target: [number, number, number]
azimuth: number
viewWidth: number
}
export type NavigationSyncPoseInput = Omit<NavigationSyncPose, 'revision'>
// Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool
@@ -326,6 +338,8 @@ type EditorState = {
toggleFloorplanOpen: () => void
isFloorplanHovered: boolean
setFloorplanHovered: (hovered: boolean) => void
navigationSyncPose: NavigationSyncPose | null
publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
floorplanSelectionTool: FloorplanSelectionTool
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep
@@ -850,6 +864,14 @@ const useEditor = create<EditorState>()(
}),
isFloorplanHovered: false,
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
navigationSyncPose: null,
publishNavigationSyncPose: (pose) =>
set((state) => ({
navigationSyncPose: {
...pose,
revision: (state.navigationSyncPose?.revision ?? 0) + 1,
},
})),
floorplanSelectionTool: 'click' as FloorplanSelectionTool,
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,