feat(editor): live floor-stacking, unified handle system, slab-hole editing + interaction polish (#375)
- Live slab-stacking Y previews for all floor-placed kinds (item/shelf/spawn/column/stair) during placement + both move pathways, via a shared core resolver; canonical positions unchanged. - Unified 3D handle system (one drag pipeline + one visual primitive) with forgiving invisible hit-areas on every handle, kept on EDITOR_LAYER so they don't poison the MRT scene pass. - Hover + click-to-edit slab holes in 3D (manual hole -> hole editor; stair/elevator hole -> select owner); generic cross-arrow polygon-move grip; normalized handle interaction colors. - NaN-safe node mutations + non-finite shadow-light bounds guard. - Built on #373 (level-scoped alignment / registry slab tool); #373 owns X/Z alignment, this owns Y floor-stacking.
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import { nodeRegistry, registerNode } from '../../registry'
|
||||
import type { AnyNodeDefinition } from '../../registry/types'
|
||||
import type { AnyNode, SlabNode } from '../../schema'
|
||||
import { getFloorPlacedElevation, getFloorStackedPosition } from './floor-placed-elevation'
|
||||
import { spatialGridManager } from './spatial-grid-manager'
|
||||
|
||||
const LEVEL_ID = 'level_test'
|
||||
|
||||
function makeDefinition(
|
||||
kind: AnyNode['type'],
|
||||
capabilities: AnyNodeDefinition['capabilities'] = {},
|
||||
): AnyNodeDefinition {
|
||||
return {
|
||||
kind,
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal(kind) }) as never,
|
||||
category: 'utility',
|
||||
defaults: () => ({}) as never,
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
function makeLevel(): AnyNode {
|
||||
return {
|
||||
id: LEVEL_ID,
|
||||
type: 'level',
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
level: 0,
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
function makeFloorNode(overrides: Partial<AnyNode> = {}): AnyNode {
|
||||
return {
|
||||
id: 'item_test',
|
||||
type: 'item',
|
||||
object: 'node',
|
||||
parentId: LEVEL_ID,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
asset: {
|
||||
id: 'asset_test',
|
||||
category: 'test',
|
||||
name: 'Test',
|
||||
thumbnail: '',
|
||||
src: 'asset:test',
|
||||
dimensions: [1, 1, 1],
|
||||
source: 'library',
|
||||
},
|
||||
...overrides,
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab_${elevation}`) {
|
||||
const slab = {
|
||||
id,
|
||||
type: 'slab',
|
||||
object: 'node',
|
||||
parentId: LEVEL_ID,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
polygon,
|
||||
holes: [],
|
||||
holeMetadata: [],
|
||||
elevation,
|
||||
autoFromWalls: false,
|
||||
} as SlabNode
|
||||
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
|
||||
}
|
||||
|
||||
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
|
||||
return Object.fromEntries(nodes.map((node) => [node.id, node]))
|
||||
}
|
||||
|
||||
describe('floor-placed elevation resolver', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
spatialGridManager.clear()
|
||||
})
|
||||
|
||||
test('returns 0 without a floorPlaced capability', () => {
|
||||
registerNode(makeDefinition('item'))
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.4,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
test('returns 0 when applies returns false', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
|
||||
applies: () => false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.4,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
test('clamps non-finite slab elevation to 0', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const original = spatialGridManager.getSlabElevationForItem
|
||||
spatialGridManager.getSlabElevationForItem = (() => Number.NaN) as typeof original
|
||||
|
||||
try {
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBe(0)
|
||||
} finally {
|
||||
spatialGridManager.getSlabElevationForItem = original
|
||||
}
|
||||
})
|
||||
|
||||
test('returns 0 for a non-level direct parent', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.4,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const shelf = {
|
||||
id: 'shelf_test',
|
||||
type: 'shelf',
|
||||
parentId: LEVEL_ID,
|
||||
} as unknown as AnyNode
|
||||
const node = makeFloorNode({ parentId: shelf.id })
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, shelf, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
test('returns 0 when the declared parent is missing', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.4,
|
||||
)
|
||||
|
||||
const node = makeFloorNode({ parentId: 'missing_level' })
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
levelId: LEVEL_ID,
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
test('uses the pending rotated footprint', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: (node) => ({
|
||||
dimensions: [4, 1, 1],
|
||||
rotation: (node as { rotation: [number, number, number] }).rotation,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-0.2, 1.2],
|
||||
[0.2, 1.2],
|
||||
[0.2, 1.8],
|
||||
[-0.2, 1.8],
|
||||
],
|
||||
0.45,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, Math.PI / 2, 0],
|
||||
}),
|
||||
).toBeCloseTo(0.45)
|
||||
})
|
||||
|
||||
test('returns slab overlap elevation and stacks Y onto canonical position', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: (node) => ({
|
||||
dimensions: [1, 1, 1],
|
||||
rotation: (node as { rotation: [number, number, number] }).rotation,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.35,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0.1, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBeCloseTo(0.35)
|
||||
const stacked = getFloorStackedPosition({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0.1, 0],
|
||||
rotation: [0, 0, 0],
|
||||
})
|
||||
expect(stacked[0]).toBe(0)
|
||||
expect(stacked[1]).toBeCloseTo(0.45)
|
||||
expect(stacked[2]).toBe(0)
|
||||
})
|
||||
|
||||
test('takes the max elevation across composite footprints', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprints: () => [
|
||||
{ position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
|
||||
{ position: [3, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.2,
|
||||
'slab_low',
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[2.5, -0.5],
|
||||
[3.5, -0.5],
|
||||
[3.5, 0.5],
|
||||
[2.5, 0.5],
|
||||
],
|
||||
0.8,
|
||||
'slab_high',
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBeCloseTo(0.8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { nodeRegistry } from '../../registry'
|
||||
import type {
|
||||
FloorPlacedConfig,
|
||||
FloorPlacedFootprint,
|
||||
FloorPlacedFootprintContext,
|
||||
FloorPlacedFootprintsResolver,
|
||||
} from '../../registry/types'
|
||||
import type { AnyNode, AnyNodeId } from '../../schema'
|
||||
import { spatialGridManager } from './spatial-grid-manager'
|
||||
|
||||
export type FloorPlacedElevationArgs = {
|
||||
node: AnyNode
|
||||
nodes: Record<string, AnyNode>
|
||||
position: [number, number, number]
|
||||
rotation?: unknown
|
||||
levelId?: string | null
|
||||
}
|
||||
|
||||
function finiteSlabElevation(elevation: number): number {
|
||||
return Number.isFinite(elevation) ? elevation : 0
|
||||
}
|
||||
|
||||
function withPositionAndRotation({
|
||||
node,
|
||||
position,
|
||||
rotation,
|
||||
}: Pick<FloorPlacedElevationArgs, 'node' | 'position' | 'rotation'>): AnyNode {
|
||||
return {
|
||||
...(node as Record<string, unknown>),
|
||||
position,
|
||||
...(rotation !== undefined ? { rotation } : {}),
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
export function getFloorPlacedFootprints(
|
||||
floorPlaced: FloorPlacedConfig,
|
||||
node: AnyNode,
|
||||
ctx?: FloorPlacedFootprintContext,
|
||||
): FloorPlacedFootprint[] {
|
||||
const rawFootprints = floorPlaced.footprints?.(node, ctx)
|
||||
if (rawFootprints) return [...rawFootprints]
|
||||
|
||||
const footprint = floorPlaced.footprint?.(node, ctx)
|
||||
return footprint ? [footprint] : []
|
||||
}
|
||||
|
||||
export function getFloorPlacedElevation({
|
||||
node,
|
||||
nodes,
|
||||
position,
|
||||
rotation,
|
||||
levelId,
|
||||
}: FloorPlacedElevationArgs): number {
|
||||
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
||||
if (!floorPlaced) return 0
|
||||
|
||||
const effectiveNode = withPositionAndRotation({ node, position, rotation })
|
||||
if (floorPlaced.applies && !floorPlaced.applies(effectiveNode)) return 0
|
||||
|
||||
const parentId = (effectiveNode as { parentId?: AnyNodeId | null }).parentId ?? null
|
||||
const parent = parentId ? nodes[parentId] : null
|
||||
if (parentId && !parent) return 0
|
||||
if (parent && parent.type !== 'level') return 0
|
||||
if (!parent && !levelId) return 0
|
||||
|
||||
const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId
|
||||
if (!resolvedLevelId) return 0
|
||||
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) {
|
||||
const footprintPosition = footprint.position ?? position
|
||||
const elevation = finiteSlabElevation(
|
||||
spatialGridManager.getSlabElevationForItem(
|
||||
resolvedLevelId,
|
||||
footprintPosition,
|
||||
footprint.dimensions,
|
||||
footprint.rotation,
|
||||
),
|
||||
)
|
||||
if (elevation > maxElevation) {
|
||||
maxElevation = elevation
|
||||
}
|
||||
}
|
||||
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
}
|
||||
|
||||
export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] {
|
||||
const [x, y, z] = args.position
|
||||
return [x, y + getFloorPlacedElevation(args), z]
|
||||
}
|
||||
|
||||
export type { FloorPlacedFootprint, FloorPlacedFootprintContext, FloorPlacedFootprintsResolver }
|
||||
@@ -1,6 +1,7 @@
|
||||
import { nodeRegistry } from '../../registry'
|
||||
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { getFloorPlacedFootprints } from './floor-placed-elevation'
|
||||
import {
|
||||
itemOverlapsPolygon,
|
||||
spatialGridManager,
|
||||
@@ -152,7 +153,7 @@ function arraysEqual(a: number[], b: number[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all floor items, walls, and stairs that may be affected by a slab change as dirty.
|
||||
* Mark all floor items and walls that may be affected by a slab change as dirty.
|
||||
*/
|
||||
function markNodesOverlappingSlab(
|
||||
slab: SlabNode,
|
||||
@@ -181,12 +182,6 @@ function markNodesOverlappingSlab(
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (node.type === 'stair') {
|
||||
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||
markDirty(node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generic floor-placed sweep: any registry kind that opts in via
|
||||
// `capabilities.floorPlaced` (item / shelf / column / spawn / …)
|
||||
// re-elevates through `<FloorElevationSystem>` when a slab below
|
||||
@@ -196,12 +191,25 @@ function markNodesOverlappingSlab(
|
||||
const floorPlaced = def?.capabilities?.floorPlaced
|
||||
if (!floorPlaced) continue
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node)) continue
|
||||
const parentId = node.parentId as AnyNodeId | null
|
||||
const parent = parentId ? nodes[parentId] : null
|
||||
if (parent && parent.type !== 'level') continue
|
||||
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||
const position = (node as { position?: [number, number, number] }).position
|
||||
if (!position) continue
|
||||
const { dimensions, rotation } = floorPlaced.footprint(node)
|
||||
if (itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
||||
for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
|
||||
if (
|
||||
itemOverlapsPolygon(
|
||||
footprint.position ?? position,
|
||||
footprint.dimensions,
|
||||
footprint.rotation,
|
||||
slab.polygon,
|
||||
0.01,
|
||||
)
|
||||
) {
|
||||
markDirty(node.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ export {
|
||||
sceneRegistry,
|
||||
useRegistry,
|
||||
} from './hooks/scene-registry/scene-registry'
|
||||
export {
|
||||
type FloorPlacedElevationArgs,
|
||||
getFloorPlacedElevation,
|
||||
getFloorPlacedFootprints,
|
||||
getFloorStackedPosition,
|
||||
} from './hooks/spatial-grid/floor-placed-elevation'
|
||||
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||
export {
|
||||
initSpatialGridSync,
|
||||
@@ -79,6 +85,12 @@ export {
|
||||
type MaterialCategory,
|
||||
toLibraryMaterialRef,
|
||||
} from './material-library'
|
||||
export type {
|
||||
FloorPlacedFootprint,
|
||||
FloorPlacedFootprintContext,
|
||||
FloorPlacedFootprintResolver,
|
||||
FloorPlacedFootprintsResolver,
|
||||
} from './registry'
|
||||
export * from './registry'
|
||||
export * from './schema'
|
||||
export * from './services'
|
||||
|
||||
@@ -346,8 +346,10 @@ export type TranslateHandle<N = any> = {
|
||||
* `[alongX, alongOther]` — `alongOther` is Z for the 'horizontal' plane and
|
||||
* Y for 'node-normal'. Used to align the node's edges to the grid (rotation-
|
||||
* aware: swap the pair at 90°). Omit / return null for free movement.
|
||||
* `sceneApi` is supplied for composite nodes whose footprint depends on
|
||||
* children, such as straight stairs.
|
||||
*/
|
||||
snapExtents?: (node: N) => readonly [number, number] | null
|
||||
snapExtents?: (node: N, sceneApi: SceneApi) => readonly [number, number] | null
|
||||
portal?: HandlePortal
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,11 @@ export type {
|
||||
CuttableConfig,
|
||||
DragAction,
|
||||
EditorCtx,
|
||||
FloorPlacedConfig,
|
||||
FloorPlacedFootprint,
|
||||
FloorPlacedFootprintContext,
|
||||
FloorPlacedFootprintResolver,
|
||||
FloorPlacedFootprintsResolver,
|
||||
FloorplanAffordance,
|
||||
FloorplanAffordanceModifiers,
|
||||
FloorplanAffordancePoint,
|
||||
|
||||
@@ -1235,20 +1235,40 @@ export type SelectableConfig = {
|
||||
override?: (ctx: CapabilityCtx) => SelectableConfig | null
|
||||
}
|
||||
|
||||
export type FloorPlacedFootprint = {
|
||||
dimensions: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
position?: [number, number, number]
|
||||
}
|
||||
|
||||
export type FloorPlacedFootprintContext = {
|
||||
nodes: Readonly<Record<AnyNodeId, AnyNode>>
|
||||
}
|
||||
|
||||
export type FloorPlacedFootprintResolver = (
|
||||
node: AnyNode,
|
||||
ctx?: FloorPlacedFootprintContext,
|
||||
) => FloorPlacedFootprint
|
||||
|
||||
export type FloorPlacedFootprintsResolver = (
|
||||
node: AnyNode,
|
||||
ctx?: FloorPlacedFootprintContext,
|
||||
) => readonly FloorPlacedFootprint[]
|
||||
|
||||
/**
|
||||
* Floor-placed kinds rest directly on a level and need their Y lifted by
|
||||
* any slab the footprint overlaps. The generic `<FloorElevationSystem>`
|
||||
* computes `slabElevation + node.position[1]` and writes it onto the
|
||||
* registered mesh on every dirty mark. `footprint` returns the world-space
|
||||
* footprint the spatial-grid manager uses to find overlapping slabs;
|
||||
* registered mesh on every dirty mark. `footprint` returns the default
|
||||
* world-space footprint the spatial-grid manager uses to find overlapping
|
||||
* slabs; `footprints` lets composite kinds expose multiple footprint
|
||||
* segments, with the canonical resolver taking the max slab elevation;
|
||||
* `applies` is an optional predicate to skip nodes that share a kind but
|
||||
* are mounted off-floor (items attached to a wall / ceiling).
|
||||
*/
|
||||
export type FloorPlacedConfig = {
|
||||
footprint: (node: AnyNode) => {
|
||||
dimensions: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
}
|
||||
footprint?: FloorPlacedFootprintResolver
|
||||
footprints?: FloorPlacedFootprintsResolver
|
||||
applies?: (node: AnyNode) => boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,10 @@ function floorFootprint(
|
||||
): { dimensions: [number, number, number]; rotation: [number, number, number] } | null {
|
||||
const capabilities = nodeRegistry.get(node.type)?.capabilities
|
||||
const floorPlaced = capabilities?.floorPlaced
|
||||
if (floorPlaced) {
|
||||
// `footprint` is optional now that floor-placed kinds may instead declare
|
||||
// composite `footprints` (e.g. stairs); those have no single centred box
|
||||
// here, so fall through to `alignmentFootprint`.
|
||||
if (floorPlaced?.footprint) {
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node)) return null
|
||||
return floorPlaced.footprint(node)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
AnyNode as AnyNodeSchema,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getWallSurfaceMaterialSignature,
|
||||
type WallNode,
|
||||
@@ -22,6 +23,478 @@ type WallMergePlan = {
|
||||
attachmentUpdates: WallAttachmentUpdate[]
|
||||
}
|
||||
|
||||
type ZodCheckLike = {
|
||||
_zod?: {
|
||||
def?: {
|
||||
check?: string
|
||||
value?: unknown
|
||||
inclusive?: boolean
|
||||
format?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ZodSchemaDefLike = {
|
||||
type?: string
|
||||
innerType?: ZodSchemaLike
|
||||
shape?: Record<string, ZodSchemaLike>
|
||||
options?: readonly ZodSchemaLike[]
|
||||
items?: readonly ZodSchemaLike[]
|
||||
rest?: ZodSchemaLike | null
|
||||
element?: ZodSchemaLike
|
||||
checks?: readonly ZodCheckLike[]
|
||||
defaultValue?: unknown
|
||||
values?: readonly unknown[]
|
||||
entries?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ZodSchemaLike = {
|
||||
_zod?: {
|
||||
def?: ZodSchemaDefLike
|
||||
bag?: {
|
||||
minimum?: number
|
||||
maximum?: number
|
||||
exclusiveMinimum?: number
|
||||
exclusiveMaximum?: number
|
||||
}
|
||||
values?: Set<unknown>
|
||||
}
|
||||
def?: ZodSchemaDefLike
|
||||
shape?: Record<string, ZodSchemaLike>
|
||||
minValue?: number | null
|
||||
maxValue?: number | null
|
||||
}
|
||||
|
||||
type NumericLimit = {
|
||||
value: number
|
||||
inclusive: boolean
|
||||
}
|
||||
|
||||
type NumericConstraints = {
|
||||
min?: NumericLimit
|
||||
max?: NumericLimit
|
||||
integer: boolean
|
||||
}
|
||||
|
||||
type NumericSanitizeIssue = {
|
||||
path: PropertyKey[]
|
||||
from: number
|
||||
to?: number
|
||||
action: 'clamped' | 'dropped' | 'rounded'
|
||||
}
|
||||
|
||||
type NumericSanitizeResult = {
|
||||
value: unknown
|
||||
issues: NumericSanitizeIssue[]
|
||||
omit?: boolean
|
||||
}
|
||||
|
||||
const NUMBER_FORMAT_BOUNDS: Record<string, [number, number] | undefined> = {
|
||||
safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
|
||||
int32: [-2147483648, 2147483647],
|
||||
uint32: [0, 4294967295],
|
||||
float32: [-3.4028234663852886e38, 3.4028234663852886e38],
|
||||
}
|
||||
|
||||
const INTEGER_NUMBER_FORMATS = new Set(['safeint', 'int32', 'uint32'])
|
||||
|
||||
function getSchemaDef(schema: ZodSchemaLike | null | undefined): ZodSchemaDefLike | undefined {
|
||||
return schema?._zod?.def ?? schema?.def
|
||||
}
|
||||
|
||||
function getSchemaDefault(schema: ZodSchemaLike): unknown {
|
||||
const def = getSchemaDef(schema)
|
||||
if (!(def?.type === 'default' || def?.type === 'prefault')) return undefined
|
||||
return def.defaultValue
|
||||
}
|
||||
|
||||
function unwrapSchema(schema: ZodSchemaLike | null | undefined): ZodSchemaLike | null {
|
||||
let current = schema ?? null
|
||||
while (current) {
|
||||
const def = getSchemaDef(current)
|
||||
if (
|
||||
!(
|
||||
def?.type === 'default' ||
|
||||
def?.type === 'prefault' ||
|
||||
def?.type === 'optional' ||
|
||||
def?.type === 'nullable' ||
|
||||
def?.type === 'catch' ||
|
||||
def?.type === 'readonly' ||
|
||||
def?.type === 'nonoptional'
|
||||
)
|
||||
) {
|
||||
return current
|
||||
}
|
||||
current = def.innerType ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getObjectShape(schema: ZodSchemaLike | null | undefined) {
|
||||
const unwrapped = unwrapSchema(schema)
|
||||
const def = getSchemaDef(unwrapped)
|
||||
if (def?.type !== 'object') return null
|
||||
|
||||
return unwrapped?.shape ?? def.shape ?? null
|
||||
}
|
||||
|
||||
function schemaAllowsValue(schema: ZodSchemaLike | null | undefined, value: unknown): boolean {
|
||||
const unwrapped = unwrapSchema(schema)
|
||||
if (!unwrapped) return false
|
||||
|
||||
const def = getSchemaDef(unwrapped)
|
||||
if (unwrapped._zod?.values?.has(value)) return true
|
||||
if (Array.isArray(def?.values) && def.values.includes(value)) return true
|
||||
if (def?.entries && Object.values(def.entries).includes(value)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getNodeSchemaForType(type: unknown): ZodSchemaLike | null {
|
||||
const schema = AnyNodeSchema as unknown as ZodSchemaLike
|
||||
const options = getSchemaDef(schema)?.options
|
||||
if (!options) return null
|
||||
|
||||
for (const option of options) {
|
||||
const shape = getObjectShape(option)
|
||||
if (shape?.type && schemaAllowsValue(shape.type, type)) {
|
||||
return option
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function applyLowerLimit(current: NumericLimit | undefined, candidate: NumericLimit): NumericLimit {
|
||||
if (!current) return candidate
|
||||
if (candidate.value > current.value) return candidate
|
||||
if (candidate.value === current.value && !candidate.inclusive) return candidate
|
||||
return current
|
||||
}
|
||||
|
||||
function applyUpperLimit(current: NumericLimit | undefined, candidate: NumericLimit): NumericLimit {
|
||||
if (!current) return candidate
|
||||
if (candidate.value < current.value) return candidate
|
||||
if (candidate.value === current.value && !candidate.inclusive) return candidate
|
||||
return current
|
||||
}
|
||||
|
||||
function getNumberConstraints(schema: ZodSchemaLike): NumericConstraints {
|
||||
const unwrapped = unwrapSchema(schema) ?? schema
|
||||
const def = getSchemaDef(unwrapped)
|
||||
const constraints: NumericConstraints = { integer: false }
|
||||
|
||||
const minValue = unwrapped.minValue
|
||||
if (typeof minValue === 'number') {
|
||||
constraints.min = applyLowerLimit(constraints.min, { value: minValue, inclusive: true })
|
||||
}
|
||||
|
||||
const maxValue = unwrapped.maxValue
|
||||
if (typeof maxValue === 'number') {
|
||||
constraints.max = applyUpperLimit(constraints.max, { value: maxValue, inclusive: true })
|
||||
}
|
||||
|
||||
for (const check of def?.checks ?? []) {
|
||||
const checkDef = check._zod?.def
|
||||
if (!checkDef) continue
|
||||
|
||||
if (checkDef.check === 'greater_than' && typeof checkDef.value === 'number') {
|
||||
constraints.min = applyLowerLimit(constraints.min, {
|
||||
value: checkDef.value,
|
||||
inclusive: checkDef.inclusive !== false,
|
||||
})
|
||||
} else if (checkDef.check === 'less_than' && typeof checkDef.value === 'number') {
|
||||
constraints.max = applyUpperLimit(constraints.max, {
|
||||
value: checkDef.value,
|
||||
inclusive: checkDef.inclusive !== false,
|
||||
})
|
||||
} else if (checkDef.check === 'number_format' && checkDef.format) {
|
||||
constraints.integer ||= INTEGER_NUMBER_FORMATS.has(checkDef.format)
|
||||
const bounds = NUMBER_FORMAT_BOUNDS[checkDef.format]
|
||||
if (bounds) {
|
||||
constraints.min = applyLowerLimit(constraints.min, {
|
||||
value: bounds[0],
|
||||
inclusive: true,
|
||||
})
|
||||
constraints.max = applyUpperLimit(constraints.max, {
|
||||
value: bounds[1],
|
||||
inclusive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bag = unwrapped._zod?.bag
|
||||
if (typeof bag?.minimum === 'number') {
|
||||
constraints.min = applyLowerLimit(constraints.min, { value: bag.minimum, inclusive: true })
|
||||
}
|
||||
if (typeof bag?.exclusiveMinimum === 'number') {
|
||||
constraints.min = applyLowerLimit(constraints.min, {
|
||||
value: bag.exclusiveMinimum,
|
||||
inclusive: false,
|
||||
})
|
||||
}
|
||||
if (typeof bag?.maximum === 'number') {
|
||||
constraints.max = applyUpperLimit(constraints.max, { value: bag.maximum, inclusive: true })
|
||||
}
|
||||
if (typeof bag?.exclusiveMaximum === 'number') {
|
||||
constraints.max = applyUpperLimit(constraints.max, {
|
||||
value: bag.exclusiveMaximum,
|
||||
inclusive: false,
|
||||
})
|
||||
}
|
||||
|
||||
return constraints
|
||||
}
|
||||
|
||||
function nextAbove(value: number) {
|
||||
return value === 0 ? Number.EPSILON : value + Math.abs(value) * Number.EPSILON
|
||||
}
|
||||
|
||||
function nextBelow(value: number) {
|
||||
return value === 0 ? -Number.EPSILON : value - Math.abs(value) * Number.EPSILON
|
||||
}
|
||||
|
||||
function clampNumber(value: number, constraints: NumericConstraints) {
|
||||
let next = value
|
||||
let rounded = false
|
||||
let clamped = false
|
||||
|
||||
if (constraints.integer && !Number.isInteger(next)) {
|
||||
next = Math.round(next)
|
||||
rounded = true
|
||||
}
|
||||
|
||||
if (constraints.min) {
|
||||
const min = constraints.min.inclusive ? constraints.min.value : nextAbove(constraints.min.value)
|
||||
if (next < min) {
|
||||
next = min
|
||||
clamped = true
|
||||
}
|
||||
}
|
||||
|
||||
if (constraints.max) {
|
||||
const max = constraints.max.inclusive ? constraints.max.value : nextBelow(constraints.max.value)
|
||||
if (next > max) {
|
||||
next = max
|
||||
clamped = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: next,
|
||||
action: clamped ? 'clamped' : rounded ? 'rounded' : undefined,
|
||||
} satisfies { value: number; action?: NumericSanitizeIssue['action'] }
|
||||
}
|
||||
|
||||
function getFiniteFallbackNumber(fallback: unknown, constraints: NumericConstraints) {
|
||||
if (typeof fallback !== 'number' || !Number.isFinite(fallback)) return undefined
|
||||
return clampNumber(fallback, constraints).value
|
||||
}
|
||||
|
||||
function sanitizeNumber(
|
||||
schema: ZodSchemaLike | null,
|
||||
value: number,
|
||||
fallback: unknown,
|
||||
path: PropertyKey[],
|
||||
): NumericSanitizeResult {
|
||||
const constraints = schema ? getNumberConstraints(schema) : { integer: false }
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
const replacement = getFiniteFallbackNumber(fallback, constraints)
|
||||
if (replacement === undefined) {
|
||||
return {
|
||||
value,
|
||||
omit: true,
|
||||
issues: [{ path, from: value, action: 'dropped' }],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: replacement,
|
||||
issues: [{ path, from: value, to: replacement, action: 'dropped' }],
|
||||
}
|
||||
}
|
||||
|
||||
const clamped = clampNumber(value, constraints)
|
||||
if (!Object.is(clamped.value, value)) {
|
||||
return {
|
||||
value: clamped.value,
|
||||
issues: [
|
||||
{
|
||||
path,
|
||||
from: value,
|
||||
to: clamped.value,
|
||||
action: clamped.action ?? 'clamped',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return { value, issues: [] }
|
||||
}
|
||||
|
||||
function sanitizeNumericValue(
|
||||
schema: ZodSchemaLike | null,
|
||||
value: unknown,
|
||||
fallback: unknown,
|
||||
path: PropertyKey[],
|
||||
): NumericSanitizeResult {
|
||||
const defaultFallback = schema ? getSchemaDefault(schema) : undefined
|
||||
const effectiveFallback = fallback === undefined ? defaultFallback : fallback
|
||||
const unwrapped = unwrapSchema(schema)
|
||||
const def = getSchemaDef(unwrapped)
|
||||
|
||||
if (def?.type === 'number') {
|
||||
if (typeof value !== 'number') return { value, issues: [] }
|
||||
return sanitizeNumber(unwrapped, value, effectiveFallback, path)
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return sanitizeNumber(null, value, effectiveFallback, path)
|
||||
}
|
||||
|
||||
if (def?.type === 'tuple' && Array.isArray(value)) {
|
||||
const fallbackItems = Array.isArray(effectiveFallback) ? effectiveFallback : []
|
||||
const next = [...value]
|
||||
const issues: NumericSanitizeIssue[] = []
|
||||
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
const itemSchema = def.items?.[index] ?? def.rest ?? null
|
||||
const child = sanitizeNumericValue(itemSchema, next[index], fallbackItems[index], [
|
||||
...path,
|
||||
index,
|
||||
])
|
||||
issues.push(...child.issues)
|
||||
|
||||
if (child.omit) {
|
||||
return { value, omit: true, issues }
|
||||
}
|
||||
|
||||
next[index] = child.value
|
||||
}
|
||||
|
||||
return { value: issues.length > 0 ? next : value, issues }
|
||||
}
|
||||
|
||||
if (def?.type === 'array' && Array.isArray(value)) {
|
||||
const fallbackItems = Array.isArray(effectiveFallback) ? effectiveFallback : []
|
||||
const next: unknown[] = []
|
||||
const issues: NumericSanitizeIssue[] = []
|
||||
let omitted = false
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const child = sanitizeNumericValue(def.element ?? null, value[index], fallbackItems[index], [
|
||||
...path,
|
||||
index,
|
||||
])
|
||||
issues.push(...child.issues)
|
||||
if (child.omit) {
|
||||
omitted = true
|
||||
continue
|
||||
}
|
||||
next.push(child.value)
|
||||
}
|
||||
|
||||
return { value: issues.length > 0 || omitted ? next : value, issues }
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const shape = def?.type === 'object' ? (unwrapped?.shape ?? def.shape ?? {}) : {}
|
||||
const fallbackObject =
|
||||
effectiveFallback &&
|
||||
typeof effectiveFallback === 'object' &&
|
||||
!Array.isArray(effectiveFallback)
|
||||
? (effectiveFallback as Record<string, unknown>)
|
||||
: {}
|
||||
const input = value as Record<string, unknown>
|
||||
const next: Record<string, unknown> = { ...input }
|
||||
const issues: NumericSanitizeIssue[] = []
|
||||
|
||||
for (const key of Object.keys(input)) {
|
||||
const child = sanitizeNumericValue(shape[key] ?? null, input[key], fallbackObject[key], [
|
||||
...path,
|
||||
key,
|
||||
])
|
||||
issues.push(...child.issues)
|
||||
|
||||
if (child.omit) {
|
||||
delete next[key]
|
||||
} else {
|
||||
next[key] = child.value
|
||||
}
|
||||
}
|
||||
|
||||
return { value: issues.length > 0 ? next : value, issues }
|
||||
}
|
||||
|
||||
return { value, issues: [] }
|
||||
}
|
||||
|
||||
function formatNumericValue(value: number) {
|
||||
if (Number.isNaN(value)) return 'NaN'
|
||||
if (value === Infinity) return 'Infinity'
|
||||
if (value === -Infinity) return '-Infinity'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function numericSanitizeIssuesToMessage(issues: NumericSanitizeIssue[]): string {
|
||||
return issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.map(String).join('.') || '<root>'
|
||||
const to = issue.to === undefined ? '' : ` -> ${formatNumericValue(issue.to)}`
|
||||
return `${path}: ${formatNumericValue(issue.from)} ${issue.action}${to}`
|
||||
})
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
function warnSanitizedNodeMutation(
|
||||
mutation: 'create' | 'update',
|
||||
nodeId: AnyNodeId,
|
||||
issues: NumericSanitizeIssue[],
|
||||
) {
|
||||
console.warn(
|
||||
`[Scene] Sanitized invalid numeric node ${mutation}`,
|
||||
nodeId,
|
||||
numericSanitizeIssuesToMessage(issues),
|
||||
)
|
||||
}
|
||||
|
||||
function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode {
|
||||
const candidate = { ...node, parentId }
|
||||
const parsed = AnyNodeSchema.safeParse(candidate)
|
||||
if (parsed.success) return parsed.data
|
||||
|
||||
const schema = getNodeSchemaForType(candidate.type)
|
||||
const sanitized = sanitizeNumericValue(schema, candidate, undefined, [])
|
||||
|
||||
if (sanitized.issues.length === 0) {
|
||||
return candidate as AnyNode
|
||||
}
|
||||
|
||||
warnSanitizedNodeMutation('create', node.id, sanitized.issues)
|
||||
|
||||
return sanitized.value as AnyNode
|
||||
}
|
||||
|
||||
function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode {
|
||||
const candidate = { ...currentNode, ...data }
|
||||
const parsed = AnyNodeSchema.safeParse(candidate)
|
||||
if (parsed.success) return parsed.data
|
||||
|
||||
const schema = getNodeSchemaForType(candidate.type)
|
||||
const sanitized = sanitizeNumericValue(schema, data, currentNode, [])
|
||||
|
||||
if (sanitized.issues.length === 0) {
|
||||
return candidate as AnyNode
|
||||
}
|
||||
|
||||
warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues)
|
||||
|
||||
return { ...currentNode, ...(sanitized.value as Partial<AnyNode>) } as AnyNode
|
||||
}
|
||||
|
||||
// Track pending RAF for updateNodesAction to prevent multiple queued callbacks
|
||||
let pendingRafId: number | null = null
|
||||
let pendingUpdates: Set<AnyNodeId> = new Set()
|
||||
@@ -245,11 +718,7 @@ export const createNodesAction = (
|
||||
for (const { node, parentId } of ops) {
|
||||
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
||||
|
||||
// 1. Assign parentId to the child (Safe because BaseNode has parentId)
|
||||
const newNode = {
|
||||
...node,
|
||||
parentId: effectiveParentId,
|
||||
}
|
||||
const newNode = parseCreatedNode(node, effectiveParentId)
|
||||
|
||||
nextNodes[newNode.id] = newNode
|
||||
|
||||
@@ -313,6 +782,7 @@ export const applyNodeChangesAction = (
|
||||
for (const { id, data } of updateOps) {
|
||||
const currentNode = nextNodes[id]
|
||||
if (!currentNode) continue
|
||||
const updatedNode = parseUpdatedNode(currentNode, data)
|
||||
|
||||
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
||||
const oldParentId = currentNode.parentId as AnyNodeId | null
|
||||
@@ -336,16 +806,13 @@ export const applyNodeChangesAction = (
|
||||
}
|
||||
}
|
||||
|
||||
nextNodes[id] = { ...currentNode, ...data } as AnyNode
|
||||
nextNodes[id] = updatedNode
|
||||
nodesToMarkDirty.add(id)
|
||||
}
|
||||
|
||||
for (const { node, parentId } of createOps) {
|
||||
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
||||
const newNode = {
|
||||
...node,
|
||||
parentId: effectiveParentId,
|
||||
} as AnyNode
|
||||
const newNode = parseCreatedNode(node, effectiveParentId)
|
||||
|
||||
nextNodes[newNode.id as AnyNodeId] = newNode
|
||||
nodesToMarkDirty.add(newNode.id as AnyNodeId)
|
||||
@@ -442,6 +909,7 @@ export const updateNodesAction = (
|
||||
for (const { id, data } of updates) {
|
||||
const currentNode = nextNodes[id]
|
||||
if (!currentNode) continue
|
||||
const updatedNode = parseUpdatedNode(currentNode, data)
|
||||
|
||||
// Handle Reparenting Logic
|
||||
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
||||
@@ -480,7 +948,7 @@ export const updateNodesAction = (
|
||||
}
|
||||
|
||||
// Apply the update
|
||||
nextNodes[id] = { ...nextNodes[id], ...data } as AnyNode
|
||||
nextNodes[id] = updatedNode
|
||||
}
|
||||
|
||||
return { nodes: nextNodes }
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode, AnyNodeId } from '../../schema/types'
|
||||
import useScene from '../use-scene'
|
||||
|
||||
type RafFn = (cb: (t: number) => void) => number
|
||||
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
|
||||
cb: (t: number) => void,
|
||||
) => {
|
||||
cb(0)
|
||||
return 0
|
||||
}) as RafFn
|
||||
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
|
||||
() => {}
|
||||
|
||||
const SHELF_ID = 'shelf_sanitize' as AnyNodeId
|
||||
const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId
|
||||
|
||||
function makeShelf(overrides: Partial<AnyNode> = {}): AnyNode {
|
||||
return {
|
||||
id: SHELF_ID,
|
||||
type: 'shelf',
|
||||
parentId: null,
|
||||
object: 'node',
|
||||
visible: true,
|
||||
name: 'Shelf',
|
||||
metadata: {},
|
||||
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',
|
||||
...overrides,
|
||||
} as unknown as AnyNode
|
||||
}
|
||||
|
||||
function makeSolarPanel(): AnyNode {
|
||||
return {
|
||||
id: SOLAR_PANEL_ID,
|
||||
type: 'solar-panel',
|
||||
parentId: null,
|
||||
object: 'node',
|
||||
visible: true,
|
||||
name: 'Panel',
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
rows: 2,
|
||||
columns: 3,
|
||||
panelWidth: 1,
|
||||
panelHeight: 1.65,
|
||||
gapX: 0.02,
|
||||
gapY: 0.02,
|
||||
mountingType: 'flush',
|
||||
tiltAngle: 15,
|
||||
standoffHeight: 0.05,
|
||||
frameThickness: 0.04,
|
||||
frameDepth: 0.04,
|
||||
} as unknown as AnyNode
|
||||
}
|
||||
|
||||
function shelf() {
|
||||
return useScene.getState().nodes[SHELF_ID] as Extract<AnyNode, { type: 'shelf' }>
|
||||
}
|
||||
|
||||
describe('node mutation numeric sanitization', () => {
|
||||
beforeEach(() => {
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[SHELF_ID]: makeShelf(),
|
||||
[SOLAR_PANEL_ID]: makeSolarPanel(),
|
||||
},
|
||||
rootNodeIds: [SHELF_ID, SOLAR_PANEL_ID],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
})
|
||||
|
||||
test('drops NaN numeric updates while preserving other fields in the patch', () => {
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
thickness: Number.NaN,
|
||||
name: 'Renamed after NaN',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
expect(shelf().thickness).toBe(0.04)
|
||||
expect(Number.isFinite(shelf().thickness)).toBe(true)
|
||||
expect(shelf().name).toBe('Renamed after NaN')
|
||||
})
|
||||
|
||||
test('drops Infinity numeric updates while preserving later normal updates', () => {
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
width: Infinity,
|
||||
name: 'Renamed after Infinity',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
expect(shelf().width).toBe(1.2)
|
||||
expect(Number.isFinite(shelf().width)).toBe(true)
|
||||
expect(shelf().name).toBe('Renamed after Infinity')
|
||||
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
name: 'Clean rename',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
expect(shelf().name).toBe('Clean rename')
|
||||
})
|
||||
|
||||
test('clamps out-of-range numeric updates to the node schema bounds', () => {
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
width: 99,
|
||||
thickness: -1,
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
expect(shelf().width).toBe(3)
|
||||
expect(shelf().thickness).toBe(0.01)
|
||||
})
|
||||
|
||||
test('preserves extra fields while sanitizing numeric updates', () => {
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[SHELF_ID]: {
|
||||
...makeShelf(),
|
||||
legacyField: 'current',
|
||||
} as unknown as AnyNode,
|
||||
},
|
||||
rootNodeIds: [SHELF_ID],
|
||||
} as never)
|
||||
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
width: Infinity,
|
||||
legacyPatch: 'patch',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
const node = useScene.getState().nodes[SHELF_ID] as Record<string, unknown>
|
||||
expect(node.width).toBe(1.2)
|
||||
expect(node.legacyField).toBe('current')
|
||||
expect(node.legacyPatch).toBe('patch')
|
||||
})
|
||||
|
||||
test('allows non-canonical ids to receive updates', () => {
|
||||
useScene.getState().updateNode(SOLAR_PANEL_ID, {
|
||||
name: 'Updated panel',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
const panel = useScene.getState().nodes[SOLAR_PANEL_ID] as { name?: string }
|
||||
expect(panel.name).toBe('Updated panel')
|
||||
})
|
||||
|
||||
test('sanitizes non-finite numeric values during create', () => {
|
||||
const createdId = 'shelf_created' as AnyNodeId
|
||||
|
||||
useScene.getState().createNode(
|
||||
makeShelf({
|
||||
id: createdId,
|
||||
width: Infinity,
|
||||
thickness: Number.NaN,
|
||||
} as Partial<AnyNode>),
|
||||
)
|
||||
|
||||
const created = useScene.getState().nodes[createdId] as Extract<AnyNode, { type: 'shelf' }>
|
||||
expect(created.width).toBe(1.2)
|
||||
expect(created.thickness).toBe(0.04)
|
||||
expect(Number.isFinite(created.width)).toBe(true)
|
||||
expect(Number.isFinite(created.thickness)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type RoofSegmentNode,
|
||||
type RoofType,
|
||||
} from '../schema/nodes/roof-segment'
|
||||
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
|
||||
import { SiteNode } from '../schema/nodes/site'
|
||||
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
|
||||
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
||||
@@ -24,6 +25,11 @@ function getFiniteNumber(value: unknown, fallback: number) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
function getFiniteNumberInRange(value: unknown, fallback: number, min: number, max: number) {
|
||||
const finite = getFiniteNumber(value, fallback)
|
||||
return Math.min(Math.max(finite, min), max)
|
||||
}
|
||||
|
||||
function getBoolean(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback
|
||||
}
|
||||
@@ -112,6 +118,37 @@ function normalizeDoorNode(node: Record<string, unknown>) {
|
||||
return parsed.success ? { ...node, ...parsed.data } : null
|
||||
}
|
||||
|
||||
function normalizeShelfNode(node: Record<string, unknown>) {
|
||||
const sanitized = {
|
||||
...node,
|
||||
children: getStringArray(node.children),
|
||||
position: getVector3(node.position, [0, 0, 0]),
|
||||
rotation: getVector3(node.rotation, [0, 0, 0]),
|
||||
width: getFiniteNumberInRange(node.width, 1.2, 0.3, 3.0),
|
||||
depth: getFiniteNumberInRange(node.depth, 0.3, 0.1, 1.0),
|
||||
thickness: getFiniteNumberInRange(node.thickness, 0.04, 0.01, 0.1),
|
||||
height: getFiniteNumberInRange(node.height, 0.9, 0.05, 2.5),
|
||||
rows: Math.round(getFiniteNumberInRange(node.rows, 1, 1, 8)),
|
||||
columns: Math.round(getFiniteNumberInRange(node.columns, 1, 1, 6)),
|
||||
style: getEnumValue(
|
||||
node.style,
|
||||
['wall-shelf', 'bookshelf', 'open-rack', 'cubby'] as const,
|
||||
'wall-shelf',
|
||||
),
|
||||
withBack: getBoolean(node.withBack, false),
|
||||
withSides: getBoolean(node.withSides, true),
|
||||
withBottom: getBoolean(node.withBottom, false),
|
||||
bracketStyle: getEnumValue(
|
||||
node.bracketStyle,
|
||||
['minimal', 'industrial', 'hidden'] as const,
|
||||
'minimal',
|
||||
),
|
||||
}
|
||||
|
||||
const parsed = ShelfNodeSchema.safeParse(sanitized)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||
const hasInterior =
|
||||
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
|
||||
@@ -396,14 +433,11 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
|
||||
}
|
||||
|
||||
// Shelf v2: hosting was added in this migration cycle. Older shelves
|
||||
// (saved before the schema gained `children`) need the field
|
||||
// initialised so `createNode(item, shelfId)` finds an array to
|
||||
// append the child id to — without this the host item ends up
|
||||
// orphaned (parented in scene state but not in the shelf's
|
||||
// children list, so the renderer doesn't mount it).
|
||||
if (node.type === 'shelf' && !Array.isArray(node.children)) {
|
||||
patchedNodes[id] = { ...node, children: [] }
|
||||
if (node.type === 'shelf') {
|
||||
const normalized = normalizeShelfNode(node)
|
||||
if (normalized) {
|
||||
patchedNodes[id] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
// Roof-segment hosting was added in this migration cycle (the same
|
||||
|
||||
@@ -287,39 +287,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
//
|
||||
// The live-transform contract varies per kind (see
|
||||
// wiki/architecture/tools.md "useLiveTransforms contract is
|
||||
// per-kind, not generic"); we narrow per kind here:
|
||||
// - item: world-plan position frame. Override `position` +
|
||||
// `rotation` and force `parentId: null` so the resolver
|
||||
// treats them as world coords directly.
|
||||
// - slab / ceiling: position is a translation **delta**
|
||||
// (`[Δx, 0, Δz]`). Translate the polygon + holes by the
|
||||
// delta — the floor-plan builder draws the polygon at its
|
||||
// new location, mirroring the 3D `<group position={delta}>`
|
||||
// visual without forcing per-tick CSG scene writes.
|
||||
// per-kind, not generic"); position-carrying floor-placed kinds
|
||||
// publish canonical X/Z, while slab / ceiling publish a polygon
|
||||
// translation delta.
|
||||
const live = liveTransforms.get(id)
|
||||
let effectiveNode: AnyNode = node
|
||||
if (live) {
|
||||
if (node.type === 'item' || node.type === 'shelf') {
|
||||
// World-plan position kinds: the live transform carries the
|
||||
// node's intended position/rotation in level-local coords.
|
||||
// Override both and force `parentId: null` so the floor-plan
|
||||
// resolver treats `position` as world plan coords directly
|
||||
// (skipping the parent-chain transform composition).
|
||||
effectiveNode = {
|
||||
...node,
|
||||
position: live.position,
|
||||
rotation: [0, live.rotation, 0] as [number, number, number],
|
||||
parentId: null,
|
||||
} as AnyNode
|
||||
} else if (node.type === 'column') {
|
||||
// Same world-plan override as item/shelf, but column stores its
|
||||
// Y rotation as a scalar (not a tuple).
|
||||
effectiveNode = {
|
||||
...node,
|
||||
position: live.position,
|
||||
rotation: live.rotation,
|
||||
parentId: null,
|
||||
} as AnyNode
|
||||
const floorPlaced = def?.capabilities?.floorPlaced
|
||||
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
||||
if (floorPlaced && hasPosition) {
|
||||
effectiveNode = applyPositionLiveTransform(node, live)
|
||||
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
||||
const dx = live.position[0]
|
||||
const dz = live.position[2]
|
||||
@@ -1591,6 +1568,29 @@ function InteractiveGeometry({
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function applyPositionLiveTransform(
|
||||
node: AnyNode,
|
||||
live: { position: [number, number, number]; rotation: number },
|
||||
): AnyNode {
|
||||
const currentRotation = (node as { rotation?: unknown }).rotation
|
||||
const rotation = Array.isArray(currentRotation)
|
||||
? ([
|
||||
(currentRotation[0] as number) ?? 0,
|
||||
live.rotation,
|
||||
(currentRotation[2] as number) ?? 0,
|
||||
] as [number, number, number])
|
||||
: typeof currentRotation === 'number'
|
||||
? live.rotation
|
||||
: currentRotation
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: live.position,
|
||||
...(rotation !== undefined ? { rotation } : {}),
|
||||
parentId: null,
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
function buildContext(
|
||||
node: AnyNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
|
||||
@@ -8,10 +8,10 @@ import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
CORNER_OFFSET,
|
||||
classifyParticipant,
|
||||
collectParticipants,
|
||||
computeGroupBox,
|
||||
CORNER_OFFSET,
|
||||
expandToComponent,
|
||||
type Vec2,
|
||||
} from './group-transform-shared'
|
||||
|
||||
@@ -21,11 +21,15 @@ import {
|
||||
ARROW_HOVER_COLOR,
|
||||
ARROW_SCALE,
|
||||
createRotateArrowHandleGeometry,
|
||||
createRotateArrowHitAreaGeometry,
|
||||
GuideRing,
|
||||
InvisibleHandleHitArea,
|
||||
NO_RAYCAST,
|
||||
RotationGuide,
|
||||
type RotationGuideData,
|
||||
swallowNextClick,
|
||||
useArrowMaterial,
|
||||
useInvisibleHitAreaMaterial,
|
||||
} from './node-arrow-handles'
|
||||
|
||||
const ROTATE_SNAP = Math.PI / 12 // 15°
|
||||
@@ -74,7 +78,9 @@ export function GroupRotateHandle() {
|
||||
function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
const { camera, raycaster, gl, scene } = useThree()
|
||||
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
|
||||
const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
|
||||
const arrowMaterial = useArrowMaterial()
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [guide, setGuide] = useState<RotationGuideData | null>(null)
|
||||
@@ -85,11 +91,13 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||
}, [arrowMaterial, isHovered])
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||
useEffect(() => () => dragCleanupRef.current?.(), [])
|
||||
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE * 1.05
|
||||
const baseScale = zoom * ARROW_SCALE * 1.05
|
||||
const scale = (isHovered ? 1.12 : 1) * baseScale
|
||||
|
||||
// World-space bounding box of the selected meshes. Levels are axis-aligned in
|
||||
// XZ, so world XZ coincides with each node's level-local placement — letting
|
||||
@@ -100,11 +108,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
const rest = useMemo(() => {
|
||||
const box = computeGroupBox(ids)
|
||||
if (!box) return null
|
||||
const pivot = new Vector3(
|
||||
(box.min.x + box.max.x) / 2,
|
||||
box.min.y,
|
||||
(box.min.z + box.max.z) / 2,
|
||||
)
|
||||
const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
|
||||
const corner = new Vector3(
|
||||
box.max.x + CORNER_OFFSET,
|
||||
(box.min.y + box.max.y) / 2,
|
||||
@@ -307,11 +311,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
<GuideRing radius={0.2 * scale} y={0} />
|
||||
</group>
|
||||
)}
|
||||
<group position={[corner.x, corner.y, corner.z]} scale={scale}>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
<group position={[corner.x, corner.y, corner.z]}>
|
||||
<InvisibleHandleHitArea
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={activate}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -323,7 +326,15 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
setIsHovered(false)
|
||||
if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
|
||||
}}
|
||||
scale={baseScale}
|
||||
/>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1010}
|
||||
scale={scale}
|
||||
/>
|
||||
</group>
|
||||
{guide ? <RotationGuide data={guide} /> : null}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
'use client'
|
||||
|
||||
import { type Cursor, emitter } from '@pascal-app/core'
|
||||
import type { ThreeEvent } from '@react-three/fiber'
|
||||
import { type ReactNode, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
BoxGeometry,
|
||||
type BufferGeometry,
|
||||
CircleGeometry,
|
||||
Color,
|
||||
CylinderGeometry,
|
||||
DoubleSide,
|
||||
ExtrudeGeometry,
|
||||
type Group,
|
||||
Shape,
|
||||
TorusGeometry,
|
||||
} from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
|
||||
export const ARROW_SCALE = 0.65
|
||||
export const ARROW_COLOR = '#8381ed'
|
||||
export const ARROW_HOVER_COLOR = '#a5b4fc'
|
||||
export const NO_RAYCAST = () => null
|
||||
export const HIT_AREA_MARGIN = 0.035
|
||||
|
||||
const HIT_AREA_RENDER_ORDER = 1011
|
||||
const HIT_AREA_THICKNESS = 0.08
|
||||
const CHEVRON_MIN_X = -0.2
|
||||
const CHEVRON_MAX_X = 0.22
|
||||
const CHEVRON_HALF_WIDTH = 0.12
|
||||
const CHEVRON_NOTCH_X = -0.04
|
||||
const CHEVRON_SHAFT_HALF_WIDTH = 0.035
|
||||
const CHEVRON_DEPTH = 0.08
|
||||
const CHEVRON_BEVEL_THICKNESS = 0.035
|
||||
const CHEVRON_BEVEL_SIZE = 0.03
|
||||
const CHEVRON_BEVEL_SEGMENTS = 10
|
||||
const MOVE_CROSS_HALF_LENGTH = 0.36
|
||||
const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03
|
||||
const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12
|
||||
const MOVE_CROSS_HEAD_INSET = 0.2
|
||||
const MOVE_CROSS_DEPTH = 0.06
|
||||
const MOVE_CROSS_BEVEL_THICKNESS = 0.018
|
||||
const MOVE_CROSS_BEVEL_SIZE = 0.012
|
||||
const MOVE_CROSS_BEVEL_SEGMENTS = 6
|
||||
const ROTATE_HANDLE_RADIUS = 0.2
|
||||
const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3
|
||||
const ROTATE_RIBBON_HALF_WIDTH = 0.02
|
||||
const ROTATE_HEAD_HALF_WIDTH = 0.045
|
||||
const TRACKER_CUBE_SIZE = 0.16
|
||||
export const CORNER_HEX_RADIUS = 0.16
|
||||
|
||||
export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker'
|
||||
export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
|
||||
|
||||
export type HandleArrowPlacement = {
|
||||
position: readonly [number, number, number]
|
||||
rotation?: readonly [number, number, number]
|
||||
baseScale: number
|
||||
}
|
||||
|
||||
type PointerHandler = (event: ThreeEvent<PointerEvent>) => void
|
||||
|
||||
export type HandleArrowProps = {
|
||||
shape: HandleArrowInputShape
|
||||
placement: HandleArrowPlacement
|
||||
hover: boolean
|
||||
cursor: Cursor
|
||||
onHoverChange: (hovered: boolean) => void
|
||||
onPointerDown: PointerHandler
|
||||
activeCursor?: Cursor
|
||||
children?: ReactNode
|
||||
hoverScale?: number
|
||||
indicatorRotation?: readonly [number, number, number]
|
||||
onPointerEnter?: PointerHandler
|
||||
onPointerLeave?: PointerHandler
|
||||
}
|
||||
|
||||
function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
|
||||
if (shape === 'arrow') return 'chevron'
|
||||
if (shape === 'move-cross') return 'cross'
|
||||
if (shape === 'chevron' && cursor === 'move') return 'cross'
|
||||
return shape
|
||||
}
|
||||
|
||||
// Two-headed curved-arrow silhouette for whole-node rotation handles
|
||||
// (today: the elevator's corner rotate gizmo). Symmetric arc centred on
|
||||
// +X with sweeps to +/-halfSweep, arrowhead wings + tangentially-extended
|
||||
// tips at each end. Drawn in 2D then extruded and rotated to lie in the
|
||||
// XZ plane - same final-orientation contract as the chevron, so the
|
||||
// outer rotation Y and inner-rotation chain in the renderer are reused
|
||||
// unchanged.
|
||||
export function createRotateArrowHandleGeometry() {
|
||||
const R = 0.2
|
||||
const ribbonHalfWidth = 0.02
|
||||
const halfSweep = Math.PI / 3
|
||||
const headHalfWidth = 0.045
|
||||
const headOvershoot = 0.075
|
||||
const rIn = R - ribbonHalfWidth
|
||||
const rOut = R + ribbonHalfWidth
|
||||
const a1 = halfSweep
|
||||
const a2 = -halfSweep
|
||||
|
||||
const tip1: [number, number] = [
|
||||
R * Math.cos(a1) - headOvershoot * Math.sin(a1),
|
||||
R * Math.sin(a1) + headOvershoot * Math.cos(a1),
|
||||
]
|
||||
const tip2: [number, number] = [
|
||||
R * Math.cos(a2) + headOvershoot * Math.sin(a2),
|
||||
R * Math.sin(a2) - headOvershoot * Math.cos(a2),
|
||||
]
|
||||
const innerWing1: [number, number] = [
|
||||
(rIn - headHalfWidth) * Math.cos(a1),
|
||||
(rIn - headHalfWidth) * Math.sin(a1),
|
||||
]
|
||||
const outerWing1: [number, number] = [
|
||||
(rOut + headHalfWidth) * Math.cos(a1),
|
||||
(rOut + headHalfWidth) * Math.sin(a1),
|
||||
]
|
||||
const innerWing2: [number, number] = [
|
||||
(rIn - headHalfWidth) * Math.cos(a2),
|
||||
(rIn - headHalfWidth) * Math.sin(a2),
|
||||
]
|
||||
const outerWing2: [number, number] = [
|
||||
(rOut + headHalfWidth) * Math.cos(a2),
|
||||
(rOut + headHalfWidth) * Math.sin(a2),
|
||||
]
|
||||
const innerCorner1: [number, number] = [rIn * Math.cos(a1), rIn * Math.sin(a1)]
|
||||
const outerCorner1: [number, number] = [rOut * Math.cos(a1), rOut * Math.sin(a1)]
|
||||
const innerCorner2: [number, number] = [rIn * Math.cos(a2), rIn * Math.sin(a2)]
|
||||
const outerCorner2: [number, number] = [rOut * Math.cos(a2), rOut * Math.sin(a2)]
|
||||
|
||||
const shape = new Shape()
|
||||
shape.moveTo(innerCorner1[0], innerCorner1[1])
|
||||
shape.lineTo(innerWing1[0], innerWing1[1])
|
||||
shape.lineTo(tip1[0], tip1[1])
|
||||
shape.lineTo(outerWing1[0], outerWing1[1])
|
||||
shape.lineTo(outerCorner1[0], outerCorner1[1])
|
||||
shape.absarc(0, 0, rOut, a1, a2, true)
|
||||
shape.lineTo(outerWing2[0], outerWing2[1])
|
||||
shape.lineTo(tip2[0], tip2[1])
|
||||
shape.lineTo(innerWing2[0], innerWing2[1])
|
||||
shape.lineTo(innerCorner2[0], innerCorner2[1])
|
||||
shape.absarc(0, 0, rIn, a2, a1, false)
|
||||
shape.closePath()
|
||||
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
depth: 0.06,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: 0.018,
|
||||
bevelSize: 0.012,
|
||||
bevelOffset: 0,
|
||||
bevelSegments: 6,
|
||||
curveSegments: 24,
|
||||
steps: 1,
|
||||
})
|
||||
geometry.translate(0, 0, -0.03)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
// Reused chevron+shaft silhouette. The chevron points along +X by default;
|
||||
// callers rotate it around Y for Z-axis handles and into a vertical frame for
|
||||
// Y-axis handles.
|
||||
export function createArrowHandleGeometry() {
|
||||
const shape = new Shape()
|
||||
shape.moveTo(CHEVRON_MAX_X, 0)
|
||||
shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH)
|
||||
shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_SHAFT_HALF_WIDTH)
|
||||
shape.lineTo(CHEVRON_MIN_X, CHEVRON_SHAFT_HALF_WIDTH)
|
||||
shape.lineTo(CHEVRON_MIN_X, -CHEVRON_SHAFT_HALF_WIDTH)
|
||||
shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_SHAFT_HALF_WIDTH)
|
||||
shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH)
|
||||
shape.lineTo(CHEVRON_MAX_X, 0)
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
depth: CHEVRON_DEPTH,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: CHEVRON_BEVEL_THICKNESS,
|
||||
bevelSize: CHEVRON_BEVEL_SIZE,
|
||||
bevelOffset: 0,
|
||||
bevelSegments: CHEVRON_BEVEL_SEGMENTS,
|
||||
curveSegments: 16,
|
||||
steps: 1,
|
||||
})
|
||||
geometry.translate(0, 0, -CHEVRON_DEPTH / 2)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createDoubleArrowShape(): Shape {
|
||||
const shape = new Shape()
|
||||
shape.moveTo(MOVE_CROSS_HALF_LENGTH, 0)
|
||||
shape.lineTo(MOVE_CROSS_HEAD_INSET, MOVE_CROSS_HEAD_HALF_WIDTH)
|
||||
shape.lineTo(MOVE_CROSS_HEAD_INSET, MOVE_CROSS_SHAFT_HALF_WIDTH)
|
||||
shape.lineTo(-MOVE_CROSS_HEAD_INSET, MOVE_CROSS_SHAFT_HALF_WIDTH)
|
||||
shape.lineTo(-MOVE_CROSS_HEAD_INSET, MOVE_CROSS_HEAD_HALF_WIDTH)
|
||||
shape.lineTo(-MOVE_CROSS_HALF_LENGTH, 0)
|
||||
shape.lineTo(-MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_HEAD_HALF_WIDTH)
|
||||
shape.lineTo(-MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_SHAFT_HALF_WIDTH)
|
||||
shape.lineTo(MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_SHAFT_HALF_WIDTH)
|
||||
shape.lineTo(MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_HEAD_HALF_WIDTH)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
export function createMoveCrossHandleGeometry() {
|
||||
const shape = createDoubleArrowShape()
|
||||
const extrudeOpts = {
|
||||
depth: MOVE_CROSS_DEPTH,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: MOVE_CROSS_BEVEL_THICKNESS,
|
||||
bevelSize: MOVE_CROSS_BEVEL_SIZE,
|
||||
bevelOffset: 0,
|
||||
bevelSegments: MOVE_CROSS_BEVEL_SEGMENTS,
|
||||
curveSegments: 8,
|
||||
steps: 1,
|
||||
}
|
||||
const armX = new ExtrudeGeometry(shape, extrudeOpts)
|
||||
armX.translate(0, 0, -MOVE_CROSS_DEPTH / 2)
|
||||
armX.rotateX(-Math.PI / 2)
|
||||
const armZ = armX.clone()
|
||||
armZ.rotateY(Math.PI / 2)
|
||||
const merged = mergeGeometries([armX, armZ], false)
|
||||
if (!merged) {
|
||||
armZ.dispose()
|
||||
armX.computeVertexNormals()
|
||||
armX.computeBoundingSphere()
|
||||
return armX
|
||||
}
|
||||
armX.dispose()
|
||||
armZ.dispose()
|
||||
merged.computeVertexNormals()
|
||||
merged.computeBoundingSphere()
|
||||
return merged
|
||||
}
|
||||
|
||||
export function createArrowHitAreaGeometry() {
|
||||
const length = CHEVRON_MAX_X - CHEVRON_MIN_X + HIT_AREA_MARGIN * 2
|
||||
const centerX = (CHEVRON_MIN_X + CHEVRON_MAX_X) / 2
|
||||
const geometry = new CylinderGeometry(
|
||||
CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN,
|
||||
CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN,
|
||||
length,
|
||||
16,
|
||||
)
|
||||
geometry.rotateZ(-Math.PI / 2)
|
||||
geometry.translate(centerX, 0, 0)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createMoveCrossHitAreaGeometry() {
|
||||
const geometry = new CylinderGeometry(
|
||||
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
|
||||
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
|
||||
HIT_AREA_THICKNESS,
|
||||
32,
|
||||
)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
export function createRotateArrowHitAreaGeometry() {
|
||||
const halfSweep = ROTATE_HANDLE_HALF_SWEEP + HIT_AREA_MARGIN / ROTATE_HANDLE_RADIUS
|
||||
const geometry = new TorusGeometry(
|
||||
ROTATE_HANDLE_RADIUS,
|
||||
ROTATE_RIBBON_HALF_WIDTH + ROTATE_HEAD_HALF_WIDTH + HIT_AREA_MARGIN,
|
||||
10,
|
||||
64,
|
||||
halfSweep * 2,
|
||||
)
|
||||
geometry.rotateZ(-halfSweep)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createTrackerHitAreaGeometry() {
|
||||
const size = TRACKER_CUBE_SIZE + HIT_AREA_MARGIN * 2
|
||||
const geometry = new BoxGeometry(size, size, size)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
export function createEndpointHitAreaGeometry(radius: number) {
|
||||
const geometry = new CylinderGeometry(
|
||||
radius + HIT_AREA_MARGIN,
|
||||
radius + HIT_AREA_MARGIN,
|
||||
HIT_AREA_THICKNESS,
|
||||
24,
|
||||
)
|
||||
geometry.rotateX(Math.PI / 2)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createHandleArrowGeometry(shape: HandleArrowShape) {
|
||||
if (shape === 'chevron') return createArrowHandleGeometry()
|
||||
if (shape === 'cross') return createMoveCrossHandleGeometry()
|
||||
if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
|
||||
if (shape === 'tracker') {
|
||||
const geometry = new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createHandleArrowHitGeometry(shape: HandleArrowShape) {
|
||||
if (shape === 'chevron') return createArrowHitAreaGeometry()
|
||||
if (shape === 'cross') return createMoveCrossHitAreaGeometry()
|
||||
if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry()
|
||||
if (shape === 'tracker') return createTrackerHitAreaGeometry()
|
||||
const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
let sharedHitAreaMaterial: MeshBasicNodeMaterial | null = null
|
||||
let sharedHitAreaMaterialRefs = 0
|
||||
|
||||
function createInvisibleHitAreaMaterial() {
|
||||
return new MeshBasicNodeMaterial({
|
||||
color: new Color('#ffffff'),
|
||||
colorWrite: false,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity: 0,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInvisibleHitAreaMaterial(): MeshBasicNodeMaterial {
|
||||
const materialRef = useRef<MeshBasicNodeMaterial | null>(null)
|
||||
if (!materialRef.current) {
|
||||
sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial()
|
||||
materialRef.current = sharedHitAreaMaterial
|
||||
}
|
||||
useEffect(() => {
|
||||
sharedHitAreaMaterialRefs += 1
|
||||
return () => {
|
||||
sharedHitAreaMaterialRefs -= 1
|
||||
if (sharedHitAreaMaterialRefs <= 0 && sharedHitAreaMaterial) {
|
||||
sharedHitAreaMaterial.dispose()
|
||||
sharedHitAreaMaterial = null
|
||||
sharedHitAreaMaterialRefs = 0
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
return materialRef.current
|
||||
}
|
||||
|
||||
export function InvisibleHandleHitArea({
|
||||
geometry,
|
||||
material,
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
scale,
|
||||
}: {
|
||||
geometry: BufferGeometry
|
||||
material: MeshBasicNodeMaterial
|
||||
onPointerDown: PointerHandler
|
||||
onPointerEnter: PointerHandler
|
||||
onPointerLeave: PointerHandler
|
||||
scale: number
|
||||
}) {
|
||||
return (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={geometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={material}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
renderOrder={HIT_AREA_RENDER_ORDER}
|
||||
scale={scale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function useArrowMaterial(): MeshBasicNodeMaterial {
|
||||
return useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(ARROW_COLOR),
|
||||
side: DoubleSide,
|
||||
// `depthTest: false` keeps the chevron drawing on top of any
|
||||
// geometry under it; `depthWrite: true` puts the chevron's depth
|
||||
// into the scenePass buffer so the ink-edge shader's depth
|
||||
// Laplacian fires on its silhouette from every angle. Without
|
||||
// depthWrite, only the normal-discontinuity branch can detect
|
||||
// the chevron, and that signal collapses when the arrow's faces
|
||||
// happen to align with whatever sits behind them in screen space
|
||||
// - which is why the lines used to drop out depending on the view.
|
||||
depthTest: false,
|
||||
depthWrite: true,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
function useHandleArrowMaterial(shape: HandleArrowShape): MeshBasicNodeMaterial {
|
||||
return useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(ARROW_COLOR),
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
opacity: shape === 'corner-picker' ? 0.95 : 1,
|
||||
depthTest: false,
|
||||
depthWrite: shape !== 'corner-picker',
|
||||
}),
|
||||
[shape],
|
||||
)
|
||||
}
|
||||
|
||||
function indicatorRenderOrder(shape: HandleArrowShape) {
|
||||
return shape === 'tracker' || shape === 'corner-picker' ? 1003 : 1010
|
||||
}
|
||||
|
||||
export function HandleArrow({
|
||||
shape,
|
||||
placement,
|
||||
hover,
|
||||
cursor,
|
||||
activeCursor,
|
||||
children,
|
||||
hoverScale = 1.12,
|
||||
indicatorRotation,
|
||||
onHoverChange,
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: HandleArrowProps) {
|
||||
const visualShape = normalizeHandleArrowShape(shape, cursor)
|
||||
const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape])
|
||||
const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape])
|
||||
const indicatorMaterial = useHandleArrowMaterial(visualShape)
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
const rootRef = useRef<Group>(null)
|
||||
const rotation: [number, number, number] = placement.rotation
|
||||
? [placement.rotation[0], placement.rotation[1], placement.rotation[2]]
|
||||
: [0, 0, 0]
|
||||
const localRotation: [number, number, number] = indicatorRotation
|
||||
? [indicatorRotation[0], indicatorRotation[1], indicatorRotation[2]]
|
||||
: [0, 0, 0]
|
||||
const scale = (hover ? hoverScale : 1) * placement.baseScale
|
||||
const hitScale = visualShape === 'corner-picker' ? scale : placement.baseScale
|
||||
|
||||
useEffect(() => {
|
||||
indicatorMaterial.color.set(hover ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||
}, [indicatorMaterial, hover])
|
||||
useEffect(() => {
|
||||
const hideForCapture = () => {
|
||||
if (rootRef.current) rootRef.current.visible = false
|
||||
}
|
||||
const restoreAfterCapture = () => {
|
||||
if (rootRef.current) rootRef.current.visible = true
|
||||
}
|
||||
emitter.on('thumbnail:before-capture', hideForCapture)
|
||||
emitter.on('thumbnail:after-capture', restoreAfterCapture)
|
||||
return () => {
|
||||
emitter.off('thumbnail:before-capture', hideForCapture)
|
||||
emitter.off('thumbnail:after-capture', restoreAfterCapture)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||
useEffect(() => () => indicatorMaterial.dispose(), [indicatorMaterial])
|
||||
|
||||
const handleEnter: PointerHandler = (event) => {
|
||||
event.stopPropagation()
|
||||
onHoverChange(true)
|
||||
if (!activeCursor || document.body.style.cursor !== activeCursor) {
|
||||
document.body.style.cursor = cursor
|
||||
}
|
||||
onPointerEnter?.(event)
|
||||
}
|
||||
const handleLeave: PointerHandler = (event) => {
|
||||
event.stopPropagation()
|
||||
onHoverChange(false)
|
||||
if (document.body.style.cursor === cursor) {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
onPointerLeave?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={placement.position} ref={rootRef} rotation={rotation}>
|
||||
{children}
|
||||
<group rotation={localRotation}>
|
||||
<InvisibleHandleHitArea
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={handleEnter}
|
||||
onPointerLeave={handleLeave}
|
||||
scale={hitScale}
|
||||
/>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={geometry}
|
||||
material={indicatorMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={indicatorRenderOrder(visualShape)}
|
||||
scale={scale}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type Cursor,
|
||||
createSceneApi,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { type ThreeEvent, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { type Camera, type Object3D, type Plane, Vector2, type Vector3 } from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
export type HandleDragControls = {
|
||||
onStart: (index: number, snapshot: AnyNode) => void
|
||||
onEnd: () => void
|
||||
}
|
||||
|
||||
type IntersectPlane = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
plane: Plane,
|
||||
target: Vector3,
|
||||
) => Vector3 | null
|
||||
|
||||
export type HandleDragStartContext = {
|
||||
event: ThreeEvent<PointerEvent>
|
||||
camera: Camera
|
||||
intersectPlane: IntersectPlane
|
||||
initialNode: AnyNode
|
||||
node: AnyNode
|
||||
nodeId: AnyNodeId
|
||||
rideObject: Object3D
|
||||
sceneApi: ReturnType<typeof createSceneApi>
|
||||
}
|
||||
|
||||
export type HandleDragMoveContext = {
|
||||
event: PointerEvent
|
||||
intersectPlane: IntersectPlane
|
||||
}
|
||||
|
||||
type HandleDragSession = {
|
||||
move: (context: HandleDragMoveContext) => Partial<AnyNode> | null
|
||||
onBegin?: () => void
|
||||
onEnd?: () => void
|
||||
overrideId?: AnyNodeId
|
||||
}
|
||||
|
||||
type UseHandleDragArgs =
|
||||
| {
|
||||
kind: 'drag'
|
||||
cursor: Cursor
|
||||
dragControls: HandleDragControls
|
||||
handleIndex: number
|
||||
node: AnyNode
|
||||
onStart: (context: HandleDragStartContext) => HandleDragSession | null
|
||||
rideObject: Object3D
|
||||
setIsDragging: (dragging: boolean) => void
|
||||
}
|
||||
| {
|
||||
kind: 'tap'
|
||||
onTap: (event: ThreeEvent<PointerEvent>) => void
|
||||
}
|
||||
|
||||
export function swallowNextClick() {
|
||||
const swallow = (clickEvent: Event) => {
|
||||
clickEvent.stopPropagation()
|
||||
clickEvent.preventDefault()
|
||||
}
|
||||
window.addEventListener('click', swallow, { capture: true, once: true })
|
||||
setTimeout(() => {
|
||||
window.removeEventListener('click', swallow, { capture: true })
|
||||
}, 300)
|
||||
}
|
||||
|
||||
export function useHandleDrag(args: UseHandleDragArgs) {
|
||||
const { camera, raycaster, gl } = useThree()
|
||||
const dragCleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => () => dragCleanupRef.current?.(), [])
|
||||
|
||||
return (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (args.kind === 'tap') {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
document.body.style.cursor = ''
|
||||
args.onTap(event)
|
||||
return
|
||||
}
|
||||
|
||||
const { cursor, dragControls, handleIndex, node, rideObject, setIsDragging } = args
|
||||
rideObject.updateMatrixWorld()
|
||||
|
||||
const ndc = new Vector2()
|
||||
const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => {
|
||||
const rect = gl.domElement.getBoundingClientRect()
|
||||
ndc.set(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
return raycaster.ray.intersectPlane(plane, target)
|
||||
}
|
||||
|
||||
const nodeId = node.id as AnyNodeId
|
||||
const sceneApi = createSceneApi(useScene)
|
||||
const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode
|
||||
const session = args.onStart({
|
||||
event,
|
||||
camera,
|
||||
intersectPlane,
|
||||
initialNode,
|
||||
node,
|
||||
nodeId,
|
||||
rideObject,
|
||||
sceneApi,
|
||||
})
|
||||
if (!session) return
|
||||
|
||||
const overrideId = session.overrideId ?? nodeId
|
||||
document.body.style.cursor = cursor
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useViewer.getState().setInputDragging(true)
|
||||
useScene.temporal.getState().pause()
|
||||
setIsDragging(true)
|
||||
dragControls.onStart(handleIndex, initialNode)
|
||||
session.onBegin?.()
|
||||
|
||||
let lastPatch: Partial<AnyNode> | null = null
|
||||
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
const patch = session.move({ event: moveEvent, intersectPlane })
|
||||
if (!patch) return
|
||||
lastPatch = patch
|
||||
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
|
||||
useScene.getState().markDirty(overrideId)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onCancel)
|
||||
if (document.body.style.cursor === cursor) {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
useViewer.getState().setInputDragging(false)
|
||||
setIsDragging(false)
|
||||
session.onEnd?.()
|
||||
dragControls.onEnd()
|
||||
dragCleanupRef.current = null
|
||||
}
|
||||
|
||||
const clearOverride = () => {
|
||||
useLiveNodeOverrides.getState().clear(overrideId)
|
||||
useScene.getState().markDirty(overrideId)
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
swallowNextClick()
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
if (lastPatch) {
|
||||
sceneApi.update(overrideId, lastPatch)
|
||||
}
|
||||
clearOverride()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
clearOverride()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
dragCleanupRef.current = cleanup
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onCancel)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ import { GroupRotateHandle } from './group-rotate-handle'
|
||||
import { NodeArrowHandles } from './node-arrow-handles'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { SlabHoleHighlights } from './slab-hole-highlights'
|
||||
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
|
||||
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
|
||||
import { WallMeasurementLabel } from './wall-measurement-label'
|
||||
@@ -610,6 +611,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
{!noEditing && <GroupRotateHandle />}
|
||||
{!noEditing && <GroupMoveHandle />}
|
||||
{!noEditing && <WallOpeningHighlights />}
|
||||
{!noEditing && <SlabHoleHighlights />}
|
||||
{!noEditing && <WallMoveSideHandles />}
|
||||
{!noEditing && <FloatingActionMenu />}
|
||||
{!noEditing && <FloatingBuildingActionMenu />}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1263,6 +1263,13 @@ export const SelectionManager = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Clicking any node (e.g. the slab surface outside a hole) exits slab
|
||||
// hole-edit mode. The hole handles + hit mesh stopPropagation, so a
|
||||
// click reaching here means the user clicked outside the hole.
|
||||
if (useEditor.getState().editingHole) {
|
||||
useEditor.getState().setEditingHole(null)
|
||||
}
|
||||
|
||||
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
||||
|
||||
let nextMaterialTargetHandled = false
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
type LevelNode,
|
||||
resolveBuildingForLevel,
|
||||
resolveLevelId,
|
||||
type SlabNode,
|
||||
type SurfaceHoleMetadata,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
BoxGeometry,
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
Float32BufferAttribute,
|
||||
type Object3D,
|
||||
ShapeUtils,
|
||||
Vector2,
|
||||
} from 'three'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { swallowNextClick } from './handles/use-handle-drag'
|
||||
|
||||
const ACCENT = 0x83_81_ed
|
||||
const SURFACE_OFFSET = 0.01
|
||||
const HIT_PADDING = 0.08
|
||||
const MIN_HIT_HEIGHT = 0.16
|
||||
|
||||
const NO_RAYCAST = () => null
|
||||
let restoreNodeClickSuppression: (() => void) | null = null
|
||||
|
||||
const outlineMaterial = new LineBasicNodeMaterial({
|
||||
color: ACCENT,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const fillMaterial = new MeshBasicNodeMaterial({
|
||||
color: ACCENT,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
side: DoubleSide,
|
||||
})
|
||||
|
||||
// Invisible hit targets must stay out of the scene MRT pass; keep them on
|
||||
// EDITOR_LAYER even though they never write color.
|
||||
const hitMaterial = new MeshBasicNodeMaterial({
|
||||
color: ACCENT,
|
||||
colorWrite: false,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity: 0,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
})
|
||||
|
||||
type HolePolygon = Array<[number, number]>
|
||||
|
||||
function makeFillGeometry(hole: HolePolygon, y: number): BufferGeometry {
|
||||
const contour2d = hole.map(([x, z]) => new Vector2(x, z))
|
||||
const positions: number[] = []
|
||||
const indices: number[] = []
|
||||
|
||||
for (const point of contour2d) {
|
||||
positions.push(point.x, y, point.y)
|
||||
}
|
||||
|
||||
const triangles = ShapeUtils.triangulateShape(contour2d, [])
|
||||
for (const tri of triangles) {
|
||||
indices.push(tri[0]!, tri[2]!, tri[1]!)
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||
geometry.setIndex(indices)
|
||||
geometry.computeVertexNormals()
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function makeOutlineGeometry(hole: HolePolygon, y: number): BufferGeometry {
|
||||
const positions: number[] = []
|
||||
|
||||
for (let index = 0; index < hole.length; index += 1) {
|
||||
const [ax, az] = hole[index]!
|
||||
const [bx, bz] = hole[(index + 1) % hole.length]!
|
||||
positions.push(ax, y, az, bx, y, bz)
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry {
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minZ = Number.POSITIVE_INFINITY
|
||||
let maxZ = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const [x, z] of hole) {
|
||||
minX = Math.min(minX, x)
|
||||
maxX = Math.max(maxX, x)
|
||||
minZ = Math.min(minZ, z)
|
||||
maxZ = Math.max(maxZ, z)
|
||||
}
|
||||
|
||||
const width = Math.max(maxX - minX + HIT_PADDING * 2, HIT_PADDING * 2)
|
||||
const depth = Math.max(maxZ - minZ + HIT_PADDING * 2, HIT_PADDING * 2)
|
||||
const centerX = (minX + maxX) / 2
|
||||
const centerZ = (minZ + maxZ) / 2
|
||||
const geometry = new BoxGeometry(width, height, depth)
|
||||
geometry.translate(centerX, centerY, centerZ)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getSurfaceY(slab: SlabNode): number {
|
||||
const elevation = slab.elevation ?? 0.05
|
||||
return (elevation > 0 ? elevation : 0) + SURFACE_OFFSET
|
||||
}
|
||||
|
||||
function getHitCenterY(slab: SlabNode): number {
|
||||
const elevation = slab.elevation ?? 0.05
|
||||
const surfaceTop = elevation > 0 ? elevation : 0
|
||||
// Keep the hit box's TOP at the slab surface (never poke above it) so the
|
||||
// slab's centre thickness/height handle, which sits on top, always wins the
|
||||
// raycast over the hole's selectable area.
|
||||
return surfaceTop - getHitHeight(slab) / 2
|
||||
}
|
||||
|
||||
function getHitHeight(slab: SlabNode): number {
|
||||
return Math.max(Math.abs(slab.elevation ?? 0.05), MIN_HIT_HEIGHT)
|
||||
}
|
||||
|
||||
function clearHoveredHoleIfMatches(nodeId: string, holeIndex: number) {
|
||||
const { hoveredHole, setHoveredHole } = useEditor.getState()
|
||||
if (hoveredHole?.nodeId === nodeId && hoveredHole.holeIndex === holeIndex) {
|
||||
setHoveredHole(null)
|
||||
}
|
||||
}
|
||||
|
||||
function selectOwnedNode(ownerId: string, expectedType: 'stair' | 'elevator') {
|
||||
const nodes = useScene.getState().nodes
|
||||
const owner = nodes[ownerId as AnyNodeId]
|
||||
if (owner?.type !== expectedType) return
|
||||
|
||||
const selectedId = owner.id as AnyNodeId
|
||||
|
||||
if (owner.type === 'elevator') {
|
||||
const buildingId =
|
||||
owner.parentId && nodes[owner.parentId as AnyNodeId]?.type === 'building'
|
||||
? (owner.parentId as BuildingNode['id'])
|
||||
: null
|
||||
|
||||
useViewer
|
||||
.getState()
|
||||
.setSelection(
|
||||
buildingId ? { buildingId, selectedIds: [selectedId] } : { selectedIds: [selectedId] },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const levelId = resolveLevelId(owner, nodes)
|
||||
const buildingId =
|
||||
levelId && levelId !== 'default' ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null
|
||||
|
||||
useViewer.getState().setSelection({
|
||||
...(buildingId ? { buildingId: buildingId as BuildingNode['id'] } : {}),
|
||||
...(levelId && levelId !== 'default' ? { levelId: levelId as LevelNode['id'] } : {}),
|
||||
selectedIds: [selectedId],
|
||||
})
|
||||
}
|
||||
|
||||
function resetPointerCursor() {
|
||||
if (document.body.style.cursor === 'pointer') {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}
|
||||
|
||||
function stopPointerPropagation(event: ThreeEvent<PointerEvent>) {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.stopPropagation()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
function suppressNodeClickUntilPointerUp() {
|
||||
restoreNodeClickSuppression?.()
|
||||
const previousInputDragging = useViewer.getState().inputDragging
|
||||
useViewer.getState().setInputDragging(true)
|
||||
swallowNextClick()
|
||||
|
||||
const restore = () => {
|
||||
if (restoreNodeClickSuppression !== restore) return
|
||||
useViewer.getState().setInputDragging(previousInputDragging)
|
||||
window.removeEventListener('pointerup', restore)
|
||||
window.removeEventListener('pointercancel', restore)
|
||||
restoreNodeClickSuppression = null
|
||||
}
|
||||
|
||||
restoreNodeClickSuppression = restore
|
||||
window.addEventListener('pointerup', restore, { once: true })
|
||||
window.addEventListener('pointercancel', restore, { once: true })
|
||||
}
|
||||
|
||||
function releaseNodeClickSuppression() {
|
||||
restoreNodeClickSuppression?.()
|
||||
}
|
||||
|
||||
export function SlabHoleHighlights() {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const hoveredHole = useEditor((state) => state.hoveredHole)
|
||||
const setHoveredHole = useEditor((state) => state.setHoveredHole)
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredHole && !selectedIds.includes(hoveredHole.nodeId as AnyNodeId)) {
|
||||
setHoveredHole(null)
|
||||
}
|
||||
}, [hoveredHole, selectedIds, setHoveredHole])
|
||||
|
||||
if (selectedIds.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedIds.map((id) => (
|
||||
<SelectedSlabHoleHighlights key={id} slabId={id} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectedSlabHoleHighlights({ slabId }: { slabId: string }) {
|
||||
const node = useScene((state) => state.nodes[slabId as AnyNodeId])
|
||||
const override = useLiveNodeOverrides((state) => state.overrides.get(slabId))
|
||||
const hoveredHole = useEditor((state) => state.hoveredHole)
|
||||
const editingHole = useEditor((state) => state.editingHole)
|
||||
const setHoveredHole = useEditor((state) => state.setHoveredHole)
|
||||
|
||||
const slab = node?.type === 'slab' ? (node as SlabNode) : null
|
||||
const effectiveSlab = slab ? ({ ...slab, ...(override ?? {}) } as SlabNode) : null
|
||||
const holes = effectiveSlab?.holes ?? []
|
||||
const holeMetadata = effectiveSlab?.holeMetadata ?? []
|
||||
|
||||
// Portal the highlights into the slab's OWN object — exactly how
|
||||
// NodeArrowHandles portals into a node object. This is what makes R3F deliver
|
||||
// pointer events to the hit meshes (portaling into the raw scene renders but
|
||||
// never receives hover/click), and the children inherit the slab's world
|
||||
// transform so the holes sit in slab-local space with no manual math.
|
||||
const [slabObject, setSlabObject] = useState<Object3D | null>(
|
||||
() => sceneRegistry.nodes.get(slabId as AnyNodeId) ?? null,
|
||||
)
|
||||
useEffect(() => {
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(slabId as AnyNodeId) ?? null
|
||||
setSlabObject((cur) => (cur === next ? cur : next))
|
||||
if (!next) {
|
||||
frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
return () => {
|
||||
if (frameId) window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}, [slabId])
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredHole?.nodeId !== slabId) return
|
||||
if (!effectiveSlab || hoveredHole.holeIndex >= holes.length) {
|
||||
setHoveredHole(null)
|
||||
}
|
||||
}, [effectiveSlab, holes.length, hoveredHole, slabId, setHoveredHole])
|
||||
|
||||
if (!effectiveSlab || holes.length === 0 || !slabObject) return null
|
||||
|
||||
const surfaceY = getSurfaceY(effectiveSlab)
|
||||
const hitCenterY = getHitCenterY(effectiveSlab)
|
||||
const hitHeight = getHitHeight(effectiveSlab)
|
||||
|
||||
return createPortal(
|
||||
<group>
|
||||
{holes.map((hole, holeIndex) => {
|
||||
const metadata = holeMetadata[holeIndex]
|
||||
if (hole.length < 3) return null
|
||||
|
||||
const isActive =
|
||||
(hoveredHole?.nodeId === slabId && hoveredHole.holeIndex === holeIndex) ||
|
||||
(editingHole?.nodeId === slabId && editingHole.holeIndex === holeIndex)
|
||||
|
||||
return (
|
||||
<SlabHoleHighlight
|
||||
active={isActive}
|
||||
hitCenterY={hitCenterY}
|
||||
hitHeight={hitHeight}
|
||||
hole={hole}
|
||||
holeIndex={holeIndex}
|
||||
key={`${slabId}:${holeIndex}`}
|
||||
metadata={metadata}
|
||||
slabId={slabId}
|
||||
surfaceY={surfaceY}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</group>,
|
||||
slabObject,
|
||||
)
|
||||
}
|
||||
|
||||
function SlabHoleHighlight({
|
||||
active,
|
||||
hitCenterY,
|
||||
hitHeight,
|
||||
hole,
|
||||
holeIndex,
|
||||
metadata,
|
||||
slabId,
|
||||
surfaceY,
|
||||
}: {
|
||||
active: boolean
|
||||
hitCenterY: number
|
||||
hitHeight: number
|
||||
hole: HolePolygon
|
||||
holeIndex: number
|
||||
metadata: SurfaceHoleMetadata | undefined
|
||||
slabId: string
|
||||
surfaceY: number
|
||||
}) {
|
||||
const fillGeometry = useMemo(() => makeFillGeometry(hole, surfaceY), [hole, surfaceY])
|
||||
const outlineGeometry = useMemo(() => makeOutlineGeometry(hole, surfaceY), [hole, surfaceY])
|
||||
const hitGeometry = useMemo(
|
||||
() => makeHitGeometry(hole, hitCenterY, hitHeight),
|
||||
[hitCenterY, hitHeight, hole],
|
||||
)
|
||||
|
||||
useEffect(() => () => fillGeometry.dispose(), [fillGeometry])
|
||||
useEffect(() => () => outlineGeometry.dispose(), [outlineGeometry])
|
||||
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||
useEffect(() => () => clearHoveredHoleIfMatches(slabId, holeIndex), [holeIndex, slabId])
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
(event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
useEditor.getState().setHoveredHole({ nodeId: slabId, holeIndex })
|
||||
if (document.body.style.cursor !== 'pointer') {
|
||||
document.body.style.cursor = 'pointer'
|
||||
}
|
||||
},
|
||||
[holeIndex, slabId],
|
||||
)
|
||||
|
||||
const handlePointerLeave = useCallback(
|
||||
(event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
clearHoveredHoleIfMatches(slabId, holeIndex)
|
||||
resetPointerCursor()
|
||||
},
|
||||
[holeIndex, slabId],
|
||||
)
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ThreeEvent<PointerEvent>) => {
|
||||
if (event.button !== 0) return
|
||||
stopPointerPropagation(event)
|
||||
suppressNodeClickUntilPointerUp()
|
||||
useEditor.getState().setHoveredHole({ nodeId: slabId, holeIndex })
|
||||
|
||||
// Auto-managed cutouts (stair / elevator) jump to their owner so the
|
||||
// user edits the source rather than the synced hole. Everything else —
|
||||
// manual holes and holes that predate holeMetadata — opens the editor.
|
||||
if (metadata?.source === 'stair' && metadata.stairId) {
|
||||
useEditor.getState().setEditingHole(null)
|
||||
useEditor.getState().setHoveredHole(null)
|
||||
resetPointerCursor()
|
||||
selectOwnedNode(metadata.stairId, 'stair')
|
||||
return
|
||||
}
|
||||
if (metadata?.source === 'elevator' && metadata.elevatorId) {
|
||||
useEditor.getState().setEditingHole(null)
|
||||
useEditor.getState().setHoveredHole(null)
|
||||
resetPointerCursor()
|
||||
selectOwnedNode(metadata.elevatorId, 'elevator')
|
||||
return
|
||||
}
|
||||
|
||||
useEditor.getState().setEditingHole({ nodeId: slabId, holeIndex })
|
||||
useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] })
|
||||
},
|
||||
[holeIndex, metadata, slabId],
|
||||
)
|
||||
|
||||
const handlePointerUp = useCallback((event: ThreeEvent<PointerEvent>) => {
|
||||
releaseNodeClickSuppression()
|
||||
stopPointerPropagation(event)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={fillGeometry}
|
||||
material={fillMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1004}
|
||||
/>
|
||||
<lineSegments
|
||||
frustumCulled={false}
|
||||
geometry={outlineGeometry}
|
||||
material={outlineMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1005}
|
||||
/>
|
||||
{active && (
|
||||
<>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={fillGeometry}
|
||||
material={fillMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1006}
|
||||
/>
|
||||
<lineSegments
|
||||
frustumCulled={false}
|
||||
geometry={outlineGeometry}
|
||||
material={outlineMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1007}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={hitGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={hitMaterial}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerUp={handlePointerUp}
|
||||
renderOrder={1006}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlabHoleHighlights
|
||||
@@ -34,6 +34,13 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
createArrowHitAreaGeometry,
|
||||
createEndpointHitAreaGeometry,
|
||||
InvisibleHandleHitArea,
|
||||
NO_RAYCAST,
|
||||
useInvisibleHitAreaMaterial,
|
||||
} from './node-arrow-handles'
|
||||
|
||||
const HANDLE_OFFSET = 0.27
|
||||
const HANDLE_MIN_OFFSET = 0.33
|
||||
@@ -227,7 +234,8 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
||||
const billboardRef = useRef<Group>(null)
|
||||
const parentWorldQuaternionRef = useRef(new Quaternion())
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const scale = (isHovered ? 1.25 : 1) * zoom
|
||||
const baseScale = zoom
|
||||
const visualScale = isHovered ? 1.25 : 1
|
||||
|
||||
const corner = endpoint === 'start' ? wall.start : wall.end
|
||||
const x = corner[0]
|
||||
@@ -235,7 +243,10 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
||||
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
|
||||
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
|
||||
const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), [])
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
|
||||
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||
|
||||
// Node materials matched to the rest of the file — mixing plain
|
||||
// `meshBasicMaterial` with WebGPU node materials trips
|
||||
@@ -332,9 +343,10 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
||||
position={[x, 0, z]}
|
||||
renderOrder={1001}
|
||||
/>
|
||||
<group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={scale}>
|
||||
<mesh
|
||||
material={hexMaterial}
|
||||
<group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={baseScale}>
|
||||
<InvisibleHandleHitArea
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={activateEndpointMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -348,14 +360,17 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}}
|
||||
renderOrder={1003}
|
||||
>
|
||||
scale={1}
|
||||
/>
|
||||
<group scale={visualScale}>
|
||||
<mesh material={hexMaterial} raycast={NO_RAYCAST} renderOrder={1003}>
|
||||
<circleGeometry args={[CORNER_HEX_RADIUS, 6]} />
|
||||
</mesh>
|
||||
<mesh material={ringMaterial} renderOrder={1002}>
|
||||
<mesh material={ringMaterial} raycast={NO_RAYCAST} renderOrder={1002}>
|
||||
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -363,6 +378,8 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
||||
function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
const arrowMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
@@ -377,7 +394,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
||||
)
|
||||
const { camera, raycaster, gl } = useThree()
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
|
||||
const baseScale = zoom * ARROW_SCALE
|
||||
const scale = (isHovered ? 1.12 : 1) * baseScale
|
||||
const dragCleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -394,6 +412,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||
|
||||
// Sit on the visual centre of the wall — for curved walls that's the
|
||||
@@ -509,12 +528,10 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
||||
|
||||
return (
|
||||
<group position={[midX, handleY, midZ]} rotation={[0, wallAngle, 0]}>
|
||||
<group rotation={[0, Math.PI / 2, Math.PI / 2]} scale={scale}>
|
||||
<mesh
|
||||
// Geometry-as-prop + frustumCulled={false} — see WallMoveArrowHandle.
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
<group rotation={[0, Math.PI / 2, Math.PI / 2]}>
|
||||
<InvisibleHandleHitArea
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={activateHeightResize}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -528,7 +545,16 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}}
|
||||
scale={baseScale}
|
||||
/>
|
||||
<mesh
|
||||
// Geometry-as-prop + frustumCulled={false} — see WallMoveArrowHandle.
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1002}
|
||||
scale={scale}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
@@ -538,6 +564,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
||||
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
const arrowMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
@@ -554,7 +582,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
|
||||
const baseScale = zoom * ARROW_SCALE
|
||||
const scale = (isHovered ? 1.12 : 1) * baseScale
|
||||
|
||||
useEffect(() => {
|
||||
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||
@@ -569,6 +598,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||
|
||||
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
|
||||
@@ -586,16 +616,10 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
|
||||
<mesh
|
||||
// Pass geometry as a prop (not `<primitive attach="geometry">`)
|
||||
// so the mesh is never rendered with R3F's default empty
|
||||
// `BufferGeometry`. Combined with `frustumCulled={false}`, the
|
||||
// primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first
|
||||
// frame and WebGPU flags "Vertex buffer slot 0 ... was not set".
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]}>
|
||||
<InvisibleHandleHitArea
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={activateWallMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -609,7 +633,20 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}}
|
||||
scale={baseScale}
|
||||
/>
|
||||
<mesh
|
||||
// Pass geometry as a prop (not `<primitive attach="geometry">`)
|
||||
// so the mesh is never rendered with R3F's default empty
|
||||
// `BufferGeometry`. Combined with `frustumCulled={false}`, the
|
||||
// primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first
|
||||
// frame and WebGPU flags "Vertex buffer slot 0 ... was not set".
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1002}
|
||||
scale={scale}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
@@ -618,6 +655,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
const arrowMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
@@ -633,7 +672,8 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
||||
const { camera } = useThree()
|
||||
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
|
||||
const baseScale = zoom * ARROW_SCALE
|
||||
const scale = (isHovered ? 1.12 : 1) * baseScale
|
||||
|
||||
useEffect(() => {
|
||||
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||
@@ -648,6 +688,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||
|
||||
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
|
||||
@@ -664,13 +705,10 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
|
||||
<mesh
|
||||
// Pass geometry as a prop — see WallMoveArrowHandle for the
|
||||
// WebGPU "Vertex buffer slot 0 ... was not set" rationale.
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]}>
|
||||
<InvisibleHandleHitArea
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={activateFenceMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -684,7 +722,17 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}}
|
||||
scale={baseScale}
|
||||
/>
|
||||
<mesh
|
||||
// Pass geometry as a prop — see WallMoveArrowHandle for the
|
||||
// WebGPU "Vertex buffer slot 0 ... was not set" rationale.
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1002}
|
||||
scale={scale}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
|
||||
@@ -103,12 +103,13 @@ export const floorStrategy = {
|
||||
// building-local ToolManager group, so local coords are correct for both data and visuals.
|
||||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||||
const y = ctx.gridPosition.y
|
||||
|
||||
return {
|
||||
gridPosition: [x, 0, z],
|
||||
cursorPosition: [x, event.localPosition[1], z],
|
||||
gridPosition: [x, y, z],
|
||||
cursorPosition: [x, y, z],
|
||||
cursorRotationY: rotY,
|
||||
nodeUpdate: { position: [x, 0, z] },
|
||||
nodeUpdate: { position: [x, y, z] },
|
||||
stopPropagation: false,
|
||||
dirtyNodeId: null,
|
||||
}
|
||||
@@ -126,7 +127,11 @@ export const floorStrategy = {
|
||||
if (ctx.state.surface !== 'floor') return null
|
||||
if (!(ctx.levelId && ctx.draftItem)) return null
|
||||
|
||||
const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
|
||||
const pos: [number, number, number] = [
|
||||
ctx.gridPosition.x,
|
||||
ctx.gridPosition.y,
|
||||
ctx.gridPosition.z,
|
||||
]
|
||||
const valid = validators.canPlaceOnFloor(
|
||||
ctx.levelId,
|
||||
pos,
|
||||
@@ -793,7 +798,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
||||
// Floor (no attachTo)
|
||||
return validators.canPlaceOnFloor(
|
||||
ctx.levelId,
|
||||
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
||||
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
alignedDims,
|
||||
ctx.draftItem.rotation,
|
||||
[ctx.draftItem.id],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssetInput } from '@pascal-app/core'
|
||||
import type { AssetInput, ItemNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
resolveLevelId,
|
||||
type ShelfEvent,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
@@ -44,6 +43,7 @@ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||
import {
|
||||
createLineGeometry,
|
||||
getBoxEdgePoints,
|
||||
@@ -141,6 +141,22 @@ function getFallbackPreviewBounds(
|
||||
}
|
||||
}
|
||||
|
||||
function getGridAlignedPreviewNode(item: ItemNode): ItemNode {
|
||||
const scaled = getScaledDimensions(item)
|
||||
const aligned = getGridAlignedDimensions(scaled, item.asset.attachTo)
|
||||
if (scaled[0] === aligned[0] && scaled[1] === aligned[1] && scaled[2] === aligned[2]) {
|
||||
return item
|
||||
}
|
||||
|
||||
const scaleAxis = (axis: 0 | 1 | 2) =>
|
||||
scaled[axis] === 0 ? item.scale[axis] : item.scale[axis] * (aligned[axis] / scaled[axis])
|
||||
|
||||
return {
|
||||
...item,
|
||||
scale: [scaleAxis(0), scaleAxis(1), scaleAxis(2)] as [number, number, number],
|
||||
}
|
||||
}
|
||||
|
||||
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
@@ -359,6 +375,23 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
applyPoints(measurementHeightRef, heightPoints)
|
||||
}, [])
|
||||
|
||||
const getFloorVisualPosition = useCallback(
|
||||
(
|
||||
position: [number, number, number],
|
||||
nodeUpdate?: Partial<ItemNode>,
|
||||
): [number, number, number] => {
|
||||
const draft = draftNode.current
|
||||
if (!(draft && !asset?.attachTo)) return position
|
||||
const previewNode = getGridAlignedPreviewNode({ ...draft, ...nodeUpdate } as ItemNode)
|
||||
return getFloorStackPreviewPosition({
|
||||
node: previewNode,
|
||||
position,
|
||||
rotation: previewNode.rotation,
|
||||
})
|
||||
},
|
||||
[asset?.attachTo, draftNode],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!asset) return
|
||||
useScene.temporal.getState().pause()
|
||||
@@ -661,11 +694,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
result.gridPosition[1],
|
||||
result.gridPosition[2] + alignZ,
|
||||
]
|
||||
const cursorPos: [number, number, number] = [
|
||||
result.cursorPosition[0] + alignX,
|
||||
result.cursorPosition[1],
|
||||
result.cursorPosition[2] + alignZ,
|
||||
]
|
||||
|
||||
// Play snap sound when grid position changes
|
||||
if (
|
||||
@@ -677,7 +705,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
previousGridPos = [...gridPos]
|
||||
gridPosition.current.set(...gridPos)
|
||||
cursorGroupRef.current.position.set(cursorPos[0], cursorPos[1], cursorPos[2])
|
||||
const cursorPosition = getFloorVisualPosition(gridPos)
|
||||
cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
|
||||
// Floor items only rotate on Y; keep the preview box (and the live
|
||||
// transform the 2D floorplan mirrors) aligned with the draft's
|
||||
// rotation. Without this the box stays at its seed rotation until a
|
||||
@@ -946,8 +975,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
shelfId: null,
|
||||
})
|
||||
gridPosition.current.set(wx, 0, wz)
|
||||
const levelId = useViewer.getState().selection.levelId as AnyNodeId | null
|
||||
const floorVisualPosition = getFloorVisualPosition(
|
||||
floorPos,
|
||||
levelId ? { parentId: levelId } : undefined,
|
||||
)
|
||||
if (cursorGroupRef.current) {
|
||||
cursorGroupRef.current.position.set(wx, 0, wz)
|
||||
cursorGroupRef.current.position.set(...floorVisualPosition)
|
||||
}
|
||||
|
||||
const draft = draftNode.current
|
||||
@@ -1466,12 +1500,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
gridPosition.current.set(x, gridPosition.current.y, z)
|
||||
draft.position = [x, gridPosition.current.y, z]
|
||||
if (cursorGroupRef.current) {
|
||||
if (surface === 'floor') {
|
||||
cursorGroupRef.current.position.set(
|
||||
...getFloorVisualPosition([x, gridPosition.current.y, z]),
|
||||
)
|
||||
} else {
|
||||
cursorGroupRef.current.position.x = x
|
||||
cursorGroupRef.current.position.z = z
|
||||
}
|
||||
}
|
||||
if (mesh) {
|
||||
mesh.position.x = x
|
||||
mesh.position.z = z
|
||||
if (surface === 'floor') {
|
||||
mesh.position.y = getFloorVisualPosition([x, gridPosition.current.y, z])[1]
|
||||
}
|
||||
}
|
||||
} else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
|
||||
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
|
||||
@@ -1501,7 +1544,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// Update live transform for 2D floorplan with post-snap position
|
||||
const currentLive = useLiveTransforms.getState().get(draft.id)
|
||||
if (currentLive) {
|
||||
const livePosition: [number, number, number] = cursorGroupRef.current
|
||||
const livePosition: [number, number, number] =
|
||||
surface === 'floor'
|
||||
? [draft.position[0], draft.position[1], draft.position[2]]
|
||||
: cursorGroupRef.current
|
||||
? [
|
||||
cursorGroupRef.current.position.x,
|
||||
cursorGroupRef.current.position.y,
|
||||
@@ -1647,6 +1693,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
canPlaceOnWall,
|
||||
canPlaceOnCeiling,
|
||||
draftNode,
|
||||
getFloorVisualPosition,
|
||||
gridSnapStep,
|
||||
updateDimensionGuides,
|
||||
updatePreviewGeometry,
|
||||
@@ -1747,18 +1794,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
// Adjust Y for slab elevation (floor items on top of slabs)
|
||||
if (!asset.attachTo) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const levelId = resolveLevelId(draftNode.current, nodes)
|
||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||
levelId,
|
||||
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
getGridAlignedDimensions(
|
||||
getScaledDimensions(draftNode.current),
|
||||
draftNode.current.asset.attachTo,
|
||||
),
|
||||
draftNode.current.rotation,
|
||||
)
|
||||
mesh.position.y = slabElevation
|
||||
const visualPosition = getFloorVisualPosition([
|
||||
gridPosition.current.x,
|
||||
gridPosition.current.y,
|
||||
gridPosition.current.z,
|
||||
])
|
||||
mesh.position.y = visualPosition[1]
|
||||
cursorGroupRef.current.position.y = visualPosition[1]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||
import { PlacementBox } from '../shared/placement-box'
|
||||
|
||||
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
||||
@@ -174,11 +175,34 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// fresh clone after a drop, or the user picks a different catalog tile —
|
||||
// and `useState` only honours its initial value, so without this the box
|
||||
// would keep the previous clone's rotation/position until the next R/T.
|
||||
setCursorPosition(originalPosition)
|
||||
setCursorRotationY(originalRotationY)
|
||||
lastCursorRef.current = originalPosition
|
||||
let committed = false
|
||||
|
||||
const baseRotation = (node as { rotation?: unknown }).rotation
|
||||
const toCommitRotation = (y: number): number | [number, number, number] =>
|
||||
Array.isArray(baseRotation)
|
||||
? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
|
||||
: y
|
||||
|
||||
const getVisualPosition = (
|
||||
position: [number, number, number],
|
||||
rotationY = rotationRef.current,
|
||||
): [number, number, number] => {
|
||||
return getFloorStackPreviewPosition({
|
||||
node,
|
||||
position,
|
||||
rotation: toCommitRotation(rotationY),
|
||||
})
|
||||
}
|
||||
const markMovedNodeDirty = () => {
|
||||
if (useScene.getState().nodes[node.id]) {
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
||||
|
||||
// Re-run the floor-collision check at the live cursor + rotation and push
|
||||
// the result to the box colour. Shift forces a valid (green) override so
|
||||
// the user can drop on top of an existing item on purpose. Only shelves
|
||||
@@ -196,10 +220,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
setValid(true)
|
||||
return
|
||||
}
|
||||
const [x, , z] = lastCursorRef.current
|
||||
const [x, y, z] = lastCursorRef.current
|
||||
const { valid: placeable } = spatialGridManager.canPlaceOnFloor(
|
||||
levelId,
|
||||
[x, 0, z],
|
||||
[x, y, z],
|
||||
boxDimensions,
|
||||
[0, rotationRef.current, 0],
|
||||
[node.id],
|
||||
@@ -209,14 +233,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
}
|
||||
recomputeValidity()
|
||||
|
||||
// The node's rotation shape (tuple vs scalar) is preserved on commit;
|
||||
// only the Y angle changes. Most registry kinds use a `[x, y, z]` tuple.
|
||||
const baseRotation = (node as { rotation?: unknown }).rotation
|
||||
const toCommitRotation = (y: number): number | [number, number, number] =>
|
||||
Array.isArray(baseRotation)
|
||||
? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
|
||||
: y
|
||||
|
||||
// Disable raycast on the moved node's meshes for the duration of
|
||||
// the drag. As the shelf follows the cursor, the cursor ray would
|
||||
// otherwise hit the moved mesh first → only `${kind}:move` fires →
|
||||
@@ -275,13 +291,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const position: [number, number, number] = [x, originalPosition[1], z]
|
||||
const visualPosition = getVisualPosition(position)
|
||||
hasMovedRef.current = true
|
||||
setCursorPosition([x, 0, z])
|
||||
lastCursorRef.current = [x, 0, z]
|
||||
setCursorPosition(visualPosition)
|
||||
lastCursorRef.current = position
|
||||
recomputeValidity()
|
||||
|
||||
// Pure imperative: move the mesh via its registered Object3D ref.
|
||||
sceneRegistry.nodes.get(node.id)?.position.set(x, 0, z)
|
||||
sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition)
|
||||
// Publish to `useLiveTransforms` so the 2D floor plan can mirror
|
||||
// the drag in real-time (the floor-plan layer subscribes to this
|
||||
// store and overrides the node's rendered position when an entry
|
||||
@@ -293,9 +311,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// (slab / ceiling / fence) follow a different delta contract —
|
||||
// their floor-plan move-targets handle the override themselves.
|
||||
useLiveTransforms.getState().set(node.id, {
|
||||
position: [x, 0, z],
|
||||
position,
|
||||
rotation: rotationRef.current,
|
||||
})
|
||||
markMovedNodeDirty()
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (!prev || prev[0] !== x || prev[1] !== z) {
|
||||
@@ -331,6 +350,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
const position: [number, number, number] = [...lastCursorRef.current]
|
||||
|
||||
const rotation = toCommitRotation(rotationRef.current)
|
||||
const visualPosition = getVisualPosition(position)
|
||||
|
||||
if (useScene.getState().nodes[node.id]) {
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -355,18 +375,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep mesh.position/rotation aligned with the just-committed scene
|
||||
// values so the next R3F frame paints correctly even if React's
|
||||
// reconciliation lags by a tick.
|
||||
// Clear after the scene write so React reconciles against the new
|
||||
// canonical position, then restamp the lifted presentation Y for the
|
||||
// current frame.
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
const mesh = sceneRegistry.nodes.get(node.id)
|
||||
if (mesh) {
|
||||
mesh.position.set(position[0], position[1], position[2])
|
||||
mesh.position.set(...visualPosition)
|
||||
mesh.rotation.y = rotationRef.current
|
||||
}
|
||||
|
||||
// Now safe to clear — node.position is already the new value, so
|
||||
// `ParametricNodeRenderer`'s next render lands at `[x, 0, z]`.
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
@@ -405,12 +423,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
rotationRef.current += delta
|
||||
setCursorRotationY(rotationRef.current)
|
||||
const position = lastCursorRef.current
|
||||
const visualPosition = getVisualPosition(position)
|
||||
setCursorPosition(visualPosition)
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) m.rotation.y = rotationRef.current
|
||||
if (m) {
|
||||
m.position.set(...visualPosition)
|
||||
m.rotation.y = rotationRef.current
|
||||
}
|
||||
useLiveTransforms.getState().set(node.id, {
|
||||
position: lastCursorRef.current,
|
||||
position,
|
||||
rotation: rotationRef.current,
|
||||
})
|
||||
markMovedNodeDirty()
|
||||
// Rotation changes the footprint's collision span — re-check validity.
|
||||
recomputeValidity()
|
||||
}
|
||||
@@ -437,13 +462,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(originalPosition[0], originalPosition[1], originalPosition[2])
|
||||
m.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
||||
m.rotation.y = originalRotationY
|
||||
}
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
markMovedNodeDirty()
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
@@ -467,10 +493,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// unmount / commit paths uniformly.
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (!committed) {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
sceneRegistry.nodes
|
||||
.get(node.id)
|
||||
?.position.set(originalPosition[0], originalPosition[1], originalPosition[2])
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
?.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
||||
markMovedNodeDirty()
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type AnyNode, type AnyNodeId, getFloorStackedPosition, useScene } from '@pascal-app/core'
|
||||
|
||||
type FloorStackPreviewArgs = {
|
||||
node: AnyNode
|
||||
position: [number, number, number]
|
||||
rotation?: unknown
|
||||
levelId?: string | null
|
||||
nodes?: Record<AnyNodeId, AnyNode>
|
||||
}
|
||||
|
||||
export function getFloorStackPreviewPosition({
|
||||
node,
|
||||
position,
|
||||
rotation,
|
||||
levelId,
|
||||
nodes,
|
||||
}: FloorStackPreviewArgs): [number, number, number] {
|
||||
return getFloorStackedPosition({
|
||||
node,
|
||||
nodes: nodes ?? useScene.getState().nodes,
|
||||
position,
|
||||
rotation,
|
||||
levelId,
|
||||
})
|
||||
}
|
||||
@@ -11,16 +11,17 @@ import {
|
||||
type Line,
|
||||
type Object3D,
|
||||
Shape,
|
||||
SphereGeometry,
|
||||
} from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
createMoveCrossHandleGeometry,
|
||||
ARROW_COLOR as EDGE_ARROW_COLOR,
|
||||
ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR,
|
||||
ARROW_SCALE as EDGE_ARROW_SCALE,
|
||||
useArrowMaterial,
|
||||
useInvisibleHitAreaMaterial,
|
||||
} from '../../editor/node-arrow-handles'
|
||||
import { snapToHalf } from '../item/placement-math'
|
||||
|
||||
@@ -185,7 +186,11 @@ function OutlinedCylinderHandle({
|
||||
)
|
||||
}
|
||||
|
||||
function OutlinedSphereHandle({
|
||||
// Whole-polygon move grip — the generic 4-way cross-arrow (matching the node
|
||||
// move handles) with an invisible cylinder hit area, replacing the old sphere.
|
||||
// The cross sits on SCENE_LAYER (so the ink pass outlines it) while the
|
||||
// cylinder hit mesh is on EDITOR_LAYER (grabbable, out of the MRT scene pass).
|
||||
function OutlinedCrossHandle({
|
||||
color,
|
||||
position,
|
||||
...handlers
|
||||
@@ -193,20 +198,33 @@ function OutlinedSphereHandle({
|
||||
color: string
|
||||
position: [number, number, number]
|
||||
} & HandleHandlers) {
|
||||
const geometry = useMemo(() => new SphereGeometry(0.09, 20, 20), [])
|
||||
const geometry = useMemo(() => createMoveCrossHandleGeometry(), [])
|
||||
const material = usePolygonNodeMaterial(color)
|
||||
const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), [])
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={geometry}
|
||||
layers={SCENE_LAYER}
|
||||
material={material}
|
||||
position={position}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1010}
|
||||
scale={EDGE_ARROW_SCALE}
|
||||
/>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={hitGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={hitMaterial}
|
||||
renderOrder={1011}
|
||||
{...handlers}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -651,7 +669,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<OutlinedCylinderHandle
|
||||
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
|
||||
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
height={height}
|
||||
key={`vertex-${index}`}
|
||||
onClick={(e) => {
|
||||
@@ -693,8 +711,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
})}
|
||||
|
||||
{allowPolygonMove && (
|
||||
<OutlinedSphereHandle
|
||||
color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'}
|
||||
<OutlinedCrossHandle
|
||||
color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
@@ -760,7 +778,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
>
|
||||
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
|
||||
<meshBasicMaterial
|
||||
color={isDragging ? '#22c55e' : '#60a5fa'}
|
||||
color={isDragging ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
|
||||
transparent
|
||||
/>
|
||||
@@ -769,9 +787,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
Points outward from the edge; dragging it translates only this
|
||||
edge's two vertices along the outward normal. */}
|
||||
<OutlinedEdgeArrowHandle
|
||||
color={
|
||||
isDragging ? '#22c55e' : isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR
|
||||
}
|
||||
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
geometry={arrowGeometry}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
@@ -807,7 +823,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<OutlinedCylinderHandle
|
||||
color={isHovered ? '#4ade80' : '#22c55e'}
|
||||
color={isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
height={height}
|
||||
key={`midpoint-${index}`}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||
import {
|
||||
DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
||||
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
||||
@@ -73,19 +74,10 @@ function createStairPreviewGeometry(): THREE.BufferGeometry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stair group with one default stair segment at the given position/rotation.
|
||||
* Creates a default straight stair segment.
|
||||
*/
|
||||
function commitStairPlacement(
|
||||
levelId: LevelNode['id'],
|
||||
position: [number, number, number],
|
||||
rotation: number,
|
||||
): void {
|
||||
const { createNodes, nodes } = useScene.getState()
|
||||
|
||||
const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
|
||||
const name = `Staircase ${stairCount + 1}`
|
||||
|
||||
const segment = StairSegmentNode.parse({
|
||||
function createDefaultStairSegment() {
|
||||
return StairSegmentNode.parse({
|
||||
segmentType: 'stair',
|
||||
width: DEFAULT_STAIR_WIDTH,
|
||||
length: DEFAULT_STAIR_LENGTH,
|
||||
@@ -96,14 +88,24 @@ function commitStairPlacement(
|
||||
thickness: DEFAULT_STAIR_THICKNESS,
|
||||
position: [0, 0, 0],
|
||||
})
|
||||
}
|
||||
|
||||
const sortedLevels = Object.values(nodes)
|
||||
.filter((node): node is LevelNode => node.type === 'level')
|
||||
.sort((left, right) => left.level - right.level)
|
||||
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
|
||||
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
|
||||
|
||||
const stair = StairNode.parse({
|
||||
function createDefaultStairNode({
|
||||
name,
|
||||
levelId,
|
||||
nextLevelId,
|
||||
position,
|
||||
rotation,
|
||||
segmentId,
|
||||
}: {
|
||||
name: string
|
||||
levelId: LevelNode['id']
|
||||
nextLevelId: LevelNode['id']
|
||||
position: [number, number, number]
|
||||
rotation: number
|
||||
segmentId: StairSegmentNode['id']
|
||||
}) {
|
||||
return StairNode.parse({
|
||||
name,
|
||||
position,
|
||||
rotation,
|
||||
@@ -125,7 +127,37 @@ function commitStairPlacement(
|
||||
showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
||||
railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
|
||||
railingMode: DEFAULT_STAIR_RAILING_MODE,
|
||||
children: [segment.id],
|
||||
children: [segmentId],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stair group with one default stair segment at the given position/rotation.
|
||||
*/
|
||||
function commitStairPlacement(
|
||||
levelId: LevelNode['id'],
|
||||
position: [number, number, number],
|
||||
rotation: number,
|
||||
): void {
|
||||
const { createNodes, nodes } = useScene.getState()
|
||||
|
||||
const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
|
||||
const name = `Staircase ${stairCount + 1}`
|
||||
const segment = createDefaultStairSegment()
|
||||
|
||||
const sortedLevels = Object.values(nodes)
|
||||
.filter((node): node is LevelNode => node.type === 'level')
|
||||
.sort((left, right) => left.level - right.level)
|
||||
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
|
||||
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
|
||||
|
||||
const stair = createDefaultStairNode({
|
||||
name,
|
||||
levelId,
|
||||
nextLevelId,
|
||||
position,
|
||||
rotation,
|
||||
segmentId: segment.id,
|
||||
})
|
||||
|
||||
createNodes([
|
||||
@@ -141,6 +173,7 @@ export const StairTool: React.FC = () => {
|
||||
const previewRef = useRef<THREE.Group>(null)
|
||||
const rotationRef = useRef(0)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
|
||||
const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
|
||||
@@ -151,6 +184,49 @@ export const StairTool: React.FC = () => {
|
||||
// Reset rotation when tool activates
|
||||
rotationRef.current = 0
|
||||
if (previewRef.current) previewRef.current.rotation.y = 0
|
||||
lastCanonicalPositionRef.current = null
|
||||
|
||||
const getPreviewPosition = (
|
||||
position: [number, number, number],
|
||||
rotation: number,
|
||||
): [number, number, number] => {
|
||||
const segment = createDefaultStairSegment()
|
||||
const stair = createDefaultStairNode({
|
||||
name: 'Staircase Preview',
|
||||
levelId: currentLevelId,
|
||||
nextLevelId: currentLevelId,
|
||||
position,
|
||||
rotation,
|
||||
segmentId: segment.id,
|
||||
})
|
||||
return getFloorStackPreviewPosition({
|
||||
node: stair,
|
||||
position,
|
||||
rotation,
|
||||
levelId: currentLevelId,
|
||||
nodes: {
|
||||
...useScene.getState().nodes,
|
||||
[stair.id]: stair,
|
||||
[segment.id]: segment,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const applyPreview = (position: [number, number, number], rotation: number) => {
|
||||
const visualPosition = getPreviewPosition(position, rotation)
|
||||
if (cursorRef.current) {
|
||||
cursorRef.current.position.set(
|
||||
visualPosition[0],
|
||||
visualPosition[1] + GRID_OFFSET,
|
||||
visualPosition[2],
|
||||
)
|
||||
}
|
||||
|
||||
if (previewRef.current) {
|
||||
previewRef.current.position.set(...visualPosition)
|
||||
previewRef.current.rotation.y = rotation
|
||||
}
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each placement. The stair aligns by its ORIGIN point.
|
||||
@@ -199,15 +275,9 @@ export const StairTool: React.FC = () => {
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true,
|
||||
)
|
||||
const y = event.localPosition[1]
|
||||
|
||||
if (cursorRef.current) {
|
||||
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
|
||||
}
|
||||
|
||||
if (previewRef.current) {
|
||||
previewRef.current.position.set(gridX, y, gridZ)
|
||||
}
|
||||
const position: [number, number, number] = [gridX, 0, gridZ]
|
||||
lastCanonicalPositionRef.current = position
|
||||
applyPreview(position, rotationRef.current)
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
@@ -248,7 +318,9 @@ export const StairTool: React.FC = () => {
|
||||
event.preventDefault()
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
rotationRef.current += rotationDelta
|
||||
if (previewRef.current) {
|
||||
if (lastCanonicalPositionRef.current) {
|
||||
applyPreview(lastCanonicalPositionRef.current, rotationRef.current)
|
||||
} else if (previewRef.current) {
|
||||
previewRef.current.rotation.y = rotationRef.current
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,11 @@ export const ToolManager: React.FC = () => {
|
||||
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
|
||||
| SlabNode['id']
|
||||
| undefined
|
||||
const selectedSlab = selectedSlabId ? (nodes[selectedSlabId as AnyNodeId] as SlabNode) : null
|
||||
const editingSlabHoleIsManual =
|
||||
selectedSlabId !== undefined &&
|
||||
editingHole?.nodeId === selectedSlabId &&
|
||||
selectedSlab?.holeMetadata?.[editingHole.holeIndex]?.source === 'manual'
|
||||
|
||||
// Check if a ceiling is selected
|
||||
const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
|
||||
@@ -92,11 +97,14 @@ export const ToolManager: React.FC = () => {
|
||||
phase === 'structure' &&
|
||||
mode === 'select' &&
|
||||
selectedSlabId !== undefined &&
|
||||
(!editingHole || editingHole.nodeId !== selectedSlabId)
|
||||
!editingSlabHoleIsManual
|
||||
|
||||
// Show slab hole editor when editing a hole on the selected slab
|
||||
const showSlabHoleEditor =
|
||||
selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId
|
||||
selectedSlabId !== undefined &&
|
||||
editingHole !== null &&
|
||||
editingHole.nodeId === selectedSlabId &&
|
||||
editingSlabHoleIsManual
|
||||
|
||||
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
|
||||
const showCeilingBoundaryEditor =
|
||||
|
||||
@@ -60,6 +60,7 @@ export {
|
||||
} from './components/tools/item/use-placement-coordinator'
|
||||
export { CursorSphere } from './components/tools/shared/cursor-sphere'
|
||||
export { DragBoundingBox } from './components/tools/shared/drag-bounding-box'
|
||||
export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview'
|
||||
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
|
||||
export {
|
||||
PolygonEditor,
|
||||
|
||||
@@ -355,6 +355,7 @@ function resetEditorInteractionState() {
|
||||
selectedReferenceId: null,
|
||||
spaces: {},
|
||||
editingHole: null,
|
||||
hoveredHole: null,
|
||||
isPreviewMode: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,6 +158,8 @@ type MaterialPaintSelectionSnapshot = {
|
||||
activePaintMaterial: ActivePaintMaterial | null
|
||||
}
|
||||
|
||||
export type SurfaceHoleTarget = { nodeId: string; holeIndex: number }
|
||||
|
||||
export type GuideUiState = {
|
||||
locked?: boolean
|
||||
scaleReferenceVisible?: boolean
|
||||
@@ -296,8 +298,10 @@ type EditorState = {
|
||||
spaces: Record<string, Space>
|
||||
setSpaces: (spaces: Record<string, Space>) => void
|
||||
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
|
||||
editingHole: { nodeId: string; holeIndex: number } | null
|
||||
setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
|
||||
editingHole: SurfaceHoleTarget | null
|
||||
setEditingHole: (hole: SurfaceHoleTarget | null) => void
|
||||
hoveredHole: SurfaceHoleTarget | null
|
||||
setHoveredHole: (hole: SurfaceHoleTarget | null) => void
|
||||
// Preview mode (viewer-like experience inside the editor)
|
||||
isPreviewMode: boolean
|
||||
setPreviewMode: (preview: boolean) => void
|
||||
@@ -808,6 +812,14 @@ const useEditor = create<EditorState>()(
|
||||
setSpaces: (spaces) => set({ spaces }),
|
||||
editingHole: null,
|
||||
setEditingHole: (hole) => set({ editingHole: hole }),
|
||||
hoveredHole: null,
|
||||
setHoveredHole: (hole) =>
|
||||
set((state) =>
|
||||
state.hoveredHole?.nodeId === hole?.nodeId &&
|
||||
state.hoveredHole?.holeIndex === hole?.holeIndex
|
||||
? state
|
||||
: { hoveredHole: hole },
|
||||
),
|
||||
isPreviewMode: false,
|
||||
setPreviewMode: (preview) => {
|
||||
if (preview) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const BRACE_HANDLE_OFFSET = 0.3
|
||||
const SPREAD_HANDLE_OFFSET = 0.22
|
||||
const ROTATE_CORNER_OFFSET = 0.32
|
||||
const ROTATE_RING_OFFSET = 0.04
|
||||
const MOVE_FRONT_OFFSET = 0.35
|
||||
const MIN_COLUMN_HEIGHT = 0.2
|
||||
const MIN_COLUMN_WIDTH = 0.1
|
||||
const MIN_COLUMN_DEPTH = 0.1
|
||||
@@ -241,6 +242,29 @@ function columnRotateHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
}
|
||||
}
|
||||
|
||||
function columnMoveHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
return {
|
||||
kind: 'translate',
|
||||
placement: {
|
||||
// Low to the floor at the front edge (matches the item move grip) so it
|
||||
// reads as a floor-move grip and stays clear of the body resize / rotate
|
||||
// handles that sit at mid-height.
|
||||
position: (n) => {
|
||||
const { halfZ } = columnFootprintHalf(n)
|
||||
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
|
||||
},
|
||||
},
|
||||
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
|
||||
snapExtents: (n) => {
|
||||
const { halfX, halfZ } = columnFootprintHalf(n)
|
||||
const dimX = Math.max(halfX * 2, MIN_COLUMN_WIDTH)
|
||||
const dimZ = Math.max(halfZ * 2, MIN_COLUMN_DEPTH)
|
||||
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
|
||||
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[] {
|
||||
// 1. Height (universal).
|
||||
// 2. Footprint arrows depending on supportStyle + crossSection:
|
||||
@@ -265,7 +289,7 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
|
||||
} else {
|
||||
handles.push(columnAxisHandle('x'), columnAxisHandle('z'))
|
||||
}
|
||||
handles.push(columnRotateHandle())
|
||||
handles.push(columnRotateHandle(), columnMoveHandle())
|
||||
return handles
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
CursorSphere,
|
||||
DragBoundingBox,
|
||||
getFloorStackPreviewPosition,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
@@ -74,6 +75,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
const getVisualPosition = (
|
||||
position: [number, number, number],
|
||||
rotation = rotationY,
|
||||
): [number, number, number] =>
|
||||
getFloorStackPreviewPosition({
|
||||
node,
|
||||
position,
|
||||
rotation,
|
||||
levelId: node.parentId ?? null,
|
||||
})
|
||||
|
||||
// Alignment candidates — every other alignable object's anchors, gathered
|
||||
// once (the scene graph is stable during the imperative drag).
|
||||
@@ -81,19 +92,23 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
|
||||
const applyPreview = (position: [number, number, number]) => {
|
||||
lastPosition = position
|
||||
setPreviewPosition(position)
|
||||
const visualPosition = getVisualPosition(position)
|
||||
setPreviewPosition(visualPosition)
|
||||
setPreviewRotation(rotationY)
|
||||
useLiveTransforms.getState().set(node.id, {
|
||||
position,
|
||||
rotation: rotationY,
|
||||
})
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(position[0], position[1], position[2])
|
||||
m.position.set(...visualPosition)
|
||||
m.rotation.y = rotationY
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewPosition(getVisualPosition(node.position, node.rotation))
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
hasMoved = true
|
||||
let x = snapToGridStep(event.localPosition[0])
|
||||
@@ -145,11 +160,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
|
||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||
committed = true
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) })
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
const m = sceneRegistry.nodes.get(nodeId)
|
||||
if (m) {
|
||||
m.position.set(...getVisualPosition(position, rotationY))
|
||||
m.rotation.y = rotationY
|
||||
}
|
||||
} else if (node.parentId) {
|
||||
const column = ColumnNodeSchema.parse({
|
||||
...node,
|
||||
@@ -174,9 +194,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(node.position[0], node.position[1], node.position[2])
|
||||
m.position.set(...getVisualPosition(node.position, node.rotation))
|
||||
m.rotation.y = node.rotation
|
||||
}
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
@@ -197,9 +218,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
if (!committed) {
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(node.position[0], node.position[1], node.position[2])
|
||||
m.position.set(...getVisualPosition(node.position, node.rotation))
|
||||
m.rotation.y = node.rotation
|
||||
}
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { getFloorStackPreviewPosition, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
@@ -91,13 +91,20 @@ const ColumnTool = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
cursorRef.current?.position.set(ax, event.localPosition[1], az)
|
||||
const position: [number, number, number] = [ax, 0, az]
|
||||
const visualPosition = getFloorStackPreviewPosition({
|
||||
node: previewNode,
|
||||
position,
|
||||
rotation: previewNode.rotation,
|
||||
levelId: activeLevelId,
|
||||
})
|
||||
cursorRef.current?.position.set(...visualPosition)
|
||||
|
||||
// Publish a transient, positioned preview node for the 2D floor-plan
|
||||
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan
|
||||
// placement-preview layer renders this node's footprint at the snapped,
|
||||
// aligned cursor so users see the pillar before they click.
|
||||
usePlacementPreview.getState().set({ ...previewNode, position: [ax, 0, az] })
|
||||
usePlacementPreview.getState().set({ ...previewNode, position })
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
nodeRegistry,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
resolveAlignment,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import {
|
||||
CursorSphere,
|
||||
clearRoofDuplicateMetadata,
|
||||
getFloorStackPreviewPosition,
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
@@ -113,6 +115,11 @@ export const MoveRoofTool: React.FC<{
|
||||
|
||||
// Track pending rotation — no store updates during drag
|
||||
let pendingRotation: number = movingNode.rotation as number
|
||||
let lastLocalPosition: [number, number, number] = [
|
||||
movingNode.position[0],
|
||||
movingNode.position[1],
|
||||
movingNode.position[2],
|
||||
]
|
||||
|
||||
// For roof-segment moves: the selection was cleared before entering move mode,
|
||||
// so isSelected=false on the parent roof, hiding individual segment meshes and
|
||||
@@ -150,6 +157,20 @@ export const MoveRoofTool: React.FC<{
|
||||
}
|
||||
|
||||
const levelId = resolveLevelId()
|
||||
const isFloorPlaced = nodeRegistry.get(movingNode.type)?.capabilities?.floorPlaced !== undefined
|
||||
const getPreviewPosition = (
|
||||
position: [number, number, number],
|
||||
rotation = pendingRotation,
|
||||
): [number, number, number] => {
|
||||
if (!isFloorPlaced) return position
|
||||
return getFloorStackPreviewPosition({
|
||||
node: movingNode,
|
||||
position,
|
||||
rotation,
|
||||
levelId,
|
||||
nodes: useScene.getState().nodes,
|
||||
})
|
||||
}
|
||||
const levelNode =
|
||||
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
|
||||
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
|
||||
@@ -260,20 +281,28 @@ export const MoveRoofTool: React.FC<{
|
||||
}
|
||||
|
||||
previousGridPosRef.current = [gridX, gridZ]
|
||||
setCursorWorldPos([lx, event.localPosition[1], lz])
|
||||
|
||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
||||
lastLocalPosition = [localX, movingNode.position[1], localZ]
|
||||
const previewPosition = getPreviewPosition(lastLocalPosition)
|
||||
setCursorWorldPos(isFloorPlaced ? previewPosition : [lx, event.localPosition[1], lz])
|
||||
|
||||
// Directly update the Three.js mesh — no store update during drag
|
||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||
if (mesh) {
|
||||
if (isFloorPlaced) {
|
||||
mesh.position.set(...previewPosition)
|
||||
} else {
|
||||
mesh.position.x = localX
|
||||
mesh.position.z = localZ
|
||||
}
|
||||
}
|
||||
|
||||
// Publish world-space position so the 2D floorplan can track the drag
|
||||
// Publish canonical position so the 2D floorplan can track the drag.
|
||||
// Floor-placed parents (stairs) stay in their committed local frame;
|
||||
// the lifted Y remains presentation-only in the 3D view.
|
||||
useLiveTransforms.getState().set(movingNode.id, {
|
||||
position: [gridX, y, gridZ],
|
||||
position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ],
|
||||
rotation: pendingRotation,
|
||||
})
|
||||
}
|
||||
@@ -359,7 +388,14 @@ export const MoveRoofTool: React.FC<{
|
||||
|
||||
// Directly update the Three.js mesh — no store update during drag
|
||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||
if (mesh) mesh.rotation.y = pendingRotation
|
||||
if (mesh) {
|
||||
mesh.rotation.y = pendingRotation
|
||||
if (isFloorPlaced) {
|
||||
const previewPosition = getPreviewPosition(lastLocalPosition, pendingRotation)
|
||||
mesh.position.set(...previewPosition)
|
||||
setCursorWorldPos(previewPosition)
|
||||
}
|
||||
}
|
||||
|
||||
// Update live transform rotation for 2D floorplan
|
||||
const currentLive = useLiveTransforms.getState().get(movingNode.id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core'
|
||||
import { sanitizeShelfDimensions } from './dimensions'
|
||||
import { buildShelfFloorplan } from './floorplan'
|
||||
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
|
||||
import { shelfFloorplanMoveTarget } from './floorplan-move'
|
||||
@@ -10,6 +11,7 @@ const SIDE_HANDLE_OFFSET = 0.18
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const ROTATE_CORNER_OFFSET = 0.32
|
||||
const ROTATE_RING_OFFSET = 0.04
|
||||
const MOVE_FRONT_OFFSET = 0.35
|
||||
const MIN_SHELF_WIDTH = 0.3
|
||||
const MIN_SHELF_DEPTH = 0.1
|
||||
const MIN_SHELF_HEIGHT = 0.05
|
||||
@@ -95,8 +97,35 @@ function shelfRotateHandle(): HandleDescriptor<ShelfNodeType> {
|
||||
}
|
||||
}
|
||||
|
||||
function shelfMoveHandle(): HandleDescriptor<ShelfNodeType> {
|
||||
return {
|
||||
kind: 'translate',
|
||||
placement: {
|
||||
// Low to the floor at the front edge (matches the item move grip) so it
|
||||
// reads as a floor-move grip and stays clear of the body resize / rotate
|
||||
// handles that sit at mid-height.
|
||||
position: (n) => {
|
||||
const shelf = sanitizeShelfDimensions(n as ShelfNode)
|
||||
return [0, 0.02, shelf.depth / 2 + MOVE_FRONT_OFFSET]
|
||||
},
|
||||
},
|
||||
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
|
||||
snapExtents: (n) => {
|
||||
const shelf = sanitizeShelfDimensions(n as ShelfNode)
|
||||
const swap = Math.abs(Math.sin(shelf.rotation[1] ?? 0)) > 0.9
|
||||
return [swap ? shelf.depth : shelf.width, swap ? shelf.width : shelf.depth]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function shelfHandles(_node: ShelfNodeType): HandleDescriptor<ShelfNodeType>[] {
|
||||
return [shelfWidthHandle(), shelfDepthHandle(), shelfHeightHandle(), shelfRotateHandle()]
|
||||
return [
|
||||
shelfWidthHandle(),
|
||||
shelfDepthHandle(),
|
||||
shelfHeightHandle(),
|
||||
shelfRotateHandle(),
|
||||
shelfMoveHandle(),
|
||||
]
|
||||
}
|
||||
|
||||
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
@@ -158,7 +187,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
// shelf sitting over a raised slab visually rests on top of it.
|
||||
floorPlaced: {
|
||||
footprint: (node) => {
|
||||
const shelf = node as ShelfNode
|
||||
const shelf = sanitizeShelfDimensions(node as ShelfNode)
|
||||
return {
|
||||
dimensions: [shelf.width, shelf.height, shelf.depth] as [number, number, number],
|
||||
rotation: shelf.rotation,
|
||||
@@ -189,7 +218,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
// `children`. Lets <GeometrySystem> skip the dispose+rebuild (and the
|
||||
// pointer enter/leave churn it causes) when an item reparents onto a row.
|
||||
geometryKey: (n) => {
|
||||
const s = n as ShelfNodeType
|
||||
const s = sanitizeShelfDimensions(n as ShelfNode)
|
||||
return JSON.stringify([
|
||||
s.style,
|
||||
s.width,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ShelfNode } from './schema'
|
||||
|
||||
function clampShelfDim(value: unknown, lo: number, hi: number, fallback: number): number {
|
||||
const v = typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
return Math.min(Math.max(v, lo), hi)
|
||||
}
|
||||
|
||||
export function sanitizeShelfDimensions(node: ShelfNode): ShelfNode {
|
||||
return {
|
||||
...node,
|
||||
width: clampShelfDim(node.width, 0.3, 3.0, 1.2),
|
||||
depth: clampShelfDim(node.depth, 0.1, 1.0, 0.3),
|
||||
thickness: clampShelfDim(node.thickness, 0.01, 0.1, 0.04),
|
||||
height: clampShelfDim(node.height, 0.05, 2.5, 0.9),
|
||||
rows: Math.round(clampShelfDim(node.rows, 1, 8, 1)),
|
||||
columns: Math.round(clampShelfDim(node.columns, 1, 6, 1)),
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
getFloorStackPreviewPosition,
|
||||
snapPointToGrid,
|
||||
triggerSFX,
|
||||
type WallPlanPoint,
|
||||
@@ -82,6 +83,12 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
}
|
||||
const visualPosition = getFloorStackPreviewPosition({
|
||||
node,
|
||||
position: next,
|
||||
rotation: node.rotation,
|
||||
levelId: node.parentId ?? null,
|
||||
})
|
||||
// Single source of truth — write the absolute position straight to
|
||||
// the scene (history is paused by the overlay). Both the 2D SVG and
|
||||
// the 3D group transform read `node.position` reactively, so they
|
||||
@@ -90,7 +97,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: shelfId,
|
||||
data: { position: next },
|
||||
data: { position: visualPosition },
|
||||
},
|
||||
])
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
|
||||
import { sanitizeShelfDimensions } from './dimensions'
|
||||
import type { ShelfResizePayload } from './floorplan-affordances'
|
||||
import type { ShelfNode } from './schema'
|
||||
|
||||
@@ -25,15 +26,16 @@ const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||
* (engaged from the action-menu Move button, not from these arrows).
|
||||
*/
|
||||
export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry {
|
||||
const [px, , pz] = node.position
|
||||
const ry = node.rotation[1] ?? 0
|
||||
const shelf = sanitizeShelfDimensions(node)
|
||||
const [px, , pz] = shelf.position
|
||||
const ry = shelf.rotation[1] ?? 0
|
||||
// Floor-plan plots at `-ry` so SVG's CW-with-y-down `rotate` direction
|
||||
// ends up visually matching Three.js Y-rotation (CCW from a top-down
|
||||
// view) — same `rotation` value rotates the same way in both views.
|
||||
// Stair already does this; column / shelf / roof-segment now do too.
|
||||
const planRy = -ry
|
||||
const halfW = node.width / 2
|
||||
const halfD = node.depth / 2
|
||||
const halfW = shelf.width / 2
|
||||
const halfD = shelf.depth / 2
|
||||
const isSelected = ctx?.viewState?.selected ?? false
|
||||
|
||||
// Floor-plan fill: a single neutral fill regardless of `material`.
|
||||
@@ -44,8 +46,8 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo
|
||||
kind: 'rect',
|
||||
x: -halfW,
|
||||
y: -halfD,
|
||||
width: node.width,
|
||||
height: node.depth,
|
||||
width: shelf.width,
|
||||
height: shelf.depth,
|
||||
fill: '#d6d3d1',
|
||||
stroke: '#1f2937',
|
||||
strokeWidth: 0.015,
|
||||
@@ -55,17 +57,17 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo
|
||||
|
||||
// Show column dividers for grid-style shelves so the cubby / bookshelf
|
||||
// grid is visible from above.
|
||||
if ((node.style === 'bookshelf' || node.style === 'cubby') && node.columns > 1) {
|
||||
const innerWidth = node.width - 2 * node.thickness
|
||||
const colStep = innerWidth / node.columns
|
||||
for (let c = 1; c < node.columns; c++) {
|
||||
if ((shelf.style === 'bookshelf' || shelf.style === 'cubby') && shelf.columns > 1) {
|
||||
const innerWidth = shelf.width - 2 * shelf.thickness
|
||||
const colStep = innerWidth / shelf.columns
|
||||
for (let c = 1; c < shelf.columns; c++) {
|
||||
const x = -innerWidth / 2 + c * colStep
|
||||
footprintChildren.push({
|
||||
kind: 'line',
|
||||
x1: x,
|
||||
y1: -halfD + node.thickness,
|
||||
y1: -halfD + shelf.thickness,
|
||||
x2: x,
|
||||
y2: halfD - node.thickness,
|
||||
y2: halfD - shelf.thickness,
|
||||
stroke: '#1f2937',
|
||||
strokeWidth: 0.012,
|
||||
opacity: 0.7,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type RenderShading,
|
||||
} from '@pascal-app/viewer'
|
||||
import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three'
|
||||
import { sanitizeShelfDimensions } from './dimensions'
|
||||
import type { ShelfNode } from './schema'
|
||||
|
||||
/**
|
||||
@@ -69,10 +70,11 @@ function getShelfMaterial(node: ShelfNode, shading: RenderShading): Material {
|
||||
}
|
||||
|
||||
export function buildShelfGeometry(
|
||||
node: ShelfNode,
|
||||
rawNode: ShelfNode,
|
||||
_ctx?: unknown,
|
||||
shading: RenderShading = 'rendered',
|
||||
): Group {
|
||||
const node = sanitizeShelfDimensions(rawNode)
|
||||
const group = new Group()
|
||||
group.name = 'shelf-geometry'
|
||||
|
||||
@@ -343,8 +345,9 @@ function addCornerPosts(
|
||||
* can host in the lowest cell.
|
||||
*/
|
||||
export function shelfRowSurfaceYs(node: ShelfNode): number[] {
|
||||
const ys = boardCenterYs(node).map((y) => y + node.thickness / 2)
|
||||
const bottomApplies = node.style === 'cubby' || node.style === 'bookshelf'
|
||||
if (node.withBottom && bottomApplies) ys.unshift(node.thickness)
|
||||
const safe = sanitizeShelfDimensions(node)
|
||||
const ys = boardCenterYs(safe).map((y) => y + safe.thickness / 2)
|
||||
const bottomApplies = safe.style === 'cubby' || safe.style === 'bookshelf'
|
||||
if (safe.withBottom && bottomApplies) ys.unshift(safe.thickness)
|
||||
return ys
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { getFloorStackPreviewPosition, triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { type Group, Vector3 } from 'three'
|
||||
@@ -70,16 +70,16 @@ function getLevelLocalPosition(
|
||||
const local = (event as GridEvent).localPosition
|
||||
if (local) {
|
||||
const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP)
|
||||
return [sx, local[1] ?? 0, sz]
|
||||
return [sx, 0, sz]
|
||||
}
|
||||
const [sx, sz] = snapPointToGrid([event.position[0], event.position[2]], GRID_STEP)
|
||||
return [sx, event.position[1], sz]
|
||||
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, worldVector.y, sz]
|
||||
return [sx, 0, sz]
|
||||
}
|
||||
|
||||
const ShelfTool = () => {
|
||||
@@ -150,8 +150,15 @@ const ShelfTool = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
cursorRef.current?.position.set(ax, event.localPosition[1], az)
|
||||
lastCursorRef.current = [ax, event.localPosition[1], az]
|
||||
const position: [number, number, number] = [ax, 0, az]
|
||||
const visualPosition = getFloorStackPreviewPosition({
|
||||
node: previewNode,
|
||||
position,
|
||||
rotation: previewNode.rotation,
|
||||
levelId: activeLevelId,
|
||||
})
|
||||
cursorRef.current?.position.set(...visualPosition)
|
||||
lastCursorRef.current = position
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
|
||||
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
||||
const holes = slab?.holes || []
|
||||
const hole = holes[holeIndex]
|
||||
const metadata = slab?.holeMetadata?.[holeIndex]
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
@@ -60,7 +61,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
|
||||
}
|
||||
}, [slabId])
|
||||
|
||||
if (!(slab && hole) || hole.length < 3) return null
|
||||
if (!(slab && hole) || hole.length < 3 || metadata?.source !== 'manual') return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core'
|
||||
import { buildSpawnFloorplan } from './floorplan'
|
||||
import { spawnParametrics } from './parametrics'
|
||||
import { SpawnNode } from './schema'
|
||||
|
||||
const SPAWN_FOOTPRINT = 0.6
|
||||
const MOVE_FRONT_OFFSET = 0.35
|
||||
|
||||
function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> {
|
||||
return {
|
||||
kind: 'translate',
|
||||
placement: {
|
||||
// Low to the floor at the front edge (matches the item move grip).
|
||||
position: () => [0, 0.02, SPAWN_FOOTPRINT / 2 + MOVE_FRONT_OFFSET],
|
||||
},
|
||||
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
|
||||
snapExtents: () => [SPAWN_FOOTPRINT, SPAWN_FOOTPRINT],
|
||||
}
|
||||
}
|
||||
|
||||
export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||
kind: 'spawn',
|
||||
schemaVersion: 1,
|
||||
@@ -37,6 +52,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||
},
|
||||
|
||||
parametrics: spawnParametrics,
|
||||
handles: [spawnMoveHandle()],
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type SpawnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type SpawnNode,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Color, type Group, Shape } from 'three'
|
||||
@@ -20,6 +26,11 @@ const SPAWN_COLOR = new Color('#22c55e')
|
||||
const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
const handlers = useNodeEvents(node, 'spawn')
|
||||
const liveOverride = useLiveNodeOverrides((state) => state.get(node.id as AnyNodeId))
|
||||
const effectiveNode = useMemo(
|
||||
() => (liveOverride ? ({ ...node, ...liveOverride } as SpawnNode) : node),
|
||||
[node, liveOverride],
|
||||
)
|
||||
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
||||
const walkthroughMode = useViewer((state) => state.walkthroughMode)
|
||||
const shading = useViewer((state) => state.shading)
|
||||
@@ -52,10 +63,10 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
|
||||
|
||||
return (
|
||||
<group
|
||||
position={liveTransform?.position ?? node.position}
|
||||
position={liveTransform?.position ?? effectiveNode.position}
|
||||
ref={ref}
|
||||
rotation={[0, liveTransform?.rotation ?? node.rotation, 0]}
|
||||
visible={!walkthroughMode}
|
||||
rotation={[0, liveTransform?.rotation ?? effectiveNode.rotation, 0]}
|
||||
visible={!walkthroughMode && effectiveNode.visible !== false}
|
||||
>
|
||||
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
|
||||
<ringGeometry args={[0.34, 0.48, 48]} />
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { CursorSphere, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
CursorSphere,
|
||||
getFloorStackPreviewPosition,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { type Group, Vector3 } from 'three'
|
||||
@@ -20,16 +25,12 @@ function getExistingSpawnIds() {
|
||||
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] {
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
if (!levelObject) {
|
||||
return [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
event.localPosition[1],
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
return [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]
|
||||
}
|
||||
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
levelObject.worldToLocal(worldVector)
|
||||
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
|
||||
return [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,19 @@ const SpawnTool = () => {
|
||||
// same half-meter snap the legacy tool uses.
|
||||
const nextX = roundToHalf(event.localPosition[0])
|
||||
const nextZ = roundToHalf(event.localPosition[2])
|
||||
cursorRef.current?.position.set(nextX, event.localPosition[1], nextZ)
|
||||
const position: [number, number, number] = [nextX, 0, nextZ]
|
||||
const previewNode = SpawnNode.parse({
|
||||
name: 'Spawn Point',
|
||||
position,
|
||||
rotation: 0,
|
||||
})
|
||||
const visualPosition = getFloorStackPreviewPosition({
|
||||
node: previewNode,
|
||||
position,
|
||||
rotation: 0,
|
||||
levelId: activeLevelId,
|
||||
})
|
||||
cursorRef.current?.position.set(...visualPosition)
|
||||
|
||||
// Fire grid-snap SFX only when the snapped position crosses a cell,
|
||||
// not every frame the mouse moves within the same cell. Matches the
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type SceneApi,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairNode as StairNodeType,
|
||||
type StairSegmentNode,
|
||||
stairFootprintAABB,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
@@ -27,6 +29,7 @@ const CURVED_INNER_RING_MIN = 0.05
|
||||
// the footprint. Same pattern as elevator / column / shelf / roof-segment.
|
||||
const STAIR_ROTATE_CORNER_OFFSET = 0.4
|
||||
const STAIR_ROTATE_RING_OFFSET = 0.08
|
||||
const STAIR_MOVE_FRONT_OFFSET = 0.35
|
||||
|
||||
type CurvedStairGeom = {
|
||||
isSpiral: boolean
|
||||
@@ -42,6 +45,14 @@ type CurvedStairGeom = {
|
||||
minInnerRadius: number
|
||||
}
|
||||
|
||||
type StairMoveBounds = {
|
||||
minX: number
|
||||
maxX: number
|
||||
minZ: number
|
||||
maxZ: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom {
|
||||
const isSpiral = node.stairType === 'spiral'
|
||||
const stepCount = Math.max(2, Math.round(node.stepCount ?? 10))
|
||||
@@ -71,6 +82,79 @@ function isCurvedOrSpiral(node: StairNodeType): boolean {
|
||||
return node.stairType === 'curved' || node.stairType === 'spiral'
|
||||
}
|
||||
|
||||
function rotateLocalXZ(x: number, z: number, angle: number): [number, number] {
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
return [x * cos + z * sin, -x * sin + z * cos]
|
||||
}
|
||||
|
||||
function fallbackStraightStairMoveBounds(node: StairNodeType): StairMoveBounds {
|
||||
const width = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
|
||||
const depth = Math.max(width, 1)
|
||||
return {
|
||||
minX: -width / 2,
|
||||
maxX: width / 2,
|
||||
minZ: 0,
|
||||
maxZ: depth,
|
||||
height: Math.max(node.totalRise ?? 2.5, 0.1),
|
||||
}
|
||||
}
|
||||
|
||||
function readStraightStairMoveBounds(node: StairNodeType, sceneApi: SceneApi): StairMoveBounds {
|
||||
const segments = (node.children ?? [])
|
||||
.map((childId) => sceneApi.get<StairSegmentNode>(childId as never))
|
||||
.filter((child): child is StairSegmentNode => child?.type === 'stair-segment')
|
||||
|
||||
if (segments.length === 0) return fallbackStraightStairMoveBounds(node)
|
||||
|
||||
const transforms = computeStairSegmentFloorStackTransforms(segments)
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minZ = Number.POSITIVE_INFINITY
|
||||
let maxZ = Number.NEGATIVE_INFINITY
|
||||
let height = 0
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const transform = transforms[index]
|
||||
if (!transform) return
|
||||
const halfWidth = segment.width / 2
|
||||
const corners = [
|
||||
[-halfWidth, 0],
|
||||
[halfWidth, 0],
|
||||
[-halfWidth, segment.length],
|
||||
[halfWidth, segment.length],
|
||||
] as const
|
||||
for (const [x, z] of corners) {
|
||||
const [rx, rz] = rotateLocalXZ(x, z, transform.rotation)
|
||||
minX = Math.min(minX, transform.position[0] + rx)
|
||||
maxX = Math.max(maxX, transform.position[0] + rx)
|
||||
minZ = Math.min(minZ, transform.position[2] + rz)
|
||||
maxZ = Math.max(maxZ, transform.position[2] + rz)
|
||||
}
|
||||
height = Math.max(
|
||||
height,
|
||||
transform.position[1] + Math.max(segment.height, segment.thickness, 0.01),
|
||||
)
|
||||
})
|
||||
|
||||
if (![minX, maxX, minZ, maxZ].every(Number.isFinite)) {
|
||||
return fallbackStraightStairMoveBounds(node)
|
||||
}
|
||||
return { minX, maxX, minZ, maxZ, height: Math.max(height, 0.1) }
|
||||
}
|
||||
|
||||
function readStairMoveBounds(node: StairNodeType, sceneApi: SceneApi): StairMoveBounds {
|
||||
if (!isCurvedOrSpiral(node)) return readStraightStairMoveBounds(node, sceneApi)
|
||||
const g = readCurvedStairGeometry(node)
|
||||
return {
|
||||
minX: -g.outerRadius,
|
||||
maxX: g.outerRadius,
|
||||
minZ: -g.outerRadius,
|
||||
maxZ: g.outerRadius,
|
||||
height: g.totalRise,
|
||||
}
|
||||
}
|
||||
|
||||
function curvedRiseHandle(): HandleDescriptor<StairNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
@@ -266,6 +350,29 @@ function stairRotateHandle(): HandleDescriptor<StairNodeType> {
|
||||
}
|
||||
}
|
||||
|
||||
function stairMoveHandle(): HandleDescriptor<StairNodeType> {
|
||||
return {
|
||||
kind: 'translate',
|
||||
placement: {
|
||||
// Low to the floor at the front edge (matches the item move grip) so it
|
||||
// reads as a floor-move grip and stays clear of the body resize / rotate
|
||||
// handles that sit at mid-height.
|
||||
position: (n, sceneApi) => {
|
||||
const bounds = readStairMoveBounds(n, sceneApi)
|
||||
return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + STAIR_MOVE_FRONT_OFFSET]
|
||||
},
|
||||
},
|
||||
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
|
||||
snapExtents: (n, sceneApi) => {
|
||||
const bounds = readStairMoveBounds(n, sceneApi)
|
||||
const dimX = Math.max(bounds.maxX - bounds.minX, MIN_CURVED_WIDTH)
|
||||
const dimZ = Math.max(bounds.maxZ - bounds.minZ, MIN_CURVED_WIDTH)
|
||||
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
|
||||
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
|
||||
// Straight stairs have no parent-level shape arrows — the segment
|
||||
// children each render their own (width / length / height). Curved +
|
||||
@@ -282,10 +389,14 @@ function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
|
||||
curvedSweepHandle('end'),
|
||||
)
|
||||
}
|
||||
handles.push(stairRotateHandle())
|
||||
handles.push(stairRotateHandle(), stairMoveHandle())
|
||||
return handles
|
||||
}
|
||||
|
||||
import {
|
||||
computeStairSegmentFloorStackTransforms,
|
||||
getStairFloorPlacedFootprints,
|
||||
} from './floor-stack'
|
||||
import { buildStairFloorplan } from './floorplan'
|
||||
import {
|
||||
curvedStairInnerRadiusAffordance,
|
||||
@@ -330,6 +441,10 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
|
||||
},
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
floorPlaced: {
|
||||
footprints: (node, ctx) =>
|
||||
ctx ? getStairFloorPlacedFootprints(node as StairNodeType, ctx.nodes) : [],
|
||||
},
|
||||
},
|
||||
|
||||
// Bespoke move shared with roof / roof-segment / stair-segment via
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeDefinition,
|
||||
getFloorPlacedElevation,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
type SlabNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
spatialGridManager,
|
||||
} from '@pascal-app/core'
|
||||
import { stairDefinition } from './definition'
|
||||
import { getStairSegmentFloorPlacedFootprints } from './floor-stack'
|
||||
|
||||
const LEVEL_ID = 'level_test'
|
||||
|
||||
function makeLevel(): AnyNode {
|
||||
return {
|
||||
id: LEVEL_ID,
|
||||
type: 'level',
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
level: 0,
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab_${elevation}`) {
|
||||
const slab = {
|
||||
id,
|
||||
type: 'slab',
|
||||
object: 'node',
|
||||
parentId: LEVEL_ID,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
polygon,
|
||||
holes: [],
|
||||
holeMetadata: [],
|
||||
elevation,
|
||||
autoFromWalls: false,
|
||||
} as SlabNode
|
||||
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
|
||||
}
|
||||
|
||||
describe('stair floor-stack footprints', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
spatialGridManager.clear()
|
||||
})
|
||||
|
||||
test('derives a rotated segment footprint from stair position and rotation', () => {
|
||||
const segment = StairSegmentNode.parse({
|
||||
id: 'sseg_single',
|
||||
width: 2,
|
||||
length: 4,
|
||||
height: 1,
|
||||
thickness: 0.25,
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_single',
|
||||
parentId: LEVEL_ID,
|
||||
position: [10, 0, 20],
|
||||
rotation: Math.PI / 2,
|
||||
children: [segment.id],
|
||||
})
|
||||
|
||||
const [footprint] = getStairSegmentFloorPlacedFootprints(stair, [segment])
|
||||
|
||||
expect(footprint?.position?.[0]).toBeCloseTo(12)
|
||||
expect(footprint?.position?.[1]).toBeCloseTo(0)
|
||||
expect(footprint?.position?.[2]).toBeCloseTo(20)
|
||||
expect(footprint?.dimensions).toEqual([2, 1, 4])
|
||||
expect(footprint?.rotation[1]).toBeCloseTo(Math.PI / 2)
|
||||
})
|
||||
|
||||
test('emits one footprint per chained stair segment', () => {
|
||||
const first = StairSegmentNode.parse({
|
||||
id: 'sseg_first',
|
||||
width: 2,
|
||||
length: 4,
|
||||
height: 1,
|
||||
thickness: 0.25,
|
||||
})
|
||||
const second = StairSegmentNode.parse({
|
||||
id: 'sseg_second',
|
||||
attachmentSide: 'left',
|
||||
width: 1.5,
|
||||
length: 3,
|
||||
height: 0.8,
|
||||
thickness: 0.2,
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_multi',
|
||||
parentId: LEVEL_ID,
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
children: [first.id, second.id],
|
||||
})
|
||||
|
||||
const footprints = getStairSegmentFloorPlacedFootprints(stair, [first, second])
|
||||
|
||||
expect(footprints).toHaveLength(2)
|
||||
expect(footprints[0]?.position).toEqual([0, 0, 2])
|
||||
expect(footprints[0]?.rotation[1]).toBeCloseTo(0)
|
||||
expect(footprints[1]?.position?.[0]).toBeCloseTo(2.5)
|
||||
expect(footprints[1]?.position?.[1]).toBeCloseTo(1)
|
||||
expect(footprints[1]?.position?.[2]).toBeCloseTo(2)
|
||||
expect(footprints[1]?.rotation[1]).toBeCloseTo(Math.PI / 2)
|
||||
})
|
||||
|
||||
test('uses the max slab elevation across stair segment footprints', () => {
|
||||
registerNode(stairDefinition as unknown as AnyNodeDefinition)
|
||||
|
||||
addSlab(
|
||||
[
|
||||
[-0.6, 1.4],
|
||||
[0.6, 1.4],
|
||||
[0.6, 2.6],
|
||||
[-0.6, 2.6],
|
||||
],
|
||||
0.25,
|
||||
'slab_low',
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[2.2, 1.7],
|
||||
[2.8, 1.7],
|
||||
[2.8, 2.3],
|
||||
[2.2, 2.3],
|
||||
],
|
||||
0.75,
|
||||
'slab_high',
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const first = StairSegmentNode.parse({
|
||||
id: 'sseg_resolver_first',
|
||||
width: 2,
|
||||
length: 4,
|
||||
height: 1,
|
||||
})
|
||||
const second = StairSegmentNode.parse({
|
||||
id: 'sseg_resolver_second',
|
||||
attachmentSide: 'left',
|
||||
width: 1.5,
|
||||
length: 3,
|
||||
height: 0.8,
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_resolver',
|
||||
parentId: LEVEL_ID,
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
children: [first.id, second.id],
|
||||
})
|
||||
const nodes = {
|
||||
[level.id]: level,
|
||||
[stair.id]: stair,
|
||||
[first.id]: first,
|
||||
[second.id]: second,
|
||||
}
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node: stair,
|
||||
nodes,
|
||||
position: stair.position,
|
||||
rotation: stair.rotation,
|
||||
levelId: LEVEL_ID,
|
||||
}),
|
||||
).toBeCloseTo(0.75)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
FloorPlacedFootprint,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
type SegmentTransform = {
|
||||
position: [number, number, number]
|
||||
rotation: number
|
||||
}
|
||||
|
||||
export function getStairFloorPlacedFootprints(
|
||||
stair: StairNode,
|
||||
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
): FloorPlacedFootprint[] {
|
||||
const segments = (stair.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId])
|
||||
.filter((node): node is StairSegmentNode => node?.type === 'stair-segment')
|
||||
|
||||
return getStairSegmentFloorPlacedFootprints(stair, segments)
|
||||
}
|
||||
|
||||
export function getStairSegmentFloorPlacedFootprints(
|
||||
stair: StairNode,
|
||||
segments: readonly StairSegmentNode[],
|
||||
): FloorPlacedFootprint[] {
|
||||
const transforms = computeStairSegmentFloorStackTransforms(segments)
|
||||
|
||||
return segments.map((segment, index) => {
|
||||
const transform = transforms[index]!
|
||||
const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation)
|
||||
const centerInGroupX = transform.position[0] + centerOffsetX
|
||||
const centerInGroupZ = transform.position[2] + centerOffsetZ
|
||||
const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ(
|
||||
centerInGroupX,
|
||||
centerInGroupZ,
|
||||
stair.rotation,
|
||||
)
|
||||
|
||||
return {
|
||||
position: [
|
||||
stair.position[0] + centerOffsetWorldX,
|
||||
stair.position[1] + transform.position[1],
|
||||
stair.position[2] + centerOffsetWorldZ,
|
||||
],
|
||||
dimensions: [
|
||||
segment.width,
|
||||
Math.max(segment.height, segment.thickness, 0.01),
|
||||
segment.length,
|
||||
],
|
||||
rotation: [0, stair.rotation + transform.rotation, 0],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function computeStairSegmentFloorStackTransforms(
|
||||
segments: readonly StairSegmentNode[],
|
||||
): SegmentTransform[] {
|
||||
const transforms: SegmentTransform[] = []
|
||||
let currentX = 0
|
||||
let currentY = 0
|
||||
let currentZ = 0
|
||||
let currentRot = 0
|
||||
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index]!
|
||||
|
||||
if (index > 0) {
|
||||
const previous = segments[index - 1]!
|
||||
let attachX = 0
|
||||
let attachZ = 0
|
||||
let rotationDelta = 0
|
||||
|
||||
switch (segment.attachmentSide) {
|
||||
case 'front':
|
||||
attachX = 0
|
||||
attachZ = previous.length
|
||||
rotationDelta = 0
|
||||
break
|
||||
case 'left':
|
||||
attachX = previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = Math.PI / 2
|
||||
break
|
||||
case 'right':
|
||||
attachX = -previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = -Math.PI / 2
|
||||
break
|
||||
}
|
||||
|
||||
const [rotatedX, rotatedZ] = rotateXZ(attachX, attachZ, currentRot)
|
||||
currentX += rotatedX
|
||||
currentY += previous.height
|
||||
currentZ += rotatedZ
|
||||
currentRot += rotationDelta
|
||||
}
|
||||
|
||||
transforms.push({
|
||||
position: [currentX, currentY, currentZ],
|
||||
rotation: currentRot,
|
||||
})
|
||||
}
|
||||
|
||||
return transforms
|
||||
}
|
||||
|
||||
function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
return [x * cos + z * sin, -x * sin + z * cos]
|
||||
}
|
||||
@@ -103,15 +103,26 @@ export function Lights() {
|
||||
if (SHADOW_EXCLUDED_TYPES.some((t) => sceneRegistry.byType[t]!.has(id))) continue
|
||||
box.expandByObject(obj)
|
||||
}
|
||||
if (box.isEmpty()) {
|
||||
// Empty scene: fall back to the origin with a default radius so the
|
||||
// ground still receives a sensible shadow region.
|
||||
box.getBoundingSphere(boundsSphere.current)
|
||||
const center = boundsSphere.current.center
|
||||
const radius = boundsSphere.current.radius
|
||||
// Empty scene OR a node with a NaN position/geometry poisoning the union
|
||||
// box: fall back to the origin with a default radius. The directional
|
||||
// light's position is derived from `focus`, so a single non-finite mesh
|
||||
// must NOT be allowed to make `focus`/`radius` NaN — that breaks every
|
||||
// shadow-casting light's position and renders the whole scene black.
|
||||
const finiteBounds =
|
||||
!box.isEmpty() &&
|
||||
Number.isFinite(center.x) &&
|
||||
Number.isFinite(center.y) &&
|
||||
Number.isFinite(center.z) &&
|
||||
Number.isFinite(radius)
|
||||
if (finiteBounds) {
|
||||
shadowFocus.current.copy(center)
|
||||
shadowRadius.current = radius
|
||||
} else {
|
||||
shadowFocus.current.set(0, 0, 0)
|
||||
shadowRadius.current = SHADOW_FALLBACK_RADIUS
|
||||
} else {
|
||||
box.getBoundingSphere(boundsSphere.current)
|
||||
shadowFocus.current.copy(boundsSphere.current.center)
|
||||
shadowRadius.current = boundsSphere.current.radius
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,43 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveNode,
|
||||
getFloorStackedPosition,
|
||||
nodeRegistry,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
type PositionedNode = AnyNode & {
|
||||
position?: [number, number, number]
|
||||
rotation?: [number, number, number] | number
|
||||
}
|
||||
|
||||
function withLiveTransform(node: AnyNode, id: string): AnyNode {
|
||||
const liveTransform = useLiveTransforms.getState().get(id)
|
||||
if (!liveTransform) return node
|
||||
|
||||
const currentRotation = (node as PositionedNode).rotation
|
||||
const rotation = Array.isArray(currentRotation)
|
||||
? ([currentRotation[0] ?? 0, liveTransform.rotation, currentRotation[2] ?? 0] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
])
|
||||
: typeof currentRotation === 'number'
|
||||
? liveTransform.rotation
|
||||
: currentRotation
|
||||
|
||||
return {
|
||||
...(node as Record<string, unknown>),
|
||||
position: liveTransform.position,
|
||||
...(rotation !== undefined ? { rotation } : {}),
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic floor-elevation system.
|
||||
*
|
||||
@@ -25,11 +53,12 @@ import type * as THREE from 'three'
|
||||
*
|
||||
* Runs at priority 1 — before the priority-2 systems (`GeometrySystem`,
|
||||
* `ItemSystem`) so the dirty mark survives long enough for those to do
|
||||
* their own work. Doesn't clear dirty; the per-kind system (or the
|
||||
* generic geometry rebuild) is responsible for that.
|
||||
* their own work. Kinds with no geometry/system have no downstream dirty
|
||||
* consumer, so this system clears their dirty mark after applying the lift.
|
||||
*/
|
||||
export const FloorElevationSystem = () => {
|
||||
const dirtyNodes = useScene((s) => s.dirtyNodes)
|
||||
const clearDirty = useScene((s) => s.clearDirty)
|
||||
|
||||
useFrame(() => {
|
||||
if (dirtyNodes.size === 0) return
|
||||
@@ -43,31 +72,31 @@ export const FloorElevationSystem = () => {
|
||||
const floorPlaced = def?.capabilities?.floorPlaced
|
||||
if (!floorPlaced) return
|
||||
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node as AnyNode)) return
|
||||
|
||||
// Only nodes parented directly to a level get the lift. Children of
|
||||
// walls / ceilings / other items inherit Y from the parent group.
|
||||
const parentId = node.parentId as AnyNodeId | null
|
||||
const parent = parentId ? nodes[parentId] : null
|
||||
if (parent && parent.type !== 'level') return
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
|
||||
if (!mesh) return
|
||||
|
||||
const position = (node as { position?: [number, number, number] }).position
|
||||
const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), id)
|
||||
const position = (effectiveNode as PositionedNode).position
|
||||
if (!position) return
|
||||
|
||||
const levelId = resolveLevelId(node, nodes)
|
||||
if (!levelId) return
|
||||
|
||||
const { dimensions, rotation } = floorPlaced.footprint(node as AnyNode)
|
||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||
levelId,
|
||||
// This system is the single drag-time authority for floor-stack mesh Y:
|
||||
// tools publish base positions to live stores, renderers may
|
||||
// reconcile that base Y onto the group, then this presentation system
|
||||
// reapplies the resolver-derived visual Y before render. Because the
|
||||
// override/store position remains base-height, the slab lift is never
|
||||
// committed or applied twice.
|
||||
const resolverNodes =
|
||||
effectiveNode === node ? nodes : { ...nodes, [effectiveNode.id]: effectiveNode }
|
||||
const visualPosition = getFloorStackedPosition({
|
||||
node: effectiveNode,
|
||||
nodes: resolverNodes,
|
||||
position,
|
||||
dimensions,
|
||||
rotation,
|
||||
)
|
||||
mesh.position.y = slabElevation + position[1]
|
||||
})
|
||||
mesh.position.y = visualPosition[1]
|
||||
|
||||
if (!(def.geometry || def.system)) {
|
||||
clearDirty(id as AnyNodeId)
|
||||
}
|
||||
})
|
||||
}, 1)
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveNode,
|
||||
resolveLevelId,
|
||||
getFloorStackedPosition,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
@@ -96,9 +95,9 @@ export const StairSystem = () => {
|
||||
// so slab-elevation spatial queries match where the segments are
|
||||
// actually being rendered. Without this, dragging the rotate gizmo
|
||||
// looks up slabs at the pre-drag world XZ — if rotation carries a
|
||||
// segment off the original slab footprint, getStairSlabElevation
|
||||
// returns 0 and `group.position.y` collapses, dropping the flight
|
||||
// or landing below the floor and out of view mid-drag.
|
||||
// segment off the original slab footprint, the floor-stack
|
||||
// resolver would otherwise read the pre-drag footprint and drop
|
||||
// the flight or landing below the floor mid-drag.
|
||||
const stairNode = getEffectiveNode(baseStairNode as StairNode)
|
||||
const group = sceneRegistry.nodes.get(stairId) as THREE.Group | undefined
|
||||
if (group) {
|
||||
@@ -273,57 +272,20 @@ function syncStairGroupElevation(
|
||||
group: THREE.Group,
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const levelId = resolveLevelId(stairNode, nodes)
|
||||
const slabElevation = getStairSlabElevation(levelId, stairNode, nodes)
|
||||
group.position.y = stairNode.position[1] + slabElevation
|
||||
}
|
||||
|
||||
function getStairSlabElevation(
|
||||
levelId: string,
|
||||
stairNode: StairNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): number {
|
||||
// Merge live overrides so slab queries match the visual chain during a drag.
|
||||
const segments = (stairNode.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
||||
.map((n) => getEffectiveNode(n))
|
||||
|
||||
if (segments.length === 0) return 0
|
||||
|
||||
const transforms = computeSegmentTransforms(segments)
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i]!
|
||||
const transform = transforms[i]!
|
||||
|
||||
const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation)
|
||||
const centerInGroupX = transform.position[0] + centerOffsetX
|
||||
const centerInGroupZ = transform.position[2] + centerOffsetZ
|
||||
const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ(
|
||||
centerInGroupX,
|
||||
centerInGroupZ,
|
||||
stairNode.rotation,
|
||||
)
|
||||
|
||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||
levelId,
|
||||
[
|
||||
stairNode.position[0] + centerOffsetWorldX,
|
||||
stairNode.position[1] + transform.position[1],
|
||||
stairNode.position[2] + centerOffsetWorldZ,
|
||||
],
|
||||
[segment.width, Math.max(segment.height, segment.thickness, 0.01), segment.length],
|
||||
[0, stairNode.rotation + transform.rotation, 0],
|
||||
)
|
||||
|
||||
if (slabElevation > maxElevation) {
|
||||
maxElevation = slabElevation
|
||||
const effectiveNodes: Record<string, AnyNode> = { ...nodes, [stairNode.id]: stairNode }
|
||||
for (const childId of stairNode.children ?? []) {
|
||||
const segment = nodes[childId as AnyNodeId]
|
||||
if (segment?.type === 'stair-segment') {
|
||||
effectiveNodes[segment.id] = getEffectiveNode(segment as StairSegmentNode)
|
||||
}
|
||||
}
|
||||
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
const visualPosition = getFloorStackedPosition({
|
||||
node: stairNode,
|
||||
nodes: effectiveNodes,
|
||||
position: stairNode.position,
|
||||
rotation: stairNode.rotation,
|
||||
})
|
||||
group.position.y = visualPosition[1]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user