Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
5c5b568
feat(viewer): lazy nested reveal for framing, prerolls, and no pop fo…
AxiomeCG Oct 14, 2026
b30d47c
fix(viewer): a gentler overshoot for the lazy rise, per style
AxiomeCG Oct 14, 2026
0d3412f
test(viewer): add_volume plays room by room at the framing level
AxiomeCG Oct 14, 2026
f49a508
One pace for the Framing level: its whole lazy timeline takes FRAMING…
AxiomeCG Oct 15, 2026
579f510
The reveal reads the person's animation speed when it plans a build
AxiomeCG Oct 15, 2026
7b22809
An animation speed setting, and a construction level that follows the…
AxiomeCG Oct 15, 2026
9ab650f
fix(viewer): preserve wall finishes and metre-scale textures during r…
AxiomeCG Oct 14, 2026
d660986
Framing carries the Pro badge in the construction animation setting
AxiomeCG Oct 15, 2026
7549343
Framing's tooltip names the plugin a person installs: Architect
AxiomeCG Oct 15, 2026
38f3fef
viewer: the level a reveal plays on shows while it plays, whatever th…
AxiomeCG Oct 15, 2026
7d5ad1d
nodes: a guard that every built-in kind an agent can create is staged…
AxiomeCG Oct 15, 2026
536d5e7
editor: Follow Pascal pulls back to the finished house with the floor…
AxiomeCG Oct 15, 2026
10d60b2
wiki: the level display never hides a build; the reveal publishes the…
AxiomeCG Oct 15, 2026
611c431
core: a commit names what the structure reconciler wrote (derivedWrit…
AxiomeCG Oct 15, 2026
facb436
viewer: an edit that changes a node's picture replays its reveal quic…
AxiomeCG Oct 15, 2026
2a2d389
viewer: many edits begin spread over one second, not piled on its las…
AxiomeCG Oct 15, 2026
cdd7677
nodes: the edit field lists and the reveal guard follow the kinds and…
AxiomeCG Oct 15, 2026
702e02f
viewer: at the Framing level a roof keeps its frame and its covering …
AxiomeCG Oct 15, 2026
75065b8
viewer: a node with nothing drawn has an empty covering sweep; the wi…
AxiomeCG Oct 15, 2026
45db88d
viewer: deleting a room or a floor takes it apart, floor by floor and…
AxiomeCG Oct 15, 2026
8bdb9e4
viewer: a plugin's preroll for a deleted node is asked in the scene i…
AxiomeCG Oct 15, 2026
6bce21c
wiki: deleting a room or a floor takes it apart
AxiomeCG Oct 15, 2026
56164b6
viewer: a derived plate or ceiling goes with its room when a floor or…
AxiomeCG Oct 15, 2026
1723b99
viewer: a roof's covering closes continuously, in a time that follows…
AxiomeCG Oct 15, 2026
8cf6ebe
core: sceneViewFacing answers for a wall with an exterior face
AxiomeCG Oct 15, 2026
51e1843
core: scene-view test names the wall's own facing
AxiomeCG Oct 15, 2026
d1073e9
viewer: the cutaway leaves what plays in view
AxiomeCG Oct 15, 2026
ced35eb
editor, nodes: Follow looks at an edit or a move seen from outside fr…
AxiomeCG Oct 15, 2026
a7db00f
nodes: a window moved along its wall replays; a wall moved does not r…
AxiomeCG Oct 15, 2026
950a707
viewer: a deleted wall plays its deletion in its own picture, not und…
AxiomeCG Oct 15, 2026
1a1c558
Merge upstream/main into feat/framing-build-animation-20261015
AxiomeCG Oct 15, 2026
a773c15
Format the Framing tests with main's Biome 2.5
AxiomeCG Oct 15, 2026
4e832ac
follow-pascal test: type the written walls modes as string[] for main…
AxiomeCG Oct 15, 2026
a7b567a
nodes: a roof segment replays its reveal for the barrel arch and the …
AxiomeCG Oct 15, 2026
89dce8b
Merge upstream/main into feat/framing-build-animation-20261015
AxiomeCG Oct 15, 2026
d7e00a0
Merge remote-tracking branch 'upstream/main' into feat/framing-sync-2…
AxiomeCG Oct 15, 2026
5e1a0a0
viewer: a deletion ghost keeps its material when the wall palette can…
AxiomeCG Oct 15, 2026
491ee10
viewer: the deletion reveal test's wall mesh carries groups its palet…
AxiomeCG Oct 15, 2026
01e52b1
core: capabilities.exterior and capabilities.revealLayout on a kind's…
AxiomeCG Oct 15, 2026
c11f332
nodes: kinds declare what is seen from outside and what a room is in …
AxiomeCG Oct 15, 2026
16f1b61
editor: Follow Pascal asks the registry what is seen from outside, no…
AxiomeCG Oct 15, 2026
40f2ee5
viewer: the reveal asks the registry for its host, rooms and lines, a…
AxiomeCG Oct 15, 2026
bcb6fd0
core: derived-write notes cover what a write changed, and are dropped…
AxiomeCG Oct 15, 2026
f936603
viewer: what stands above a deleted floor keeps its place while the f…
AxiomeCG Oct 15, 2026
ba4cc73
Merge remote-tracking branch 'upstream/main' into feat/framing-sync-2…
AxiomeCG Oct 16, 2026
5f26ba6
viewer, editor: Bugbot round 2: runs follow the outline, a departing …
AxiomeCG Oct 16, 2026
faa6e27
editor: Bugbot round 3: a click on the level that plays keeps a saved…
AxiomeCG Oct 16, 2026
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
16 changes: 12 additions & 4 deletions packages/core/src/agent-operations/scene-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,10 +878,18 @@ describe('which way an opening faces', () => {
expect(facing[1]).toBeCloseTo(0, 9)
})

test('nothing for what is not an opening', () => {
expect(sceneViewFacing(facadeScene('back'), 'wall_face')).toBeNull()
expect(sceneViewFacing(facadeScene('back'), 'item_lamp')).toBeNull()
expect(sceneViewFacing(facadeScene('back'), 'nothing_here')).toBeNull()
test('a wall with an exterior face faces the way that face does, whichever way it was drawn', () => {
for (const outside of ['back', 'front'] as const)
expect(sceneViewFacing(facadeScene(outside), 'wall_face')).toEqual([0, -1])
})

test('nothing for a wall with no exterior face, or for what is not an opening or a wall', () => {
const scene = facadeScene('back')
const wall = scene.wall_face as WallNode
const interior = { ...scene, wall_face: { ...wall, backSide: 'interior' } } as typeof scene
expect(sceneViewFacing(interior, 'wall_face')).toBeNull()
expect(sceneViewFacing(scene, 'item_lamp')).toBeNull()
expect(sceneViewFacing(scene, 'nothing_here')).toBeNull()
})
})

Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/agent-operations/scene-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,16 +509,20 @@ function outwardOf(opening: AnyNode & { rotation?: number[] }, wall: WallNode, y

/**
* Which way a door or a window faces, outward, as a unit vector in the site's plan (x, z): what a
* view of it is taken from, and what a camera that follows the build looks at it along. Null for
* anything that is not an opening in a wall.
* view of it is taken from, and what a camera that follows the build looks at it along. The same
* for a wall with an exterior side: the way that side faces. Null for anything else, and for a wall
* with no exterior side.
*/
export function sceneViewFacing(
nodes: Readonly<Record<string, AnyNode>>,
id: string,
): [number, number] | null {
const opening = openingOf(nodes, nodes[id])
if (!opening) return null
return outwardOf(opening.opening, opening.wall, standingOf(nodes, opening.wall.parentId).yaw)
if (opening)
return outwardOf(opening.opening, opening.wall, standingOf(nodes, opening.wall.parentId).yaw)
const wall = nodes[id]
if (wall?.type !== 'wall' || resolveWallExteriorSide(wall) === null) return null
return outwardOf(wall, wall, standingOf(nodes, wall.parentId).yaw)
}

/**
Expand Down
189 changes: 189 additions & 0 deletions packages/core/src/lib/derived-commit-nodes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '../schema'
import { DERIVED_WRITER_TOKEN } from '../store/derived-node-guard'
import {
noteDerivedNodeWrites,
notifySceneCommit,
runAsSingleSceneHistoryStep,
type SceneCommit,
subscribeSceneCommits,
} from '../store/history-control'
import useScene, { clearSceneHistory } from '../store/use-scene'
import { initSpaceDetectionSync } from './space-detection'

// What the structure reconciler writes while an edit is committed (plates, ceilings, zones) is derived
// from the edit, not authored by it: the commit says so, so presentation can leave it out of an edit's
// own animation.
const levelId = 'level_derived'
const corners: [number, number][] = [
[0, 0],
[6, 0],
[6, 4],
[0, 4],
]

test('a commit names what the reconciler wrote, and the fields of a node it only touched', () => {
globalThis.requestAnimationFrame ??= (callback) => {
callback(0)
return 0
}
globalThis.cancelAnimationFrame ??= () => {}
const previous = useScene.getState()
const level = LevelNode.parse({ id: levelId, children: [] })
useScene.setState({
nodes: { [levelId]: level } as Record<AnyNodeId, AnyNode>,
rootNodeIds: [levelId] as AnyNodeId[],
collections: {},
materials: {},
readOnly: false,
})
clearSceneHistory()
const stop = initSpaceDetectionSync(useScene, {
getState: () => ({ spaces: {}, setSpaces: () => {} }),
})
const commits: SceneCommit[] = []
const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
try {
const walls = corners.map((start, i) =>
WallNode.parse({
id: `wall_derived_${i}`,
parentId: levelId,
start,
end: corners[(i + 1) % corners.length],
}),
)
useScene
.getState()
.createNodes(walls.map((node) => ({ node: node as AnyNode, parentId: levelId as AnyNodeId })))
const commit = commits.at(-1)!
const writes = commit.derivedWrites ?? new Map()
// The room's plate is the reconciler's whole: it made it.
const made = [...writes].filter(
([id, fields]) => fields === 'all' && !id.startsWith('wall_derived_'),
)
expect(made.length).toBeGreaterThan(0)
// A wall it only classified the sides of: it wrote those fields, not the wall's line.
for (const [id, fields] of writes) {
if (!id.startsWith('wall_derived_') || fields === 'all') continue
expect(fields.has('start')).toBe(false)
expect(fields.has('end')).toBe(false)
}

// An edit that triggers no reconciliation says nothing is derived.
commits.length = 0
useScene
.getState()
.updateNodes([{ id: 'wall_derived_0' as AnyNodeId, data: { name: 'North' } as never }])
expect(commits.at(-1)?.derivedWrites).toBeUndefined()
} finally {
unsubscribe()
stop()
useScene.setState(previous)
}
})

// A write the reconciler made that changed nothing, or that a commit never carried, must not ride on the
// next commit: that commit's own edit would be read as derived and presentation would skip it.
const wallId = 'wall_derived_one' as AnyNodeId
const derived = { derivedWriter: DERIVED_WRITER_TOKEN }

function withOneWall(run: (authoredEdit: (name: string) => SceneCommit | undefined) => void) {
globalThis.requestAnimationFrame ??= (callback) => {
callback(0)
return 0
}
globalThis.cancelAnimationFrame ??= () => {}
const previous = useScene.getState()
useScene.setState({
nodes: {
[levelId]: LevelNode.parse({ id: levelId, children: [wallId] }),
[wallId]: WallNode.parse({
id: wallId,
parentId: levelId,
name: 'North',
start: [0, 0],
end: [4, 0],
}),
} as Record<AnyNodeId, AnyNode>,
rootNodeIds: [levelId] as AnyNodeId[],
collections: {},
materials: {},
readOnly: false,
})
clearSceneHistory()
const commits: SceneCommit[] = []
const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
try {
run((name) => {
commits.length = 0
useScene.getState().updateNodes([{ id: wallId, data: { name } as never }])
return commits.at(-1)
})
} finally {
unsubscribe()
useScene.setState(previous)
}
}

test('a derived update to the value a node already has leaves the next edit authored', () => {
withOneWall((authoredEdit) => {
useScene.getState().updateNodes([{ id: wallId, data: { name: 'North' } as never }], derived)
expect(authoredEdit('South')?.derivedWrites).toBeUndefined()
})
})

test('a derived delete of a node that is not there leaves the next edit authored', () => {
withOneWall((authoredEdit) => {
useScene.getState().deleteNodes(['wall_missing' as AnyNodeId], derived)
expect(authoredEdit('South')?.derivedWrites).toBeUndefined()
})
})

test('a history step whose derived writes cancel out leaves the next edit authored', () => {
withOneWall((authoredEdit) => {
runAsSingleSceneHistoryStep(useScene, () => {
useScene.getState().updateNodes([{ id: wallId, data: { name: 'Tmp' } as never }], derived)
useScene.getState().updateNodes([{ id: wallId, data: { name: 'North' } as never }], derived)
})
expect(authoredEdit('South')?.derivedWrites).toBeUndefined()
})
})

test('a commit that is a no-op takes the writes noted for it with it', () => {
withOneWall((authoredEdit) => {
noteDerivedNodeWrites([{ id: wallId, fields: ['name'] }])
const { nodes, rootNodeIds } = useScene.getState()
const snapshot = { nodes, rootNodeIds, collections: {}, materials: {}, installedPlugins: [] }
notifySceneCommit({ origin: 'local', before: snapshot, current: snapshot })
expect(authoredEdit('South')?.derivedWrites).toBeUndefined()
})
})

test('a derived write that changes a node is reported, by the fields that changed', () => {
withOneWall(() => {
const commits: SceneCommit[] = []
const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
try {
useScene
.getState()
.updateNodes([{ id: wallId, data: { name: 'North', thickness: 0.2 } as never }], derived)
} finally {
unsubscribe()
}
const fields = commits.at(-1)?.derivedWrites?.get(wallId) as ReadonlySet<string> | undefined
expect([...(fields ?? [])]).toEqual(['thickness'])
})
})

test('a derived delete is noted as the whole node, through applyNodeChanges too', () => {
withOneWall(() => {
const commits: SceneCommit[] = []
const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
try {
useScene.getState().applyNodeChanges({ delete: [wallId] }, derived)
} finally {
unsubscribe()
}
expect(commits.at(-1)?.derivedWrites?.get(wallId)).toBe('all')
})
})
2 changes: 2 additions & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ export type {
RendererSource,
RevealClears,
RevealConfig,
RevealLayout,
RevealPhase,
RevealPlanPoint,
RevealStyle,
RoofAccessoryConfig,
RotatableConfig,
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1737,6 +1737,32 @@ export type Capabilities = {
* `RevealConfig`.
*/
reveal?: RevealConfig
/**
* The fields that make the node's picture: an edit that changes one (a wall bent to a curve, a slab
* resized) plays the node's reveal again, quickly, instead of jumping. A change to any other field
* (a name, a colour) stays instant, and so does a change the structure reconciler derives. See
* `RevealConfig`.
*/
revealOnEdit?: readonly string[]
/**
* What the node is in a floor's plan, for the order a build is revealed in (room by room, wall by
* wall). Set by a kind that outlines a room (a zone) or bounds rooms along a line (a wall); a kind
* that sets neither is placed by its own `position`, `polygon` or `start`/`end`. See `RevealLayout`.
*/
revealLayout?: RevealLayout
/**
* Whether a change to the node is seen from outside the building, for hosts that follow a change
* with a camera (Follow Pascal). `true` for a kind that always stands outdoors (a roof and what is
* mounted on it, a fence, the site); `false` for one that never does, and that holds what is looked
* at through a cutaway (a storey, a room). A function decides from the node's fields and its
* surroundings (a wall with an exterior face, an opening in it) and returns `undefined` when the
* node says nothing. A node whose kind says nothing, or whose function does, is outdoors when what
* holds it is (a `true` kind, looking up to the first `false` one), or when it stands in no
* storey. Presentation only.
*/
exterior?:
| boolean
| ((node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) => boolean | undefined)
/**
* Plan footprint this kind exposes to the alignment-anchor pool when it
* isn't `floorPlaced` and isn't a structural primitive the bridge handles
Expand Down Expand Up @@ -2618,6 +2644,26 @@ export type RevealConfig =
*/
export type RevealClears = { for: RevealPhase; height: number }

/** A point of a level's plan, in metres: `[x, z]`. */
export type RevealPlanPoint = readonly [x: number, z: number]

/**
* What a kind is in its level's plan, read by the construction reveal to lay a build out room by
* room (`capabilities.revealLayout`). Both fields are pure: they read only the node.
*
* - `room` says the node outlines a room and returns its polygon, or `null` for a node with none yet.
* What stands in the polygon (walls along it, furniture in it) is revealed together, around its
* centre, and rooms sweep across the floor.
* - `line` says the node bounds rooms along a line and returns its two ends. Its middle is where it
* stands; the room either side of it is its room; a line in no room runs with the lines it meets
* end to end. The derived plates and ceilings of a level go out with the `room` and `line` nodes a
* commit takes off it.
*/
export type RevealLayout = {
room?: (node: AnyNode) => readonly RevealPlanPoint[] | null
line?: (node: AnyNode) => readonly [start: RevealPlanPoint, end: RevealPlanPoint] | null
}

/**
* Plan footprint a kind contributes to the alignment-anchor pool when it is
* neither `floorPlaced` (columns / items, whose footprint the bridge already
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/store/actions/node-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
resolveMergedWallEndpoints,
type WallAttachmentUpdate,
} from '../../systems/wall/wall-merge'
import { areSemanticValuesEqual } from '../../utils/semantic-equal'
import {
convertDerivedPlateHoleWrites,
DERIVED_WRITER_TOKEN,
Expand All @@ -55,6 +56,7 @@ import {
import {
activeSceneCommitNodeIds,
addActiveSceneCommitNodeIds,
noteDerivedNodeWrites,
runWithSceneCommitNodeIds,
} from '../history-control'
import type { SceneState } from '../use-scene'
Expand All @@ -68,6 +70,25 @@ type NodeCreateOp = {
}
type NodeUpdateOp = { id: AnyNodeId; data: Partial<AnyNode> }
type NodeDeleteOp = AnyNodeId

/**
* What a derived write of `updates` changes, for the commit that carries it: a node, and the fields whose
* value differs from the node's own. A write that sets what is already there changes nothing, so it is
* not noted and cannot mark a later authored edit of that field as derived.
*/
function changedDerivedFields(
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
updates: NodeUpdateOp[],
) {
return updates.flatMap(({ id, data }) => {
const node = nodes[id] as Record<string, unknown> | undefined
if (!node) return []
const fields = Object.keys(data).filter(
(field) => !areSemanticValuesEqual(node[field], (data as Record<string, unknown>)[field]),
)
return fields.length ? [{ id, fields }] : []
})
}
type WallMergePlan = {
primaryWallId: AnyNodeId
secondaryWallId: AnyNodeId
Expand Down Expand Up @@ -1167,6 +1188,8 @@ const createNodesActionImpl = (
if (get().readOnly) return
ops = filterDerivedNodeWrites(get().nodes, { create: ops }, options).create
if (!ops.length) return
if (options?.derivedWriter === DERIVED_WRITER_TOKEN)
noteDerivedNodeWrites(ops.map((op) => ({ id: op.node.id })))
const extraNodesToMarkDirty = new Set<AnyNodeId>()
const extraNodesToClearDirty = new Set<AnyNodeId>()
set((state) => {
Expand Down Expand Up @@ -1309,6 +1332,12 @@ const applyNodeChangesActionImpl = (
changes = convertDerivedPlateHoleWrites(get().nodes, changes, options)
changes = filterDerivedNodeWrites(get().nodes, changes, options)
if (!changes.create?.length && !changes.update?.length && !changes.delete?.length) return
if (options?.derivedWriter === DERIVED_WRITER_TOKEN)
noteDerivedNodeWrites([
...(changes.create ?? []).map((op) => ({ id: op.node.id })),
...changedDerivedFields(get().nodes, changes.update ?? []),
...(changes.delete ?? []).filter((id) => get().nodes[id]).map((id) => ({ id })),
])

const createOps = changes.create ?? []
const deletionIntent =
Expand Down Expand Up @@ -1490,6 +1519,8 @@ const updateNodesActionImpl = (
}
updates = filterDerivedNodeWrites(get().nodes, { update: converted.update ?? [] }, options).update
if (!updates.length) return
if (options?.derivedWriter === DERIVED_WRITER_TOKEN)
noteDerivedNodeWrites(changedDerivedFields(get().nodes, updates))
const parentsToUpdate = new Set<AnyNodeId>()
const extraNodesToUpdate = new Set<AnyNodeId>()
const extraNodesToDelete = new Set<AnyNodeId>()
Expand Down Expand Up @@ -1958,6 +1989,8 @@ const deleteNodesActionImpl = (
wallDeletionPlan?: StructurePlan,
) => {
if (get().readOnly) return
if (options?.derivedWriter === DERIVED_WRITER_TOKEN)
noteDerivedNodeWrites(ids.filter((id) => get().nodes[id]).map((id) => ({ id })))
let deletedIds = new Set<AnyNodeId>()
let parentsToMarkDirty = new Set<AnyNodeId>()
let nodesToMarkDirty = new Set<AnyNodeId>()
Expand Down
Loading
Loading