Skip to content

[feat] 15-G convert to mesh (materials preserved, one undo) + multi-select menu audit - #92

Open
AlexZ005 wants to merge 4 commits into
release/nextfrom
feat/roadmap15-g-convert-to-mesh
Open

[feat] 15-G convert to mesh (materials preserved, one undo) + multi-select menu audit#92
AlexZ005 wants to merge 4 commits into
release/nextfrom
feat/roadmap15-g-convert-to-mesh

Conversation

@AlexZ005

@AlexZ005 AlexZ005 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

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 through mergeGeometries. Each source geometry is normalized first (non-indexed, position/normal/uv only, 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.
  • Materials survive. One geometry group per source material slot, deduplicated by material identity, then re-pointed onto the merged groups — mergeGeometries numbers 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: mergeGeometries writes 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.
  • The multi-select emissive highlight is stripped from the cloned materials — same mechanism as the 15-B2 duplicate bug (a clone made from a tinted source keeps the selection blue forever).
  • ONE undo entry: the deletes and the create run inside beginHistoryBatch()/endHistoryBatch('Convert to mesh'), the groupSelection template.
  • Replication deviates from the plan. The plan said addImported, which ends in sendObjects(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.
  • Guards: rigged (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):

  • Rename, Add note, Add flow to Scene graph, Ungroup — hidden while a SET is selected. Add note especially: the ViewportMenu path passes the sticky primary, not necessarily what is under the cursor, so it would anchor a note somewhere the user did not point.
  • Properties used to call 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.
  • Bonus (the plan's optional S): ungroupObject is 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:

  • merge basics (one object out, both originals gone, 2 geometry groups, 2-slot material array, both colors, no tint baked in, vertex counts add up, world span preserved)
  • ONE undo restores both originals and removes the merge; redo re-converts and the redone mesh keeps its groups + geometry
  • re-merging a 2-slot mesh with a box gives 3 groups / 3 materials (the multi-material split)
  • group path: a Group of 3 becomes one mesh with 3 groups, named after the group
  • guards: lone mesh, light-in-the-selection, peer-locked — each refuses AND changes nothing, plus an A/B showing the same pair merges once the lock is released (so the refusal was the lock, not some other guard)
  • menu audit: the counted entry appears for a set and for a lone Group but not a lone mesh, the four single-target entries are hidden for a set and still present for a lone object, Properties keeps the set
  • broadcast: exactly one object message, two delete messages, and NO group message (the sendObjects trap)
  • ungroup = one undo
  • two peers: B receives the merged mesh as a Mesh with both geometry groups, both source colors and the full geometry, and loses both originals

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 build passes; svelte-check baseline held at 419/62 (none of the touched files appear in the output).

🤖 Generated with Claude Code

AlexZ005 and others added 4 commits August 9, 2026 10:05
… 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
AlexZ005 force-pushed the feat/roadmap15-g-convert-to-mesh branch from 07cca4b to 9962ddc Compare August 9, 2026 07:16
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.

1 participant