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
+2 -2
View File
@@ -1286,8 +1286,8 @@ export type FloorPlacedConfig = {
* serves both the static candidate and the moving node. * serves both the static candidate and the moving node.
* - `aabb` — an already-resolved XZ bounding box, for kinds whose plan * - `aabb` — an already-resolved XZ bounding box, for kinds whose plan
* shape isn't a centred rectangle (stair: a segment chain or annular * shape isn't a centred rectangle (stair: a segment chain or annular
* sector). Static candidates only — these kinds move by their origin, so * sector). The moving-anchor bridge can relocate these by patching the
* the box's relocation path never needs them. * proposed plan position and resolving the AABB again.
* *
* `nodes` is supplied only when a kind needs siblings / children to resolve * `nodes` is supplied only when a kind needs siblings / children to resolve
* its footprint (a straight stair walks its `stair-segment` children); box * its footprint (a straight stair walks its `stair-segment` children); box
@@ -13,6 +13,7 @@ import {
collectAlignmentAnchors, collectAlignmentAnchors,
footprintAABB, footprintAABB,
footprintAABBFrom, footprintAABBFrom,
movingAlignmentAnchors,
movingFootprintAnchors, movingFootprintAnchors,
polygonAnchors, polygonAnchors,
wallSegmentAnchors, wallSegmentAnchors,
@@ -196,6 +197,71 @@ describe('movingFootprintAnchors', () => {
}) })
}) })
describe('movingAlignmentAnchors', () => {
beforeEach(() => nodeRegistry._reset())
test('relocates a straight stair by its segment-chain footprint', () => {
registerNode(stairDef())
const nodes = {
st: node({
id: 'st',
type: 'stair',
position: [0, 0, 0],
rotation: 0,
stairType: 'straight',
width: 1,
children: ['seg'],
}),
seg: node({
id: 'seg',
type: 'stair-segment',
parentId: 'st',
width: 1,
length: 3,
height: 2.5,
attachmentSide: 'front',
}),
}
const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, 0)
expect(anchors).toHaveLength(4)
expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9.5, 10.5]))
expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([20, 23]))
})
test('rotation override drives a moving straight stair footprint', () => {
registerNode(stairDef())
const nodes = {
st: node({
id: 'st',
type: 'stair',
position: [0, 0, 0],
rotation: 0,
stairType: 'straight',
width: 1,
children: ['seg'],
}),
seg: node({
id: 'seg',
type: 'stair-segment',
parentId: 'st',
width: 1,
length: 3,
height: 2.5,
attachmentSide: 'front',
}),
}
const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, Math.PI / 2)
const xs = anchors.map((a) => a.x)
const zs = anchors.map((a) => a.z)
expect(Math.min(...xs)).toBeCloseTo(10, 10)
expect(Math.max(...xs)).toBeCloseTo(13, 10)
expect(Math.min(...zs)).toBeCloseTo(19.5, 10)
expect(Math.max(...zs)).toBeCloseTo(20.5, 10)
})
})
describe('wallSegmentAnchors', () => { describe('wallSegmentAnchors', () => {
test('returns both endpoints as corners and the chord midpoint as center', () => { test('returns both endpoints as corners and the chord midpoint as center', () => {
const anchors = wallSegmentAnchors('w', [0, 0], [4, 2]) const anchors = wallSegmentAnchors('w', [0, 0], [4, 2])
@@ -152,6 +152,61 @@ export function movingFootprintAnchors(
return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
} }
function relocatedPlanNode(node: AnyNode, x: number, z: number, rotationY?: number): AnyNode {
const position = (node as { position?: unknown }).position
const y = Array.isArray(position) && typeof position[1] === 'number' ? position[1] : 0
const relocated: Record<string, unknown> = {
...(node as Record<string, unknown>),
position: [x, y, z],
}
if (rotationY !== undefined && 'rotation' in node) {
const rotation = (node as { rotation?: unknown }).rotation
relocated.rotation = Array.isArray(rotation)
? [rotation[0] ?? 0, rotationY, rotation[2] ?? 0]
: rotationY
}
return relocated as AnyNode
}
/**
* Corner anchors for a moving node relocated to the proposed plan position.
* Covers both the centred-box path (`floorPlaced.footprint` /
* `alignmentFootprint: box`) and explicit AABB footprints such as stairs,
* whose occupied plan bounds depend on children or curved/spiral geometry.
*/
export function movingAlignmentAnchors(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode>> | undefined,
x: number,
z: number,
rotationY?: number,
): AlignmentAnchor[] {
const box = footprintAABBAt(node, x, z, rotationY)
if (box) return bboxCornerAnchors(node.id, box.minX, box.minZ, box.maxX, box.maxZ)
const alignment = nodeRegistry
.get(node.type)
?.capabilities?.alignmentFootprint?.(relocatedPlanNode(node, x, z, rotationY), nodes)
if (alignment?.shape === 'box') {
const aabb = footprintAABBFrom([x, 0, z], alignment.dimensions, alignment.rotation[1] ?? 0)
return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
}
if (alignment?.shape === 'aabb') {
return bboxCornerAnchors(
node.id,
alignment.minX,
alignment.minZ,
alignment.maxX,
alignment.maxZ,
)
}
return []
}
/** /**
* Alignment anchors for a wall segment: the two centerline endpoints + chord * Alignment anchors for a wall segment: the two centerline endpoints + chord
* midpoint, plus — when `thickness` is known — four **face** corner anchors, * midpoint, plus — when `thickness` is known — four **face** corner anchors,
+1
View File
@@ -15,6 +15,7 @@ export {
footprintAABB, footprintAABB,
footprintAABBAt, footprintAABBAt,
footprintAABBFrom, footprintAABBFrom,
movingAlignmentAnchors,
movingFootprintAnchors, movingFootprintAnchors,
nodeAlignmentAnchors, nodeAlignmentAnchors,
polygonAnchors, polygonAnchors,
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '../schema'
import useScene from './use-scene'
describe('scene elevator migrations', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
test('normalizes legacy level-parented elevators into building-scoped nodes', () => {
useScene.getState().setScene(
{
site_test: {
object: 'node',
id: 'site_test',
type: 'site',
parentId: null,
visible: true,
metadata: {},
children: ['building_test'],
},
building_test: {
object: 'node',
id: 'building_test',
type: 'building',
parentId: 'site_test',
visible: true,
metadata: {},
children: ['level_test'],
},
level_test: {
object: 'node',
id: 'level_test',
type: 'level',
parentId: 'building_test',
visible: true,
metadata: {},
children: ['elevator_test'],
level: 0,
},
elevator_test: {
object: 'node',
id: 'elevator_test',
type: 'elevator',
parentId: 'level_test',
visible: true,
metadata: {},
},
} as unknown as Record<string, AnyNode>,
['site_test'] as never,
)
const nodes = useScene.getState().nodes
const elevator = nodes.elevator_test as Extract<AnyNode, { type: 'elevator' }>
const level = nodes.level_test as Extract<AnyNode, { type: 'level' }>
const building = nodes.building_test as Extract<AnyNode, { type: 'building' }>
expect(elevator.parentId).toBe('building_test')
expect(elevator.position).toEqual([0, 0, 0])
expect(elevator.rotation).toBe(0)
expect(level.children).not.toContain('elevator_test')
expect(building.children).toContain('elevator_test')
})
test('migrates level-parented elevators when the level parentId is missing', () => {
useScene.getState().setScene(
{
site_test: {
object: 'node',
id: 'site_test',
type: 'site',
parentId: null,
visible: true,
metadata: {},
children: ['building_test'],
},
building_test: {
object: 'node',
id: 'building_test',
type: 'building',
parentId: 'site_test',
visible: true,
metadata: {},
children: ['level_test'],
},
level_test: {
object: 'node',
id: 'level_test',
type: 'level',
parentId: null,
visible: true,
metadata: {},
children: ['elevator_test'],
level: 0,
},
elevator_test: {
object: 'node',
id: 'elevator_test',
type: 'elevator',
parentId: 'level_test',
visible: true,
metadata: {},
},
} as unknown as Record<string, AnyNode>,
['site_test'] as never,
)
const nodes = useScene.getState().nodes
const elevator = nodes.elevator_test as Extract<AnyNode, { type: 'elevator' }>
const level = nodes.level_test as Extract<AnyNode, { type: 'level' }>
const building = nodes.building_test as Extract<AnyNode, { type: 'building' }>
expect(elevator.parentId).toBe('building_test')
expect(level.children).not.toContain('elevator_test')
expect(building.children).toContain('elevator_test')
})
})
+87
View File
@@ -7,6 +7,7 @@ import { BuildingNode } from '../schema'
import type { Collection, CollectionId } from '../schema/collections' import type { Collection, CollectionId } from '../schema/collections'
import { generateCollectionId } from '../schema/collections' import { generateCollectionId } from '../schema/collections'
import { DoorNode as DoorNodeSchema } from '../schema/nodes/door' import { DoorNode as DoorNodeSchema } from '../schema/nodes/door'
import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator'
import { LevelNode } from '../schema/nodes/level' import { LevelNode } from '../schema/nodes/level'
import { import {
getPitchFromActiveRoofHeight, getPitchFromActiveRoofHeight,
@@ -149,6 +150,84 @@ function normalizeShelfNode(node: Record<string, unknown>) {
return parsed.success ? parsed.data : null return parsed.success ? parsed.data : null
} }
function normalizeElevatorNode(node: Record<string, unknown>) {
const sanitized = {
...node,
position: getVector3(node.position, [0, 0, 0]),
rotation: getFiniteNumber(node.rotation, 0),
width: getFiniteNumber(node.width, 1.84),
depth: getFiniteNumber(node.depth, 1.84),
shaftWidth: node.shaftWidth === undefined ? undefined : getFiniteNumber(node.shaftWidth, 1.84),
shaftDepth: node.shaftDepth === undefined ? undefined : getFiniteNumber(node.shaftDepth, 1.84),
shaftWallThickness: getFiniteNumber(node.shaftWallThickness, 0.09),
cabHeight: getFiniteNumber(node.cabHeight, 2.35),
doorWidth: getFiniteNumber(node.doorWidth, 0.95),
doorHeight: getFiniteNumber(node.doorHeight, 2.1),
fromLevelId: getNullableString(node.fromLevelId),
toLevelId: getNullableString(node.toLevelId),
servedLevelIds:
node.servedLevelIds === undefined ? undefined : getStringArray(node.servedLevelIds),
disabledLevelIds: getStringArray(node.disabledLevelIds),
serviceOnlyLevelIds: getStringArray(node.serviceOnlyLevelIds),
defaultLevelId: getNullableString(node.defaultLevelId),
speed: getFiniteNumber(node.speed, 2.2),
doorDurationMs: getFiniteNumber(node.doorDurationMs, 900),
dwellMs: getFiniteNumber(node.dwellMs, 1400),
}
const parsed = ElevatorNodeSchema.safeParse(sanitized)
return parsed.success ? parsed.data : null
}
function findBuildingIdForLevel(levelId: string, nodes: Record<string, any>): string | null {
const level = nodes[levelId]
const directBuildingId = typeof level?.parentId === 'string' ? level.parentId : null
if (directBuildingId && nodes[directBuildingId]?.type === 'building') {
return directBuildingId
}
for (const [candidateId, candidate] of Object.entries(nodes)) {
if (candidate?.type !== 'building') continue
if (getStringArray(candidate.children).includes(levelId)) {
return candidateId
}
}
return null
}
function migrateElevatorParent(
id: string,
node: Record<string, unknown>,
nodes: Record<string, any>,
) {
const parentId = typeof node.parentId === 'string' ? node.parentId : null
if (!parentId) return node
const parent = parentId ? nodes[parentId] : null
if (parent?.type !== 'level') return node
const buildingId = findBuildingIdForLevel(parentId, nodes)
if (!buildingId) return node
const building = buildingId ? nodes[buildingId] : null
if (building?.type !== 'building') return node
nodes[parentId] = {
...parent,
children: getStringArray(parent.children).filter((childId) => childId !== id),
}
const buildingChildren = getStringArray(building.children)
nodes[buildingId] = {
...building,
children: buildingChildren.includes(id) ? buildingChildren : [...buildingChildren, id],
}
return {
...node,
parentId: buildingId,
}
}
function migrateWallSurfaceMaterials(node: Record<string, any>) { function migrateWallSurfaceMaterials(node: Record<string, any>) {
const hasInterior = const hasInterior =
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string' node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
@@ -440,6 +519,14 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
} }
} }
if (node.type === 'elevator') {
const parentMigrated = migrateElevatorParent(id, node, patchedNodes)
const normalized = normalizeElevatorNode(parentMigrated)
if (normalized) {
patchedNodes[id] = normalized
}
}
// Roof-segment hosting was added in this migration cycle (the same // Roof-segment hosting was added in this migration cycle (the same
// pattern as shelf above). Older segments saved before the schema // pattern as shelf above). Older segments saved before the schema
// gained `children` need the field initialised so // gained `children` need the field initialised so
@@ -6,12 +6,12 @@ import {
type AnyNodeId, type AnyNodeId,
bboxAnchors, bboxAnchors,
bboxCornerAnchors, bboxCornerAnchors,
emitter,
type FloorplanMoveTargetSession, type FloorplanMoveTargetSession,
nodeRegistry, nodeRegistry,
pauseSceneHistory, pauseSceneHistory,
resolveAlignment, resolveAlignment,
resumeSceneHistory, resumeSceneHistory,
snapPointToGrid,
useAlignmentGuides, useAlignmentGuides,
useLiveNodeOverrides, useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
@@ -19,12 +19,13 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' 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 { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts' import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
const GRID_STEP = 0.5
// Figma-style alignment snap threshold. Meters in world space; 8cm gives // Figma-style alignment snap threshold. Meters in world space; 8cm gives
// a comfortable "magnetic" pull at default zoom without fighting the // a comfortable "magnetic" pull at default zoom without fighting the
// grid snap. Held fixed for v1 — a future revision can scale this with // 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] 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` ─────────────────── // ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
if (hasMoveTarget && def?.floorplanMoveTarget) { if (hasMoveTarget && def?.floorplanMoveTarget) {
const sceneNodes = useScene.getState().nodes const sceneNodes = useScene.getState().nodes
@@ -109,26 +125,6 @@ export function FloorplanRegistryMoveOverlay() {
// all entries use the action menu now. // all entries use the action menu now.
let hasMovedSinceStart = false 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) => { const onMove = (event: PointerEvent) => {
// Skip 3D-canvas / other-UI cursor moves so the overlay only // Skip 3D-canvas / other-UI cursor moves so the overlay only
// tracks pointer events that actually correspond to a floor-plan // tracks pointer events that actually correspond to a floor-plan
@@ -175,6 +171,7 @@ export function FloorplanRegistryMoveOverlay() {
} }
session.commit() session.commit()
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
return return
} }
@@ -195,6 +192,24 @@ export function FloorplanRegistryMoveOverlay() {
if (changed) finalUpdates.push({ id: snap.id, data }) 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) { if (commitValid && finalUpdates.length > 0) {
// Single-undo dance: // Single-undo dance:
// 1. Revert to baseline while history is still paused. // 1. Revert to baseline while history is still paused.
@@ -206,24 +221,6 @@ export function FloorplanRegistryMoveOverlay() {
historyPaused = false historyPaused = false
} }
useScene.getState().updateNodes(finalUpdates) 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') sfxEmitter.emit('sfx:item-place')
// Re-select the moved node(s) — mirrors the legacy 3D move // Re-select the moved node(s) — mirrors the legacy 3D move
// tool. The action menu cleared selection on Move click so // 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 // reason as `onMove`: commits should land for any pointer-up
// inside the SVG viewport, including empty grid background. // inside the SVG viewport, including empty grid background.
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
if (!hasMovedSinceStart) return
// Commit using the LAST pointermove's state — no re-apply at // Commit using the LAST pointermove's state — no re-apply at
// pointer-up coords. A previous version re-applied here to // 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 // the following click are separate DOM events, so we listen on
// window in the capture phase to intercept the click before any // window in the capture phase to intercept the click before any
// bubble-phase handler (the floor-plan SVG) sees it. // bubble-phase handler (the floor-plan SVG) sees it.
const swallowClick = (e: MouseEvent) => { swallowNextClick()
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)
} }
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
@@ -300,6 +288,23 @@ export function FloorplanRegistryMoveOverlay() {
// its own restore — without this, both sides would race to // its own restore — without this, both sides would race to
// write the same baseline, harmless but wasteful. // write the same baseline, harmless but wasteful.
setMovingNodeOrigin('2d') 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. // Revert untracked, then resume — no history entry.
useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) { if (historyPaused) {
@@ -389,6 +394,9 @@ export function FloorplanRegistryMoveOverlay() {
position?: [number, number, number] position?: [number, number, number]
} }
).position ?? [0, 0, 0]) as [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 // 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, // `<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 lastSnapped: [number, number] | null = null
let dragAnchor: [number, number] | null = null
const onMove = (event: PointerEvent) => { const onMove = (event: PointerEvent) => {
// Same target guard as Path 1 — pointer must be over the floor // 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 scene; otherwise we'd react to 3D-canvas moves with garbage
// plan coords. // plan coords.
const target = event.target as Element | null if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
if (!target?.closest('[data-floorplan-scene]')) return
const m = toMeters(event.clientX, event.clientY) const m = toMeters(event.clientX, event.clientY)
if (!m) return if (!m) return
// 1) Grid snap baseline (unchanged behaviour with Alt held). // 1) Grid snap baseline. Fresh catalog placement is absolute under
const [gridX, gridZ] = snapPointToGrid([m[0], m[1]], GRID_STEP) // 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 // 2) Alignment snap layered on top. Treat the grid-snapped point
// as the "proposed" position so alignment competes from a stable // as the "proposed" position so alignment competes from a stable
@@ -467,33 +486,52 @@ export function FloorplanRegistryMoveOverlay() {
const onPointerUp = (event: PointerEvent) => { const onPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return if (event.button !== 0) return
const target = event.target as Element | null if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
if (!target?.closest('[data-floorplan-scene]')) return
const snapped = lastSnapped const snapped = lastSnapped
if (snapped) { if (!snapped) return
const [sx, sz] = snapped const [sx, sz] = snapped
const [, oldY] = originalPosition const [, oldY] = originalPosition
useScene setMovingNodeOrigin('2d')
.getState() let selectedId = movingNode.id as AnyNodeId
.updateNode(movingNode.id as AnyNodeId, { position: [sx, oldY, sz] } as Partial<AnyNode>) if (isFreshPlacement) {
const meta = (movingNode as unknown as { metadata?: Record<string, unknown> }).metadata selectedId =
if (meta?.isNew) { commitFreshPlacementSubtree(
movingNode.id as AnyNodeId,
{
position: [sx, oldY, sz],
metadata: stripPlacementMetadataFlags(
(movingNode as { metadata?: unknown }).metadata,
),
visible: true,
} as Partial<AnyNode>,
) ?? selectedId
} else {
useScene.getState().updateNode( useScene.getState().updateNode(
movingNode.id as AnyNodeId, movingNode.id as AnyNodeId,
{ {
metadata: { ...meta, isNew: false }, position: [sx, oldY, sz],
} as Partial<AnyNode>, } as Partial<AnyNode>,
) )
} }
} useViewer.getState().setSelection({ selectedIds: [selectedId] })
entry.removeAttribute('transform') entry.removeAttribute('transform')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
setMovingNode(null) setMovingNode(null)
swallowNextClick()
} }
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') { 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') entry.removeAttribute('transform')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
setMovingNode(null) setMovingNode(null)
@@ -557,3 +595,17 @@ function deepEqual(a: unknown, b: unknown): boolean {
} }
return false 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({ export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
geometry, geometry,
pointerEventsOverride,
}: { }: {
geometry: FloorplanGeometry 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- // Shared SVG attribute mapping for any styled primitive. Keeps the per-
// primitive switch arms terse and ensures new style fields land // primitive switch arms terse and ensures new style fields land
// everywhere at once. `as any` avoids re-asserting every variant // 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, strokeOpacity: s.strokeOpacity,
opacity: s.opacity, opacity: s.opacity,
vectorEffect: s.vectorEffect, vectorEffect: s.vectorEffect,
pointerEvents: s.pointerEvents, pointerEvents: pointerEventsOverride ?? s.pointerEvents,
style: s.cursor ? { cursor: s.cursor } : undefined, 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) { switch (g.kind) {
case 'path': case 'path':
return <path d={g.d} key={keyHint} {...styleAttrs(g)} /> return <path d={g.d} key={keyHint} {...styleAttrs(g, pointerEventsOverride)} />
case 'polygon': 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': 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': case 'rect':
return ( return (
@@ -86,15 +107,32 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement |
width={g.width} width={g.width}
x={g.x} x={g.x}
y={g.y} y={g.y}
{...styleAttrs(g)} {...styleAttrs(g, pointerEventsOverride)}
/> />
) )
case 'circle': 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': 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': case 'text':
return ( return (
@@ -112,6 +150,7 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement |
strokeLinejoin={g.stroke ? 'round' : undefined} strokeLinejoin={g.stroke ? 'round' : undefined}
strokeWidth={g.strokeWidth} strokeWidth={g.strokeWidth}
textAnchor={g.textAnchor ?? 'start'} textAnchor={g.textAnchor ?? 'start'}
pointerEvents={pointerEventsOverride}
x={g.x} x={g.x}
y={g.y} y={g.y}
> >
@@ -137,7 +176,7 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement |
const transform = formatTransform(g.transform) const transform = formatTransform(g.transform)
return ( return (
<g key={keyHint} transform={transform}> <g key={keyHint} transform={transform}>
{g.children.map((child, i) => renderNode(child, i))} {g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))}
</g> </g>
) )
} }
@@ -187,11 +187,20 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const editorPhase = useEditor((s) => s.phase) const editorPhase = useEditor((s) => s.phase)
const editorMode = useEditor((s) => s.mode) const editorMode = useEditor((s) => s.mode)
const editorTool = useEditor((s) => s.tool) 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 = const isOpeningPlacementActive =
(editorPhase === 'structure' && (editorPhase === 'structure' &&
editorMode === 'build' && editorMode === 'build' &&
(editorTool === 'door' || editorTool === 'window')) || (editorTool === 'door' || editorTool === 'window')) ||
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement) (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 // Subscribe to the live-transforms map ref so the layer re-renders
// whenever a 3D mover publishes a per-frame position (see // whenever a 3D mover publishes a per-frame position (see
// `usePlacementCoordinator`). Without this the 2D floor plan only // `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 // tree the builder returns. Builders don't need to know about the
// partition. // partition.
const entries = useMemo(() => { const entries = useMemo(() => {
// Some builders read elevator runtime state imperatively; this keeps the memo subscribed.
void interactiveElevators
if (!levelId) return [] if (!levelId) return []
const out: { const out: {
id: AnyNodeId id: AnyNodeId
@@ -273,6 +285,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const visit = (id: AnyNodeId) => { const visit = (id: AnyNodeId) => {
const node = nodes[id] const node = nodes[id]
if (!node) return if (!node) return
if ((node as { visible?: boolean }).visible === false) return
const def = nodeRegistry.get(node.type) const def = nodeRegistry.get(node.type)
const builder = def?.floorplan const builder = def?.floorplan
if (builder) { if (builder) {
@@ -373,6 +386,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const buildingScopedKindSet = new Set(buildingScopedKinds) const buildingScopedKindSet = new Set(buildingScopedKinds)
for (const [id, node] of Object.entries(nodes)) { for (const [id, node] of Object.entries(nodes)) {
if (!node || !buildingScopedKindSet.has(node.type)) continue if (!node || !buildingScopedKindSet.has(node.type)) continue
if ((node as { visible?: boolean }).visible === false) continue
const parentId = (node as { parentId?: AnyNodeId | null }).parentId const parentId = (node as { parentId?: AnyNodeId | null }).parentId
if (parentId !== activeBuildingId) continue if (parentId !== activeBuildingId) continue
const cid = id as AnyNodeId const cid = id as AnyNodeId
@@ -383,8 +397,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const highlighted = highlightedIdSet.has(cid) const highlighted = highlightedIdSet.has(cid)
const hovered = hoveredId === cid const hovered = hoveredId === cid
const moving = movingNode?.id === 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 = { 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: [], children: [],
siblings: [], siblings: [],
parent: activeLevelNode, parent: activeLevelNode,
@@ -399,12 +427,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
: undefined, : undefined,
} }
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)( const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
node, effectiveNode,
ctx, ctx,
) )
if (geometry) { if (geometry) {
const { base, overlay } = splitFloorplanOverlay(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" className="floorplan-registry-entry"
data-node-id={id} data-node-id={id}
key={key} key={key}
onClick={isOpeningPlacementActive ? undefined : handleClickStop} onClick={isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handleClickStop}
onPointerDown={isOpeningPlacementActive ? undefined : (e) => handleSelect(id, e)} onPointerDown={
isOpeningPlacementActive || isMarqueeSelectionActive
? undefined
: (e) => handleSelect(id, e)
}
// Mirror the sidebar tree nodes' hover wiring — `useViewer. // Mirror the sidebar tree nodes' hover wiring — `useViewer.
// hoveredId` drives the highlight halo in 3D as well as the // hoveredId` drives the highlight halo in 3D as well as the
// wall / fence floor-plan hover stroke. Setting it on // wall / fence floor-plan hover stroke. Setting it on
@@ -716,6 +748,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
geometry={geometry} geometry={geometry}
hatchPatternId={renderCtx?.hatchPatternId} hatchPatternId={renderCtx?.hatchPatternId}
hoveredHandleId={hoveredHandleId} hoveredHandleId={hoveredHandleId}
isMarqueeSelectionActive={isMarqueeSelectionActive}
nodeId={id} nodeId={id}
onHandleHoverChange={setHoveredHandleId} onHandleHoverChange={setHoveredHandleId}
onHandlePointerDown={(affordance, payload, event, rotationPivot) => onHandlePointerDown={(affordance, payload, event, rotationPivot) =>
@@ -807,6 +840,7 @@ function InteractiveGeometry({
hatchPatternId, hatchPatternId,
hoveredHandleId, hoveredHandleId,
activeDragId, activeDragId,
isMarqueeSelectionActive,
nodeId, nodeId,
sceneRotationDeg, sceneRotationDeg,
onHandleHoverChange, onHandleHoverChange,
@@ -819,6 +853,7 @@ function InteractiveGeometry({
hatchPatternId: string | undefined hatchPatternId: string | undefined
hoveredHandleId: string | null hoveredHandleId: string | null
activeDragId: string | null activeDragId: string | null
isMarqueeSelectionActive: boolean
nodeId: AnyNodeId nodeId: AnyNodeId
sceneRotationDeg: number sceneRotationDeg: number
onHandleHoverChange: (id: string | null) => void onHandleHoverChange: (id: string | null) => void
@@ -860,7 +895,7 @@ function InteractiveGeometry({
return ( return (
<line <line
key={keyHint} key={keyHint}
pointerEvents={g.pointerEvents ?? 'stroke'} pointerEvents={isMarqueeSelectionActive ? 'none' : (g.pointerEvents ?? 'stroke')}
stroke="transparent" stroke="transparent"
strokeLinecap="round" strokeLinecap="round"
strokeWidth={g.strokeWidthPx * unitsPerPixel} strokeWidth={g.strokeWidthPx * unitsPerPixel}
@@ -1562,7 +1597,13 @@ function InteractiveGeometry({
) )
} }
default: 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 { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' 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 { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
@@ -23,8 +30,13 @@ const tempDelta = new Vector3()
const tempPosition = new Vector3() const tempPosition = new Vector3()
const tempSize = new Vector3() const tempSize = new Vector3()
const tempTarget = 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 DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 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 CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
type CameraPoseSnapshot = { type CameraPoseSnapshot = {
mode: CameraMode 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( function useFirstPersonCameraPoseRestore(
controls: { current: CameraControlsImpl | null }, controls: { current: CameraControlsImpl | null },
isFirstPersonMode: boolean, isFirstPersonMode: boolean,
@@ -131,6 +223,7 @@ export const CustomCameraControls = () => {
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
const selection = useViewer((s) => s.selection) const selection = useViewer((s) => s.selection)
const cameraMode = useViewer((state) => state.cameraMode) const cameraMode = useViewer((state) => state.cameraMode)
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore( const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
@@ -140,11 +233,19 @@ export const CustomCameraControls = () => {
) )
const currentLevelId = selection.levelId const currentLevelId = selection.levelId
const firstLoad = useRef(true) const firstLoad = useRef(true)
const lastPublishedNavigationSync = useRef<{
target: [number, number, number]
azimuth: number
viewWidth: number
} | null>(null)
const lastApplied2dNavigationRevision = useRef(0)
const maxPolarAngle = const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
const camera = useThree((state) => state.camera) const camera = useThree((state) => state.camera)
const gl = useThree((state) => state.gl)
const raycaster = useThree((state) => state.raycaster) const raycaster = useThree((state) => state.raycaster)
const viewportSize = useThree((state) => state.size)
useEffect(() => { useEffect(() => {
camera.layers.enable(EDITOR_LAYER) camera.layers.enable(EDITOR_LAYER)
camera.layers.enable(GRID_LAYER) camera.layers.enable(GRID_LAYER)
@@ -209,6 +310,73 @@ export const CustomCameraControls = () => {
[isPreviewMode, isFirstPersonMode], [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 // Configure mouse buttons based on control mode and camera mode
const mouseButtons = useMemo(() => { const mouseButtons = useMemo(() => {
// Use ZOOM for orthographic camera, DOLLY for perspective camera // Use ZOOM for orthographic camera, DOLLY for perspective camera
@@ -284,6 +452,45 @@ export const CustomCameraControls = () => {
controlLeft: false, controlLeft: false,
space: 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 = () => { const updateConfig = () => {
if (!controls.current) return if (!controls.current) return
@@ -311,8 +518,10 @@ export const CustomCameraControls = () => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.code === 'Space') { if (event.code === 'Space') {
if (isEditableKeyboardTarget(event.target)) return
event.preventDefault()
keyState.space = true keyState.space = true
document.body.style.cursor = 'grab' updateNavigationCursor()
} }
if (event.code === 'ShiftRight') { if (event.code === 'ShiftRight') {
keyState.shiftRight = true keyState.shiftRight = true
@@ -332,7 +541,11 @@ export const CustomCameraControls = () => {
const onKeyUp = (event: KeyboardEvent) => { const onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Space') { if (event.code === 'Space') {
keyState.space = false keyState.space = false
document.body.style.cursor = '' if (panPointerButton === 0) {
panPointerId = null
panPointerButton = null
}
updateNavigationCursor()
} }
if (event.code === 'ShiftRight') { if (event.code === 'ShiftRight') {
keyState.shiftRight = false keyState.shiftRight = false
@@ -349,16 +562,51 @@ export const CustomCameraControls = () => {
updateConfig() 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('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp) document.addEventListener('keyup', onKeyUp)
window.addEventListener('pointerdown', onPointerDown, true)
window.addEventListener('pointerup', onPointerUp, true)
window.addEventListener('pointercancel', onPointerUp, true)
window.addEventListener('blur', onBlur)
updateConfig() updateConfig()
return () => { return () => {
document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp) 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) // Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode const previewTargetNodeId = isPreviewMode
@@ -669,6 +917,7 @@ export const CustomCameraControls = () => {
minDistance={minDistance} minDistance={minDistance}
minPolarAngle={0} minPolarAngle={0}
mouseButtons={mouseButtons} mouseButtons={mouseButtons}
onUpdate={publishCurrentNavigationPose}
onRest={onRest} onRest={onRest}
onSleep={onRest} onSleep={onRest}
onTransitionStart={onTransitionStart} onTransitionStart={onTransitionStart}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,19 @@
'use client' '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 { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { import {
CORNER_OFFSET, CORNER_OFFSET,
classifyParticipant, classifyParticipant,
@@ -41,7 +48,10 @@ export function GroupMoveHandle() {
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const participantIds = useMemo( 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], [selectedIds, levelId, nodes],
) )
@@ -103,6 +113,7 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
const activate = (event: ThreeEvent<PointerEvent>) => { const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event)
frozenCorner.current = rest.corner.clone() frozenCorner.current = rest.corner.clone()
const planeY = rest.baseY const planeY = rest.baseY
@@ -159,18 +170,28 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
lastSnap = [dx, dz] lastSnap = [dx, dz]
} }
const overrides = useLiveNodeOverrides.getState() const overrideEntries: Array<readonly [string, Record<string, unknown>]> = []
const liveTransforms = useLiveTransforms.getState()
for (const s of starts) { for (const s of starts) {
if (s.kind === 'endpoint') { if (s.kind === 'endpoint') {
overrides.set(s.id, { overrideEntries.push([
s.id,
{
start: [s.start[0] + dx, s.start[1] + dz], start: [s.start[0] + dx, s.start[1] + dz],
end: [s.end[0] + dx, s.end[1] + dz], end: [s.end[0] + dx, s.end[1] + dz],
}) },
])
} else { } else {
// Slide on the floor: XZ shift, Y and rotation untouched. // Slide on the floor: XZ shift, Y and rotation untouched.
overrides.set(s.id, { const position: [number, number, number] = [
position: [s.position[0] + dx, s.position[1], s.position[2] + dz], 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) 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 // Shared endpoints of connected neighbours follow by the same delta so
// the junction stays welded; the far end stays put. // the junction stays welded; the far end stays put.
for (const l of links) { for (const l of links) {
overrides.set(l.id, { overrideEntries.push([
l.id,
{
start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start, 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, end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
}) },
])
useScene.getState().markDirty(l.id) useScene.getState().markDirty(l.id)
} }
useLiveNodeOverrides.getState().setMany(overrideEntries)
setLiveDelta([dx, dz]) 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 = () => { const cleanup = () => {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
@@ -201,8 +237,6 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
dragCleanupRef.current = null dragCleanupRef.current = null
} }
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
const commitFromOverrides = () => { const commitFromOverrides = () => {
const overrides = useLiveNodeOverrides.getState() const overrides = useLiveNodeOverrides.getState()
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [] 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. // tracked set — collapsing the whole group move into one undo.
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
if (updates.length > 0) useScene.getState().updateNodes(updates) if (updates.length > 0) useScene.getState().updateNodes(updates)
for (const id of affectedIds) { clearLivePreviews()
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
cleanup() cleanup()
} }
const onCancel = () => { const onCancel = () => {
for (const id of affectedIds) { clearLivePreviews()
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
cleanup() cleanup()
} }
dragCleanupRef.current = cleanup dragCleanupRef.current = () => {
clearLivePreviews()
cleanup()
}
for (const id of affectedIds) {
useLiveTransforms.getState().clear(id)
}
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel) window.addEventListener('pointercancel', onCancel)
@@ -1,12 +1,19 @@
'use client' '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 { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { import {
CORNER_OFFSET, CORNER_OFFSET,
classifyParticipant, classifyParticipant,
@@ -35,11 +42,11 @@ import {
const ROTATE_SNAP = Math.PI / 12 // 15° const ROTATE_SNAP = Math.PI / 12 // 15°
/** /**
* Group-rotate gizmo. When 2+ "movable" nodes (position + rotation, sitting * Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
* directly on the active level) are selected, a single rotation handle appears * selected, a single rotation handle appears at the selection's bounding-box
* at the selection's bounding-box center. Dragging it spins every selected node * center. Dragging it spins every selected node rigidly around that shared
* rigidly around that shared center — orbiting each node's position AND turning * center — orbiting each node's position AND turning its yaw by the same delta,
* its yaw by the same delta, so the group rotates as one piece. * so the group rotates as one piece.
* *
* The single-selection case is handled by `NodeArrowHandles`; a full-level * The single-selection case is handled by `NodeArrowHandles`; a full-level
* box-select promotes to a building selection, so neither reaches this gizmo. * 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 nodes = useScene((s) => s.nodes)
const participantIds = useMemo( 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], [selectedIds, levelId, nodes],
) )
@@ -123,6 +133,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const activate = (event: ThreeEvent<PointerEvent>) => { const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event)
frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() } frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
const center = rest.pivot.clone() const center = rest.pivot.clone()
@@ -200,10 +211,14 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const dz = z - center.z const dz = z - center.z
return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos] 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) { for (const s of starts) {
if (s.kind === 'endpoint') { 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 { } else {
const [px, pz] = rot(s.position[0], s.position[2]) const [px, pz] = rot(s.position[0], s.position[2])
const position: Vec3 = [px, s.position[1], pz] const position: Vec3 = [px, s.position[1], pz]
@@ -211,7 +226,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
s.kind === 'vec3' s.kind === 'vec3'
? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3) ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
: s.rotation - delta : 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) 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 // (rot is deterministic, so it lands exactly on the selected wall's
// rotated endpoint), keeping the junction welded; the far end stays put. // rotated endpoint), keeping the junction welded; the far end stays put.
for (const l of links) { for (const l of links) {
overrides.set(l.id, { overrideEntries.push([
l.id,
{
start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start, start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end, end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
}) },
])
useScene.getState().markDirty(l.id) useScene.getState().markDirty(l.id)
} }
useLiveNodeOverrides.getState().setMany(overrideEntries)
if (Math.abs(delta) < 0.0087) { if (Math.abs(delta) < 0.0087) {
setGuide(null) 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 = () => { const cleanup = () => {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
@@ -260,8 +293,6 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
dragCleanupRef.current = null dragCleanupRef.current = null
} }
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
const commitFromOverrides = () => { const commitFromOverrides = () => {
const overrides = useLiveNodeOverrides.getState() const overrides = useLiveNodeOverrides.getState()
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [] 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. // one tracked set — collapsing the whole group rotation into one undo.
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
if (updates.length > 0) useScene.getState().updateNodes(updates) if (updates.length > 0) useScene.getState().updateNodes(updates)
for (const id of affectedIds) { clearLivePreviews()
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
cleanup() cleanup()
} }
const onCancel = () => { const onCancel = () => {
// Revert: drop overrides + mark dirty so renderers rebuild from the store. // Revert: drop overrides + mark dirty so renderers rebuild from the store.
for (const id of affectedIds) { clearLivePreviews()
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
cleanup() cleanup()
} }
dragCleanupRef.current = cleanup dragCleanupRef.current = () => {
clearLivePreviews()
cleanup()
}
for (const id of affectedIds) {
useLiveTransforms.getState().clear(id)
}
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel) 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' import { Box3 } from 'three'
// Shared plumbing for the group transform gizmos (rotate + move). Both operate // 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) // - 'endpoint' start/end tuples (walls, fences)
export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint' export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint'
// A selected node qualifies when it sits directly on the active level and its // A selected node qualifies when it belongs to the active level's horizontal
// placement is one of the transformable shapes. Doors/windows parent to their // frame: either parented to that level, or declared building-scoped and parented
// wall (not the level), so they're excluded here and ride their wall. // 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( export function classifyParticipant(
node: AnyNode | undefined, node: AnyNode | undefined,
levelId: string | null, levelId: string | null,
sceneNodes: Record<string, AnyNode | undefined>,
): ParticipantKind | null { ): ParticipantKind | null {
if (!node || node.parentId !== levelId) return null if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null
const p = (node as { position?: unknown }).position const p = getParticipantPosition(node)
const r = (node as { rotation?: unknown }).rotation const r = (node as { rotation?: unknown }).rotation
const start = (node as { start?: unknown }).start const start = (node as { start?: unknown }).start
const end = (node as { end?: unknown }).end const end = (node as { end?: unknown }).end
if (isVec3(p) && isVec3(r)) return 'vec3' 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' if (isVec2(start) && isVec2(end)) return 'endpoint'
return null return null
} }
@@ -74,23 +121,27 @@ export function collectParticipants(
const starts: ParticipantStart[] = [] const starts: ParticipantStart[] = []
for (const id of ids) { for (const id of ids) {
const node = sceneNodes[id] const node = sceneNodes[id]
const kind = classifyParticipant(node, levelId) const kind = classifyParticipant(node, levelId, sceneNodes)
if (!node || !kind) continue if (!node || !kind) continue
if (kind === 'vec3') { if (kind === 'vec3') {
const n = node as AnyNode & { position: Vec3; rotation: Vec3 } const n = node as AnyNode & { position: Vec3; rotation: Vec3 }
const position = getParticipantPosition(node)
if (!position) continue
starts.push({ starts.push({
id: id as AnyNodeId, id: id as AnyNodeId,
kind, 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]], rotation: [n.rotation[0], n.rotation[1], n.rotation[2]],
}) })
} else if (kind === 'scalar') { } 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({ starts.push({
id: id as AnyNodeId, id: id as AnyNodeId,
kind, kind,
position: [n.position[0], n.position[1], n.position[2]], position: [position[0], position[1], position[2]],
rotation: n.rotation, rotation,
}) })
} else { } else {
const n = node as AnyNode & { start: Vec2; end: Vec2 } 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)) const selected = new Set(starts.map((s) => s.id))
for (const [nid, node] of Object.entries(sceneNodes)) { for (const [nid, node] of Object.entries(sceneNodes)) {
if (selected.has(nid as AnyNodeId)) continue 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 n = node as AnyNode & { start: Vec2; end: Vec2 }
const start: Vec2 = [n.start[0], n.start[1]] const start: Vec2 = [n.start[0], n.start[1]]
const end: Vec2 = [n.end[0], n.end[1]] const end: Vec2 = [n.end[0], n.end[1]]
@@ -138,7 +189,7 @@ export function expandToComponent(
): string[] { ): string[] {
const endpoints: { id: string; start: Vec2; end: Vec2 }[] = [] const endpoints: { id: string; start: Vec2; end: Vec2 }[] = []
for (const [id, node] of Object.entries(sceneNodes)) { 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 } 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]] }) 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 { useEffect, useRef } from 'react'
import { type Camera, type Object3D, type Plane, Vector2, type Vector3 } from 'three' import { type Camera, type Object3D, type Plane, Vector2, type Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
export type HandleDragControls = { export type HandleDragControls = {
onStart: (index: number, snapshot: AnyNode) => void onStart: (index: number, snapshot: AnyNode) => void
@@ -77,6 +78,26 @@ export function swallowNextClick() {
}, 300) }, 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) { export function useHandleDrag(args: UseHandleDragArgs) {
const { camera, raycaster, gl } = useThree() const { camera, raycaster, gl } = useThree()
const dragCleanupRef = useRef<(() => void) | null>(null) const dragCleanupRef = useRef<(() => void) | null>(null)
@@ -85,8 +106,11 @@ export function useHandleDrag(args: UseHandleDragArgs) {
return (event: ThreeEvent<PointerEvent>) => { return (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event)
if (args.kind === 'tap') { if (args.kind === 'tap') {
suppressInputDraggingUntilPointerRelease(event.nativeEvent.pointerId)
swallowNextClick()
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
document.body.style.cursor = '' document.body.style.cursor = ''
args.onTap(event) args.onTap(event)
@@ -341,6 +341,7 @@ const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
{ {
action: 'Pan', action: 'Pan',
keys: [{ value: 'Space' }, { value: 'Left click' }], keys: [{ value: 'Space' }, { value: 'Left click' }],
alternativeKeys: [{ value: 'Middle click' }],
}, },
{ action: 'Rotate', keys: [{ value: 'Right click' }] }, { action: 'Rotate', keys: [{ value: 'Right click' }] },
{ action: 'Zoom', keys: [{ value: 'Scroll' }] }, { action: 'Zoom', keys: [{ value: 'Scroll' }] },
@@ -125,6 +125,7 @@ export function NodeArrowHandles() {
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const placementDragMode = useEditor((state) => state.placementDragMode)
// Endpoint / curve drags reshape the selected wall or fence; hide its // Endpoint / curve drags reshape the selected wall or fence; hide its
// resize arrows for the duration so they don't clutter (or get blocked // 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 // 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 ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
[rawNode, liveOverride], [rawNode, liveOverride],
) )
const isOwnPressDragMove =
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
const def = node ? nodeRegistry.get(node.type) : null const def = node ? nodeRegistry.get(node.type) : null
const descriptors = useMemo(() => { const descriptors = useMemo(() => {
@@ -163,7 +166,7 @@ export function NodeArrowHandles() {
Boolean(node && descriptors?.length) && Boolean(node && descriptors?.length) &&
!isFloorplanHovered && !isFloorplanHovered &&
mode !== 'delete' && mode !== 'delete' &&
!movingNode && (!movingNode || isOwnPressDragMove) &&
!movingWallEndpoint && !movingWallEndpoint &&
!movingFenceEndpoint && !movingFenceEndpoint &&
!curvingWall && !curvingWall &&
@@ -26,6 +26,7 @@ import {
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { swallowNextClick } from './handles/use-handle-drag' import { swallowNextClick } from './handles/use-handle-drag'
const ACCENT = 0x83_81_ed const ACCENT = 0x83_81_ed
@@ -199,6 +200,7 @@ function resetPointerCursor() {
function stopPointerPropagation(event: ThreeEvent<PointerEvent>) { function stopPointerPropagation(event: ThreeEvent<PointerEvent>) {
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event)
event.nativeEvent.stopPropagation() event.nativeEvent.stopPropagation()
event.nativeEvent.stopImmediatePropagation() event.nativeEvent.stopImmediatePropagation()
} }
@@ -34,6 +34,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { import {
createArrowHitAreaGeometry, createArrowHitAreaGeometry,
createEndpointHitAreaGeometry, createEndpointHitAreaGeometry,
@@ -329,6 +330,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => { const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
document.body.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing'
useEditor.getState().setMovingWallEndpoint({ wall, endpoint }) useEditor.getState().setMovingWallEndpoint({ wall, endpoint })
@@ -432,6 +434,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
const activateHeightResize = (event: ThreeEvent<PointerEvent>) => { const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event)
const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
if (!levelObject) return if (!levelObject) return
@@ -603,6 +606,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
const activateWallMove = (event: ThreeEvent<PointerEvent>) => { const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event)
document.body.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
@@ -693,6 +697,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => { const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event)
document.body.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
@@ -22,10 +22,14 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' 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 { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
import { PlacementBox } from '../shared/placement-box' import { PlacementBox } from '../shared/placement-box'
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 /** 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 [valid, setValid] = useState(true)
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
useFreshPlacementVisibility({ node })
// Mirrors of `valid` / Shift for the event handlers inside the effect, which // Mirrors of `valid` / Shift for the event handlers inside the effect, which
// can't read React state without stale closures. // can't read React state without stale closures.
const validRef = useRef(true) const validRef = useRef(true)
@@ -180,6 +186,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
setCursorRotationY(originalRotationY) setCursorRotationY(originalRotationY)
lastCursorRef.current = originalPosition lastCursorRef.current = originalPosition
let committed = false let committed = false
const isNew = isFreshPlacement
const baseRotation = (node as { rotation?: unknown }).rotation const baseRotation = (node as { rotation?: unknown }).rotation
const toCommitRotation = (y: number): number | [number, number, number] => const toCommitRotation = (y: number): number | [number, number, number] =>
@@ -271,11 +278,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const rawX = event.localPosition[0] const rawX = event.localPosition[0]
const rawZ = event.localPosition[2] const rawZ = event.localPosition[2]
const anchor = dragAnchorRef.current ?? [rawX, rawZ] revealFreshPlacement()
dragAnchorRef.current = anchor
let x = originalPosition[0] + snapToGridStep(rawX - anchor[0]) const resolved = resolvePlanarCursorPosition({
let z = originalPosition[2] + snapToGridStep(rawZ - anchor[1]) 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 // 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, // 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 rotation = toCommitRotation(rotationRef.current)
const visualPosition = getVisualPosition(position) const visualPosition = getVisualPosition(position)
let committedId = node.id as AnyNodeId
if (useScene.getState().nodes[node.id]) { if (useScene.getState().nodes[node.id]) {
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.temporal.getState().resume()
useScene.getState().updateNode(node.id, { position, rotation } as Partial<AnyNode>) useScene.getState().updateNode(node.id, data)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
committed = true committed = true
}
} else if (node.parentId) { } else if (node.parentId) {
// Orphan re-create path: re-parse via the registry's schema. // Orphan re-create path: re-parse via the registry's schema.
const def = nodeRegistry.get(node.type) const def = nodeRegistry.get(node.type)
@@ -393,8 +426,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
} }
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
if (isNew && committed) {
useViewer.getState().setSelection({ selectedIds: [committedId] })
}
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode() exitMoveMode()
// Stop further propagation so other listeners (e.g. a selection // Stop further propagation so other listeners (e.g. a selection
@@ -470,13 +507,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const onCancel = () => { const onCancel = () => {
useLiveTransforms.getState().clear(node.id) useLiveTransforms.getState().clear(node.id)
if (isNew) {
useScene.getState().deleteNode(node.id as AnyNodeId)
} else {
const m = sceneRegistry.nodes.get(node.id) const m = sceneRegistry.nodes.get(node.id)
if (m) { if (m) {
m.position.set(...getVisualPosition(originalPosition, originalRotationY)) m.position.set(...getVisualPosition(originalPosition, originalRotationY))
m.rotation.y = originalRotationY m.rotation.y = originalRotationY
} }
useAlignmentGuides.getState().clear()
markMovedNodeDirty() markMovedNodeDirty()
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
@@ -499,16 +540,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// Drop any alignment guides this drag published — covers Esc / mid-drag // Drop any alignment guides this drag published — covers Esc / mid-drag
// unmount / commit paths uniformly. // unmount / commit paths uniformly.
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
if (!committed) { const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
if (!(committed || isNew || finalisedBy2D)) {
useLiveTransforms.getState().clear(node.id) useLiveTransforms.getState().clear(node.id)
sceneRegistry.nodes sceneRegistry.nodes
.get(node.id) .get(node.id)
?.position.set(...getVisualPosition(originalPosition, originalRotationY)) ?.position.set(...getVisualPosition(originalPosition, originalRotationY))
markMovedNodeDirty() 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) { if (boxDimensions) {
return ( return (
@@ -1,6 +1,21 @@
export let boxSelectHandled = false export let boxSelectHandled = false
let resetTimeout: ReturnType<typeof setTimeout> | null = null 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() { export function markBoxSelectHandled() {
boxSelectHandled = true boxSelectHandled = true
@@ -13,10 +28,50 @@ export function markBoxSelectHandled() {
}, 50) }, 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() { export function clearBoxSelectHandled() {
if (resetTimeout) { if (resetTimeout) {
clearTimeout(resetTimeout) clearTimeout(resetTimeout)
resetTimeout = null resetTimeout = null
} }
boxSelectHandled = false 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 { useCallback, useEffect, useRef } from 'react'
import { Box3, type Camera, type Object3D, Vector3 } from 'three' import { Box3, type Camera, type Object3D, Vector3 } from 'three'
import useEditor from '../../../store/use-editor' 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 { 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' 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 tempBox = new Box3()
const tempWorldPoint = new Vector3() const tempWorldPoint = new Vector3()
const tempScreenPoint = 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( function projectWorldPointToScreen(
point: Vector3, point: Vector3,
camera: Camera, camera: Camera,
@@ -268,7 +206,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
pointerDownRef.current = false pointerDownRef.current = false
isDraggingRef.current = false isDraggingRef.current = false
pointerIdRef.current = null pointerIdRef.current = null
hideSelectionElement(elementRef.current) hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
if (ownsInputDraggingRef.current) { if (ownsInputDraggingRef.current) {
@@ -278,7 +216,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
}, [syncPreviewSelectedIds]) }, [syncPreviewSelectedIds])
useEffect(() => { useEffect(() => {
const element = createSelectionElement() const element = createScreenRectangleSelectionElement()
document.body.appendChild(element) document.body.appendChild(element)
elementRef.current = element elementRef.current = element
@@ -331,6 +269,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
const viewer = useViewer.getState() const viewer = useViewer.getState()
if ( if (
isBoxSelectPointerSuppressed(event) ||
spaceDownRef.current || spaceDownRef.current ||
viewer.cameraDragging || viewer.cameraDragging ||
(viewer.inputDragging && !ownsInputDraggingRef.current) (viewer.inputDragging && !ownsInputDraggingRef.current)
@@ -348,7 +287,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
currentClientYRef.current - startClientYRef.current, currentClientYRef.current - startClientYRef.current,
) )
if (!isDraggingRef.current && dragDistance >= DRAG_THRESHOLD_PX) { if (!isDraggingRef.current && dragDistance >= SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX) {
isDraggingRef.current = true isDraggingRef.current = true
ownsInputDraggingRef.current = true ownsInputDraggingRef.current = true
useViewer.getState().setInputDragging(true) useViewer.getState().setInputDragging(true)
@@ -372,12 +311,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
screenRectFromDomRect(canvas.getBoundingClientRect()), screenRectFromDomRect(canvas.getBoundingClientRect()),
) )
if (!clampedRect) { if (!clampedRect) {
hideSelectionElement(elementRef.current) hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
return return
} }
updateSelectionElement(elementRef.current!, clampedRect) updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas)) syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas))
} }
@@ -385,7 +324,10 @@ const ScreenRectangleSelectTool: React.FC = () => {
if (!pointerDownRef.current) return if (!pointerDownRef.current) return
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.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() markBoxSelectHandled()
resetDrag() resetDrag()
return return
@@ -420,6 +362,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
const onCanvasPointerDown = (event: PointerEvent) => { const onCanvasPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return if (event.button !== 0) return
if (spaceDownRef.current) return if (spaceDownRef.current) return
if (isBoxSelectPointerSuppressed(event)) return
const viewer = useViewer.getState() const viewer = useViewer.getState()
if (viewer.cameraDragging || viewer.inputDragging) return if (viewer.cameraDragging || viewer.inputDragging) return
@@ -31,7 +31,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { markBoxSelectHandled } from './box-select-state' import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state'
import { collectSelectableCandidateIds } from './select-candidates' import { collectSelectableCandidateIds } from './select-candidates'
declare module 'react/jsx-runtime' { declare module 'react/jsx-runtime' {
@@ -407,6 +407,7 @@ export const PlaneBoxSelectTool: React.FC = () => {
const onCanvasPointerDown = (event: PointerEvent) => { const onCanvasPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return if (event.button !== 0) return
if (spaceDownRef.current) return if (spaceDownRef.current) return
if (isBoxSelectPointerSuppressed(event)) return
if (useViewer.getState().cameraDragging) return if (useViewer.getState().cameraDragging) return
if (useViewer.getState().inputDragging) return if (useViewer.getState().inputDragging) return
@@ -426,7 +427,8 @@ export const PlaneBoxSelectTool: React.FC = () => {
const onCanvasPointerUp = (event: PointerEvent) => { const onCanvasPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return if (event.button !== 0) return
if (useViewer.getState().inputDragging) { if (isBoxSelectPointerSuppressed(event) || useViewer.getState().inputDragging) {
markBoxSelectHandled()
resetDrag() resetDrag()
return return
} }
@@ -494,7 +496,16 @@ export const PlaneBoxSelectTool: React.FC = () => {
} }
if (!pointerDown.current) return 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) currentPoint.current.set(snappedX, event.position[1], snappedZ)
@@ -538,7 +549,7 @@ export const PlaneBoxSelectTool: React.FC = () => {
return () => { return () => {
emitter.off('grid:move', onMove) emitter.off('grid:move', onMove)
} }
}, [syncPreviewSelectedIds]) }, [resetDrag, syncPreviewSelectedIds])
return ( return (
<group> <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, type LevelNode,
nodeRegistry, nodeRegistry,
resolveBuildingForLevel, resolveBuildingForLevel,
resolveLevelId,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
export function isFurnishSelectableCandidate(node: AnyNode): boolean { function isVisibleSelectableNode(node: AnyNode): boolean {
if (node.type === 'item') { if ((node as { visible?: boolean }).visible === false) return false
return node.asset.category !== 'door' && node.asset.category !== 'window' return isRegistrySelectable(node.type)
}
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)
} }
export function collectSelectableCandidateIds(): string[] { export function collectSelectableCandidateIds(): string[] {
@@ -51,10 +23,23 @@ export function collectSelectableCandidateIds(): string[] {
const result: string[] = [] const result: string[] = []
const seen = new Set<string>() const seen = new Set<string>()
const addNode = (node: AnyNode | undefined) => { 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) seen.add(node.id)
result.push(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') { if (phase === 'site') {
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
@@ -76,49 +61,22 @@ export function collectSelectableCandidateIds(): string[] {
} }
for (const childId of levelNode.children) { for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId] visitLevelDescendant(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)
}
} }
const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes) const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes)
const buildingNode = buildingId ? nodes[buildingId] : undefined for (const node of Object.values(nodes)) {
const buildingChildren = if (!node || node.type === 'level' || !isVisibleSelectableNode(node)) continue
buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children)
? (buildingNode.children as AnyNodeId[]) const def = nodeRegistry.get(node.type)
: [] const isBuildingScoped = def?.floorplanScope === 'building'
for (const childId of buildingChildren) { const parentId = (node as { parentId?: AnyNodeId | null }).parentId
const node = nodes[childId] if (isBuildingScoped && buildingId && parentId === buildingId) {
if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue addNode(node)
if (phase === 'furnish') { continue
if (isFurnishSelectableCandidate(node)) addNode(node) }
} else if (isStructureSelectableCandidate(node)) {
if (!isBuildingScoped && resolveLevelId(node, nodes) === levelId) {
addNode(node) 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, emitter,
type GridEvent, type GridEvent,
type LevelNode, type LevelNode,
movingAlignmentAnchors,
type NodeEvent, type NodeEvent,
resolveAlignment, resolveAlignment,
StairNode, StairNode,
@@ -295,14 +296,30 @@ export const StairTool: React.FC = () => {
} }
// Alignment candidates — anchors of every alignable object; refreshed // 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) let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
// Snap the stair origin onto another object's nearest real anchor and const resolveStairFootprintAlignment = (
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped x: number,
// point: resolving against the grid point would only ever catch anchors z: number,
// that happen to sit on a grid line, so off-grid items (furniture, angled rotation: number,
// walls) would never surface a guide. The matched axis locks exactly to the ): ReturnType<typeof resolveAlignment> | null => {
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses. 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 = ( const alignPoint = (
gridX: number, gridX: number,
gridZ: number, gridZ: number,
@@ -314,22 +331,19 @@ export const StairTool: React.FC = () => {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return [gridX, gridZ] return [gridX, gridZ]
} }
const ar = resolveAlignment({ const ar = resolveStairFootprintAlignment(rawX, rawZ, rotationRef.current)
moving: [{ nodeId: '__stair-draft__', kind: 'corner', x: rawX, z: rawZ }], if (!ar || ar.guides.length === 0) {
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return [gridX, gridZ] return [gridX, gridZ]
} }
useAlignmentGuides.getState().set(ar.guides)
let x = gridX let x = gridX
let z = gridZ let z = gridZ
for (const guide of ar.guides) { if (ar.snap) {
if (guide.axis === 'x') x = guide.coord if (ar.guides.some((guide) => guide.axis === 'x')) x = rawX + ar.snap.dx
else z = guide.coord 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] return [x, z]
} }
+13
View File
@@ -61,6 +61,7 @@ export {
export { CursorSphere } from './components/tools/shared/cursor-sphere' export { CursorSphere } from './components/tools/shared/cursor-sphere'
export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' export { DragBoundingBox } from './components/tools/shared/drag-bounding-box'
export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' 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. // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export { export {
PolygonEditor, PolygonEditor,
@@ -195,6 +196,7 @@ export {
type FloorplanStairSegmentEntry, type FloorplanStairSegmentEntry,
getFloorplanWallThickness, getFloorplanWallThickness,
} from './lib/floorplan' } from './lib/floorplan'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { export {
buildResetSurfaceMaterialUpdates, buildResetSurfaceMaterialUpdates,
buildRoofSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch,
@@ -204,6 +206,17 @@ export {
getActivePaintMaterialLabel, getActivePaintMaterialLabel,
hasActivePaintMaterial, hasActivePaintMaterial,
} from './lib/material-paint' } 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 { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication'
export type { SceneGraph } from './lib/scene' export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } 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( export function clearRoofDuplicateMetadata(
roofId: AnyNodeId, roofId: AnyNodeId,
updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata'>> = {}, updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata' | 'visible'>> = {},
) { ) {
const scene = useScene.getState() const scene = useScene.getState()
const roofNode = scene.nodes[roofId] const roofNode = scene.nodes[roofId]
+6 -2
View File
@@ -1,6 +1,6 @@
'use client' '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 { useViewer } from '@pascal-app/viewer'
import useEditor, { import useEditor, {
hasCustomPersistedEditorUiState, hasCustomPersistedEditorUiState,
@@ -220,7 +220,11 @@ function getValidatedSelectionForScene(
const selectedIds = selection.selectedIds.filter((id) => { const selectedIds = selection.selectedIds.filter((id) => {
const node = sceneNodes[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 { return {
+22
View File
@@ -118,6 +118,18 @@ export type StructureLayer = 'zones' | 'elements'
export type FloorplanSelectionTool = 'click' | 'marquee' export type FloorplanSelectionTool = 'click' | 'marquee'
export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05 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 // Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool export type Tool = SiteTool | StructureTool | FurnishTool
@@ -326,6 +338,8 @@ type EditorState = {
toggleFloorplanOpen: () => void toggleFloorplanOpen: () => void
isFloorplanHovered: boolean isFloorplanHovered: boolean
setFloorplanHovered: (hovered: boolean) => void setFloorplanHovered: (hovered: boolean) => void
navigationSyncPose: NavigationSyncPose | null
publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
floorplanSelectionTool: FloorplanSelectionTool floorplanSelectionTool: FloorplanSelectionTool
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep gridSnapStep: GridSnapStep
@@ -850,6 +864,14 @@ const useEditor = create<EditorState>()(
}), }),
isFloorplanHovered: false, isFloorplanHovered: false,
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }), setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
navigationSyncPose: null,
publishNavigationSyncPose: (pose) =>
set((state) => ({
navigationSyncPose: {
...pose,
revision: (state.navigationSyncPose?.revision ?? 0) + 1,
},
})),
floorplanSelectionTool: 'click' as FloorplanSelectionTool, floorplanSelectionTool: 'click' as FloorplanSelectionTool,
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
+12 -6
View File
@@ -10,10 +10,11 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
applyFloorplanAlignment, applyFloorplanAlignment,
snapPointToGrid,
triggerSFX, triggerSFX,
useEditor,
type WallPlanPoint, type WallPlanPoint,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
/** /**
* 2D floor-plan move handler for column — mirrors `itemFloorplanMoveTarget`: * 2D floor-plan move handler for column — mirrors `itemFloorplanMoveTarget`:
@@ -39,12 +40,14 @@ import {
* Column stores rotation as a scalar (not a tuple); position is `[x, y, z]`. * Column stores rotation as a scalar (not a tuple); position is `[x, y, z]`.
*/ */
const GRID_STEP = 0.5
export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ node, nodes }) => { export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ node, nodes }) => {
const columnId = node.id as AnyNodeId const columnId = node.id as AnyNodeId
const originalPosition: [number, number, number] = [...node.position] as [number, number, number] const originalPosition: [number, number, number] = [...node.position] as [number, number, number]
const rotationY = node.rotation ?? 0 const rotationY = node.rotation ?? 0
const resolveCursor = createFloorplanCursorResolver({
original: [originalPosition[0], originalPosition[2]],
metadata: node.metadata,
})
let lastPosition: [number, number, number] = originalPosition let lastPosition: [number, number, number] = originalPosition
let lastSnapKey: string | null = null let lastSnapKey: string | null = null
@@ -54,9 +57,12 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
const session: FloorplanMoveTargetSession = { const session: FloorplanMoveTargetSession = {
affectedIds: [columnId], affectedIds: [columnId],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const gridSnapped: WallPlanPoint = modifiers.shiftKey const snap = (value: number) => {
? ([planPoint[0], planPoint[1]] as WallPlanPoint) if (modifiers.shiftKey) return value
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step
}
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
// Figma-style alignment layered on the grid snap (Alt bypasses). // Figma-style alignment layered on the grid snap (Alt bypasses).
const { point: snapped } = applyFloorplanAlignment( const { point: snapped } = applyFloorplanAlignment(
gridSnapped, gridSnapped,
+53 -14
View File
@@ -16,12 +16,17 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
commitFreshPlacementSubtree,
DragBoundingBox, DragBoundingBox,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
markToolCancelConsumed, markToolCancelConsumed,
resolvePlanarCursorPosition,
stripPlacementMetadataFlags,
triggerSFX, triggerSFX,
useEditor, useEditor,
useFreshPlacementVisibility,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
/** /**
@@ -54,6 +59,8 @@ const ALIGNMENT_THRESHOLD_M = 0.08
function MoveColumnTool({ node }: { node: ColumnNode }) { function MoveColumnTool({ node }: { node: ColumnNode }) {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const [previewRotation, setPreviewRotation] = useState<number>(node.rotation) const [previewRotation, setPreviewRotation] = useState<number>(node.rotation)
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
useFreshPlacementVisibility({ node })
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
@@ -71,11 +78,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
// Latest previewed position, so an R/T press can re-apply at the spot. // Latest previewed position, so an R/T press can re-apply at the spot.
let lastPosition: [number, number, number] = node.position let lastPosition: [number, number, number] = node.position
let dragAnchor: [number, number] | null = null let dragAnchor: [number, number] | null = null
const meta = const isNew = isFreshPlacement
typeof node.metadata === 'object' && node.metadata !== null
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const getVisualPosition = ( const getVisualPosition = (
position: [number, number, number], position: [number, number, number],
rotation = rotationY, rotation = rotationY,
@@ -114,9 +117,17 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
hasMoved = true hasMoved = true
const rawX = event.localPosition[0] const rawX = event.localPosition[0]
const rawZ = event.localPosition[2] const rawZ = event.localPosition[2]
dragAnchor ??= [rawX, rawZ] revealFreshPlacement()
let x = node.position[0] + snapToGridStep(rawX - dragAnchor[0])
let z = node.position[2] + snapToGridStep(rawZ - dragAnchor[1]) const resolved = resolvePlanarCursorPosition({
cursor: [rawX, rawZ],
original: [node.position[0], node.position[2]],
anchor: dragAnchor,
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
snap: snapToGridStep,
})
dragAnchor = resolved.anchor
let [x, z] = resolved.point
// Figma-style alignment snap on top of grid snap; Alt bypasses. The // Figma-style alignment snap on top of grid snap; Alt bypasses. The
// guide connects to the candidate's nearest real anchor (resolver // guide connects to the candidate's nearest real anchor (resolver
@@ -161,13 +172,30 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
// click to the grid. // click to the grid.
const position: [number, number, number] = [...lastPosition] const position: [number, number, number] = [...lastPosition]
const nodeId = (node as { id?: ColumnNode['id'] }).id const nodeId = (node as { id?: ColumnNode['id'] }).id
let committedId = node.id as AnyNodeId
if (nodeId && useScene.getState().nodes[nodeId]) { if (nodeId && useScene.getState().nodes[nodeId]) {
const data = {
position,
rotation: rotationY,
...(isNew
? {
metadata: stripPlacementMetadataFlags(node.metadata) as ColumnNode['metadata'],
visible: true,
}
: null),
}
if (isNew) {
const finalId = commitFreshPlacementSubtree(nodeId as AnyNodeId, data)
if (finalId) {
committed = true
committedId = finalId
}
} else {
committed = true committed = true
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene useScene.getState().updateNode(nodeId, data)
.getState() }
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) })
useLiveTransforms.getState().clear(nodeId) useLiveTransforms.getState().clear(nodeId)
const m = sceneRegistry.nodes.get(nodeId) const m = sceneRegistry.nodes.get(nodeId)
if (m) { if (m) {
@@ -188,7 +216,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
} }
useLiveTransforms.getState().clear(node.id) useLiveTransforms.getState().clear(node.id)
if (isNew && committed) {
useViewer.getState().setSelection({ selectedIds: [committedId] })
}
triggerSFX('sfx:item-place') triggerSFX('sfx:item-place')
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode() exitMoveMode()
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
@@ -196,12 +228,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
const onCancel = () => { const onCancel = () => {
useLiveTransforms.getState().clear(node.id) useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
if (isNew) {
useScene.getState().deleteNode(node.id as AnyNodeId)
} else {
const m = sceneRegistry.nodes.get(node.id) const m = sceneRegistry.nodes.get(node.id)
if (m) { if (m) {
m.position.set(...getVisualPosition(node.position, node.rotation)) m.position.set(...getVisualPosition(node.position, node.rotation))
m.rotation.y = node.rotation m.rotation.y = node.rotation
} }
useScene.getState().markDirty(node.id as AnyNodeId) useScene.getState().markDirty(node.id as AnyNodeId)
}
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
@@ -219,17 +255,20 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id) useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
if (!committed) { const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
if (!(committed || isNew || finalisedBy2D)) {
const m = sceneRegistry.nodes.get(node.id) const m = sceneRegistry.nodes.get(node.id)
if (m) { if (m) {
m.position.set(...getVisualPosition(node.position, node.rotation)) m.position.set(...getVisualPosition(node.position, node.rotation))
m.rotation.y = node.rotation m.rotation.y = node.rotation
} }
useScene.getState().markDirty(node.id as AnyNodeId) useScene.getState().markDirty(node.id as AnyNodeId)
}
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
} }
} }, [exitMoveMode, isFreshPlacement, node, revealFreshPlacement, useAbsoluteCursorPlacement])
}, [exitMoveMode, node])
if (!previewVisible) return null
return ( return (
<> <>
+42 -53
View File
@@ -7,24 +7,27 @@ import {
collectAlignmentAnchors, collectAlignmentAnchors,
emitter, emitter,
type GridEvent, type GridEvent,
movingFootprintAnchors,
resolveAlignment,
snapPointToGrid,
useAlignmentGuides, useAlignmentGuides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { getFloorStackPreviewPosition, triggerSFX, usePlacementPreview } from '@pascal-app/editor' import {
getFloorStackPreviewPosition,
triggerSFX,
useEditor,
usePlacementPreview,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { Group } from 'three' import type { Group } from 'three'
import {
type FloorPlacementClickTriggerEvent,
getLevelLocalSnappedPosition,
resolveAlignedFloorPlacement,
stopPlacementCommitPropagation,
subscribeFloorPlacementClicks,
} from '../shared/floor-placement'
import { ColumnPreview } from './renderer' import { ColumnPreview } from './renderer'
const GRID_STEP = 0.5
/** Figma-style alignment-snap threshold (meters), matching the move tools and
* the shelf placement tool. */
const ALIGNMENT_THRESHOLD_M = 0.08
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) { function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
@@ -52,6 +55,8 @@ const ColumnTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId) const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null) const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<[number, number] | null>(null) const previousSnapRef = useRef<[number, number] | null>(null)
const cursorVisibleRef = useRef(false)
const [cursorVisible, setCursorVisible] = useState(false)
// Default-preset column for the placement ghost — matches exactly what the // Default-preset column for the placement ghost — matches exactly what the
// commit creates (`basicPillar`), so the preview is faithful. // commit creates (`basicPillar`), so the preview is faithful.
@@ -60,6 +65,9 @@ const ColumnTool = () => {
useEffect(() => { useEffect(() => {
if (!activeLevelId) return if (!activeLevelId) return
previousSnapRef.current = null previousSnapRef.current = null
cursorVisibleRef.current = false
setCursorVisible(false)
const lastCursorRef: { current: [number, number, number] | null } = { current: null }
// Alignment candidates — anchors of every other alignable object, gathered // Alignment candidates — anchors of every other alignable object, gathered
// here and refreshed after each placement so a just-placed column becomes a // here and refreshed after each placement so a just-placed column becomes a
@@ -68,30 +76,21 @@ const ColumnTool = () => {
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) if (!cursorVisibleRef.current) {
cursorVisibleRef.current = true
setCursorVisible(true)
}
// Figma-style alignment snap layered on top of grid snap: when the const { position, guides } = resolveAlignedFloorPlacement({
// preview column's footprint edge lines up (on X or Z) with another node: previewNode,
// object's edge, snap there and publish a guide. Alt bypasses. rawX: event.localPosition[0],
let ax = sx rawZ: event.localPosition[2],
let az = sz gridStep: useEditor.getState().gridSnapStep,
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
candidates: alignmentCandidates, candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M, bypassAlignment: event.nativeEvent?.altKey === true,
}) })
if (result.snap) { useAlignmentGuides.getState().set(guides)
ax += result.snap.dx
az += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
const position: [number, number, number] = [ax, 0, az]
const visualPosition = getFloorStackPreviewPosition({ const visualPosition = getFloorStackPreviewPosition({
node: previewNode, node: previewNode,
position, position,
@@ -99,6 +98,7 @@ const ColumnTool = () => {
levelId: activeLevelId, levelId: activeLevelId,
}) })
cursorRef.current?.position.set(...visualPosition) cursorRef.current?.position.set(...visualPosition)
lastCursorRef.current = position
// Publish a transient, positioned preview node for the 2D floor-plan // Publish a transient, positioned preview node for the 2D floor-plan
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan // ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan
@@ -107,30 +107,18 @@ const ColumnTool = () => {
usePlacementPreview.getState().set({ ...previewNode, position }) usePlacementPreview.getState().set({ ...previewNode, position })
const prev = previousSnapRef.current const prev = previousSnapRef.current
if (!prev || prev[0] !== ax || prev[1] !== az) { if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
previousSnapRef.current = [ax, az] previousSnapRef.current = [position[0], position[2]]
} }
} }
const onGridClick = (event: GridEvent) => { const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) const position =
let ax = sx lastCursorRef.current ??
let az = sz getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep)
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
ax += result.snap.dx
az += result.snap.dz
}
}
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [ax, 0, az]) const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
useScene.getState().createNode(column, activeLevelId) useScene.getState().createNode(column, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [column.id] }) useViewer.getState().setSelection({ selectedIds: [column.id] })
triggerSFX('sfx:structure-build') triggerSFX('sfx:structure-build')
@@ -140,14 +128,15 @@ const ColumnTool = () => {
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear() usePlacementPreview.getState().clear()
stopPlacementCommitPropagation(event)
} }
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) const unsubscribePlacementClicks = subscribeFloorPlacementClicks(commitAtCursor)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) unsubscribePlacementClicks()
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear() usePlacementPreview.getState().clear()
} }
@@ -156,7 +145,7 @@ const ColumnTool = () => {
if (!activeLevelId) return null if (!activeLevelId) return null
return ( return (
<group ref={cursorRef}> <group ref={cursorRef} visible={cursorVisible}>
<ColumnPreview node={previewNode} /> <ColumnPreview node={previewNode} />
</group> </group>
) )
+19 -2
View File
@@ -4,9 +4,15 @@ import {
type FloorplanMoveTarget, type FloorplanMoveTarget,
type FloorplanMoveTargetSession, type FloorplanMoveTargetSession,
useScene, useScene,
type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor' import { snapToHalf } from '@pascal-app/editor'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
snapLocalXToNeighbors,
} from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './door-math' import { clampToWall, hasWallChildOverlap } from './door-math'
/** /**
@@ -36,6 +42,16 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const wall = useScene.getState().nodes[node.parentId as AnyNodeId] const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null return wall ? (wall.parentId as AnyNodeId | null) : null
})() })()
const originalWall = node.parentId
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
: undefined
const resolveCursor = createFloorplanCursorResolver({
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: [node.position[0], 0],
metadata: node.metadata,
})
// Track the last successful placement so `commit()` can write it // Track the last successful placement so `commit()` can write it
// atomically — see the comment on `commit` below for why we don't // atomically — see the comment on `commit` below for why we don't
@@ -52,7 +68,8 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
affectedIds: [node.id as AnyNodeId], affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) return // pointer off any wall — keep door at last valid position if (!hit) return // pointer off any wall — keep door at last valid position
// Figma-style along-wall alignment first (edge-to-edge with other // Figma-style along-wall alignment first (edge-to-edge with other
+36 -12
View File
@@ -17,6 +17,7 @@ import { ElevatorNode } from './schema'
const SIDE_HANDLE_OFFSET = 0.22 const SIDE_HANDLE_OFFSET = 0.22
const HEIGHT_HANDLE_OFFSET = 0.3 const HEIGHT_HANDLE_OFFSET = 0.3
const MOVE_FRONT_OFFSET = 0.35
const MIN_ELEVATOR_DIM = 0.6 const MIN_ELEVATOR_DIM = 0.6
const MIN_CAB_HEIGHT = 1.4 const MIN_CAB_HEIGHT = 1.4
const ROTATE_CORNER_OFFSET = 0.4 const ROTATE_CORNER_OFFSET = 0.4
@@ -81,6 +82,16 @@ function elevatorCabHeightHandle(): HandleDescriptor<ElevatorNodeType> {
} }
} }
function elevatorOuterHalfExtents(n: ElevatorNodeType): { halfX: number; halfZ: number } {
const cabWidth = getElevatorCabWidth(n)
const cabDepth = getElevatorCabDepth(n)
const wallThickness = getElevatorShaftWallThickness(n)
return {
halfX: getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness,
halfZ: getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness,
}
}
// Rotation handle — sits at the front-right corner of the shaft // Rotation handle — sits at the front-right corner of the shaft
// footprint. `arc-resize` does the angular drag math (raycasts a // footprint. `arc-resize` does the angular drag math (raycasts a
// horizontal plane at the arrow's Y, measures cursor angle around the // horizontal plane at the arrow's Y, measures cursor angle around the
@@ -103,11 +114,7 @@ function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
// shaft rather than diagonally at the corner — matches the column's // shaft rather than diagonally at the corner — matches the column's
// one-direction rotate placement. // one-direction rotate placement.
position: (n) => { position: (n) => {
const cabWidth = getElevatorCabWidth(n) const { halfX, halfZ } = elevatorOuterHalfExtents(n)
const cabDepth = getElevatorCabDepth(n)
const wallThickness = getElevatorShaftWallThickness(n)
const halfX = getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness
const halfZ = getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness
const yMid = Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2 const yMid = Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2
return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET] return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET]
}, },
@@ -120,11 +127,7 @@ function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
// Bounding circle through the shaft corners — drawn slightly larger // Bounding circle through the shaft corners — drawn slightly larger
// so it sits outside the visible shell. // so it sits outside the visible shell.
radius: (n) => { radius: (n) => {
const cabWidth = getElevatorCabWidth(n) const { halfX, halfZ } = elevatorOuterHalfExtents(n)
const cabDepth = getElevatorCabDepth(n)
const wallThickness = getElevatorShaftWallThickness(n)
const halfX = getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness
const halfZ = getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness
return Math.hypot(halfX, halfZ) + ROTATE_RING_OFFSET return Math.hypot(halfX, halfZ) + ROTATE_RING_OFFSET
}, },
y: (n) => Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2, y: (n) => Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2,
@@ -132,11 +135,32 @@ function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
} }
} }
function elevatorMoveHandle(): HandleDescriptor<ElevatorNodeType> {
return {
kind: 'translate',
placement: {
position: (n) => {
const { halfZ } = elevatorOuterHalfExtents(n)
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const { halfX, halfZ } = elevatorOuterHalfExtents(n)
const dimX = Math.max(halfX * 2, MIN_ELEVATOR_DIM)
const dimZ = Math.max(halfZ * 2, MIN_ELEVATOR_DIM)
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
},
}
}
const elevatorHandles: HandleDescriptor<ElevatorNodeType>[] = [ const elevatorHandles: HandleDescriptor<ElevatorNodeType>[] = [
elevatorAxisHandle('x'), elevatorAxisHandle('x'),
elevatorAxisHandle('z'), elevatorAxisHandle('z'),
elevatorCabHeightHandle(), elevatorCabHeightHandle(),
elevatorRotateHandle(), elevatorRotateHandle(),
elevatorMoveHandle(),
] ]
/** /**
@@ -174,10 +198,10 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
// bridge relocates this same footprint to the drag point. // bridge relocates this same footprint to the drag point.
alignmentFootprint: (node) => { alignmentFootprint: (node) => {
const e = node as ElevatorNodeType const e = node as ElevatorNodeType
const wall = getElevatorShaftWallThickness(e) const { halfX, halfZ } = elevatorOuterHalfExtents(e)
return { return {
shape: 'box', shape: 'box',
dimensions: [getElevatorShaftWidth(e) + wall * 2, 1, getElevatorShaftDepth(e) + wall * 2], dimensions: [halfX * 2, 1, halfZ * 2],
rotation: [0, e.rotation ?? 0, 0], rotation: [0, e.rotation ?? 0, 0],
} }
}, },
+106 -11
View File
@@ -10,7 +10,8 @@ import {
movingFootprintAnchors, movingFootprintAnchors,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { applyFloorplanAlignment, snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target' import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
/** /**
@@ -34,7 +35,95 @@ import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-att
* the item's current attach family. * the item's current attach family.
*/ */
const GRID_STEP = 0.5 type ItemPlanTransform = {
point: [number, number]
rotation: number
}
function rotateVec(x: number, z: number, rotationY: number): [number, number] {
const c = Math.cos(rotationY)
const s = Math.sin(rotationY)
return [x * c + z * s, -x * s + z * c]
}
function resolveItemPlanTransform(
item: ItemNode,
nodes: Record<AnyNodeId, AnyNode>,
cache = new Map<AnyNodeId, ItemPlanTransform>(),
): ItemPlanTransform {
const cached = cache.get(item.id as AnyNodeId)
if (cached) return cached
const localRotation = item.rotation[1] ?? 0
let result: ItemPlanTransform = {
point: [item.position[0], item.position[2]],
rotation: localRotation,
}
const parent = item.parentId ? nodes[item.parentId as AnyNodeId] : null
if (parent?.type === 'wall') {
const wallRotation = -Math.atan2(
parent.end[1] - parent.start[1],
parent.end[0] - parent.start[0],
)
const wallLocalZ =
item.asset.attachTo === 'wall-side'
? ((parent.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
: item.position[2]
const [offsetX, offsetZ] = rotateVec(item.position[0], wallLocalZ, wallRotation)
result = {
point: [parent.start[0] + offsetX, parent.start[1] + offsetZ],
rotation: wallRotation + localRotation,
}
} else if (parent?.type === 'shelf') {
const shelf = parent as AnyNode & {
position: [number, number, number]
rotation: [number, number, number]
}
const [offsetX, offsetZ] = rotateVec(item.position[0], item.position[2], shelf.rotation[1] ?? 0)
result = {
point: [shelf.position[0] + offsetX, shelf.position[2] + offsetZ],
rotation: (shelf.rotation[1] ?? 0) + localRotation,
}
} else if (parent?.type === 'item') {
const parentTransform = resolveItemPlanTransform(parent as ItemNode, nodes, cache)
const [offsetX, offsetZ] = rotateVec(
item.position[0],
item.position[2],
parentTransform.rotation,
)
result = {
point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ],
rotation: parentTransform.rotation + localRotation,
}
}
cache.set(item.id as AnyNodeId, result)
return result
}
function resolveItemPlanPoint(
item: ItemNode,
nodes: Record<AnyNodeId, AnyNode>,
cache = new Map<AnyNodeId, ItemPlanTransform>(),
): [number, number] {
return resolveItemPlanTransform(item, nodes, cache).point
}
function createPlanarMovePointResolver(originalPlanPoint: [number, number], node: ItemNode) {
const resolveCursor = createFloorplanCursorResolver({
original: originalPlanPoint,
metadata: node.metadata,
})
return (planPoint: readonly [number, number], shiftKey: boolean): WallPlanPoint => {
const snap = (value: number) => {
if (shiftKey) return value
const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step
}
return resolveCursor(planPoint, { snap }) as WallPlanPoint
}
}
export const itemFloorplanMoveTarget: FloorplanMoveTarget<ItemNode> = ({ node, nodes }) => { export const itemFloorplanMoveTarget: FloorplanMoveTarget<ItemNode> = ({ node, nodes }) => {
const attachTo = node.asset.attachTo const attachTo = node.asset.attachTo
@@ -77,12 +166,17 @@ function buildWallItemSession(
// local-Y carries over from the source item's position (2D can't // local-Y carries over from the source item's position (2D can't
// express vertical movement). // express vertical movement).
const startLocalY = node.position[1] const startLocalY = node.position[1]
const resolveCursor = createFloorplanCursorResolver({
original: resolveItemPlanPoint(node, useScene.getState().nodes),
metadata: node.metadata,
})
return { return {
affectedIds: [node.id as AnyNodeId], affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) return if (!hit) return
const [width] = getScaledDimensions(node) const [width] = getScaledDimensions(node)
@@ -99,9 +193,9 @@ function buildWallItemSession(
selfId: node.id as AnyNodeId, selfId: node.id as AnyNodeId,
nodes, nodes,
}) })
const step = useEditor.getState().gridSnapStep
const snappedLocalX = const snappedLocalX =
neighborX ?? neighborX ?? (modifiers.shiftKey ? hit.localX : Math.round(hit.localX / step) * step)
(modifiers.shiftKey ? hit.localX : Math.round(hit.localX / GRID_STEP) * GRID_STEP)
const halfW = width / 2 const halfW = width / 2
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX)) const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
@@ -143,14 +237,13 @@ function buildFloorItemSession(
nodes: Record<AnyNodeId, AnyNode>, nodes: Record<AnyNodeId, AnyNode>,
): FloorplanMoveTargetSession { ): FloorplanMoveTargetSession {
const rotationY = node.rotation[1] ?? 0 const rotationY = node.rotation[1] ?? 0
const resolvePlanPoint = createPlanarMovePointResolver(resolveItemPlanPoint(node, nodes), node)
// Alignment candidates gathered once — scene is stable during the drag. // Alignment candidates gathered once — scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, node.id) const candidates = collectAlignmentAnchors(nodes, node.id)
return { return {
affectedIds: [node.id as AnyNodeId], affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const gridSnapped: WallPlanPoint = modifiers.shiftKey const gridSnapped = resolvePlanPoint(planPoint, modifiers.shiftKey)
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
// Figma-style alignment layered on the grid snap (Alt bypasses). // Figma-style alignment layered on the grid snap (Alt bypasses).
const { point: snapped } = applyFloorplanAlignment( const { point: snapped } = applyFloorplanAlignment(
gridSnapped, gridSnapped,
@@ -200,13 +293,15 @@ function buildSurfaceItemSession(
startLevelId: AnyNodeId | null, startLevelId: AnyNodeId | null,
targetKind: 'ceiling', targetKind: 'ceiling',
): FloorplanMoveTargetSession { ): FloorplanMoveTargetSession {
const resolvePlanPoint = createPlanarMovePointResolver(
resolveItemPlanPoint(node, useScene.getState().nodes),
node,
)
return { return {
affectedIds: [node.id as AnyNodeId], affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const snapped: WallPlanPoint = modifiers.shiftKey const snapped = resolvePlanPoint(planPoint, modifiers.shiftKey)
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
const surface = findContainingSurface(snapped, nodes, startLevelId, targetKind) const surface = findContainingSurface(snapped, nodes, startLevelId, targetKind)
@@ -7,6 +7,8 @@ import {
snapScalar, snapScalar,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { getSegmentGridStep } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
const MIN_ROOF_DIM = 1 const MIN_ROOF_DIM = 1
@@ -148,11 +150,15 @@ export const roofSegmentRotateAffordance: FloorplanAffordance<RoofSegmentNode> =
export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ node, nodes }) => { export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ node, nodes }) => {
const segmentId = node.id as AnyNodeId const segmentId = node.id as AnyNodeId
const initialY = node.position[1] const initialY = node.position[1]
const { roofRot, cosRoof, sinRoof } = resolveSegmentFrame(node, nodes) const { cx, cz, roofRot, cosRoof, sinRoof } = resolveSegmentFrame(node, nodes)
const roofId = (node as unknown as { parentId?: AnyNodeId | null }).parentId const roofId = (node as unknown as { parentId?: AnyNodeId | null }).parentId
const roof = roofId ? (nodes[roofId] as RoofNode | undefined) : undefined const roof = roofId ? (nodes[roofId] as RoofNode | undefined) : undefined
const roofPosX = roof?.position[0] ?? 0 const roofPosX = roof?.position[0] ?? 0
const roofPosZ = roof?.position[2] ?? 0 const roofPosZ = roof?.position[2] ?? 0
const resolveCursor = createFloorplanCursorResolver({
original: [cx, cz],
metadata: node.metadata,
})
// Inverse of the forward transform `[cosRoof, -sinRoof; sinRoof, cosRoof]` // Inverse of the forward transform `[cosRoof, -sinRoof; sinRoof, cosRoof]`
// is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor // is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor
// back into roof-local coords. // back into roof-local coords.
@@ -162,17 +168,13 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ no
return { return {
affectedIds: [segmentId], affectedIds: [segmentId],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const dx = planPoint[0] - roofPosX const step = getSegmentGridStep()
const dz = planPoint[1] - roofPosZ const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
const worldPoint = resolveCursor(planPoint, { snap })
const dx = worldPoint[0] - roofPosX
const dz = worldPoint[1] - roofPosZ
let localX = dx * cosRoof + dz * sinRoof let localX = dx * cosRoof + dz * sinRoof
let localZ = -dx * sinRoof + dz * cosRoof let localZ = -dx * sinRoof + dz * cosRoof
// 0.5m grid snap (alt held disables). Mirrors the generic Path 2
// fallback's `snapPointToGrid` step so floor-plan moves feel
// consistent across kinds.
if (!modifiers.altKey) {
localX = Math.round(localX * 2) / 2
localZ = Math.round(localZ * 2) / 2
}
lastLocal = [localX, initialY, localZ] lastLocal = [localX, initialY, localZ]
useScene.getState().updateNode(segmentId, { position: lastLocal }) useScene.getState().updateNode(segmentId, { position: lastLocal })
}, },
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test'
import { type GridEvent, type NodeEvent, ShelfNode } from '@pascal-app/core'
import { Object3D } from 'three'
import { getLevelLocalSnappedPosition, resolveAlignedFloorPlacement } from './floor-placement'
const nativeEvent = {} as GridEvent['nativeEvent']
describe('floor placement helpers', () => {
test('resolveAlignedFloorPlacement snaps to the provided grid step', () => {
const node = ShelfNode.parse({ position: [0, 0, 0] })
const { guides, position } = resolveAlignedFloorPlacement({
node,
rawX: 0.13,
rawZ: 0.37,
gridStep: 0.25,
candidates: [],
})
expect(position).toEqual([0.25, 0, 0.25])
expect(guides).toEqual([])
})
test('getLevelLocalSnappedPosition falls back to node world position for node events', () => {
const node = ShelfNode.parse({ position: [0, 0, 0] })
const event: NodeEvent = {
node,
position: [0.13, 0, 0.37],
localPosition: [42, 0, 42],
object: new Object3D(),
stopPropagation: () => {},
nativeEvent,
}
expect(getLevelLocalSnappedPosition('missing-level', event, 0.25)).toEqual([0.25, 0, 0.25])
})
})
@@ -0,0 +1,125 @@
import {
type AnyNode,
type EventSuffix,
emitter,
type GridEvent,
movingFootprintAnchors,
type NodeEvent,
resolveAlignment,
sceneRegistry,
snapPointToGrid,
} from '@pascal-app/core'
import { Vector3 } from 'three'
export const FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M = 0.08
export const FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS = [
'shelf',
'item',
'slab',
'ceiling',
'wall',
'fence',
'column',
'roof',
'roof-segment',
'stair',
'stair-segment',
] as const
export type FloorPlacementClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
type FloorPlacementAlignmentArgs = {
node: AnyNode
rawX: number
rawZ: number
gridStep: number
candidates: Parameters<typeof resolveAlignment>[0]['candidates']
bypassAlignment?: boolean
rotationY?: number
}
const worldVector = new Vector3()
export function getLevelLocalSnappedPosition(
levelId: string,
event: FloorPlacementClickTriggerEvent,
gridStep: number,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
const rawPoint = 'node' in event ? event.position : event.localPosition
const [sx, sz] = snapPointToGrid([rawPoint[0], rawPoint[2]], gridStep)
return [sx, 0, sz]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], gridStep)
return [sx, 0, sz]
}
export function resolveAlignedFloorPlacement({
node,
rawX,
rawZ,
gridStep,
candidates,
bypassAlignment = false,
rotationY = 0,
}: FloorPlacementAlignmentArgs) {
const [sx, sz] = snapPointToGrid([rawX, rawZ], gridStep)
let ax = sx
let az = sz
const result =
!bypassAlignment && candidates.length > 0
? resolveAlignment({
moving: movingFootprintAnchors(node, sx, sz, rotationY),
candidates,
threshold: FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M,
})
: null
if (result?.snap) {
ax += result.snap.dx
az += result.snap.dz
}
return {
position: [ax, 0, az] as [number, number, number],
guides: result?.guides ?? [],
}
}
export function stopPlacementCommitPropagation(event: FloorPlacementClickTriggerEvent) {
const native = (event as { nativeEvent?: unknown }).nativeEvent
const nativeStopPropagation = (native as { stopPropagation?: () => void } | undefined)
?.stopPropagation
if (typeof nativeStopPropagation === 'function') {
nativeStopPropagation.call(native)
}
const direct = (event as { stopPropagation?: () => void }).stopPropagation
if (typeof direct === 'function') direct.call(event)
}
export function subscribeFloorPlacementClicks(
onClick: (event: FloorPlacementClickTriggerEvent) => void,
) {
emitter.on('grid:click', onClick)
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
type ClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]>
for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.on(key, onClick as never)
}
return () => {
emitter.off('grid:click', onClick)
for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.off(key, onClick as never)
}
}
}
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'bun:test'
import { createFloorplanCursorResolver } from './floorplan-cursor'
describe('createFloorplanCursorResolver', () => {
test('keeps existing nodes at their original position on the first cursor sample', () => {
const resolveCursor = createFloorplanCursorResolver({ original: [4, 6] })
expect(resolveCursor([10, 12])).toEqual([4, 6])
expect(resolveCursor([11, 14])).toEqual([5, 8])
})
test('places fresh nodes absolutely under the cursor', () => {
const resolveCursor = createFloorplanCursorResolver({
original: [0, 0],
metadata: { isNew: true },
})
expect(resolveCursor([10, 12])).toEqual([10, 12])
expect(resolveCursor([11, 14])).toEqual([11, 14])
})
test('snaps relative movement without snapping the original position', () => {
const resolveCursor = createFloorplanCursorResolver({ original: [4.1, 6.1] })
const snap = (value: number) => Math.round(value / 0.5) * 0.5
expect(resolveCursor([10.1, 12.1], { snap })).toEqual([4.1, 6.1])
expect(resolveCursor([10.37, 12.88], { snap })).toEqual([4.6, 7.1])
})
})
@@ -0,0 +1,35 @@
import {
isFreshPlacementMetadata,
type PlanarCursorPlacementMode,
type PlanarPoint,
resolvePlanarCursorPosition,
} from '@pascal-app/editor'
type FloorplanCursorResolverOptions = {
snap?: (value: number) => number
}
export function createFloorplanCursorResolver(args: {
original: readonly [number, number]
metadata?: unknown
mode?: PlanarCursorPlacementMode
}) {
const original: PlanarPoint = [args.original[0], args.original[1]]
const mode = args.mode ?? (isFreshPlacementMetadata(args.metadata) ? 'absolute' : 'relative')
let anchor: PlanarPoint | null = null
return (
planPoint: readonly [number, number],
options: FloorplanCursorResolverOptions = {},
): PlanarPoint => {
const resolved = resolvePlanarCursorPosition({
cursor: [planPoint[0], planPoint[1]],
original,
anchor,
mode,
...(options.snap ? { snap: options.snap } : {}),
})
anchor = resolved.anchor
return resolved.point
}
}
+56 -42
View File
@@ -5,6 +5,7 @@ import {
type FenceNode, type FenceNode,
type GridEvent, type GridEvent,
type LevelNode, type LevelNode,
movingAlignmentAnchors,
nodeRegistry, nodeRegistry,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
@@ -19,11 +20,14 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
clearRoofDuplicateMetadata, commitFreshPlacementSubtree,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
resolvePlanarCursorPosition,
snapFenceDraftPoint, snapFenceDraftPoint,
stripPlacementMetadataFlags,
triggerSFX, triggerSFX,
useEditor, useEditor,
useFreshPlacementVisibility,
type WallPlanPoint, type WallPlanPoint,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -36,6 +40,15 @@ const ALIGNMENT_THRESHOLD_M = 0.08
export const MoveRoofTool: React.FC<{ export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
}> = ({ node: movingNode }) => { }> = ({ node: movingNode }) => {
const {
isFreshPlacement,
previewVisible: cursorVisible,
revealFreshPlacement,
useAbsoluteCursorPlacement,
} = useFreshPlacementVisibility({
node: movingNode,
enabled: movingNode.type === 'roof' || movingNode.type === 'stair',
})
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
}, []) }, [])
@@ -82,25 +95,8 @@ export const MoveRoofTool: React.FC<{
dragAnchorRef.current = null dragAnchorRef.current = null
previousGridPosRef.current = null previousGridPosRef.current = null
const meta = const isNew = isFreshPlacement
typeof movingNode.metadata === 'object' && movingNode.metadata !== null const committedMeta = stripPlacementMetadataFlags(movingNode.metadata) as RoofNode['metadata']
? (movingNode.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const committedMeta: RoofNode['metadata'] = (() => {
if (
typeof movingNode.metadata !== 'object' ||
movingNode.metadata === null ||
Array.isArray(movingNode.metadata)
) {
return movingNode.metadata
}
const nextMeta = { ...movingNode.metadata } as Record<string, unknown>
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta as RoofNode['metadata']
})()
const original = { const original = {
position: [...movingNode.position] as [number, number, number], position: [...movingNode.position] as [number, number, number],
@@ -115,6 +111,7 @@ export const MoveRoofTool: React.FC<{
// expensive merged-mesh CSG rebuilds on every frame. // expensive merged-mesh CSG rebuilds on every frame.
let wasCommitted = false let wasCommitted = false
let wasCancelled = false let wasCancelled = false
let hasMoved = false
// Track pending rotation — no store updates during drag // Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number let pendingRotation: number = movingNode.rotation as number
@@ -190,20 +187,28 @@ export const MoveRoofTool: React.FC<{
// Alignment for top-level stair / roof only. Segments live in parent-local // Alignment for top-level stair / roof only. Segments live in parent-local
// space (a different frame from the building-local candidate pool / guide // space (a different frame from the building-local candidate pool / guide
// layer), so we leave them on the plain grid+corner snap. The moving node // layer), so we leave them on the plain grid+corner snap. Stairs align by
// is aligned by its ORIGIN point (how this tool positions it), snapped to // their footprint edges; roofs keep the origin-point behavior.
// any other alignable object's anchors.
const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof' const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof'
const alignmentCandidates = alignTopLevel const alignmentCandidates = alignTopLevel
? collectAlignmentAnchors(useScene.getState().nodes, movingNode.id) ? collectAlignmentAnchors(
useScene.getState().nodes,
movingNode.id,
movingNode.type === 'stair' ? levelId : undefined,
)
: [] : []
const alignLocalPoint = (lx: number, lz: number, bypass: boolean): [number, number] => { const alignLocalPoint = (lx: number, lz: number, bypass: boolean): [number, number] => {
if (!alignTopLevel || bypass || alignmentCandidates.length === 0) { if (!alignTopLevel || bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return [lx, lz] return [lx, lz]
} }
const moving =
movingNode.type === 'stair'
? movingAlignmentAnchors(movingNode, useScene.getState().nodes, lx, lz, pendingRotation)
: []
const ar = resolveAlignment({ const ar = resolveAlignment({
moving: [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }], moving:
moving.length > 0 ? moving : [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }],
candidates: alignmentCandidates, candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M, threshold: ALIGNMENT_THRESHOLD_M,
}) })
@@ -277,6 +282,9 @@ export const MoveRoofTool: React.FC<{
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
hasMoved = true
revealFreshPlacement()
const y = event.position[1] const y = event.position[1]
const snappedLocal = snapFenceDraftPoint({ const snappedLocal = snapFenceDraftPoint({
@@ -292,11 +300,14 @@ export const MoveRoofTool: React.FC<{
snappedLocal[0], snappedLocal[0],
snappedLocal[1], snappedLocal[1],
) )
const anchor = dragAnchorRef.current ?? [rawLocalX, rawLocalZ] const resolved = resolvePlanarCursorPosition({
dragAnchorRef.current = anchor cursor: [rawLocalX, rawLocalZ],
original: [movingNode.position[0], movingNode.position[2]],
let localX = movingNode.position[0] + (rawLocalX - anchor[0]) anchor: dragAnchorRef.current,
let localZ = movingNode.position[2] + (rawLocalZ - anchor[1]) mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
})
dragAnchorRef.current = resolved.anchor
let [localX, localZ] = resolved.point
if (alignTopLevel) { if (alignTopLevel) {
const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true) const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true)
@@ -340,34 +351,37 @@ export const MoveRoofTool: React.FC<{
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (!hasMoved) return
const [localX, , localZ] = lastLocalPosition const [localX, , localZ] = lastLocalPosition
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
wasCommitted = true wasCommitted = true
// The store still holds the original values (we didn't update during drag). let committedId = movingNode.id as AnyNodeId
// Resume temporal and apply the final state as a single undoable step. if (isNew) {
useScene.temporal.getState().resume() committedId =
commitFreshPlacementSubtree(movingNode.id as AnyNodeId, {
if (isNew && movingNode.type === 'roof') {
clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, {
position: [localX, movingNode.position[1], localZ], position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation, rotation: pendingRotation,
metadata: committedMeta, metadata: committedMeta,
}) visible: true,
}) ?? committedId
} else { } else {
// The store still holds the original values (we didn't update during drag).
// Resume temporal and apply the final state as a single undoable step.
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingNode.id, { useScene.getState().updateNode(movingNode.id, {
position: [localX, movingNode.position[1], localZ], position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation, rotation: pendingRotation,
metadata: committedMeta, metadata: committedMeta,
}) })
useScene.temporal.getState().pause()
} }
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place') triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [movingNode.id] }) useViewer.getState().setSelection({ selectedIds: [committedId] })
useLiveTransforms.getState().clear(movingNode.id) useLiveTransforms.getState().clear(movingNode.id)
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode() exitMoveMode()
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
@@ -463,10 +477,10 @@ export const MoveRoofTool: React.FC<{
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
} }
}, [movingNode, exitMoveMode]) }, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
return ( return (
<group> <group visible={cursorVisible}>
<CursorSphere position={cursorWorldPos} showTooltip={false} /> <CursorSphere position={cursorWorldPos} showTooltip={false} />
</group> </group>
) )
@@ -10,17 +10,16 @@ import {
useLiveTransforms, useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' import { getSegmentGridStep, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three' import type * as THREE from 'three'
import { createFloorplanCursorResolver } from './floorplan-cursor'
/** /**
* Shared 2D floor-plan move for polygon-based kinds (slab / ceiling / zone). * Shared 2D floor-plan move for polygon-based kinds (slab / ceiling / zone).
* *
* **Pivot semantics.** The move uses the polygon's **centroid** as the pivot: * Existing polygon kinds preserve the cursor grab offset; fresh catalog
* the centroid snaps to the (grid-snapped, then Figma-aligned) cursor — the * placement uses the polygon centroid as the cursor-following pivot. This
* same way a regular item's origin snaps to the cursor in both 3D and 2D. * matches the generic 3D move tool while keeping polygon geometry in vertices.
* This replaces the old grab-relative delta ("drag from wherever you first
* touched"), so polygon kinds move consistently with every other item.
* *
* **Why a delta in `useLiveTransforms`** (see `wiki/architecture/tools.md`): * **Why a delta in `useLiveTransforms`** (see `wiki/architecture/tools.md`):
* polygon kinds carry their position in their vertices, not a `position` * polygon kinds carry their position in their vertices, not a `position`
@@ -35,8 +34,6 @@ import type * as THREE from 'three'
* ceiling: `height 0.01`) so the 3D mesh doesn't teleport vertically in a * ceiling: `height 0.01`) so the 3D mesh doesn't teleport vertically in a
* split view during the drag. * split view during the drag.
*/ */
const GRID_STEP = 0.5
/** Figma-style alignment threshold (meters) — parity with the 3D move tools. */ /** Figma-style alignment threshold (meters) — parity with the 3D move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08 const ALIGNMENT_THRESHOLD_M = 0.08
@@ -66,6 +63,7 @@ export function createPolygonCentroidMoveTarget(args: {
type: string type: string
polygon: Array<[number, number]> polygon: Array<[number, number]>
holes?: Array<Array<[number, number]>> holes?: Array<Array<[number, number]>>
metadata?: unknown
} }
nodes: Record<AnyNodeId, AnyNode> nodes: Record<AnyNodeId, AnyNode>
/** 3D mesh Y the kind's system parks the group at on rebuild. */ /** 3D mesh Y the kind's system parks the group at on rebuild. */
@@ -80,6 +78,10 @@ export function createPolygonCentroidMoveTarget(args: {
hole.map(([x, z]) => [x, z] as [number, number]), hole.map(([x, z]) => [x, z] as [number, number]),
) )
const originalCenter = polygonCentroid(originalPolygon) const originalCenter = polygonCentroid(originalPolygon)
const resolveCursor = createFloorplanCursorResolver({
original: originalCenter,
metadata: node.metadata,
})
// Alignment candidates gathered once — the scene is stable during the drag. // Alignment candidates gathered once — the scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, id) const candidates = collectAlignmentAnchors(nodes, id)
let lastDelta: [number, number] = [0, 0] let lastDelta: [number, number] = [0, 0]
@@ -90,9 +92,9 @@ export function createPolygonCentroidMoveTarget(args: {
// Centroid → snapped cursor. Grid-snap the target centroid (Shift // Centroid → snapped cursor. Grid-snap the target centroid (Shift
// drops the grid snap), then layer Figma alignment on the translated // drops the grid snap), then layer Figma alignment on the translated
// polygon's vertices and fold its snap into the delta. Alt bypasses. // polygon's vertices and fold its snap into the delta. Alt bypasses.
const target: WallPlanPoint = modifiers.shiftKey const step = getSegmentGridStep()
? ([planPoint[0], planPoint[1]] as WallPlanPoint) const snap = (value: number) => (modifiers.shiftKey ? value : Math.round(value / step) * step)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) const target = resolveCursor(planPoint, { snap }) as WallPlanPoint
let dx = target[0] - originalCenter[0] let dx = target[0] - originalCenter[0]
let dz = target[1] - originalCenter[1] let dz = target[1] - originalCenter[1]
@@ -49,6 +49,17 @@ export type WallHit = {
itemRotation: number itemRotation: number
} }
export function projectWallLocalPointToPlan(
wall: WallNode,
localX: number,
localZ = 0,
): [number, number] {
const angle = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
const c = Math.cos(angle)
const s = Math.sin(angle)
return [wall.start[0] + localX * c + localZ * s, wall.start[1] - localX * s + localZ * c]
}
/** /**
* Walk every wall under `parentLevelId` and return the closest one to * Walk every wall under `parentLevelId` and return the closest one to
* `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`. * `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`.
+12 -6
View File
@@ -11,10 +11,11 @@ import {
import { import {
applyFloorplanAlignment, applyFloorplanAlignment,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
snapPointToGrid,
triggerSFX, triggerSFX,
useEditor,
type WallPlanPoint, type WallPlanPoint,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
/** /**
* 2D floor-plan move handler for shelf — mirrors `itemFloorplanMoveTarget`, * 2D floor-plan move handler for shelf — mirrors `itemFloorplanMoveTarget`,
@@ -38,12 +39,14 @@ import {
* live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the * live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the
* scene directly removes that second source of truth entirely. * scene directly removes that second source of truth entirely.
*/ */
const GRID_STEP = 0.5
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node, nodes }) => { export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node, nodes }) => {
const shelfId = node.id as AnyNodeId const shelfId = node.id as AnyNodeId
const originalPosition: [number, number, number] = [...node.position] as [number, number, number] const originalPosition: [number, number, number] = [...node.position] as [number, number, number]
const originalRotationY = node.rotation[1] ?? 0 const originalRotationY = node.rotation[1] ?? 0
const resolveCursor = createFloorplanCursorResolver({
original: [originalPosition[0], originalPosition[2]],
metadata: node.metadata,
})
let lastPosition: [number, number, number] = originalPosition let lastPosition: [number, number, number] = originalPosition
let lastSnapKey: string | null = null let lastSnapKey: string | null = null
@@ -55,9 +58,12 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
const session: FloorplanMoveTargetSession = { const session: FloorplanMoveTargetSession = {
affectedIds: [shelfId], affectedIds: [shelfId],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const gridSnapped: WallPlanPoint = modifiers.shiftKey const snap = (value: number) => {
? ([planPoint[0], planPoint[1]] as WallPlanPoint) if (modifiers.shiftKey) return value
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step
}
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
// Figma-style alignment layered on the grid snap — the shelf footprint // Figma-style alignment layered on the grid snap — the shelf footprint
// edges snap to neighbours / wall faces and a guide is published. Alt // edges snap to neighbours / wall faces and a guide is published. Alt
// bypasses (matches placement tools' "No snap"). // bypasses (matches placement tools' "No snap").
+35 -119
View File
@@ -1,91 +1,33 @@
'use client' 'use client'
import { import {
type AnyNode,
collectAlignmentAnchors, collectAlignmentAnchors,
type EventSuffix,
emitter, emitter,
type GridEvent, type GridEvent,
movingFootprintAnchors,
type NodeEvent,
resolveAlignment,
ShelfNode, ShelfNode,
sceneRegistry,
snapPointToGrid,
useAlignmentGuides, useAlignmentGuides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { getFloorStackPreviewPosition, triggerSFX } from '@pascal-app/editor' import { getFloorStackPreviewPosition, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { type Group, Vector3 } from 'three' import type { Group } from 'three'
import {
type FloorPlacementClickTriggerEvent,
getLevelLocalSnappedPosition,
resolveAlignedFloorPlacement,
stopPlacementCommitPropagation,
subscribeFloorPlacementClicks,
} from '../shared/floor-placement'
import { shelfDefinition } from './definition' import { shelfDefinition } from './definition'
import ShelfPreview from './preview' import ShelfPreview from './preview'
const worldVector = new Vector3()
const GRID_STEP = 0.5
/** Figma-style alignment-snap threshold (meters), matching the move tools and
* the 2D floor-plan overlay. 8 cm gives a magnetic pull layered on top of the
* grid snap without fighting it. */
const ALIGNMENT_THRESHOLD_M = 0.08
/**
* Click-trigger kinds: when the user clicks ANY of these during shelf
* placement, we commit at the latest cursor position. R3F's pointer
* raycaster dispatches to the closest intersected mesh, so a click on
* a wall / slab / item / etc. would otherwise never reach `grid:click`
* — the placement would silently drop. Listening for each kind's click
* (and committing at the snapshot of the last `grid:move` cursor)
* mirrors the fix in `MoveRegistryNodeTool`.
*/
const CLICK_TRIGGER_KINDS = [
'shelf',
'item',
'slab',
'ceiling',
'wall',
'fence',
'column',
'roof',
'roof-segment',
'stair',
'stair-segment',
] as const
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
/**
* Convert the latest cursor world hit into level-local coords for the
* commit `position`. The cursor's local position from `event.localPosition`
* (building-local) needs to come back through the level's world transform
* so the shelf is stored in its parent's frame.
*/
function getLevelLocalPosition(
levelId: string,
event: GridEvent | NodeEvent<AnyNode>,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
const local = (event as GridEvent).localPosition
if (local) {
const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP)
return [sx, 0, sz]
}
const [sx, sz] = snapPointToGrid([event.position[0], event.position[2]], GRID_STEP)
return [sx, 0, sz]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP)
return [sx, 0, sz]
}
const ShelfTool = () => { const ShelfTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId) const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null) const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<[number, number] | null>(null) const previousSnapRef = useRef<[number, number] | null>(null)
const cursorVisibleRef = useRef(false)
const [cursorVisible, setCursorVisible] = useState(false)
// Default-shaped shelf for the placement preview. Pulls from // Default-shaped shelf for the placement preview. Pulls from
// `shelfDefinition.defaults()` so the preview matches what the commit // `shelfDefinition.defaults()` so the preview matches what the commit
@@ -108,6 +50,8 @@ const ShelfTool = () => {
useEffect(() => { useEffect(() => {
if (!activeLevelId) return if (!activeLevelId) return
previousSnapRef.current = null previousSnapRef.current = null
cursorVisibleRef.current = false
setCursorVisible(false)
/** /**
* Snapped cursor position from the latest `grid:move`. Used as the * Snapped cursor position from the latest `grid:move`. Used as the
* commit position for ANY click variant (grid or node), so clicks * commit position for ANY click variant (grid or node), so clicks
@@ -124,33 +68,21 @@ const ShelfTool = () => {
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) if (!cursorVisibleRef.current) {
cursorVisibleRef.current = true
setCursorVisible(true)
}
// Figma-style alignment snap layered on top of grid snap: when the const { position, guides } = resolveAlignedFloorPlacement({
// preview shelf's footprint edge lines up (on X or Z) with another node: previewNode,
// object's edge, snap there and publish a guide. The probe uses the rawX: event.localPosition[0],
// shelf's footprint corners at the proposed grid position so it aligns rawZ: event.localPosition[2],
// by its edges, not its centre — matching `MoveRegistryNodeTool`. Alt gridStep: useEditor.getState().gridSnapStep,
// bypasses.
let ax = sx
let az = sz
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
candidates: alignmentCandidates, candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M, bypassAlignment: event.nativeEvent?.altKey === true,
}) })
if (result.snap) { useAlignmentGuides.getState().set(guides)
ax += result.snap.dx
az += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
const position: [number, number, number] = [ax, 0, az]
const visualPosition = getFloorStackPreviewPosition({ const visualPosition = getFloorStackPreviewPosition({
node: previewNode, node: previewNode,
position, position,
@@ -161,18 +93,20 @@ const ShelfTool = () => {
lastCursorRef.current = position lastCursorRef.current = position
const prev = previousSnapRef.current const prev = previousSnapRef.current
if (!prev || prev[0] !== ax || prev[1] !== az) { if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
previousSnapRef.current = [ax, az] previousSnapRef.current = [position[0], position[2]]
} }
} }
const commitAtCursor = (event: ClickTriggerEvent) => { const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
// Prefer the latest `grid:move` cursor snapshot; fall back to // Prefer the latest `grid:move` cursor snapshot; fall back to
// projecting the click event into level-local coords if no // projecting the click event into level-local coords if no
// grid:move has fired yet (e.g. cursor entered via a node hit // grid:move has fired yet (e.g. cursor entered via a node hit
// first). Both paths apply the same grid snap. // first). Both paths apply the same grid snap.
const position = lastCursorRef.current ?? getLevelLocalPosition(activeLevelId, event) const position =
lastCursorRef.current ??
getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep)
const shelf = ShelfNode.parse({ const shelf = ShelfNode.parse({
...shelfDefinition.defaults(), ...shelfDefinition.defaults(),
name: 'Shelf', name: 'Shelf',
@@ -187,33 +121,15 @@ const ShelfTool = () => {
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
const native = (event as { nativeEvent?: unknown }).nativeEvent stopPlacementCommitPropagation(event)
if (
native &&
typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function'
) {
;(native as { stopPropagation: () => void }).stopPropagation()
}
const direct = (event as { stopPropagation?: () => void }).stopPropagation
if (typeof direct === 'function') direct.call(event)
} }
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', commitAtCursor) const unsubscribePlacementClicks = subscribeFloorPlacementClicks(commitAtCursor)
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.on(key, commitAtCursor as never)
}
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', commitAtCursor) unsubscribePlacementClicks()
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.off(key, commitAtCursor as never)
}
// Drop any alignment guide left over when the tool deactivates (kind // Drop any alignment guide left over when the tool deactivates (kind
// switch, Esc, unmount) so it doesn't linger over the canvas. // switch, Esc, unmount) so it doesn't linger over the canvas.
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
@@ -223,7 +139,7 @@ const ShelfTool = () => {
if (!activeLevelId) return null if (!activeLevelId) return null
return ( return (
<group ref={cursorRef}> <group ref={cursorRef} visible={cursorVisible}>
<ShelfPreview node={previewNode} /> <ShelfPreview node={previewNode} />
</group> </group>
) )
+2 -2
View File
@@ -433,8 +433,8 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
// A stair has no centred box footprint: straight = a cumulative // A stair has no centred box footprint: straight = a cumulative
// `stair-segment` chain, curved / spiral = an annular sector. Hand the // `stair-segment` chain, curved / spiral = an annular sector. Hand the
// alignment bridge the resolved plan `aabb` directly (not a `box`) — the // alignment bridge the resolved plan `aabb` directly (not a `box`) — the
// stair moves by its origin via `affordanceTools.move`, so it only ever // moving-anchor helper can relocate the same shape when a stair is being
// contributes static candidate anchors, never the relocatable box path. // placed or dragged.
alignmentFootprint: (node, nodes) => { alignmentFootprint: (node, nodes) => {
const aabb = stairFootprintAABB(node as StairNodeType, nodes) const aabb = stairFootprintAABB(node as StairNodeType, nodes)
return aabb ? { shape: 'aabb', ...aabb } : null return aabb ? { shape: 'aabb', ...aabb } : null
+18 -13
View File
@@ -3,24 +3,22 @@ import {
collectAlignmentAnchors, collectAlignmentAnchors,
type FloorplanMoveTarget, type FloorplanMoveTarget,
type FloorplanMoveTargetSession, type FloorplanMoveTargetSession,
movingAlignmentAnchors,
type StairNode, type StairNode,
snapScalar, snapScalar,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor' import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
/** /**
* 2D floor-plan move handler for stair. * 2D floor-plan move handler for stair.
* *
* **Pivot semantics.** The stair's ORIGIN (its `position`) follows the * Existing stairs preserve the cursor grab offset, matching the 3D move
* snapped cursor — the same pivot the 3D move tool (`shared/move-roof-tool`) * tools; fresh catalog placement follows the cursor absolutely.
* uses: it positions the stair by its origin at the grid-snapped, aligned
* cursor, NOT by the grab offset under the mouse. This replaces the old
* grab-relative delta so dragging in 2D tracks the same point as 3D.
* *
* Figma alignment is layered on the origin point (single anchor), matching * Figma alignment is layered on the stair footprint edges; Alt bypasses.
* `move-roof-tool`'s "align by origin" behaviour; Alt bypasses. Guides are * Guides are cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown.
* cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown.
* *
* The position is written straight to scene each tick (the stair has a real * The position is written straight to scene each tick (the stair has a real
* `position` field, unlike polygon kinds) and re-applied atomically via * `position` field, unlike polygon kinds) and re-applied atomically via
@@ -29,6 +27,10 @@ import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor'
*/ */
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node, nodes }) => { export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node, nodes }) => {
const startY = node.position[1] const startY = node.position[1]
const resolveCursor = createFloorplanCursorResolver({
original: [node.position[0], node.position[2]],
metadata: node.metadata,
})
// Alignment candidates gathered once — the scene is stable during the drag. // Alignment candidates gathered once — the scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, node.id) const candidates = collectAlignmentAnchors(nodes, node.id)
let lastValid: { position: [number, number, number] } | null = null let lastValid: { position: [number, number, number] } | null = null
@@ -39,13 +41,16 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
// Snap the origin to the editor's current grid step (driven by // Snap the origin to the editor's current grid step (driven by
// `useEditor.gridSnapStep`). Shift bypasses the grid snap. // `useEditor.gridSnapStep`). Shift bypasses the grid snap.
const step = getSegmentGridStep() const step = getSegmentGridStep()
const gx = modifiers.shiftKey ? planPoint[0] : snapScalar(planPoint[0], step) const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
const gz = modifiers.shiftKey ? planPoint[1] : snapScalar(planPoint[1], step) const [gx, gz] = resolveCursor(planPoint, { snap })
// Figma alignment on the origin point (Alt bypasses), matching the 3D // Figma alignment on the actual stair footprint (Alt bypasses),
// move tool. Publishes guides via `useAlignmentGuides`. // matching the 3D move tool. Publishes guides via `useAlignmentGuides`.
const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0)
const { point: aligned } = applyFloorplanAlignment( const { point: aligned } = applyFloorplanAlignment(
[gx, gz], [gx, gz],
[{ nodeId: node.id, kind: 'corner', x: gx, z: gz }], movingAnchors.length > 0
? movingAnchors
: [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
candidates, candidates,
{ bypass: modifiers.altKey }, { bypass: modifiers.altKey },
) )
+19 -2
View File
@@ -3,10 +3,16 @@ import {
type FloorplanMoveTarget, type FloorplanMoveTarget,
type FloorplanMoveTargetSession, type FloorplanMoveTargetSession,
useScene, useScene,
type WallNode,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor' import { snapToHalf } from '@pascal-app/editor'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
snapLocalXToNeighbors,
} from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './window-math' import { clampToWall, hasWallChildOverlap } from './window-math'
/** /**
@@ -26,6 +32,16 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
const wall = useScene.getState().nodes[node.parentId as AnyNodeId] const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null return wall ? (wall.parentId as AnyNodeId | null) : null
})() })()
const originalWall = node.parentId
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
: undefined
const resolveCursor = createFloorplanCursorResolver({
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: [node.position[0], 0],
metadata: node.metadata,
})
// Preserve the source window's local Y — 2D move doesn't have a way // Preserve the source window's local Y — 2D move doesn't have a way
// to express vertical motion, so we keep whatever vertical position // to express vertical motion, so we keep whatever vertical position
@@ -46,7 +62,8 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
affectedIds: [node.id as AnyNodeId], affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) return if (!hit) return
// Figma-style along-wall alignment first (edge-to-edge with other // Figma-style along-wall alignment first (edge-to-edge with other