[feat] 15-G convert to mesh (materials preserved, one undo) + multi-select menu audit - #92
Open
AlexZ005 wants to merge 4 commits into
Open
[feat] 15-G convert to mesh (materials preserved, one undo) + multi-select menu audit#92AlexZ005 wants to merge 4 commits into
AlexZ005 wants to merge 4 commits into
Conversation
… one undo) + multi-select menu audit - objectActions.convertToMesh(uuids): a Group or a 2+ multi-selection merges into ONE new mesh via mergeGeometries. Every source geometry is normalized (non-indexed, position/normal/uv only, generated normals/uvs when missing) and baked relative to the first source position, so world layout survives and local coordinates stay small. - Materials are kept: one geometry group per source material slot, deduplicated by material identity, re-pointed onto the merged groups (mergeGeometries numbers its groups by input order). A source that ALREADY carries a material array is split along its own geometry groups first - mergeGeometries ignores those, so a re-merge would otherwise drop every slot but the first. - The multi-select emissive highlight is stripped from the cloned materials (the 15-B2 duplicate bug, same mechanism). - ONE undo entry: the deletes and the create run inside a history batch. - Replication goes through the object message (ObjectLoader on the receiver), NOT sendObjects: that helper announces a GROUP for the root it is handed and then walks its children, so a bare mesh would arrive as an empty group. This is also exactly what the create/delete history entries replay. - Guards: refuses rigged (skinned) meshes, peer-locked objects, viewers without edit rights, and anything that collects fewer than 2 meshes. A selection holding both a group and one of its descendants merges the child once. - Menu: Convert to mesh for a multi-selection (counted suffix) and for a lone Group, beside Ungroup. New combine icon in the data-driven Icon map. - Multi-select menu audit: Rename, Add note, Add flow to Scene graph and Ungroup are single-target, so they are hidden while a SET is selected (the 15-B8 Edit mesh / Sculpt precedent); Properties re-applies the SET instead of collapsing it to the clicked object. - ungroupObject is now one history batch too (it used to be N+1 undo steps). - New suite tests/e2e/convert-to-mesh.test.cjs (57 checks incl. two-peer replication). The tint and multi-material guards were proven by breaking the code and watching them go red. Baseline held at 419/62. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erged mesh)
Reported: merge two boxes with Convert to mesh, select a face on each, click
Bridge - nothing happens.
The bridge ALGORITHM was correct. Measured on the real scene: the op takes the
mesh from 24 to 28 triangles (both caps removed, four wall quads stitched) and
the result still spans both boxes. What broke is rendering - the renderer drew
ZERO of those 28 triangles.
Root cause: three draws an ARRAY material by iterating geometry.groups. Every
geometry swap in faceEdit built a fresh BufferGeometry carrying only positions,
so a merged multi-material mesh came out with no groups at all and vanished
completely. Convert to mesh is what made multi-material meshes common, so the
whole face-edit toolset was affected on them - bridge, extrude, inset,
subdivide, the live gesture preview, remote applies and undo/redo alike.
Fix - carry the material slot through the edit:
- readTriangles stamps each triangle with `mi`, the material slot it came from
(read off the source geometry's groups); cloneTris and every op that maps,
filters or splits triangles carries it, and geometry an op STITCHES (extrude
and inset walls, bridge tunnel walls, subdivide children) inherits the slot of
the face it grew from.
- trisToGroups run-length encodes those slots back into geometry groups. It
returns null when everything is slot 0, so a single-material mesh gains no
groups and puts nothing extra on the wire - that path is byte-unchanged.
- preserveMaterialGroups is the one place a swapped-in geometry gets its groups:
the ones the edit computed, else the previous geometry's (exact for the
sculpt / vertex-drag / grab paths, which never change the vertex count), and
it always covers the tail so no triangle can be left unrendered - including
snapshots from an older peer that carry no groups at all.
- The groups ride the existing meshgeo message as a small plain array (absent
for single-material meshes; an older peer just ignores the field), and the
meshgeo history entries store {positions, groups} while still accepting the
bare positions array every other producer records.
Verified: new tests/e2e/mesh-edit-materials.test.cjs (35 checks) asserts the
geometry AND that the renderer still draws every triangle, isolating one
object's contribution by toggling its visibility across two frames and
calibrating the passes-per-mesh multiplier from a known-good state. Covers
bridge, undo/redo, extrude/inset/subdivide, a single-material mesh gaining no
groups, and a two-peer run. Proven by reverting the fix: 13 failures, the
bridged mesh drawn 0 of 56. Baseline held at 419/62.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… walls, inset) Reported: merge two cubes standing side by side with a gap, multi-select the top face of each, Extrude - the two walls FACING EACH OTHER never appear. Root cause: opTargetFace() synthesizes ONE face for a multi-selection, and its centroid lands in the empty gap between the shells. extrudeFace derived each wall's visible side from that centroid (radialOut = mid - centroid, normal component dropped), so for the two INNER boundary edges "away from the centroid" was the exact opposite of "away from the cube": those walls were wound inward and backface-culled to invisible. Measured on the reported scene, the four X-facing walls came out nx = -1, -1, +1, +1 where the correct answer is -1, +1, -1, +1. insetFace had the same flaw from the other end: every selected vertex was lerped toward that shared centroid, so both top faces SLID into the gap instead of insetting in place (their centroids moved 0 -> 0.27 and 3 -> 2.73, and the left face's vertices landed at 0.1 / 0.8 instead of a symmetric -0.35 / +0.35). Fixes: - A wall's outward direction is now derived LOCALLY from the boundary edge: `edge x normal`. Boundary edges inherit their direction from the triangles' winding and a face is wound counter-clockwise seen from +normal, so that cross product always points away from the face interior - no centroid, no global reference point. This also fixes concave faces and faces with holes, which the centroid heuristic got wrong for the same reason. radialOut is gone. - boundaryEdges carries the owning triangle index, so each wall takes THAT triangle's own normal and material slot rather than the synthetic union's. A merged mesh's walls now inherit the colour of the cube they grew from. - insetFace runs per CONNECTED COMPONENT (new componentsOfTris, welded by vertex position): each component shrinks toward its own centre and stitches its own frame ring. A single connected face computes exactly what it did before, so the ordinary single-face inset is unchanged. Verified: new tests/e2e/mesh-multishell-ops.test.cjs (18 checks) - every extrude wall must face away from the cube it belongs to (assigned by nearest shell centre), the two specific inner walls the user saw missing are named individually, a MIDDLE shell in a three-cube row gets outward walls on both sides, each cube's walls carry its own material slot, inset keeps each face on its own centre and shrinks it symmetrically, and a lone-face extrude still behaves exactly as before. Proven by reverting the fix: 7 failures, 4 inward wall triangles, inset centroids at 0.27 / 2.73 and vertices at 0.1 / 0.8. All face/mesh-edit suites re-run green (mesh-ops, mesh-sculpt, mesh-edit- materials, convert-to-mesh, terrain-sculpt, collider-custom, desktop-face-gizmo, editmesh-*, faces-*, every vr-face-*). Baseline held at 419/62. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: merge two cubes, extrude both tops by 0.3, then select the two extruded walls that face each other and Bridge - the result is not the expected tunnel between the extruded parts. Two things were happening, and only the second is a bug: 1. Extruding a face leaves a wall that is COPLANAR with, and edge-adjacent to, the flat side beneath it. groupFaces derives logical faces by coplanarity, so it merges the two: after the extrude, "the extruded wall" is not a face of its own - clicking it with FACE granularity selects the whole side of the cube, from y -0.5 all the way to 0.8. Bridging that fuses the full sides, which is the correct answer to that selection (it produced a clean solid bar - the bridge math itself was fine). 2. The bug: bridgeFaces mapped the selected triangles to logical faces and then used `faces[fi].triIndices` - the WHOLE coplanar group. That is exactly the op-target anti-pattern the codebase already warns about: it silently ignores Face/Triangle/Shell granularity and the Multi set. So even with TRIANGLE granularity and only the four band triangles picked, bridge still consumed the entire side of each cube. There was no way to bridge just the bands. Fix: the two caps are now the two CONNECTED COMPONENTS of the actual selection (componentsOfTris, added with the inset fix), not the faces it happens to touch. Selecting the two 2-triangle bands now bridges exactly those - each inner side is left intact up to y 0.5 and a tunnel floor appears across the gap. A whole-face pick still consumes the whole side, so the selection genuinely decides. Two TOUCHING picks (one component) are refused with a clear message instead of being bridged into garbage; the tunnel walls take the first piece's material slot. faceSlot is gone - it was the last caller. Verified: three new checks in tests/e2e/mesh-multishell-ops.test.cjs assert that the band pick consumes ONLY the band, that the tunnel floor spans the gap, and that a whole-face pick gives a DIFFERENT result (which is the proof the selection is honoured, since before the fix the two were identical). Proven by reverting only this change, with the extrude/inset fixes left in place: 3 failures. Every face/mesh-edit suite re-run green. Baseline held at 419/62. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AlexZ005
force-pushed
the
feat/roadmap15-g-convert-to-mesh
branch
from
August 9, 2026 07:16
07cca4b to
9962ddc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Executes
plans-core/pending/15-g-convert-to-mesh.md(roadmap #15 batch G).Feature
Context-menu Convert to mesh for (a) a selected Group, (b) a multi-selection of 2+ meshes. The sources merge into ONE new mesh, materials are preserved as slots, the originals are deleted, and the whole thing is ONE undo entry and ONE replicated object.
How it works
objectActions.convertToMesh(uuids)merges throughmergeGeometries. Each source geometry is normalized first (non-indexed,position/normal/uvonly, generated normals/uvs when a source lacks them, morph attributes dropped) and baked relative to the FIRST source's world position, so the world layout survives while local coordinates stay small.mergeGeometriesnumbers its groups by INPUT order, so the dedup mapping has to be written back. A source that already carries a material ARRAY is split along its own geometry groups first:mergeGeometrieswrites exactly one group per input geometry and ignores the groups already on it, so re-merging a previously-merged mesh would otherwise silently drop every slot but the first.beginHistoryBatch()/endHistoryBatch('Convert to mesh'), thegroupSelectiontemplate.addImported, which ends insendObjects(null, mesh)— but that helper announces a GROUP for the root it is handed and then walks its CHILDREN, so a bare mesh would have arrived on peers as an empty group. The merge goes out as{type:'object', element: mesh.toJSON()}(ObjectLoader on the receiver) instead, which is also exactly what the create/delete history entries replay, so a live convert and an undo/redo are byte-identical for peers.isSkinnedMesh) sources refuse with a toast, peer-locked objects refuse, viewers without edit rights refuse (converting deletes the sources), and fewer than 2 collected meshes refuses. A selection holding both a group and one of its descendants merges the child once, not twice.Multi-select menu audit
Sweeping the remaining single-target entries shown during a multi-select, following the 15-B8 precedent (Edit mesh / Sculpt are hidden rather than silently acting on one object):
selectObject(uuid, true), which COLLAPSED the set to the clicked object. It now re-applies the SET, so the panel opens on what the menu header says it acts on.ungroupObjectis wrapped in a history batch — it used to be N+1 separate undo steps.Verification
New suite
tests/e2e/convert-to-mesh.test.cjs— 57 checks, all green, run against the lane server on 5182:objectmessage, twodeletemessages, and NOgroupmessage (the sendObjects trap)The tint guard and the multi-material split guard were proven by editing the fix out and watching them go red (3 FAILURES), then restoring.
Adjacent suites re-run green:
multiselect-menu,context-menu-v2,context-menu-redesign,context-menu-overflow,undo,vr-mesh-undo,flow-node-undocked,particles,particles-impact,ui-fixes-15b.npm run buildpasses; svelte-check baseline held at 419/62 (none of the touched files appear in the output).🤖 Generated with Claude Code