From efa0e52295bc5fbd3cbc470ce19fe5611b7945ac Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 3 Aug 2026 11:01:54 -0400 Subject: [PATCH 1/2] fix(core): share retired scene node migration --- packages/core/package.json | 5 +++ packages/core/src/store/use-scene.ts | 24 +---------- .../src/utils/retired-scene-nodes.test.ts | 40 ++++++++++++++++++ .../core/src/utils/retired-scene-nodes.ts | 42 +++++++++++++++++++ 4 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/utils/retired-scene-nodes.test.ts create mode 100644 packages/core/src/utils/retired-scene-nodes.ts diff --git a/packages/core/package.json b/packages/core/package.json index 10910d73d2..d503e3e35d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,11 @@ "import": "./dist/utils/clone-scene-graph.js", "default": "./dist/utils/clone-scene-graph.js" }, + "./scene-migrations": { + "types": "./dist/utils/retired-scene-nodes.d.ts", + "import": "./dist/utils/retired-scene-nodes.js", + "default": "./dist/utils/retired-scene-nodes.js" + }, "./registry": { "types": "./dist/registry/index.d.ts", "import": "./dist/registry/index.js", diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 60f9a23291..bc1ddda8f9 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -37,6 +37,7 @@ import { getCeilingClampBound } from '../services/storey' import { computeWallSlabSupport } from '../systems/slab/slab-support' import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' import { healSceneNodes } from '../utils/heal-scene-graph' +import { removeRetiredDrawingSheetNodes } from '../utils/retired-scene-nodes' import * as nodeActions from './actions/node-actions' import { areSceneSnapshotsEqual, @@ -591,26 +592,6 @@ function migrateConstructionDimension(node: Record) { } } -function removeRetiredDrawingSheets(nodes: Record) { - const retiredIds = new Set( - Object.entries(nodes) - .filter(([, node]) => node?.type === 'drawing-sheet') - .map(([id]) => id), - ) - if (retiredIds.size === 0) return - - for (const id of retiredIds) delete nodes[id] - for (const [id, node] of Object.entries(nodes)) { - if (!Array.isArray(node?.children)) continue - const children = getStringArray(node.children) - if (!children.some((childId) => retiredIds.has(childId))) continue - nodes[id] = { - ...node, - children: children.filter((childId) => !retiredIds.has(childId)), - } - } -} - function migrateWallAssembly(node: Record) { if (!Object.hasOwn(node, 'assemblyLayers')) return node @@ -643,8 +624,7 @@ function migrateNodes(nodes: Record): { // Repair pre-existing corruption (null children, zero-length walls) before // any per-type migration runs, so already-saved scenes load cleanly. const { nodes: healed } = healSceneNodes(nodes) - const patchedNodes = { ...healed } as Record - removeRetiredDrawingSheets(patchedNodes) + const { nodes: patchedNodes } = removeRetiredDrawingSheetNodes(healed as Record) // Scene materials minted while moving legacy wall fields onto `node.slots`; // merged into the scene material map by the caller (`setScene`). diff --git a/packages/core/src/utils/retired-scene-nodes.test.ts b/packages/core/src/utils/retired-scene-nodes.test.ts new file mode 100644 index 0000000000..6e34a35d5a --- /dev/null +++ b/packages/core/src/utils/retired-scene-nodes.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import { removeRetiredDrawingSheetNodes } from './retired-scene-nodes' + +describe('removeRetiredDrawingSheetNodes', () => { + test('removes retired nodes and parent references without mutating the input', () => { + const nodes = { + building_test: { + id: 'building_test', + type: 'building', + children: ['level_test', 'drawing-sheet_a101'], + }, + 'drawing-sheet_a101': { + id: 'drawing-sheet_a101', + type: 'drawing-sheet', + parentId: 'building_test', + }, + level_test: { id: 'level_test', type: 'level' }, + } + + const result = removeRetiredDrawingSheetNodes(nodes) + + expect(result.removedNodeIds).toEqual(new Set(['drawing-sheet_a101'])) + expect(result.nodes).not.toBe(nodes) + expect(result.nodes['drawing-sheet_a101']).toBeUndefined() + expect(result.nodes.building_test.children).toEqual(['level_test']) + expect(nodes.building_test.children).toEqual(['level_test', 'drawing-sheet_a101']) + expect(nodes['drawing-sheet_a101']).toBeDefined() + }) + + test('returns the original node map when no retired nodes are present', () => { + const nodes = { + level_test: { id: 'level_test', type: 'level' }, + } + + const result = removeRetiredDrawingSheetNodes(nodes) + + expect(result.nodes).toBe(nodes) + expect(result.removedNodeIds.size).toBe(0) + }) +}) diff --git a/packages/core/src/utils/retired-scene-nodes.ts b/packages/core/src/utils/retired-scene-nodes.ts new file mode 100644 index 0000000000..9c12b105e7 --- /dev/null +++ b/packages/core/src/utils/retired-scene-nodes.ts @@ -0,0 +1,42 @@ +export type RetiredSceneNodeMigration = { + nodes: Record + removedNodeIds: ReadonlySet +} + +export function removeRetiredDrawingSheetNodes( + nodes: Record, +): RetiredSceneNodeMigration { + const removedNodeIds = new Set( + Object.entries(nodes) + .filter(([, node]) => { + return ( + node !== null && + typeof node === 'object' && + !Array.isArray(node) && + (node as { type?: unknown }).type === 'drawing-sheet' + ) + }) + .map(([id]) => id), + ) + if (removedNodeIds.size === 0) return { nodes, removedNodeIds } + + const migratedNodes = { ...nodes } + for (const id of removedNodeIds) delete migratedNodes[id] + + for (const [id, node] of Object.entries(migratedNodes)) { + if (!(node !== null && typeof node === 'object' && !Array.isArray(node))) continue + const children = (node as { children?: unknown }).children + if (!Array.isArray(children)) continue + if (!children.some((childId) => typeof childId === 'string' && removedNodeIds.has(childId))) { + continue + } + migratedNodes[id] = { + ...node, + children: children.filter( + (childId) => !(typeof childId === 'string' && removedNodeIds.has(childId)), + ), + } + } + + return { nodes: migratedNodes, removedNodeIds } +} From f65686eb4b0f1ae33595f00b338c0ff88a7d2d49 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 3 Aug 2026 14:00:38 -0400 Subject: [PATCH 2/2] fix(collaboration): stream live terrain previews --- packages/core/src/index.ts | 16 +++- packages/core/src/lib/terrain-codec.test.ts | 45 ++++++++++- packages/core/src/lib/terrain-codec.ts | 76 +++++++++++++++---- packages/core/src/lib/terrain-source.ts | 8 +- .../core/src/store/use-live-terrain.test.ts | 33 +++++++- packages/core/src/store/use-live-terrain.ts | 46 ++++++++++- packages/editor/src/index.tsx | 1 + packages/editor/src/lib/terrain-sculpt.ts | 4 +- 8 files changed, 204 insertions(+), 25 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3c88db3ca8..9bf1a6b741 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -168,7 +168,14 @@ export { type TerrainVerb, weightAt, } from './lib/terrain-brush' -export { decodeTerrainField, encodeTerrainField, isDatumField } from './lib/terrain-codec' +export { + decodeHeightPatch, + decodeTerrainField, + type EncodedHeightPatch, + encodeHeightPatch, + encodeTerrainField, + isDatumField, +} from './lib/terrain-codec' export { applyHeightPatch, createTerrainField, @@ -188,7 +195,12 @@ export { type TerrainField, } from './lib/terrain-field' export { raycastTerrain, type TerrainHit } from './lib/terrain-raycast' -export { commitTerrainField, terrainFieldForEdit, terrainFieldOf } from './lib/terrain-source' +export { + commitTerrainField, + persistedTerrainFieldOf, + terrainFieldForEdit, + terrainFieldOf, +} from './lib/terrain-source' export { isLevelBaseConsumer, isSiteDatum, diff --git a/packages/core/src/lib/terrain-codec.test.ts b/packages/core/src/lib/terrain-codec.test.ts index 89f6f357a8..04c1762f82 100644 --- a/packages/core/src/lib/terrain-codec.test.ts +++ b/packages/core/src/lib/terrain-codec.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from 'bun:test' import type { TerrainData } from '../schema/terrain' -import { decodeTerrainField, encodeTerrainField, isDatumField } from './terrain-codec' +import { + decodeHeightPatch, + decodeTerrainField, + encodeHeightPatch, + encodeTerrainField, + isDatumField, +} from './terrain-codec' import { createTerrainField, heightAt, type TerrainField } from './terrain-field' function fieldWith(values: number[], cols: number, rows: number): TerrainField { @@ -93,6 +99,43 @@ describe('encodeTerrainField / decodeTerrainField', () => { }) }) +describe('encodeHeightPatch / decodeHeightPatch', () => { + test('round-trips a bounded patch without expanding samples into JSON numbers', () => { + const patch = { + col0: 3, + row0: 5, + cols: 2, + rows: 2, + heights: Int16Array.from([0, 32767, -32768, 42]), + } + + const encoded = encodeHeightPatch(patch) + const decoded = decodeHeightPatch(JSON.parse(JSON.stringify(encoded))) + + expect(encoded.heights).toBe('AAD/fwCAKgA=') + expect(decoded && { ...decoded, heights: Array.from(decoded.heights) }).toEqual({ + ...patch, + heights: [0, 32767, -32768, 42], + }) + }) + + test('rejects invalid bounds, dimensions, and sample payloads', () => { + const encoded = encodeHeightPatch({ + col0: 0, + row0: 0, + cols: 2, + rows: 2, + heights: Int16Array.from([1, 2, 3, 4]), + }) + + expect(decodeHeightPatch({ ...encoded, col0: -1 })).toBeNull() + expect(decodeHeightPatch({ ...encoded, cols: 258 })).toBeNull() + expect(decodeHeightPatch({ ...encoded, rows: 0 })).toBeNull() + expect(decodeHeightPatch({ ...encoded, heights: encoded.heights.slice(0, -4) })).toBeNull() + expect(decodeHeightPatch({ ...encoded, heights: `${encoded.heights}AAAA` })).toBeNull() + }) +}) + describe('decodeTerrainField — hostile and corrupt input', () => { test('rejects non-objects and wrong discriminators', () => { expect(decodeTerrainField(null)).toBeNull() diff --git a/packages/core/src/lib/terrain-codec.ts b/packages/core/src/lib/terrain-codec.ts index c266af5176..39fc209a4d 100644 --- a/packages/core/src/lib/terrain-codec.ts +++ b/packages/core/src/lib/terrain-codec.ts @@ -26,7 +26,7 @@ */ import { MAX_TERRAIN_SIDE, type TerrainData } from '../schema/terrain' -import type { TerrainField } from './terrain-field' +import type { HeightPatch, TerrainField } from './terrain-field' const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' @@ -78,12 +78,64 @@ function decodeBase64(text: string): Uint8Array | null { return bytes } -export function encodeTerrainField(field: TerrainField): TerrainData { - const bytes = new Uint8Array(field.heights.length * 2) +function encodeInt16Samples(samples: Int16Array): string { + const bytes = new Uint8Array(samples.length * 2) const view = new DataView(bytes.buffer) - for (let i = 0; i < field.heights.length; i++) { - view.setInt16(i * 2, field.heights[i] ?? 0, true) + for (let i = 0; i < samples.length; i++) { + view.setInt16(i * 2, samples[i] ?? 0, true) + } + return encodeBase64(bytes) +} + +function decodeInt16Samples(text: string, sampleCount: number): Int16Array | null { + const bytes = decodeBase64(text) + if (!bytes || encodeBase64(bytes) !== text || bytes.byteLength !== sampleCount * 2) return null + + const samples = new Int16Array(sampleCount) + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + for (let i = 0; i < samples.length; i++) { + samples[i] = view.getInt16(i * 2, true) + } + return samples +} + +export type EncodedHeightPatch = Omit & { heights: string } + +export function encodeHeightPatch(patch: HeightPatch): EncodedHeightPatch { + return { ...patch, heights: encodeInt16Samples(patch.heights) } +} + +export function decodeHeightPatch(value: unknown): HeightPatch | null { + if (!value || typeof value !== 'object') return null + const patch = value as Partial + if ( + !Number.isInteger(patch.col0) || + !Number.isInteger(patch.row0) || + !Number.isInteger(patch.cols) || + !Number.isInteger(patch.rows) + ) { + return null + } + const col0 = patch.col0 as number + const row0 = patch.row0 as number + const cols = patch.cols as number + const rows = patch.rows as number + if ( + col0 < 0 || + row0 < 0 || + cols < 1 || + rows < 1 || + col0 + cols > MAX_TERRAIN_SIDE || + row0 + rows > MAX_TERRAIN_SIDE || + typeof patch.heights !== 'string' + ) { + return null } + const heights = decodeInt16Samples(patch.heights, cols * rows) + return heights ? { col0, row0, cols, rows, heights } : null +} + +export function encodeTerrainField(field: TerrainField): TerrainData { return { type: 'heightfield', origin: [field.origin[0], field.origin[1]], @@ -91,7 +143,7 @@ export function encodeTerrainField(field: TerrainField): TerrainData { cols: field.cols, rows: field.rows, step: field.step, - heights: encodeBase64(bytes), + heights: encodeInt16Samples(field.heights), } } @@ -128,16 +180,8 @@ export function decodeTerrainField(data: unknown): TerrainField | null { return null } - const bytes = decodeBase64(d.heights) - if (!bytes) return null - if (encodeBase64(bytes) !== d.heights) return null - if (bytes.byteLength !== cols * rows * 2) return null - - const heights = new Int16Array(cols * rows) - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) - for (let i = 0; i < heights.length; i++) { - heights[i] = view.getInt16(i * 2, true) - } + const heights = decodeInt16Samples(d.heights, cols * rows) + if (!heights) return null return { origin: [d.origin[0] as number, d.origin[1] as number], diff --git a/packages/core/src/lib/terrain-source.ts b/packages/core/src/lib/terrain-source.ts index 759d9b2e2c..c78394a869 100644 --- a/packages/core/src/lib/terrain-source.ts +++ b/packages/core/src/lib/terrain-source.ts @@ -55,6 +55,12 @@ export function terrainFieldOf( const live = useLiveTerrain.getState().fieldOf(site.id) if (live) return live } + return persistedTerrainFieldOf(site) +} + +export function persistedTerrainFieldOf( + site: Pick | null | undefined, +): TerrainField | null { const data = site?.terrain if (!data) return null const cached = fieldCache.get(data) @@ -82,7 +88,7 @@ export function terrainFieldForEdit( site: Pick | null | undefined, options?: Parameters[0], ): TerrainField { - return terrainFieldOf(site) ?? createTerrainField(options) + return persistedTerrainFieldOf(site) ?? createTerrainField(options) } /** diff --git a/packages/core/src/store/use-live-terrain.test.ts b/packages/core/src/store/use-live-terrain.test.ts index a282204bdc..99e20b6ba4 100644 --- a/packages/core/src/store/use-live-terrain.test.ts +++ b/packages/core/src/store/use-live-terrain.test.ts @@ -7,7 +7,7 @@ import { heightAt, type TerrainField, } from '../lib/terrain-field' -import { terrainFieldOf } from '../lib/terrain-source' +import { persistedTerrainFieldOf, terrainFieldOf } from '../lib/terrain-source' import useLiveTerrain from './use-live-terrain' function flatten(field: TerrainField, metres: number) { @@ -103,6 +103,26 @@ describe('useLiveTerrain', () => { useLiveTerrain.getState().end('site_1') expect(useLiveTerrain.getState().fieldOf('site_1')).toBeUndefined() }) + + test('local strokes take precedence over remote previews without adopting them', () => { + const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const remote = flatten(base, 3) + const local = flatten(base, 7) + + useLiveTerrain.getState().previewRemote('site_1', 'session_remote', remote.field, remote.patch) + expect(heightAt(useLiveTerrain.getState().fieldOf('site_1') as never, 2, 2)).toBeCloseTo(3, 6) + + useLiveTerrain.getState().begin('site_1', base) + useLiveTerrain.getState().advance('site_1', local.field, local.patch) + expect(heightAt(useLiveTerrain.getState().fieldOf('site_1') as never, 2, 2)).toBeCloseTo(7, 6) + + useLiveTerrain.getState().end('site_1') + expect(heightAt(useLiveTerrain.getState().fieldOf('site_1') as never, 2, 2)).toBeCloseTo(3, 6) + useLiveTerrain.getState().endRemote('site_1', 'another_session') + expect(useLiveTerrain.getState().remoteStrokeOf('site_1')).toBeDefined() + useLiveTerrain.getState().endRemoteSource('session_remote') + expect(useLiveTerrain.getState().fieldOf('site_1')).toBeUndefined() + }) }) describe('terrainFieldOf sees the live stroke', () => { @@ -147,4 +167,15 @@ describe('terrainFieldOf sees the live stroke', () => { test('a site with no id and no terrain is still null — no crash', () => { expect(terrainFieldOf({ terrain: undefined })).toBeNull() }) + + test('the persisted-only read ignores local and remote previews', () => { + const persisted = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const site = { id: 'site_1', terrain: encodeTerrainField(persisted) } + const remote = flatten(persisted, 4) + useLiveTerrain.getState().previewRemote(site.id, 'session_remote', remote.field, remote.patch) + + expect(persistedTerrainFieldOf(site)).toBe(persistedTerrainFieldOf(site)) + expect(heightAt(persistedTerrainFieldOf(site) as never, 2, 2)).toBeCloseTo(0, 6) + expect(heightAt(terrainFieldOf(site) as never, 2, 2)).toBeCloseTo(4, 6) + }) }) diff --git a/packages/core/src/store/use-live-terrain.ts b/packages/core/src/store/use-live-terrain.ts index 54ad09e070..0ea486c4ea 100644 --- a/packages/core/src/store/use-live-terrain.ts +++ b/packages/core/src/store/use-live-terrain.ts @@ -35,9 +35,16 @@ export type LiveTerrainStroke = { readonly lastPatch: HeightPatch | null } +export type RemoteLiveTerrainStroke = { + readonly sourceId: string + readonly field: TerrainField + readonly lastPatch: HeightPatch +} + type LiveTerrainState = { /** Keyed by site node id — a scene can hold more than one site. */ strokes: Map + remoteStrokes: Map /** * Start a stroke from `field`. * @@ -60,6 +67,10 @@ type LiveTerrainState = { /** The live field for a site, or undefined when no stroke is in flight. */ fieldOf(siteId: string): TerrainField | undefined strokeOf(siteId: string): LiveTerrainStroke | undefined + remoteStrokeOf(siteId: string): RemoteLiveTerrainStroke | undefined + previewRemote(siteId: string, sourceId: string, field: TerrainField, patch: HeightPatch): void + endRemote(siteId: string, sourceId: string): void + endRemoteSource(sourceId: string): void /** End the stroke. The caller persists the field first if it wants to keep it. */ end(siteId: string): void endAll(): void @@ -67,6 +78,7 @@ type LiveTerrainState = { const useLiveTerrain = create((set, get) => ({ strokes: new Map(), + remoteStrokes: new Map(), begin: (siteId, field) => set((state) => { @@ -87,8 +99,33 @@ const useLiveTerrain = create((set, get) => ({ return { strokes: next } }), - fieldOf: (siteId) => get().strokes.get(siteId)?.field, + fieldOf: (siteId) => get().strokes.get(siteId)?.field ?? get().remoteStrokes.get(siteId)?.field, strokeOf: (siteId) => get().strokes.get(siteId), + remoteStrokeOf: (siteId) => get().remoteStrokes.get(siteId), + + previewRemote: (siteId, sourceId, field, patch) => + set((state) => { + const next = new Map(state.remoteStrokes) + next.set(siteId, { sourceId, field, lastPatch: patch }) + return { remoteStrokes: next } + }), + + endRemote: (siteId, sourceId) => + set((state) => { + if (state.remoteStrokes.get(siteId)?.sourceId !== sourceId) return state + const next = new Map(state.remoteStrokes) + next.delete(siteId) + return { remoteStrokes: next } + }), + + endRemoteSource: (sourceId) => + set((state) => { + const next = new Map(state.remoteStrokes) + for (const [siteId, stroke] of next) { + if (stroke.sourceId === sourceId) next.delete(siteId) + } + return next.size === state.remoteStrokes.size ? state : { remoteStrokes: next } + }), end: (siteId) => set((state) => { @@ -98,7 +135,12 @@ const useLiveTerrain = create((set, get) => ({ return { strokes: next } }), - endAll: () => set((state) => (state.strokes.size === 0 ? state : { strokes: new Map() })), + endAll: () => + set((state) => + state.strokes.size === 0 && state.remoteStrokes.size === 0 + ? state + : { remoteStrokes: new Map(), strokes: new Map() }, + ), })) export default useLiveTerrain diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 05ffecf805..212478ab49 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -518,6 +518,7 @@ export { snapBuildingLocalToWorldGrid, snapWorldXZForActiveBuilding, } from './lib/world-grid-snap' +export { subscribeCameraPose } from './store/camera-pose-store' export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAudio } from './store/use-audio' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' diff --git a/packages/editor/src/lib/terrain-sculpt.ts b/packages/editor/src/lib/terrain-sculpt.ts index 65ddef6058..89ff74e4c9 100644 --- a/packages/editor/src/lib/terrain-sculpt.ts +++ b/packages/editor/src/lib/terrain-sculpt.ts @@ -14,6 +14,7 @@ import { type HeightPatch, isDatumField, minBrushRadius, + persistedTerrainFieldOf, pointInPolygon2D, quantize, runAsSingleSceneHistoryStep, @@ -21,7 +22,6 @@ import { sampleTarget, type TerrainField, type TerrainVerb, - terrainFieldOf, useLiveTerrain, useScene, } from '@pascal-app/core' @@ -97,7 +97,7 @@ export function fieldExtentForSite(site: Pick | null | unde * here is that the tool must not have to know how to size a grid. */ export function sculptFieldForSite(site: SiteNode): TerrainField { - return terrainFieldOf(site) ?? createTerrainField(fieldExtentForSite(site)) + return persistedTerrainFieldOf(site) ?? createTerrainField(fieldExtentForSite(site)) } /** Whether an XZ point belongs to the editable property footprint. */