editor: add Blender-style custom mesh editing - #638
Conversation
Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| [faceId, host, liveTopology], | ||
| ) | ||
|
|
||
| if (!transform) return children |
There was a problem hiding this comment.
Deleted faces orphan hosted items
Medium Severity
Face delete/dissolve/merge can remove a face while items still reference it via customMeshFaceId. CustomMeshFaceHostFrame then fails to resolve a frame and renders those children without the face transform, so hosted items jump to incorrect local coordinates instead of being cleaned up or reparented.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 63c131e. Configure here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f5e30df. Configure here.
| return host | ||
| ? [...(nodeRegistry.get(host.type)?.capabilities.faceHost?.clearItemFields ?? [])] | ||
| : ['position', 'rotation'] | ||
| } |
There was a problem hiding this comment.
Stale face-host live overrides
High Severity
commitDraft clears live overrides only after draftNode.commit(), when draftNode.current is already null, so faceHostClearFields falls back to position/rotation and skips blockFaceId. Face-host moves write blockFaceId into useLiveNodeOverrides, and wall-side / ceiling items can leave a block without the leave path clearing those overrides. getEffectiveNode can then keep reapplying a stale blockFaceId after a successful wall or ceiling commit.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit f5e30df. Configure here.
| } | ||
| }, | ||
| } | ||
|
|
There was a problem hiding this comment.
Ceiling enter keeps block face
Medium Severity
Ceiling enter and click update parentId and position but never clear blockFaceId or blockId, unlike wall and roof-wall transitions. Ceiling items can move from a block underside onto a real ceiling without going through face-host leave, so the draft can stay marked as block-hosted while parented to a ceiling.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit f5e30df. Configure here.
`registerTestBlockFaceHost` skipped registration whenever any `block` kind was already present in the shared node registry. The wall drafting suite registers its own floor-placed `block` stub, which has no `faceHost` capability, so when it ran first the face-host stub was never installed and every face placement resolved to null — 7 tests failing by file order alone. Renaming `custom-mesh` to `block` is what made the two stubs collide. Gate on the capability rather than the kind name, replacing a registered `block` that cannot host faces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pickable (#686) * fix(editor): stop node tops lifting floor placements off the ground #638 made the pointer support election consider node tops by default — `options?.includeNodeTopSurfaces === false` opted out, so every caller that passed nothing (item placement, registry move/presets, slab drafting) started electing them. It also gave `item` a `surfaces.top`, widening the candidate set from wall/item/column to wall, slab, ceiling, cabinet, column, item, shelf, block. A ray aimed at a floor crosses every upward-facing face above that floor first. In a finished room that is the ceiling: placing an item at the room centre elects the ceiling's top face (nearest hit, normal.y ≈ 1) and freezes it into the draft's authored Y via `resolveFrozenFloorPlacementPatch`, so the item sits at ceiling height instead of on the floor. Walls the ray passes over do the same in a narrower band. Restore the opt-in. Keep #638's registry-driven discovery — the kind list is still derived from `capabilities.surfaces.top` rather than hardcoded — but only the tools that build ON a surface ask for it: wall (already did), column, fence, stair, block. Item placement, registry move and slab drafting go back to placing against the floor the pointer indicates. Also exclude the node the active interaction is placing or moving. Its mesh rides the cursor, so electing its own top would raise it by its own height every pointer move. The tools neuter the dragged mesh's `raycast` for their own pointer routing, which happens to cover this today — but that is each tool's private convention, and async-mounted item children are only neutered on the next frame. The election owns the invariant now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(viewer): keep batched walls answering the pointer #608 sews a level's walls into one mesh once they settle (8+ walls, 180ms quiet). Each sewn wall is moved off SCENE_LAYER onto BATCHED_LAYER by `hideBatchedWall` so it costs no draw call while staying in the graph — with its R3F pointer handlers still attached. R3F picks with one shared raycaster whose default mask is SCENE_LAYER alone, so a batched wall stops being hit: no `wall:enter` (no hover outline, no paint preview), no `wall:move`, no `wall:click`. Selection is the circular case — a selected wall leaves the batch, but the click that would select it never lands. #608 saw this for measurement and added `setSurfaceRaycastLayers` for the raycasters that module builds; the shared event raycaster was never opted in. Enable BATCHED_LAYER on it. Additive rather than `setSurfaceRaycastLayers`, which resets the mask — right for the private per-query raycasters it was written for, wrong for the one every pointer event goes through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * feat: add Blender-style custom mesh edit mode * fix: complete custom mesh edit mode * feat: refine custom mesh editing experience * fix: support elevated placement and refine mesh editing * fix: make surface placement registry-driven * feat(nodes): add custom mesh face materials * fix(nodes): refine custom mesh face materials * fix(editor): address custom mesh review findings * feat: improve custom mesh editing and material slots * feat: support items on custom mesh faces * fix(editor): reject wall attachments on sloped custom mesh faces * Rename custom mesh to block * Move block face placement behind face host capability * Add modal uniform scale for block editing * test(editor): keep the block face host registered across suites `registerTestBlockFaceHost` skipped registration whenever any `block` kind was already present in the shared node registry. The wall drafting suite registers its own floor-placed `block` stub, which has no `faceHost` capability, so when it ran first the face-host stub was never installed and every face placement resolved to null — 7 tests failing by file order alone. Renaming `custom-mesh` to `block` is what made the two stubs collide. Gate on the capability rather than the kind name, replacing a registered `block` that cannot host faces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Wassim SAMAD <wass08@gmail.com>
Lean-to roof extensions with automatic drainage, Blender-style custom mesh editing, synchronized 2D viewer modes, shared-parameter editing across a homogeneous multi-selection, plugin inspector-card extensions, an empty-graph save guard, a batch of wall hover/pick correctness fixes, and the autosave fix that stopped scenes being wiped during the load window. Thirty-five files conflicted; five of them were not real conflicts. `integration` carries cherry-picks of upstream pascalorg#607, pascalorg#608 and pascalorg#638, so git saw two independent additions of the same path. Four were byte-identical to the commit they were picked from and the fifth differed by one defensive `?.`, so upstream's newer copies were taken outright — upstream has since fixed the same files. Fork positions kept, each already written down in UPSTREAM.md: the `resolveSelectionHighlight` thunk and `freezeObjectTransform` in the wall systems, the warehouse-scale room test with no upper area bound, the plugin-aware `graph-schema.ts`, the ownership and edit-lease checks on the scene API, `output: 'standalone'`, the warehouse pin, and the vendored articraft and trees workspaces. Four positions were not written down and now are: the room-envelope height caps, which upstream raised from 6 m to 20 m and this fork removed outright because a cap clamps typed input as well as drag; the trees pin, which stays `workspace:*` while the vendored copy exists; the scene-loader's floating navigation; and the site tree's `def.tree` gate, which now lives inside upstream's own `getTreeNodeComponent`. Upstream's empty-graph guard tests failed on arrival for the reason the last merge's log predicted. Their fixture builds an invented `qa:box` kind, which upstream's validator holds to the BaseNode envelope and this fork's refuses outright, so all three saves returned 400 before reaching the guard under test. The fixture now calls `WallNode.parse`, which is the rule that log already drew from `graph-schema.test.ts`. `bun.lock` is committed unchanged. Upstream bumped plugin-bones to 85238a8e, and the lockfile records the sha512 of each GitHub tarball — which only a machine that can reach the real api.github.com can compute. Relock is dispatched on this branch afterwards; until it runs, `--frozen-lockfile` is expected to fail on that hash and nothing else. Also folds in the one item from the audit that this file was already open for: `@pascal-app/plugin-articraft` joins `transpilePackages`. It ships raw TypeScript and `bootstrap.ts` imports it, and it built until now only because bun's symlink layout drops its real path outside `node_modules`. Gates: biome clean, `check-types` clean for every workspace that can resolve its dependencies here, and `bun run test` green across all 16 tasks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5bdAFduH4BkPCjvtJgFzn
…on the right storey The floor-path Y was frozen at drag start (#638), so an item pulled off a shelf kept the shelf height after reparenting. Read the live grid Y instead. The cursor group, grid surface and facing pose now add the level mesh's stacked Y, which the building-local tool group lacks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc
…on the right storey The floor-path Y was frozen at drag start (#638), so an item pulled off a shelf kept the shelf height after reparenting. Read the live grid Y instead. The cursor group, grid surface and facing pose now add the level mesh's stacked Y, which the building-local tool group lacks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc
…ture, Cmd+S, three 0.186 (#807) * fix(capture): round armed FOV, add Alt slow modifier for the drone camera armCaptureFov stored the live camera FOV verbatim, so fractional pose FOVs printed float tails in the HUD and left the reset button enabled. Both writers now share clampCaptureFov. Alt holds the drone at 0.2x speed and look sensitivity for fine framing; Shift stays the boost. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(roof): keep the gable shell base on the wall top The CSG degeneracy guard enforced its 5 cm minimum by lowering the shell base, which for wallHeight-0 room roofs put the gable 4 cm inside the wall and z-fought its faces. Raise the eave instead; mirror the floor in the opening-placement frame and the shed inset panel. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * feat(editor): Cmd/Ctrl+S saves instead of opening the browser dialog Capture-phase, always-on listener so the page-save dialog never appears. Hosts can take the chord over via onSaveShortcut; the default flushes the autosave through the existing executeSave path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(tools): anchor composite presets at their footprint centre, lift previews to the level Fresh (absolute) placement mapped the cursor to the node origin, so a cabinet run landed |bounds.center| away from the pointer. Subtract the rotated centre and keep it under the cursor across R/T. The registry mover's box/sphere now ride the target level's stacked Y like the other placement tools. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(stair): follow the storey height from the elected base Level-destination stairs returned the full floor-to-floor height even when a slab lifted their base, so the top overshot the storey plane. The resolver now subtracts the elected base for both destinations. The panel exposes Follows storey / Custom rise for level stairs, and the stair tool and landing toggle seed from the storey instead of a 2.5 m constant. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(item): drop un-hosted items to the floor, draw the placement box on the right storey The floor-path Y was frozen at drag start (#638), so an item pulled off a shelf kept the shelf height after reparenting. Read the live grid Y instead. The cursor group, grid surface and facing pose now add the level mesh's stacked Y, which the building-local tool group lacks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(selection): keep member rotation when pressing R/T mid-drag translateGroupPatches dropped the snapshots' yaw after a mid-gesture rotation, so the layout orbited while every item kept its old facing and the commit wrote the same. Carry rotation for vec3/scalar participants, pivot every session on the shared mesh-box centre the idle shortcut uses, and engage an armed session before rotating. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * chore(deps): three 0.186.0 No removed export is used and every peer range admits r186. Two adjustments: Renderer.dispose() is async now, so the capability probe swallows its rejection; and r186's CommonJS entry re-exports the ES module, which Bun cannot require() while the same process imports three as ESM. A bun test preload steers fiber/drei/maath/meshline (no exports map, CJS main) to their module builds, the way bundlers already resolve them. Types stay on 0.184.1 (0.185 types OOM tsgo). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * test: pre-evaluate three in the bun test preload Bun's plugin onResolve does not run for static imports, so steering the R3F packages to their module builds never applied in CI (isolated linker) and the CJS require("three") kept racing the ESM import. Evaluating the package's own three copy first makes the later require() a cache hit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(roof): lift the inner cutter and deck with the shell eave The 5 cm CSG floor lifted only the outer shell, so a flat zero-height roof would have ended up with a solid cap under the deck. Compute the lift once and apply it to every volume. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(editor): fall back to the autosave flush when the host does not handle Cmd+S Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(roof): floor every prism at 5 cm instead of lifting by the shell's eave A shell-derived lift left overhanging deck cutters with a negative eave. Clamp each volume's top the way main did, just at 5 cm and without the base sink, so cutters stay level with the shells they carve. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(stair): drop the duplicate geometry Rise control The rise-mode block already exposes the Rise field in custom mode; the geometry copy wrote totalRise behind the Follows storey toggle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * test: import resolveSync explicitly in the three preload Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * test: skip the three preload where the cwd has no three dependency Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(editor): seed placed stairs from the elected base; keep the gesture when R/T cannot engage The stair tool seeded the flight from the storey height alone, a slab thickness too tall until syncStairRises caught up; it now subtracts the drop point's elected base like the resolver. A failed engage() on R/T no longer tears down the pointer listeners. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(stair): cap the placed rise by the pointed support surface Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix: scale the stair ghost to the placed rise; await renderer.dispose() before the WebGL fallback Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(stair): read the placed rise from the preview scene Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(selection): re-fit alignment bounds from the start footprint after each R/T Rotating the previous axis-aligned fit inflated the anchors every step. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(stair): switching to straight materializes a flight; level labels use the shared display name A curved stair switched to straight had no stair-segment child and drew nothing (and vanished on select). The type change now creates a default flight in the same history step and the viewer falls back to that flight for already-broken scenes. Stair and elevator panels label levels the way the level switcher does, and the rise toggle reads Follows level like walls. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * feat(roof): wall-footprint roofs follow their source walls' tops Room roofs computed their elevation once at creation, so a later custom wall height left the roof at the storey plane. Roofs now remember their source walls and a core system re-derives position[1] (highest top, clamped to the level floor) on wall/slab/level edits, history-paused like the stair rise sync. Moving the roof by hand detaches it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(stair): a flight height edit pins the parent stair to the new total rise On a follows-level stair the sync handed the edited height straight back, so the segment slider did nothing. The edit now also writes totalRise (the stair becomes Custom rise, as editing Rise on its own panel does). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(roof): follow wall tops below the storey plane Walls shorter than the level (2.5 m in a 3 m storey) left a gap because the roof elevation was clamped to its level floor. Follow the wall top. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * feat(roof): follow walls by intent, resolved from the footprint Replace the source-wall id list with support.kind 'walls': room and conical roofs are created following, the system resolves the enclosure under the roof centre on the level below and writes the highest wall top (unclamped), an explicit Y edit or vertical handle drag flips the roof to custom, and the panel offers Follows walls / Custom like walls do. No migration; existing roofs stay custom until the user opts in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(roof): pick supporting walls by footprint overlap, not closed-room membership A room missing a wall, or an L-room whose centre falls outside, left the roof frozen. Walls whose band overlaps a segment footprint on the level below now count; segment-less roofs keep the point-in-room lookup. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc * fix(roof): parent room roofs to the storey above their walls; follow walls on the roof's own level too Armed on the walls' level, the tool parented the roof to that level and the follow rule only looked one storey down, so a Floor 1 roof dropped to the Level 0 wall tops. The roof now goes to the level above the walls when one exists (top floor keeps it on the walls' level), and the resolver considers walls on the roof's level and the one below. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>


What does this PR do?
How to test
bun dev, open the editor, choose Custom Mesh from the build palette, and place it on the ground and on an elevated top surface; verify the preview follows the pointed surface and commits without an error overlay.bun run check,bun run check-types, andbun run build.Screenshots / screen recording
A short screen recording will be added before review. The interactive placement and helper UI were smoke-tested in the local collaborative preview.
Checklist
bun devbun checkto verify)mainbranchNote
High Risk
Touches core spatial-grid support election, item placement/commit, and scene load migration for a new structural node type—regressions could affect elevation, hosting, and saved scenes.
Overview
Renames and migrates legacy
custom-meshscenes toblock: new topology schema, level children, itemblockFaceId,BlockEvent, and genericnode:*bus events alongside per-kind events.Adds registry
faceHostso blocks (and other kinds) can host wall, ceiling, and floor items on planar faces, with a dedicated placement strategy, preview/commit paths, sloped-face rules, and grid/oriented-surface snapping.Floor support gains
pinSupport, preferred-slab resolution,resolveFrozenFloorPlacementPatch(exact elevation on mesh tops without live hosting edges), and shared wall/fence construction helpers so later slabs do not lift pinned placements; fence drafting and item/move commits thread pointer caps and pinning throughresolveSupportSlabPatch.Editor UX: Edit mesh on the action menu,
mesh-editingscope disables normal selection/hover, build tab waits for client registry viauseSyncExternalStore, and move/placement tools consolidate commits onnode:clickwith frozen support on pointed construction surfaces.getTopSurfaceHeightnow receives the full node map for context-aware host surfaces.Reviewed by Cursor Bugbot for commit 0d96de0. Bugbot is set up for automated code reviews on this repo. Configure here.