Conversation
Adds the surface descriptor, the two providers and the pure placement resolver behind the surface hosting protocol. Declared surfaces come from nodes that know their geometry; every other mesh resolves through a hit-derived surface with no durable id, which is how a book keeps landing anywhere on a sofa armrest. Permissive by default: acceptance only restricts when a provider declares it, and the single restriction is a list of non-physical host kinds. Additive only. No mover, node definition or schema changes, and the tests pin parity by executing the existing item, shelf and procedural helpers rather than restating their expected output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
An independent audit found three blockers. The module was never exported from the package barrel, so no mover could have imported it and the first consumer was forced to reach into core's source. Declared attachments are stored in the surface's own frame while items and shelves store host-local poses, and the resolver returned only the latter, so the slice meant to put a procedural item on a shelf could not have written its result. And every test stayed green when the surface rotation maths was deleted. Providers now declare which frame their children live in and the resolver returns both poses. The snap flag becomes an explicit grid-snap boolean, since grid snapping is universal here and the old flag centred nothing while ignoring its own free setting. Rejections return nothing at all, matching both legacy paths, with an opt-out for the footprint check because dragging skips it today. The parity table is frozen alongside the live comparison so it survives the slice that moves the code it pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
A kitchen run now advertises the surfaces something can actually rest on: one per real countertop span and one per bar ledge at its own height, with sink openings, faucet bases and cooktops as holes. Wall runs, tall runs and empty runs publish nothing, so a kettle cannot land on a pantry. Surface ids are anchored to a module rather than a position, so inserting a module elsewhere in the run does not move something already resting on the counter. The span and appliance geometry is extracted so the builder and the surface derivation read the same numbers and cannot drift. Nothing consumes this yet. Declaring it changes no behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
… service Surface eligibility, height, grid snapping and footprint fit now come from the shared resolver instead of the mover's own copy. The mover keeps what is genuinely its own: self and descendant rejection, the grab offset, the frame conversion, the scene writes and the session lifecycle. Behaviour is unchanged by construction. Every existing registry test passes untouched, and the footprint check still runs on enter and move rather than at commit, which is this mover's own rule and not the catalog mover's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
…egy adopts the resolver Four files were computing the same world-space surface normal, and adopting the resolver in a second mover would have made a fifth. One adapter now turns a host and a pointer event into the resolver's hit, and both movers use it. The catalog item-surface strategy resolves through the shared service. Its timing is preserved: entering checks the footprint, moving deliberately does not. The registry mover picks its write frame from the provider's declared childFrame rather than sniffing the shape of the node's rotation field. One deliberate difference from legacy: a non-finite hit is now rejected rather than producing a node at a NaN position. That path was reachable only for a host with an authored surface height, and its old result was corrupt. The roof strategy keeps its own normal because it needs the full vector for tilt, not just the upward component. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
… the parents gate Shelf row election, grid snapping and the entry fit check now come from the shared resolver and the shelf provider. Stickiness stays where it belongs, in the coordinator, which keeps hosting while the cursor ray is inside the shelf volume even as board-gap leave events arrive. canAttach no longer enforces a child's declared parent list. That gate was never reached by a pointer path, and wiring it up later would have quietly made procedural items more restricted than they are today. Its self-host, cycle and depth checks remain, because those are real. The field itself survives for now: the registry mover genuinely reads it to decide whether to open an item-surface session. Retiring that reader needs its own replacement and is tracked in the plan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
…he legacy resolver goes The parity tests used to pull mover helpers out of source at runtime and run them beside the new code. One shared flag gated every comparison, so deleting two shelf helpers silently skipped 39 tests across all three adapters and the suite still reported green. A silent skip reads exactly like coverage. The frozen table is now the pin: 431 recorded cases across item, shelf and procedural, up from 328 with procedural previously unrepresented. Each adapter asserts its own case count, so quietly dropping cases fails instead of shrinking into a smaller green run. Deleting one case fails one test; shifting item height by a tenth fails twelve. With the extraction gone, the legacy resolver and its private helpers had no callers left and are deleted rather than kept alive by the tests that measured them. Core and editor now skip nothing at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
Procedural items shipped to production but were never added to the runtime node union, only to the TypeScript type. Two things broke for real users: the scene API that agents and MCP use rejected any scene containing one, and collaborative projects rejected the node outright, so a procedural item could not be placed in a shared project at all. An item hosting a procedural child also failed to parse. That path is reached on every update to such a host and on community wall merging; it failed silently and fell back to an unvalidated candidate, which is why the child kept rendering and nothing looked wrong. Items are now widened the same way wall and ceiling already were. Blocks are deliberately left alone: their face attachments need an identifier a procedural node does not carry. Strictly additive. All 48 existing node kinds still round-trip, and scenes without procedural items validate exactly as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
They were registered separately by the community app instead of belonging to the builtin plugin like every other kind, which is the same oversight that left them out of the persisted node union. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
Every host typed its children list to one kind by id prefix, so each new host and child pairing needed a core schema edit and a plugin kind could never be hosted at all. Shelf, cabinet, cabinet module, block and item now take plain node ids, and column gains the field so it can host without a second pass. The schema stops being the gate. What a host accepts is decided by its surface and its accept predicate, which is the thing that actually knows. Cycle limits, depth limits and id format are untouched. A shelf can now hold a procedural item and a cabinet can hold an item, at the core schema, the scene API and the collaboration contract. All 50 existing node kinds round-trip unchanged and all eight previously allowed pairings still validate. One deliberate tightening: a column carrying a malformed children value, such as a number, used to be ignored and now fails validation. That was never valid data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
The original report. The mover that handles procedural items subscribed only to item events and refused any host that was not an item, so it never entered a shelf state at all: it kept tracking the floor and a click committed there or refused as a collision. Shelf events now route through the same resolver the catalog mover uses, so row election, grid snapping, the fit check and the shelf-local yaw are the shelf's one implementation rather than a second copy. Both movers now share the stickiness rule, so a design stays hosted while the cursor is still inside the shelf even as leave events arrive over the gaps between boards. A shelf standing on a raised slab lifts the child once, not twice: attaching clears the child's own support and detaching re-elects it. Moving or resizing the shelf cascades dirty marks to procedural children, and attaching one still does not churn the shelf geometry key. Wall and ceiling mounted designs keep their own session and never enter a shelf. In plan view a shelf-hosted design draws correctly; dragging behaves exactly as a catalog item does today, which is its own slice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
Both movers now route cabinet events through the shared resolver and the shared stickiness rule, so a catalog item and a procedural item can each sit on a counter. A counter elects no sub-surface: you slide along it freely, with the grid snapping that applies everywhere else, and the bar ledge is reachable as its own surface at its own height. The child parents to the run that actually owns the surface it was dropped on, including a nested corner leg. An upward hit is not a fit, so the rotated footprint is validated after snapping against the real span, minus sink openings and appliance regions. The known trap is handled generically rather than by special-casing cabinets: a host now declares which child kinds affect its geometry, and both the geometry key and the viewer's live override key honour that. Attaching or dragging something on a counter no longer rebuilds the countertop, which would have made the pointer flicker. Structural module changes still rebuild it, as they must. In plan view a counter-hosted child draws correctly and keeps its host while it stays within it. Acquiring a host in 2D remains its own slice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
…port cycle Two protocol defects survived the earlier slices. The registry mover still required a child to declare a hostable opt-in, and it still checked host kinds against a hardcoded list. Both are exactly what this plan exists to delete: eligibility now comes from whether the host offers a surface that accepts the child. A design that declares nothing now hosts on all four surfaces. Adds the host matrix test that should have existed: both movers against an item top, a shelf board, a countertop and a bar ledge. The previous tests passed because their fixtures declared the opt-in and used known hosts, so they could not see either defect. Also breaks a real import cycle. Making procedural items a persisted kind gave the schema a runtime dependency on the procedural query module and everything it pulls in, which reproduced as an initialisation error in a clean bundle. Extracting the pure footprint helper removes the cycle with no change to validation and no lazy loading. This does not yet explain the browser report that a procedural item ignores a countertop; the ordinary fixture passed before these fixes too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
…s stop coming out lopsided The expression language had only add, subtract, multiply, divide, min and max, and divide is floating point. The model writes the standard idiom for a two by two arrangement, dividing a part index by two and expecting it to round down, and gets fractions instead. A four-legged table put its fourth leg outside the tabletop and bunched the rest to one side, making a design whose stated width was 450 mm actually 655 mm, 46 percent larger. Diagnosed in a running browser: the evaluated bounds matched that arithmetic to the digit, and the oversized result is why the design would not fit a standard 600 mm counter while an equally oversized catalog chair was refused identically. Adds floor, ceil, round, abs and mod. Floor rounds toward negative infinity, mod returns a non-negative remainder and refuses a zero or negative divisor, and the evaluation budget still applies. Existing recipes using plain division are left exactly as they are, since fractional spacing can be deliberate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
The snap tick fired on the ground, walls, roof wall faces, block faces and ceilings, and nowhere else, so moving something across an item top, a shelf board, a countertop or a bar ledge was the only silent movement in the editor. A procedural item went quiet the moment it attached to anything. Both movers now tick through one shared helper on the same step rule, so hosted movement is indistinguishable from moving on the floor. Native event identity suppresses the duplicate tick when a grid event and a host event arrive for the same physical movement, which would otherwise have doubled the cadence on a counter. Muting and throttling are unchanged, and the place sound on commit is untouched. A generated design used as a host is not fully covered yet: the catalog route is missing and the registry route needs a named surface selected first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
…fusals that say why The fit test behaved two ways. A counter published its size, so the child's rotated hull had to sit inside it. A shelf board published nothing, so the check fell back to the whole shelf and ignored rotation entirely. The same design was refused by a 600 mm counter and simultaneously accepted by a board it visibly overhung. Strictness now follows what a surface knows about itself. Shelf boards publish their real rectangles, counters publish spans with the sink and hob holes, and generated designs publish their evaluated size; a declared surface is required by the type to carry an extent, so no future adapter can slip onto the lenient path. Arbitrary geometry like a sofa keeps a best-effort check but stops ignoring rotation. 200 recorded shelf cases flip from accepted to refused. Each was a genuine overhang, the smallest around 68 mm. Because those were every acceptance the shelf fixtures had, 286 contained placements were captured alongside them, and each adapter now asserts it holds both verdicts so a section can never collapse to one unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
… surface Two earlier attempts got this wrong. Requiring the whole footprint inside the surface was too strict, and sliding an object back on when it neared an edge was worse, because it takes control away from the person dragging. The rule is now the one that matches the world: overhang freely, and let go only once more than half the object is off, which is when its centre leaves the surface. A mug can sit half off a table and stay there. Nothing is nudged, clamped or snapped back. A child larger than the surface is placeable while its centre rests on it. Holes still refuse, so a centre over a sink or hob is not a placement, though overhanging across one is fine. The refusal wording is gone; near an edge there is nothing to read. The machine-readable reason stays for tests and diagnosis, and the guard against silently committing to the floor at a different position stays, since that was the one genuine defect behind the wording. All 717 recorded cases were recaptured: 248 move from refused to accepted and none the other way, which is the expected direction for a more permissive rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
A generated design already declared that it accepts items and other designs, but a child could only attach to a surface the recipe explicitly named, and most generated recipes name none. So in practice nothing could rest on one, which is what the owner reported. It now has the same fallback a sofa has. Where the recipe names a surface and the hit lands on it, that path wins unchanged: the child stores the surface id and a pose in the surface frame, so it survives the design being resized. Otherwise the surface comes from where the pointer actually touches the shape, and the child stores a pose only, with no durable surface identity it never had. The catalog route needed real work rather than the wiring being assumed: its event routing and attachment persistence for this host were both missing. Wall and ceiling mounted recipes stay out; they are not floor-resting hosts. Hosted movement on a generated design now ticks like every other surface. The host matrix covers all six surfaces across both movers and both child kinds, including a design that declares no opt-in at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
Dragging a catalog item onto a generated table drew the green bounds box on the tabletop while the mesh hovered a table's height above it, snapping into place only on release. The floor elevation system was copying a hosted preview's world-space height into the mesh's local height, so the host's surface height was applied twice. Elevation writes are now restricted to nodes parented to the level, which are the only ones that should receive a floor lift. The attachment bookkeeping and the procedural surface groups were correct all along. This was never specific to generated designs. 52 combinations were wrong: every host type when standing on a raised slab or itself hosted, plus generated named surfaces even at ground level, which is why it showed up there first. The durable fix is the matrix that was missing: 324 cases mounting the real movers, renderers and frame systems, asserting that the preview mesh, the preview box and the committed transform agree to six decimal places across entry, movement, rotation, fresh and existing placements, and composed host frames. It fails 52 before this change and passes 324 after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
A stack resting on a slab vanished when the slab's surface, base or thickness changed by any amount, down to a 2.5 mm nudge. Not culled or sunk: the subtree unmounted, autosave then stored the slab without its child list, and after a reload those nodes survived in the saved graph as orphans nothing pointed at. Two independent faults, both fixed. Slabs were never meant to be hosts. The editor's model is that something on a slab is a level child carrying a supportSlabId, lifted by the elevation system, and there is a comment warning that parenting to a slab corrupts the parent and child bookkeeping. Removing the hardcoded host allowlists on this branch made slabs reachable as hosts, which released main does not allow. A slab hit now defers to floor placement, so the node stays a level child as before. Separately, a slab's schema declares no children field, and node updates return the parsed object, so zod stripped the key and severed the link on every write. An update that does not mention children now carries the existing ones forward, which closes the same hole for 24 other kinds that can be hosted onto and also lack the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
|
I hit an error while handling your request (Model unavailable on AI Gateway free tier: Free tier users do not have access to this model. Upgrade to paid credits at https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dtop-up for unrestricted…). Please try again, rephrase, or reach out if it keeps failing. Error id: 94fb1019-a079-4dc9-be27-d814ba2acb4b |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 04eb64f. Configure here.
| (host as { recipe?: { mounting?: { attachTo?: string } } }).recipe?.mounting?.attachTo !== | ||
| 'ceiling' | ||
| ) | ||
| return attachTo !== 'ceiling' && !(host as { recipe?: { mounting?: unknown } }).recipe?.mounting |
There was a problem hiding this comment.
Mounted designs cannot host items
Medium Severity
canHostOnTop now treats any truthy recipe.mounting as ineligible, not only ceiling attachment. Wall-mounted generated designs are rejected as host-not-eligible before their named surfaces run, while catalog items with attachTo: 'wall' still host. The comment still describes a ceiling-only gate.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 04eb64f. Configure here.


What
Hosting was the one layer of the editor that never became data-driven. Six declarations answered "who can host whom", two movers hardcoded the answer per kind, and the generic service in
core/src/services/hosting.tshad five of its six functions with zero production callers. The visible symptoms were a procedural item that could not sit on a shelf, and a kitchen countertop that hosted nothing at all, for any asset type.Surfaces are now first-class and addressable, with two providers. Declared surfaces come from nodes that know their own geometry: shelf boards, countertop spans, bar ledges, a recipe's named surfaces. Hit-derived surfaces come from the raycast for every other mesh, which is how a book still lands anywhere on a sofa armrest. Absence of a declaration means freeform and allowed, never unhostable.
Behaviour
hostable.parentsis gone as an eligibility gate; the host's surface decides.Fixes found on the way
floor,ceil,round,absandmod.childrenfield so every update stripped the link. Slabs defer to floor placement again, and an update that omitschildrennow carries them forward, closing the same hole for 24 other kinds.Checks
Editor 1032, core 2807, nodes 3110, viewer 341, host and preview matrices 406, frozen behaviour table 720. Root typecheck 12/12, Biome clean, zero skips in core and editor.
The frozen table records 717 cases across three adapters with per-adapter guards asserting both accepted and refused verdicts are represented, after a section once collapsed to a single verdict and hid two real defects.
Browser-verified on a live scene: a procedural item on a shelf board with row switching, gap stickiness, reload and undo; catalog items on counters with the countertop proven not to rebuild across ~14k sampled frames.
Not in this PR
2D floor plan hosting (acquire, retain, cycle) and the remaining adapters for column tops and block faces. Tracked in
plans/editor-surface-hosting-protocol.mdin the private repo.🤖 Generated with Claude Code
https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG
Note
High Risk
Touches scene graph validation, hosting/placement, and floor/slab elevation paths across many node kinds; incorrect surface or children handling could drop hosted nodes or mis-lift geometry on save/load.
Overview
Introduces a surface-based hosting model where eligibility comes from declared or hit-derived surfaces (shelf boards, countertops, recipe surfaces, mesh raycasts) rather than hardcoded parent-kind lists.
hostable.parentsis removed fromcanAttach, registry gainssurfaces.hosting/SurfaceProvider, and fit policy tests center-based placement with overhang allowed.Procedural items become a first-class
AnyNodekind (ProceduralItemNodein the discriminated union viasafeExtend), with persistence/round-trip tests and a browser bundle guard so schema imports do not pull in scene/spatial-grid runtime.Refactors pure geometry helpers into
floor-placed-footprints.tsanditem-polygon-overlap.tsto break import cycles; proceduralquery.tsgains slab lift for shelves/cabinets (multi-footprint for cabinets), skips unmapped procedural children in attachment validation, and usessurfaceRegionContainsPointfor hosted fit checks.Host
childrenschemas widen tostring[](pluscolumn.children) so shelves, blocks, cabinets, and items can list procedural/plugin children while frozen corpus tests lock backward compatibility.Recipe expressions add
floor,ceil,round,abs, andmod(with tests and a 2×2 leg grid-table fixture) so indexed part layouts stay within nominal bounds.Reviewed by Cursor Bugbot for commit 04eb64f. Bugbot is set up for automated code reviews on this repo. Configure here.