From f8e75f4bddeefad88f7531a21c2b226a80ec768e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 8 Aug 2026 20:45:11 +0300 Subject: [PATCH 1/5] [feat] convert group/selection to a single mesh (materials preserved, one undo) + multi-select menu audit - objectActions.convertToMesh(uuids): a Group or a 2+ multi-selection merges into ONE new mesh via mergeGeometries. Every source geometry is normalized (non-indexed, position/normal/uv only, generated normals/uvs when missing) and baked relative to the first source position, so world layout survives and local coordinates stay small. - Materials are kept: one geometry group per source material slot, deduplicated by material identity, re-pointed onto the merged groups (mergeGeometries numbers its groups by input order). A source that ALREADY carries a material array is split along its own geometry groups first - mergeGeometries ignores those, so a re-merge would otherwise drop every slot but the first. - The multi-select emissive highlight is stripped from the cloned materials (the 15-B2 duplicate bug, same mechanism). - ONE undo entry: the deletes and the create run inside a history batch. - Replication goes through the object message (ObjectLoader on the receiver), NOT sendObjects: that helper announces a GROUP for the root it is handed and then walks its children, so a bare mesh would arrive as an empty group. This is also exactly what the create/delete history entries replay. - Guards: refuses rigged (skinned) meshes, peer-locked objects, viewers without edit rights, and anything that collects fewer than 2 meshes. A selection holding both a group and one of its descendants merges the child once. - Menu: Convert to mesh for a multi-selection (counted suffix) and for a lone Group, beside Ungroup. New combine icon in the data-driven Icon map. - Multi-select menu audit: Rename, Add note, Add flow to Scene graph and Ungroup are single-target, so they are hidden while a SET is selected (the 15-B8 Edit mesh / Sculpt precedent); Properties re-applies the SET instead of collapsing it to the clicked object. - ungroupObject is now one history batch too (it used to be N+1 undo steps). - New suite tests/e2e/convert-to-mesh.test.cjs (57 checks incl. two-peer replication). The tint and multi-material guards were proven by breaking the code and watching them go red. Baseline held at 419/62. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ui/Icon.svelte | 2 + src/lib/objectActions.js | 219 +++++++++++++++- src/lib/objectMenu.js | 90 +++++-- tests/e2e/convert-to-mesh.test.cjs | 408 +++++++++++++++++++++++++++++ 4 files changed, 698 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/convert-to-mesh.test.cjs 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/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/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); +}); From 47f3c1e36e5da3f9a01cc39bacbd7de7ba6cd63b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 9 Aug 2026 06:05:11 +0300 Subject: [PATCH 2/5] [fix] mesh edits keep a multi-material mesh renderable (Bridge on a merged mesh) Reported: merge two boxes with Convert to mesh, select a face on each, click Bridge - nothing happens. The bridge ALGORITHM was correct. Measured on the real scene: the op takes the mesh from 24 to 28 triangles (both caps removed, four wall quads stitched) and the result still spans both boxes. What broke is rendering - the renderer drew ZERO of those 28 triangles. Root cause: three draws an ARRAY material by iterating geometry.groups. Every geometry swap in faceEdit built a fresh BufferGeometry carrying only positions, so a merged multi-material mesh came out with no groups at all and vanished completely. Convert to mesh is what made multi-material meshes common, so the whole face-edit toolset was affected on them - bridge, extrude, inset, subdivide, the live gesture preview, remote applies and undo/redo alike. Fix - carry the material slot through the edit: - readTriangles stamps each triangle with `mi`, the material slot it came from (read off the source geometry's groups); cloneTris and every op that maps, filters or splits triangles carries it, and geometry an op STITCHES (extrude and inset walls, bridge tunnel walls, subdivide children) inherits the slot of the face it grew from. - trisToGroups run-length encodes those slots back into geometry groups. It returns null when everything is slot 0, so a single-material mesh gains no groups and puts nothing extra on the wire - that path is byte-unchanged. - preserveMaterialGroups is the one place a swapped-in geometry gets its groups: the ones the edit computed, else the previous geometry's (exact for the sculpt / vertex-drag / grab paths, which never change the vertex count), and it always covers the tail so no triangle can be left unrendered - including snapshots from an older peer that carry no groups at all. - The groups ride the existing meshgeo message as a small plain array (absent for single-material meshes; an older peer just ignores the field), and the meshgeo history entries store {positions, groups} while still accepting the bare positions array every other producer records. Verified: new tests/e2e/mesh-edit-materials.test.cjs (35 checks) asserts the geometry AND that the renderer still draws every triangle, isolating one object's contribution by toggling its visibility across two frames and calibrating the passes-per-mesh multiplier from a known-good state. Covers bridge, undo/redo, extrude/inset/subdivide, a single-material mesh gaining no groups, and a two-peer run. Proven by reverting the fix: 13 failures, the bridged mesh drawn 0 of 56. Baseline held at 419/62. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/faceEdit.js | 216 +++++++++++++++++---- src/lib/peerHandler.svelte.js | 2 +- tests/e2e/mesh-edit-materials.test.cjs | 249 +++++++++++++++++++++++++ 3 files changed, 429 insertions(+), 38 deletions(-) create mode 100644 tests/e2e/mesh-edit-materials.test.cjs diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index 254dbaef..19712a8a 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; } @@ -198,7 +223,13 @@ export function groupFaces(tris) { } 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)); +} + +/** the material slot of a face's triangles (new geometry an op stitches onto a + * face inherits it) @param {any[]} tris @param {any} face */ +function faceSlot(tris, face) { + return tris[face.triIndices[0]]?.mi || 0; } /** boundary edges of a face group: directed edges appearing once (unordered) @@ -225,15 +256,16 @@ 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 @@ -251,8 +283,9 @@ export function extrudeFace(tris, face, dist) { const offset = face.normal.clone().multiplyScalar(dist); const faceSet = new Set(face.triIndices); const boundary = boundaryEdges(tris, face); + const mi = faceSlot(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 }) => { const a = p0.clone(); @@ -260,7 +293,7 @@ export function extrudeFace(tris, face, dist) { 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)); + pushQuad(out, a, b, b2, a2, radialOut(mid, face), mi); }); return out; } @@ -283,7 +316,7 @@ 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; } @@ -294,8 +327,9 @@ export function insetFace(tris, face, amount) { const faceSet = new Set(face.triIndices); const t = Math.min(Math.max(amount, 0), 0.95); const boundary = boundaryEdges(tris, face); + const mi = faceSlot(tris, face); out.forEach((tri, ti) => { - if (faceSet.has(ti)) tri.forEach((v) => v.lerp(face.centroid, t)); + if (faceSet.has(ti)) tri.forEach((/** @type {any} */ v) => v.lerp(face.centroid, t)); }); // frame ring: original boundary edge → its inset counterpart, facing outward // like the face did (normal ≈ the face normal) @@ -304,7 +338,7 @@ export function insetFace(tris, face, amount) { 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); + pushQuad(out, a, b, b2, a2, face.normal, mi); }); return out; } @@ -325,7 +359,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 +367,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 +381,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 + ) ); } @@ -413,6 +450,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 +482,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 face's material slot (15-G) — a merged + // multi-material mesh must stay fully grouped or it renders as nothing + const mi = faceSlot(workingTris, faces[fiA]); for (let k = 0; k < n; k++) { const a0 = loopA[(ai + k) % n]; const a1 = loopA[(ai + k + 1) % n]; @@ -457,16 +498,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 +536,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 +616,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 +644,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 @@ -1101,6 +1205,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 +1219,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 +1251,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 +1269,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 +1384,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 +1403,7 @@ function liveGeometryUpdate() { const now = Date.now(); if (now - lastFaceBroadcast > 200) { lastFaceBroadcast = now; - broadcastMeshGeo(faceEdited.uuid, positions); + broadcastMeshGeo(faceEdited.uuid, positions, groups); } } @@ -1631,11 +1762,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/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 6ab72f7b..b6cf974f 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -454,7 +454,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/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); +}); From f741515982db7d468d48fed3e741d73ca4dcfd50 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 9 Aug 2026 09:03:08 +0300 Subject: [PATCH 3/5] [fix] face ops on a multi-selection spanning separate shells (extrude walls, inset) Reported: merge two cubes standing side by side with a gap, multi-select the top face of each, Extrude - the two walls FACING EACH OTHER never appear. Root cause: opTargetFace() synthesizes ONE face for a multi-selection, and its centroid lands in the empty gap between the shells. extrudeFace derived each wall's visible side from that centroid (radialOut = mid - centroid, normal component dropped), so for the two INNER boundary edges "away from the centroid" was the exact opposite of "away from the cube": those walls were wound inward and backface-culled to invisible. Measured on the reported scene, the four X-facing walls came out nx = -1, -1, +1, +1 where the correct answer is -1, +1, -1, +1. insetFace had the same flaw from the other end: every selected vertex was lerped toward that shared centroid, so both top faces SLID into the gap instead of insetting in place (their centroids moved 0 -> 0.27 and 3 -> 2.73, and the left face's vertices landed at 0.1 / 0.8 instead of a symmetric -0.35 / +0.35). Fixes: - A wall's outward direction is now derived LOCALLY from the boundary edge: `edge x normal`. Boundary edges inherit their direction from the triangles' winding and a face is wound counter-clockwise seen from +normal, so that cross product always points away from the face interior - no centroid, no global reference point. This also fixes concave faces and faces with holes, which the centroid heuristic got wrong for the same reason. radialOut is gone. - boundaryEdges carries the owning triangle index, so each wall takes THAT triangle's own normal and material slot rather than the synthetic union's. A merged mesh's walls now inherit the colour of the cube they grew from. - insetFace runs per CONNECTED COMPONENT (new componentsOfTris, welded by vertex position): each component shrinks toward its own centre and stitches its own frame ring. A single connected face computes exactly what it did before, so the ordinary single-face inset is unchanged. Verified: new tests/e2e/mesh-multishell-ops.test.cjs (18 checks) - every extrude wall must face away from the cube it belongs to (assigned by nearest shell centre), the two specific inner walls the user saw missing are named individually, a MIDDLE shell in a three-cube row gets outward walls on both sides, each cube's walls carry its own material slot, inset keeps each face on its own centre and shrinks it symmetrically, and a lone-face extrude still behaves exactly as before. Proven by reverting the fix: 7 failures, 4 inward wall triangles, inset centroids at 0.27 / 2.73 and vertices at 0.1 / 0.8. All face/mesh-edit suites re-run green (mesh-ops, mesh-sculpt, mesh-edit- materials, convert-to-mesh, terrain-sculpt, collider-custom, desktop-face-gizmo, editmesh-*, faces-*, every vr-face-*). Baseline held at 419/62. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/faceEdit.js | 126 +++++++++++--- tests/e2e/mesh-multishell-ops.test.cjs | 227 +++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 28 deletions(-) create mode 100644 tests/e2e/mesh-multishell-ops.test.cjs diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index 19712a8a..d6068904 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -232,6 +232,54 @@ function faceSlot(tris, face) { return tris[face.triIndices[0]]?.mi || 0; } +/** 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) * within the group @param {any[]} tris @param {any} face */ function boundaryEdges(tris, face) { @@ -246,7 +294,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); @@ -268,13 +318,26 @@ function pushQuad(out, a, b, c, d, wantDir, mi) { 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 */ @@ -283,17 +346,17 @@ export function extrudeFace(tris, face, dist) { const offset = face.normal.clone().multiplyScalar(dist); const faceSet = new Set(face.triIndices); const boundary = boundaryEdges(tris, face); - const mi = faceSlot(tris, face); out.forEach((t, ti) => { 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), mi); + // 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; } @@ -321,25 +384,32 @@ export function moveFaceAlongNormal(tris, face, dist) { } /** 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); - const mi = faceSlot(tris, face); - out.forEach((tri, ti) => { - if (faceSet.has(ti)) tri.forEach((/** @type {any} */ 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, mi); - }); + 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; } diff --git a/tests/e2e/mesh-multishell-ops.test.cjs b/tests/e2e/mesh-multishell-ops.test.cjs new file mode 100644 index 00000000..5af95675 --- /dev/null +++ b/tests/e2e/mesh-multishell-ops.test.cjs @@ -0,0 +1,227 @@ +// 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'); + + // -------------------------- 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); +}); From 9962ddc20fe4ce253d9383653e5ed7696f80c0a5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 9 Aug 2026 09:36:09 +0300 Subject: [PATCH 4/5] [fix] bridge acts on the SELECTION, not the coplanar faces it touches Reported: merge two cubes, extrude both tops by 0.3, then select the two extruded walls that face each other and Bridge - the result is not the expected tunnel between the extruded parts. Two things were happening, and only the second is a bug: 1. Extruding a face leaves a wall that is COPLANAR with, and edge-adjacent to, the flat side beneath it. groupFaces derives logical faces by coplanarity, so it merges the two: after the extrude, "the extruded wall" is not a face of its own - clicking it with FACE granularity selects the whole side of the cube, from y -0.5 all the way to 0.8. Bridging that fuses the full sides, which is the correct answer to that selection (it produced a clean solid bar - the bridge math itself was fine). 2. The bug: bridgeFaces mapped the selected triangles to logical faces and then used `faces[fi].triIndices` - the WHOLE coplanar group. That is exactly the op-target anti-pattern the codebase already warns about: it silently ignores Face/Triangle/Shell granularity and the Multi set. So even with TRIANGLE granularity and only the four band triangles picked, bridge still consumed the entire side of each cube. There was no way to bridge just the bands. Fix: the two caps are now the two CONNECTED COMPONENTS of the actual selection (componentsOfTris, added with the inset fix), not the faces it happens to touch. Selecting the two 2-triangle bands now bridges exactly those - each inner side is left intact up to y 0.5 and a tunnel floor appears across the gap. A whole-face pick still consumes the whole side, so the selection genuinely decides. Two TOUCHING picks (one component) are refused with a clear message instead of being bridged into garbage; the tunnel walls take the first piece's material slot. faceSlot is gone - it was the last caller. Verified: three new checks in tests/e2e/mesh-multishell-ops.test.cjs assert that the band pick consumes ONLY the band, that the tunnel floor spans the gap, and that a whole-face pick gives a DIFFERENT result (which is the proof the selection is honoured, since before the fix the two were identical). Proven by reverting only this change, with the extrude/inset fixes left in place: 3 failures. Every face/mesh-edit suite re-run green. Baseline held at 419/62. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/faceEdit.js | 39 ++++----- tests/e2e/mesh-multishell-ops.test.cjs | 113 +++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 21 deletions(-) diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index d6068904..1cf74634 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -226,12 +226,6 @@ function cloneTris(/** @type {any[]} */ tris) { return tris.map((t) => withSlot([t[0].clone(), t[1].clone(), t[2].clone()], t.mi)); } -/** the material slot of a face's triangles (new geometry an op stitches onto a - * face inherits it) @param {any[]} tris @param {any} face */ -function faceSlot(tris, face) { - return tris[face.triIndices[0]]?.mi || 0; -} - /** average vertex position of a triangle set @param {any[]} tris @param {number[]} triIndices */ function centroidOfTris(tris, triIndices) { const centroid = new THREE.Vector3(); @@ -483,7 +477,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 + @@ -496,23 +490,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) { @@ -552,9 +549,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 face's material slot (15-G) — a merged + // 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 = faceSlot(workingTris, faces[fiA]); + 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]; diff --git a/tests/e2e/mesh-multishell-ops.test.cjs b/tests/e2e/mesh-multishell-ops.test.cjs index 5af95675..2ddf2097 100644 --- a/tests/e2e/mesh-multishell-ops.test.cjs +++ b/tests/e2e/mesh-multishell-ops.test.cjs @@ -200,6 +200,119 @@ h.run(async () => { ); 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')); From 03685871fa046ccbea934c56a818b8520d32aa65 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 9 Aug 2026 11:06:06 +0300 Subject: [PATCH 5/5] [feat] Quad pick granularity - select the quad under the cursor, not its triangles Selecting faces by triangle is tedious, and the existing Face mode is a coplanar REGION: after an extrude the new wall is coplanar with, and edge-adjacent to, the flat side beneath it, so groupFaces merges them and Face mode cannot isolate the band. Quad sits between the two. - pairQuads(tris) derives the quads from the triangle soup (there is no stored face topology): two triangles pair when they share an edge, face the same way (dot > 0.999), and the quad they would form is CONVEX - the shared edge has to be a real diagonal, or the result is a bow-tie. Candidates are scored by how rectangular the quad is and matched GREEDILY best-first, so a coplanar fan pairs the obvious way rather than the first way. Ties break on triangle index, so the pairing is deterministic for a given geometry. Recomputed in rebuildFaces alongside groupFaces. - A triangle with no possible mate is its own unit - a genuine 3-sided face, or the odd one out in a fan, picks alone rather than jumping to the whole coplanar face. - Quad is the DEFAULT granularity. On a plain box a quad IS the side, so simple meshes behave exactly as before; the difference shows on extruded geometry. - Cycle is now quad > face > triangle > shell > object, in the desktop toolbar and the VR Edit menu. The VR label came off a nested ternary that predated 'object' and showed both 'quad' and 'object' as "Triangle" - replaced with a label map. - highlightFaceByTriangle now compares the picked UNIT, not the raw triangle, for quad mode: crossing a quad's internal diagonal is not a new unit, so the overlay must not rebuild (and VR reads that return value). face keeps its group compare; triangle/shell/object stay per-tri, since a shell key would mean re-running the union-find on every hover frame. - NOTE the naming trap: 'polygon' is a RETIRED alias that migrates to 'triangle', not to 'quad'. Kept, and covered by a check. Verified: new tests/e2e/mesh-quad-select.test.cjs (24 checks) - a box pairs into 6 mutual quads, a click selects 2 triangles where Triangle selects 1 and Face selects the same 2, an extrusion wall picks as its own quad (both triangles ABOVE the original top) while Face on the same click still takes all 4, an unpairable triangle picks alone, a 3-triangle strip pairs what it can, extrude on a quad stitches four walls (12 -> 20 tris), the cycle order, the legacy alias, and the diagonal-crossing hover. All 28 mesh/face/VR suites re-run green. Baseline held at 419/62. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/menu/MeshEditPopup.svelte | 6 + src/components/play/VREditMenu.svelte | 12 +- src/lib/faceEdit.js | 160 +++++++++++-- src/lib/vrControls.js | 2 +- tests/e2e/mesh-quad-select.test.cjs | 280 +++++++++++++++++++++++ 5 files changed, 445 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/mesh-quad-select.test.cjs 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/lib/faceEdit.js b/src/lib/faceEdit.js index 1cf74634..7890da7e 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -222,6 +222,101 @@ 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) => withSlot([t[0].clone(), t[1].clone(), t[2].clone()], t.mi)); } @@ -904,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() { @@ -929,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. @@ -960,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]) ); @@ -1033,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 */ @@ -1040,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() { @@ -1168,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; } 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/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); +});