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:
Aymeric Rabot
2026-06-05 16:24:48 -04:00
committed by GitHub
parent d1b40aa98d
commit 0b338cf647
51 changed files with 3942 additions and 1418 deletions
@@ -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
}
}
}
}
+12
View File
@@ -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'
+3 -1
View File
@@ -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
}
+5
View File
@@ -57,6 +57,11 @@ export type {
CuttableConfig,
DragAction,
EditorCtx,
FloorPlacedConfig,
FloorPlacedFootprint,
FloorPlacedFootprintContext,
FloorPlacedFootprintResolver,
FloorPlacedFootprintsResolver,
FloorplanAffordance,
FloorplanAffordanceModifiers,
FloorplanAffordancePoint,
+26 -6
View File
@@ -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)
}
+479 -11
View File
@@ -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)
})
})
+42 -8
View File
@@ -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