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
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
45 changes: 44 additions & 1 deletion packages/core/src/lib/terrain-codec.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
76 changes: 60 additions & 16 deletions packages/core/src/lib/terrain-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+/'

Expand Down Expand Up @@ -78,20 +78,72 @@ 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<HeightPatch, 'heights'> & { 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<EncodedHeightPatch>
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]],
spacing: field.spacing,
cols: field.cols,
rows: field.rows,
step: field.step,
heights: encodeBase64(bytes),
heights: encodeInt16Samples(field.heights),
}
}

Expand Down Expand Up @@ -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],
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/lib/terrain-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SiteNode, 'terrain'> | null | undefined,
): TerrainField | null {
const data = site?.terrain
if (!data) return null
const cached = fieldCache.get(data)
Expand Down Expand Up @@ -82,7 +88,7 @@ export function terrainFieldForEdit(
site: Pick<SiteNode, 'terrain'> | null | undefined,
options?: Parameters<typeof createTerrainField>[0],
): TerrainField {
return terrainFieldOf(site) ?? createTerrainField(options)
return persistedTerrainFieldOf(site) ?? createTerrainField(options)
}

/**
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/store/use-live-terrain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
46 changes: 44 additions & 2 deletions packages/core/src/store/use-live-terrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, LiveTerrainStroke>
remoteStrokes: Map<string, RemoteLiveTerrainStroke>
/**
* Start a stroke from `field`.
*
Expand All @@ -60,13 +67,18 @@ 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
}

const useLiveTerrain = create<LiveTerrainState>((set, get) => ({
strokes: new Map(),
remoteStrokes: new Map(),

begin: (siteId, field) =>
set((state) => {
Expand All @@ -87,8 +99,33 @@ const useLiveTerrain = create<LiveTerrainState>((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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remote previews skip terrain mesh

High Severity

The new remoteStrokes are exposed via fieldOf, but downstream rendering and persistence logic isn't fully updated. This causes remote sculpts to appear flat on the grid, prevents mesh updates from remote lastPatches, and allows remote previews to incorrectly override persisted terrain after updates.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f65686e. Configure here.

}),

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) => {
Expand All @@ -98,7 +135,12 @@ const useLiveTerrain = create<LiveTerrainState>((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
Loading
Loading