diff --git a/src/components/menu/MeshEditPopup.svelte b/src/components/menu/MeshEditPopup.svelte index 14150ff6..9f4d8b8b 100644 --- a/src/components/menu/MeshEditPopup.svelte +++ b/src/components/menu/MeshEditPopup.svelte @@ -82,6 +82,12 @@ ]; const GRANULARITIES = [ + { + value: 'quad', + label: 'Quad', + title: + 'Pick the quad under the cursor — the two triangles that form it (a 3-sided face picks alone)' + }, { value: 'face', label: 'Face', title: 'Pick the whole coplanar face' }, { value: 'triangle', label: 'Tri', title: 'Pick the single triangle under the cursor' }, { diff --git a/src/components/play/VREditMenu.svelte b/src/components/play/VREditMenu.svelte index 5d712a71..3a306068 100644 --- a/src/components/play/VREditMenu.svelte +++ b/src/components/play/VREditMenu.svelte @@ -17,6 +17,16 @@ // Control meshes stay named vredit- for the vrControls raycast; // the 111 grab/persist applies (id editmenu). + // 15-G: one label map instead of a nested ternary — that ternary predated + // 'object' and would have shown BOTH 'quad' and 'object' as "Triangle" + const GRAN_LABELS: Record = { + quad: 'Quad', + face: 'Face', + triangle: 'Triangle', + shell: 'Shell', + object: 'Object' + } + const { renderer } = useThrelte() const WIDTH = 0.22 @@ -48,7 +58,7 @@ // 212: granularity + multi toggles above the ops const nSel = $faceEditSelectedTris.length list.push( - { action: 'edit:granularity', label: `Select: ${$faceEditGranularity === 'shell' ? 'Shell' : $faceEditGranularity === 'face' ? 'Face' : 'Triangle'}`, active: $faceEditGranularity !== 'face' }, + { action: 'edit:granularity', label: `Select: ${GRAN_LABELS[$faceEditGranularity] ?? 'Quad'}`, active: $faceEditGranularity !== 'quad' }, { action: 'edit:multi', label: `Multi: ${$faceEditMulti ? 'On' : 'Off'}${$faceEditMulti && nSel ? ` (${nSel})` : ''}`, active: $faceEditMulti }, { action: 'face:extrude', label: 'Extrude', active: op === 'extrude' }, { action: 'face:inset', label: 'Inset', active: op === 'inset' }, diff --git a/src/components/ui/Icon.svelte b/src/components/ui/Icon.svelte index 987babb0..98df7e64 100644 --- a/src/components/ui/Icon.svelte +++ b/src/components/ui/Icon.svelte @@ -10,6 +10,7 @@ Boxes, Brush, Camera, + Combine, Copy, Eye, FileText, @@ -51,6 +52,7 @@ boxes: Boxes, brush: Brush, camera: Camera, + combine: Combine, copy: Copy, eye: Eye, 'file-text': FileText, diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index 254dbaef..7890da7e 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -119,21 +119,46 @@ export function lookupEditable(uuid) { return editProxy && editProxy.uuid === uuid ? editProxy : null; } +/** Carry a MATERIAL SLOT onto a triangle. Stored as a property on the triangle + * array so every op that clones/filters/maps tris keeps it without changing the + * [v0,v1,v2] shape every caller expects. @param {any} tri @param {number=} mi */ +function withSlot(tri, mi) { + tri.mi = mi || 0; + return tri; +} + +/** element index -> material slot, read off a geometry's groups (0 when the + * geometry is ungrouped) @param {any[]} groups @param {number} count */ +function slotLookup(groups, count) { + if (!groups?.length) return () => 0; + const slots = new Int32Array(count); + for (const group of groups) { + const start = Math.max(group.start | 0, 0); + const end = Math.min(start + (group.count | 0), count); + for (let i = start; i < end; i++) slots[i] = group.materialIndex || 0; + } + return (/** @type {number} */ i) => slots[i] || 0; +} + /** * Read a geometry into triangles [[Vector3,Vector3,Vector3], ...] (index - * expanded). @param {any} geometry + * expanded). Each triangle also carries `mi` — the MATERIAL SLOT it came from + * (15-G): a merged or imported mesh can wear a material ARRAY, and three draws + * an array material by walking `geometry.groups`, so a swapped-in geometry with + * no groups renders NOTHING AT ALL. @param {any} geometry */ export function readTriangles(geometry) { const pos = geometry.attributes.position; const index = geometry.index; const count = index ? index.count : pos.count; + const slotAt = slotLookup(geometry.groups, count); const tris = []; for (let i = 0; i < count; i += 3) { const vert = (/** @type {number} */ o) => { const j = index ? index.getX(i + o) : i + o; return new THREE.Vector3(pos.getX(j), pos.getY(j), pos.getZ(j)); }; - tris.push([vert(0), vert(1), vert(2)]); + tris.push(withSlot([vert(0), vert(1), vert(2)], slotAt(i))); } return tris; } @@ -197,8 +222,151 @@ export function groupFaces(tris) { }); } +/** + * 15-G: pair coplanar neighbour triangles into QUADS — the unit a modeler + * actually thinks in. The geometry here is a triangle SOUP with no stored face + * topology, so quads are re-derived from the mesh whenever it changes. + * + * Two triangles pair when they share an edge, face the same way, and the quad + * they would form is CONVEX (the shared edge has to be a real diagonal — + * pairing across a concave joint gives a bow-tie). Every candidate is scored by + * how rectangular the result is and matched GREEDILY best-first, so a coplanar + * fan pairs the obvious way rather than the first way. A triangle left without + * a partner is its own unit (the answer to "a genuine 3-sided face"). + * + * Returns `partner`, where partner[i] is i's quad-mate or -1. Deterministic for + * a given geometry: score ties break on triangle index. + * @param {any[]} tris @returns {Int32Array} + */ +export function pairQuads(tris) { + const partner = new Int32Array(tris.length).fill(-1); + const normals = tris.map(triNormal); + /** @type {Map} edge key -> the triangles touching it */ + const edgeMap = new Map(); + tris.forEach((t, ti) => { + for (let e = 0; e < 3; e++) { + const k1 = keyOf(t[e].x, t[e].y, t[e].z); + const k2 = keyOf(t[(e + 1) % 3].x, t[(e + 1) % 3].y, t[(e + 1) % 3].z); + const ek = [k1, k2].sort().join('|'); + let list = edgeMap.get(ek); + if (!list) edgeMap.set(ek, (list = [])); + list.push(ti); + } + }); + + /** the corner of `t` that is NOT on the shared edge @param {any[]} t @param {string[]} shared */ + const cornerOff = (t, shared) => + t.find((/** @type {any} */ v) => !shared.includes(keyOf(v.x, v.y, v.z))); + /** the corner of `t` at a given key @param {any[]} t @param {string} key */ + const cornerAt = (t, key) => t.find((/** @type {any} */ v) => keyOf(v.x, v.y, v.z) === key); + + /** @type {{ a: number, b: number, score: number }[]} */ + const candidates = []; + for (const [ek, list] of edgeMap) { + if (list.length !== 2) continue; // an open or non-manifold edge is no diagonal + const [a, b] = list; + if (a === b) continue; + if (normals[a].dot(normals[b]) < 0.999) continue; // coplanar AND co-facing + const shared = ek.split('|'); + const ra = cornerOff(tris[a], shared); + const rb = cornerOff(tris[b], shared); + const p = cornerAt(tris[a], shared[0]); + const q = cornerAt(tris[a], shared[1]); + if (!ra || !rb || !p || !q) continue; + // ring order around the quad: the shared edge p-q is the diagonal, so the + // two off-corners sit between its ends + const score = quadScore([p, ra, q, rb], normals[a]); + if (score === null) continue; // concave or degenerate — not a quad + candidates.push({ a: Math.min(a, b), b: Math.max(a, b), score }); + } + + // best-first greedy matching; ties break on index so the result is stable + candidates.sort((x, y) => x.score - y.score || x.a - y.a || x.b - y.b); + for (const { a, b } of candidates) { + if (partner[a] !== -1 || partner[b] !== -1) continue; + partner[a] = b; + partner[b] = a; + } + return partner; +} + +/** + * How rectangular a candidate quad is (lower is better), or null when the ring + * is concave or degenerate — which means those two triangles do NOT form a quad. + * @param {any[]} ring 4 corners in order @param {any} normal + */ +function quadScore(ring, normal) { + let score = 0; + let sign = 0; + for (let i = 0; i < 4; i++) { + const cur = ring[i]; + const u = new THREE.Vector3().subVectors(ring[(i + 3) % 4], cur); + const v = new THREE.Vector3().subVectors(ring[(i + 1) % 4], cur); + if (u.lengthSq() < 1e-12 || v.lengthSq() < 1e-12) return null; + u.normalize(); + v.normalize(); + // every corner must turn the same way around the face normal, or the ring + // folds over itself (the bow-tie case) + const turn = new THREE.Vector3().crossVectors(v, u).dot(normal); + if (Math.abs(turn) < 1e-9) return null; // collinear corner + if (sign === 0) sign = Math.sign(turn); + else if (Math.sign(turn) !== sign) return null; // concave + // squareness: 0 at a right angle, approaching 1 as the corner collapses + score += Math.abs(u.dot(v)); + } + return score; +} + function cloneTris(/** @type {any[]} */ tris) { - return tris.map((t) => [t[0].clone(), t[1].clone(), t[2].clone()]); + return tris.map((t) => withSlot([t[0].clone(), t[1].clone(), t[2].clone()], t.mi)); +} + +/** average vertex position of a triangle set @param {any[]} tris @param {number[]} triIndices */ +function centroidOfTris(tris, triIndices) { + const centroid = new THREE.Vector3(); + let count = 0; + triIndices.forEach((ti) => tris[ti].forEach((/** @type {any} */ v) => (centroid.add(v), count++))); + return centroid.divideScalar(count || 1); +} + +/** + * Split a target triangle set into CONNECTED COMPONENTS, welded by vertex + * position. `opTargetFace` synthesizes ONE face for a multi-selection, so a + * selection spanning two separate shells arrives as a single "face" whose + * centroid sits in the empty space between them — any op that reasons about a + * face's CENTRE has to work per component or it drags one shell toward the + * other (15-G: two merged cubes, both top faces inset, slid together). + * @param {any[]} tris @param {number[]} triIndices @returns {number[][]} + */ +function componentsOfTris(tris, triIndices) { + /** @type {Map} */ + const parent = new Map(); + const find = (/** @type {string} */ key) => { + let root = key; + while (parent.get(root) !== root) root = /** @type {string} */ (parent.get(root)); + while (parent.get(key) !== root) { + const next = /** @type {string} */ (parent.get(key)); + parent.set(key, root); + key = next; + } + return root; + }; + const keysOf = (/** @type {number} */ ti) => + tris[ti].map((/** @type {any} */ v) => keyOf(v.x, v.y, v.z)); + for (const ti of triIndices) { + const keys = keysOf(ti); + for (const key of keys) if (!parent.has(key)) parent.set(key, key); + for (let i = 1; i < keys.length; i++) parent.set(find(keys[0]), find(keys[i])); + } + /** @type {Map} */ + const byRoot = new Map(); + for (const ti of triIndices) { + const root = find(keysOf(ti)[0]); + let list = byRoot.get(root); + if (!list) byRoot.set(root, (list = [])); + list.push(ti); + } + return [...byRoot.values()]; } /** boundary edges of a face group: directed edges appearing once (unordered) @@ -215,7 +383,9 @@ function boundaryEdges(tris, face) { const p1 = t[(e + 1) % 3]; const ek = [keyOf(p0.x, p0.y, p0.z), keyOf(p1.x, p1.y, p1.z)].sort().join('|'); count.set(ek, (count.get(ek) || 0) + 1); - dir.push({ ek, p0, p1 }); + // `ti` = the triangle this directed edge belongs to, so a wall stitched + // onto it can take that triangle's OWN normal + material slot (15-G) + dir.push({ ek, p0, p1, ti }); } }); return dir.filter((d) => count.get(d.ek) === 1); @@ -225,24 +395,38 @@ function boundaryEdges(tris, face) { * Add a quad (a,b,c,d) as two triangles, winding it so its normal aligns with * wantDir — otherwise the wall/ring backface-culls to invisible (121 fix). * @param {any[]} out @param {any} a @param {any} b @param {any} c @param {any} d @param {any} wantDir + * @param {number=} mi material slot the new geometry belongs to (15-G) */ -function pushQuad(out, a, b, c, d, wantDir) { +function pushQuad(out, a, b, c, d, wantDir, mi) { let t1 = [a, b, c]; let t2 = [a, c, d]; if (triNormal(t1).dot(wantDir) < 0) { t1 = [a, c, b]; t2 = [a, d, c]; } - out.push(t1, t2); + out.push(withSlot(t1, mi), withSlot(t2, mi)); } -/** radial-outward direction at a wall midpoint (perpendicular to the face - * normal) — the visible side of an extrude wall @param {any} mid @param {any} face */ -function radialOut(mid, face) { - const r = mid.clone().sub(face.centroid); - r.addScaledVector(face.normal, -r.dot(face.normal)); // drop the normal component - if (r.lengthSq() < 1e-9) r.copy(face.normal); - return r.normalize(); +/** + * Outward direction of the wall stitched onto a boundary edge — the side that + * must face the viewer, or the wall backface-culls to invisible. + * + * Derived LOCALLY from the edge itself: boundary edges inherit their direction + * from the triangles' winding, and a face is wound counter-clockwise seen from + * +normal, so `edge x normal` points away from the face interior. It used to be + * measured from the face CENTROID instead, which is only right for a single + * convex face: with two shells multi-selected the synthetic centroid lands in + * the gap between them, so the two walls FACING EACH OTHER were wound inward + * and vanished (15-G — extruding both top faces of two merged cubes). Concave + * faces and faces with holes were wrong for the same reason. + * @param {any} p0 @param {any} p1 @param {any} normal + */ +function edgeOutward(p0, p1, normal) { + const out = new THREE.Vector3().subVectors(p1, p0).cross(normal); + // an edge always lies in its own face plane, so this is only ever degenerate + // for a zero-area triangle + if (out.lengthSq() < 1e-12) return normal.clone(); + return out.normalize(); } /** Extrude a face by dist along its normal, stitching visible side walls @param {any[]} tris @param {any} face @param {number} dist */ @@ -252,15 +436,16 @@ export function extrudeFace(tris, face, dist) { const faceSet = new Set(face.triIndices); const boundary = boundaryEdges(tris, face); out.forEach((t, ti) => { - if (faceSet.has(ti)) t.forEach((v) => v.add(offset)); + if (faceSet.has(ti)) t.forEach((/** @type {any} */ v) => v.add(offset)); }); - boundary.forEach(({ p0, p1 }) => { + boundary.forEach(({ p0, p1, ti }) => { const a = p0.clone(); const b = p1.clone(); const a2 = p0.clone().add(offset); const b2 = p1.clone().add(offset); - const mid = a.clone().add(b).add(b2).add(a2).multiplyScalar(0.25); - pushQuad(out, a, b, b2, a2, radialOut(mid, face)); + // each wall takes its OWN triangle's normal + slot: a multi-selection can + // span shells (and, in a mixed selection, differently-facing faces) + pushQuad(out, a, b, b2, a2, edgeOutward(p0, p1, triNormal(tris[ti])), tris[ti].mi); }); return out; } @@ -283,29 +468,37 @@ export function moveFaceAlongNormal(tris, face, dist) { const out = cloneTris(tris); const offset = face.normal.clone().multiplyScalar(dist); const keys = faceVertexKeys(tris, face); - out.forEach((t) => t.forEach((v) => { if (keys.has(keyOf(v.x, v.y, v.z))) v.add(offset); })); + out.forEach((t) => t.forEach((/** @type {any} */ v) => { if (keys.has(keyOf(v.x, v.y, v.z))) v.add(offset); })); return out; } /** Inset: shrink a face toward its centroid + stitch a visible frame ring so - * the gap doesn't read as a hole (121). @param {any[]} tris @param {any} face @param {number} amount */ + * the gap doesn't read as a hole (121). + * + * 15-G: each CONNECTED COMPONENT shrinks toward its OWN centre. A multi-select + * spanning two shells arrives as one synthetic face whose centroid sits between + * them, so shrinking toward it slid both faces sideways into the gap instead of + * insetting either of them in place. + * @param {any[]} tris @param {any} face @param {number} amount */ export function insetFace(tris, face, amount) { const out = cloneTris(tris); - const faceSet = new Set(face.triIndices); const t = Math.min(Math.max(amount, 0), 0.95); - const boundary = boundaryEdges(tris, face); - out.forEach((tri, ti) => { - if (faceSet.has(ti)) tri.forEach((v) => v.lerp(face.centroid, t)); - }); - // frame ring: original boundary edge → its inset counterpart, facing outward - // like the face did (normal ≈ the face normal) - boundary.forEach(({ p0, p1 }) => { - const a = p0.clone(); - const b = p1.clone(); - const b2 = p1.clone().lerp(face.centroid, t); - const a2 = p0.clone().lerp(face.centroid, t); - pushQuad(out, a, b, b2, a2, face.normal); - }); + for (const component of componentsOfTris(tris, face.triIndices)) { + const centroid = centroidOfTris(tris, component); + const componentSet = new Set(component); + out.forEach((tri, ti) => { + if (componentSet.has(ti)) tri.forEach((/** @type {any} */ v) => v.lerp(centroid, t)); + }); + // frame ring: original boundary edge → its inset counterpart, facing outward + // like the face did (normal ≈ the face normal) + boundaryEdges(tris, { triIndices: component }).forEach(({ p0, p1, ti }) => { + const a = p0.clone(); + const b = p1.clone(); + const b2 = p1.clone().lerp(centroid, t); + const a2 = p0.clone().lerp(centroid, t); + pushQuad(out, a, b, b2, a2, triNormal(tris[ti]), tris[ti].mi); + }); + } return out; } @@ -325,7 +518,7 @@ export function subdivideFaceTris(tris, targetTris) { const out = []; tris.forEach((t, ti) => { if (!targets.has(ti)) { - out.push([t[0].clone(), t[1].clone(), t[2].clone()]); + out.push(withSlot([t[0].clone(), t[1].clone(), t[2].clone()], t.mi)); return; } const [a, b, c] = t; @@ -333,10 +526,10 @@ export function subdivideFaceTris(tris, targetTris) { const bc = b.clone().add(c).multiplyScalar(0.5); const ca = c.clone().add(a).multiplyScalar(0.5); out.push( - [a.clone(), ab.clone(), ca.clone()], - [ab.clone(), b.clone(), bc.clone()], - [ca.clone(), bc.clone(), c.clone()], - [ab, bc, ca] + withSlot([a.clone(), ab.clone(), ca.clone()], t.mi), + withSlot([ab.clone(), b.clone(), bc.clone()], t.mi), + withSlot([ca.clone(), bc.clone(), c.clone()], t.mi), + withSlot([ab, bc, ca], t.mi) ); }); return out; @@ -347,9 +540,12 @@ export function subdivideFaceTris(tris, targetTris) { export function flipFaceNormals(tris, targetTris) { const targets = new Set(targetTris); return tris.map((t, ti) => - targets.has(ti) - ? [t[0].clone(), t[2].clone(), t[1].clone()] - : [t[0].clone(), t[1].clone(), t[2].clone()] + withSlot( + targets.has(ti) + ? [t[0].clone(), t[2].clone(), t[1].clone()] + : [t[0].clone(), t[1].clone(), t[2].clone()], + t.mi + ) ); } @@ -376,7 +572,7 @@ export function boundaryLoop(tris, triIndices) { } /** - * B4: bridge exactly TWO multi-selected faces into a tunnel — delete both + * B4: bridge exactly TWO multi-selected pieces into a tunnel — delete both * caps, stitch quads between their boundary loops (equal edge counts * required), walking both loops from the closest-vertex-pair anchor and * winding each quad OUTWARD from the tunnel axis. Commits + replicates + @@ -389,23 +585,26 @@ export function bridgeFaces() { showToast('Multi-select two faces first (Multi on, click both)'); return false; } - /** @type {Set} logical faces the selection covers */ - const faceSet = new Set(); - sel.forEach((/** @type {number} */ ti) => { - const fi = faceIndexForTriangle(ti); - if (fi >= 0) faceSet.add(fi); - }); - if (faceSet.size !== 2) { - showToast('Bridge needs exactly TWO selected faces (' + faceSet.size + ' selected)'); + // The op target is the SELECTION, split into its two connected pieces — NOT + // the coplanar groups the selection happens to touch (the opTargetFace rule). + // Expanding to whole logical faces silently ignored Face/Triangle/Shell + // granularity: extruding a face leaves a wall that is COPLANAR with the flat + // side beneath it, so groupFaces merges the two, and picking just the wall + // band bridged the entire side of the shell instead (15-G). + const parts = componentsOfTris(workingTris, sel); + if (parts.length !== 2) { + showToast( + parts.length < 2 + ? 'Bridge needs TWO separate pieces — the selected faces touch each other' + : 'Bridge needs exactly TWO pieces (' + parts.length + ' separate pieces selected)' + ); return false; } - const [fiA, fiB] = [...faceSet]; - const setA = faces[fiA].triIndices; - const setB = faces[fiB].triIndices; + const [setA, setB] = parts; const loopA = boundaryLoop(workingTris, setA); const loopB = boundaryLoop(workingTris, setB); if (!loopA || !loopB) { - showToast('Bridge faces need one closed boundary each'); + showToast('Bridge pieces need one closed boundary each'); return false; } if (loopA.length !== loopB.length) { @@ -413,6 +612,7 @@ export function bridgeFaces() { return false; } const before = trisToPositions(workingTris); + const beforeGroups = trisToGroups(workingTris); const remove = new Set([...setA, ...setB]); const next = cloneTris(workingTris.filter((/** @type {any} */ _, /** @type {number} */ ti) => !remove.has(ti))); const n = loopA.length; @@ -444,6 +644,9 @@ export function bridgeFaces() { loopB.forEach((/** @type {any} */ p) => centB.add(p)); centB.multiplyScalar(1 / n); const axis = centB.clone().sub(centA); + // the tunnel walls take the FIRST piece's material slot (15-G) — a merged + // multi-material mesh must stay fully grouped or it renders as nothing + const mi = workingTris[setA[0]]?.mi || 0; for (let k = 0; k < n; k++) { const a0 = loopA[(ai + k) % n]; const a1 = loopA[(ai + k + 1) % n]; @@ -457,16 +660,22 @@ export function bridgeFaces() { wantDir = mid.clone().sub(centA.clone().addScaledVector(axis, t)); } else wantDir = mid.clone().sub(centA); if (wantDir.lengthSq() < 1e-9) wantDir = new THREE.Vector3(0, 1, 0); - pushQuad(next, a0.clone(), a1.clone(), b1.clone(), b0.clone(), wantDir.normalize()); + pushQuad(next, a0.clone(), a1.clone(), b1.clone(), b0.clone(), wantDir.normalize(), mi); } const positions = trisToPositions(next); if (positions.length > MAX_SNAPSHOT) { showToast('That edit is too large to sync'); return false; } - applyGeometrySnapshot(positions); - broadcastMeshGeo(faceEdited.uuid, positions); - recordEntry({ kind: 'meshgeo', uuid: faceEdited.uuid, before, after: positions }); + const groups = trisToGroups(next); + applyGeometrySnapshot(positions, groups); + broadcastMeshGeo(faceEdited.uuid, positions, groups); + recordEntry({ + kind: 'meshgeo', + uuid: faceEdited.uuid, + before: { positions: before, groups: beforeGroups }, + after: { positions, groups } + }); faceEditSelectedTris.set([]); faceEditHighlight.set(-1); return true; @@ -489,6 +698,59 @@ export function trisToGeometry(tris) { return geometry; } +/** + * 15-G: the triangles' MATERIAL SLOTS run-length encoded into geometry groups + * (vertex units, the shape three wants). Returns null when everything is slot 0 + * — a single-material mesh needs no groups at all, so the overwhelmingly common + * case puts nothing extra on the wire and behaves exactly as before. + * @param {any[]} tris @returns {any[] | null} + */ +export function trisToGroups(tris) { + if (!tris.length || !tris.some((t) => (t.mi || 0) > 0)) return null; + /** @type {any[]} */ + const groups = []; + let start = 0; + let slot = tris[0].mi || 0; + for (let i = 1; i <= tris.length; i++) { + const next = i < tris.length ? tris[i].mi || 0 : -1; + if (next === slot) continue; + groups.push({ start: start * 3, count: (i - start) * 3, materialIndex: slot }); + start = i; + slot = next; + } + return groups; +} + +/** + * Keep a MULTI-MATERIAL mesh renderable across a geometry swap (15-G). three + * draws an array material by iterating `geometry.groups`, so a fresh geometry + * with none draws NOTHING — a merged mesh vanished the moment any face op ran. + * Prefer the groups the edit computed; else carry the previous geometry's over + * (exact whenever the vertex count is unchanged — the sculpt / vertex-drag + * paths); and always cover the tail so no triangle is left unrendered. + * @param {any} geometry @param {any} previous the geometry being replaced + * @param {any} object @param {any[] | null} [groups] + */ +function preserveMaterialGroups(geometry, previous, object, groups) { + if (!Array.isArray(object?.material) || object.material.length < 2) return; + const count = geometry.attributes.position.count; + const source = groups?.length ? groups : previous?.groups; + let covered = 0; + let last = 0; + if (source?.length) + for (const group of source) { + const start = Math.max(group.start | 0, 0); + const size = Math.min(group.count | 0, count - start); + if (start >= count || size <= 0) continue; + last = group.materialIndex || 0; + geometry.addGroup(start, size, last); + covered = Math.max(covered, start + size); + } + // a shorter list than the geometry (an older peer's ungrouped snapshot, or a + // path that does not track slots) would leave the tail invisible + if (covered < count) geometry.addGroup(covered, count - covered, last); +} + /** flat positions array for a snapshot message @param {any[]} tris */ export function trisToPositions(tris) { /** @type {number[]} */ @@ -516,8 +778,10 @@ export function stretchPositions(positions, axis, factor) { /** * Swap an object's geometry to a positions snapshot (remote msg / undo replay). * @param {string} uuid @param {number[]} positions + * @param {any[] | null} [groups] material groups for a multi-material mesh (15-G); + * omitted by the sculpt/vertex paths, which never change the vertex count */ -export function applyMeshGeo(uuid, positions) { +export function applyMeshGeo(uuid, positions, groups) { const object = lookupEditable(uuid); // A8: also finds the collider-edit proxy if (!object) return; // positions arrive as a plain array (history replays), an ArrayBuffer (the @@ -542,7 +806,9 @@ export function applyMeshGeo(uuid, positions) { // position-welded vertices (deterministic: every peer derives the same // shading from the same positions; nothing extra on the wire) if (object.userData.terrain) smoothWeldedNormals(geometry); - object.geometry?.dispose?.(); + const previous = object.geometry; + preserveMaterialGroups(geometry, previous, object, groups); + previous?.dispose?.(); object.geometry = geometry; object.userData.faceEdited = true; // parametric Geometry rows disable (like vertexEdited) // if we're editing this object, re-derive working tris + faces (a remote @@ -733,13 +999,16 @@ export function commitArmedFaceOp() { return commitFaceOp(op, get(faceEditAmount)); } -// ---- 212: granularity + multiselect (CL-B B3: Face / Triangle / Shell) ----- -/** face-select granularity: 'face' = coplanar group (default), 'triangle' = - * the single tri under the ray (was MISLABELED 'polygon' — still accepted at - * read time), 'shell' = the whole connected island of welded triangles. - * @type {import('svelte/store').Writable<'face'|'triangle'|'shell'|'polygon'>} */ -/** @type {import('svelte/store').Writable<'face'|'triangle'|'shell'|'object'|'polygon'>} */ -export const faceEditGranularity = writable('face'); +// ---- 212: granularity + multiselect (CL-B B3, 15-G Quad) ----- +/** face-select granularity: 'quad' = the two triangles forming a quad (DEFAULT + * since 15-G — what a modeler expects to click, and unlike 'face' an extrusion + * wall stays its own unit instead of merging into the coplanar side beneath it; + * on a plain box a quad IS the side); 'face' = the whole coplanar group; + * 'triangle' = the single tri under the ray (was MISLABELED 'polygon' — that + * RETIRED alias still reads as 'triangle', and must not be confused with + * 'quad'); 'shell' = the connected island of welded triangles; 'object' = all. + * @type {import('svelte/store').Writable<'quad'|'face'|'triangle'|'shell'|'object'|'polygon'>} */ +export const faceEditGranularity = writable('quad'); /** the granularity with the legacy 'polygon' value migrated at read time */ function granularity() { @@ -758,6 +1027,11 @@ function pickFaceUnitTris(tri) { if (tri < 0 || !workingTris[tri]) return []; const g = granularity(); if (g === 'triangle') return [tri]; + // 15-G: the quad the triangle belongs to — what a modeler means by a face. + // Sits BETWEEN triangle and face: a box side is one quad either way, but an + // extrusion wall stays its own quad instead of merging into the coplanar + // side beneath it (which is what `face` does, by design). + if (g === 'quad') return quadOfTriangle(tri); // the WHOLE mesh. Differs from `shell` only when the mesh has SEVERAL // disconnected islands (a merged group, a multi-part import) — on a plain box // or sphere every triangle is one island, so Shell already is the object. @@ -789,17 +1063,19 @@ export function clearFaceSelection() { } /** B3: set the pick granularity (units differ, so drop the selection). - * Legacy 'polygon' maps to 'triangle'. @param {'face'|'triangle'|'shell'|'object'|'polygon'} mode */ + * Legacy 'polygon' maps to 'triangle' — it is a RETIRED alias for the old + * triangle mode and must NOT be confused with 15-G's 'quad'. + * @param {'face'|'quad'|'triangle'|'shell'|'object'|'polygon'} mode */ export function setFaceGranularity(mode) { const next = mode === 'polygon' ? 'triangle' : mode; - if (!['face', 'triangle', 'shell', 'object'].includes(next)) return; + if (!['face', 'quad', 'triangle', 'shell', 'object'].includes(next)) return; faceEditGranularity.set(/** @type {any} */ (next)); clearFaceSelection(); } -/** Cycle FACE -> TRIANGLE -> SHELL -> OBJECT (VR menu keeps its one toggle button) */ +/** Cycle QUAD -> FACE -> TRIANGLE -> SHELL -> OBJECT (VR keeps one toggle button) */ export function toggleFaceGranularity() { - const order = ['face', 'triangle', 'shell', 'object']; + const order = ['quad', 'face', 'triangle', 'shell', 'object']; setFaceGranularity( /** @type {any} */ (order[(order.indexOf(granularity()) + 1) % order.length]) ); @@ -862,6 +1138,8 @@ function overlayTris() { let stashedFace = { uuid: null, fi: -1 }; /** @type {any[]} */ let workingTris = []; /** @type {any[]} */ let faces = []; +/** 15-G: quad pairing over workingTris — quadPartner[i] is i's mate, or -1 + * @type {Int32Array} */ let quadPartner = new Int32Array(0); /** @type {any} */ let overlay = null; // highlighted-face tint at the scene root /** rebuild the working triangles + face groups from the live geometry */ @@ -869,6 +1147,23 @@ function rebuildFaces() { if (!faceEdited) return; workingTris = readTriangles(faceEdited.geometry); faces = groupFaces(workingTris); + quadPartner = pairQuads(workingTris); +} + +/** O(1) identity of the quad a triangle belongs to — the lower of the pair, so + * both halves key the same. -1 for no triangle. @param {number} tri */ +function quadKey(tri) { + if (tri < 0 || !workingTris[tri]) return -1; + const mate = quadPartner[tri] ?? -1; + return mate >= 0 ? Math.min(tri, mate) : tri; +} + +/** the tri indices of the quad `tri` belongs to — itself when it has no mate + * (a genuine 3-sided face, or an odd triangle in a fan). @param {number} tri */ +export function quadOfTriangle(tri) { + if (tri < 0 || !workingTris[tri]) return []; + const mate = quadPartner[tri] ?? -1; + return mate >= 0 ? [tri, mate] : [tri]; } export function faceCount() { @@ -997,8 +1292,18 @@ export function highlightFaceByTriangle(triangleIndex, healStale = true) { } } } - // triangle/shell units are picked per-tri, so refresh per raw tri change - const changed = granularity() !== 'face' ? triangleIndex !== prevTri : fi !== prevFi; + // "changed" means the picked UNIT changed — that is what the overlay draws. + // Face compares the coplanar group; 15-G quad compares the PAIR (crossing a + // quad's internal diagonal is not a new unit, so the overlay must not + // rebuild); triangle/shell/object stay per raw tri (a shell key would mean + // re-running the union-find on every hover frame). + const g = granularity(); + const changed = + g === 'face' + ? fi !== prevFi + : g === 'quad' + ? quadKey(triangleIndex) !== quadKey(prevTri) + : triangleIndex !== prevTri; if (changed || healed) refreshFaceOverlay(); return changed; } @@ -1101,6 +1406,7 @@ export function commitFaceOp(op, amount) { const face = opTargetFace(); if (!faceEdited || !face) return false; const before = trisToPositions(workingTris); + const beforeGroups = trisToGroups(workingTris); let next; if (op === 'extrude') next = extrudeFace(workingTris, face, amount); else if (op === 'inset') next = insetFace(workingTris, face, amount); @@ -1114,9 +1420,18 @@ export function commitFaceOp(op, amount) { showToast('That edit is too large to sync'); return false; } - applyGeometrySnapshot(positions); - broadcastMeshGeo(faceEdited.uuid, positions); - recordEntry({ kind: 'meshgeo', uuid: faceEdited.uuid, before, after: positions }); + // 15-G: these ops change the triangle COUNT, so a multi-material mesh needs + // its groups recomputed (the grab/adjust paths only move vertices, and their + // counts match, so applyGeometrySnapshot carries the old groups over) + const groups = trisToGroups(next); + applyGeometrySnapshot(positions, groups); + broadcastMeshGeo(faceEdited.uuid, positions, groups); + recordEntry({ + kind: 'meshgeo', + uuid: faceEdited.uuid, + before: { positions: before, groups: beforeGroups }, + after: { positions, groups } + }); if (op === 'inset' || op === 'extrude' || op === 'move') { // E6: keep the CAP selected — its tri indices survive the op (cloneTris // keeps order, ring/walls APPEND). groupFaces re-merges a coplanar inset @@ -1137,13 +1452,16 @@ export function commitFaceOp(op, amount) { return true; } -/** swap the LIVE edited object's geometry + re-derive faces + overlay @param {number[]} positions */ -function applyGeometrySnapshot(positions) { +/** swap the LIVE edited object's geometry + re-derive faces + overlay + * @param {number[]} positions @param {any[] | null} [groups] material groups (15-G) */ +function applyGeometrySnapshot(positions, groups) { const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3)); geometry.computeVertexNormals(); geometry.computeBoundingSphere(); - faceEdited.geometry?.dispose?.(); + const previous = faceEdited.geometry; + preserveMaterialGroups(geometry, previous, faceEdited, groups); + previous?.dispose?.(); faceEdited.geometry = geometry; faceEdited.userData.faceEdited = true; rebuildFaces(); @@ -1152,15 +1470,23 @@ function applyGeometrySnapshot(positions) { objectsGroup.update((v) => v); } -/** @param {string} uuid @param {number[]} positions */ -function broadcastMeshGeo(uuid, positions) { +/** @param {string} uuid @param {number[]} positions @param {any[] | null} [groups] */ +function broadcastMeshGeo(uuid, positions, groups) { /** @type {any} */ const peer = get(peers); // raw Float32 BYTES, not a plain number array: binarypack recurses per // element and blows the call stack on big arrays (a 48-seg terrain snapshot // = 41k numbers silently vanished — broadcast() catches the throw), and // bytes are ~half the wire size anyway. applyMeshGeo accepts either shape. - if (peer) peer.send({ type: 'meshgeo', uuid: uuid, positions: new Float32Array(positions).buffer }); + // `groups` is a handful of small objects — safe as a plain value, and absent + // entirely for the single-material case (an older peer simply ignores it) + if (peer) + peer.send({ + type: 'meshgeo', + uuid: uuid, + positions: new Float32Array(positions).buffer, + ...(groups?.length ? { groups } : {}) + }); } /** @@ -1259,11 +1585,17 @@ let faceAdjust = null; * (indices stay stable through a gesture); broadcasts a preview ~5/s. */ function liveGeometryUpdate() { const positions = trisToPositions(workingTris); + // 15-G: the live preview swaps geometry every frame — without the groups a + // multi-material mesh blinks out for the whole gesture (a live extrude/inset + // adjust re-stitches walls, so the count changes too) + const groups = trisToGroups(workingTris); const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3)); geometry.computeVertexNormals(); geometry.computeBoundingSphere(); - faceEdited.geometry?.dispose?.(); + const previous = faceEdited.geometry; + preserveMaterialGroups(geometry, previous, faceEdited, groups); + previous?.dispose?.(); faceEdited.geometry = geometry; faceEdited.userData.faceEdited = true; refreshFaceOverlay(); @@ -1272,7 +1604,7 @@ function liveGeometryUpdate() { const now = Date.now(); if (now - lastFaceBroadcast > 200) { lastFaceBroadcast = now; - broadcastMeshGeo(faceEdited.uuid, positions); + broadcastMeshGeo(faceEdited.uuid, positions, groups); } } @@ -1631,11 +1963,22 @@ export function cancelFaceAdjust() { // undo/redo replays meshgeo snapshots through the same apply + broadcast path registerHistoryKind('meshgeo', (entry, state) => { - applyMeshGeo(entry.uuid, state); + // 15-G: a topology op stores {positions, groups} so a multi-material mesh + // keeps its slots through undo/redo; every other producer (sculpt strokes, + // vertex drags, VR grabs) still stores a bare positions array + const positions = state?.positions ?? state; + const groups = state?.positions ? state.groups : undefined; + applyMeshGeo(entry.uuid, positions, groups); // same raw-bytes wire format as broadcastMeshGeo (big plain arrays blow // binarypack's recursion and the replay would silently not replicate) /** @type {any} */ const peer = get(peers); - if (peer) peer.send({ type: 'meshgeo', uuid: entry.uuid, positions: new Float32Array(state).buffer }); + if (peer) + peer.send({ + type: 'meshgeo', + uuid: entry.uuid, + positions: new Float32Array(positions).buffer, + ...(groups?.length ? { groups } : {}) + }); return true; }); diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index a1b5481b..4375dfc9 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -4,7 +4,7 @@ import { dropToSurface } from './snapping'; import { recordTransform, recordEntry, recordObjectPresence, registerHistoryKind, beginHistoryBatch, endHistoryBatch } from './history'; import { cascadeJointDeletes } from './joints'; import { createGroup } from './geometries.svelte'; -import { suspendAnimation, resumeAnimation } from './flowRuntime'; +import { suspendAnimation, resumeAnimation, parkAnimatedAtBase } from './flowRuntime'; import { objectsGroup, TControls, @@ -566,8 +566,11 @@ export function ungroupObject(groupUuid) { const grp = root?.getObjectByProperty('uuid', groupUuid); if (!grp || grp.type !== 'Group') return false; const children = [...grp.children]; // snapshot: attach() mutates .children + // 15-G: one undo step, not N+1 (a move per child plus the group delete) + beginHistoryBatch(); for (const child of children) moveObjectToGroup(child.uuid, 'up'); deleteObjectsByUuid([groupUuid]); // now empty -> removes just the group + endHistoryBatch('Ungroup'); return true; } @@ -624,6 +627,220 @@ export function groupSelection() { return groupUuid; } +// --- 15-G: convert a Group / multi-selection into ONE mesh --------------------- + +// mergeGeometries wants every input to carry the SAME attribute set, so each +// source geometry is normalized down to this triple (extras like color/uv1/ +// tangent/skinning are dropped, a missing normal/uv is generated). +const MERGE_ATTRIBUTES = ['position', 'normal', 'uv']; + +/** @param {any} ancestor @param {any} object */ +function isAncestorOf(ancestor, object) { + let current = object.parent; + while (current) { + if (current === ancestor) return true; + current = current.parent; + } + return false; +} + +/** Copy a vertex RANGE of a NON-INDEXED geometry into its own geometry. + * @param {any} geometry @param {number} start @param {number} count */ +function sliceGeometry(geometry, start, count) { + const out = new THREE.BufferGeometry(); + for (const name of Object.keys(geometry.attributes)) { + const attribute = geometry.attributes[name]; + const size = attribute.itemSize; + out.setAttribute( + name, + new THREE.BufferAttribute(attribute.array.slice(start * size, (start + count) * size), size) + ); + } + return out; +} + +/** + * One normalized geometry per MATERIAL SLOT of a source mesh. A multi-material + * source has to be split along its own geometry groups first: mergeGeometries + * writes exactly ONE group per input geometry and ignores the groups already on + * it, so a merged mesh would otherwise collapse every slot onto one material. + * @param {any} mesh @returns {{ geometry: any, material: any }[]} + */ +function mergePieces(mesh) { + const source = mesh.geometry; + // toNonIndexed() returns a NEW geometry (and copies the groups, whose + // start/count map 1:1 onto the expanded vertices) + const base = source.index ? source.toNonIndexed() : source.clone(); + for (const name of Object.keys(base.attributes)) + if (!MERGE_ATTRIBUTES.includes(name)) base.deleteAttribute(name); + base.morphAttributes = {}; + if (!base.attributes.normal) base.computeVertexNormals(); + if (!base.attributes.uv) + base.setAttribute( + 'uv', + new THREE.BufferAttribute(new Float32Array(base.attributes.position.count * 2), 2) + ); + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + if (materials.length < 2 || !base.groups.length) + return [{ geometry: base, material: materials[0] }]; + const pieces = base.groups.map((/** @type {any} */ slot) => ({ + geometry: sliceGeometry(base, slot.start, slot.count), + material: materials[slot.materialIndex ?? 0] ?? materials[0] + })); + base.dispose(); + return pieces; +} + +/** + * Merge a Group (or a 2+ multi-selection) into ONE new mesh: geometries baked + * into a shared frame, every distinct source material kept as a slot of a + * material ARRAY, the originals deleted — all as ONE undo entry. + * + * 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. The + * `object` path is also exactly what the create/delete history entries replay, + * so undo/redo and the live convert stay byte-identical for peers. + * + * @param {string[]=} uuids - defaults to the current selection + * @returns {Promise} the merged mesh's uuid, or null when refused + */ +export async function convertToMesh(uuids) { + const group = get(objectsGroup); + const requested = uuids?.length ? uuids : selectionUuids(); + /** @type {any[]} */ + let targets = requested.map((uuid) => group?.getObjectByProperty('uuid', uuid)).filter(Boolean); + // a selection can hold a group AND one of its descendants — merging the child + // twice (it is deleted with its parent anyway) would duplicate its geometry + targets = targets.filter( + (object) => !targets.some((other) => other !== object && isAncestorOf(other, object)) + ); + if (!targets.length) { + showToast('Nothing selected to convert'); + return null; + } + + const locks = get(lockedObjects); + const lockedTarget = targets.find((object) => locks.find((lock) => lock[1] === object.uuid)); + if (lockedTarget) { + showToast('Cannot convert: an object is locked by another peer'); + return null; + } + // viewer perms: converting DELETES the sources, so it needs edit rights on all + if (!targets.every((object) => canEditObject(object))) { + warnViewerReadOnly(); + return null; + } + + /** @type {{ mesh: any, rootUuid: string }[]} */ + const sources = []; + let skinned = false; + for (const target of targets) + target.traverse((/** @type {any} */ node) => { + if (node.isSkinnedMesh) skinned = true; + else if (node.isMesh && node.geometry?.attributes?.position) + sources.push({ mesh: node, rootUuid: target.uuid }); + }); + if (skinned) { + showToast('Cannot convert: rigged models keep their skeleton and cannot be merged'); + return null; + } + if (sources.length < 2) { + showToast('Convert to mesh needs at least 2 meshes'); + return null; + } + + const { mergeGeometries } = await import('three/addons/utils/BufferGeometryUtils.js'); + + // serializer rule 10: bake the animation BASE pose, never a mid-swing one + const restorePose = parkAnimatedAtBase(); + /** @type {any[]} */ + const geometries = []; + /** @type {any[]} */ + const materials = []; + /** @type {number[]} */ + const slotOfGroup = []; // merged group i -> index into `materials` + /** @type {Map} */ + const slotOfMaterial = new Map(); + const origin = new THREE.Vector3(); + try { + targets[0].updateWorldMatrix(true, false); + origin.setFromMatrixPosition(targets[0].matrixWorld); + const toLocal = new THREE.Matrix4().makeTranslation(-origin.x, -origin.y, -origin.z); + for (const { mesh, rootUuid } of sources) { + mesh.updateWorldMatrix(true, false); + const relative = new THREE.Matrix4().multiplyMatrices(toLocal, mesh.matrixWorld); + for (const piece of mergePieces(mesh)) { + piece.geometry.applyMatrix4(relative); + geometries.push(piece.geometry); + const material = piece.material ?? new THREE.MeshStandardMaterial(); + if (!slotOfMaterial.has(material.uuid)) { + const clone = material.clone(); + // a multi-select member wears the emissive HIGHLIGHT, and the clone + // bakes it in forever (the 15-B2 duplicate bug) — put the recorded + // pre-selection emissive back on the copy + const tint = memberTints.get(rootUuid)?.[mesh.uuid]; + if (tint !== undefined && clone.emissive) clone.emissive.setHex(tint); + slotOfMaterial.set(material.uuid, materials.length); + materials.push(clone); + } + slotOfGroup.push(/** @type {number} */ (slotOfMaterial.get(material.uuid))); + } + } + } finally { + restorePose(); + } + + const merged = mergeGeometries(geometries, true); + geometries.forEach((geometry) => geometry.dispose()); + if (!merged) { + showToast('Cannot convert: these geometries could not be merged'); + return null; + } + // mergeGeometries numbers its groups by INPUT order; re-point them at the + // de-duplicated material slots (two boxes sharing one material = one slot) + merged.groups.forEach((/** @type {any} */ slot, /** @type {number} */ index) => { + slot.materialIndex = slotOfGroup[index] ?? 0; + }); + if (materials.length === 1) merged.clearGroups(); + merged.computeBoundingSphere(); + + const mesh = new THREE.Mesh(merged, materials.length === 1 ? materials[0] : materials); + mesh.name = + targets.length === 1 && targets[0].name ? targets[0].name + ' (mesh)' : 'Merged mesh'; + mesh.castShadow = sources[0].mesh.castShadow; + mesh.receiveShadow = sources[0].mesh.receiveShadow; + + // keep the merge where the sources were, and inside the same parent group + const parent = targets[0].parent && targets[0].parent !== group ? targets[0].parent : null; + const parentUuid = parent?.uuid ?? null; + + /** @type {any} */ + const peer = get(peers); + beginHistoryBatch(); + // delete FIRST so the batch replays as "restore the originals, then drop the + // merge" on undo, and so deselectObject's gizmo detach cannot fight the + // selection we set at the end + deleteObjectsByUuid(targets.map((object) => object.uuid)); + group.add(mesh); + mesh.position.copy(origin); + if (parent) parent.attach(mesh); // keeps the world pose, rewrites the local one + mesh.updateMatrix(); + recordObjectPresence('create', mesh); + if (peer) + peer.send({ + type: 'object', + element: mesh.toJSON(), + groupuuid: parentUuid ?? undefined + }); + endHistoryBatch('Convert to mesh'); + + objectsGroup.update((value) => value); + applySelectionSet([mesh.uuid]); + showToast(`Merged ${sources.length} meshes into "${mesh.name}"`); + return mesh.uuid; +} + /** * One-shot "Align to ground": drop the selected object onto the surface below, * replicate and record an undoable history entry. diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index 3745913e..691878ff 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -10,7 +10,9 @@ import { requestDeleteSelection, groupSelection, ungroupObject, + convertToMesh, selectObject, + applySelectionSet, selectionUuids } from './objectActions'; import { requestControl, nameOf } from './lockControl'; @@ -95,7 +97,9 @@ export function buildObjectMenuItems(uuid, opts = {}) { } ] : []), - ...(isGroup + // Ungroup is single-target (the clicked object); during a multi-select the + // header says "N objects selected", so acting on one of them would mislead + ...(isGroup && !multi ? [ { label: 'Ungroup', @@ -106,6 +110,21 @@ export function buildObjectMenuItems(uuid, opts = {}) { } ] : []), + // 15-G: bake a group / a set of meshes down to ONE mesh (materials kept as + // slots, originals deleted, one undo step) + ...(multi || isGroup + ? [ + { + label: 'Convert to mesh' + suffix, + icon: 'combine', + disabled: locked, + tooltip: locked + ? lockedTooltip + : 'Merge into a single mesh — every material is kept as a slot', + action: () => convertToMesh(targets) + } + ] + : []), { label: 'Align to ground' + suffix, icon: 'arrow-down-to-line', @@ -140,9 +159,23 @@ export function buildObjectMenuItems(uuid, opts = {}) { label: 'Properties', icon: 'sliders-horizontal', tooltip: 'Open the properties panel (double-click does this too)', - action: () => selectObject(uuid, true) + // 15-G audit: during a multi-select, selectObject(uuid) would COLLAPSE the + // set to the clicked object — re-apply the set instead so the panel opens + // on what the header says it acts on + action: () => (multi ? applySelectionSet(targets, true) : selectObject(uuid, true)) }, - { label: 'Rename', icon: 'pencil', disabled: locked, tooltip: lockedTooltip, action: () => renamingObject.set(uuid) }, + // 15-G audit: renaming is inherently single-target (one name field) + ...(multi + ? [] + : [ + { + label: 'Rename', + icon: 'pencil', + disabled: locked, + tooltip: lockedTooltip, + action: () => renamingObject.set(uuid) + } + ]), // 15-B8: Edit mesh / Sculpt are SINGLE-object modes — with a set selected // they'd silently act on the last-picked object only (the ViewportMenu path // passes the sticky primary), so hide them rather than mislead. @@ -260,23 +293,28 @@ export function buildObjectMenuItems(uuid, opts = {}) { ) ) }, - { - // H5: embed this object's flow into the SCENE graph as an Object Flow node - label: 'Add flow to Scene graph', - icon: 'git-branch-plus', - tooltip: 'Embed this object’s flow as a node with its declared inputs/outputs', - action: () => - Promise.all([import('./objectFlow'), import('../stores/flowStore'), import('../stores/appStore')]).then( - ([objectFlow, flowStore, appStore]) => { - if (!flowStore.graphExists(uuid)) { - appStore.showToast('This object has no flow yet — select it in the Flow editor and click Create flow.'); - return; - } - const added = objectFlow.addObjectFlowToScene(uuid, object?.name || object?.type); - appStore.showToast(added ? 'Object Flow node added to the Scene graph' : 'This flow is already embedded in the Scene graph'); + // 15-G audit: one embed carries ONE object's declared sockets — single-target + ...(multi + ? [] + : [ + { + // H5: embed this object's flow into the SCENE graph as an Object Flow node + label: 'Add flow to Scene graph', + icon: 'git-branch-plus', + tooltip: 'Embed this object’s flow as a node with its declared inputs/outputs', + action: () => + Promise.all([import('./objectFlow'), import('../stores/flowStore'), import('../stores/appStore')]).then( + ([objectFlow, flowStore, appStore]) => { + if (!flowStore.graphExists(uuid)) { + appStore.showToast('This object has no flow yet — select it in the Flow editor and click Create flow.'); + return; + } + const added = objectFlow.addObjectFlowToScene(uuid, object?.name || object?.type); + appStore.showToast(added ? 'Object Flow node added to the Scene graph' : 'This flow is already embedded in the Scene graph'); + } + ) } - ) - }, + ]), { section: 'Share' }, { label: multi ? 'Ping selection' + suffix : 'Ping this object', @@ -284,7 +322,19 @@ export function buildObjectMenuItems(uuid, opts = {}) { tooltip: 'Everyone sees a pulse here (Alt+click pings anywhere)', action: () => (multi ? pingObjects(targets) : pingObject(uuid)) }, - { label: 'Add note', icon: 'sticky-note', tooltip: 'Pin a synced note exactly where you pointed', action: () => addAnnotation(uuid, point) }, + // 15-G audit: a note pins to ONE point on ONE object. The ViewportMenu path + // passes the sticky primary (not necessarily what is under the cursor), so + // during a multi-select this would anchor somewhere the user did not point. + ...(multi + ? [] + : [ + { + label: 'Add note', + icon: 'sticky-note', + tooltip: 'Pin a synced note exactly where you pointed', + action: () => addAnnotation(uuid, point) + } + ]), { label: 'Save as prefab' + suffix, icon: 'package', diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 61613d39..e93138e8 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -530,7 +530,7 @@ export class PeerConnection { } else if(data.type == 'verts') { applyVerts(data.uuid, data.indices, data.position); } else if(data.type == 'meshgeo') { - applyMeshGeo(data.uuid, data.positions); + applyMeshGeo(data.uuid, data.positions, data.groups); } else if(data.type == 'vrhands') { peerHands.update((map) => ({ ...map, diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 3078e9c2..b91b5a02 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -2579,7 +2579,7 @@ export function executeVRMenuAction(name) { return; } if (name === 'edit:granularity') { - toggleFaceGranularity(); // B3: cycles FACE -> TRIANGLE -> SHELL + toggleFaceGranularity(); // B3/15-G: cycles QUAD -> FACE -> TRIANGLE -> SHELL -> OBJECT return; } if (name === 'edit:multi') { diff --git a/tests/e2e/convert-to-mesh.test.cjs b/tests/e2e/convert-to-mesh.test.cjs new file mode 100644 index 00000000..c8606766 --- /dev/null +++ b/tests/e2e/convert-to-mesh.test.cjs @@ -0,0 +1,408 @@ +// 15-G: "Convert to mesh" — a Group or a 2+ multi-selection merges into ONE mesh +// (every distinct source material kept as a slot), the originals are deleted, and +// the whole thing is ONE undo entry + ONE replicated object. +// +// Also covers the multi-select MENU AUDIT: single-target entries (Rename, Add +// note, Add flow to Scene graph, Ungroup) must not be offered while a SET is +// selected, and Properties must not COLLAPSE the set to the clicked object. +const h = require('./helpers.cjs'); + +/** compact description of every top-level object (uuid, type, material slots) */ +const sceneInfo = (page) => + page.evaluate( + () => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => + r( + g.children.map((o) => ({ + uuid: o.uuid, + name: o.name, + type: o.type, + isMesh: !!o.isMesh, + children: o.children.length, + groups: o.geometry ? o.geometry.groups.length : -1, + // triangle CORNERS, so an indexed source and the non-indexed + // merge are directly comparable + vertices: o.geometry + ? o.geometry.index + ? o.geometry.index.count + : o.geometry.attributes.position.count + : -1, + materials: Array.isArray(o.material) + ? o.material.map((m) => '#' + m.color.getHexString()) + : o.material + ? ['#' + o.material.color.getHexString()] + : [], + emissive: Array.isArray(o.material) + ? o.material.map((m) => m.emissive.getHex()) + : o.material + ? [o.material.emissive.getHex()] + : [] + })) + ) + )() + ) + ); + +const selectionSet = (page) => + page.evaluate( + () => new Promise((r) => window.__stores.selectedObjects.subscribe((v) => r([...v]))()) + ); + +/** labels of the object context menu built for `uuid` */ +const menuLabels = (page, uuid) => + page.evaluate( + (uuid) => + window.__stores.objectMenu + .buildObjectMenuItems(uuid) + .map((item) => item.label) + .filter(Boolean), + uuid + ); + +/** two boxes 2m apart with distinct colors; returns their uuids */ +const makeTwoBoxes = (page) => + page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const [a, b] = g.children.slice(-2); + a.position.set(-1, 0, 0); + a.material.color.set('#ff0000'); + b.position.set(1, 0, 0); + b.material.color.set('#0000ff'); + return [a.uuid, b.uuid]; + }); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // ---------------------------------------------------------------- 1. merge + const boxes = await makeTwoBoxes(A.page); + let before = await sceneInfo(A.page); + const baseCount = before.length - 2; + h.check(before.length >= 2, 'two boxes exist to merge (premise)'); + const sourceVerts = before + .filter((o) => boxes.includes(o.uuid)) + .reduce((sum, o) => sum + o.vertices, 0); + + await A.page.evaluate((ids) => window.__stores.objectActions.applySelectionSet(ids), boxes); + h.check((await selectionSet(A.page)).length === 2, 'both boxes are in the selection set (premise)'); + // the "tint is not baked in" check below can only fail if the tint is REALLY on + // the sources at merge time + h.check( + (await sceneInfo(A.page)) + .filter((o) => boxes.includes(o.uuid)) + .every((o) => o.emissive[0] === 0x2a4d8f), + 'the multi-select highlight is on both sources (premise)' + ); + + const mergedUuid = await A.page.evaluate( + (ids) => window.__stores.objectActions.convertToMesh(ids), + boxes + ); + h.check(!!mergedUuid, 'convertToMesh returns the new mesh uuid'); + + let after = await sceneInfo(A.page); + const merged = after.find((o) => o.uuid === mergedUuid); + h.check(after.length === baseCount + 1, 'the two sources became exactly one object'); + h.check( + !after.some((o) => boxes.includes(o.uuid)), + 'both originals are gone from the scene' + ); + h.check(!!merged && merged.isMesh, 'the result is a Mesh'); + h.check(!!merged && merged.groups === 2, 'geometry has 2 groups (one per source material)'); + h.check(!!merged && merged.materials.length === 2, 'the mesh carries a 2-slot material array'); + h.check( + !!merged && merged.materials.includes('#ff0000') && merged.materials.includes('#0000ff'), + 'both source colors are preserved' + ); + // the multi-select emissive HIGHLIGHT must not be baked into the copies (the + // 15-B2 duplicate bug, same mechanism) + h.check( + !!merged && merged.emissive.every((hex) => hex !== 0x2a4d8f), + 'the selection tint is not baked into the merged materials' + ); + h.check(!!merged && merged.vertices === sourceVerts, 'no geometry was lost (vertex counts add up)'); + + // world geometry survived: the merge spans both original positions (-1 and +1) + const bounds = await A.page.evaluate(async (uuid) => { + const w = window.__stores; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const mesh = g.getObjectByProperty('uuid', uuid); + const box = new w.THREE.Box3().setFromObject(mesh); + return { min: box.min.toArray(), max: box.max.toArray(), pos: mesh.position.toArray() }; + }, mergedUuid); + h.check( + Math.abs(bounds.min[0] + 1.5) < 0.01 && Math.abs(bounds.max[0] - 1.5) < 0.01, + 'the merged geometry spans both source positions in world space' + ); + h.check( + Math.abs(bounds.pos[0] + 1) < 0.01, + 'the new object sits at the first source position (coordinates stay local)' + ); + h.check((await selectionSet(A.page))[0] === mergedUuid, 'the merged mesh becomes the selection'); + + // ------------------------------------------------------------ 2. undo/redo + await A.page.evaluate(() => window.__stores.history.undo()); + after = await sceneInfo(A.page); + h.check( + boxes.every((uuid) => after.some((o) => o.uuid === uuid)), + 'ONE undo restores both originals' + ); + h.check(!after.some((o) => o.uuid === mergedUuid), '...and removes the merged mesh'); + + await A.page.evaluate(() => window.__stores.history.redo()); + after = await sceneInfo(A.page); + h.check( + after.some((o) => o.uuid === mergedUuid) && !after.some((o) => boxes.includes(o.uuid)), + 'redo re-converts in one step' + ); + // the redone mesh must still be a real merge, not an empty shell + const redone = after.find((o) => o.uuid === mergedUuid); + h.check( + !!redone && redone.groups === 2 && redone.vertices === sourceVerts, + 'the redone mesh keeps its groups and geometry' + ); + + // ------------------------------------------------------------ 3. group path + const groupUuid = await A.page.evaluate(async () => { + const w = window.__stores; + for (let i = 0; i < 3; i++) w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const kids = g.children.slice(-3); + kids.forEach((o, i) => o.position.set(i * 2, 0, 4)); + w.objectActions.applySelectionSet(kids.map((o) => o.uuid)); + const uuid = w.objectActions.groupSelection(); + const grp = g.getObjectByProperty('uuid', uuid); + grp.name = 'Fence'; + return uuid; + }); + const groupMerged = await A.page.evaluate( + (uuid) => window.__stores.objectActions.convertToMesh([uuid]), + groupUuid + ); + after = await sceneInfo(A.page); + const fromGroup = after.find((o) => o.uuid === groupMerged); + h.check(!!fromGroup && fromGroup.isMesh, 'a Group converts to a single mesh'); + h.check(!!fromGroup && fromGroup.groups === 3, '...with one geometry group per child mesh'); + h.check(!!fromGroup && fromGroup.materials.length === 3, '...and a 3-slot material array'); + h.check(!!fromGroup && fromGroup.name === 'Fence (mesh)', '...named after the group'); + h.check(!after.some((o) => o.uuid === groupUuid), '...and the group itself is gone'); + + // -------------------------------------------- 3b. re-merging a MULTI-material + // mergeGeometries writes ONE group per input geometry and ignores the groups + // already on it, so a source that already carries a material ARRAY has to be + // split along its own groups first — otherwise every slot but the first is lost. + const reMerged = await A.page.evaluate(async (mergedUuid) => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const extra = g.children[g.children.length - 1]; + extra.position.set(0, 0, 9); + extra.material.color.set('#00ff00'); + w.objectActions.applySelectionSet([mergedUuid, extra.uuid]); + return await w.objectActions.convertToMesh([mergedUuid, extra.uuid]); + }, mergedUuid); + after = await sceneInfo(A.page); + const three = after.find((o) => o.uuid === reMerged); + h.check(!!three && three.groups === 3, 'a 2-slot mesh + a box re-merge into 3 geometry groups'); + h.check( + !!three && + ['#ff0000', '#0000ff', '#00ff00'].every((hex) => three.materials.includes(hex)), + '...keeping all three materials (the source array was split along its groups)' + ); + h.check( + !!three && three.vertices === sourceVerts + sourceVerts / 2, + '...and every triangle of both sources' + ); + + // ---------------------------------------------------------------- 4. guards + const guards = await A.page.evaluate(async () => { + const w = window.__stores; + const out = {}; + const count = async () => + (await new Promise((r) => w.objectsGroup.subscribe(r)())).children.length; + + // a lone mesh has nothing to merge with + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + let g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const lone = g.children[g.children.length - 1].uuid; + let n = await count(); + out.lone = await w.objectActions.convertToMesh([lone]); + out.loneUnchanged = (await count()) === n; + + // a light contributes no mesh -> only one mesh left -> refuse + w.commandsHandler.sceneCommand('/light point'); + g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const light = g.children[g.children.length - 1].uuid; + n = await count(); + out.withLight = await w.objectActions.convertToMesh([lone, light]); + out.withLightUnchanged = (await count()) === n; + + // a peer-locked object refuses outright + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const other = g.children[g.children.length - 1].uuid; + w.lockedObjects.update((locks) => [...locks, ['ghost-peer', other]]); + n = await count(); + out.locked = await w.objectActions.convertToMesh([lone, other]); + out.lockedUnchanged = (await count()) === n; + w.lockedObjects.update((locks) => locks.filter((l) => l[0] !== 'ghost-peer')); + + // with the lock released the SAME pair merges — proves the refusal above was + // the lock and not some other guard + out.afterUnlock = await w.objectActions.convertToMesh([lone, other]); + out.afterUnlockCount = await count(); + out.expectedAfterUnlock = n - 1; + return out; + }); + h.check(guards.lone === null, 'a single mesh refuses (needs 2+)'); + h.check(guards.loneUnchanged, '...and changes nothing'); + h.check(guards.withLight === null, 'a selection whose only mesh is one object refuses'); + h.check(guards.withLightUnchanged, '...and changes nothing'); + h.check(guards.locked === null, 'a peer-locked object refuses'); + h.check(guards.lockedUnchanged, '...and changes nothing'); + h.check( + !!guards.afterUnlock && guards.afterUnlockCount === guards.expectedAfterUnlock, + 'the same pair merges once the lock is released (the refusal was the lock)' + ); + + // ------------------------------------------------------- 5. menu audit + const pair = await makeTwoBoxes(A.page); + await A.page.evaluate((ids) => window.__stores.objectActions.applySelectionSet(ids), pair); + const multiMenu = await menuLabels(A.page, pair[0]); + h.check(multiMenu.includes('Convert to mesh (2)'), 'a multi-selection offers "Convert to mesh (2)"'); + h.check(!multiMenu.includes('Rename'), 'multi-select hides Rename (single-target)'); + h.check(!multiMenu.includes('Add note'), 'multi-select hides Add note (single-point)'); + h.check( + !multiMenu.includes('Add flow to Scene graph'), + 'multi-select hides Add flow to Scene graph (single-target)' + ); + + // Properties during a multi-select must keep the SET, not collapse it + await A.page.evaluate((uuid) => { + const items = window.__stores.objectMenu.buildObjectMenuItems(uuid); + items.find((item) => item.label === 'Properties').action(); + }, pair[0]); + h.check( + (await selectionSet(A.page)).length === 2, + 'Properties keeps the multi-selection instead of collapsing it to one object' + ); + + // a lone mesh is not mergeable, a lone GROUP is + await A.page.evaluate((uuid) => window.__stores.objectActions.selectObject(uuid), pair[0]); + const singleMenu = await menuLabels(A.page, pair[0]); + h.check( + !singleMenu.some((label) => label.startsWith('Convert to mesh')), + 'a lone mesh does not offer Convert to mesh' + ); + h.check(singleMenu.includes('Rename'), 'a lone object still offers Rename (audit did not over-hide)'); + h.check(singleMenu.includes('Add note'), '...and Add note'); + + const soloGroup = await A.page.evaluate(async () => { + const w = window.__stores; + for (let i = 0; i < 2; i++) w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const kids = g.children.slice(-2); + kids.forEach((o, i) => o.position.set(i * 2, 0, -6)); + w.objectActions.applySelectionSet(kids.map((o) => o.uuid)); + const uuid = w.objectActions.groupSelection(); + w.objectActions.selectObject(uuid); + return uuid; + }); + const groupMenu = await menuLabels(A.page, soloGroup); + h.check(groupMenu.includes('Convert to mesh'), 'a lone Group offers Convert to mesh'); + h.check(groupMenu.includes('Ungroup'), '...beside Ungroup'); + + // ------------------------------------------- 6. one replicated object message + const sent = await A.page.evaluate(async (ids) => { + const w = window.__stores; + const peer = await new Promise((r) => w.peers.subscribe(r)()); + const captured = []; + // shadow the prototype method so everything else on the instance survives + peer.send = (msg) => captured.push({ type: msg.type, uuid: msg.uuid, element: !!msg.element }); + w.objectActions.applySelectionSet(ids); + const uuid = await w.objectActions.convertToMesh(ids); + delete peer.send; + return { uuid, captured }; + }, pair); + const objectMsgs = sent.captured.filter((m) => m.type === 'object'); + const deleteMsgs = sent.captured.filter((m) => m.type === 'delete'); + h.check(objectMsgs.length === 1 && objectMsgs[0].element, 'exactly ONE object message carries the merge'); + h.check( + deleteMsgs.length === 2 && pair.every((uuid) => deleteMsgs.some((m) => m.uuid === uuid)), + 'both originals are deleted for peers' + ); + h.check( + !sent.captured.some((m) => m.type === 'group'), + 'the merge does not travel as a group (sendObjects would have made it one)' + ); + + // -------------------------------------------------- 7. ungroup = ONE undo + const ungroup = await A.page.evaluate(async () => { + const w = window.__stores; + for (let i = 0; i < 3; i++) w.commandsHandler.sceneCommand('/create Box 1 1 1'); + let g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const kids = g.children.slice(-3).map((o) => o.uuid); + w.objectActions.applySelectionSet(kids); + const uuid = w.objectActions.groupSelection(); + w.objectActions.ungroupObject(uuid); + g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const goneAfterUngroup = !g.getObjectByProperty('uuid', uuid); + w.history.undo(); + g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const restored = g.getObjectByProperty('uuid', uuid); + return { goneAfterUngroup, restored: !!restored, children: restored ? restored.children.length : -1 }; + }); + h.check(ungroup.goneAfterUngroup, 'ungroup removes the empty group (premise)'); + h.check(ungroup.restored, 'ONE undo brings the group back'); + h.check(ungroup.children === 3, '...with all three children back inside it'); + + // ------------------------------------------------------------ 8. two peers + // start B against an EMPTY scene: a joiner with 0 objects never triggers the + // share-or-stash gate, so the handshake replies straight away + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const netBoxes = await makeTwoBoxes(A.page); + await A.page.waitForTimeout(2500); + await h.eventually( + () => sceneInfo(B.page), + (info) => netBoxes.every((uuid) => info.some((o) => o.uuid === uuid)), + 'B received both source boxes (premise)', + 20000 + ); + + const netMerged = await A.page.evaluate(async (ids) => { + const w = window.__stores; + w.objectActions.applySelectionSet(ids); + return await w.objectActions.convertToMesh(ids); + }, netBoxes); + + await h.eventually( + () => sceneInfo(B.page), + (info) => info.some((o) => o.uuid === netMerged), + 'B receives the merged mesh', + 20000 + ); + await h.eventually( + () => sceneInfo(B.page), + (info) => !info.some((o) => netBoxes.includes(o.uuid)), + 'B loses both originals', + 20000 + ); + const remote = (await sceneInfo(B.page)).find((o) => o.uuid === netMerged); + h.check(!!remote && remote.isMesh, 'B sees it as a Mesh, not a group'); + h.check(!!remote && remote.groups === 2, 'B sees both geometry groups'); + h.check( + !!remote && remote.materials.includes('#ff0000') && remote.materials.includes('#0000ff'), + 'B sees both source colors' + ); + h.check(!!remote && remote.vertices === sourceVerts, 'B sees the full merged geometry'); + + await h.finish(browser); +}); diff --git a/tests/e2e/mesh-edit-materials.test.cjs b/tests/e2e/mesh-edit-materials.test.cjs new file mode 100644 index 00000000..e07b310a --- /dev/null +++ b/tests/e2e/mesh-edit-materials.test.cjs @@ -0,0 +1,249 @@ +// 15-G follow-up: mesh edits on a MULTI-MATERIAL mesh (what Convert to mesh +// produces) must keep the mesh renderable. +// +// The reported bug: merge two boxes, select a face on each, click Bridge — +// "nothing happens". The bridge ALGORITHM was fine (24 -> 28 triangles spanning +// both boxes); the mesh went INVISIBLE. three draws an array material by walking +// `geometry.groups`, and every geometry swap in faceEdit built a fresh geometry +// with no groups at all, so the renderer drew zero triangles for it. +// +// Every check here therefore asserts BOTH the geometry and that the renderer +// still draws the thing. +const h = require('./helpers.cjs'); + +/** install per-page helpers: exact triangle count the renderer draws for one object */ +async function installProbe(page) { + await page.evaluate(async () => { + const w = window.__stores; + const renderer = await new Promise((r) => w.globalRenderer.subscribe(r)()); + const scene = await new Promise((r) => w.globalScene.subscribe(r)()); + const camera = await new Promise((r) => w.globalCamera.subscribe(r)()); + const draw = () => { + renderer.info.reset(); + renderer.render(scene, camera); + return renderer.info.render.triangles; + }; + // isolate ONE object's contribution by toggling it off for a second frame — + // independent of the grid/environment/overlay triangles in the same scene + window.__drawnFor = (uuid) => { + const g = w.objectsGroup; + let group; + g.subscribe((v) => (group = v))(); + const mesh = group.getObjectByProperty('uuid', uuid); + if (!mesh) return -1; + const on = draw(); + mesh.visible = false; + const off = draw(); + mesh.visible = true; + return on - off; + }; + window.__geoInfo = (uuid) => { + const g = w.objectsGroup; + let group; + g.subscribe((v) => (group = v))(); + const mesh = group.getObjectByProperty('uuid', uuid); + if (!mesh) return null; + const verts = mesh.geometry.attributes.position.count; + const groups = mesh.geometry.groups.map((x) => ({ + start: x.start, + count: x.count, + materialIndex: x.materialIndex + })); + // every vertex covered exactly once, in order, with no gap? + let covered = 0; + let contiguous = true; + for (const x of [...groups].sort((a, b) => a.start - b.start)) { + if (x.start !== covered) contiguous = false; + covered += x.count; + } + return { + verts, + tris: verts / 3, + groups, + groupCount: groups.length, + fullyCovered: groups.length === 0 || (contiguous && covered === verts), + materials: Array.isArray(mesh.material) ? mesh.material.length : 1, + slots: [...new Set(groups.map((x) => x.materialIndex))].sort() + }; + }; + }); +} + +/** two coloured boxes side by side, merged into one multi-material mesh */ +const mergedPair = (page) => + page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const [a, b] = g.children.slice(-2); + a.position.set(-1.5, 0, 0); + a.material.color.set('#ff0000'); + b.position.set(1.5, 0, 0); + b.material.color.set('#0000ff'); + return await w.objectActions.convertToMesh([a.uuid, b.uuid]); + }); + +/** enter face edit and select the two INNER facing faces (+X of the left box, -X of the right) */ +const selectFacingFaces = (page, uuid) => + page.evaluate((uuid) => { + const w = window.__stores; + w.faceEdit.enterFaceEdit(uuid); + const faces = w.faceEdit.currentFaces(); + const byNormal = (sign) => + faces + .map((f, i) => ({ f, i })) + .filter((e) => e.f.normal.x * sign > 0.99) + .sort((p, q) => (p.f.centroid.x - q.f.centroid.x) * sign); + const fA = byNormal(1)[0]; + const fB = byNormal(-1)[0]; + if (!fA || !fB) return null; + w.faceEdit.faceEditMulti.set(true); + w.faceEdit.faceEditSelectedTris.set([...fA.f.triIndices, ...fB.f.triIndices]); + return { faces: faces.length, fA: fA.i, fB: fB.i }; + }, uuid); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + await installProbe(A.page); + + // ------------------------------------------------- 1. bridge a merged mesh + const uuid = await mergedPair(A.page); + const start = await A.page.evaluate((u) => window.__geoInfo(u), uuid); + const drawnStart = await A.page.evaluate((u) => window.__drawnFor(u), uuid); + h.check(start.materials === 2 && start.groupCount === 2, 'the merge starts multi-material with 2 groups (premise)'); + h.check(drawnStart > 0, 'the merged mesh renders before any edit (premise, ' + drawnStart + ' tris)'); + // each mesh is drawn once per PASS (shadow map + colour), so calibrate the + // multiplier from this known-good state and demand it exactly afterwards — a + // partially-grouped geometry draws only SOME of its triangles, which a bare + // "> 0" would happily pass + const passes = drawnStart / start.tris; + h.check(Number.isInteger(passes) && passes > 0, 'render passes per mesh = ' + passes + ' (premise)'); + + const picked = await selectFacingFaces(A.page, uuid); + h.check(!!picked && picked.faces === 12, 'face edit sees 12 faces on the merged pair (premise)'); + + const bridged = await A.page.evaluate(() => window.__stores.faceEdit.bridgeFaces()); + h.check(bridged === true, 'bridgeFaces reports success'); + + const afterBridge = await A.page.evaluate((u) => window.__geoInfo(u), uuid); + const drawnBridge = await A.page.evaluate((u) => window.__drawnFor(u), uuid); + // 24 tris - 2 caps (2 tris each) + 4 wall quads (2 tris each) = 28 + h.check(afterBridge.tris === 28, 'both caps are gone and four wall quads stitched (28 tris)'); + h.check( + drawnBridge === afterBridge.tris * passes, + 'THE BUG: every triangle of the bridged mesh is still drawn (' + + drawnBridge + ' of ' + afterBridge.tris * passes + ')' + ); + h.check(afterBridge.fullyCovered, 'the material groups cover every vertex, in order, with no gap'); + h.check( + afterBridge.slots.length === 2, + 'both material slots survive the bridge (slots ' + JSON.stringify(afterBridge.slots) + ')' + ); + + // the tunnel really spans the gap between the two boxes + const span = await A.page.evaluate(async (u) => { + const w = window.__stores; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const box = new w.THREE.Box3().setFromObject(g.getObjectByProperty('uuid', u)); + return { min: box.min.toArray(), max: box.max.toArray() }; + }, uuid); + h.check( + Math.abs(span.min[0] + 2) < 0.01 && Math.abs(span.max[0] - 2) < 0.01, + 'the bridged mesh still spans both boxes' + ); + + // ------------------------------------------------------- 2. undo / redo + await A.page.evaluate(() => window.__stores.history.undo()); + const undone = await A.page.evaluate((u) => window.__geoInfo(u), uuid); + const drawnUndo = await A.page.evaluate((u) => window.__drawnFor(u), uuid); + h.check(undone.tris === 24, 'undo restores the 24-triangle pair'); + h.check(drawnUndo === undone.tris * passes, '...and it is still drawn after the undo swap'); + h.check(undone.fullyCovered && undone.slots.length === 2, '...with both slots intact'); + + await A.page.evaluate(() => window.__stores.history.redo()); + const redone = await A.page.evaluate((u) => window.__geoInfo(u), uuid); + const drawnRedo = await A.page.evaluate((u) => window.__drawnFor(u), uuid); + h.check(redone.tris === 28 && drawnRedo === redone.tris * passes, 'redo re-bridges and still draws'); + + // -------------------------------- 3. the other topology ops on the same mesh + for (const op of ['extrude', 'inset', 'subdivide']) { + const ok = await A.page.evaluate( + ({ uuid, op }) => { + const w = window.__stores; + w.faceEdit.enterFaceEdit(uuid); + const faces = w.faceEdit.currentFaces(); + // a face on the SECOND material slot, so a lost slot shows up + w.faceEdit.faceEditMulti.set(false); + w.faceEdit.highlightFaceByTriangle(faces[faces.length - 1].triIndices[0]); + return w.faceEdit.commitFaceOp(op, 0.2); + }, + { uuid, op } + ); + const info = await A.page.evaluate((u) => window.__geoInfo(u), uuid); + const drawn = await A.page.evaluate((u) => window.__drawnFor(u), uuid); + h.check(ok === true, op + ' commits'); + h.check( + drawn === info.tris * passes, + op + ' leaves every triangle drawn (' + drawn + '/' + info.tris * passes + ')' + ); + h.check(info.fullyCovered, op + ' leaves the groups covering the whole geometry'); + h.check(info.slots.length === 2, op + ' keeps both material slots'); + } + await A.page.evaluate(() => window.__stores.faceEdit.exitFaceEdit()); + + // ---------------------- 4. a SINGLE-material mesh is untouched by all this + const plain = await A.page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + box.position.set(0, 0, 6); + w.faceEdit.enterFaceEdit(box.uuid); + const faces = w.faceEdit.currentFaces(); + w.faceEdit.highlightFaceByTriangle(faces[0].triIndices[0]); + w.faceEdit.commitFaceOp('extrude', 0.4); + w.faceEdit.exitFaceEdit(); + return box.uuid; + }); + const plainInfo = await A.page.evaluate((u) => window.__geoInfo(u), plain); + const plainDrawn = await A.page.evaluate((u) => window.__drawnFor(u), plain); + h.check(plainInfo.materials === 1, 'a plain box is single-material (premise)'); + h.check(plainInfo.groupCount === 0, 'a single-material mesh gains NO groups (unchanged behaviour)'); + h.check(plainDrawn === plainInfo.tris * passes, '...and renders as before'); + + // ------------------------------------------------------------ 5. two peers + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + const B = await h.setupPage(browser, 'B'); + await installProbe(B.page); + await h.connect(B, A); + + const netUuid = await mergedPair(A.page); + await h.eventually( + () => B.page.evaluate((u) => window.__geoInfo(u), netUuid), + (info) => !!info && info.tris === 24, + 'B received the merged mesh (premise)', + 20000 + ); + await selectFacingFaces(A.page, netUuid); + await A.page.evaluate(() => window.__stores.faceEdit.bridgeFaces()); + + await h.eventually( + () => B.page.evaluate((u) => window.__geoInfo(u), netUuid), + (info) => !!info && info.tris === 28, + 'B receives the bridged geometry', + 20000 + ); + const remote = await B.page.evaluate((u) => window.__geoInfo(u), netUuid); + const remoteDrawn = await B.page.evaluate((u) => window.__drawnFor(u), netUuid); + h.check(remote.fullyCovered, 'B rebuilds groups covering the whole geometry'); + h.check(remote.slots.length === 2, 'B keeps both material slots'); + const remotePasses = remoteDrawn / remote.tris; + h.check( + Number.isInteger(remotePasses) && remotePasses > 0, + 'B still draws EVERY triangle of the bridged mesh (' + remoteDrawn + '/' + remote.tris + ')' + ); + + await h.finish(browser); +}); diff --git a/tests/e2e/mesh-multishell-ops.test.cjs b/tests/e2e/mesh-multishell-ops.test.cjs new file mode 100644 index 00000000..2ddf2097 --- /dev/null +++ b/tests/e2e/mesh-multishell-ops.test.cjs @@ -0,0 +1,340 @@ +// 15-G follow-up 2: face ops on a multi-selection that spans SEPARATE SHELLS. +// +// 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. +// +// `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, so for the inner edges "away from the +// centroid" was the exact opposite of "away from the cube": those walls were +// wound inward and backface-culled. insetFace had the same flaw from the other +// end — both faces shrank toward the shared centroid, sliding into the gap +// instead of insetting in place. +// +// The walls' outward direction is now derived LOCALLY from each boundary edge +// (edge x its own triangle's normal), and inset works per connected component. +const h = require('./helpers.cjs'); + +/** N unit cubes in a row on X with a 1-unit gap, merged into one mesh. + * Returns { uuid, centres } — centres are MESH-LOCAL x of each cube. */ +const mergedRow = (page, n) => + page.evaluate(async (n) => { + const w = window.__stores; + const uuids = []; + for (let i = 0; i < n; i++) { + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + box.position.set(i * 3, 0, 0); // 1-wide cubes, 2-wide gaps + uuids.push(box.uuid); + } + const uuid = await w.objectActions.convertToMesh(uuids); + // the merge sits at the FIRST cube, so local centres are 0, 3, 6, ... + return { uuid, centres: uuids.map((_, i) => i * 3) }; + }, n); + +/** enter face edit and multi-select every +Y face */ +const selectAllTops = (page, uuid) => + page.evaluate((uuid) => { + const w = window.__stores; + w.faceEdit.enterFaceEdit(uuid); + const tops = w.faceEdit.currentFaces().filter((f) => f.normal.y > 0.99); + w.faceEdit.faceEditMulti.set(true); + w.faceEdit.faceEditSelectedTris.set(tops.flatMap((f) => f.triIndices)); + return tops.length; + }, uuid); + +/** every triangle of a mesh as { c: centroid, n: normal, slot } */ +const trianglesOf = (page, uuid) => + page.evaluate(async (uuid) => { + const w = window.__stores; + const T = w.THREE; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const mesh = g.getObjectByProperty('uuid', uuid); + const pos = mesh.geometry.attributes.position; + const slotAt = (i) => { + const hit = mesh.geometry.groups.find((x) => i >= x.start && i < x.start + x.count); + return hit ? hit.materialIndex : 0; + }; + const out = []; + for (let i = 0; i < pos.count; i += 3) { + const p = [0, 1, 2].map((k) => new T.Vector3(pos.getX(i + k), pos.getY(i + k), pos.getZ(i + k))); + const n = new T.Vector3() + .subVectors(p[1], p[0]) + .cross(new T.Vector3().subVectors(p[2], p[0])) + .normalize(); + out.push({ + c: p[0].clone().add(p[1]).add(p[2]).multiplyScalar(1 / 3).toArray(), + n: n.toArray(), + slot: slotAt(i) + }); + } + return out; + }, uuid); + +/** the +Y faces' centroids and X extents, per face */ +const topFaces = (page) => + page.evaluate(() => + window.__stores.faceEdit + .currentFaces() + .filter((f) => f.normal.y > 0.99) + .map((f) => ({ x: +f.centroid.x.toFixed(3), z: +f.centroid.z.toFixed(3) })) + .sort((p, q) => p.x - q.x) + ); + +/** + * Every SIDE wall of the extruded band must face away from the cube it belongs + * to. A wall is assigned to the nearest cube centre; "outward" is the sign of + * n . (c - centre) in the horizontal plane. + */ +function inwardWalls(tris, centres, bandMinY) { + const bad = []; + for (const t of tris) { + const [cx, cy, cz] = t.c; + const [nx, , nz] = t.n; + if (cy < bandMinY) continue; // only the new band + if (Math.abs(t.n[1]) > 0.5) continue; // skip the cap + const centre = centres.reduce((best, x) => (Math.abs(x - cx) < Math.abs(best - cx) ? x : best), centres[0]); + const dot = nx * (cx - centre) + nz * cz; + if (dot <= 0) bad.push({ c: t.c.map((v) => +v.toFixed(2)), n: t.n.map((v) => +v.toFixed(2)), centre }); + } + return bad; +} + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // ------------------------------------------- 1. the reported case: 2 cubes + const two = await mergedRow(A.page, 2); + const tops = await selectAllTops(A.page, two.uuid); + h.check(tops === 2, 'both cube tops are multi-selected (premise, ' + tops + ')'); + + const beforeTris = (await trianglesOf(A.page, two.uuid)).length; + h.check(beforeTris === 24, 'the merged pair starts at 24 triangles (premise)'); + + const extruded = await A.page.evaluate(() => window.__stores.faceEdit.commitFaceOp('extrude', 0.4)); + h.check(extruded === true, 'extrude commits on the two-shell selection'); + + const after = await trianglesOf(A.page, two.uuid); + // 24 + one wall quad (2 tris) per boundary edge: 4 edges per cube, 2 cubes + h.check(after.length === 24 + 16, 'eight wall quads are stitched (' + after.length + ' tris)'); + + const bad = inwardWalls(after, two.centres, 0.5); + h.check( + bad.length === 0, + 'THE BUG: every extrude wall faces away from its OWN cube (' + bad.length + ' inward: ' + JSON.stringify(bad.slice(0, 2)) + ')' + ); + + // name the two walls the user actually saw missing, so a regression is legible + const wallAt = (x, sign) => + after.find( + (t) => Math.abs(t.c[0] - x) < 0.01 && t.c[1] > 0.5 && Math.abs(t.n[0] - sign) < 0.01 + ); + h.check(!!wallAt(0.5, 1), 'the left cube\'s INNER wall faces +X (into the gap)'); + h.check(!!wallAt(2.5, -1), 'the right cube\'s INNER wall faces -X (into the gap)'); + h.check(!!wallAt(-0.5, -1) && !!wallAt(3.5, 1), '...and the outer walls still face outward'); + + // each cube's walls carry that cube's own material slot + const leftWall = wallAt(0.5, 1); + const rightWall = wallAt(2.5, -1); + h.check( + leftWall?.slot === 0 && rightWall?.slot === 1, + 'each cube\'s walls take that cube\'s material slot (' + + leftWall?.slot + '/' + rightWall?.slot + ')' + ); + + // ---------------------------------- 2. a MIDDLE shell (walls on both sides) + await A.page.evaluate(() => window.__stores.faceEdit.exitFaceEdit()); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + const three = await mergedRow(A.page, 3); + h.check((await selectAllTops(A.page, three.uuid)) === 3, 'three cube tops multi-selected (premise)'); + await A.page.evaluate(() => window.__stores.faceEdit.commitFaceOp('extrude', 0.4)); + const threeTris = await trianglesOf(A.page, three.uuid); + const badThree = inwardWalls(threeTris, three.centres, 0.5); + h.check( + badThree.length === 0, + 'a MIDDLE shell gets outward walls on both sides too (' + badThree.length + ' inward)' + ); + h.check(threeTris.length === 36 + 24, 'three cubes stitch twelve wall quads'); + + // ------------------------------------------------------------- 3. inset + await A.page.evaluate(() => window.__stores.faceEdit.exitFaceEdit()); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + const pair = await mergedRow(A.page, 2); + await selectAllTops(A.page, pair.uuid); + const insetBefore = await topFaces(A.page); + h.check( + insetBefore.length === 2 && insetBefore[0].x === 0 && insetBefore[1].x === 3, + 'the two top faces start centred on their own cubes (premise)' + ); + await A.page.evaluate(() => window.__stores.faceEdit.commitFaceOp('inset', 0.3)); + const insetAfter = await topFaces(A.page); + h.check( + insetAfter.length === 2 && + Math.abs(insetAfter[0].x - 0) < 0.001 && + Math.abs(insetAfter[1].x - 3) < 0.001, + 'each face insets toward its OWN centre, neither slides into the gap (' + + JSON.stringify(insetAfter.map((f) => f.x)) + ')' + ); + // ...and it really shrank, SYMMETRICALLY about its own centre. The left cube + // spans x -0.5..0.5, so a 0.3 inset must leave vertices at exactly +/-0.35. + // Shrinking toward the shared centroid instead put them at 0.1 and 0.8. + const insetXs = await A.page.evaluate(async (uuid) => { + const w = window.__stores; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const pos = g.getObjectByProperty('uuid', uuid).geometry.attributes.position; + let topY = -Infinity; + for (let i = 0; i < pos.count; i++) topY = Math.max(topY, pos.getY(i)); + const xs = new Set(); + for (let i = 0; i < pos.count; i++) { + if (Math.abs(pos.getY(i) - topY) > 1e-4) continue; + if (Math.abs(pos.getX(i)) > 1.5) continue; // the LEFT cube only + xs.add(+pos.getX(i).toFixed(3)); + } + return [...xs].sort((a, b) => a - b); + }, pair.uuid); + h.check( + insetXs.includes(-0.35) && insetXs.includes(0.35), + 'the inset face shrinks symmetrically about its own centre (x ' + JSON.stringify(insetXs) + ')' + ); + h.check(insetXs.includes(-0.5) && insetXs.includes(0.5), '...with the original boundary kept as the ring'); + + // ------------------ 3b. BRIDGE must act on the SELECTION, not on the + // coplanar groups it touches. Extruding a top leaves a wall COPLANAR with + // the flat side beneath it, so groupFaces merges the two into one logical + // face — picking just the wall band used to bridge the whole side of the + // shell (the op-target rule: never expand a selection to faces[...]). + const scenario = async () => { + await A.page.evaluate(() => window.__stores.faceEdit.exitFaceEdit()); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + const row = await mergedRow(A.page, 2); + await selectAllTops(A.page, row.uuid); + await A.page.evaluate(() => window.__stores.faceEdit.commitFaceOp('extrude', 0.3)); + return row.uuid; + }; + + /** the +X / -X faces flanking the gap, as { minY, maxY, tris } */ + const innerSides = (page, uuid) => + page.evaluate(async (uuid) => { + const w = window.__stores; + const T = w.THREE; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const pos = g.getObjectByProperty('uuid', uuid).geometry.attributes.position; + const tri = (ti) => + [0, 1, 2].map((k) => new T.Vector3(pos.getX(ti * 3 + k), pos.getY(ti * 3 + k), pos.getZ(ti * 3 + k))); + return w.faceEdit + .currentFaces() + .filter((f) => Math.abs(f.normal.x) > 0.99 && (Math.abs(f.centroid.x - 0.5) < 0.01 || Math.abs(f.centroid.x - 2.5) < 0.01)) + .map((f) => { + const box = new T.Box3(); + f.triIndices.forEach((ti) => tri(ti).forEach((v) => box.expandByPoint(v))); + return { x: +f.centroid.x.toFixed(2), maxY: +box.max.y.toFixed(2), tris: f.triIndices.length }; + }) + .sort((p, q) => p.x - q.x); + }, uuid); + + // (i) TRIANGLE granularity, only the two 2-triangle wall bands selected + const bandUuid = await scenario(); + const bandPick = await A.page.evaluate(async (uuid) => { + const w = window.__stores; + const T = w.THREE; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const pos = g.getObjectByProperty('uuid', uuid).geometry.attributes.position; + const picked = []; + for (let ti = 0; ti < pos.count / 3; ti++) { + const p = [0, 1, 2].map((k) => new T.Vector3(pos.getX(ti * 3 + k), pos.getY(ti * 3 + k), pos.getZ(ti * 3 + k))); + const c = p[0].clone().add(p[1]).add(p[2]).multiplyScalar(1 / 3); + const n = new T.Vector3().subVectors(p[1], p[0]).cross(new T.Vector3().subVectors(p[2], p[0])).normalize(); + if (c.y <= 0.5 || Math.abs(n.x) < 0.99) continue; + if (Math.abs(c.x - 0.5) < 0.01 || Math.abs(c.x - 2.5) < 0.01) picked.push(ti); + } + w.faceEdit.setFaceGranularity('triangle'); + w.faceEdit.faceEditMulti.set(true); + w.faceEdit.faceEditSelectedTris.set(picked); + return picked.length; + }, bandUuid); + h.check(bandPick === 4, 'the two extruded wall bands are 2 triangles each (premise, ' + bandPick + ')'); + + const bandOk = await A.page.evaluate(() => window.__stores.faceEdit.bridgeFaces()); + h.check(bandOk === true, 'bridge commits on the two extruded bands'); + const bandSides = await innerSides(A.page, bandUuid); + h.check( + bandSides.length === 2 && bandSides.every((s) => s.maxY === 0.5 && s.tris === 2), + 'THE BUG: only the BAND is consumed — each inner side is left intact up to y 0.5 (' + + JSON.stringify(bandSides) + ')' + ); + // the tunnel really spans the gap: an underside at y 0.5 between the cubes + const floor = await A.page.evaluate(() => + window.__stores.faceEdit + .currentFaces() + .filter((f) => f.normal.y < -0.99 && Math.abs(f.centroid.y - 0.5) < 0.01) + .map((f) => +f.centroid.x.toFixed(2)) + ); + h.check( + floor.length === 1 && Math.abs(floor[0] - 1.5) < 0.01, + 'the tunnel floor spans the gap between the cubes (x ' + JSON.stringify(floor) + ')' + ); + + // (ii) FACE granularity picks the WHOLE coplanar side, and bridging that + // consumes the whole side — correct for THAT selection, and it must differ + // from (i), which is the proof the selection is honoured + const faceUuid = await scenario(); + await A.page.evaluate(async (uuid) => { + const w = window.__stores; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + void g; + const fs = w.faceEdit.currentFaces(); + const inner = fs.filter( + (f) => Math.abs(f.normal.x) > 0.99 && (Math.abs(f.centroid.x - 0.5) < 0.01 || Math.abs(f.centroid.x - 2.5) < 0.01) + ); + w.faceEdit.setFaceGranularity('face'); + w.faceEdit.faceEditMulti.set(true); + w.faceEdit.faceEditSelectedTris.set(inner.flatMap((f) => f.triIndices)); + }, faceUuid); + await A.page.evaluate(() => window.__stores.faceEdit.bridgeFaces()); + const faceSides = await innerSides(A.page, faceUuid); + h.check( + faceSides.length === 0, + 'a whole-face pick consumes the whole side (a DIFFERENT result — the selection decides)' + ); + + // (iii) two TOUCHING picks are refused with a clear message, not garbage + await scenario(); + const touching = await A.page.evaluate(() => { + const w = window.__stores; + const fs = w.faceEdit.currentFaces(); + // the +X and +Z sides of the same cube share an edge + const a = fs.find((f) => f.normal.x > 0.99 && f.centroid.x < 1.5); + const b = fs.find((f) => f.normal.z > 0.99 && f.centroid.x < 1.5); + w.faceEdit.faceEditMulti.set(true); + w.faceEdit.faceEditSelectedTris.set([...a.triIndices, ...b.triIndices]); + return w.faceEdit.bridgeFaces(); + }); + h.check(touching === false, 'two TOUCHING selections are refused rather than bridged into garbage'); + + // -------------------------- 4. a plain single-face extrude is unchanged + await A.page.evaluate(() => window.__stores.faceEdit.exitFaceEdit()); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + const plain = await A.page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + w.faceEdit.enterFaceEdit(box.uuid); + const top = w.faceEdit.currentFaces().find((f) => f.normal.y > 0.99); + w.faceEdit.faceEditMulti.set(false); + w.faceEdit.highlightFaceByTriangle(top.triIndices[0]); + w.faceEdit.commitFaceOp('extrude', 0.4); + w.faceEdit.exitFaceEdit(); + return box.uuid; + }); + const plainTris = await trianglesOf(A.page, plain); + h.check(plainTris.length === 12 + 8, 'a lone face still stitches four wall quads'); + h.check( + inwardWalls(plainTris, [0], 0.5).length === 0, + 'a lone face\'s walls all face outward (unchanged behaviour)' + ); + + await h.finish(browser); +}); diff --git a/tests/e2e/mesh-quad-select.test.cjs b/tests/e2e/mesh-quad-select.test.cjs new file mode 100644 index 00000000..a298d280 --- /dev/null +++ b/tests/e2e/mesh-quad-select.test.cjs @@ -0,0 +1,280 @@ +// 15-G: QUAD pick granularity — select the two triangles that form a quad, +// which is the unit a modeler thinks in. Sits between `triangle` and `face`: +// a box side is one quad either way, but an extrusion wall stays its own quad +// instead of merging into the coplanar side beneath it (which is what `face` +// does, by design). +const h = require('./helpers.cjs'); + +/** the selection, sorted */ +const sel = (page) => + page.evaluate( + () => + new Promise((r) => + window.__stores.faceEdit.faceEditSelectedTris.subscribe((v) => r([...v].sort((a, b) => a - b)))() + ) + ); + +/** triangle centroid + normal, mesh-local. Honours the INDEX: a raw BoxGeometry + * is indexed (24 positions / 36 indices), so reading positions by ti*3+k reads + * unrelated corners and invents diagonal normals. */ +const triInfo = (page, uuid, indices) => + page.evaluate( + async ({ uuid, indices }) => { + const w = window.__stores; + const T = w.THREE; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const geo = g.getObjectByProperty('uuid', uuid).geometry; + const pos = geo.attributes.position; + const idx = geo.index; + return indices.map((ti) => { + const p = [0, 1, 2].map((k) => { + const j = idx ? idx.getX(ti * 3 + k) : ti * 3 + k; + return new T.Vector3(pos.getX(j), pos.getY(j), pos.getZ(j)); + }); + const n = new T.Vector3() + .subVectors(p[1], p[0]) + .cross(new T.Vector3().subVectors(p[2], p[0])) + .normalize(); + return { + c: p[0].clone().add(p[1]).add(p[2]).multiplyScalar(1 / 3).toArray().map((x) => +x.toFixed(3)), + n: n.toArray().map((x) => +x.toFixed(2)) + }; + }); + }, + { uuid, indices } + ); + +/** a plain box in face-edit mode */ +const editBox = (page) => + page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + w.faceEdit.enterFaceEdit(box.uuid); + return box.uuid; + }); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // ------------------------------------------------------- 1. the default + const startMode = await A.page.evaluate( + () => new Promise((r) => window.__stores.faceEdit.faceEditGranularity.subscribe(r)()) + ); + h.check(startMode === 'quad', 'Edit Mesh opens in Quad granularity (' + startMode + ')'); + + // ------------------------------------------- 2. a box: 12 tris -> 6 quads + const uuid = await editBox(A.page); + const pairing = await A.page.evaluate(async (uuid) => { + const w = window.__stores; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const geo = g.getObjectByProperty('uuid', uuid).geometry; + const tris = w.faceEdit.readTriangles(geo); + const partner = w.faceEdit.pairQuads(tris); + const paired = [...partner].filter((p) => p >= 0).length; + // mutual and never self-paired? + let mutual = true; + for (let i = 0; i < partner.length; i++) { + if (partner[i] === i) mutual = false; + if (partner[i] >= 0 && partner[partner[i]] !== i) mutual = false; + } + return { count: tris.length, paired, mutual, partner: [...partner] }; + }, uuid); + h.check(pairing.count === 12, 'a box is 12 triangles (premise)'); + h.check(pairing.paired === 12, 'every triangle of a box finds a quad mate (6 quads)'); + h.check(pairing.mutual, 'the pairing is mutual and never self-paired'); + + // a quad pick returns exactly 2 coplanar, co-facing triangles + const unit = await A.page.evaluate(() => window.__stores.faceEdit.quadOfTriangle(0)); + h.check(unit.length === 2, 'picking a triangle selects its quad (2 tris)'); + const info = await triInfo(A.page, uuid, unit); + h.check( + info[0].n.join(',') === info[1].n.join(','), + 'the two triangles of a quad face the same way (' + info.map((i) => i.n.join(',')).join(' vs ') + ')' + ); + + // clicking through the real pick path selects the quad, not the triangle + await A.page.evaluate(() => window.__stores.faceEdit.pickFaceUnit(0)); + h.check((await sel(A.page)).length === 2, 'a click in Quad mode selects 2 triangles'); + await A.page.evaluate(() => { + window.__stores.faceEdit.setFaceGranularity('triangle'); + window.__stores.faceEdit.pickFaceUnit(0); + }); + h.check((await sel(A.page)).length === 1, '...where Triangle mode selects 1'); + await A.page.evaluate(() => { + window.__stores.faceEdit.setFaceGranularity('face'); + window.__stores.faceEdit.pickFaceUnit(0); + }); + h.check((await sel(A.page)).length === 2, '...and on a plain box Face mode matches Quad (2)'); + + // ------------------- 3. THE POINT: an extrusion wall is its own quad + await A.page.evaluate(() => window.__stores.faceEdit.exitFaceEdit()); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + const ex = await editBox(A.page); + await A.page.evaluate(() => { + const w = window.__stores; + const top = w.faceEdit.currentFaces().find((f) => f.normal.y > 0.99); + w.faceEdit.setFaceGranularity('face'); + w.faceEdit.pickFaceUnit(top.triIndices[0]); + w.faceEdit.commitFaceOp('extrude', 0.4); + }); + + // find a triangle in the +X WALL band (y above the original top) + const wallTri = await A.page.evaluate(async (uuid) => { + const w = window.__stores; + const T = w.THREE; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const pos = g.getObjectByProperty('uuid', uuid).geometry.attributes.position; + for (let ti = 0; ti < pos.count / 3; ti++) { + const p = [0, 1, 2].map((k) => new T.Vector3(pos.getX(ti * 3 + k), pos.getY(ti * 3 + k), pos.getZ(ti * 3 + k))); + const c = p[0].clone().add(p[1]).add(p[2]).multiplyScalar(1 / 3); + const n = new T.Vector3().subVectors(p[1], p[0]).cross(new T.Vector3().subVectors(p[2], p[0])).normalize(); + if (n.x > 0.99 && c.y > 0.5) return ti; + } + return -1; + }, ex); + h.check(wallTri >= 0, 'found a triangle in the extruded wall band (premise)'); + + await A.page.evaluate((ti) => { + window.__stores.faceEdit.setFaceGranularity('quad'); + window.__stores.faceEdit.pickFaceUnit(ti); + }, wallTri); + const quadSel = await sel(A.page); + const quadInfo = await triInfo(A.page, ex, quadSel); + h.check(quadSel.length === 2, 'Quad mode picks the wall band alone (2 tris)'); + h.check( + quadInfo.every((t) => t.c[1] > 0.5), + '...both of them ABOVE the original top — the flat side below is not swept in' + ); + + await A.page.evaluate((ti) => { + window.__stores.faceEdit.setFaceGranularity('face'); + window.__stores.faceEdit.pickFaceUnit(ti); + }, wallTri); + const faceSel = await sel(A.page); + h.check( + faceSel.length === 4, + 'Face mode on the same click still takes the WHOLE coplanar side (4 tris) — the modes differ' + ); + + // ------------------------------- 4. an unpaired triangle picks alone + const lone = await A.page.evaluate(async () => { + const w = window.__stores; + w.faceEdit.exitFaceEdit(); + w.commandsHandler.sceneCommand('/clear all'); + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + // a single free-standing TRIANGLE has no possible mate + w.faceEdit.applyMeshGeo(box.uuid, [0, 0, 0, 1, 0, 0, 0, 1, 0]); + w.faceEdit.enterFaceEdit(box.uuid); + w.faceEdit.setFaceGranularity('quad'); + return w.faceEdit.quadOfTriangle(0); + }); + h.check(lone.length === 1 && lone[0] === 0, 'a triangle with no mate picks alone, not as a face'); + + // a coplanar FAN (odd triangle count) still pairs what it can + const fan = await A.page.evaluate(async () => { + const w = window.__stores; + // three coplanar tris in a strip: 2 pair into a quad, 1 is left over + const strip = [ + 0, 0, 0, 1, 0, 0, 0, 1, 0, + 1, 0, 0, 1, 1, 0, 0, 1, 0, + 1, 0, 0, 2, 0, 0, 1, 1, 0 + ]; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + let box; + g.children.forEach((c) => (box = c)); + w.faceEdit.applyMeshGeo(box.uuid, strip); + const tris = w.faceEdit.readTriangles( + g.getObjectByProperty('uuid', box.uuid).geometry + ); + const partner = w.faceEdit.pairQuads(tris); + return { count: tris.length, partner: [...partner] }; + }); + const fanPaired = fan.partner.filter((p) => p >= 0).length; + h.check(fan.count === 3, 'a 3-triangle coplanar strip (premise)'); + h.check( + fanPaired === 2 && fan.partner.filter((p) => p === -1).length === 1, + 'a fan pairs what it can and leaves the odd triangle alone (' + JSON.stringify(fan.partner) + ')' + ); + + // -------------------------------------------- 5. quads drive the OPS too + await A.page.evaluate(() => window.__stores.faceEdit.exitFaceEdit()); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + const opUuid = await editBox(A.page); + const opResult = await A.page.evaluate(async (uuid) => { + const w = window.__stores; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const mesh = g.getObjectByProperty('uuid', uuid); + // an indexed source counts triangles from the INDEX, not the positions + const triCount = () => + (mesh.geometry.index ? mesh.geometry.index.count : mesh.geometry.attributes.position.count) / 3; + const before = triCount(); + w.faceEdit.setFaceGranularity('quad'); + const top = w.faceEdit.currentFaces().find((f) => f.normal.y > 0.99); + w.faceEdit.pickFaceUnit(top.triIndices[0]); + const ok = w.faceEdit.commitFaceOp('extrude', 0.3); + return { ok, before, after: triCount() }; + }, opUuid); + // extruding one quad: +4 wall quads = +8 triangles + h.check(opResult.ok === true, 'extrude commits on a quad selection'); + h.check( + opResult.after === opResult.before + 8, + 'extruding a quad stitches four walls (' + opResult.before + ' -> ' + opResult.after + ')' + ); + + // ----------------------------------- 6. the granularity cycle + legacy alias + const cycle = await A.page.evaluate(() => { + const w = window.__stores; + const seen = []; + const read = () => { + let v; + w.faceEdit.faceEditGranularity.subscribe((x) => (v = x))(); + return v; + }; + w.faceEdit.setFaceGranularity('quad'); + for (let i = 0; i < 5; i++) { + w.faceEdit.toggleFaceGranularity(); + seen.push(read()); + } + // the RETIRED alias must still mean triangle, never quad + w.faceEdit.setFaceGranularity('polygon'); + const legacy = read(); + return { seen, legacy }; + }); + h.check( + cycle.seen.join('>') === 'face>triangle>shell>object>quad', + 'the cycle is quad > face > triangle > shell > object (' + cycle.seen.join('>') + ')' + ); + h.check(cycle.legacy === 'triangle', 'the retired "polygon" alias still means triangle, not quad'); + + // ---------------- 7. hovering across a quad's diagonal is not a new unit + await A.page.evaluate(() => window.__stores.faceEdit.exitFaceEdit()); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + const hoverUuid = await editBox(A.page); + void hoverUuid; + const hover = await A.page.evaluate(() => { + const f = window.__stores.faceEdit; + f.setFaceGranularity('quad'); + const mate = f.quadOfTriangle(0).find((t) => t !== 0); + const other = [0, 1, 2, 3, 4, 5].find((t) => !f.quadOfTriangle(0).includes(t)); + return { + mate, + first: f.highlightFaceByTriangle(0), // -1 -> quad: change + sibling: f.highlightFaceByTriangle(mate), // same quad: NO change + nextQuad: f.highlightFaceByTriangle(other) // different quad: change + }; + }); + h.check(hover.mate !== undefined, 'triangle 0 has a quad mate (premise)'); + h.check(hover.first === true, 'the first hover reports a change'); + h.check( + hover.sibling === false, + 'crossing a quad\'s internal diagonal is NOT a new unit (no overlay rebuild)' + ); + h.check(hover.nextQuad === true, 'hovering a different quad does report a change'); + + await h.finish(browser); +});