From 5c5b56818bbbe3c223a90c60b112880d1b9968e3 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Wed, 14 Oct 2026 18:07:44 +0200 Subject: [PATCH 01/43] feat(viewer): lazy nested reveal for framing, prerolls, and no pop for undeclared kinds The framing level now plays on its own schedule (`planLazyReveal`, `LAZY_REVEAL_TIMING`): floors a beat apart, the rooms of a floor a beat apart, the pieces of a room one by one around it, every start jittered; roofs once every floor is up, then furniture room by room. A big build takes proportionally longer and is pulled together only past the 40 s cap. Full keeps `planReveal` and its timing unchanged. Candidates carry their room: the zone a node stands in (a wall, the zone on either side of its middle), else runs of walls that meet end to end. A plugin may ask for a preroll before nodes of the lazy schedule (`setRevealPreroll`) and reads each node's turn on the reveal's clock (`getRevealTurn`, `revealClockMs`); a preroll already playing keeps its turn when the plan is made again. `emitRevealPuff` lends the reveal's dust. On the lazy schedule a rise, scale or settle overshoots and settles (back.out), drops and settles fall from higher, and drops anticipate. A new node whose kind declares no reveal, added where its host was already there, now comes in on a default reveal instead of popping; under a host still coming in it comes in with it. Co-Authored-By: Claude Opus 5.5 --- packages/viewer/src/index.ts | 10 + packages/viewer/src/lib/reveal-candidates.ts | 268 +++++++++++++++++- packages/viewer/src/lib/reveal-pose.ts | 16 +- .../viewer/src/lib/reveal-schedule.test.ts | 122 ++++++++ packages/viewer/src/lib/reveal-schedule.ts | 190 +++++++++++++ .../construction-reveal.test.tsx | 100 ++++++- .../construction-reveal.tsx | 118 +++++++- 7 files changed, 810 insertions(+), 14 deletions(-) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 0ece9475f4..afd6804db5 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -189,6 +189,12 @@ export { type RendererInitializationResult, type RendererPowerPreference, } from './lib/renderer-capability' +export { + LAZY_REVEAL_TIMING, + type LazyRevealTiming, + planLazyReveal, + type RevealCandidate, +} from './lib/reveal-schedule' export { planRevealWindows, type RevealWindow } from './lib/reveal-windows' export { createSceneSupportHeightSampler } from './lib/scene-support-height' export { @@ -258,7 +264,9 @@ export { type ConstructionRevealDriver, type ConstructionRevealLevel, type ConstructionRevealOptions, + emitRevealPuff, getRevealPlanState, + getRevealTurn, isNodeRevealing, isRevealAssembling, type RetractResult, @@ -271,7 +279,9 @@ export { type RevealPlanState, type RevealRetractEvent, retractNodes, + revealClockMs, seatLiftedNow, + setRevealPreroll, startConstructionReveal, subscribeRevealAssembling, subscribeRevealEvents, diff --git a/packages/viewer/src/lib/reveal-candidates.ts b/packages/viewer/src/lib/reveal-candidates.ts index 81adc7cdce..0e76aca46b 100644 --- a/packages/viewer/src/lib/reveal-candidates.ts +++ b/packages/viewer/src/lib/reveal-candidates.ts @@ -3,6 +3,7 @@ import { type AnyNodeId, type LevelNode, nodeRegistry, + type RevealConfig, type RevealStyle, type SceneCommit, } from '@pascal-app/core' @@ -11,8 +12,10 @@ import type { RevealCandidate } from './reveal-schedule' /** * 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' @@ -28,6 +31,47 @@ export function styleAt(style: RevealStyle, at: ConstructionRevealLevel): Reveal return 'scale' } +/** Kinds that hold the others: staging one would unmount everything under it. */ +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 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 (parent.type === 'roof') 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 +79,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 +115,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 +130,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 +164,208 @@ export function commitRevealCandidates( }) } } + layoutRevealCandidates(candidates, current.nodes) return candidates } +type Pt = readonly [number, number] + +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): Pt[] | null { + const polygon = (node as { polygon?: unknown }).polygon + return Array.isArray(polygon) && polygon.length >= 3 && polygon.every(isPt) ? polygon : null +} + +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 wall's middle, a polygon's centre, a placed node's spot. */ +function planPointOf(node: AnyNode): Pt | null { + const { start, end, position } = node as { start?: unknown; end?: unknown; position?: unknown } + if (isPt(start) && isPt(end)) return [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + 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 zone it stands in (a wall, the zone on + * either side of its middle), else, for a wall, a run of the walls it meets end to end; 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?.type === 'zone' ? polygonOf(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 + } + // Walls in no zone, 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) + let probes: Pt[] = [point] + if (standing.type === 'wall') { + const { start, end } = standing as unknown as { start: Pt; end: Pt } + 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 (standing.type === 'wall' && standing.id === candidate.id) { + const list = loose.get(candidate.levelId) ?? [] + list.push({ candidate, wall: standing }) + loose.set(candidate.levelId, list) + continue + } + if (standing.type === 'wall') 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 + } +} + +/** Walls in no room: the walls that meet end to end make an outline, cut in runs along it. */ +function layoutRuns(levelId: string, walls: { candidate: StagedCandidate; wall: AnyNode }[]) { + const key = ([x, z]: Pt) => `${Math.round(x / MEETS_M)}:${Math.round(z / MEETS_M)}` + const byEnd = new Map() + const ends = walls.map(({ wall }) => { + const { start, end } = wall as unknown as { start: Pt; end: Pt } + return [key(start), key(end)] as const + }) + ends.forEach((pair, index) => { + for (const end of pair) byEnd.set(end, [...(byEnd.get(end) ?? []), index]) + }) + const midOf = (index: number) => planPointOf(walls[index]!.wall) ?? [0, 0] + const sweepOf = (index: number) => { + const [x, z] = midOf(index) + return x + z + } + 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 + // Walk the outline from its first wall in the sweep, wall to the wall it meets. + const order: number[] = [] + const queue = [first] + seen.add(first) + while (queue.length > 0) { + const index = queue.shift()! + order.push(index) + for (const end of ends[index]!) { + for (const next of byEnd.get(end) ?? []) { + if (seen.has(next)) continue + seen.add(next) + queue.push(next) + } + } + } + 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 diff --git a/packages/viewer/src/lib/reveal-pose.ts b/packages/viewer/src/lib/reveal-pose.ts index 7dcffd98de..5654f598ee 100644 --- a/packages/viewer/src/lib/reveal-pose.ts +++ b/packages/viewer/src/lib/reveal-pose.ts @@ -118,8 +118,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 @@ -137,17 +145,19 @@ export function revealPoseAt( out.scale.set(1, 1, 1) if (t >= 1 || style === 'assemble') 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) diff --git a/packages/viewer/src/lib/reveal-schedule.test.ts b/packages/viewer/src/lib/reveal-schedule.test.ts index 4d258611c5..79f2e3ebc3 100644 --- a/packages/viewer/src/lib/reveal-schedule.test.ts +++ b/packages/viewer/src/lib/reveal-schedule.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'bun:test' import type { RevealPhase } from '@pascal-app/core' import { + LAZY_REVEAL_TIMING, + planLazyReveal, planRetract, planReveal, RETRACT_TIMING, @@ -145,3 +147,123 @@ 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) + }) +}) diff --git a/packages/viewer/src/lib/reveal-schedule.ts b/packages/viewer/src/lib/reveal-schedule.ts index ac4a2fcc0c..acbe768e23 100644 --- a/packages/viewer/src/lib/reveal-schedule.ts +++ b/packages/viewer/src/lib/reveal-schedule.ts @@ -49,6 +49,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 & { @@ -138,6 +152,182 @@ 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. */ + durationMs: Record + /** The overshoot of a rise or a scale (GSAP's `back.out` strength; 0 is a plain ease out). */ + overshoot: number + /** Drops and settles fall from at least this high (m), whatever their kind declares. */ + minHeightM: Partial> +} + +/** The one place to tune the lazy schedule and the framing level's motion. */ +export const LAZY_REVEAL_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 }, + overshoot: 1.6, + minHeightM: { drop: 1.8, settle: 0.6 }, +} + +/** 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 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..883c833bce 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -25,7 +25,7 @@ import { NodeRenderer } from '../../components/renderers/node-renderer' import { DustPool } from '../../lib/construction-dust' import { OVERLAY_LAYER, scenePassLayers } from '../../lib/layers' import { hasRevealPose } from '../../lib/reveal-pose' -import { RETRACT_TIMING, REVEAL_TIMING } from '../../lib/reveal-schedule' +import { LAZY_REVEAL_TIMING, RETRACT_TIMING, REVEAL_TIMING } from '../../lib/reveal-schedule' import useViewer from '../../store/use-viewer' import { GeometrySystem } from '../geometry/geometry-system' import { ConstructionDust } from './construction-dust' @@ -39,6 +39,7 @@ import { type ConstructionRevealDriver, type ConstructionRevealLevel, getRevealPlanState, + getRevealTurn, isNodeRevealing, isRevealAssembling, isRevealWaiting, @@ -46,7 +47,9 @@ import { type RevealEvent, type RevealPhaseEvent, retractNodes, + revealClockMs, seatLiftedNow, + setRevealPreroll, startConstructionReveal, subscribeRevealAssembling, subscribeRevealEvents, @@ -305,6 +308,7 @@ beforeEach(async () => { }) afterEach(async () => { + setRevealPreroll(null) await act(async () => driver?.stop()) driver = null await renderer?.unmount() @@ -2549,3 +2553,97 @@ 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() + }) +}) diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 13ec861d1e..6a53a4ecd5 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -44,7 +44,9 @@ import { setRevealPose, } from '../../lib/reveal-pose' import { + LAZY_REVEAL_TIMING, type PlannedReveal, + planLazyReveal, planRetract, planReveal, REVEAL_TIMING, @@ -89,6 +91,8 @@ 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 @@ -208,6 +212,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 +257,10 @@ 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: ((candidate: RevealCandidate) => number) | null = null let unplanned = false /** The plan is to be made again, once what mounts up front has drawn. */ let replanning = false @@ -527,6 +537,45 @@ export function getRevealPlanState(id: string): RevealPlanState | null { 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: ((candidate: RevealCandidate) => number) | 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) @@ -929,6 +978,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 +989,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,7 +1003,10 @@ 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), + ) } function levelShown(levelId: string | null): boolean { @@ -993,6 +1051,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 +1074,29 @@ 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 ? LAZY_REVEAL_TIMING : REVEAL_TIMING 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: timing.durationMs[candidate.style], 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 ? LAZY_REVEAL_TIMING.overshoot : 0, weight: 'finale' in candidate && candidate.finale ? FINALE_WEIGHT : 1, finale: 'finale' in candidate && candidate.finale === true, finaleSaid: false, @@ -1133,6 +1201,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' @@ -1748,6 +1819,38 @@ 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[] { + if (!lazyOn()) { + return planReveal([...pending.values()], Math.max(now, lastStartMs + REVEAL_TIMING.nodeGapMs)) + } + 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)].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 @@ -1764,11 +1867,14 @@ 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++]! + planned.delete(entry.id) if (pending.has(entry.id)) startReveal(entry, now, levelShown(entry.levelId)) } if (due > 0) queue = queue.slice(due) @@ -1807,6 +1913,7 @@ function finishReveals(keepHeld = false) { const busy = waiting.length > 0 || active.size > 0 || retracts.size > 0 pending.clear() queue = [] + planned.clear() unplanned = false replanning = false warmWaiting = false @@ -1841,6 +1948,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) From b30d47c206f82fb85a0e90414d4f5ee87b0a7768 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Wed, 14 Oct 2026 18:24:18 +0200 Subject: [PATCH 02/43] fix(viewer): a gentler overshoot for the lazy rise, per style At 1.6 a rising wall peaked 9 % past its top, into the slab over it while the floors build side by side. The lazy schedule's overshoot is now per style: rise 0.7 (about 2 % past), scale and settle 1.6. Co-Authored-By: Claude Opus 5.5 --- packages/viewer/src/lib/reveal-schedule.ts | 9 ++++++--- .../systems/construction-reveal/construction-reveal.tsx | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/viewer/src/lib/reveal-schedule.ts b/packages/viewer/src/lib/reveal-schedule.ts index acbe768e23..d3aaddd0f3 100644 --- a/packages/viewer/src/lib/reveal-schedule.ts +++ b/packages/viewer/src/lib/reveal-schedule.ts @@ -172,8 +172,11 @@ export type LazyRevealTiming = { capMs: number /** How long each style moves its node at this level. */ durationMs: Record - /** The overshoot of a rise or a scale (GSAP's `back.out` strength; 0 is a plain ease out). */ - overshoot: 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> } @@ -194,7 +197,7 @@ export const LAZY_REVEAL_TIMING: LazyRevealTiming = { jitter: 0.3, capMs: 40_000, durationMs: { rise: 760, scale: 520, settle: 640, drop: 820, cut: 320, assemble: 0 }, - overshoot: 1.6, + overshoot: { rise: 0.7, scale: 1.6, settle: 1.6 }, minHeightM: { drop: 1.8, settle: 0.6 }, } diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 6a53a4ecd5..c74c27cd83 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -45,6 +45,7 @@ import { } from '../../lib/reveal-pose' import { LAZY_REVEAL_TIMING, + type LazyRevealTiming, type PlannedReveal, planLazyReveal, planRetract, @@ -1096,7 +1097,10 @@ function startReveal(candidate: RevealCandidate, now: number, animate: boolean) lazy && candidate.style === 'drop' ? Math.max(candidate.lead ?? 0, LAZY_DROP_LEAD) : (candidate.lead ?? 0), - overshoot: lazy ? LAZY_REVEAL_TIMING.overshoot : 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, From 0d3412f7c311eac2b305acf412849aacdc98587f Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Wed, 14 Oct 2026 18:17:58 +0200 Subject: [PATCH 03/43] test(viewer): add_volume plays room by room at the framing level The owner's case: one add_volume commit (two storeys, three rooms below, a hip roof) staged at the framing level. Every wall, slab, room and roof it makes goes through the timeline; its walls are grouped by the zones it makes and the rooms go up side by side; slabs first, floors offset, the roof once the walls are up, at most two starts in a 60 fps frame. Co-Authored-By: Claude Opus 5.5 --- .../lib/__fixtures__/add-volume-commit.json | 1090 +++++++++++++++++ .../viewer/src/lib/reveal-add-volume.test.ts | 162 +++ 2 files changed, 1252 insertions(+) create mode 100644 packages/viewer/src/lib/__fixtures__/add-volume-commit.json create mode 100644 packages/viewer/src/lib/reveal-add-volume.test.ts 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..e5e5215738 --- /dev/null +++ b/packages/viewer/src/lib/reveal-add-volume.test.ts @@ -0,0 +1,162 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + BuildingNode, + CeilingNode, + DoorNode, + LevelNode, + loadPlugin, + nodeRegistry, + 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 { commitRevealCandidates } from './reveal-candidates' +import { 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) => + ({ + kind: type, + schemaVersion: 1, + schema, + category, + defaults: () => ({}), + capabilities: reveal ? { reveal } : {}, + 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'), + kind('wall', WallNode, 'structure', { phase: 'structure', style: 'rise' }), + 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) + for (const node of created) { + if ( + ['wall', 'slab', 'zone', 'roof', 'door', 'window', 'roof-segment', 'ceiling'].includes( + node.type, + ) + ) + expect(byId.has(node.id)).toBe(true) + } + }) + + 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]), + ) + expect(end).toBeGreaterThan(6000) + 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`, + ) + }) +}) From f49a50832d86589c567e64883c1c3657c740db68 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 09:33:05 +0200 Subject: [PATCH 04/43] One pace for the Framing level: its whole lazy timeline takes FRAMING_PACE times as long FRAMING_PACE (1.75) multiplies every offset, gap, beat, motion duration and the cap of the lazy schedule, so a build that took 20 s takes 35 s and a smaller or larger one scales the same; jitter, overshoots and heights do not move. The original numbers stay as LAZY_REVEAL_BASE_TIMING and LAZY_REVEAL_TIMING is pacedLazyTiming(base, FRAMING_PACE). Full reads REVEAL_TIMING and RETRACT_TIMING and is untouched; a test pins its tables as literals. The viewer exports commitRevealCandidates and the pace for Bones and its measurements. Co-Authored-By: Claude Sonnet 5.5 --- packages/viewer/src/index.ts | 5 + .../viewer/src/lib/reveal-add-volume.test.ts | 5 +- .../viewer/src/lib/reveal-schedule.test.ts | 120 ++++++++++++++++++ packages/viewer/src/lib/reveal-schedule.ts | 37 +++++- 4 files changed, 163 insertions(+), 4 deletions(-) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index afd6804db5..9261123434 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -189,9 +189,14 @@ export { type RendererInitializationResult, type RendererPowerPreference, } from './lib/renderer-capability' +export { commitRevealCandidates } from './lib/reveal-candidates' export { + FRAMING_PACE, + LAZY_REVEAL_BASE_TIMING, LAZY_REVEAL_TIMING, type LazyRevealTiming, + type PlannedReveal, + pacedLazyTiming, planLazyReveal, type RevealCandidate, } from './lib/reveal-schedule' diff --git a/packages/viewer/src/lib/reveal-add-volume.test.ts b/packages/viewer/src/lib/reveal-add-volume.test.ts index e5e5215738..819c4f020b 100644 --- a/packages/viewer/src/lib/reveal-add-volume.test.ts +++ b/packages/viewer/src/lib/reveal-add-volume.test.ts @@ -20,7 +20,7 @@ import { import { Group } from 'three' import addVolumeCommit from './__fixtures__/add-volume-commit.json' import { commitRevealCandidates } from './reveal-candidates' -import { LAZY_REVEAL_TIMING, planLazyReveal } from './reveal-schedule' +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 @@ -153,7 +153,8 @@ describe('add_volume at the framing level', () => { const end = Math.max( ...plan.map((entry) => entry.startMs + LAZY_REVEAL_TIMING.durationMs[entry.style]), ) - expect(end).toBeGreaterThan(6000) + // 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`, diff --git a/packages/viewer/src/lib/reveal-schedule.test.ts b/packages/viewer/src/lib/reveal-schedule.test.ts index 79f2e3ebc3..a6a32103af 100644 --- a/packages/viewer/src/lib/reveal-schedule.test.ts +++ b/packages/viewer/src/lib/reveal-schedule.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from 'bun:test' import type { RevealPhase } from '@pascal-app/core' import { + FRAMING_PACE, + LAZY_REVEAL_BASE_TIMING, LAZY_REVEAL_TIMING, + pacedLazyTiming, planLazyReveal, planRetract, planReveal, @@ -267,3 +270,120 @@ describe('the lazy, nested schedule (framing)', () => { 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]), + ) + }) +}) diff --git a/packages/viewer/src/lib/reveal-schedule.ts b/packages/viewer/src/lib/reveal-schedule.ts index d3aaddd0f3..41c2f0540b 100644 --- a/packages/viewer/src/lib/reveal-schedule.ts +++ b/packages/viewer/src/lib/reveal-schedule.ts @@ -181,8 +181,18 @@ export type LazyRevealTiming = { minHeightM: Partial> } -/** The one place to tune the lazy schedule and the framing level's motion. */ -export const LAZY_REVEAL_TIMING: LazyRevealTiming = { +/** + * 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: { @@ -201,6 +211,29 @@ export const LAZY_REVEAL_TIMING: LazyRevealTiming = { 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), + } +} + +/** 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, +) + /** 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 From 579f5100c638e187e68c632a5776a2676dfdc636 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 12:33:09 +0200 Subject: [PATCH 05/43] The reveal reads the person's animation speed when it plans a build A speed of x0.5 to x2 divides every time of a schedule: Full's REVEAL_TIMING, Framing's lazy table (at its pace) and the undo's rewind, caps included. x1 returns the shipped tables themselves. ConstructionReveal takes a speed; it is read when a plan is made, the plan's speed is kept until what waits has started, and what is already moving keeps its own durations, so a change applies to the next build and never moves one playing. getRevealPlanSpeed tells a plugin that stages its own pieces which speed the plan in force was made at. Co-Authored-By: Claude Sonnet 5.5 --- packages/viewer/src/index.ts | 8 ++ .../viewer/src/lib/reveal-schedule.test.ts | 64 +++++++++++++++ packages/viewer/src/lib/reveal-schedule.ts | 61 +++++++++++++- .../construction-reveal.test.tsx | 79 ++++++++++++++++++- .../construction-reveal.tsx | 55 +++++++++++-- 5 files changed, 257 insertions(+), 10 deletions(-) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 9261123434..217a969024 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -191,14 +191,21 @@ export { } from './lib/renderer-capability' export { 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' @@ -270,6 +277,7 @@ export { type ConstructionRevealLevel, type ConstructionRevealOptions, emitRevealPuff, + getRevealPlanSpeed, getRevealPlanState, getRevealTurn, isNodeRevealing, diff --git a/packages/viewer/src/lib/reveal-schedule.test.ts b/packages/viewer/src/lib/reveal-schedule.test.ts index a6a32103af..1ff62410da 100644 --- a/packages/viewer/src/lib/reveal-schedule.test.ts +++ b/packages/viewer/src/lib/reveal-schedule.test.ts @@ -1,9 +1,12 @@ 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, @@ -11,6 +14,8 @@ import { RETRACT_TIMING, REVEAL_TIMING, type RevealCandidate, + retractTimingAt, + revealTimingAt, } from './reveal-schedule' let seq = 0 @@ -387,3 +392,62 @@ describe('the full level keeps its own schedule', () => { ) }) }) + +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 41c2f0540b..7ab8bd65ec 100644 --- a/packages/viewer/src/lib/reveal-schedule.ts +++ b/packages/viewer/src/lib/reveal-schedule.ts @@ -234,6 +234,62 @@ export const LAZY_REVEAL_TIMING: LazyRevealTiming = pacedLazyTiming( 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: Record, factor: number): Record { + return Object.fromEntries( + (Object.entries(table) as [K, number][]).map(([key, ms]) => [key, ms * factor]), + ) as Record +} + +/** 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 @@ -400,8 +456,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]) @@ -414,7 +471,7 @@ 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 lastStart = Math.max(0, ...placed.map(({ offset }) => offset)) 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 883c833bce..2616f2b89c 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -25,7 +25,13 @@ import { NodeRenderer } from '../../components/renderers/node-renderer' import { DustPool } from '../../lib/construction-dust' import { OVERLAY_LAYER, scenePassLayers } from '../../lib/layers' import { hasRevealPose } from '../../lib/reveal-pose' -import { LAZY_REVEAL_TIMING, RETRACT_TIMING, REVEAL_TIMING } from '../../lib/reveal-schedule' +import { + LAZY_REVEAL_TIMING, + lazyTimingAt, + RETRACT_TIMING, + REVEAL_TIMING, + revealTimingAt, +} from '../../lib/reveal-schedule' import useViewer from '../../store/use-viewer' import { GeometrySystem } from '../geometry/geometry-system' import { ConstructionDust } from './construction-dust' @@ -38,6 +44,7 @@ import { ConstructionReveal, type ConstructionRevealDriver, type ConstructionRevealLevel, + getRevealPlanSpeed, getRevealPlanState, getRevealTurn, isNodeRevealing, @@ -252,12 +259,13 @@ async function agentWrites(write: () => void) { }) } -function start(options: { prefersReducedMotion?: () => boolean } = {}) { +function start(options: { prefersReducedMotion?: () => boolean; speed?: () => number } = {}) { dust = new DustPool() driver = startConstructionReveal({ reveals: (commit) => commit.author === 'agent', prefersReducedMotion: options.prefersReducedMotion ?? (() => false), level: () => level, + ...(options.speed ? { speed: options.speed } : {}), dust, }) } @@ -2647,3 +2655,70 @@ describe('the framing level: the lazy schedule and its prerolls', () => { 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 c74c27cd83..848c6ed1ed 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -46,13 +46,16 @@ import { import { LAZY_REVEAL_TIMING, type LazyRevealTiming, + lazyTimingAt, + normalizeAnimationSpeed, type PlannedReveal, planLazyReveal, planRetract, planReveal, - REVEAL_TIMING, type RevealCandidate, + retractTimingAt, revealGroupKey, + revealTimingAt, } from '../../lib/reveal-schedule' import useViewer from '../../store/use-viewer' import { resolveLevelVisibility } from '../level/level-utils' @@ -84,6 +87,8 @@ type ActiveReveal = { 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 @@ -287,6 +292,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 = { @@ -533,7 +548,7 @@ 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)) } } @@ -1077,7 +1092,7 @@ function startReveal(candidate: RevealCandidate, now: number, animate: boolean) groupReleased(candidate) // The lazy schedule overdoes it a little: higher falls, an anticipation, an overshoot. const lazy = lazyOn() - const timing = lazy ? LAZY_REVEAL_TIMING : REVEAL_TIMING + const timing = lazy ? lazyTimingAt(planSpeed) : revealTimingAt(planSpeed) active.set(candidate.id, { id: candidate.id, candidate, @@ -1088,6 +1103,7 @@ function startReveal(candidate: RevealCandidate, now: number, animate: boolean) levelId: candidate.levelId, startedAt: now, durationMs: timing.durationMs[candidate.style], + assembleMs: revealTimingAt(planSpeed).durationMs.drop, clockStart: null, pivot: null, base: null, @@ -1606,11 +1622,16 @@ 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 rewind = retractTimingAt(speed) + const forward = revealTimingAt(speed) const plan = [ - ...planRetract(clearing, clockMs), + ...planRetract(clearing, clockMs, rewind, forward), ...planRetract( staged.filter((candidate) => !clearingIds.has(candidate.id)), clockMs + lead, + rewind, + forward, ), ].sort((left, right) => left.startMs - right.startMs) const end = Math.max(...plan.map((entry) => entry.startMs + entry.durationMs)) @@ -1828,8 +1849,12 @@ function stepWarm(now: number) { * 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()) { - return planReveal([...pending.values()], Math.max(now, lastStartMs + REVEAL_TIMING.nodeGapMs)) + const timing = revealTimingAt(planSpeed) + return planReveal([...pending.values()], Math.max(now, lastStartMs + timing.nodeGapMs), timing) } const kept: PlannedReveal[] = [] for (const entry of queue) { @@ -1850,7 +1875,7 @@ function planPending(now: number): PlannedReveal[] { } // 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)].sort( + return [...kept, ...planLazyReveal(waiting, from, lazyTimingAt(planSpeed))].sort( (left, right) => left.startMs - right.startMs || left.seq - right.seq, ) } @@ -2007,6 +2032,11 @@ 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 /** Defaults to the `prefers-reduced-motion` media query. */ prefersReducedMotion?: () => boolean /** Where rising walls and landings puff their dust; none without it. */ @@ -2037,6 +2067,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 = () => { @@ -2148,6 +2180,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 @@ -2165,12 +2199,16 @@ export function startConstructionReveal( export function ConstructionReveal({ reveals, level = 'full', + speed = 1, }: { reveals: (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 levelRef = useRef(level) + const speedRef = useRef(speed) const driverRef = useRef(null) const clockRef = useRef(0) const [pool] = useState(() => new DustPool()) @@ -2184,10 +2222,15 @@ export function ConstructionReveal({ levelRef.current = level }, [level]) + useEffect(() => { + speedRef.current = speed + }, [speed]) + useEffect(() => { const driver = startConstructionReveal({ reveals: (commit) => revealsRef.current(commit), level: () => levelRef.current, + speed: () => speedRef.current, dust: pool, }) driverRef.current = driver From 7b22809d30a56863bea7b8297aa6513fea5dc48d Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 12:33:10 +0200 Subject: [PATCH 06/43] An animation speed setting, and a construction level that follows the project Settings, Preferences: Animation speed (x0.5, x0.75, x1, x1.5, x2), persisted with the level under the same key and normalised to a step. The level that plays is the person's choice, or, with none of their own, Framing where the project has the plugin that draws the framing and Full where it has not; Framing is greyed out with a tip ("Framing needs the Bones plugin.") without it and falls back to Full, and the saved choice comes back with the plugin. The plugin is read through the registered framing kind and the project's installed plugins, so it holds wherever the kind lives. Hosts read the level through useConstructionRevealLevel; a segmented control's option can be disabled. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/controls/segmented-control.tsx | 14 +- .../animation-speed-setting.tsx | 40 ++++++ .../construction-animation-setting.test.tsx | 127 +++++++++++++++++ .../construction-animation-setting.tsx | 43 +++++- .../sidebar/panels/settings-panel/index.tsx | 2 + packages/editor/src/index.tsx | 6 + .../src/store/use-construction-reveal.test.ts | 130 +++++++++++++++++- .../src/store/use-construction-reveal.ts | 93 +++++++++++-- 8 files changed, 436 insertions(+), 19 deletions(-) create mode 100644 packages/editor/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx create mode 100644 packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx diff --git a/packages/editor/src/components/ui/controls/segmented-control.tsx b/packages/editor/src/components/ui/controls/segmented-control.tsx index 75dd6d6aae..6f7bf1d210 100644 --- a/packages/editor/src/components/ui/controls/segmented-control.tsx +++ b/packages/editor/src/components/ui/controls/segmented-control.tsx @@ -5,7 +5,11 @@ import { cn } from '../../../lib/utils' interface SegmentedControlProps { value: T onChange: (value: T) => void - options: { label: React.ReactNode; value: T }[] + /** + * `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 mixed?: boolean @@ -31,18 +35,22 @@ export function SegmentedControl({ const isSelected = !mixed && value === option.value 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 Bones', () => { + const framingOption = constructionLevelOptions(false).find((o) => o.value === 'framing')! + expect(framingOption.disabled).toBe(true) + expect(framingOption.title).toBe(FRAMING_NEEDS_BONES) + expect(FRAMING_NEEDS_BONES).toBe('Framing needs the Bones 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('the animation speed control, drawn', () => { + 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..3634997045 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,8 +1,15 @@ 'use client' import type { ConstructionRevealLevel } from '@pascal-app/viewer' -import useConstructionReveal from '../../../../../store/use-construction-reveal' +import useConstructionReveal, { + useConstructionRevealLevel, + useFramingAvailable, +} from '../../../../../store/use-construction-reveal' import { SegmentedControl } from '../../../controls/segmented-control' +import { Tooltip, TooltipContent, TooltipTrigger } from '../../../primitives/tooltip' + +/** Why Framing is greyed out. */ +export const FRAMING_NEEDS_BONES = 'Framing needs the Bones plugin.' const OPTIONS: { label: string; value: ConstructionRevealLevel; title: string }[] = [ { label: 'Off', value: 'off', title: 'What the AI builds appears at once.' }, @@ -23,11 +30,41 @@ const OPTIONS: { label: string; value: ConstructionRevealLevel; title: string }[ }, ] +/** + * 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_BONES, disabled: true } + : { ...option, disabled: false }, + ) +} + /** How much of an AI build plays as a construction. */ export function ConstructionAnimationSetting() { - const level = useConstructionReveal((state) => state.level) + // What plays: the person's choice, or Framing where Bones is installed and Full where it is not. + const level = useConstructionRevealLevel() const setLevel = useConstructionReveal((state) => state.setLevel) + const framingAvailable = useFramingAvailable() const current = OPTIONS.find((option) => option.value === level) + const options = constructionLevelOptions(framingAvailable).map((option) => + option.disabled + ? { + ...option, + title: undefined, + label: ( + + + {option.label} + + {option.title} + + ), + } + : option, + ) return (
@@ -37,7 +74,7 @@ export function ConstructionAnimationSetting() {
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 f0a20465dd..00a6c132bb 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 @@ -77,6 +77,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' @@ -1261,6 +1262,7 @@ export function SettingsPanel({ title="Preferences" > + {preferences}
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index f042f09ffe..9ef733df82 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -894,6 +894,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/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..17b078cc33 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,90 @@ 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 +} + +/** 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 From 9ab650f4c430c94f0b3aa2bf972d2ac394c23146 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Wed, 14 Oct 2026 17:48:28 +0200 Subject: [PATCH 07/43] fix(viewer): preserve wall finishes and metre-scale textures during reveal --- .../src/wall/curtain-wall-materials.test.ts | 49 +- .../nodes/src/wall/curtain-wall-materials.ts | 2 + .../src/wall/wall-batch-canonical.test.ts | 492 +++++++++++++++++- packages/nodes/src/wall/wall-batch-system.tsx | 17 +- .../src/lib/reveal-texture-scale.test.ts | 39 ++ .../viewer/src/lib/reveal-texture-scale.ts | 117 +++++ .../construction-reveal.tsx | 12 +- .../systems/wall/wall-cutout-cache.test.ts | 39 ++ .../systems/wall/wall-finish-geometry.test.ts | 103 ++++ .../viewer/src/systems/wall/wall-materials.ts | 7 +- 10 files changed, 865 insertions(+), 12 deletions(-) create mode 100644 packages/viewer/src/lib/reveal-texture-scale.test.ts create mode 100644 packages/viewer/src/lib/reveal-texture-scale.ts 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/wall-batch-canonical.test.ts b/packages/nodes/src/wall/wall-batch-canonical.test.ts index d41ca4a194..06c50b9acb 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/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/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 848c6ed1ed..27235739ed 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -57,6 +57,7 @@ import { revealGroupKey, revealTimingAt, } from '../../lib/reveal-schedule' +import { clearRiseTextureScale, setRiseTextureScale } from '../../lib/reveal-texture-scale' import useViewer from '../../store/use-viewer' import { resolveLevelVisibility } from '../level/level-utils' import { ConstructionDust } from './construction-dust' @@ -1046,7 +1047,10 @@ function levelShown(levelId: string | null): boolean { } 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) settle(reveal.id) closeNode(reveal.id, clockMs, forced) @@ -1169,7 +1173,10 @@ function stepReveal(reveal: ActiveReveal, now: number) { 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. @@ -1198,6 +1205,7 @@ function stepReveal(reveal: ActiveReveal, now: number) { 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.landed && revealHasLanded(reveal.style, t)) { reveal.landed = true 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 806b1c52b6..90d359ee4c 100644 --- a/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts +++ b/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts @@ -604,6 +604,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-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, }) } From d660986a0facab4ae2cde197539c4e3f041f05e0 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 13:11:29 +0200 Subject: [PATCH 08/43] Framing carries the Pro badge in the construction animation setting The plugin that draws the framing is a Pro one, so its level wears the mark the plugin manager shows next to a plugin that needs a plan. The manager's badge becomes a shared primitive, used by both. --- .../components/editor/views/plugins-view.tsx | 9 +--- .../src/components/ui/primitives/badge.tsx | 8 ++++ .../construction-animation-setting.test.tsx | 13 ++++++ .../construction-animation-setting.tsx | 44 ++++++++++++------- 4 files changed, 49 insertions(+), 25 deletions(-) create mode 100644 packages/editor/src/components/ui/primitives/badge.tsx 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/primitives/badge.tsx b/packages/editor/src/components/ui/primitives/badge.tsx new file mode 100644 index 0000000000..a91d0b9ff3 --- /dev/null +++ b/packages/editor/src/components/ui/primitives/badge.tsx @@ -0,0 +1,8 @@ +/** A small mark beside a name: the plan a plugin needs ("Pro"), as the plugin manager shows it. */ +export function Badge({ label }: { label: string }) { + return ( + + {label} + + ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx index 9156eaf977..18fa93203a 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx @@ -116,6 +116,19 @@ describe('the construction animation control, drawn', () => { }) }) +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 }) 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 3634997045..9249b19756 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 @@ -6,12 +6,13 @@ import useConstructionReveal, { useFramingAvailable, } from '../../../../../store/use-construction-reveal' import { SegmentedControl } from '../../../controls/segmented-control' +import { Badge } from '../../../primitives/badge' import { Tooltip, TooltipContent, TooltipTrigger } from '../../../primitives/tooltip' /** Why Framing is greyed out. */ export const FRAMING_NEEDS_BONES = 'Framing needs the Bones plugin.' -const OPTIONS: { label: string; value: ConstructionRevealLevel; title: string }[] = [ +const OPTIONS: { label: string; value: ConstructionRevealLevel; title: string; badge?: string }[] = [ { label: 'Off', value: 'off', title: 'What the AI builds appears at once.' }, { label: 'Simple', @@ -27,6 +28,8 @@ 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', }, ] @@ -49,22 +52,29 @@ export function ConstructionAnimationSetting() { const setLevel = useConstructionReveal((state) => state.setLevel) const framingAvailable = useFramingAvailable() const current = OPTIONS.find((option) => option.value === level) - const options = constructionLevelOptions(framingAvailable).map((option) => - option.disabled - ? { - ...option, - title: undefined, - label: ( - - - {option.label} - - {option.title} - - ), - } - : option, - ) + 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 (
From 7549343ec548823b58b8e3d9d32fd65d08c0cecf Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 13:38:24 +0200 Subject: [PATCH 09/43] Framing's tooltip names the plugin a person installs: Architect Where the framing plugin is missing the Framing option is greyed out, and its tip no longer names Bones: on the hosted product the plugin a person installs is Architect, which brings Procedural and with it the framing kinds. --- .../construction-animation-setting.test.tsx | 10 +++++----- .../settings-panel/construction-animation-setting.tsx | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx index 18fa93203a..8c74101d27 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx @@ -16,10 +16,10 @@ import { AnimationSpeedSetting } from './animation-speed-setting' import { ConstructionAnimationSetting, constructionLevelOptions, - FRAMING_NEEDS_BONES, + FRAMING_NEEDS_ARCHITECT, } from './construction-animation-setting' -// Bones carries the Framing level: without it in the project the choice is greyed out with the +// The framing plugin carries the Framing level: without it in the project the choice is greyed out with the // reason as its tip, and the level that plays is Full. const framing = { @@ -70,11 +70,11 @@ const optionOf = (markup: string, label: string) => { } describe('the levels the control offers', () => { - test('without the plugin, Framing is disabled and its tip says it needs Bones', () => { + 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_BONES) - expect(FRAMING_NEEDS_BONES).toBe('Framing needs the Bones plugin.') + 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) }) 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 9249b19756..b75c6e55e2 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 @@ -10,7 +10,7 @@ import { Badge } from '../../../primitives/badge' import { Tooltip, TooltipContent, TooltipTrigger } from '../../../primitives/tooltip' /** Why Framing is greyed out. */ -export const FRAMING_NEEDS_BONES = 'Framing needs the Bones plugin.' +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.' }, @@ -40,14 +40,14 @@ const OPTIONS: { label: string; value: ConstructionRevealLevel; title: string; b export function constructionLevelOptions(framingAvailable: boolean) { return OPTIONS.map((option) => option.value === 'framing' && !framingAvailable - ? { ...option, title: FRAMING_NEEDS_BONES, disabled: true } + ? { ...option, title: FRAMING_NEEDS_ARCHITECT, disabled: true } : { ...option, disabled: false }, ) } /** How much of an AI build plays as a construction. */ export function ConstructionAnimationSetting() { - // What plays: the person's choice, or Framing where Bones is installed and Full where it is not. + // What plays: the person's choice, or Framing where the framing plugin is installed and Full where it is not. const level = useConstructionRevealLevel() const setLevel = useConstructionReveal((state) => state.setLevel) const framingAvailable = useFramingAvailable() From 38f3fef836b160a379de85961b921a598126d76f Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 13:24:49 +0200 Subject: [PATCH 10/43] viewer: the level a reveal plays on shows while it plays, whatever the level display hides; an undo plays back the kinds that came in with a default reveal A build above the selected level, in the solo view of another, or an undo of such a build, used to start at once and unseen. The reveal now publishes the levels it has work on (levelsRevealing); the level system shows them, the node batch re-offers its sources when they change, and Follow's line of sight counts them. Only the author's own eye on a level still keeps its build instant. --- .../components/editor/follow-pascal.test.tsx | 31 ++- .../editor/src/lib/follow-pascal-sight.ts | 7 +- packages/editor/src/lib/follow-pascal.test.ts | 5 +- .../src/shared/node-batch/node-batch.test.ts | 2 + .../nodes/src/shared/node-batch/system.tsx | 6 +- .../src/components/viewer/glb-scene.tsx | 1 + packages/viewer/src/lib/reveal-candidates.ts | 14 +- packages/viewer/src/store/use-viewer.ts | 7 + .../construction-reveal.test.tsx | 220 ++++++++++++++---- .../construction-reveal.tsx | 82 ++++--- .../src/systems/level/level-system.test.ts | 37 +++ .../viewer/src/systems/level/level-system.tsx | 2 + .../src/systems/level/level-utils.test.ts | 23 ++ .../viewer/src/systems/level/level-utils.ts | 7 + 14 files changed, 361 insertions(+), 83 deletions(-) diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx index c732433e18..5e9ea3e180 100644 --- a/packages/editor/src/components/editor/follow-pascal.test.tsx +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -9,11 +9,12 @@ import { loadPlugin, nodeRegistry, runAsSceneCommitAuthor, + sceneRegistry, useScene, } from '@pascal-app/core' import { 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 useEditor from '../../store/use-editor' import useFollowPascal from '../../store/use-follow-pascal' import { usePointAsk } from '../../store/use-point-ask' @@ -68,6 +69,7 @@ afterEach(async () => { await renderer?.unmount() renderer = null restoreRegistry() + sceneRegistry.nodes.delete('item_follow') useScene.setState(previousScene) useViewer.setState(previousViewer) useEditor.setState(previousEditor) @@ -90,6 +92,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 +148,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 } @@ -172,6 +182,23 @@ 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') +}) + test('an obstacle appearing after arrival causes one calm flight to a clear view', async () => { await followItem() await renderer!.advanceFrames(120, 0.05) 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.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/nodes/src/shared/node-batch/node-batch.test.ts b/packages/nodes/src/shared/node-batch/node-batch.test.ts index ae52453244..11e71d0a59 100644 --- a/packages/nodes/src/shared/node-batch/node-batch.test.ts +++ b/packages/nodes/src/shared/node-batch/node-batch.test.ts @@ -299,6 +299,7 @@ test('overlapping preview holds end only after the final restore; failed preview test.each([ 'mode', 'selected-level', + 'revealing', ])('shadow-only candidates are re-offered on %s restoration', (change) => { const { root, meshes } = setup() useViewer.setState({ @@ -310,6 +311,7 @@ test.each([ 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/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/lib/reveal-candidates.ts b/packages/viewer/src/lib/reveal-candidates.ts index 0e76aca46b..e44fc1f6d0 100644 --- a/packages/viewer/src/lib/reveal-candidates.ts +++ b/packages/viewer/src/lib/reveal-candidates.ts @@ -452,21 +452,27 @@ 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 @@ -474,6 +480,6 @@ export function nodeRevealCandidates( if (typeof childId === 'string') visit(childId, guard + 1) } } - for (const id of ids) visit(id) + for (const id of asked) visit(id) return candidates } diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index fdb4af131e..ad4a9eabe4 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -119,6 +119,12 @@ 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[] wallMode: WallMode setWallMode: (mode: WallMode) => void @@ -448,6 +454,7 @@ const useViewer = create()( levelMode: 'stacked', setLevelMode: (mode) => set({ levelMode: mode }), hideLevelsAboveSelection: false, + levelsRevealing: [], 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 2616f2b89c..e977306333 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -137,6 +137,12 @@ const Plate = revealKind( { phase: 'foundation', style: 'settle', height: 0.08 }, () => new BoxGeometry(4, 0.2, 4).translate(2, -0.1, 2), ) +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), ) @@ -301,6 +307,7 @@ beforeEach(async () => { Item.definition, Post.definition, Plate.definition, + Flight.definition, Pane.definition, Roof.definition, Lid.definition, @@ -702,30 +709,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', () => { @@ -1087,37 +1070,182 @@ describe('the plan view reads the same reveal', () => { }) }) -describe('failure mode 10: a level nobody sees', () => { - test('hidden after planning, it stops holding the visible level back', async () => { +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), make(Wall)] - const above = Array.from({ length: 24 }, () => make(Wall)) - const item = make(Item) + 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([ - ...below.map((node) => ({ node, parentId: levelIds[0]! })), - ...above.map((node) => ({ node, parentId: levelIds[1]! })), - { node: item, parentId: levelIds[0]! }, + ...walls.map((node) => ({ node, parentId: levelIds[0]! })), + ...upperWalls.map((node) => ({ node, parentId: levelIds[1]! })), + { node: upperRoof, parentId: levelIds[1]! }, ]), ) - await frames(60) + 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({ - hideLevelsAboveSelection: true, selection: { ...useViewer.getState().selection, levelId: levelIds[0]! }, } as never), ) await frame() - for (const wall of above) expect(isNodeRevealing(wall.id)).toBe(false) + 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]!]) + }) - // 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('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) }) }) @@ -1980,6 +2108,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[] = [] diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 27235739ed..1fde1c5150 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -59,7 +59,6 @@ import { } from '../../lib/reveal-schedule' import { clearRiseTextureScale, setRiseTextureScale } from '../../lib/reveal-texture-scale' import useViewer from '../../store/use-viewer' -import { resolveLevelVisibility } from '../level/level-utils' import { ConstructionDust } from './construction-dust' /** @@ -73,8 +72,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' @@ -1026,24 +1026,38 @@ function revealCandidates(commit: SceneCommit, at: ConstructionRevealLevel): Sta ) } -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 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) + return [...levels] +} + +/** + * 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() { + const next = levelsAtWork() + const current = useViewer.getState().levelsRevealing + if (next.length === current.length && next.every((levelId) => current.includes(levelId))) return + useViewer.setState({ levelsRevealing: next }) } function endReveal(reveal: ActiveReveal, forced = false) { @@ -1166,7 +1180,8 @@ 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) @@ -1594,7 +1609,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)) @@ -1687,6 +1702,7 @@ export function retractNodes(ids: Iterable): Promise { // 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) @@ -1770,7 +1786,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) @@ -1896,7 +1912,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 @@ -1912,7 +1928,7 @@ function tickReveals(now: number, frameMs?: number) { while (due < queue.length && (queue[due]?.startMs ?? Number.POSITIVE_INFINITY) <= now) { const entry = queue[due++]! planned.delete(entry.id) - if (pending.has(entry.id)) startReveal(entry, now, levelShown(entry.levelId)) + 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) @@ -1937,6 +1953,7 @@ function tickReveals(now: number, frameMs?: number) { } const busy = building || retracts.size > 0 || held.size > 0 || glides.size > 0 || changeQueue.length > 0 + publishLevelsAtWork() if (busy || tickedBusy) notifyTicks() tickedBusy = busy } @@ -1976,6 +1993,7 @@ function finishReveals(keepHeld = false) { finishRetracts(keepHeld) finishChanges() completeBuild(clockMs, true) + publishLevelsAtWork() if (busy) notifyTicks() } @@ -2099,9 +2117,10 @@ export function startConstructionReveal( const changes = commitChangeCandidates(commit, revealLevel()).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()) { @@ -2122,6 +2141,7 @@ export function startConstructionReveal( notifyPending(candidate.id) } for (const hostId of assembling) notifyAssembly(hostId, true) + publishLevelsAtWork() unplanned = true }), useScene.subscribe((state, previous) => { @@ -2153,14 +2173,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 = () => { diff --git a/packages/viewer/src/systems/level/level-system.test.ts b/packages/viewer/src/systems/level/level-system.test.ts index 68f801ecfc..85fc7d20a0 100644 --- a/packages/viewer/src/systems/level/level-system.test.ts +++ b/packages/viewer/src/systems/level/level-system.test.ts @@ -85,6 +85,7 @@ afterEach(() => { useViewer.setState({ levelMode: previousViewerState.levelMode, hideLevelsAboveSelection: false, + levelsRevealing: [], selection: previousViewerState.selection, }) }) @@ -172,6 +173,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]) diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index c6d06fb067..b0d70e6ec3 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -12,6 +12,7 @@ export const LevelSystem = () => { 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 = { @@ -57,6 +58,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) || From 7d5ad1d7efafcbe363f37918a1e78a3fbe17822b Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 13:26:01 +0200 Subject: [PATCH 11/43] nodes: a guard that every built-in kind an agent can create is staged by the reveal, on a level and on a roof, at the full and framing levels --- packages/nodes/src/reveal-coverage.test.ts | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/nodes/src/reveal-coverage.test.ts diff --git a/packages/nodes/src/reveal-coverage.test.ts b/packages/nodes/src/reveal-coverage.test.ts new file mode 100644 index 0000000000..0c60555bc4 --- /dev/null +++ b/packages/nodes/src/reveal-coverage.test.ts @@ -0,0 +1,71 @@ +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', + 'facade', + '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) + }) +}) From 536d5e76d27568c0c3a8c64cebf2ebc97e6ff6ce Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 13:27:31 +0200 Subject: [PATCH 12/43] editor: Follow Pascal pulls back to the finished house with the floors above shown, and the person's display comes back when it ends --- .../components/editor/follow-pascal.test.tsx | 24 ++++++++++++++++--- .../src/components/editor/follow-pascal.tsx | 4 ++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx index 5e9ea3e180..5b8d453bf5 100644 --- a/packages/editor/src/components/editor/follow-pascal.test.tsx +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -173,7 +173,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({ @@ -199,6 +200,19 @@ test('with Follow off, a build on the floor above plays on show and the person k 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) @@ -273,12 +287,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) diff --git a/packages/editor/src/components/editor/follow-pascal.tsx b/packages/editor/src/components/editor/follow-pascal.tsx index 84d3100866..29a2de13ea 100644 --- a/packages/editor/src/components/editor/follow-pascal.tsx +++ b/packages/editor/src/components/editor/follow-pascal.tsx @@ -187,6 +187,10 @@ 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) } if (!spring.current) { spring.current = new OrbitSpring({ x: 0, y: 1, z: 0, r: 20, az: 0.6, el: 0.5 }) From 10d60b23c8edea1c8fc752ee51b60f8a26f3628f Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 13:27:42 +0200 Subject: [PATCH 13/43] wiki: the level display never hides a build; the reveal publishes the levels it works on --- wiki/architecture/construction-animation.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index 3c49abdcdb..dcca7e024b 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,14 +51,18 @@ 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. +## 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. + ## 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. From 611c431c483d6f4bb9f7f1113a0b31400d2bb9cb Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 14:08:54 +0200 Subject: [PATCH 14/43] core: a commit names what the structure reconciler wrote (derivedWrites), and a kind lists the fields that make its picture (revealOnEdit) --- .../core/src/lib/derived-commit-nodes.test.ts | 76 +++++++++++++++++++ packages/core/src/registry/types.ts | 7 ++ .../core/src/store/actions/node-actions.ts | 17 +++++ packages/core/src/store/history-control.ts | 28 ++++++- 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/lib/derived-commit-nodes.test.ts 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..412cd33dd5 --- /dev/null +++ b/packages/core/src/lib/derived-commit-nodes.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '../schema' +import { 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) + } +}) diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 5b43f29d03..e569e9cfdf 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1725,6 +1725,13 @@ 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[] /** * Plan footprint this kind exposes to the alignment-anchor pool when it * isn't `floorPlaced` and isn't a structural primitive the bridge handles diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 6692510d76..80cddccb95 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -54,6 +54,7 @@ import { import { activeSceneCommitNodeIds, addActiveSceneCommitNodeIds, + noteDerivedNodeWrites, runWithSceneCommitNodeIds, } from '../history-control' import type { SceneState } from '../use-scene' @@ -1165,6 +1166,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) => { @@ -1307,6 +1310,14 @@ 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 })), + ...(changes.update ?? []).map((update) => ({ + id: update.id, + fields: Object.keys(update.data), + })), + ]) const createOps = changes.create ?? [] const deletionIntent = @@ -1481,6 +1492,10 @@ const updateNodesActionImpl = ( deferDirty = true, ) => { if (get().readOnly) return + if (options?.derivedWriter === DERIVED_WRITER_TOKEN) + noteDerivedNodeWrites( + updates.map((update) => ({ id: update.id, fields: Object.keys(update.data) })), + ) const converted = convertDerivedPlateHoleWrites(get().nodes, { update: updates }, options) if (converted.create?.length || converted.delete?.length) { applyNodeChangesActionImpl(set, get, converted, options) @@ -1948,6 +1963,8 @@ const deleteNodesActionImpl = ( wallDeletionPlan?: StructurePlan, ) => { if (get().readOnly) return + if (options?.derivedWriter === DERIVED_WRITER_TOKEN) + noteDerivedNodeWrites(ids.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..348e83f6fd 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) From facb43604b892bd63c83e83a2ba157ff25921679 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 15:53:04 +0200 Subject: [PATCH 15/43] viewer: an edit that changes a node's picture replays its reveal quickly, and a deletion plays the creation backwards from a snapshot Edits: nodes list revealOnEdit fields (wall, fence, slab, ceiling, column, stair, openings, cabinet, shelf, item scale, roof segment); a change to one of them from an edit, not from reconciliation, re-rises (or re-scales, re-settles) the mounted node at the animation speed, in the changes' groups. Deletions: the node is out of the store at once; a clone of its root, taken as the commit lands, plays the reverse reveal staggered like a build in reverse, and is removed. The host chooses which commits play deletions. Follow looks at an edited piece. Co-Authored-By: Claude Sonnet 5.5 --- .../src/lib/follow-pascal-targets.test.ts | 8 + .../editor/src/lib/follow-pascal-targets.ts | 5 + packages/nodes/src/cabinet/definition.ts | 1 + packages/nodes/src/ceiling/definition.ts | 1 + packages/nodes/src/column/definition.ts | 1 + packages/nodes/src/door/definition.ts | 1 + packages/nodes/src/fence/definition.ts | 12 + packages/nodes/src/item/definition.ts | 1 + .../nodes/src/procedural-item/definition.ts | 1 + packages/nodes/src/reveal-coverage.test.ts | 20 ++ packages/nodes/src/roof-segment/definition.ts | 11 + packages/nodes/src/shelf/definition.ts | 10 + packages/nodes/src/slab/definition.ts | 8 + packages/nodes/src/stair/definition.ts | 11 + packages/nodes/src/wall/definition.ts | 1 + packages/nodes/src/window/definition.ts | 1 + packages/viewer/src/lib/reveal-candidates.ts | 90 ++++- .../construction-reveal.test.tsx | 324 +++++++++++++++++- .../construction-reveal.tsx | 210 +++++++++++- 19 files changed, 705 insertions(+), 12 deletions(-) diff --git a/packages/editor/src/lib/follow-pascal-targets.test.ts b/packages/editor/src/lib/follow-pascal-targets.test.ts index 14a8e30605..5826016240 100644 --- a/packages/editor/src/lib/follow-pascal-targets.test.ts +++ b/packages/editor/src/lib/follow-pascal-targets.test.ts @@ -149,6 +149,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() }) diff --git a/packages/editor/src/lib/follow-pascal-targets.ts b/packages/editor/src/lib/follow-pascal-targets.ts index 9fcc13e2fc..a45dac75bc 100644 --- a/packages/editor/src/lib/follow-pascal-targets.ts +++ b/packages/editor/src/lib/follow-pascal-targets.ts @@ -137,6 +137,11 @@ 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. + if (event.changed) { + const box = boxOf(nodes, event.id) + return box ? { kind: 'detail', box, at } : null + } return null } case 'settle': { diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 8e2ff3f9fc..33839c350d 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -2340,6 +2340,7 @@ export const cabinetDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 }, + revealOnEdit: ['width', 'depth', 'carcassHeight', 'plinthHeight', 'curveRadius'], 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/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/door/definition.ts b/packages/nodes/src/door/definition.ts index e6a8d74107..4db8cd1837 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -237,6 +237,7 @@ export const doorDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'openings', style: 'cut' }, + revealOnEdit: ['width', 'height', 'openingShape', 'openingKind', 'doorType'], batchable: doorBatchable, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index 10f3646bd7..a9a2b23d82 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -385,6 +385,18 @@ export const fenceDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'structure', style: 'rise' }, + revealOnEdit: [ + 'start', + 'end', + 'path', + 'curveOffset', + 'height', + 'thickness', + 'baseHeight', + 'postSpacing', + 'picketSpacing', + 'style', + ], surfacePlacement: 'floor-only', selectable: { hitVolume: 'bbox' }, surfaces: { sides: { faces: 'all' }, hosting: false }, 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/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 index 0c60555bc4..96af75fb4a 100644 --- a/packages/nodes/src/reveal-coverage.test.ts +++ b/packages/nodes/src/reveal-coverage.test.ts @@ -69,3 +69,23 @@ describe('every built-in kind an agent can create', () => { 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.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/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 3f045c3a6c..f6c966db09 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -334,6 +334,17 @@ export const roofSegmentDefinition: NodeDefinition = { }, capabilities: { + revealOnEdit: [ + 'roofType', + 'width', + 'depth', + 'wallHeight', + 'pitch', + 'overhang', + 'wallThickness', + 'deckThickness', + 'sweptProfile', + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 9b82cf45f3..c87880856e 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -166,6 +166,16 @@ export const shelfDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 }, + revealOnEdit: [ + 'width', + 'depth', + 'height', + 'thickness', + 'rows', + 'columns', + 'curveRadius', + 'style', + ], movable: { axes: ['x', 'z'], gridSnap: true }, rotatable: { axes: ['y'], 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/stair/definition.ts b/packages/nodes/src/stair/definition.ts index c0adaaf555..243e5f5842 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', + ], surfacePlacement: 'floor-only', selectable: { hitVolume: 'bbox' }, // A stair has no centred box footprint: straight = a cumulative diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index eb1f5c9815..3348e4ddda 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -138,6 +138,7 @@ export const wallDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'structure', style: 'rise' }, + 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/window/definition.ts b/packages/nodes/src/window/definition.ts index 2c3353c99f..1557b28059 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -296,6 +296,7 @@ export const windowDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'openings', style: 'cut' }, + revealOnEdit: ['width', 'height', 'openingShape', 'windowType'], batchable: windowBatchable, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/viewer/src/lib/reveal-candidates.ts b/packages/viewer/src/lib/reveal-candidates.ts index e44fc1f6d0..c5fdad94df 100644 --- a/packages/viewer/src/lib/reveal-candidates.ts +++ b/packages/viewer/src/lib/reveal-candidates.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + isDerivedNode, type LevelNode, nodeRegistry, type RevealConfig, @@ -383,8 +384,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 @@ -483,3 +488,84 @@ export function nodeRevealCandidates( 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', +} + +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 + for (const id in before.nodes) { + if (current.nodes[id as AnyNodeId] || derivedWrites?.get(id as AnyNodeId) === 'all') continue + const node = before.nodes[id as AnyNodeId] + if (!node || isDerivedNode(node)) 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/systems/construction-reveal/construction-reveal.test.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx index e977306333..4c77a25c1e 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -16,6 +16,7 @@ import { objectId, type RevealConfig, runAsSceneCommitAuthor, + type SceneCommit, sceneRegistry, useScene, } from '@pascal-app/core' @@ -24,6 +25,7 @@ import { BoxGeometry, Group, Matrix4, Mesh, type Object3D } from 'three' 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 { LAZY_REVEAL_TIMING, @@ -82,6 +84,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), @@ -95,7 +98,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() @@ -137,6 +143,24 @@ 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', @@ -265,10 +289,17 @@ async function agentWrites(write: () => void) { }) } -function start(options: { prefersReducedMotion?: () => boolean; speed?: () => number } = {}) { +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 } : {}), @@ -308,6 +339,8 @@ beforeEach(async () => { Post.definition, Plate.definition, Flight.definition, + Beam.definition, + Floorplate.definition, Pane.definition, Roof.definition, Lid.definition, @@ -1249,6 +1282,293 @@ describe('the level the work is on shows while it plays', () => { }) }) +// 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('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) + }) + } + + 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([]) + }) +}) + // Victor run 12: the dust showed through walls. It drew on the overlay layer, which composites on // top of the scene with no depth test. describe('the dust', () => { diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 1fde1c5150..b9083445d1 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -18,10 +18,13 @@ import { useFrame } from '@react-three/fiber' import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { type Box3, type Object3D, Vector3 } from 'three' import { DustPool, emitAlongBase, emitAroundFoot } from '../../lib/construction-dust' +import { SCENE_LAYER } from '../../lib/layers' import { type ChangeCandidate, type ConstructionRevealLevel, commitChangeCandidates, + commitDeleteCandidates, + commitEditCandidates, commitRevealCandidates, nodeRevealCandidates, PART_LEAD, @@ -103,6 +106,8 @@ type ActiveReveal = { /** 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 } export type RevealPhaseEvent = { phase: RevealPhase; levelId: string | null } @@ -808,7 +813,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, @@ -865,7 +870,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. */ @@ -919,6 +928,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: 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 @@ -931,6 +975,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) @@ -1044,6 +1094,7 @@ function levelsAtWork(): string[] { 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) return [...levels] } @@ -1066,6 +1117,7 @@ function endReveal(reveal: ActiveReveal, forced = false) { clearRiseTextureScale(reveal.root) } active.delete(reveal.id) + if (reveal.edit) return endChange(reveal.id, clockMs, forced) settle(reveal.id) closeNode(reveal.id, clockMs, forced) } @@ -1213,7 +1265,7 @@ 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.edit) groupStarted(reveal.candidate, now) if (reveal.style === 'rise') puffAlongBase(reveal, root, now) } const t = (now - reveal.clockStart) / reveal.durationMs @@ -1224,7 +1276,7 @@ function stepReveal(reveal: ActiveReveal, now: number) { } 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) @@ -1506,6 +1558,120 @@ 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 + style: RevealStyle + height: number + startMs: number + durationMs: number + group: RetractGroup + pivot: Vector3 | null +} + +const ghosts = new Map() + +/** 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) + }) +} + +/** + * 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 speed = currentSpeed() + const plan = planRetract(staged, clockMs, retractTimingAt(speed), revealTimingAt(speed)) + const groupsByKey = new Map() + for (const entry of plan) { + const original = sceneRegistry.nodes.get(entry.id) + const parent = original?.parent + if (!(original && parent)) continue + const root = original.clone(true) + drawnInScenePass(root) + parent.add(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 + ghosts.set(entry.id, { + id: entry.id, + levelId: entry.levelId, + root, + parent, + style: entry.style, + height: entry.height, + startMs: entry.startMs, + durationMs: entry.durationMs, + group, + pivot: null, + }) + } +} + +/** 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 (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) +} + function stepRetracts(now: number) { const nodes = useScene.getState().nodes for (const retract of [...retracts.values()]) { @@ -1934,6 +2100,7 @@ function tickReveals(now: number, frameMs?: number) { for (const reveal of active.values()) stepReveal(reveal, now) stepLifts(now) stepRetracts(now) + stepGhosts(now) stepGlides(now) const building = pending.size > 0 || active.size > 0 || lifts.size > 0 || liftWanted.size > 0 if (!building) { @@ -1952,7 +2119,12 @@ 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 || + changeQueue.length > 0 publishLevelsAtWork() if (busy || tickedBusy) notifyTicks() tickedBusy = busy @@ -1964,7 +2136,7 @@ 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() @@ -1991,6 +2163,7 @@ function finishReveals(keepHeld = false) { dust?.clear() seatAllLifts() finishRetracts(keepHeld) + finishGhosts() finishChanges() completeBuild(clockMs, true) publishLevelsAtWork() @@ -2063,6 +2236,11 @@ export type ConstructionRevealOptions = { * 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. */ @@ -2107,14 +2285,21 @@ 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)) && !levelHiddenByAuthor(change.levelId), @@ -2218,15 +2403,19 @@ 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) @@ -2238,6 +2427,10 @@ export function ConstructionReveal({ revealsRef.current = reveals }, [reveals]) + useEffect(() => { + deletesRef.current = deletes + }, [deletes]) + useEffect(() => { levelRef.current = level }, [level]) @@ -2249,6 +2442,7 @@ export function ConstructionReveal({ useEffect(() => { const driver = startConstructionReveal({ reveals: (commit) => revealsRef.current(commit), + deletes: (commit) => (deletesRef.current ?? revealsRef.current)(commit), level: () => levelRef.current, speed: () => speedRef.current, dust: pool, From 2a2d3890a8c1030309c7286187ac1814927b1249 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 15:53:14 +0200 Subject: [PATCH 16/43] viewer: many edits begin spread over one second, not piled on its last frame The edits' stagger is the cap divided over the pieces. The wiki says how an edit and a deletion play. Co-Authored-By: Claude Sonnet 5.5 --- .../construction-reveal.test.tsx | 31 +++++++++++++++++++ .../construction-reveal.tsx | 6 ++-- wiki/architecture/construction-animation.md | 6 ++++ 3 files changed, 41 insertions(+), 2 deletions(-) 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 4c77a25c1e..ed5829b16b 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -1406,6 +1406,37 @@ describe('an edit that changes a picture plays again', () => { 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() diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index b9083445d1..fb80394bc9 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -904,6 +904,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) @@ -911,11 +913,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 diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index dcca7e024b..515b221627 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -54,6 +54,12 @@ It stays up 10 s after the last piece of the phase (a model's turn between write - **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. +## 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. + ## 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. From cdd76775990e2d0f65c6adeb1bc610aa7d1b50cb Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 15:59:45 +0200 Subject: [PATCH 17/43] nodes: the edit field lists and the reveal guard follow the kinds and schemas main has Main's shelf and cabinet have no curveRadius, its roof segment no sweptProfile, and it has no facade kind. Co-Authored-By: Claude Sonnet 5.5 --- packages/nodes/src/cabinet/definition.ts | 2 +- packages/nodes/src/reveal-coverage.test.ts | 1 - packages/nodes/src/roof-segment/definition.ts | 1 - packages/nodes/src/shelf/definition.ts | 11 +---------- 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 33839c350d..8f16ecc2fa 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -2340,7 +2340,7 @@ export const cabinetDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 }, - revealOnEdit: ['width', 'depth', 'carcassHeight', 'plinthHeight', 'curveRadius'], + revealOnEdit: ['width', 'depth', 'carcassHeight', 'plinthHeight'], surfacePlacement: 'floor-only', selectable: { hitVolume: 'bbox' }, movable: { diff --git a/packages/nodes/src/reveal-coverage.test.ts b/packages/nodes/src/reveal-coverage.test.ts index 96af75fb4a..abf388888a 100644 --- a/packages/nodes/src/reveal-coverage.test.ts +++ b/packages/nodes/src/reveal-coverage.test.ts @@ -10,7 +10,6 @@ const DRAWS_NOTHING_OF_ITS_OWN = new Set([ 'site', 'building', 'level', - 'facade', 'unit', 'floor-opening', 'structural-grid', diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index f6c966db09..3b65c0f032 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -343,7 +343,6 @@ export const roofSegmentDefinition: NodeDefinition = { 'overhang', 'wallThickness', 'deckThickness', - 'sweptProfile', ], selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index c87880856e..592b1af92d 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -166,16 +166,7 @@ export const shelfDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 }, - revealOnEdit: [ - 'width', - 'depth', - 'height', - 'thickness', - 'rows', - 'columns', - 'curveRadius', - 'style', - ], + revealOnEdit: ['width', 'depth', 'height', 'thickness', 'rows', 'columns', 'style'], movable: { axes: ['x', 'z'], gridSnap: true }, rotatable: { axes: ['y'], From 702e02f2b480aa900ca2d928321e7b4aaf7929c7 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 16:23:07 +0200 Subject: [PATCH 18/43] viewer: at the Framing level a roof keeps its frame and its covering is laid over it in place, from eave to ridge An assembled node (the roof) played the same drop of its whole shell at the Framing level as at Full, after Bones' frame had been shown and taken away. Framing now plays it as a covering: the node holds still and its meshes wear a world-height mask that opens row by row from the lowest edge (the eave) to the highest (the ridge); a flat roof sweeps along its longer plan side. Its parts are not staged on their own. Undo and a deletion lift the covering off from the ridge in reverse, then the bare node goes. The speed setting scales the sweep. Full, Simple and Off are as they were: the Full tables are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../viewer/src/lib/reveal-add-volume.test.ts | 10 +- packages/viewer/src/lib/reveal-candidates.ts | 8 +- packages/viewer/src/lib/reveal-pose.ts | 23 ++- packages/viewer/src/lib/reveal-schedule.ts | 38 ++-- packages/viewer/src/lib/reveal-windows.ts | 10 +- packages/viewer/src/lib/reveal-wipe.test.ts | 104 ++++++++++ packages/viewer/src/lib/reveal-wipe.ts | 175 +++++++++++++++++ .../construction-reveal.test.tsx | 181 +++++++++++++++++- .../construction-reveal.tsx | 68 +++++-- 9 files changed, 578 insertions(+), 39 deletions(-) create mode 100644 packages/viewer/src/lib/reveal-wipe.test.ts create mode 100644 packages/viewer/src/lib/reveal-wipe.ts diff --git a/packages/viewer/src/lib/reveal-add-volume.test.ts b/packages/viewer/src/lib/reveal-add-volume.test.ts index 819c4f020b..dcd56e775a 100644 --- a/packages/viewer/src/lib/reveal-add-volume.test.ts +++ b/packages/viewer/src/lib/reveal-add-volume.test.ts @@ -95,13 +95,13 @@ describe('add_volume at the framing level', () => { 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', 'roof-segment', 'ceiling'].includes( - node.type, - ) - ) + 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') } }) diff --git a/packages/viewer/src/lib/reveal-candidates.ts b/packages/viewer/src/lib/reveal-candidates.ts index c5fdad94df..d4ae367970 100644 --- a/packages/viewer/src/lib/reveal-candidates.ts +++ b/packages/viewer/src/lib/reveal-candidates.ts @@ -9,6 +9,7 @@ import { 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` @@ -27,7 +28,9 @@ 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' } @@ -490,13 +493,14 @@ export function nodeRevealCandidates( } /** How an edited node plays again: a quick re-rise or re-scale, never a fall from a height. */ -const EDIT_STYLE: Record = { +const EDIT_STYLE: Record = { rise: 'rise', scale: 'scale', settle: 'settle', drop: 'scale', cut: 'scale', assemble: 'scale', + cover: 'scale', } const sameValue = (was: unknown, now: unknown) => diff --git a/packages/viewer/src/lib/reveal-pose.ts b/packages/viewer/src/lib/reveal-pose.ts index 5654f598ee..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() @@ -135,7 +138,7 @@ const easeOutBack = (t: number, s: number) => 1 + (s + 1) * (t - 1) ** 3 + s * ( * rounding error. */ export function revealPoseAt( - style: RevealStyle, + style: PlayedStyle, t: number, height: number, out: RevealPoseSample = { lift: 0, scale: new Vector3() }, @@ -143,7 +146,7 @@ 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) @@ -213,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 }, @@ -233,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': @@ -254,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 } @@ -296,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.ts b/packages/viewer/src/lib/reveal-schedule.ts index 7ab8bd65ec..41f3ffc20e 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 { 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 @@ -126,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 @@ -135,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. @@ -170,15 +186,15 @@ export type LazyRevealTiming = { 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. */ - durationMs: Record + /** How long each style moves its node at this level; `cover` is how long a roof's covering takes to lay. */ + durationMs: Record /** * 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> + minHeightM: Partial> } /** @@ -206,7 +222,7 @@ export const LAZY_REVEAL_BASE_TIMING: LazyRevealTiming = { phaseGapMs: 260, jitter: 0.3, capMs: 40_000, - durationMs: { rise: 760, scale: 520, settle: 640, drop: 820, cut: 320, assemble: 0 }, + durationMs: { rise: 760, scale: 520, settle: 640, drop: 820, cut: 320, assemble: 0, cover: 2600 }, overshoot: { rise: 0.7, scale: 1.6, settle: 1.6 }, minHeightM: { drop: 1.8, settle: 0.6 }, } @@ -473,7 +489,7 @@ export function planRetract( placed.push({ entry, offset: cursor + (last - entry.startMs) * 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 @@ -483,7 +499,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-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..16ca0da576 --- /dev/null +++ b/packages/viewer/src/lib/reveal-wipe.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test' +import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D } from 'three' +import { + COVER_MAX_ROWS, + COVER_MIN_ROWS, + clearRevealWipe, + coverShareAt, + coverSweepOf, + planCoverRows, + 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 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('the rows of a covering', () => { + const sweep = { axis: 'y', from: 3, to: 5.4 } as const + + test('start in order from the eave, the first at once and the last before the end', () => { + const rows = planCoverRows(sweep, 3000) + expect(rows.length).toBeGreaterThanOrEqual(COVER_MIN_ROWS) + expect(rows[0]?.atMs).toBe(0) + for (let i = 1; i < rows.length; i += 1) { + expect(rows[i]!.atMs).toBeGreaterThan(rows[i - 1]!.atMs) + expect(rows[i]!.upTo).toBeGreaterThan(rows[i - 1]!.upTo) + } + expect(rows.at(-1)?.upTo).toBe(1) + expect(rows.at(-1)!.atMs).toBeLessThan(3000) + }) + + test('their count follows the sweep, within bounds', () => { + expect(planCoverRows({ axis: 'y', from: 0, to: 0.7 }, 2000)).toHaveLength(COVER_MIN_ROWS) + expect(planCoverRows({ axis: 'x', from: 0, to: 40 }, 2000)).toHaveLength(COVER_MAX_ROWS) + }) + + test('the covering closes row by row, never going back', () => { + const rows = planCoverRows(sweep, 3000) + let last = 0 + for (let at = -1; at <= 3000; at += 50) { + const share = coverShareAt(rows, at) + expect(share).toBeGreaterThanOrEqual(last) + last = share + } + expect(coverShareAt(rows, -1)).toBe(0) + expect(coverShareAt(rows, 3000)).toBe(1) + }) + + test('a slower time stretches the rows, it does not change their order', () => { + const quick = planCoverRows(sweep, 1000) + const slow = planCoverRows(sweep, 4000) + expect(slow.map((row) => row.upTo)).toEqual(quick.map((row) => row.upTo)) + expect(slow[3]!.atMs).toBeCloseTo(quick[3]!.atMs * 4, 6) + }) +}) + +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..a3198cc573 --- /dev/null +++ b/packages/viewer/src/lib/reveal-wipe.ts @@ -0,0 +1,175 @@ +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 row by row 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 +/** One row of the covering takes about this much of the sweep (m): a course of tiles or a sheet. */ +export const COVER_ROW_M = 0.5 +export const COVER_MIN_ROWS = 4 +export const COVER_MAX_ROWS = 14 + +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 { + 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 } +} + +/** One row of the covering: the part of the sweep it closes, and when it is laid. */ +export type CoverRow = { + index: number + /** The row's far edge along the sweep, as a share of it; the rows go 0 → 1 in order. */ + upTo: number + /** When the row is laid, from the start of the cover (ms). */ + atMs: number +} + +/** + * The rows of a covering that takes `durationMs`: their count follows the length of the sweep, the + * first is laid at once, and each next one a stride later, so the last lands with the end of the + * time. In order from the lowest edge. + */ +export function planCoverRows(sweep: CoverSweep, durationMs: number): CoverRow[] { + const length = Math.max(0, sweep.to - sweep.from) + const count = Math.min(COVER_MAX_ROWS, Math.max(COVER_MIN_ROWS, Math.round(length / COVER_ROW_M))) + const stride = count > 1 ? durationMs / count : 0 + return Array.from({ length: count }, (_, index) => ({ + index, + upTo: (index + 1) / count, + atMs: index * stride, + })) +} + +/** + * How much of the sweep the covering has closed `elapsedMs` into it, as a share in [0, 1]: the far + * edge of the last row laid. 0 before the first row, 1 once the last is laid. + */ +export function coverShareAt(rows: readonly CoverRow[], elapsedMs: number): number { + let share = 0 + for (const row of rows) { + if (row.atMs > elapsedMs) break + share = row.upTo + } + return share +} + +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/systems/construction-reveal/construction-reveal.test.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx index ed5829b16b..ca0fc060dc 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -21,7 +21,7 @@ import { 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, Object3D } from 'three' import { NodeRenderer } from '../../components/renderers/node-renderer' import { DustPool } from '../../lib/construction-dust' import { OVERLAY_LAYER, scenePassLayers } from '../../lib/layers' @@ -34,6 +34,7 @@ import { REVEAL_TIMING, revealTimingAt, } from '../../lib/reveal-schedule' +import { revealWipeLevelOf } from '../../lib/reveal-wipe' import useViewer from '../../store/use-viewer' import { GeometrySystem } from '../geometry/geometry-system' import { ConstructionDust } from './construction-dust' @@ -213,6 +214,13 @@ const Lid = revealKind( const Part = revealKind('fixture:part', 'fxpart', null, () => new BoxGeometry(3, 1, 4).translate(0, 3.5, 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 @@ -348,6 +356,7 @@ beforeEach(async () => { Movable.definition, Model.definition, Part.definition, + Gable.definition, ], }) now = 0 @@ -937,6 +946,176 @@ 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('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(3) + for (let i = 1; i < levels.length; i += 1) + expect(levels[i]!).toBeLessThanOrEqual(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' diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index fb80394bc9..a5e40af2f0 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -8,7 +8,6 @@ import { isRestoringSceneHistory, nodeRegistry, type RevealPhase, - type RevealStyle, type SceneCommit, sceneRegistry, subscribeSceneCommits, @@ -16,7 +15,7 @@ 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 { @@ -59,8 +58,18 @@ import { retractTimingAt, revealGroupKey, revealTimingAt, + styleMs, } from '../../lib/reveal-schedule' import { clearRiseTextureScale, setRiseTextureScale } from '../../lib/reveal-texture-scale' +import { + type CoverRow, + type CoverSweep, + coverShareAt, + coverSweepOf, + type PlayedStyle, + planCoverRows, + setRevealWipe, +} from '../../lib/reveal-wipe' import useViewer from '../../store/use-viewer' import { ConstructionDust } from './construction-dust' @@ -86,7 +95,7 @@ export { isRevealWaiting } type ActiveReveal = { id: string candidate: RevealCandidate - style: RevealStyle + style: PlayedStyle height: number levelId: string | null startedAt: number @@ -108,6 +117,29 @@ type ActiveReveal = { 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 rows, planned when it starts (see `layCover`). */ + cover?: Cover +} + +/** How a covering is laid over a node held still: its sweep along the node and its rows in order. */ +type Cover = { sweep: CoverSweep; rows: readonly CoverRow[] } + +const coverBox = new Box3() + +/** The rows of a covering that takes `durationMs` over what is drawn under `root`. */ +function planCover(root: Object3D, durationMs: number): Cover { + root.updateWorldMatrix(true, true) + const sweep = coverSweepOf(coverBox.setFromObject(root)) + return { sweep, rows: planCoverRows(sweep, durationMs) } +} + +/** The world coordinate the covering has reached when `share` of its sweep is closed. */ +const coverLevel = (sweep: CoverSweep, share: number) => + share <= 0 ? sweep.from - 0.01 : sweep.from + (sweep.to - sweep.from) * share + 0.01 + +/** Lays the rows of `cover` that `elapsedMs` has reached; `-1` before the first row is laid. */ +function layCover(root: Object3D, cover: Cover, elapsedMs: number) { + setRevealWipe(root, cover.sweep, coverLevel(cover.sweep, coverShareAt(cover.rows, elapsedMs))) } export type RevealPhaseEvent = { phase: RevealPhase; levelId: string | null } @@ -141,7 +173,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. */ @@ -207,7 +239,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 @@ -946,7 +978,7 @@ function startEdit(change: ChangeCandidate, now: number) { height: change.height, levelId: change.levelId, startedAt: now, - durationMs: timing.durationMs[change.style] * EDIT_SHARE, + durationMs: styleMs(timing.durationMs, change.style) * EDIT_SHARE, assembleMs: 0, clockStart: null, pivot: null, @@ -1174,7 +1206,7 @@ function startReveal(candidate: RevealCandidate, now: number, animate: boolean) : candidate.height, levelId: candidate.levelId, startedAt: now, - durationMs: timing.durationMs[candidate.style], + durationMs: styleMs(timing.durationMs, candidate.style), assembleMs: revealTimingAt(planSpeed).durationMs.drop, clockStart: null, pivot: null, @@ -1267,6 +1299,10 @@ 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) + if (reveal.style === 'cover') { + reveal.cover = planCover(root, reveal.durationMs) + layCover(root, reveal.cover, 0) + } if (!reveal.edit) groupStarted(reveal.candidate, now) if (reveal.style === 'rise') puffAlongBase(reveal, root, now) } @@ -1275,6 +1311,7 @@ function stepReveal(reveal: ActiveReveal, now: number) { 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 @@ -1466,7 +1503,7 @@ type RetractRun = { type Retract = { id: string - style: RevealStyle + style: PlayedStyle height: number startMs: number durationMs: number @@ -1476,11 +1513,13 @@ 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 } type Held = { root: Object3D | null - style: RevealStyle + style: PlayedStyle pivot: Vector3 scale: Vector3 lift: number @@ -1495,7 +1534,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) { @@ -1571,12 +1610,13 @@ type Ghost = { /** The picture: a clone of the deleted node's root, standing where it stood. */ root: Object3D parent: Object3D - style: RevealStyle + style: PlayedStyle height: number startMs: number durationMs: number group: RetractGroup pivot: Vector3 | null + cover?: Cover } const ghosts = new Map() @@ -1665,6 +1705,10 @@ function stepGhosts(now: number) { 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.style === 'cover') { + ghost.cover ??= planCover(ghost.root, ghost.durationMs) + layCover(ghost.root, ghost.cover, (1 - Math.min(1, t)) * ghost.durationMs - 1) + } if (fadesAway(ghost.style)) setGhost(ghost.root, pose.opacity) if (t >= 1) ghostGone(ghost, now) } @@ -1701,6 +1745,7 @@ function stepRetracts(now: number) { } if (!retract.pivot) { retract.pivot = revealPivot(revealLocalBounds(root), retract.style) + if (retract.style === 'cover') retract.cover = planCover(root, retract.durationMs) if (!retract.group.started) { retract.group.started = true emitRetract('retract-start', retract.group, now) @@ -1709,6 +1754,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) layCover(root, retract.cover, (1 - Math.min(1, t)) * retract.durationMs - 1) if (fadesAway(retract.style)) setGhost(root, retract.from.opacity * pose.opacity) if (t >= 1) { holdGone(retract, root, now) From 75065b85a5183b47bdea0e12762a8d46e2015849 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 16:26:34 +0200 Subject: [PATCH 19/43] viewer: a node with nothing drawn has an empty covering sweep; the wiki says how a roof is covered at Framing Co-Authored-By: Claude Sonnet 5.5 --- packages/viewer/src/lib/reveal-wipe.test.ts | 8 ++++++++ packages/viewer/src/lib/reveal-wipe.ts | 2 ++ wiki/architecture/construction-animation.md | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/packages/viewer/src/lib/reveal-wipe.test.ts b/packages/viewer/src/lib/reveal-wipe.test.ts index 16ca0da576..8bb0b16ff7 100644 --- a/packages/viewer/src/lib/reveal-wipe.test.ts +++ b/packages/viewer/src/lib/reveal-wipe.test.ts @@ -21,6 +21,14 @@ describe('the sweep of a covering', () => { 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 }) diff --git a/packages/viewer/src/lib/reveal-wipe.ts b/packages/viewer/src/lib/reveal-wipe.ts index a3198cc573..57d08e496e 100644 --- a/packages/viewer/src/lib/reveal-wipe.ts +++ b/packages/viewer/src/lib/reveal-wipe.ts @@ -40,6 +40,8 @@ type BoxLike = { /** 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 } } diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index 515b221627..f9fbc0abca 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -54,6 +54,10 @@ It stays up 10 s after the last piece of the phase (a model's turn between write - **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 opens row by row from the lowest edge to the highest, the eave to the ridge (`planCoverRows`; a flat roof sweeps along its longer plan side). Rows are laid in order from the first at once to the last before the end of `LAZY_REVEAL_TIMING.durationMs.cover`, 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. From 45db88d6683dd75432cda3181c09e0e0bcff160e Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 16:39:30 +0200 Subject: [PATCH 20/43] viewer: deleting a room or a floor takes it apart, floor by floor and room by room, as it went up At the Framing level a deletion (and an undo) plays the build's own plan read from its end: the furniture lifts out first, 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, with the prerolls a plugin asked kept after each piece's going (getRetractTurn). A deleted floor's pictures stand in their building, so they outlive the floor's own object. Full keeps its rewind. The reverse plan is capped at 14 s however many pieces it takes back. Co-Authored-By: Claude Sonnet 5.5 --- packages/viewer/src/index.ts | 1 + packages/viewer/src/lib/reveal-candidates.ts | 4 +- packages/viewer/src/lib/reveal-schedule.ts | 54 ++++++ .../construction-reveal.test.tsx | 174 +++++++++++++++++- .../construction-reveal.tsx | 83 +++++++-- 5 files changed, 302 insertions(+), 14 deletions(-) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 217a969024..1b05c64cb0 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -277,6 +277,7 @@ export { type ConstructionRevealLevel, type ConstructionRevealOptions, emitRevealPuff, + getRetractTurn, getRevealPlanSpeed, getRevealPlanState, getRevealTurn, diff --git a/packages/viewer/src/lib/reveal-candidates.ts b/packages/viewer/src/lib/reveal-candidates.ts index d4ae367970..c674329e7c 100644 --- a/packages/viewer/src/lib/reveal-candidates.ts +++ b/packages/viewer/src/lib/reveal-candidates.ts @@ -482,8 +482,8 @@ export function nodeRevealCandidates( ) 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) } diff --git a/packages/viewer/src/lib/reveal-schedule.ts b/packages/viewer/src/lib/reveal-schedule.ts index 41f3ffc20e..676aab2524 100644 --- a/packages/viewer/src/lib/reveal-schedule.ts +++ b/packages/viewer/src/lib/reveal-schedule.ts @@ -461,6 +461,60 @@ 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. + */ +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), +} + +/** 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 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 ca0fc060dc..4f2f3aa13c 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, applySceneSnapshot, BaseNode, + BuildingNode, clearSceneHistory, emitter, ItemNode, @@ -47,6 +48,7 @@ import { ConstructionReveal, type ConstructionRevealDriver, type ConstructionRevealLevel, + getRetractTurn, getRevealPlanSpeed, getRevealPlanState, getRevealTurn, @@ -225,9 +227,12 @@ const Gable = revealKind( 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 ( <> @@ -1779,6 +1784,173 @@ describe('a deletion plays the creation backwards', () => { }) }) +// 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[])) + }) + + 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(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)) + } + await until(() => pictures().length === 0, 90000) + }) + + 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({ 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('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({ deletes: () => true }) + const walls = Array.from({ length: 120 }, () => make(Wall)) + const crates = Array.from({ length: 120 }, () => make(Crate)) + await agentWrites(() => + useScene + .getState() + .createNodes([...walls, ...crates].map((node) => ({ node, parentId: levelId }))), + ) + 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) + }) +}) + // Victor run 12: the dust showed through walls. It drew on the overlay layer, which composites on // top of the scene with no depth test. describe('the dust', () => { diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index a5e40af2f0..21bf08e5ba 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -48,9 +48,11 @@ import { import { LAZY_REVEAL_TIMING, type LazyRevealTiming, + lazyRetractTimingAt, lazyTimingAt, normalizeAnimationSpeed, type PlannedReveal, + planLazyRetract, planLazyReveal, planRetract, planReveal, @@ -1515,6 +1517,8 @@ type Retract = { 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 = { @@ -1617,10 +1621,45 @@ type Ghost = { 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 { + 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 ? sceneRegistry.nodes.get(above) : undefined) ?? own +} + +/** + * 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) => { @@ -1628,6 +1667,24 @@ function drawnInScenePass(root: Object3D) { }) } +/** + * 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) { + if (!lazyOn()) return planRetract(candidates, at, retractTimingAt(speed), revealTimingAt(speed)) + const asked = candidates.map((candidate) => { + let prerollMs = 0 + try { + prerollMs = prerollOf?.(candidate) ?? 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 @@ -1643,16 +1700,21 @@ function playDeletions(commit: SceneCommit) { sceneRegistry.nodes.get(candidate.id)?.parent, ) if (staged.length === 0) return - const speed = currentSpeed() - const plan = planRetract(staged, clockMs, retractTimingAt(speed), revealTimingAt(speed)) + const plan = planBack(staged, clockMs, currentSpeed()) const groupsByKey = new Map() for (const entry of plan) { const original = sceneRegistry.nodes.get(entry.id) - const parent = original?.parent - if (!(original && parent)) continue + const own = original?.parent + if (!(original && own)) continue const root = original.clone(true) drawnInScenePass(root) - parent.add(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) { @@ -1672,6 +1734,7 @@ function playDeletions(commit: SceneCommit) { durationMs: entry.durationMs, group, pivot: null, + ...(entry.prerollMs ? { prerollMs: entry.prerollMs } : {}), }) } } @@ -1860,15 +1923,12 @@ export function retractNodes(ids: Iterable): Promise { const roofFirst = clearing.some((candidate) => !from.has(candidate.id)) const lead = liftWanted.size > lifting || roofFirst ? RETRACT_LIFT_LEAD_MS : 0 const speed = currentSpeed() - const rewind = retractTimingAt(speed) - const forward = revealTimingAt(speed) const plan = [ - ...planRetract(clearing, clockMs, rewind, forward), - ...planRetract( + ...planBack(clearing, clockMs, speed), + ...planBack( staged.filter((candidate) => !clearingIds.has(candidate.id)), clockMs + lead, - rewind, - forward, + speed, ), ].sort((left, right) => left.startMs - right.startMs) const end = Math.max(...plan.map((entry) => entry.startMs + entry.durationMs)) @@ -1912,6 +1972,7 @@ export function retractNodes(ids: Iterable): Promise { run, root: null, pivot: null, + ...(entry.prerollMs ? { prerollMs: entry.prerollMs } : {}), }) // A plan entry that draws it hears that it is going. notifyPending(entry.id) From 8bdb9e479740f1423b0db31349736652ffb5c0e7 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 16:45:20 +0200 Subject: [PATCH 21/43] viewer: a plugin's preroll for a deleted node is asked in the scene it stood in Co-Authored-By: Claude Sonnet 5.5 --- packages/viewer/src/index.ts | 1 + .../construction-reveal.tsx | 26 +++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 1b05c64cb0..0b04cf5516 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -291,6 +291,7 @@ export { type RevealNodeStartEvent, type RevealPhaseEvent, type RevealPlanState, + type RevealPreroll, type RevealRetractEvent, retractNodes, revealClockMs, diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 21bf08e5ba..5cdf546821 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -306,7 +306,13 @@ 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: ((candidate: RevealCandidate) => number) | null = null +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 @@ -613,7 +619,7 @@ export function revealClockMs(): number { * 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: ((candidate: RevealCandidate) => number) | null): void { +export function setRevealPreroll(next: RevealPreroll | null): void { prerollOf = next } @@ -1671,12 +1677,17 @@ function drawnInScenePass(root: Object3D) { * 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) { +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) ?? 0 + prerollMs = prerollOf?.(candidate, nodes) ?? 0 } catch (error) { console.error('[viewer] reveal preroll failed', error) } @@ -1700,7 +1711,12 @@ function playDeletions(commit: SceneCommit) { sceneRegistry.nodes.get(candidate.id)?.parent, ) if (staged.length === 0) return - const plan = planBack(staged, clockMs, currentSpeed()) + 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) From 6bce21c5f4c29251ea38941ff53792463c33a3f9 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 16:45:43 +0200 Subject: [PATCH 22/43] wiki: deleting a room or a floor takes it apart Co-Authored-By: Claude Sonnet 5.5 --- wiki/architecture/construction-animation.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index f9fbc0abca..5ad31ced2c 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -64,6 +64,10 @@ At Full a roof is an `assemble` node: its parts drop in drawn apart and the merg **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. +## 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 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. From 56164b66b70d3c4380be8a9cf2f3e5108001cb83 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 16:46:55 +0200 Subject: [PATCH 23/43] viewer: a derived plate or ceiling goes with its room when a floor or a room is deleted The slabs and ceilings of a floor are all derived by the reconciler, and a deletion skipped every derived surface, so the last pieces of a floor never came apart. They play when the commit also takes a wall or a room off their level; a plate the reconciler merely derives anew still is not a deletion. Co-Authored-By: Claude Sonnet 5.5 --- .../viewer/src/lib/reveal-add-volume.test.ts | 40 ++++++++++++++++++- packages/viewer/src/lib/reveal-candidates.ts | 15 ++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/viewer/src/lib/reveal-add-volume.test.ts b/packages/viewer/src/lib/reveal-add-volume.test.ts index dcd56e775a..d650311966 100644 --- a/packages/viewer/src/lib/reveal-add-volume.test.ts +++ b/packages/viewer/src/lib/reveal-add-volume.test.ts @@ -19,7 +19,7 @@ import { } from '@pascal-app/core' import { Group } from 'three' import addVolumeCommit from './__fixtures__/add-volume-commit.json' -import { commitRevealCandidates } from './reveal-candidates' +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 @@ -161,3 +161,41 @@ describe('add_volume at the framing level', () => { ) }) }) + +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.ts b/packages/viewer/src/lib/reveal-candidates.ts index c674329e7c..78f941efeb 100644 --- a/packages/viewer/src/lib/reveal-candidates.ts +++ b/packages/viewer/src/lib/reveal-candidates.ts @@ -558,10 +558,21 @@ export function commitDeleteCandidates( ): 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) { - if (current.nodes[id as AnyNodeId] || derivedWrites?.get(id as AnyNodeId) === 'all') continue const node = before.nodes[id as AnyNodeId] - if (!node || isDerivedNode(node)) continue + if (current.nodes[id as AnyNodeId] || !node?.parentId) continue + if (node.type === 'wall' || node.type === 'zone') 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], From 1723b99ab36820e7e32ba23d83d2c8ceb744c83a Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 17:03:36 +0200 Subject: [PATCH 24/43] viewer: a roof's covering closes continuously, in a time that follows its size The covering was laid in 4 to 14 steps, one row at a time, over a fixed 2.6 s. Its mask level now moves with every frame from the lowest edge to the highest (an ease in and out, no steps), over a time of 450 ms for each metre of sweep at pace 1, never under 1.4 s nor over 4.2 s (at the Framing pace 0.79 s a metre, 2.45 s to 7.35 s); a lifting off, for an undo or a deletion, plays the same sweep backwards in 0.6 of that. The speed setting scales all of it; Full is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- packages/viewer/src/lib/reveal-schedule.ts | 46 +++++++++--- packages/viewer/src/lib/reveal-wipe.test.ts | 71 +++++++++++-------- packages/viewer/src/lib/reveal-wipe.ts | 52 +++++--------- .../construction-reveal.test.tsx | 71 ++++++++++++++++++- .../construction-reveal.tsx | 61 ++++++++++------ wiki/architecture/construction-animation.md | 2 +- 6 files changed, 204 insertions(+), 99 deletions(-) diff --git a/packages/viewer/src/lib/reveal-schedule.ts b/packages/viewer/src/lib/reveal-schedule.ts index 676aab2524..fbbde1b313 100644 --- a/packages/viewer/src/lib/reveal-schedule.ts +++ b/packages/viewer/src/lib/reveal-schedule.ts @@ -1,6 +1,6 @@ import type { RevealPhase, RevealStyle } from '@pascal-app/core' import { REVEAL_ARRIVAL } from './reveal-pose' -import type { PlayedStyle } from './reveal-wipe' +import type { CoverRange, PlayedStyle } from './reveal-wipe' export const REVEAL_PHASE_ORDER: readonly RevealPhase[] = [ 'foundation', @@ -186,8 +186,14 @@ export type LazyRevealTiming = { 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 how long a roof's covering takes to lay. */ + /** 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. @@ -222,7 +228,9 @@ export const LAZY_REVEAL_BASE_TIMING: LazyRevealTiming = { phaseGapMs: 260, jitter: 0.3, capMs: 40_000, - durationMs: { rise: 760, scale: 520, settle: 640, drop: 820, cut: 320, assemble: 0, cover: 2600 }, + 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 }, } @@ -241,6 +249,8 @@ export function pacedLazyTiming(timing: LazyRevealTiming, pace: number): LazyRev phaseGapMs: timing.phaseGapMs * pace, capMs: timing.capMs * pace, durationMs: scaled(timing.durationMs), + coverMsPerM: timing.coverMsPerM * pace, + coverMinMs: timing.coverMinMs * pace, } } @@ -288,10 +298,10 @@ export function pacedRetractTiming(timing: RetractTiming, speed: number): Retrac } } -function scaleTimes(table: Record, factor: number): Record { +function scaleTimes>>(table: T, factor: number): T { return Object.fromEntries( - (Object.entries(table) as [K, number][]).map(([key, ms]) => [key, ms * factor]), - ) as Record + 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. */ @@ -444,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. */ @@ -466,11 +476,31 @@ export type PlannedRetract = RevealCandidate & { * 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), + 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. */ diff --git a/packages/viewer/src/lib/reveal-wipe.test.ts b/packages/viewer/src/lib/reveal-wipe.test.ts index 8bb0b16ff7..ce7545768a 100644 --- a/packages/viewer/src/lib/reveal-wipe.test.ts +++ b/packages/viewer/src/lib/reveal-wipe.test.ts @@ -1,12 +1,10 @@ import { describe, expect, test } from 'bun:test' import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D } from 'three' import { - COVER_MAX_ROWS, - COVER_MIN_ROWS, clearRevealWipe, + coverDurationMs, coverShareAt, coverSweepOf, - planCoverRows, revealWipeLevelOf, setRevealWipe, } from './reveal-wipe' @@ -35,43 +33,54 @@ describe('the sweep of a covering', () => { }) }) -describe('the rows of a covering', () => { - const sweep = { axis: 'y', from: 3, to: 5.4 } as const +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('start in order from the eave, the first at once and the last before the end', () => { - const rows = planCoverRows(sweep, 3000) - expect(rows.length).toBeGreaterThanOrEqual(COVER_MIN_ROWS) - expect(rows[0]?.atMs).toBe(0) - for (let i = 1; i < rows.length; i += 1) { - expect(rows[i]!.atMs).toBeGreaterThan(rows[i - 1]!.atMs) - expect(rows[i]!.upTo).toBeGreaterThan(rows[i - 1]!.upTo) - } - expect(rows.at(-1)?.upTo).toBe(1) - expect(rows.at(-1)!.atMs).toBeLessThan(3000) + 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('their count follows the sweep, within bounds', () => { - expect(planCoverRows({ axis: 'y', from: 0, to: 0.7 }, 2000)).toHaveLength(COVER_MIN_ROWS) - expect(planCoverRows({ axis: 'x', from: 0, to: 40 }, 2000)).toHaveLength(COVER_MAX_ROWS) + 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) }) +}) - test('the covering closes row by row, never going back', () => { - const rows = planCoverRows(sweep, 3000) - let last = 0 - for (let at = -1; at <= 3000; at += 50) { - const share = coverShareAt(rows, at) - expect(share).toBeGreaterThanOrEqual(last) +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 } - expect(coverShareAt(rows, -1)).toBe(0) - expect(coverShareAt(rows, 3000)).toBe(1) }) - test('a slower time stretches the rows, it does not change their order', () => { - const quick = planCoverRows(sweep, 1000) - const slow = planCoverRows(sweep, 4000) - expect(slow.map((row) => row.upTo)).toEqual(quick.map((row) => row.upTo)) - expect(slow[3]!.atMs).toBeCloseTo(quick[3]!.atMs * 4, 6) + 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) }) }) diff --git a/packages/viewer/src/lib/reveal-wipe.ts b/packages/viewer/src/lib/reveal-wipe.ts index 57d08e496e..6b8a4c0b6f 100644 --- a/packages/viewer/src/lib/reveal-wipe.ts +++ b/packages/viewer/src/lib/reveal-wipe.ts @@ -5,8 +5,8 @@ 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 row by row 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 + * 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). * @@ -28,10 +28,6 @@ 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 -/** One row of the covering takes about this much of the sweep (m): a course of tiles or a sheet. */ -export const COVER_ROW_M = 0.5 -export const COVER_MIN_ROWS = 4 -export const COVER_MAX_ROWS = 14 type BoxLike = { min: { x: number; y: number; z: number } @@ -51,42 +47,28 @@ export function coverSweepOf(box: BoxLike): CoverSweep { : { axis: 'z', from: box.min.z, to: box.max.z } } -/** One row of the covering: the part of the sweep it closes, and when it is laid. */ -export type CoverRow = { - index: number - /** The row's far edge along the sweep, as a share of it; the rows go 0 → 1 in order. */ - upTo: number - /** When the row is laid, from the start of the cover (ms). */ - atMs: number -} - /** - * The rows of a covering that takes `durationMs`: their count follows the length of the sweep, the - * first is laid at once, and each next one a stride later, so the last lands with the end of the - * time. In order from the lowest edge. + * 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 function planCoverRows(sweep: CoverSweep, durationMs: number): CoverRow[] { +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) - const count = Math.min(COVER_MAX_ROWS, Math.max(COVER_MIN_ROWS, Math.round(length / COVER_ROW_M))) - const stride = count > 1 ? durationMs / count : 0 - return Array.from({ length: count }, (_, index) => ({ - index, - upTo: (index + 1) / count, - atMs: index * stride, - })) + return Math.min(range.maxMs, Math.max(range.minMs, length * range.perMeterMs)) } /** - * How much of the sweep the covering has closed `elapsedMs` into it, as a share in [0, 1]: the far - * edge of the last row laid. 0 before the first row, 1 once the last is laid. + * 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(rows: readonly CoverRow[], elapsedMs: number): number { - let share = 0 - for (const row of rows) { - if (row.atMs > elapsedMs) break - share = row.upTo - } - return share +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 = { 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 4f2f3aa13c..fb0499a58c 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -29,6 +29,7 @@ import { OVERLAY_LAYER, scenePassLayers } from '../../lib/layers' import { commitEditCandidates } from '../../lib/reveal-candidates' import { hasRevealPose } from '../../lib/reveal-pose' import { + coverRangeAt, LAZY_REVEAL_TIMING, lazyTimingAt, RETRACT_TIMING, @@ -216,6 +217,25 @@ 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', @@ -362,6 +382,9 @@ beforeEach(async () => { Model.definition, Part.definition, Gable.definition, + Porch.definition, + Hip.definition, + LongFlat.definition, ], }) now = 0 @@ -1020,6 +1043,48 @@ describe('a roof at the framing level is covered in place, not dropped', () => { }) }) + 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]) { @@ -1113,9 +1178,9 @@ describe('a roof at the framing level is covered in place, not dropped', () => { } expect(result).not.toBeNull() expect(yScale(roofRoot)).toBeLessThan(0.01) - expect(levels.length).toBeGreaterThan(3) - for (let i = 1; i < levels.length; i += 1) - expect(levels[i]!).toBeLessThanOrEqual(levels[i - 1]!) + 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) }) diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 5cdf546821..09d7a7670e 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -46,6 +46,7 @@ import { setRevealPose, } from '../../lib/reveal-pose' import { + coverRangeAt, LAZY_REVEAL_TIMING, type LazyRevealTiming, lazyRetractTimingAt, @@ -64,12 +65,12 @@ import { } from '../../lib/reveal-schedule' import { clearRiseTextureScale, setRiseTextureScale } from '../../lib/reveal-texture-scale' import { - type CoverRow, + type CoverRange, type CoverSweep, + coverDurationMs, coverShareAt, coverSweepOf, type PlayedStyle, - planCoverRows, setRevealWipe, } from '../../lib/reveal-wipe' import useViewer from '../../store/use-viewer' @@ -119,29 +120,41 @@ type ActiveReveal = { 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 rows, planned when it starts (see `layCover`). */ + /** 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 its rows in order. */ -type Cover = { sweep: CoverSweep; rows: readonly CoverRow[] } +/** 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 rows of a covering that takes `durationMs` over what is drawn under `root`. */ -function planCover(root: Object3D, durationMs: number): Cover { +/** 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, rows: planCoverRows(sweep, durationMs) } + return { sweep, durationMs: coverDurationMs(sweep, range) } } -/** The world coordinate the covering has reached when `share` of its sweep is closed. */ +/** + * 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) => - share <= 0 ? sweep.from - 0.01 : sweep.from + (sweep.to - sweep.from) * share + 0.01 + sweep.from - 0.01 + (sweep.to - sweep.from + 0.02) * share -/** Lays the rows of `cover` that `elapsedMs` has reached; `-1` before the first row is laid. */ +/** Lays the covering `elapsedMs` into its time: it moves with every frame, easing in and out. */ function layCover(root: Object3D, cover: Cover, elapsedMs: number) { - setRevealWipe(root, cover.sweep, coverLevel(cover.sweep, coverShareAt(cover.rows, elapsedMs))) + 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 } @@ -1216,6 +1229,7 @@ function startReveal(candidate: RevealCandidate, now: number, animate: boolean) startedAt: now, durationMs: styleMs(timing.durationMs, candidate.style), assembleMs: revealTimingAt(planSpeed).durationMs.drop, + ...(candidate.style === 'cover' ? { coverRange: coverRangeAt(planSpeed) } : {}), clockStart: null, pivot: null, base: null, @@ -1308,7 +1322,9 @@ function stepReveal(reveal: ActiveReveal, now: number) { revealPoseAt(reveal.style, 0, reveal.height, sample, reveal) setRevealPose(root, reveal.pivot, sample.scale, sample.lift) if (reveal.style === 'cover') { - reveal.cover = planCover(root, reveal.durationMs) + // 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) @@ -1739,6 +1755,8 @@ function playDeletions(commit: SceneCommit) { } 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, @@ -1747,7 +1765,8 @@ function playDeletions(commit: SceneCommit) { style: entry.style, height: entry.height, startMs: entry.startMs, - durationMs: entry.durationMs, + durationMs: cover?.durationMs ?? entry.durationMs, + ...(cover ? { cover } : {}), group, pivot: null, ...(entry.prerollMs ? { prerollMs: entry.prerollMs } : {}), @@ -1784,10 +1803,7 @@ function stepGhosts(now: number) { 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.style === 'cover') { - ghost.cover ??= planCover(ghost.root, ghost.durationMs) - layCover(ghost.root, ghost.cover, (1 - Math.min(1, t)) * ghost.durationMs - 1) - } + if (ghost.cover) liftCover(ghost.root, ghost.cover, t) if (fadesAway(ghost.style)) setGhost(ghost.root, pose.opacity) if (t >= 1) ghostGone(ghost, now) } @@ -1824,7 +1840,6 @@ function stepRetracts(now: number) { } if (!retract.pivot) { retract.pivot = revealPivot(revealLocalBounds(root), retract.style) - if (retract.style === 'cover') retract.cover = planCover(root, retract.durationMs) if (!retract.group.started) { retract.group.started = true emitRetract('retract-start', retract.group, now) @@ -1833,7 +1848,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) layCover(root, retract.cover, (1 - Math.min(1, t)) * retract.durationMs - 1) + 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) @@ -1977,12 +1992,16 @@ 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, diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index 5ad31ced2c..70d7b3b8b9 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -56,7 +56,7 @@ It stays up 10 s after the last piece of the phase (a model's turn between write ## 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 opens row by row from the lowest edge to the highest, the eave to the ridge (`planCoverRows`; a flat roof sweeps along its longer plan side). Rows are laid in order from the first at once to the last before the end of `LAZY_REVEAL_TIMING.durationMs.cover`, 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. +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 From 8cf6ebe25bb5113ec2633aa68c544e0320f1b677 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 16:20:21 +0200 Subject: [PATCH 25/43] core: sceneViewFacing answers for a wall with an exterior face An exterior wall faces the way its exterior side does: what a camera that follows an edit of it looks at it along. An interior wall, and a wall with no exterior side, still answer null. Co-Authored-By: Claude Sonnet 5.5 --- packages/core/src/agent-operations/scene-view.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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) } /** From 51e18432ebf803097036d114a2c377839a4b16a3 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 16:25:43 +0200 Subject: [PATCH 26/43] core: scene-view test names the wall's own facing Co-Authored-By: Claude Sonnet 5.5 --- .../core/src/agent-operations/scene-view.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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() }) }) From d1073e9ffb9320d2261adee5e8e0bf1bf1fd9c21 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 17:12:43 +0200 Subject: [PATCH 27/43] viewer: the cutaway leaves what plays in view The reveal publishes the nodes playing (moving in, going out, carried, an edit replaying) in nodesRevealing; in cutaway the wall cutout cache keeps a wall stamped visible for as long as it, an opening it hosts or a node it stands under plays, and goes back to what the view wants after. The person's wall mode is never written; walls down stays their choice. Co-Authored-By: Claude Sonnet 5.5 --- packages/viewer/src/index.ts | 1 + packages/viewer/src/store/use-viewer.ts | 8 +++ .../construction-reveal.test.tsx | 28 +++++++++ .../construction-reveal.tsx | 35 +++++++++++ .../systems/wall/wall-cutout-cache.test.ts | 43 +++++++++++++ .../src/systems/wall/wall-cutout-cache.ts | 61 ++++++++++++++++++- 6 files changed, 173 insertions(+), 3 deletions(-) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 0b04cf5516..3055f60010 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -294,6 +294,7 @@ export { type RevealPreroll, type RevealRetractEvent, retractNodes, + revealChangedNodeIds, revealClockMs, seatLiftedNow, setRevealPreroll, diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index ad4a9eabe4..da26e711cc 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -125,6 +125,13 @@ type ViewerState = { * 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 @@ -455,6 +462,7 @@ const useViewer = create()( 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 fb0499a58c..21d30f849d 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -1352,6 +1352,34 @@ describe('the plan view reads the same reveal', () => { }) }) +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({ diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 09d7a7670e..1b380a8d92 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -1153,6 +1153,19 @@ function levelsAtWork(): string[] { return [...levels] } +/** + * 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 @@ -1160,12 +1173,34 @@ function levelsAtWork(): string[] { * 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. + */ +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) + 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) 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 90d359ee4c..b2b8bc38be 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, @@ -115,6 +116,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() diff --git a/packages/viewer/src/systems/wall/wall-cutout-cache.ts b/packages/viewer/src/systems/wall/wall-cutout-cache.ts index dec34d5149..8384edfeb2 100644 --- a/packages/viewer/src/systems/wall/wall-cutout-cache.ts +++ b/packages/viewer/src/systems/wall/wall-cutout-cache.ts @@ -112,7 +112,43 @@ 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 +} export type WallCutoutViewerStore = { getState: () => WallCutoutViewerState } @@ -135,6 +171,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, @@ -201,6 +240,16 @@ 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 + } const invalidated = overridesChanged || appearanceChanged || @@ -208,7 +257,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 @@ -287,6 +337,7 @@ export class WallCutoutCache { changed || rebuilt || releasedPreview === id || + playChanged.has(id) || cameraChanged ) { this.apply(wall, viewer.wallMode, refresh) @@ -382,7 +433,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. From ced35eb5835d69de1841c39ed3e4331ee7423cf5 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 17:13:16 +0200 Subject: [PATCH 28/43] editor, nodes: Follow looks at an edit or a move seen from outside from outside, with the walls up An exterior wall bent, a window moved along one, a roof or the ground edited is an outside step: Follow sets the walls up for it and frames the exterior face (it would have fallen back to the cutaway, which hides the wall that plays). A commit that changes one such piece takes the outside view for all of it. A build keeps its single cutaway and never writes the walls up in between. The walls going up is a presentation change Follow puts back; a mode the person picks meanwhile is theirs. A window or door moved along its wall replays (position joins revealOnEdit). Co-Authored-By: Claude Sonnet 5.5 --- .../components/editor/follow-pascal.test.tsx | 239 +++++++++++++++++- .../src/components/editor/follow-pascal.tsx | 35 ++- .../src/lib/follow-pascal-exterior.test.ts | 93 +++++++ .../editor/src/lib/follow-pascal-exterior.ts | 74 ++++++ .../src/lib/follow-pascal-targets.test.ts | 64 +++++ .../editor/src/lib/follow-pascal-targets.ts | 21 +- packages/editor/src/lib/follow-pascal.ts | 7 + packages/nodes/src/door/definition.ts | 2 +- packages/nodes/src/window/definition.ts | 2 +- wiki/architecture/construction-animation.md | 4 +- 10 files changed, 531 insertions(+), 10 deletions(-) create mode 100644 packages/editor/src/lib/follow-pascal-exterior.test.ts create mode 100644 packages/editor/src/lib/follow-pascal-exterior.ts diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx index 5b8d453bf5..33024d04de 100644 --- a/packages/editor/src/components/editor/follow-pascal.test.tsx +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -8,9 +8,12 @@ import { LevelNode, loadPlugin, nodeRegistry, + RoofNode, runAsSceneCommitAuthor, sceneRegistry, useScene, + WallNode, + WindowNode, } from '@pascal-app/core' import { startConstructionReveal, useViewer } from '@pascal-app/viewer' import { act, create } from '@react-three/test-renderer' @@ -54,6 +57,39 @@ 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'], + }, + geometry: () => null, + } as unknown as AnyNodeDefinition, + { + kind: 'roof', + schemaVersion: 1, + schema: RoofNode, + category: 'structure', + defaults: () => ({}), + capabilities: { reveal: { phase: 'roof', style: 'assemble', height: 4 } }, + 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'], + }, + geometry: () => null, + } as unknown as AnyNodeDefinition, ], }) useFollowPascal.setState({ on: true, paused: false, building: false }) @@ -69,7 +105,8 @@ afterEach(async () => { await renderer?.unmount() renderer = null restoreRegistry() - sceneRegistry.nodes.delete('item_follow') + for (const id of ['item_follow', 'item_sofa', 'window_east', 'wall_east', 'roof_east']) + sceneRegistry.nodes.delete(id) useScene.setState(previousScene) useViewer.setState(previousViewer) useEditor.setState(previousEditor) @@ -410,3 +447,203 @@ 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'] }, + // 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) +}) diff --git a/packages/editor/src/components/editor/follow-pascal.tsx b/packages/editor/src/components/editor/follow-pascal.tsx index 29a2de13ea..456fe24724 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,18 @@ 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) + 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 } @@ -192,6 +209,8 @@ export function FollowPascal() { 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 @@ -219,6 +238,14 @@ export function FollowPascal() { store.beginBuild() follower.current.reset() synced.current = false + outsideBuild.current = false + } + // What the commit changes, read before its first piece plays: one change seen from outside + // takes the whole commit outdoors. + if (!outsideBuild.current) { + const changed = revealChangedNodeIds() + if (changed.length && anyExterior(useScene.getState().nodes, changed)) + outsideBuild.current = true } over.current = setTimeout(() => useFollowPascal.getState().endBuild(), BUILD_OVER_MS) if (event.type === 'complete') return 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..b11f2ec865 --- /dev/null +++ b/packages/editor/src/lib/follow-pascal-exterior.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + BuildingNode, + DoorNode, + ItemNode, + LevelNode, + RoofNode, + RoofSegmentNode, + SiteNode, + WallNode, +} from '@pascal-app/core' +import { anyExterior, isExteriorNode } from './follow-pascal-exterior' + +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 its kind and parent matter here; its samples are not read. + const tile = { + id: 'terrain-tile_x', + type: 'terrain-tile', + parentId: site.id, + } as unknown as AnyNode + return Object.fromEntries( + [site, building, level, outer, inner, onOuter, onInner, sofa, bench, roof, segment, tile].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', 'terrain-tile_x', 'item_bench']) + expect(isExteriorNode(nodes, id)).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..f605bad98c --- /dev/null +++ b/packages/editor/src/lib/follow-pascal-exterior.ts @@ -0,0 +1,74 @@ +import { type AnyNode, type AnyNodeId, findLevelAncestorId } from '@pascal-app/core' + +type Nodes = Readonly> + +/** + * Kinds that stand outdoors whatever hosts them: seen from outside, never from a cutaway into a + * room. A roof and what stands on it, the ground and what is cut into it, a fence, a facade. + */ +const OUTDOOR_KINDS: ReadonlySet = new Set([ + 'roof', + 'roof-segment', + 'dormer', + 'skylight', + 'chimney', + 'cupola', + 'solar-panel', + 'gutter', + 'downspout', + 'eyebrow-vent', + 'ridge-vent', + 'turbine-vent', + 'box-vent', + 'lean-to-extension', + 'fence', + 'fence-feature', + 'ramp', + 'rock', + 'terrain-tile', + 'terrain-break', + 'site', + 'facade', +]) + +/** Whether a wall has an outdoor face: the cutaway treats it as a wall seen from outside or inside. */ +function exteriorWall(node: AnyNode): boolean { + if (node.type !== 'wall') return false + return node.frontSide === 'exterior' || node.backSide === 'exterior' +} + +/** + * Whether a change to this node is seen from outside: a wall with an exterior face, an opening in + * one, a roof or what stands on it, the terrain, a facade part, or anything standing 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 + if (OUTDOOR_KINDS.has(node.type)) return true + if (node.type === 'wall') return exteriorWall(node) + if (node.type === 'door' || node.type === 'window') { + if ((node as { roofSegmentId?: string | null }).roofSegmentId) return true + const host = nodes[(node as { wallId?: string | null }).wallId ?? node.parentId ?? ''] + return host ? exteriorWall(host) : false + } + // A manual outdoor surface (a patio, a path) follows the ground. + if (node.type === 'slab' && (node as { terrainMode?: string }).terrainMode !== undefined) + return true + // Standing under a node that is outdoors (a dormer's window, a chimney's cap). + for ( + let at = node.parentId ? nodes[node.parentId] : undefined, guard = 0; + at && guard < 16; + at = at.parentId ? nodes[at.parentId] : undefined, guard += 1 + ) + if (OUTDOOR_KINDS.has(at.type) && at.type !== 'site') return true + // Furnishing outdoors: no storey of a building to stand in. + if (node.type === 'level' || node.type === 'building' || node.type === 'zone') return false + 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-targets.test.ts b/packages/editor/src/lib/follow-pascal-targets.test.ts index 5826016240..9bb6bd8d8d 100644 --- a/packages/editor/src/lib/follow-pascal-targets.test.ts +++ b/packages/editor/src/lib/follow-pascal-targets.test.ts @@ -297,3 +297,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 a45dac75bc..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,10 +151,13 @@ 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. + // 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) - return box ? { kind: 'detail', box, at } : null + if (!box) return null + const normal = sceneViewFacing(nodes, event.id) + return { kind: 'detail', box, ...(normal ? { normal } : {}), at } } return null } 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/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 4db8cd1837..3bb41ff90c 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -237,7 +237,7 @@ export const doorDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'openings', style: 'cut' }, - revealOnEdit: ['width', 'height', 'openingShape', 'openingKind', 'doorType'], + revealOnEdit: ['position', 'width', 'height', 'openingShape', 'openingKind', 'doorType'], batchable: doorBatchable, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 1557b28059..f3dcb4bc86 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -296,7 +296,7 @@ export const windowDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'openings', style: 'cut' }, - revealOnEdit: ['width', 'height', 'openingShape', 'windowType'], + revealOnEdit: ['position', 'width', 'height', 'openingShape', 'windowType'], batchable: windowBatchable, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index 70d7b3b8b9..acfab12905 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -72,6 +72,8 @@ A deletion plays the build backwards for every node it removes (`playDeletions`, 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`). @@ -79,7 +81,7 @@ The viewport's toolbar toggle (on by default, persisted as `pascal-follow-pascal - **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 From a7db00f8fad97c13fa05f605ada8c143c37dab0b Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 17:14:51 +0200 Subject: [PATCH 29/43] nodes: a window moved along its wall replays; a wall moved does not replay its windows The viewer index exports commitEditCandidates for the test. Co-Authored-By: Claude Sonnet 5.5 --- .../nodes/src/window/reveal-on-move.test.ts | 124 ++++++++++++++++++ packages/viewer/src/index.ts | 2 +- .../construction-reveal.test.tsx | 87 +++++++++++- .../construction-reveal.tsx | 2 + .../construction-reveal/ghost-dress.test.ts | 103 +++++++++++++++ .../construction-reveal/ghost-dress.ts | 34 +++++ wiki/architecture/construction-animation.md | 2 +- 7 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 packages/nodes/src/window/reveal-on-move.test.ts create mode 100644 packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts create mode 100644 packages/viewer/src/systems/construction-reveal/ghost-dress.ts 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/viewer/src/index.ts b/packages/viewer/src/index.ts index 3055f60010..d4c521d3ff 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -189,7 +189,7 @@ export { type RendererInitializationResult, type RendererPowerPreference, } from './lib/renderer-capability' -export { commitRevealCandidates } from './lib/reveal-candidates' +export { commitEditCandidates, commitRevealCandidates } from './lib/reveal-candidates' export { ANIMATION_SPEEDS, type AnimationSpeed, 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 21d30f849d..85fd32ff84 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -22,7 +22,8 @@ import { useScene, } from '@pascal-app/core' import { act, create } from '@react-three/test-renderer' -import { BoxGeometry, Group, Matrix4, Mesh, 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' @@ -128,6 +129,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), ) @@ -366,6 +382,7 @@ beforeEach(async () => { nodes: [ Slab.definition, Wall.definition, + RealWall.definition, QueuedWall.definition, Door.definition, Item.definition, @@ -1767,6 +1784,74 @@ describe('a deletion plays the creation backwards', () => { }) } + // 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 mesh = new Mesh(new BoxGeometry(4, 2.5, 0.2), [stipple, stipple, stipple]) + mesh.userData.wallHidden = true + 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[] = [] diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 1b380a8d92..379e5147c4 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -75,6 +75,7 @@ import { } from '../../lib/reveal-wipe' import useViewer from '../../store/use-viewer' import { ConstructionDust } from './construction-dust' +import { dressDeletionGhost } from './ghost-dress' /** * Construction reveal: what an agent builds appears as a construction, not a @@ -1775,6 +1776,7 @@ function playDeletions(commit: SceneCommit) { if (!(original && own)) continue const root = original.clone(true) drawnInScenePass(root) + dressDeletionGhost(original, root, commit.before.nodes[entry.id as AnyNodeId]) 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. 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..4f831be97a --- /dev/null +++ b/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { LevelNode, useScene, WallNode } from '@pascal-app/core' +import { BoxGeometry, Mesh, MeshBasicMaterial } from 'three' +import { registerRevealGhostDresser } from '../../lib/reveal-ghost' +import useViewer from '../../store/use-viewer' +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) { + const stipple = new MeshBasicMaterial({ transparent: true, opacity: 0.2 }) + const source = new Mesh(new BoxGeometry(4, 2.5, 0.2), [stipple, stipple, stipple]) + source.userData.wallHidden = hidden + 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, node) + 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) + }) + + 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, node) + 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, node) + expect(clone.material).toBe(worn) + } + }) + + test('is left alone for anything that is not a wall', () => { + useViewer.setState({ wallMode: 'cutaway' }) + const node = wall() + const { source, clone } = cutAway(node, true) + const worn = clone.material + dressDeletionGhost(source, clone, { ...node, type: 'fence' } as never) + expect(clone.material).toBe(worn) + }) + + test('is offered to a kind that dresses what it shows', () => { + const seen: [unknown, unknown][] = [] + const stop = registerRevealGhostDresser((source, clone) => seen.push([source, clone])) + const node = wall() + const { source, clone } = cutAway(node, false) + dressDeletionGhost(source, clone, node) + stop() + expect(seen).toEqual([[source, clone]]) + dressDeletionGhost(source, clone, node) + expect(seen).toHaveLength(1) + }) +}) 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..8dd981a455 --- /dev/null +++ b/packages/viewer/src/systems/construction-reveal/ghost-dress.ts @@ -0,0 +1,34 @@ +import { type AnyNode, useScene, type WallNode } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import useViewer from '../../store/use-viewer' +import { getWallFinishRefs } from '../wall/wall-finish-data' +import { getMaterialsForWall } from '../wall/wall-materials' + +/** + * 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 wall the cutaway was hiding wears its stipple, so its + * deletion would play as faint dots: it wears the wall's own visible variant instead, the one a + * capture draws (`wallsForCapture`), for as long as it plays. A wall the cutaway showed, and any + * other wall mode, keep what they wear. + */ +export function dressDeletionGhost(source: Object3D, clone: Object3D, node: AnyNode | undefined) { + const viewer = useViewer.getState() + const mesh = clone as Mesh + if ( + node?.type === 'wall' && + viewer.wallMode === 'cutaway' && + source.userData.wallHidden === true && + mesh.isMesh && + mesh.geometry + ) { + mesh.material = getMaterialsForWall( + node as WallNode, + viewer.shading, + viewer.textures, + viewer.colorPreset, + viewer.sceneTheme, + useScene.getState().materials, + getWallFinishRefs(mesh.geometry), + ).visible + } +} diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index acfab12905..612097cc11 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -62,7 +62,7 @@ At Full a roof is an `assemble` node: its parts drop in drawn apart and the merg **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. +**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; a roof that was veiled wears its own materials (the veil registers a dresser with `registerRevealGhostDresser`). 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 From 950a7075053811d846439e12a8fd8e24c2918526 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 17:14:51 +0200 Subject: [PATCH 30/43] viewer: a deleted wall plays its deletion in its own picture, not under the cutaway The picture a deleted wall plays is a copy of what stood there and wore the material its source had in that frame: for a wall the cutaway was hiding, the stipple, for the whole reverse play. The copy now wears the wall's own visible variant, as a capture draws it; a wall the cutaway showed and the person's other wall modes keep what they wear. Co-Authored-By: Claude Sonnet 5.5 --- .../systems/construction-reveal/ghost-dress.test.ts | 13 ------------- wiki/architecture/construction-animation.md | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts b/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts index 4f831be97a..39b0badfdb 100644 --- a/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts +++ b/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test' import { LevelNode, useScene, WallNode } from '@pascal-app/core' import { BoxGeometry, Mesh, MeshBasicMaterial } from 'three' -import { registerRevealGhostDresser } from '../../lib/reveal-ghost' import useViewer from '../../store/use-viewer' import { getMaterialsForWall } from '../wall/wall-materials' import { dressDeletionGhost } from './ghost-dress' @@ -88,16 +87,4 @@ describe('the picture of a deleted wall', () => { dressDeletionGhost(source, clone, { ...node, type: 'fence' } as never) expect(clone.material).toBe(worn) }) - - test('is offered to a kind that dresses what it shows', () => { - const seen: [unknown, unknown][] = [] - const stop = registerRevealGhostDresser((source, clone) => seen.push([source, clone])) - const node = wall() - const { source, clone } = cutAway(node, false) - dressDeletionGhost(source, clone, node) - stop() - expect(seen).toEqual([[source, clone]]) - dressDeletionGhost(source, clone, node) - expect(seen).toHaveLength(1) - }) }) diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index 612097cc11..5ea672f128 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -62,7 +62,7 @@ At Full a roof is an `assemble` node: its parts drop in drawn apart and the merg **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; a roof that was veiled wears its own materials (the veil registers a dresser with `registerRevealGhostDresser`). A wall the cutaway showed, and the person's other wall modes, keep what they wear. +**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. 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 From a773c15ba1e03d8699261a8e393d7b2d772d2bf1 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 19:04:11 +0200 Subject: [PATCH 31/43] Format the Framing tests with main's Biome 2.5 --- .../components/editor/follow-pascal.test.tsx | 158 +++++++++--------- packages/nodes/src/reveal-coverage.test.ts | 45 ++--- .../src/wall/wall-batch-canonical.test.ts | 26 +-- 3 files changed, 116 insertions(+), 113 deletions(-) diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx index abe8e90002..2318191c02 100644 --- a/packages/editor/src/components/editor/follow-pascal.test.tsx +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -568,83 +568,83 @@ test.each([ { person: 'up' as const, written: ['cutaway'] }, // 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) +])( + '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) }) - 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) -}) + 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) + }, +) diff --git a/packages/nodes/src/reveal-coverage.test.ts b/packages/nodes/src/reveal-coverage.test.ts index abf388888a..5406998980 100644 --- a/packages/nodes/src/reveal-coverage.test.ts +++ b/packages/nodes/src/reveal-coverage.test.ts @@ -47,22 +47,24 @@ function staged(type: string, at: 'full' | 'framing', parentId: string) { 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(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.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) @@ -80,11 +82,12 @@ describe('the fields an edit replays a node for', () => { expect(kinds).toContain(kind) }) - 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) - }) + 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/wall/wall-batch-canonical.test.ts b/packages/nodes/src/wall/wall-batch-canonical.test.ts index 06c50b9acb..1851c09c5f 100644 --- a/packages/nodes/src/wall/wall-batch-canonical.test.ts +++ b/packages/nodes/src/wall/wall-batch-canonical.test.ts @@ -681,16 +681,16 @@ async function disposeRevealFixture(fixture: RevealFixture) { 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) - } -}) +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) + } + }, +) From 4e832accbc2ffe3cc8298c586bb7cc30c37463d6 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 19:16:07 +0200 Subject: [PATCH 32/43] follow-pascal test: type the written walls modes as string[] for main's stricter toEqual --- packages/editor/src/components/editor/follow-pascal.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx index 2318191c02..c34fee8da9 100644 --- a/packages/editor/src/components/editor/follow-pascal.test.tsx +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -565,7 +565,7 @@ test('a commit with a window and a sofa in it takes the outside view', async () // 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'] }, + { 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[] }, ])( From a7b567a055ba4174dcde6a299ff309cf43bf1e7d Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Thu, 15 Oct 2026 19:20:16 +0200 Subject: [PATCH 33/43] nodes: a roof segment replays its reveal for the barrel arch and the glazing main added --- packages/nodes/src/reveal-coverage.test.ts | 7 +++++++ packages/nodes/src/roof-segment/definition.ts | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/packages/nodes/src/reveal-coverage.test.ts b/packages/nodes/src/reveal-coverage.test.ts index 5406998980..7929e75bb2 100644 --- a/packages/nodes/src/reveal-coverage.test.ts +++ b/packages/nodes/src/reveal-coverage.test.ts @@ -82,6 +82,13 @@ describe('the fields an edit replays a node for', () => { 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) => { diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index fdb6b44d44..b120f48b4c 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -293,6 +293,10 @@ export const roofSegmentDefinition: NodeDefinition = { 'overhang', 'wallThickness', 'deckThickness', + 'barrelRise', + 'barrelSegments', + 'roofSystem', + 'glazing', ], paint: roofSegmentPaint, slots: (node) => roofSegmentSlots(node as RoofSegmentNodeType), From 5e1a0a00c2f56030a1e1cdfa6b608be355a04f31 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 00:39:52 +0200 Subject: [PATCH 34/43] viewer: a deletion ghost keeps its material when the wall palette cannot draw its groups --- .../construction-reveal/ghost-dress.test.ts | 22 +++++++++++++++++-- .../construction-reveal/ghost-dress.ts | 7 +++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts b/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts index 39b0badfdb..a8f837c63e 100644 --- a/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts +++ b/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts @@ -1,6 +1,7 @@ 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 { getMaterialsForWall } from '../wall/wall-materials' import { dressDeletionGhost } from './ghost-dress' @@ -26,9 +27,15 @@ function wall() { }) } -function cutAway(node: ReturnType, hidden: boolean) { +function cutAway(node: ReturnType, hidden: boolean, groupIndex = 0) { const stipple = new MeshBasicMaterial({ transparent: true, opacity: 0.2 }) - const source = new Mesh(new BoxGeometry(4, 2.5, 0.2), [stipple, stipple, stipple]) + 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 const clone = source.clone() return { source, clone, stipple, node } @@ -59,6 +66,17 @@ describe('the picture of a deleted wall', () => { 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, node) + 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' }) diff --git a/packages/viewer/src/systems/construction-reveal/ghost-dress.ts b/packages/viewer/src/systems/construction-reveal/ghost-dress.ts index 8dd981a455..0e2a7f89e1 100644 --- a/packages/viewer/src/systems/construction-reveal/ghost-dress.ts +++ b/packages/viewer/src/systems/construction-reveal/ghost-dress.ts @@ -1,5 +1,6 @@ import { type AnyNode, useScene, type WallNode } from '@pascal-app/core' import type { Mesh, Object3D } from 'three' +import { hasMaterialsForGroups } from '../../lib/pointer-events' import useViewer from '../../store/use-viewer' import { getWallFinishRefs } from '../wall/wall-finish-data' import { getMaterialsForWall } from '../wall/wall-materials' @@ -21,7 +22,7 @@ export function dressDeletionGhost(source: Object3D, clone: Object3D, node: AnyN mesh.isMesh && mesh.geometry ) { - mesh.material = getMaterialsForWall( + const visible = getMaterialsForWall( node as WallNode, viewer.shading, viewer.textures, @@ -30,5 +31,9 @@ export function dressDeletionGhost(source: Object3D, clone: Object3D, node: AnyN useScene.getState().materials, getWallFinishRefs(mesh.geometry), ).visible + // 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. + if (hasMaterialsForGroups(mesh, visible)) mesh.material = visible } } From 491ee102913bd91cdeb7645cd6113e89da6fd930 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 00:41:52 +0200 Subject: [PATCH 35/43] viewer: the deletion reveal test's wall mesh carries groups its palette covers --- .../construction-reveal/construction-reveal.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 85fd32ff84..e54486e60f 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -1794,7 +1794,11 @@ describe('a deletion plays the creation backwards', () => { 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 mesh = new Mesh(new BoxGeometry(4, 2.5, 0.2), [stipple, stipple, stipple]) + 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 root(wall.id)!.parent!.add(mesh) sceneRegistry.nodes.set(wall.id, mesh) From 01e52b15b03bf04c7063f81d0063182b55435373 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 00:58:28 +0200 Subject: [PATCH 36/43] core: capabilities.exterior and capabilities.revealLayout on a kind's definition --- packages/core/src/registry/index.ts | 2 ++ packages/core/src/registry/types.ts | 39 +++++++++++++++++++++ wiki/architecture/construction-animation.md | 2 +- wiki/architecture/node-definitions.md | 30 ++++++++++++++++ wiki/architecture/plugin-authoring.md | 2 +- 5 files changed, 73 insertions(+), 2 deletions(-) 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 e569e9cfdf..0fe6568c2c 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1732,6 +1732,25 @@ export type Capabilities = { * `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 @@ -2601,6 +2620,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/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index 5ea672f128..1c1b153aa7 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -62,7 +62,7 @@ At Full a roof is an `assemble` node: its parts drop in drawn apart and the merg **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. A wall the cutaway showed, and the person's other wall modes, keep what they wear. +**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 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 ab1f85bc13..f2a43af65e 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). From c11f33298f1230e6459aba8b277dabafbff9a989 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 00:58:28 +0200 Subject: [PATCH 37/43] nodes: kinds declare what is seen from outside and what a room is in a plan --- packages/nodes/src/box-vent/definition.ts | 1 + packages/nodes/src/building/definition.ts | 1 + packages/nodes/src/chimney/definition.ts | 1 + packages/nodes/src/cupola/definition.ts | 1 + packages/nodes/src/door/definition.ts | 2 + packages/nodes/src/dormer/definition.ts | 1 + packages/nodes/src/downspout/definition.ts | 1 + .../nodes/src/exterior-declarations.test.ts | 134 ++++++++++++++++++ packages/nodes/src/eyebrow-vent/definition.ts | 1 + .../nodes/src/fence-feature/definition.ts | 6 +- packages/nodes/src/fence/definition.ts | 1 + packages/nodes/src/gutter/definition.ts | 1 + .../nodes/src/lean-to-extension/definition.ts | 1 + packages/nodes/src/level/definition.ts | 1 + packages/nodes/src/ridge-vent/definition.ts | 1 + packages/nodes/src/roof-segment/definition.ts | 1 + packages/nodes/src/roof/definition.ts | 1 + packages/nodes/src/site/definition.ts | 1 + packages/nodes/src/skylight/definition.ts | 1 + packages/nodes/src/solar-panel/definition.ts | 1 + packages/nodes/src/turbine-vent/definition.ts | 1 + packages/nodes/src/wall/definition.ts | 3 + packages/nodes/src/wall/exterior.ts | 17 +++ packages/nodes/src/window/definition.ts | 2 + packages/nodes/src/zone/definition.ts | 2 + 25 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 packages/nodes/src/exterior-declarations.test.ts create mode 100644 packages/nodes/src/wall/exterior.ts 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/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/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 3bb41ff90c..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,7 @@ export const doorDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'openings', style: 'cut' }, + exterior: openingSeenFromOutside, revealOnEdit: ['position', 'width', 'height', 'openingShape', 'openingKind', 'doorType'], batchable: doorBatchable, selectable: { hitVolume: 'bbox' }, 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 a9a2b23d82..708ef29d41 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -384,6 +384,7 @@ export const fenceDefinition: NodeDefinition = { }), capabilities: { + exterior: true, reveal: { phase: 'structure', style: 'rise' }, revealOnEdit: [ 'start', 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/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/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 b120f48b4c..17024a27eb 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -284,6 +284,7 @@ export const roofSegmentDefinition: NodeDefinition = { }, capabilities: { + exterior: true, revealOnEdit: [ 'roofType', 'width', 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/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/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/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/definition.ts b/packages/nodes/src/wall/definition.ts index 3348e4ddda..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,8 @@ 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 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/window/definition.ts b/packages/nodes/src/window/definition.ts index f3dcb4bc86..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,7 @@ export const windowDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'openings', style: 'cut' }, + exterior: openingSeenFromOutside, revealOnEdit: ['position', 'width', 'height', 'openingShape', 'windowType'], batchable: windowBatchable, selectable: { hitVolume: 'bbox' }, 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 From 16f1b618b61db1fae648393e11263f7a06a17f2c Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 00:58:28 +0200 Subject: [PATCH 38/43] editor: Follow Pascal asks the registry what is seen from outside, not a list of kind names --- .../components/editor/follow-pascal.test.tsx | 5 +- .../follow-pascal-exterior.test-fixture.ts | 17 +++ .../src/lib/follow-pascal-exterior.test.ts | 100 ++++++++++++++++-- .../editor/src/lib/follow-pascal-exterior.ts | 71 ++++--------- .../src/lib/follow-pascal-targets.test.ts | 28 +++++ 5 files changed, 156 insertions(+), 65 deletions(-) create mode 100644 packages/editor/src/lib/follow-pascal-exterior.test-fixture.ts diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx index c34fee8da9..4c936d05d6 100644 --- a/packages/editor/src/components/editor/follow-pascal.test.tsx +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -18,6 +18,7 @@ import { import { startConstructionReveal, useViewer } from '@pascal-app/viewer' import { act, create } from '@react-three/test-renderer' 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' @@ -66,6 +67,7 @@ beforeEach(async () => { capabilities: { reveal: { phase: 'openings', style: 'cut' }, revealOnEdit: ['position'], + exterior: openingExterior, }, geometry: () => null, } as unknown as AnyNodeDefinition, @@ -75,7 +77,7 @@ beforeEach(async () => { schema: RoofNode, category: 'structure', defaults: () => ({}), - capabilities: { reveal: { phase: 'roof', style: 'assemble', height: 4 } }, + capabilities: { reveal: { phase: 'roof', style: 'assemble', height: 4 }, exterior: true }, geometry: () => null, } as unknown as AnyNodeDefinition, { @@ -87,6 +89,7 @@ beforeEach(async () => { capabilities: { reveal: { phase: 'structure', style: 'rise' }, revealOnEdit: ['start', 'end', 'curveOffset'], + exterior: wallExterior, }, geometry: () => null, } as unknown as AnyNodeDefinition, 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 index b11f2ec865..b882d80d5c 100644 --- a/packages/editor/src/lib/follow-pascal-exterior.test.ts +++ b/packages/editor/src/lib/follow-pascal-exterior.test.ts @@ -1,16 +1,60 @@ -import { describe, expect, test } from 'bun:test' +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' }) @@ -49,16 +93,39 @@ function scene() { 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 its kind and parent matter here; its samples are not read. - const tile = { - id: 'terrain-tile_x', - type: 'terrain-tile', - parentId: site.id, - } as unknown as AnyNode + // 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, tile].map( - (node) => [node.id, node as AnyNode], - ), + [ + 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]), ) } @@ -77,10 +144,21 @@ describe('what a change is seen from', () => { }) 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', 'terrain-tile_x', 'item_bench']) + 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) }) diff --git a/packages/editor/src/lib/follow-pascal-exterior.ts b/packages/editor/src/lib/follow-pascal-exterior.ts index f605bad98c..f984a2eaef 100644 --- a/packages/editor/src/lib/follow-pascal-exterior.ts +++ b/packages/editor/src/lib/follow-pascal-exterior.ts @@ -1,70 +1,35 @@ -import { type AnyNode, type AnyNodeId, findLevelAncestorId } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, findLevelAncestorId, nodeRegistry } from '@pascal-app/core' type Nodes = Readonly> -/** - * Kinds that stand outdoors whatever hosts them: seen from outside, never from a cutaway into a - * room. A roof and what stands on it, the ground and what is cut into it, a fence, a facade. - */ -const OUTDOOR_KINDS: ReadonlySet = new Set([ - 'roof', - 'roof-segment', - 'dormer', - 'skylight', - 'chimney', - 'cupola', - 'solar-panel', - 'gutter', - 'downspout', - 'eyebrow-vent', - 'ridge-vent', - 'turbine-vent', - 'box-vent', - 'lean-to-extension', - 'fence', - 'fence-feature', - 'ramp', - 'rock', - 'terrain-tile', - 'terrain-break', - 'site', - 'facade', -]) - -/** Whether a wall has an outdoor face: the cutaway treats it as a wall seen from outside or inside. */ -function exteriorWall(node: AnyNode): boolean { - if (node.type !== 'wall') return false - return node.frontSide === 'exterior' || node.backSide === 'exterior' +/** 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: a wall with an exterior face, an opening in - * one, a roof or what stands on it, the terrain, a facade part, or anything standing 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. + * 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 - if (OUTDOOR_KINDS.has(node.type)) return true - if (node.type === 'wall') return exteriorWall(node) - if (node.type === 'door' || node.type === 'window') { - if ((node as { roofSegmentId?: string | null }).roofSegmentId) return true - const host = nodes[(node as { wallId?: string | null }).wallId ?? node.parentId ?? ''] - return host ? exteriorWall(host) : false - } - // A manual outdoor surface (a patio, a path) follows the ground. - if (node.type === 'slab' && (node as { terrainMode?: string }).terrainMode !== undefined) - return true - // Standing under a node that is outdoors (a dormer's window, a chimney's cap). + 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 - ) - if (OUTDOOR_KINDS.has(at.type) && at.type !== 'site') return true - // Furnishing outdoors: no storey of a building to stand in. - if (node.type === 'level' || node.type === 'building' || node.type === 'zone') return false + ) { + 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 } diff --git a/packages/editor/src/lib/follow-pascal-targets.test.ts b/packages/editor/src/lib/follow-pascal-targets.test.ts index 9bb6bd8d8d..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, From 40f2ee5097726938bdbc53dab3370dc917e08373 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 00:58:28 +0200 Subject: [PATCH 39/43] viewer: the reveal asks the registry for its host, rooms and lines, and dresses a deletion ghost from the wall cutout --- .../viewer/src/lib/reveal-add-volume.test.ts | 25 ++- packages/viewer/src/lib/reveal-candidates.ts | 74 ++++--- .../src/lib/reveal-declared-kinds.test.ts | 199 ++++++++++++++++++ .../construction-reveal.test.tsx | 3 + .../construction-reveal.tsx | 2 +- .../construction-reveal/ghost-dress.test.ts | 17 +- .../construction-reveal/ghost-dress.ts | 37 ++-- .../systems/wall/wall-cutout-cache.test.ts | 25 +++ .../src/systems/wall/wall-cutout-cache.ts | 9 + 9 files changed, 330 insertions(+), 61 deletions(-) create mode 100644 packages/viewer/src/lib/reveal-declared-kinds.test.ts diff --git a/packages/viewer/src/lib/reveal-add-volume.test.ts b/packages/viewer/src/lib/reveal-add-volume.test.ts index d650311966..af0701fd05 100644 --- a/packages/viewer/src/lib/reveal-add-volume.test.ts +++ b/packages/viewer/src/lib/reveal-add-volume.test.ts @@ -8,6 +8,7 @@ import { LevelNode, loadPlugin, nodeRegistry, + type RevealLayout, RoofNode, RoofSegmentNode, type SceneCommit, @@ -27,14 +28,20 @@ import { FRAMING_PACE, LAZY_REVEAL_TIMING, planLazyReveal } from './reveal-sched // 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) => +const kind = ( + type: string, + schema: unknown, + category: string, + reveal?: unknown, + revealLayout?: RevealLayout, +) => ({ kind: type, schemaVersion: 1, schema, category, defaults: () => ({}), - capabilities: reveal ? { reveal } : {}, + capabilities: { ...(reveal ? { reveal } : {}), ...(revealLayout ? { revealLayout } : {}) }, geometry: () => new Group(), }) as unknown as AnyNodeDefinition @@ -49,8 +56,18 @@ beforeAll(async () => { kind('site', SiteNode, 'site'), kind('building', BuildingNode, 'site'), kind('level', LevelNode, 'site'), - kind('zone', ZoneNode, 'site'), - kind('wall', WallNode, 'structure', { phase: 'structure', style: 'rise' }), + 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' }), diff --git a/packages/viewer/src/lib/reveal-candidates.ts b/packages/viewer/src/lib/reveal-candidates.ts index 78f941efeb..d7da0d68e0 100644 --- a/packages/viewer/src/lib/reveal-candidates.ts +++ b/packages/viewer/src/lib/reveal-candidates.ts @@ -5,6 +5,7 @@ import { type LevelNode, nodeRegistry, type RevealConfig, + type RevealPlanPoint, type RevealStyle, type SceneCommit, } from '@pascal-app/core' @@ -40,7 +41,7 @@ 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 a roof, it falls onto it) and what it is. Null for the containers, + * 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 { @@ -50,7 +51,8 @@ function defaultReveal(node: AnyNode, nodes: Record): Reveal return null let parent = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined for (let guard = 0; parent && guard < 32; guard += 1) { - if (parent.type === 'roof') return { phase: 'roof', style: 'drop', height: 2 } + 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 } @@ -172,7 +174,7 @@ export function commitRevealCandidates( return candidates } -type Pt = readonly [number, number] +type Pt = RevealPlanPoint const isPt = (value: unknown): value is Pt => Array.isArray(value) && @@ -180,11 +182,28 @@ const isPt = (value: unknown): value is Pt => typeof value[0] === 'number' && typeof value[1] === 'number' -function polygonOf(node: AnyNode): Pt[] | null { +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 @@ -215,10 +234,12 @@ function inside([x, z]: Pt, polygon: readonly Pt[]): boolean { return within } -/** Where a node stands in its level's plan: a wall's middle, a polygon's centre, a placed node's spot. */ +/** 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 [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + 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') @@ -243,8 +264,9 @@ const WALLS_PER_RUN = 6 const MEETS_M = 0.05 /** - * Gives each candidate its room for the lazy schedule: the zone it stands in (a wall, the zone on - * either side of its middle), else, for a wall, a run of the walls it meets end to end; its room's + * 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. */ @@ -252,7 +274,7 @@ export function layoutRevealCandidates( candidates: StagedCandidate[], nodes: Record, ): void { - const zonesByLevel = new Map() + const zonesByLevel = new Map() const zonesOf = (levelId: string) => { let zones = zonesByLevel.get(levelId) if (zones) return zones @@ -260,7 +282,7 @@ export function layoutRevealCandidates( 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?.type === 'zone' ? polygonOf(child) : null + 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)) @@ -271,8 +293,8 @@ export function layoutRevealCandidates( const angle = Math.atan2(point[1] - centre[1], point[0] - centre[0]) return angle < 0 ? angle + 2 * Math.PI : angle } - // Walls in no zone, by level: they make runs; what they host follows them. - const loose = new Map() + // 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 @@ -280,9 +302,10 @@ export function layoutRevealCandidates( const point = standing && planPointOf(standing) if (!(standing && point)) continue const zones = zonesOf(candidate.levelId) + const line = lineOf(standing) let probes: Pt[] = [point] - if (standing.type === 'wall') { - const { start, end } = standing as unknown as { start: Pt; end: Pt } + 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 @@ -300,13 +323,13 @@ export function layoutRevealCandidates( candidate.order = turnAround(zone.centre, point) continue } - if (standing.type === 'wall' && standing.id === candidate.id) { + if (line && standing.id === candidate.id) { const list = loose.get(candidate.levelId) ?? [] - list.push({ candidate, wall: standing }) + list.push({ candidate, line }) loose.set(candidate.levelId, list) continue } - if (standing.type === 'wall') onLooseWalls.push({ candidate, wallId: standing.id, point }) + if (line) onLooseWalls.push({ candidate, wallId: standing.id, point }) candidate.order = point[0] + point[1] } for (const [levelId, walls] of loose) layoutRuns(levelId, walls) @@ -321,20 +344,19 @@ export function layoutRevealCandidates( } } -/** Walls in no room: the walls that meet end to end make an outline, cut in runs along it. */ -function layoutRuns(levelId: string, walls: { candidate: StagedCandidate; wall: AnyNode }[]) { +/** 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(({ wall }) => { - const { start, end } = wall as unknown as { start: Pt; end: Pt } - return [key(start), key(end)] as const - }) + 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 midOf = (index: number) => planPointOf(walls[index]!.wall) ?? [0, 0] const sweepOf = (index: number) => { - const [x, z] = midOf(index) + const [x, z] = middleOf(walls[index]!.line) return x + z } const seen = new Set() @@ -564,7 +586,7 @@ export function commitDeleteCandidates( for (const id in before.nodes) { const node = before.nodes[id as AnyNodeId] if (current.nodes[id as AnyNodeId] || !node?.parentId) continue - if (node.type === 'wall' || node.type === 'zone') goingLevels.add(node.parentId) + if (nodeRegistry.get(node.type)?.capabilities?.revealLayout) goingLevels.add(node.parentId) } for (const id in before.nodes) { if (current.nodes[id as AnyNodeId]) continue 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/systems/construction-reveal/construction-reveal.test.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx index e54486e60f..f8d27e6423 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -40,6 +40,7 @@ import { import { revealWipeLevelOf } from '../../lib/reveal-wipe' import useViewer from '../../store/use-viewer' import { GeometrySystem } from '../geometry/geometry-system' +import { visibleMaterialsOf } from '../wall/wall-cutout-cache' import { ConstructionDust } from './construction-dust' // Construction reveal, failure modes written before the code: a person's edit, @@ -1800,6 +1801,8 @@ describe('a deletion plays the creation backwards', () => { 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' }) diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 379e5147c4..15fbf5b6b4 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -1776,7 +1776,7 @@ function playDeletions(commit: SceneCommit) { if (!(original && own)) continue const root = original.clone(true) drawnInScenePass(root) - dressDeletionGhost(original, root, commit.before.nodes[entry.id as AnyNodeId]) + 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. diff --git a/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts b/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts index a8f837c63e..92c04a3b84 100644 --- a/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts +++ b/packages/viewer/src/systems/construction-reveal/ghost-dress.test.ts @@ -3,6 +3,7 @@ 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' @@ -37,6 +38,8 @@ function cutAway(node: ReturnType, hidden: boolean, groupIndex = 0) 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 } } @@ -60,7 +63,7 @@ describe('the picture of a deleted wall', () => { useViewer.setState({ wallMode: 'cutaway' }) const { source, clone, stipple } = cutAway(node, true) expect((clone.material as unknown[])[0]).toBe(stipple) - dressDeletionGhost(source, clone, node) + 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) @@ -72,7 +75,7 @@ describe('the picture of a deleted wall', () => { const node = wall() useViewer.setState({ wallMode: 'cutaway' }) const { source, clone, stipple } = cutAway(node, true, 99) - dressDeletionGhost(source, clone, node) + dressDeletionGhost(source, clone) expect((clone.material as unknown[])[0]).toBe(stipple) expect(hasMaterialsForGroups(clone)).toBe(true) }) @@ -82,7 +85,7 @@ describe('the picture of a deleted wall', () => { useViewer.setState({ wallMode: 'cutaway' }) const { source, clone } = cutAway(node, false) const worn = clone.material - dressDeletionGhost(source, clone, node) + dressDeletionGhost(source, clone) expect(clone.material).toBe(worn) }) @@ -92,17 +95,19 @@ describe('the picture of a deleted wall', () => { useViewer.setState({ wallMode }) const { source, clone } = cutAway(node, true) const worn = clone.material - dressDeletionGhost(source, clone, node) + dressDeletionGhost(source, clone) expect(clone.material).toBe(worn) } }) - test('is left alone for anything that is not a wall', () => { + // 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, { ...node, type: 'fence' } as never) + 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 index 0e2a7f89e1..0fb62551c8 100644 --- a/packages/viewer/src/systems/construction-reveal/ghost-dress.ts +++ b/packages/viewer/src/systems/construction-reveal/ghost-dress.ts @@ -1,39 +1,28 @@ -import { type AnyNode, useScene, type WallNode } from '@pascal-app/core' import type { Mesh, Object3D } from 'three' import { hasMaterialsForGroups } from '../../lib/pointer-events' import useViewer from '../../store/use-viewer' -import { getWallFinishRefs } from '../wall/wall-finish-data' -import { getMaterialsForWall } from '../wall/wall-materials' +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 wall the cutaway was hiding wears its stipple, so its - * deletion would play as faint dots: it wears the wall's own visible variant instead, the one a - * capture draws (`wallsForCapture`), for as long as it plays. A wall the cutaway showed, and any - * other wall mode, keep what they wear. + * 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, node: AnyNode | undefined) { - const viewer = useViewer.getState() +export function dressDeletionGhost(source: Object3D, clone: Object3D) { + const visible = visibleMaterialsOf.get(source) const mesh = clone as Mesh if ( - node?.type === 'wall' && - viewer.wallMode === 'cutaway' && + visible && + useViewer.getState().wallMode === 'cutaway' && source.userData.wallHidden === true && mesh.isMesh && - mesh.geometry - ) { - const visible = getMaterialsForWall( - node as WallNode, - viewer.shading, - viewer.textures, - viewer.colorPreset, - viewer.sceneTheme, - useScene.getState().materials, - getWallFinishRefs(mesh.geometry), - ).visible + 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. - if (hasMaterialsForGroups(mesh, visible)) mesh.material = visible - } + hasMaterialsForGroups(mesh, visible) + ) + mesh.material = visible } 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 36bebb99d8..0308ed09b8 100644 --- a/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts +++ b/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts @@ -34,6 +34,7 @@ import { } from './wall-cutout' import { sameMaterialArray, + visibleMaterialsOf, WALL_FACING_HYSTERESIS, WallCutoutCache, type WallCutoutViewerState, @@ -601,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' }) diff --git a/packages/viewer/src/systems/wall/wall-cutout-cache.ts b/packages/viewer/src/systems/wall/wall-cutout-cache.ts index df9e2ffc34..21a7ae7820 100644 --- a/packages/viewer/src/systems/wall/wall-cutout-cache.ts +++ b/packages/viewer/src/systems/wall/wall-cutout-cache.ts @@ -150,6 +150,13 @@ export function wallsInPlay( 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 } export class WallCutoutCache { @@ -418,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 { From bcb6fd013cfec48b1ff61c8128e098944e489ecd Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 01:22:16 +0200 Subject: [PATCH 40/43] core: derived-write notes cover what a write changed, and are dropped with a no-op commit A derived write that set a value a node already had, deleted a node that was not there, or cancelled out inside a history step left its note in the module map. The next real commit inherited it, and the reveal read that commit's own edit as derived and skipped its replay. Notes now take only the fields whose value changed and the nodes that exist, a no-op commit or transaction clears them, and applyNodeChanges notes its deletes as the whole node. --- .../core/src/lib/derived-commit-nodes.test.ts | 115 +++++++++++++++++- .../core/src/store/actions/node-actions.ts | 34 ++++-- packages/core/src/store/history-control.ts | 11 +- 3 files changed, 146 insertions(+), 14 deletions(-) diff --git a/packages/core/src/lib/derived-commit-nodes.test.ts b/packages/core/src/lib/derived-commit-nodes.test.ts index 412cd33dd5..17338a9f27 100644 --- a/packages/core/src/lib/derived-commit-nodes.test.ts +++ b/packages/core/src/lib/derived-commit-nodes.test.ts @@ -1,6 +1,13 @@ import { expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '../schema' -import { type SceneCommit, subscribeSceneCommits } from '../store/history-control' +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' @@ -74,3 +81,109 @@ test('a commit names what the reconciler wrote, and the fields of a node it only 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/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 847ceccc2d..6f7d728a6e 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -42,6 +42,7 @@ import { resolveMergedWallEndpoints, type WallAttachmentUpdate, } from '../../systems/wall/wall-merge' +import { areSemanticValuesEqual } from '../../utils/semantic-equal' import { convertDerivedPlateHoleWrites, DERIVED_WRITER_TOKEN, @@ -68,6 +69,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 @@ -1314,10 +1334,8 @@ const applyNodeChangesActionImpl = ( if (options?.derivedWriter === DERIVED_WRITER_TOKEN) noteDerivedNodeWrites([ ...(changes.create ?? []).map((op) => ({ id: op.node.id })), - ...(changes.update ?? []).map((update) => ({ - id: update.id, - fields: Object.keys(update.data), - })), + ...changedDerivedFields(get().nodes, changes.update ?? []), + ...(changes.delete ?? []).filter((id) => get().nodes[id]).map((id) => ({ id })), ]) const createOps = changes.create ?? [] @@ -1493,10 +1511,6 @@ const updateNodesActionImpl = ( deferDirty = true, ) => { if (get().readOnly) return - if (options?.derivedWriter === DERIVED_WRITER_TOKEN) - noteDerivedNodeWrites( - updates.map((update) => ({ id: update.id, fields: Object.keys(update.data) })), - ) const converted = convertDerivedPlateHoleWrites(get().nodes, { update: updates }, options) if (converted.create?.length || converted.delete?.length) { applyNodeChangesActionImpl(set, get, converted, options) @@ -1504,6 +1518,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() @@ -1965,7 +1981,7 @@ const deleteNodesActionImpl = ( ) => { if (get().readOnly) return if (options?.derivedWriter === DERIVED_WRITER_TOKEN) - noteDerivedNodeWrites(ids.map((id) => ({ id }))) + 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 348e83f6fd..8487bc690e 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -269,7 +269,10 @@ function emitSceneCommit(authored: 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, @@ -317,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 { From f936603cd10e6550f92ca6f593f6182e885717b2 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 01:25:29 +0200 Subject: [PATCH 41/43] viewer: what stands above a deleted floor keeps its place while the floor comes apart, then eases down --- .../construction-reveal.test.tsx | 240 ++++++++++++++++++ .../construction-reveal.tsx | 95 +++++++ .../viewer/src/systems/level/level-hold.ts | 17 ++ .../src/systems/level/level-system.test.ts | 47 ++++ .../viewer/src/systems/level/level-system.tsx | 13 +- wiki/architecture/construction-animation.md | 2 + 6 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 packages/viewer/src/systems/level/level-hold.ts 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 f8d27e6423..247221e44a 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -8,6 +8,7 @@ import { BuildingNode, clearSceneHistory, emitter, + getLevelElevations, ItemNode, LevelNode, loadPlugin, @@ -32,14 +33,18 @@ import { hasRevealPose } from '../../lib/reveal-pose' 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 } from '../wall/wall-cutout-cache' import { ConstructionDust } from './construction-dust' @@ -2136,6 +2141,241 @@ describe('deleting a floor or a room deconstructs it', () => { }) }) +// 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, + }) + 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 + } + for (const id of [ground, upper, roofLevel]) register(id) + const roof = make(Gable) + useScene.getState().createNode(roof, roofLevel) + renderer = await create( + <> + {[ground, upper, roofLevel].map((levelId) => ( + + ))} + + + , + ) + 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, + } + } + + 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) + }) + + 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() + 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) + } + }) + + 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) + } + }) +}) + // Victor run 12: the dust showed through walls. It drew on the overlay layer, which composites on // top of the scene with no depth test. describe('the dust', () => { diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 15fbf5b6b4..84efa35370 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, criticallyDamped, emitter, + getLevelElevations, isRestoringSceneHistory, nodeRegistry, type RevealPhase, @@ -74,6 +75,8 @@ import { setRevealWipe, } from '../../lib/reveal-wipe' import useViewer from '../../store/use-viewer' +import { releaseLevel, setHeldLevelY } from '../level/level-hold' +import { getLevelPresentationY } from '../level/level-utils' import { ConstructionDust } from './construction-dust' import { dressDeletionGhost } from './ghost-dress' @@ -1151,6 +1154,7 @@ function levelsAtWork(): string[] { 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] } @@ -1809,6 +1813,7 @@ function playDeletions(commit: SceneCommit) { ...(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. */ @@ -1850,6 +1855,93 @@ 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()]) { @@ -2282,6 +2374,7 @@ function tickReveals(now: number, frameMs?: number) { 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) { @@ -2305,6 +2398,7 @@ function tickReveals(now: number, frameMs?: number) { held.size > 0 || glides.size > 0 || ghosts.size > 0 || + levelHolds.size > 0 || changeQueue.length > 0 publishLevelsAtWork() if (busy || tickedBusy) notifyTicks() @@ -2345,6 +2439,7 @@ function finishReveals(keepHeld = false) { seatAllLifts() finishRetracts(keepHeld) finishGhosts() + releaseLevelHolds() finishChanges() completeBuild(clockMs, true) publishLevelsAtWork() 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 85fc7d20a0..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' @@ -241,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 b0d70e6ec3..f6da9a0d54 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -3,9 +3,12 @@ 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 @@ -33,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 @@ -48,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, diff --git a/wiki/architecture/construction-animation.md b/wiki/architecture/construction-animation.md index 1c1b153aa7..8e90afb4ba 100644 --- a/wiki/architecture/construction-animation.md +++ b/wiki/architecture/construction-animation.md @@ -68,6 +68,8 @@ At Full a roof is an `assemble` node: its parts drop in drawn apart and the merg 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. From 5f26ba6539c0b2ceabc65003285b851d0d9de76e Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 15:25:15 +0200 Subject: [PATCH 42/43] viewer, editor: Bugbot round 2: runs follow the outline, a departing opening keeps its wall in play, Follow reads an outside edit per commit - A run of loose walls is walked along the outline (an open chain from one of its ends), so a run is one stretch of it and the pieces come up around the outline. - A deleted node's picture plays from a snapshot out of the scene: the node it hung on (when it stays and is not a storey or a building) is published in nodesRevealing, so the cutaway leaves the wall a departing opening stood in. - Follow takes a commit outdoors only from the edit it is hearing: a build, a deletion or the edit after a finished play never inherits what another commit queued. --- .../components/editor/follow-pascal.test.tsx | 147 +++++++++++++++++- .../src/components/editor/follow-pascal.tsx | 15 +- .../src/lib/reveal-candidates-runs.test.ts | 120 ++++++++++++++ packages/viewer/src/lib/reveal-candidates.ts | 30 ++-- .../construction-reveal.test.tsx | 28 +++- .../construction-reveal.tsx | 23 ++- 6 files changed, 340 insertions(+), 23 deletions(-) create mode 100644 packages/viewer/src/lib/reveal-candidates-runs.test.ts diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx index 4c936d05d6..e1c366cc86 100644 --- a/packages/editor/src/components/editor/follow-pascal.test.tsx +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -15,7 +15,7 @@ import { 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, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' import { openingExterior, wallExterior } from '../../lib/follow-pascal-exterior.test-fixture' @@ -108,8 +108,9 @@ afterEach(async () => { await renderer?.unmount() renderer = null restoreRegistry() - for (const id of ['item_follow', 'item_sofa', 'window_east', 'wall_east', 'roof_east']) - sceneRegistry.nodes.delete(id) + 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) @@ -651,3 +652,143 @@ test.each([ 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 456fe24724..f7c3ac0656 100644 --- a/packages/editor/src/components/editor/follow-pascal.tsx +++ b/packages/editor/src/components/editor/follow-pascal.tsx @@ -164,6 +164,8 @@ 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) @@ -240,13 +242,18 @@ export function FollowPascal() { synced.current = false outsideBuild.current = false } - // What the commit changes, read before its first piece plays: one change seen from outside - // takes the whole commit outdoors. - if (!outsideBuild.current) { - const changed = revealChangedNodeIds() + // 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/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 d7da0d68e0..8e96d14bd9 100644 --- a/packages/viewer/src/lib/reveal-candidates.ts +++ b/packages/viewer/src/lib/reveal-candidates.ts @@ -37,7 +37,7 @@ export function styleAt(style: RevealStyle, at: ConstructionRevealLevel): Played } /** Kinds that hold the others: staging one would unmount everything under it. */ -const CONTAINERS: ReadonlySet = new Set(['site', 'building', 'level']) +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 @@ -359,6 +359,8 @@ function layoutRuns( 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) @@ -366,20 +368,22 @@ function layoutRuns( let outline = 0 for (const first of starts) { if (seen.has(first)) continue - // Walk the outline from its first wall in the sweep, wall to the wall it meets. + // 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 queue = [first] - seen.add(first) - while (queue.length > 0) { - const index = queue.shift()! + const stack = [begin] + while (stack.length > 0) { + const index = stack.pop()! + if (seen.has(index)) continue + seen.add(index) order.push(index) - for (const end of ends[index]!) { - for (const next of byEnd.get(end) ?? []) { - if (seen.has(next)) continue - seen.add(next) - queue.push(next) - } - } + stack.push(...meeting(index).reverse()) } order.forEach((index, rank) => { const run = Math.floor(rank / WALLS_PER_RUN) 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 247221e44a..668798ea54 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.test.tsx @@ -45,7 +45,7 @@ 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 } from '../wall/wall-cutout-cache' +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, @@ -1972,6 +1972,32 @@ describe('a deletion plays the creation backwards', () => { 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, diff --git a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx index 84efa35370..dab87154b7 100644 --- a/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx +++ b/packages/viewer/src/systems/construction-reveal/construction-reveal.tsx @@ -21,6 +21,7 @@ import { DustPool, emitAlongBase, emitAroundFoot } from '../../lib/construction- import { SCENE_LAYER } from '../../lib/layers' import { type ChangeCandidate, + CONTAINERS, type ConstructionRevealLevel, commitChangeCandidates, commitDeleteCandidates, @@ -1188,7 +1189,8 @@ function publishLevelsAtWork() { /** * 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. + * (`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() @@ -1196,6 +1198,7 @@ function nodesAtPlay(): string[] { 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] } @@ -1676,6 +1679,8 @@ type Ghost = { /** 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 @@ -1695,10 +1700,23 @@ const ghosts = new Map() * 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 ? sceneRegistry.nodes.get(above) : undefined) ?? own + 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 } /** @@ -1803,6 +1821,7 @@ function playDeletions(commit: SceneCommit) { levelId: entry.levelId, root, parent, + host: ghostHost(entry.id, commit), style: entry.style, height: entry.height, startMs: entry.startMs, From faa6e27c38ac06b3fffc7e11dbac87f7001b6e72 Mon Sep 17 00:00:00 2001 From: Adam NAILI Date: Fri, 16 Oct 2026 16:08:38 +0200 Subject: [PATCH 43/43] editor: Bugbot round 3: a click on the level that plays keeps a saved Framing the plugin cannot play yet --- .../construction-animation-setting.test.tsx | 30 +++++++++++++++++++ .../construction-animation-setting.tsx | 6 ++-- .../src/store/use-construction-reveal.ts | 14 +++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx index 8c74101d27..25c79f5530 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx @@ -8,9 +8,12 @@ import { objectId, useScene, } from '@pascal-app/core' +import type { ConstructionRevealLevel } from '@pascal-app/viewer' import { renderToStaticMarkup } from 'react-dom/server' import useConstructionReveal, { + chooseConstructionLevel, FRAMING_KIND, + resolveConstructionLevel, } from '../../../../../store/use-construction-reveal' import { AnimationSpeedSetting } from './animation-speed-setting' import { @@ -116,6 +119,33 @@ describe('the construction animation control, drawn', () => { }) }) +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. 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 b75c6e55e2..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,7 +1,8 @@ 'use client' import type { ConstructionRevealLevel } from '@pascal-app/viewer' -import useConstructionReveal, { +import { + chooseConstructionLevel, useConstructionRevealLevel, useFramingAvailable, } from '../../../../../store/use-construction-reveal' @@ -49,7 +50,6 @@ export function constructionLevelOptions(framingAvailable: boolean) { export function ConstructionAnimationSetting() { // What plays: the person's choice, or Framing where the framing plugin is installed and Full where it is not. const level = useConstructionRevealLevel() - const setLevel = useConstructionReveal((state) => state.setLevel) const framingAvailable = useFramingAvailable() const current = OPTIONS.find((option) => option.value === level) const options = constructionLevelOptions(framingAvailable).map((option) => { @@ -83,7 +83,7 @@ export function ConstructionAnimationSetting() { chooseConstructionLevel(next, framingAvailable)} options={options} value={level} /> diff --git a/packages/editor/src/store/use-construction-reveal.ts b/packages/editor/src/store/use-construction-reveal.ts index 17b078cc33..694eec7d8b 100644 --- a/packages/editor/src/store/use-construction-reveal.ts +++ b/packages/editor/src/store/use-construction-reveal.ts @@ -102,6 +102,20 @@ export function resolveConstructionLevel( 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)