diff --git a/packages/core/src/agent-operations/scene-view.test.ts b/packages/core/src/agent-operations/scene-view.test.ts index 978699d043..b4b70b2e2f 100644 --- a/packages/core/src/agent-operations/scene-view.test.ts +++ b/packages/core/src/agent-operations/scene-view.test.ts @@ -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() }) }) diff --git a/packages/core/src/agent-operations/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts index cb2bdf8ae9..c4cbbab8e5 100644 --- a/packages/core/src/agent-operations/scene-view.ts +++ b/packages/core/src/agent-operations/scene-view.ts @@ -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>, 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) } /** diff --git a/packages/core/src/lib/derived-commit-nodes.test.ts b/packages/core/src/lib/derived-commit-nodes.test.ts new file mode 100644 index 0000000000..17338a9f27 --- /dev/null +++ b/packages/core/src/lib/derived-commit-nodes.test.ts @@ -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, + 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, + 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 | 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') + }) +}) diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 64a34c3d37..cb8d9acafc 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -173,7 +173,9 @@ export type { RendererSource, RevealClears, RevealConfig, + RevealLayout, RevealPhase, + RevealPlanPoint, RevealStyle, RoofAccessoryConfig, RotatableConfig, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index b96c39e270..d57c7750da 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -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>) => 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 @@ -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 diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 242a7566ca..8db316bb92 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -43,6 +43,7 @@ import { resolveMergedWallEndpoints, type WallAttachmentUpdate, } from '../../systems/wall/wall-merge' +import { areSemanticValuesEqual } from '../../utils/semantic-equal' import { convertDerivedPlateHoleWrites, DERIVED_WRITER_TOKEN, @@ -55,6 +56,7 @@ import { import { activeSceneCommitNodeIds, addActiveSceneCommitNodeIds, + noteDerivedNodeWrites, runWithSceneCommitNodeIds, } from '../history-control' import type { SceneState } from '../use-scene' @@ -68,6 +70,25 @@ type NodeCreateOp = { } type NodeUpdateOp = { id: AnyNodeId; data: Partial } 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>, + updates: NodeUpdateOp[], +) { + return updates.flatMap(({ id, data }) => { + const node = nodes[id] as Record | undefined + if (!node) return [] + const fields = Object.keys(data).filter( + (field) => !areSemanticValuesEqual(node[field], (data as Record)[field]), + ) + return fields.length ? [{ id, fields }] : [] + }) +} type WallMergePlan = { primaryWallId: AnyNodeId secondaryWallId: AnyNodeId @@ -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() const extraNodesToClearDirty = new Set() set((state) => { @@ -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 = @@ -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() const extraNodesToUpdate = new Set() const extraNodesToDelete = new Set() @@ -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() let parentsToMarkDirty = new Set() let nodesToMarkDirty = new Set() diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts index 847c787fd6..8487bc690e 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -66,6 +66,12 @@ export type SceneCommit = { before: SceneSnapshot current: SceneSnapshot changedNodeIds?: ReadonlySet + /** + * What the structure reconciler wrote while this commit was made (plates, ceilings, side + * classification): derived from an edit, not authored by it. Each node it wrote, with the fields it + * wrote (`'all'` for one it created or deleted). Presentation does not play these as the edit's own. + */ + derivedWrites?: ReadonlyMap | 'all'> } export type SceneCommitListener = (commit: SceneCommit) => void @@ -232,7 +238,27 @@ export function subscribeSceneCommits(listener: SceneCommitListener): () => void } } -function emitSceneCommit(commit: SceneCommit): void { +let derivedWrites = new Map | 'all'>() + +/** + * Records what the derived-writer capability writes, for the commit that follows: each node, and the + * fields written (none given: the node as a whole). + */ +export function noteDerivedNodeWrites( + writes: Iterable<{ id: AnyNodeId; fields?: Iterable }>, +): void { + for (const { id, fields } of writes) { + const noted = derivedWrites.get(id) + if (noted === 'all') continue + if (!fields) derivedWrites.set(id, 'all') + else derivedWrites.set(id, new Set([...(noted ?? []), ...fields])) + } +} + +function emitSceneCommit(authored: SceneCommit): void { + const derived = derivedWrites + derivedWrites = new Map() + const commit = derived.size > 0 ? { ...authored, derivedWrites: derived } : authored for (const listener of [...sceneCommitListeners]) { try { listener(commit) @@ -243,7 +269,10 @@ function emitSceneCommit(commit: SceneCommit): void { } export function notifySceneCommit(commit: SceneCommit): void { - if (areSceneSnapshotsEqual(commit.before, commit.current)) return + if (areSceneSnapshotsEqual(commit.before, commit.current)) { + if (sceneCommitTransactionDepth === 0) derivedWrites = new Map() + return + } const author = commit.author ?? activeSceneCommitAuthor() const contextualCommit: SceneCommit = { ...commit, @@ -291,9 +320,9 @@ function endSceneCommitTransaction(): void { const commit = pendingSceneCommit pendingSceneCommit = null - if (commit && !areSceneSnapshotsEqual(commit.before, commit.current)) { - emitSceneCommit(commit) - } + if (!commit) return + if (areSceneSnapshotsEqual(commit.before, commit.current)) derivedWrites = new Map() + else emitSceneCommit(commit) } export function pauseSceneHistory(sceneStore: TemporalStoreLike): void { diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx index 305532e6d0..e1c366cc86 100644 --- a/packages/editor/src/components/editor/follow-pascal.test.tsx +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -8,12 +8,17 @@ import { LevelNode, loadPlugin, nodeRegistry, + RoofNode, runAsSceneCommitAuthor, + sceneRegistry, useScene, + WallNode, + WindowNode, } from '@pascal-app/core' -import { startConstructionReveal, useViewer } from '@pascal-app/viewer' +import { isNodeRevealing, startConstructionReveal, useViewer } from '@pascal-app/viewer' import { act, create } from '@react-three/test-renderer' -import { BoxGeometry, DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { BoxGeometry, DoubleSide, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { openingExterior, wallExterior } from '../../lib/follow-pascal-exterior.test-fixture' import useEditor from '../../store/use-editor' import useFollowPascal from '../../store/use-follow-pascal' import { usePointAsk } from '../../store/use-point-ask' @@ -53,6 +58,41 @@ beforeEach(async () => { capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 } }, geometry: () => null, } as unknown as AnyNodeDefinition, + { + kind: 'window', + schemaVersion: 1, + schema: WindowNode, + category: 'structure', + defaults: () => ({}), + capabilities: { + reveal: { phase: 'openings', style: 'cut' }, + revealOnEdit: ['position'], + exterior: openingExterior, + }, + geometry: () => null, + } as unknown as AnyNodeDefinition, + { + kind: 'roof', + schemaVersion: 1, + schema: RoofNode, + category: 'structure', + defaults: () => ({}), + capabilities: { reveal: { phase: 'roof', style: 'assemble', height: 4 }, exterior: true }, + geometry: () => null, + } as unknown as AnyNodeDefinition, + { + kind: 'wall', + schemaVersion: 1, + schema: WallNode, + category: 'structure', + defaults: () => ({}), + capabilities: { + reveal: { phase: 'structure', style: 'rise' }, + revealOnEdit: ['start', 'end', 'curveOffset'], + exterior: wallExterior, + }, + geometry: () => null, + } as unknown as AnyNodeDefinition, ], }) useFollowPascal.setState({ on: true, paused: false, building: false }) @@ -68,6 +108,9 @@ afterEach(async () => { await renderer?.unmount() renderer = null restoreRegistry() + for (const id of [...sceneRegistry.nodes.keys()]) + if (id.startsWith('item_') || ['window_east', 'wall_east', 'roof_east'].includes(id)) + sceneRegistry.nodes.delete(id) useScene.setState(previousScene) useViewer.setState(previousViewer) useEditor.setState(previousEditor) @@ -90,6 +133,8 @@ type FollowSetup = { /** Walls all round the target, so no eye sees it clear and Follow falls back to cutaway. */ enclosed?: boolean hideAbove?: boolean + /** How long the reveal plays before the test looks (long enough to play the item in, by default). */ + playMs?: number } async function followItem( @@ -144,7 +189,13 @@ async function followItem( target.id, ), ) - driver!.tick(20) + // The item's renderer has mounted its root, as it does once the reveal lets the item in. + sceneRegistry.nodes.set('item_follow', new Group()) + // The reveal plays whatever floor the person has: its words come as each piece starts. + for (let at = 50; at <= (setup.playMs ?? 6000); at += 50) { + driver!.tick(at) + await renderer!.advanceFrames(1, 0.02) + } await renderer!.advanceFrames(10, 0.1) }) return { building, target } @@ -162,7 +213,8 @@ test.each([ levelId: target.id, }) expect(useViewer.getState().levelMode).toBe('stacked') - expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) + // The finished house is looked at whole: the floors above are shown, until Follow ends. + expect(useViewer.getState().hideLevelsAboveSelection).toBe(false) // The build is over and no turn is running: the person has the floor they had. useFollowPascal.getState().endBuild() expect(useViewer.getState().selection).toMatchObject({ @@ -172,6 +224,36 @@ test.each([ }, ) +// The owner, 15 October: a person on the ground floor with the floors above hidden did not see the +// animation of a build on the floor above. With Follow off the build still plays on show, and the +// person's own floor is not touched. +test('with Follow off, a build on the floor above plays on show and the person keeps their floor', async () => { + useFollowPascal.setState({ on: false }) + const { target } = await followItem(false, 'stacked', { playMs: 200 }) + expect(useViewer.getState().selection.levelId).toBe('level_ground') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) + expect(useViewer.getState().levelsRevealing).toEqual([target.id]) + expect(poses.length).toBe(0) + await act(async () => { + for (let at = 250; at <= 9000; at += 50) driver!.tick(at) + }) + expect(useViewer.getState().levelsRevealing).toEqual([]) + expect(useViewer.getState().selection.levelId).toBe('level_ground') +}) + +// The build is finished and the camera pulls back to the whole house: the floors above the one it +// worked on are not hidden then, or the finished house would be shown without its upper floors. +test('the pull-back to the finished house shows the floors above, and the person gets their display back', async () => { + usePointAsk.getState().setBusy(true) + await followItem(false, 'stacked') + expect(useViewer.getState().selection.levelId).toBe('level_upper') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(false) + + turnOver() + expect(useViewer.getState().selection.levelId).toBe('level_ground') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) +}) + test('an obstacle appearing after arrival causes one calm flight to a clear view', async () => { await followItem() await renderer!.advanceFrames(120, 0.05) @@ -246,12 +328,16 @@ const ENDS: { name: string; end: () => unknown }[] = [ ] // The person is on the upper floor in the solo display, with the floors above not hidden; the build -// is on the ground floor, which is not shown, so the reveal speaks at once. +// is on the ground floor, which the solo display hides: it plays on show, and Follow takes the floor. test.each(ENDS)( 'when $name, the cutaway, the floor and the hidden floors are put back', async ({ end }) => { usePointAsk.getState().setBusy(true) - const { building } = await followItem(true, 'solo', { enclosed: true, hideAbove: false }) + const { building } = await followItem(true, 'solo', { + enclosed: true, + hideAbove: false, + playMs: 1500, + }) expect(useViewer.getState().wallMode).toBe('cutaway') expect(useViewer.getState().levelMode).toBe('stacked') expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) @@ -366,3 +452,343 @@ test('without a host that says a turn is running, the build being over ends it', expect(useViewer.getState().wallMode).toBe('up') expect(useViewer.getState().selection.levelId).toBe('level_ground') }) + +// The owner, 15 October: the agent bent an exterior wall and moved a window, and the cutaway hid +// exactly what moved. A change seen from outside is looked at from outdoors, with the walls up; an +// interior one keeps the cutaway; a commit with one of each takes the outside view. What the person +// had is theirs after. +async function followMoves(setup: { + moveWindow?: boolean + moveSofa?: boolean + bendWall?: boolean + personWall: 'up' | 'cutaway' +}) { + const building = BuildingNode.parse({ id: 'building_follow' }) + const level = LevelNode.parse({ id: 'level_ground', parentId: building.id, level: 0 }) + const wall = WallNode.parse({ + id: 'wall_east', + parentId: level.id, + start: [4, 0], + end: [4, 4], + frontSide: 'interior', + backSide: 'exterior', + }) + const window = WindowNode.parse({ + id: 'window_east', + parentId: wall.id, + wallId: wall.id, + position: [1, 1.2, 0], + }) + const sofa = ItemNode.parse({ + id: 'item_sofa', + parentId: level.id, + position: [1, 0, 1], + asset: { + id: 'sofa', + category: 'furniture', + name: 'Sofa', + thumbnail: '', + src: '/sofa.glb', + dimensions: [2, 1, 1], + }, + }) + building.children = [level.id] + level.children = [wall.id, sofa.id] + wall.children = [window.id] + useScene + .getState() + .setScene( + Object.fromEntries([building, level, wall, window, sofa].map((node) => [node.id, node])), + [building.id], + ) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + useViewer.setState({ levelMode: 'stacked', hideLevelsAboveSelection: false }) + useViewer.getState().setWallMode(setup.personWall) + renderer = await create( + <> + + + , + ) + // Walls all round: no eye sees the target, so an interior step falls back to the cutaway. + shell = new Mesh(new BoxGeometry(6, 6, 6), new MeshBasicMaterial({ side: DoubleSide })) + shell.position.set(2, 0.5, 2) + renderer.scene.instance.getObjectByName('scene-renderer')!.add(shell) + shell.updateWorldMatrix(true, true) + for (const id of ['window_east', 'item_sofa', 'wall_east']) + sceneRegistry.nodes.set(id, new Group()) + driver = startConstructionReveal({ reveals: (commit) => commit.author === 'agent' }) + usePointAsk.getState().setBusy(true) + await act(async () => { + runAsSceneCommitAuthor('agent', () => { + if (setup.moveWindow) + useScene.getState().updateNode('window_east', { position: [2.5, 1.2, 0] } as never) + if (setup.moveSofa) + useScene.getState().updateNode('item_sofa', { position: [2.5, 0, 2.5] } as never) + if (setup.bendWall) useScene.getState().updateNode('wall_east', { curveOffset: 0.6 } as never) + }) + for (let at = 50; at <= 4000; at += 50) { + driver!.tick(at) + await renderer!.advanceFrames(1, 0.02) + } + await renderer!.advanceFrames(10, 0.1) + }) +} + +test('a window moved on an exterior wall is looked at from outside, and the person keeps their cutaway', async () => { + await followMoves({ moveWindow: true, personWall: 'cutaway' }) + expect(poses.length).toBeGreaterThan(0) + // The cutaway the person had is off while the change plays: it would hide the very wall. + expect(useViewer.getState().wallMode).toBe('up') + turnOver() + expect(useViewer.getState().wallMode).toBe('cutaway') +}) + +test('an exterior wall bent is looked at from outside too', async () => { + await followMoves({ bendWall: true, personWall: 'cutaway' }) + expect(useViewer.getState().wallMode).toBe('up') + turnOver() + expect(useViewer.getState().wallMode).toBe('cutaway') +}) + +test('a sofa moved inside is looked at through the cutaway, and the person gets their walls back', async () => { + await followMoves({ moveSofa: true, personWall: 'up' }) + expect(poses.length).toBeGreaterThan(0) + expect(useViewer.getState().wallMode).toBe('cutaway') + turnOver() + expect(useViewer.getState().wallMode).toBe('up') +}) + +test('a commit with a window and a sofa in it takes the outside view', async () => { + await followMoves({ moveWindow: true, moveSofa: true, personWall: 'cutaway' }) + expect(useViewer.getState().wallMode).toBe('up') + turnOver() + expect(useViewer.getState().wallMode).toBe('cutaway') +}) + +// The owner, 15 October: the outside view is for edits and moves only. A build is followed as it +// always was: the cutaway once, when no eye sees the target, and never the walls up in between. +test.each([ + { person: 'up' as const, written: ['cutaway'] as string[] }, + // The person already cuts the walls away: a build never puts them up in between. + { person: 'cutaway' as const, written: [] as string[] }, +])( + 'a full build with Follow on (structure, openings, furnishing, roof) keeps the single cutaway, from $person', + async ({ person, written: expected }) => { + const building = BuildingNode.parse({ id: 'building_follow' }) + const level = LevelNode.parse({ id: 'level_ground', parentId: building.id, level: 0 }) + building.children = [level.id] + useScene + .getState() + .setScene(Object.fromEntries([building, level].map((node) => [node.id, node])), [building.id]) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + useViewer.setState({ levelMode: 'stacked', hideLevelsAboveSelection: false }) + useViewer.getState().setWallMode(person) + renderer = await create( + <> + + + , + ) + shell = new Mesh(new BoxGeometry(6, 6, 6), new MeshBasicMaterial({ side: DoubleSide })) + shell.position.set(2, 0.5, 2) + renderer.scene.instance.getObjectByName('scene-renderer')!.add(shell) + shell.updateWorldMatrix(true, true) + const written: string[] = [] + const stop = useViewer.subscribe((state, before) => { + if (state.wallMode !== before.wallMode) written.push(state.wallMode) + }) + driver = startConstructionReveal({ reveals: (commit) => commit.author === 'agent' }) + usePointAsk.getState().setBusy(true) + const wall = WallNode.parse({ + id: 'wall_east', + parentId: level.id, + start: [4, 0], + end: [4, 4], + frontSide: 'interior', + backSide: 'exterior', + }) + const window = WindowNode.parse({ + id: 'window_east', + parentId: wall.id, + wallId: wall.id, + position: [1, 1.2, 0], + }) + const sofa = ItemNode.parse({ + id: 'item_sofa', + parentId: level.id, + position: [1, 0, 1], + asset: { + id: 'sofa', + category: 'furniture', + name: 'Sofa', + thumbnail: '', + src: '/sofa.glb', + dimensions: [2, 1, 1], + }, + }) + const roof = RoofNode.parse({ id: 'roof_east', parentId: level.id }) + await act(async () => { + runAsSceneCommitAuthor('agent', () => { + useScene.getState().createNode(wall, level.id) + useScene.getState().createNode(window, wall.id) + useScene.getState().createNode(sofa, level.id) + useScene.getState().createNode(roof, level.id) + }) + for (const id of ['wall_east', 'window_east', 'item_sofa', 'roof_east']) + sceneRegistry.nodes.set(id, new Group()) + for (let at = 50; at <= 20000; at += 50) { + driver!.tick(at) + await renderer!.advanceFrames(1, 0.02) + } + await renderer!.advanceFrames(10, 0.1) + }) + stop() + // The build was followed (the camera moved) and the walls were cut away once, never put up between. + expect(poses.length).toBeGreaterThan(0) + expect(written).toEqual(expected) + expect(useViewer.getState().wallMode).toBe('cutaway') + turnOver() + expect(useViewer.getState().wallMode).toBe(person) + }, +) + +// A commit's outside view is its own: an exterior edit another commit queued behind a build, or an +// exterior commit before an interior one, does not take the build's walls up between its steps. +async function followBuildAmid(setup: { + first: 'build' | 'exterior' + next: 'exterior' | 'build' + /** When the second commit lands, on the reveal clock; with the first when absent. */ + nextAtMs?: number +}) { + const building = BuildingNode.parse({ id: 'building_follow' }) + const level = LevelNode.parse({ id: 'level_ground', parentId: building.id, level: 0 }) + const wall = WallNode.parse({ + id: 'wall_east', + parentId: level.id, + start: [4, 0], + end: [4, 4], + frontSide: 'interior', + backSide: 'exterior', + }) + const window = WindowNode.parse({ + id: 'window_east', + parentId: wall.id, + wallId: wall.id, + position: [1, 1.2, 0], + }) + const resting = ItemNode.parse({ + id: 'item_sofa_resting', + parentId: level.id, + position: [3, 0, 3], + asset: { + id: 'sofa', + category: 'furniture', + name: 'Sofa', + thumbnail: '', + src: '/sofa.glb', + dimensions: [2, 1, 1], + }, + }) + building.children = [level.id] + level.children = [wall.id, resting.id] + wall.children = [window.id] + useScene + .getState() + .setScene( + Object.fromEntries([building, level, wall, window, resting].map((node) => [node.id, node])), + [building.id], + ) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + useViewer.setState({ levelMode: 'stacked', hideLevelsAboveSelection: false }) + useViewer.getState().setWallMode('cutaway') + renderer = await create( + <> + + + , + ) + shell = new Mesh(new BoxGeometry(6, 6, 6), new MeshBasicMaterial({ side: DoubleSide })) + shell.position.set(2, 0.5, 2) + renderer.scene.instance.getObjectByName('scene-renderer')!.add(shell) + shell.updateWorldMatrix(true, true) + for (const id of ['window_east', 'wall_east', resting.id]) + sceneRegistry.nodes.set(id, new Group()) + const sofa = ItemNode.parse({ + id: 'item_sofa', + parentId: level.id, + position: [1, 0, 1], + asset: { + id: 'sofa', + category: 'furniture', + name: 'Sofa', + thumbnail: '', + src: '/sofa.glb', + dimensions: [2, 1, 1], + }, + }) + // Enough furniture that the build plays for seconds, in several steps. + const buildSofa = () => { + const pieces = Array.from({ length: 12 }, (_, index) => + ItemNode.parse({ + ...sofa, + id: `item_sofa${index === 0 ? '' : index}`, + position: [index * 0.3, 0, 1 + (index % 3)], + }), + ) + for (const piece of pieces) { + useScene.getState().createNode(piece, level.id) + sceneRegistry.nodes.set(piece.id, new Group()) + } + } + // A commit with a window and a sofa in it: the window takes it outdoors. + const moveWindow = () => { + useScene.getState().updateNode('window_east', { position: [2.5, 1.2, 0] } as never) + useScene.getState().updateNode(resting.id, { position: [2.5, 0, 2.5] } as never) + } + const playing = () => + Array.from({ length: 12 }, (_, i) => `item_sofa${i === 0 ? '' : i}`).some((id) => + isNodeRevealing(id), + ) + const upDuringBuild: number[] = [] + let at = 0 + const stop = useViewer.subscribe((state, before) => { + if (state.wallMode === 'up' && before.wallMode !== 'up' && playing()) upDuringBuild.push(at) + }) + driver = startConstructionReveal({ reveals: (commit) => commit.author === 'agent' }) + usePointAsk.getState().setBusy(true) + await act(async () => { + const commit = (kind: 'build' | 'exterior') => + runAsSceneCommitAuthor('agent', kind === 'build' ? buildSofa : moveWindow) + commit(setup.first) + if (setup.nextAtMs === undefined) commit(setup.next) + for (at = 50; at <= 12000; at += 50) { + if (at === setup.nextAtMs) commit(setup.next) + driver!.tick(at) + await renderer!.advanceFrames(1, 0.02) + } + await renderer!.advanceFrames(10, 0.1) + }) + stop() + return { upDuringBuild } +} + +test('an exterior edit queued behind a build does not put the walls up while the build plays', async () => { + const { upDuringBuild } = await followBuildAmid({ first: 'build', next: 'exterior' }) + expect(poses.length).toBeGreaterThan(0) + expect(upDuringBuild).toEqual([]) + // The edit itself is looked at from outside once it plays. + expect(useViewer.getState().wallMode).toBe('up') +}) + +test('an exterior edit that has played does not take the next build outdoors', async () => { + const { upDuringBuild } = await followBuildAmid({ + first: 'exterior', + next: 'build', + nextAtMs: 3000, + }) + expect(poses.length).toBeGreaterThan(0) + expect(upDuringBuild).toEqual([]) + // The walls the edit put up are cut away again for the build, once. + expect(useViewer.getState().wallMode).toBe('cutaway') +}) diff --git a/packages/editor/src/components/editor/follow-pascal.tsx b/packages/editor/src/components/editor/follow-pascal.tsx index 84d3100866..f7c3ac0656 100644 --- a/packages/editor/src/components/editor/follow-pascal.tsx +++ b/packages/editor/src/components/editor/follow-pascal.tsx @@ -11,7 +11,12 @@ import { useScene, type ZoneNode, } from '@pascal-app/core' -import { type RevealEvent, subscribeRevealEvents, useViewer } from '@pascal-app/viewer' +import { + type RevealEvent, + revealChangedNodeIds, + subscribeRevealEvents, + useViewer, +} from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import { Vector3 } from 'three' @@ -23,6 +28,7 @@ import { OrbitSpring, type OrbitValue, } from '../../lib/follow-pascal' +import { anyExterior } from '../../lib/follow-pascal-exterior' import { FollowPresentation, type PresentationPort } from '../../lib/follow-pascal-presentation' import { FollowSight } from '../../lib/follow-pascal-sight' import { wantFromEvent } from '../../lib/follow-pascal-targets' @@ -38,9 +44,12 @@ import { usePointAsk } from '../../store/use-point-ask' * person resumes it from the pill, or the next build takes it again. Under reduced motion it cuts. * * To see a build it changes the presentation: it cuts the walls away when no eye sees the target, - * selects the floor being built and hides the floors above it. When Follow ends (switched off, the - * person takes the camera, the build is over and no turn is running) it puts back what it changed, - * and leaves what the person changed since (`FollowPresentation`). + * selects the floor being built and hides the floors above it. An edit or a move seen from outside + * (an exterior wall bent, a window moved on one, a roof, the ground) is looked at from outdoors with + * the walls up instead: a cutaway would hide the very wall that moves. A commit with one such change + * in it takes the outside view for all of it. A build is followed as it always was. When Follow ends (switched off, the person takes the + * camera, the build is over and no turn is running) it puts back what it changed, and leaves what + * the person changed since (`FollowPresentation`). */ /** A build is over this long after its last word; the pill goes with it. */ @@ -153,10 +162,20 @@ export function FollowPascal() { } } + /** The build in hand has a change seen from outside in it: all of it is looked at from outdoors. */ + const outsideBuild = useRef(false) + /** The last word was that the play is over: the next edit is another commit's. */ + const played = useRef(false) + const unobstructed = (want: FollowWant, goal: OrbitValue): OrbitValue => { sight.refresh(want) const clear = clearFrame(goal, (pose) => sight.clear(pose)) if (clear) return clear + // Seen from outside the walls stay up: a cutaway would take the wall that changes away. + if (want.exterior || outsideBuild.current) { + presentation.wall('up') + return goal + } presentation.wall('cutaway') return goal } @@ -187,7 +206,13 @@ export function FollowPascal() { }) presentation.levels('stacked') presentation.hideAbove(true) + } else if (want.kind === 'group' && !want.levelId) { + // The finished house is looked at whole: its upper floors are not hidden. + presentation.levels('stacked') + presentation.hideAbove(false) } + // A change seen from outside: the walls up for it, even if an earlier step cut them away. + if (want.exterior || outsideBuild.current) presentation.wall('up') if (!spring.current) { spring.current = new OrbitSpring({ x: 0, y: 1, z: 0, r: 20, az: 0.6, el: 0.5 }) synced.current = false @@ -215,7 +240,20 @@ export function FollowPascal() { store.beginBuild() follower.current.reset() synced.current = false + outsideBuild.current = false + } + // An edit or a move is read before its first piece plays: one change seen from outside takes the + // whole commit outdoors, until the play is over. What is waiting in the queue belongs to a later + // commit, so a build, a deletion, or the edit that follows a finished play never inherits it. + const edit = 'changed' in event && event.changed === true + if (!edit) outsideBuild.current = false + else { + if (played.current) outsideBuild.current = false + const changed = outsideBuild.current ? [] : revealChangedNodeIds() + if (changed.length && anyExterior(useScene.getState().nodes, changed)) + outsideBuild.current = true } + played.current = event.type === 'complete' over.current = setTimeout(() => useFollowPascal.getState().endBuild(), BUILD_OVER_MS) if (event.type === 'complete') return const want = wantFromEvent(event, useScene.getState().nodes, clock.current) diff --git a/packages/editor/src/components/editor/views/plugins-view.tsx b/packages/editor/src/components/editor/views/plugins-view.tsx index 63cd26c529..a3d4779b65 100644 --- a/packages/editor/src/components/editor/views/plugins-view.tsx +++ b/packages/editor/src/components/editor/views/plugins-view.tsx @@ -30,6 +30,7 @@ import { openSidebarPanel } from '../../../lib/sidebar-panel' import { cn } from '../../../lib/utils' import useEditor from '../../../store/use-editor' import { IconRefGlyph } from '../../ui/icon-ref' +import { Badge } from '../../ui/primitives/badge' import { Button } from '../../ui/primitives/button' import { PLUGINS_VIEW_ID } from './use-editor-views' @@ -101,14 +102,6 @@ function PluginIcon({ panel, size }: { panel: EditorHostPanel; size: 'md' | 'lg' ) } -function Badge({ label }: { label: string }) { - return ( - - {label} - - ) -} - function StatusLine({ entry, projectName }: { entry: PluginEntry; projectName?: string }) { return ( diff --git a/packages/editor/src/components/ui/controls/segmented-control.tsx b/packages/editor/src/components/ui/controls/segmented-control.tsx index fa43f725df..33b31722c4 100644 --- a/packages/editor/src/components/ui/controls/segmented-control.tsx +++ b/packages/editor/src/components/ui/controls/segmented-control.tsx @@ -5,6 +5,10 @@ import { cn } from '../../../lib/utils' interface SegmentedControlProps { value: T onChange: (value: T) => void + /** + * `disabled` greys one option and ignores its click; it stays hoverable, so a tooltip in its + * `label` can say why. + */ options: { label: React.ReactNode; value: T; disabled?: boolean }[] className?: string disabled?: boolean @@ -32,6 +36,7 @@ export function SegmentedControl({ const isDisabled = disabled || option.disabled === true return ( ).)*${label}(?:(?!).)*`).exec( + markup, + ) + return match?.[0] ?? '' +} + +describe('the levels the control offers', () => { + test('without the plugin, Framing is disabled and its tip says it needs the Architect plugin', () => { + const framingOption = constructionLevelOptions(false).find((o) => o.value === 'framing')! + expect(framingOption.disabled).toBe(true) + expect(framingOption.title).toBe(FRAMING_NEEDS_ARCHITECT) + expect(FRAMING_NEEDS_ARCHITECT).toBe('Framing needs the Architect plugin.') + for (const option of constructionLevelOptions(false).filter((o) => o.value !== 'framing')) + expect(option.disabled).toBe(false) + }) + + test('with the plugin, every level is on offer', () => { + expect(constructionLevelOptions(true).every((option) => option.disabled === false)).toBe(true) + }) +}) + +describe('the construction animation control, drawn', () => { + test('without the plugin: Framing is greyed out and Full is the level that plays', () => { + project({ installed: false }) + const markup = renderToStaticMarkup() + expect(optionOf(markup, 'Framing')).toContain('aria-disabled="true"') + expect(optionOf(markup, 'Full')).toContain('aria-pressed="true"') + expect(optionOf(markup, 'Full')).not.toContain('aria-disabled') + }) + + test('with the plugin and no choice of the person’s own: Framing plays, nothing is greyed', () => { + project({ installed: true }) + const markup = renderToStaticMarkup() + expect(optionOf(markup, 'Framing')).toContain('aria-pressed="true"') + expect(markup).not.toContain('aria-disabled') + }) + + test('with the plugin and a choice of Full: Full is kept', () => { + project({ installed: true, level: 'full', chosen: true }) + const markup = renderToStaticMarkup() + expect(optionOf(markup, 'Full')).toContain('aria-pressed="true"') + expect(optionOf(markup, 'Framing')).toContain('aria-pressed="false"') + }) + + test('a saved choice of Framing falls back to Full while the plugin is gone, greyed out', () => { + project({ installed: false, level: 'framing', chosen: true }) + const markup = renderToStaticMarkup() + expect(optionOf(markup, 'Full')).toContain('aria-pressed="true"') + expect(optionOf(markup, 'Framing')).toContain('aria-disabled="true"') + expect(optionOf(markup, 'Framing')).toContain('aria-pressed="false"') + }) +}) + +describe('clicking the level that plays', () => { + // A saved Framing plays as Full while the plugin is gone; the control shows Full pressed, and a click on + // the pressed option is still a click. It must not turn the kept Framing into a chosen Full. + const click = (level: ConstructionRevealLevel, installed: boolean) => + chooseConstructionLevel(level, installed) + + beforeEach(() => useConstructionReveal.setState({ level: 'framing', chosen: true })) + afterEach(() => useConstructionReveal.setState({ level: 'full', chosen: false })) + + test('the Full shown for a saved Framing keeps the Framing when it is clicked, and it plays again with the plugin', () => { + click('full', false) + expect(useConstructionReveal.getState()).toMatchObject({ level: 'framing', chosen: true }) + expect(resolveConstructionLevel(useConstructionReveal.getState(), true)).toBe('framing') + }) + + test('another level is a choice and replaces it', () => { + click('simple', false) + expect(useConstructionReveal.getState()).toMatchObject({ level: 'simple', chosen: true }) + }) + + test('with no choice of the person’s own, a click on the level that plays is a choice of it', () => { + useConstructionReveal.setState({ level: 'full', chosen: false }) + click('full', false) + expect(useConstructionReveal.getState()).toMatchObject({ level: 'full', chosen: true }) + }) +}) + +describe('the Pro badge', () => { + // The plugin that draws the framing is a Pro one: the option carries the plugin manager's mark, + // greyed out or not, and no other level does. + test('Framing carries it, greyed out or on offer; the other levels do not', () => { + for (const installed of [false, true]) { + project({ installed }) + const markup = renderToStaticMarkup() + expect(optionOf(markup, 'Framing')).toMatch(/>ProPro { + test('offers the five steps and shows the saved one', () => { + project({ installed: true, speed: 1.5 }) + const markup = renderToStaticMarkup() + for (const label of ['×0.5', '×0.75', '×1', '×1.5', '×2']) expect(markup).toContain(label) + expect(optionOf(markup, '×1.5')).toContain('aria-pressed="true"') + expect(optionOf(markup, '×0.5')).toContain('aria-pressed="false"') + }) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx index f14a44df51..8ae4fe6098 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx @@ -1,10 +1,19 @@ 'use client' import type { ConstructionRevealLevel } from '@pascal-app/viewer' -import useConstructionReveal from '../../../../../store/use-construction-reveal' +import { + chooseConstructionLevel, + useConstructionRevealLevel, + useFramingAvailable, +} from '../../../../../store/use-construction-reveal' import { SegmentedControl } from '../../../controls/segmented-control' +import { Badge } from '../../../primitives/badge' +import { Tooltip, TooltipContent, TooltipTrigger } from '../../../primitives/tooltip' -const OPTIONS: { label: string; value: ConstructionRevealLevel; title: string }[] = [ +/** Why Framing is greyed out. */ +export const FRAMING_NEEDS_ARCHITECT = 'Framing needs the Architect plugin.' + +const OPTIONS: { label: string; value: ConstructionRevealLevel; title: string; badge?: string }[] = [ { label: 'Off', value: 'off', title: 'What the AI builds appears at once.' }, { label: 'Simple', @@ -20,14 +29,52 @@ const OPTIONS: { label: string; value: ConstructionRevealLevel; title: string }[ label: 'Framing', value: 'framing', title: 'Full, plus the framing members dropping in first, where the framing plugin draws them.', + // The plugin that draws the framing is a Pro one. + badge: 'Pro', }, ] +/** + * The four levels as the control offers them: Framing is greyed out, with the reason as its tip, + * where the project has no plugin that draws the framing. + */ +export function constructionLevelOptions(framingAvailable: boolean) { + return OPTIONS.map((option) => + option.value === 'framing' && !framingAvailable + ? { ...option, title: FRAMING_NEEDS_ARCHITECT, disabled: true } + : { ...option, disabled: false }, + ) +} + /** How much of an AI build plays as a construction. */ export function ConstructionAnimationSetting() { - const level = useConstructionReveal((state) => state.level) - const setLevel = useConstructionReveal((state) => state.setLevel) + // What plays: the person's choice, or Framing where the framing plugin is installed and Full where it is not. + const level = useConstructionRevealLevel() + const framingAvailable = useFramingAvailable() const current = OPTIONS.find((option) => option.value === level) + const options = constructionLevelOptions(framingAvailable).map((option) => { + const label = option.badge ? ( + + {option.label} + + + ) : ( + option.label + ) + if (!option.disabled) return { ...option, label } + return { + ...option, + title: undefined, + label: ( + + + {label} + + {option.title} + + ), + } + }) return (
@@ -36,8 +83,8 @@ export function ConstructionAnimationSetting() {
chooseConstructionLevel(next, framingAvailable)} + options={options} value={level} />
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index fa7846c98d..c3267bd531 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -79,6 +79,7 @@ import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store import useFloorplanMode from './../../../../../store/use-floorplan-mode' import { type SendToAppStep, useSendToApp } from './../../../../../store/use-send-to-app' import { AudioSettingsDialog } from './audio-settings-dialog' +import { AnimationSpeedSetting } from './animation-speed-setting' import { ConstructionAnimationSetting } from './construction-animation-setting' import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog' import { LoadBuildDialog, type PendingImport } from './load-build-dialog' @@ -1272,6 +1273,7 @@ export function SettingsPanel({ title="Preferences" > + {preferences}
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index f3c74be7e7..44c1d1b87e 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -901,6 +901,12 @@ export { type CommandAction, useCommandRegistry } from './store/use-command-regi export { CONSTRUCTION_REVEAL_LEVELS, default as useConstructionReveal, + FRAMING_KIND, + getConstructionRevealLevel, + isFramingAvailable, + resolveConstructionLevel, + useConstructionRevealLevel, + useFramingAvailable, } from './store/use-construction-reveal' export { CORNER_BLOCK_NOTES, diff --git a/packages/editor/src/lib/follow-pascal-exterior.test-fixture.ts b/packages/editor/src/lib/follow-pascal-exterior.test-fixture.ts new file mode 100644 index 0000000000..cf69b652e8 --- /dev/null +++ b/packages/editor/src/lib/follow-pascal-exterior.test-fixture.ts @@ -0,0 +1,17 @@ +import type { AnyNode } from '@pascal-app/core' + +// What the nodes package declares as `capabilities.exterior` for its wall, door and window, written +// out for the tests that register their own throw-away kinds (`packages/editor` does not import +// `@pascal-app/nodes`). + +/** A wall with an outdoor face. */ +export const wallExterior = (node: AnyNode) => + node.type === 'wall' && (node.frontSide === 'exterior' || node.backSide === 'exterior') + +/** An opening in a roof segment or in such a wall. */ +export const openingExterior = (node: AnyNode, nodes: Readonly>) => { + if (node.type !== 'door' && node.type !== 'window') return false + if (node.roofSegmentId) return true + const host = nodes[node.wallId ?? node.parentId ?? ''] + return host ? wallExterior(host) : false +} diff --git a/packages/editor/src/lib/follow-pascal-exterior.test.ts b/packages/editor/src/lib/follow-pascal-exterior.test.ts new file mode 100644 index 0000000000..b882d80d5c --- /dev/null +++ b/packages/editor/src/lib/follow-pascal-exterior.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + BuildingNode, + type Capabilities, + DoorNode, + ItemNode, + LevelNode, + loadPlugin, + nodeRegistry, + RoofNode, + RoofSegmentNode, + SiteNode, + WallNode, +} from '@pascal-app/core' +import { anyExterior, isExteriorNode } from './follow-pascal-exterior' +import { openingExterior, wallExterior } from './follow-pascal-exterior.test-fixture' + +// The kinds are the nodes package's (it declares `capabilities.exterior` on them); fixtures of the +// same kind stand in for them here, and plugin-style kinds that declare the same fields are held to +// the same rules. +const kind = (type: string, schema: unknown, exterior?: Capabilities['exterior']) => + ({ + kind: type, + schemaVersion: 1, + schema, + category: 'structure', + defaults: () => ({}), + capabilities: exterior === undefined ? {} : { exterior }, + geometry: () => null, + }) as unknown as AnyNodeDefinition + +let restoreRegistry: () => void = () => {} +beforeAll(async () => { + restoreRegistry = nodeRegistry._snapshot() + nodeRegistry._reset() + await loadPlugin({ + id: 'fixture:exterior', + apiVersion: 1, + nodes: [ + kind('site', SiteNode, true), + kind('building', BuildingNode, false), + kind('level', LevelNode, false), + kind('roof', RoofNode, true), + kind('roof-segment', RoofSegmentNode, true), + kind('wall', WallNode, wallExterior), + kind('door', DoorNode, openingExterior), + kind('item', ItemNode), + // A plugin's own kinds: outdoors, outdoors by a field, and saying nothing. + kind('fixture:deck', ItemNode, true), + kind('fixture:cover', ItemNode, (node) => (node.name === 'seen' ? true : undefined)), + kind('fixture:cable', ItemNode), + ], + }) +}) +afterAll(() => restoreRegistry()) + +function scene() { + const site = SiteNode.parse({ id: 'site_x' }) + const building = BuildingNode.parse({ id: 'building_x', parentId: site.id }) + const level = LevelNode.parse({ id: 'level_x', parentId: building.id, level: 0 }) + const outer = WallNode.parse({ + id: 'wall_outer', + parentId: level.id, + start: [0, 0], + end: [4, 0], + frontSide: 'interior', + backSide: 'exterior', + }) + const inner = WallNode.parse({ + id: 'wall_inner', + parentId: level.id, + start: [0, 2], + end: [4, 2], + frontSide: 'interior', + backSide: 'interior', + }) + const onOuter = DoorNode.parse({ id: 'door_outer', parentId: outer.id, wallId: outer.id }) + const onInner = DoorNode.parse({ id: 'door_inner', parentId: inner.id, wallId: inner.id }) + const sofa = ItemNode.parse({ + id: 'item_sofa', + parentId: level.id, + asset: { + id: 'sofa', + category: 'furniture', + name: 'Sofa', + thumbnail: '', + src: '/sofa.glb', + dimensions: [2, 1, 1], + }, + }) + const bench = ItemNode.parse({ ...sofa, id: 'item_bench', parentId: site.id }) + const segment = RoofSegmentNode.parse({ id: 'rseg_x', parentId: 'roof_x' }) + const roof = RoofNode.parse({ id: 'roof_x', parentId: level.id, children: [segment.id] }) + // Only their kind, name and parent matter here. + const plugin = (id: string, type: string, parentId: string, name?: string) => + ({ id, type, parentId, name }) as unknown as AnyNode + const deck = plugin('deck_x', 'fixture:deck', site.id) + const deckOnLevel = plugin('deck_level', 'fixture:deck', level.id) + const underDeck = plugin('cable_deck', 'fixture:cable', deckOnLevel.id) + const underRoof = plugin('cable_roof', 'fixture:cable', roof.id) + const cableInRoom = plugin('cable_room', 'fixture:cable', level.id) + const seen = plugin('cover_seen', 'fixture:cover', level.id, 'seen') + const unseen = plugin('cover_unseen', 'fixture:cover', level.id) + const coverOnSite = plugin('cover_site', 'fixture:cover', site.id) + return Object.fromEntries( + [ + site, + building, + level, + outer, + inner, + onOuter, + onInner, + sofa, + bench, + roof, + segment, + deck, + deckOnLevel, + underDeck, + underRoof, + cableInRoom, + seen, + unseen, + coverOnSite, + ].map((node) => [node.id, node as AnyNode]), + ) +} + +describe('what a change is seen from', () => { + const nodes = scene() + + test('a wall with an exterior face, and an opening in it, are seen from outside', () => { + expect(isExteriorNode(nodes, 'wall_outer')).toBe(true) + expect(isExteriorNode(nodes, 'door_outer')).toBe(true) + }) + + test('an interior wall, an opening in it and furniture are looked at through a cutaway', () => { + expect(isExteriorNode(nodes, 'wall_inner')).toBe(false) + expect(isExteriorNode(nodes, 'door_inner')).toBe(false) + expect(isExteriorNode(nodes, 'item_sofa')).toBe(false) + }) + + test('a roof and what stands on it, the ground, and furnishing standing in no storey are outside', () => { + for (const id of ['roof_x', 'rseg_x', 'deck_x', 'item_bench']) + expect(isExteriorNode(nodes, id)).toBe(true) + }) + + // A kind that declares what the built-ins declare is treated as the built-in is. + test('a plugin kind is outdoors as its definition says: always, by a field, or by what holds it', () => { + expect(isExteriorNode(nodes, 'deck_level')).toBe(true) + expect(isExteriorNode(nodes, 'cover_seen')).toBe(true) + expect(isExteriorNode(nodes, 'cover_unseen')).toBe(false) + expect(isExteriorNode(nodes, 'cable_deck')).toBe(true) + expect(isExteriorNode(nodes, 'cable_roof')).toBe(true) + expect(isExteriorNode(nodes, 'cable_room')).toBe(false) + expect(isExteriorNode(nodes, 'cover_site')).toBe(true) + }) + + test('a node that is not in the scene is nothing', () => { + expect(isExteriorNode(nodes, 'wall_gone')).toBe(false) + }) + + test('a group is outside when any of its nodes is', () => { + expect(anyExterior(nodes, ['item_sofa', 'door_outer'])).toBe(true) + expect(anyExterior(nodes, ['item_sofa', 'wall_inner'])).toBe(false) + expect(anyExterior(nodes, [])).toBe(false) + }) +}) diff --git a/packages/editor/src/lib/follow-pascal-exterior.ts b/packages/editor/src/lib/follow-pascal-exterior.ts new file mode 100644 index 0000000000..f984a2eaef --- /dev/null +++ b/packages/editor/src/lib/follow-pascal-exterior.ts @@ -0,0 +1,39 @@ +import { type AnyNode, type AnyNodeId, findLevelAncestorId, nodeRegistry } from '@pascal-app/core' + +type Nodes = Readonly> + +/** What a kind says about a node (`capabilities.exterior`): outdoors, never, or nothing. */ +function declaredExterior(node: AnyNode, nodes: Nodes): boolean | undefined { + const exterior = nodeRegistry.get(node.type)?.capabilities?.exterior + return typeof exterior === 'function' ? exterior(node, nodes) : exterior +} + +/** + * Whether a change to this node is seen from outside, as its kind declares (`capabilities.exterior`: + * a roof, a wall with an exterior face and an opening in it, a fence). A node whose kind says nothing + * is outdoors when what holds it is (a chimney's cap), or when it stands in no building's storey + * (outdoor furnishing, plants). A sofa, an interior wall and a finish inside are not: they are looked + * at through a cutaway. + */ +export function isExteriorNode(nodes: Nodes, id: string): boolean { + const node = nodes[id] + if (!node) return false + const own = declaredExterior(node, nodes) + if (own !== undefined) return own + // Standing under a node that is outdoors, up to the first one that never is (a storey). + for ( + let at = node.parentId ? nodes[node.parentId] : undefined, guard = 0; + at && guard < 16; + at = at.parentId ? nodes[at.parentId] : undefined, guard += 1 + ) { + const held = nodeRegistry.get(at.type)?.capabilities?.exterior + if (held === true) return true + if (held === false) break + } + return findLevelAncestorId(id as AnyNodeId, nodes as never) === null +} + +/** Whether any of `ids` is a change seen from outside. */ +export function anyExterior(nodes: Nodes, ids: readonly string[]): boolean { + return ids.some((id) => isExteriorNode(nodes, id)) +} diff --git a/packages/editor/src/lib/follow-pascal-sight.ts b/packages/editor/src/lib/follow-pascal-sight.ts index fd7012c90a..2cbcd59fbe 100644 --- a/packages/editor/src/lib/follow-pascal-sight.ts +++ b/packages/editor/src/lib/follow-pascal-sight.ts @@ -54,7 +54,12 @@ export class FollowSight { ) { const levelId = findLevelAncestorId(id as AnyNodeId, nodes) const level = levelId ? nodes[levelId as AnyNodeId] : undefined - if (level?.type === 'level' && level.level > selected.level) return + if ( + level?.type === 'level' && + level.level > selected.level && + !viewer.levelsRevealing.includes(level.id) + ) + return } } this.meshes.push(mesh) diff --git a/packages/editor/src/lib/follow-pascal-targets.test.ts b/packages/editor/src/lib/follow-pascal-targets.test.ts index 14a8e30605..f034a984b5 100644 --- a/packages/editor/src/lib/follow-pascal-targets.test.ts +++ b/packages/editor/src/lib/follow-pascal-targets.test.ts @@ -13,6 +13,7 @@ import { WallNode, } from '@pascal-app/core' import type { RevealEvent } from '@pascal-app/viewer' +import { openingExterior, wallExterior } from './follow-pascal-exterior.test-fixture' import { wantFromEvent } from './follow-pascal-targets' // What the camera looks at for each thing the reveal says: a floor when its walls rise, a window when @@ -129,6 +130,33 @@ beforeAll(async () => { height: 4, clears: { for: 'furnishing', height: 2.4 }, }, + exterior: true, + }, + geometry: () => null, + } as unknown as AnyNodeDefinition, + { + kind: 'wall', + schemaVersion: 1, + schema: WallNode, + category: 'structure', + defaults: () => ({}), + capabilities: { + reveal: { phase: 'structure', style: 'rise' }, + revealOnEdit: ['start', 'end', 'curveOffset'], + exterior: wallExterior, + }, + geometry: () => null, + } as unknown as AnyNodeDefinition, + { + kind: 'door', + schemaVersion: 1, + schema: DoorNode, + category: 'structure', + defaults: () => ({}), + capabilities: { + reveal: { phase: 'openings', style: 'cut' }, + revealOnEdit: ['position'], + exterior: openingExterior, }, geometry: () => null, } as unknown as AnyNodeDefinition, @@ -149,6 +177,14 @@ describe('what the camera looks at', () => { } }) + test('a wall the agent edited is looked at, close up: it is not a floor being built', () => { + const nodes = house() + const edited = { ...started('wall_0', 'structure', 'rise'), changed: true } as RevealEvent + const want = wantFromEvent(edited, nodes, 0) + expect(want?.kind).toBe('detail') + expect(want?.levelId).toBe('level_h') + }) + test('the walls themselves are not targets: the floor already is', () => { expect(wantFromEvent(started('wall_1', 'structure', 'rise'), house(), 0)).toBeNull() }) @@ -289,3 +325,67 @@ describe('what the camera looks at', () => { expect(wantFromEvent(started('item_gone', 'furnishing'), house(), 0)).toBeNull() }) }) + +// The owner, 15 October: the cutaway hid the exterior wall the agent was bending. A change seen from +// outside is looked at from outdoors, with the walls up; the rest keeps the cutaway. +describe('what is seen from outside', () => { + const changed = (id: string, phase: string, style = 'rise') => + ({ ...started(id, phase, style), changed: true }) as RevealEvent + + test('an exterior wall the agent bent is an outside step, framed from its exterior face', () => { + const want = wantFromEvent(changed('wall_0', 'structure'), house(), 0)! + expect(want.exterior).toBe(true) + // wall_0 runs along +x with its exterior on the back side: it faces -z. + expect(want.normal).toEqual([0, -1]) + }) + + test('a window moved on an exterior wall is an outside step; a chair moved is not', () => { + const nodes = house() + expect(wantFromEvent(changed('door_h', 'openings', 'cut'), nodes, 0)!.exterior).toBe(true) + expect( + wantFromEvent(changed('item_h', 'furnishing', 'drop'), nodes, 0)!.exterior, + ).toBeUndefined() + }) + + test('an interior wall the agent bent keeps the cutaway', () => { + const nodes = house() as Record + nodes.wall_1 = { ...nodes.wall_1, frontSide: 'interior', backSide: 'interior' } + const want = wantFromEvent(changed('wall_1', 'structure'), nodes, 0)! + expect(want.exterior).toBeUndefined() + expect(want.normal).toBeUndefined() + }) + + test('an opening on an interior wall keeps the cutaway', () => { + const nodes = house() as Record + nodes.wall_0 = { ...nodes.wall_0, frontSide: 'interior', backSide: 'interior' } + expect(wantFromEvent(changed('door_h', 'openings', 'cut'), nodes, 0)!.exterior).toBeUndefined() + }) + + // The owner, 15 Oct: the outside view is for edits and moves only; a build is followed as it was. + test('a build says nothing of outside: a roof assembling, an opening coming in, a floor of walls', () => { + const nodes = house() + for (const event of [ + group('start', 'roof', ['roof_h'], 'level_roof'), + group('start', 'openings', ['door_h']), + group('start', 'structure', ['wall_0', 'wall_1']), + started('door_h', 'openings', 'cut'), + started('item_h', 'furnishing'), + { ...group('start', 'roof', ['roof_h'], 'level_roof'), type: 'retract-start' } as RevealEvent, + ]) + expect(wantFromEvent(event, nodes, 0)?.exterior).toBeUndefined() + }) + + test('the same pieces edited or moved are outside steps', () => { + const nodes = house() + const edited = (id: string, phase: string) => + ({ ...started(id, phase), changed: true }) as RevealEvent + expect(wantFromEvent(edited('door_h', 'openings'), nodes, 0)!.exterior).toBe(true) + expect( + wantFromEvent( + { ...group('start', 'roof', ['roof_h'], 'level_roof'), changed: true } as RevealEvent, + nodes, + 0, + )!.exterior, + ).toBe(true) + }) +}) diff --git a/packages/editor/src/lib/follow-pascal-targets.ts b/packages/editor/src/lib/follow-pascal-targets.ts index 9fcc13e2fc..490677e925 100644 --- a/packages/editor/src/lib/follow-pascal-targets.ts +++ b/packages/editor/src/lib/follow-pascal-targets.ts @@ -2,6 +2,7 @@ import { type AnyNode, type AnyNodeId, findLevelAncestorId, nodeRegistry } from import { sceneViewBounds, sceneViewFacing } from '@pascal-app/core/agent-operations' import type { RevealEvent } from '@pascal-app/viewer' import type { Box, FollowWant } from './follow-pascal' +import { anyExterior } from './follow-pascal-exterior' type Nodes = Readonly> @@ -90,6 +91,7 @@ export function wantFromEvent(event: RevealEvent, nodes: Nodes, at: number): Fol const nodeIds = 'id' in event ? [event.id] : 'nodeIds' in event ? event.nodeIds : [] want = { ...want, nodeIds } } + if (want && exteriorStep(event, nodes)) want = { ...want, exterior: true } if (!want || event.type === 'complete' || event.type === 'finale' || event.type === 'settle') return want let level = event.levelId ? nodes[event.levelId] : undefined @@ -101,6 +103,18 @@ export function wantFromEvent(event: RevealEvent, nodes: Nodes, at: number): Fol return level?.type === 'level' ? { ...want, levelId: level.id } : want } +/** + * Whether the step is a change seen from outside, not through a cutaway into a room: a piece the + * agent edited or moved that stands outdoors (a wall with an exterior face, an opening in one, a + * roof, the ground). Only edits and moves: a build is followed as it always was, whatever it builds + * (the owner's call, 15 Oct), and furnishing and interior walls are not outside either. + */ +function exteriorStep(event: RevealEvent, nodes: Nodes): boolean { + if (!('changed' in event) || event.changed !== true) return false + const ids = 'id' in event ? [event.id] : 'nodeIds' in event ? [...event.nodeIds] : [] + return anyExterior(nodes, ids) +} + function targetFromEvent(event: RevealEvent, nodes: Nodes, at: number): FollowWant | null { switch (event.type) { case 'start': { @@ -137,6 +151,14 @@ function targetFromEvent(event: RevealEvent, nodes: Nodes, at: number): FollowWa const eyeAbove = clearanceAbove(nodes, event.id) return { kind: 'furnish', box, ...(eyeAbove === undefined ? {} : { eyeAbove }), at } } + // A piece the agent edited (a wall bent, a slab resized) plays again: the camera goes to it, + // from the side that faces outdoors when it has one. + if (event.changed) { + const box = boxOf(nodes, event.id) + if (!box) return null + const normal = sceneViewFacing(nodes, event.id) + return { kind: 'detail', box, ...(normal ? { normal } : {}), at } + } return null } case 'settle': { diff --git a/packages/editor/src/lib/follow-pascal.test.ts b/packages/editor/src/lib/follow-pascal.test.ts index 4e59881b97..14c045f4fb 100644 --- a/packages/editor/src/lib/follow-pascal.test.ts +++ b/packages/editor/src/lib/follow-pascal.test.ts @@ -225,7 +225,10 @@ describe('keeping the followed target in sight', () => { expect(clear()).toBe(false) useViewer.setState({ hideLevelsAboveSelection: true }) expect(clear()).toBe(true) - useViewer.setState({ hideLevelsAboveSelection: false }) + // The storey a reveal is playing on shows, so what is batched on it stands in the way again. + useViewer.setState({ levelsRevealing: [upper.id] }) + expect(clear()).toBe(false) + useViewer.setState({ levelsRevealing: [], hideLevelsAboveSelection: false }) ;(obstacle.material as MeshBasicMaterial).opacity = 0 expect(clear()).toBe(true) } finally { diff --git a/packages/editor/src/lib/follow-pascal.ts b/packages/editor/src/lib/follow-pascal.ts index a1060d4b42..a4c884c007 100644 --- a/packages/editor/src/lib/follow-pascal.ts +++ b/packages/editor/src/lib/follow-pascal.ts @@ -25,6 +25,11 @@ export type FollowWant = { normal?: [number, number] /** The eye must stand above this height (metres): a roof lifted out of the way is looked down through, not from within. */ eyeAbove?: number + /** + * What it works on is seen from outside (an exterior wall bent, a window moved on one, a roof, the + * ground): the view is from outdoors with no cutaway, whatever hides the target. + */ + exterior?: boolean /** When it was asked for, in seconds on the follower's clock. */ at: number } @@ -84,6 +89,8 @@ export function mergeWants(wants: readonly FollowWant[]): FollowWant { ...(nodeIds.length ? { nodeIds } : {}), ...(normal ? { normal } : {}), ...(eyes.length ? { eyeAbove: Math.max(...eyes) } : {}), + // Seen from outside when any of what lands together is: a mixed step takes the outside view. + ...(wants.some((want) => want.exterior) ? { exterior: true } : {}), at: Math.max(...wants.map((want) => want.at)), } } diff --git a/packages/editor/src/store/use-construction-reveal.test.ts b/packages/editor/src/store/use-construction-reveal.test.ts index e6ab2b7ead..742661eabf 100644 --- a/packages/editor/src/store/use-construction-reveal.test.ts +++ b/packages/editor/src/store/use-construction-reveal.test.ts @@ -1,8 +1,15 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' +import type { AnyNodeDefinition } from '@pascal-app/core' +import { BaseNode, loadPlugin, nodeRegistry, nodeType, objectId, useScene } from '@pascal-app/core' +import { ANIMATION_SPEEDS } from '@pascal-app/viewer' import { createJSONStorage } from 'zustand/middleware' import useConstructionReveal, { CONSTRUCTION_REVEAL_LEVELS, CONSTRUCTION_REVEAL_STORAGE_KEY, + FRAMING_KIND, + getConstructionRevealLevel, + isFramingAvailable, + resolveConstructionLevel, } from './use-construction-reveal' function memoryStorage() { @@ -18,21 +25,24 @@ function memoryStorage() { } afterEach(() => { - useConstructionReveal.setState({ level: 'full' }) + useConstructionReveal.setState({ level: 'full', chosen: false, speed: 1 }) }) describe('the construction animation preference', () => { - test('starts at full and offers four levels', () => { + test('starts at full, with no choice of the person’s own, and offers four levels', () => { expect(useConstructionReveal.getState().level).toBe('full') + expect(useConstructionReveal.getState().chosen).toBe(false) expect(CONSTRUCTION_REVEAL_LEVELS).toEqual(['off', 'simple', 'full', 'framing']) }) - test('persists the chosen level under its own key', () => { + test('persists the chosen level and the speed under its own key', () => { const { items, storage } = memoryStorage() useConstructionReveal.persist.setOptions({ storage }) useConstructionReveal.getState().setLevel('simple') expect(JSON.parse(items.get(CONSTRUCTION_REVEAL_STORAGE_KEY)!).state).toEqual({ level: 'simple', + chosen: true, + speed: 1, }) expect(CONSTRUCTION_REVEAL_STORAGE_KEY).not.toBe('pascal-audio-settings') }) @@ -49,3 +59,115 @@ describe('the construction animation preference', () => { expect(useConstructionReveal.getState().level).toBe('full') }) }) + +describe('the animation speed preference', () => { + test('starts at ×1', () => { + expect(useConstructionReveal.getState().speed).toBe(1) + }) + + test.each([...ANIMATION_SPEEDS])('×%d persists and reloads', async (speed) => { + const { items, storage } = memoryStorage() + useConstructionReveal.persist.setOptions({ storage }) + useConstructionReveal.getState().setSpeed(speed) + const saved = items.get(CONSTRUCTION_REVEAL_STORAGE_KEY)! + expect(JSON.parse(saved).state.speed).toBe(speed) + // A new page: the store starts from its defaults and reads what was saved. + useConstructionReveal.setState({ speed: 1 }) + items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, saved) + await useConstructionReveal.persist.rehydrate() + expect(useConstructionReveal.getState().speed).toBe(speed) + }) + + test('a person saved before the setting is at ×1, and a stray value snaps to a step', async () => { + const { items, storage } = memoryStorage() + useConstructionReveal.persist.setOptions({ storage }) + items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { level: 'full' } })) + await useConstructionReveal.persist.rehydrate() + expect(useConstructionReveal.getState().speed).toBe(1) + items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { speed: 1.6 } })) + await useConstructionReveal.persist.rehydrate() + expect(useConstructionReveal.getState().speed).toBe(1.5) + items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { speed: 'fast' } })) + await useConstructionReveal.persist.rehydrate() + expect(useConstructionReveal.getState().speed).toBe(1) + }) +}) + +describe('the level that plays follows the project', () => { + const none = { level: 'full', chosen: false } as const + + test('with no choice of the person’s own: Framing with the framing plugin, Full without', () => { + expect(resolveConstructionLevel(none, true)).toBe('framing') + expect(resolveConstructionLevel(none, false)).toBe('full') + }) + + test('a choice is kept while the plugin stays installed', () => { + for (const level of CONSTRUCTION_REVEAL_LEVELS) + expect(resolveConstructionLevel({ level, chosen: true }, true)).toBe(level) + }) + + test('without the plugin Framing falls back to Full; every other choice stays', () => { + expect(resolveConstructionLevel({ level: 'framing', chosen: true }, false)).toBe('full') + expect(resolveConstructionLevel({ level: 'off', chosen: true }, false)).toBe('off') + expect(resolveConstructionLevel({ level: 'simple', chosen: true }, false)).toBe('simple') + // The saved choice is not erased: the plugin coming back brings Framing back. + expect(resolveConstructionLevel({ level: 'framing', chosen: true }, true)).toBe('framing') + }) + + test('a level saved before the default followed the project is a choice', async () => { + const { items, storage } = memoryStorage() + useConstructionReveal.persist.setOptions({ storage }) + items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { level: 'full' } })) + await useConstructionReveal.persist.rehydrate() + expect(useConstructionReveal.getState().chosen).toBe(true) + items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: {} })) + await useConstructionReveal.persist.rehydrate() + expect(useConstructionReveal.getState().chosen).toBe(false) + }) +}) + +describe('the framing plugin of a project', () => { + const framing = { + kind: FRAMING_KIND, + schemaVersion: 1, + schema: BaseNode.extend({ id: objectId('framing'), type: nodeType(FRAMING_KIND) }), + category: 'structure', + defaults: () => ({}), + capabilities: {}, + geometry: () => null, + } as unknown as AnyNodeDefinition + let restore: () => void = () => {} + const wasInstalled = useScene.getState().installedPlugins + + beforeAll(async () => { + restore = nodeRegistry._snapshot() + nodeRegistry._reset() + await loadPlugin({ id: 'fixture:framing', apiVersion: 1, nodes: [framing] }) + }) + afterAll(() => { + restore() + useScene.setState({ installedPlugins: wasInstalled }) + }) + + test('is available where the plugin that carries the kind is installed', () => { + expect(isFramingAvailable([])).toBe(false) + expect(isFramingAvailable(['fixture:other'])).toBe(false) + expect(isFramingAvailable(['fixture:framing'])).toBe(true) + }) + + test('is not available where no loaded plugin carries the kind at all', () => { + const snapshot = nodeRegistry._snapshot() + nodeRegistry._reset() + expect(isFramingAvailable(['fixture:framing'])).toBe(false) + snapshot() + }) + + test('the level that plays is read from the open project', () => { + useScene.setState({ installedPlugins: [] }) + expect(getConstructionRevealLevel()).toBe('full') + useScene.setState({ installedPlugins: ['fixture:framing'] }) + expect(getConstructionRevealLevel()).toBe('framing') + useConstructionReveal.getState().setLevel('full') + expect(getConstructionRevealLevel()).toBe('full') + }) +}) diff --git a/packages/editor/src/store/use-construction-reveal.ts b/packages/editor/src/store/use-construction-reveal.ts index a66996b804..694eec7d8b 100644 --- a/packages/editor/src/store/use-construction-reveal.ts +++ b/packages/editor/src/store/use-construction-reveal.ts @@ -1,6 +1,12 @@ 'use client' -import type { ConstructionRevealLevel } from '@pascal-app/viewer' +import { isNodeKindEnabled, nodeRegistry, useRegistryVersion, useScene } from '@pascal-app/core' +import { + type AnimationSpeed, + type ConstructionRevealLevel, + DEFAULT_ANIMATION_SPEED, + normalizeAnimationSpeed, +} from '@pascal-app/viewer' import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' @@ -13,6 +19,13 @@ export const CONSTRUCTION_REVEAL_LEVELS: readonly ConstructionRevealLevel[] = [ export const CONSTRUCTION_REVEAL_STORAGE_KEY = 'pascal-construction-reveal' +/** + * The kind the Framing level draws: it plays only in a project where this kind is registered and + * its plugin is installed. Read through the kind, not a plugin id, so it holds wherever the framing + * kinds live (the Bones plugin, or the procedural plugin that carries them). + */ +export const FRAMING_KIND = 'bones:framing' + const DEFAULT_LEVEL: ConstructionRevealLevel = 'full' function normalizeLevel(value: unknown): ConstructionRevealLevel { @@ -29,28 +42,104 @@ const browserStorage = createJSONStorage(() => ({ })) type ConstructionRevealState = { - /** How much of an agent's build plays as a construction; the host passes it to `ConstructionReveal`. */ + /** + * The level the person last chose (see `chosen`). Hosts read the level that plays through + * `useConstructionRevealLevel`, which also knows whether the framing plugin is installed. + */ level: ConstructionRevealLevel + /** Whether the person has chosen a level themselves; until then the default follows the project. */ + chosen: boolean setLevel: (level: ConstructionRevealLevel) => void + /** + * How fast builds play, a multiple of the shipped pace (×0.5 to ×2, higher is faster); the host + * passes it to `ConstructionReveal`. A change applies to the next build, never to one playing. + */ + speed: AnimationSpeed + setSpeed: (speed: AnimationSpeed) => void } -/** The person's construction-animation preference, kept across sessions. */ +/** The person's construction-animation preferences (level and speed), kept across sessions. */ const useConstructionReveal = create()( persist( (set) => ({ level: DEFAULT_LEVEL, - setLevel: (level) => set({ level }), + chosen: false, + setLevel: (level) => set({ level, chosen: true }), + speed: DEFAULT_ANIMATION_SPEED, + setSpeed: (speed) => set({ speed: normalizeAnimationSpeed(speed) }), }), { name: CONSTRUCTION_REVEAL_STORAGE_KEY, storage: browserStorage, - partialize: (state) => ({ level: state.level }), - merge: (persisted, current) => ({ - ...current, - level: normalizeLevel((persisted as { level?: unknown } | undefined)?.level), - }), + partialize: (state) => ({ level: state.level, chosen: state.chosen, speed: state.speed }), + merge: (persisted, current) => { + const saved = persisted as + | { level?: unknown; chosen?: unknown; speed?: unknown } + | undefined + return { + ...current, + level: normalizeLevel(saved?.level), + // A level saved before the default followed the project was the person's own choice. + chosen: typeof saved?.chosen === 'boolean' ? saved.chosen : saved?.level !== undefined, + // A person saved before the setting existed is at ×1; a stray value snaps to a step. + speed: normalizeAnimationSpeed(saved?.speed), + } + }, }, ), ) +/** + * The level that plays. With no choice of the person's own it is Framing where the framing plugin + * is available in the project and Full where it is not; a choice is kept, except that Framing falls + * back to Full while the plugin is missing (the choice comes back with the plugin). + */ +export function resolveConstructionLevel( + saved: Pick, + framingInstalled: boolean, +): ConstructionRevealLevel { + if (!saved.chosen) return framingInstalled ? 'framing' : 'full' + return saved.level === 'framing' && !framingInstalled ? 'full' : saved.level +} + +/** + * A click on a level in the control. The level that plays is not always the saved one (a saved Framing + * plays as Full while the plugin is gone), and a click on that pressed level is not a choice: it keeps + * the saved one, so it plays again with the plugin. + */ +export function chooseConstructionLevel( + level: ConstructionRevealLevel, + framingInstalled: boolean, +): void { + const state = useConstructionReveal.getState() + if (state.chosen && resolveConstructionLevel(state, framingInstalled) === level) return + state.setLevel(level) +} + +/** Whether the project has what draws the framing: the kind is registered and its plugin installed. */ +export function isFramingAvailable(installedPlugins: readonly string[]): boolean { + return nodeRegistry.has(FRAMING_KIND) && isNodeKindEnabled(FRAMING_KIND, installedPlugins) +} + +/** The same, for a component: it re-reads when a plugin registers after the first render. */ +export function useFramingAvailable(): boolean { + useRegistryVersion() + return useScene((state) => isFramingAvailable(state.installedPlugins)) +} + +/** The level that plays now, for a component: the person's choice or the project's default. */ +export function useConstructionRevealLevel(): ConstructionRevealLevel { + const level = useConstructionReveal((state) => state.level) + const chosen = useConstructionReveal((state) => state.chosen) + return resolveConstructionLevel({ level, chosen }, useFramingAvailable()) +} + +/** The level that plays now, read once (a tool's executor, not a component). */ +export function getConstructionRevealLevel(): ConstructionRevealLevel { + return resolveConstructionLevel( + useConstructionReveal.getState(), + isFramingAvailable(useScene.getState().installedPlugins), + ) +} + export default useConstructionReveal diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index 97c2650047..53fd52d3e4 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -188,6 +188,7 @@ export const boxVentDefinition: NodeDefinition = { }, capabilities: { + exterior: true, slots: boxVentSlots, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts index fc76d61ff3..d9233d063c 100644 --- a/packages/nodes/src/building/definition.ts +++ b/packages/nodes/src/building/definition.ts @@ -23,6 +23,7 @@ export const buildingDefinition: NodeDefinition = { }, capabilities: { + exterior: false, // Building is a container — sidebar / building switcher drive // selection, never 3D click. Same reasoning as `level` / `site`. duplicable: false, diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index c0a0ac2417..4f9a0e059d 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -2341,6 +2341,7 @@ export const cabinetDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 }, + revealOnEdit: ['width', 'depth', 'carcassHeight', 'plinthHeight'], surfacePlacement: 'floor-only', selectable: { hitVolume: 'bbox' }, movable: { diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index c8ab0cff83..0129eb6a22 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -157,6 +157,7 @@ export const ceilingDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'roof', style: 'settle', height: 0.08 }, + revealOnEdit: ['polygon', 'holes', 'height'], batchable: surfaceBatchable, selectable: { hitVolume: 'bbox' }, surfaces: { diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index ef7e6f7c19..ea1d9a0135 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -369,6 +369,7 @@ export const chimneyDefinition: NodeDefinition = { }, capabilities: { + exterior: true, selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index febaced790..a24e8d0148 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -386,6 +386,7 @@ export const columnDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'structure', style: 'drop', height: 4 }, + revealOnEdit: ['height', 'radius', 'width', 'depth', 'crossSection', 'shaftProfile', 'style'], batchable: columnBatchable, surfacePlacement: 'floor-only', selectable: { hitVolume: 'bbox' }, diff --git a/packages/nodes/src/cupola/definition.ts b/packages/nodes/src/cupola/definition.ts index a78be361c5..f49574a1a6 100644 --- a/packages/nodes/src/cupola/definition.ts +++ b/packages/nodes/src/cupola/definition.ts @@ -125,6 +125,7 @@ export const cupolaDefinition: NodeDefinition = { }, capabilities: { + exterior: true, slots: cupolaSlots, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index e6a8d74107..4c6ba27b87 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -21,6 +21,7 @@ import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-open import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { scriptedOpeningHandles } from '../shared/scripted-opening-handles' import { readHostWallCeiling } from '../shared/wall-opening-ceiling' +import { openingSeenFromOutside } from '../wall/exterior' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildDoorContextualDimensions } from './contextual-dimensions' import { scaleHandleHeight } from './door-math' @@ -237,6 +238,8 @@ export const doorDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'openings', style: 'cut' }, + exterior: openingSeenFromOutside, + revealOnEdit: ['position', 'width', 'height', 'openingShape', 'openingKind', 'doorType'], batchable: doorBatchable, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 88ca1c1337..e2e1711970 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -279,6 +279,7 @@ export const dormerDefinition: NodeDefinition = { }, capabilities: { + exterior: true, selectable: { hitVolume: 'bbox' }, // Dormers own their WindowNode children. Duplicate the complete subtree // so the copied dormer never aliases windows from the source dormer. diff --git a/packages/nodes/src/downspout/definition.ts b/packages/nodes/src/downspout/definition.ts index 594f70640e..c4c17366cd 100644 --- a/packages/nodes/src/downspout/definition.ts +++ b/packages/nodes/src/downspout/definition.ts @@ -178,6 +178,7 @@ export const downspoutDefinition: NodeDefinition = { }, capabilities: { + exterior: true, slots: downspoutSlots, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/exterior-declarations.test.ts b/packages/nodes/src/exterior-declarations.test.ts new file mode 100644 index 0000000000..ff2f6ca72a --- /dev/null +++ b/packages/nodes/src/exterior-declarations.test.ts @@ -0,0 +1,134 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + DoorNode, + LevelNode, + nodeRegistry, + registerNode, + WallNode, + WindowNode, + ZoneNode, +} from '@pascal-app/core' +import { builtinPlugin } from './index' + +// What the built-in kinds declare for hosts that follow a change from outside (`capabilities.exterior`) +// and for the construction reveal's room-by-room layout (`capabilities.revealLayout`). Hosts read the +// registry and name no kind, so what these kinds say is all they know. + +let restore: () => void = () => {} +beforeAll(() => { + restore = nodeRegistry._snapshot() + nodeRegistry._reset() + for (const definition of builtinPlugin.nodes ?? []) registerNode(definition) +}) +afterAll(() => restore()) + +const capabilitiesOf = (kind: string) => nodeRegistry.get(kind)?.capabilities +const exteriorOf = (kind: string, node: AnyNode, nodes: Record = {}) => { + const exterior = capabilitiesOf(kind)?.exterior + return typeof exterior === 'function' ? exterior(node, nodes) : exterior +} + +describe('what is seen from outside', () => { + test.each([ + 'roof', + 'roof-segment', + 'dormer', + 'skylight', + 'chimney', + 'cupola', + 'solar-panel', + 'gutter', + 'downspout', + 'eyebrow-vent', + 'ridge-vent', + 'turbine-vent', + 'box-vent', + 'lean-to-extension', + 'fence', + 'fence-gate', + 'fence-opening', + 'site', + ])('%s always stands outdoors', (kind) => { + expect(capabilitiesOf(kind)?.exterior).toBe(true) + }) + + test.each(['level', 'building', 'zone'])( + '%s never does, and holds what is looked at through a cutaway', + (kind) => { + expect(capabilitiesOf(kind)?.exterior).toBe(false) + }, + ) + + test.each(['item', 'slab', 'ceiling', 'stair', 'column', 'unit'])( + '%s says nothing: what holds it decides', + (kind) => { + expect(capabilitiesOf(kind)?.exterior).toBeUndefined() + }, + ) + + test('a wall is outdoors with an exterior face, on either side', () => { + const wall = (frontSide: string, backSide: string) => + WallNode.parse({ start: [0, 0], end: [4, 0], frontSide, backSide }) + expect(exteriorOf('wall', wall('interior', 'exterior'))).toBe(true) + expect(exteriorOf('wall', wall('exterior', 'interior'))).toBe(true) + expect(exteriorOf('wall', wall('interior', 'interior'))).toBe(false) + expect(exteriorOf('wall', wall('unknown', 'unknown'))).toBe(false) + }) + + test('a door or a window is outdoors in a roof segment or in a wall with an exterior face', () => { + const outer = WallNode.parse({ + id: 'wall_out', + start: [0, 0], + end: [4, 0], + frontSide: 'interior', + backSide: 'exterior', + }) + const inner = WallNode.parse({ + id: 'wall_in', + start: [0, 3], + end: [4, 3], + frontSide: 'interior', + backSide: 'interior', + }) + const nodes = { [outer.id]: outer, [inner.id]: inner } as Record + for (const [kind, schema] of [ + ['door', DoorNode], + ['window', WindowNode], + ] as const) { + const on = (wall: typeof outer) => schema.parse({ parentId: wall.id, wallId: wall.id }) + expect(exteriorOf(kind, on(outer), nodes)).toBe(true) + expect(exteriorOf(kind, on(inner), nodes)).toBe(false) + expect(exteriorOf(kind, schema.parse({ roofSegmentId: 'rseg_x' }), nodes)).toBe(true) + expect(exteriorOf(kind, schema.parse({ parentId: 'wall_gone' }), nodes)).toBe(false) + } + }) +}) + +describe('what lays a floor out room by room', () => { + test('only the zone outlines a room and only the wall stands on a line', () => { + const declaring = (builtinPlugin.nodes ?? []) + .filter((definition) => definition.capabilities?.revealLayout) + .map((definition) => definition.kind) + .sort() + expect(declaring).toEqual(['wall', 'zone']) + }) + + test("a zone's room is its polygon, a wall's line its two ends", () => { + const level = LevelNode.parse({ id: 'level_x', level: 0 }) + const polygon: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + ] + const zone = ZoneNode.parse({ parentId: level.id, name: 'Room', polygon }) + const wall = WallNode.parse({ parentId: level.id, start: [1, 2], end: [5, 2] }) + expect(capabilitiesOf('zone')?.revealLayout?.room?.(zone)).toEqual(polygon) + expect(capabilitiesOf('zone')?.revealLayout?.room?.(wall)).toBeNull() + expect(capabilitiesOf('wall')?.revealLayout?.line?.(wall)).toEqual([ + [1, 2], + [5, 2], + ]) + expect(capabilitiesOf('wall')?.revealLayout?.line?.(zone)).toBeNull() + }) +}) diff --git a/packages/nodes/src/eyebrow-vent/definition.ts b/packages/nodes/src/eyebrow-vent/definition.ts index af63003470..010df84785 100644 --- a/packages/nodes/src/eyebrow-vent/definition.ts +++ b/packages/nodes/src/eyebrow-vent/definition.ts @@ -128,6 +128,7 @@ export const eyebrowVentDefinition: NodeDefinition = { }, capabilities: { + exterior: true, slots: eyebrowVentSlots, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/fence-feature/definition.ts b/packages/nodes/src/fence-feature/definition.ts index dd1a11d4f9..51d627f902 100644 --- a/packages/nodes/src/fence-feature/definition.ts +++ b/packages/nodes/src/fence-feature/definition.ts @@ -45,7 +45,11 @@ const parametrics: ParametricDescriptor = { const shared = { schemaVersion: 1, category: 'structure' as const, - capabilities: { selectable: { hitVolume: 'bbox' as const }, deletable: true }, + capabilities: { + exterior: true, + selectable: { hitVolume: 'bbox' as const }, + deletable: true, + }, geometry: buildFenceFeatureGeometry, floorplan: buildFenceFeatureFloorplan, floorplanDependencies: (node: FenceFeatureNode) => diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index f94057027a..c284638fd0 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -384,7 +384,20 @@ export const fenceDefinition: NodeDefinition = { }), capabilities: { + exterior: true, reveal: { phase: 'structure', style: 'rise' }, + revealOnEdit: [ + 'start', + 'end', + 'path', + 'curveOffset', + 'height', + 'thickness', + 'baseHeight', + 'postSpacing', + 'picketSpacing', + 'style', + ], hostRefFields: ['supportSurfaceNodeId'], surfacePlacement: 'floor-only', selectable: { hitVolume: 'bbox' }, diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index ba8d93a7ed..734e9f954b 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -158,6 +158,7 @@ export const gutterDefinition: NodeDefinition = { }, capabilities: { + exterior: true, slots: gutterSlots, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 53744c0b9c..b05e9989d2 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -215,6 +215,7 @@ export const itemDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 }, + revealOnEdit: ['scale'], batchable: itemBatchable, selectable: { hitVolume: 'bbox' }, // Authored objects host ceiling items on their undersides; catalog items do not. diff --git a/packages/nodes/src/lean-to-extension/definition.ts b/packages/nodes/src/lean-to-extension/definition.ts index b1fc67bcb3..4056c92e35 100644 --- a/packages/nodes/src/lean-to-extension/definition.ts +++ b/packages/nodes/src/lean-to-extension/definition.ts @@ -453,6 +453,7 @@ export const leanToExtensionDefinition: NodeDefinition = { }, capabilities: { + exterior: false, // Level is a container — selection happens via the sidebar tree // and the floating level switcher, never via 3D click. Declaring // `selectable` here would make `getSelectableKinds()` add the diff --git a/packages/nodes/src/procedural-item/definition.ts b/packages/nodes/src/procedural-item/definition.ts index fa5e307c76..b69b69e8da 100644 --- a/packages/nodes/src/procedural-item/definition.ts +++ b/packages/nodes/src/procedural-item/definition.ts @@ -130,6 +130,7 @@ export const proceduralItemDefinition: NodeDefinition }, capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 }, + revealOnEdit: ['recipe'], batchable: { scope: 'level', // Part lights clone their emissive slot per node, and a playing motion diff --git a/packages/nodes/src/reveal-coverage.test.ts b/packages/nodes/src/reveal-coverage.test.ts new file mode 100644 index 0000000000..7929e75bb2 --- /dev/null +++ b/packages/nodes/src/reveal-coverage.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, nodeRegistry, registerNode } from '@pascal-app/core' +import { commitRevealCandidates } from '@pascal-app/viewer' +import { builtinPlugin } from './index' + +// What an agent builds appears as a construction, not a pop (the construction reveal). A kind either +// declares its reveal or comes in with the default one; the kinds below draw nothing of their own +// (data, containers, annotations, a hole cut in a slab), so there is nothing to move in. +const DRAWS_NOTHING_OF_ITS_OWN = new Set([ + 'site', + 'building', + 'level', + 'unit', + 'floor-opening', + 'structural-grid', + 'construction-dimension', + 'measurement', +]) + +let restore: () => void = () => {} +beforeAll(() => { + restore = nodeRegistry._snapshot() + nodeRegistry._reset() + for (const definition of builtinPlugin.nodes ?? []) registerNode(definition) +}) +afterAll(() => restore()) + +const nodes = { + building: { id: 'building', type: 'building', parentId: null }, + level: { id: 'level', type: 'level', level: 0, parentId: 'building' }, + roof: { id: 'roof', type: 'roof', parentId: 'level' }, +} as unknown as Record + +/** What a commit that adds one node of `type` stages at `at`, standing on `parentId`. */ +function staged(type: string, at: 'full' | 'framing', parentId: string) { + const added = { id: 'added', type, parentId } as unknown as AnyNode + return commitRevealCandidates( + { + before: { nodes }, + current: { nodes: { ...nodes, added } }, + author: 'agent', + } as never, + at, + ) +} + +describe('every built-in kind an agent can create', () => { + const kinds = (builtinPlugin.nodes ?? []).map((definition) => definition.kind) + + test.each(kinds.filter((kind) => !DRAWS_NOTHING_OF_ITS_OWN.has(kind)))( + '%s is staged, on a level and on a roof, at the full and the framing levels', + (kind) => { + for (const at of ['full', 'framing'] as const) { + for (const parentId of ['level', 'roof']) { + const [candidate] = staged(kind, at, parentId) + expect(candidate?.id).toBe('added') + } + } + }, + ) + + test.each([...DRAWS_NOTHING_OF_ITS_OWN])( + '%s stages nothing: it draws nothing of its own', + (kind) => { + expect(staged(kind, 'full', 'level')).toEqual([]) + }, + ) + + test('the exemptions are kinds that exist', () => { + for (const kind of DRAWS_NOTHING_OF_ITS_OWN) expect(kinds).toContain(kind) + }) +}) + +describe('the fields an edit replays a node for', () => { + const withEdits = (builtinPlugin.nodes ?? []).filter( + (definition) => definition.capabilities?.revealOnEdit, + ) + + test('the kinds an agent edits most are listed', () => { + const kinds = withEdits.map((definition) => definition.kind) + for (const kind of ['wall', 'slab', 'ceiling', 'roof-segment', 'stair', 'door', 'window']) + expect(kinds).toContain(kind) + }) + + test('a roof segment replays for the barrel arch and the glazing it can be given', () => { + const roofSegment = withEdits.find((definition) => definition.kind === 'roof-segment') + expect(roofSegment?.capabilities?.revealOnEdit).toEqual( + expect.arrayContaining(['barrelRise', 'barrelSegments', 'roofSystem', 'glazing']), + ) + }) + + test.each(withEdits.map((definition) => [definition.kind, definition] as const))( + '%s lists only fields its schema has', + (_kind, definition) => { + const shape = (definition.schema as unknown as { shape: Record }).shape + for (const field of definition.capabilities?.revealOnEdit ?? []) + expect(shape).toHaveProperty(field) + }, + ) +}) diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index fcaa102e61..1be7ae925e 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -172,6 +172,7 @@ export const ridgeVentDefinition: NodeDefinition = { }, capabilities: { + exterior: true, selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index eb443038ea..17024a27eb 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -284,6 +284,21 @@ export const roofSegmentDefinition: NodeDefinition = { }, capabilities: { + exterior: true, + revealOnEdit: [ + 'roofType', + 'width', + 'depth', + 'wallHeight', + 'pitch', + 'overhang', + 'wallThickness', + 'deckThickness', + 'barrelRise', + 'barrelSegments', + 'roofSystem', + 'glazing', + ], paint: roofSegmentPaint, slots: (node) => roofSegmentSlots(node as RoofSegmentNodeType), selectable: { hitVolume: 'bbox' }, diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index 4241099007..657b5f7718 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -135,6 +135,7 @@ export const roofDefinition: NodeDefinition = { }, capabilities: { + exterior: true, reveal: { phase: 'roof', style: 'assemble', diff --git a/packages/nodes/src/shared/node-batch/node-batch.test.ts b/packages/nodes/src/shared/node-batch/node-batch.test.ts index 3cd9ec4731..4de37cbaf1 100644 --- a/packages/nodes/src/shared/node-batch/node-batch.test.ts +++ b/packages/nodes/src/shared/node-batch/node-batch.test.ts @@ -295,7 +295,7 @@ test('overlapping preview holds end only after the final restore; failed preview expect(isSlotPaintPreviewActive('ceiling_0')).toBe(false) }) -test.each(['mode', 'selected-level'])( +test.each(['mode', 'selected-level', 'revealing'])( 'shadow-only candidates are re-offered on %s restoration', (change) => { const { root, meshes } = setup() @@ -308,6 +308,7 @@ test.each(['mode', 'selected-level'])( expect(batches(root)).toHaveLength(0) clearShadowOnly(root) if (change === 'mode') useViewer.setState({ levelMode: 'stacked' }) + else if (change === 'revealing') useViewer.setState({ levelsRevealing: ['level_test'] }) else useViewer.setState({ selection: { ...useViewer.getState().selection, levelId: 'level_test' }, diff --git a/packages/nodes/src/shared/node-batch/system.tsx b/packages/nodes/src/shared/node-batch/system.tsx index d0b69b2f50..5c3b68fdd8 100644 --- a/packages/nodes/src/shared/node-batch/system.tsx +++ b/packages/nodes/src/shared/node-batch/system.tsx @@ -82,6 +82,7 @@ let batchingSuspended = false let lastLevelMode: string | undefined let lastHideAbove: boolean | undefined let lastSelectedLevel: string | null | undefined +let lastLevelsRevealing: readonly string[] | undefined type AppearanceInputs = { shading: unknown @@ -135,6 +136,7 @@ export function resetNodeBatchState() { lastLevelMode = undefined lastHideAbove = undefined lastSelectedLevel = undefined + lastLevelsRevealing = undefined lastAppearance.shading = undefined lastAppearance.textures = undefined lastAppearance.colorPreset = undefined @@ -286,11 +288,13 @@ function processBatchFrame( if ( lastLevelMode !== viewer.levelMode || lastHideAbove !== viewer.hideLevelsAboveSelection || - lastSelectedLevel !== viewer.selection.levelId + lastSelectedLevel !== viewer.selection.levelId || + lastLevelsRevealing !== viewer.levelsRevealing ) { lastLevelMode = viewer.levelMode lastHideAbove = viewer.hideLevelsAboveSelection lastSelectedLevel = viewer.selection.levelId + lastLevelsRevealing = viewer.levelsRevealing // Shadow-only sources were rejected and dropped from the previous join wave. for (const nodeId of nodeIds) if (!store.has(nodeId)) staleNodes.add(nodeId) changed = true diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 9b82cf45f3..592b1af92d 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -166,6 +166,7 @@ export const shelfDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 }, + revealOnEdit: ['width', 'depth', 'height', 'thickness', 'rows', 'columns', 'style'], movable: { axes: ['x', 'z'], gridSnap: true }, rotatable: { axes: ['y'], diff --git a/packages/nodes/src/site/definition.ts b/packages/nodes/src/site/definition.ts index 2ca24257e9..018abd1e18 100644 --- a/packages/nodes/src/site/definition.ts +++ b/packages/nodes/src/site/definition.ts @@ -22,6 +22,7 @@ export const siteDefinition: NodeDefinition = { }, capabilities: { + exterior: true, // Site is the root container — sidebar / property-line tool drive // selection, never 3D click (event bubbling from descendants would // override their selection). Same reasoning as `level`. diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index 0411f2e8b1..a7549d3e3b 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -233,6 +233,7 @@ export const skylightDefinition: NodeDefinition = { }, capabilities: { + exterior: true, selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 92e29bfca0..cf0895d432 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -307,6 +307,14 @@ export const slabDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'foundation', style: 'settle', height: 0.08 }, + revealOnEdit: [ + 'polygon', + 'holes', + 'elevation', + 'thickness', + 'recessed', + 'recessedRimElevation', + ], batchable: surfaceBatchable, selectable: { hitVolume: 'bbox' }, surfaces: { diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index b19be8e3d8..9f8ebcd116 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -242,6 +242,7 @@ export const solarPanelDefinition: NodeDefinition = { }, capabilities: { + exterior: true, selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index a0248381d3..cca6b32a6b 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -439,6 +439,17 @@ export const stairDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'circulation', style: 'drop', height: 2.5 }, + revealOnEdit: [ + 'stairType', + 'width', + 'totalRise', + 'stepCount', + 'thickness', + 'innerRadius', + 'sweepAngle', + 'railingMode', + 'topLandingDepth', + ], hostRefFields: ['destinationSurfaceId'], surfacePlacement: 'floor-only', selectable: { hitVolume: 'bbox' }, diff --git a/packages/nodes/src/turbine-vent/definition.ts b/packages/nodes/src/turbine-vent/definition.ts index f42140d880..48e995a9b4 100644 --- a/packages/nodes/src/turbine-vent/definition.ts +++ b/packages/nodes/src/turbine-vent/definition.ts @@ -94,6 +94,7 @@ export const turbineVentDefinition: NodeDefinition = { }, capabilities: { + exterior: true, slots: turbineVentSlots, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/wall/curtain-wall-materials.test.ts b/packages/nodes/src/wall/curtain-wall-materials.test.ts index 1ba221c3fa..792417c4c9 100644 --- a/packages/nodes/src/wall/curtain-wall-materials.test.ts +++ b/packages/nodes/src/wall/curtain-wall-materials.test.ts @@ -1,5 +1,12 @@ import { expect, test } from 'bun:test' -import { CurtainWallConfig, SceneMaterial, WallNode } from '@pascal-app/core' +import { + CurtainWallConfig, + MaterialPresetPayloadSchema, + registerLibraryMaterials, + SceneMaterial, + unregisterLibraryMaterials, + WallNode, +} from '@pascal-app/core' import { resolveMaterialRef } from '@pascal-app/viewer' import { Texture } from 'three' import type { MeshStandardNodeMaterial } from 'three/webgpu' @@ -70,3 +77,43 @@ test('scene material edits update the frame while preserving glass and solid pan expect((next.visible[0] as MeshStandardNodeMaterial).roughness).toBe(0.2) expect(next.visible[1]!.opacity).toBe(0.3) }) + +test('late library frame paint replaces the fallback without turning curtain glass opaque', () => { + const id = 'mtl_curtain_continuity' + const ref = `library:${id}` + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + wallType: 'curtain', + slots: { 'curtain-frame': ref }, + curtainWall: { glassOpacity: 0.42, glassRoughness: 0.19 }, + }) + const first = getCurtainAwareWallMaterials(wall) + try { + registerLibraryMaterials([ + { + id, + label: 'Late frame paint', + category: 'colors', + preset: MaterialPresetPayloadSchema.parse({ + maps: {}, + mapProperties: { color: '#2468ac', metalness: 0.8, roughness: 0.23 }, + }), + }, + ]) + const next = getCurtainAwareWallMaterials(wall) + const frame = next.visible[0] as MeshStandardNodeMaterial + const glass = next.visible[1] as MeshStandardNodeMaterial + expect(frame).not.toBe(first.visible[0]) + expect(frame).toBe(resolveMaterialRef(ref, undefined, 'rendered')) + expect(frame.color.getHexString()).toBe('2468ac') + expect(frame.metalness).toBe(0.8) + expect(frame.roughness).toBe(0.23) + expect(glass.transparent).toBe(true) + expect(glass.opacity).toBe(0.42) + expect(glass.depthWrite).toBe(false) + expect(glass.roughness).toBe(0.19) + } finally { + unregisterLibraryMaterials([id]) + } +}) diff --git a/packages/nodes/src/wall/curtain-wall-materials.ts b/packages/nodes/src/wall/curtain-wall-materials.ts index 86f50430ee..9d7473e773 100644 --- a/packages/nodes/src/wall/curtain-wall-materials.ts +++ b/packages/nodes/src/wall/curtain-wall-materials.ts @@ -1,5 +1,6 @@ import { getCurtainWallConfig, + getMaterialPresetByRef, type SceneMaterial, type SceneMaterialId, type WallNode, @@ -28,6 +29,7 @@ export function createCurtainWallMaterials( return [ ref, ref?.startsWith('scene:') ? materials?.[ref.slice(6) as SceneMaterialId] : null, + getMaterialPresetByRef(ref), ] }), }), diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index eb1f5c9815..00a3333302 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -19,6 +19,7 @@ import { } from '@pascal-app/editor' import { buildWallContextualDimensions } from './contextual-dimensions' import { hasWallCurveBlockingChildren } from './curve-eligibility' +import { wallSeenFromOutside } from './exterior' import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan' import { wallCurveAffordance, @@ -138,6 +139,9 @@ export const wallDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'structure', style: 'rise' }, + exterior: wallSeenFromOutside, + revealLayout: { line: (node) => (node.type === 'wall' ? [node.start, node.end] : null) }, + revealOnEdit: ['start', 'end', 'curveOffset', 'height', 'thickness', 'wallType', 'curtainWall'], // Wall move is bespoke (endpoint drag, linked-wall corner cascade, // ALT-detach). Omitting `movable` keeps the legacy MoveWallTool via // capability-driven dispatch. diff --git a/packages/nodes/src/wall/exterior.ts b/packages/nodes/src/wall/exterior.ts new file mode 100644 index 0000000000..8db7bb18fd --- /dev/null +++ b/packages/nodes/src/wall/exterior.ts @@ -0,0 +1,17 @@ +import type { AnyNode } from '@pascal-app/core' + +/** A wall with an outdoor face: the cutaway treats it as a wall seen from outside or inside. */ +export function wallSeenFromOutside(node: AnyNode): boolean { + return node.type === 'wall' && (node.frontSide === 'exterior' || node.backSide === 'exterior') +} + +/** An opening in a roof segment, or in a wall with an outdoor face, is seen from outside. */ +export function openingSeenFromOutside( + node: AnyNode, + nodes: Readonly>, +): boolean { + if (node.type !== 'door' && node.type !== 'window') return false + if (node.roofSegmentId) return true + const host = nodes[node.wallId ?? node.parentId ?? ''] + return host ? wallSeenFromOutside(host) : false +} diff --git a/packages/nodes/src/wall/wall-batch-canonical.test.ts b/packages/nodes/src/wall/wall-batch-canonical.test.ts index d41ca4a194..1851c09c5f 100644 --- a/packages/nodes/src/wall/wall-batch-canonical.test.ts +++ b/packages/nodes/src/wall/wall-batch-canonical.test.ts @@ -1,7 +1,45 @@ -import { afterAll, afterEach, describe, expect, spyOn, test } from 'bun:test' -import { sceneRegistry, useScene, WallNode } from '@pascal-app/core' -import { getVisibleWallMaterials, useViewer } from '@pascal-app/viewer' -import { BufferGeometry, Float32BufferAttribute, type Material, Mesh, Object3D } from 'three' +import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test' +import { + type AnyNodeId, + LevelNode, + loadPlugin, + MaterialPresetPayloadSchema, + nodeRegistry, + registerLibraryMaterials, + runAsSceneCommitAuthor, + SceneMaterial, + sceneRegistry, + unregisterLibraryMaterials, + useScene, + WallNode, +} from '@pascal-app/core' +import { + getVisibleWallMaterials, + isNodeRevealing, + SCENE_LAYER, + startConstructionReveal, + useViewer, + WallCutout, + WallCutoutCache, + WallSystem, +} from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import { createElement, Fragment } from 'react' +import { + BufferGeometry, + Float32BufferAttribute, + type Material, + Mesh, + Object3D, + PerspectiveCamera, + Texture, + TextureLoader, + Vector3, +} from 'three' +import type { MeshStandardNodeMaterial } from 'three/webgpu' +import { getCurtainAwareWallMaterials } from './curtain-wall-materials' +import { wallDefinition } from './definition' +import WallRenderer from './renderer' import { canonicalWallMaterials, materialSetKey, runBatchFrame } from './wall-batch-system' // A 4 × 4 grid of 2 m rooms: 20 horizontal + 20 vertical walls = 40 walls. Rooms @@ -118,6 +156,19 @@ const canonicalKey = (build: ReturnType) => let nowMs = 0 const performanceNow = spyOn(performance, 'now').mockImplementation(() => nowMs) const registered: string[] = [] +const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +const savedActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT +const savedRequestAnimationFrame = globalThis.requestAnimationFrame +const savedCancelAnimationFrame = globalThis.cancelAnimationFrame + +beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true + globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 + } + globalThis.cancelAnimationFrame ??= () => {} +}) afterEach(() => { useViewer.setState({ wallMode: 'down' } as never) @@ -131,7 +182,12 @@ afterEach(() => { useViewer.setState({ wallMode: 'up' } as never) }) -afterAll(() => performanceNow.mockRestore()) +afterAll(() => { + performanceNow.mockRestore() + actEnvironment.IS_REACT_ACT_ENVIRONMENT = savedActEnvironment + globalThis.requestAnimationFrame = savedRequestAnimationFrame + globalThis.cancelAnimationFrame = savedCancelAnimationFrame +}) describe('canonical wall batch keys', () => { test('equal resolved material sets share a key whatever the palette order or unused entries', () => { @@ -212,3 +268,429 @@ describe('canonical wall batch keys', () => { } }) }) + +function required(value: T | null | undefined, label: string): T { + if (value === null || value === undefined) throw new Error(`Expected ${label}`) + return value +} + +function lateBatchFixture(id: string) { + const root = new Object3D() + const builds = Array.from({ length: 8 }, (_, index) => { + const build = wallBuild({ + node: WallNode.parse({ + id: `wall_late_batch_${index}`, + parentId: 'level_late_batch', + start: [0, index * 4], + end: [4, index * 4], + }), + faces: { a: { room: null }, b: { room: null } }, + }) + const node = WallNode.parse({ + ...build.node, + slots: { a: `library:${id}`, b: 'library:preset-white' }, + }) + const mesh = new Mesh(build.geometry, getVisibleWallMaterials(node)) + root.add(mesh) + sceneRegistry.nodes.set(node.id, mesh) + sceneRegistry.byType.wall.add(node.id) + registered.push(node.id) + return { node, mesh } + }) + sceneRegistry.nodes.set('level_late_batch', root) + sceneRegistry.byType.level.add('level_late_batch') + registered.push('level_late_batch') + useScene.setState({ + nodes: { + level_late_batch: LevelNode.parse({ + id: 'level_late_batch', + children: builds.map(({ node }) => node.id), + }), + ...Object.fromEntries(builds.map(({ node }) => [node.id, node])), + }, + rootNodeIds: ['level_late_batch'], + dirtyNodes: new Set(), + materials: {}, + } as never) + useViewer.setState({ + wallMode: 'up', + shading: 'rendered', + textures: true, + selection: { ...useViewer.getState().selection, selectedIds: [], zoneId: null }, + previewSelectedIds: [], + hoveredId: null, + hoverHighlightMode: 'default', + }) + const cache = new WallCutoutCache(useViewer) + const camera = new PerspectiveCamera() + const wake = { current: null as ReturnType | null } + return { root, builds, cache, camera, wake } +} + +function expectNoVisibleMaterial(root: Object3D, forbidden: Material) { + for (const child of root.children) { + const mesh = child as Mesh + if (!mesh.visible || !mesh.layers.isEnabled(SCENE_LAYER)) continue + const palette = mesh.material as Material[] + for (const group of mesh.geometry.groups) { + if (!group.count) continue + const index = required(group.materialIndex, 'draw group material index') + expect(required(palette[index], 'draw group material')).not.toBe(forbidden) + } + } +} + +test('a late library finish replaces the materials drawn by an already sewn wall batch', () => { + const id = 'mtl_wall_batch_continuity' + const { root, builds, cache, camera, wake } = lateBatchFixture(id) + cache.update(camera, 0) + nowMs = 0 + runBatchFrame(() => undefined, wake) + nowMs = 181 + runBatchFrame(() => undefined, wake) + const old = root.children.find((child) => child.name === 'wall-batch') as Mesh + expect(old).toBeDefined() + const first = required(builds[0], 'first wall') + const fallback = required((first.mesh.material as Material[])[1], 'fallback face material') + try { + registerLibraryMaterials([ + { + id, + label: 'Late batched wall finish', + category: 'colors', + preset: MaterialPresetPayloadSchema.parse({ + maps: {}, + mapProperties: { color: '#2468ac', roughness: 0.27, metalness: 0.63 }, + }), + }, + ]) + cache.update(camera, 0.2) + const resolved = (first.mesh.material as Material[])[1] as MeshStandardNodeMaterial + expect(resolved).not.toBe(fallback) + expect(resolved.color.getHexString()).toBe('2468ac') + nowMs = 200 + runBatchFrame(() => undefined, wake) + // No draw between invalidation and re-sewing may retain the stale finish. + expectNoVisibleMaterial(root, fallback) + nowMs = 381 + runBatchFrame(() => undefined, wake) + const batches = root.children.filter((child) => child.name === 'wall-batch') as Mesh[] + expect(batches).toHaveLength(1) + expect(batches[0]).not.toBe(old) + expect(required(batches[0], 'replacement batch').material as Material[]).toContain(resolved) + expect(builds.every(({ mesh }) => !mesh.layers.isEnabled(SCENE_LAYER))).toBe(true) + } finally { + unregisterLibraryMaterials([id]) + useViewer.setState({ wallMode: 'down' }) + runBatchFrame(() => undefined, wake) + if (wake.current) clearTimeout(wake.current) + } +}) + +// Construction is presentation only: authored paint and metre UVs must survive every +// live frame and the collective draw. Values come from the fixture, not the resolver. +function materialState(material: Material) { + const surface = material as MeshStandardNodeMaterial + return { + type: surface.type, + color: surface.color?.getHexString(), + opacity: surface.opacity, + transparent: surface.transparent, + depthWrite: surface.depthWrite, + side: surface.side, + roughness: surface.roughness, + metalness: surface.metalness, + maps: [surface.map, surface.normalMap, surface.roughnessMap].map((map) => + map + ? { + uuid: map.uuid, + repeat: map.repeat.toArray(), + rotation: map.rotation, + flipY: map.flipY, + channel: map.channel, + colorSpace: map.colorSpace, + } + : null, + ), + } +} + +function LiveWalls() { + const nodes = useScene((state) => state.nodes) + return createElement( + 'group', + {}, + ...Object.values(nodes) + .filter((node): node is WallNode => node.type === 'wall') + .map((node) => createElement(WallRenderer, { key: node.id, node })), + ) +} + +function expectMetreFaceUVs(mesh: Mesh) { + mesh.updateWorldMatrix(true, false) + const position = mesh.geometry.getAttribute('position') + const index = mesh.geometry.index + let edges = 0 + for (const name of ['uv', 'uv2']) { + const uv = mesh.geometry.getAttribute(name) + for (const group of mesh.geometry.groups) { + if (group.materialIndex !== 1 && group.materialIndex !== 2) continue + for (let offset = group.start; offset < group.start + group.count; offset += 3) { + const start = index ? index.getX(offset) : offset + const a = new Vector3().fromBufferAttribute(position, start).applyMatrix4(mesh.matrixWorld) + for (const corner of [1, 2]) { + const end = index ? index.getX(offset + corner) : offset + corner + const b = new Vector3().fromBufferAttribute(position, end).applyMatrix4(mesh.matrixWorld) + // One UV unit is one drawn metre, not one pre-animation metre. The opposite + // face reverses U; both faces keep the resting wall's downward V orientation. + expect(uv.getX(end) - uv.getX(start)).toBeCloseTo( + (group.materialIndex === 1 ? 1 : -1) * (b.x - a.x), + 5, + ) + expect(uv.getY(end) - uv.getY(start)).toBeCloseTo(a.y - b.y, 5) + edges++ + } + } + } + } + expect(edges).toBeGreaterThan(0) +} + +function registerRevealTexture(id: string) { + registerLibraryMaterials([ + { + id, + label: 'Directional wall texture', + category: 'colors', + preset: MaterialPresetPayloadSchema.parse({ + maps: { + albedoMap: `/fixtures/${id}-albedo.png`, + normalMap: `/fixtures/${id}-normal.png`, + roughnessMap: `/fixtures/${id}-roughness.png`, + }, + mapProperties: { + color: '#a47345', + roughness: 0.37, + metalness: 0.12, + repeatX: 2, + repeatY: 3, + rotation: Math.PI / 2, + }, + }), + }, + ]) +} + +function createRevealFixture(shading: 'solid' | 'rendered') { + const id = `mtl_wall_reveal_${shading}` + const textures: Texture[] = [] + const loader = spyOn(TextureLoader.prototype, 'loadAsync').mockImplementation(async () => { + const texture = new Texture() + textures.push(texture) + return texture + }) + const paint = SceneMaterial.parse({ + id: 'smat_reveal_paint', + name: 'Face paint', + material: { properties: { color: '#2468ac', roughness: 0.61, metalness: 0.08 } }, + }) + const walls = Array.from({ length: 8 }, (_, index) => + WallNode.parse({ + id: `wall_material_reveal_${index}`, + start: [0, index * 4], + end: [4, index * 4], + height: 2.5, + thickness: 0.2, + slots: { a: `scene:${paint.id}`, b: `library:${id}` }, + }), + ) + return { + id, + shading, + paint, + walls, + textures, + loader, + level: LevelNode.parse({ id: 'level_material_reveal', level: 0 }), + savedScene: useScene.getState(), + savedViewer: useViewer.getState(), + restoreRegistry: nodeRegistry._snapshot(), + renderer: undefined as Awaited> | undefined, + driver: undefined as ReturnType | undefined, + root: undefined as Object3D | undefined, + wake: { current: null as ReturnType | null }, + samples: new Map(), + rising: new Set(), + } +} + +type RevealFixture = ReturnType + +async function mountRevealFixture(fixture: RevealFixture) { + const { level, paint, shading, walls } = fixture + nodeRegistry._reset() + await loadPlugin({ id: 'fixture:wall-materials', apiVersion: 1, nodes: [wallDefinition] }) + registerRevealTexture(fixture.id) + useScene.getState().setScene({ [level.id]: level }, [level.id as AnyNodeId], { + installedPlugins: ['fixture:wall-materials'], + hasExplicitPluginInstallState: true, + }) + useScene.setState({ materials: { [paint.id]: paint }, dirtyNodes: new Set() }) + useViewer.setState({ + shading, + textures: true, + wallMode: 'up', + colorPreset: 'clay', + sceneTheme: 'studio', + selection: { ...fixture.savedViewer.selection, selectedIds: [], zoneId: null }, + previewSelectedIds: [], + hoveredId: null, + hoverHighlightMode: 'default', + }) + fixture.renderer = await create( + createElement( + Fragment, + {}, + createElement(LiveWalls), + createElement(WallCutout, { materialResolver: getCurtainAwareWallMaterials }), + createElement(WallSystem), + ), + ) + fixture.root = required(fixture.renderer.scene.children[0], 'mounted level').instance as Object3D + sceneRegistry.nodes.set(level.id, fixture.root) + sceneRegistry.byType.level.add(level.id) + fixture.driver = startConstructionReveal({ + reveals: (commit) => commit.author === 'agent', + prefersReducedMotion: () => false, + level: () => 'full', + }) + await act(async () => { + runAsSceneCommitAuthor('agent', () => + useScene.getState().createNodes(walls.map((node) => ({ node, parentId: level.id }))), + ) + }) +} + +function expectRevealingWall(fixture: RevealFixture, wall: WallNode, mesh: Mesh) { + const materials = mesh.material as Material[] + const faceA = materials[1] as MeshStandardNodeMaterial + const faceB = materials[2] as MeshStandardNodeMaterial + expect(faceA.color.getHexString()).toBe('2468ac') + expect(faceB.color.getHexString()).toBe('a47345') + expect(faceA.transparent).toBe(false) + expect(faceB.opacity).toBe(1) + expect(faceB.type).toBe( + fixture.shading === 'solid' ? 'MeshLambertNodeMaterial' : 'MeshStandardNodeMaterial', + ) + expect(faceB.map).not.toBeNull() + const map = required(faceB.map, 'textured wall albedo map') + expect(map.repeat.toArray()).toEqual([2, 3]) + expect(map.rotation).toBe(Math.PI / 2) + if (fixture.shading === 'rendered') { + expect(faceB.normalMap).not.toBeNull() + expect(faceB.roughnessMap).not.toBeNull() + expect(faceB.roughness).toBe(0.37) + expect(faceB.metalness).toBe(0.12) + } + const saved = fixture.samples.get(wall.id) + if (saved) { + materials.forEach((material, index) => { + expect(material).toBe(saved.materials[index]) + }) + expect(materials.map(materialState)).toEqual(saved.states) + } else { + fixture.samples.set(wall.id, { + materials: [...materials], + states: materials.map(materialState), + }) + } + mesh.updateMatrix() + const scaleY = required(mesh.matrix.elements[5], 'wall world Y scale') + if (scaleY > 0.05 && scaleY < 0.95) { + fixture.rising.add(wall.id) + expect(isNodeRevealing(wall.id)).toBe(true) + expect(mesh.layers.isEnabled(SCENE_LAYER)).toBe(true) + expectMetreFaceUVs(mesh) + } +} + +async function advanceRevealFixture(fixture: RevealFixture) { + const driver = required(fixture.driver, 'construction driver') + const renderer = required(fixture.renderer, 'mounted renderer') + nowMs = 0 + for (let frame = 0; frame < 300; frame++) { + nowMs += 20 + await act(async () => { + driver.tick(nowMs) + await renderer.advanceFrames(1, 0.02) + }) + runBatchFrame(() => undefined, fixture.wake) + for (const wall of fixture.walls) { + const mesh = sceneRegistry.nodes.get(wall.id) as Mesh | undefined + if (mesh && !mesh.geometry.userData.placeholder) expectRevealingWall(fixture, wall, mesh) + } + } +} + +// Compare the submitted UV/material tuples, allowing palette, vertex and triangle reordering. +function triangleMaterialUVs(mesh: Mesh) { + const uv = mesh.geometry.getAttribute('uv') + const index = mesh.geometry.index + const palette = mesh.material as Material[] + return mesh.geometry.groups.flatMap((group) => + Array.from({ length: group.count }, (_, offset) => { + const at = index ? index.getX(group.start + offset) : group.start + offset + const materialIndex = required(group.materialIndex, 'triangle material index') + const material = required(palette[materialIndex], 'triangle material') + return `${material.uuid}:${uv.getX(at)}:${uv.getY(at)}` + }), + ) +} + +function expectSettledRevealBatch(fixture: RevealFixture) { + expect(fixture.samples.size).toBe(8) + expect(fixture.rising.size).toBe(8) + const root = required(fixture.root, 'level root') + const batches = root.children.filter((child) => child.name === 'wall-batch') as Mesh[] + expect(batches).toHaveLength(1) + const batch = required(batches[0], 'settled wall batch') + const sources = fixture.walls.map((wall) => sceneRegistry.nodes.get(wall.id) as Mesh) + expect(triangleMaterialUVs(batch).sort()).toEqual(sources.flatMap(triangleMaterialUVs).sort()) + for (const wall of fixture.walls) { + const mesh = sceneRegistry.nodes.get(wall.id) as Mesh + expect(isNodeRevealing(wall.id)).toBe(false) + expect(mesh.layers.isEnabled(SCENE_LAYER)).toBe(false) + mesh.updateMatrix() + expect(mesh.matrix.elements[5]).toBe(1) + expectMetreFaceUVs(mesh) + } +} + +async function disposeRevealFixture(fixture: RevealFixture) { + await act(async () => fixture.driver?.stop()) + useViewer.setState({ wallMode: 'down' }) + runBatchFrame(() => undefined, fixture.wake) + if (fixture.wake.current) clearTimeout(fixture.wake.current) + await fixture.renderer?.unmount() + fixture.restoreRegistry() + unregisterLibraryMaterials([fixture.id]) + fixture.loader.mockRestore() + for (const texture of fixture.textures) texture.dispose() + sceneRegistry.clear() + useScene.setState(fixture.savedScene) + useViewer.setState(fixture.savedViewer) +} + +test.each(['solid', 'rendered'] as const)( + '%s painted and textured wall faces keep their materials and UVs while rising and joining a batch', + async (shading) => { + const fixture = createRevealFixture(shading) + try { + await mountRevealFixture(fixture) + await advanceRevealFixture(fixture) + expectSettledRevealBatch(fixture) + } finally { + await disposeRevealFixture(fixture) + } + }, +) diff --git a/packages/nodes/src/wall/wall-batch-system.tsx b/packages/nodes/src/wall/wall-batch-system.tsx index d8de27299a..7eaf0e026c 100644 --- a/packages/nodes/src/wall/wall-batch-system.tsx +++ b/packages/nodes/src/wall/wall-batch-system.tsx @@ -1,6 +1,13 @@ 'use client' -import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' +import { + type AnyNodeId, + emitter, + getLibraryMaterialsVersion, + sceneRegistry, + useScene, + type WallNode, +} from '@pascal-app/core' import { drainRebuiltWalls, getPendingWallRebuildCount, @@ -55,6 +62,7 @@ type AppearanceInputs = { colorPreset: unknown sceneTheme: unknown materials: object | null + libraryVersion: number } const lastAppearance: AppearanceInputs = { @@ -63,18 +71,21 @@ const lastAppearance: AppearanceInputs = { colorPreset: undefined, sceneTheme: undefined, materials: null, + libraryVersion: -1, } function appearanceChanged(): boolean { const viewer = useViewer.getState() const materials = useScene.getState().materials as object + const libraryVersion = getLibraryMaterialsVersion() if ( lastAppearance.shading === viewer.shading && lastAppearance.textures === viewer.textures && lastAppearance.colorPreset === viewer.colorPreset && lastAppearance.sceneTheme === viewer.sceneTheme && - lastAppearance.materials === materials + lastAppearance.materials === materials && + lastAppearance.libraryVersion === libraryVersion ) { return false } @@ -84,6 +95,7 @@ function appearanceChanged(): boolean { lastAppearance.colorPreset = viewer.colorPreset lastAppearance.sceneTheme = viewer.sceneTheme lastAppearance.materials = materials + lastAppearance.libraryVersion = libraryVersion return true } @@ -448,6 +460,7 @@ export const WallBatchSystem = () => { lastAppearance.colorPreset = undefined lastAppearance.sceneTheme = undefined lastAppearance.materials = null + lastAppearance.libraryVersion = -1 }, [], ) diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 2c3353c99f..c49bb1b625 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -27,6 +27,7 @@ import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-open import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { scriptedOpeningHandles } from '../shared/scripted-opening-handles' import { readHostWallCeiling } from '../shared/wall-opening-ceiling' +import { openingSeenFromOutside } from '../wall/exterior' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildWindowContextualDimensions } from './contextual-dimensions' import { buildWindowFloorplan } from './floorplan' @@ -296,6 +297,8 @@ export const windowDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'openings', style: 'cut' }, + exterior: openingSeenFromOutside, + revealOnEdit: ['position', 'width', 'height', 'openingShape', 'windowType'], batchable: windowBatchable, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/window/reveal-on-move.test.ts b/packages/nodes/src/window/reveal-on-move.test.ts new file mode 100644 index 0000000000..b3638d1478 --- /dev/null +++ b/packages/nodes/src/window/reveal-on-move.test.ts @@ -0,0 +1,124 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + clearSceneHistory, + LevelNode, + nodeRegistry, + registerNode, + runAsSceneCommitAuthor, + type SceneCommit, + subscribeSceneCommits, + useScene, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { commitEditCandidates } from '@pascal-app/viewer' +import { builtinPlugin } from '../index' + +/* + * A window or door moved along its wall replays (a quick re-cut, so Follow looks at it); what the + * structure reconciler repositions when a wall moves is its own write and plays nothing. + */ + +// The store settles a commit on a frame: here a frame is a tick. +globalThis.requestAnimationFrame ??= ((callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) as unknown as number) as typeof requestAnimationFrame +globalThis.cancelAnimationFrame ??= ((id: number) => + clearTimeout(id as unknown as ReturnType)) as typeof cancelAnimationFrame + +let restore: () => void = () => {} +beforeAll(() => { + restore = nodeRegistry._snapshot() + nodeRegistry._reset() + for (const definition of builtinPlugin.nodes ?? []) registerNode(definition) +}) +afterAll(() => restore()) + +const previous = useScene.getState() + +function house() { + const building = BuildingNode.parse({ id: 'building_m' }) + const level = LevelNode.parse({ id: 'level_m', parentId: building.id, level: 0 }) + const wall = WallNode.parse({ + id: 'wall_m', + parentId: level.id, + start: [0, 0], + end: [8, 0], + frontSide: 'interior', + backSide: 'exterior', + }) + const window = WindowNode.parse({ + id: 'window_m', + parentId: wall.id, + wallId: wall.id, + position: [6, 1.2, 0], + width: 1, + height: 1, + }) + building.children = [level.id] + level.children = [wall.id] + wall.children = [window.id] + useScene + .getState() + .setScene( + Object.fromEntries([building, level, wall, window].map((node) => [node.id, node as AnyNode])), + [building.id], + ) + clearSceneHistory() + return { wall, window } +} + +async function agentCommit(write: () => void): Promise { + const commits: SceneCommit[] = [] + const stop = subscribeSceneCommits((commit) => { + if (commit.author === 'agent') commits.push(commit) + }) + runAsSceneCommitAuthor('agent', write) + for (let tick = 0; tick < 40 && commits.length === 0; tick += 1) + await new Promise((resolve) => setTimeout(resolve, 10)) + stop() + const commit = commits[0] + if (!commit) throw new Error('The agent commit was not announced') + return commit +} + +describe('a window moved along its wall', () => { + test('replays when the agent moves it', async () => { + const { window } = house() + const commit = await agentCommit(() => + useScene.getState().updateNode(window.id as AnyNodeId, { position: [2, 1.2, 0] } as never), + ) + expect(commitEditCandidates(commit, 'full').map((candidate) => candidate.id)).toEqual([ + window.id, + ]) + useScene.setState(previous) + }) + + test('does not replay because its wall moved: its place along the wall is the same', async () => { + const { wall, window } = house() + const moved = await agentCommit(() => + useScene.getState().updateNode(wall.id as AnyNodeId, { start: [1, 0] } as never), + ) + const ids = commitEditCandidates(moved, 'full').map((candidate) => candidate.id) + expect(ids).toContain(wall.id) + expect(ids).not.toContain(window.id) + useScene.setState(previous) + }) + + test('is not moved by a wall shortened under it: only the wall replays', async () => { + const { wall, window } = house() + const shortened = await agentCommit(() => + useScene.getState().updateNode(wall.id as AnyNodeId, { end: [4, 0] } as never), + ) + const ids = commitEditCandidates(shortened, 'full').map((candidate) => candidate.id) + // Main's structure leaves the window where it was: nothing moved it, so it plays nothing. + expect(shortened.current.nodes[window.id as AnyNodeId]).toBe( + shortened.before.nodes[window.id as AnyNodeId], + ) + expect(ids).toContain(wall.id) + expect(ids).not.toContain(window.id) + useScene.setState(previous) + }) +}) diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index 24654fd92e..fb200c238b 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -43,6 +43,8 @@ export const zoneDefinition: NodeDefinition = { capabilities: { selectable: { hitVolume: 'bbox' }, + exterior: false, + revealLayout: { room: (node) => (node.type === 'zone' ? node.polygon : null) }, duplicable: true, deletable: true, // Zones describe regions of a site — they don't translate as diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index cdde18ee05..5a6b6bad7e 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -575,6 +575,7 @@ export function GlbScene({ index, selectedIndex: selectedIdx >= 0 ? selectedIdx : undefined, nodeVisible: true, + revealing: false, }) if (shadowOnly) applyShadowOnly(node) else clearShadowOnly(node) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 617c3faebb..0229e1d432 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -189,6 +189,24 @@ export { type RendererInitializationResult, type RendererPowerPreference, } from './lib/renderer-capability' +export { commitEditCandidates, commitRevealCandidates } from './lib/reveal-candidates' +export { + ANIMATION_SPEEDS, + type AnimationSpeed, + DEFAULT_ANIMATION_SPEED, + FRAMING_PACE, + LAZY_REVEAL_BASE_TIMING, + LAZY_REVEAL_TIMING, + type LazyRevealTiming, + lazyTimingAt, + normalizeAnimationSpeed, + type PlannedReveal, + pacedLazyTiming, + planLazyReveal, + type RevealCandidate, + retractTimingAt, + revealTimingAt, +} from './lib/reveal-schedule' export { planRevealWindows, type RevealWindow } from './lib/reveal-windows' export { createSceneSupportHeightSampler } from './lib/scene-support-height' export { @@ -258,7 +276,11 @@ export { type ConstructionRevealDriver, type ConstructionRevealLevel, type ConstructionRevealOptions, + emitRevealPuff, + getRetractTurn, + getRevealPlanSpeed, getRevealPlanState, + getRevealTurn, isNodeRevealing, isRevealAssembling, type RetractResult, @@ -269,9 +291,13 @@ export { type RevealNodeStartEvent, type RevealPhaseEvent, type RevealPlanState, + type RevealPreroll, type RevealRetractEvent, retractNodes, + revealChangedNodeIds, + revealClockMs, seatLiftedNow, + setRevealPreroll, startConstructionReveal, subscribeRevealAssembling, subscribeRevealEvents, diff --git a/packages/viewer/src/lib/__fixtures__/add-volume-commit.json b/packages/viewer/src/lib/__fixtures__/add-volume-commit.json new file mode 100644 index 0000000000..fb32863da3 --- /dev/null +++ b/packages/viewer/src/lib/__fixtures__/add-volume-commit.json @@ -0,0 +1,1090 @@ +{ + "before": { + "site_a": { + "object": "node", + "id": "site_a", + "type": "site", + "parentId": null, + "visible": true, + "metadata": {}, + "polygon": { + "type": "polygon", + "points": [ + [ + -15, + -15 + ], + [ + 15, + -15 + ], + [ + 15, + 15 + ], + [ + -15, + 15 + ] + ] + }, + "children": [ + "building_a" + ] + }, + "building_a": { + "object": "node", + "id": "building_a", + "type": "building", + "parentId": "site_a", + "visible": true, + "metadata": {}, + "children": [ + "level_0" + ], + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ] + }, + "level_0": { + "object": "node", + "id": "level_0", + "type": "level", + "parentId": "building_a", + "visible": true, + "metadata": {}, + "children": [], + "level": 0, + "baseElevation": 0, + "height": 3 + } + }, + "after": { + "site_a": { + "object": "node", + "id": "site_a", + "type": "site", + "parentId": null, + "visible": true, + "metadata": {}, + "polygon": { + "type": "polygon", + "points": [ + [ + -15, + -15 + ], + [ + 15, + -15 + ], + [ + 15, + 15 + ], + [ + -15, + 15 + ] + ] + }, + "children": [ + "building_a" + ] + }, + "building_a": { + "object": "node", + "id": "building_a", + "type": "building", + "parentId": "site_a", + "visible": true, + "metadata": {}, + "children": [ + "level_0", + "level_j6v1596mtvaocjpa", + "level_agjbv9yb7ksjizx7" + ], + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ] + }, + "level_0": { + "object": "node", + "id": "level_0", + "type": "level", + "parentId": "building_a", + "visible": true, + "metadata": {}, + "children": [ + "wall_qwc27eu49vd91ju1", + "wall_v9kulziyihe5f7ad", + "wall_ddm2mmqcebavxe7h", + "zone_1fazxau7jord3psp", + "wall_b9jmeifn7sm0tsim", + "wall_yip91gfmwiwnblqy", + "wall_brdsdawnqy4dsusp", + "wall_60v9vzb2s4xssypq", + "wall_m0d0065o62y5mmcs", + "zone_l6ma9wgwn4u1h3s6", + "wall_ak1lcqqlwpv243f7", + "wall_dg28x4y5hzglb2hc", + "zone_j565ajxsej42p435", + "ceiling_4g4wvqkqjgwmtkim", + "ceiling_a7k72ji5cfbaqhp5", + "ceiling_bx09rretxag6c3vx", + "slab_00030i53q9ofvtdh" + ], + "level": 0, + "baseElevation": 0, + "height": 3 + }, + "level_j6v1596mtvaocjpa": { + "object": "node", + "id": "level_j6v1596mtvaocjpa", + "type": "level", + "parentId": "building_a", + "visible": true, + "metadata": {}, + "children": [ + "wall_o2q7ha4a2m2vny50", + "wall_4jv15x6y1h3oxoe8", + "wall_u4xxk16kstfttoqu", + "wall_p8wy981a6z2w3oxw", + "zone_uljofwb1f1eetgj4", + "ceiling_0ngoh3y0ja8kfirp", + "slab_0002e7cn19dngnbw" + ], + "level": 1, + "baseElevation": 0, + "height": 3 + }, + "wall_qwc27eu49vd91ju1": { + "object": "node", + "id": "wall_qwc27eu49vd91ju1", + "type": "wall", + "name": "Wall 1", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 0, + 0 + ], + "end": [ + 4, + 0 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "wall_v9kulziyihe5f7ad": { + "object": "node", + "id": "wall_v9kulziyihe5f7ad", + "type": "wall", + "name": "Wall 3", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 4, + 10 + ], + "end": [ + 0, + 10 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "wall_ddm2mmqcebavxe7h": { + "object": "node", + "id": "wall_ddm2mmqcebavxe7h", + "type": "wall", + "name": "Wall 4", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 0, + 10 + ], + "end": [ + 0, + 0 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "zone_1fazxau7jord3psp": { + "object": "node", + "id": "zone_1fazxau7jord3psp", + "type": "zone", + "name": "Left", + "parentId": "level_0", + "visible": true, + "metadata": { + "volume": { + "id": "volume_3ib4cusfugfqczfk", + "name": "Block", + "shape": "rectangle", + "storeyIndex": 0, + "partIndex": 0 + } + }, + "polygon": [ + [ + 0, + 0 + ], + [ + 4, + 0 + ], + [ + 4, + 5 + ], + [ + 4, + 10 + ], + [ + 0, + 10 + ] + ], + "holes": [], + "autoFromWalls": true, + "boundaryWallIds": [ + "wall_60v9vzb2s4xssypq", + "wall_b9jmeifn7sm0tsim", + "wall_ddm2mmqcebavxe7h", + "wall_qwc27eu49vd91ju1", + "wall_v9kulziyihe5f7ad" + ], + "boundarySeparatorIds": [], + "seed": [ + 2, + 5 + ], + "spaceRole": "room", + "roomNumber": "", + "enclosureStatus": "enclosed", + "floorFinish": "", + "wallFinish": "", + "ceilingFinish": "", + "ceilingHeight": 2.7, + "occupancy": "", + "clearDimensionPolicy": "none", + "color": "#06b6d4" + }, + "wall_b9jmeifn7sm0tsim": { + "object": "node", + "id": "wall_b9jmeifn7sm0tsim", + "type": "wall", + "name": "Wall 2", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [ + "door_ovr34n1i6gz553nb" + ], + "start": [ + 4, + 0 + ], + "end": [ + 4, + 5 + ], + "frontSide": "interior", + "backSide": "interior" + }, + "door_ovr34n1i6gz553nb": { + "object": "node", + "id": "door_ovr34n1i6gz553nb", + "type": "door", + "name": "Door 1", + "parentId": "wall_b9jmeifn7sm0tsim", + "visible": true, + "metadata": {}, + "position": [ + 2.5, + 1.05, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "floorThresholdVersion": 1, + "wallId": "wall_b9jmeifn7sm0tsim", + "width": 0.9, + "height": 2.1, + "constructionType": "framed", + "dimensionReference": "nominal", + "doorCategory": "interior", + "doorType": "hinged", + "leafCount": 1, + "operationState": 0, + "slideDirection": "left", + "trackStyle": "none", + "garagePanelCount": 4, + "openingKind": "door", + "openingShape": "rectangle", + "openingRadiusMode": "all", + "openingTopRadii": [ + 0.15, + 0.15 + ], + "cornerRadius": 0.15, + "archHeight": 0.45, + "openingRevealRadius": 0.025, + "frameThickness": 0.05, + "frameDepth": 0.07, + "threshold": true, + "thresholdHeight": 0.02, + "hingesSide": "left", + "swingDirection": "inward", + "swingAngle": 0, + "segments": [ + { + "type": "panel", + "heightRatio": 0.4, + "columnRatios": [ + 1 + ], + "dividerThickness": 0.03, + "panelDepth": 0.01, + "panelInset": 0.04 + }, + { + "type": "panel", + "heightRatio": 0.6, + "columnRatios": [ + 1 + ], + "dividerThickness": 0.03, + "panelDepth": 0.01, + "panelInset": 0.04 + } + ], + "handle": true, + "handleHeight": 1.05, + "handleSide": "right", + "contentPadding": [ + 0.04, + 0.04 + ], + "doorCloser": false, + "panicBar": false, + "panicBarHeight": 1 + }, + "wall_yip91gfmwiwnblqy": { + "object": "node", + "id": "wall_yip91gfmwiwnblqy", + "type": "wall", + "name": "Wall 5", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 4, + 0 + ], + "end": [ + 10, + 0 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "wall_brdsdawnqy4dsusp": { + "object": "node", + "id": "wall_brdsdawnqy4dsusp", + "type": "wall", + "name": "Wall 6", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 10, + 0 + ], + "end": [ + 10, + 5 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "wall_60v9vzb2s4xssypq": { + "object": "node", + "id": "wall_60v9vzb2s4xssypq", + "type": "wall", + "name": "Wall 2", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 4, + 5 + ], + "end": [ + 4, + 10 + ], + "frontSide": "interior", + "backSide": "interior" + }, + "wall_m0d0065o62y5mmcs": { + "object": "node", + "id": "wall_m0d0065o62y5mmcs", + "type": "wall", + "name": "Wall 7", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 10, + 5 + ], + "end": [ + 4, + 5 + ], + "frontSide": "interior", + "backSide": "interior" + }, + "zone_l6ma9wgwn4u1h3s6": { + "object": "node", + "id": "zone_l6ma9wgwn4u1h3s6", + "type": "zone", + "name": "Right bottom", + "parentId": "level_0", + "visible": true, + "metadata": { + "volume": { + "id": "volume_3ib4cusfugfqczfk", + "name": "Block", + "shape": "rectangle", + "storeyIndex": 0, + "partIndex": 1 + } + }, + "polygon": [ + [ + 4, + 0 + ], + [ + 10, + 0 + ], + [ + 10, + 5 + ], + [ + 4, + 5 + ] + ], + "holes": [], + "autoFromWalls": true, + "boundaryWallIds": [ + "wall_b9jmeifn7sm0tsim", + "wall_brdsdawnqy4dsusp", + "wall_m0d0065o62y5mmcs", + "wall_yip91gfmwiwnblqy" + ], + "boundarySeparatorIds": [], + "seed": [ + 7, + 2.5 + ], + "spaceRole": "room", + "roomNumber": "", + "enclosureStatus": "enclosed", + "floorFinish": "", + "wallFinish": "", + "ceilingFinish": "", + "ceilingHeight": 2.7, + "occupancy": "", + "clearDimensionPolicy": "none", + "color": "#f59e0b" + }, + "wall_ak1lcqqlwpv243f7": { + "object": "node", + "id": "wall_ak1lcqqlwpv243f7", + "type": "wall", + "name": "Wall 9", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 10, + 5 + ], + "end": [ + 10, + 10 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "wall_dg28x4y5hzglb2hc": { + "object": "node", + "id": "wall_dg28x4y5hzglb2hc", + "type": "wall", + "name": "Wall 10", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 10, + 10 + ], + "end": [ + 4, + 10 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "zone_j565ajxsej42p435": { + "object": "node", + "id": "zone_j565ajxsej42p435", + "type": "zone", + "name": "Right top", + "parentId": "level_0", + "visible": true, + "metadata": { + "volume": { + "id": "volume_3ib4cusfugfqczfk", + "name": "Block", + "shape": "rectangle", + "storeyIndex": 0, + "partIndex": 2 + } + }, + "polygon": [ + [ + 4, + 5 + ], + [ + 10, + 5 + ], + [ + 10, + 10 + ], + [ + 4, + 10 + ] + ], + "holes": [], + "autoFromWalls": true, + "boundaryWallIds": [ + "wall_60v9vzb2s4xssypq", + "wall_ak1lcqqlwpv243f7", + "wall_dg28x4y5hzglb2hc", + "wall_m0d0065o62y5mmcs" + ], + "boundarySeparatorIds": [], + "seed": [ + 7, + 7.5 + ], + "spaceRole": "room", + "roomNumber": "", + "enclosureStatus": "enclosed", + "floorFinish": "", + "wallFinish": "", + "ceilingFinish": "", + "ceilingHeight": 2.7, + "occupancy": "", + "clearDimensionPolicy": "none", + "color": "#10b981" + }, + "wall_o2q7ha4a2m2vny50": { + "object": "node", + "id": "wall_o2q7ha4a2m2vny50", + "type": "wall", + "name": "Wall 11", + "parentId": "level_j6v1596mtvaocjpa", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 0, + 0 + ], + "end": [ + 10, + 0 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "wall_4jv15x6y1h3oxoe8": { + "object": "node", + "id": "wall_4jv15x6y1h3oxoe8", + "type": "wall", + "name": "Wall 12", + "parentId": "level_j6v1596mtvaocjpa", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 10, + 0 + ], + "end": [ + 10, + 10 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "wall_u4xxk16kstfttoqu": { + "object": "node", + "id": "wall_u4xxk16kstfttoqu", + "type": "wall", + "name": "Wall 13", + "parentId": "level_j6v1596mtvaocjpa", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 10, + 10 + ], + "end": [ + 0, + 10 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "wall_p8wy981a6z2w3oxw": { + "object": "node", + "id": "wall_p8wy981a6z2w3oxw", + "type": "wall", + "name": "Wall 14", + "parentId": "level_j6v1596mtvaocjpa", + "visible": true, + "metadata": {}, + "children": [], + "start": [ + 0, + 10 + ], + "end": [ + 0, + 0 + ], + "frontSide": "interior", + "backSide": "exterior" + }, + "zone_uljofwb1f1eetgj4": { + "object": "node", + "id": "zone_uljofwb1f1eetgj4", + "type": "zone", + "name": "Block", + "parentId": "level_j6v1596mtvaocjpa", + "visible": true, + "metadata": { + "volume": { + "id": "volume_3ib4cusfugfqczfk", + "name": "Block", + "shape": "rectangle", + "storeyIndex": 1, + "partIndex": 0 + } + }, + "polygon": [ + [ + 0, + 0 + ], + [ + 10, + 0 + ], + [ + 10, + 10 + ], + [ + 0, + 10 + ] + ], + "holes": [], + "autoFromWalls": true, + "boundaryWallIds": [ + "wall_4jv15x6y1h3oxoe8", + "wall_o2q7ha4a2m2vny50", + "wall_p8wy981a6z2w3oxw", + "wall_u4xxk16kstfttoqu" + ], + "boundarySeparatorIds": [], + "seed": [ + 5, + 5 + ], + "spaceRole": "room", + "roomNumber": "", + "enclosureStatus": "enclosed", + "floorFinish": "", + "wallFinish": "", + "ceilingFinish": "", + "ceilingHeight": 2.7, + "occupancy": "", + "clearDimensionPolicy": "none", + "color": "#ef4444" + }, + "level_agjbv9yb7ksjizx7": { + "object": "node", + "id": "level_agjbv9yb7ksjizx7", + "type": "level", + "name": "Roof", + "parentId": "building_a", + "visible": true, + "metadata": { + "role": "roof", + "label": "Roof", + "referenceLevelId": "level_j6v1596mtvaocjpa" + }, + "children": [ + "roof_sbx40be14tyc9ezy" + ], + "level": 2, + "baseElevation": 0, + "height": 4.1954981558863995 + }, + "roof_sbx40be14tyc9ezy": { + "object": "node", + "id": "roof_sbx40be14tyc9ezy", + "type": "roof", + "name": "Block roof", + "parentId": "level_agjbv9yb7ksjizx7", + "visible": true, + "metadata": { + "volumeId": "volume_3ib4cusfugfqczfk", + "referenceLevelId": "level_j6v1596mtvaocjpa", + "roofLevelId": "level_agjbv9yb7ksjizx7" + }, + "position": [ + 5, + 0, + 5 + ], + "rotation": 0, + "support": { + "kind": "walls" + }, + "children": [ + "rseg_jrkzbxl4ytfh1ris" + ] + }, + "rseg_jrkzbxl4ytfh1ris": { + "object": "node", + "id": "rseg_jrkzbxl4ytfh1ris", + "type": "roof-segment", + "parentId": "roof_sbx40be14tyc9ezy", + "visible": true, + "metadata": {}, + "position": [ + 0, + 0, + 0 + ], + "rotation": 0, + "roofType": "hip", + "width": 10, + "depth": 10, + "trim": { + "left": 0, + "right": 0, + "front": 0, + "back": 0, + "frontLeft": 0, + "frontRight": 0, + "backLeft": 0, + "backRight": 0, + "frontLeftX": 0, + "frontLeftZ": 0, + "frontRightX": 0, + "frontRightZ": 0, + "backLeftX": 0, + "backLeftZ": 0, + "backRightX": 0, + "backRightZ": 0 + }, + "wallHeight": 0, + "pitch": 40, + "wallThickness": 0.1, + "deckThickness": 0.1, + "overhang": 0.3, + "shingleThickness": 0.05, + "managedByParent": false, + "wallShell": "auto", + "shedInsetEndPanels": false, + "gambrelLowerWidthRatio": 0.5, + "gambrelLowerHeightRatio": 0.6, + "mansardSteepWidthRatio": 0.15, + "mansardSteepHeightRatio": 0.7, + "dutchHipWidthRatio": 0.25, + "dutchHipHeightRatio": 0.5, + "dutchWaistLengthRatio": 0.98, + "dutchGabletRake": 0.48, + "dutchTopRakeThickness": 0.21, + "children": [] + }, + "ceiling_0ngoh3y0ja8kfirp": { + "object": "node", + "id": "ceiling_0ngoh3y0ja8kfirp", + "type": "ceiling", + "name": "Block Ceiling", + "parentId": "level_j6v1596mtvaocjpa", + "visible": true, + "metadata": {}, + "children": [], + "polygon": [ + [ + 0.05, + 0.05 + ], + [ + 9.95, + 0.05 + ], + [ + 9.95, + 9.95 + ], + [ + 0.05, + 9.95 + ] + ], + "holes": [], + "holeMetadata": [], + "autoFromWalls": true, + "zoneId": "zone_uljofwb1f1eetgj4", + "boundary": "auto" + }, + "slab_0002e7cn19dngnbw": { + "object": "node", + "id": "slab_0002e7cn19dngnbw", + "type": "slab", + "name": "Floor plate", + "parentId": "level_j6v1596mtvaocjpa", + "visible": true, + "metadata": {}, + "polygon": [ + [ + -0.05, + -0.05 + ], + [ + 10.05, + -0.05 + ], + [ + 10.05, + 10.05 + ], + [ + -0.05, + 10.05 + ] + ], + "holes": [], + "holeMetadata": [], + "elevation": 0.05, + "foundation": { + "type": "none" + }, + "thickness": 0.05, + "recessed": false, + "boundary": "auto", + "plateRole": "base", + "zoneIds": [ + "zone_uljofwb1f1eetgj4" + ], + "autoFromWalls": true + }, + "ceiling_4g4wvqkqjgwmtkim": { + "object": "node", + "id": "ceiling_4g4wvqkqjgwmtkim", + "type": "ceiling", + "name": "Right bottom Ceiling", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "polygon": [ + [ + 4.05, + 0.05 + ], + [ + 9.95, + 0.05 + ], + [ + 9.95, + 4.95 + ], + [ + 4.05, + 4.95 + ] + ], + "holes": [], + "holeMetadata": [], + "autoFromWalls": true, + "zoneId": "zone_l6ma9wgwn4u1h3s6", + "boundary": "auto" + }, + "ceiling_a7k72ji5cfbaqhp5": { + "object": "node", + "id": "ceiling_a7k72ji5cfbaqhp5", + "type": "ceiling", + "name": "Left Ceiling", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "polygon": [ + [ + 0.05, + 0.05 + ], + [ + 3.95, + 0.05 + ], + [ + 3.95, + 9.95 + ], + [ + 0.05, + 9.95 + ] + ], + "holes": [], + "holeMetadata": [], + "autoFromWalls": true, + "zoneId": "zone_1fazxau7jord3psp", + "boundary": "auto" + }, + "ceiling_bx09rretxag6c3vx": { + "object": "node", + "id": "ceiling_bx09rretxag6c3vx", + "type": "ceiling", + "name": "Right top Ceiling", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "children": [], + "polygon": [ + [ + 4.05, + 5.05 + ], + [ + 9.95, + 5.05 + ], + [ + 9.95, + 9.95 + ], + [ + 4.05, + 9.95 + ] + ], + "holes": [], + "holeMetadata": [], + "autoFromWalls": true, + "zoneId": "zone_j565ajxsej42p435", + "boundary": "auto" + }, + "slab_00030i53q9ofvtdh": { + "object": "node", + "id": "slab_00030i53q9ofvtdh", + "type": "slab", + "name": "Floor plate", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "polygon": [ + [ + -0.05, + -0.05 + ], + [ + 10.05, + -0.05 + ], + [ + 10.05, + 10.05 + ], + [ + -0.05, + 10.05 + ] + ], + "holes": [], + "holeMetadata": [], + "elevation": 0.05, + "foundation": { + "type": "none" + }, + "thickness": 0.05, + "recessed": false, + "boundary": "auto", + "plateRole": "base", + "zoneIds": [ + "zone_1fazxau7jord3psp", + "zone_j565ajxsej42p435", + "zone_l6ma9wgwn4u1h3s6" + ], + "autoFromWalls": true + } + } +} diff --git a/packages/viewer/src/lib/reveal-add-volume.test.ts b/packages/viewer/src/lib/reveal-add-volume.test.ts new file mode 100644 index 0000000000..af0701fd05 --- /dev/null +++ b/packages/viewer/src/lib/reveal-add-volume.test.ts @@ -0,0 +1,218 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + BuildingNode, + CeilingNode, + DoorNode, + LevelNode, + loadPlugin, + nodeRegistry, + type RevealLayout, + RoofNode, + RoofSegmentNode, + type SceneCommit, + SiteNode, + SlabNode, + WallNode, + WindowNode, + ZoneNode, +} from '@pascal-app/core' +import { Group } from 'three' +import addVolumeCommit from './__fixtures__/add-volume-commit.json' +import { commitDeleteCandidates, commitRevealCandidates } from './reveal-candidates' +import { FRAMING_PACE, LAZY_REVEAL_TIMING, planLazyReveal } from './reveal-schedule' + +// The owner's case (14 Oct): an add_volume build "pulsed too fast, a bit of a mess". One commit +// makes a whole volume, its storeys, rooms (zones), walls, slabs and roof: at the framing level +// it plays room by room, wall by wall, floors offset, never in one pulse. + +type Nodes = Record +const kind = ( + type: string, + schema: unknown, + category: string, + reveal?: unknown, + revealLayout?: RevealLayout, +) => + ({ + kind: type, + schemaVersion: 1, + schema, + category, + defaults: () => ({}), + capabilities: { ...(reveal ? { reveal } : {}), ...(revealLayout ? { revealLayout } : {}) }, + geometry: () => new Group(), + }) as unknown as AnyNodeDefinition + +let restore: () => void = () => {} +beforeAll(async () => { + restore = nodeRegistry._snapshot() + nodeRegistry._reset() + await loadPlugin({ + id: 'fixture:add-volume', + apiVersion: 1, + nodes: [ + kind('site', SiteNode, 'site'), + kind('building', BuildingNode, 'site'), + kind('level', LevelNode, 'site'), + kind('zone', ZoneNode, 'site', undefined, { + room: (node) => (node.type === 'zone' ? node.polygon : null), + }), + kind( + 'wall', + WallNode, + 'structure', + { phase: 'structure', style: 'rise' }, + { + line: (node) => (node.type === 'wall' ? [node.start, node.end] : null), + }, + ), + kind('slab', SlabNode, 'structure', { phase: 'foundation', style: 'settle', height: 0.08 }), + kind('ceiling', CeilingNode, 'structure', { phase: 'roof', style: 'settle', height: 0.08 }), + kind('door', DoorNode, 'structure', { phase: 'openings', style: 'cut' }), + kind('window', WindowNode, 'structure', { phase: 'openings', style: 'cut' }), + kind('roof', RoofNode, 'structure', { phase: 'roof', style: 'assemble', height: 4 }), + kind('roof-segment', RoofSegmentNode, 'structure'), + ], + }) +}) +afterAll(() => restore()) + +/** + * The commit R&D's add_volume makes for the owner's case, frozen as data (a rectangle block of two + * storeys, a hip roof, three rooms below, a door): this tree has no add_volume, and the plan only + * reads the commit's nodes. + */ +function volume(): SceneCommit { + return { + before: { nodes: addVolumeCommit.before }, + current: { nodes: addVolumeCommit.after }, + author: 'agent', + } as unknown as SceneCommit +} + +/** The commit, staged and planned (once the kinds are registered). */ +function staged() { + const commit = volume() + const created = Object.values(commit.current.nodes).filter( + (node) => !commit.before.nodes[node.id as keyof typeof commit.before.nodes], + ) + const candidates = commitRevealCandidates(commit, 'framing') + const byId = new Map(candidates.map((candidate) => [candidate.id, candidate])) + const plan = planLazyReveal(candidates, 0) + const at = new Map(plan.map((entry) => [entry.id, entry.startMs])) + const wallsOf = (levelId: string) => + created.filter((node) => node.type === 'wall' && node.parentId === levelId) + return { created, byId, plan, at, wallsOf } +} + +describe('add_volume at the framing level', () => { + test('every wall, slab, room and roof it makes goes through the timeline', () => { + const { created, byId } = staged() + const kinds = new Set(created.map((node) => node.type)) + for (const type of ['wall', 'slab', 'zone', 'roof']) expect(kinds.has(type)).toBe(true) + // A roof's segments are not staged on their own at this level: its covering is laid over + // its frame in place, the whole shell at once (`cover`), so they ride with the roof. + for (const node of created) { + if (['wall', 'slab', 'zone', 'roof', 'door', 'window', 'ceiling'].includes(node.type)) + expect(byId.has(node.id)).toBe(true) + if (node.type === 'roof-segment') expect(byId.has(node.id)).toBe(false) + if (node.type === 'roof') expect(byId.get(node.id)?.style).toBe('cover') + } + }) + + test('its walls are grouped by the rooms it makes, and the rooms of a floor go up side by side', () => { + const { created, byId, at, wallsOf } = staged() + const zoneIds = new Set(created.filter((node) => node.type === 'zone').map((node) => node.id)) + const ground = wallsOf('level_0') + expect(ground.length).toBeGreaterThan(6) + for (const wall of ground) expect(zoneIds.has(byId.get(wall.id)!.cluster ?? '')).toBe(true) + const rooms = new Map() + for (const wall of ground) { + const cluster = byId.get(wall.id)!.cluster! + rooms.set(cluster, [...(rooms.get(cluster) ?? []), at.get(wall.id)!]) + } + expect(rooms.size).toBe(3) + const spans = [...rooms.values()].map( + (starts) => [Math.min(...starts), Math.max(...starts)] as const, + ) + spans.sort((left, right) => left[0] - right[0]) + // Each room starts after the one before, while it is still going up. + for (let index = 1; index < spans.length; index += 1) { + expect(spans[index]![0]).toBeGreaterThan(spans[index - 1]![0]) + expect(spans[index]![0]).toBeLessThan(spans[index - 1]![1]) + } + }) + + test('slabs first, the floors offset, the roof once the walls are up; never a pulse', () => { + const { created, plan, at, wallsOf } = staged() + const slabs = created.filter((node) => node.type === 'slab' && node.parentId === 'level_0') + const ground = wallsOf('level_0').map((wall) => at.get(wall.id)!) + for (const slab of slabs) expect(at.get(slab.id)!).toBeLessThan(Math.min(...ground)) + const upperLevel = created.find( + (node) => node.type === 'level' && (node as { level?: number }).level === 1, + ) + const upper = upperLevel ? wallsOf(upperLevel.id).map((wall) => at.get(wall.id)!) : [] + if (upper.length > 0) { + expect(Math.min(...upper)).toBeGreaterThan(Math.min(...ground)) + expect(Math.min(...upper)).toBeLessThan(Math.max(...ground)) + } + const roof = created.find((node) => node.type === 'roof')! + const walls = created.filter((node) => node.type === 'wall').map((wall) => at.get(wall.id)!) + expect(at.get(roof.id)!).toBeGreaterThan(Math.max(...walls)) + const perFrame = new Map() + for (const entry of plan) { + const frame = Math.floor(entry.startMs / (1000 / 60)) + perFrame.set(frame, (perFrame.get(frame) ?? 0) + 1) + } + expect(Math.max(...perFrame.values())).toBeLessThanOrEqual(2) + const end = Math.max( + ...plan.map((entry) => entry.startMs + LAZY_REVEAL_TIMING.durationMs[entry.style]), + ) + // Not a pulse: at least 6 s at the original pace, stretched with it. + expect(end).toBeGreaterThan(6000 * FRAMING_PACE) + expect(end).toBeLessThan(LAZY_REVEAL_TIMING.capMs) + console.log( + `add_volume (2 storeys, 3 rooms below): ${plan.length} pieces, ${created.filter((n) => n.type === 'wall').length} walls, plays ${Math.round(end)} ms`, + ) + }) +}) + +describe('deleting what add_volume made', () => { + const derived = (node: AnyNode) => + (node.type === 'slab' || node.type === 'ceiling') && + ((node as { boundary?: string }).boundary === 'auto' || + (node as { autoFromWalls?: boolean }).autoFromWalls === true) + + test('a floor or a room that goes takes its derived plates and ceilings, in the plan, with it', () => { + const made = volume() + const gone: SceneCommit = { + origin: 'local', + before: made.current, + current: made.before, + } as unknown as SceneCommit + const candidates = commitDeleteCandidates(gone, 'framing') + const ids = new Set(candidates.map((candidate) => candidate.id)) + const plates = Object.values(made.current.nodes).filter(derived) + expect(plates.length).toBeGreaterThan(0) + for (const plate of plates) expect(ids.has(plate.id)).toBe(true) + // And every wall of it, in the same plan, each at its room. + for (const node of Object.values(made.current.nodes)) + if (node.type === 'wall') expect(ids.has(node.id)).toBe(true) + }) + + test('a plate the reconciler derives anew, with no wall or room going, is not a deletion', () => { + const made = volume() + const plates = Object.values(made.current.nodes).filter(derived) + const nodes = { ...made.current.nodes } as Record + for (const plate of plates) delete nodes[plate.id] + const restructured: SceneCommit = { + origin: 'local', + before: made.current, + current: { ...made.current, nodes }, + derivedWrites: new Map(plates.map((plate) => [plate.id, 'all' as const])), + } as unknown as SceneCommit + expect(commitDeleteCandidates(restructured, 'framing')).toEqual([]) + }) +}) diff --git a/packages/viewer/src/lib/reveal-candidates-runs.test.ts b/packages/viewer/src/lib/reveal-candidates-runs.test.ts new file mode 100644 index 0000000000..f845856f0c --- /dev/null +++ b/packages/viewer/src/lib/reveal-candidates-runs.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + type AnyNodeId, + LevelNode, + loadPlugin, + nodeRegistry, + WallNode, +} from '@pascal-app/core' +import { Group } from 'three' +import { layoutRevealCandidates, type StagedCandidate } from './reveal-candidates' + +// Walls in no room are laid out in runs along the outline they make: a run is a stretch of it, not +// every wall of the same distance from where the walk began. + +let restore: () => void = () => {} +beforeAll(async () => { + restore = nodeRegistry._snapshot() + nodeRegistry._reset() + await loadPlugin({ + id: 'fixture:runs', + apiVersion: 1, + nodes: [ + { + kind: 'wall', + schemaVersion: 1, + schema: WallNode, + category: 'structure', + defaults: () => ({}), + capabilities: { + reveal: { phase: 'structure', style: 'rise' }, + revealLayout: { + line: (node: AnyNode) => { + const { start, end } = node as unknown as WallNode + return [start, end] as const + }, + }, + }, + geometry: () => new Group(), + } as unknown as AnyNodeDefinition, + ], + }) +}) +afterAll(() => restore()) + +/** A closed outline of `side` metres a side, a wall to each metre, walked from its corner. */ +function ring(side: number) { + const points: [number, number][] = [] + for (let i = 0; i < side; i += 1) points.push([i, 0]) + for (let i = 0; i < side; i += 1) points.push([side, i]) + for (let i = 0; i < side; i += 1) points.push([side - i, side]) + for (let i = 0; i < side; i += 1) points.push([0, side - i]) + return points.map((start, index) => { + const end = points[(index + 1) % points.length]! + return WallNode.parse({ id: `wall_ring${index}`, parentId: 'level_ring', start, end }) + }) +} + +function laidOut(walls: WallNode[]) { + const level = LevelNode.parse({ id: 'level_ring', level: 0, children: walls.map((w) => w.id) }) + const nodes = Object.fromEntries([level, ...walls].map((n) => [n.id, n])) as Record< + AnyNodeId, + AnyNode + > + const candidates: StagedCandidate[] = walls.map((wall, seq) => ({ + id: wall.id, + phase: 'structure', + style: 'rise', + height: 0, + levelId: level.id, + levelIndex: 0, + depth: 1, + seq, + })) + layoutRevealCandidates(candidates, nodes) + return candidates +} + +const meets = (a: WallNode, b: WallNode) => + [a.start, a.end].some((p) => [b.start, b.end].some((q) => p[0] === q[0] && p[1] === q[1])) + +describe('walls in no room, in runs along their outline', () => { + for (const side of [3, 6]) { + test(`each run of a closed outline of ${side * 4} walls is one stretch of it`, () => { + const walls = ring(side) + const byId = new Map(walls.map((wall) => [wall.id, wall])) + const runs = new Map() + for (const candidate of laidOut(walls)) { + const list = runs.get(candidate.cluster!) ?? [] + list.push(candidate) + runs.set(candidate.cluster!, list) + } + expect(runs.size).toBeGreaterThan(1) + for (const [, run] of runs) { + run.sort((a, b) => a.order! - b.order!) + for (let i = 1; i < run.length; i += 1) + expect(meets(byId.get(run[i - 1]!.id)!, byId.get(run[i]!.id)!)).toBe(true) + } + }) + } + + test('an open chain is walked from one of its ends, so no run straddles its middle', () => { + const walls = Array.from({ length: 12 }, (_, index) => + WallNode.parse({ + id: `wall_row${index}`, + parentId: 'level_ring', + start: [index, 0], + end: [index + 1, 0], + }), + ) + const run = (candidate: StagedCandidate) => candidate.cluster! + const candidates = laidOut(walls) + const first = candidates.filter((c) => run(c) === run(candidates[0]!)) + const xs = first.map((c) => walls.find((w) => w.id === c.id)!.start[0]) + expect(xs.length).toBe(6) + expect(Math.max(...xs) - Math.min(...xs)).toBe(5) + expect([0, 6]).toContain(Math.min(...xs)) + }) +}) diff --git a/packages/viewer/src/lib/reveal-candidates.ts b/packages/viewer/src/lib/reveal-candidates.ts index 81adc7cdce..8e96d14bd9 100644 --- a/packages/viewer/src/lib/reveal-candidates.ts +++ b/packages/viewer/src/lib/reveal-candidates.ts @@ -1,18 +1,24 @@ import { type AnyNode, type AnyNodeId, + isDerivedNode, type LevelNode, nodeRegistry, + type RevealConfig, + type RevealPlanPoint, type RevealStyle, type SceneCommit, } from '@pascal-app/core' import type { RevealCandidate } from './reveal-schedule' +import type { PlayedStyle } from './reveal-wipe' /** * How much of the construction plays. `off` shows a build at once; `simple` * keeps the scattered order with only `rise` and `scale`; `full` adds the - * falls, settles, cuts, assembled roofs and the dust. `framing` is `full` in - * the viewer: plugins that stage their own members (the framing) read it. + * falls, settles, cuts, assembled roofs and the dust. `framing` plays the + * same motions on the lazy, nested schedule (floor by floor, room by room, + * piece by piece; `planLazyReveal`), overdone a little, and plugins that stage + * their own members (the framing) read it and play them in the prerolls. */ export type ConstructionRevealLevel = 'off' | 'simple' | 'full' | 'framing' @@ -23,11 +29,55 @@ export type StagedCandidate = RevealCandidate & { host?: string } export const PART_LEAD = 0.05 /** `simple` keeps the stagger with only the two slice-one motions. */ -export function styleAt(style: RevealStyle, at: ConstructionRevealLevel): RevealStyle { +export function styleAt(style: RevealStyle, at: ConstructionRevealLevel): PlayedStyle { + // Framing lays a roof's covering over its frame in place; Full drops the whole roof. + if (at === 'framing') return style === 'assemble' ? 'cover' : style if (at !== 'simple' || style === 'rise') return style return 'scale' } +/** Kinds that hold the others: staging one would unmount everything under it. */ +export const CONTAINERS: ReadonlySet = new Set(['site', 'building', 'level']) + +/** + * The reveal a new node of a kind that declares none comes in with, so nothing a build adds pops in + * at once: by where it stands (on an assembled node such as a roof, it falls onto it) and what it is. Null for the containers, + * the annotations and the kinds that draw nothing. + */ +function defaultReveal(node: AnyNode, nodes: Record): RevealConfig | null { + const definition = nodeRegistry.get(node.type) + if (!definition || CONTAINERS.has(node.type)) return null + if (definition.category === 'analysis' || !(definition.renderer || definition.geometry)) + return null + let parent = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + for (let guard = 0; parent && guard < 32; guard += 1) { + if (nodeRegistry.get(parent.type)?.capabilities?.reveal?.style === 'assemble') + return { phase: 'roof', style: 'drop', height: 2 } + if (parent.type === 'level') break + parent = parent.parentId ? nodes[parent.parentId as AnyNodeId] : undefined + } + switch (definition.category) { + case 'structure': + return { phase: 'structure', style: 'scale' } + case 'utility': + return { phase: 'openings', style: 'scale' } + case 'site': + return { phase: 'foundation', style: 'scale' } + default: + return { phase: 'furnishing', style: 'drop', height: 0.3 } + } +} + +/** Whether `node` comes in with a new host the commit stages (it is drawn inside it), not on its own. */ +function ridesWithHost( + node: AnyNode, + before: SceneCommit['before'], + current: SceneCommit['current'], +) { + const parent = node.parentId ? current.nodes[node.parentId as AnyNodeId] : undefined + return Boolean(parent && !CONTAINERS.has(parent.type) && !before.nodes[parent.id as AnyNodeId]) +} + /** What a node declares and where it stands: its candidate, or null for a kind that declares no reveal. */ function candidateOf( node: AnyNode, @@ -35,8 +85,11 @@ function candidateOf( at: ConstructionRevealLevel, source: string | null, seq: number, + fallback = false, ): StagedCandidate | null { - const reveal = nodeRegistry.get(node.type)?.capabilities?.reveal + const reveal = + nodeRegistry.get(node.type)?.capabilities?.reveal ?? + (fallback ? defaultReveal(node, nodes) : null) if (!reveal) return null let depth = 0 let levelId: string | null = null @@ -68,7 +121,10 @@ function candidateOf( /** * What `commit` stages at `at`: each node it creates whose kind declares a * reveal, and the new parts (children declaring none) of each one that - * assembles. `seq` counts on from `firstSeq` in arrival order. + * assembles. A new node of a kind that declares none comes in with a default + * reveal when what holds it was already there (under a new host, it comes in + * with the host). `seq` counts on from `firstSeq` in arrival order. Each + * candidate carries its room and its place in it (`layoutRevealCandidates`). */ export function commitRevealCandidates( { before, current, author }: SceneCommit, @@ -80,7 +136,16 @@ export function commitRevealCandidates( for (const id in current.nodes) { if (before.nodes[id as AnyNodeId]) continue const node = current.nodes[id as AnyNodeId] - const candidate = node && candidateOf(node, current.nodes, at, author ?? null, seq) + const candidate = + node && + candidateOf( + node, + current.nodes, + at, + author ?? null, + seq, + !ridesWithHost(node, before, current), + ) if (!(node && candidate)) continue seq += 1 candidates.push(candidate) @@ -105,9 +170,232 @@ export function commitRevealCandidates( }) } } + layoutRevealCandidates(candidates, current.nodes) return candidates } +type Pt = RevealPlanPoint + +const isPt = (value: unknown): value is Pt => + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number' + +function polygonOf(node: AnyNode): readonly Pt[] | null { + const polygon = (node as { polygon?: unknown }).polygon + return Array.isArray(polygon) && polygon.length >= 3 && polygon.every(isPt) ? polygon : null +} + +/** The room a node outlines, as its kind declares it (`capabilities.revealLayout.room`). */ +function roomOf(node: AnyNode): readonly Pt[] | null { + const polygon = nodeRegistry.get(node.type)?.capabilities?.revealLayout?.room?.(node) + return polygon && polygon.length >= 3 && polygon.every(isPt) ? polygon : null +} + +/** The line a node bounds rooms along, as its kind declares it (`capabilities.revealLayout.line`). */ +function lineOf(node: AnyNode): readonly [Pt, Pt] | null { + const line = nodeRegistry.get(node.type)?.capabilities?.revealLayout?.line?.(node) + return line && isPt(line[0]) && isPt(line[1]) ? line : null +} + +const middleOf = ([start, end]: readonly [Pt, Pt]): Pt => [ + (start[0] + end[0]) / 2, + (start[1] + end[1]) / 2, +] + +function centroidOf(polygon: readonly Pt[]): Pt { + let x = 0 + let z = 0 + for (const [px, pz] of polygon) { + x += px + z += pz + } + return [x / polygon.length, z / polygon.length] +} + +function areaOf(polygon: readonly Pt[]): number { + let twice = 0 + for (let index = 0; index < polygon.length; index += 1) { + const [x0, z0] = polygon[index]! + const [x1, z1] = polygon[(index + 1) % polygon.length]! + twice += x0 * z1 - x1 * z0 + } + return Math.abs(twice) / 2 +} + +function inside([x, z]: Pt, polygon: readonly Pt[]): boolean { + let within = false + for (let index = 0, last = polygon.length - 1; index < polygon.length; last = index++) { + const [xi, zi] = polygon[index]! + const [xj, zj] = polygon[last]! + if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) within = !within + } + return within +} + +/** Where a node stands in its level's plan: a line's middle, a polygon's centre, a placed node's spot. */ +function planPointOf(node: AnyNode): Pt | null { + const line = lineOf(node) + if (line) return middleOf(line) + const { start, end, position } = node as { start?: unknown; end?: unknown; position?: unknown } + if (isPt(start) && isPt(end)) return middleOf([start, end]) + const polygon = polygonOf(node) + if (polygon) return centroidOf(polygon) + if (Array.isArray(position) && typeof position[0] === 'number' && typeof position[2] === 'number') + return [position[0], position[2]] + return null +} + +/** The node right under the level that `id` stands in (itself, or the host it hangs on). */ +function standingOf(id: string, nodes: Record): AnyNode | null { + let node = nodes[id as AnyNodeId] + for (let guard = 0; node && guard < 32; guard += 1) { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (!parent || parent.type === 'level') return node + node = parent + } + return null +} + +/** Walls a run holds before the next run of the same outline starts beside it. */ +const WALLS_PER_RUN = 6 +/** Wall ends closer than this (m) meet. */ +const MEETS_M = 0.05 + +/** + * Gives each candidate its room for the lazy schedule: the room it stands in (a node on a line, the + * room on either side of its middle), else, for a node on a line, a run of the lines it meets end to + * end (`capabilities.revealLayout`); its room's + * sweep across the floor; and its place in the room (around the room's centre, or along the run). + * What stands in no room keeps none: it belongs to the floor. + */ +export function layoutRevealCandidates( + candidates: StagedCandidate[], + nodes: Record, +): void { + const zonesByLevel = new Map() + const zonesOf = (levelId: string) => { + let zones = zonesByLevel.get(levelId) + if (zones) return zones + zones = [] + const level = nodes[levelId as AnyNodeId] as (AnyNode & { children?: unknown[] }) | undefined + for (const childId of level?.children ?? []) { + const child = typeof childId === 'string' ? nodes[childId as AnyNodeId] : undefined + const polygon = child ? roomOf(child) : null + if (child && polygon) zones.push({ id: child.id, polygon, centre: centroidOf(polygon) }) + } + zones.sort((left, right) => areaOf(left.polygon) - areaOf(right.polygon)) + zonesByLevel.set(levelId, zones) + return zones + } + const turnAround = (centre: Pt, point: Pt) => { + const angle = Math.atan2(point[1] - centre[1], point[0] - centre[0]) + return angle < 0 ? angle + 2 * Math.PI : angle + } + // Lines in no room, by level: they make runs; what they host follows them. + const loose = new Map() + const onLooseWalls: { candidate: StagedCandidate; wallId: string; point: Pt }[] = [] + for (const candidate of candidates) { + if (!candidate.levelId) continue + const standing = standingOf(candidate.host ?? candidate.id, nodes) + const point = standing && planPointOf(standing) + if (!(standing && point)) continue + const zones = zonesOf(candidate.levelId) + const line = lineOf(standing) + let probes: Pt[] = [point] + if (line) { + const [start, end] = line + const length = Math.hypot(end[0] - start[0], end[1] - start[1]) || 1 + const nx = -(end[1] - start[1]) / length + const nz = (end[0] - start[0]) / length + probes = [ + [point[0] + nx * 0.3, point[1] + nz * 0.3], + [point[0] - nx * 0.3, point[1] - nz * 0.3], + ] + } + const zone = zones.find((candidateZone) => + probes.some((probe) => inside(probe, candidateZone.polygon)), + ) + if (zone) { + candidate.cluster = zone.id + candidate.sweep = zone.centre[0] + zone.centre[1] + candidate.order = turnAround(zone.centre, point) + continue + } + if (line && standing.id === candidate.id) { + const list = loose.get(candidate.levelId) ?? [] + list.push({ candidate, line }) + loose.set(candidate.levelId, list) + continue + } + if (line) onLooseWalls.push({ candidate, wallId: standing.id, point }) + candidate.order = point[0] + point[1] + } + for (const [levelId, walls] of loose) layoutRuns(levelId, walls) + if (onLooseWalls.length === 0) return + const byId = new Map(candidates.map((candidate) => [candidate.id, candidate])) + for (const { candidate, wallId } of onLooseWalls) { + const wall = byId.get(wallId) + if (!wall?.cluster) continue + candidate.cluster = wall.cluster + candidate.sweep = wall.sweep + candidate.order = wall.order + } +} + +/** Lines in no room: the lines that meet end to end make an outline, cut in runs along it. */ +function layoutRuns( + levelId: string, + walls: { candidate: StagedCandidate; line: readonly [Pt, Pt] }[], +) { + const key = ([x, z]: Pt) => `${Math.round(x / MEETS_M)}:${Math.round(z / MEETS_M)}` + const byEnd = new Map() + const ends = walls.map(({ line: [start, end] }) => [key(start), key(end)] as const) + ends.forEach((pair, index) => { + for (const end of pair) byEnd.set(end, [...(byEnd.get(end) ?? []), index]) + }) + const sweepOf = (index: number) => { + const [x, z] = middleOf(walls[index]!.line) + return x + z + } + const meeting = (index: number) => + ends[index]!.flatMap((end) => byEnd.get(end) ?? []).filter((next) => next !== index) + const seen = new Set() + const starts = walls + .map((_, index) => index) + .sort((left, right) => sweepOf(left) - sweepOf(right)) + let outline = 0 + for (const first of starts) { + if (seen.has(first)) continue + // An open chain is walked from one of its free ends, a closed outline from its first wall in + // the sweep: wall to the wall it meets, so each run is one stretch of the outline. + const component = new Set([first]) + for (const index of component) for (const next of meeting(index)) component.add(next) + const begin = + [...component] + .filter((index) => ends[index]!.some((end) => byEnd.get(end)?.length === 1)) + .sort((left, right) => sweepOf(left) - sweepOf(right))[0] ?? first + const order: number[] = [] + const stack = [begin] + while (stack.length > 0) { + const index = stack.pop()! + if (seen.has(index)) continue + seen.add(index) + order.push(index) + stack.push(...meeting(index).reverse()) + } + order.forEach((index, rank) => { + const run = Math.floor(rank / WALLS_PER_RUN) + const candidate = walls[index]!.candidate + candidate.cluster = `${levelId}:run:${outline}:${run}` + candidate.sweep = sweepOf(order[run * WALLS_PER_RUN]!) + candidate.order = rank + }) + outline += 1 + } +} + /** Moved less than this (metres, radians) and a piece is where it was. */ const PLACED_EPSILON = 1e-4 @@ -125,8 +413,12 @@ function placedDifferently(was: unknown, now: unknown): boolean { /** Where a piece stood in its level: its position, and its turn about the vertical. */ export type Placement = { position: [number, number, number]; yaw: number } -/** A piece moved by a commit; `from` is where it stood when it can glide from there, else null. */ -export type ChangeCandidate = StagedCandidate & { from: Placement | null } +/** + * A piece changed by a commit. `from` is where a moved piece stood when it can glide from there, else + * null; `edited` marks a piece whose picture was edited (a wall bent, a slab resized), which plays its + * reveal again, quickly (`commitEditCandidates`). + */ +export type ChangeCandidate = StagedCandidate & { from: Placement | null; edited?: true } /** Moved less than this (metres, radians) and a piece does not glide: it is where it was. */ const GLIDES_FROM = 0.02 @@ -194,28 +486,127 @@ export function commitChangeCandidates( /** * What taking `ids` back reveals: each of them, and everything under them (a wall's openings, a - * room's furniture), whose kind declares a reveal, as the build would have staged it. An - * assembled node (a roof) goes whole, not part by part. + * room's furniture), as the build would have staged it: by what its kind declares, or the default + * reveal a kind that declares none came in with. What stands in a host that goes (a segment of a + * stair, a part of a pool) goes with it; an assembled node (a roof) goes whole, not part by part. */ export function nodeRevealCandidates( ids: Iterable, nodes: Record, at: ConstructionRevealLevel, ): RevealCandidate[] { + const asked = new Set(ids) const seen = new Set() const candidates: RevealCandidate[] = [] const visit = (id: string, guard = 0) => { const node = nodes[id as AnyNodeId] if (!node || seen.has(id) || guard > 64) return seen.add(id) - const candidate = candidateOf(node, nodes, at, null, candidates.length) + const host = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + const goesWithHost = Boolean( + host && !CONTAINERS.has(host.type) && (asked.has(host.id) || seen.has(host.id)), + ) + const candidate = candidateOf(node, nodes, at, null, candidates.length, !goesWithHost) if (candidate) candidates.push(candidate) - // An assembled node's parts are not staged on their own. - if (candidate?.style === 'assemble') return + // An assembled or covered node's parts are not staged on their own. + if (candidate?.style === 'assemble' || candidate?.style === 'cover') return for (const childId of (node as AnyNode & { children?: unknown[] }).children ?? []) { if (typeof childId === 'string') visit(childId, guard + 1) } } - for (const id of ids) visit(id) + for (const id of asked) visit(id) + return candidates +} + +/** How an edited node plays again: a quick re-rise or re-scale, never a fall from a height. */ +const EDIT_STYLE: Record = { + rise: 'rise', + scale: 'scale', + settle: 'settle', + drop: 'scale', + cut: 'scale', + assemble: 'scale', + cover: 'scale', +} + +const sameValue = (was: unknown, now: unknown) => + Object.is(was, now) || JSON.stringify(was) === JSON.stringify(now) + +/** + * What `commit` edits: each node already there whose kind lists `capabilities.revealOnEdit` and one of + * those fields (the ones that make its picture) changed. A change to any other field stays instant, and + * so does what the structure reconciler derived from an edit (`derivedWrites`: the plates and ceilings it + * owns, the fields it wrote on a node): a wall moved does not make its room's floor rise again. + */ +export function commitEditCandidates( + { before, current, author, derivedWrites }: SceneCommit, + at: ConstructionRevealLevel, + firstSeq = 0, +): ChangeCandidate[] { + const candidates: ChangeCandidate[] = [] + let seq = firstSeq + for (const id in current.nodes) { + const was = before.nodes[id as AnyNodeId] + const node = current.nodes[id as AnyNodeId] + const derived = derivedWrites?.get(id as AnyNodeId) + if (!(was && node) || was === node || derived === 'all' || isDerivedNode(node)) continue + const fields = nodeRegistry.get(node.type)?.capabilities?.revealOnEdit + const edited = fields?.some( + (field) => + !derived?.has(field) && + !sameValue(was[field as keyof AnyNode], node[field as keyof AnyNode]), + ) + if (!edited) continue + const candidate = candidateOf(node, current.nodes, at, author ?? null, seq, true) + if (!candidate) continue + seq += 1 + const style = EDIT_STYLE[candidate.style] + candidates.push({ + ...candidate, + style, + height: style === candidate.style ? candidate.height : 0, + from: null, + edited: true, + }) + } + return candidates +} + +/** + * What `commit` deletes that the picture can play out: each node that was there and is gone whose kind + * stages a reveal, as it was staged when built (a node standing in a host that goes with it goes with + * its host). Derived construction the reconciler retires is not a deletion the person or the agent made. + */ +export function commitDeleteCandidates( + { before, current, author, derivedWrites }: SceneCommit, + at: ConstructionRevealLevel, + firstSeq = 0, +): StagedCandidate[] { + const candidates: StagedCandidate[] = [] + let seq = firstSeq + // A plate or a ceiling the reconciler owns goes with its room: it plays when the commit takes a + // wall or a room off its level, and not when the reconciler merely derives it anew. + const goingLevels = new Set() + for (const id in before.nodes) { + const node = before.nodes[id as AnyNodeId] + if (current.nodes[id as AnyNodeId] || !node?.parentId) continue + if (nodeRegistry.get(node.type)?.capabilities?.revealLayout) goingLevels.add(node.parentId) + } + for (const id in before.nodes) { + if (current.nodes[id as AnyNodeId]) continue + const node = before.nodes[id as AnyNodeId] + if (!node) continue + if (isDerivedNode(node)) { + if (!(node.parentId && goingLevels.has(node.parentId))) continue + } else if (derivedWrites?.get(id as AnyNodeId) === 'all') continue + const host = node.parentId ? before.nodes[node.parentId as AnyNodeId] : undefined + const goesWithHost = Boolean( + host && !CONTAINERS.has(host.type) && !current.nodes[host.id as AnyNodeId], + ) + const candidate = candidateOf(node, before.nodes, at, author ?? null, seq, !goesWithHost) + if (!candidate) continue + seq += 1 + candidates.push(candidate) + } return candidates } diff --git a/packages/viewer/src/lib/reveal-declared-kinds.test.ts b/packages/viewer/src/lib/reveal-declared-kinds.test.ts new file mode 100644 index 0000000000..198897c6f7 --- /dev/null +++ b/packages/viewer/src/lib/reveal-declared-kinds.test.ts @@ -0,0 +1,199 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + type Capabilities, + LevelNode, + loadPlugin, + nodeRegistry, + RoofNode, + type SceneCommit, + SlabNode, + WallNode, + ZoneNode, +} from '@pascal-app/core' +import { Group } from 'three' +import { commitDeleteCandidates, commitRevealCandidates } from './reveal-candidates' + +// The reveal asks the registry what a kind is (an assembled host, a room, a line in a plan) and +// never names a kind: a plugin kind that declares what a built-in declares is staged exactly as the +// built-in is. Each case builds the same scene with the built-in and with the plugin's kinds. + +const kind = (type: string, schema: unknown, category: string, capabilities: Capabilities = {}) => + ({ + kind: type, + schemaVersion: 1, + schema, + category, + defaults: () => ({}), + capabilities, + geometry: () => new Group(), + }) as unknown as AnyNodeDefinition + +const roomOf = (node: AnyNode) => + (node as unknown as { polygon?: [number, number][] }).polygon ?? null +const lineOf = (node: AnyNode) => { + const { start, end } = node as unknown as { start?: [number, number]; end?: [number, number] } + return start && end ? ([start, end] as const) : null +} + +let restore: () => void = () => {} +beforeAll(async () => { + restore = nodeRegistry._snapshot() + nodeRegistry._reset() + await loadPlugin({ + id: 'fixture:declared', + apiVersion: 1, + nodes: [ + kind('level', LevelNode, 'site'), + kind('roof', RoofNode, 'structure', { + reveal: { phase: 'roof', style: 'assemble', height: 4 }, + }), + kind('zone', ZoneNode, 'site', { + revealLayout: { room: (n) => (n.type === 'zone' ? n.polygon : null) }, + }), + kind('wall', WallNode, 'structure', { + reveal: { phase: 'structure', style: 'rise' }, + revealLayout: { line: (n) => (n.type === 'wall' ? [n.start, n.end] : null) }, + }), + kind('slab', SlabNode, 'structure', { + reveal: { phase: 'foundation', style: 'settle', height: 0.08 }, + }), + // A plugin's kinds, declaring what the three above declare. + kind('x:assembly', RoofNode, 'structure', { + reveal: { phase: 'roof', style: 'assemble', height: 4 }, + }), + kind('x:part', RoofNode, 'structure'), + kind('x:room', ZoneNode, 'site', { revealLayout: { room: roomOf } }), + kind('x:beam', WallNode, 'structure', { + reveal: { phase: 'structure', style: 'rise' }, + revealLayout: { line: lineOf }, + }), + // No declaration: a node that stands on a line by its own fields, and no more. + kind('x:rail', WallNode, 'structure', { reveal: { phase: 'structure', style: 'rise' } }), + ], + }) +}) +afterAll(() => restore()) + +const level = LevelNode.parse({ id: 'level_x', level: 0 }) +const raw = (fields: Record) => fields as unknown as AnyNode +const square: [number, number][] = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] +const room = (type: string, id: string) => raw({ id, type, parentId: level.id, polygon: square }) +const along = (type: string, id: string, start: [number, number], end: [number, number]) => + raw({ id, type, parentId: level.id, start, end }) + +function commitOf(created: AnyNode[], existing: AnyNode[] = []): SceneCommit { + const nodes = (list: AnyNode[]) => + Object.fromEntries( + [{ ...level, children: list.map((node) => node.id) } as AnyNode, ...list].map((node) => [ + node.id, + node, + ]), + ) as SceneCommit['before']['nodes'] + return { + before: { nodes: nodes(existing) }, + current: { nodes: nodes([...existing, ...created]) }, + author: 'agent', + } as unknown as SceneCommit +} + +/** The layout of one candidate, without the ids, which differ between the two scenes. */ +function layoutOf(commit: SceneCommit, id: string, rooms: Record) { + const found = commitRevealCandidates(commit, 'framing').find((candidate) => candidate.id === id)! + return { + phase: found.phase, + style: found.style, + cluster: rooms[found.cluster ?? ''] ?? found.cluster?.replace(/^[^:]+/, 'level'), + sweep: found.sweep, + order: found.order, + } +} + +describe('a kind that declares what a built-in declares is staged as the built-in is', () => { + test('a node on an assembled host falls onto it, whatever the host is called', () => { + const staged = (host: string, part: string) => { + const hostNode = raw({ id: `${host}_h`, type: host, parentId: level.id, children: [] }) + const partNode = raw({ id: `${part}_p`, type: part, parentId: hostNode.id }) + const [candidate] = commitRevealCandidates(commitOf([partNode], [hostNode]), 'full') + return ( + candidate && { phase: candidate.phase, style: candidate.style, height: candidate.height } + ) + } + expect(staged('roof', 'x:part')).toEqual({ phase: 'roof', style: 'drop', height: 2 }) + expect(staged('x:assembly', 'x:part')).toEqual(staged('roof', 'x:part')) + }) + + test('a node on a line in a room comes in with its room, around the room’s centre', () => { + const builtIn = commitOf([room('zone', 'zone_a'), along('wall', 'wall_a', [0, 0], [4, 0])]) + const plugin = commitOf([room('x:room', 'room_a'), along('x:beam', 'beam_a', [0, 0], [4, 0])]) + const expected = layoutOf(builtIn, 'wall_a', { zone_a: 'room' }) + expect(expected.cluster).toBe('room') + expect(layoutOf(plugin, 'beam_a', { room_a: 'room' })).toEqual(expected) + }) + + test('lines in no room run along the lines they meet, as walls do', () => { + const lines: [number, number][][] = [ + [ + [10, 0], + [14, 0], + ], + [ + [14, 0], + [14, 4], + ], + [ + [14, 4], + [10, 4], + ], + ] + const builtIn = commitOf(lines.map(([a, b], i) => along('wall', `wall_${i}`, a, b))) + const plugin = commitOf(lines.map(([a, b], i) => along('x:beam', `beam_${i}`, a, b))) + const was = commitRevealCandidates(builtIn, 'framing') + const now = commitRevealCandidates(plugin, 'framing') + expect(was.every((c) => c.cluster?.includes(':run:'))).toBe(true) + expect(now.map(({ cluster, sweep, order }) => ({ cluster, sweep, order }))).toEqual( + was.map(({ cluster, sweep, order }) => ({ cluster, sweep, order })), + ) + }) + + test('without the declaration a loose line is not linked into runs', () => { + const loose = commitOf([ + along('x:rail', 'rail_a', [10, 0], [14, 0]), + along('x:rail', 'rail_b', [14, 0], [14, 4]), + ]) + for (const candidate of commitRevealCandidates(loose, 'framing')) + expect(candidate.cluster).toBeUndefined() + }) + + test('a derived plate goes with the line or the room a commit takes off its level', () => { + const plate = SlabNode.parse({ + id: 'slab_auto', + parentId: level.id, + polygon: square, + autoFromWalls: true, + }) as AnyNode + const goes = (leaving: AnyNode) => { + const before = commitOf([plate, leaving]) + const commit = { ...before, before: before.current, current: commitOf([]).current } + return commitDeleteCandidates(commit as SceneCommit, 'framing').some( + (candidate) => candidate.id === plate.id, + ) + } + for (const type of ['wall', 'x:beam', 'zone', 'x:room']) + expect( + goes( + type.endsWith('room') || type === 'zone' + ? room(type, 'a') + : along(type, 'a', [0, 0], [4, 0]), + ), + ).toBe(true) + // A kind that declares nothing about plans does not take a plate with it. + expect(goes(along('x:rail', 'a', [0, 0], [4, 0]))).toBe(false) + }) +}) diff --git a/packages/viewer/src/lib/reveal-pose.ts b/packages/viewer/src/lib/reveal-pose.ts index 7dcffd98de..79ea6f2cbf 100644 --- a/packages/viewer/src/lib/reveal-pose.ts +++ b/packages/viewer/src/lib/reveal-pose.ts @@ -1,5 +1,6 @@ -import type { RevealStyle } from '@pascal-app/core' import { Box3, Matrix4, type Mesh, type Object3D, Vector3 } from 'three' +import type { PlayedStyle } from './reveal-wipe' +import { clearRevealWipe } from './reveal-wipe' /** * A presentation-only pose composed on a registered root. @@ -86,6 +87,8 @@ export function setGlidePose( } export function clearRevealPose(object: Object3D): void { + // A covering laid in place ends with the pose that held its node. + clearRevealWipe(object) if (!poses.delete(object)) return delete (object as Partial>).updateMatrix if (object.matrixAutoUpdate) object.updateMatrix() @@ -118,8 +121,16 @@ export type RevealPoseOptions = { lead?: number /** A heavier drop lands with a bigger squash and hop; 1 by default. The last piece of a build is heavier. */ weight?: number + /** + * A rise, a scale or a settle goes past its rest and comes back (GSAP's `back.out` strength, + * 1.7 is GSAP's default); 0, the default, is a plain ease out. + */ + overshoot?: number } +/** GSAP's `back.out(s)`: past 1 by a little near the end, exactly 1 at the end. */ +const easeOutBack = (t: number, s: number) => 1 + (s + 1) * (t - 1) ** 3 + s * (t - 1) ** 2 + /** * Where a `style` reveal holds its node at `t` in [0, 1]: a lift in metres * along the parent's up and a scale about the style's pivot. At `t >= 1` it @@ -127,7 +138,7 @@ export type RevealPoseOptions = { * rounding error. */ export function revealPoseAt( - style: RevealStyle, + style: PlayedStyle, t: number, height: number, out: RevealPoseSample = { lift: 0, scale: new Vector3() }, @@ -135,19 +146,21 @@ export function revealPoseAt( ): RevealPoseSample { out.lift = 0 out.scale.set(1, 1, 1) - if (t >= 1 || style === 'assemble') return out + if (t >= 1 || style === 'assemble' || style === 'cover') return out const at = Math.max(0, t) + const overshoot = Math.max(0, options.overshoot ?? 0) + const eased = overshoot > 0 ? easeOutBack(at, overshoot) : easeOutCubic(at) switch (style) { case 'rise': - out.scale.y = Math.max(MIN_SCALE, easeOutCubic(at)) + out.scale.y = Math.max(MIN_SCALE, eased) break case 'scale': { - const f = Math.max(MIN_SCALE, easeOutCubic(at)) + const f = Math.max(MIN_SCALE, eased) out.scale.set(f, f, f) break } case 'settle': - out.lift = height * (1 - easeOutCubic(at)) + out.lift = height * (1 - eased) break case 'drop': { const lead = Math.max(0, options.lead ?? 0) @@ -203,7 +216,7 @@ const smoothstep = (low: number, high: number, value: number) => { * thrown up does. At `p >= 1` it is gone: its scale at the least and its opacity 0. */ export function retractPoseAt( - style: RevealStyle, + style: PlayedStyle, p: number, height: number, out: RetractPoseSample = { lift: 0, scale: new Vector3(), opacity: 1 }, @@ -223,6 +236,10 @@ export function retractPoseAt( case 'cut': out.scale.z = shrunk break + case 'cover': + // Its covering lifts off row by row (`setRevealWipe`); the bare node goes with the last. + if (at >= 1) out.scale.set(MIN_SCALE, MIN_SCALE, MIN_SCALE) + break case 'settle': case 'drop': case 'assemble': @@ -244,17 +261,19 @@ export function retractPoseAt( * How far into its duration each style has arrived, the frame its contact or its last movement * lands on: a drop's impact, a rise's last centimetre. An assembled node holds still: its parts land. */ -export const REVEAL_ARRIVAL: Record = { +export const REVEAL_ARRIVAL: Record = { rise: 1, scale: 1, settle: 1, drop: DROP_FALL, cut: 1, assemble: 0, + cover: 1, } /** Whether `style` has landed at `t`: a drop's impact, after which it only settles. */ -export function revealHasLanded(style: RevealStyle, t: number): boolean { +export function revealHasLanded(style: PlayedStyle, t: number): boolean { + if (style === 'cover') return t >= 1 return style !== 'drop' || t >= DROP_FALL } @@ -286,7 +305,7 @@ export function revealLocalBounds(root: Object3D, out: Box3 = new Box3()): Box3 * base), the centre of the bounds for `cut`, and the base of their centre for * the rest. */ -export function revealPivot(box: Box3, style: RevealStyle): Vector3 { +export function revealPivot(box: Box3, style: PlayedStyle): Vector3 { if (box.isEmpty()) return new Vector3() const centre = box.getCenter(new Vector3()) if (style === 'rise') return new Vector3(0, box.min.y, 0) diff --git a/packages/viewer/src/lib/reveal-schedule.test.ts b/packages/viewer/src/lib/reveal-schedule.test.ts index 4d258611c5..1ff62410da 100644 --- a/packages/viewer/src/lib/reveal-schedule.test.ts +++ b/packages/viewer/src/lib/reveal-schedule.test.ts @@ -1,11 +1,21 @@ import { describe, expect, test } from 'bun:test' import type { RevealPhase } from '@pascal-app/core' import { + ANIMATION_SPEEDS, + FRAMING_PACE, + LAZY_REVEAL_BASE_TIMING, + LAZY_REVEAL_TIMING, + lazyTimingAt, + normalizeAnimationSpeed, + pacedLazyTiming, + planLazyReveal, planRetract, planReveal, RETRACT_TIMING, REVEAL_TIMING, type RevealCandidate, + retractTimingAt, + revealTimingAt, } from './reveal-schedule' let seq = 0 @@ -145,3 +155,299 @@ describe('the schedule in reverse', () => { expect(only!.startMs).toBe(700) }) }) + +describe('the lazy, nested schedule (framing)', () => { + /** A floor of `rooms` rooms of `walls` walls each, with a slab, a door a room and `items` a room. */ + function floor(level: number, rooms: number, walls = 4, items = 3, prerollMs = 0) { + const nodes: RevealCandidate[] = [ + candidate(`slab_${level}`, 'foundation', level, { style: 'settle', height: 0.08 }), + ] + for (let room = 0; room < rooms; room += 1) { + const cluster = `zone_${level}_${room}` + const inRoom = { cluster, sweep: room } + for (let wall = 0; wall < walls; wall += 1) + nodes.push( + candidate(`wall_${level}_${room}_${wall}`, 'structure', level, { + ...inRoom, + order: wall, + ...(prerollMs ? { prerollMs } : {}), + }), + ) + nodes.push( + candidate(`door_${level}_${room}`, 'openings', level, { + ...inRoom, + style: 'cut', + depth: 3, + }), + ) + for (let item = 0; item < items; item += 1) + nodes.push( + candidate(`item_${level}_${room}_${item}`, 'furnishing', level, { + ...inRoom, + style: 'drop', + height: 0.3, + }), + ) + } + return nodes + } + const startsOf = (plan: ReturnType) => { + const byId = new Map(plan.map((entry) => [entry.id, entry.startMs])) + return (id: string) => byId.get(id)! + } + const endOf = (plan: ReturnType) => + Math.max(...plan.map((entry) => entry.startMs + LAZY_REVEAL_TIMING.durationMs[entry.style])) + + test('a floor builds phase after phase, its rooms side by side a beat apart, each room wall by wall', () => { + const plan = planLazyReveal(floor(0, 3), 0) + const at = startsOf(plan) + const walls = (room: number) => [0, 1, 2, 3].map((wall) => at(`wall_0_${room}_${wall}`)) + // The slab, then every wall, then the doors, then the furniture. + expect(Math.min(...walls(0))).toBeGreaterThan(at('slab_0')) + const lastWall = Math.max(...[0, 1, 2].flatMap(walls)) + for (const room of [0, 1, 2]) expect(at(`door_0_${room}`)).toBeGreaterThan(lastWall) + // A room's walls go up one after the other, around it. + for (const room of [0, 1, 2]) { + const own = walls(room) + for (let index = 1; index < own.length; index += 1) + expect(own[index]!).toBeGreaterThan(own[index - 1]!) + } + // The next room starts a beat after the first, while the first is still going up: side by side. + expect(Math.min(...walls(1))).toBeGreaterThan(Math.min(...walls(0))) + expect(Math.min(...walls(1))).toBeLessThan(Math.max(...walls(0))) + }) + + test('floors go up side by side, offset; roofs wait for every floor; furniture comes last', () => { + const roof = candidate('roof', 'roof', 2, { style: 'assemble', height: 4 }) + const plan = planLazyReveal([...floor(0, 3), ...floor(1, 3), roof], 0) + const at = startsOf(plan) + const walls = (level: number) => + plan.filter((entry) => entry.id.startsWith(`wall_${level}_`)).map((entry) => entry.startMs) + expect(Math.min(...walls(1))).toBeGreaterThan(Math.min(...walls(0))) + expect(Math.min(...walls(1))).toBeLessThan(Math.max(...walls(0))) + const stageA = plan.filter((entry) => entry.phase !== 'roof' && entry.phase !== 'furnishing') + expect(at('roof')).toBeGreaterThan(Math.max(...stageA.map((entry) => entry.startMs))) + const items = plan.filter((entry) => entry.phase === 'furnishing').map((entry) => entry.startMs) + expect(Math.min(...items)).toBeGreaterThan(at('roof')) + }) + + test('a big build takes proportionally longer, never in one pulse, and ends by the cap', () => { + const house = planLazyReveal([...floor(0, 4), ...floor(1, 4)], 0) + const big = planLazyReveal([...floor(0, 12, 6, 6), ...floor(1, 12, 6, 6)], 0) + expect(endOf(big)).toBeGreaterThan(endOf(house) * 1.8) + const huge = Array.from({ length: 4 }, (_, level) => floor(level, 60, 8, 8)).flat() + const plan = planLazyReveal(huge, 1000) + expect(plan).toHaveLength(huge.length) + expect(endOf(plan) - 1000).toBeLessThanOrEqual(LAZY_REVEAL_TIMING.capMs + 1e-6) + expect(endOf(plan) - 1000).toBeGreaterThan(LAZY_REVEAL_TIMING.capMs * 0.9) + // 4,000 pieces over the cap: a few start in a frame at 60 fps, never a crowd. + const perFrame = new Map() + for (const entry of plan) { + const frame = Math.floor((entry.startMs - 1000) / (1000 / 60)) + perFrame.set(frame, (perFrame.get(frame) ?? 0) + 1) + } + expect(Math.max(...perFrame.values())).toBeLessThanOrEqual(12) + }) + + test('a preroll plays before its node, and the room’s next phase waits for it', () => { + const preroll = 2000 + const plain = planLazyReveal(floor(0, 2), 0) + const plan = planLazyReveal(floor(0, 2, 4, 3, preroll), 0) + const at = startsOf(plan) + for (const entry of plan.filter((entry) => entry.phase === 'structure')) { + expect(entry.prerollMs).toBe(preroll) + expect(entry.startMs).toBeGreaterThanOrEqual(at('slab_0') + preroll) + } + const lastWall = Math.max( + ...plan.filter((entry) => entry.phase === 'structure').map((entry) => entry.startMs), + ) + expect(at('door_0_0')).toBeGreaterThan(lastWall) + expect(endOf(plan)).toBeGreaterThan(endOf(plain) + preroll * 0.9) + }) + + test('the same build plans the same way every time, jittered but never before its phase', () => { + const one = planLazyReveal(floor(0, 3), 0) + const timesOf = (plan: typeof one) => plan.map((entry) => [entry.id, entry.startMs]) + expect(timesOf(planLazyReveal(floor(0, 3), 0))).toEqual(timesOf(one)) + const at = startsOf(one) + const gaps = [1, 2, 3].map((wall) => at(`wall_0_0_${wall}`) - at(`wall_0_0_${wall - 1}`)) + // Jittered: the walls of a room are not evenly spaced. + expect(new Set(gaps.map((gap) => Math.round(gap))).size).toBeGreaterThan(1) + }) +}) + +/** A floor of `rooms` rooms of four walls, a slab under them, a door and two items in each. */ +function houseFloor(level: number, rooms: number): RevealCandidate[] { + const nodes = [candidate(`slab_${level}`, 'foundation', level, { style: 'settle', height: 0.08 })] + for (let room = 0; room < rooms; room += 1) { + const place = { cluster: `zone_${level}_${room}`, sweep: room } + for (let wall = 0; wall < 4; wall += 1) + nodes.push( + candidate(`wall_${level}_${room}_${wall}`, 'structure', level, { ...place, order: wall }), + ) + nodes.push( + candidate(`door_${level}_${room}`, 'openings', level, { ...place, style: 'cut', depth: 3 }), + ) + for (let item = 0; item < 2; item += 1) + nodes.push( + candidate(`item_${level}_${room}_${item}`, 'furnishing', level, { + ...place, + style: 'drop', + height: 0.3, + }), + ) + } + return nodes +} + +describe('the framing pace', () => { + const small = [...houseFloor(0, 3), ...houseFloor(1, 3)] + const startsOf = (plan: ReturnType) => + new Map(plan.map((entry) => [entry.id, entry.startMs])) + + test('the framing table is the original one at one pace: FRAMING_PACE times its times', () => { + expect(LAZY_REVEAL_TIMING).toEqual(pacedLazyTiming(LAZY_REVEAL_BASE_TIMING, FRAMING_PACE)) + expect(pacedLazyTiming(LAZY_REVEAL_BASE_TIMING, 1)).toEqual(LAZY_REVEAL_BASE_TIMING) + const { floorOffsetMs, roomOffsetMs, phaseGapMs, capMs } = LAZY_REVEAL_BASE_TIMING + expect(LAZY_REVEAL_TIMING.floorOffsetMs).toBeCloseTo(floorOffsetMs * FRAMING_PACE, 9) + expect(LAZY_REVEAL_TIMING.roomOffsetMs).toBeCloseTo(roomOffsetMs * FRAMING_PACE, 9) + expect(LAZY_REVEAL_TIMING.phaseGapMs).toBeCloseTo(phaseGapMs * FRAMING_PACE, 9) + expect(LAZY_REVEAL_TIMING.capMs).toBeCloseTo(capMs * FRAMING_PACE, 9) + for (const phase of Object.keys(LAZY_REVEAL_BASE_TIMING.elementOffsetMs) as RevealPhase[]) + expect(LAZY_REVEAL_TIMING.elementOffsetMs[phase]).toBeCloseTo( + LAZY_REVEAL_BASE_TIMING.elementOffsetMs[phase] * FRAMING_PACE, + 9, + ) + for (const style of Object.keys( + LAZY_REVEAL_BASE_TIMING.durationMs, + ) as (keyof typeof LAZY_REVEAL_TIMING.durationMs)[]) + expect(LAZY_REVEAL_TIMING.durationMs[style]).toBeCloseTo( + LAZY_REVEAL_BASE_TIMING.durationMs[style] * FRAMING_PACE, + 9, + ) + }) + + test('what is not a time does not move: the jitter, the overshoots and the heights', () => { + expect(LAZY_REVEAL_TIMING.jitter).toBe(LAZY_REVEAL_BASE_TIMING.jitter) + expect(LAZY_REVEAL_TIMING.overshoot).toEqual(LAZY_REVEAL_BASE_TIMING.overshoot) + expect(LAZY_REVEAL_TIMING.minHeightM).toEqual(LAZY_REVEAL_BASE_TIMING.minHeightM) + }) + + test('a build plays the original plan stretched: every start is FRAMING_PACE times its old one', () => { + const original = startsOf(planLazyReveal(small, 0, LAZY_REVEAL_BASE_TIMING)) + const paced = startsOf(planLazyReveal(small, 0, LAZY_REVEAL_TIMING)) + expect(paced.size).toBe(original.size) + for (const [id, startMs] of original) + expect(paced.get(id)!).toBeCloseTo(startMs * FRAMING_PACE, 6) + }) + + test('a build over the cap is pulled to the stretched cap, not to the old one', () => { + const huge = Array.from({ length: 4 }, (_, level) => + Array.from({ length: 60 }, (_, room) => + houseFloor(level, 1).map((node) => ({ + ...node, + id: `${node.id}_${room}`, + cluster: `${node.cluster}_${room}`, + sweep: room, + })), + ).flat(), + ).flat() + const plan = planLazyReveal(huge, 0) + const end = Math.max(...plan.map((e) => e.startMs + LAZY_REVEAL_TIMING.durationMs[e.style])) + expect(end).toBeLessThanOrEqual(LAZY_REVEAL_TIMING.capMs + 1e-6) + expect(end).toBeGreaterThan(LAZY_REVEAL_BASE_TIMING.capMs) + }) +}) + +describe('the full level keeps its own schedule', () => { + // Full reads REVEAL_TIMING and RETRACT_TIMING; these are the tables Full shipped with, pinned as + // literals on purpose: the framing pace must never reach them. + test('its tables are the ones it shipped with', () => { + expect(REVEAL_TIMING).toEqual({ + nodeGapMs: 70, + groupGapMs: 280, + capMs: 6000, + durationMs: { rise: 650, scale: 400, settle: 380, drop: 620, cut: 260, assemble: 0 }, + }) + expect(RETRACT_TIMING).toEqual({ + speed: 0.62, + nodeGapMs: 70 * 0.62, + capMs: 2400, + durationMs: { rise: 420, scale: 300, settle: 380, drop: 400, cut: 260, assemble: 520 }, + }) + }) + + test('its plan is the plan of those tables, at any framing pace', () => { + const nodes = [...houseFloor(0, 3), ...houseFloor(1, 3)] + const shipped = { + nodeGapMs: 70, + groupGapMs: 280, + capMs: 6000, + durationMs: { rise: 650, scale: 400, settle: 380, drop: 620, cut: 260, assemble: 0 }, + } as const + const now = planReveal(nodes, 0) + const then = planReveal(nodes, 0, shipped) + expect(now.map((entry) => [entry.id, entry.startMs])).toEqual( + then.map((entry) => [entry.id, entry.startMs]), + ) + }) +}) + +describe('the animation speed', () => { + const nodes = [...houseFloor(0, 3), ...houseFloor(1, 3)] + const endOfFull = (speed: number) => { + const timing = revealTimingAt(speed) + return Math.max( + ...planReveal(nodes, 0, timing).map( + (entry) => entry.startMs + timing.durationMs[entry.style], + ), + ) + } + const endOfFraming = (speed: number) => { + const timing = lazyTimingAt(speed) + return Math.max( + ...planLazyReveal(nodes, 0, timing).map( + (entry) => entry.startMs + timing.durationMs[entry.style], + ), + ) + } + const endOfRewind = (speed: number) => { + const rewind = retractTimingAt(speed) + return Math.max( + ...planRetract(nodes, 0, rewind, revealTimingAt(speed)).map( + (entry) => entry.startMs + entry.durationMs, + ), + ) + } + + test('the steps are ×0.5, ×0.75, ×1, ×1.5 and ×2; anything else snaps to one, junk is ×1', () => { + expect([...ANIMATION_SPEEDS]).toEqual([0.5, 0.75, 1, 1.5, 2]) + expect(normalizeAnimationSpeed(1.2)).toBe(1) + expect(normalizeAnimationSpeed(1.3)).toBe(1.5) + expect(normalizeAnimationSpeed(9)).toBe(2) + expect(normalizeAnimationSpeed(0.01)).toBe(0.5) + for (const junk of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, '2', null, undefined]) + expect(normalizeAnimationSpeed(junk)).toBe(1) + }) + + test('×1 is every table as shipped: the very same objects, at Full and at Framing', () => { + expect(revealTimingAt(1)).toBe(REVEAL_TIMING) + expect(lazyTimingAt(1)).toBe(LAZY_REVEAL_TIMING) + expect(retractTimingAt(1)).toBe(RETRACT_TIMING) + expect(lazyTimingAt(1)).toEqual(pacedLazyTiming(LAZY_REVEAL_BASE_TIMING, FRAMING_PACE)) + }) + + test.each([...ANIMATION_SPEEDS])('×%d plays a build in exactly 1/speed of its time', (speed) => { + expect(endOfFull(speed)).toBeCloseTo(endOfFull(1) / speed, 6) + expect(endOfFraming(speed)).toBeCloseTo(endOfFraming(1) / speed, 6) + expect(endOfRewind(speed)).toBeCloseTo(endOfRewind(1) / speed, 6) + }) + + test('the caps scale with it, so a big build is neither squeezed nor stretched past them', () => { + for (const speed of ANIMATION_SPEEDS) { + expect(revealTimingAt(speed).capMs).toBeCloseTo(REVEAL_TIMING.capMs / speed, 9) + expect(lazyTimingAt(speed).capMs).toBeCloseTo(LAZY_REVEAL_TIMING.capMs / speed, 9) + expect(retractTimingAt(speed).capMs).toBeCloseTo(RETRACT_TIMING.capMs / speed, 9) + } + }) +}) diff --git a/packages/viewer/src/lib/reveal-schedule.ts b/packages/viewer/src/lib/reveal-schedule.ts index ac4a2fcc0c..fbbde1b313 100644 --- a/packages/viewer/src/lib/reveal-schedule.ts +++ b/packages/viewer/src/lib/reveal-schedule.ts @@ -1,5 +1,6 @@ import type { RevealPhase, RevealStyle } from '@pascal-app/core' import { REVEAL_ARRIVAL } from './reveal-pose' +import type { CoverRange, PlayedStyle } from './reveal-wipe' export const REVEAL_PHASE_ORDER: readonly RevealPhase[] = [ 'foundation', @@ -18,8 +19,9 @@ export type RevealTiming = { /** A reveal ends within this however many nodes it has: the gaps shrink, no node is skipped. */ capMs: number /** - * How long each style moves its node. `assemble` holds its node still (its parts drop), and the - * heights of the travelling styles are declared by each kind (`capabilities.reveal.height`). + * How long each style moves its node. `assemble` holds its node still (its parts drop), `cover` + * holds it still while its covering is laid, and the heights of the travelling styles are + * declared by each kind (`capabilities.reveal.height`). */ durationMs: Record } @@ -32,10 +34,23 @@ export const REVEAL_TIMING: RevealTiming = { durationMs: { rise: 650, scale: 400, settle: 380, drop: 620, cut: 260, assemble: 0 }, } +/** + * How long `style` moves its node in `table`. Only the Framing level's table has a `cover` time (it + * lays a roof's covering where Full drops the roof whole); a plan made on a Full table or the reverse + * play of a framing build gives a covering twice what the whole roof takes to come or go. + */ +export function styleMs( + table: Readonly> & { cover?: number }, + style: PlayedStyle, +): number { + if (style !== 'cover') return table[style] + return table.cover ?? 2 * (table.assemble > 0 ? table.assemble : table.drop) +} + export type RevealCandidate = { id: string phase: RevealPhase - style: RevealStyle + style: PlayedStyle /** Metres above the rest pose for the travelling styles, 0 for the others. */ height: number levelId: string | null @@ -49,6 +64,20 @@ export type RevealCandidate = { source?: string | null /** A drop's anticipation, as a share of its height (see `RevealPoseOptions.lead`). */ lead?: number + /** + * The room (or wall run) it is built with, for the lazy schedule: rooms of a floor build side + * by side, a beat apart. Absent: it belongs to its floor as a whole (a slab under every room). + */ + cluster?: string + /** Where its room comes in its floor's sweep: the lower first. */ + sweep?: number + /** Its place in its room: a wall's turn around the room's outline. */ + order?: number + /** + * How long a plugin's own presentation plays at its turn before the node itself moves in (a wall's + * frame and boards, Bones): the lazy schedule leaves the time, and the node starts after it. + */ + prerollMs?: number } export type PlannedReveal = RevealCandidate & { @@ -112,7 +141,7 @@ export function planReveal( const offset = phaseStart + (index + lane / lanes.length) * timing.nodeGapMs planned.push({ candidate, offset }) phaseEnd = Math.max(phaseEnd, offset) - longest = Math.max(longest, timing.durationMs[candidate.style]) + longest = Math.max(longest, styleMs(timing.durationMs, candidate.style)) }) }) end = phaseEnd @@ -121,7 +150,8 @@ export function planReveal( const room = Math.max(0, timing.capMs - longest) const compression = end > room ? room / end : 1 const arrival = ({ candidate, offset }: (typeof planned)[number]) => - offset * compression + timing.durationMs[candidate.style] * REVEAL_ARRIVAL[candidate.style] + offset * compression + + styleMs(timing.durationMs, candidate.style) * REVEAL_ARRIVAL[candidate.style] let finale: (typeof planned)[number] | null = null for (const entry of planned) { // Held still, an assembled node never arrives; its parts do. @@ -138,6 +168,284 @@ export function planReveal( })) } +/** + * The lazy, nested schedule (the `framing` level): floors a beat apart, the rooms of a floor a beat + * apart, the pieces of a room a beat apart, each with a little jitter, so many small builds progress + * side by side instead of one global pulse. A big build takes proportionally longer, up to `capMs`. + */ +export type LazyRevealTiming = { + /** Between one floor's build and the next one's: floors go up side by side, offset. */ + floorOffsetMs: number + /** Between one room (or wall run) of a floor and the next, in each phase. */ + roomOffsetMs: number + /** Between two pieces of one room, by phase: a room's walls go up one after the other. */ + elementOffsetMs: Record + /** After a floor's phase has started its last piece, before its next phase starts. */ + phaseGapMs: number + /** Each start moves by up to this share of its offset, either way (never mechanical). */ + jitter: number + /** A plan longer than this is pulled together to end by it: a huge build does not take forever. */ + capMs: number + /** How long each style moves its node at this level; `cover` is the longest a roof's covering takes to lay. */ + durationMs: Record + /** + * A covering takes this long for each metre of what it covers, never under `coverMinMs` nor over + * `durationMs.cover` (`coverRangeAt`). + */ + coverMsPerM: number + coverMinMs: number + /** + * How far a rise, a scale or a settle goes past its rest before it settles (GSAP's `back.out` + * strength; 0 is a plain ease out). A rise's is gentler: a wall 2 % past its top, not into the slab over it. + */ + overshoot: Record<'rise' | 'scale' | 'settle', number> + /** Drops and settles fall from at least this high (m), whatever their kind declares. */ + minHeightM: Partial> +} + +/** + * The Framing level's pace: one factor on its whole timeline, the staging and the motions alike. 1 is + * the pace it was built at; 1.75 makes a build that took 20 s take 35 s, and anything smaller or + * larger takes proportionally longer. A pace of `p` multiplies every offset, gap, beat, duration and + * cap of this level (`LAZY_REVEAL_TIMING`) and of the frame each wall plays before it rises (Bones' + * `WALL_BUILD`), so pieces still snap and the build only plays out slower. The Full level reads + * `REVEAL_TIMING` and does not move with it. + */ +export const FRAMING_PACE = 1.75 + +/** The lazy schedule at pace 1; `LAZY_REVEAL_TIMING` is this at `FRAMING_PACE`. */ +export const LAZY_REVEAL_BASE_TIMING: LazyRevealTiming = { + floorOffsetMs: 1200, + roomOffsetMs: 420, + elementOffsetMs: { + foundation: 200, + structure: 360, + circulation: 260, + openings: 140, + roof: 180, + furnishing: 110, + }, + phaseGapMs: 260, + jitter: 0.3, + capMs: 40_000, + durationMs: { rise: 760, scale: 520, settle: 640, drop: 820, cut: 320, assemble: 0, cover: 4200 }, + coverMsPerM: 450, + coverMinMs: 1400, + overshoot: { rise: 0.7, scale: 1.6, settle: 1.6 }, + minHeightM: { drop: 1.8, settle: 0.6 }, +} + +/** `timing` played at `pace` times its length: times scale, shares and heights do not. */ +export function pacedLazyTiming(timing: LazyRevealTiming, pace: number): LazyRevealTiming { + const scaled = (table: Record) => + Object.fromEntries( + (Object.entries(table) as [K, number][]).map(([key, ms]) => [key, ms * pace]), + ) as Record + return { + ...timing, + floorOffsetMs: timing.floorOffsetMs * pace, + roomOffsetMs: timing.roomOffsetMs * pace, + elementOffsetMs: scaled(timing.elementOffsetMs), + phaseGapMs: timing.phaseGapMs * pace, + capMs: timing.capMs * pace, + durationMs: scaled(timing.durationMs), + coverMsPerM: timing.coverMsPerM * pace, + coverMinMs: timing.coverMinMs * pace, + } +} + +/** The one place the lazy schedule and the framing level's motion are read from. */ +export const LAZY_REVEAL_TIMING: LazyRevealTiming = pacedLazyTiming( + LAZY_REVEAL_BASE_TIMING, + FRAMING_PACE, +) + +/** + * The person's animation speed: how fast the build animations play, as a multiple of today's. + * Higher is faster, so every time of a schedule is divided by it: ×2 plays a build in half the + * time, ×0.5 in twice as long. ×1 is every table exactly as shipped (the very same objects). + */ +export const ANIMATION_SPEEDS = [0.5, 0.75, 1, 1.5, 2] as const +export type AnimationSpeed = (typeof ANIMATION_SPEEDS)[number] +export const DEFAULT_ANIMATION_SPEED: AnimationSpeed = 1 + +/** The step nearest to `value`; anything that is not a finite positive number is ×1. */ +export function normalizeAnimationSpeed(value: unknown): AnimationSpeed { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) + return DEFAULT_ANIMATION_SPEED + return ANIMATION_SPEEDS.reduce((best, step) => + Math.abs(step - value) < Math.abs(best - value) ? step : best, + ) +} + +/** `timing` with every time divided by `speed`: durations, gaps and the cap. */ +export function pacedRevealTiming(timing: RevealTiming, speed: number): RevealTiming { + return { + nodeGapMs: timing.nodeGapMs / speed, + groupGapMs: timing.groupGapMs / speed, + capMs: timing.capMs / speed, + durationMs: scaleTimes(timing.durationMs, 1 / speed), + } +} + +/** `timing` with every time divided by `speed`; the share `speed` of its pace is not a time. */ +export function pacedRetractTiming(timing: RetractTiming, speed: number): RetractTiming { + return { + ...timing, + nodeGapMs: timing.nodeGapMs / speed, + capMs: timing.capMs / speed, + durationMs: scaleTimes(timing.durationMs, 1 / speed), + } +} + +function scaleTimes>>(table: T, factor: number): T { + return Object.fromEntries( + Object.entries(table).map(([key, ms]) => [key, ms === undefined ? ms : ms * factor]), + ) as T +} + +/** The Full level's schedule at `speed`; at ×1 it is `REVEAL_TIMING` itself. */ +export const revealTimingAt = (speed: number): RevealTiming => + speed === 1 ? REVEAL_TIMING : pacedRevealTiming(REVEAL_TIMING, speed) + +/** The Framing level's schedule at `speed`; at ×1 it is `LAZY_REVEAL_TIMING` itself. */ +export const lazyTimingAt = (speed: number): LazyRevealTiming => + speed === 1 ? LAZY_REVEAL_TIMING : pacedLazyTiming(LAZY_REVEAL_BASE_TIMING, FRAMING_PACE / speed) + +/** The reverse play's schedule at `speed`; at ×1 it is `RETRACT_TIMING` itself. */ +export const retractTimingAt = (speed: number): RetractTiming => + speed === 1 ? RETRACT_TIMING : pacedRetractTiming(RETRACT_TIMING, speed) + +/** A stable share in [0, 1) from an id: the same jitter every time. */ +function jitterKey(id: string): number { + return (scatterKey(`${id}#jitter`) % 10_007) / 10_007 +} + +const STAGE_A: readonly RevealPhase[] = ['foundation', 'structure', 'circulation', 'openings'] + +/** + * Start times for `candidates` on the lazy, nested schedule, from `startMs`: + * - each floor builds its foundation, structure, stairs and openings, a phase after the other, and + * the floors go up side by side, `floorOffsetMs` apart; + * - in a phase, the rooms (`cluster`) start `roomOffsetMs` apart in their sweep order, and the + * pieces of a room `elementOffsetMs` apart around it; what belongs to the whole floor comes first; + * - roofs come once every floor is up, then the furniture, floor by floor and room by room again; + * - a piece with a preroll starts after it (its plugin plays then), and its room's next phase waits. + * Every start is jittered. The plan grows with the build (its biggest room, its rooms, its floors), + * and is pulled together only past `capMs`. `startMs` of a planned entry is when the node itself + * moves; its preroll plays before. + */ +export function planLazyReveal( + candidates: readonly RevealCandidate[], + startMs: number, + timing: LazyRevealTiming = LAZY_REVEAL_TIMING, +): PlannedReveal[] { + type Placed = { candidate: RevealCandidate; motion: number; preroll: number } + const placed: Placed[] = [] + const floors = new Map() + for (const candidate of candidates) { + const floor = `${candidate.levelIndex}:${candidate.levelId ?? ''}` + const list = floors.get(floor) + if (list) list.push(candidate) + else floors.set(floor, [candidate]) + } + const lanes = [...floors.entries()].sort(([left], [right]) => + left.localeCompare(right, undefined, { numeric: true }), + ) + const jittered = (id: string, offset: number) => (2 * jitterKey(id) - 1) * timing.jitter * offset + + /** Places one floor's nodes of one phase from `from`; returns when its last piece moves, or null. */ + const placePhase = (nodes: readonly RevealCandidate[], phase: RevealPhase, from: number) => { + if (nodes.length === 0) return null + const rooms = new Map() + for (const node of nodes) { + const room = node.cluster ?? '' + const list = rooms.get(room) + if (list) list.push(node) + else rooms.set(room, [node]) + } + const sweepOf = (members: readonly RevealCandidate[]) => + Math.min(...members.map((member) => member.sweep ?? Number.NEGATIVE_INFINITY)) + const ordered = [...rooms.entries()].sort( + ([leftKey, left], [rightKey, right]) => + // What belongs to the whole floor first, then the rooms in their sweep. + (leftKey === '' ? -1 : 0) - (rightKey === '' ? -1 : 0) || + sweepOf(left) - sweepOf(right) || + leftKey.localeCompare(rightKey), + ) + const step = timing.elementOffsetMs[phase] + let last = from + ordered.forEach(([room, members], index) => { + const roomStart = + from + + index * timing.roomOffsetMs + + jittered(`${room}:${phase}`, index > 0 ? timing.roomOffsetMs : 0) + members.sort( + (left, right) => + left.depth - right.depth || + (left.order ?? 0) - (right.order ?? 0) || + scatterKey(left.id) - scatterKey(right.id) || + left.seq - right.seq, + ) + members.forEach((candidate, rank) => { + const turn = Math.max(from, roomStart + rank * step + jittered(candidate.id, step)) + const preroll = Math.max(0, candidate.prerollMs ?? 0) + placed.push({ candidate, motion: turn + preroll, preroll }) + last = Math.max(last, turn + preroll) + }) + }) + return last + } + + const phaseOf = (nodes: readonly RevealCandidate[], phase: RevealPhase) => + nodes.filter((node) => node.phase === phase) + /** Every floor builds `phases` from `from`, `floorOffsetMs` apart; returns when the last piece moves. */ + const stage = (phases: readonly RevealPhase[], from: number) => { + let end: number | null = null + let lane = 0 + for (const [, nodes] of lanes) { + const own = phases.flatMap((phase) => phaseOf(nodes, phase)) + if (own.length === 0) continue + let cursor = from + lane * timing.floorOffsetMs + lane += 1 + for (const phase of phases) { + const last = placePhase(phaseOf(nodes, phase), phase, cursor) + if (last === null) continue + end = Math.max(end ?? last, last) + cursor = last + timing.phaseGapMs + } + } + return end + } + + let cursor = 0 + for (const phases of [STAGE_A, ['roof'], ['furnishing']] as const) { + const end = stage(phases, cursor) + if (end !== null) cursor = end + timing.phaseGapMs + } + + const longest = Math.max(0, ...placed.map(({ candidate }) => timing.durationMs[candidate.style])) + const lastMotion = Math.max(0, ...placed.map(({ motion }) => motion)) + const room = Math.max(0, timing.capMs - longest) + const compression = lastMotion > room ? room / lastMotion : 1 + const arrival = ({ candidate, motion }: Placed) => + motion * compression + timing.durationMs[candidate.style] * REVEAL_ARRIVAL[candidate.style] + let finale: Placed | null = null + for (const entry of placed) { + if (entry.candidate.style === 'assemble') continue + if (!finale || arrival(entry) >= arrival(finale)) finale = entry + } + return placed + .sort((left, right) => left.motion - right.motion || left.candidate.seq - right.candidate.seq) + .map((entry) => ({ + ...entry.candidate, + group: revealGroupKey(entry.candidate), + startMs: startMs + entry.motion * compression, + ...(entry.preroll > 0 ? { prerollMs: entry.preroll * compression } : {}), + ...(entry === finale ? { finale: true } : {}), + })) +} + export type RetractTiming = { /** A reverse plan keeps the build's order, reversed, at this share of its pace. */ speed: number @@ -146,7 +454,7 @@ export type RetractTiming = { /** A reverse play ends within this however many pieces it takes back. */ capMs: number /** How long each style takes to go: a rewind is quicker than the build. */ - durationMs: Record + durationMs: Record & { cover?: number } } /** The one place to tune the reverse play's pace. */ @@ -163,6 +471,80 @@ export type PlannedRetract = RevealCandidate & { durationMs: number } +/** + * The reverse play at the Framing level: the rewind's durations at the Framing pace, so a deleted + * floor comes apart as slowly as it went up. `capMs` keeps a whole floor's deconstruction in a + * bounded time however many pieces it takes back. + */ +/** A covering lifts off in this share of the time it took to lay. */ +export const COVER_REVERSE_SHARE = 0.6 + +export const LAZY_RETRACT_TIMING: RetractTiming = { + speed: RETRACT_TIMING.speed, + nodeGapMs: RETRACT_TIMING.nodeGapMs * FRAMING_PACE, + capMs: 14_000, + durationMs: { + ...scaleTimes(RETRACT_TIMING.durationMs, FRAMING_PACE), + cover: LAZY_REVEAL_TIMING.durationMs.cover * COVER_REVERSE_SHARE, + }, +} + +/** + * How long a roof's covering takes by the size it covers at the Framing level at `speed`: its range + * in metres of sweep (`coverDurationMs`); `reverse` for the lifting off of a deletion or an undo. + */ +export function coverRangeAt(speed: number, reverse = false): CoverRange { + const timing = lazyTimingAt(speed) + const share = reverse ? COVER_REVERSE_SHARE : 1 + return { + perMeterMs: timing.coverMsPerM * share, + minMs: timing.coverMinMs * share, + maxMs: timing.durationMs.cover * share, + } +} + +/** The Framing level's reverse play at `speed`; at ×1 it is `LAZY_RETRACT_TIMING` itself. */ +export const lazyRetractTimingAt = (speed: number): RetractTiming => + speed === 1 ? LAZY_RETRACT_TIMING : pacedRetractTiming(LAZY_RETRACT_TIMING, speed) + +/** + * The Framing build played backwards, for a deletion or an undo: the plan of the build (floor by + * floor, room by room, piece by piece, `planLazyReveal`) read from its end, so the furniture lifts + * out first, then the roof, the openings, the walls and the slabs last, and the floors come apart + * from the top down, each room as it went up. Each piece takes its own style's rewind time; a piece + * that had a preroll keeps it, after its own going, for the plugin that took it apart (a wall's + * boards come off and its studs fly up then). The whole ends within the cap by pulling the starts + * closer, never by skipping a piece. + */ +export function planLazyRetract( + candidates: readonly RevealCandidate[], + startMs: number, + timing: RetractTiming = LAZY_RETRACT_TIMING, + build: LazyRevealTiming = LAZY_REVEAL_TIMING, +): PlannedRetract[] { + const forward = planLazyReveal(candidates, 0, build) + const lastTurn = Math.max(0, ...forward.map((entry) => entry.startMs)) + const placed = forward.map((entry) => ({ + entry, + offset: (lastTurn - entry.startMs) * timing.speed, + })) + const longest = Math.max(0, ...placed.map(({ entry }) => styleMs(timing.durationMs, entry.style))) + const lastStart = Math.max(0, ...placed.map(({ offset }) => offset)) + const room = Math.max(0, timing.capMs - longest) + const compression = lastStart > room ? room / lastStart : 1 + return placed + .map(({ entry, offset }) => { + const { startMs: _start, finale: _finale, prerollMs, ...candidate } = entry + return { + ...candidate, + ...(prerollMs ? { prerollMs: prerollMs * timing.speed * compression } : {}), + startMs: startMs + offset * compression, + durationMs: styleMs(timing.durationMs, entry.style), + } + }) + .sort((left, right) => left.startMs - right.startMs || right.seq - left.seq) +} + /** * The build played backwards, for an undo: the last phase goes first and each goes after the one * built on it (the furniture, the roof, the openings, the walls, the slab), and inside a phase the last @@ -174,8 +556,9 @@ export function planRetract( candidates: readonly RevealCandidate[], startMs: number, timing: RetractTiming = RETRACT_TIMING, + build: RevealTiming = REVEAL_TIMING, ): PlannedRetract[] { - const forward = planReveal(candidates, 0) + const forward = planReveal(candidates, 0, build) const byPhase = new Map() for (const entry of forward) byPhase.set(entry.phase, [...(byPhase.get(entry.phase) ?? []), entry]) @@ -188,9 +571,9 @@ export function planRetract( const last = Math.max(...entries.map((entry) => entry.startMs)) for (const entry of entries) placed.push({ entry, offset: cursor + (last - entry.startMs) * timing.speed }) - cursor += (last - first + REVEAL_TIMING.groupGapMs) * timing.speed + cursor += (last - first + build.groupGapMs) * timing.speed } - const longest = Math.max(0, ...placed.map(({ entry }) => timing.durationMs[entry.style])) + const longest = Math.max(0, ...placed.map(({ entry }) => styleMs(timing.durationMs, entry.style))) const lastStart = Math.max(0, ...placed.map(({ offset }) => offset)) const room = Math.max(0, timing.capMs - longest) const compression = lastStart > room ? room / lastStart : 1 @@ -200,7 +583,7 @@ export function planRetract( return { ...candidate, startMs: startMs + offset * compression, - durationMs: timing.durationMs[entry.style], + durationMs: styleMs(timing.durationMs, entry.style), } }) .sort((left, right) => left.startMs - right.startMs || right.seq - left.seq) diff --git a/packages/viewer/src/lib/reveal-texture-scale.test.ts b/packages/viewer/src/lib/reveal-texture-scale.test.ts new file mode 100644 index 0000000000..406ccc1d12 --- /dev/null +++ b/packages/viewer/src/lib/reveal-texture-scale.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'bun:test' +import { BoxGeometry, Group, Mesh, MeshStandardMaterial } from 'three' +import { clearRiseTextureScale, setRiseTextureScale } from './reveal-texture-scale' + +test('a rise keeps shared source geometry untouched and restores it when finished', () => { + const geometry = new BoxGeometry(4, 3, 0.2).translate(0, 1.5, 0) + const material = new MeshStandardMaterial() + const rising = new Mesh(geometry, material) + const resting = new Mesh(geometry, material) + const root = new Group().add(rising) + const uv = geometry.getAttribute('uv') + const original = Array.from(uv.array) + setRiseTextureScale(root, 0.3, 0) + expect(rising.geometry).not.toBe(geometry) + expect(resting.geometry).toBe(geometry) + expect(Array.from(uv.array)).toEqual(original) + clearRiseTextureScale(root) + expect(rising.geometry).toBe(geometry) + expect(rising.material).toBe(material) + geometry.dispose() + material.dispose() +}) + +test('a geometry rebuild mid-rise gets fresh UV compensation without restoring stale geometry', () => { + const initial = new BoxGeometry(4, 3, 0.2) + const replacement = new BoxGeometry(6, 3, 0.2) + const mesh = new Mesh(initial, new MeshStandardMaterial()) + const root = new Group().add(mesh) + setRiseTextureScale(root, 0.3, -1.5) + mesh.geometry = replacement + setRiseTextureScale(root, 0.6, -1.5) + expect(mesh.geometry).not.toBe(initial) + expect(mesh.geometry).not.toBe(replacement) + clearRiseTextureScale(root) + expect(mesh.geometry).toBe(replacement) + initial.dispose() + replacement.dispose() + ;(mesh.material as MeshStandardMaterial).dispose() +}) diff --git a/packages/viewer/src/lib/reveal-texture-scale.ts b/packages/viewer/src/lib/reveal-texture-scale.ts new file mode 100644 index 0000000000..745eead758 --- /dev/null +++ b/packages/viewer/src/lib/reveal-texture-scale.ts @@ -0,0 +1,117 @@ +import { BufferAttribute, type BufferGeometry, Mesh, type Object3D } from 'three' + +type UVChannel = { original: BufferAttribute; drawn: BufferAttribute; vertical: Float32Array } +type DrawnGeometry = { original: BufferGeometry; drawn: BufferGeometry; channels: UVChannel[] } +const geometryByRoot = new WeakMap>() + +function verticalDerivatives(geometry: BufferGeometry, uv: BufferAttribute): Float32Array { + const positions = geometry.getAttribute('position') + const vertical = new Float32Array(uv.count * 2) + if (!positions) return vertical + const count = geometry.index?.count ?? positions.count + for (let offset = 0; offset + 2 < count; offset += 3) { + const first = geometry.index?.getX(offset) ?? offset + const second = geometry.index?.getX(offset + 1) ?? offset + 1 + const third = geometry.index?.getX(offset + 2) ?? offset + 2 + const dy1 = positions.getY(second) - positions.getY(first) + const dy2 = positions.getY(third) - positions.getY(first) + const dx1 = positions.getX(second) - positions.getX(first) + const dx2 = positions.getX(third) - positions.getX(first) + const dz1 = positions.getZ(second) - positions.getZ(first) + const dz2 = positions.getZ(third) - positions.getZ(first) + const useX = Math.abs(dy1 * dx2 - dy2 * dx1) > Math.abs(dy1 * dz2 - dy2 * dz1) + const horizontal1 = useX ? dx1 : dz1 + const horizontal2 = useX ? dx2 : dz2 + const determinant = dy1 * horizontal2 - dy2 * horizontal1 + if (Math.abs(determinant) < 1e-10) continue + const du = + ((uv.getX(second) - uv.getX(first)) * horizontal2 - + (uv.getX(third) - uv.getX(first)) * horizontal1) / + determinant + const dv = + ((uv.getY(second) - uv.getY(first)) * horizontal2 - + (uv.getY(third) - uv.getY(first)) * horizontal1) / + determinant + for (const vertex of [first, second, third]) { + vertical[vertex * 2] = du + vertical[vertex * 2 + 1] = dv + } + } + return vertical +} + +function cloneDrawnGeometry(mesh: Mesh): DrawnGeometry | null { + const original = mesh.geometry + const channels: UVChannel[] = [] + for (const name of ['uv', 'uv1', 'uv2', 'uv3']) { + const attribute = original.getAttribute(name) + if (attribute instanceof BufferAttribute) { + const uv = attribute + channels.push({ + original: uv, + drawn: uv.clone(), + vertical: verticalDerivatives(original, uv), + }) + } + } + if (channels.length === 0) return null + const drawn = original.clone() + let channel = 0 + for (const name of ['uv', 'uv1', 'uv2', 'uv3']) { + const attribute = original.getAttribute(name) + if (attribute instanceof BufferAttribute) { + const entry = channels[channel++] + if (entry) drawn.setAttribute(name, entry.drawn) + } + } + mesh.geometry = drawn + return { original, drawn, channels } +} + +/** Keep tiles per metre while a wall rises, without changing shared materials or cached geometry. */ +export function setRiseTextureScale(root: Object3D, scaleY: number, pivotY: number): void { + let geometries = geometryByRoot.get(root) + if (!geometries) { + geometries = new Map() + geometryByRoot.set(root, geometries) + } + const owned = geometries + root.traverse((object) => { + if (!(object instanceof Mesh)) return + let entry = owned.get(object) + if (entry && object.geometry !== entry.drawn) { + entry.drawn.dispose() + owned.delete(object) + entry = undefined + } + if (!entry) { + const cloned = cloneDrawnGeometry(object) + if (!cloned) return + entry = cloned + owned.set(object, entry) + } + const positions = entry.original.getAttribute('position') + if (!positions) return + for (const channel of entry.channels) { + for (let vertex = 0; vertex < channel.original.count; vertex += 1) { + const shift = (scaleY - 1) * (positions.getY(vertex) - pivotY) + channel.drawn.setXY( + vertex, + channel.original.getX(vertex) + shift * (channel.vertical[vertex * 2] ?? 0), + channel.original.getY(vertex) + shift * (channel.vertical[vertex * 2 + 1] ?? 0), + ) + } + channel.drawn.needsUpdate = true + } + }) +} + +export function clearRiseTextureScale(root: Object3D): void { + const geometries = geometryByRoot.get(root) + if (!geometries) return + for (const [mesh, entry] of geometries) { + if (mesh.geometry === entry.drawn) mesh.geometry = entry.original + entry.drawn.dispose() + } + geometryByRoot.delete(root) +} diff --git a/packages/viewer/src/lib/reveal-windows.ts b/packages/viewer/src/lib/reveal-windows.ts index 5bcd7a17b8..b86c92df5f 100644 --- a/packages/viewer/src/lib/reveal-windows.ts +++ b/packages/viewer/src/lib/reveal-windows.ts @@ -1,6 +1,12 @@ import type { RevealPhase, SceneCommit } from '@pascal-app/core' import { type ConstructionRevealLevel, commitRevealCandidates } from './reveal-candidates' -import { planReveal, REVEAL_TIMING, type RevealTiming, revealGroupKey } from './reveal-schedule' +import { + planReveal, + REVEAL_TIMING, + type RevealTiming, + revealGroupKey, + styleMs, +} from './reveal-schedule' /** When one (phase, level) group of a reveal plays. */ export type RevealWindow = { @@ -28,7 +34,7 @@ export function planRevealWindows( ): RevealWindow[] { const windows = new Map() for (const entry of planReveal(commitRevealCandidates(commit, level), 0, timing)) { - const endMs = entry.startMs + timing.durationMs[entry.style] + const endMs = entry.startMs + styleMs(timing.durationMs, entry.style) const key = revealGroupKey(entry) const window = windows.get(key) if (!window) { diff --git a/packages/viewer/src/lib/reveal-wipe.test.ts b/packages/viewer/src/lib/reveal-wipe.test.ts new file mode 100644 index 0000000000..ce7545768a --- /dev/null +++ b/packages/viewer/src/lib/reveal-wipe.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'bun:test' +import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D } from 'three' +import { + clearRevealWipe, + coverDurationMs, + coverShareAt, + coverSweepOf, + revealWipeLevelOf, + setRevealWipe, +} from './reveal-wipe' + +const box = (min: [number, number, number], max: [number, number, number]) => ({ + min: { x: min[0], y: min[1], z: min[2] }, + max: { x: max[0], y: max[1], z: max[2] }, +}) + +describe('the sweep of a covering', () => { + test('a pitched roof sweeps along its height, from its eave to its ridge', () => { + expect(coverSweepOf(box([0, 3, 0], [8, 5.4, 6]))).toEqual({ axis: 'y', from: 3, to: 5.4 }) + }) + + test('a node with nothing drawn has an empty sweep, not a broken one', () => { + const empty = box( + [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], + [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], + ) + expect(coverSweepOf(empty)).toEqual({ axis: 'y', from: 0, to: 0 }) + }) + + test('a flat roof sweeps along its longer plan side', () => { + expect(coverSweepOf(box([0, 3, 0], [8, 3.2, 5]))).toEqual({ axis: 'x', from: 0, to: 8 }) + expect(coverSweepOf(box([0, 3, 0], [4, 3.2, 9]))).toEqual({ axis: 'z', from: 0, to: 9 }) + }) +}) + +describe('how long a covering takes', () => { + const range = { perMeterMs: 800, minMs: 2400, maxMs: 7000 } + const over = (length: number) => ({ axis: 'y', from: 3, to: 3 + length }) as const + + test('follows the size of what it covers', () => { + expect(coverDurationMs(over(4), range)).toBe(3200) + expect(coverDurationMs(over(6), range)).toBe(4800) + expect(coverDurationMs(over(6), range)).toBeGreaterThan(coverDurationMs(over(4), range)) + }) + + test('never crawls on a small roof and never flashes or drags on the others', () => { + expect(coverDurationMs(over(0.7), range)).toBe(2400) + expect(coverDurationMs(over(0), range)).toBe(2400) + expect(coverDurationMs(over(40), range)).toBe(7000) + }) +}) + +describe('the covering closes continuously', () => { + const duration = 3000 + + test('is exactly open at the start and closed at the end', () => { + expect(coverShareAt(0, duration)).toBe(0) + expect(coverShareAt(duration, duration)).toBe(1) + expect(coverShareAt(-50, duration)).toBe(0) + expect(coverShareAt(duration + 50, duration)).toBe(1) + }) + + test('moves with every frame: strictly increasing at 60 Hz, with no plateau', () => { + let last = -1 + for (let at = 0; at <= duration; at += 1000 / 60) { + const share = coverShareAt(at, duration) + expect(share).toBeGreaterThan(last) + last = share + } + }) + + test('eases in and out: the first and last frames move less than the middle ones', () => { + const step = 1000 / 60 + const first = coverShareAt(step, duration) - coverShareAt(0, duration) + const middle = + coverShareAt(duration / 2 + step, duration) - coverShareAt(duration / 2, duration) + const last = coverShareAt(duration, duration) - coverShareAt(duration - step, duration) + expect(first).toBeLessThan(middle / 3) + expect(last).toBeLessThan(middle / 3) + }) + + test('a covering with no time is closed', () => { + expect(coverShareAt(0, 0)).toBe(1) + }) +}) + +describe('the wipe on a drawn node', () => { + function drawn() { + const root = new Object3D() + const material = new MeshBasicMaterial() + const mesh = new Mesh(new BoxGeometry(1, 1, 1), material) + mesh.castShadow = true + root.add(mesh) + return { root, mesh, material } + } + + test('dresses the meshes in clones, never the shared material, and gives them back', () => { + const { root, mesh, material } = drawn() + setRevealWipe(root, { axis: 'y', from: 0, to: 2 }, 0.5) + expect(mesh.material).not.toBe(material) + expect(mesh.castShadow).toBe(false) + expect(revealWipeLevelOf(root)).toBe(0.5) + setRevealWipe(root, { axis: 'y', from: 0, to: 2 }, 1.2) + expect(revealWipeLevelOf(root)).toBe(1.2) + clearRevealWipe(root) + expect(mesh.material).toBe(material) + expect(mesh.castShadow).toBe(true) + expect(revealWipeLevelOf(root)).toBeNull() + }) + + test('a mesh whose renderer put its own material back is dressed again', () => { + const { root, mesh } = drawn() + setRevealWipe(root, { axis: 'y', from: 0, to: 2 }, 0.5) + const fresh = new MeshBasicMaterial() + mesh.material = fresh + setRevealWipe(root, { axis: 'y', from: 0, to: 2 }, 0.8) + expect(mesh.material).not.toBe(fresh) + clearRevealWipe(root) + expect(mesh.material).toBe(fresh) + }) +}) diff --git a/packages/viewer/src/lib/reveal-wipe.ts b/packages/viewer/src/lib/reveal-wipe.ts new file mode 100644 index 0000000000..6b8a4c0b6f --- /dev/null +++ b/packages/viewer/src/lib/reveal-wipe.ts @@ -0,0 +1,159 @@ +import type { RevealStyle } from '@pascal-app/core' +import type { Material, Mesh, Object3D } from 'three' +import { positionWorld, uniform } from 'three/tsl' +import type { Node, NodeMaterial } from 'three/webgpu' + +/** + * The covering of a node laid over what stands under it, in place: a roof at the Framing level + * keeps its frame and its shell is revealed continuously from its lowest edge to its highest, the + * way a wall's boards and finish go over its studs. Presentation only, like the pose: the scene is not + * touched, and the meshes wear masked clones of their materials while it plays (the scene's + * materials are shared and cached, so a wipe never writes to them). + * + * The sweep runs along the height when the node rises enough for that to read as eave to ridge, and + * along its longer plan side when it is flat. + */ + +/** + * What a candidate is played as: a kind's declared style, or `cover`, which an `assemble` node + * plays at the Framing level (a roof holds still while its covering is laid over its frame). A + * kind never declares it, so it is the viewer's own and `RevealStyle` does not carry it. + */ +export type PlayedStyle = RevealStyle | 'cover' + +export type CoverAxis = 'x' | 'y' | 'z' + +/** Where the sweep starts and ends along its axis, in world metres. */ +export type CoverSweep = { axis: CoverAxis; from: number; to: number } + +/** A node that rises less than this (m) is flat: it sweeps along its longer plan side. */ +export const COVER_MIN_RISE_M = 0.6 + +type BoxLike = { + min: { x: number; y: number; z: number } + max: { x: number; y: number; z: number } +} + +/** The sweep of a node drawn inside `box` (world space). */ +export function coverSweepOf(box: BoxLike): CoverSweep { + // Nothing drawn yet: an empty sweep, so the covering has nothing to close. + if (!(box.max.y >= box.min.y)) return { axis: 'y', from: 0, to: 0 } + if (box.max.y - box.min.y >= COVER_MIN_RISE_M) { + return { axis: 'y', from: box.min.y, to: box.max.y } + } + const alongX = box.max.x - box.min.x >= box.max.z - box.min.z + return alongX + ? { axis: 'x', from: box.min.x, to: box.max.x } + : { axis: 'z', from: box.min.z, to: box.max.z } +} + +/** + * How long a covering takes by the size of what it covers, at a level's pace and the person's speed: + * `perMeterMs` for each metre of the sweep, never under `minMs` (a small porch roof does not crawl, + * it does not flash either) and never over `maxMs` (a long flat roof does not take half a minute). + */ +export type CoverRange = { perMeterMs: number; minMs: number; maxMs: number } + +/** The time a covering of `sweep` takes in `range`. */ +export function coverDurationMs(sweep: CoverSweep, range: CoverRange): number { + const length = Math.max(0, sweep.to - sweep.from) + return Math.min(range.maxMs, Math.max(range.minMs, length * range.perMeterMs)) +} + +/** + * How much of the sweep the covering has closed `elapsedMs` into `durationMs`, as a share in [0, 1]. + * It moves continuously: an ease in and out (a sine), so it starts and stops without a jolt, and it + * is exactly 0 at the start and 1 at the end. + */ +export function coverShareAt(elapsedMs: number, durationMs: number): number { + if (!(durationMs > 0)) return elapsedMs >= 0 ? 1 : 0 + const t = Math.min(1, Math.max(0, elapsedMs / durationMs)) + return 0.5 - 0.5 * Math.cos(Math.PI * t) +} + +type Worn = { + original: Material | Material[] + wiped: Material | Material[] + cast: boolean +} + +type Sweep = { + level: { value: number } + axis: CoverAxis +} + +const worn = new WeakMap() +const sweeps = new WeakMap() + +const eachOf = (material: Material | Material[]) => + Array.isArray(material) ? material : [material] + +const isMesh = (object: Object3D): object is Mesh => + (object as Mesh).isMesh === true && Boolean((object as Mesh).material) + +function maskOf(sweep: Sweep): Node<'bool'> { + const coordinate = + sweep.axis === 'y' ? positionWorld.y : sweep.axis === 'x' ? positionWorld.x : positionWorld.z + return coordinate.lessThanEqual(sweep.level as never) as Node<'bool'> +} + +function wipedClone(material: Material, mask: Node<'bool'>): Material { + const clone = material.clone() as unknown as NodeMaterial + clone.maskNode = clone.maskNode ? (clone.maskNode as Node<'bool'>).and(mask) : mask + return clone as unknown as Material +} + +/** + * Shows what is below `at` (world metres along the sweep's axis) of everything drawn under `root`, + * and hides the rest. Idempotent: a frame later it only moves the level; a mesh whose renderer put + * its own materials back is dressed again. Cleared by `clearRevealWipe`. + */ +export function setRevealWipe(root: Object3D, sweep: CoverSweep, at: number): void { + let state = sweeps.get(root) + if (!state || state.axis !== sweep.axis) { + if (state) clearRevealWipe(root) + state = { level: uniform(at) as unknown as { value: number }, axis: sweep.axis } + sweeps.set(root, state) + } + state.level.value = at + const mask = maskOf(state) + root.traverse((child) => { + if (!isMesh(child)) return + let entry = worn.get(child) + if (entry && child.material !== entry.wiped) { + // Its renderer set the real materials again: they are the originals now. + for (const material of eachOf(entry.wiped)) material.dispose() + entry = undefined + } + if (!entry) { + const original = child.material + const wiped = Array.isArray(original) + ? original.map((material) => wipedClone(material, mask)) + : wipedClone(original, mask) + entry = { original, wiped, cast: child.castShadow } + worn.set(child, entry) + child.material = wiped + } + // The depth pass has no mask: a row not yet laid would still throw its shadow. + child.castShadow = false + }) +} + +/** Gives every mesh under `root` its own materials and its shadow back. */ +export function clearRevealWipe(root: Object3D): void { + if (!sweeps.delete(root)) return + root.traverse((child) => { + if (!isMesh(child)) return + const entry = worn.get(child) + if (!entry) return + if (child.material === entry.wiped) child.material = entry.original + for (const material of eachOf(entry.wiped)) material.dispose() + child.castShadow = entry.cast + worn.delete(child) + }) +} + +/** The world coordinate the covering has reached on `root`, or null when it is not under a wipe. */ +export function revealWipeLevelOf(root: Object3D): number | null { + return sweeps.get(root)?.level.value ?? null +} diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index fdb4af131e..da26e711cc 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -119,6 +119,19 @@ type ViewerState = { * while editing; never persisted, so the viewer app keeps its own mode. */ hideLevelsAboveSelection: boolean + /** + * The levels a construction reveal is playing on (a build, an undo, a move): each shows while its + * work plays, whatever the level display hides, so the animation is seen. Set by the reveal, never + * persisted; the author's own hiding of a level still wins. + */ + levelsRevealing: readonly string[] + /** + * The nodes a construction reveal is playing now (moving in, going out, carried, or an edit + * replaying): the cutaway leaves each of them, and the openings it hosts, in view while it + * plays, whatever the wall mode. Set by the reveal, never persisted; the person's own wall mode + * is not written. + */ + nodesRevealing: readonly string[] wallMode: WallMode setWallMode: (mode: WallMode) => void @@ -448,6 +461,8 @@ const useViewer = create()( levelMode: 'stacked', setLevelMode: (mode) => set({ levelMode: mode }), hideLevelsAboveSelection: false, + levelsRevealing: [], + nodesRevealing: [], wallMode: 'up', setWallMode: (mode) => set({ wallMode: mode }), diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx index bf2375b4b3..668798ea54 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -5,8 +5,10 @@ import { type AnyNodeId, applySceneSnapshot, BaseNode, + BuildingNode, clearSceneHistory, emitter, + getLevelElevations, ItemNode, LevelNode, loadPlugin, @@ -16,18 +18,34 @@ import { objectId, type RevealConfig, runAsSceneCommitAuthor, + type SceneCommit, sceneRegistry, useScene, } from '@pascal-app/core' import { act, create } from '@react-three/test-renderer' -import { BoxGeometry, Group, Matrix4, Mesh, type Object3D } from 'three' +import { BoxGeometry, Group, Matrix4, Mesh, MeshBasicMaterial, Object3D } from 'three' +import { z } from 'zod' import { NodeRenderer } from '../../components/renderers/node-renderer' import { DustPool } from '../../lib/construction-dust' import { OVERLAY_LAYER, scenePassLayers } from '../../lib/layers' +import { commitEditCandidates } from '../../lib/reveal-candidates' import { hasRevealPose } from '../../lib/reveal-pose' -import { RETRACT_TIMING, REVEAL_TIMING } from '../../lib/reveal-schedule' +import { + coverRangeAt, + LAZY_REVEAL_TIMING, + lazyRetractTimingAt, + lazyTimingAt, + RETRACT_TIMING, + REVEAL_TIMING, + retractTimingAt, + revealTimingAt, +} from '../../lib/reveal-schedule' +import { revealWipeLevelOf } from '../../lib/reveal-wipe' import useViewer from '../../store/use-viewer' import { GeometrySystem } from '../geometry/geometry-system' +import { heldLevelY } from '../level/level-hold' +import { LevelSystem } from '../level/level-system' +import { visibleMaterialsOf, wallsInPlay } from '../wall/wall-cutout-cache' import { ConstructionDust } from './construction-dust' // Construction reveal, failure modes written before the code: a person's edit, @@ -38,7 +56,10 @@ import { ConstructionReveal, type ConstructionRevealDriver, type ConstructionRevealLevel, + getRetractTurn, + getRevealPlanSpeed, getRevealPlanState, + getRevealTurn, isNodeRevealing, isRevealAssembling, isRevealWaiting, @@ -46,7 +67,9 @@ import { type RevealEvent, type RevealPhaseEvent, retractNodes, + revealClockMs, seatLiftedNow, + setRevealPreroll, startConstructionReveal, subscribeRevealAssembling, subscribeRevealEvents, @@ -72,6 +95,7 @@ function revealKind( reveal: RevealConfig | null, geometry: (node: AnyNode) => BoxGeometry | null, extra: Record = {}, + revealOnEdit: readonly string[] = [], ): { schema: typeof BaseNode; definition: AnyNodeDefinition } { const schema = BaseNode.extend({ id: objectId(prefix), @@ -85,7 +109,10 @@ function revealKind( schema, category: 'structure', defaults: () => base, - capabilities: reveal ? { reveal } : {}, + capabilities: { + ...(reveal ? { reveal } : {}), + ...(revealOnEdit.length ? { revealOnEdit } : {}), + }, geometry: (node) => { const built = geometry(node as AnyNode) return built ? new Group().add(new Mesh(built)) : new Group() @@ -108,6 +135,21 @@ const QueuedWall = revealKind( { phase: 'structure', style: 'rise' }, (node) => (reached.has(node.id) ? new BoxGeometry(4, 2.5, 0.2).translate(2, 1.25, 0) : null), ) +/** A wall of the real kind's name: what a deletion dresses is a node of type `wall`. */ +const RealWall = revealKind( + 'wall', + 'wall', + { phase: 'structure', style: 'rise' }, + () => new BoxGeometry(4, 2.5, 0.2).translate(2, 1.25, 0), + { + start: z.tuple([z.number(), z.number()]).default([0, 0]), + end: z.tuple([z.number(), z.number()]).default([4, 0]), + height: z.number().default(2.5), + thickness: z.number().default(0.2), + frontSide: z.string().default('interior'), + backSide: z.string().default('exterior'), + }, +) const Door = revealKind('fixture:door', 'fxdoor', { phase: 'openings', style: 'scale' }, () => new BoxGeometry(0.9, 2.1, 0.1).translate(0, 1.05, 0), ) @@ -127,6 +169,30 @@ const Plate = revealKind( { phase: 'foundation', style: 'settle', height: 0.08 }, () => new BoxGeometry(4, 0.2, 4).translate(2, -0.1, 2), ) +// A wall that is bent or resized: its `scale` makes its picture, its `name` does not. +const Beam = revealKind( + 'fixture:beam', + 'fxbeam', + { phase: 'structure', style: 'rise' }, + () => new BoxGeometry(4, 2.5, 0.2).translate(2, 1.25, 0), + { scale: ItemNode.shape.scale }, + ['scale'], +) +// A slab edited the same way: the edit plays as a settle, as it came in. +const Floorplate = revealKind( + 'fixture:floorplate', + 'fxfloorplate', + { phase: 'foundation', style: 'settle', height: 0.08 }, + () => new BoxGeometry(4, 0.2, 4).translate(2, -0.1, 2), + { scale: ItemNode.shape.scale }, + ['scale'], +) +const Flight = revealKind( + 'fixture:flight', + 'fxflight', + { phase: 'circulation', style: 'drop', height: 2.5 }, + () => new BoxGeometry(1, 2.5, 3).translate(0, 1.25, 1.5), +) const Pane = revealKind('fixture:pane', 'fxpane', { phase: 'openings', style: 'cut' }, () => new BoxGeometry(1, 1.2, 0.1).translate(0, 1.5, 0), ) @@ -173,13 +239,42 @@ const Lid = revealKind( const Part = revealKind('fixture:part', 'fxpart', null, () => new BoxGeometry(3, 1, 4).translate(0, 3.5, 0), ) +// Roofs of three sizes: a small porch, a tall hip, a long flat one. +const Porch = revealKind( + 'fixture:porch', + 'fxporch', + { phase: 'roof', style: 'assemble', height: 4 }, + () => new BoxGeometry(3, 0.7, 2).translate(0, 3.1, 0), +) +const Hip = revealKind( + 'fixture:hip', + 'fxhip', + { phase: 'roof', style: 'assemble', height: 4 }, + () => new BoxGeometry(9, 5, 9).translate(0, 5.5, 0), +) +const LongFlat = revealKind( + 'fixture:longflat', + 'fxlongflat', + { phase: 'roof', style: 'assemble', height: 4 }, + () => new BoxGeometry(14, 0.3, 4).translate(0, 3, 0), +) +// A pitched roof: it rises 1.6 m from its eave to its ridge. +const Gable = revealKind( + 'fixture:gable', + 'fxgable', + { phase: 'roof', style: 'assemble', height: 4 }, + () => new BoxGeometry(6, 1.6, 4).translate(0, 3.6, 0), +) const make = (kind: { schema: typeof BaseNode }, data: Record = {}) => kind.schema.parse(data) as AnyNode +const NO_CHILDREN: AnyNodeId[] = [] + function LevelChildren({ levelId }: { levelId: AnyNodeId }) { const children = useScene( - (state) => (state.nodes[levelId] as { children?: AnyNodeId[] } | undefined)?.children ?? [], + (state) => + (state.nodes[levelId] as { children?: AnyNodeId[] } | undefined)?.children ?? NO_CHILDREN, ) return ( <> @@ -249,12 +344,20 @@ async function agentWrites(write: () => void) { }) } -function start(options: { prefersReducedMotion?: () => boolean } = {}) { +function start( + options: { + prefersReducedMotion?: () => boolean + speed?: () => number + deletes?: (commit: SceneCommit) => boolean + } = {}, +) { dust = new DustPool() driver = startConstructionReveal({ reveals: (commit) => commit.author === 'agent', + ...(options.deletes ? { deletes: options.deletes } : {}), prefersReducedMotion: options.prefersReducedMotion ?? (() => false), level: () => level, + ...(options.speed ? { speed: options.speed } : {}), dust, }) } @@ -285,11 +388,15 @@ beforeEach(async () => { nodes: [ Slab.definition, Wall.definition, + RealWall.definition, QueuedWall.definition, Door.definition, Item.definition, Post.definition, Plate.definition, + Flight.definition, + Beam.definition, + Floorplate.definition, Pane.definition, Roof.definition, Lid.definition, @@ -297,6 +404,10 @@ beforeEach(async () => { Movable.definition, Model.definition, Part.definition, + Gable.definition, + Porch.definition, + Hip.definition, + LongFlat.definition, ], }) now = 0 @@ -305,6 +416,7 @@ beforeEach(async () => { }) afterEach(async () => { + setRevealPreroll(null) await act(async () => driver?.stop()) driver = null await renderer?.unmount() @@ -690,30 +802,6 @@ describe('always instant', () => { await agentWrites(() => useScene.getState().createNode(wall, levelId)) expect(root(wall.id)).toBeDefined() }) - - test('a level nobody sees shows at once and holds no slot', async () => { - const levelIds = loadLevels(2) - await mount(levelIds) - useViewer.setState({ - hideLevelsAboveSelection: true, - selection: { ...useViewer.getState().selection, levelId: levelIds[0]! }, - } as never) - start() - const below = make(Wall) - const above = make(Wall) - await agentWrites(() => - useScene.getState().createNodes([ - { node: above, parentId: levelIds[1]! }, - { node: below, parentId: levelIds[0]! }, - ]), - ) - await frame() - - expect(root(above.id)).toBeDefined() - expect(isNodeRevealing(above.id)).toBe(false) - expect(root(below.id)).toBeDefined() - expect(isNodeRevealing(below.id)).toBe(true) - }) }) describe('the root stays its own', () => { @@ -909,6 +997,218 @@ describe('a roof assembles from its parts', () => { }) }) +describe('a roof at the framing level is covered in place, not dropped', () => { + async function buildGable(partCount = 2) { + level = 'framing' + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const roof = make(Gable) + const parts = Array.from({ length: partCount }, () => make(Part)) + await agentWrites(() => { + useScene.getState().createNode(roof, levelId) + useScene + .getState() + .createNodes(parts.map((node) => ({ node, parentId: roof.id as AnyNodeId }))) + }) + return { roof, parts, levelId: levelId! } + } + + /** Runs the build a frame at a time, noting what the roof's root shows. */ + async function watch(roofId: string, until_ = 20000) { + const lifts: number[] = [] + const levels: number[] = [] + const begun = now + while ((isNodeRevealing(roofId) || !root(roofId)) && now - begun < until_) { + await frame() + const roofRoot = root(roofId) + if (!roofRoot?.children.length) continue + lifts.push(lift(roofRoot)) + const at = revealWipeLevelOf(roofRoot) + if (at !== null) levels.push(at) + } + return { lifts, levels, took: now - begun } + } + + test('it is staged as a covering: nothing drops, and no part is staged on its own', async () => { + const { roof, parts } = await buildGable() + expect(isRevealAssembling(roof.id)).toBe(false) + const styles = new Map() + const stop = subscribeRevealEvents((event) => { + if (event.type === 'node-start') styles.set(event.id, event.style) + }) + try { + const { lifts } = await watch(roof.id) + expect(styles.get(roof.id)).toBe('cover') + for (const part of parts) expect(styles.has(part.id)).toBe(false) + expect(Math.max(...lifts)).toBe(0) + expect(isNodeRevealing(roof.id)).toBe(false) + } finally { + stop() + } + }) + + test('the covering is laid from the eave to the ridge, row after row, until the last has landed', async () => { + const { roof } = await buildGable() + const { levels } = await watch(roof.id) + expect(levels.length).toBeGreaterThan(4) + for (let i = 1; i < levels.length; i += 1) + expect(levels[i]!).toBeGreaterThanOrEqual(levels[i - 1]!) + // The eave is at 2.8 m and the ridge at 4.4 m: it starts low and is closed at the top. + expect(levels[0]!).toBeLessThan(3.4) + expect(levels.at(-1)!).toBeGreaterThanOrEqual(4.4) + // Nothing is left dressed once it is whole. + const roofRoot = root(roof.id)! + expect(revealWipeLevelOf(roofRoot)).toBeNull() + roofRoot.traverse((child) => { + const mesh = child as Mesh + if (mesh.isMesh) expect((mesh.material as { maskNode?: unknown }).maskNode).toBeUndefined() + }) + }) + + test('the covering moves with every frame: strictly increasing, closed at the top at the end', async () => { + const { roof } = await buildGable() + const { levels } = await watch(roof.id) + expect(levels.length).toBeGreaterThan(30) + for (let i = 1; i < levels.length; i += 1) expect(levels[i]!).toBeGreaterThan(levels[i - 1]!) + // The ridge is at 4.4 m: the last frame it is drawn in has closed it. + expect(levels.at(-1)!).toBeGreaterThanOrEqual(4.4) + }) + + test('it takes as long as it is big, within a floor and a ceiling', async () => { + const range = coverRangeAt(1) + const took: Record = {} + for (const [name, kind] of [ + ['porch', Porch], + ['hip', Hip], + ['flat', LongFlat], + ] as const) { + await renderer?.unmount() + renderer = null + await act(async () => driver?.stop()) + driver = null + sceneRegistry.clear() + now = 0 + level = 'framing' + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const roof = make(kind) + await agentWrites(() => useScene.getState().createNode(roof, levelId)) + const { levels, took: ms } = await watch(roof.id, 60000) + expect(levels.length).toBeGreaterThan(0) + took[name] = ms + } + const near = (ms: number, want: number) => Math.abs(ms - want) <= 600 + // A 0.7 m rise is under the floor; a 5 m rise follows its size; 14 m of flat roof is capped. + expect(near(took.porch!, range.minMs)).toBe(true) + expect(near(took.hip!, 5.02 * range.perMeterMs)).toBe(true) + expect(near(took.flat!, range.maxMs)).toBe(true) + expect(took.porch!).toBeLessThan(took.hip!) + expect(took.hip!).toBeLessThan(took.flat!) + }) + + test('it plays at the person’s animation speed', async () => { + const took: Record = {} + for (const speed of [0.5, 2]) { + await renderer?.unmount() + renderer = null + await act(async () => driver?.stop()) + driver = null + sceneRegistry.clear() + now = 0 + level = 'framing' + const [levelId] = loadLevels() + await mount([levelId!]) + start({ speed: () => speed }) + const roof = make(Gable) + await agentWrites(() => useScene.getState().createNode(roof, levelId)) + const { levels, took: ms } = await watch(roof.id, 60000) + expect(levels.length).toBeGreaterThan(0) + took[speed] = ms + } + expect(took[2]!).toBeLessThan(took[0.5]! / 2.5) + }) + + test('the full level still drops the roof whole, part by part', async () => { + level = 'full' + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const roof = make(Gable) + const parts = [make(Part), make(Part)] + await agentWrites(() => { + useScene.getState().createNode(roof, levelId) + useScene + .getState() + .createNodes(parts.map((node) => ({ node, parentId: roof.id as AnyNodeId }))) + }) + expect(isRevealAssembling(roof.id)).toBe(true) + const highest = new Map() + while (isRevealAssembling(roof.id)) { + await frame() + expect(now).toBeLessThan(REVEAL_TIMING.capMs + 1000) + for (const part of parts) { + const partRoot = root(part.id) + if (partRoot?.children.length) + highest.set(part.id, Math.max(highest.get(part.id) ?? 0, lift(partRoot))) + } + expect(revealWipeLevelOf(root(roof.id) ?? new Object3D())).toBeNull() + } + for (const part of parts) expect(highest.get(part.id)!).toBeGreaterThan(3) + }) + + test('simple scales it in and off shows it at once: neither lays a covering', async () => { + for (const at of ['simple', 'off'] as const) { + await renderer?.unmount() + renderer = null + await act(async () => driver?.stop()) + driver = null + sceneRegistry.clear() + now = 0 + level = at + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const roof = make(Gable) + await agentWrites(() => useScene.getState().createNode(roof, levelId)) + for (let i = 0; i < 80; i += 1) { + await frame() + const roofRoot = root(roof.id) + if (roofRoot) expect(revealWipeLevelOf(roofRoot)).toBeNull() + } + } + }) + + test('an undo lifts the covering off from the ridge, then the bare node is gone', async () => { + const { roof } = await buildGable() + await watch(roof.id) + const roofRoot = root(roof.id)! + let result: RetractResult | null = null + await act(async () => { + retractNodes([roof.id]).then((value) => { + result = value + }) + }) + const levels: number[] = [] + const begun = now + while (!result && now < begun + 8000) { + await frame(20) + const at = revealWipeLevelOf(roofRoot) + if (at !== null) levels.push(at) + // It never lifts off whole: only its covering goes, then the bare node. + if (yScale(roofRoot) > 0.5) expect(lift(roofRoot)).toBe(0) + } + expect(result).not.toBeNull() + expect(yScale(roofRoot)).toBeLessThan(0.01) + expect(levels.length).toBeGreaterThan(10) + // It lifts continuously from the ridge: every frame is lower than the one before. + for (let i = 1; i < levels.length; i += 1) expect(levels[i]!).toBeLessThan(levels[i - 1]!) + expect(levels[0]!).toBeGreaterThan(4) + expect(levels.at(-1)!).toBeLessThan(3.4) + }) +}) + describe('the animation level', () => { test('off shows the build at once, with no dust', async () => { level = 'off' @@ -948,164 +1248,1157 @@ describe('the animation level', () => { ]) useScene.getState().createNode(part, roof.id as AnyNodeId) }) - expect(root(post.id)).toBeUndefined() - expect(isRevealAssembling(roof.id)).toBe(false) + expect(root(post.id)).toBeUndefined() + expect(isRevealAssembling(roof.id)).toBe(false) + + let grew = false + while (isNodeRevealing(post.id) || isNodeRevealing(wall.id) || isNodeRevealing(roof.id)) { + await frame() + expect(now).toBeLessThan(REVEAL_TIMING.capMs + 1000) + const postRoot = root(post.id) + if (postRoot?.children.length) { + expect(lift(postRoot)).toBeCloseTo(0, 9) + if (composed(postRoot).elements[0]! < 0.95) grew = true + } + // The part shows with its roof, not after it. + if (root(roof.id)) expect(isNodeRevealing(part.id)).toBe(false) + expect(dust.live).toBe(0) + } + expect(grew).toBe(true) + }) + + test('framing plays like full in the viewer: the fall and the dust', async () => { + level = 'framing' + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const post = make(Post) + await agentWrites(() => useScene.getState().createNode(post, levelId)) + let highest = 0 + while (isNodeRevealing(post.id)) { + await frame() + expect(now).toBeLessThan(5000) + const postRoot = root(post.id) + if (postRoot?.children.length) highest = Math.max(highest, lift(postRoot)) + } + expect(highest).toBeGreaterThan(3) + expect(dust.live).toBeGreaterThan(0) + }) + + test('turning it off mid-build finishes the build', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const walls = [make(Wall), make(Wall), make(Wall)] + await agentWrites(() => + useScene + .getState() + .createNodes(walls.map((node) => ({ node, parentId: levelId as AnyNodeId }))), + ) + await frame() + level = 'off' + await frame() + for (const wall of walls) { + expect(isNodeRevealing(wall.id)).toBe(false) + expect(root(wall.id)).toBeDefined() + } + expect(dust.live).toBe(0) + }) +}) + +describe('the plan view reads the same reveal', () => { + test('each node waits, then grows from 0 to 1 on the reveal clock, then is done', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const wall = make(Wall) + await agentWrites(() => useScene.getState().createNode(wall, levelId)) + expect(getRevealPlanState(wall.id)).toEqual({ style: 'rise', progress: 0 }) + + let ticks = 0 + const stop = subscribeRevealTicks(() => { + ticks += 1 + }) + try { + const progress: number[] = [] + while (getRevealPlanState(wall.id)) { + progress.push(getRevealPlanState(wall.id)!.progress) + await frame() + expect(now).toBeLessThan(5000) + } + expect(progress.some((value) => value > 0 && value < 1)).toBe(true) + for (let index = 1; index < progress.length; index += 1) { + expect(progress[index]!).toBeGreaterThanOrEqual(progress[index - 1]!) + } + expect(ticks).toBeGreaterThan(5) + const settled = ticks + await frames(200) + expect(ticks).toBe(settled) + } finally { + stop() + } + expect(getRevealPlanState(make(Wall).id)).toBeNull() + }) + + test(' keeps its clock while the canvas is paused, as when only the plan shows', async () => { + const [levelId] = loadLevels() + renderer = await create( + <> + + commit.author === 'agent'} /> + , + ) + const queued: FrameRequestCallback[] = [] + const previousRequest = globalThis.requestAnimationFrame + const previousCancel = globalThis.cancelAnimationFrame + globalThis.requestAnimationFrame = (callback) => queued.push(callback) + globalThis.cancelAnimationFrame = () => {} + try { + await act(async () => useViewer.setState({ renderPaused: true } as never)) + const wall = make(Wall) + await agentWrites(() => useScene.getState().createNode(wall, levelId)) + expect(root(wall.id)).toBeUndefined() + + let clock = 0 + for (let step = 0; step < 100 && !root(wall.id); step += 1) { + clock += 16 + const due = queued.splice(0) + await act(async () => { + for (const callback of due) callback(clock) + }) + } + expect(root(wall.id)).toBeDefined() + } finally { + globalThis.requestAnimationFrame = previousRequest + globalThis.cancelAnimationFrame = previousCancel + } + }) +}) + +describe('the nodes playing are named for the cutaway', () => { + test('a wall is listed while it moves in, and not before or after', async () => { + const levelIds = loadLevels(1) + await mount(levelIds) + start() + const wall = make(Wall) + await agentWrites(() => useScene.getState().createNode(wall, levelIds[0]!)) + await frame() + await until(() => useViewer.getState().nodesRevealing.includes(wall.id), 6000) + expect(useViewer.getState().wallMode).not.toBe('down') + await until(() => !isNodeRevealing(wall.id), 9000) + expect(useViewer.getState().nodesRevealing).toEqual([]) + }) + + test('the person’s wall mode is not written by a build', async () => { + const levelIds = loadLevels(1) + await mount(levelIds) + useViewer.getState().setWallMode('cutaway') + start() + const wall = make(Wall) + await agentWrites(() => useScene.getState().createNode(wall, levelIds[0]!)) + await until(() => useViewer.getState().nodesRevealing.includes(wall.id), 6000) + expect(useViewer.getState().wallMode).toBe('cutaway') + await until(() => !isNodeRevealing(wall.id), 9000) + expect(useViewer.getState().wallMode).toBe('cutaway') + }) +}) + +describe('the level the work is on shows while it plays', () => { + const GROUND_ONLY = (levelIds: AnyNodeId[]) => + useViewer.setState({ + hideLevelsAboveSelection: true, + selection: { ...useViewer.getState().selection, levelId: levelIds[0]! }, + } as never) + + test('a wall built above the selected level is revealed, and its level is on show meanwhile', async () => { + const levelIds = loadLevels(2) + await mount(levelIds) + GROUND_ONLY(levelIds) + start() + const below = make(Wall) + const above = make(Wall) + await agentWrites(() => + useScene.getState().createNodes([ + { node: above, parentId: levelIds[1]! }, + { node: below, parentId: levelIds[0]! }, + ]), + ) + await frame() + expect(isNodeRevealing(above.id)).toBe(true) + expect(isNodeRevealing(below.id)).toBe(true) + expect(useViewer.getState().levelsRevealing).toContain(levelIds[1]!) + + await until(() => Boolean(root(above.id)) && hasRevealPose(root(above.id)!)) + await until(() => !isNodeRevealing(above.id) && !isNodeRevealing(below.id), 9000) + expect(composed(root(above.id)!).equals(own(root(above.id)!))).toBe(true) + expect(useViewer.getState().levelsRevealing).toEqual([]) + }) + + test('the solo view of the ground floor does not hide a storey being built', async () => { + const levelIds = loadLevels(2) + await mount(levelIds) + useViewer.setState({ + levelMode: 'solo', + selection: { ...useViewer.getState().selection, levelId: levelIds[0]! }, + } as never) + start() + const above = make(Wall) + await agentWrites(() => useScene.getState().createNode(above, levelIds[1]!)) + await frame() + expect(isNodeRevealing(above.id)).toBe(true) + expect(useViewer.getState().levelsRevealing).toEqual([levelIds[1]!]) + }) + + test('a level the author hid still shows its build at once and holds no slot', async () => { + const levelIds = loadLevels(2) + await mount(levelIds) + useScene.getState().updateNode(levelIds[1]!, { visible: false } as never) + start() + const hidden = make(Wall) + const seen = make(Wall) + await agentWrites(() => + useScene.getState().createNodes([ + { node: hidden, parentId: levelIds[1]! }, + { node: seen, parentId: levelIds[0]! }, + ]), + ) + await frame() + expect(root(hidden.id)).toBeDefined() + expect(isNodeRevealing(hidden.id)).toBe(false) + expect(isNodeRevealing(seen.id)).toBe(true) + expect(useViewer.getState().levelsRevealing).not.toContain(levelIds[1]!) + }) + + test('a volume over two storeys plays on both while the person watches the ground floor', async () => { + const levelIds = loadLevels(2) + await mount(levelIds) + GROUND_ONLY(levelIds) + start() + const walls = [make(Wall), make(Wall)] + const upperWalls = [make(Wall), make(Wall)] + const upperRoof = make(Roof) + await agentWrites(() => + useScene + .getState() + .createNodes([ + ...walls.map((node) => ({ node, parentId: levelIds[0]! })), + ...upperWalls.map((node) => ({ node, parentId: levelIds[1]! })), + { node: upperRoof, parentId: levelIds[1]! }, + ]), + ) + await frame() + for (const node of [...walls, ...upperWalls, upperRoof]) { + expect(isNodeRevealing(node.id)).toBe(true) + } + await until( + () => [...walls, ...upperWalls, upperRoof].every((node) => !isNodeRevealing(node.id)), + 12000, + ) + expect(useViewer.getState().levelsRevealing).toEqual([]) + }) + + test('a stair across two storeys plays on the upper one too', async () => { + const levelIds = loadLevels(2) + await mount(levelIds) + GROUND_ONLY(levelIds) + start() + const flight = make(Flight) + const landing = make(Plate) + await agentWrites(() => + useScene.getState().createNodes([ + { node: flight, parentId: levelIds[0]! }, + { node: landing, parentId: levelIds[1]! }, + ]), + ) + await frame() + expect(isNodeRevealing(flight.id)).toBe(true) + expect(isNodeRevealing(landing.id)).toBe(true) + expect(useViewer.getState().levelsRevealing).toEqual( + expect.arrayContaining([levelIds[0]!, levelIds[1]!]), + ) + }) + + test("the person's later choice of level does not cut the animation short", async () => { + const levelIds = loadLevels(2) + await mount(levelIds) + GROUND_ONLY(levelIds) + start() + const upperWalls = Array.from({ length: 4 }, () => make(Wall)) + await agentWrites(() => + useScene.getState().createNodes(upperWalls.map((node) => ({ node, parentId: levelIds[1]! }))), + ) + await frames(200) + // They look at the ground floor again, then at the upper one: the build is on show either way. + await act(async () => + useViewer.setState({ + selection: { ...useViewer.getState().selection, levelId: levelIds[0]! }, + } as never), + ) + await frame() + expect(upperWalls.some((node) => isNodeRevealing(node.id))).toBe(true) + expect(useViewer.getState().levelsRevealing).toEqual([levelIds[1]!]) + await until(() => upperWalls.every((node) => !isNodeRevealing(node.id)), 9000) + for (const node of upperWalls) { + expect(composed(root(node.id)!).equals(own(root(node.id)!))).toBe(true) + } + }) + + test('a piece the agent moves on an upper level glides while the person watches the ground floor', async () => { + const levelIds = loadLevels(2) + await mount(levelIds) + start() + const piece = make(Movable, { position: [0, 0, 0] }) + await agentWrites(() => useScene.getState().createNode(piece, levelIds[1]!)) + await until(() => !isNodeRevealing(piece.id) && root(piece.id) !== undefined, 9000) + GROUND_ONLY(levelIds) + await agentWrites(() => + useScene + .getState() + .updateNodes([{ id: piece.id as AnyNodeId, data: { position: [3, 0, 2] } as never }]), + ) + await frame() + expect(isNodeRevealing(piece.id)).toBe(true) + expect(useViewer.getState().levelsRevealing).toEqual([levelIds[1]!]) + }) + + test('an undo plays back a build on an upper level the display hides', async () => { + const levelIds = loadLevels(2) + await mount(levelIds) + start() + const above = make(Wall) + await agentWrites(() => useScene.getState().createNode(above, levelIds[1]!)) + await until(() => !isNodeRevealing(above.id) && root(above.id) !== undefined, 9000) + GROUND_ONLY(levelIds) + let result: RetractResult | null = null + await act(async () => { + retractNodes([above.id]).then((value) => { + result = value + }) + }) + expect(useViewer.getState().levelsRevealing).toEqual([levelIds[1]!]) + await until(() => result !== null, 6000) + expect((result as RetractResult | null)?.retracted).toEqual([above.id]) + expect((result as RetractResult | null)?.instant).toBe(false) + }) +}) + +// The owner, 15 October: a wall bent to a curve, or turned curtain, jumped to its new shape. An edit +// that changes what a node looks like plays its reveal again, quickly; any other edit stays instant. +describe('an edit that changes a picture plays again', () => { + async function builtBeam() { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const beam = make(Beam) + await agentWrites(() => useScene.getState().createNode(beam, levelId)) + await until(() => !isNodeRevealing(beam.id) && root(beam.id) !== undefined, 9000) + return { levelId: levelId!, beam } + } + const edit = (id: string, data: Record) => + agentWrites(() => + useScene.getState().updateNodes([{ id: id as AnyNodeId, data: data as never }]), + ) + + for (const at of ['simple', 'full', 'framing'] as const) { + test(`${at}: a changed geometry field re-rises the node and it ends at rest`, async () => { + level = at + const { beam } = await builtBeam() + const events: RevealEvent[] = [] + const stop = subscribeRevealEvents((event) => events.push(event)) + await edit(beam.id, { scale: [1, 1, 1.5] }) + await frame() + await frame() + expect(isNodeRevealing(beam.id)).toBe(true) + while (!(root(beam.id) && hasRevealPose(root(beam.id)!))) await frame() + expect(yScale(root(beam.id)!)).toBeLessThan(1) + await until(() => !isNodeRevealing(beam.id), 4000) + stop() + expect(composed(root(beam.id)!).equals(own(root(beam.id)!))).toBe(true) + expect(events.some((e) => e.type === 'node-start' && e.changed === true)).toBe(true) + expect(events.at(-1)).toMatchObject({ type: 'complete', changed: true }) + }) + } + + test('an edit of a field that is not geometry stays instant', async () => { + const { beam } = await builtBeam() + await edit(beam.id, { name: 'North wall' }) + await frames(100) + expect(isNodeRevealing(beam.id)).toBe(false) + expect(hasRevealPose(root(beam.id)!)).toBe(false) + }) + + test("a person's own edit stays instant", async () => { + const { beam } = await builtBeam() + await act(async () => + useScene + .getState() + .updateNodes([{ id: beam.id as AnyNodeId, data: { scale: [2, 1, 1] } as never }]), + ) + await frames(100) + expect(isNodeRevealing(beam.id)).toBe(false) + }) + + test('what the structure reconciler derived is not an edit, but its field-by-field writes leave the edit alone', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const plate = make(Floorplate) + await agentWrites(() => useScene.getState().createNode(plate, levelId)) + await until(() => !isNodeRevealing(plate.id) && root(plate.id) !== undefined, 9000) + const before = useScene.getState().nodes + const after = { ...before, [plate.id]: { ...before[plate.id as AnyNodeId]!, scale: [3, 1, 3] } } + const commit = { + origin: 'local', + author: 'agent', + before: { nodes: before }, + current: { nodes: after }, + } as unknown as SceneCommit + expect(commitEditCandidates(commit, 'full').map((c) => c.id)).toEqual([plate.id]) + const derived = { + ...commit, + derivedWrites: new Map([[plate.id as AnyNodeId, new Set(['scale'])]]), + } as SceneCommit + expect(commitEditCandidates(derived, 'full')).toEqual([]) + const authored = { + ...commit, + derivedWrites: new Map([[plate.id as AnyNodeId, new Set(['name'])]]), + } as SceneCommit + expect(commitEditCandidates(authored, 'full').map((c) => c.id)).toEqual([plate.id]) + }) + + test('a slab edit settles, as a slab came in', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const plate = make(Floorplate) + await agentWrites(() => useScene.getState().createNode(plate, levelId)) + await until(() => !isNodeRevealing(plate.id) && root(plate.id) !== undefined, 9000) + const events: RevealEvent[] = [] + const stop = subscribeRevealEvents((event) => events.push(event)) + await edit(plate.id, { scale: [2, 1, 2] }) + await frames(300) + stop() + expect(events.find((e) => e.type === 'node-start')).toMatchObject({ + style: 'settle', + changed: true, + }) + }) + + test('it plays at the person’s animation speed', async () => { + const took: Record = {} + for (const speed of [0.5, 2]) { + await act(async () => driver?.stop()) + await renderer?.unmount() + renderer = null + sceneRegistry.clear() + const [levelId] = loadLevels() + await mount([levelId!]) + start({ speed: () => speed }) + const beam = make(Beam) + await agentWrites(() => useScene.getState().createNode(beam, levelId)) + await until(() => !isNodeRevealing(beam.id) && root(beam.id) !== undefined, 20000) + await edit(beam.id, { scale: [1, 1, 1.5] }) + const began = now + await frame() + await until(() => !isNodeRevealing(beam.id) && !hasRevealPose(root(beam.id)!), 8000) + took[speed] = now - began + } + expect(took[2]!).toBeLessThan(took[0.5]! / 1.5) + }) + + test('many edits begin spread over the same time, not piled on its last frame', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const beams = Array.from({ length: 24 }, () => make(Beam)) + await agentWrites(() => + useScene.getState().createNodes(beams.map((node) => ({ node, parentId: levelId }))), + ) + await until(() => beams.every((b) => !isNodeRevealing(b.id) && root(b.id)), 30000) + const starts: number[] = [] + const stop = subscribeRevealEvents((event) => { + if (event.type === 'node-start') starts.push(event.atMs) + }) + await agentWrites(() => + useScene + .getState() + .updateNodes( + beams.map((b) => ({ id: b.id as AnyNodeId, data: { scale: [1, 1, 2] } as never })), + ), + ) + await until(() => beams.every((b) => !isNodeRevealing(b.id)), 12000) + stop() + expect(starts).toHaveLength(24) + const span = Math.max(...starts) - Math.min(...starts) + expect(span).toBeLessThanOrEqual(1300) + // No frame starts more than a few: the last ones are not clamped onto one moment. + const perMoment = new Map() + for (const at of starts) perMoment.set(at, (perMoment.get(at) ?? 0) + 1) + expect(Math.max(...perMoment.values())).toBeLessThanOrEqual(3) + }) + + test('with the animation off, an edit is instant', async () => { + level = 'off' + const { beam } = await builtBeam() + await edit(beam.id, { scale: [1, 1, 1.5] }) + await frames(100) + expect(isNodeRevealing(beam.id)).toBe(false) + expect(hasRevealPose(root(beam.id)!)).toBe(false) + }) +}) + +// A deletion plays the creation backwards. The node is out of the scene at once; a snapshot of its +// picture plays out, and is taken away. +describe('a deletion plays the creation backwards', () => { + /** What stands in the scene that is not a mounted node: the snapshots. */ + const pictures = () => + (renderer!.scene.instance as Object3D).children.filter( + (child) => ![...sceneRegistry.nodes.values()].includes(child) && child.children.length > 0, + ) + const remove = (ids: string[], author: 'agent' | null = 'agent') => + act(async () => { + const run = () => useScene.getState().deleteNodes(ids as AnyNodeId[]) + if (author) runAsSceneCommitAuthor(author, run) + else run() + }) + + async function builtWalls(count: number) { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const walls = Array.from({ length: count }, () => make(Wall)) + await agentWrites(() => + useScene.getState().createNodes(walls.map((node) => ({ node, parentId: levelId }))), + ) + await until(() => walls.every((w) => !isNodeRevealing(w.id) && root(w.id)), 12000) + return { levelId: levelId!, walls } + } + + for (const at of ['simple', 'full', 'framing'] as const) { + test(`${at}: the node is gone from the store at once and its picture plays out, then goes`, async () => { + level = at + const { walls } = await builtWalls(1) + const [wall] = walls + await remove([wall!.id]) + expect(useScene.getState().nodes[wall!.id as AnyNodeId]).toBeUndefined() + await frame() + expect(pictures()).toHaveLength(1) + await frames(120) + const picture = pictures()[0]! + expect(hasRevealPose(picture)).toBe(true) + await until(() => pictures().length === 0, 4000) + }) + } + + // The owner's rule (15 Oct): a node that plays is never hidden by the cutaway. + test('a wall the cutaway was hiding plays its deletion whole, not as its stipple, then goes', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const wall = make(RealWall) + await agentWrites(() => useScene.getState().createNode(wall, levelId)) + await until(() => !isNodeRevealing(wall.id) && Boolean(root(wall.id)), 12000) + // The wall as the wall system leaves it in cutaway: its mesh in the stipple, stamped hidden. + const stipple = new MeshBasicMaterial({ transparent: true, opacity: 0.2 }) + const geometry = new BoxGeometry(4, 2.5, 0.2) + // One group over the whole mesh, as a wall's own geometry draws it: the palette covers it. + geometry.clearGroups() + geometry.addGroup(0, 6, 0) + const mesh = new Mesh(geometry, [stipple, stipple, stipple]) + mesh.userData.wallHidden = true + // ...with the visible variant it keeps for whoever draws a copy of it. + visibleMaterialsOf.set(mesh, [new MeshBasicMaterial()]) + root(wall.id)!.parent!.add(mesh) + sceneRegistry.nodes.set(wall.id, mesh) + useViewer.setState({ wallMode: 'cutaway' }) + const copies = () => { + const found: Mesh[] = [] + ;(renderer!.scene.instance as Object3D).traverse((object) => { + const candidate = object as Mesh + if (candidate.isMesh && candidate !== mesh && candidate.userData.wallHidden === true) + found.push(candidate) + }) + return found + } + await remove([wall.id]) + await frame() + const [copy] = copies() + expect(copy).toBeDefined() + const worn = copy!.material + expect(Array.isArray(worn) ? worn[0] : worn).not.toBe(stipple) + // It plays: the same visible variant on every frame of the reverse play, until it is taken away. + for (let step = 0; step < 6; step += 1) { + await frames(100) + if (!copies().includes(copy!)) break + expect(copy!.material).toBe(worn) + } + await until(() => !copies().includes(copy!), 6000) + }) + + test('a wall the cutaway showed plays its deletion in what it wore', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const wall = make(RealWall) + await agentWrites(() => useScene.getState().createNode(wall, levelId)) + await until(() => !isNodeRevealing(wall.id) && Boolean(root(wall.id)), 12000) + const own = new MeshBasicMaterial() + const mesh = new Mesh(new BoxGeometry(4, 2.5, 0.2), own) + mesh.userData.wallHidden = false + root(wall.id)!.parent!.add(mesh) + sceneRegistry.nodes.set(wall.id, mesh) + useViewer.setState({ wallMode: 'cutaway' }) + await remove([wall.id]) + await frame() + const copy: Mesh | undefined = (renderer!.scene.instance as Object3D).getObjectByProperty( + 'type', + 'Mesh', + ) as Mesh | undefined + const ghosts: Mesh[] = [] + ;(renderer!.scene.instance as Object3D).traverse((object) => { + const candidate = object as Mesh + if (candidate.isMesh && candidate !== mesh && candidate.material === own) + ghosts.push(candidate) + }) + expect(copy).toBeDefined() + expect(ghosts.length).toBe(1) + }) + + test('many deletions stagger, the last built going first', async () => { + const { walls } = await builtWalls(4) + const events: RevealEvent[] = [] + const stop = subscribeRevealEvents((event) => events.push(event)) + await remove(walls.map((w) => w.id)) + const poses: number[] = [] + for (let step = 0; step < 100; step += 1) { + await frame() + poses.push(pictures().filter((p) => hasRevealPose(p)).length) + } + stop() + expect(Math.max(...poses)).toBeGreaterThan(0) + // Not all at once: some are still waiting for their turn while others are moving. + expect(poses.some((count) => count > 0 && count < walls.length)).toBe(true) + await until(() => pictures().length === 0, 6000) + const retract = events.filter((e) => e.type === 'retract-start' || e.type === 'retract-end') + expect(retract.map((e) => e.type)).toEqual(['retract-start', 'retract-end']) + }) + + test('it plays at the person’s animation speed', async () => { + const took: Record = {} + for (const speed of [0.5, 2]) { + await act(async () => driver?.stop()) + await renderer?.unmount() + renderer = null + sceneRegistry.clear() + const [levelId] = loadLevels() + await mount([levelId!]) + start({ speed: () => speed }) + const wall = make(Wall) + await agentWrites(() => useScene.getState().createNode(wall, levelId)) + await until(() => !isNodeRevealing(wall.id) && root(wall.id) !== undefined, 20000) + await remove([wall.id]) + const began = now + await frame() + await until(() => pictures().length === 0, 8000) + took[speed] = now - began + } + expect(took[2]!).toBeLessThan(took[0.5]! / 1.5) + }) + + test("a person's delete plays when the host says so, and not otherwise", async () => { + const { walls } = await builtWalls(2) + await remove([walls[0]!.id], null) + await frame() + expect(pictures()).toHaveLength(0) + + await act(async () => driver?.stop()) + start({ deletes: () => true }) + await remove([walls[1]!.id], null) + await frame() + expect(pictures()).toHaveLength(1) + }) + + test('an undo plays its own reverse and leaves no second picture', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const wall = make(Wall) + await agentWrites(() => useScene.getState().createNode(wall, levelId)) + await until(() => !isNodeRevealing(wall.id) && root(wall.id) !== undefined, 9000) + await act(async () => useScene.temporal.getState().undo()) + await frame() + expect(useScene.getState().nodes[wall.id as AnyNodeId]).toBeUndefined() + expect(pictures()).toHaveLength(0) + }) + + test('nothing plays with the animation off', async () => { + level = 'off' + const { walls } = await builtWalls(1) + await remove([walls[0]!.id]) + await frame() + expect(pictures()).toHaveLength(0) + }) + + test('a load clears what is playing', async () => { + const { walls } = await builtWalls(1) + await remove([walls[0]!.id]) + await frame() + expect(pictures()).toHaveLength(1) + await act(async () => { + const { nodes, rootNodeIds } = useScene.getState() + applySceneSnapshot( + { nodes, rootNodeIds, collections: {}, materials: {}, installedPlugins: [PLUGIN_ID] }, + { origin: 'load' }, + ) + }) + expect(pictures()).toHaveLength(0) + }) + + test('the level it was on shows while its picture plays', async () => { + const levelIds = loadLevels(2) + await mount(levelIds) + start() + const wall = make(Wall) + await agentWrites(() => useScene.getState().createNode(wall, levelIds[1]!)) + await until(() => !isNodeRevealing(wall.id) && root(wall.id) !== undefined, 9000) + useViewer.setState({ + hideLevelsAboveSelection: true, + selection: { ...useViewer.getState().selection, levelId: levelIds[0]! }, + } as never) + await remove([wall.id]) + await frame() + expect(useViewer.getState().levelsRevealing).toEqual([levelIds[1]!]) + await until(() => pictures().length === 0, 6000) + await frame() + expect(useViewer.getState().levelsRevealing).toEqual([]) + }) + + // The owner's rule (15 Oct): a node that plays is never hidden by the cutaway; the wall a + // departing opening stands in is no exception, though the opening is already out of the scene. + test('the wall an opening is leaving stays in play for as long as its picture plays', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const wall = make(RealWall) + const door = make(Door) + await agentWrites(() => useScene.getState().createNode(wall, levelId)) + await agentWrites(() => useScene.getState().createNode(door, wall.id as AnyNodeId)) + await until( + () => !isNodeRevealing(wall.id) && !isNodeRevealing(door.id) && Boolean(root(door.id)), + 12000, + ) + expect(useViewer.getState().nodesRevealing).toEqual([]) + await remove([door.id]) + await frame() + expect(getRetractTurn(door.id)).not.toBeNull() + const inPlay = () => wallsInPlay(useScene.getState().nodes, useViewer.getState().nodesRevealing) + expect(inPlay().has(wall.id)).toBe(true) + await until(() => getRetractTurn(door.id) === null, 6000) + await frame() + expect(useViewer.getState().nodesRevealing).toEqual([]) + expect(inPlay().size).toBe(0) + }) +}) + +// Deleting a room or a whole floor takes it apart: every node it removes plays its build backwards, +// room by room and floor by floor, the furniture first and the slab last. +describe('deleting a floor or a room deconstructs it', () => { + const pictures = () => + (renderer!.scene.instance as Object3D).children.filter( + (child) => ![...sceneRegistry.nodes.values()].includes(child) && child.children.length > 0, + ) + + /** A floor: its slab, walls with a pane in the first, and crates. */ + const floor = (levelId: AnyNodeId) => { + const slab = make(Slab) + const walls = [make(Wall), make(Wall)] + const pane = make(Pane) + const crates = [make(Crate), make(Crate)] + return { + slab, + walls, + pane, + crates, + levelId, + all: [slab, ...walls, pane, ...crates], + create: () => { + const store = useScene.getState() + store.createNodes([ + { node: slab, parentId: levelId }, + ...walls.map((node) => ({ node, parentId: levelId })), + ...crates.map((node) => ({ node, parentId: levelId })), + ]) + store.createNode(pane, walls[0]!.id as AnyNodeId) + }, + } + } + + async function builtFloors(count: number) { + const levelIds = loadLevels(count) + await mount(levelIds) + start({ deletes: () => true }) + const floors = levelIds.map((levelId) => floor(levelId)) + await agentWrites(() => { + for (const one of floors) one.create() + }) + const everything = floors.flatMap((one) => one.all) + await until(() => everything.every((node) => !isNodeRevealing(node.id) && root(node.id)), 60000) + return { levelIds, floors, everything } + } + + const removeLevels = (ids: string[]) => + act(async () => { + runAsSceneCommitAuthor('agent', () => useScene.getState().deleteNodes(ids as AnyNodeId[])) + }) - let grew = false - while (isNodeRevealing(post.id) || isNodeRevealing(wall.id) || isNodeRevealing(roof.id)) { + for (const at of ['full', 'framing'] as const) { + test(`${at}: a floor delete plays every node it removes, the furniture first and the slab last`, async () => { + level = at + const { floors, levelIds } = await builtFloors(1) + const [one] = floors + await removeLevels([levelIds[0]!]) + for (const node of one!.all) + expect(useScene.getState().nodes[node.id as AnyNodeId]).toBeUndefined() + const turn = (id: string) => getRetractTurn(id) + for (const node of one!.all) expect(turn(node.id)).not.toBeNull() + const starts = (nodes: AnyNode[]) => nodes.map((node) => turn(node.id)!.startMs) + const furniture = Math.max(...starts(one!.crates)) + const opening = turn(one!.pane.id)!.startMs + const walls = Math.min(...starts(one!.walls)) + const slab = turn(one!.slab.id)!.startMs + expect(furniture).toBeLessThanOrEqual(opening) + expect(opening).toBeLessThanOrEqual(walls) + expect(walls).toBeLessThanOrEqual(slab) + // They play, and then every picture is gone. await frame() - expect(now).toBeLessThan(REVEAL_TIMING.capMs + 1000) - const postRoot = root(post.id) - if (postRoot?.children.length) { - expect(lift(postRoot)).toBeCloseTo(0, 9) - if (composed(postRoot).elements[0]! < 0.95) grew = true - } - // The part shows with its roof, not after it. - if (root(roof.id)) expect(isNodeRevealing(part.id)).toBe(false) - expect(dust.live).toBe(0) + expect(pictures().length).toBeGreaterThan(0) + await until(() => pictures().length === 0, 60000) + for (const node of one!.all) expect(getRetractTurn(node.id)).toBeNull() + }) + } + + test('framing: two floors come apart from the top down, each floor in its own order', async () => { + level = 'framing' + const { floors, levelIds } = await builtFloors(2) + await removeLevels(levelIds as string[]) + const [ground, top] = floors + const wallStart = (one: ReturnType) => + Math.min(...one.walls.map((wall) => getRetractTurn(wall.id)!.startMs)) + expect(wallStart(top!)).toBeLessThan(wallStart(ground!)) + for (const one of floors) { + const furniture = Math.max(...one.crates.map((crate) => getRetractTurn(crate.id)!.startMs)) + expect(furniture).toBeLessThanOrEqual(wallStart(one)) } - expect(grew).toBe(true) + await until(() => pictures().length === 0, 90000) }) - test('framing plays like full in the viewer: the fall and the dust', async () => { + test('framing: the pictures of a deleted floor stand in their building, not in the floor that goes', async () => { level = 'framing' + const building = BuildingNode.parse({ children: [] }) const [levelId] = loadLevels() + const levelNode = useScene.getState().nodes[levelId!] as AnyNode & { parentId: string | null } + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + [building.id]: { ...building, children: [levelId] } as AnyNode, + [levelId!]: { ...levelNode, parentId: building.id } as AnyNode, + }, + } as never) await mount([levelId!]) - start() - const post = make(Post) - await agentWrites(() => useScene.getState().createNode(post, levelId)) - let highest = 0 - while (isNodeRevealing(post.id)) { - await frame() - expect(now).toBeLessThan(5000) - const postRoot = root(post.id) - if (postRoot?.children.length) highest = Math.max(highest, lift(postRoot)) - } - expect(highest).toBeGreaterThan(3) - expect(dust.live).toBeGreaterThan(0) + start({ deletes: () => true }) + const one = floor(levelId!) + await agentWrites(() => one.create()) + await until(() => one.all.every((node) => !isNodeRevealing(node.id) && root(node.id)), 60000) + // The host's renderers register a group for the building and one for the floor that holds the roots. + const buildingGroup = new Group() + const levelGroup = new Group() + buildingGroup.add(levelGroup) + sceneRegistry.nodes.set(building.id, buildingGroup) + sceneRegistry.nodes.set(levelId!, levelGroup) + for (const node of one.all) levelGroup.add(root(node.id)!) + await removeLevels([levelId!]) + await frame() + const standing = buildingGroup.children.filter((child) => child !== levelGroup) + expect(standing.length).toBeGreaterThan(0) + // The floor goes: its group is taken out of the building, and the pictures are still there. + buildingGroup.remove(levelGroup) + await frames(200) + expect(buildingGroup.children.length).toBeGreaterThan(0) + await until(() => buildingGroup.children.length === 0, 60000) }) - test('turning it off mid-build finishes the build', async () => { + test('with the animation off a floor delete is instant: nothing plays', async () => { + level = 'off' + const { levelIds, floors } = await builtFloors(1) + await removeLevels([levelIds[0]!]) + await frame() + expect(pictures()).toHaveLength(0) + for (const node of floors[0]!.all) expect(getRetractTurn(node.id)).toBeNull() + }) + + test('a big floor is planned in one frame’s budget', async () => { + level = 'framing' const [levelId] = loadLevels() await mount([levelId!]) - start() - const walls = [make(Wall), make(Wall), make(Wall)] + start({ deletes: () => true }) + const walls = Array.from({ length: 120 }, () => make(Wall)) + const crates = Array.from({ length: 120 }, () => make(Crate)) await agentWrites(() => useScene .getState() - .createNodes(walls.map((node) => ({ node, parentId: levelId as AnyNodeId }))), + .createNodes([...walls, ...crates].map((node) => ({ node, parentId: levelId }))), ) - await frame() - level = 'off' - await frame() - for (const wall of walls) { - expect(isNodeRevealing(wall.id)).toBe(false) - expect(root(wall.id)).toBeDefined() - } - expect(dust.live).toBe(0) + await until( + () => [...walls, ...crates].every((node) => !isNodeRevealing(node.id) && root(node.id)), + 120000, + ) + const began = performance.now() + await removeLevels([levelId!]) + const planned = performance.now() - began + expect(getRetractTurn(walls[0]!.id)).not.toBeNull() + // Planning 240 snapshots in the commit itself: well under what would hitch a frame twice over. + expect(planned).toBeLessThan(120) + const ends = [...walls, ...crates].map((node) => { + const turn = getRetractTurn(node.id)! + return turn.startMs + turn.durationMs + }) + // However many pieces, the whole play ends within the cap of the reverse plan. + expect(Math.max(...ends) - now).toBeLessThanOrEqual(14_000 + 1000) }) }) -describe('the plan view reads the same reveal', () => { - test('each node waits, then grows from 0 to 1 on the reveal clock, then is done', async () => { - const [levelId] = loadLevels() - await mount([levelId!]) - start() - const wall = make(Wall) - await agentWrites(() => useScene.getState().createNode(wall, levelId)) - expect(getRevealPlanState(wall.id)).toEqual({ style: 'rise', progress: 0 }) - - let ticks = 0 - const stop = subscribeRevealTicks(() => { - ticks += 1 +// Deleting a storey takes it apart where it stands. What stands above it (the roof level, the roofs +// and everything on them) keeps its place meanwhile and comes down onto what is below in one eased +// move once the floor has gone, so the roof is never drawn inside the storey that is coming apart. +describe('what stands above a floor being deleted waits for it', () => { + const FLOOR_HEIGHT = 3 + + /** A two-storey house with a roof level on top: the stack create_roof leaves for a gable roof. */ + async function houseWithRoof({ speed = 1, reducedMotion = false } = {}) { + const building = BuildingNode.parse({ children: [] }) + const levels = [0, 1, 2].map((index) => + LevelNode.parse({ level: index, height: FLOOR_HEIGHT, parentId: building.id, children: [] }), + ) + const nodes: Record = { + [building.id]: { ...building, children: levels.map((one) => one.id) } as AnyNode, + } + for (const one of levels) nodes[one.id] = one as AnyNode + useScene.getState().setScene(nodes, [building.id] as AnyNodeId[], { + installedPlugins: [PLUGIN_ID], + hasExplicitPluginInstallState: true, }) - try { - const progress: number[] = [] - while (getRevealPlanState(wall.id)) { - progress.push(getRevealPlanState(wall.id)!.progress) - await frame() - expect(now).toBeLessThan(5000) - } - expect(progress.some((value) => value > 0 && value < 1)).toBe(true) - for (let index = 1; index < progress.length; index += 1) { - expect(progress[index]!).toBeGreaterThanOrEqual(progress[index - 1]!) - } - expect(ticks).toBeGreaterThan(5) - const settled = ticks - await frames(200) - expect(ticks).toBe(settled) - } finally { - stop() + clearSceneHistory() + const [ground, upper, roofLevel] = levels.map((one) => one.id as AnyNodeId) as [ + AnyNodeId, + AnyNodeId, + AnyNodeId, + ] + const objects = new Map() + const register = (id: string) => { + const object = new Object3D() + objects.set(id, object) + sceneRegistry.nodes.set(id as AnyNodeId, object) + sceneRegistry.byType.level!.add(id as AnyNodeId) + return object } - expect(getRevealPlanState(make(Wall).id)).toBeNull() - }) - - test(' keeps its clock while the canvas is paused, as when only the plan shows', async () => { - const [levelId] = loadLevels() + for (const id of [ground, upper, roofLevel]) register(id) + const roof = make(Gable) + useScene.getState().createNode(roof, roofLevel) renderer = await create( <> - - commit.author === 'agent'} /> + {[ground, upper, roofLevel].map((levelId) => ( + + ))} + + , ) - const queued: FrameRequestCallback[] = [] - const previousRequest = globalThis.requestAnimationFrame - const previousCancel = globalThis.cancelAnimationFrame - globalThis.requestAnimationFrame = (callback) => queued.push(callback) - globalThis.cancelAnimationFrame = () => {} - try { - await act(async () => useViewer.setState({ renderPaused: true } as never)) - const wall = make(Wall) - await agentWrites(() => useScene.getState().createNode(wall, levelId)) - expect(root(wall.id)).toBeUndefined() + await frame(0) + // The roof's picture stands in its level's object, as the host's renderer mounts it. + objects.get(roofLevel)!.add(root(roof.id)!) + start({ deletes: () => true, speed: () => speed, prefersReducedMotion: () => reducedMotion }) + const floors = [ground, upper].map((levelId) => { + const slab = make(Slab) + const walls = [make(Wall), make(Wall)] + const crates = [make(Crate), make(Crate)] + return { levelId, slab, walls, crates, all: [slab, ...walls, ...crates] } + }) + await agentWrites(() => { + for (const one of floors) + useScene.getState().createNodes( + [one.slab, ...one.walls, ...one.crates].map((node) => ({ + node, + parentId: one.levelId, + })), + ) + }) + const everything = floors.flatMap((one) => one.all) + await until(() => everything.every((node) => !isNodeRevealing(node.id) && root(node.id)), 60000) + await frames(100) + const y = (id: AnyNodeId) => objects.get(id)!.position.y + const dismantling = () => floors[1]!.all.some((node) => getRetractTurn(node.id) !== null) + /** The upper floor goes: its object leaves the registry as its renderer unmounts. */ + const deleteUpper = async () => { + await removeLevel(upper) + sceneRegistry.nodes.delete(upper) + sceneRegistry.byType.level!.delete(upper) + } + return { + ground, + upper, + roofLevel, + roof, + floors, + objects, + register, + y, + dismantling, + deleteUpper, + } + } - let clock = 0 - for (let step = 0; step < 100 && !root(wall.id); step += 1) { - clock += 16 - const due = queued.splice(0) - await act(async () => { - for (const callback of due) callback(clock) - }) - } - expect(root(wall.id)).toBeDefined() - } finally { - globalThis.requestAnimationFrame = previousRequest - globalThis.cancelAnimationFrame = previousCancel + const removeLevel = (id: AnyNodeId) => + act(async () => { + runAsSceneCommitAuthor('agent', () => useScene.getState().deleteNodes([id])) + }) + + /** Frames until the dismantle has ended and the roof level is at rest; what each frame showed. */ + async function playOut(house: Awaited>, targetY: number) { + const trace: { roofY: number; dismantling: boolean; held: boolean }[] = [] + for (let elapsed = 0; elapsed < 60000; elapsed += 20) { + await frame() + trace.push({ + roofY: house.y(house.roofLevel), + dismantling: house.dismantling(), + held: heldLevelY(house.roofLevel) !== undefined, + }) + if (!house.dismantling() && Math.abs(house.y(house.roofLevel) - targetY) < 1e-4) break + } + await frames(400) + return trace + } + + for (const at of ['full', 'framing'] as const) { + test(`${at}: the roof level keeps its place while the floor comes apart, then eases down onto what is below`, async () => { + level = at + const house = await houseWithRoof() + expect(house.y(house.roofLevel)).toBeCloseTo(2 * FLOOR_HEIGHT, 6) + await house.deleteUpper() + expect(house.dismantling()).toBe(true) + // The store already stacks the roof level on the ground floor; what is drawn waits. + expect(getLevelElevations(useScene.getState().nodes).get(house.roofLevel)?.baseY).toBeCloseTo( + FLOOR_HEIGHT, + 6, + ) + expect(heldLevelY(house.roofLevel)).toBeCloseTo(2 * FLOOR_HEIGHT, 6) + await frame() + expect(useViewer.getState().levelsRevealing).toContain(house.roofLevel) + + const trace = await playOut(house, FLOOR_HEIGHT) + const lowering = trace.findIndex((sample) => sample.roofY < 2 * FLOOR_HEIGHT - 1e-9) + expect(lowering).toBeGreaterThan(0) + // Nothing overlaps: every frame the floor is in pieces, the roof is where it stood. + for (const sample of trace.slice(0, lowering)) expect(sample.roofY).toBeCloseTo(6, 9) + for (const sample of trace.filter((one) => one.dismantling)) + expect(sample.roofY).toBeCloseTo(6, 9) + expect(trace.slice(lowering).every((sample) => !sample.dismantling)).toBe(true) + // One eased move: down only, ending exactly on the stack's elevation, slower at both ends. + const down = trace.slice(lowering - 1).map((sample) => sample.roofY) + for (let i = 1; i < down.length; i += 1) + expect(down[i]!).toBeLessThanOrEqual(down[i - 1]! + 1e-9) + expect(down.at(-1)!).toBeCloseTo(FLOOR_HEIGHT, 3) + const steps = down.slice(1).map((value, i) => down[i]! - value) + expect(steps[0]!).toBeLessThan(Math.max(...steps) / 2) + expect(steps.at(-2)!).toBeLessThan(Math.max(...steps) / 2) + // It takes the rewind's own drop time at this level. + const dropMs = (at === 'framing' ? lazyRetractTimingAt(1) : retractTimingAt(1)).durationMs + .drop + const lowered = trace.filter((sample) => sample.held && sample.roofY < 6 - 1e-9).length + expect(lowered * 20).toBeGreaterThanOrEqual(dropMs - 40) + expect(lowered * 20).toBeLessThanOrEqual(dropMs + 40) + // The hold is gone, and so is the level's reason to show. + expect(heldLevelY(house.roofLevel)).toBeUndefined() + await frame() + expect(useViewer.getState().levelsRevealing).toEqual([]) + expect(house.y(house.roofLevel)).toBeCloseTo(FLOOR_HEIGHT, 6) + // What is below does not move, and the roof's picture is still in its level. + expect(house.y(house.ground)).toBe(0) + expect(root(house.roof.id)!.parent).toBe(house.objects.get(house.roofLevel)!) + }) + } + + test('the drop follows the animation speed', async () => { + level = 'framing' + const took: Record = {} + for (const speed of [1, 2]) { + const house = await houseWithRoof({ speed }) + await house.deleteUpper() + const trace = await playOut(house, FLOOR_HEIGHT) + took[speed] = trace.filter((sample) => sample.held && sample.roofY < 6 - 1e-9).length + await act(async () => driver?.stop()) + await renderer?.unmount() + renderer = null + sceneRegistry.clear() } + expect(took[2]!).toBeLessThan(took[1]! * 0.65) }) -}) -describe('failure mode 10: a level nobody sees', () => { - test('hidden after planning, it stops holding the visible level back', async () => { - const levelIds = loadLevels(2) - await mount(levelIds) - start() - const below = [make(Wall), make(Wall)] - const above = Array.from({ length: 24 }, () => make(Wall)) - const item = make(Item) - await agentWrites(() => - useScene - .getState() - .createNodes([ - ...below.map((node) => ({ node, parentId: levelIds[0]! })), - ...above.map((node) => ({ node, parentId: levelIds[1]! })), - { node: item, parentId: levelIds[0]! }, - ]), - ) - await frames(60) - await act(async () => - useViewer.setState({ - hideLevelsAboveSelection: true, - selection: { ...useViewer.getState().selection, levelId: levelIds[0]! }, - } as never), - ) + test('with the animation off, or motion reduced, nothing is held: the stack settles as it always did', async () => { + for (const mode of ['off', 'reduced'] as const) { + level = mode === 'off' ? 'off' : 'framing' + const house = await houseWithRoof({ reducedMotion: mode === 'reduced' }) + await house.deleteUpper() + expect(heldLevelY(house.roofLevel)).toBeUndefined() + await frame() + expect(house.dismantling()).toBe(false) + expect(useViewer.getState().levelsRevealing).toEqual([]) + expect(house.y(house.roofLevel)).toBeLessThan(2 * FLOOR_HEIGHT) + await frames(1500) + expect(house.y(house.roofLevel)).toBeCloseTo(FLOOR_HEIGHT, 3) + await act(async () => driver?.stop()) + await renderer?.unmount() + renderer = null + sceneRegistry.clear() + } + }) + + test('an undo mid-dismantle puts the floor back whole and the roof never moves', async () => { + level = 'framing' + const house = await houseWithRoof() + await house.deleteUpper() + await frames(400) + expect(house.dismantling()).toBe(true) + await act(async () => useScene.temporal.getState().undo()) + const object = house.register(house.upper) await frame() - for (const wall of above) expect(isNodeRevealing(wall.id)).toBe(false) + expect(house.dismantling()).toBe(false) + expect(heldLevelY(house.roofLevel)).toBeUndefined() + expect(house.y(house.upper)).toBeCloseTo(FLOOR_HEIGHT, 6) + expect(object.position.y).toBeCloseTo(FLOOR_HEIGHT, 6) + for (let i = 0; i < 20; i += 1) { + await frame() + expect(house.y(house.roofLevel)).toBeCloseTo(2 * FLOOR_HEIGHT, 6) + } + }) - // Planned with the upper floor, the item would wait for its 24 walls (about 1.9 s). - const hiddenAt = now - await until(() => Boolean(root(item.id))) - expect(now - hiddenAt).toBeLessThan(900) + test('the undo of a finished deletion brings the roof up and the floor back together, never through each other', async () => { + level = 'framing' + const house = await houseWithRoof() + await house.deleteUpper() + await playOut(house, FLOOR_HEIGHT) + expect(house.y(house.roofLevel)).toBeCloseTo(FLOOR_HEIGHT, 6) + await act(async () => useScene.temporal.getState().undo()) + // The restored floor mounts at the origin, as every new level object does. + house.register(house.upper) + for (let i = 0; i < 30; i += 1) { + await frame() + // The floor's top never reaches the roof's base, and neither moves through the other. + expect(house.y(house.roofLevel) - house.y(house.upper)).toBeGreaterThanOrEqual( + FLOOR_HEIGHT - 1e-6, + ) + expect(house.y(house.upper)).toBeCloseTo(FLOOR_HEIGHT, 6) + expect(house.y(house.roofLevel)).toBeCloseTo(2 * FLOOR_HEIGHT, 6) + } }) }) @@ -1968,6 +3261,20 @@ describe('undo plays the build in reverse', () => { return { result: result as RetractResult | null, took: now - begun } } + test('a kind that declares no reveal, which came in with a default one, goes out the same way', async () => { + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const loose = make(Part) + await agentWrites(() => useScene.getState().createNode(loose, levelId)) + await until(() => !isNodeRevealing(loose.id) && root(loose.id) !== undefined, 9000) + + const { result } = await retract([loose.id]) + expect(result?.instant).toBe(false) + expect(result?.retracted).toEqual([loose.id]) + expect(result?.skipped).toEqual([]) + }) + test('the last phase goes first, back to the slab, each saying so, and all are hidden after', async () => { const { slab, walls, pane, crates, all } = await builtHouse() const events: RevealEvent[] = [] @@ -2549,3 +3856,164 @@ describe('a warm start', () => { expect(highest).toBeGreaterThan(2) }) }) + +describe('nothing a build adds pops in at once', () => { + for (const at of ['full', 'framing'] as const) { + test(`${at}: a kind that declares no reveal, added on a level already there, waits for its turn`, async () => { + level = at + const [levelId] = loadLevels() + await mount([levelId!]) + start() + const wall = make(Wall) + const loose = make(Part) + await agentWrites(() => + useScene.getState().createNodes([ + { node: wall, parentId: levelId! }, + { node: loose, parentId: levelId! }, + ]), + ) + expect(root(loose.id)).toBeUndefined() + expect(isRevealWaiting(loose.id)).toBe(true) + await until( + () => !isNodeRevealing(loose.id) && !isNodeRevealing(wall.id), + LAZY_REVEAL_TIMING.capMs, + ) + expect(root(loose.id)).toBeDefined() + expect(hasRevealPose(root(loose.id)!)).toBe(false) + }) + } +}) + +describe('the framing level: the lazy schedule and its prerolls', () => { + test('a wall waits out its preroll, unmounted, on the reveal clock a plugin reads; then it rises past its top and settles', async () => { + level = 'framing' + const [levelId] = loadLevels() + await mount([levelId!]) + start() + setRevealPreroll((candidate) => (candidate.style === 'rise' ? 1500 : 0)) + const slab = make(Slab) + const walls = [make(Wall), make(Wall)] + await agentWrites(() => + useScene + .getState() + .createNodes([ + { node: slab, parentId: levelId! }, + ...walls.map((node) => ({ node, parentId: levelId! })), + ]), + ) + await until(() => getRevealTurn(walls[0]!.id) !== null) + expect(revealClockMs()).toBe(now) + const turns = walls.map((wall) => getRevealTurn(wall.id)!) + for (const turn of turns) expect(turn.prerollMs).toBe(1500) + // Its preroll: the plugin plays, the wall waits, unmounted or (the first of its kind, drawn up + // front for its shaders) hidden. + const unseen = (id: string) => { + const wallRoot = root(id) + return !wallRoot || yScale(wallRoot) < 0.01 + } + while (revealClockMs() + 20 < turns[0]!.startMs) { + await frame() + expect(unseen(walls[0]!.id)).toBe(true) + expect(isRevealWaiting(walls[0]!.id)).toBe(true) + } + let tallest = 0 + while (isNodeRevealing(walls[0]!.id)) { + await frame() + expect(now).toBeLessThan(turns[0]!.startMs + 3000) + const wallRoot = root(walls[0]!.id) + if (wallRoot?.children.length) tallest = Math.max(tallest, yScale(wallRoot)) + } + expect(getRevealTurn(walls[0]!.id)).toBeNull() + // Overdone a little: past its full height, then back to exactly its own transform. + expect(tallest).toBeGreaterThan(1.01) + expect(composed(root(walls[0]!.id)!).elements).toEqual(own(root(walls[0]!.id)!).elements) + }) + + test('a capture ends a preroll at once: the wall stands whole', async () => { + level = 'framing' + const [levelId] = loadLevels() + await mount([levelId!]) + start() + setRevealPreroll((candidate) => (candidate.style === 'rise' ? 4000 : 0)) + const wall = make(Wall) + await agentWrites(() => useScene.getState().createNode(wall, levelId!)) + await until(() => getRevealTurn(wall.id) !== null) + await frames(200) + const waiting = root(wall.id) + expect(!waiting || yScale(waiting) < 0.01).toBe(true) + await act(async () => { + emitter.emit('thumbnail:before-capture', undefined) + }) + await frame() + expect(isNodeRevealing(wall.id)).toBe(false) + expect(getRevealTurn(wall.id)).toBeNull() + expect(root(wall.id)).toBeDefined() + }) +}) + +describe('the animation speed', () => { + /** One wall written by the agent, and the progress it makes per frame once it moves. */ + async function wallProgress(frameMs: number) { + const [levelId] = loadLevels() + await mount([levelId!]) + return { levelId: levelId! } + } + + test('is read when the plan is made: the build plays at the speed the person had then', async () => { + let speed = 2 + const { levelId } = await wallProgress(20) + start({ speed: () => speed }) + const wall = make(Wall) + await agentWrites(() => useScene.getState().createNodes([{ node: wall, parentId: levelId }])) + // The build waits a few frames for its shaders, then is planned: at the speed asked for then. + for (let elapsed = 0; elapsed < 2000 && getRevealPlanSpeed() !== 2; elapsed += 20) + await frame(20) + expect(getRevealPlanSpeed()).toBe(2) + // Played at ×2, the wall rises in half the shipped time: its progress per frame is doubled. + let before = -1 + let after = -1 + for (let elapsed = 0; elapsed < 4000 && after < 0; elapsed += 20) { + await frame(20) + const state = getRevealPlanState(wall.id) + if (state && state.progress > 0.05 && state.progress < 0.8) { + before = state.progress + await frame(20) + after = getRevealPlanState(wall.id)!.progress + } + } + expect(before).toBeGreaterThan(0) + expect(after - before).toBeCloseTo(20 / revealTimingAt(2).durationMs.rise, 3) + speed = 1 + }) + + test('a change applies to the next build and never moves one playing', async () => { + let speed = 1 + const { levelId } = await wallProgress(20) + start({ speed: () => speed }) + const first = make(Wall) + await agentWrites(() => useScene.getState().createNodes([{ node: first, parentId: levelId }])) + let mid = -1 + for (let elapsed = 0; elapsed < 4000 && mid < 0; elapsed += 20) { + await frame(20) + const state = getRevealPlanState(first.id) + if (state && state.progress > 0.1) mid = state.progress + } + expect(mid).toBeGreaterThan(0.1) + // The person turns it up while this wall is rising: it keeps its own pace. + speed = 2 + await frame(20) + const next = getRevealPlanState(first.id)!.progress + expect(next - mid).toBeCloseTo(20 / REVEAL_TIMING.durationMs.rise, 3) + // The next build is planned at the new speed. + const second = make(Wall) + await agentWrites(() => useScene.getState().createNodes([{ node: second, parentId: levelId }])) + for (let elapsed = 0; elapsed < 2000 && getRevealPlanSpeed() !== 2; elapsed += 20) + await frame(20) + expect(getRevealPlanSpeed()).toBe(2) + }) + + test('at ×1 a build plays on the shipped tables, at both levels', () => { + expect(revealTimingAt(1)).toBe(REVEAL_TIMING) + expect(lazyTimingAt(1)).toBe(LAZY_REVEAL_TIMING) + }) +}) diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 13ec861d1e..dab87154b7 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -5,10 +5,10 @@ import { type AnyNodeId, criticallyDamped, emitter, + getLevelElevations, isRestoringSceneHistory, nodeRegistry, type RevealPhase, - type RevealStyle, type SceneCommit, sceneRegistry, subscribeSceneCommits, @@ -16,12 +16,16 @@ import { } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' -import { type Box3, type Object3D, Vector3 } from 'three' +import { Box3, type Object3D, Vector3 } from 'three' import { DustPool, emitAlongBase, emitAroundFoot } from '../../lib/construction-dust' +import { SCENE_LAYER } from '../../lib/layers' import { type ChangeCandidate, + CONTAINERS, type ConstructionRevealLevel, commitChangeCandidates, + commitDeleteCandidates, + commitEditCandidates, commitRevealCandidates, nodeRevealCandidates, PART_LEAD, @@ -44,16 +48,38 @@ import { setRevealPose, } from '../../lib/reveal-pose' import { + coverRangeAt, + LAZY_REVEAL_TIMING, + type LazyRevealTiming, + lazyRetractTimingAt, + lazyTimingAt, + normalizeAnimationSpeed, type PlannedReveal, + planLazyRetract, + planLazyReveal, planRetract, planReveal, - REVEAL_TIMING, type RevealCandidate, + retractTimingAt, revealGroupKey, + revealTimingAt, + styleMs, } from '../../lib/reveal-schedule' +import { clearRiseTextureScale, setRiseTextureScale } from '../../lib/reveal-texture-scale' +import { + type CoverRange, + type CoverSweep, + coverDurationMs, + coverShareAt, + coverSweepOf, + type PlayedStyle, + setRevealWipe, +} from '../../lib/reveal-wipe' import useViewer from '../../store/use-viewer' -import { resolveLevelVisibility } from '../level/level-utils' +import { releaseLevel, setHeldLevelY } from '../level/level-hold' +import { getLevelPresentationY } from '../level/level-utils' import { ConstructionDust } from './construction-dust' +import { dressDeletionGhost } from './ghost-dress' /** * Construction reveal: what an agent builds appears as a construction, not a @@ -66,8 +92,9 @@ import { ConstructionDust } from './construction-dust' * The host decides which commits reveal (`reveals`) and how much animation * plays (`level`); the viewer knows no agent. A reveal ends at once on a load * or hydration, an undo, a capture or export, and for selected nodes; it - * never plays under reduced motion or in a read-only viewer, and never for a - * level nobody sees. + * never plays under reduced motion or in a read-only viewer. A level the person's + * display hides (the levels above the selected one, solo) shows while its work plays, + * so the build is seen; a level the author hid shows its build at once. */ export type { ConstructionRevealLevel } from '../../lib/reveal-candidates' @@ -76,11 +103,13 @@ export { isRevealWaiting } type ActiveReveal = { id: string candidate: RevealCandidate - style: RevealStyle + style: PlayedStyle height: number levelId: string | null startedAt: number durationMs: number + /** What an assembled node's own parts take to drop, at the speed its plan was made at. */ + assembleMs: number clockStart: number | null pivot: Vector3 | null base: Box3 | null @@ -89,9 +118,48 @@ type ActiveReveal = { /** A drop's anticipation, and how heavy it lands: the last piece of a build is heavier. */ lead: number weight: number + /** A rise's or a scale's overshoot past its rest before it settles (the lazy schedule's). */ + overshoot: number /** The last piece of the plan to arrive; the reveal says when it has. */ finale: boolean finaleSaid: boolean + /** An edit plays the node again (`commitEditCandidates`): it counts in the changes' groups, not the build's. */ + edit?: true + /** A `cover` node's covering, planned when it starts (see `layCover`). */ + cover?: Cover + /** How long a covering takes by its size, at the speed this plan was made at (`cover` nodes). */ + coverRange?: CoverRange +} + +/** How a covering is laid over a node held still: its sweep along the node and how long it takes. */ +type Cover = { sweep: CoverSweep; durationMs: number } + +const coverBox = new Box3() + +/** The sweep over what is drawn under `root`, and the time it takes in `range`. */ +function planCover(root: Object3D, range: CoverRange): Cover { + root.updateWorldMatrix(true, true) + const sweep = coverSweepOf(coverBox.setFromObject(root)) + return { sweep, durationMs: coverDurationMs(sweep, range) } +} + +/** + * The world coordinate the covering has reached when `share` of its sweep is closed: a hair under + * the lowest edge at the start and over the highest at the end, so it is whole where it ends. + */ +const coverLevel = (sweep: CoverSweep, share: number) => + sweep.from - 0.01 + (sweep.to - sweep.from + 0.02) * share + +/** Lays the covering `elapsedMs` into its time: it moves with every frame, easing in and out. */ +function layCover(root: Object3D, cover: Cover, elapsedMs: number) { + const share = coverShareAt(elapsedMs, cover.durationMs) + setRevealWipe(root, cover.sweep, coverLevel(cover.sweep, share)) +} + +/** Lifts the covering off: the same sweep played from the highest edge to the lowest. */ +function liftCover(root: Object3D, cover: Cover, progress: number) { + const left = (1 - Math.min(1, Math.max(0, progress))) * cover.durationMs + setRevealWipe(root, cover.sweep, coverLevel(cover.sweep, coverShareAt(left, cover.durationMs))) } export type RevealPhaseEvent = { phase: RevealPhase; levelId: string | null } @@ -125,7 +193,7 @@ export type RevealNodeStartEvent = { phase: RevealPhase levelId: string | null /** `glide` is a piece moving from one place to another (`changed`). */ - style: RevealStyle | 'glide' + style: PlayedStyle | 'glide' source: string | null atMs: number /** The agent moved this piece; it was already there. */ @@ -191,7 +259,7 @@ export type RetractResult = { } /** A node's reveal as a plan view draws it: its style and how far it has come, 0 → 1. */ -export type RevealPlanState = { style: RevealStyle; progress: number } +export type RevealPlanState = { style: PlayedStyle; progress: number } /** A started node waits this long for its renderer to register (its kind may never mount). */ const MOUNT_TIMEOUT_MS = 2000 @@ -208,6 +276,8 @@ const MIN_SCALE = 1e-3 const FULL_DUST_DROP_M = 4 /** The last piece of a build lands this much heavier: a bigger squash, a bigger puff. */ const FINALE_WEIGHT = 1.7 +/** On the lazy schedule a drop lifts this share of its height before it falls (anticipation). */ +const LAZY_DROP_LEAD = 0.08 /** A lifted node rises over this many seconds, and comes back a little quicker. */ const LIFT_RESPONSE_UP_S = 0.55 const LIFT_RESPONSE_DOWN_S = 0.45 @@ -251,6 +321,16 @@ const foot = [new Vector3(), new Vector3(), new Vector3(), new Vector3()] const pending = revealPending let queue: PlannedReveal[] = [] +/** The plan's entries still to start, by id: when each moves in, and its preroll before. */ +const planned = new Map() +/** How long a plugin plays before a node of the lazy schedule moves in (its preroll), if it plays any. */ +let prerollOf: RevealPreroll | null = null + +/** A plugin's preroll for a candidate: `nodes` is the scene the candidate stood in (the one before a deletion). */ +export type RevealPreroll = ( + candidate: RevealCandidate, + nodes?: Readonly>, +) => number let unplanned = false /** The plan is to be made again, once what mounts up front has drawn. */ let replanning = false @@ -276,6 +356,16 @@ let clockMs = 0 let buildLandedMs = Number.NEGATIVE_INFINITY let tickedBusy = false let revealLevel: () => ConstructionRevealLevel = () => 'full' +/** The person's animation speed, asked when a plan is made (never mid-animation). */ +let revealSpeed: () => number = () => 1 +/** The speed the plan in force was made at: what waits for its turn plays at it. */ +let planSpeed = 1 +const currentSpeed = () => normalizeAnimationSpeed(revealSpeed()) + +/** The animation speed the plan in force was made at, for a plugin that stages its own pieces. */ +export function getRevealPlanSpeed(): number { + return planSpeed +} let dust: DustPool | null = null type RevealGroup = { @@ -522,11 +612,50 @@ export function getRevealPlanState(id: string): RevealPlanState | null { if (gone) return { style: gone.style, progress: 0 } const reveal = active.get(id) if (!reveal) return null - const durationMs = reveal.style === 'assemble' ? REVEAL_TIMING.durationMs.drop : reveal.durationMs + const durationMs = reveal.style === 'assemble' ? reveal.assembleMs : reveal.durationMs const progress = (clockMs - reveal.startedAt) / Math.max(1, durationMs) return { style: reveal.style, progress: Math.min(1, Math.max(0, progress)) } } +/** + * When a waiting node of the plan moves in, on the reveal's clock (`revealClockMs`), and how long its + * preroll plays before: a plugin that stages its own pieces for it (a wall's frame and boards) plays + * them in `[startMs - prerollMs, startMs]`. Null once it has started, or when it is not planned. + */ +export function getRevealTurn(id: string): { startMs: number; prerollMs: number } | null { + const entry = planned.get(id) + return entry ? { startMs: entry.startMs, prerollMs: entry.prerollMs ?? 0 } : null +} + +/** The reveal's own clock: what `getRevealTurn` counts in. */ +export function revealClockMs(): number { + return clockMs +} + +/** + * Lets a plugin ask for a preroll before nodes of the lazy schedule (the `framing` level) move in: + * the plan leaves `prerollOf(candidate)` ms before each, and the plugin plays its own pieces then. + * Asked once per node when the plan is made; 0 for none. Null takes it back. + */ +export function setRevealPreroll(next: RevealPreroll | null): void { + prerollOf = next +} + +const puffFoot = [new Vector3(), new Vector3(), new Vector3(), new Vector3()] + +/** + * A puff of the reveal's dust around `centre` (world), `radius` metres out, `strength` in (0, 1]: + * for a plugin's own pieces landing. Nothing when no dust plays (the simple level, no reveal). + */ +export function emitRevealPuff(centre: Vector3, radius: number, strength: number): void { + if (!(dust && dustOn())) return + puffFoot[0]!.set(centre.x - radius, centre.y, centre.z - radius) + puffFoot[1]!.set(centre.x + radius, centre.y, centre.z - radius) + puffFoot[2]!.set(centre.x + radius, centre.y, centre.z + radius) + puffFoot[3]!.set(centre.x - radius, centre.y, centre.z + radius) + emitAroundFoot(dust, puffFoot, Math.min(1, Math.max(0.05, strength)), clockMs) +} + /** Calls `listener` after every reveal step while anything reveals, and once when it all ends. */ export function subscribeRevealTicks(listener: () => void): () => void { tickListeners.add(listener) @@ -742,7 +871,7 @@ function beginChange(change: ChangeCandidate, atMs: number) { id: change.id, phase: change.phase, levelId: change.levelId, - style: 'glide', + style: change.edited ? change.style : 'glide', source: change.source ?? null, atMs, changed: true, @@ -799,7 +928,11 @@ function poseGlide(change: ChangeCandidate, root: Object3D, left: number) { /** Whether a move glides now: it has somewhere to glide from, and the animation plays. */ function glidePlays(change: ChangeCandidate): boolean { - return change.from !== null && !showsAtOnce() && !useViewer.getState().renderPaused + return ( + (change.from !== null || change.edited === true) && + !showsAtOnce() && + !useViewer.getState().renderPaused + ) } /** The agent's moves wait for the reveal to be quiet; until then each piece holds at its old place. */ @@ -829,6 +962,8 @@ function flushChanges(now: number) { if (waiting.length === 0) return registerChanges(waiting, now) const atOnce: ChangeCandidate[] = [] + // However many move, they begin spread over the same time, never piled on the last frame. + const stagger = Math.min(GLIDE_STAGGER_MS, GLIDE_STAGGER_CAP_MS / waiting.length) let delay = 0 for (const change of waiting) { const root = sceneRegistry.nodes.get(change.id) @@ -836,11 +971,11 @@ function flushChanges(now: number) { poseGlide(change, root, 1) glides.set(change.id, { change, - startAt: now + Math.min(delay, GLIDE_STAGGER_CAP_MS), + startAt: now + delay, state: { x: 1, v: 0 }, began: false, }) - delay += GLIDE_STAGGER_MS + delay += stagger const group = changeGroups.get(changeGroupOf.get(change.id) ?? '') if (group) group.animated = true if (changeRun) changeRun.animated = true @@ -853,6 +988,41 @@ function flushChanges(now: number) { for (const change of atOnce) endChange(change.id, now, false) } +/** An edited node plays again in this share of the time its first appearance took. */ +const EDIT_SHARE = 0.55 + +/** An edited node begins its quick re-rise, at the person's animation speed. */ +function startEdit(change: ChangeCandidate, now: number) { + beginChange(change, now) + const speed = currentSpeed() + const lazy = lazyOn() + const timing = lazy ? lazyTimingAt(speed) : revealTimingAt(speed) + active.set(change.id, { + id: change.id, + candidate: change, + style: change.style, + height: change.height, + levelId: change.levelId, + startedAt: now, + durationMs: styleMs(timing.durationMs, change.style) * EDIT_SHARE, + assembleMs: 0, + clockStart: null, + pivot: null, + base: null, + landed: false, + root: null, + lead: 0, + weight: 1, + overshoot: + lazy && change.style in LAZY_REVEAL_TIMING.overshoot + ? LAZY_REVEAL_TIMING.overshoot[change.style as keyof LazyRevealTiming['overshoot']] + : 0, + finale: false, + finaleSaid: false, + edit: true, + }) +} + function stepGlides(now: number) { const dt = Math.min(0.05, Math.max(0, (now - glideClockMs) / 1000)) glideClockMs = now @@ -865,6 +1035,12 @@ function stepGlides(now: number) { endChange(change.id, now, true) continue } + if (change.edited) { + if (now < glide.startAt) continue + glides.delete(change.id) + startEdit(change, now) + continue + } if (!glide.began) { if (now < glide.startAt) { if (root) poseGlide(change, root, 1) @@ -929,6 +1105,10 @@ function announce(candidate: RevealCandidate) { function revealCandidates(commit: SceneCommit, at: ConstructionRevealLevel): StagedCandidate[] { const candidates = commitRevealCandidates(commit, at, arrivals) arrivals += candidates.length + // A kind that declares no reveal may have come in on its own (a default reveal): on a host still + // assembling, it is one of its parts instead; on a host still coming in, it comes in with it. + const staged = new Set(candidates.map((candidate) => candidate.id)) + const replaced = new Set() const { before, current } = commit for (const id in current.nodes) { if (before.nodes[id as AnyNodeId]) continue @@ -936,6 +1116,8 @@ function revealCandidates(commit: SceneCommit, at: ConstructionRevealLevel): Sta if (!node || nodeRegistry.get(node.type)?.capabilities?.reveal) continue const hostId = node.parentId as string | null const host = hostId ? (pending.get(hostId) ?? active.get(hostId)?.candidate) : undefined + // Under a host still waiting or moving in, it is drawn inside it and comes in with it. + if (staged.has(id) && hostId && (pending.has(hostId) || active.has(hostId))) replaced.add(id) if (host?.style === 'assemble') { candidates.push({ ...host, @@ -948,32 +1130,92 @@ function revealCandidates(commit: SceneCommit, at: ConstructionRevealLevel): Sta }) } } - return candidates + if (replaced.size === 0) return candidates + return candidates.filter( + (candidate) => !(replaced.has(candidate.id) && candidate.host === undefined), + ) +} + +/** The author's own eye on a level (the sidebar's) is the one thing that keeps its work out of sight. */ +function levelHiddenByAuthor(levelId: string | null): boolean { + if (!levelId) return false + const level = useScene.getState().nodes[levelId as AnyNodeId] + return level?.type === 'level' && level.visible === false +} + +/** The levels the reveal has work on: what waits, moves, goes out or is carried to a new place. */ +function levelsAtWork(): string[] { + const levels = new Set() + const add = (levelId: string | null) => { + if (levelId) levels.add(levelId) + } + for (const candidate of pending.values()) add(candidate.levelId) + for (const reveal of active.values()) add(reveal.levelId) + for (const retract of retracts.values()) add(retract.group.levelId) + for (const glide of glides.values()) add(glide.change.levelId) + for (const change of changeQueue) add(change.levelId) + for (const ghost of ghosts.values()) add(ghost.levelId) + for (const levelId of levelHolds.keys()) add(levelId) + return [...levels] } -function levelShown(levelId: string | null): boolean { - if (!levelId) return true - const nodes = useScene.getState().nodes - const level = nodes[levelId as AnyNodeId] - if (level?.type !== 'level') return true - const viewer = useViewer.getState() - const selectedId = viewer.selection.levelId - const selected = selectedId ? nodes[selectedId as AnyNodeId] : undefined - const { visible, shadowOnly } = resolveLevelVisibility({ - levelMode: viewer.levelMode, - hideAbove: viewer.hideLevelsAboveSelection, - hasSelectedLevel: Boolean(selectedId), - isSelected: level.id === selectedId, - index: level.level, - selectedIndex: selected?.type === 'level' ? selected.level : undefined, - nodeVisible: level.visible !== false, - }) - return visible && !shadowOnly +/** + * The nodes the agent changed that the reveal has not finished with: pieces moved or edited (not + * built, not going out), waiting for their turn, carried or replaying. What a commit that edits and + * moves is made of, read before its first piece plays. + */ +export function revealChangedNodeIds(): string[] { + const ids = new Set(queuedChanges) + for (const change of changeQueue) ids.add(change.id) + for (const id of glides.keys()) ids.add(id) + for (const reveal of active.values()) if (reveal.edit) ids.add(reveal.id) + return [...ids] +} + +/** + * Tells the viewer which levels the reveal is working on, so each shows while its work plays: a build + * on the storey above the one the person has selected, or in the solo view of another, is seen, not + * played to nobody. The person's own choice of level is left as it is; the level steps aside again + * when its work is done. + */ +function publishLevelsAtWork() { + publishNodesAtPlay() + const next = levelsAtWork() + const current = useViewer.getState().levelsRevealing + if (next.length === current.length && next.every((levelId) => current.includes(levelId))) return + useViewer.setState({ levelsRevealing: next }) +} + +/** + * The nodes playing now: moving in, going out, carried to a new place, or an edit waiting its turn + * with its new picture held. The cutaway leaves these, and what they host, in view + * (`nodesRevealing`); a node waiting for its turn is not drawn yet, so it is not among them. A + * deleted node's picture plays from a snapshot, out of the scene: what it leaves stands for it. + */ +function nodesAtPlay(): string[] { + const ids = new Set() + for (const id of active.keys()) ids.add(id) + for (const id of glides.keys()) ids.add(id) + for (const id of retracts.keys()) ids.add(id) + for (const id of queuedChanges) ids.add(id) + for (const ghost of ghosts.values()) if (ghost.host) ids.add(ghost.host) + return [...ids] +} + +function publishNodesAtPlay() { + const next = nodesAtPlay() + const current = useViewer.getState().nodesRevealing + if (next.length === current.length && next.every((id) => current.includes(id))) return + useViewer.setState({ nodesRevealing: next }) } function endReveal(reveal: ActiveReveal, forced = false) { - if (reveal.root) clearRevealPose(reveal.root) + if (reveal.root) { + clearRevealPose(reveal.root) + clearRiseTextureScale(reveal.root) + } active.delete(reveal.id) + if (reveal.edit) return endChange(reveal.id, clockMs, forced) settle(reveal.id) closeNode(reveal.id, clockMs, forced) } @@ -993,6 +1235,7 @@ function settle(id: string) { function startReveal(candidate: RevealCandidate, now: number, animate: boolean) { if (!pending.delete(candidate.id)) return + planned.delete(candidate.id) // A node shows only inside its host: a host still waiting starts with it. const nodes = useScene.getState().nodes let parentId = nodes[candidate.id as AnyNodeId]?.parentId as AnyNodeId | null | undefined @@ -1015,20 +1258,34 @@ function startReveal(candidate: RevealCandidate, now: number, animate: boolean) lastStartMs = now announce(candidate) groupReleased(candidate) + // The lazy schedule overdoes it a little: higher falls, an anticipation, an overshoot. + const lazy = lazyOn() + const timing = lazy ? lazyTimingAt(planSpeed) : revealTimingAt(planSpeed) active.set(candidate.id, { id: candidate.id, candidate, style: candidate.style, - height: candidate.height, + height: lazy + ? Math.max(candidate.height, LAZY_REVEAL_TIMING.minHeightM[candidate.style] ?? 0) + : candidate.height, levelId: candidate.levelId, startedAt: now, - durationMs: REVEAL_TIMING.durationMs[candidate.style], + durationMs: styleMs(timing.durationMs, candidate.style), + assembleMs: revealTimingAt(planSpeed).durationMs.drop, + ...(candidate.style === 'cover' ? { coverRange: coverRangeAt(planSpeed) } : {}), clockStart: null, pivot: null, base: null, landed: false, root: null, - lead: candidate.lead ?? 0, + lead: + lazy && candidate.style === 'drop' + ? Math.max(candidate.lead ?? 0, LAZY_DROP_LEAD) + : (candidate.lead ?? 0), + overshoot: + lazy && candidate.style in LAZY_REVEAL_TIMING.overshoot + ? LAZY_REVEAL_TIMING.overshoot[candidate.style as keyof LazyRevealTiming['overshoot']] + : 0, weight: 'finale' in candidate && candidate.finale ? FINALE_WEIGHT : 1, finale: 'finale' in candidate && candidate.finale === true, finaleSaid: false, @@ -1074,14 +1331,18 @@ function puffAroundFoot(reveal: ActiveReveal, root: Object3D, now: number) { function stepReveal(reveal: ActiveReveal, now: number) { const { nodes, dirtyNodes } = useScene.getState() - if (!nodes[reveal.id as AnyNodeId] || !levelShown(reveal.levelId)) return endReveal(reveal) + if (!nodes[reveal.id as AnyNodeId] || levelHiddenByAuthor(reveal.levelId)) + return endReveal(reveal) const root = sceneRegistry.nodes.get(reveal.id) if (!root) { if (now - reveal.startedAt > MOUNT_TIMEOUT_MS) endReveal(reveal) return } if (reveal.root !== root) { - if (reveal.root) clearRevealPose(reveal.root) + if (reveal.root) { + clearRevealPose(reveal.root) + clearRiseTextureScale(reveal.root) + } reveal.root = root } // An assembled node holds still; it is done once its last part has landed. @@ -1103,17 +1364,25 @@ function stepReveal(reveal: ActiveReveal, now: number) { reveal.pivot = revealPivot(reveal.base, reveal.style) revealPoseAt(reveal.style, 0, reveal.height, sample, reveal) setRevealPose(root, reveal.pivot, sample.scale, sample.lift) - groupStarted(reveal.candidate, now) + if (reveal.style === 'cover') { + // The covering takes as long as what it covers is big, now that the roof is drawn. + reveal.cover = planCover(root, reveal.coverRange ?? coverRangeAt(planSpeed)) + reveal.durationMs = reveal.cover.durationMs + layCover(root, reveal.cover, 0) + } + if (!reveal.edit) groupStarted(reveal.candidate, now) if (reveal.style === 'rise') puffAlongBase(reveal, root, now) } const t = (now - reveal.clockStart) / reveal.durationMs if (t < 1) { revealPoseAt(reveal.style, t, reveal.height, sample, reveal) setRevealPose(root, reveal.pivot ?? ORIGIN, sample.scale, sample.lift) + if (reveal.style === 'rise') setRiseTextureScale(root, sample.scale.y, reveal.pivot?.y ?? 0) + if (reveal.cover) layCover(root, reveal.cover, now - reveal.clockStart) } if (!reveal.landed && revealHasLanded(reveal.style, t)) { reveal.landed = true - groupLanded(reveal.id, now) + if (!reveal.edit) groupLanded(reveal.id, now) if (reveal.finale && !reveal.finaleSaid && t >= REVEAL_ARRIVAL[reveal.style]) sayFinale(reveal, now) if (reveal.style === 'drop') puffAroundFoot(reveal, root, now) @@ -1133,6 +1402,9 @@ const climbToBuilding = (id: string): string | null => { return null } +/** Whether the plan is the lazy, nested one: the framing level. */ +const lazyOn = () => revealLevel() === 'framing' + /** Whether lifts play at the person's level: the full construction only. */ const liftsPlay = () => revealLevel() === 'full' || revealLevel() === 'framing' @@ -1298,7 +1570,7 @@ type RetractRun = { type Retract = { id: string - style: RevealStyle + style: PlayedStyle height: number startMs: number durationMs: number @@ -1308,11 +1580,15 @@ type Retract = { run: RetractRun root: Object3D | null pivot: Vector3 | null + /** A `cover` node's rows, planned when it starts going: its covering lifts off in reverse. */ + cover?: Cover + /** What a plugin that takes the node apart plays after it has gone. */ + prerollMs?: number } type Held = { root: Object3D | null - style: RevealStyle + style: PlayedStyle pivot: Vector3 scale: Vector3 lift: number @@ -1327,7 +1603,7 @@ const retractSample: RetractPoseSample = { lift: 0, scale: new Vector3(), opacit let showsAtOnce: () => boolean = () => true /** What lifts away as it goes fades with it; what shrinks does not. */ -const fadesAway = (style: RevealStyle) => +const fadesAway = (style: PlayedStyle) => style === 'settle' || style === 'drop' || style === 'assemble' function emitRetract(type: RevealRetractEvent['type'], group: RetractGroup, atMs: number) { @@ -1392,6 +1668,299 @@ function holdGone(retract: Retract, root: Object3D, atMs: number) { notifyPending(retract.id) } +// --------------------------------------------------------------------------------------------- +// Deletions. The nodes are out of the scene at once (selection, queries, saves); a snapshot of each +// one's picture, taken as its commit lands, plays its construction backwards and is then taken away. +// --------------------------------------------------------------------------------------------- + +type Ghost = { + id: string + levelId: string | null + /** The picture: a clone of the deleted node's root, standing where it stood. */ + root: Object3D + parent: Object3D + /** What it left behind it in the scene that is not a storey or a building (the wall of a departing opening). */ + host: string | null + style: PlayedStyle + height: number + startMs: number + durationMs: number + group: RetractGroup + pivot: Vector3 | null + cover?: Cover + /** What a plugin that takes the piece apart plays after it has gone (a wall's boards and studs). */ + prerollMs?: number +} + +const ghosts = new Map() + +/** + * Where a deleted node's snapshot stands: its own parent's object while that node stays in the + * scene, else the nearest object above it that does (a deleted floor's pictures stand in their + * building), so they outlive the object that held them. + */ +function ghostStand(id: string, commit: SceneCommit, own: Object3D): Object3D { + const above = survivingAbove(id, commit) + return (above ? sceneRegistry.nodes.get(above) : undefined) ?? own +} + +/** The nearest node above a deleted one that the commit leaves in the scene. */ +function survivingAbove(id: string, commit: SceneCommit): AnyNodeId | null { + let above = commit.before.nodes[id as AnyNodeId]?.parentId as AnyNodeId | null | undefined + for (let guard = 0; above && !commit.current.nodes[above] && guard < 32; guard += 1) + above = commit.before.nodes[above]?.parentId as AnyNodeId | null | undefined + return above ?? null +} + +/** The node a departing one hung on, when it stays: a storey or a building is not a host. */ +function ghostHost(id: string, commit: SceneCommit): string | null { + const above = survivingAbove(id, commit) + const type = above && commit.current.nodes[above]?.type + return above && type && !CONTAINERS.has(type) ? above : null +} + +/** + * When a node that is going out (undone, or deleted) plays, on the reveal clock: its own going + * starts at `startMs` and takes `durationMs`; a plugin that takes it apart plays `prerollMs` after. + * Null when the node is not going. + */ +export function getRetractTurn( + id: string, +): { startMs: number; durationMs: number; prerollMs: number } | null { + const ghost = ghosts.get(id) + if (ghost) { + return { startMs: ghost.startMs, durationMs: ghost.durationMs, prerollMs: ghost.prerollMs ?? 0 } + } + const retract = retracts.get(id) + if (!retract) return null + return { + startMs: retract.startMs, + durationMs: retract.durationMs, + prerollMs: retract.prerollMs ?? 0, + } +} + +/** Draws the clone as the scene pass does: a batched source keeps no layer of its own. */ +function drawnInScenePass(root: Object3D) { + root.traverse((object) => { + object.layers.set(SCENE_LAYER) + }) +} + +/** + * The reverse plan of `candidates` from `at`: at the Framing level the build's own plan read from its + * end (the rooms come apart as they went up, a plugin's prerolls kept), else the Full rewind. + */ +function planBack( + candidates: readonly RevealCandidate[], + at: number, + speed: number, + nodes?: Readonly>, +) { + if (!lazyOn()) return planRetract(candidates, at, retractTimingAt(speed), revealTimingAt(speed)) + const asked = candidates.map((candidate) => { + let prerollMs = 0 + try { + prerollMs = prerollOf?.(candidate, nodes) ?? 0 + } catch (error) { + console.error('[viewer] reveal preroll failed', error) + } + return prerollMs > 0 ? { ...candidate, prerollMs } : candidate + }) + return planLazyRetract(asked, at, lazyRetractTimingAt(speed), lazyTimingAt(speed)) +} + +/** + * A commit deleted nodes: each one that stages a reveal and is on screen leaves a ghost that plays the + * reveal backwards, staggered like a build in reverse. Nothing plays for an undo (it plays its own + * reverse), a load, the animation off, or a level the author hid. + */ +function playDeletions(commit: SceneCommit) { + if (commit.origin !== 'local' || isRestoringSceneHistory() || showsAtOnce()) return + if (useViewer.getState().renderPaused) return + const staged = commitDeleteCandidates(commit, revealLevel()).filter( + (candidate) => + !(retracts.has(candidate.id) || held.has(candidate.id)) && + !levelHiddenByAuthor(candidate.levelId) && + sceneRegistry.nodes.get(candidate.id)?.parent, + ) + if (staged.length === 0) return + const plan = planBack( + staged, + clockMs, + currentSpeed(), + commit.before.nodes as Readonly>, + ) + const groupsByKey = new Map() + for (const entry of plan) { + const original = sceneRegistry.nodes.get(entry.id) + const own = original?.parent + if (!(original && own)) continue + const root = original.clone(true) + drawnInScenePass(root) + dressDeletionGhost(original, root) + const parent = ghostStand(entry.id, commit, own) + own.add(root) + // A deleted floor's pieces would go with their floor's object: they stand in the building. + if (parent !== own) { + own.updateWorldMatrix(true, false) + parent.attach(root) + } + const key = `${entry.phase}:${entry.levelId}` + let group = groupsByKey.get(key) + if (!group) { + group = { phase: entry.phase, levelId: entry.levelId, ids: [], remaining: 0, started: false } + groupsByKey.set(key, group) + } + group.ids.push(entry.id) + group.remaining += 1 + const cover = + entry.style === 'cover' ? planCover(root, coverRangeAt(currentSpeed(), true)) : undefined + ghosts.set(entry.id, { + id: entry.id, + levelId: entry.levelId, + root, + parent, + host: ghostHost(entry.id, commit), + style: entry.style, + height: entry.height, + startMs: entry.startMs, + durationMs: cover?.durationMs ?? entry.durationMs, + ...(cover ? { cover } : {}), + group, + pivot: null, + ...(entry.prerollMs ? { prerollMs: entry.prerollMs } : {}), + }) + } + holdLevelsAbove(commit, new Set(plan.map((entry) => entry.id).filter((id) => ghosts.has(id)))) +} + +/** A ghost has gone (or was cut short): its picture is taken away, and its group ends with its last. */ +function ghostGone(ghost: Ghost, atMs: number) { + if (!ghosts.delete(ghost.id)) return + clearRevealPose(ghost.root) + clearGhost(ghost.root) + ghost.parent.remove(ghost.root) + const { group } = ghost + group.remaining -= 1 + if (group.remaining > 0) return + if (!group.started) { + group.started = true + emitRetract('retract-start', group, atMs) + } + emitRetract('retract-end', group, atMs) +} + +function stepGhosts(now: number) { + for (const ghost of [...ghosts.values()]) { + if (now < ghost.startMs) continue + if (!ghost.pivot) { + ghost.pivot = revealPivot(revealLocalBounds(ghost.root), ghost.style) + if (!ghost.group.started) { + ghost.group.started = true + emitRetract('retract-start', ghost.group, now) + } + } + const t = (now - ghost.startMs) / ghost.durationMs + const pose = retractPoseAt(ghost.style, t, ghost.height, retractSample) + setRevealPose(ghost.root, ghost.pivot, pose.scale, pose.lift) + if (ghost.cover) liftCover(ghost.root, ghost.cover, t) + if (fadesAway(ghost.style)) setGhost(ghost.root, pose.opacity) + if (t >= 1) ghostGone(ghost, now) + } +} + +function finishGhosts() { + for (const ghost of [...ghosts.values()]) ghostGone(ghost, clockMs) +} + +/** + * A level that stands above a floor being taken apart. It keeps the height it stood at while the + * floor's pictures play (`ghostIds`), then comes down onto what is below in one eased move of the + * rewind's own length (`dropMs`). What it holds, a roof level and the roofs on it, goes with it. + */ +type LevelHold = { + ghostIds: Set + dropMs: number + heldY: number + lowering: { startMs: number; fromY: number } | null +} + +const levelHolds = new Map() + +const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2) + +/** + * A commit deleted levels whose pictures now play: each level that stood above them, in the same + * building, and whose height changes, keeps its place until they have gone. The stack's elevations + * in the store are not written; only what is presented waits. + */ +function holdLevelsAbove(commit: SceneCommit, ghostIds: ReadonlySet) { + const before = commit.before.nodes + const current = commit.current.nodes + const stack = getLevelElevations(before) + const going = [...stack].filter(([id]) => !current[id as AnyNodeId]) + if (going.length === 0 || ghostIds.size === 0) return + const levelMode = useViewer.getState().levelMode + const dropMs = (lazyOn() ? lazyRetractTimingAt(currentSpeed()) : retractTimingAt(currentSpeed())) + .durationMs.drop + for (const [id, above] of stack) { + const node = current[id as AnyNodeId] + if (node?.type !== 'level' || node.visible === false) continue + if ( + !going.some( + ([, gone]) => gone.buildingId === above.buildingId && gone.ordinal < above.ordinal, + ) + ) + continue + const object = sceneRegistry.nodes.get(id) + if (!object) continue + const standing = levelHolds.get(id) + const heldY = standing?.heldY ?? object.position.y + if (Math.abs(heldY - getLevelPresentationY(id, current, levelMode)) < 0.01) continue + if (standing) { + for (const ghostId of ghostIds) standing.ghostIds.add(ghostId) + standing.lowering = null + continue + } + levelHolds.set(id, { ghostIds: new Set(ghostIds), dropMs, heldY, lowering: null }) + setHeldLevelY(id, heldY) + } +} + +/** Holds each level at its place until its floor has gone, then eases it down to its elevation. */ +function stepLevelHolds(now: number) { + if (levelHolds.size === 0) return + const nodes = useScene.getState().nodes + const levelMode = useViewer.getState().levelMode + for (const [id, hold] of [...levelHolds]) { + if (!nodes[id as AnyNodeId]) { + levelHolds.delete(id) + releaseLevel(id) + continue + } + if (!hold.lowering) { + if ([...hold.ghostIds].some((ghostId) => ghosts.has(ghostId))) continue + hold.lowering = { startMs: now, fromY: hold.heldY } + } + const t = Math.min(1, (now - hold.lowering.startMs) / Math.max(1, hold.dropMs)) + if (t >= 1) { + levelHolds.delete(id) + releaseLevel(id) + continue + } + const to = getLevelPresentationY(id, nodes, levelMode) + hold.heldY = hold.lowering.fromY + (to - hold.lowering.fromY) * easeInOut(t) + setHeldLevelY(id, hold.heldY) + } +} + +/** Every level goes to its own height now (a load, an undo, a capture, the reveal stopping). */ +function releaseLevelHolds() { + for (const id of levelHolds.keys()) releaseLevel(id) + levelHolds.clear() +} + function stepRetracts(now: number) { const nodes = useScene.getState().nodes for (const retract of [...retracts.values()]) { @@ -1427,6 +1996,7 @@ function stepRetracts(now: number) { const t = (now - retract.startMs) / retract.durationMs const pose = retractPoseAt(retract.style, t, retract.height, retractSample) setRevealPose(root, retract.pivot, pose.scale, retract.from.lift + pose.lift) + if (retract.cover) liftCover(root, retract.cover, t) if (fadesAway(retract.style)) setGhost(root, retract.from.opacity * pose.opacity) if (t >= 1) { holdGone(retract, root, now) @@ -1495,7 +2065,7 @@ export function retractNodes(ids: Iterable): Promise { !pending.has(candidate.id) && !retracts.has(candidate.id) && !held.has(candidate.id) && - levelShown(candidate.levelId), + !levelHiddenByAuthor(candidate.levelId), ) const taken = new Set(staged.map((candidate) => candidate.id)) const skipped = asked.filter((id) => !taken.has(id)) @@ -1531,11 +2101,13 @@ export function retractNodes(ids: Iterable): Promise { const clearingIds = new Set(clearing.map((candidate) => candidate.id)) const roofFirst = clearing.some((candidate) => !from.has(candidate.id)) const lead = liftWanted.size > lifting || roofFirst ? RETRACT_LIFT_LEAD_MS : 0 + const speed = currentSpeed() const plan = [ - ...planRetract(clearing, clockMs), - ...planRetract( + ...planBack(clearing, clockMs, speed), + ...planBack( staged.filter((candidate) => !clearingIds.has(candidate.id)), clockMs + lead, + speed, ), ].sort((left, right) => left.startMs - right.startMs) const end = Math.max(...plan.map((entry) => entry.startMs + entry.durationMs)) @@ -1568,21 +2140,27 @@ export function retractNodes(ids: Iterable): Promise { } group.ids.push(entry.id) group.remaining += 1 + // A covering lifts off in a time that follows its size: known now, with the roof still drawn. + const mounted = entry.style === 'cover' ? sceneRegistry.nodes.get(entry.id) : undefined + const cover = mounted ? planCover(mounted, coverRangeAt(speed, true)) : undefined retracts.set(entry.id, { id: entry.id, style: entry.style, height: entry.height, startMs: entry.startMs, - durationMs: entry.durationMs, + durationMs: cover?.durationMs ?? entry.durationMs, + ...(cover ? { cover } : {}), from: from.get(entry.id) ?? { lift: 0, opacity: 1 }, group, run, root: null, pivot: null, + ...(entry.prerollMs ? { prerollMs: entry.prerollMs } : {}), }) // A plan entry that draws it hears that it is going. notifyPending(entry.id) } + publishLevelsAtWork() // With a roof to lift first, the first group is said to start now: the camera and the card move // with the roof, not a beat after it, and its pieces follow once the way is clear. const first = groups.get(plan[0]!.group) @@ -1666,7 +2244,7 @@ function chooseWarm(now: number) { } if (kinds.has(kind)) continue } - if (!levelShown(candidate.levelId) || hasWaitingAncestor(candidate.id, nodes)) continue + if (levelHiddenByAuthor(candidate.levelId) || hasWaitingAncestor(candidate.id, nodes)) continue kinds.add(kind) warming.set(candidate.id, { culled: new Map(), builtTicks: 0 }) notifyPending(candidate.id) @@ -1748,6 +2326,42 @@ function stepWarm(now: number) { warmWaiting = false } +/** + * The plan for what waits, from `now`. On the lazy schedule, a node whose preroll has begun keeps its + * turn (its plugin is playing it), and the rest are planned around it with their prerolls. + */ +function planPending(now: number): PlannedReveal[] { + // The person's speed is read here, when the plan is made: what is already moving keeps its own + // durations, and what is planned now keeps this speed until it starts. + planSpeed = currentSpeed() + if (!lazyOn()) { + const timing = revealTimingAt(planSpeed) + return planReveal([...pending.values()], Math.max(now, lastStartMs + timing.nodeGapMs), timing) + } + const kept: PlannedReveal[] = [] + for (const entry of queue) { + if (pending.has(entry.id) && entry.prerollMs && entry.startMs - entry.prerollMs <= now) + kept.push(entry) + } + const keptIds = new Set(kept.map((entry) => entry.id)) + const waiting: RevealCandidate[] = [] + for (const candidate of pending.values()) { + if (keptIds.has(candidate.id)) continue + let prerollMs = 0 + try { + prerollMs = prerollOf?.(candidate) ?? 0 + } catch (error) { + console.error('[viewer] reveal preroll failed', error) + } + waiting.push(prerollMs > 0 ? { ...candidate, prerollMs } : candidate) + } + // What comes next waits for the prerolls already playing: nothing of it can pull their node in early. + const from = Math.max(now, ...kept.map((entry) => entry.startMs)) + return [...kept, ...planLazyReveal(waiting, from, lazyTimingAt(planSpeed))].sort( + (left, right) => left.startMs - right.startMs || left.seq - right.seq, + ) +} + function tickReveals(now: number, frameMs?: number) { clockMs = now frameQuiet = frameMs === undefined || frameMs < WARM_QUIET_FRAME_MS @@ -1756,7 +2370,7 @@ function tickReveals(now: number, frameMs?: number) { if (unplanned) { unplanned = false for (const candidate of [...pending.values()]) { - if (!levelShown(candidate.levelId)) startReveal(candidate, now, false) + if (levelHiddenByAuthor(candidate.levelId)) startReveal(candidate, now, false) } chooseWarm(now) replanning = true @@ -1764,17 +2378,22 @@ function tickReveals(now: number, frameMs?: number) { stepWarm(now) if (replanning && !warmWaiting) { replanning = false - queue = planReveal([...pending.values()], Math.max(now, lastStartMs + REVEAL_TIMING.nodeGapMs)) + queue = planPending(now) + planned.clear() + for (const entry of queue) planned.set(entry.id, entry) } let due = 0 while (due < queue.length && (queue[due]?.startMs ?? Number.POSITIVE_INFINITY) <= now) { const entry = queue[due++]! - if (pending.has(entry.id)) startReveal(entry, now, levelShown(entry.levelId)) + planned.delete(entry.id) + if (pending.has(entry.id)) startReveal(entry, now, !levelHiddenByAuthor(entry.levelId)) } if (due > 0) queue = queue.slice(due) for (const reveal of active.values()) stepReveal(reveal, now) stepLifts(now) stepRetracts(now) + stepGhosts(now) + stepLevelHolds(now) stepGlides(now) const building = pending.size > 0 || active.size > 0 || lifts.size > 0 || liftWanted.size > 0 if (!building) { @@ -1793,7 +2412,14 @@ function tickReveals(now: number, frameMs?: number) { flushChanges(now) } const busy = - building || retracts.size > 0 || held.size > 0 || glides.size > 0 || changeQueue.length > 0 + building || + retracts.size > 0 || + held.size > 0 || + glides.size > 0 || + ghosts.size > 0 || + levelHolds.size > 0 || + changeQueue.length > 0 + publishLevelsAtWork() if (busy || tickedBusy) notifyTicks() tickedBusy = busy } @@ -1804,9 +2430,10 @@ function tickReveals(now: number, frameMs?: number) { */ function finishReveals(keepHeld = false) { const waiting = [...pending.values()] - const busy = waiting.length > 0 || active.size > 0 || retracts.size > 0 + const busy = waiting.length > 0 || active.size > 0 || retracts.size > 0 || ghosts.size > 0 pending.clear() queue = [] + planned.clear() unplanned = false replanning = false warmWaiting = false @@ -1830,8 +2457,11 @@ function finishReveals(keepHeld = false) { dust?.clear() seatAllLifts() finishRetracts(keepHeld) + finishGhosts() + releaseLevelHolds() finishChanges() completeBuild(clockMs, true) + publishLevelsAtWork() if (busy) notifyTicks() } @@ -1841,6 +2471,7 @@ function finishNode(id: string) { const root = sceneRegistry.nodes.get(id) if (root) clearRevealPose(root) } + planned.delete(id) const waiting = pending.get(id) if (waiting && pending.delete(id)) { released(waiting) @@ -1895,6 +2526,16 @@ export type ConstructionRevealOptions = { reveals: (commit: SceneCommit) => boolean /** How much animation plays; defaults to `full`. Reduced motion always means `off`. */ level?: () => ConstructionRevealLevel + /** + * The person's animation speed, a multiple of the shipped pace (higher is faster); defaults to ×1. + * Read when a plan is made: a change applies to the next build and never moves one playing. + */ + speed?: () => number + /** + * Which commits play the nodes they delete backwards, as a snapshot of their picture (the nodes are out + * of the scene at once); defaults to `reveals`. + */ + deletes?: (commit: SceneCommit) => boolean /** Defaults to the `prefers-reduced-motion` media query. */ prefersReducedMotion?: () => boolean /** Where rising walls and landings puff their dust; none without it. */ @@ -1925,6 +2566,8 @@ export function startConstructionReveal( const token = {} currentDriver = token revealLevel = options.level ?? (() => 'full') + revealSpeed = options.speed ?? (() => 1) + planSpeed = 1 dust = options.dust ?? null const reducedMotion = options.prefersReducedMotion ?? mediaPrefersReducedMotion() const instant = () => { @@ -1937,19 +2580,27 @@ export function startConstructionReveal( ) } showsAtOnce = instant + const deletes = options.deletes ?? options.reveals const stops = [ subscribeSceneCommits((commit) => { if (currentDriver !== token) return if (commit.origin === 'load') return finishReveals() - if (!options.reveals(commit)) return + if (deletes(commit)) playDeletions(commit) + if (!options.reveals(commit)) return publishLevelsAtWork() const candidates = revealCandidates(commit, revealLevel()) // Furniture the agent moved, that is shown and not still on its way in. - const changes = commitChangeCandidates(commit, revealLevel()).filter( + const moved = commitChangeCandidates(commit, revealLevel()) + const movedIds = new Set(moved.map((change) => change.id)) + const changes = [ + ...moved, + ...commitEditCandidates(commit, revealLevel()).filter((edit) => !movedIds.has(edit.id)), + ].filter( (change) => !(pending.has(change.id) || active.has(change.id) || isNodeRevealing(change.id)) && - levelShown(change.levelId), + !levelHiddenByAuthor(change.levelId), ) if (changes.length > 0) queueChanges(changes) + publishLevelsAtWork() if (candidates.length === 0) return registerCandidates(candidates, clockMs) if (instant()) { @@ -1970,6 +2621,7 @@ export function startConstructionReveal( notifyPending(candidate.id) } for (const hostId of assembling) notifyAssembly(hostId, true) + publishLevelsAtWork() unplanned = true }), useScene.subscribe((state, previous) => { @@ -2001,14 +2653,6 @@ export function startConstructionReveal( ...state.previewSelectedIds, ]) } - if ( - pending.size > 0 && - (state.levelMode !== previous.levelMode || - state.selection.levelId !== previous.selection.levelId || - state.hideLevelsAboveSelection !== previous.hideLevelsAboveSelection) - ) { - unplanned = true - } }), ] const onCapture = () => { @@ -2036,6 +2680,8 @@ export function startConstructionReveal( glideClockMs = Number.NEGATIVE_INFINITY frameQuiet = true revealLevel = () => 'full' + revealSpeed = () => 1 + planSpeed = 1 dust = null lastStartMs = Number.NEGATIVE_INFINITY buildLandedMs = Number.NEGATIVE_INFINITY @@ -2052,13 +2698,21 @@ export function startConstructionReveal( */ export function ConstructionReveal({ reveals, + deletes, level = 'full', + speed = 1, }: { reveals: (commit: SceneCommit) => boolean + /** Which commits play what they delete backwards; defaults to `reveals`. */ + deletes?: (commit: SceneCommit) => boolean level?: ConstructionRevealLevel + /** The person's animation speed (×0.5 to ×2); a change applies to the next build. */ + speed?: number }) { const revealsRef = useRef(reveals) + const deletesRef = useRef(deletes) const levelRef = useRef(level) + const speedRef = useRef(speed) const driverRef = useRef(null) const clockRef = useRef(0) const [pool] = useState(() => new DustPool()) @@ -2068,14 +2722,24 @@ export function ConstructionReveal({ revealsRef.current = reveals }, [reveals]) + useEffect(() => { + deletesRef.current = deletes + }, [deletes]) + useEffect(() => { levelRef.current = level }, [level]) + useEffect(() => { + speedRef.current = speed + }, [speed]) + useEffect(() => { const driver = startConstructionReveal({ reveals: (commit) => revealsRef.current(commit), + deletes: (commit) => (deletesRef.current ?? revealsRef.current)(commit), level: () => levelRef.current, + speed: () => speedRef.current, dust: pool, }) driverRef.current = driver diff --git a/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts b/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts new file mode 100644 index 0000000000..92c04a3b84 --- /dev/null +++ b/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { LevelNode, useScene, WallNode } from '@pascal-app/core' +import { BoxGeometry, Mesh, MeshBasicMaterial } from 'three' +import { hasMaterialsForGroups } from '../../lib/pointer-events' +import useViewer from '../../store/use-viewer' +import { visibleMaterialsOf } from '../wall/wall-cutout-cache' +import { getMaterialsForWall } from '../wall/wall-materials' +import { dressDeletionGhost } from './ghost-dress' + +/* + * Failure mode this file pins (the owner's rule, 15 Oct: a node that plays is never hidden by the + * cutaway): a deleted wall's picture is a copy of its mesh, and a copy shares the material its source + * wore in that frame. A wall the cutaway was hiding wore its stipple, so its deletion played as faint + * dots and never changed with the view, because the copy is not a registered wall. + */ + +const previousViewer = useViewer.getState() +afterEach(() => useViewer.setState(previousViewer)) + +function wall() { + const level = LevelNode.parse({ level: 0 }) + return WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [4, 0], + frontSide: 'interior', + backSide: 'exterior', + }) +} + +function cutAway(node: ReturnType, hidden: boolean, groupIndex = 0) { + const stipple = new MeshBasicMaterial({ transparent: true, opacity: 0.2 }) + const geometry = new BoxGeometry(4, 2.5, 0.2) + geometry.clearGroups() + geometry.addGroup(0, 6, groupIndex) + const source = new Mesh( + geometry, + Array.from({ length: Math.max(3, groupIndex + 1) }, () => stipple), + ) + source.userData.wallHidden = hidden + // What the wall cutout keeps on the mesh it cuts away (`WallCutoutCache`). + visibleMaterialsOf.set(source, visibleOf(node)) + const clone = source.clone() + return { source, clone, stipple, node } +} + +function visibleOf(node: ReturnType) { + const viewer = useViewer.getState() + return getMaterialsForWall( + node, + viewer.shading, + viewer.textures, + viewer.colorPreset, + viewer.sceneTheme, + useScene.getState().materials, + [], + ).visible +} + +describe('the picture of a deleted wall', () => { + test('wears the wall’s own visible variant when the cutaway was hiding it', () => { + const node = wall() + useViewer.setState({ wallMode: 'cutaway' }) + const { source, clone, stipple } = cutAway(node, true) + expect((clone.material as unknown[])[0]).toBe(stipple) + dressDeletionGhost(source, clone) + expect(clone.material).toBe(visibleOf(node)) + // The wall in the scene is not touched: only its copy. + expect((source.material as unknown[])[0]).toBe(stipple) + }) + + // A room-wide paint preview regroups a wall's faces onto entries past its palette's end; a copy + // that wore the shorter palette over those groups would throw on every raycast (`material.side`). + test('keeps what it wears when the groups it carries outrun the palette', () => { + const node = wall() + useViewer.setState({ wallMode: 'cutaway' }) + const { source, clone, stipple } = cutAway(node, true, 99) + dressDeletionGhost(source, clone) + expect((clone.material as unknown[])[0]).toBe(stipple) + expect(hasMaterialsForGroups(clone)).toBe(true) + }) + + test('keeps what it wears when the cutaway showed the wall', () => { + const node = wall() + useViewer.setState({ wallMode: 'cutaway' }) + const { source, clone } = cutAway(node, false) + const worn = clone.material + dressDeletionGhost(source, clone) + expect(clone.material).toBe(worn) + }) + + test('keeps what it wears in the person’s other wall modes', () => { + for (const wallMode of ['down', 'translucent', 'up'] as const) { + const node = wall() + useViewer.setState({ wallMode }) + const { source, clone } = cutAway(node, true) + const worn = clone.material + dressDeletionGhost(source, clone) + expect(clone.material).toBe(worn) + } + }) + + // The reveal knows no kind: a source that keeps no visible variant is not a cutaway's. + test('is left alone when its source keeps no visible variant', () => { + useViewer.setState({ wallMode: 'cutaway' }) + const node = wall() + const { source, clone } = cutAway(node, true) + visibleMaterialsOf.delete(source) + const worn = clone.material + dressDeletionGhost(source, clone) + expect(clone.material).toBe(worn) + }) +}) diff --git a/packages/viewer/src/systems/construction-reveal/ghost-dress.ts b/packages/viewer/src/systems/construction-reveal/ghost-dress.ts new file mode 100644 index 0000000000..0fb62551c8 --- /dev/null +++ b/packages/viewer/src/systems/construction-reveal/ghost-dress.ts @@ -0,0 +1,28 @@ +import type { Mesh, Object3D } from 'three' +import { hasMaterialsForGroups } from '../../lib/pointer-events' +import useViewer from '../../store/use-viewer' +import { visibleMaterialsOf } from '../wall/wall-cutout-cache' + +/** + * The picture a deleted node plays out is a copy of what stood there, and a copy wears the + * material its source had in that frame. A node the cutaway was hiding wears its stipple, so its + * deletion would play as faint dots: when its source carries a visible variant (the wall cutout + * keeps one, `visibleMaterialsOf`), the copy wears that instead, the one a capture draws, for as long + * as it plays. What the cutaway showed, and any other wall mode, keep what they wear. + */ +export function dressDeletionGhost(source: Object3D, clone: Object3D) { + const visible = visibleMaterialsOf.get(source) + const mesh = clone as Mesh + if ( + visible && + useViewer.getState().wallMode === 'cutaway' && + source.userData.wallHidden === true && + mesh.isMesh && + mesh.geometry && + // A room-wide paint preview can leave the source with more groups than the palette has + // entries; a copy with a group and no material throws on every raycast over it (see + // `WallCutoutCache.mayAssign`), so it keeps what it wears then. + hasMaterialsForGroups(mesh, visible) + ) + mesh.material = visible +} diff --git a/packages/viewer/src/systems/level/level-hold.ts b/packages/viewer/src/systems/level/level-hold.ts new file mode 100644 index 0000000000..80db297f03 --- /dev/null +++ b/packages/viewer/src/systems/level/level-hold.ts @@ -0,0 +1,17 @@ +/** + * Levels a construction reveal presents at a height of its own: the levels above a floor being taken + * apart stay where they stood until it has gone, then the reveal brings them down onto what is below, + * writing the height every frame. `LevelSystem` reads `heldLevelY` ahead of the stacked elevation, so a + * held level is neither lerped nor snapped. Presentation only: no scene write. + */ +const heldY = new Map() + +export const setHeldLevelY = (levelId: string, y: number) => { + heldY.set(levelId, y) +} + +export const heldLevelY = (levelId: string): number | undefined => heldY.get(levelId) + +export const releaseLevel = (levelId: string) => { + heldY.delete(levelId) +} diff --git a/packages/viewer/src/systems/level/level-system.test.ts b/packages/viewer/src/systems/level/level-system.test.ts index 68f801ecfc..500f76692e 100644 --- a/packages/viewer/src/systems/level/level-system.test.ts +++ b/packages/viewer/src/systems/level/level-system.test.ts @@ -5,6 +5,7 @@ import { create } from '@react-three/test-renderer' import { createElement } from 'react' import { Object3D } from 'three' import useViewer from '../../store/use-viewer' +import { releaseLevel, setHeldLevelY } from './level-hold' import { LevelSystem } from './level-system' import { snapLevelsToTruePositions } from './level-utils' @@ -85,6 +86,7 @@ afterEach(() => { useViewer.setState({ levelMode: previousViewerState.levelMode, hideLevelsAboveSelection: false, + levelsRevealing: [], selection: previousViewerState.selection, }) }) @@ -172,6 +174,42 @@ describe("the editor's level display", () => { }) }) +describe('a level a construction reveal is playing on', () => { + test('shows above the selected level while its work plays, and steps aside after', async () => { + const { levels, objects } = setupLevels([0, 0]) + setLevelMode('stacked', levels[0]!.id) + useViewer.setState({ hideLevelsAboveSelection: true }) + await updateLevelPresentation(1 / 12) + expect(objects[1]!.layers.isEnabled(0)).toBe(false) + + useViewer.setState({ levelsRevealing: [levels[1]!.id] }) + await updateLevelPresentation(1 / 12) + expect(objects[1]!.visible).toBe(true) + expect(objects[1]!.layers.isEnabled(0)).toBe(true) + + useViewer.setState({ levelsRevealing: [] }) + await updateLevelPresentation(1 / 12) + expect(objects[1]!.layers.isEnabled(0)).toBe(false) + }) + + test('shows in solo, where every other level is hidden', async () => { + const { levels, objects } = setupLevels([0, 0]) + setLevelMode('solo', levels[0]!.id) + useViewer.setState({ levelsRevealing: [levels[1]!.id] }) + await updateLevelPresentation(1 / 12) + expect(objects.map((object) => object.visible)).toEqual([true, true]) + }) + + test('stays hidden when the author hid it', async () => { + const { levels, objects } = setupLevels([0, 0]) + hideLevel(levels[1]!.id) + setLevelMode('stacked', levels[0]!.id) + useViewer.setState({ levelsRevealing: [levels[1]!.id] }) + await updateLevelPresentation(1 / 12) + expect(objects[1]!.visible).toBe(false) + }) +}) + describe('snapLevelsToTruePositions', () => { test('bakes offset-aware stacked positions and restores the prior presentation', () => { const { objects } = setupLevels([0.5, 1.25]) @@ -204,3 +242,49 @@ describe('snapLevelsToTruePositions', () => { expect(objects.map((object) => object.visible)).toEqual([true, true]) }) }) + +describe('a level held by a construction reveal', () => { + test('stays at the height the reveal gives it, and eases from there once released', async () => { + const { levels, objects } = setupLevels([0, 0]) + setLevelMode('stacked') + await updateLevelPresentation(1 / 12) + expect(objects.map((object) => object.position.y)).toEqual([0, 2.5]) + + setHeldLevelY(levels[1]!.id, 4) + await updateLevelPresentation(1 / 12) + expect(objects.map((object) => object.position.y)).toEqual([0, 4]) + + releaseLevel(levels[1]!.id) + await updateLevelPresentation(1 / 60) + const [, eased] = objects.map((object) => object.position.y) + expect(eased!).toBeLessThan(4) + expect(eased!).toBeGreaterThan(2.5) + }) + + test('a level that has just mounted cuts every level to its height, none slides through another', async () => { + const { levels, objects } = setupLevels([0, 0]) + setLevelMode('stacked') + await updateLevelPresentation(1 / 12) + // The ground floor and a roof level stand; a restored floor between them mounts at the origin. + useViewer.setState({ levelMode: 'stacked' }) + const restored = new Object3D() + sceneRegistry.nodes.set('level_restored' as AnyNodeId, restored) + sceneRegistry.byType.level!.add('level_restored' as AnyNodeId) + const nodes = useScene.getState().nodes + const upper = nodes[levels[1]!.id as AnyNodeId] as unknown as { level: number } + useScene.setState({ + nodes: { + ...nodes, + [levels[1]!.id]: { ...upper, level: 2 }, + level_restored: { + ...(nodes[levels[1]!.id as AnyNodeId] as object), + id: 'level_restored', + level: 1, + }, + } as unknown as Record, + }) + await updateLevelPresentation(1 / 60) + expect(restored.position.y).toBe(2.5) + expect(objects[1]!.position.y).toBe(5) + }) +}) diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index c6d06fb067..f6da9a0d54 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -3,15 +3,19 @@ import { useFrame } from '@react-three/fiber' import { lerp } from 'three/src/math/MathUtils.js' import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' +import { heldLevelY } from './level-hold' import { EXPLODED_GAP, resolveLevelVisibility } from './level-utils' import { shadowOnlyLevels } from './shadow-only-levels' +const placed = new WeakSet() + export const LevelSystem = () => { useFrame((_, delta) => { const nodes = useScene.getState().nodes const levelMode = useViewer.getState().levelMode const hideAbove = useViewer.getState().hideLevelsAboveSelection const selectedLevel = useViewer.getState().selection.levelId + const levelsRevealing = useViewer.getState().levelsRevealing const levelElevations = getLevelElevations(nodes) type LevelEntry = { @@ -32,6 +36,10 @@ export const LevelSystem = () => { } }) + // A level that has just mounted (a restored floor, a new storey) cuts every level to its height + // together: sliding them from where they were would run the floor and the levels above through + // each other. + const appeared = entries.some(({ obj }) => !placed.has(obj)) const selectedIndex = selectedLevel ? entries.find((e) => e.levelId === selectedLevel)?.index : undefined @@ -47,7 +55,11 @@ export const LevelSystem = () => { // oscillated kilometers off-screen and the level-fit camera followed // (blank viewport). Clamping keeps every step a contraction: identical // feel at 60 fps, exact snap instead of overshoot on slow frames. - obj.position.y = lerp(obj.position.y, targetY, Math.min(1, delta * 12)) + const held = heldLevelY(levelId) + if (held !== undefined) obj.position.y = held + else if (appeared) obj.position.y = targetY + else obj.position.y = lerp(obj.position.y, targetY, Math.min(1, delta * 12)) + placed.add(obj) const { visible, shadowOnly } = resolveLevelVisibility({ levelMode, @@ -57,6 +69,7 @@ export const LevelSystem = () => { index, selectedIndex, nodeVisible: level?.visible !== false, + revealing: levelsRevealing.includes(levelId), }) if (shadowOnly) { applyShadowOnly(obj) diff --git a/packages/viewer/src/systems/level/level-utils.test.ts b/packages/viewer/src/systems/level/level-utils.test.ts index 37c2ab7a6d..045f90ebd3 100644 --- a/packages/viewer/src/systems/level/level-utils.test.ts +++ b/packages/viewer/src/systems/level/level-utils.test.ts @@ -12,6 +12,7 @@ const decide = ( index: 0, selectedIndex: undefined, nodeVisible: true, + revealing: false, ...overrides, }) @@ -81,4 +82,26 @@ describe('resolveLevelVisibility', () => { }), ).toEqual({ visible: false, shadowOnly: false }) }) + + test('a level a construction reveal is playing on shows whatever the display hides', () => { + const above = { hideAbove: true, hasSelectedLevel: true, selectedIndex: 0, index: 1 } + expect(decide(above)).toEqual({ visible: true, shadowOnly: true }) + expect(decide({ ...above, revealing: true })).toEqual({ visible: true, shadowOnly: false }) + expect( + decide({ + levelMode: 'solo', + hasSelectedLevel: true, + index: 0, + selectedIndex: 1, + revealing: true, + }), + ).toEqual({ visible: true, shadowOnly: false }) + }) + + test('the reveal does not show a level the author hid', () => { + expect(decide({ nodeVisible: false, revealing: true })).toEqual({ + visible: false, + shadowOnly: false, + }) + }) }) diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts index d3a1969394..eec11c81ca 100644 --- a/packages/viewer/src/systems/level/level-utils.ts +++ b/packages/viewer/src/systems/level/level-utils.ts @@ -35,6 +35,10 @@ export function getLevelPresentationY( * shadow map so the sun still shadows the selected floor through them. * A level the author hid never enters that shadow-caster branch: its shadows * on the floor below would be exactly what hiding it was meant to remove. + * + * A level a construction reveal is playing on (`revealing`) shows whatever the + * presentation hides, so the animation is seen; only the author's own hiding + * keeps it out. */ export function resolveLevelVisibility({ levelMode, @@ -44,6 +48,7 @@ export function resolveLevelVisibility({ index, selectedIndex, nodeVisible, + revealing, }: { levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' hideAbove: boolean @@ -52,8 +57,10 @@ export function resolveLevelVisibility({ index: number selectedIndex: number | undefined nodeVisible: boolean + revealing: boolean }): { visible: boolean; shadowOnly: boolean } { if (!nodeVisible) return { visible: false, shadowOnly: false } + if (revealing) return { visible: true, shadowOnly: false } const hidden = (levelMode === 'solo' && hasSelectedLevel && !isSelected) || diff --git a/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts b/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts index ca72bd2b50..0308ed09b8 100644 --- a/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts +++ b/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts @@ -11,6 +11,7 @@ import { useLiveTransforms, useScene, WallNode, + WindowNode, } from '@pascal-app/core' import { BoxGeometry, @@ -33,6 +34,7 @@ import { } from './wall-cutout' import { sameMaterialArray, + visibleMaterialsOf, WALL_FACING_HYSTERESIS, WallCutoutCache, type WallCutoutViewerState, @@ -115,6 +117,48 @@ afterEach(() => { }) describe('WallCutoutCache', () => { + // The owner, 15 Oct: the agent bent an exterior wall in cutaway and the cutaway hid exactly the + // wall that was animating. + test('a wall in play stays in view in cutaway while it plays, then follows the cutaway again', () => { + const { node, mesh } = addWall() + cache.update(camera, 1) + expect(mesh.userData.wallHidden).toBe(true) + viewerStore.setState({ nodesRevealing: [node.id] }) + cache.update(camera, 1.01) + expect(mesh.userData.wallHidden).toBe(false) + expect(viewerStore.getState().wallMode).toBe('cutaway') + viewerStore.setState({ nodesRevealing: [] }) + cache.update(camera, 1.02) + expect(mesh.userData.wallHidden).toBe(true) + expect(viewerStore.getState().wallMode).toBe('cutaway') + }) + + test('the wall that hosts an opening in play stays in view with it, and no other wall does', () => { + const host = addWall() + const other = addWall() + const window = WindowNode.parse({ parentId: host.node.id, wallId: host.node.id }) + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + [host.node.id]: { ...host.node, children: [window.id] }, + [window.id]: window, + }, + }) + cache.update(camera, 1) + expect(host.mesh.userData.wallHidden).toBe(true) + viewerStore.setState({ nodesRevealing: [window.id] }) + cache.update(camera, 1.01) + expect(host.mesh.userData.wallHidden).toBe(false) + expect(other.mesh.userData.wallHidden).toBe(true) + }) + + test('a wall in play stays down when the person has put the walls down', () => { + const { node, mesh } = addWall() + viewerStore.setState({ wallMode: 'down', nodesRevealing: [node.id] }) + cache.update(camera, 1) + expect(mesh.userData.wallHidden).toBe(true) + }) + test('camera orbit in full height never iterates cached walls or reads camera direction', () => { viewerStore.setState({ wallMode: 'up' }) const { mesh } = addWall() @@ -356,6 +400,35 @@ describe('WallCutoutCache', () => { expect(spread.mesh.material).toBe(cache.walls.get(spread.node.id)!.visibleVariant.materials) }) + test('a wall in play while a room-wide preview holds its groups is stamped now and given its palette when the preview lets go', () => { + const hovered = addWall() + const spread = addWall() + cache.update(camera, 1) + expect(spread.mesh.userData.wallHidden).toBe(true) + const palette = spread.mesh.material as Material[] + viewerStore.setState({ hoveredId: hovered.node.id, hoverHighlightMode: 'paint-ready' }) + const preview = [...palette, new MeshBasicMaterial(), new MeshBasicMaterial()] + spread.mesh.material = preview + spread.mesh.geometry.addGroup(0, 3, preview.length - 1) + // The play starts under the preview: the stamp follows at once, the palette waits for the groups. + viewerStore.setState({ nodesRevealing: [spread.node.id] }) + cache.update(camera, 1.01) + expect(spread.mesh.userData.wallHidden).toBe(false) + expect(spread.mesh.material).toBe(preview) + // The preview gives the groups back while the wall still plays: it gets its visible palette. + spread.mesh.geometry.clearGroups() + spread.mesh.material = new MeshBasicMaterial() + viewerStore.setState({ hoveredId: null, hoverHighlightMode: 'default' }) + cache.update(camera, 1.02) + expect(spread.mesh.userData.wallHidden).toBe(false) + expect(spread.mesh.material).toBe(cache.walls.get(spread.node.id)!.visibleVariant.materials) + // The play ends: the cutaway hides it again. + viewerStore.setState({ nodesRevealing: [] }) + cache.update(camera, 1.03) + expect(spread.mesh.userData.wallHidden).toBe(true) + expect(spread.mesh.material).toBe(cache.walls.get(spread.node.id)!.hiddenVariant.materials) + }) + test('appearance changes during preview are applied when temporary ownership ends', () => { viewerStore.setState({ wallMode: 'up' }) const { node, mesh } = addWall() @@ -529,6 +602,30 @@ describe('WallCutoutCache', () => { } }) + // A deleted wall's ghost is drawn from this, so the reveal needs no wall palette of its own. + test('keeps the wall’s plain visible palette on the mesh, whether the cutaway hides it or not', () => { + const { node, mesh } = addWall() + cache.update(camera, 1) + expect(mesh.userData.wallHidden).toBe(true) + const v = viewerStore.getState() + const visible = getMaterialsForWall( + node, + v.shading, + v.textures, + v.colorPreset, + v.sceneTheme, + useScene.getState().materials, + ).visible + expect(visibleMaterialsOf.get(mesh)).toBe(visible) + expect(sameMaterialArray(mesh.material, visible)).toBe(false) + // A selection tints what the wall wears, not the palette a capture draws. + viewerStore.setState({ + selection: { ...viewerStore.getState().selection, selectedIds: [node.id] }, + }) + cache.update(camera, 1.01) + expect(visibleMaterialsOf.get(mesh)).toBe(visible) + }) + test('appearance and highlight refreshes do not reassign an already-current material array', () => { const { node, mesh } = addWall() viewerStore.setState({ wallMode: 'up' }) @@ -631,6 +728,45 @@ describe('WallCutoutCache', () => { } }) + test('a late library finish on a legacy painted face replaces the fallback without a camera move', () => { + const { node, mesh } = addWall() + const id = 'mtl_wall_legacy_continuity' + const painted = WallNode.parse({ ...node, materialPreset: `library:${id}` }) + useScene.setState({ nodes: { [node.id]: painted } }) + viewerStore.setState({ wallMode: 'up', textures: true, shading: 'rendered' }) + cache.update(camera, 1) + const fallback = (mesh.material as Material[])[1] + try { + registerLibraryMaterials([ + { + id, + label: 'Late wall finish', + category: 'colors', + preset: MaterialPresetPayloadSchema.parse({ + maps: {}, + mapProperties: { color: '#2468ac', roughness: 0.27, metalness: 0.63 }, + }), + }, + ]) + cache.update(camera, 1.02) + const resolved = (mesh.material as Material[])[1] as Material & { + color: { getHexString(): string } + roughness: number + metalness: number + } + expect(resolved).not.toBe(fallback) + expect(resolved.color.getHexString()).toBe('2468ac') + expect(resolved.roughness).toBe(0.27) + expect(resolved.metalness).toBe(0.63) + for (const time of [1.04, 1.2, 2]) { + cache.update(camera, time) + expect((mesh.material as Material[])[1]).toBe(resolved) + } + } finally { + unregisterLibraryMaterials([id]) + } + }) + test('selected wall clones pick up a late texture with a stationary full-height camera', async () => { const { node, mesh } = addWall() viewerStore.setState({ diff --git a/packages/viewer/src/systems/wall/wall-cutout-cache.ts b/packages/viewer/src/systems/wall/wall-cutout-cache.ts index 9a5d324525..21a7ae7820 100644 --- a/packages/viewer/src/systems/wall/wall-cutout-cache.ts +++ b/packages/viewer/src/systems/wall/wall-cutout-cache.ts @@ -112,7 +112,50 @@ export type WallCutoutViewerState = Pick< | 'previewSelectedIds' | 'hoveredId' | 'hoverHighlightMode' -> +> & { nodesRevealing?: readonly string[] } + +const NOT_REVEALING: readonly string[] = [] + +/** + * The walls the cutaway must leave in view because something on them is playing: a wall that is + * moving in, going out or being edited, a wall that hosts an opening that is, and the walls under a + * node that is (a facade part). The person's wall mode is not touched: these are stamped visible + * for as long as they play, and the next frame after hides them again as the view wants. + */ +export function wallsInPlay( + nodes: ReturnType['nodes'], + revealing: readonly string[], +): Set { + const walls = new Set() + if (revealing.length === 0) return walls + const down = (id: string, depth: number) => { + const node = nodes[id as AnyNodeId] + if (!node) return + if (node.type === 'wall') walls.add(id) + if (depth >= 4) return + for (const child of (node as { children?: readonly string[] }).children ?? []) + down(child, depth + 1) + } + for (const id of revealing) { + // The wall itself, or an opening in it: the host stands visible while the opening plays. + for ( + let node = nodes[id as AnyNodeId], guard = 0; + node && guard < 16; + node = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined, guard += 1 + ) + if (node.type === 'wall') walls.add(node.id) + // What stands under a node that plays rides with it. + down(id, 0) + } + return walls +} + +/** + * The visible variant of a mesh's materials, kept on the object by the system that cuts it away, for + * whoever must draw a copy of the mesh while the cutaway hides it (a deleted wall's ghost). Not in + * `userData`: `Object3D.clone` copies that through JSON, which would serialize every material. + */ +export const visibleMaterialsOf = new WeakMap() export type WallCutoutViewerStore = { getState: () => WallCutoutViewerState } @@ -137,6 +180,9 @@ export class WallCutoutCache { private highlightKey = '' private transformed = new Set() private overrides = useLiveNodeOverrides.getState().overrides + /** The reveal's list the walls in play were read from, and the walls it named. */ + private revealing: readonly string[] = NOT_REVEALING + private inPlay = new Set() constructor( private readonly viewerStore: WallCutoutViewerStore = useViewer, @@ -203,6 +249,17 @@ export class WallCutoutCache { : null const releasedPreview = previewId(previous) === previewId(viewer) ? null : previewId(previous) this.viewer = viewer + // A wall whose play starts or ends, or a node that moves under one, changes only its own stamp. + const revealing = viewer.nodesRevealing ?? NOT_REVEALING + const playChanged = new Set() + if (this.revealing !== revealing || (nodesChanged && revealing.length > 0)) { + const next = wallsInPlay(scene.nodes, revealing) + for (const id of next) if (!this.inPlay.has(id)) playChanged.add(id) + for (const id of this.inPlay) if (!next.has(id)) playChanged.add(id) + this.inPlay = next + this.revealing = revealing + } + // After the stamps are current: a deferred wall is applied with the walls in play it stands among. for (const id of this.deferred.size > 0 ? Array.from(this.deferred) : []) { this.deferred.delete(id) const wall = this.walls.get(id) @@ -215,7 +272,8 @@ export class WallCutoutCache { registryChanged || this.rebuilt.size > 0 || this.transformed.size > 0 || - releasedPreview !== null + releasedPreview !== null || + playChanged.size > 0 const cutaway = viewer.wallMode === 'cutaway' if (!invalidated && !cutaway) return @@ -294,6 +352,7 @@ export class WallCutoutCache { changed || rebuilt || releasedPreview === id || + playChanged.has(id) || cameraChanged ) { this.apply(wall, viewer.wallMode, refresh) @@ -366,6 +425,8 @@ export class WallCutoutCache { } wall.visibleVariant = variant(false) wall.hiddenVariant = variant(true) + // The plain visible palette (no selection or hover tint): what the wall wears when a capture sees it. + visibleMaterialsOf.set(wall.mesh, materials.visible) } private refreshNormal(wall: CachedWall, visited: Set): void { @@ -389,7 +450,11 @@ export class WallCutoutCache { wall.negativeFacing, ) } - const hidden = wallHiddenFromFacing(wall.node, mode, wall.negativeFacing ?? false) + // A wall in play is seen whatever the cutaway says (walls down is the person's own choice, and + // stays); `mode` itself is the person's, untouched. + const hidden = + wallHiddenFromFacing(wall.node, mode, wall.negativeFacing ?? false) && + !(mode === 'cutaway' && this.inPlay.has(wall.node.id)) wall.hidden = hidden // The wall batch and pointer handlers consume this boolean, including // false on stamp lift; translucent walls must continue receiving events. diff --git a/packages/viewer/src/systems/wall/wall-finish-geometry.test.ts b/packages/viewer/src/systems/wall/wall-finish-geometry.test.ts index e26cb0e10b..4050c5b550 100644 --- a/packages/viewer/src/systems/wall/wall-finish-geometry.test.ts +++ b/packages/viewer/src/systems/wall/wall-finish-geometry.test.ts @@ -9,6 +9,7 @@ import { DoorNode, extractRooms, resolveWallFinish, + SceneMaterial, sceneRegistry, useScene, WallNode, @@ -262,6 +263,108 @@ describe('wall finish geometry', () => { const untextured = getMaterialsForWall(host, 'rendered', false, 'clay', undefined, {}, refs) expect(untextured.visible).toHaveLength(5) }) + + function paintedOpeningFixture() { + const colors = { + base: '#123456', + back: '#654321', + room: '#2468ac', + override: '#ac6824', + region: '#358721', + } + const materials = Object.fromEntries( + Object.entries(colors).map(([name, color]) => { + const material = SceneMaterial.parse({ + id: `smat_finish_${name}`, + name, + material: { properties: { color, roughness: 0.43, metalness: 0.17 } }, + }) + return [material.id, material] + }), + ) + const { walls, host, zone } = fixture({ + slots: { a: 'scene:smat_finish_base', b: 'scene:smat_finish_back' }, + faceRegions: [ + { id: 'wainscot', face: 'a', u0: 1, u1: 5, v1: 0.9, finish: 'scene:smat_finish_region' }, + ], + }) + const zones = [ + zone('A', { wallMaterial: 'scene:smat_finish_room' }), + zone('B', { + wallMaterial: 'scene:smat_finish_room', + wallOverrides: [{ wallId: host.id, face: 'a', finish: 'scene:smat_finish_override' }], + }), + ] + const children = [ + DoorNode.parse({ + id: 'door_finish_color', + wallId: host.id, + position: [6.5, 1.05, 0], + width: 0.9, + height: 2.1, + }), + WindowNode.parse({ + id: 'window_finish_color', + wallId: host.id, + position: [2.5, 1.7, 0], + width: 1.2, + height: 1, + }), + ] + return { colors, materials, host, walls, zones, children } + } + + test('drawn finishes respect room overrides and face paint on both sides of door and window cuts', () => { + const { colors, materials, host, walls, zones, children } = paintedOpeningFixture() + const geometry = build(host, walls, zones, children) + const palette = getMaterialsForWall( + host, + 'rendered', + true, + 'clay', + 'studio', + materials, + getWallFinishRefs(geometry), + ).visible + const mesh = new THREE.Mesh(geometry, palette) + const hit = (x: number, y: number, face: 'a' | 'b' = 'a') => { + const sign = face === 'a' ? 1 : -1 + return new THREE.Raycaster( + new THREE.Vector3(x, y, sign), + new THREE.Vector3(0, 0, -sign), + 0, + 2, + ).intersectObject(mesh)[0] + } + try { + // These points and colors are authored independently of layout/material-index helpers: + // a room replaces the wall slot, its local override replaces the room, a region wins. + for (const [x, y, face, color] of [ + [0.5, 1.4, 'a', colors.room], + [2, 0.5, 'a', colors.region], + [4.5, 0.5, 'a', colors.region], + [5.5, 1.4, 'a', colors.override], + [7.5, 1.4, 'a', colors.override], + [0.5, 1.4, 'b', colors.back], + [5.5, 1.4, 'b', colors.back], + ] as const) { + const intersection = hit(x, y, face) + expect(intersection).toBeDefined() + if (!intersection?.face) throw new Error(`Missing wall face at ${x}, ${y}, ${face}`) + const material = palette[intersection.face.materialIndex] as THREE.MeshStandardMaterial + expect(material.color.getHexString()).toBe(color.slice(1)) + expect(material.roughness).toBe(0.43) + expect(material.metalness).toBe(0.17) + expect(material.transparent).toBe(false) + } + for (const face of ['a', 'b'] as const) { + expect(hit(6.5, 1, face)).toBeUndefined() + expect(hit(2.5, 1.7, face)).toBeUndefined() + } + } finally { + geometry.dispose() + } + }) }) describe('room finish invalidation', () => { diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index d4c58689e4..40bd48b42e 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -187,10 +187,13 @@ function wallFaceMaterialSignature( ): string { const ref = wallNode.slots?.[face] if (ref) return refMaterialSignature(ref, sceneMaterials) + const spec = getEffectiveWallFaceMaterial(wallNode, face) + const assemblyRef = wallAssemblyFaceFinishRef(wallNode, face) return JSON.stringify({ - legacy: getWallSurfaceMaterialSignature(getEffectiveWallFaceMaterial(wallNode, face)), + legacy: getWallSurfaceMaterialSignature(spec), + preset: spec.materialPreset ? materialPresetRefSignature(spec.materialPreset) : null, // Changing the assembly's cladding must re-skin the face. - assemblyFinish: wallAssemblyFaceFinishRef(wallNode, face), + assemblyFinish: assemblyRef ? materialPresetRefSignature(assemblyRef) : null, }) } diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index 3c49abdcdb..8e90afb4ba 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -16,7 +16,7 @@ What the construction reveal (see [systems](systems.md), "Construction reveal") **Edits.** An agent commit that moves furniture already in the scene (another `position`, `rotation` or parent) says it in the same words with `changed: true` on `start`/`land`/`settle`, `node-start` (`style: 'glide'`) and `complete`: one group per (phase, level), then one `complete`. A piece standing in the same level before and after, moved more than 2 cm or 0.02 rad, glides (`setGlidePose` in `reveal-pose.ts`): it holds at its old place from the moment the commit lands, then travels to the new one on a critically damped spring (response 0.7 s, a 5 cm hop half way), starting a beat after the previous piece (80 ms, spread capped at 1.2 s). `node-start` and the group's `start` are said when it begins to move, `land` when the first piece is home, `settle` when the last is; `instant` is false. A piece that cannot glide (another parent, or a hair's move), reduced motion, the animation off, the canvas paused: shown at once and said at once (`instant: true`, start, node-start, land, settle, complete together). The root keeps its own transform (the renderer writes the new place whenever it likes); the pose reads it, so nothing flashes. A glide counts as revealing (`isNodeRevealing`), so a batch hands the piece back to its own meshes. The roof lifts for the move as it does for furniture and is let go of a beat after the last piece is home. Moves wait for the reveal to be quiet (no group open, nothing waiting or moving in), so a build's group is never closed early; selecting a piece, an undo, a load or a capture puts it home at once. A piece still waiting for its turn is not said to have moved; the person's own edits say nothing. A host that counts a `complete` as the end of a build can tell the two apart by `changed`. -`start` and `node-start` are said when a node first moves (its pose is set that frame), not when the reveal lets it in: a node waiting for its first build is not seen to move, and a card or the camera must not run ahead of it. `source` is the commit's `author` (`'agent'`). `instant` is true when the host showed the group at once (animation off, reduced motion, the read-only viewer, an export, a level nobody sees) or the reveal was cut short; start, land and settle then come together, in that order. A build shown at once has no `finale`; `complete` is always said, with `instant: true`. `complete` can fire more than once in a turn (once per quiet stretch): the host picks the one that ends its turn. `subscribeRevealPhases` (one event per group start) is unchanged. +`start` and `node-start` are said when a node first moves (its pose is set that frame), not when the reveal lets it in: a node waiting for its first build is not seen to move, and a card or the camera must not run ahead of it. `source` is the commit's `author` (`'agent'`). `instant` is true when the host showed the group at once (animation off, reduced motion, the read-only viewer, an export, a level the author hid) or the reveal was cut short; start, land and settle then come together, in that order. A build shown at once has no `finale`; `complete` is always said, with `instant: true`. `complete` can fire more than once in a turn (once per quiet stretch): the host picks the one that ends its turn. `subscribeRevealPhases` (one event per group start) is unchanged. ## Weight @@ -51,17 +51,39 @@ It stays up 10 s after the last piece of the phase (a model's turn between write - **Held gone.** The nodes stay hidden until they leave the scene (or 2 s pass, then they come back whole), so no frame shows them whole between the promise resolving and the removal. An undo's history restore ends other reveals at once but keeps retracted nodes hidden for the same reason. Pass only what the undo removes: a node that stays would sink and then come back. - **The roof.** Furniture going from under a seated roof lifts the roof first (a lead of 450 ms), as it did for the build; a roof going with the furniture under it goes first, so the furniture is seen leaving; a roof already up carries on from where it is, with no second finish. - **Batches.** A node going out counts as revealing (`isNodeRevealing`: waiting, moving in, going out, held gone). The wall batch and the node batch (items, doors, windows, slabs, ceilings, columns) hand such a node back to its own meshes, so the pose is what is drawn, and keep it out until the host takes it away; a level is not sewn again under the play. A merged mesh draws its members whole, whatever pose their own meshes are given. -- **Instant.** With no reveal running, the animation off or motion reduced it resolves at once with `instant: true` and nothing retracted; ids not in the scene, not mounted, on a hidden level or already going come back in `skipped`. +- **Instant.** With no reveal running, the animation off or motion reduced it resolves at once with `instant: true` and nothing retracted; ids not in the scene, not mounted, on a level the author hid or already going come back in `skipped`. - **Events.** `retract-start` and `retract-end` per group; a capture, a load or the reveal stopping ends the reverse play and resolves what waits on it. +## A roof at the Framing level is covered, not dropped + +At Full a roof is an `assemble` node: its parts drop in drawn apart and the merged shell takes over. At Framing the same node plays `cover` (`styleAt`; a kind never declares it): it holds still and its covering is laid in place, over the frame Bones dropped. The node's meshes wear masked clones of their materials (`reveal-wipe.ts`, `setRevealWipe`: a world-coordinate mask, never a write to the shared materials), and the mask level moves with every frame from the lowest edge to the highest (an ease in and out, a hard edge: a stippled one would be a shader nobody has seen compile; a flat roof sweeps along its longer plan side). How long it takes follows the size of what it covers (`coverRangeAt`, `coverDurationMs`: 790 ms for each metre of sweep at ×1 Framing, never under 2.45 s nor over 7.35 s; a lifting off takes 0.6 of that), at the person's speed. The roof's parts are not staged on their own. An undo or a deletion lifts the covering off from the ridge, then the bare node goes (`retractPoseAt('cover')`). Bones asks a preroll for the roof (`roofFramePrerollMs`) in which the roof's frame falls, keeps it while the roof is revealing and takes it away when the covering has closed, as a wall's studs go when the wall has risen. + +## Edits and deletions + +**An edit that changes a picture plays again.** A kind lists the fields that make its picture in `capabilities.revealOnEdit` (a wall's `start`, `end`, `curveOffset`, `height`, `thickness`, `wallType`; a slab's `polygon`, `elevation`; the guard test `nodes/src/reveal-coverage.test.ts` checks every field exists in the schema). When an agent commit changes one of them on a node that is already there, `commitEditCandidates` stages it with its own style turned into a quick one (`drop`, `cut` and `assemble` become `scale`; `rise` and `settle` stay) and the node re-plays it in 55 % of the time it first took, at the person's speed, in the `changed` groups (`node-start` carries the style and `changed: true`; Follow looks at it). Moves of furnishing still glide. Nothing replays for a field outside the list (a name, a colour), for the person's own edit, with the animation off, or for what the structure reconciler derived: a commit carries `derivedWrites` (each node the reconciler wrote through the derived-writer capability, and the fields; `'all'` for one it created or deleted), and an auto plate or ceiling, or a field the reconciler wrote, is never an edit. + +**A deletion plays the creation backwards.** The node leaves the store at once. As its commit lands, a clone of its registered root is added beside it in the scene and plays `retractPoseAt` (staggered by `planRetract`, at the person's speed, `retract-start` / `retract-end` events, its level shown meanwhile), then is removed. The host chooses which commits (`deletes`; Community: any local commit); a load, an undo (which plays its own reverse), the animation off and a level the author hid play nothing. A node standing in a host that goes with it goes with its host. The clone is a copy of the node's picture and wears the material its source wore in that frame, so it is dressed for what plays (`ghost-dress.ts`): a wall the cutaway was hiding (stamped `wallHidden`, wall mode cutaway) wears its visible variant, the one a capture draws, instead of its stipple. The wall cutout keeps that variant on the mesh it cuts away (`visibleMaterialsOf`, `wall-cutout-cache.ts`), so the reveal names no wall kind and builds no wall palette; a copy never wears a palette that cannot draw its groups. A wall the cutaway showed, and the person's other wall modes, keep what they wear. + +## Deleting a room or a floor takes it apart + +A deletion plays the build backwards for every node it removes (`playDeletions`, `commitDeleteCandidates`: the nodes are out of the store at once, a snapshot of each one's picture plays). At Full the rewind is `planRetract`. At Framing the plan is the build's own (`planLazyReveal`) read from its end (`planLazyRetract`, capped at `LAZY_RETRACT_TIMING.capMs`): the furniture lifts out, then the roof's covering, the openings, the walls and the slabs last, the floors from the top down, each room as it went up. The pictures of a deleted floor stand in its building (`ghostStand`), so they outlive the floor's own object. A plugin that took the piece apart asks a preroll for it (`setRevealPreroll`, given the scene the node stood in) and reads when each piece goes with `getRetractTurn`: Bones derives the frame of the deleted walls and roofs from the scene as it was, draws it at rest as each finish begins to go, and plays the wall build's pose backwards, boards first, then studs and plates (`planWallDismantle`). + +**What stands above a deleted floor waits for it.** When a deletion takes a level apart (the pictures play, above), each level that stood above it in the same building, and whose height changes, keeps the height it was presented at until the last of those pictures has gone (`holdLevelsAbove`, `systems/level/level-hold.ts`: `LevelSystem` reads `heldLevelY` ahead of the stacked elevation). The roof level and the roofs on it, the frame Bones mounts in their level, and anything else the level holds stay with it, so nothing is drawn inside the floor that is coming apart. When the floor has gone the level comes down onto what is below in one eased move (`stepLevelHolds`: ease in and out, the rewind's own `drop` time at the level and the person's speed). The store's elevations are never written, and the held level shows while it waits (`levelsRevealing`). The animation off, reduced motion, a capture, an export, a load and an undo release it at once (`finishReveals`). An undo or a redo plays nothing, so a level that has just mounted cuts every level to its height together (`LevelSystem`): the floor comes back and the levels above go up in the same frame. + +## What the person's level display hides + +The display (the selected level and `hideLevelsAboveSelection`, solo, the cutaway) never hides a build: the reveal publishes the levels it has work on in `useViewer.levelsRevealing` (what waits, moves, goes out in an undo or is carried to a new place), `LevelSystem` shows each of them whatever the display hides (`resolveLevelVisibility`'s `revealing`), the node batch re-offers its sources when the list changes, and the level steps aside again when its work is done. The person's selection is not written. Only the author's own eye on a level (`visible: false`) keeps its build out of sight: that build shows at once. With Follow Architect on, the floor is also selected and framed (below); with it off, the level shows only while its work plays. + +The cutaway never hides what plays either: the reveal publishes the nodes playing (moving in, going out, carried, or an edit replaying) in `useViewer.nodesRevealing`; in cutaway `WallCutoutCache` stamps a wall that plays, or hosts an opening that does, visible for as long as it plays (`wallsInPlay`). It goes back to what the view wants when the play ends. The person's wall mode is not written; walls down stays their choice. + ## Follow Pascal The viewport's toolbar toggle (on by default, persisted as `pascal-follow-pascal`): while a build plays, the camera eases to what the current step is working on, at the distance its scale needs, reusing `view_scene`'s bounds (`sceneViewBounds`, `sceneViewFacing`). -- **Targets** (`follow-pascal-targets.ts`, from the reveal's events): a floor, whole, when its slabs and walls start; each opening close up, square to its wall, from outside; each piece of furniture from above, looking down through the lifted roof (the eye clears it); the roof from the group that assembles it; the whole house when the furniture has landed and at the finale (not after furniture the agent only moved: the camera stays on it). An undo's `retract-start` is followed group by group, and a piece the agent moved is framed like one it placed. +- **Targets** (`follow-pascal-targets.ts`, from the reveal's events): a floor, whole, when its slabs and walls start; each opening close up, square to its wall, from outside; each piece of furniture from above, looking down through the lifted roof (the eye clears it); the roof from the group that assembles it; the whole house, with its upper floors shown, when the furniture has landed and at the finale (not after furniture the agent only moved: the camera stays on it). An undo's `retract-start` is followed group by group, and a piece the agent moved is framed like one it placed. - **Moves** (`follow-pascal.ts`). Orbit space (the point looked at, a distance, an azimuth and an elevation per kind of target), so the horizon stays level; six critically damped springs (response 1.1 s) take the nearest way round; a target holds at least 1.5 s; what lands within 0.35 s of the first is framed with it as one group. The poses go out as `camera-controls:apply-pose`. - **The person wins.** Any of `camera-controls:interaction-start | view | focus | top-view | orbit-cw | orbit-ccw | fit-scene` pauses it and shows a "Resume following" pill while a build is on; resuming flies back to what it last looked at; the next build takes the camera again. A host that moves the camera itself calls `useFollowPascal.getState().pause()` first (`apply-pose` is Follow Pascal's own move, so it is not a signal). -- **What it changes, it puts back** (`follow-pascal-presentation.ts`). To see a build it cuts the walls away when no eye sees the target (`wallMode: 'cutaway'`, which the viewer persists), selects the floor being built (dropping the room and what was picked) and hides the floors above it. When Follow ends (switched off, the person takes the camera, the editor is left, or the build is over with no turn running: the host's busy flag, `setPointAskBusy`, is false; while a turn runs a build being over is not an end, since a turn runs several programs and the view holds steady between them; a reveal that plays on after the turn is still followed and ends it) each part it changed goes back to what the person had before the first change. A part the person changed since (their wall mode, their floor, an element they picked on it) is theirs: it is not put back, and Follow does not write it again until it has ended. The sightline test (`follow-pascal-sight.ts`) counts every opaque drawn mesh, walls, roof and wall-mounted objects alike: a target inside a closed shell has no clear eye whatever is mounted on the walls, so the fallback to cutaway is the normal case for an interior build, and putting it back at the end is what keeps the building readable. +- **What it changes, it puts back** (`follow-pascal-presentation.ts`). To see a build it cuts the walls away when no eye sees the target (`wallMode: 'cutaway'`, which the viewer persists), selects the floor being built (dropping the room and what was picked) and hides the floors above it. When Follow ends (switched off, the person takes the camera, the editor is left, or the build is over with no turn running: the host's busy flag, `setPointAskBusy`, is false; while a turn runs a build being over is not an end, since a turn runs several programs and the view holds steady between them; a reveal that plays on after the turn is still followed and ends it) each part it changed goes back to what the person had before the first change. A part the person changed since (their wall mode, their floor, an element they picked on it) is theirs: it is not put back, and Follow does not write it again until it has ended. **A change seen from outside is looked at from outside.** A wall with an exterior face the agent edited, an opening moved or resized on one, a roof (and what stands on it) or the ground (`follow-pascal-exterior.ts`) is a step with `exterior: true` (`follow-pascal-targets.ts`, from `node-start` with `changed` and the `changed` groups; **only edits and moves**: a build, an opening coming in or a roof assembling included, is followed as it always was): Follow sets the walls up for it instead of falling back to the cutaway, which would hide the wall that moves, and frames its exterior face (`sceneViewFacing` also answers for an exterior wall). A commit with one such change in it takes the outside view for all of it (`revealChangedNodeIds`, read when the build begins); the person's wall mode comes back when Follow ends, as any other part it changed. Furniture, interior walls and finishes inside keep the cutaway. The sightline test (`follow-pascal-sight.ts`) counts every opaque drawn mesh, walls, roof and wall-mounted objects alike: a target inside a closed shell has no clear eye whatever is mounted on the walls, so the fallback to cutaway is the normal case for an interior build, and putting it back at the end is what keeps the building readable. - **Reduced motion** cuts: the canvas dips and comes back as a cross-fade; in an orthographic view or the walkthrough it does nothing. ## The first draw of a kind, and the first mount diff --git a/wiki/architecture/node-definitions.md b/wiki/architecture/node-definitions.md index c01e49b088..77c0917633 100644 --- a/wiki/architecture/node-definitions.md +++ b/wiki/architecture/node-definitions.md @@ -609,6 +609,36 @@ mechanism: { --- +### `capabilities.exterior` + +Whether a change to a node is seen from outside the building. Hosts that follow a change with a camera (Follow Pascal) frame it from outdoors, with the walls up, instead of through a cutaway, and ask the registry; the editor names no kind. + +```ts +exterior?: boolean | ((node: AnyNode, nodes: Readonly>) => boolean | undefined) +``` + +- `true` — the kind always stands outdoors: the roof, roof segments and everything mounted on them (dormer, skylight, chimney, vents, gutters, solar panels), a fence and its gates, the site. +- `false` — the kind never does and holds what is looked at through a cutaway (level, building, zone). The walk up a node's parents stops at it. +- a function — decided from the node's fields and its surroundings: a wall with an exterior face; a door or window in such a wall or in a roof segment (`nodes/src/wall/exterior.ts`). Return `undefined` to say nothing. +- omitted, or `undefined` — what holds the node decides: a node under a `true` kind is outdoors, and so is one that stands in no storey (outdoor furnishing, plants). A sofa in a room is not. + +A plugin kind sets it in its own `definition.ts` and is treated as a built-in is (a pool deck sets `exterior: true`). + +### `capabilities.revealLayout` + +What a kind is in its floor's plan, so a build is revealed room by room instead of in one pulse (see [construction-animation](construction-animation.md)). Both fields are pure functions of the node, in metres on the level's plan. + +```ts +revealLayout?: { + room?: (node: AnyNode) => readonly [x: number, z: number][] | null // an outline of a room (zone) + line?: (node: AnyNode) => readonly [[x: number, z: number], [x: number, z: number]] | null // bounds rooms along a line (wall) +} +``` + +What stands in a `room` (walls along it, furniture in it) is revealed together around its centre, and the rooms of a floor sweep across it. A `line` stands at its middle: the room on either side of it is its room, and lines in no room run along the lines they meet end to end. The derived plates and ceilings of a level also go out, in a deletion, with the `room` and `line` nodes a commit takes off it. A kind that declares neither is still placed by its own `position`, `polygon` or `start`/`end`. + +--- + ## See also - [renderers.md](renderers.md) — `NodeRenderer` dispatch and the contract for a custom `def.renderer`. diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index 6746c3bdea..531af7775a 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -39,7 +39,7 @@ The same shape powers the built-in `pascal:core` plugin in `@pascal-app/nodes` The core `Plugin` manifest owns semantic node definitions (and registry-backed inspector extensions); host UI and viewer-wide presentation remain separate exports. Each `nodes` entry is a `NodeDefinition` that the registry stamps with `kind`, `schemaVersion`, `schema`, and any combination of: - `defaults` — initial field values for new instances. -- `capabilities` — `selectable` / `duplicable` / `deletable` / `surfaces` / `relations` flags consumed by the framework. `reveal` (`{ phase, style }`, plus `height` in metres for `settle`, `drop` and `assemble`) stages the kind when an agent builds it (see [`systems.md`](systems.md#construction-reveal)); without it the node appears with its parent, or drops in as a part of an `assemble` parent. +- `capabilities` — `selectable` / `duplicable` / `deletable` / `surfaces` / `relations` flags consumed by the framework. `reveal` (`{ phase, style }`, plus `height` in metres for `settle`, `drop` and `assemble`) stages the kind when an agent builds it (see [`systems.md`](systems.md#construction-reveal)); without it the node appears with its parent, or drops in as a part of an `assemble` parent. `exterior` says whether a change to the node is seen from outside, and `revealLayout` that it outlines a room or bounds rooms along a line (see [`node-definitions.md`](node-definitions.md#capabilitiesexterior)). - `parametrics` — auto-derived inspector UI shape (`fields` + optional `customPanel` escape hatch). - `renderer` — custom 3D React component (GLB, drei, TSL — opt-out of `def.geometry`). - `system` — per-frame work (animation, dirty-cascade, runtime state).