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 { nodeRegistry } from '../../registry'
|
||||||
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
|
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
|
import { getFloorPlacedFootprints } from './floor-placed-elevation'
|
||||||
import {
|
import {
|
||||||
itemOverlapsPolygon,
|
itemOverlapsPolygon,
|
||||||
spatialGridManager,
|
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(
|
function markNodesOverlappingSlab(
|
||||||
slab: SlabNode,
|
slab: SlabNode,
|
||||||
@@ -181,12 +182,6 @@ function markNodesOverlappingSlab(
|
|||||||
}
|
}
|
||||||
continue
|
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
|
// Generic floor-placed sweep: any registry kind that opts in via
|
||||||
// `capabilities.floorPlaced` (item / shelf / column / spawn / …)
|
// `capabilities.floorPlaced` (item / shelf / column / spawn / …)
|
||||||
// re-elevates through `<FloorElevationSystem>` when a slab below
|
// re-elevates through `<FloorElevationSystem>` when a slab below
|
||||||
@@ -196,12 +191,25 @@ function markNodesOverlappingSlab(
|
|||||||
const floorPlaced = def?.capabilities?.floorPlaced
|
const floorPlaced = def?.capabilities?.floorPlaced
|
||||||
if (!floorPlaced) continue
|
if (!floorPlaced) continue
|
||||||
if (floorPlaced.applies && !floorPlaced.applies(node)) 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
|
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||||
const position = (node as { position?: [number, number, number] }).position
|
const position = (node as { position?: [number, number, number] }).position
|
||||||
if (!position) continue
|
if (!position) continue
|
||||||
const { dimensions, rotation } = floorPlaced.footprint(node)
|
for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
|
||||||
if (itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
if (
|
||||||
markDirty(node.id)
|
itemOverlapsPolygon(
|
||||||
|
footprint.position ?? position,
|
||||||
|
footprint.dimensions,
|
||||||
|
footprint.rotation,
|
||||||
|
slab.polygon,
|
||||||
|
0.01,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
markDirty(node.id)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
} from './hooks/scene-registry/scene-registry'
|
} 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 { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||||
export {
|
export {
|
||||||
initSpatialGridSync,
|
initSpatialGridSync,
|
||||||
@@ -79,6 +85,12 @@ export {
|
|||||||
type MaterialCategory,
|
type MaterialCategory,
|
||||||
toLibraryMaterialRef,
|
toLibraryMaterialRef,
|
||||||
} from './material-library'
|
} from './material-library'
|
||||||
|
export type {
|
||||||
|
FloorPlacedFootprint,
|
||||||
|
FloorPlacedFootprintContext,
|
||||||
|
FloorPlacedFootprintResolver,
|
||||||
|
FloorPlacedFootprintsResolver,
|
||||||
|
} from './registry'
|
||||||
export * from './registry'
|
export * from './registry'
|
||||||
export * from './schema'
|
export * from './schema'
|
||||||
export * from './services'
|
export * from './services'
|
||||||
|
|||||||
@@ -346,8 +346,10 @@ export type TranslateHandle<N = any> = {
|
|||||||
* `[alongX, alongOther]` — `alongOther` is Z for the 'horizontal' plane and
|
* `[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-
|
* 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.
|
* 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
|
portal?: HandlePortal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export type {
|
|||||||
CuttableConfig,
|
CuttableConfig,
|
||||||
DragAction,
|
DragAction,
|
||||||
EditorCtx,
|
EditorCtx,
|
||||||
|
FloorPlacedConfig,
|
||||||
|
FloorPlacedFootprint,
|
||||||
|
FloorPlacedFootprintContext,
|
||||||
|
FloorPlacedFootprintResolver,
|
||||||
|
FloorPlacedFootprintsResolver,
|
||||||
FloorplanAffordance,
|
FloorplanAffordance,
|
||||||
FloorplanAffordanceModifiers,
|
FloorplanAffordanceModifiers,
|
||||||
FloorplanAffordancePoint,
|
FloorplanAffordancePoint,
|
||||||
|
|||||||
@@ -1235,20 +1235,40 @@ export type SelectableConfig = {
|
|||||||
override?: (ctx: CapabilityCtx) => SelectableConfig | null
|
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
|
* Floor-placed kinds rest directly on a level and need their Y lifted by
|
||||||
* any slab the footprint overlaps. The generic `<FloorElevationSystem>`
|
* any slab the footprint overlaps. The generic `<FloorElevationSystem>`
|
||||||
* computes `slabElevation + node.position[1]` and writes it onto the
|
* computes `slabElevation + node.position[1]` and writes it onto the
|
||||||
* registered mesh on every dirty mark. `footprint` returns the world-space
|
* registered mesh on every dirty mark. `footprint` returns the default
|
||||||
* footprint the spatial-grid manager uses to find overlapping slabs;
|
* 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
|
* `applies` is an optional predicate to skip nodes that share a kind but
|
||||||
* are mounted off-floor (items attached to a wall / ceiling).
|
* are mounted off-floor (items attached to a wall / ceiling).
|
||||||
*/
|
*/
|
||||||
export type FloorPlacedConfig = {
|
export type FloorPlacedConfig = {
|
||||||
footprint: (node: AnyNode) => {
|
footprint?: FloorPlacedFootprintResolver
|
||||||
dimensions: [number, number, number]
|
footprints?: FloorPlacedFootprintsResolver
|
||||||
rotation: [number, number, number]
|
|
||||||
}
|
|
||||||
applies?: (node: AnyNode) => boolean
|
applies?: (node: AnyNode) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ function floorFootprint(
|
|||||||
): { dimensions: [number, number, number]; rotation: [number, number, number] } | null {
|
): { dimensions: [number, number, number]; rotation: [number, number, number] } | null {
|
||||||
const capabilities = nodeRegistry.get(node.type)?.capabilities
|
const capabilities = nodeRegistry.get(node.type)?.capabilities
|
||||||
const floorPlaced = capabilities?.floorPlaced
|
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
|
if (floorPlaced.applies && !floorPlaced.applies(node)) return null
|
||||||
return floorPlaced.footprint(node)
|
return floorPlaced.footprint(node)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
AnyNode as AnyNodeSchema,
|
||||||
getEffectiveWallSurfaceMaterial,
|
getEffectiveWallSurfaceMaterial,
|
||||||
getWallSurfaceMaterialSignature,
|
getWallSurfaceMaterialSignature,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
@@ -22,6 +23,478 @@ type WallMergePlan = {
|
|||||||
attachmentUpdates: WallAttachmentUpdate[]
|
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
|
// Track pending RAF for updateNodesAction to prevent multiple queued callbacks
|
||||||
let pendingRafId: number | null = null
|
let pendingRafId: number | null = null
|
||||||
let pendingUpdates: Set<AnyNodeId> = new Set()
|
let pendingUpdates: Set<AnyNodeId> = new Set()
|
||||||
@@ -245,11 +718,7 @@ export const createNodesAction = (
|
|||||||
for (const { node, parentId } of ops) {
|
for (const { node, parentId } of ops) {
|
||||||
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
||||||
|
|
||||||
// 1. Assign parentId to the child (Safe because BaseNode has parentId)
|
const newNode = parseCreatedNode(node, effectiveParentId)
|
||||||
const newNode = {
|
|
||||||
...node,
|
|
||||||
parentId: effectiveParentId,
|
|
||||||
}
|
|
||||||
|
|
||||||
nextNodes[newNode.id] = newNode
|
nextNodes[newNode.id] = newNode
|
||||||
|
|
||||||
@@ -313,6 +782,7 @@ export const applyNodeChangesAction = (
|
|||||||
for (const { id, data } of updateOps) {
|
for (const { id, data } of updateOps) {
|
||||||
const currentNode = nextNodes[id]
|
const currentNode = nextNodes[id]
|
||||||
if (!currentNode) continue
|
if (!currentNode) continue
|
||||||
|
const updatedNode = parseUpdatedNode(currentNode, data)
|
||||||
|
|
||||||
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
||||||
const oldParentId = currentNode.parentId as AnyNodeId | null
|
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)
|
nodesToMarkDirty.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { node, parentId } of createOps) {
|
for (const { node, parentId } of createOps) {
|
||||||
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
||||||
const newNode = {
|
const newNode = parseCreatedNode(node, effectiveParentId)
|
||||||
...node,
|
|
||||||
parentId: effectiveParentId,
|
|
||||||
} as AnyNode
|
|
||||||
|
|
||||||
nextNodes[newNode.id as AnyNodeId] = newNode
|
nextNodes[newNode.id as AnyNodeId] = newNode
|
||||||
nodesToMarkDirty.add(newNode.id as AnyNodeId)
|
nodesToMarkDirty.add(newNode.id as AnyNodeId)
|
||||||
@@ -442,6 +909,7 @@ export const updateNodesAction = (
|
|||||||
for (const { id, data } of updates) {
|
for (const { id, data } of updates) {
|
||||||
const currentNode = nextNodes[id]
|
const currentNode = nextNodes[id]
|
||||||
if (!currentNode) continue
|
if (!currentNode) continue
|
||||||
|
const updatedNode = parseUpdatedNode(currentNode, data)
|
||||||
|
|
||||||
// Handle Reparenting Logic
|
// Handle Reparenting Logic
|
||||||
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
||||||
@@ -480,7 +948,7 @@ export const updateNodesAction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply the update
|
// Apply the update
|
||||||
nextNodes[id] = { ...nextNodes[id], ...data } as AnyNode
|
nextNodes[id] = updatedNode
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes: nextNodes }
|
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 RoofSegmentNode,
|
||||||
type RoofType,
|
type RoofType,
|
||||||
} from '../schema/nodes/roof-segment'
|
} from '../schema/nodes/roof-segment'
|
||||||
|
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
|
||||||
import { SiteNode } from '../schema/nodes/site'
|
import { SiteNode } from '../schema/nodes/site'
|
||||||
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
|
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
|
||||||
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
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
|
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) {
|
function getBoolean(value: unknown, fallback: boolean) {
|
||||||
return typeof value === 'boolean' ? value : fallback
|
return typeof value === 'boolean' ? value : fallback
|
||||||
}
|
}
|
||||||
@@ -112,6 +118,37 @@ function normalizeDoorNode(node: Record<string, unknown>) {
|
|||||||
return parsed.success ? { ...node, ...parsed.data } : null
|
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>) {
|
function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||||
const hasInterior =
|
const hasInterior =
|
||||||
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
|
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])
|
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shelf v2: hosting was added in this migration cycle. Older shelves
|
if (node.type === 'shelf') {
|
||||||
// (saved before the schema gained `children`) need the field
|
const normalized = normalizeShelfNode(node)
|
||||||
// initialised so `createNode(item, shelfId)` finds an array to
|
if (normalized) {
|
||||||
// append the child id to — without this the host item ends up
|
patchedNodes[id] = normalized
|
||||||
// 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: [] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roof-segment hosting was added in this migration cycle (the same
|
// 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
|
// The live-transform contract varies per kind (see
|
||||||
// wiki/architecture/tools.md "useLiveTransforms contract is
|
// wiki/architecture/tools.md "useLiveTransforms contract is
|
||||||
// per-kind, not generic"); we narrow per kind here:
|
// per-kind, not generic"); position-carrying floor-placed kinds
|
||||||
// - item: world-plan position frame. Override `position` +
|
// publish canonical X/Z, while slab / ceiling publish a polygon
|
||||||
// `rotation` and force `parentId: null` so the resolver
|
// translation delta.
|
||||||
// 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.
|
|
||||||
const live = liveTransforms.get(id)
|
const live = liveTransforms.get(id)
|
||||||
let effectiveNode: AnyNode = node
|
let effectiveNode: AnyNode = node
|
||||||
if (live) {
|
if (live) {
|
||||||
if (node.type === 'item' || node.type === 'shelf') {
|
const floorPlaced = def?.capabilities?.floorPlaced
|
||||||
// World-plan position kinds: the live transform carries the
|
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
||||||
// node's intended position/rotation in level-local coords.
|
if (floorPlaced && hasPosition) {
|
||||||
// Override both and force `parentId: null` so the floor-plan
|
effectiveNode = applyPositionLiveTransform(node, live)
|
||||||
// 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
|
|
||||||
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
||||||
const dx = live.position[0]
|
const dx = live.position[0]
|
||||||
const dz = live.position[2]
|
const dz = live.position[2]
|
||||||
@@ -1591,6 +1568,29 @@ function InteractiveGeometry({
|
|||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── 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(
|
function buildContext(
|
||||||
node: AnyNode,
|
node: AnyNode,
|
||||||
nodes: Record<string, AnyNode>,
|
nodes: Record<string, AnyNode>,
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
|||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import {
|
import {
|
||||||
|
CORNER_OFFSET,
|
||||||
classifyParticipant,
|
classifyParticipant,
|
||||||
collectParticipants,
|
collectParticipants,
|
||||||
computeGroupBox,
|
computeGroupBox,
|
||||||
CORNER_OFFSET,
|
|
||||||
expandToComponent,
|
expandToComponent,
|
||||||
type Vec2,
|
type Vec2,
|
||||||
} from './group-transform-shared'
|
} from './group-transform-shared'
|
||||||
|
|||||||
@@ -21,11 +21,15 @@ import {
|
|||||||
ARROW_HOVER_COLOR,
|
ARROW_HOVER_COLOR,
|
||||||
ARROW_SCALE,
|
ARROW_SCALE,
|
||||||
createRotateArrowHandleGeometry,
|
createRotateArrowHandleGeometry,
|
||||||
|
createRotateArrowHitAreaGeometry,
|
||||||
GuideRing,
|
GuideRing,
|
||||||
|
InvisibleHandleHitArea,
|
||||||
|
NO_RAYCAST,
|
||||||
RotationGuide,
|
RotationGuide,
|
||||||
type RotationGuideData,
|
type RotationGuideData,
|
||||||
swallowNextClick,
|
swallowNextClick,
|
||||||
useArrowMaterial,
|
useArrowMaterial,
|
||||||
|
useInvisibleHitAreaMaterial,
|
||||||
} from './node-arrow-handles'
|
} from './node-arrow-handles'
|
||||||
|
|
||||||
const ROTATE_SNAP = Math.PI / 12 // 15°
|
const ROTATE_SNAP = Math.PI / 12 // 15°
|
||||||
@@ -74,7 +78,9 @@ export function GroupRotateHandle() {
|
|||||||
function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||||
const { camera, raycaster, gl, scene } = useThree()
|
const { camera, raycaster, gl, scene } = useThree()
|
||||||
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
|
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
|
||||||
|
const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
|
||||||
const arrowMaterial = useArrowMaterial()
|
const arrowMaterial = useArrowMaterial()
|
||||||
|
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [guide, setGuide] = useState<RotationGuideData | null>(null)
|
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.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||||
}, [arrowMaterial, isHovered])
|
}, [arrowMaterial, isHovered])
|
||||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||||
|
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||||
useEffect(() => () => dragCleanupRef.current?.(), [])
|
useEffect(() => () => dragCleanupRef.current?.(), [])
|
||||||
|
|
||||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
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
|
// 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
|
// 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 rest = useMemo(() => {
|
||||||
const box = computeGroupBox(ids)
|
const box = computeGroupBox(ids)
|
||||||
if (!box) return null
|
if (!box) return null
|
||||||
const pivot = new Vector3(
|
const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
|
||||||
(box.min.x + box.max.x) / 2,
|
|
||||||
box.min.y,
|
|
||||||
(box.min.z + box.max.z) / 2,
|
|
||||||
)
|
|
||||||
const corner = new Vector3(
|
const corner = new Vector3(
|
||||||
box.max.x + CORNER_OFFSET,
|
box.max.x + CORNER_OFFSET,
|
||||||
(box.min.y + box.max.y) / 2,
|
(box.min.y + box.max.y) / 2,
|
||||||
@@ -307,11 +311,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|||||||
<GuideRing radius={0.2 * scale} y={0} />
|
<GuideRing radius={0.2 * scale} y={0} />
|
||||||
</group>
|
</group>
|
||||||
)}
|
)}
|
||||||
<group position={[corner.x, corner.y, corner.z]} scale={scale}>
|
<group position={[corner.x, corner.y, corner.z]}>
|
||||||
<mesh
|
<InvisibleHandleHitArea
|
||||||
frustumCulled={false}
|
geometry={hitGeometry}
|
||||||
geometry={arrowGeometry}
|
material={hitMaterial}
|
||||||
material={arrowMaterial}
|
|
||||||
onPointerDown={activate}
|
onPointerDown={activate}
|
||||||
onPointerEnter={(event) => {
|
onPointerEnter={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -323,7 +326,15 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|||||||
setIsHovered(false)
|
setIsHovered(false)
|
||||||
if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
|
if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
|
||||||
}}
|
}}
|
||||||
|
scale={baseScale}
|
||||||
|
/>
|
||||||
|
<mesh
|
||||||
|
frustumCulled={false}
|
||||||
|
geometry={arrowGeometry}
|
||||||
|
material={arrowMaterial}
|
||||||
|
raycast={NO_RAYCAST}
|
||||||
renderOrder={1010}
|
renderOrder={1010}
|
||||||
|
scale={scale}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
{guide ? <RotationGuide data={guide} /> : null}
|
{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 { NodeArrowHandles } from './node-arrow-handles'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { SiteEdgeLabels } from './site-edge-labels'
|
import { SiteEdgeLabels } from './site-edge-labels'
|
||||||
|
import { SlabHoleHighlights } from './slab-hole-highlights'
|
||||||
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
|
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
|
||||||
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
|
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
|
||||||
import { WallMeasurementLabel } from './wall-measurement-label'
|
import { WallMeasurementLabel } from './wall-measurement-label'
|
||||||
@@ -610,6 +611,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
|||||||
{!noEditing && <GroupRotateHandle />}
|
{!noEditing && <GroupRotateHandle />}
|
||||||
{!noEditing && <GroupMoveHandle />}
|
{!noEditing && <GroupMoveHandle />}
|
||||||
{!noEditing && <WallOpeningHighlights />}
|
{!noEditing && <WallOpeningHighlights />}
|
||||||
|
{!noEditing && <SlabHoleHighlights />}
|
||||||
{!noEditing && <WallMoveSideHandles />}
|
{!noEditing && <WallMoveSideHandles />}
|
||||||
{!noEditing && <FloatingActionMenu />}
|
{!noEditing && <FloatingActionMenu />}
|
||||||
{!noEditing && <FloatingBuildingActionMenu />}
|
{!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)
|
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
||||||
|
|
||||||
let nextMaterialTargetHandled = false
|
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 { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
import {
|
||||||
|
createArrowHitAreaGeometry,
|
||||||
|
createEndpointHitAreaGeometry,
|
||||||
|
InvisibleHandleHitArea,
|
||||||
|
NO_RAYCAST,
|
||||||
|
useInvisibleHitAreaMaterial,
|
||||||
|
} from './node-arrow-handles'
|
||||||
|
|
||||||
const HANDLE_OFFSET = 0.27
|
const HANDLE_OFFSET = 0.27
|
||||||
const HANDLE_MIN_OFFSET = 0.33
|
const HANDLE_MIN_OFFSET = 0.33
|
||||||
@@ -227,7 +234,8 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
|||||||
const billboardRef = useRef<Group>(null)
|
const billboardRef = useRef<Group>(null)
|
||||||
const parentWorldQuaternionRef = useRef(new Quaternion())
|
const parentWorldQuaternionRef = useRef(new Quaternion())
|
||||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
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 corner = endpoint === 'start' ? wall.start : wall.end
|
||||||
const x = corner[0]
|
const x = corner[0]
|
||||||
@@ -235,7 +243,10 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
|||||||
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||||
|
|
||||||
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
|
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
|
||||||
|
const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), [])
|
||||||
|
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||||
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
|
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
|
||||||
|
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||||
|
|
||||||
// Node materials matched to the rest of the file — mixing plain
|
// Node materials matched to the rest of the file — mixing plain
|
||||||
// `meshBasicMaterial` with WebGPU node materials trips
|
// `meshBasicMaterial` with WebGPU node materials trips
|
||||||
@@ -332,9 +343,10 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
|||||||
position={[x, 0, z]}
|
position={[x, 0, z]}
|
||||||
renderOrder={1001}
|
renderOrder={1001}
|
||||||
/>
|
/>
|
||||||
<group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={scale}>
|
<group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={baseScale}>
|
||||||
<mesh
|
<InvisibleHandleHitArea
|
||||||
material={hexMaterial}
|
geometry={hitGeometry}
|
||||||
|
material={hitMaterial}
|
||||||
onPointerDown={activateEndpointMove}
|
onPointerDown={activateEndpointMove}
|
||||||
onPointerEnter={(event) => {
|
onPointerEnter={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -348,13 +360,16 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
|||||||
document.body.style.cursor = ''
|
document.body.style.cursor = ''
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
renderOrder={1003}
|
scale={1}
|
||||||
>
|
/>
|
||||||
<circleGeometry args={[CORNER_HEX_RADIUS, 6]} />
|
<group scale={visualScale}>
|
||||||
</mesh>
|
<mesh material={hexMaterial} raycast={NO_RAYCAST} renderOrder={1003}>
|
||||||
<mesh material={ringMaterial} renderOrder={1002}>
|
<circleGeometry args={[CORNER_HEX_RADIUS, 6]} />
|
||||||
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
|
</mesh>
|
||||||
</mesh>
|
<mesh material={ringMaterial} raycast={NO_RAYCAST} renderOrder={1002}>
|
||||||
|
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
</group>
|
</group>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -363,6 +378,8 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
|
|||||||
function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||||
|
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
|
||||||
|
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||||
const arrowMaterial = useMemo(
|
const arrowMaterial = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new MeshBasicNodeMaterial({
|
new MeshBasicNodeMaterial({
|
||||||
@@ -377,7 +394,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
)
|
)
|
||||||
const { camera, raycaster, gl } = useThree()
|
const { camera, raycaster, gl } = useThree()
|
||||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
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)
|
const dragCleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -394,6 +412,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||||
|
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||||
|
|
||||||
// Sit on the visual centre of the wall — for curved walls that's the
|
// Sit on the visual centre of the wall — for curved walls that's the
|
||||||
@@ -509,12 +528,10 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[midX, handleY, midZ]} rotation={[0, wallAngle, 0]}>
|
<group position={[midX, handleY, midZ]} rotation={[0, wallAngle, 0]}>
|
||||||
<group rotation={[0, Math.PI / 2, Math.PI / 2]} scale={scale}>
|
<group rotation={[0, Math.PI / 2, Math.PI / 2]}>
|
||||||
<mesh
|
<InvisibleHandleHitArea
|
||||||
// Geometry-as-prop + frustumCulled={false} — see WallMoveArrowHandle.
|
geometry={hitGeometry}
|
||||||
frustumCulled={false}
|
material={hitMaterial}
|
||||||
geometry={arrowGeometry}
|
|
||||||
material={arrowMaterial}
|
|
||||||
onPointerDown={activateHeightResize}
|
onPointerDown={activateHeightResize}
|
||||||
onPointerEnter={(event) => {
|
onPointerEnter={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -528,7 +545,16 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
document.body.style.cursor = ''
|
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}
|
renderOrder={1002}
|
||||||
|
scale={scale}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
@@ -538,6 +564,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
|
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||||
|
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
|
||||||
|
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||||
const arrowMaterial = useMemo(
|
const arrowMaterial = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new MeshBasicNodeMaterial({
|
new MeshBasicNodeMaterial({
|
||||||
@@ -554,7 +582,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
|||||||
|
|
||||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
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(() => {
|
useEffect(() => {
|
||||||
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
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(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||||
|
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||||
|
|
||||||
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
|
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
|
||||||
@@ -586,16 +616,10 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
|
<group position={handle.position} rotation={[0, handle.rotationY, 0]}>
|
||||||
<mesh
|
<InvisibleHandleHitArea
|
||||||
// Pass geometry as a prop (not `<primitive attach="geometry">`)
|
geometry={hitGeometry}
|
||||||
// so the mesh is never rendered with R3F's default empty
|
material={hitMaterial}
|
||||||
// `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}
|
|
||||||
onPointerDown={activateWallMove}
|
onPointerDown={activateWallMove}
|
||||||
onPointerEnter={(event) => {
|
onPointerEnter={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -609,7 +633,20 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
|||||||
document.body.style.cursor = ''
|
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}
|
renderOrder={1002}
|
||||||
|
scale={scale}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
@@ -618,6 +655,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
|||||||
function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
|
function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||||
|
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
|
||||||
|
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||||
const arrowMaterial = useMemo(
|
const arrowMaterial = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new MeshBasicNodeMaterial({
|
new MeshBasicNodeMaterial({
|
||||||
@@ -633,7 +672,8 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
|||||||
const { camera } = useThree()
|
const { camera } = useThree()
|
||||||
|
|
||||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
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(() => {
|
useEffect(() => {
|
||||||
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
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(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||||
|
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||||
|
|
||||||
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
|
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
|
||||||
@@ -664,13 +705,10 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
|
<group position={handle.position} rotation={[0, handle.rotationY, 0]}>
|
||||||
<mesh
|
<InvisibleHandleHitArea
|
||||||
// Pass geometry as a prop — see WallMoveArrowHandle for the
|
geometry={hitGeometry}
|
||||||
// WebGPU "Vertex buffer slot 0 ... was not set" rationale.
|
material={hitMaterial}
|
||||||
frustumCulled={false}
|
|
||||||
geometry={arrowGeometry}
|
|
||||||
material={arrowMaterial}
|
|
||||||
onPointerDown={activateFenceMove}
|
onPointerDown={activateFenceMove}
|
||||||
onPointerEnter={(event) => {
|
onPointerEnter={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -684,7 +722,17 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
|||||||
document.body.style.cursor = ''
|
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}
|
renderOrder={1002}
|
||||||
|
scale={scale}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -103,12 +103,13 @@ export const floorStrategy = {
|
|||||||
// building-local ToolManager group, so local coords are correct for both data and visuals.
|
// building-local ToolManager group, so local coords are correct for both data and visuals.
|
||||||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||||||
|
const y = ctx.gridPosition.y
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gridPosition: [x, 0, z],
|
gridPosition: [x, y, z],
|
||||||
cursorPosition: [x, event.localPosition[1], z],
|
cursorPosition: [x, y, z],
|
||||||
cursorRotationY: rotY,
|
cursorRotationY: rotY,
|
||||||
nodeUpdate: { position: [x, 0, z] },
|
nodeUpdate: { position: [x, y, z] },
|
||||||
stopPropagation: false,
|
stopPropagation: false,
|
||||||
dirtyNodeId: null,
|
dirtyNodeId: null,
|
||||||
}
|
}
|
||||||
@@ -126,7 +127,11 @@ export const floorStrategy = {
|
|||||||
if (ctx.state.surface !== 'floor') return null
|
if (ctx.state.surface !== 'floor') return null
|
||||||
if (!(ctx.levelId && ctx.draftItem)) 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(
|
const valid = validators.canPlaceOnFloor(
|
||||||
ctx.levelId,
|
ctx.levelId,
|
||||||
pos,
|
pos,
|
||||||
@@ -793,7 +798,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|||||||
// Floor (no attachTo)
|
// Floor (no attachTo)
|
||||||
return validators.canPlaceOnFloor(
|
return validators.canPlaceOnFloor(
|
||||||
ctx.levelId,
|
ctx.levelId,
|
||||||
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||||
alignedDims,
|
alignedDims,
|
||||||
ctx.draftItem.rotation,
|
ctx.draftItem.rotation,
|
||||||
[ctx.draftItem.id],
|
[ctx.draftItem.id],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AssetInput } from '@pascal-app/core'
|
import type { AssetInput, ItemNode } from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
type AlignmentAnchor,
|
type AlignmentAnchor,
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
type ShelfEvent,
|
type ShelfEvent,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
|
||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -44,6 +43,7 @@ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||||
import {
|
import {
|
||||||
createLineGeometry,
|
createLineGeometry,
|
||||||
getBoxEdgePoints,
|
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
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||||
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||||
const edgeMaterial = new LineBasicNodeMaterial({
|
const edgeMaterial = new LineBasicNodeMaterial({
|
||||||
@@ -359,6 +375,23 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
applyPoints(measurementHeightRef, heightPoints)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
@@ -661,11 +694,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
result.gridPosition[1],
|
result.gridPosition[1],
|
||||||
result.gridPosition[2] + alignZ,
|
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
|
// Play snap sound when grid position changes
|
||||||
if (
|
if (
|
||||||
@@ -677,7 +705,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
previousGridPos = [...gridPos]
|
previousGridPos = [...gridPos]
|
||||||
gridPosition.current.set(...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
|
// Floor items only rotate on Y; keep the preview box (and the live
|
||||||
// transform the 2D floorplan mirrors) aligned with the draft's
|
// transform the 2D floorplan mirrors) aligned with the draft's
|
||||||
// rotation. Without this the box stays at its seed rotation until a
|
// rotation. Without this the box stays at its seed rotation until a
|
||||||
@@ -946,8 +975,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
shelfId: null,
|
shelfId: null,
|
||||||
})
|
})
|
||||||
gridPosition.current.set(wx, 0, wz)
|
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) {
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.set(wx, 0, wz)
|
cursorGroupRef.current.position.set(...floorVisualPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
@@ -1466,12 +1500,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
gridPosition.current.set(x, gridPosition.current.y, z)
|
gridPosition.current.set(x, gridPosition.current.y, z)
|
||||||
draft.position = [x, gridPosition.current.y, z]
|
draft.position = [x, gridPosition.current.y, z]
|
||||||
if (cursorGroupRef.current) {
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.x = x
|
if (surface === 'floor') {
|
||||||
cursorGroupRef.current.position.z = z
|
cursorGroupRef.current.position.set(
|
||||||
|
...getFloorVisualPosition([x, gridPosition.current.y, z]),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cursorGroupRef.current.position.x = x
|
||||||
|
cursorGroupRef.current.position.z = z
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
mesh.position.x = x
|
mesh.position.x = x
|
||||||
mesh.position.z = z
|
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) {
|
} else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
|
||||||
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
|
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
|
||||||
@@ -1501,13 +1544,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// Update live transform for 2D floorplan with post-snap position
|
// Update live transform for 2D floorplan with post-snap position
|
||||||
const currentLive = useLiveTransforms.getState().get(draft.id)
|
const currentLive = useLiveTransforms.getState().get(draft.id)
|
||||||
if (currentLive) {
|
if (currentLive) {
|
||||||
const livePosition: [number, number, number] = cursorGroupRef.current
|
const livePosition: [number, number, number] =
|
||||||
? [
|
surface === 'floor'
|
||||||
cursorGroupRef.current.position.x,
|
? [draft.position[0], draft.position[1], draft.position[2]]
|
||||||
cursorGroupRef.current.position.y,
|
: cursorGroupRef.current
|
||||||
cursorGroupRef.current.position.z,
|
? [
|
||||||
]
|
cursorGroupRef.current.position.x,
|
||||||
: [draft.position[0], draft.position[1], draft.position[2]]
|
cursorGroupRef.current.position.y,
|
||||||
|
cursorGroupRef.current.position.z,
|
||||||
|
]
|
||||||
|
: [draft.position[0], draft.position[1], draft.position[2]]
|
||||||
useLiveTransforms.getState().set(draft.id, {
|
useLiveTransforms.getState().set(draft.id, {
|
||||||
...currentLive,
|
...currentLive,
|
||||||
position: livePosition,
|
position: livePosition,
|
||||||
@@ -1647,6 +1693,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
canPlaceOnWall,
|
canPlaceOnWall,
|
||||||
canPlaceOnCeiling,
|
canPlaceOnCeiling,
|
||||||
draftNode,
|
draftNode,
|
||||||
|
getFloorVisualPosition,
|
||||||
gridSnapStep,
|
gridSnapStep,
|
||||||
updateDimensionGuides,
|
updateDimensionGuides,
|
||||||
updatePreviewGeometry,
|
updatePreviewGeometry,
|
||||||
@@ -1747,18 +1794,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
// Adjust Y for slab elevation (floor items on top of slabs)
|
// Adjust Y for slab elevation (floor items on top of slabs)
|
||||||
if (!asset.attachTo) {
|
if (!asset.attachTo) {
|
||||||
const nodes = useScene.getState().nodes
|
const visualPosition = getFloorVisualPosition([
|
||||||
const levelId = resolveLevelId(draftNode.current, nodes)
|
gridPosition.current.x,
|
||||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
gridPosition.current.y,
|
||||||
levelId,
|
gridPosition.current.z,
|
||||||
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
])
|
||||||
getGridAlignedDimensions(
|
mesh.position.y = visualPosition[1]
|
||||||
getScaledDimensions(draftNode.current),
|
cursorGroupRef.current.position.y = visualPosition[1]
|
||||||
draftNode.current.asset.attachTo,
|
|
||||||
),
|
|
||||||
draftNode.current.rotation,
|
|
||||||
)
|
|
||||||
mesh.position.y = slabElevation
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||||
import { PlacementBox } from '../shared/placement-box'
|
import { PlacementBox } from '../shared/placement-box'
|
||||||
|
|
||||||
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
||||||
@@ -174,11 +175,34 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
// fresh clone after a drop, or the user picks a different catalog tile —
|
// 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
|
// 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.
|
// would keep the previous clone's rotation/position until the next R/T.
|
||||||
setCursorPosition(originalPosition)
|
|
||||||
setCursorRotationY(originalRotationY)
|
setCursorRotationY(originalRotationY)
|
||||||
lastCursorRef.current = originalPosition
|
lastCursorRef.current = originalPosition
|
||||||
let committed = false
|
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
|
// 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 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
|
// 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)
|
setValid(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [x, , z] = lastCursorRef.current
|
const [x, y, z] = lastCursorRef.current
|
||||||
const { valid: placeable } = spatialGridManager.canPlaceOnFloor(
|
const { valid: placeable } = spatialGridManager.canPlaceOnFloor(
|
||||||
levelId,
|
levelId,
|
||||||
[x, 0, z],
|
[x, y, z],
|
||||||
boxDimensions,
|
boxDimensions,
|
||||||
[0, rotationRef.current, 0],
|
[0, rotationRef.current, 0],
|
||||||
[node.id],
|
[node.id],
|
||||||
@@ -209,14 +233,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
}
|
}
|
||||||
recomputeValidity()
|
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
|
// Disable raycast on the moved node's meshes for the duration of
|
||||||
// the drag. As the shelf follows the cursor, the cursor ray would
|
// the drag. As the shelf follows the cursor, the cursor ray would
|
||||||
// otherwise hit the moved mesh first → only `${kind}:move` fires →
|
// otherwise hit the moved mesh first → only `${kind}:move` fires →
|
||||||
@@ -275,13 +291,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const position: [number, number, number] = [x, originalPosition[1], z]
|
||||||
|
const visualPosition = getVisualPosition(position)
|
||||||
hasMovedRef.current = true
|
hasMovedRef.current = true
|
||||||
setCursorPosition([x, 0, z])
|
setCursorPosition(visualPosition)
|
||||||
lastCursorRef.current = [x, 0, z]
|
lastCursorRef.current = position
|
||||||
recomputeValidity()
|
recomputeValidity()
|
||||||
|
|
||||||
// Pure imperative: move the mesh via its registered Object3D ref.
|
// 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
|
// Publish to `useLiveTransforms` so the 2D floor plan can mirror
|
||||||
// the drag in real-time (the floor-plan layer subscribes to this
|
// the drag in real-time (the floor-plan layer subscribes to this
|
||||||
// store and overrides the node's rendered position when an entry
|
// 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 —
|
// (slab / ceiling / fence) follow a different delta contract —
|
||||||
// their floor-plan move-targets handle the override themselves.
|
// their floor-plan move-targets handle the override themselves.
|
||||||
useLiveTransforms.getState().set(node.id, {
|
useLiveTransforms.getState().set(node.id, {
|
||||||
position: [x, 0, z],
|
position,
|
||||||
rotation: rotationRef.current,
|
rotation: rotationRef.current,
|
||||||
})
|
})
|
||||||
|
markMovedNodeDirty()
|
||||||
|
|
||||||
const prev = previousSnapRef.current
|
const prev = previousSnapRef.current
|
||||||
if (!prev || prev[0] !== x || prev[1] !== z) {
|
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 position: [number, number, number] = [...lastCursorRef.current]
|
||||||
|
|
||||||
const rotation = toCommitRotation(rotationRef.current)
|
const rotation = toCommitRotation(rotationRef.current)
|
||||||
|
const visualPosition = getVisualPosition(position)
|
||||||
|
|
||||||
if (useScene.getState().nodes[node.id]) {
|
if (useScene.getState().nodes[node.id]) {
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
@@ -355,18 +375,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep mesh.position/rotation aligned with the just-committed scene
|
// Clear after the scene write so React reconciles against the new
|
||||||
// values so the next R3F frame paints correctly even if React's
|
// canonical position, then restamp the lifted presentation Y for the
|
||||||
// reconciliation lags by a tick.
|
// current frame.
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
const mesh = sceneRegistry.nodes.get(node.id)
|
const mesh = sceneRegistry.nodes.get(node.id)
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
mesh.position.set(position[0], position[1], position[2])
|
mesh.position.set(...visualPosition)
|
||||||
mesh.rotation.y = rotationRef.current
|
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()
|
useAlignmentGuides.getState().clear()
|
||||||
|
|
||||||
sfxEmitter.emit('sfx:item-place')
|
sfxEmitter.emit('sfx:item-place')
|
||||||
@@ -405,12 +423,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
rotationRef.current += delta
|
rotationRef.current += delta
|
||||||
setCursorRotationY(rotationRef.current)
|
setCursorRotationY(rotationRef.current)
|
||||||
|
const position = lastCursorRef.current
|
||||||
|
const visualPosition = getVisualPosition(position)
|
||||||
|
setCursorPosition(visualPosition)
|
||||||
const m = sceneRegistry.nodes.get(node.id)
|
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, {
|
useLiveTransforms.getState().set(node.id, {
|
||||||
position: lastCursorRef.current,
|
position,
|
||||||
rotation: rotationRef.current,
|
rotation: rotationRef.current,
|
||||||
})
|
})
|
||||||
|
markMovedNodeDirty()
|
||||||
// Rotation changes the footprint's collision span — re-check validity.
|
// Rotation changes the footprint's collision span — re-check validity.
|
||||||
recomputeValidity()
|
recomputeValidity()
|
||||||
}
|
}
|
||||||
@@ -437,13 +462,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
const m = sceneRegistry.nodes.get(node.id)
|
const m = sceneRegistry.nodes.get(node.id)
|
||||||
if (m) {
|
if (m) {
|
||||||
m.position.set(originalPosition[0], originalPosition[1], originalPosition[2])
|
m.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
||||||
m.rotation.y = originalRotationY
|
m.rotation.y = originalRotationY
|
||||||
}
|
}
|
||||||
useLiveTransforms.getState().clear(node.id)
|
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
markMovedNodeDirty()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
@@ -467,10 +493,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
// unmount / commit paths uniformly.
|
// unmount / commit paths uniformly.
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
if (!committed) {
|
if (!committed) {
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
sceneRegistry.nodes
|
sceneRegistry.nodes
|
||||||
.get(node.id)
|
.get(node.id)
|
||||||
?.position.set(originalPosition[0], originalPosition[1], originalPosition[2])
|
?.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
||||||
useLiveTransforms.getState().clear(node.id)
|
markMovedNodeDirty()
|
||||||
useScene.temporal.getState().resume()
|
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 Line,
|
||||||
type Object3D,
|
type Object3D,
|
||||||
Shape,
|
Shape,
|
||||||
SphereGeometry,
|
|
||||||
} from 'three'
|
} from 'three'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import {
|
import {
|
||||||
|
createMoveCrossHandleGeometry,
|
||||||
ARROW_COLOR as EDGE_ARROW_COLOR,
|
ARROW_COLOR as EDGE_ARROW_COLOR,
|
||||||
ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR,
|
ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR,
|
||||||
ARROW_SCALE as EDGE_ARROW_SCALE,
|
ARROW_SCALE as EDGE_ARROW_SCALE,
|
||||||
useArrowMaterial,
|
useArrowMaterial,
|
||||||
|
useInvisibleHitAreaMaterial,
|
||||||
} from '../../editor/node-arrow-handles'
|
} from '../../editor/node-arrow-handles'
|
||||||
import { snapToHalf } from '../item/placement-math'
|
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,
|
color,
|
||||||
position,
|
position,
|
||||||
...handlers
|
...handlers
|
||||||
@@ -193,20 +198,33 @@ function OutlinedSphereHandle({
|
|||||||
color: string
|
color: string
|
||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
} & HandleHandlers) {
|
} & HandleHandlers) {
|
||||||
const geometry = useMemo(() => new SphereGeometry(0.09, 20, 20), [])
|
const geometry = useMemo(() => createMoveCrossHandleGeometry(), [])
|
||||||
const material = usePolygonNodeMaterial(color)
|
const material = usePolygonNodeMaterial(color)
|
||||||
|
const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), [])
|
||||||
|
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||||
useEffect(() => () => geometry.dispose(), [geometry])
|
useEffect(() => () => geometry.dispose(), [geometry])
|
||||||
|
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<group position={position}>
|
||||||
frustumCulled={false}
|
<mesh
|
||||||
geometry={geometry}
|
frustumCulled={false}
|
||||||
layers={SCENE_LAYER}
|
geometry={geometry}
|
||||||
material={material}
|
layers={SCENE_LAYER}
|
||||||
position={position}
|
material={material}
|
||||||
renderOrder={1010}
|
raycast={NO_RAYCAST}
|
||||||
{...handlers}
|
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 (
|
return (
|
||||||
<OutlinedCylinderHandle
|
<OutlinedCylinderHandle
|
||||||
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
|
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||||
height={height}
|
height={height}
|
||||||
key={`vertex-${index}`}
|
key={`vertex-${index}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -693,8 +711,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{allowPolygonMove && (
|
{allowPolygonMove && (
|
||||||
<OutlinedSphereHandle
|
<OutlinedCrossHandle
|
||||||
color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'}
|
color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -760,7 +778,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
>
|
>
|
||||||
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
|
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
color={isDragging ? '#22c55e' : '#60a5fa'}
|
color={isDragging ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||||
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
|
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
|
||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
@@ -769,9 +787,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
Points outward from the edge; dragging it translates only this
|
Points outward from the edge; dragging it translates only this
|
||||||
edge's two vertices along the outward normal. */}
|
edge's two vertices along the outward normal. */}
|
||||||
<OutlinedEdgeArrowHandle
|
<OutlinedEdgeArrowHandle
|
||||||
color={
|
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||||
isDragging ? '#22c55e' : isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR
|
|
||||||
}
|
|
||||||
geometry={arrowGeometry}
|
geometry={arrowGeometry}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
@@ -807,7 +823,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<OutlinedCylinderHandle
|
<OutlinedCylinderHandle
|
||||||
color={isHovered ? '#4ade80' : '#22c55e'}
|
color={isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||||
height={height}
|
height={height}
|
||||||
key={`midpoint-${index}`}
|
key={`midpoint-${index}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
||||||
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
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(
|
function createDefaultStairSegment() {
|
||||||
levelId: LevelNode['id'],
|
return StairSegmentNode.parse({
|
||||||
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({
|
|
||||||
segmentType: 'stair',
|
segmentType: 'stair',
|
||||||
width: DEFAULT_STAIR_WIDTH,
|
width: DEFAULT_STAIR_WIDTH,
|
||||||
length: DEFAULT_STAIR_LENGTH,
|
length: DEFAULT_STAIR_LENGTH,
|
||||||
@@ -96,14 +88,24 @@ function commitStairPlacement(
|
|||||||
thickness: DEFAULT_STAIR_THICKNESS,
|
thickness: DEFAULT_STAIR_THICKNESS,
|
||||||
position: [0, 0, 0],
|
position: [0, 0, 0],
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const sortedLevels = Object.values(nodes)
|
function createDefaultStairNode({
|
||||||
.filter((node): node is LevelNode => node.type === 'level')
|
name,
|
||||||
.sort((left, right) => left.level - right.level)
|
levelId,
|
||||||
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
|
nextLevelId,
|
||||||
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
|
position,
|
||||||
|
rotation,
|
||||||
const stair = StairNode.parse({
|
segmentId,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
levelId: LevelNode['id']
|
||||||
|
nextLevelId: LevelNode['id']
|
||||||
|
position: [number, number, number]
|
||||||
|
rotation: number
|
||||||
|
segmentId: StairSegmentNode['id']
|
||||||
|
}) {
|
||||||
|
return StairNode.parse({
|
||||||
name,
|
name,
|
||||||
position,
|
position,
|
||||||
rotation,
|
rotation,
|
||||||
@@ -125,7 +127,37 @@ function commitStairPlacement(
|
|||||||
showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
||||||
railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
|
railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
|
||||||
railingMode: DEFAULT_STAIR_RAILING_MODE,
|
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([
|
createNodes([
|
||||||
@@ -141,6 +173,7 @@ export const StairTool: React.FC = () => {
|
|||||||
const previewRef = useRef<THREE.Group>(null)
|
const previewRef = useRef<THREE.Group>(null)
|
||||||
const rotationRef = useRef(0)
|
const rotationRef = useRef(0)
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
|
const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
|
||||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||||
|
|
||||||
const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
|
const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
|
||||||
@@ -151,6 +184,49 @@ export const StairTool: React.FC = () => {
|
|||||||
// Reset rotation when tool activates
|
// Reset rotation when tool activates
|
||||||
rotationRef.current = 0
|
rotationRef.current = 0
|
||||||
if (previewRef.current) previewRef.current.rotation.y = 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
|
// Alignment candidates — anchors of every alignable object; refreshed
|
||||||
// after each placement. The stair aligns by its ORIGIN point.
|
// after each placement. The stair aligns by its ORIGIN point.
|
||||||
@@ -199,15 +275,9 @@ export const StairTool: React.FC = () => {
|
|||||||
event.localPosition[2],
|
event.localPosition[2],
|
||||||
event.nativeEvent?.altKey === true,
|
event.nativeEvent?.altKey === true,
|
||||||
)
|
)
|
||||||
const y = event.localPosition[1]
|
const position: [number, number, number] = [gridX, 0, gridZ]
|
||||||
|
lastCanonicalPositionRef.current = position
|
||||||
if (cursorRef.current) {
|
applyPreview(position, rotationRef.current)
|
||||||
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previewRef.current) {
|
|
||||||
previewRef.current.position.set(gridX, y, gridZ)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousGridPosRef.current &&
|
previousGridPosRef.current &&
|
||||||
@@ -248,7 +318,9 @@ export const StairTool: React.FC = () => {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
rotationRef.current += rotationDelta
|
rotationRef.current += rotationDelta
|
||||||
if (previewRef.current) {
|
if (lastCanonicalPositionRef.current) {
|
||||||
|
applyPreview(lastCanonicalPositionRef.current, rotationRef.current)
|
||||||
|
} else if (previewRef.current) {
|
||||||
previewRef.current.rotation.y = rotationRef.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
|
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
|
||||||
| SlabNode['id']
|
| SlabNode['id']
|
||||||
| undefined
|
| 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
|
// Check if a ceiling is selected
|
||||||
const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
|
const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
|
||||||
@@ -92,11 +97,14 @@ export const ToolManager: React.FC = () => {
|
|||||||
phase === 'structure' &&
|
phase === 'structure' &&
|
||||||
mode === 'select' &&
|
mode === 'select' &&
|
||||||
selectedSlabId !== undefined &&
|
selectedSlabId !== undefined &&
|
||||||
(!editingHole || editingHole.nodeId !== selectedSlabId)
|
!editingSlabHoleIsManual
|
||||||
|
|
||||||
// Show slab hole editor when editing a hole on the selected slab
|
// Show slab hole editor when editing a hole on the selected slab
|
||||||
const showSlabHoleEditor =
|
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)
|
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
|
||||||
const showCeilingBoundaryEditor =
|
const showCeilingBoundaryEditor =
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export {
|
|||||||
} from './components/tools/item/use-placement-coordinator'
|
} from './components/tools/item/use-placement-coordinator'
|
||||||
export { CursorSphere } from './components/tools/shared/cursor-sphere'
|
export { CursorSphere } from './components/tools/shared/cursor-sphere'
|
||||||
export { DragBoundingBox } from './components/tools/shared/drag-bounding-box'
|
export { DragBoundingBox } from './components/tools/shared/drag-bounding-box'
|
||||||
|
export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview'
|
||||||
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
|
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
|
||||||
export {
|
export {
|
||||||
PolygonEditor,
|
PolygonEditor,
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ function resetEditorInteractionState() {
|
|||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
spaces: {},
|
spaces: {},
|
||||||
editingHole: null,
|
editingHole: null,
|
||||||
|
hoveredHole: null,
|
||||||
isPreviewMode: false,
|
isPreviewMode: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ type MaterialPaintSelectionSnapshot = {
|
|||||||
activePaintMaterial: ActivePaintMaterial | null
|
activePaintMaterial: ActivePaintMaterial | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SurfaceHoleTarget = { nodeId: string; holeIndex: number }
|
||||||
|
|
||||||
export type GuideUiState = {
|
export type GuideUiState = {
|
||||||
locked?: boolean
|
locked?: boolean
|
||||||
scaleReferenceVisible?: boolean
|
scaleReferenceVisible?: boolean
|
||||||
@@ -296,8 +298,10 @@ type EditorState = {
|
|||||||
spaces: Record<string, Space>
|
spaces: Record<string, Space>
|
||||||
setSpaces: (spaces: Record<string, Space>) => void
|
setSpaces: (spaces: Record<string, Space>) => void
|
||||||
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
|
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
|
||||||
editingHole: { nodeId: string; holeIndex: number } | null
|
editingHole: SurfaceHoleTarget | null
|
||||||
setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
|
setEditingHole: (hole: SurfaceHoleTarget | null) => void
|
||||||
|
hoveredHole: SurfaceHoleTarget | null
|
||||||
|
setHoveredHole: (hole: SurfaceHoleTarget | null) => void
|
||||||
// Preview mode (viewer-like experience inside the editor)
|
// Preview mode (viewer-like experience inside the editor)
|
||||||
isPreviewMode: boolean
|
isPreviewMode: boolean
|
||||||
setPreviewMode: (preview: boolean) => void
|
setPreviewMode: (preview: boolean) => void
|
||||||
@@ -808,6 +812,14 @@ const useEditor = create<EditorState>()(
|
|||||||
setSpaces: (spaces) => set({ spaces }),
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
editingHole: null,
|
editingHole: null,
|
||||||
setEditingHole: (hole) => set({ editingHole: hole }),
|
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,
|
isPreviewMode: false,
|
||||||
setPreviewMode: (preview) => {
|
setPreviewMode: (preview) => {
|
||||||
if (preview) {
|
if (preview) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const BRACE_HANDLE_OFFSET = 0.3
|
|||||||
const SPREAD_HANDLE_OFFSET = 0.22
|
const SPREAD_HANDLE_OFFSET = 0.22
|
||||||
const ROTATE_CORNER_OFFSET = 0.32
|
const ROTATE_CORNER_OFFSET = 0.32
|
||||||
const ROTATE_RING_OFFSET = 0.04
|
const ROTATE_RING_OFFSET = 0.04
|
||||||
|
const MOVE_FRONT_OFFSET = 0.35
|
||||||
const MIN_COLUMN_HEIGHT = 0.2
|
const MIN_COLUMN_HEIGHT = 0.2
|
||||||
const MIN_COLUMN_WIDTH = 0.1
|
const MIN_COLUMN_WIDTH = 0.1
|
||||||
const MIN_COLUMN_DEPTH = 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>[] {
|
function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[] {
|
||||||
// 1. Height (universal).
|
// 1. Height (universal).
|
||||||
// 2. Footprint arrows depending on supportStyle + crossSection:
|
// 2. Footprint arrows depending on supportStyle + crossSection:
|
||||||
@@ -265,7 +289,7 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
|
|||||||
} else {
|
} else {
|
||||||
handles.push(columnAxisHandle('x'), columnAxisHandle('z'))
|
handles.push(columnAxisHandle('x'), columnAxisHandle('z'))
|
||||||
}
|
}
|
||||||
handles.push(columnRotateHandle())
|
handles.push(columnRotateHandle(), columnMoveHandle())
|
||||||
return handles
|
return handles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
|
getFloorStackPreviewPosition,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useEditor,
|
useEditor,
|
||||||
@@ -74,6 +75,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
? (node.metadata as Record<string, unknown>)
|
? (node.metadata as Record<string, unknown>)
|
||||||
: {}
|
: {}
|
||||||
const isNew = !!meta.isNew
|
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
|
// Alignment candidates — every other alignable object's anchors, gathered
|
||||||
// once (the scene graph is stable during the imperative drag).
|
// 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]) => {
|
const applyPreview = (position: [number, number, number]) => {
|
||||||
lastPosition = position
|
lastPosition = position
|
||||||
setPreviewPosition(position)
|
const visualPosition = getVisualPosition(position)
|
||||||
|
setPreviewPosition(visualPosition)
|
||||||
setPreviewRotation(rotationY)
|
setPreviewRotation(rotationY)
|
||||||
useLiveTransforms.getState().set(node.id, {
|
useLiveTransforms.getState().set(node.id, {
|
||||||
position,
|
position,
|
||||||
rotation: rotationY,
|
rotation: rotationY,
|
||||||
})
|
})
|
||||||
|
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||||
const m = sceneRegistry.nodes.get(node.id)
|
const m = sceneRegistry.nodes.get(node.id)
|
||||||
if (m) {
|
if (m) {
|
||||||
m.position.set(position[0], position[1], position[2])
|
m.position.set(...visualPosition)
|
||||||
m.rotation.y = rotationY
|
m.rotation.y = rotationY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPreviewPosition(getVisualPosition(node.position, node.rotation))
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
hasMoved = true
|
hasMoved = true
|
||||||
let x = snapToGridStep(event.localPosition[0])
|
let x = snapToGridStep(event.localPosition[0])
|
||||||
@@ -145,11 +160,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
|
|
||||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||||
committed = true
|
committed = true
|
||||||
useLiveTransforms.getState().clear(nodeId)
|
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useScene
|
useScene
|
||||||
.getState()
|
.getState()
|
||||||
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) })
|
.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) {
|
} else if (node.parentId) {
|
||||||
const column = ColumnNodeSchema.parse({
|
const column = ColumnNodeSchema.parse({
|
||||||
...node,
|
...node,
|
||||||
@@ -174,9 +194,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
const m = sceneRegistry.nodes.get(node.id)
|
const m = sceneRegistry.nodes.get(node.id)
|
||||||
if (m) {
|
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
|
m.rotation.y = node.rotation
|
||||||
}
|
}
|
||||||
|
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
@@ -197,9 +218,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
if (!committed) {
|
if (!committed) {
|
||||||
const m = sceneRegistry.nodes.get(node.id)
|
const m = sceneRegistry.nodes.get(node.id)
|
||||||
if (m) {
|
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
|
m.rotation.y = node.rotation
|
||||||
}
|
}
|
||||||
|
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} 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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import type { Group } from 'three'
|
import type { Group } from 'three'
|
||||||
@@ -91,13 +91,20 @@ const ColumnTool = () => {
|
|||||||
useAlignmentGuides.getState().clear()
|
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
|
// Publish a transient, positioned preview node for the 2D floor-plan
|
||||||
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan
|
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan
|
||||||
// placement-preview layer renders this node's footprint at the snapped,
|
// placement-preview layer renders this node's footprint at the snapped,
|
||||||
// aligned cursor so users see the pillar before they click.
|
// 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
|
const prev = previousSnapRef.current
|
||||||
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type FenceNode,
|
type FenceNode,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
|
nodeRegistry,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
resolveAlignment,
|
resolveAlignment,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
clearRoofDuplicateMetadata,
|
clearRoofDuplicateMetadata,
|
||||||
|
getFloorStackPreviewPosition,
|
||||||
snapFenceDraftPoint,
|
snapFenceDraftPoint,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useEditor,
|
useEditor,
|
||||||
@@ -113,6 +115,11 @@ export const MoveRoofTool: React.FC<{
|
|||||||
|
|
||||||
// Track pending rotation — no store updates during drag
|
// Track pending rotation — no store updates during drag
|
||||||
let pendingRotation: number = movingNode.rotation as number
|
let pendingRotation: number = movingNode.rotation as number
|
||||||
|
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,
|
// For roof-segment moves: the selection was cleared before entering move mode,
|
||||||
// so isSelected=false on the parent roof, hiding individual segment meshes and
|
// 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 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 =
|
const levelNode =
|
||||||
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
|
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
|
||||||
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
|
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
|
||||||
@@ -260,20 +281,28 @@ export const MoveRoofTool: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
previousGridPosRef.current = [gridX, gridZ]
|
previousGridPosRef.current = [gridX, gridZ]
|
||||||
setCursorWorldPos([lx, event.localPosition[1], lz])
|
|
||||||
|
|
||||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, 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
|
// Directly update the Three.js mesh — no store update during drag
|
||||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
mesh.position.x = localX
|
if (isFloorPlaced) {
|
||||||
mesh.position.z = localZ
|
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, {
|
useLiveTransforms.getState().set(movingNode.id, {
|
||||||
position: [gridX, y, gridZ],
|
position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ],
|
||||||
rotation: pendingRotation,
|
rotation: pendingRotation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -359,7 +388,14 @@ export const MoveRoofTool: React.FC<{
|
|||||||
|
|
||||||
// Directly update the Three.js mesh — no store update during drag
|
// Directly update the Three.js mesh — no store update during drag
|
||||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||||
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
|
// Update live transform rotation for 2D floorplan
|
||||||
const currentLive = useLiveTransforms.getState().get(movingNode.id)
|
const currentLive = useLiveTransforms.getState().get(movingNode.id)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core'
|
import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core'
|
||||||
|
import { sanitizeShelfDimensions } from './dimensions'
|
||||||
import { buildShelfFloorplan } from './floorplan'
|
import { buildShelfFloorplan } from './floorplan'
|
||||||
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
|
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
|
||||||
import { shelfFloorplanMoveTarget } from './floorplan-move'
|
import { shelfFloorplanMoveTarget } from './floorplan-move'
|
||||||
@@ -10,6 +11,7 @@ const SIDE_HANDLE_OFFSET = 0.18
|
|||||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||||
const ROTATE_CORNER_OFFSET = 0.32
|
const ROTATE_CORNER_OFFSET = 0.32
|
||||||
const ROTATE_RING_OFFSET = 0.04
|
const ROTATE_RING_OFFSET = 0.04
|
||||||
|
const MOVE_FRONT_OFFSET = 0.35
|
||||||
const MIN_SHELF_WIDTH = 0.3
|
const MIN_SHELF_WIDTH = 0.3
|
||||||
const MIN_SHELF_DEPTH = 0.1
|
const MIN_SHELF_DEPTH = 0.1
|
||||||
const MIN_SHELF_HEIGHT = 0.05
|
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>[] {
|
function shelfHandles(_node: ShelfNodeType): HandleDescriptor<ShelfNodeType>[] {
|
||||||
return [shelfWidthHandle(), shelfDepthHandle(), shelfHeightHandle(), shelfRotateHandle()]
|
return [
|
||||||
|
shelfWidthHandle(),
|
||||||
|
shelfDepthHandle(),
|
||||||
|
shelfHeightHandle(),
|
||||||
|
shelfRotateHandle(),
|
||||||
|
shelfMoveHandle(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
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.
|
// shelf sitting over a raised slab visually rests on top of it.
|
||||||
floorPlaced: {
|
floorPlaced: {
|
||||||
footprint: (node) => {
|
footprint: (node) => {
|
||||||
const shelf = node as ShelfNode
|
const shelf = sanitizeShelfDimensions(node as ShelfNode)
|
||||||
return {
|
return {
|
||||||
dimensions: [shelf.width, shelf.height, shelf.depth] as [number, number, number],
|
dimensions: [shelf.width, shelf.height, shelf.depth] as [number, number, number],
|
||||||
rotation: shelf.rotation,
|
rotation: shelf.rotation,
|
||||||
@@ -189,7 +218,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
|||||||
// `children`. Lets <GeometrySystem> skip the dispose+rebuild (and the
|
// `children`. Lets <GeometrySystem> skip the dispose+rebuild (and the
|
||||||
// pointer enter/leave churn it causes) when an item reparents onto a row.
|
// pointer enter/leave churn it causes) when an item reparents onto a row.
|
||||||
geometryKey: (n) => {
|
geometryKey: (n) => {
|
||||||
const s = n as ShelfNodeType
|
const s = sanitizeShelfDimensions(n as ShelfNode)
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
s.style,
|
s.style,
|
||||||
s.width,
|
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'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
applyFloorplanAlignment,
|
applyFloorplanAlignment,
|
||||||
|
getFloorStackPreviewPosition,
|
||||||
snapPointToGrid,
|
snapPointToGrid,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
@@ -82,6 +83,12 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
|||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
lastSnapKey = snapKey
|
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
|
// Single source of truth — write the absolute position straight to
|
||||||
// the scene (history is paused by the overlay). Both the 2D SVG and
|
// the scene (history is paused by the overlay). Both the 2D SVG and
|
||||||
// the 3D group transform read `node.position` reactively, so they
|
// the 3D group transform read `node.position` reactively, so they
|
||||||
@@ -90,7 +97,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
|||||||
useScene.getState().updateNodes([
|
useScene.getState().updateNodes([
|
||||||
{
|
{
|
||||||
id: shelfId,
|
id: shelfId,
|
||||||
data: { position: next },
|
data: { position: visualPosition },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
|
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
|
||||||
|
import { sanitizeShelfDimensions } from './dimensions'
|
||||||
import type { ShelfResizePayload } from './floorplan-affordances'
|
import type { ShelfResizePayload } from './floorplan-affordances'
|
||||||
import type { ShelfNode } from './schema'
|
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).
|
* (engaged from the action-menu Move button, not from these arrows).
|
||||||
*/
|
*/
|
||||||
export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry {
|
export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry {
|
||||||
const [px, , pz] = node.position
|
const shelf = sanitizeShelfDimensions(node)
|
||||||
const ry = node.rotation[1] ?? 0
|
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
|
// 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
|
// ends up visually matching Three.js Y-rotation (CCW from a top-down
|
||||||
// view) — same `rotation` value rotates the same way in both views.
|
// view) — same `rotation` value rotates the same way in both views.
|
||||||
// Stair already does this; column / shelf / roof-segment now do too.
|
// Stair already does this; column / shelf / roof-segment now do too.
|
||||||
const planRy = -ry
|
const planRy = -ry
|
||||||
const halfW = node.width / 2
|
const halfW = shelf.width / 2
|
||||||
const halfD = node.depth / 2
|
const halfD = shelf.depth / 2
|
||||||
const isSelected = ctx?.viewState?.selected ?? false
|
const isSelected = ctx?.viewState?.selected ?? false
|
||||||
|
|
||||||
// Floor-plan fill: a single neutral fill regardless of `material`.
|
// Floor-plan fill: a single neutral fill regardless of `material`.
|
||||||
@@ -44,8 +46,8 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo
|
|||||||
kind: 'rect',
|
kind: 'rect',
|
||||||
x: -halfW,
|
x: -halfW,
|
||||||
y: -halfD,
|
y: -halfD,
|
||||||
width: node.width,
|
width: shelf.width,
|
||||||
height: node.depth,
|
height: shelf.depth,
|
||||||
fill: '#d6d3d1',
|
fill: '#d6d3d1',
|
||||||
stroke: '#1f2937',
|
stroke: '#1f2937',
|
||||||
strokeWidth: 0.015,
|
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
|
// Show column dividers for grid-style shelves so the cubby / bookshelf
|
||||||
// grid is visible from above.
|
// grid is visible from above.
|
||||||
if ((node.style === 'bookshelf' || node.style === 'cubby') && node.columns > 1) {
|
if ((shelf.style === 'bookshelf' || shelf.style === 'cubby') && shelf.columns > 1) {
|
||||||
const innerWidth = node.width - 2 * node.thickness
|
const innerWidth = shelf.width - 2 * shelf.thickness
|
||||||
const colStep = innerWidth / node.columns
|
const colStep = innerWidth / shelf.columns
|
||||||
for (let c = 1; c < node.columns; c++) {
|
for (let c = 1; c < shelf.columns; c++) {
|
||||||
const x = -innerWidth / 2 + c * colStep
|
const x = -innerWidth / 2 + c * colStep
|
||||||
footprintChildren.push({
|
footprintChildren.push({
|
||||||
kind: 'line',
|
kind: 'line',
|
||||||
x1: x,
|
x1: x,
|
||||||
y1: -halfD + node.thickness,
|
y1: -halfD + shelf.thickness,
|
||||||
x2: x,
|
x2: x,
|
||||||
y2: halfD - node.thickness,
|
y2: halfD - shelf.thickness,
|
||||||
stroke: '#1f2937',
|
stroke: '#1f2937',
|
||||||
strokeWidth: 0.012,
|
strokeWidth: 0.012,
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type RenderShading,
|
type RenderShading,
|
||||||
} from '@pascal-app/viewer'
|
} from '@pascal-app/viewer'
|
||||||
import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three'
|
import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three'
|
||||||
|
import { sanitizeShelfDimensions } from './dimensions'
|
||||||
import type { ShelfNode } from './schema'
|
import type { ShelfNode } from './schema'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,10 +70,11 @@ function getShelfMaterial(node: ShelfNode, shading: RenderShading): Material {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildShelfGeometry(
|
export function buildShelfGeometry(
|
||||||
node: ShelfNode,
|
rawNode: ShelfNode,
|
||||||
_ctx?: unknown,
|
_ctx?: unknown,
|
||||||
shading: RenderShading = 'rendered',
|
shading: RenderShading = 'rendered',
|
||||||
): Group {
|
): Group {
|
||||||
|
const node = sanitizeShelfDimensions(rawNode)
|
||||||
const group = new Group()
|
const group = new Group()
|
||||||
group.name = 'shelf-geometry'
|
group.name = 'shelf-geometry'
|
||||||
|
|
||||||
@@ -343,8 +345,9 @@ function addCornerPosts(
|
|||||||
* can host in the lowest cell.
|
* can host in the lowest cell.
|
||||||
*/
|
*/
|
||||||
export function shelfRowSurfaceYs(node: ShelfNode): number[] {
|
export function shelfRowSurfaceYs(node: ShelfNode): number[] {
|
||||||
const ys = boardCenterYs(node).map((y) => y + node.thickness / 2)
|
const safe = sanitizeShelfDimensions(node)
|
||||||
const bottomApplies = node.style === 'cubby' || node.style === 'bookshelf'
|
const ys = boardCenterYs(safe).map((y) => y + safe.thickness / 2)
|
||||||
if (node.withBottom && bottomApplies) ys.unshift(node.thickness)
|
const bottomApplies = safe.style === 'cubby' || safe.style === 'bookshelf'
|
||||||
|
if (safe.withBottom && bottomApplies) ys.unshift(safe.thickness)
|
||||||
return ys
|
return ys
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { triggerSFX } from '@pascal-app/editor'
|
import { getFloorStackPreviewPosition, triggerSFX } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { type Group, Vector3 } from 'three'
|
import { type Group, Vector3 } from 'three'
|
||||||
@@ -70,16 +70,16 @@ function getLevelLocalPosition(
|
|||||||
const local = (event as GridEvent).localPosition
|
const local = (event as GridEvent).localPosition
|
||||||
if (local) {
|
if (local) {
|
||||||
const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP)
|
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)
|
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])
|
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||||
levelObject.updateWorldMatrix(true, false)
|
levelObject.updateWorldMatrix(true, false)
|
||||||
levelObject.worldToLocal(worldVector)
|
levelObject.worldToLocal(worldVector)
|
||||||
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP)
|
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP)
|
||||||
return [sx, worldVector.y, sz]
|
return [sx, 0, sz]
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShelfTool = () => {
|
const ShelfTool = () => {
|
||||||
@@ -150,8 +150,15 @@ const ShelfTool = () => {
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
cursorRef.current?.position.set(ax, event.localPosition[1], az)
|
const position: [number, number, number] = [ax, 0, az]
|
||||||
lastCursorRef.current = [ax, event.localPosition[1], az]
|
const visualPosition = getFloorStackPreviewPosition({
|
||||||
|
node: previewNode,
|
||||||
|
position,
|
||||||
|
rotation: previewNode.rotation,
|
||||||
|
levelId: activeLevelId,
|
||||||
|
})
|
||||||
|
cursorRef.current?.position.set(...visualPosition)
|
||||||
|
lastCursorRef.current = position
|
||||||
|
|
||||||
const prev = previousSnapRef.current
|
const prev = previousSnapRef.current
|
||||||
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
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 slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
||||||
const holes = slab?.holes || []
|
const holes = slab?.holes || []
|
||||||
const hole = holes[holeIndex]
|
const hole = holes[holeIndex]
|
||||||
|
const metadata = slab?.holeMetadata?.[holeIndex]
|
||||||
|
|
||||||
const handlePolygonChange = useCallback(
|
const handlePolygonChange = useCallback(
|
||||||
(newPolygon: Array<[number, number]>) => {
|
(newPolygon: Array<[number, number]>) => {
|
||||||
@@ -60,7 +61,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
|
|||||||
}
|
}
|
||||||
}, [slabId])
|
}, [slabId])
|
||||||
|
|
||||||
if (!(slab && hole) || hole.length < 3) return null
|
if (!(slab && hole) || hole.length < 3 || metadata?.source !== 'manual') return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PolygonEditor
|
<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 { buildSpawnFloorplan } from './floorplan'
|
||||||
import { spawnParametrics } from './parametrics'
|
import { spawnParametrics } from './parametrics'
|
||||||
import { SpawnNode } from './schema'
|
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> = {
|
export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||||
kind: 'spawn',
|
kind: 'spawn',
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -37,6 +52,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
parametrics: spawnParametrics,
|
parametrics: spawnParametrics,
|
||||||
|
handles: [spawnMoveHandle()],
|
||||||
|
|
||||||
renderer: {
|
renderer: {
|
||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
'use client'
|
'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 { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { Color, type Group, Shape } from 'three'
|
import { Color, type Group, Shape } from 'three'
|
||||||
@@ -20,6 +26,11 @@ const SPAWN_COLOR = new Color('#22c55e')
|
|||||||
const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
|
const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
|
||||||
const ref = useRef<Group>(null!)
|
const ref = useRef<Group>(null!)
|
||||||
const handlers = useNodeEvents(node, 'spawn')
|
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 liveTransform = useLiveTransforms((state) => state.get(node.id))
|
||||||
const walkthroughMode = useViewer((state) => state.walkthroughMode)
|
const walkthroughMode = useViewer((state) => state.walkthroughMode)
|
||||||
const shading = useViewer((state) => state.shading)
|
const shading = useViewer((state) => state.shading)
|
||||||
@@ -52,10 +63,10 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group
|
<group
|
||||||
position={liveTransform?.position ?? node.position}
|
position={liveTransform?.position ?? effectiveNode.position}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
rotation={[0, liveTransform?.rotation ?? node.rotation, 0]}
|
rotation={[0, liveTransform?.rotation ?? effectiveNode.rotation, 0]}
|
||||||
visible={!walkthroughMode}
|
visible={!walkthroughMode && effectiveNode.visible !== false}
|
||||||
>
|
>
|
||||||
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
|
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
|
||||||
<ringGeometry args={[0.34, 0.48, 48]} />
|
<ringGeometry args={[0.34, 0.48, 48]} />
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core'
|
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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { type Group, Vector3 } from 'three'
|
import { type Group, Vector3 } from 'three'
|
||||||
@@ -20,16 +25,12 @@ function getExistingSpawnIds() {
|
|||||||
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] {
|
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] {
|
||||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||||
if (!levelObject) {
|
if (!levelObject) {
|
||||||
return [
|
return [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]
|
||||||
roundToHalf(event.localPosition[0]),
|
|
||||||
event.localPosition[1],
|
|
||||||
roundToHalf(event.localPosition[2]),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
worldVector.set(event.position[0], event.position[1], event.position[2])
|
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||||
levelObject.updateWorldMatrix(true, false)
|
levelObject.updateWorldMatrix(true, false)
|
||||||
levelObject.worldToLocal(worldVector)
|
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.
|
// same half-meter snap the legacy tool uses.
|
||||||
const nextX = roundToHalf(event.localPosition[0])
|
const nextX = roundToHalf(event.localPosition[0])
|
||||||
const nextZ = roundToHalf(event.localPosition[2])
|
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,
|
// Fire grid-snap SFX only when the snapped position crosses a cell,
|
||||||
// not every frame the mouse moves within the same cell. Matches the
|
// not every frame the mouse moves within the same cell. Matches the
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
type HandleDescriptor,
|
type HandleDescriptor,
|
||||||
type NodeDefinition,
|
type NodeDefinition,
|
||||||
|
type SceneApi,
|
||||||
StairNode as StairNodeSchema,
|
StairNode as StairNodeSchema,
|
||||||
type StairNode as StairNodeType,
|
type StairNode as StairNodeType,
|
||||||
|
type StairSegmentNode,
|
||||||
stairFootprintAABB,
|
stairFootprintAABB,
|
||||||
} from '@pascal-app/core'
|
} 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.
|
// the footprint. Same pattern as elevator / column / shelf / roof-segment.
|
||||||
const STAIR_ROTATE_CORNER_OFFSET = 0.4
|
const STAIR_ROTATE_CORNER_OFFSET = 0.4
|
||||||
const STAIR_ROTATE_RING_OFFSET = 0.08
|
const STAIR_ROTATE_RING_OFFSET = 0.08
|
||||||
|
const STAIR_MOVE_FRONT_OFFSET = 0.35
|
||||||
|
|
||||||
type CurvedStairGeom = {
|
type CurvedStairGeom = {
|
||||||
isSpiral: boolean
|
isSpiral: boolean
|
||||||
@@ -42,6 +45,14 @@ type CurvedStairGeom = {
|
|||||||
minInnerRadius: number
|
minInnerRadius: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StairMoveBounds = {
|
||||||
|
minX: number
|
||||||
|
maxX: number
|
||||||
|
minZ: number
|
||||||
|
maxZ: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom {
|
function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom {
|
||||||
const isSpiral = node.stairType === 'spiral'
|
const isSpiral = node.stairType === 'spiral'
|
||||||
const stepCount = Math.max(2, Math.round(node.stepCount ?? 10))
|
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'
|
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> {
|
function curvedRiseHandle(): HandleDescriptor<StairNodeType> {
|
||||||
return {
|
return {
|
||||||
kind: 'linear-resize',
|
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>[] {
|
function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
|
||||||
// Straight stairs have no parent-level shape arrows — the segment
|
// Straight stairs have no parent-level shape arrows — the segment
|
||||||
// children each render their own (width / length / height). Curved +
|
// children each render their own (width / length / height). Curved +
|
||||||
@@ -282,10 +389,14 @@ function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
|
|||||||
curvedSweepHandle('end'),
|
curvedSweepHandle('end'),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
handles.push(stairRotateHandle())
|
handles.push(stairRotateHandle(), stairMoveHandle())
|
||||||
return handles
|
return handles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
computeStairSegmentFloorStackTransforms,
|
||||||
|
getStairFloorPlacedFootprints,
|
||||||
|
} from './floor-stack'
|
||||||
import { buildStairFloorplan } from './floorplan'
|
import { buildStairFloorplan } from './floorplan'
|
||||||
import {
|
import {
|
||||||
curvedStairInnerRadiusAffordance,
|
curvedStairInnerRadiusAffordance,
|
||||||
@@ -330,6 +441,10 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
|
|||||||
},
|
},
|
||||||
duplicable: true,
|
duplicable: true,
|
||||||
deletable: true,
|
deletable: true,
|
||||||
|
floorPlaced: {
|
||||||
|
footprints: (node, ctx) =>
|
||||||
|
ctx ? getStairFloorPlacedFootprints(node as StairNodeType, ctx.nodes) : [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bespoke move shared with roof / roof-segment / stair-segment via
|
// 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
|
if (SHADOW_EXCLUDED_TYPES.some((t) => sceneRegistry.byType[t]!.has(id))) continue
|
||||||
box.expandByObject(obj)
|
box.expandByObject(obj)
|
||||||
}
|
}
|
||||||
if (box.isEmpty()) {
|
box.getBoundingSphere(boundsSphere.current)
|
||||||
// Empty scene: fall back to the origin with a default radius so the
|
const center = boundsSphere.current.center
|
||||||
// ground still receives a sensible shadow region.
|
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)
|
shadowFocus.current.set(0, 0, 0)
|
||||||
shadowRadius.current = SHADOW_FALLBACK_RADIUS
|
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 {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
getEffectiveNode,
|
||||||
|
getFloorStackedPosition,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
resolveLevelId,
|
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import type * as THREE from 'three'
|
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.
|
* Generic floor-elevation system.
|
||||||
*
|
*
|
||||||
@@ -25,11 +53,12 @@ import type * as THREE from 'three'
|
|||||||
*
|
*
|
||||||
* Runs at priority 1 — before the priority-2 systems (`GeometrySystem`,
|
* Runs at priority 1 — before the priority-2 systems (`GeometrySystem`,
|
||||||
* `ItemSystem`) so the dirty mark survives long enough for those to do
|
* `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
|
* their own work. Kinds with no geometry/system have no downstream dirty
|
||||||
* generic geometry rebuild) is responsible for that.
|
* consumer, so this system clears their dirty mark after applying the lift.
|
||||||
*/
|
*/
|
||||||
export const FloorElevationSystem = () => {
|
export const FloorElevationSystem = () => {
|
||||||
const dirtyNodes = useScene((s) => s.dirtyNodes)
|
const dirtyNodes = useScene((s) => s.dirtyNodes)
|
||||||
|
const clearDirty = useScene((s) => s.clearDirty)
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (dirtyNodes.size === 0) return
|
if (dirtyNodes.size === 0) return
|
||||||
@@ -43,31 +72,31 @@ export const FloorElevationSystem = () => {
|
|||||||
const floorPlaced = def?.capabilities?.floorPlaced
|
const floorPlaced = def?.capabilities?.floorPlaced
|
||||||
if (!floorPlaced) return
|
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
|
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
|
||||||
if (!mesh) return
|
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
|
if (!position) return
|
||||||
|
|
||||||
const levelId = resolveLevelId(node, nodes)
|
// This system is the single drag-time authority for floor-stack mesh Y:
|
||||||
if (!levelId) return
|
// tools publish base positions to live stores, renderers may
|
||||||
|
// reconcile that base Y onto the group, then this presentation system
|
||||||
const { dimensions, rotation } = floorPlaced.footprint(node as AnyNode)
|
// reapplies the resolver-derived visual Y before render. Because the
|
||||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
// override/store position remains base-height, the slab lift is never
|
||||||
levelId,
|
// committed or applied twice.
|
||||||
|
const resolverNodes =
|
||||||
|
effectiveNode === node ? nodes : { ...nodes, [effectiveNode.id]: effectiveNode }
|
||||||
|
const visualPosition = getFloorStackedPosition({
|
||||||
|
node: effectiveNode,
|
||||||
|
nodes: resolverNodes,
|
||||||
position,
|
position,
|
||||||
dimensions,
|
})
|
||||||
rotation,
|
mesh.position.y = visualPosition[1]
|
||||||
)
|
|
||||||
mesh.position.y = slabElevation + position[1]
|
if (!(def.geometry || def.system)) {
|
||||||
|
clearDirty(id as AnyNodeId)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, 1)
|
}, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getEffectiveNode,
|
getEffectiveNode,
|
||||||
resolveLevelId,
|
getFloorStackedPosition,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
@@ -96,9 +95,9 @@ export const StairSystem = () => {
|
|||||||
// so slab-elevation spatial queries match where the segments are
|
// so slab-elevation spatial queries match where the segments are
|
||||||
// actually being rendered. Without this, dragging the rotate gizmo
|
// actually being rendered. Without this, dragging the rotate gizmo
|
||||||
// looks up slabs at the pre-drag world XZ — if rotation carries a
|
// looks up slabs at the pre-drag world XZ — if rotation carries a
|
||||||
// segment off the original slab footprint, getStairSlabElevation
|
// segment off the original slab footprint, the floor-stack
|
||||||
// returns 0 and `group.position.y` collapses, dropping the flight
|
// resolver would otherwise read the pre-drag footprint and drop
|
||||||
// or landing below the floor and out of view mid-drag.
|
// the flight or landing below the floor mid-drag.
|
||||||
const stairNode = getEffectiveNode(baseStairNode as StairNode)
|
const stairNode = getEffectiveNode(baseStairNode as StairNode)
|
||||||
const group = sceneRegistry.nodes.get(stairId) as THREE.Group | undefined
|
const group = sceneRegistry.nodes.get(stairId) as THREE.Group | undefined
|
||||||
if (group) {
|
if (group) {
|
||||||
@@ -273,57 +272,20 @@ function syncStairGroupElevation(
|
|||||||
group: THREE.Group,
|
group: THREE.Group,
|
||||||
nodes: Record<string, AnyNode>,
|
nodes: Record<string, AnyNode>,
|
||||||
) {
|
) {
|
||||||
const levelId = resolveLevelId(stairNode, nodes)
|
const effectiveNodes: Record<string, AnyNode> = { ...nodes, [stairNode.id]: stairNode }
|
||||||
const slabElevation = getStairSlabElevation(levelId, stairNode, nodes)
|
for (const childId of stairNode.children ?? []) {
|
||||||
group.position.y = stairNode.position[1] + slabElevation
|
const segment = nodes[childId as AnyNodeId]
|
||||||
}
|
if (segment?.type === 'stair-segment') {
|
||||||
|
effectiveNodes[segment.id] = getEffectiveNode(segment as StairSegmentNode)
|
||||||
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 visualPosition = getFloorStackedPosition({
|
||||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
node: stairNode,
|
||||||
|
nodes: effectiveNodes,
|
||||||
|
position: stairNode.position,
|
||||||
|
rotation: stairNode.rotation,
|
||||||
|
})
|
||||||
|
group.position.y = visualPosition[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user