Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/core/src/commands/structure/delete-zone.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import { reconcileLevelStructure } from '../../lib/structure-kernel'
import { ItemNode, SeparatorNode, WallNode, type ZoneNode } from '../../schema'
import { getWallCurveFrameAt, getWallCurveLength } from '../../systems/wall/wall-curve'
import { SHARED_WALLS_DELETE_MESSAGE } from './delete-zone'
import { deleteZone, divideZone, setZoneIntent } from './index'
import { applyToScratch, type StructureNodes, structureChangeBatch } from './shared'
Expand Down Expand Up @@ -240,3 +241,49 @@ describe('deleting a room', () => {
expect(plan.payload.wallIds.sort()).toEqual(['wall_m0', 'wall_m1', 'wall_m2'])
})
})

test('an item kept from a curved wall faces along the curve where it hung', () => {
const nodes: Record<string, any> = fixture()
const corners: [number, number][] = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
for (let i = 0; i < 4; i++)
nodes[`wall_c${i}`] = WallNode.parse({
id: `wall_c${i}`,
parentId: 'level_test',
start: corners[i],
end: corners[(i + 1) % 4],
...(i === 0 ? { curveOffset: -1 } : {}),
})
const curved = nodes.wall_c0
const along = getWallCurveLength(curved) * 0.2
nodes.item_shelf = ItemNode.parse({
...chair('item_shelf', 0, 0),
parentId: curved.id,
position: [along, 1, 0.1],
rotation: [0, 0.3, 0],
})
curved.children = ['item_shelf']
let n = 0
const graph = applyToScratch(
nodes,
structureChangeBatch(
reconcileLevelStructure({
levelId: 'level_test',
nodes,
mintId: (kind) => `${kind}_c${++n}`,
}).patches,
),
)
const [zone] = zones(graph)
const plan = deleteZone(graph, { zoneId: zone!.id, contents: 'keep' })
const kept = applyToScratch(graph, structureChangeBatch(plan.changes)).item_shelf as ItemNode
const frame = getWallCurveFrameAt(curved, 0.2)
expect(kept.parentId).toBe('level_test')
expect(kept.rotation[1]).toBeCloseTo(0.3 - Math.atan2(frame.tangent.y, frame.tangent.x), 6)
expect(kept.position[0]).toBeCloseTo(frame.point.x + frame.normal.x * 0.1, 6)
expect(kept.position[2]).toBeCloseTo(frame.point.y + frame.normal.y * 0.1, 6)
})
2 changes: 1 addition & 1 deletion packages/core/src/commands/structure/delete-zone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function levelPose(nodes: StructureNodes, item: PositionedIntent, levelId: strin
position[1],
frame.point.y + frame.normal.y * position[2],
]
yaw -= Math.atan2(parent.end[1] - parent.start[1], parent.end[0] - parent.start[0])
yaw -= Math.atan2(frame.tangent.y, frame.tangent.x)
} else if (parent.type === 'ceiling') position[1] += parent.height ?? 2.7
else if ('position' in parent && Array.isArray(parent.position)) {
const rotation =
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/utils/ensure-scene-openings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { AnyNode, AnyNodeId } from '../schema'
import { syncStairRises } from '../systems/stair/stair-rise-query'
import { loadMigration } from './load-migration'

export function ensureSceneOpenings(sourceNodes: Record<string, unknown>) {
function ensureSceneOpeningsOnView(sourceNodes: Record<string, unknown>) {
let nodes = sourceNodes as Record<string, AnyNode>
const patches = new Map<AnyNodeId, Partial<AnyNode>>()
const kinds = new Set(Object.values(nodes).map((node) => node.type))
Expand All @@ -27,3 +28,9 @@ export function ensureSceneOpenings(sourceNodes: Record<string, unknown>) {
updates: [...patches].map(([id, data]) => ({ id, data })),
}
}

export const ensureSceneOpenings = loadMigration(
'stair and elevator openings',
ensureSceneOpeningsOnView,
(nodes) => ({ nodes: nodes as Record<string, AnyNode>, changed: false, updates: [] }),
)
16 changes: 15 additions & 1 deletion packages/core/src/utils/floor-opening-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { adjacentLevelId } from '../lib/floor-opening-intent'
import { area, intersection, type Ring, union } from '../lib/polygon-boolean'
import type { AnyNode, CeilingNode, SlabNode } from '../schema'
import { FloorOpeningNode } from '../schema/nodes/floor-opening'
import { loadMigration } from './load-migration'

function polygonKey(polygon: Ring) {
return polygon.map(([x, z]) => [Math.round(x * 10_000), Math.round(z * 10_000)])
Expand Down Expand Up @@ -35,7 +36,7 @@ function matchingCeilingHole(nodes: Record<string, unknown>, levelId: string, po
})
}

export function migrateFloorOpeningNodes(sourceNodes: Record<string, unknown>) {
function migrateFloorOpeningNodesOnView(sourceNodes: Record<string, unknown>) {
let nodes: Record<string, unknown> = sourceNodes
const groups = new Map<
string,
Expand Down Expand Up @@ -157,3 +158,16 @@ export function migrateFloorOpeningNodes(sourceNodes: Record<string, unknown>) {
}
return { nodes, changed: nodes !== sourceNodes, adoptedHoles, created, dedupes, unions }
}

export const migrateFloorOpeningNodes = loadMigration(
'floor openings',
migrateFloorOpeningNodesOnView,
(nodes) => ({
nodes,
changed: false,
adoptedHoles: 0,
created: 0,
dedupes: 0,
unions: 0,
}),
)
16 changes: 14 additions & 2 deletions packages/core/src/utils/floor-plate-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
planFloorPieceAdoption,
} from './floor-piece-adoption'
import { legacyWallElevations } from './legacy-wall-datums'
import { loadMigration } from './load-migration'
import { omitUndefined } from './omit-undefined'

export type FloorPlateMigrationReport = {
Expand Down Expand Up @@ -465,7 +466,7 @@ function clearDanglingFloorSources(nodes: Record<string, AnyNode>) {
/** A level whose hand-drawn floor pieces cannot be adopted exactly: migrate it without them. */
class AdoptionRejected extends Error {}

export function migrateFloorPlates(sourceNodes: Record<string, unknown>) {
function migrateFloorPlatesOnView(sourceNodes: Record<string, unknown>) {
// Adoption is all-or-nothing per level: a level whose pieces are not all
// consumed migrates exactly as it would without adoption.
const skip = new Set<string>()
Expand Down Expand Up @@ -1609,7 +1610,7 @@ function runFloorPlateMigration(
return { nodes: changed ? nodes : sourceNodes, plateIds, reports }
}

export function migrateSlabSlots(sourceNodes: Record<string, unknown>) {
function migrateSlabSlotsOnView(sourceNodes: Record<string, unknown>) {
const nodes = { ...sourceNodes } as Record<string, AnyNode>
let changed = false
for (const node of Object.values(nodes)) {
Expand All @@ -1623,3 +1624,14 @@ export function migrateSlabSlots(sourceNodes: Record<string, unknown>) {
}
return { nodes: changed ? nodes : sourceNodes, changed }
}

export const migrateFloorPlates = loadMigration(
'floor plates',
migrateFloorPlatesOnView,
(nodes) => ({ nodes, plateIds: [], reports: [] }),
)

export const migrateSlabSlots = loadMigration('slab slots', migrateSlabSlotsOnView, (nodes) => ({
nodes,
changed: false,
}))
9 changes: 8 additions & 1 deletion packages/core/src/utils/legacy-material-slots.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { parseMaterialRef, toSceneMaterialRef } from '../material-library'
import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall'
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material'
import { loadMigration } from './load-migration'

// Legacy inline finishes (`material*` / `materialPreset*`) → `node.slots`,
// shared by the client loader and the hosted authority. Both must run it
Expand Down Expand Up @@ -130,7 +131,7 @@ export function migrateSingleMaterialSlots(
* read (walls, slabs, ceilings), for the hosted authority: the client loader
* runs the same per-node functions in `migrateNodes`.
*/
export function migrateStructuralMaterialSlots(sourceNodes: Record<string, unknown>) {
function migrateStructuralMaterialSlotsOnView(sourceNodes: Record<string, unknown>) {
const materials: MintedMaterials = {}
let nodes: Record<string, unknown> | null = null
for (const [id, value] of Object.entries(sourceNodes)) {
Expand All @@ -148,3 +149,9 @@ export function migrateStructuralMaterialSlots(sourceNodes: Record<string, unkno
}
return { nodes: nodes ?? sourceNodes, materials, changed: nodes !== null }
}

export const migrateStructuralMaterialSlots = loadMigration(
'legacy material slots',
migrateStructuralMaterialSlotsOnView,
(nodes) => ({ nodes, materials: {}, changed: false }),
)
86 changes: 86 additions & 0 deletions packages/core/src/utils/load-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, spyOn, test } from 'bun:test'
import { loadMigration, loadNodeView } from './load-migration'

const raw = () => ({
wall: { id: 'wall', type: 'wall', start: [0, 0], end: [4, 0] },
door: { id: 'door', type: 'door', parentId: 'wall', offset: 1 },
slab: { id: 'slab', type: 'slab', polygon: [], metadata: { keep: true } },
})

test('the view supplies container defaults and leaves scalar absences for migrations to read', () => {
const source = raw()
const { view } = loadNodeView(source)
expect(view.wall).toMatchObject({ children: [], metadata: {} })
expect(view.door).toMatchObject({ position: [0, 0, 0], rotation: [0, 0, 0], offset: 1 })
// Absence a migration reads: a legacy opening, a legacy slab solid to its level.
expect(view.door).not.toHaveProperty('floorThresholdVersion')
expect(view.slab).not.toHaveProperty('thickness')
expect(view.slab).toMatchObject({ holes: [], metadata: { keep: true } })
expect(source).toEqual(raw())
const complete = view as Record<string, unknown>
expect(loadNodeView(complete).view).toBe(complete)
})

test('a migration that changes nothing returns the stored nodes themselves', () => {
const source = raw()
const migrate = loadMigration(
'noop',
(nodes) => ({ nodes, changed: false }),
(nodes) => ({ nodes, changed: false }),
)
expect(migrate(source).nodes).toBe(source)
})

test('fills a migration carried through are not written; values it wrote are', () => {
const source = raw()
const migrate = loadMigration(
'rewrite',
(nodes) => ({
nodes: {
...nodes,
wall: { ...(nodes.wall as object), height: 3 },
door: { ...(nodes.door as object), rotation: [0, 0, 0] },
slab: nodes.slab,
},
}),
(nodes) => ({ nodes }),
)
const { nodes } = migrate(source)
expect(nodes.wall).toEqual({ ...source.wall, height: 3 })
expect(nodes.door).toEqual({ ...source.door, rotation: [0, 0, 0] })
expect(nodes.slab).toBe(source.slab)
})

test('a fill a migration grew in place is its output and stays', () => {
const source = raw()
const migrate = loadMigration(
'grow',
(nodes) => {
;(nodes.wall as { children: string[] }).children.push('door')
return { nodes }
},
(nodes) => ({ nodes }),
)
const { nodes } = migrate(source)
expect(nodes.wall).toEqual({ ...source.wall, children: ['door'] })
expect(source.wall).not.toHaveProperty('children')
})

test('a migration that throws is reported and the scene loads without it', () => {
const error = spyOn(console, 'error').mockImplementation(() => {})
try {
const source = raw()
const migrate = loadMigration(
'broken',
() => {
throw new Error('boom')
},
(nodes) => ({ nodes, changed: false }),
)
expect(migrate(source)).toEqual({ nodes: source, changed: false })
expect(migrate(source).nodes).toBe(source)
expect(String(error.mock.calls[0]?.[0])).toStartWith('[scene load] broken failed')
} finally {
error.mockRestore()
}
})
101 changes: 101 additions & 0 deletions packages/core/src/utils/load-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { containerDefaultFields } from './node-defaults'

type Nodes = Record<string, unknown>
type Filled = Map<string, readonly { key: string; json: string; value: unknown }[]>

/**
* The stored nodes as the load migrations read them: container schema
* defaults (`children`, `holes`, `position`, `metadata`, …) supplied where a
* raw legacy node leaves them out. Scalar defaults stay absent: migrations read
* their absence (see `materializeNodeDefaults`).
*/
export function loadNodeView(source: Nodes): { view: Nodes; filled: Filled } {
let view: Nodes | null = null
const filled: Filled = new Map()
for (const [id, value] of Object.entries(source)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) continue
const node = value as Record<string, unknown>
if (typeof node.type !== 'string') continue
let next: Record<string, unknown> | null = null
const keys: { key: string; json: string; value: unknown }[] = []
for (const { key, json, value } of containerDefaultFields(node.type)) {
if (node[key] !== undefined) continue
next ??= { ...node }
next[key] = structuredClone(value)
keys.push({ key, json, value: next[key] })
}
if (!next) continue
view ??= { ...source }
view[id] = next
filled.set(id, keys)
}
return { view: view ?? source, filled }
}

/**
* Drops the view's fills a migration carried through untouched (the same
* instance, same content), so no stored node is rewritten by them. A value the
* migration wrote itself, even an equal one, is its output and stays.
*/
function restoreStoredShape(source: Nodes, view: Nodes, filled: Filled, migrated: Nodes): Nodes {
const out: Nodes = { ...migrated }
let same = Object.keys(out).length === Object.keys(source).length
for (const [id, node] of Object.entries(out)) {
const fields = filled.get(id)
if (fields && node && typeof node === 'object') {
const record = node as Record<string, unknown>
const untouched = fields.filter(
({ key, json, value }) => record[key] === value && JSON.stringify(value) === json,
)
if (node === view[id] && untouched.length === fields.length) out[id] = source[id]
else if (untouched.length) {
const next = { ...record }
for (const { key } of untouched) delete next[key]
out[id] = next
}
}
if (out[id] !== source[id]) same = false
}
return same ? source : out
}

function reportFailure(name: string, error: unknown) {
console.error(`[scene load] ${name} failed; the scene loads without it`, error)
}

/**
* Wraps a load migration so a raw legacy scene can never stop a project
* opening: the migration reads {@link loadNodeView}, and if it still throws the
* scene loads without it — as main loaded it before the migration existed.
*/
export function loadMigration<A extends unknown[], R extends { nodes: Nodes }>(
name: string,
migrate: (nodes: Nodes, ...args: A) => R,
unchanged: (nodes: Nodes) => R,
): (nodes: Nodes, ...args: A) => R {
return (source, ...args) => {
const { view, filled } = loadNodeView(source)
let result: R
try {
result = migrate(view, ...args)
} catch (error) {
reportFailure(name, error)
return unchanged(source)
}
if (!filled.size) return result
return { ...result, nodes: restoreStoredShape(source, view, filled, result.nodes) } as R
}
}

/** {@link loadMigration} for a migration that returns the node map itself. */
export function loadMapMigration<A extends unknown[], R extends Nodes>(
name: string,
migrate: (nodes: Nodes, ...args: A) => R,
): (nodes: Nodes, ...args: A) => R {
const wrapped = loadMigration(
name,
(nodes: Nodes, ...args: A) => ({ nodes: migrate(nodes, ...args) as Nodes }),
(nodes) => ({ nodes }),
)
return (source, ...args) => wrapped(source, ...args).nodes as R
}
Loading
Loading