Merge pull request #383 from pascalorg/fix/relative-move-grab-offset
Fix relative move and fresh placement commits
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
+50
-11
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,16 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
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 { 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,22 +30,243 @@ 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 CameraPoseSnapshot = {
|
||||||
|
mode: CameraMode
|
||||||
|
position: [number, number, number]
|
||||||
|
target: [number, number, number]
|
||||||
|
}
|
||||||
|
type NavigationCameraPoseSnapshot = {
|
||||||
|
target: [number, number, number]
|
||||||
|
azimuth: number
|
||||||
|
viewWidth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeVectorTuple(tuple: [number, number, number], vector: Vector3) {
|
||||||
|
tuple[0] = vector.x
|
||||||
|
tuple[1] = vector.y
|
||||||
|
tuple[2] = vector.z
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCameraPose(
|
||||||
|
control: CameraControlsImpl,
|
||||||
|
mode: CameraMode,
|
||||||
|
pose: CameraPoseSnapshot,
|
||||||
|
position: Vector3,
|
||||||
|
target: Vector3,
|
||||||
|
) {
|
||||||
|
control.getPosition(position)
|
||||||
|
control.getTarget(target)
|
||||||
|
pose.mode = mode
|
||||||
|
writeVectorTuple(pose.position, position)
|
||||||
|
writeVectorTuple(pose.target, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot) {
|
||||||
|
control.setLookAt(
|
||||||
|
pose.position[0],
|
||||||
|
pose.position[1],
|
||||||
|
pose.position[2],
|
||||||
|
pose.target[0],
|
||||||
|
pose.target[1],
|
||||||
|
pose.target[2],
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getAngleDeltaRadians(a: number, b: number) {
|
||||||
|
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCameraAtNavigationPose(
|
||||||
|
pose: NavigationCameraPoseSnapshot,
|
||||||
|
target: Vector3,
|
||||||
|
azimuth: number,
|
||||||
|
viewWidth: number,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
||||||
|
Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
||||||
|
Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
||||||
|
Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
|
||||||
|
Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraDistanceForViewWidth(
|
||||||
|
camera: Camera,
|
||||||
|
viewWidth: number,
|
||||||
|
size: CameraViewportSize,
|
||||||
|
) {
|
||||||
|
if (!isPerspectiveCamera(camera)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180
|
||||||
|
const denominator = 2 * Math.tan(fovRadians / 2) * getCameraViewAspect(size)
|
||||||
|
|
||||||
|
return denominator > 0 ? Math.max(0.001, viewWidth / denominator) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraZoomForViewWidth(camera: Camera, viewWidth: number) {
|
||||||
|
if (!isOrthographicCamera(camera)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return viewWidth > 0 ? Math.max(0.001, (camera.right - camera.left) / viewWidth) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCameraViewWidth(
|
||||||
|
control: CameraControlsImpl,
|
||||||
|
camera: Camera,
|
||||||
|
viewWidth: number,
|
||||||
|
size: CameraViewportSize,
|
||||||
|
) {
|
||||||
|
const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size)
|
||||||
|
if (nextDistance !== null) {
|
||||||
|
control.dollyTo(nextDistance, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextZoom = getCameraZoomForViewWidth(camera, viewWidth)
|
||||||
|
if (nextZoom !== null) {
|
||||||
|
control.zoomTo(nextZoom, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFirstPersonCameraPoseRestore(
|
||||||
|
controls: { current: CameraControlsImpl | null },
|
||||||
|
isFirstPersonMode: boolean,
|
||||||
|
cameraMode: CameraMode,
|
||||||
|
) {
|
||||||
|
const restorePose = useRef<CameraPoseSnapshot>({
|
||||||
|
mode: cameraMode,
|
||||||
|
position: [0, 0, 0],
|
||||||
|
target: [0, 0, 0],
|
||||||
|
})
|
||||||
|
const hasRestorePose = useRef(false)
|
||||||
|
const isRestoring = useRef(false)
|
||||||
|
const wasFirstPersonMode = useRef(isFirstPersonMode)
|
||||||
|
const snapshotPosition = useRef(new Vector3())
|
||||||
|
const snapshotTarget = useRef(new Vector3())
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (isFirstPersonMode || isRestoring.current) return
|
||||||
|
const control = controls.current
|
||||||
|
if (!control) return
|
||||||
|
|
||||||
|
saveCameraPose(
|
||||||
|
control,
|
||||||
|
cameraMode,
|
||||||
|
restorePose.current,
|
||||||
|
snapshotPosition.current,
|
||||||
|
snapshotTarget.current,
|
||||||
|
)
|
||||||
|
hasRestorePose.current = true
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasFirstPerson = wasFirstPersonMode.current
|
||||||
|
wasFirstPersonMode.current = isFirstPersonMode
|
||||||
|
|
||||||
|
if (isFirstPersonMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasFirstPerson || !hasRestorePose.current) return
|
||||||
|
|
||||||
|
const pose = restorePose.current
|
||||||
|
isRestoring.current = true
|
||||||
|
useViewer.getState().setCameraMode(pose.mode)
|
||||||
|
|
||||||
|
const restoreFrame = requestAnimationFrame(() => {
|
||||||
|
const currentControls = controls.current
|
||||||
|
if (currentControls) {
|
||||||
|
restoreCameraPose(currentControls, pose)
|
||||||
|
}
|
||||||
|
isRestoring.current = false
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(restoreFrame)
|
||||||
|
isRestoring.current = false
|
||||||
|
}
|
||||||
|
}, [controls, isFirstPersonMode])
|
||||||
|
|
||||||
|
return useCallback(() => isRestoring.current, [])
|
||||||
|
}
|
||||||
|
|
||||||
export const CustomCameraControls = () => {
|
export const CustomCameraControls = () => {
|
||||||
const controls = useRef<CameraControlsImpl>(null!)
|
const controls = useRef<CameraControlsImpl | null>(null)
|
||||||
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 isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
|
||||||
|
controls,
|
||||||
|
isFirstPersonMode,
|
||||||
|
cameraMode,
|
||||||
|
)
|
||||||
const currentLevelId = selection.levelId
|
const currentLevelId = selection.levelId
|
||||||
const firstLoad = useRef(true)
|
const firstLoad = useRef(true)
|
||||||
|
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
|
||||||
|
const pendingFloorplanNavigationPose = useRef<NavigationCameraPoseSnapshot | 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)
|
||||||
@@ -47,7 +275,7 @@ export const CustomCameraControls = () => {
|
|||||||
}, [camera, raycaster])
|
}, [camera, raycaster])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPreviewMode) return // Preview mode uses auto-navigate instead
|
if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
|
||||||
let targetY = 0
|
let targetY = 0
|
||||||
if (currentLevelId) {
|
if (currentLevelId) {
|
||||||
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
||||||
@@ -62,10 +290,10 @@ export const CustomCameraControls = () => {
|
|||||||
}
|
}
|
||||||
controls.current.getTarget(currentTarget)
|
controls.current.getTarget(currentTarget)
|
||||||
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
||||||
}, [currentLevelId, isPreviewMode])
|
}, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
|
|
||||||
controls.current.maxPolarAngle = maxPolarAngle
|
controls.current.maxPolarAngle = maxPolarAngle
|
||||||
controls.current.minPolarAngle = 0
|
controls.current.minPolarAngle = 0
|
||||||
@@ -73,11 +301,11 @@ export const CustomCameraControls = () => {
|
|||||||
if (controls.current.polarAngle > maxPolarAngle) {
|
if (controls.current.polarAngle > maxPolarAngle) {
|
||||||
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
||||||
}
|
}
|
||||||
}, [maxPolarAngle])
|
}, [isFirstPersonMode, maxPolarAngle])
|
||||||
|
|
||||||
const focusNode = useCallback(
|
const focusNode = useCallback(
|
||||||
(nodeId: string) => {
|
(nodeId: string) => {
|
||||||
if (isPreviewMode || !controls.current) return
|
if (isPreviewMode || isFirstPersonMode || !controls.current) return
|
||||||
|
|
||||||
const object3D = sceneRegistry.nodes.get(nodeId)
|
const object3D = sceneRegistry.nodes.get(nodeId)
|
||||||
if (!object3D) return
|
if (!object3D) return
|
||||||
@@ -100,11 +328,96 @@ export const CustomCameraControls = () => {
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[isPreviewMode],
|
[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
|
||||||
|
pendingFloorplanNavigationPose.current = {
|
||||||
|
target: [...pose.target],
|
||||||
|
azimuth: pose.azimuth,
|
||||||
|
viewWidth: pose.viewWidth,
|
||||||
|
}
|
||||||
|
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 pendingFloorplanPose = pendingFloorplanNavigationPose.current
|
||||||
|
if (pendingFloorplanPose) {
|
||||||
|
// The camera is still damping toward a 2D-originated pose; do not echo
|
||||||
|
// intermediate 3D poses back into the floorplan.
|
||||||
|
if (
|
||||||
|
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
|
||||||
|
) {
|
||||||
|
lastPublishedNavigationSync.current = pendingFloorplanPose
|
||||||
|
pendingFloorplanNavigationPose.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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(getAngleDeltaRadians(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 cameraMode = useViewer((state) => state.cameraMode)
|
|
||||||
const mouseButtons = useMemo(() => {
|
const mouseButtons = useMemo(() => {
|
||||||
// Use ZOOM for orthographic camera, DOLLY for perspective camera
|
// Use ZOOM for orthographic camera, DOLLY for perspective camera
|
||||||
const wheelAction =
|
const wheelAction =
|
||||||
@@ -170,6 +483,8 @@ export const CustomCameraControls = () => {
|
|||||||
}, [cameraMode, isPreviewMode, isInteracting])
|
}, [cameraMode, isPreviewMode, isInteracting])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isFirstPersonMode) return
|
||||||
|
|
||||||
const keyState = {
|
const keyState = {
|
||||||
shiftRight: false,
|
shiftRight: false,
|
||||||
shiftLeft: false,
|
shiftLeft: false,
|
||||||
@@ -177,6 +492,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
|
||||||
@@ -204,8 +558,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
|
||||||
@@ -225,7 +581,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
|
||||||
@@ -242,15 +602,58 @@ export const CustomCameraControls = () => {
|
|||||||
updateConfig()
|
updateConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
|
||||||
|
pendingFloorplanNavigationPose.current = null
|
||||||
|
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
|
||||||
|
|
||||||
|
panPointerId = event.pointerId
|
||||||
|
panPointerButton = event.button
|
||||||
|
updateNavigationCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWheel = () => {
|
||||||
|
pendingFloorplanNavigationPose.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
gl.domElement.addEventListener('wheel', onWheel, { passive: true })
|
||||||
updateConfig()
|
updateConfig()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
document.removeEventListener('keyup', onKeyUp)
|
document.removeEventListener('keyup', onKeyUp)
|
||||||
|
window.removeEventListener('pointerdown', onPointerDown, true)
|
||||||
|
window.removeEventListener('pointerup', onPointerUp, true)
|
||||||
|
window.removeEventListener('pointercancel', onPointerUp, true)
|
||||||
|
window.removeEventListener('blur', onBlur)
|
||||||
|
gl.domElement.removeEventListener('wheel', onWheel)
|
||||||
|
clearNavigationCursor()
|
||||||
}
|
}
|
||||||
}, [cameraMode, isPreviewMode])
|
}, [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
|
||||||
@@ -258,7 +661,7 @@ export const CustomCameraControls = () => {
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(isPreviewMode && controls.current)) return
|
if (!(isPreviewMode && controls.current) || isFirstPersonMode) return
|
||||||
|
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
|
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
|
||||||
@@ -318,7 +721,7 @@ export const CustomCameraControls = () => {
|
|||||||
tempCenter.z,
|
tempCenter.z,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}, [isPreviewMode, previewTargetNodeId])
|
}, [isPreviewMode, isFirstPersonMode, previewTargetNodeId])
|
||||||
|
|
||||||
// Preset capture auto-framing — when `setCaptureMode({ mode: 'preset',
|
// Preset capture auto-framing — when `setCaptureMode({ mode: 'preset',
|
||||||
// isolated })` fires, fly the camera to a pose that fits the union
|
// isolated })` fires, fly the camera to a pose that fits the union
|
||||||
@@ -329,6 +732,7 @@ export const CustomCameraControls = () => {
|
|||||||
// modal opened.
|
// modal opened.
|
||||||
const captureMode = useEditor((s) => s.captureMode)
|
const captureMode = useEditor((s) => s.captureMode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isFirstPersonMode) return
|
||||||
if (!controls.current) return
|
if (!controls.current) return
|
||||||
if (captureMode.mode !== 'preset') return
|
if (captureMode.mode !== 'preset') return
|
||||||
const ids = captureMode.isolated
|
const ids = captureMode.isolated
|
||||||
@@ -417,11 +821,11 @@ export const CustomCameraControls = () => {
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [captureMode])
|
}, [captureMode, isFirstPersonMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
||||||
if (!controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
|
|
||||||
const position = new Vector3()
|
const position = new Vector3()
|
||||||
const target = new Vector3()
|
const target = new Vector3()
|
||||||
@@ -439,7 +843,7 @@ export const CustomCameraControls = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const handleNodeView = ({ nodeId }: CameraControlEvent) => {
|
const handleNodeView = ({ nodeId }: CameraControlEvent) => {
|
||||||
if (!controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
|
|
||||||
const node = useScene.getState().nodes[nodeId]
|
const node = useScene.getState().nodes[nodeId]
|
||||||
if (!node?.camera) return
|
if (!node?.camera) return
|
||||||
@@ -457,7 +861,7 @@ export const CustomCameraControls = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleTopView = () => {
|
const handleTopView = () => {
|
||||||
if (!controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
|
|
||||||
const currentPolarAngle = controls.current.polarAngle
|
const currentPolarAngle = controls.current.polarAngle
|
||||||
|
|
||||||
@@ -469,7 +873,7 @@ export const CustomCameraControls = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOrbitCW = () => {
|
const handleOrbitCW = () => {
|
||||||
if (!controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
|
|
||||||
const currentAzimuth = controls.current.azimuthAngle
|
const currentAzimuth = controls.current.azimuthAngle
|
||||||
const currentPolar = controls.current.polarAngle
|
const currentPolar = controls.current.polarAngle
|
||||||
@@ -481,7 +885,7 @@ export const CustomCameraControls = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOrbitCCW = () => {
|
const handleOrbitCCW = () => {
|
||||||
if (!controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
|
|
||||||
const currentAzimuth = controls.current.azimuthAngle
|
const currentAzimuth = controls.current.azimuthAngle
|
||||||
const currentPolar = controls.current.polarAngle
|
const currentPolar = controls.current.polarAngle
|
||||||
@@ -497,7 +901,7 @@ export const CustomCameraControls = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
|
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
|
||||||
if (!controls.current || isPreviewMode) return
|
if (isFirstPersonMode || !controls.current || isPreviewMode) return
|
||||||
if (!bounds) {
|
if (!bounds) {
|
||||||
// Restore default framing pose when no bounds were computed.
|
// Restore default framing pose when no bounds were computed.
|
||||||
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
||||||
@@ -530,7 +934,7 @@ export const CustomCameraControls = () => {
|
|||||||
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||||
emitter.off('camera-controls:fit-scene', handleFitScene)
|
emitter.off('camera-controls:fit-scene', handleFitScene)
|
||||||
}
|
}
|
||||||
}, [focusNode, isPreviewMode])
|
}, [focusNode, isPreviewMode, isFirstPersonMode])
|
||||||
|
|
||||||
const onTransitionStart = useCallback(() => {
|
const onTransitionStart = useCallback(() => {
|
||||||
useViewer.getState().setCameraDragging(true)
|
useViewer.getState().setCameraDragging(true)
|
||||||
@@ -540,10 +944,6 @@ export const CustomCameraControls = () => {
|
|||||||
useViewer.getState().setCameraDragging(false)
|
useViewer.getState().setCameraDragging(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (isFirstPersonMode) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preset capture mode frames a single subtree (often a 0.3–2m preset),
|
// Preset capture mode frames a single subtree (often a 0.3–2m preset),
|
||||||
// so the default 6m minDistance prevents the user from getting close
|
// so the default 6m minDistance prevents the user from getting close
|
||||||
// enough to compose a good thumbnail. Relax the clamp to 0.5m while
|
// enough to compose a good thumbnail. Relax the clamp to 0.5m while
|
||||||
@@ -552,6 +952,10 @@ export const CustomCameraControls = () => {
|
|||||||
const isPresetCapture = captureMode.mode === 'preset'
|
const isPresetCapture = captureMode.mode === 'preset'
|
||||||
const minDistance = isPresetCapture ? 0.5 : 6
|
const minDistance = isPresetCapture ? 0.5 : 6
|
||||||
|
|
||||||
|
if (isFirstPersonMode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CameraControls
|
<CameraControls
|
||||||
makeDefault
|
makeDefault
|
||||||
@@ -560,6 +964,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}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeDefinition,
|
||||||
|
CeilingNode,
|
||||||
|
ColumnNode,
|
||||||
|
ElevatorNode,
|
||||||
|
LevelNode,
|
||||||
|
nodeRegistry,
|
||||||
|
registerNode,
|
||||||
|
ShelfNode,
|
||||||
|
SiteNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
|
||||||
|
import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world'
|
||||||
|
|
||||||
|
function registerColliderDefinition(
|
||||||
|
kind: AnyNode['type'],
|
||||||
|
schema: AnyNodeDefinition['schema'],
|
||||||
|
category: AnyNodeDefinition['category'],
|
||||||
|
surfaceRole?: AnyNodeDefinition['surfaceRole'],
|
||||||
|
) {
|
||||||
|
registerNode({
|
||||||
|
kind,
|
||||||
|
schema,
|
||||||
|
schemaVersion: 1,
|
||||||
|
category,
|
||||||
|
surfaceRole,
|
||||||
|
capabilities: {},
|
||||||
|
} as AnyNodeDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountNode(
|
||||||
|
node: AnyNode,
|
||||||
|
box: [number, number, number],
|
||||||
|
position: [number, number, number],
|
||||||
|
) {
|
||||||
|
const group = new Group()
|
||||||
|
const mesh = new Mesh(new BoxGeometry(box[0], box[1], box[2]), new MeshBasicMaterial())
|
||||||
|
mesh.position.set(position[0], position[1], position[2])
|
||||||
|
group.add(mesh)
|
||||||
|
group.updateMatrixWorld(true)
|
||||||
|
sceneRegistry.nodes.set(node.id, group)
|
||||||
|
sceneRegistry.byType[node.type]!.add(node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountRegistryGroup(node: AnyNode) {
|
||||||
|
const group = new Group()
|
||||||
|
group.updateMatrixWorld(true)
|
||||||
|
sceneRegistry.nodes.set(node.id, group)
|
||||||
|
sceneRegistry.byType[node.type]!.add(node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSceneNodes(nodes: AnyNode[]) {
|
||||||
|
useScene.setState({
|
||||||
|
nodes: Object.fromEntries(nodes.map((node) => [node.id, node])),
|
||||||
|
rootNodeIds: nodes.map((node) => node.id),
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildFirstPersonColliderWorldFromRegistry', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
sceneRegistry.clear()
|
||||||
|
nodeRegistry._reset()
|
||||||
|
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('includes structure and furnish nodes discovered through the node registry', () => {
|
||||||
|
registerColliderDefinition('column', ColumnNode, 'structure')
|
||||||
|
registerColliderDefinition('shelf', ShelfNode, 'furnish')
|
||||||
|
|
||||||
|
const column = ColumnNode.parse({ id: 'column_test' })
|
||||||
|
const shelf = ShelfNode.parse({ id: 'shelf_test', position: [3, 0, 0] })
|
||||||
|
setSceneNodes([column, shelf])
|
||||||
|
mountNode(column, [1, 2, 1], [0, 1, 0])
|
||||||
|
mountNode(shelf, [2, 1, 1], [3, 0.5, 0])
|
||||||
|
|
||||||
|
const world = buildFirstPersonColliderWorldFromRegistry()
|
||||||
|
|
||||||
|
expect(world).not.toBeNull()
|
||||||
|
expect(world?.bounds?.min.x).toBeCloseTo(-0.5)
|
||||||
|
expect(world?.bounds?.max.x).toBeCloseTo(4)
|
||||||
|
world?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('excludes ceiling surfaces so the walkthrough player passes through them', () => {
|
||||||
|
registerColliderDefinition('column', ColumnNode, 'structure')
|
||||||
|
registerColliderDefinition('ceiling', CeilingNode, 'structure', 'ceiling')
|
||||||
|
|
||||||
|
const column = ColumnNode.parse({ id: 'column_test' })
|
||||||
|
const ceiling = CeilingNode.parse({ id: 'ceiling_test', polygon: [] })
|
||||||
|
setSceneNodes([column, ceiling])
|
||||||
|
mountNode(column, [1, 2, 1], [0, 1, 0])
|
||||||
|
// A wide ceiling at head height — if it were collected, bounds would span ±5.
|
||||||
|
mountNode(ceiling, [10, 0.1, 10], [0, 2.5, 0])
|
||||||
|
|
||||||
|
const world = buildFirstPersonColliderWorldFromRegistry()
|
||||||
|
|
||||||
|
expect(world).not.toBeNull()
|
||||||
|
// Bounds reflect only the 1×1 column; the ceiling contributed no geometry.
|
||||||
|
expect(world?.bounds?.min.x).toBeCloseTo(-0.5)
|
||||||
|
expect(world?.bounds?.max.x).toBeCloseTo(0.5)
|
||||||
|
world?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves elevators to their dedicated dynamic collider meshes', () => {
|
||||||
|
registerColliderDefinition('elevator', ElevatorNode, 'structure')
|
||||||
|
|
||||||
|
const elevator = ElevatorNode.parse({ id: 'elevator_test' })
|
||||||
|
setSceneNodes([elevator])
|
||||||
|
mountNode(elevator, [2, 3, 2], [0, 1.5, 0])
|
||||||
|
|
||||||
|
const world = buildFirstPersonColliderWorldFromRegistry()
|
||||||
|
|
||||||
|
expect(world).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds a fallback floor for a visible level with no slab', () => {
|
||||||
|
const level = LevelNode.parse({ id: 'level_test', level: 0 })
|
||||||
|
setSceneNodes([level])
|
||||||
|
mountRegistryGroup(level)
|
||||||
|
|
||||||
|
const world = buildFirstPersonColliderWorldFromRegistry()
|
||||||
|
|
||||||
|
expect(world).not.toBeNull()
|
||||||
|
expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
|
||||||
|
expect(world?.bounds?.max.y).toBeCloseTo(0)
|
||||||
|
world?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds a site ground collider so a spawn on bare ground has a floor', () => {
|
||||||
|
const site = SiteNode.parse({ id: 'site_test' })
|
||||||
|
setSceneNodes([site])
|
||||||
|
mountRegistryGroup(site)
|
||||||
|
|
||||||
|
const world = buildFirstPersonColliderWorldFromRegistry()
|
||||||
|
|
||||||
|
expect(world).not.toBeNull()
|
||||||
|
// Ground slab sits just below the site ground plane (y = 0).
|
||||||
|
expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
|
||||||
|
expect(world?.bounds?.max.y).toBeCloseTo(0)
|
||||||
|
// Default site footprint falls back to the 30 m minimum size.
|
||||||
|
expect(world?.bounds?.min.x).toBeCloseTo(-15)
|
||||||
|
expect(world?.bounds?.max.x).toBeCloseTo(15)
|
||||||
|
world?.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
getGarageVisibleOpeningRatio,
|
getGarageVisibleOpeningRatio,
|
||||||
isOperationDoorType,
|
isOperationDoorType,
|
||||||
|
nodeRegistry,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -10,21 +12,11 @@ import {
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||||
|
import { computeSceneBoundsXZ } from '../../../lib/scene-bounds'
|
||||||
const COLLIDER_NODE_TYPES = [
|
|
||||||
'wall',
|
|
||||||
'fence',
|
|
||||||
'slab',
|
|
||||||
'stair',
|
|
||||||
'stair-segment',
|
|
||||||
'roof',
|
|
||||||
'roof-segment',
|
|
||||||
'door',
|
|
||||||
'window',
|
|
||||||
'item',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
|
const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
|
||||||
|
const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish'])
|
||||||
|
const DEDICATED_COLLIDER_NODE_TYPES = new Set<AnyNode['type']>(['elevator'])
|
||||||
const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial()
|
const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial()
|
||||||
const DOWN = new THREE.Vector3(0, -1, 0)
|
const DOWN = new THREE.Vector3(0, -1, 0)
|
||||||
const UP = new THREE.Vector3(0, 1, 0)
|
const UP = new THREE.Vector3(0, 1, 0)
|
||||||
@@ -32,6 +24,9 @@ const SPAWN_EYE_HEIGHT = 1.65
|
|||||||
const RAYCAST_CLEARANCE = 25
|
const RAYCAST_CLEARANCE = 25
|
||||||
const DOOR_LEAF_COLLIDER_DEPTH = 0.06
|
const DOOR_LEAF_COLLIDER_DEPTH = 0.06
|
||||||
const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
|
const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
|
||||||
|
const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08
|
||||||
|
const LEVEL_FALLBACK_FLOOR_PADDING = 2
|
||||||
|
const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30
|
||||||
|
|
||||||
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
|
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
|
||||||
|
|
||||||
@@ -46,7 +41,9 @@ export type FirstPersonSpawn = {
|
|||||||
yaw: number
|
yaw: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number]
|
type LevelNode = Extract<AnyNode, { type: 'level' }>
|
||||||
|
type SiteNode = Extract<AnyNode, { type: 'site' }>
|
||||||
|
type SceneNodes = ReturnType<typeof useScene.getState>['nodes']
|
||||||
|
|
||||||
function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
||||||
return 'isMesh' in object && (object as THREE.Mesh).isMesh
|
return 'isMesh' in object && (object as THREE.Mesh).isMesh
|
||||||
@@ -56,6 +53,126 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[])
|
|||||||
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
|
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGenericColliderNode(node: AnyNode) {
|
||||||
|
if (node.visible === false) return false
|
||||||
|
if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) return false
|
||||||
|
const def = nodeRegistry.get(node.type)
|
||||||
|
// Ceilings are a transparent mount surface for fixtures (lights, fans), not a
|
||||||
|
// walkable or blocking structure — the walkthrough player must pass through
|
||||||
|
// them rather than be held up as if standing on a floor slab.
|
||||||
|
if (def?.surfaceRole === 'ceiling') return false
|
||||||
|
return COLLIDER_NODE_CATEGORIES.has(def?.category ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBoxColliderGeometry(width: number, height: number, depth: number) {
|
||||||
|
const sourceGeometry = new THREE.BoxGeometry(width, height, depth).toNonIndexed()
|
||||||
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
|
||||||
|
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
|
||||||
|
sourceGeometry.dispose()
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleLevelChildren(level: LevelNode, nodes: SceneNodes) {
|
||||||
|
return level.children
|
||||||
|
.map((childId) => nodes[childId as AnyNodeId])
|
||||||
|
.filter((child): child is AnyNode => Boolean(child && child.visible !== false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) {
|
||||||
|
if (level.visible === false) return null
|
||||||
|
|
||||||
|
const children = getVisibleLevelChildren(level, nodes)
|
||||||
|
if (children.some((child) => child.type === 'slab')) return null
|
||||||
|
|
||||||
|
const levelObject = sceneRegistry.nodes.get(level.id)
|
||||||
|
if (!levelObject?.visible) return null
|
||||||
|
|
||||||
|
const bounds = computeSceneBoundsXZ(children)
|
||||||
|
const [centerX, centerZ] = bounds?.center ?? [0, 0]
|
||||||
|
const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
|
||||||
|
const width = Math.max(
|
||||||
|
boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
|
||||||
|
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
|
||||||
|
)
|
||||||
|
const depth = Math.max(
|
||||||
|
boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
|
||||||
|
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
|
||||||
|
|
||||||
|
levelObject.updateWorldMatrix(true, false)
|
||||||
|
geometry.applyMatrix4(
|
||||||
|
new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ),
|
||||||
|
)
|
||||||
|
geometry.applyMatrix4(levelObject.matrixWorld)
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
|
||||||
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
for (const levelId of sceneRegistry.byType.level!) {
|
||||||
|
const node = nodes[levelId as AnyNodeId]
|
||||||
|
if (node?.type !== 'level') continue
|
||||||
|
|
||||||
|
const geometry = createLevelFallbackFloorGeometry(node, nodes)
|
||||||
|
if (geometry) geometries.push(geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return geometries
|
||||||
|
}
|
||||||
|
|
||||||
|
// The visible ground is the site node's ground mesh, but `site` is a `site`
|
||||||
|
// category node and therefore excluded from the generic collider sweep. Without
|
||||||
|
// a dedicated collider, a spawn on the bare ground (no slab, or not parented to
|
||||||
|
// a level that triggers the per-level fallback) has no floor to stand on and the
|
||||||
|
// walkthrough player falls through. Derive a thin ground slab from node data (not
|
||||||
|
// the rendered mesh) so it exists regardless of geometry-mount timing, sized to
|
||||||
|
// cover the whole scene footprint at the site's ground plane.
|
||||||
|
function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
|
||||||
|
if (site.visible === false) return null
|
||||||
|
|
||||||
|
const siteObject = sceneRegistry.nodes.get(site.id)
|
||||||
|
if (!siteObject?.visible) return null
|
||||||
|
|
||||||
|
const bounds = computeSceneBoundsXZ(nodes)
|
||||||
|
const [centerX, centerZ] = bounds?.center ?? [0, 0]
|
||||||
|
const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
|
||||||
|
const width = Math.max(
|
||||||
|
boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
|
||||||
|
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
|
||||||
|
)
|
||||||
|
const depth = Math.max(
|
||||||
|
boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
|
||||||
|
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
|
||||||
|
|
||||||
|
siteObject.updateWorldMatrix(true, false)
|
||||||
|
geometry.applyMatrix4(
|
||||||
|
new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ),
|
||||||
|
)
|
||||||
|
geometry.applyMatrix4(siteObject.matrixWorld)
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSiteGroundColliderGeometries(nodes: SceneNodes) {
|
||||||
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
for (const siteId of sceneRegistry.byType.site ?? []) {
|
||||||
|
const node = nodes[siteId as AnyNodeId]
|
||||||
|
if (node?.type !== 'site') continue
|
||||||
|
|
||||||
|
const geometry = createSiteGroundColliderGeometry(node, nodes)
|
||||||
|
if (geometry) geometries.push(geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return geometries
|
||||||
|
}
|
||||||
|
|
||||||
// Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a
|
// Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a
|
||||||
// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
|
// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
|
||||||
// merged geometry to share the same typed-array constructor for matching attributes, so
|
// merged geometry to share the same typed-array constructor for matching attributes, so
|
||||||
@@ -107,16 +224,12 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
|
|||||||
return cleanGeometry
|
return cleanGeometry
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) {
|
function shouldSkipColliderNode(node: AnyNode) {
|
||||||
if (type === 'window') {
|
if (node.type === 'window') {
|
||||||
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
return node.openingKind === 'opening'
|
||||||
return node?.type === 'window' && node.openingKind === 'opening'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type !== 'door') return false
|
if (node.type !== 'door') return false
|
||||||
|
|
||||||
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
|
||||||
if (!node || node.type !== 'door') return false
|
|
||||||
|
|
||||||
if (!node.segments.length) return true
|
if (!node.segments.length) return true
|
||||||
|
|
||||||
@@ -145,15 +258,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
|
|||||||
const visibleHeight = leafH * (1 - openAmount)
|
const visibleHeight = leafH * (1 - openAmount)
|
||||||
if (visibleHeight <= 0.12) return null
|
if (visibleHeight <= 0.12) return null
|
||||||
|
|
||||||
const sourceGeometry = new THREE.BoxGeometry(
|
const geometry = createBoxColliderGeometry(leafW, visibleHeight, DOOR_LEAF_COLLIDER_DEPTH)
|
||||||
leafW,
|
|
||||||
visibleHeight,
|
|
||||||
DOOR_LEAF_COLLIDER_DEPTH,
|
|
||||||
).toNonIndexed()
|
|
||||||
const geometry = new THREE.BufferGeometry()
|
|
||||||
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
|
|
||||||
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
|
|
||||||
sourceGeometry.dispose()
|
|
||||||
const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
|
const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
|
||||||
geometry.applyMatrix4(
|
geometry.applyMatrix4(
|
||||||
root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
|
root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
|
||||||
@@ -174,15 +279,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
|
|||||||
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0))
|
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0))
|
||||||
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
|
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
|
||||||
|
|
||||||
const sourceGeometry = new THREE.BoxGeometry(
|
const geometry = createBoxColliderGeometry(leafW, leafH, DOOR_LEAF_COLLIDER_DEPTH)
|
||||||
leafW,
|
|
||||||
leafH,
|
|
||||||
DOOR_LEAF_COLLIDER_DEPTH,
|
|
||||||
).toNonIndexed()
|
|
||||||
const geometry = new THREE.BufferGeometry()
|
|
||||||
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
|
|
||||||
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
|
|
||||||
sourceGeometry.dispose()
|
|
||||||
const matrix = root.matrixWorld
|
const matrix = root.matrixWorld
|
||||||
.clone()
|
.clone()
|
||||||
.multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
|
.multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
|
||||||
@@ -193,16 +290,17 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
|
|||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegisteredNodeTypeLookup() {
|
function buildRegisteredColliderNodeIds(nodes: SceneNodes) {
|
||||||
const nodeTypes = new Map<string, ColliderNodeType>()
|
const nodeIds = new Set<string>()
|
||||||
|
|
||||||
for (const type of COLLIDER_NODE_TYPES) {
|
for (const nodeId of sceneRegistry.nodes.keys()) {
|
||||||
for (const nodeId of sceneRegistry.byType[type]!) {
|
const node = nodes[nodeId as AnyNodeId]
|
||||||
nodeTypes.set(nodeId, type)
|
if (!node || !isGenericColliderNode(node)) continue
|
||||||
}
|
if (shouldSkipColliderNode(node)) continue
|
||||||
|
nodeIds.add(nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodeTypes
|
return nodeIds
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectColliderGeometriesFromNode(
|
function collectColliderGeometriesFromNode(
|
||||||
@@ -210,7 +308,7 @@ function collectColliderGeometriesFromNode(
|
|||||||
rootNodeId: string,
|
rootNodeId: string,
|
||||||
visitedMeshes: WeakSet<THREE.Object3D>,
|
visitedMeshes: WeakSet<THREE.Object3D>,
|
||||||
registeredObjectIds: Map<THREE.Object3D, string>,
|
registeredObjectIds: Map<THREE.Object3D, string>,
|
||||||
registeredNodeTypes: Map<string, ColliderNodeType>,
|
registeredColliderNodeIds: Set<string>,
|
||||||
): THREE.BufferGeometry[] {
|
): THREE.BufferGeometry[] {
|
||||||
const geometries: THREE.BufferGeometry[] = []
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
@@ -232,12 +330,9 @@ function collectColliderGeometriesFromNode(
|
|||||||
|
|
||||||
for (const child of object.children) {
|
for (const child of object.children) {
|
||||||
const childNodeId = registeredObjectIds.get(child)
|
const childNodeId = registeredObjectIds.get(child)
|
||||||
if (childNodeId && childNodeId !== rootNodeId) {
|
if (childNodeId && childNodeId !== rootNodeId && registeredColliderNodeIds.has(childNodeId)) {
|
||||||
const childType = registeredNodeTypes.get(childNodeId)
|
|
||||||
if (childType && COLLIDER_NODE_TYPES.includes(childType)) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
visit(child)
|
visit(child)
|
||||||
}
|
}
|
||||||
@@ -249,26 +344,24 @@ function collectColliderGeometriesFromNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null {
|
export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null {
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
const geometries: THREE.BufferGeometry[] = []
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
const visitedMeshes = new WeakSet<THREE.Object3D>()
|
const visitedMeshes = new WeakSet<THREE.Object3D>()
|
||||||
const registeredNodeTypes = buildRegisteredNodeTypeLookup()
|
const registeredColliderNodeIds = buildRegisteredColliderNodeIds(nodes)
|
||||||
const registeredObjectIds = new Map<THREE.Object3D, string>()
|
const registeredObjectIds = new Map<THREE.Object3D, string>()
|
||||||
|
|
||||||
for (const [nodeId, object] of sceneRegistry.nodes) {
|
for (const [nodeId, object] of sceneRegistry.nodes) {
|
||||||
registeredObjectIds.set(object, nodeId)
|
registeredObjectIds.set(object, nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const type of COLLIDER_NODE_TYPES) {
|
for (const nodeId of registeredColliderNodeIds) {
|
||||||
for (const nodeId of sceneRegistry.byType[type]!) {
|
const node = nodes[nodeId as AnyNodeId]
|
||||||
if (shouldSkipColliderNode(nodeId, type)) continue
|
if (!node) continue
|
||||||
|
|
||||||
const root = sceneRegistry.nodes.get(nodeId)
|
const root = sceneRegistry.nodes.get(nodeId)
|
||||||
if (!root) continue
|
if (!root) continue
|
||||||
|
|
||||||
if (type === 'door') {
|
if (node.type === 'door') {
|
||||||
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
|
||||||
if (node?.type !== 'door') continue
|
|
||||||
|
|
||||||
const doorGeometry = createDoorLeafColliderGeometry(root, node)
|
const doorGeometry = createDoorLeafColliderGeometry(root, node)
|
||||||
if (doorGeometry) {
|
if (doorGeometry) {
|
||||||
geometries.push(doorGeometry)
|
geometries.push(doorGeometry)
|
||||||
@@ -283,11 +376,13 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
|
|||||||
nodeId,
|
nodeId,
|
||||||
visitedMeshes,
|
visitedMeshes,
|
||||||
registeredObjectIds,
|
registeredObjectIds,
|
||||||
registeredNodeTypes,
|
registeredColliderNodeIds,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
geometries.push(...collectLevelFallbackFloorGeometries(nodes))
|
||||||
|
geometries.push(...collectSiteGroundColliderGeometries(nodes))
|
||||||
|
|
||||||
if (geometries.length === 0) {
|
if (geometries.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -311,7 +406,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
|
|||||||
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
|
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
|
||||||
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
|
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
|
||||||
bvhGeometry.computeBoundsTree?.({
|
bvhGeometry.computeBoundsTree?.({
|
||||||
maxLeafTris: 12,
|
maxLeafSize: 12,
|
||||||
strategy: 0,
|
strategy: 0,
|
||||||
} as never)
|
} as never)
|
||||||
bvhGeometry.computeBoundingBox()
|
bvhGeometry.computeBoundingBox()
|
||||||
|
|||||||
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')
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function MoveElevatorTool({
|
|||||||
const onCommittedRef = useRef(onCommitted)
|
const onCommittedRef = useRef(onCommitted)
|
||||||
const historyPausedRef = useRef(false)
|
const historyPausedRef = useRef(false)
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
|
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||||
const previewPositionRef = useRef<ElevatorNode['position']>([
|
const previewPositionRef = useRef<ElevatorNode['position']>([
|
||||||
movingNode.position[0],
|
movingNode.position[0],
|
||||||
movingNode.position[1],
|
movingNode.position[1],
|
||||||
@@ -73,6 +74,8 @@ export function MoveElevatorTool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
pauseHistory()
|
pauseHistory()
|
||||||
|
dragAnchorRef.current = null
|
||||||
|
previousGridPosRef.current = null
|
||||||
const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
|
const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
|
||||||
|
|
||||||
const meta =
|
const meta =
|
||||||
@@ -128,8 +131,12 @@ export function MoveElevatorTool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
const rawX = Math.round(event.localPosition[0] * 2) / 2
|
||||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
const rawZ = Math.round(event.localPosition[2] * 2) / 2
|
||||||
|
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
|
||||||
|
dragAnchorRef.current = anchor
|
||||||
|
const gridX = movingNode.position[0] + (rawX - anchor[0])
|
||||||
|
const gridZ = movingNode.position[2] + (rawZ - anchor[1])
|
||||||
const supportY = resolveElevatorSupportY({
|
const supportY = resolveElevatorSupportY({
|
||||||
buildingId: supportBuildingId,
|
buildingId: supportBuildingId,
|
||||||
preferredLevelId: supportLevelId,
|
preferredLevelId: supportLevelId,
|
||||||
@@ -151,15 +158,7 @@ export function MoveElevatorTool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
|
||||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
||||||
const supportY = resolveElevatorSupportY({
|
|
||||||
buildingId: supportBuildingId,
|
|
||||||
preferredLevelId: supportLevelId,
|
|
||||||
x: gridX,
|
|
||||||
z: gridZ,
|
|
||||||
})
|
|
||||||
const nextPosition: ElevatorNode['position'] = [gridX, supportY, gridZ]
|
|
||||||
|
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
clearPreview()
|
clearPreview()
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { stripTransient } from './placement-math'
|
||||||
|
|
||||||
|
describe('stripTransient', () => {
|
||||||
|
test('removes placement-only metadata flags before commit', () => {
|
||||||
|
expect(stripTransient({ isNew: true, isTransient: true, label: 'copy' })).toEqual({
|
||||||
|
label: 'copy',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -111,13 +111,13 @@ export function isValidWallSideFace(normal: [number, number, number] | undefined
|
|||||||
return Math.abs(normal[2]) > 0.7
|
return Math.abs(normal[2]) > 0.7
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Strip placement-only metadata flags before committing a draft. */
|
||||||
* Strip the `isTransient` flag from node metadata before committing.
|
|
||||||
*/
|
|
||||||
export function stripTransient(meta: any): any {
|
export function stripTransient(meta: any): any {
|
||||||
if (!isObject(meta)) return meta
|
if (!isObject(meta)) return meta
|
||||||
const { isTransient, ...rest } = meta as Record<string, any>
|
const nextMeta = { ...(meta as Record<string, any>) }
|
||||||
return rest
|
delete nextMeta.isNew
|
||||||
|
delete nextMeta.isTransient
|
||||||
|
return nextMeta
|
||||||
}
|
}
|
||||||
|
|
||||||
const _up = new Vector3(0, 1, 0)
|
const _up = new Vector3(0, 1, 0)
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ export interface PlacementCoordinatorConfig {
|
|||||||
initialState?: PlacementState
|
initialState?: PlacementState
|
||||||
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
||||||
defaultScale?: [number, number, number]
|
defaultScale?: [number, number, number]
|
||||||
|
/** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */
|
||||||
|
preserveFloorDragOffset?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
||||||
@@ -405,6 +407,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// building-local, matching the draft's grid position and the guide
|
// building-local, matching the draft's grid position and the guide
|
||||||
// layer's frame.
|
// layer's frame.
|
||||||
let alignmentCandidates: AlignmentAnchor[] | null = null
|
let alignmentCandidates: AlignmentAnchor[] | null = null
|
||||||
|
let floorDragAnchor: [number, number] | null = null
|
||||||
|
|
||||||
// Reset placement state
|
// Reset placement state
|
||||||
placementState.current = configRef.current.initialState ?? {
|
placementState.current = configRef.current.initialState ?? {
|
||||||
@@ -526,6 +529,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
// ---- Init draft ----
|
// ---- Init draft ----
|
||||||
configRef.current.initDraft(gridPosition.current)
|
configRef.current.initDraft(gridPosition.current)
|
||||||
|
const preserveFloorDragOffset =
|
||||||
|
configRef.current.preserveFloorDragOffset === true &&
|
||||||
|
placementState.current.surface === 'floor' &&
|
||||||
|
!asset.attachTo
|
||||||
|
const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null
|
||||||
|
|
||||||
// Sync cursor to the draft mesh's world position and rotation
|
// Sync cursor to the draft mesh's world position and rotation
|
||||||
if (draftNode.current) {
|
if (draftNode.current) {
|
||||||
@@ -649,9 +657,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
detachItemSurfaceToFloor(event as unknown as ItemEvent)
|
detachItemSurfaceToFloor(event as unknown as ItemEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
const floorEvent =
|
||||||
|
relativeFloorStart !== null
|
||||||
|
? (() => {
|
||||||
|
const rawX = event.localPosition[0]
|
||||||
|
const rawZ = event.localPosition[2]
|
||||||
|
const anchor = floorDragAnchor ?? [rawX, rawZ]
|
||||||
|
floorDragAnchor = anchor
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
localPosition: [
|
||||||
|
relativeFloorStart.x + (rawX - anchor[0]),
|
||||||
|
event.localPosition[1],
|
||||||
|
relativeFloorStart.z + (rawZ - anchor[1]),
|
||||||
|
] as [number, number, number],
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: event
|
||||||
|
|
||||||
|
lastRawPos.current.set(
|
||||||
|
floorEvent.localPosition[0],
|
||||||
|
floorEvent.localPosition[1],
|
||||||
|
floorEvent.localPosition[2],
|
||||||
|
)
|
||||||
if (!cursorGroupRef.current) return
|
if (!cursorGroupRef.current) return
|
||||||
const result = floorStrategy.move(getContext(), event)
|
const result = floorStrategy.move(getContext(), floorEvent)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
// Figma-style alignment snap layered on top of the floor strategy's
|
// Figma-style alignment snap layered on top of the floor strategy's
|
||||||
@@ -663,7 +693,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
let alignX = 0
|
let alignX = 0
|
||||||
let alignZ = 0
|
let alignZ = 0
|
||||||
const bypassAlign = event.nativeEvent?.altKey === true
|
const bypassAlign = floorEvent.nativeEvent?.altKey === true
|
||||||
if (!bypassAlign && draft) {
|
if (!bypassAlign && draft) {
|
||||||
alignmentCandidates ??= collectAlignmentAnchors(
|
alignmentCandidates ??= collectAlignmentAnchors(
|
||||||
useScene.getState().nodes,
|
useScene.getState().nodes,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -125,6 +129,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
* commit position consistent with the visible cursor.
|
* commit position consistent with the visible cursor.
|
||||||
*/
|
*/
|
||||||
const lastCursorRef = useRef<[number, number, number]>(originalPosition)
|
const lastCursorRef = useRef<[number, number, number]>(originalPosition)
|
||||||
|
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||||
/**
|
/**
|
||||||
* Becomes true on the first `grid:move` after this move arms. Commits are
|
* Becomes true on the first `grid:move` after this move arms. Commits are
|
||||||
* ignored until then so a click that *armed* this move (e.g. the trailing
|
* ignored until then so a click that *armed* this move (e.g. the trailing
|
||||||
@@ -154,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)
|
||||||
@@ -166,6 +173,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
previousSnapRef.current = null
|
previousSnapRef.current = null
|
||||||
|
dragAnchorRef.current = null
|
||||||
hasMovedRef.current = false
|
hasMovedRef.current = false
|
||||||
rotationRef.current = originalRotationY
|
rotationRef.current = originalRotationY
|
||||||
shiftRef.current = false
|
shiftRef.current = false
|
||||||
@@ -178,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] =>
|
||||||
@@ -267,8 +276,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
let x = snapToGridStep(event.localPosition[0])
|
const rawX = event.localPosition[0]
|
||||||
let z = snapToGridStep(event.localPosition[2])
|
const rawZ = event.localPosition[2]
|
||||||
|
revealFreshPlacement()
|
||||||
|
|
||||||
|
const resolved = resolvePlanarCursorPosition({
|
||||||
|
cursor: [rawX, rawZ],
|
||||||
|
original: [originalPosition[0], originalPosition[2]],
|
||||||
|
anchor: dragAnchorRef.current,
|
||||||
|
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
|
||||||
|
snap: snapToGridStep,
|
||||||
|
})
|
||||||
|
dragAnchorRef.current = resolved.anchor
|
||||||
|
let [x, z] = resolved.point
|
||||||
|
|
||||||
// Figma-style alignment snap layered on top of grid snap: when the
|
// 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,
|
||||||
@@ -351,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)
|
||||||
@@ -386,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
|
||||||
@@ -463,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()
|
||||||
@@ -492,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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import {
|
|||||||
type BoxVentNode,
|
type BoxVentNode,
|
||||||
emitter,
|
emitter,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import {
|
||||||
|
createRelativeRoofDrag,
|
||||||
|
type RelativeRoofDragTarget,
|
||||||
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
} from '../shared/relative-roof-drag'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import BoxVentPreview from './preview'
|
import BoxVentPreview from './preview'
|
||||||
|
|
||||||
@@ -55,48 +57,39 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||||
if (ventObj) ventObj.visible = false
|
if (ventObj) ventObj.visible = false
|
||||||
|
|
||||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
||||||
if (!buildingObj) return [wx, wy, wz]
|
|
||||||
const v = new THREE.Vector3(wx, wy, wz)
|
|
||||||
buildingObj.worldToLocal(v)
|
|
||||||
return [v.x, v.y, v.z]
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const wx = event.position[0]
|
const target = roofDrag.resolve(event)
|
||||||
const wy = event.position[1]
|
if (!target) return
|
||||||
const wz = event.position[2]
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(wx * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
const sz = Math.round(wz * 20) / 20
|
const sz = Math.round(target.localZ * 20) / 20
|
||||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
lastSnap = [sx, sz]
|
lastSnap = [sx, sz]
|
||||||
}
|
}
|
||||||
|
|
||||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||||
if (!hit) return
|
|
||||||
|
|
||||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
|
||||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
setPreviewPos(
|
||||||
|
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||||
|
target.localX,
|
||||||
|
target.localY,
|
||||||
|
target.localZ,
|
||||||
|
]),
|
||||||
|
)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
event.node as RoofNode,
|
if (!target) return
|
||||||
event.position[0],
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
// Reparent if the cursor landed on a different segment than the
|
// Reparent if the cursor landed on a different segment than the
|
||||||
@@ -124,7 +117,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
st.updateNode(node.id as AnyNodeId, {
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
roofSegmentId: targetSegmentId,
|
roofSegmentId: targetSegmentId,
|
||||||
parentId: targetSegmentId,
|
parentId: targetSegmentId,
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
visible: true,
|
visible: true,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const Y_AXIS = new THREE.Vector3(0, 1, 0)
|
|||||||
|
|
||||||
export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
|
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
// Stable refs so the effect never needs node in its dependency array
|
// Stable refs so the effect never needs node in its dependency array
|
||||||
const nodeIdRef = useRef(node.id)
|
const nodeIdRef = useRef(node.id)
|
||||||
@@ -29,9 +30,8 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|||||||
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
|
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
|
||||||
|
|
||||||
// Local-space offset from the building's origin to its bbox center. The
|
// Local-space offset from the building's origin to its bbox center. The
|
||||||
// floating drag button anchors at the bbox center, so we pin that point to
|
// move preview preserves the first pointer-to-center delta, then uses this
|
||||||
// the cursor during the drag — otherwise the raw origin (often nowhere near
|
// offset to write the origin while keeping rotation around the visual center.
|
||||||
// the visual center) would snap to the cursor and the building would jump.
|
|
||||||
const centerOffsetLocalRef = useRef<THREE.Vector3>(new THREE.Vector3())
|
const centerOffsetLocalRef = useRef<THREE.Vector3>(new THREE.Vector3())
|
||||||
|
|
||||||
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
||||||
@@ -66,8 +66,15 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|||||||
const offsetWork = new THREE.Vector3()
|
const offsetWork = new THREE.Vector3()
|
||||||
const offsetAt = (rotationY: number) =>
|
const offsetAt = (rotationY: number) =>
|
||||||
offsetWork.copy(centerOffsetLocalRef.current).applyAxisAngle(Y_AXIS, rotationY)
|
offsetWork.copy(centerOffsetLocalRef.current).applyAxisAngle(Y_AXIS, rotationY)
|
||||||
|
const originalCenterOffset = offsetAt(originalRotationRef.current).clone()
|
||||||
|
const originalCenter: [number, number] = [
|
||||||
|
originalPosition[0] + originalCenterOffset.x,
|
||||||
|
originalPosition[2] + originalCenterOffset.z,
|
||||||
|
]
|
||||||
|
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
dragAnchorRef.current = null
|
||||||
|
previousGridPosRef.current = null
|
||||||
|
|
||||||
// Publish the building's current pose to useLiveTransforms so the
|
// Publish the building's current pose to useLiveTransforms so the
|
||||||
// floor-plan (and any other live consumers) can follow per-frame
|
// floor-plan (and any other live consumers) can follow per-frame
|
||||||
@@ -114,8 +121,12 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2
|
const rawX = Math.round(event.position[0] * 2) / 2
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
const rawZ = Math.round(event.position[2] * 2) / 2
|
||||||
|
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
|
||||||
|
dragAnchorRef.current = anchor
|
||||||
|
const gridX = originalCenter[0] + (rawX - anchor[0])
|
||||||
|
const gridZ = originalCenter[1] + (rawZ - anchor[1])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousGridPosRef.current &&
|
previousGridPosRef.current &&
|
||||||
@@ -138,8 +149,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2
|
const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
||||||
|
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ChimneyNode as ChimneyNodeSchema,
|
ChimneyNode as ChimneyNodeSchema,
|
||||||
emitter,
|
emitter,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -15,7 +14,7 @@ import { triggerSFX, useEditor } from '@pascal-app/editor'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { createRelativeRoofDrag, type RelativeRoofDragTarget } from '../shared/relative-roof-drag'
|
||||||
import ChimneyPreview from './preview'
|
import ChimneyPreview from './preview'
|
||||||
|
|
||||||
const tmpMatrix = new THREE.Matrix4()
|
const tmpMatrix = new THREE.Matrix4()
|
||||||
@@ -84,38 +83,36 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
const wx = event.position[0]
|
const roofDrag = createRelativeRoofDrag({
|
||||||
const wy = event.position[1]
|
position: [...node.position] as [number, number, number],
|
||||||
const wz = event.position[2]
|
roofSegmentId: node.roofSegmentId,
|
||||||
|
})
|
||||||
|
|
||||||
const sx = Math.round(wx * 20) / 20
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const sz = Math.round(wz * 20) / 20
|
const target = roofDrag.resolve(event)
|
||||||
|
if (!target) return
|
||||||
|
lastTarget = target
|
||||||
|
|
||||||
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
|
const sz = Math.round(target.localZ * 20) / 20
|
||||||
const prev = lastSnapRef.current
|
const prev = lastSnapRef.current
|
||||||
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
lastSnapRef.current = [sx, sz]
|
lastSnapRef.current = [sx, sz]
|
||||||
}
|
}
|
||||||
|
|
||||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
const xform = computeSegmentXform(target.segment.id)
|
||||||
if (!hit) return
|
|
||||||
|
|
||||||
const xform = computeSegmentXform(hit.segment.id)
|
|
||||||
if (!xform) return
|
if (!xform) return
|
||||||
setSegmentXform(xform)
|
setSegmentXform(xform)
|
||||||
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
setHitLocal([target.localX, target.localY, target.localZ])
|
||||||
setPreviewSegment(hit.segment)
|
setPreviewSegment(target.segment)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = (event: RoofEvent) => {
|
const onClick = (event: RoofEvent) => {
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
event.node as RoofNode,
|
if (!target) return
|
||||||
event.position[0],
|
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
const state = useScene.getState()
|
const state = useScene.getState()
|
||||||
|
|
||||||
// Strip the `isNew` flag — only used to mark a duplicate clone
|
// Strip the `isNew` flag — only used to mark a duplicate clone
|
||||||
@@ -135,23 +132,23 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
|||||||
const committed = ChimneyNodeSchema.parse({
|
const committed = ChimneyNodeSchema.parse({
|
||||||
...node,
|
...node,
|
||||||
id: undefined as never,
|
id: undefined as never,
|
||||||
roofSegmentId: hit.segment.id,
|
roofSegmentId: target.segment.id,
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
metadata: cleanedMeta,
|
metadata: cleanedMeta,
|
||||||
})
|
})
|
||||||
state.createNode(committed, hit.segment.id as AnyNodeId)
|
state.createNode(committed, target.segment.id as AnyNodeId)
|
||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(target.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [committed.id] })
|
setSelection({ selectedIds: [committed.id] })
|
||||||
} else {
|
} else {
|
||||||
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
|
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
|
||||||
state.updateNode(node.id as AnyNodeId, {
|
state.updateNode(node.id as AnyNodeId, {
|
||||||
roofSegmentId: hit.segment.id,
|
roofSegmentId: target.segment.id,
|
||||||
parentId: hit.segment.id,
|
parentId: target.segment.id,
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
metadata: cleanedMeta,
|
metadata: cleanedMeta,
|
||||||
})
|
})
|
||||||
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
|
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
|
||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(target.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [node.id] })
|
setSelection({ selectedIds: [node.id] })
|
||||||
}
|
}
|
||||||
setMovingNode(null)
|
setMovingNode(null)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -70,11 +77,8 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
let rotationY = node.rotation
|
let rotationY = node.rotation
|
||||||
// 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
|
||||||
const meta =
|
let dragAnchor: [number, number] | null = null
|
||||||
typeof node.metadata === 'object' && node.metadata !== null
|
const isNew = isFreshPlacement
|
||||||
? (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,
|
||||||
@@ -111,8 +115,19 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
hasMoved = true
|
hasMoved = true
|
||||||
let x = snapToGridStep(event.localPosition[0])
|
const rawX = event.localPosition[0]
|
||||||
let z = snapToGridStep(event.localPosition[2])
|
const rawZ = event.localPosition[2]
|
||||||
|
revealFreshPlacement()
|
||||||
|
|
||||||
|
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
|
||||||
@@ -157,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) {
|
||||||
@@ -184,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?.()
|
||||||
}
|
}
|
||||||
@@ -192,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()
|
||||||
@@ -215,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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import {
|
|||||||
type CupolaNode,
|
type CupolaNode,
|
||||||
emitter,
|
emitter,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import {
|
||||||
|
createRelativeRoofDrag,
|
||||||
|
type RelativeRoofDragTarget,
|
||||||
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
} from '../shared/relative-roof-drag'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import CupolaPreview from './preview'
|
import CupolaPreview from './preview'
|
||||||
|
|
||||||
@@ -53,48 +55,39 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
const cupolaObj = sceneRegistry.nodes.get(node.id)
|
const cupolaObj = sceneRegistry.nodes.get(node.id)
|
||||||
if (cupolaObj) cupolaObj.visible = false
|
if (cupolaObj) cupolaObj.visible = false
|
||||||
|
|
||||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
||||||
if (!buildingObj) return [wx, wy, wz]
|
|
||||||
const v = new THREE.Vector3(wx, wy, wz)
|
|
||||||
buildingObj.worldToLocal(v)
|
|
||||||
return [v.x, v.y, v.z]
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const wx = event.position[0]
|
const target = roofDrag.resolve(event)
|
||||||
const wy = event.position[1]
|
if (!target) return
|
||||||
const wz = event.position[2]
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(wx * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
const sz = Math.round(wz * 20) / 20
|
const sz = Math.round(target.localZ * 20) / 20
|
||||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
lastSnap = [sx, sz]
|
lastSnap = [sx, sz]
|
||||||
}
|
}
|
||||||
|
|
||||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||||
if (!hit) return
|
|
||||||
|
|
||||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
|
||||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
setPreviewPos(
|
||||||
|
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||||
|
target.localX,
|
||||||
|
target.localY,
|
||||||
|
target.localZ,
|
||||||
|
]),
|
||||||
|
)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
event.node as RoofNode,
|
if (!target) return
|
||||||
event.position[0],
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||||
@@ -118,7 +111,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
st.updateNode(node.id as AnyNodeId, {
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
roofSegmentId: targetSegmentId,
|
roofSegmentId: targetSegmentId,
|
||||||
parentId: targetSegmentId,
|
parentId: targetSegmentId,
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
visible: true,
|
visible: true,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
|
stripPlacementMetadataFlags,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useEditor,
|
useEditor,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
@@ -66,6 +67,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentWallId: string | null = movingDoorNode.parentId
|
let currentWallId: string | null = movingDoorNode.parentId
|
||||||
|
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
||||||
|
let lastTarget: {
|
||||||
|
wallNode: WallEvent['node']
|
||||||
|
wallId: string
|
||||||
|
side: DoorNode['side']
|
||||||
|
itemRotation: number
|
||||||
|
cursorRotation: number
|
||||||
|
clampedX: number
|
||||||
|
clampedY: number
|
||||||
|
valid: boolean
|
||||||
|
event: WallEvent
|
||||||
|
} | null = null
|
||||||
|
|
||||||
const markWallDirty = (wallId: string | null) => {
|
const markWallDirty = (wallId: string | null) => {
|
||||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||||
@@ -131,7 +144,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const resolveMoveTarget = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
if (isCurvedWall(event.node)) {
|
if (isCurvedWall(event.node)) {
|
||||||
hideCursor()
|
hideCursor()
|
||||||
@@ -141,9 +154,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
|
|
||||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||||
|
|
||||||
|
const rawLocalX = event.localPosition[0]
|
||||||
|
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
||||||
|
dragAnchor = {
|
||||||
|
wallId: event.node.id,
|
||||||
|
rawX: rawLocalX,
|
||||||
|
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
||||||
const localX = resolveWallSlideAlignment({
|
const localX = resolveWallSlideAlignment({
|
||||||
wallNode: event.node,
|
wallNode: event.node,
|
||||||
rawLocalX: event.localPosition[0],
|
rawLocalX: targetLocalX,
|
||||||
width: movingDoorNode.width,
|
width: movingDoorNode.width,
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
bypass: event.nativeEvent?.altKey === true,
|
bypass: event.nativeEvent?.altKey === true,
|
||||||
@@ -155,24 +177,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
movingDoorNode.height,
|
movingDoorNode.height,
|
||||||
)
|
)
|
||||||
|
|
||||||
const prevWallId = currentWallId
|
|
||||||
currentWallId = event.node.id
|
|
||||||
|
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
|
||||||
position: [clampedX, clampedY, 0],
|
|
||||||
rotation: [0, itemRotation, 0],
|
|
||||||
side,
|
|
||||||
parentId: event.node.id,
|
|
||||||
wallId: event.node.id,
|
|
||||||
})
|
|
||||||
useLiveTransforms.getState().set(movingDoorNode.id, {
|
|
||||||
position: [clampedX, clampedY, 0],
|
|
||||||
rotation: itemRotation,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
|
||||||
markWallDirtyThrottled(event.node.id)
|
|
||||||
|
|
||||||
const valid = !hasWallChildOverlap(
|
const valid = !hasWallChildOverlap(
|
||||||
event.node.id,
|
event.node.id,
|
||||||
clampedX,
|
clampedX,
|
||||||
@@ -182,17 +186,62 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
movingDoorNode.id,
|
movingDoorNode.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
updateCursor(
|
return {
|
||||||
wallLocalToWorld(
|
wallNode: event.node,
|
||||||
event.node,
|
wallId: event.node.id,
|
||||||
|
side,
|
||||||
|
itemRotation,
|
||||||
|
cursorRotation,
|
||||||
clampedX,
|
clampedX,
|
||||||
clampedY,
|
clampedY,
|
||||||
getLevelYOffset(),
|
|
||||||
getSlabElevation(event),
|
|
||||||
),
|
|
||||||
cursorRotation,
|
|
||||||
valid,
|
valid,
|
||||||
|
event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||||
|
if (currentWallId !== target.wallId) {
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
|
rotation: [0, target.itemRotation, 0],
|
||||||
|
side: target.side,
|
||||||
|
parentId: target.wallId,
|
||||||
|
wallId: target.wallId,
|
||||||
|
})
|
||||||
|
markWallDirty(currentWallId)
|
||||||
|
currentWallId = target.wallId
|
||||||
|
} else {
|
||||||
|
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
||||||
|
if (doorMesh) {
|
||||||
|
doorMesh.position.set(target.clampedX, target.clampedY, 0)
|
||||||
|
doorMesh.rotation.set(0, target.itemRotation, 0)
|
||||||
|
doorMesh.updateMatrixWorld(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useLiveTransforms.getState().set(movingDoorNode.id, {
|
||||||
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
|
rotation: target.itemRotation,
|
||||||
|
})
|
||||||
|
markWallDirtyThrottled(target.wallId)
|
||||||
|
|
||||||
|
updateCursor(
|
||||||
|
wallLocalToWorld(
|
||||||
|
target.wallNode,
|
||||||
|
target.clampedX,
|
||||||
|
target.clampedY,
|
||||||
|
getLevelYOffset(),
|
||||||
|
getSlabElevation(target.event),
|
||||||
|
),
|
||||||
|
target.cursorRotation,
|
||||||
|
target.valid,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallEnter = (event: WallEvent) => {
|
||||||
|
const target = resolveMoveTarget(event)
|
||||||
|
if (!target) return
|
||||||
|
lastTarget = target
|
||||||
|
applyPreview(target)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,69 +253,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
if (event.node.parentId !== getLevelId()) return
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
const target = resolveMoveTarget(event)
|
||||||
|
if (!target) return
|
||||||
const localX = resolveWallSlideAlignment({
|
lastTarget = target
|
||||||
wallNode: event.node,
|
applyPreview(target)
|
||||||
rawLocalX: event.localPosition[0],
|
|
||||||
width: movingDoorNode.width,
|
|
||||||
candidates: alignmentCandidates,
|
|
||||||
bypass: event.nativeEvent?.altKey === true,
|
|
||||||
})
|
|
||||||
const { clampedX, clampedY } = clampToWall(
|
|
||||||
event.node,
|
|
||||||
localX,
|
|
||||||
movingDoorNode.width,
|
|
||||||
movingDoorNode.height,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (currentWallId !== event.node.id) {
|
|
||||||
// Wall changed mid-move: must updateNode to reparent
|
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
|
||||||
position: [clampedX, clampedY, 0],
|
|
||||||
rotation: [0, itemRotation, 0],
|
|
||||||
side,
|
|
||||||
parentId: event.node.id,
|
|
||||||
wallId: event.node.id,
|
|
||||||
})
|
|
||||||
markWallDirty(currentWallId)
|
|
||||||
currentWallId = event.node.id
|
|
||||||
} else {
|
|
||||||
// Same wall: update Three.js mesh directly to avoid store churn
|
|
||||||
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
|
||||||
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
|
||||||
if (doorMesh) {
|
|
||||||
doorMesh.position.set(clampedX, clampedY, 0)
|
|
||||||
doorMesh.rotation.set(0, itemRotation, 0)
|
|
||||||
doorMesh.updateMatrixWorld(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useLiveTransforms.getState().set(movingDoorNode.id, {
|
|
||||||
position: [clampedX, clampedY, 0],
|
|
||||||
rotation: itemRotation,
|
|
||||||
})
|
|
||||||
markWallDirtyThrottled(event.node.id)
|
|
||||||
|
|
||||||
const valid = !hasWallChildOverlap(
|
|
||||||
event.node.id,
|
|
||||||
clampedX,
|
|
||||||
clampedY,
|
|
||||||
movingDoorNode.width,
|
|
||||||
movingDoorNode.height,
|
|
||||||
movingDoorNode.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
updateCursor(
|
|
||||||
wallLocalToWorld(
|
|
||||||
event.node,
|
|
||||||
clampedX,
|
|
||||||
clampedY,
|
|
||||||
getLevelYOffset(),
|
|
||||||
getSlabElevation(event),
|
|
||||||
),
|
|
||||||
cursorRotation,
|
|
||||||
valid,
|
|
||||||
)
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,31 +265,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
if (isCurvedWall(event.node)) return
|
if (isCurvedWall(event.node)) return
|
||||||
if (event.node.parentId !== getLevelId()) return
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const { side, itemRotation } = getPlacementOrientation(event)
|
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||||
|
if (!target?.valid) return
|
||||||
const localX = resolveWallSlideAlignment({
|
|
||||||
wallNode: event.node,
|
|
||||||
rawLocalX: event.localPosition[0],
|
|
||||||
width: movingDoorNode.width,
|
|
||||||
candidates: alignmentCandidates,
|
|
||||||
bypass: event.nativeEvent?.altKey === true,
|
|
||||||
})
|
|
||||||
const { clampedX, clampedY } = clampToWall(
|
|
||||||
event.node,
|
|
||||||
localX,
|
|
||||||
movingDoorNode.width,
|
|
||||||
movingDoorNode.height,
|
|
||||||
)
|
|
||||||
|
|
||||||
const valid = !hasWallChildOverlap(
|
|
||||||
event.node.id,
|
|
||||||
clampedX,
|
|
||||||
clampedY,
|
|
||||||
movingDoorNode.width,
|
|
||||||
movingDoorNode.height,
|
|
||||||
movingDoorNode.id,
|
|
||||||
)
|
|
||||||
if (!valid) return
|
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
|
|
||||||
@@ -309,15 +276,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
|
|
||||||
const cloned = structuredClone(movingDoorNode) as any
|
const cloned = structuredClone(movingDoorNode) as any
|
||||||
delete cloned.id
|
delete cloned.id
|
||||||
|
cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
|
||||||
const node = DoorNode.parse({
|
const node = DoorNode.parse({
|
||||||
...cloned,
|
...cloned,
|
||||||
position: [clampedX, clampedY, 0],
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, target.itemRotation, 0],
|
||||||
side,
|
side: target.side,
|
||||||
wallId: event.node.id,
|
wallId: target.wallId,
|
||||||
parentId: event.node.id,
|
parentId: target.wallId,
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||||
placedId = node.id
|
placedId = node.id
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
@@ -331,21 +299,21 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
position: [clampedX, clampedY, 0],
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, target.itemRotation, 0],
|
||||||
side,
|
side: target.side,
|
||||||
parentId: event.node.id,
|
parentId: target.wallId,
|
||||||
wallId: event.node.id,
|
wallId: target.wallId,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (original.parentId && original.parentId !== event.node.id) {
|
if (original.parentId && original.parentId !== target.wallId) {
|
||||||
markWallDirty(original.parentId)
|
markWallDirty(original.parentId)
|
||||||
}
|
}
|
||||||
placedId = movingDoorNode.id
|
placedId = movingDoorNode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
markWallDirty(event.node.id)
|
markWallDirty(target.wallId)
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
@@ -359,6 +327,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
const onWallLeave = () => {
|
const onWallLeave = () => {
|
||||||
hideCursor()
|
hideCursor()
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
|
dragAnchor = null
|
||||||
|
lastTarget = null
|
||||||
if (isNew) return
|
if (isNew) return
|
||||||
if (currentWallId && currentWallId !== original.parentId) {
|
if (currentWallId && currentWallId !== original.parentId) {
|
||||||
markWallDirty(currentWallId)
|
markWallDirty(currentWallId)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
|||||||
const originalRotation = node.rotation ?? 0
|
const originalRotation = node.rotation ?? 0
|
||||||
const originalMetadata = node.metadata
|
const originalMetadata = node.metadata
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
useScene.getState().updateNode(node.id as AnyNodeId, {
|
useScene.getState().updateNode(node.id as AnyNodeId, {
|
||||||
@@ -71,11 +72,14 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes.
|
|
||||||
}, [node.id, isNew])
|
}, [node.id, isNew])
|
||||||
|
|
||||||
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
||||||
initialRotation: originalRotation,
|
initialRotation: originalRotation,
|
||||||
|
relativeStart: {
|
||||||
|
position: [...node.position] as [number, number, number],
|
||||||
|
roofSegmentId: node.roofSegmentId,
|
||||||
|
},
|
||||||
onCommit: (hit, rotation) => {
|
onCommit: (hit, rotation) => {
|
||||||
const state = useScene.getState()
|
const state = useScene.getState()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry'
|
import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry'
|
||||||
|
|
||||||
@@ -50,6 +51,10 @@ export type DormerPlacementHit = {
|
|||||||
*/
|
*/
|
||||||
export function useDormerPlacement(opts: {
|
export function useDormerPlacement(opts: {
|
||||||
initialRotation?: number
|
initialRotation?: number
|
||||||
|
relativeStart?: {
|
||||||
|
position: [number, number, number]
|
||||||
|
roofSegmentId?: string
|
||||||
|
}
|
||||||
onCommit: (hit: DormerPlacementHit, rotation: number) => void
|
onCommit: (hit: DormerPlacementHit, rotation: number) => void
|
||||||
}): {
|
}): {
|
||||||
activeBuildingId: string | undefined
|
activeBuildingId: string | undefined
|
||||||
@@ -66,6 +71,7 @@ export function useDormerPlacement(opts: {
|
|||||||
// Mirror of `ghostRotation` so the click handler (registered once
|
// Mirror of `ghostRotation` so the click handler (registered once
|
||||||
// inside useEffect) can read the latest value at commit time.
|
// inside useEffect) can read the latest value at commit time.
|
||||||
const ghostRotationRef = useRef(opts.initialRotation ?? 0)
|
const ghostRotationRef = useRef(opts.initialRotation ?? 0)
|
||||||
|
const relativeStartRef = useRef(opts.relativeStart)
|
||||||
// Latest commit callback, captured via ref so the useEffect doesn't
|
// Latest commit callback, captured via ref so the useEffect doesn't
|
||||||
// need it in its dep list (we don't want to re-register listeners
|
// need it in its dep list (we don't want to re-register listeners
|
||||||
// every time the parent rerenders).
|
// every time the parent rerenders).
|
||||||
@@ -90,9 +96,23 @@ export function useDormerPlacement(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roofDrag = relativeStartRef.current
|
||||||
|
? createRelativeRoofDrag(relativeStartRef.current)
|
||||||
|
: null
|
||||||
|
let lastRelativeHit: DormerPlacementHit | null = null
|
||||||
|
|
||||||
|
const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => {
|
||||||
|
if (roofDrag) return roofDrag.resolve(event)
|
||||||
|
return resolveRoofSegmentHit(
|
||||||
|
event.node as RoofNode,
|
||||||
|
event.position[0],
|
||||||
|
event.position[1],
|
||||||
|
event.position[2],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const wx = event.position[0]
|
const wx = event.position[0]
|
||||||
const wy = event.position[1]
|
|
||||||
const wz = event.position[2]
|
const wz = event.position[2]
|
||||||
|
|
||||||
const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M
|
const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M
|
||||||
@@ -103,8 +123,9 @@ export function useDormerPlacement(opts: {
|
|||||||
lastSnapRef.current = [sx, sz]
|
lastSnapRef.current = [sx, sz]
|
||||||
}
|
}
|
||||||
|
|
||||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
const hit = resolvePlacementHit(event)
|
||||||
if (!hit) return
|
if (!hit) return
|
||||||
|
if (roofDrag) lastRelativeHit = hit
|
||||||
const xform = computeSegmentXform(hit.segment.id)
|
const xform = computeSegmentXform(hit.segment.id)
|
||||||
if (!xform) return
|
if (!xform) return
|
||||||
setSegmentXform(xform)
|
setSegmentXform(xform)
|
||||||
@@ -118,12 +139,9 @@ export function useDormerPlacement(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onClick = (event: RoofEvent) => {
|
const onClick = (event: RoofEvent) => {
|
||||||
const hit = resolveRoofSegmentHit(
|
const hit = roofDrag
|
||||||
event.node as RoofNode,
|
? (lastRelativeHit ?? resolvePlacementHit(event))
|
||||||
event.position[0],
|
: resolvePlacementHit(event)
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
if (!hit) return
|
||||||
onCommitRef.current(hit, ghostRotationRef.current)
|
onCommitRef.current(hit, ghostRotationRef.current)
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
|||||||
@@ -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],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import {
|
|||||||
type EyebrowVentNode,
|
type EyebrowVentNode,
|
||||||
emitter,
|
emitter,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import {
|
||||||
|
createRelativeRoofDrag,
|
||||||
|
type RelativeRoofDragTarget,
|
||||||
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
} from '../shared/relative-roof-drag'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import EyebrowVentPreview from './preview'
|
import EyebrowVentPreview from './preview'
|
||||||
|
|
||||||
@@ -54,48 +56,39 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||||
if (ventObj) ventObj.visible = false
|
if (ventObj) ventObj.visible = false
|
||||||
|
|
||||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
||||||
if (!buildingObj) return [wx, wy, wz]
|
|
||||||
const v = new THREE.Vector3(wx, wy, wz)
|
|
||||||
buildingObj.worldToLocal(v)
|
|
||||||
return [v.x, v.y, v.z]
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const wx = event.position[0]
|
const target = roofDrag.resolve(event)
|
||||||
const wy = event.position[1]
|
if (!target) return
|
||||||
const wz = event.position[2]
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(wx * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
const sz = Math.round(wz * 20) / 20
|
const sz = Math.round(target.localZ * 20) / 20
|
||||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
lastSnap = [sx, sz]
|
lastSnap = [sx, sz]
|
||||||
}
|
}
|
||||||
|
|
||||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||||
if (!hit) return
|
|
||||||
|
|
||||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
|
||||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
setPreviewPos(
|
||||||
|
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||||
|
target.localX,
|
||||||
|
target.localY,
|
||||||
|
target.localZ,
|
||||||
|
]),
|
||||||
|
)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
event.node as RoofNode,
|
if (!target) return
|
||||||
event.position[0],
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||||
@@ -119,7 +112,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
st.updateNode(node.id as AnyNodeId, {
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
roofSegmentId: targetSegmentId,
|
roofSegmentId: targetSegmentId,
|
||||||
parentId: targetSegmentId,
|
parentId: targetSegmentId,
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
visible: true,
|
visible: true,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
|
||||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||||
import GutterPreview from './preview'
|
import GutterPreview from './preview'
|
||||||
|
|
||||||
@@ -22,6 +22,11 @@ type PreviewTarget = {
|
|||||||
snap: EaveSnap
|
snap: EaveSnap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GutterDragTarget = {
|
||||||
|
segment: RoofSegmentNode
|
||||||
|
snap: EaveSnap
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gutter move tool. Mirrors the ridge-vent move flow — ghost follows
|
* Gutter move tool. Mirrors the ridge-vent move flow — ghost follows
|
||||||
* the cursor over any roof segment, click commits the new position +
|
* the cursor over any roof segment, click commits the new position +
|
||||||
@@ -65,23 +70,30 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
if (gutterObj) gutterObj.visible = false
|
if (gutterObj) gutterObj.visible = false
|
||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
|
let lastTarget: GutterDragTarget | null = null
|
||||||
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
|
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
|
||||||
|
const target = roofDrag.resolve(event)
|
||||||
|
if (!target) return null
|
||||||
|
return {
|
||||||
|
segment: target.segment,
|
||||||
|
snap: resolveEaveSnap(target.segment, target.localX, target.localZ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const roof = event.node as RoofNode
|
const roof = event.node as RoofNode
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = resolveTarget(event)
|
||||||
roof,
|
if (!target) return
|
||||||
event.position[0],
|
lastTarget = target
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
|
|
||||||
// Same snap math as the placement tool — picking-up and putting-
|
// Same snap math as the placement tool — picking-up and putting-
|
||||||
// down round-trip identically. roofType-aware: hip/flat picks
|
// down round-trip identically. roofType-aware: hip/flat picks
|
||||||
// ±X or ±Z based on which slope the cursor is on; shed always
|
// ±X or ±Z based on which slope the cursor is on; shed always
|
||||||
// snaps to its low (+Z) eave; gable / gambrel / mansard / dutch
|
// snaps to its low (+Z) eave; gable / gambrel / mansard / dutch
|
||||||
// stay on ±Z.
|
// stay on ±Z.
|
||||||
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
|
const { snap } = target
|
||||||
|
|
||||||
const sx = Math.round(snap.eaveX * 20) / 20
|
const sx = Math.round(snap.eaveX * 20) / 20
|
||||||
const sz = Math.round(snap.eaveZ * 20) / 20
|
const sz = Math.round(snap.eaveZ * 20) / 20
|
||||||
@@ -96,8 +108,8 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
rotation: roof.rotation ?? 0,
|
rotation: roof.rotation ?? 0,
|
||||||
},
|
},
|
||||||
segment: {
|
segment: {
|
||||||
position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number],
|
position: (target.segment.position ?? [0, 0, 0]) as [number, number, number],
|
||||||
rotation: hit.segment.rotation ?? 0,
|
rotation: target.segment.rotation ?? 0,
|
||||||
},
|
},
|
||||||
snap,
|
snap,
|
||||||
})
|
})
|
||||||
@@ -105,15 +117,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = lastTarget ?? resolveTarget(event)
|
||||||
event.node as RoofNode,
|
if (!target) return
|
||||||
event.position[0],
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
event.position[1],
|
const { snap } = target
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
|
||||||
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
|
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
|
|||||||
: getInitialState(node),
|
: getInitialState(node),
|
||||||
// Preserve the original item's scale so Y-position calculations use the correct height.
|
// Preserve the original item's scale so Y-position calculations use the correct height.
|
||||||
defaultScale: isNew ? node.scale : undefined,
|
defaultScale: isNew ? node.scale : undefined,
|
||||||
|
preserveFloorDragOffset: true,
|
||||||
initDraft: (gridPosition) => {
|
initDraft: (gridPosition) => {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
// Duplicate: floor items get a draft immediately; wall/ceiling
|
// Duplicate: floor items get a draft immediately; wall/ceiling
|
||||||
|
|||||||
@@ -5,18 +5,25 @@ import {
|
|||||||
emitter,
|
emitter,
|
||||||
type RidgeVentNode,
|
type RidgeVentNode,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import {
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
createRelativeRoofDrag,
|
||||||
|
type RelativeRoofDragTarget,
|
||||||
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
} from '../shared/relative-roof-drag'
|
||||||
|
import { getSurfaceY } from '../shared/roof-surface'
|
||||||
import RidgeVentPreview from './preview'
|
import RidgeVentPreview from './preview'
|
||||||
|
|
||||||
|
type RidgeVentDragTarget = Pick<RelativeRoofDragTarget, 'segment' | 'localX'> & {
|
||||||
|
localY: number
|
||||||
|
localZ: 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ridge-vent move tool. Mirrors the box-vent move flow — ghost follows
|
* Ridge-vent move tool. Mirrors the box-vent move flow — ghost follows
|
||||||
* the cursor over any roof segment, click commits the new position +
|
* the cursor over any roof segment, click commits the new position +
|
||||||
@@ -51,46 +58,48 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
|||||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||||
if (ventObj) ventObj.visible = false
|
if (ventObj) ventObj.visible = false
|
||||||
|
|
||||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
let lastSnap: [number, number] | null = null
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
let lastTarget: RidgeVentDragTarget | null = null
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
if (!buildingObj) return [wx, wy, wz]
|
|
||||||
const v = new THREE.Vector3(wx, wy, wz)
|
const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
|
||||||
buildingObj.worldToLocal(v)
|
const target = roofDrag.resolve(event)
|
||||||
return [v.x, v.y, v.z]
|
if (!target) return null
|
||||||
|
return {
|
||||||
|
segment: target.segment,
|
||||||
|
localX: target.localX,
|
||||||
|
localY: getSurfaceY(target.localX, 0, target.segment),
|
||||||
|
localZ: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const wx = event.position[0]
|
const target = resolveTarget(event)
|
||||||
const wy = event.position[1]
|
if (!target) return
|
||||||
const wz = event.position[2]
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(wx * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
const sz = Math.round(wz * 20) / 20
|
const sz = Math.round(target.localZ * 20) / 20
|
||||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
lastSnap = [sx, sz]
|
lastSnap = [sx, sz]
|
||||||
}
|
}
|
||||||
|
|
||||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||||
if (!hit) return
|
setPreviewPos(
|
||||||
|
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
target.localX,
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
target.localY,
|
||||||
|
target.localZ,
|
||||||
|
]),
|
||||||
|
)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = lastTarget ?? resolveTarget(event)
|
||||||
event.node as RoofNode,
|
if (!target) return
|
||||||
event.position[0],
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||||
@@ -114,7 +123,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
|||||||
st.updateNode(node.id as AnyNodeId, {
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
roofSegmentId: targetSegmentId,
|
roofSegmentId: targetSegmentId,
|
||||||
parentId: targetSegmentId,
|
parentId: targetSegmentId,
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
visible: true,
|
visible: true,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,11 +40,21 @@ 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)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
|
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
||||||
const obj = sceneRegistry.nodes.get(movingNode.id)
|
const obj = sceneRegistry.nodes.get(movingNode.id)
|
||||||
@@ -78,26 +92,11 @@ export const MoveRoofTool: React.FC<{
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
dragAnchorRef.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],
|
||||||
@@ -112,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
|
||||||
@@ -187,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,
|
||||||
})
|
})
|
||||||
@@ -255,7 +263,28 @@ export const MoveRoofTool: React.FC<{
|
|||||||
return [buildingLocalX, buildingLocalZ]
|
return [buildingLocalX, buildingLocalZ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localPositionToToolLocal = (
|
||||||
|
position: [number, number, number],
|
||||||
|
): [number, number, number] => {
|
||||||
|
if (
|
||||||
|
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
||||||
|
movingNode.parentId
|
||||||
|
) {
|
||||||
|
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
|
||||||
|
if (parentObj) {
|
||||||
|
const point = parentObj.localToWorld(new THREE.Vector3(...position))
|
||||||
|
if (buildingObj) buildingObj.worldToLocal(point)
|
||||||
|
return [point.x, point.y, point.z]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
|
||||||
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({
|
||||||
@@ -263,29 +292,43 @@ export const MoveRoofTool: React.FC<{
|
|||||||
walls: levelWalls,
|
walls: levelWalls,
|
||||||
fences: levelFences,
|
fences: levelFences,
|
||||||
})
|
})
|
||||||
// Layer alignment snap on top (top-level stair/roof). Recompute the
|
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
|
||||||
// world point from the aligned building-local point so it stays correct
|
const [rawLocalX, rawLocalZ] = computeLocal(
|
||||||
// under building rotation.
|
rawGridX,
|
||||||
const [lx, lz] = alignLocalPoint(
|
rawGridZ,
|
||||||
|
y,
|
||||||
snappedLocal[0],
|
snappedLocal[0],
|
||||||
snappedLocal[1],
|
snappedLocal[1],
|
||||||
event.nativeEvent?.altKey === true,
|
|
||||||
)
|
)
|
||||||
const [gridX, , gridZ] = localToWorldPoint([lx, lz], y)
|
const resolved = resolvePlanarCursorPosition({
|
||||||
|
cursor: [rawLocalX, rawLocalZ],
|
||||||
|
original: [movingNode.position[0], movingNode.position[2]],
|
||||||
|
anchor: dragAnchorRef.current,
|
||||||
|
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
|
||||||
|
})
|
||||||
|
dragAnchorRef.current = resolved.anchor
|
||||||
|
let [localX, localZ] = resolved.point
|
||||||
|
|
||||||
|
if (alignTopLevel) {
|
||||||
|
const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true)
|
||||||
|
localX = aligned[0]
|
||||||
|
localZ = aligned[1]
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousGridPosRef.current &&
|
previousGridPosRef.current &&
|
||||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
||||||
) {
|
) {
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
|
|
||||||
previousGridPosRef.current = [gridX, gridZ]
|
previousGridPosRef.current = [localX, localZ]
|
||||||
|
|
||||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
|
||||||
lastLocalPosition = [localX, movingNode.position[1], localZ]
|
lastLocalPosition = [localX, movingNode.position[1], localZ]
|
||||||
const previewPosition = getPreviewPosition(lastLocalPosition)
|
const previewPosition = getPreviewPosition(lastLocalPosition)
|
||||||
setCursorWorldPos(isFloorPlaced ? previewPosition : [lx, event.localPosition[1], lz])
|
setCursorWorldPos(
|
||||||
|
isFloorPlaced ? previewPosition : localPositionToToolLocal(lastLocalPosition),
|
||||||
|
)
|
||||||
|
|
||||||
// Directly update the Three.js mesh — no store update during drag
|
// Directly update the Three.js mesh — no store update during drag
|
||||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||||
@@ -302,53 +345,43 @@ export const MoveRoofTool: React.FC<{
|
|||||||
// Floor-placed parents (stairs) stay in their committed local frame;
|
// Floor-placed parents (stairs) stay in their committed local frame;
|
||||||
// the lifted Y remains presentation-only in the 3D view.
|
// the lifted Y remains presentation-only in the 3D view.
|
||||||
useLiveTransforms.getState().set(movingNode.id, {
|
useLiveTransforms.getState().set(movingNode.id, {
|
||||||
position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ],
|
position: lastLocalPosition,
|
||||||
rotation: pendingRotation,
|
rotation: pendingRotation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
const y = event.position[1]
|
if (!hasMoved) return
|
||||||
const snappedLocal = snapFenceDraftPoint({
|
const [localX, , localZ] = lastLocalPosition
|
||||||
point: [event.localPosition[0], event.localPosition[2]],
|
|
||||||
walls: levelWalls,
|
|
||||||
fences: levelFences,
|
|
||||||
})
|
|
||||||
const [lx, lz] = alignLocalPoint(
|
|
||||||
snappedLocal[0],
|
|
||||||
snappedLocal[1],
|
|
||||||
event.nativeEvent?.altKey === true,
|
|
||||||
)
|
|
||||||
const [gridX, , gridZ] = localToWorldPoint([lx, lz], y)
|
|
||||||
|
|
||||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
|
||||||
|
|
||||||
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?.()
|
||||||
}
|
}
|
||||||
@@ -444,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]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
sceneRegistry,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { type RoofSegmentHit, resolveRoofSegmentHit } from './roof-segment-hit'
|
||||||
|
import { getSurfaceY } from './roof-surface'
|
||||||
|
|
||||||
|
export type RelativeRoofDragTarget = {
|
||||||
|
segment: RoofSegmentNode
|
||||||
|
localX: number
|
||||||
|
localY: number
|
||||||
|
localZ: number
|
||||||
|
hit: RoofSegmentHit
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelativeRoofDragState = {
|
||||||
|
segmentId: string
|
||||||
|
anchor: [number, number]
|
||||||
|
start: [number, number, number]
|
||||||
|
current: [number, number, number]
|
||||||
|
surfaceOffsetY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roofSegmentLocalToBuildingLocal(
|
||||||
|
segmentId: string,
|
||||||
|
position: [number, number, number],
|
||||||
|
): [number, number, number] {
|
||||||
|
const segmentObj = sceneRegistry.nodes.get(segmentId as AnyNodeId)
|
||||||
|
if (!segmentObj) return position
|
||||||
|
|
||||||
|
const point = segmentObj.localToWorld(new THREE.Vector3(...position))
|
||||||
|
const buildingId = useViewer.getState().selection.buildingId
|
||||||
|
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||||
|
if (buildingObj) buildingObj.worldToLocal(point)
|
||||||
|
return [point.x, point.y, point.z]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRelativeRoofDrag(original: {
|
||||||
|
position: [number, number, number]
|
||||||
|
roofSegmentId?: string
|
||||||
|
}): {
|
||||||
|
resolve: (event: RoofEvent) => RelativeRoofDragTarget | null
|
||||||
|
} {
|
||||||
|
let state: RelativeRoofDragState | null = null
|
||||||
|
|
||||||
|
const getPositionInSegment = (
|
||||||
|
position: [number, number, number],
|
||||||
|
fromSegmentId: string | undefined,
|
||||||
|
segment: RoofSegmentNode,
|
||||||
|
): [number, number, number] => {
|
||||||
|
if (fromSegmentId === segment.id) return position
|
||||||
|
|
||||||
|
const fromSegmentObj = fromSegmentId
|
||||||
|
? sceneRegistry.nodes.get(fromSegmentId as AnyNodeId)
|
||||||
|
: null
|
||||||
|
const targetSegmentObj = sceneRegistry.nodes.get(segment.id as AnyNodeId)
|
||||||
|
if (!(fromSegmentObj && targetSegmentObj)) return position
|
||||||
|
|
||||||
|
const point = fromSegmentObj.localToWorld(new THREE.Vector3(...position))
|
||||||
|
targetSegmentObj.worldToLocal(point)
|
||||||
|
return [point.x, point.y, point.z]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStartPositionForSegment = (
|
||||||
|
segment: RoofSegmentNode,
|
||||||
|
previousState: RelativeRoofDragState | null,
|
||||||
|
): [number, number, number] => {
|
||||||
|
if (previousState) {
|
||||||
|
return getPositionInSegment(previousState.current, previousState.segmentId, segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (original.roofSegmentId === segment.id) return original.position
|
||||||
|
|
||||||
|
return getPositionInSegment(original.position, original.roofSegmentId, segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolve(event) {
|
||||||
|
const hit = resolveRoofSegmentHit(
|
||||||
|
event.node as RoofNode,
|
||||||
|
event.position[0],
|
||||||
|
event.position[1],
|
||||||
|
event.position[2],
|
||||||
|
)
|
||||||
|
if (!hit) return null
|
||||||
|
|
||||||
|
if (!state || state.segmentId !== hit.segment.id) {
|
||||||
|
const start = getStartPositionForSegment(hit.segment, state)
|
||||||
|
state = {
|
||||||
|
segmentId: hit.segment.id,
|
||||||
|
anchor: [hit.localX, hit.localZ],
|
||||||
|
start,
|
||||||
|
current: start,
|
||||||
|
surfaceOffsetY: start[1] - getSurfaceY(start[0], start[2], hit.segment),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localX = state.start[0] + (hit.localX - state.anchor[0])
|
||||||
|
const localZ = state.start[2] + (hit.localZ - state.anchor[1])
|
||||||
|
const localY = getSurfaceY(localX, localZ, hit.segment) + state.surfaceOffsetY
|
||||||
|
state.current = [localX, localY, localZ]
|
||||||
|
return {
|
||||||
|
segment: hit.segment,
|
||||||
|
localX,
|
||||||
|
localY,
|
||||||
|
localZ,
|
||||||
|
hit,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,35 +11,16 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import {
|
||||||
|
createRelativeRoofDrag,
|
||||||
|
type RelativeRoofDragTarget,
|
||||||
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
} from '../shared/relative-roof-drag'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import SkylightPreview from './preview'
|
import SkylightPreview from './preview'
|
||||||
|
|
||||||
function resolveSegmentFromWorldPoint(
|
|
||||||
roof: RoofNode,
|
|
||||||
worldX: number,
|
|
||||||
worldY: number,
|
|
||||||
worldZ: number,
|
|
||||||
state: ReturnType<typeof useScene.getState>,
|
|
||||||
): { segment: RoofSegmentNode; localX: number; localY: number; localZ: number } | null {
|
|
||||||
const worldPt = new THREE.Vector3(worldX, worldY, worldZ)
|
|
||||||
for (const childId of roof.children ?? []) {
|
|
||||||
const seg = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
|
|
||||||
if (seg?.type !== 'roof-segment') continue
|
|
||||||
const segObj = sceneRegistry.nodes.get(seg.id)
|
|
||||||
if (!segObj) continue
|
|
||||||
segObj.updateWorldMatrix(true, false)
|
|
||||||
const local = segObj.worldToLocal(worldPt.clone())
|
|
||||||
if (Math.abs(local.x) <= seg.width / 2 && Math.abs(local.z) <= seg.depth / 2) {
|
|
||||||
return { segment: seg, localX: local.x, localY: local.y, localZ: local.z }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||||
const exitMoveMode = useCallback(() => {
|
const exitMoveMode = useCallback(() => {
|
||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
@@ -81,19 +62,10 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
|||||||
const skylightObj = sceneRegistry.nodes.get(node.id)
|
const skylightObj = sceneRegistry.nodes.get(node.id)
|
||||||
if (skylightObj) skylightObj.visible = false
|
if (skylightObj) skylightObj.visible = false
|
||||||
|
|
||||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
||||||
if (buildingObj) {
|
|
||||||
const v = new THREE.Vector3(wx, wy, wz)
|
|
||||||
buildingObj.worldToLocal(v)
|
|
||||||
return [v.x, v.y, v.z]
|
|
||||||
}
|
|
||||||
return [wx, wy, wz]
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSnapX = 0
|
let lastSnapX = 0
|
||||||
let lastSnapZ = 0
|
let lastSnapZ = 0
|
||||||
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
// Resolve which segment the cursor is over, then derive the same
|
// Resolve which segment the cursor is over, then derive the same
|
||||||
// preview transform stack the placement tool uses (`skylight/tool.tsx`):
|
// preview transform stack the placement tool uses (`skylight/tool.tsx`):
|
||||||
@@ -103,20 +75,22 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
|||||||
// same via its `if (!hit) return` guard.
|
// same via its `if (!hit) return` guard.
|
||||||
const updateFromHit = (event: RoofEvent) => {
|
const updateFromHit = (event: RoofEvent) => {
|
||||||
const roof = event.node as RoofNode
|
const roof = event.node as RoofNode
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = roofDrag.resolve(event)
|
||||||
roof,
|
if (!target) {
|
||||||
event.position[0],
|
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) {
|
|
||||||
setHasHit(false)
|
setHasHit(false)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
lastTarget = target
|
||||||
|
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
setPreviewYaw((roof.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((roof.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||||
setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2]))
|
setPreviewPos(
|
||||||
|
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||||
|
target.localX,
|
||||||
|
target.localY,
|
||||||
|
target.localZ,
|
||||||
|
]),
|
||||||
|
)
|
||||||
setHasHit(true)
|
setHasHit(true)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -139,19 +113,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const roof = event.node as RoofNode
|
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
const hit = resolveSegmentFromWorldPoint(
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
roof,
|
if (!target) return
|
||||||
event.position[0],
|
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
st,
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
|
|
||||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
const finalRotation = original.rotation
|
const finalRotation = original.rotation
|
||||||
|
|
||||||
st.updateNode(node.id as AnyNodeId, {
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
@@ -166,7 +133,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
|||||||
st.updateNode(node.id as AnyNodeId, {
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
roofSegmentId: targetSegmentId,
|
roofSegmentId: targetSegmentId,
|
||||||
parentId: targetSegmentId,
|
parentId: targetSegmentId,
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
rotation: finalRotation,
|
rotation: finalRotation,
|
||||||
visible: true,
|
visible: true,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
emitter,
|
emitter,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
type SolarPanelNode,
|
type SolarPanelNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import {
|
||||||
|
createRelativeRoofDrag,
|
||||||
|
type RelativeRoofDragTarget,
|
||||||
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
} from '../shared/relative-roof-drag'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
|
|
||||||
// MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding
|
// MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding
|
||||||
@@ -86,27 +88,18 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
|||||||
const panelObj = sceneRegistry.nodes.get(node.id)
|
const panelObj = sceneRegistry.nodes.get(node.id)
|
||||||
if (panelObj) panelObj.visible = false
|
if (panelObj) panelObj.visible = false
|
||||||
|
|
||||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
||||||
if (buildingObj) {
|
|
||||||
const v = new THREE.Vector3(wx, wy, wz)
|
|
||||||
buildingObj.worldToLocal(v)
|
|
||||||
return [v.x, v.y, v.z]
|
|
||||||
}
|
|
||||||
return [wx, wy, wz]
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSnapX = 0
|
let lastSnapX = 0
|
||||||
let lastSnapZ = 0
|
let lastSnapZ = 0
|
||||||
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
const updateGhost = (event: RoofEvent) => {
|
const updateGhost = (event: RoofEvent) => {
|
||||||
const wx = event.position[0]
|
const target = roofDrag.resolve(event)
|
||||||
const wy = event.position[1]
|
if (!target) return
|
||||||
const wz = event.position[2]
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(wx * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
const sz = Math.round(wz * 20) / 20
|
const sz = Math.round(target.localZ * 20) / 20
|
||||||
if (sx !== lastSnapX || sz !== lastSnapZ) {
|
if (sx !== lastSnapX || sz !== lastSnapZ) {
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
lastSnapX = sx
|
lastSnapX = sx
|
||||||
@@ -119,35 +112,32 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
|||||||
// because analytical normals are computed in segment-local space
|
// because analytical normals are computed in segment-local space
|
||||||
// and the yaw is applied explicitly, avoiding any world-vs-local
|
// and the yaw is applied explicitly, avoiding any world-vs-local
|
||||||
// normal mismatch.
|
// normal mismatch.
|
||||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||||
if (!hit) return
|
|
||||||
|
|
||||||
const segLocalNormal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
|
||||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(segLocalNormal, new THREE.Quaternion()))
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(segLocalNormal, new THREE.Quaternion()))
|
||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
setPreviewPos(
|
||||||
|
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||||
|
target.localX,
|
||||||
|
target.localY,
|
||||||
|
target.localZ,
|
||||||
|
]),
|
||||||
|
)
|
||||||
setHasHit(true)
|
setHasHit(true)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const roof = event.node as RoofNode
|
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
roof,
|
if (!target) return
|
||||||
event.position[0],
|
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
|
|
||||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
|
|
||||||
// Compute segment-local normal for the committed node so the
|
// Compute segment-local normal for the committed node so the
|
||||||
// renderer's surfaceQuat + outer segment.rotation compose to
|
// renderer's surfaceQuat + outer segment.rotation compose to
|
||||||
// the same world orientation the ghost showed.
|
// the same world orientation the ghost showed.
|
||||||
const segLocalNormal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||||
|
|
||||||
st.updateNode(node.id as AnyNodeId, {
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
@@ -161,7 +151,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
|||||||
st.updateNode(node.id as AnyNodeId, {
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
roofSegmentId: targetSegmentId,
|
roofSegmentId: targetSegmentId,
|
||||||
parentId: targetSegmentId,
|
parentId: targetSegmentId,
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
// Segment-local normal — must stay consistent with getAnalyticalNormal
|
// Segment-local normal — must stay consistent with getAnalyticalNormal
|
||||||
// semantics so the renderer's surfaceQuat is in the correct frame.
|
// semantics so the renderer's surfaceQuat is in the correct frame.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { SpawnNode as SpawnSchemaFromCore } from '@pascal-app/core'
|
import {
|
||||||
|
type FloorplanGeometry,
|
||||||
|
type GeometryContext,
|
||||||
|
SpawnNode as SpawnSchemaFromCore,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { spawnDefinition } from '../definition'
|
import { spawnDefinition } from '../definition'
|
||||||
|
import { buildSpawnFloorplan } from '../floorplan'
|
||||||
import { SpawnNode } from '../schema'
|
import { SpawnNode } from '../schema'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,7 +13,7 @@ import { SpawnNode } from '../schema'
|
|||||||
*
|
*
|
||||||
* The new renderer is a near-line-by-line port of the legacy
|
* The new renderer is a near-line-by-line port of the legacy
|
||||||
* `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` —
|
* `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` —
|
||||||
* same mesh count, same primitives, same colors. The "parity" assertion
|
* same mesh count and primitives. The "parity" assertion
|
||||||
* for the spike is structural (definition is well-formed, both lazy
|
* for the spike is structural (definition is well-formed, both lazy
|
||||||
* modules resolve to React components) plus a manual visual eyeball check
|
* modules resolve to React components) plus a manual visual eyeball check
|
||||||
* documented in the plan. Pixel-level Playwright parity lands in Phase 4
|
* documented in the plan. Pixel-level Playwright parity lands in Phase 4
|
||||||
@@ -52,6 +57,56 @@ describe('spawn definition', () => {
|
|||||||
expect(angles).toContain(0)
|
expect(angles).toContain(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('handles expose rotation and move controls', () => {
|
||||||
|
expect(Array.isArray(spawnDefinition.handles)).toBe(true)
|
||||||
|
if (!Array.isArray(spawnDefinition.handles)) return
|
||||||
|
expect(spawnDefinition.handles.map((handle) => handle.kind)).toEqual([
|
||||||
|
'arc-resize',
|
||||||
|
'translate',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('floorplan uses indigo marker color and selected rotation affordance', () => {
|
||||||
|
const spawn = SpawnNode.parse({
|
||||||
|
id: 'spawn_test1234567890ab',
|
||||||
|
position: [1, 0, 2],
|
||||||
|
rotation: Math.PI / 4,
|
||||||
|
})
|
||||||
|
const geometry = buildSpawnFloorplan(spawn, {
|
||||||
|
resolve: () => undefined,
|
||||||
|
children: [],
|
||||||
|
siblings: [],
|
||||||
|
parent: null,
|
||||||
|
viewState: {
|
||||||
|
selected: true,
|
||||||
|
highlighted: false,
|
||||||
|
hovered: false,
|
||||||
|
moving: false,
|
||||||
|
palette: {
|
||||||
|
selectedStroke: '#60a5fa',
|
||||||
|
selectedFill: '#dbeafe',
|
||||||
|
selectedHatch: '#60a5fa',
|
||||||
|
wallHoverStroke: '#60a5fa',
|
||||||
|
endpointHandleFill: '#fed7aa',
|
||||||
|
endpointHandleStroke: '#f97316',
|
||||||
|
endpointHandleHoverStroke: '#fb923c',
|
||||||
|
endpointHandleActiveFill: '#fdba74',
|
||||||
|
endpointHandleActiveStroke: '#ea580c',
|
||||||
|
curveHandleFill: '#99f6e4',
|
||||||
|
curveHandleStroke: '#14b8a6',
|
||||||
|
curveHandleHoverStroke: '#2dd4bf',
|
||||||
|
measurementStroke: '#6366f1',
|
||||||
|
measurementLabelBackground: '#ffffff',
|
||||||
|
measurementLabelText: '#111827',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies GeometryContext)
|
||||||
|
|
||||||
|
const flat = flattenFloorplan(geometry)
|
||||||
|
expect(flat.some((entry) => entry.kind === 'polygon' && entry.fill === '#818cf8')).toBe(true)
|
||||||
|
expect(flat.some((entry) => entry.kind === 'rotate-arrow')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test('renderer is a parametric lazy module reference', () => {
|
test('renderer is a parametric lazy module reference', () => {
|
||||||
expect(spawnDefinition.renderer.kind).toBe('parametric')
|
expect(spawnDefinition.renderer.kind).toBe('parametric')
|
||||||
if (spawnDefinition.renderer.kind !== 'parametric') return
|
if (spawnDefinition.renderer.kind !== 'parametric') return
|
||||||
@@ -67,3 +122,8 @@ describe('spawn definition', () => {
|
|||||||
expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0)
|
expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function flattenFloorplan(geometry: FloorplanGeometry): FloorplanGeometry[] {
|
||||||
|
if (geometry.kind !== 'group') return [geometry]
|
||||||
|
return geometry.children.flatMap((child) => flattenFloorplan(child))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core'
|
import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core'
|
||||||
import { buildSpawnFloorplan } from './floorplan'
|
import { buildSpawnFloorplan } from './floorplan'
|
||||||
|
import { spawnRotateAffordance } from './floorplan-affordances'
|
||||||
import { spawnParametrics } from './parametrics'
|
import { spawnParametrics } from './parametrics'
|
||||||
import { SpawnNode } from './schema'
|
import { SpawnNode } from './schema'
|
||||||
|
|
||||||
const SPAWN_FOOTPRINT = 0.6
|
const SPAWN_FOOTPRINT = 0.6
|
||||||
|
const SPAWN_HANDLE_HEIGHT = 0.46
|
||||||
const MOVE_FRONT_OFFSET = 0.35
|
const MOVE_FRONT_OFFSET = 0.35
|
||||||
|
const ROTATE_CORNER_OFFSET = 0.32
|
||||||
|
const ROTATE_RING_OFFSET = 0.04
|
||||||
|
|
||||||
|
function spawnRotateHandle(): HandleDescriptor<SpawnNodeType> {
|
||||||
|
return {
|
||||||
|
kind: 'arc-resize',
|
||||||
|
axis: 'angular',
|
||||||
|
shape: 'rotate',
|
||||||
|
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||||
|
placement: {
|
||||||
|
position: () => [
|
||||||
|
SPAWN_FOOTPRINT / 2,
|
||||||
|
SPAWN_HANDLE_HEIGHT,
|
||||||
|
SPAWN_FOOTPRINT / 2 + ROTATE_CORNER_OFFSET,
|
||||||
|
],
|
||||||
|
rotationY: () => -Math.PI / 4,
|
||||||
|
},
|
||||||
|
decoration: {
|
||||||
|
kind: 'ring',
|
||||||
|
radius: () => Math.hypot(SPAWN_FOOTPRINT / 2, SPAWN_FOOTPRINT / 2) + ROTATE_RING_OFFSET,
|
||||||
|
y: () => SPAWN_HANDLE_HEIGHT,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> {
|
function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> {
|
||||||
return {
|
return {
|
||||||
@@ -52,7 +78,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
parametrics: spawnParametrics,
|
parametrics: spawnParametrics,
|
||||||
handles: [spawnMoveHandle()],
|
handles: [spawnRotateHandle(), spawnMoveHandle()],
|
||||||
|
|
||||||
renderer: {
|
renderer: {
|
||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
@@ -66,6 +92,9 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
|||||||
// delete. Legacy spawn click handlers in FloorplanNodeLayer become
|
// delete. Legacy spawn click handlers in FloorplanNodeLayer become
|
||||||
// dead code once Phase 6 cleanup removes the [] entries path.
|
// dead code once Phase 6 cleanup removes the [] entries path.
|
||||||
floorplan: buildSpawnFloorplan,
|
floorplan: buildSpawnFloorplan,
|
||||||
|
floorplanAffordances: {
|
||||||
|
'spawn-rotate': spawnRotateAffordance,
|
||||||
|
},
|
||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Place spawn point' },
|
{ key: 'Left click', label: 'Place spawn point' },
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type FloorplanAffordance,
|
||||||
|
type SpawnNode,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
|
export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
|
||||||
|
start({ node, initialPlanPoint }) {
|
||||||
|
const spawnId = node.id as AnyNodeId
|
||||||
|
const initialRotation = node.rotation ?? 0
|
||||||
|
const cx = node.position[0]
|
||||||
|
const cz = node.position[2]
|
||||||
|
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
|
||||||
|
let lastRotation = initialRotation
|
||||||
|
|
||||||
|
return {
|
||||||
|
affectedIds: [spawnId],
|
||||||
|
apply({ planPoint }) {
|
||||||
|
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
||||||
|
let delta = currentAngle - initialAngle
|
||||||
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||||
|
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||||
|
lastRotation = initialRotation - delta
|
||||||
|
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
||||||
|
},
|
||||||
|
canCommit() {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
commit() {
|
||||||
|
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
import type { FloorplanGeometry } from '@pascal-app/core'
|
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
|
||||||
import type { SpawnNode } from './schema'
|
import type { SpawnNode } from './schema'
|
||||||
|
|
||||||
|
const SPAWN_COLOR = '#818cf8'
|
||||||
|
const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2D floor-plan marker for a spawn point. A small filled circle at the
|
* 2D floor-plan marker for a spawn point. A small filled circle at the
|
||||||
* spawn's position, with a triangular arrow indicating the facing
|
* spawn's position, with a triangular arrow indicating the facing
|
||||||
* direction (rotation around Y, looking down at the X-Z plane).
|
* direction (rotation around Y, looking down at the X-Z plane).
|
||||||
*
|
*
|
||||||
* Color matches the 3D renderer's `SPAWN_COLOR = '#22c55e'` so the user
|
* Color matches the 3D renderer's indigo spawn material so the user
|
||||||
* sees the same visual identity in both views.
|
* sees the same visual identity in both views.
|
||||||
*
|
*
|
||||||
* Coordinates are level-local meters; rotation is radians.
|
* Coordinates are level-local meters; rotation is radians.
|
||||||
*/
|
*/
|
||||||
export function buildSpawnFloorplan(node: SpawnNode): FloorplanGeometry {
|
export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): FloorplanGeometry {
|
||||||
const [px, , pz] = node.position
|
const [px, , pz] = node.position
|
||||||
const ry = node.rotation
|
const ry = node.rotation
|
||||||
|
const isSelected = ctx.viewState?.selected ?? false
|
||||||
|
|
||||||
return {
|
const children: FloorplanGeometry[] = [
|
||||||
|
{
|
||||||
kind: 'group',
|
kind: 'group',
|
||||||
transform: { translate: [px, pz], rotate: ry },
|
transform: { translate: [px, pz], rotate: ry },
|
||||||
children: [
|
children: [
|
||||||
@@ -28,7 +33,7 @@ export function buildSpawnFloorplan(node: SpawnNode): FloorplanGeometry {
|
|||||||
[-0.18, 0.12],
|
[-0.18, 0.12],
|
||||||
[0.18, 0.12],
|
[0.18, 0.12],
|
||||||
],
|
],
|
||||||
fill: '#22c55e',
|
fill: SPAWN_COLOR,
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
},
|
},
|
||||||
// Spawn body marker — circle outline so the spawn is legible at
|
// Spawn body marker — circle outline so the spawn is legible at
|
||||||
@@ -38,11 +43,37 @@ export function buildSpawnFloorplan(node: SpawnNode): FloorplanGeometry {
|
|||||||
cx: 0,
|
cx: 0,
|
||||||
cy: 0,
|
cy: 0,
|
||||||
r: 0.34,
|
r: 0.34,
|
||||||
stroke: '#22c55e',
|
stroke: SPAWN_COLOR,
|
||||||
strokeWidth: 0.025,
|
strokeWidth: 0.025,
|
||||||
fill: '#22c55e',
|
fill: SPAWN_COLOR,
|
||||||
opacity: 0.18,
|
opacity: 0.18,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
const cornerLocalX = 0.34 + ROTATE_ARROW_CORNER_OFFSET
|
||||||
|
const cornerLocalZ = 0.34 + ROTATE_ARROW_CORNER_OFFSET
|
||||||
|
const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, ry)
|
||||||
|
const [radialX, radialZ] = rotatePlanVector(1, 1, ry)
|
||||||
|
children.push({
|
||||||
|
kind: 'rotate-arrow',
|
||||||
|
point: [px + cornerX, pz + cornerZ],
|
||||||
|
angle: Math.atan2(radialZ, radialX),
|
||||||
|
affordance: 'spawn-rotate',
|
||||||
|
pivot: [px, pz],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'group',
|
||||||
|
children,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rotatePlanVector(x: number, y: number, rotation: number): FloorplanPoint {
|
||||||
|
const c = Math.cos(rotation)
|
||||||
|
const s = Math.sin(rotation)
|
||||||
|
return [x * c - y * s, x * s + y * c]
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/vie
|
|||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { Color, type Group, Shape } from 'three'
|
import { Color, type Group, Shape } from 'three'
|
||||||
|
|
||||||
const SPAWN_COLOR = new Color('#22c55e')
|
const SPAWN_COLOR = new Color('#818cf8')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry-driven spawn renderer. Behaviorally identical to the legacy
|
* Registry-driven spawn renderer. Behaviorally identical to the legacy
|
||||||
* `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — same
|
* `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — same
|
||||||
* geometry, same colors, same event surface. When the spawn definition lands
|
* geometry and event surface. When the spawn definition lands
|
||||||
* in `builtinPlugin.nodes`, the Phase 0 dispatch shims switch the renderer
|
* in `builtinPlugin.nodes`, the Phase 0 dispatch shims switch the renderer
|
||||||
* here and the legacy one is short-circuited.
|
* here and the legacy one is short-circuited.
|
||||||
*
|
*
|
||||||
@@ -38,7 +38,7 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
|
|||||||
useRegistry(node.id, 'spawn', ref)
|
useRegistry(node.id, 'spawn', ref)
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const next = createDefaultMaterial('#22c55e', 0.42, shading) as ReturnType<
|
const next = createDefaultMaterial('#818cf8', 0.42, shading) as ReturnType<
|
||||||
typeof createDefaultMaterial
|
typeof createDefaultMaterial
|
||||||
> & {
|
> & {
|
||||||
emissive?: Color
|
emissive?: Color
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const SpawnTool = () => {
|
|||||||
|
|
||||||
if (!activeLevelId) return null
|
if (!activeLevelId) return null
|
||||||
|
|
||||||
return <CursorSphere color="#60a5fa" height={2.2} ref={cursorRef} />
|
return <CursorSphere color="#818cf8" height={2.2} ref={cursorRef} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SpawnTool
|
export default SpawnTool
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
emitter,
|
emitter,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
type TurbineVentNode,
|
type TurbineVentNode,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import {
|
||||||
|
createRelativeRoofDrag,
|
||||||
|
type RelativeRoofDragTarget,
|
||||||
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
} from '../shared/relative-roof-drag'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
import TurbineVentPreview from './preview'
|
import TurbineVentPreview from './preview'
|
||||||
|
|
||||||
@@ -54,48 +56,39 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
|||||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||||
if (ventObj) ventObj.visible = false
|
if (ventObj) ventObj.visible = false
|
||||||
|
|
||||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
|
||||||
const buildingId = useViewer.getState().selection.buildingId
|
|
||||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
||||||
if (!buildingObj) return [wx, wy, wz]
|
|
||||||
const v = new THREE.Vector3(wx, wy, wz)
|
|
||||||
buildingObj.worldToLocal(v)
|
|
||||||
return [v.x, v.y, v.z]
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSnap: [number, number] | null = null
|
let lastSnap: [number, number] | null = null
|
||||||
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
const roofDrag = createRelativeRoofDrag(original)
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const wx = event.position[0]
|
const target = roofDrag.resolve(event)
|
||||||
const wy = event.position[1]
|
if (!target) return
|
||||||
const wz = event.position[2]
|
lastTarget = target
|
||||||
|
|
||||||
const sx = Math.round(wx * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
const sz = Math.round(wz * 20) / 20
|
const sz = Math.round(target.localZ * 20) / 20
|
||||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
lastSnap = [sx, sz]
|
lastSnap = [sx, sz]
|
||||||
}
|
}
|
||||||
|
|
||||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
|
||||||
if (!hit) return
|
|
||||||
|
|
||||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
|
||||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
setPreviewPos(
|
||||||
|
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||||
|
target.localX,
|
||||||
|
target.localY,
|
||||||
|
target.localZ,
|
||||||
|
]),
|
||||||
|
)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
const hit = resolveRoofSegmentHit(
|
const target = lastTarget ?? roofDrag.resolve(event)
|
||||||
event.node as RoofNode,
|
if (!target) return
|
||||||
event.position[0],
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
event.position[1],
|
|
||||||
event.position[2],
|
|
||||||
)
|
|
||||||
if (!hit) return
|
|
||||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
|
||||||
const st = useScene.getState()
|
const st = useScene.getState()
|
||||||
|
|
||||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||||
@@ -119,7 +112,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
|
|||||||
st.updateNode(node.id as AnyNodeId, {
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
roofSegmentId: targetSegmentId,
|
roofSegmentId: targetSegmentId,
|
||||||
parentId: targetSegmentId,
|
parentId: targetSegmentId,
|
||||||
position: [hit.localX, hit.localY, hit.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
visible: true,
|
visible: true,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentWallId: string | null = movingWindowNode.parentId
|
let currentWallId: string | null = movingWindowNode.parentId
|
||||||
|
let dragAnchor: {
|
||||||
|
wallId: string
|
||||||
|
rawX: number
|
||||||
|
rawY: number
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
} | null = null
|
||||||
|
let lastTarget: {
|
||||||
|
wallNode: WallEvent['node']
|
||||||
|
wallId: string
|
||||||
|
side: WindowNode['side']
|
||||||
|
itemRotation: number
|
||||||
|
cursorRotation: number
|
||||||
|
clampedX: number
|
||||||
|
clampedY: number
|
||||||
|
valid: boolean
|
||||||
|
event: WallEvent
|
||||||
|
} | null = null
|
||||||
|
|
||||||
const markWallDirty = (wallId: string | null) => {
|
const markWallDirty = (wallId: string | null) => {
|
||||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||||
@@ -140,7 +158,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const resolveMoveTarget = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
if (isCurvedWall(event.node)) {
|
if (isCurvedWall(event.node)) {
|
||||||
hideCursor()
|
hideCursor()
|
||||||
@@ -153,40 +171,35 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||||
|
|
||||||
|
const rawLocalX = event.localPosition[0]
|
||||||
|
const rawLocalY = event.localPosition[1]
|
||||||
|
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
||||||
|
dragAnchor = {
|
||||||
|
wallId: event.node.id,
|
||||||
|
rawX: rawLocalX,
|
||||||
|
rawY: rawLocalY,
|
||||||
|
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
|
||||||
|
startY:
|
||||||
|
event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
||||||
|
const targetLocalY = snapToHalf(dragAnchor.startY + (rawLocalY - dragAnchor.rawY))
|
||||||
const localX = resolveWallSlideAlignment({
|
const localX = resolveWallSlideAlignment({
|
||||||
wallNode: event.node,
|
wallNode: event.node,
|
||||||
rawLocalX: event.localPosition[0],
|
rawLocalX: targetLocalX,
|
||||||
width: movingWindowNode.width,
|
width: movingWindowNode.width,
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
bypass: event.nativeEvent?.altKey === true,
|
bypass: event.nativeEvent?.altKey === true,
|
||||||
})
|
})
|
||||||
const localY = snapToHalf(event.localPosition[1])
|
|
||||||
const { clampedX, clampedY } = clampToWall(
|
const { clampedX, clampedY } = clampToWall(
|
||||||
event.node,
|
event.node,
|
||||||
localX,
|
localX,
|
||||||
localY,
|
targetLocalY,
|
||||||
movingWindowNode.width,
|
movingWindowNode.width,
|
||||||
movingWindowNode.height,
|
movingWindowNode.height,
|
||||||
)
|
)
|
||||||
|
|
||||||
const prevWallId = currentWallId
|
|
||||||
currentWallId = event.node.id
|
|
||||||
|
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
|
||||||
position: [clampedX, clampedY, 0],
|
|
||||||
rotation: [0, itemRotation, 0],
|
|
||||||
side,
|
|
||||||
parentId: event.node.id,
|
|
||||||
wallId: event.node.id,
|
|
||||||
})
|
|
||||||
useLiveTransforms.getState().set(movingWindowNode.id, {
|
|
||||||
position: [clampedX, clampedY, 0],
|
|
||||||
rotation: itemRotation,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
|
||||||
markWallDirtyThrottled(event.node.id)
|
|
||||||
|
|
||||||
const valid = !hasWallChildOverlap(
|
const valid = !hasWallChildOverlap(
|
||||||
event.node.id,
|
event.node.id,
|
||||||
clampedX,
|
clampedX,
|
||||||
@@ -196,17 +209,62 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
movingWindowNode.id,
|
movingWindowNode.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
updateCursor(
|
return {
|
||||||
wallLocalToWorld(
|
wallNode: event.node,
|
||||||
event.node,
|
wallId: event.node.id,
|
||||||
|
side,
|
||||||
|
itemRotation,
|
||||||
|
cursorRotation,
|
||||||
clampedX,
|
clampedX,
|
||||||
clampedY,
|
clampedY,
|
||||||
getLevelYOffset(),
|
|
||||||
getSlabElevation(event),
|
|
||||||
),
|
|
||||||
cursorRotation,
|
|
||||||
valid,
|
valid,
|
||||||
|
event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||||
|
if (currentWallId !== target.wallId) {
|
||||||
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
|
rotation: [0, target.itemRotation, 0],
|
||||||
|
side: target.side,
|
||||||
|
parentId: target.wallId,
|
||||||
|
wallId: target.wallId,
|
||||||
|
})
|
||||||
|
markWallDirty(currentWallId)
|
||||||
|
currentWallId = target.wallId
|
||||||
|
} else {
|
||||||
|
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
||||||
|
if (windowMesh) {
|
||||||
|
windowMesh.position.set(target.clampedX, target.clampedY, 0)
|
||||||
|
windowMesh.rotation.set(0, target.itemRotation, 0)
|
||||||
|
windowMesh.updateMatrixWorld(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useLiveTransforms.getState().set(movingWindowNode.id, {
|
||||||
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
|
rotation: target.itemRotation,
|
||||||
|
})
|
||||||
|
markWallDirtyThrottled(target.wallId)
|
||||||
|
|
||||||
|
updateCursor(
|
||||||
|
wallLocalToWorld(
|
||||||
|
target.wallNode,
|
||||||
|
target.clampedX,
|
||||||
|
target.clampedY,
|
||||||
|
getLevelYOffset(),
|
||||||
|
getSlabElevation(target.event),
|
||||||
|
),
|
||||||
|
target.cursorRotation,
|
||||||
|
target.valid,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallEnter = (event: WallEvent) => {
|
||||||
|
const target = resolveMoveTarget(event)
|
||||||
|
if (!target) return
|
||||||
|
lastTarget = target
|
||||||
|
applyPreview(target)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,73 +277,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
// Only interact with walls on the current level
|
// Only interact with walls on the current level
|
||||||
if (event.node.parentId !== getLevelId()) return
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const target = resolveMoveTarget(event)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
if (!target) return
|
||||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
lastTarget = target
|
||||||
|
applyPreview(target)
|
||||||
const localX = resolveWallSlideAlignment({
|
|
||||||
wallNode: event.node,
|
|
||||||
rawLocalX: event.localPosition[0],
|
|
||||||
width: movingWindowNode.width,
|
|
||||||
candidates: alignmentCandidates,
|
|
||||||
bypass: event.nativeEvent?.altKey === true,
|
|
||||||
})
|
|
||||||
const localY = snapToHalf(event.localPosition[1])
|
|
||||||
const { clampedX, clampedY } = clampToWall(
|
|
||||||
event.node,
|
|
||||||
localX,
|
|
||||||
localY,
|
|
||||||
movingWindowNode.width,
|
|
||||||
movingWindowNode.height,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (currentWallId !== event.node.id) {
|
|
||||||
// Wall changed mid-move: must updateNode to reparent
|
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
|
||||||
position: [clampedX, clampedY, 0],
|
|
||||||
rotation: [0, itemRotation, 0],
|
|
||||||
side,
|
|
||||||
parentId: event.node.id,
|
|
||||||
wallId: event.node.id,
|
|
||||||
})
|
|
||||||
markWallDirty(currentWallId)
|
|
||||||
currentWallId = event.node.id
|
|
||||||
} else {
|
|
||||||
// Same wall: update Three.js mesh directly to avoid store churn
|
|
||||||
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
|
||||||
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
|
||||||
if (windowMesh) {
|
|
||||||
windowMesh.position.set(clampedX, clampedY, 0)
|
|
||||||
windowMesh.rotation.set(0, itemRotation, 0)
|
|
||||||
windowMesh.updateMatrixWorld(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useLiveTransforms.getState().set(movingWindowNode.id, {
|
|
||||||
position: [clampedX, clampedY, 0],
|
|
||||||
rotation: itemRotation,
|
|
||||||
})
|
|
||||||
markWallDirtyThrottled(event.node.id)
|
|
||||||
|
|
||||||
const valid = !hasWallChildOverlap(
|
|
||||||
event.node.id,
|
|
||||||
clampedX,
|
|
||||||
clampedY,
|
|
||||||
movingWindowNode.width,
|
|
||||||
movingWindowNode.height,
|
|
||||||
movingWindowNode.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
updateCursor(
|
|
||||||
wallLocalToWorld(
|
|
||||||
event.node,
|
|
||||||
clampedX,
|
|
||||||
clampedY,
|
|
||||||
getLevelYOffset(),
|
|
||||||
getSlabElevation(event),
|
|
||||||
),
|
|
||||||
cursorRotation,
|
|
||||||
valid,
|
|
||||||
)
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,34 +290,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
// Only interact with walls on the current level
|
// Only interact with walls on the current level
|
||||||
if (event.node.parentId !== getLevelId()) return
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
if (!target?.valid) return
|
||||||
|
|
||||||
const localX = resolveWallSlideAlignment({
|
|
||||||
wallNode: event.node,
|
|
||||||
rawLocalX: event.localPosition[0],
|
|
||||||
width: movingWindowNode.width,
|
|
||||||
candidates: alignmentCandidates,
|
|
||||||
bypass: event.nativeEvent?.altKey === true,
|
|
||||||
})
|
|
||||||
const localY = snapToHalf(event.localPosition[1])
|
|
||||||
const { clampedX, clampedY } = clampToWall(
|
|
||||||
event.node,
|
|
||||||
localX,
|
|
||||||
localY,
|
|
||||||
movingWindowNode.width,
|
|
||||||
movingWindowNode.height,
|
|
||||||
)
|
|
||||||
|
|
||||||
const valid = !hasWallChildOverlap(
|
|
||||||
event.node.id,
|
|
||||||
clampedX,
|
|
||||||
clampedY,
|
|
||||||
movingWindowNode.width,
|
|
||||||
movingWindowNode.height,
|
|
||||||
movingWindowNode.id,
|
|
||||||
)
|
|
||||||
if (!valid) return
|
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
|
|
||||||
@@ -341,13 +310,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
|
|
||||||
const node = WindowNode.parse({
|
const node = WindowNode.parse({
|
||||||
...cloned,
|
...cloned,
|
||||||
position: [clampedX, clampedY, 0],
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, target.itemRotation, 0],
|
||||||
side,
|
side: target.side,
|
||||||
wallId: event.node.id,
|
wallId: target.wallId,
|
||||||
parentId: event.node.id,
|
parentId: target.wallId,
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||||
placedId = node.id
|
placedId = node.id
|
||||||
} else {
|
} else {
|
||||||
// Move mode: restore original (clean baseline) + resume + updateNode
|
// Move mode: restore original (clean baseline) + resume + updateNode
|
||||||
@@ -363,21 +332,21 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
position: [clampedX, clampedY, 0],
|
position: [target.clampedX, target.clampedY, 0],
|
||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, target.itemRotation, 0],
|
||||||
side,
|
side: target.side,
|
||||||
parentId: event.node.id,
|
parentId: target.wallId,
|
||||||
wallId: event.node.id,
|
wallId: target.wallId,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (original.parentId && original.parentId !== event.node.id) {
|
if (original.parentId && original.parentId !== target.wallId) {
|
||||||
markWallDirty(original.parentId)
|
markWallDirty(original.parentId)
|
||||||
}
|
}
|
||||||
placedId = movingWindowNode.id
|
placedId = movingWindowNode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
markWallDirty(event.node.id)
|
markWallDirty(target.wallId)
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
@@ -391,6 +360,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
const onWallLeave = () => {
|
const onWallLeave = () => {
|
||||||
hideCursor()
|
hideCursor()
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
|
dragAnchor = null
|
||||||
|
lastTarget = null
|
||||||
if (isNew) return // No original to restore for duplicates
|
if (isNew) return // No original to restore for duplicates
|
||||||
// Move mode: restore to original position while off-wall
|
// Move mode: restore to original position while off-wall
|
||||||
if (currentWallId && currentWallId !== original.parentId) {
|
if (currentWallId && currentWallId !== original.parentId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user