Skip to content

Preserve authored scenes through conversion and level verification - #1108

Merged
Aymericr merged 47 commits into
mainfrom
feat/v2-scene-integration
Oct 19, 2026
Merged

Aymericr merged 47 commits into
mainfrom
feat/v2-scene-integration

Conversation

@Aymericr

@Aymericr Aymericr commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Combine the author_scene converter from #1107 with the retained-design and level exploration work. Re-conversion now keeps stable node IDs, preserves unrelated/manual changes and rejects overlapping edits. All scripted geometry and room finishes compile before one scene commit, shared by MCP and chat hosts.

Preserve room cameras alongside explicit Solo and Exploded presentation so roof separation and interior verification remain observable. Hosts using the evidence-led design workflow own their brief policy; existing MCP sessions retain their brief behavior.

How to test

  1. Convert a fixture, move one authored item manually, add another, and convert again. The manual changes survive; a conflicting authored move is refused without a scene mutation.
  2. Repeat conversion and verify node IDs and room boundaries remain stable. A populated scene with no conversion baseline is refused.
  3. Capture Solo and Exploded views, including a room camera.

Validation: 82 targeted converter/MCP/view tests pass; core, geometry-script, MCP and editor TypeScript builds pass. Full upstream CI runs on this PR.

Checklist

  • Targeted tests and package TypeScript builds pass
  • This PR targets main
  • Full upstream CI passes
  • Hosted end-to-end qualification completes in the companion private PR

Note

Medium Risk
Large changes to destructive author_scene merging and scene commits, plus roof-level and view-capture behavior; mitigated by conflict refusal and extensive tests but still core agent editing paths.

Overview
author_scene is now an incremental, building-scoped conversion instead of wiping and reminting levels. Stable payload-keyed node IDs and authorSceneState on the building support replace: true deltas that merge against the last candidate via authoredSceneChanges, keeping manual moves/adds/deletes and refusing overlapping edits; first-time conversion refuses buildings that already have native work without a baseline, and copied buildings cannot hijack another building’s conversion.

Hosts share one compile-then-commit path: @pascal-app/geometry-script/author-scene’s compileAuthorScene builds the full candidate (items, openings, floor/wall finishes) before finalizeAuthorScene applies a single patch set; MCP adds abort/assertActive, a scene-changed guard, optional buildingId, and re-registers author_scene in the tool list.

Retained authored roof/ceiling items (metadata.role) are treated as geometry for roof-level rules, verification counts, interior/view hiding, clipboard paste, and capture framing—not ordinary storey furniture.

view_scene gains optional stacked / exploded / solo presentation (with renderer confirmation), editor capture applies it, and bounds framing respects exploded vertical gap.

Site can persist presentation (lighting, exposure, edges, etc.) and showGround; ceilings and imported GLTF items keep authored PBR/unlit appearance more faithfully; authorSceneTool is exported for MCP.

Reviewed by Cursor Bugbot for commit effe912. Bugbot is set up for automated code reviews on this repo. Configure here.

Aymericr and others added 30 commits October 17, 2026 14:05
…, written as native nodes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, keep its provenance, answer the gate report

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d things stack, and keeps a door's keep-out to its swing side

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…one call

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tadata classified

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…il the chat can run it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r itself is not wiped

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n id map, square columns, slab finishes as slots, rugs allowed, shared enclosures reported

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ours per wall face, walls as hosts, and reports defaults and warnings

Four fresh agents converted real designs with author_scene and the payload could
not say what they knew: every window came out fixed, every door plain and hinged
with its handle on the hinge edge, sage became mid grey through the nearest
library colour, one colour painted both faces, baseboards and casings had no
wall to follow, and 43 twelve-millimetre "wall cap" slabs lifted the walls onto
them with nothing in the report to say so.

An opening now takes `type` (DoorType / WindowType), `style` (the door and
window style presets, applied as add_door/add_window apply them; a style on an
operable window is dropped and said), `leaves` and `code` (a module compiled
and stored as an item's, keyed `<wallId>/<index>`, written as the scripted
door or window). A door's handle is set opposite its hinge. A hex colour is one
exact scene material per distinct colour (roughness 0.9, metalness 0, named by
the hex) reused across walls, floors, regions and slabs; `colorLeft` /
`colorRight` paint one face (left = front = role a). `items[].host` accepts a
wall id: the item is written as a wall-side item on the face nearest its point,
facing the room, as place_items does. Hosted things and lights are written in
their host's frame, so a rotated or wall-hung host keeps them where they were.

The report gains `defaults` (one sentence per thing Pascal filled in or
dropped), `warnings` (`thin_slab`, `many_slabs`, `many_regions`: written, but
doubtful) and the defect `slab_over_wall`, which refuses a slab over a wall's
footprint with its top above half the wall's height.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…as scene materials

The host compiles every module the payload carries, the items' and the
openings' with code, keyed by the payload's own ids (`<wallId>/<index>` for
an opening), through the same compileAndStore as add_door and add_window, and
lends the operation the scene's materials so a hex colour is one exact scene
material rather than the nearest library colour. The stored payload drops an
opening's code as it drops an item's: each module is its own artifact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…wall hosts, defaults and warnings

The pascal-3d skill, its conversion workflow and the agent-surfaces page name
the payload's new fields and the report's defaults, warnings and
slab_over_wall, and carry two rules the acceptance runs needed: Pascal builds a
wall's cap, the floor plate and the ceiling itself, so a module's caps,
underlays and planks fold into the wall, the floor finish and the room (said in
notes), and whatever opens in the module carries an open clip so it keeps
working after conversion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An importer wrote 12 mm "wall caps" exactly over 2.6 m walls, and every
wall elected its own cap as its base: wall base 2.662, level height 5.26.
The election now drops a slab whose underside reaches the wall's own top
and whose footprint lies on the wall body (between its faces, within its
length, 5 cm of slack). The top is measured from the lowest candidate (an
authored height above it, else the storey plane), since the base being
elected cannot decide it. A floor or terrace reaching past the wall keeps
carrying it however short the wall, and an explicitly preferred host
stays. The slab-elevation clamp probes just past its bound instead of
past the storey plane, where a slab now reads as resting on the wall.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The viewer draws every material front side only (its WebGPU MRT
workaround), so a curtain built as a DoubleSide plane showed nothing from
the room. The compiler now appends the triangles again, wound the other
way with negated normals, and sets the material to FrontSide; a BackSide
material keeps only the flipped copy. Runs before the triangle count and
the per-material merge, so the manifest counts what is exported.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Hinge and handle sides are both measured from the door's own left, so a
door written with hingesSide right and the schema's default handle had
both on one edge. add_door (and create_room's doors through it) now set
handleSide = hingesSide === 'right' ? 'left' : 'right' unless the input
names a handle side; the tool takes an optional handleSide.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A one-face paint looked flipped after a room resize. The push itself
(setWallGeometry, which the editor's room arrows commit) keeps every wall,
its direction, its slots and its children; what changes on the first
structural edit is frontSide/backSide, which the kernel classifies from
the room's face. The test writes the walls as an importer does, with the
room on the back wall's b face, and pins all of that.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…of stamping interior

Every wall was written frontSide interior, backSide exterior whatever its direction, so half
the walls of a converted flat carried the room on the face tagged exterior until the first edit
reclassified them: the facing hide rule, cladding and door facing then flipped. The same
detection the editor runs classifies them at write time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
wass08 and others added 17 commits October 19, 2026 16:34
…payload written again is not wiped

The salt loop probed a 'probe' id no conversion ever writes, so a payload written over itself
kept revision 0, minted the ids already in the scene, created nothing and deleted the old level:
the hosted store refused it as a scene wipe. The test passed vacuously on an empty create list;
it now demands the second write's creates and the first level's deletion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…exact item and opening colours, and ceiling points for lights with no fixture

What the four converting agents asked for after comparing pictures. A wall-hosted item was
flattened onto the wall face, so a pleated curtain sat half inside the wall: its outward distance
from the face is kept, and only a point inside the wall body is moved to the face and reported.
An authored item's slots went through the library match, silently (oak to an orange texture, a
red book to parquet): they are written as exact scene materials now, as walls already were; an
opening's color is painted on its panel and frame (frameColor for a frame of its own); a scripted
opening keeps its declared type. A light with no fixture was hosted on the nearest item (a rug, a
bed): it now hangs from the ceiling of the room containing it, on a minimal ceiling point the
operation writes itself, with the nearest item only as the reported fallback outside any room.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… when the room changes

A design converted from three.js faked plank and tile floors with free-standing
objects or fixed floor regions that did not follow the room. A room's floor
finish can now be a three.js module that Pascal keeps on the zone and runs for
exactly the room's floor.

- Schema: zone.floor.script (ZoneFloorScript) is the script source record items
  use (language, script and artifact hashes, params, manifest) plus `inputs`,
  the key of the floor it was built for. Absent on every existing scene.
- geometry-script: compile input `inputs` reaches the build in `params` (and as
  `inputs`), is never returned among the stored params, and keeps the build in
  the host's frame instead of re-origining it for its mount. The sandbox worker
  and the editor's worker client carry it.
- core lib/floor-script: floorScriptInputs (the room's outline inside the wall
  faces clipped to its plate, holes, and the plate top, level coordinates),
  floorScriptInputsKey, staleFloorScripts, refreshFloorScripts(nodes, host) for
  any FloorScriptHost (read the module, compile and store for a floor).
- core setRoomFloorScript operation: writes a compile on the zone with the key
  of its inputs, or removes it; refuses Blender builds, a room with no floor,
  unmeasurable output.
- Paint: the script owns the floor's colours. A scripted room gets its own
  plate top cell, and floorPlatePaintRefusal (the editor's Paint, and the slab
  paint commit) and set_zone_intent's floor finish/regions refuse with a
  sentence until the script is removed.
- Rendering: the zone renderer draws the artifact through the scripted-model
  path, 1 mm over the plate, with no pointer events, so clicks reach the floor.
- Editor: the tab rebuilds stale floor scripts after a settled change (sandbox
  compile, artifact store), writing outside history like the reconcile's derived
  writes and only while the room's floor is still the one built for; the room
  panel shows "Scripted finish" with View code and Remove; a paste into another
  project brings the floor script's artifacts.
- Tests: built for the floor inside the wall faces at the plate top; compiled
  once and again only after the structure reconcile re-derives the room (fake
  host); paint refused through the Paint tool, the slab commit and the room
  intent until removed; no floor, no script; an old zone parses unchanged;
  compile with inputs stays in the host frame; clipboard copies the artifacts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…script

Converting agents faked baseboards with free-standing items hosted on walls and
plank floors with fixed floor regions, because the payload had no way to say
either. Both finishes are native now, so the payload carries them.

- Payload: a wall's `skirting { height (0.1 m), depth (0.012 m), color? }` and a
  room's `floorCode` (a three.js module given params.polygon, params.holes and
  params.elevation, at most 48k), with `floor` as its fallback. Old payloads
  parse unchanged.
- Operation: once the rooms exist, setWallSkirting on each wall with skirting,
  on the faces a room stands on, its colour through the payload's exact
  materials; a refusal is a `skirting_refused` defect naming the wall. Core
  cannot compile, so authorSceneFloorScripts(payload, report) lists
  { roomId, zoneId, code } for the host; the report gains `floorScripts`.
- MCP: after the write, each room's floor (floorScriptInputs) is compiled
  through the GeometryScriptHost with `inputs` (compile and build carry them;
  ScriptedKind 'floor', no artifact kind) and written with setRoomFloorScript;
  a failure is `floor_script_failed` and the room keeps its floor. The stored
  payload drops floorCode like the other modules.
- get_source: readableScript reads a room's floor script by its zone id
  (zone.floor.script); the contract says so for both surfaces.
- Skill, workflow reference and agent-surfaces describe both.
- Tests: skirting on the room's face in its exact colour, defaults, no skirting
  when absent; a wall with no room is a defect; rooms with floorCode listed by
  zone, unenclosed ones not; through a real MCP client and the real compiler,
  a wall's skirting painted and a zone's floor.script built inside the wall
  faces and read back with get_source; a failing floor module is a defect and
  the room keeps its finish.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…when the wall changes

A converted design faked bathroom tiles, panelling to a dado and boards with
objects hosted on the wall, which did not follow the wall or its openings. A
wall face's finish can now be a three.js module that Pascal keeps on the wall
and runs for exactly that face, the floor script's twin.

- Schema: wall.finishScripts { a?, b? } (WallFaceScript) is the script source
  record a floor script is, plus `inputs` (the key of the face it was built for)
  and `toHeight` (metres above the face's base; absent, the whole face). Absent
  on every existing scene.
- core lib/wall-face-script: wallFaceScriptInputs(nodes, wallId, face, toHeight?)
  gives the face in its own frame (x along the wall from its start, y up from
  the face's lowest floor, z out of the face): `outline` {x0,y0,x1,y1} between
  the face's mitred ends up to the wall's top or toHeight, `holes` (doors from
  the floor under them, windows from their sill, clipped), `base` ([x, y] points
  of the face's floor), `thickness`; wallFaceScriptInputsKey, wallFaceScriptRecord,
  wallFaceScriptPlacement, wallFaceScriptSkirtingGap, wallFaceScriptPaintRefusal.
  Curved and curtain walls have no face to build on.
- One refresh for both finishes: staleFloorScripts / refreshFloorScripts /
  FloorScriptHost become lib/finish-script's staleFinishScripts,
  refreshFinishScripts and FinishScriptHost over rooms' floors and walls' faces,
  and finishScriptWrites, which writes a rebuild only while its floor or face is
  still the one it was rebuilt for. The editor tab's refresh
  (initFinishScriptRefresh, finish-scripts.ts) runs both, outside history.
- core setWallFaceScript operation: writes a compile on a face with its key and
  toHeight, or removes it; refuses Blender builds, a face with nothing to build
  on, unmeasurable output, a toHeight not above 0.
- Rendering: the wall renderer draws the artifact under the wall mesh at the
  face's base, 1 mm proud of the face, with no pointer events; face b's build is
  mirrored through the wall (scale z -1) so x still runs from the wall's start.
  The zone and wall renderers share FinishScriptModel.
- Skirting: it keeps running along the base and stops over the build's span
  where the build comes down into its band, as at a door; a splashback above it
  leaves it whole.
- Paint: a face scripted to the top refuses paint (the face, its regions, the
  room's finish on it) in the editor's Paint, the wall's paint commit and the
  `paint` operation, with a sentence; the other face, and a face scripted to
  toHeight, stay paintable.
- get_source: readableScript reads a wall's face script with a `face` argument
  (optional when one face has one); the contract says so. ScriptedKind gains
  'wall-face'. Turning a wall around swaps its face scripts; the reference
  inventory and the clipboard's artifact copy know them.
- Wall panel: "Scripted finish" with View code and Remove per scripted side.
- Tests: the face's outline, door and window in wall-local metres; built once
  and again only after an opening is added or the wall lengthened (fake host);
  paint refused on the scripted face, allowed on the other and on a dado face,
  through the operation and the wall's paint commit; the skirting gives way
  under panelling and not under a splashback; placement on each face; a curved
  wall refused; get_source through a real MCP client; reversal swaps the
  scripts; an old wall parses unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eft/faceRight and a room's wallCode

Converting agents still faked bathroom tiles, panelling and boards with items
hosted on walls, because the payload had no way to say a wall face's finish.
Wall face scripts are native now, so the payload carries them, wired the way
room floor scripts are.

- Payload: a wall's `faceLeft` / `faceRight` ({ code (at most 48k), toHeight?
  metres from the face's base, default the whole face }), left and right as
  colorLeft / colorRight define them (left = front = face a), and a room's
  `wallCode` (+ `wallToHeight`) for every face the room looks into, a wall's
  own module winning. Old payloads parse unchanged.
- Operation: authorSceneFaceScripts(payload, report, nodes) lists
  { wallId, face, nodeId, code, toHeight? } once walls and zones exist; a
  room's faces are the spans of the derived room its zone was made from (the
  faces setWallSkirting runs along). It needs the nodes because a zone alone
  does not say which face of each wall it looks into. The report gains
  `faceScripts`; a face no room looks into (or with nothing to build on) is a
  `no_face` defect named <wallId>/<face>, and a face two rooms' different
  wallCodes claim is a `face_shared` warning (the first room builds it).
- MCP: after the write and the floor scripts, each face is read
  (wallFaceScriptInputs with toHeight), compiled as a 'wall-face' with
  `inputs` and written with setWallFaceScript; a failure is
  `face_script_failed` naming <wallId>/<face> and the face keeps its paint.
  The stored payload drops the faces' and wallCode's code.
- Skill, workflow reference and agent-surfaces describe it.
- Tests: faces listed for a wall's own module and a room's wallCode (to
  1.2 m), the wall's own winning, none for an old payload; a face no room
  looks into is no_face; two rooms on one face warn; through a real MCP client
  and the real compiler, tiles skipping the windows on one face inside its
  bounds, the other walls panelled to 1.2 m, read back with get_source and
  face; a failing face module is a defect and the face stays painted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gion and openings

A finish module was asked to clip itself to the floor polygon and skip the door and window
holes; the first one written against that contract dropped whole tiles touching an opening
and left whole planks out of a staggered row. Every module gets this slightly wrong, so the
compiler now does it: a build with host inputs (a floor's polygon and holes, a face's outline
and hole rectangles) is cut after the module runs, before the merge and export. A mesh wholly
inside and touching no hole is kept as built (no CSG), one wholly outside or in a hole is
dropped, and one across the boundary is intersected with the region's prism (three-bvh-csg;
closed meshes gain the cut faces, open ones are trimmed), keeping its material and part.
Triangles and bounds are measured after the cut; output stays byte-deterministic.

The author_scene contract (floorCode, faceLeft/faceRight, wallCode), the skill reference and
the wiki now say the module lays out and Pascal cuts; holes stay in the inputs for layouts
that want them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r face a finish script covers

The cut trims what a floor or face module builds past its region, but cannot add what it never
built: odd plank rows started 40 cm in left a bare strip along the wall and the report was green.
After the cut the compiler now measures the build seen square-on to its region (strips cut at
every corner's height, exact for boards and tiles laid square, about 5 ms for a 20 m2 plank
floor) and writes `coverage` (0..1, joints count as bare) and `uncovered` (up to four rectangles
bounding the largest bare patches, in the host's frame; a joint is never a patch) in the
manifest. author_scene warns `finish_gaps` with the share and where each patch lies when
coverage is under 0.98 and a patch exists, so tile joints alone stay silent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…act slots

A converted house showed its window glass and a pendant's bulb opaque, and two agents painted the
originals back by hand: every authored slot with a colour was written as an exact matte material
(roughness 0.9, metalness 0, opaque), over the glass the library match had given it and over the
module's own emissive material.

A slot add_object writes as glass (named glass, or transparent in the manifest) now stays
library:preset-glass, on items, scripted doors and scripted windows alike. A scene material has no
emission (MaterialProperties is colour, roughness, metalness, opacity, transparent, side), so an
emissive slot keeps the module's own material and a bulb stays lit, its light effect unchanged.
Every other exact slot carries the module's roughness and metalness (0.9 / 0 only when absent),
one scene material per colour and surface, named by them when not matte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e exemption goes

Main refuses a thin wide box only where no floor lies under it; a rug on a room floor is built
without naming it. The exemption author_scene added for rugs is no longer needed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion

# Conflicts:
#	packages/core/src/agent-operations/author-scene.test.ts
#	packages/core/src/agent-operations/author-scene.ts
#	packages/core/src/agent-operations/index.ts
#	packages/core/src/agent-tools/author-scene.ts
#	packages/mcp/src/tools/author-scene.ts
@Aymericr
Aymericr marked this pull request as ready for review October 19, 2026 23:31
@Aymericr
Aymericr merged commit df2491a into main Oct 19, 2026
4 checks passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit effe912. Configure here.

const size =
(event.ortho || event.perspective) && event.standardSize && supersample === 1
? [event.standardSize.w, event.standardSize.h]
: canvasSize

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Supersampled cameras ignore requested size

Medium Severity

captureDimensions uses the requested standardSize only when supersample === 1. An explicit ortho or perspective camera with supersampling still sizes from the live pane, so the capture inherits the editor aspect the new path is meant to avoid and the picture no longer matches the planned pose.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit effe912. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants