feat(editor): live floor-stacking, unified handle system, slab-hole editing + interaction polish (#375)
- Live slab-stacking Y previews for all floor-placed kinds (item/shelf/spawn/column/stair) during placement + both move pathways, via a shared core resolver; canonical positions unchanged. - Unified 3D handle system (one drag pipeline + one visual primitive) with forgiving invisible hit-areas on every handle, kept on EDITOR_LAYER so they don't poison the MRT scene pass. - Hover + click-to-edit slab holes in 3D (manual hole -> hole editor; stair/elevator hole -> select owner); generic cross-arrow polygon-move grip; normalized handle interaction colors. - NaN-safe node mutations + non-finite shadow-light bounds guard. - Built on #373 (level-scoped alignment / registry slab tool); #373 owns X/Z alignment, this owns Y floor-stacking.
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import { nodeRegistry, registerNode } from '../../registry'
|
||||
import type { AnyNodeDefinition } from '../../registry/types'
|
||||
import type { AnyNode, SlabNode } from '../../schema'
|
||||
import { getFloorPlacedElevation, getFloorStackedPosition } from './floor-placed-elevation'
|
||||
import { spatialGridManager } from './spatial-grid-manager'
|
||||
|
||||
const LEVEL_ID = 'level_test'
|
||||
|
||||
function makeDefinition(
|
||||
kind: AnyNode['type'],
|
||||
capabilities: AnyNodeDefinition['capabilities'] = {},
|
||||
): AnyNodeDefinition {
|
||||
return {
|
||||
kind,
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal(kind) }) as never,
|
||||
category: 'utility',
|
||||
defaults: () => ({}) as never,
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
function makeLevel(): AnyNode {
|
||||
return {
|
||||
id: LEVEL_ID,
|
||||
type: 'level',
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
level: 0,
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
function makeFloorNode(overrides: Partial<AnyNode> = {}): AnyNode {
|
||||
return {
|
||||
id: 'item_test',
|
||||
type: 'item',
|
||||
object: 'node',
|
||||
parentId: LEVEL_ID,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
asset: {
|
||||
id: 'asset_test',
|
||||
category: 'test',
|
||||
name: 'Test',
|
||||
thumbnail: '',
|
||||
src: 'asset:test',
|
||||
dimensions: [1, 1, 1],
|
||||
source: 'library',
|
||||
},
|
||||
...overrides,
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab_${elevation}`) {
|
||||
const slab = {
|
||||
id,
|
||||
type: 'slab',
|
||||
object: 'node',
|
||||
parentId: LEVEL_ID,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
polygon,
|
||||
holes: [],
|
||||
holeMetadata: [],
|
||||
elevation,
|
||||
autoFromWalls: false,
|
||||
} as SlabNode
|
||||
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
|
||||
}
|
||||
|
||||
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
|
||||
return Object.fromEntries(nodes.map((node) => [node.id, node]))
|
||||
}
|
||||
|
||||
describe('floor-placed elevation resolver', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
spatialGridManager.clear()
|
||||
})
|
||||
|
||||
test('returns 0 without a floorPlaced capability', () => {
|
||||
registerNode(makeDefinition('item'))
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.4,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
test('returns 0 when applies returns false', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
|
||||
applies: () => false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.4,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
test('clamps non-finite slab elevation to 0', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const original = spatialGridManager.getSlabElevationForItem
|
||||
spatialGridManager.getSlabElevationForItem = (() => Number.NaN) as typeof original
|
||||
|
||||
try {
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBe(0)
|
||||
} finally {
|
||||
spatialGridManager.getSlabElevationForItem = original
|
||||
}
|
||||
})
|
||||
|
||||
test('returns 0 for a non-level direct parent', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.4,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const shelf = {
|
||||
id: 'shelf_test',
|
||||
type: 'shelf',
|
||||
parentId: LEVEL_ID,
|
||||
} as unknown as AnyNode
|
||||
const node = makeFloorNode({ parentId: shelf.id })
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, shelf, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
test('returns 0 when the declared parent is missing', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.4,
|
||||
)
|
||||
|
||||
const node = makeFloorNode({ parentId: 'missing_level' })
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
levelId: LEVEL_ID,
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
test('uses the pending rotated footprint', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: (node) => ({
|
||||
dimensions: [4, 1, 1],
|
||||
rotation: (node as { rotation: [number, number, number] }).rotation,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-0.2, 1.2],
|
||||
[0.2, 1.2],
|
||||
[0.2, 1.8],
|
||||
[-0.2, 1.8],
|
||||
],
|
||||
0.45,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, Math.PI / 2, 0],
|
||||
}),
|
||||
).toBeCloseTo(0.45)
|
||||
})
|
||||
|
||||
test('returns slab overlap elevation and stacks Y onto canonical position', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprint: (node) => ({
|
||||
dimensions: [1, 1, 1],
|
||||
rotation: (node as { rotation: [number, number, number] }).rotation,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.35,
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0.1, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBeCloseTo(0.35)
|
||||
const stacked = getFloorStackedPosition({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0.1, 0],
|
||||
rotation: [0, 0, 0],
|
||||
})
|
||||
expect(stacked[0]).toBe(0)
|
||||
expect(stacked[1]).toBeCloseTo(0.45)
|
||||
expect(stacked[2]).toBe(0)
|
||||
})
|
||||
|
||||
test('takes the max elevation across composite footprints', () => {
|
||||
registerNode(
|
||||
makeDefinition('item', {
|
||||
floorPlaced: {
|
||||
footprints: () => [
|
||||
{ position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
|
||||
{ position: [3, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
],
|
||||
0.2,
|
||||
'slab_low',
|
||||
)
|
||||
addSlab(
|
||||
[
|
||||
[2.5, -0.5],
|
||||
[3.5, -0.5],
|
||||
[3.5, 0.5],
|
||||
[2.5, 0.5],
|
||||
],
|
||||
0.8,
|
||||
'slab_high',
|
||||
)
|
||||
|
||||
const level = makeLevel()
|
||||
const node = makeFloorNode()
|
||||
|
||||
expect(
|
||||
getFloorPlacedElevation({
|
||||
node,
|
||||
nodes: nodesFor(level, node),
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
).toBeCloseTo(0.8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { nodeRegistry } from '../../registry'
|
||||
import type {
|
||||
FloorPlacedConfig,
|
||||
FloorPlacedFootprint,
|
||||
FloorPlacedFootprintContext,
|
||||
FloorPlacedFootprintsResolver,
|
||||
} from '../../registry/types'
|
||||
import type { AnyNode, AnyNodeId } from '../../schema'
|
||||
import { spatialGridManager } from './spatial-grid-manager'
|
||||
|
||||
export type FloorPlacedElevationArgs = {
|
||||
node: AnyNode
|
||||
nodes: Record<string, AnyNode>
|
||||
position: [number, number, number]
|
||||
rotation?: unknown
|
||||
levelId?: string | null
|
||||
}
|
||||
|
||||
function finiteSlabElevation(elevation: number): number {
|
||||
return Number.isFinite(elevation) ? elevation : 0
|
||||
}
|
||||
|
||||
function withPositionAndRotation({
|
||||
node,
|
||||
position,
|
||||
rotation,
|
||||
}: Pick<FloorPlacedElevationArgs, 'node' | 'position' | 'rotation'>): AnyNode {
|
||||
return {
|
||||
...(node as Record<string, unknown>),
|
||||
position,
|
||||
...(rotation !== undefined ? { rotation } : {}),
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
export function getFloorPlacedFootprints(
|
||||
floorPlaced: FloorPlacedConfig,
|
||||
node: AnyNode,
|
||||
ctx?: FloorPlacedFootprintContext,
|
||||
): FloorPlacedFootprint[] {
|
||||
const rawFootprints = floorPlaced.footprints?.(node, ctx)
|
||||
if (rawFootprints) return [...rawFootprints]
|
||||
|
||||
const footprint = floorPlaced.footprint?.(node, ctx)
|
||||
return footprint ? [footprint] : []
|
||||
}
|
||||
|
||||
export function getFloorPlacedElevation({
|
||||
node,
|
||||
nodes,
|
||||
position,
|
||||
rotation,
|
||||
levelId,
|
||||
}: FloorPlacedElevationArgs): number {
|
||||
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
||||
if (!floorPlaced) return 0
|
||||
|
||||
const effectiveNode = withPositionAndRotation({ node, position, rotation })
|
||||
if (floorPlaced.applies && !floorPlaced.applies(effectiveNode)) return 0
|
||||
|
||||
const parentId = (effectiveNode as { parentId?: AnyNodeId | null }).parentId ?? null
|
||||
const parent = parentId ? nodes[parentId] : null
|
||||
if (parentId && !parent) return 0
|
||||
if (parent && parent.type !== 'level') return 0
|
||||
if (!parent && !levelId) return 0
|
||||
|
||||
const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId
|
||||
if (!resolvedLevelId) return 0
|
||||
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) {
|
||||
const footprintPosition = footprint.position ?? position
|
||||
const elevation = finiteSlabElevation(
|
||||
spatialGridManager.getSlabElevationForItem(
|
||||
resolvedLevelId,
|
||||
footprintPosition,
|
||||
footprint.dimensions,
|
||||
footprint.rotation,
|
||||
),
|
||||
)
|
||||
if (elevation > maxElevation) {
|
||||
maxElevation = elevation
|
||||
}
|
||||
}
|
||||
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
}
|
||||
|
||||
export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] {
|
||||
const [x, y, z] = args.position
|
||||
return [x, y + getFloorPlacedElevation(args), z]
|
||||
}
|
||||
|
||||
export type { FloorPlacedFootprint, FloorPlacedFootprintContext, FloorPlacedFootprintsResolver }
|
||||
@@ -1,6 +1,7 @@
|
||||
import { nodeRegistry } from '../../registry'
|
||||
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { getFloorPlacedFootprints } from './floor-placed-elevation'
|
||||
import {
|
||||
itemOverlapsPolygon,
|
||||
spatialGridManager,
|
||||
@@ -152,7 +153,7 @@ function arraysEqual(a: number[], b: number[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all floor items, walls, and stairs that may be affected by a slab change as dirty.
|
||||
* Mark all floor items and walls that may be affected by a slab change as dirty.
|
||||
*/
|
||||
function markNodesOverlappingSlab(
|
||||
slab: SlabNode,
|
||||
@@ -181,12 +182,6 @@ function markNodesOverlappingSlab(
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (node.type === 'stair') {
|
||||
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||
markDirty(node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generic floor-placed sweep: any registry kind that opts in via
|
||||
// `capabilities.floorPlaced` (item / shelf / column / spawn / …)
|
||||
// re-elevates through `<FloorElevationSystem>` when a slab below
|
||||
@@ -196,12 +191,25 @@ function markNodesOverlappingSlab(
|
||||
const floorPlaced = def?.capabilities?.floorPlaced
|
||||
if (!floorPlaced) continue
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node)) continue
|
||||
const parentId = node.parentId as AnyNodeId | null
|
||||
const parent = parentId ? nodes[parentId] : null
|
||||
if (parent && parent.type !== 'level') continue
|
||||
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||
const position = (node as { position?: [number, number, number] }).position
|
||||
if (!position) continue
|
||||
const { dimensions, rotation } = floorPlaced.footprint(node)
|
||||
if (itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
||||
markDirty(node.id)
|
||||
for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
|
||||
if (
|
||||
itemOverlapsPolygon(
|
||||
footprint.position ?? position,
|
||||
footprint.dimensions,
|
||||
footprint.rotation,
|
||||
slab.polygon,
|
||||
0.01,
|
||||
)
|
||||
) {
|
||||
markDirty(node.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ export {
|
||||
sceneRegistry,
|
||||
useRegistry,
|
||||
} from './hooks/scene-registry/scene-registry'
|
||||
export {
|
||||
type FloorPlacedElevationArgs,
|
||||
getFloorPlacedElevation,
|
||||
getFloorPlacedFootprints,
|
||||
getFloorStackedPosition,
|
||||
} from './hooks/spatial-grid/floor-placed-elevation'
|
||||
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||
export {
|
||||
initSpatialGridSync,
|
||||
@@ -79,6 +85,12 @@ export {
|
||||
type MaterialCategory,
|
||||
toLibraryMaterialRef,
|
||||
} from './material-library'
|
||||
export type {
|
||||
FloorPlacedFootprint,
|
||||
FloorPlacedFootprintContext,
|
||||
FloorPlacedFootprintResolver,
|
||||
FloorPlacedFootprintsResolver,
|
||||
} from './registry'
|
||||
export * from './registry'
|
||||
export * from './schema'
|
||||
export * from './services'
|
||||
|
||||
@@ -346,8 +346,10 @@ export type TranslateHandle<N = any> = {
|
||||
* `[alongX, alongOther]` — `alongOther` is Z for the 'horizontal' plane and
|
||||
* Y for 'node-normal'. Used to align the node's edges to the grid (rotation-
|
||||
* aware: swap the pair at 90°). Omit / return null for free movement.
|
||||
* `sceneApi` is supplied for composite nodes whose footprint depends on
|
||||
* children, such as straight stairs.
|
||||
*/
|
||||
snapExtents?: (node: N) => readonly [number, number] | null
|
||||
snapExtents?: (node: N, sceneApi: SceneApi) => readonly [number, number] | null
|
||||
portal?: HandlePortal
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,11 @@ export type {
|
||||
CuttableConfig,
|
||||
DragAction,
|
||||
EditorCtx,
|
||||
FloorPlacedConfig,
|
||||
FloorPlacedFootprint,
|
||||
FloorPlacedFootprintContext,
|
||||
FloorPlacedFootprintResolver,
|
||||
FloorPlacedFootprintsResolver,
|
||||
FloorplanAffordance,
|
||||
FloorplanAffordanceModifiers,
|
||||
FloorplanAffordancePoint,
|
||||
|
||||
@@ -1235,20 +1235,40 @@ export type SelectableConfig = {
|
||||
override?: (ctx: CapabilityCtx) => SelectableConfig | null
|
||||
}
|
||||
|
||||
export type FloorPlacedFootprint = {
|
||||
dimensions: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
position?: [number, number, number]
|
||||
}
|
||||
|
||||
export type FloorPlacedFootprintContext = {
|
||||
nodes: Readonly<Record<AnyNodeId, AnyNode>>
|
||||
}
|
||||
|
||||
export type FloorPlacedFootprintResolver = (
|
||||
node: AnyNode,
|
||||
ctx?: FloorPlacedFootprintContext,
|
||||
) => FloorPlacedFootprint
|
||||
|
||||
export type FloorPlacedFootprintsResolver = (
|
||||
node: AnyNode,
|
||||
ctx?: FloorPlacedFootprintContext,
|
||||
) => readonly FloorPlacedFootprint[]
|
||||
|
||||
/**
|
||||
* Floor-placed kinds rest directly on a level and need their Y lifted by
|
||||
* any slab the footprint overlaps. The generic `<FloorElevationSystem>`
|
||||
* computes `slabElevation + node.position[1]` and writes it onto the
|
||||
* registered mesh on every dirty mark. `footprint` returns the world-space
|
||||
* footprint the spatial-grid manager uses to find overlapping slabs;
|
||||
* registered mesh on every dirty mark. `footprint` returns the default
|
||||
* world-space footprint the spatial-grid manager uses to find overlapping
|
||||
* slabs; `footprints` lets composite kinds expose multiple footprint
|
||||
* segments, with the canonical resolver taking the max slab elevation;
|
||||
* `applies` is an optional predicate to skip nodes that share a kind but
|
||||
* are mounted off-floor (items attached to a wall / ceiling).
|
||||
*/
|
||||
export type FloorPlacedConfig = {
|
||||
footprint: (node: AnyNode) => {
|
||||
dimensions: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
}
|
||||
footprint?: FloorPlacedFootprintResolver
|
||||
footprints?: FloorPlacedFootprintsResolver
|
||||
applies?: (node: AnyNode) => boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,10 @@ function floorFootprint(
|
||||
): { dimensions: [number, number, number]; rotation: [number, number, number] } | null {
|
||||
const capabilities = nodeRegistry.get(node.type)?.capabilities
|
||||
const floorPlaced = capabilities?.floorPlaced
|
||||
if (floorPlaced) {
|
||||
// `footprint` is optional now that floor-placed kinds may instead declare
|
||||
// composite `footprints` (e.g. stairs); those have no single centred box
|
||||
// here, so fall through to `alignmentFootprint`.
|
||||
if (floorPlaced?.footprint) {
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node)) return null
|
||||
return floorPlaced.footprint(node)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
AnyNode as AnyNodeSchema,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getWallSurfaceMaterialSignature,
|
||||
type WallNode,
|
||||
@@ -22,6 +23,478 @@ type WallMergePlan = {
|
||||
attachmentUpdates: WallAttachmentUpdate[]
|
||||
}
|
||||
|
||||
type ZodCheckLike = {
|
||||
_zod?: {
|
||||
def?: {
|
||||
check?: string
|
||||
value?: unknown
|
||||
inclusive?: boolean
|
||||
format?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ZodSchemaDefLike = {
|
||||
type?: string
|
||||
innerType?: ZodSchemaLike
|
||||
shape?: Record<string, ZodSchemaLike>
|
||||
options?: readonly ZodSchemaLike[]
|
||||
items?: readonly ZodSchemaLike[]
|
||||
rest?: ZodSchemaLike | null
|
||||
element?: ZodSchemaLike
|
||||
checks?: readonly ZodCheckLike[]
|
||||
defaultValue?: unknown
|
||||
values?: readonly unknown[]
|
||||
entries?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ZodSchemaLike = {
|
||||
_zod?: {
|
||||
def?: ZodSchemaDefLike
|
||||
bag?: {
|
||||
minimum?: number
|
||||
maximum?: number
|
||||
exclusiveMinimum?: number
|
||||
exclusiveMaximum?: number
|
||||
}
|
||||
values?: Set<unknown>
|
||||
}
|
||||
def?: ZodSchemaDefLike
|
||||
shape?: Record<string, ZodSchemaLike>
|
||||
minValue?: number | null
|
||||
maxValue?: number | null
|
||||
}
|
||||
|
||||
type NumericLimit = {
|
||||
value: number
|
||||
inclusive: boolean
|
||||
}
|
||||
|
||||
type NumericConstraints = {
|
||||
min?: NumericLimit
|
||||
max?: NumericLimit
|
||||
integer: boolean
|
||||
}
|
||||
|
||||
type NumericSanitizeIssue = {
|
||||
path: PropertyKey[]
|
||||
from: number
|
||||
to?: number
|
||||
action: 'clamped' | 'dropped' | 'rounded'
|
||||
}
|
||||
|
||||
type NumericSanitizeResult = {
|
||||
value: unknown
|
||||
issues: NumericSanitizeIssue[]
|
||||
omit?: boolean
|
||||
}
|
||||
|
||||
const NUMBER_FORMAT_BOUNDS: Record<string, [number, number] | undefined> = {
|
||||
safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
|
||||
int32: [-2147483648, 2147483647],
|
||||
uint32: [0, 4294967295],
|
||||
float32: [-3.4028234663852886e38, 3.4028234663852886e38],
|
||||
}
|
||||
|
||||
const INTEGER_NUMBER_FORMATS = new Set(['safeint', 'int32', 'uint32'])
|
||||
|
||||
function getSchemaDef(schema: ZodSchemaLike | null | undefined): ZodSchemaDefLike | undefined {
|
||||
return schema?._zod?.def ?? schema?.def
|
||||
}
|
||||
|
||||
function getSchemaDefault(schema: ZodSchemaLike): unknown {
|
||||
const def = getSchemaDef(schema)
|
||||
if (!(def?.type === 'default' || def?.type === 'prefault')) return undefined
|
||||
return def.defaultValue
|
||||
}
|
||||
|
||||
function unwrapSchema(schema: ZodSchemaLike | null | undefined): ZodSchemaLike | null {
|
||||
let current = schema ?? null
|
||||
while (current) {
|
||||
const def = getSchemaDef(current)
|
||||
if (
|
||||
!(
|
||||
def?.type === 'default' ||
|
||||
def?.type === 'prefault' ||
|
||||
def?.type === 'optional' ||
|
||||
def?.type === 'nullable' ||
|
||||
def?.type === 'catch' ||
|
||||
def?.type === 'readonly' ||
|
||||
def?.type === 'nonoptional'
|
||||
)
|
||||
) {
|
||||
return current
|
||||
}
|
||||
current = def.innerType ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getObjectShape(schema: ZodSchemaLike | null | undefined) {
|
||||
const unwrapped = unwrapSchema(schema)
|
||||
const def = getSchemaDef(unwrapped)
|
||||
if (def?.type !== 'object') return null
|
||||
|
||||
return unwrapped?.shape ?? def.shape ?? null
|
||||
}
|
||||
|
||||
function schemaAllowsValue(schema: ZodSchemaLike | null | undefined, value: unknown): boolean {
|
||||
const unwrapped = unwrapSchema(schema)
|
||||
if (!unwrapped) return false
|
||||
|
||||
const def = getSchemaDef(unwrapped)
|
||||
if (unwrapped._zod?.values?.has(value)) return true
|
||||
if (Array.isArray(def?.values) && def.values.includes(value)) return true
|
||||
if (def?.entries && Object.values(def.entries).includes(value)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getNodeSchemaForType(type: unknown): ZodSchemaLike | null {
|
||||
const schema = AnyNodeSchema as unknown as ZodSchemaLike
|
||||
const options = getSchemaDef(schema)?.options
|
||||
if (!options) return null
|
||||
|
||||
for (const option of options) {
|
||||
const shape = getObjectShape(option)
|
||||
if (shape?.type && schemaAllowsValue(shape.type, type)) {
|
||||
return option
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function applyLowerLimit(current: NumericLimit | undefined, candidate: NumericLimit): NumericLimit {
|
||||
if (!current) return candidate
|
||||
if (candidate.value > current.value) return candidate
|
||||
if (candidate.value === current.value && !candidate.inclusive) return candidate
|
||||
return current
|
||||
}
|
||||
|
||||
function applyUpperLimit(current: NumericLimit | undefined, candidate: NumericLimit): NumericLimit {
|
||||
if (!current) return candidate
|
||||
if (candidate.value < current.value) return candidate
|
||||
if (candidate.value === current.value && !candidate.inclusive) return candidate
|
||||
return current
|
||||
}
|
||||
|
||||
function getNumberConstraints(schema: ZodSchemaLike): NumericConstraints {
|
||||
const unwrapped = unwrapSchema(schema) ?? schema
|
||||
const def = getSchemaDef(unwrapped)
|
||||
const constraints: NumericConstraints = { integer: false }
|
||||
|
||||
const minValue = unwrapped.minValue
|
||||
if (typeof minValue === 'number') {
|
||||
constraints.min = applyLowerLimit(constraints.min, { value: minValue, inclusive: true })
|
||||
}
|
||||
|
||||
const maxValue = unwrapped.maxValue
|
||||
if (typeof maxValue === 'number') {
|
||||
constraints.max = applyUpperLimit(constraints.max, { value: maxValue, inclusive: true })
|
||||
}
|
||||
|
||||
for (const check of def?.checks ?? []) {
|
||||
const checkDef = check._zod?.def
|
||||
if (!checkDef) continue
|
||||
|
||||
if (checkDef.check === 'greater_than' && typeof checkDef.value === 'number') {
|
||||
constraints.min = applyLowerLimit(constraints.min, {
|
||||
value: checkDef.value,
|
||||
inclusive: checkDef.inclusive !== false,
|
||||
})
|
||||
} else if (checkDef.check === 'less_than' && typeof checkDef.value === 'number') {
|
||||
constraints.max = applyUpperLimit(constraints.max, {
|
||||
value: checkDef.value,
|
||||
inclusive: checkDef.inclusive !== false,
|
||||
})
|
||||
} else if (checkDef.check === 'number_format' && checkDef.format) {
|
||||
constraints.integer ||= INTEGER_NUMBER_FORMATS.has(checkDef.format)
|
||||
const bounds = NUMBER_FORMAT_BOUNDS[checkDef.format]
|
||||
if (bounds) {
|
||||
constraints.min = applyLowerLimit(constraints.min, {
|
||||
value: bounds[0],
|
||||
inclusive: true,
|
||||
})
|
||||
constraints.max = applyUpperLimit(constraints.max, {
|
||||
value: bounds[1],
|
||||
inclusive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bag = unwrapped._zod?.bag
|
||||
if (typeof bag?.minimum === 'number') {
|
||||
constraints.min = applyLowerLimit(constraints.min, { value: bag.minimum, inclusive: true })
|
||||
}
|
||||
if (typeof bag?.exclusiveMinimum === 'number') {
|
||||
constraints.min = applyLowerLimit(constraints.min, {
|
||||
value: bag.exclusiveMinimum,
|
||||
inclusive: false,
|
||||
})
|
||||
}
|
||||
if (typeof bag?.maximum === 'number') {
|
||||
constraints.max = applyUpperLimit(constraints.max, { value: bag.maximum, inclusive: true })
|
||||
}
|
||||
if (typeof bag?.exclusiveMaximum === 'number') {
|
||||
constraints.max = applyUpperLimit(constraints.max, {
|
||||
value: bag.exclusiveMaximum,
|
||||
inclusive: false,
|
||||
})
|
||||
}
|
||||
|
||||
return constraints
|
||||
}
|
||||
|
||||
function nextAbove(value: number) {
|
||||
return value === 0 ? Number.EPSILON : value + Math.abs(value) * Number.EPSILON
|
||||
}
|
||||
|
||||
function nextBelow(value: number) {
|
||||
return value === 0 ? -Number.EPSILON : value - Math.abs(value) * Number.EPSILON
|
||||
}
|
||||
|
||||
function clampNumber(value: number, constraints: NumericConstraints) {
|
||||
let next = value
|
||||
let rounded = false
|
||||
let clamped = false
|
||||
|
||||
if (constraints.integer && !Number.isInteger(next)) {
|
||||
next = Math.round(next)
|
||||
rounded = true
|
||||
}
|
||||
|
||||
if (constraints.min) {
|
||||
const min = constraints.min.inclusive ? constraints.min.value : nextAbove(constraints.min.value)
|
||||
if (next < min) {
|
||||
next = min
|
||||
clamped = true
|
||||
}
|
||||
}
|
||||
|
||||
if (constraints.max) {
|
||||
const max = constraints.max.inclusive ? constraints.max.value : nextBelow(constraints.max.value)
|
||||
if (next > max) {
|
||||
next = max
|
||||
clamped = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: next,
|
||||
action: clamped ? 'clamped' : rounded ? 'rounded' : undefined,
|
||||
} satisfies { value: number; action?: NumericSanitizeIssue['action'] }
|
||||
}
|
||||
|
||||
function getFiniteFallbackNumber(fallback: unknown, constraints: NumericConstraints) {
|
||||
if (typeof fallback !== 'number' || !Number.isFinite(fallback)) return undefined
|
||||
return clampNumber(fallback, constraints).value
|
||||
}
|
||||
|
||||
function sanitizeNumber(
|
||||
schema: ZodSchemaLike | null,
|
||||
value: number,
|
||||
fallback: unknown,
|
||||
path: PropertyKey[],
|
||||
): NumericSanitizeResult {
|
||||
const constraints = schema ? getNumberConstraints(schema) : { integer: false }
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
const replacement = getFiniteFallbackNumber(fallback, constraints)
|
||||
if (replacement === undefined) {
|
||||
return {
|
||||
value,
|
||||
omit: true,
|
||||
issues: [{ path, from: value, action: 'dropped' }],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: replacement,
|
||||
issues: [{ path, from: value, to: replacement, action: 'dropped' }],
|
||||
}
|
||||
}
|
||||
|
||||
const clamped = clampNumber(value, constraints)
|
||||
if (!Object.is(clamped.value, value)) {
|
||||
return {
|
||||
value: clamped.value,
|
||||
issues: [
|
||||
{
|
||||
path,
|
||||
from: value,
|
||||
to: clamped.value,
|
||||
action: clamped.action ?? 'clamped',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return { value, issues: [] }
|
||||
}
|
||||
|
||||
function sanitizeNumericValue(
|
||||
schema: ZodSchemaLike | null,
|
||||
value: unknown,
|
||||
fallback: unknown,
|
||||
path: PropertyKey[],
|
||||
): NumericSanitizeResult {
|
||||
const defaultFallback = schema ? getSchemaDefault(schema) : undefined
|
||||
const effectiveFallback = fallback === undefined ? defaultFallback : fallback
|
||||
const unwrapped = unwrapSchema(schema)
|
||||
const def = getSchemaDef(unwrapped)
|
||||
|
||||
if (def?.type === 'number') {
|
||||
if (typeof value !== 'number') return { value, issues: [] }
|
||||
return sanitizeNumber(unwrapped, value, effectiveFallback, path)
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return sanitizeNumber(null, value, effectiveFallback, path)
|
||||
}
|
||||
|
||||
if (def?.type === 'tuple' && Array.isArray(value)) {
|
||||
const fallbackItems = Array.isArray(effectiveFallback) ? effectiveFallback : []
|
||||
const next = [...value]
|
||||
const issues: NumericSanitizeIssue[] = []
|
||||
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
const itemSchema = def.items?.[index] ?? def.rest ?? null
|
||||
const child = sanitizeNumericValue(itemSchema, next[index], fallbackItems[index], [
|
||||
...path,
|
||||
index,
|
||||
])
|
||||
issues.push(...child.issues)
|
||||
|
||||
if (child.omit) {
|
||||
return { value, omit: true, issues }
|
||||
}
|
||||
|
||||
next[index] = child.value
|
||||
}
|
||||
|
||||
return { value: issues.length > 0 ? next : value, issues }
|
||||
}
|
||||
|
||||
if (def?.type === 'array' && Array.isArray(value)) {
|
||||
const fallbackItems = Array.isArray(effectiveFallback) ? effectiveFallback : []
|
||||
const next: unknown[] = []
|
||||
const issues: NumericSanitizeIssue[] = []
|
||||
let omitted = false
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const child = sanitizeNumericValue(def.element ?? null, value[index], fallbackItems[index], [
|
||||
...path,
|
||||
index,
|
||||
])
|
||||
issues.push(...child.issues)
|
||||
if (child.omit) {
|
||||
omitted = true
|
||||
continue
|
||||
}
|
||||
next.push(child.value)
|
||||
}
|
||||
|
||||
return { value: issues.length > 0 || omitted ? next : value, issues }
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const shape = def?.type === 'object' ? (unwrapped?.shape ?? def.shape ?? {}) : {}
|
||||
const fallbackObject =
|
||||
effectiveFallback &&
|
||||
typeof effectiveFallback === 'object' &&
|
||||
!Array.isArray(effectiveFallback)
|
||||
? (effectiveFallback as Record<string, unknown>)
|
||||
: {}
|
||||
const input = value as Record<string, unknown>
|
||||
const next: Record<string, unknown> = { ...input }
|
||||
const issues: NumericSanitizeIssue[] = []
|
||||
|
||||
for (const key of Object.keys(input)) {
|
||||
const child = sanitizeNumericValue(shape[key] ?? null, input[key], fallbackObject[key], [
|
||||
...path,
|
||||
key,
|
||||
])
|
||||
issues.push(...child.issues)
|
||||
|
||||
if (child.omit) {
|
||||
delete next[key]
|
||||
} else {
|
||||
next[key] = child.value
|
||||
}
|
||||
}
|
||||
|
||||
return { value: issues.length > 0 ? next : value, issues }
|
||||
}
|
||||
|
||||
return { value, issues: [] }
|
||||
}
|
||||
|
||||
function formatNumericValue(value: number) {
|
||||
if (Number.isNaN(value)) return 'NaN'
|
||||
if (value === Infinity) return 'Infinity'
|
||||
if (value === -Infinity) return '-Infinity'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function numericSanitizeIssuesToMessage(issues: NumericSanitizeIssue[]): string {
|
||||
return issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.map(String).join('.') || '<root>'
|
||||
const to = issue.to === undefined ? '' : ` -> ${formatNumericValue(issue.to)}`
|
||||
return `${path}: ${formatNumericValue(issue.from)} ${issue.action}${to}`
|
||||
})
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
function warnSanitizedNodeMutation(
|
||||
mutation: 'create' | 'update',
|
||||
nodeId: AnyNodeId,
|
||||
issues: NumericSanitizeIssue[],
|
||||
) {
|
||||
console.warn(
|
||||
`[Scene] Sanitized invalid numeric node ${mutation}`,
|
||||
nodeId,
|
||||
numericSanitizeIssuesToMessage(issues),
|
||||
)
|
||||
}
|
||||
|
||||
function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode {
|
||||
const candidate = { ...node, parentId }
|
||||
const parsed = AnyNodeSchema.safeParse(candidate)
|
||||
if (parsed.success) return parsed.data
|
||||
|
||||
const schema = getNodeSchemaForType(candidate.type)
|
||||
const sanitized = sanitizeNumericValue(schema, candidate, undefined, [])
|
||||
|
||||
if (sanitized.issues.length === 0) {
|
||||
return candidate as AnyNode
|
||||
}
|
||||
|
||||
warnSanitizedNodeMutation('create', node.id, sanitized.issues)
|
||||
|
||||
return sanitized.value as AnyNode
|
||||
}
|
||||
|
||||
function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode {
|
||||
const candidate = { ...currentNode, ...data }
|
||||
const parsed = AnyNodeSchema.safeParse(candidate)
|
||||
if (parsed.success) return parsed.data
|
||||
|
||||
const schema = getNodeSchemaForType(candidate.type)
|
||||
const sanitized = sanitizeNumericValue(schema, data, currentNode, [])
|
||||
|
||||
if (sanitized.issues.length === 0) {
|
||||
return candidate as AnyNode
|
||||
}
|
||||
|
||||
warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues)
|
||||
|
||||
return { ...currentNode, ...(sanitized.value as Partial<AnyNode>) } as AnyNode
|
||||
}
|
||||
|
||||
// Track pending RAF for updateNodesAction to prevent multiple queued callbacks
|
||||
let pendingRafId: number | null = null
|
||||
let pendingUpdates: Set<AnyNodeId> = new Set()
|
||||
@@ -245,11 +718,7 @@ export const createNodesAction = (
|
||||
for (const { node, parentId } of ops) {
|
||||
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
||||
|
||||
// 1. Assign parentId to the child (Safe because BaseNode has parentId)
|
||||
const newNode = {
|
||||
...node,
|
||||
parentId: effectiveParentId,
|
||||
}
|
||||
const newNode = parseCreatedNode(node, effectiveParentId)
|
||||
|
||||
nextNodes[newNode.id] = newNode
|
||||
|
||||
@@ -313,6 +782,7 @@ export const applyNodeChangesAction = (
|
||||
for (const { id, data } of updateOps) {
|
||||
const currentNode = nextNodes[id]
|
||||
if (!currentNode) continue
|
||||
const updatedNode = parseUpdatedNode(currentNode, data)
|
||||
|
||||
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
||||
const oldParentId = currentNode.parentId as AnyNodeId | null
|
||||
@@ -336,16 +806,13 @@ export const applyNodeChangesAction = (
|
||||
}
|
||||
}
|
||||
|
||||
nextNodes[id] = { ...currentNode, ...data } as AnyNode
|
||||
nextNodes[id] = updatedNode
|
||||
nodesToMarkDirty.add(id)
|
||||
}
|
||||
|
||||
for (const { node, parentId } of createOps) {
|
||||
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
||||
const newNode = {
|
||||
...node,
|
||||
parentId: effectiveParentId,
|
||||
} as AnyNode
|
||||
const newNode = parseCreatedNode(node, effectiveParentId)
|
||||
|
||||
nextNodes[newNode.id as AnyNodeId] = newNode
|
||||
nodesToMarkDirty.add(newNode.id as AnyNodeId)
|
||||
@@ -442,6 +909,7 @@ export const updateNodesAction = (
|
||||
for (const { id, data } of updates) {
|
||||
const currentNode = nextNodes[id]
|
||||
if (!currentNode) continue
|
||||
const updatedNode = parseUpdatedNode(currentNode, data)
|
||||
|
||||
// Handle Reparenting Logic
|
||||
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
||||
@@ -480,7 +948,7 @@ export const updateNodesAction = (
|
||||
}
|
||||
|
||||
// Apply the update
|
||||
nextNodes[id] = { ...nextNodes[id], ...data } as AnyNode
|
||||
nextNodes[id] = updatedNode
|
||||
}
|
||||
|
||||
return { nodes: nextNodes }
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode, AnyNodeId } from '../../schema/types'
|
||||
import useScene from '../use-scene'
|
||||
|
||||
type RafFn = (cb: (t: number) => void) => number
|
||||
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
|
||||
cb: (t: number) => void,
|
||||
) => {
|
||||
cb(0)
|
||||
return 0
|
||||
}) as RafFn
|
||||
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
|
||||
() => {}
|
||||
|
||||
const SHELF_ID = 'shelf_sanitize' as AnyNodeId
|
||||
const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId
|
||||
|
||||
function makeShelf(overrides: Partial<AnyNode> = {}): AnyNode {
|
||||
return {
|
||||
id: SHELF_ID,
|
||||
type: 'shelf',
|
||||
parentId: null,
|
||||
object: 'node',
|
||||
visible: true,
|
||||
name: 'Shelf',
|
||||
metadata: {},
|
||||
children: [],
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
width: 1.2,
|
||||
depth: 0.3,
|
||||
thickness: 0.04,
|
||||
height: 0.9,
|
||||
style: 'wall-shelf',
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
withBack: false,
|
||||
withSides: true,
|
||||
withBottom: false,
|
||||
bracketStyle: 'minimal',
|
||||
...overrides,
|
||||
} as unknown as AnyNode
|
||||
}
|
||||
|
||||
function makeSolarPanel(): AnyNode {
|
||||
return {
|
||||
id: SOLAR_PANEL_ID,
|
||||
type: 'solar-panel',
|
||||
parentId: null,
|
||||
object: 'node',
|
||||
visible: true,
|
||||
name: 'Panel',
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
rows: 2,
|
||||
columns: 3,
|
||||
panelWidth: 1,
|
||||
panelHeight: 1.65,
|
||||
gapX: 0.02,
|
||||
gapY: 0.02,
|
||||
mountingType: 'flush',
|
||||
tiltAngle: 15,
|
||||
standoffHeight: 0.05,
|
||||
frameThickness: 0.04,
|
||||
frameDepth: 0.04,
|
||||
} as unknown as AnyNode
|
||||
}
|
||||
|
||||
function shelf() {
|
||||
return useScene.getState().nodes[SHELF_ID] as Extract<AnyNode, { type: 'shelf' }>
|
||||
}
|
||||
|
||||
describe('node mutation numeric sanitization', () => {
|
||||
beforeEach(() => {
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[SHELF_ID]: makeShelf(),
|
||||
[SOLAR_PANEL_ID]: makeSolarPanel(),
|
||||
},
|
||||
rootNodeIds: [SHELF_ID, SOLAR_PANEL_ID],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
})
|
||||
|
||||
test('drops NaN numeric updates while preserving other fields in the patch', () => {
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
thickness: Number.NaN,
|
||||
name: 'Renamed after NaN',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
expect(shelf().thickness).toBe(0.04)
|
||||
expect(Number.isFinite(shelf().thickness)).toBe(true)
|
||||
expect(shelf().name).toBe('Renamed after NaN')
|
||||
})
|
||||
|
||||
test('drops Infinity numeric updates while preserving later normal updates', () => {
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
width: Infinity,
|
||||
name: 'Renamed after Infinity',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
expect(shelf().width).toBe(1.2)
|
||||
expect(Number.isFinite(shelf().width)).toBe(true)
|
||||
expect(shelf().name).toBe('Renamed after Infinity')
|
||||
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
name: 'Clean rename',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
expect(shelf().name).toBe('Clean rename')
|
||||
})
|
||||
|
||||
test('clamps out-of-range numeric updates to the node schema bounds', () => {
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
width: 99,
|
||||
thickness: -1,
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
expect(shelf().width).toBe(3)
|
||||
expect(shelf().thickness).toBe(0.01)
|
||||
})
|
||||
|
||||
test('preserves extra fields while sanitizing numeric updates', () => {
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[SHELF_ID]: {
|
||||
...makeShelf(),
|
||||
legacyField: 'current',
|
||||
} as unknown as AnyNode,
|
||||
},
|
||||
rootNodeIds: [SHELF_ID],
|
||||
} as never)
|
||||
|
||||
useScene.getState().updateNode(SHELF_ID, {
|
||||
width: Infinity,
|
||||
legacyPatch: 'patch',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
const node = useScene.getState().nodes[SHELF_ID] as Record<string, unknown>
|
||||
expect(node.width).toBe(1.2)
|
||||
expect(node.legacyField).toBe('current')
|
||||
expect(node.legacyPatch).toBe('patch')
|
||||
})
|
||||
|
||||
test('allows non-canonical ids to receive updates', () => {
|
||||
useScene.getState().updateNode(SOLAR_PANEL_ID, {
|
||||
name: 'Updated panel',
|
||||
} as Partial<AnyNode>)
|
||||
|
||||
const panel = useScene.getState().nodes[SOLAR_PANEL_ID] as { name?: string }
|
||||
expect(panel.name).toBe('Updated panel')
|
||||
})
|
||||
|
||||
test('sanitizes non-finite numeric values during create', () => {
|
||||
const createdId = 'shelf_created' as AnyNodeId
|
||||
|
||||
useScene.getState().createNode(
|
||||
makeShelf({
|
||||
id: createdId,
|
||||
width: Infinity,
|
||||
thickness: Number.NaN,
|
||||
} as Partial<AnyNode>),
|
||||
)
|
||||
|
||||
const created = useScene.getState().nodes[createdId] as Extract<AnyNode, { type: 'shelf' }>
|
||||
expect(created.width).toBe(1.2)
|
||||
expect(created.thickness).toBe(0.04)
|
||||
expect(Number.isFinite(created.width)).toBe(true)
|
||||
expect(Number.isFinite(created.thickness)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type RoofSegmentNode,
|
||||
type RoofType,
|
||||
} from '../schema/nodes/roof-segment'
|
||||
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
|
||||
import { SiteNode } from '../schema/nodes/site'
|
||||
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
|
||||
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
||||
@@ -24,6 +25,11 @@ function getFiniteNumber(value: unknown, fallback: number) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
function getFiniteNumberInRange(value: unknown, fallback: number, min: number, max: number) {
|
||||
const finite = getFiniteNumber(value, fallback)
|
||||
return Math.min(Math.max(finite, min), max)
|
||||
}
|
||||
|
||||
function getBoolean(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback
|
||||
}
|
||||
@@ -112,6 +118,37 @@ function normalizeDoorNode(node: Record<string, unknown>) {
|
||||
return parsed.success ? { ...node, ...parsed.data } : null
|
||||
}
|
||||
|
||||
function normalizeShelfNode(node: Record<string, unknown>) {
|
||||
const sanitized = {
|
||||
...node,
|
||||
children: getStringArray(node.children),
|
||||
position: getVector3(node.position, [0, 0, 0]),
|
||||
rotation: getVector3(node.rotation, [0, 0, 0]),
|
||||
width: getFiniteNumberInRange(node.width, 1.2, 0.3, 3.0),
|
||||
depth: getFiniteNumberInRange(node.depth, 0.3, 0.1, 1.0),
|
||||
thickness: getFiniteNumberInRange(node.thickness, 0.04, 0.01, 0.1),
|
||||
height: getFiniteNumberInRange(node.height, 0.9, 0.05, 2.5),
|
||||
rows: Math.round(getFiniteNumberInRange(node.rows, 1, 1, 8)),
|
||||
columns: Math.round(getFiniteNumberInRange(node.columns, 1, 1, 6)),
|
||||
style: getEnumValue(
|
||||
node.style,
|
||||
['wall-shelf', 'bookshelf', 'open-rack', 'cubby'] as const,
|
||||
'wall-shelf',
|
||||
),
|
||||
withBack: getBoolean(node.withBack, false),
|
||||
withSides: getBoolean(node.withSides, true),
|
||||
withBottom: getBoolean(node.withBottom, false),
|
||||
bracketStyle: getEnumValue(
|
||||
node.bracketStyle,
|
||||
['minimal', 'industrial', 'hidden'] as const,
|
||||
'minimal',
|
||||
),
|
||||
}
|
||||
|
||||
const parsed = ShelfNodeSchema.safeParse(sanitized)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||
const hasInterior =
|
||||
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
|
||||
@@ -396,14 +433,11 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
|
||||
}
|
||||
|
||||
// Shelf v2: hosting was added in this migration cycle. Older shelves
|
||||
// (saved before the schema gained `children`) need the field
|
||||
// initialised so `createNode(item, shelfId)` finds an array to
|
||||
// append the child id to — without this the host item ends up
|
||||
// orphaned (parented in scene state but not in the shelf's
|
||||
// children list, so the renderer doesn't mount it).
|
||||
if (node.type === 'shelf' && !Array.isArray(node.children)) {
|
||||
patchedNodes[id] = { ...node, children: [] }
|
||||
if (node.type === 'shelf') {
|
||||
const normalized = normalizeShelfNode(node)
|
||||
if (normalized) {
|
||||
patchedNodes[id] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
// Roof-segment hosting was added in this migration cycle (the same
|
||||
|
||||
Reference in New Issue
Block a user