From 3db9f121e6d2ea25a14d9b1d4ef032a06f343801 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:24:40 +0200 Subject: [PATCH 01/10] feat(sdk): a scene node attaches a child where it stands in the world (#758) --- .../src/math/transform-tree/attach.ts | 45 +++++++++++++++++++ packages/sdk-core/src/scene/core/node.test.ts | 24 ++++++++++ packages/sdk-core/src/scene/core/node.ts | 22 ++++----- .../src/world/object/object3d.test.ts | 24 ++++++++++ .../sdk-core/src/world/object/object3d.ts | 17 +++---- 5 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 packages/sdk-core/src/math/transform-tree/attach.ts diff --git a/packages/sdk-core/src/math/transform-tree/attach.ts b/packages/sdk-core/src/math/transform-tree/attach.ts new file mode 100644 index 0000000000..75f5b4ab1c --- /dev/null +++ b/packages/sdk-core/src/math/transform-tree/attach.ts @@ -0,0 +1,45 @@ +import { multiplyMatrix4 } from '../matrix/matrix4.ts'; +import { invertMatrix4 } from '../matrix/matrix4Inverse.ts'; +import { decomposeMatrix4 } from '../matrix/matrix4Trs.ts'; +import { + setNodeLocalMatrix, + setNodePosition, + setNodeQuaternion, + setNodeScale, + type TransformTree, +} from './transformTree.ts'; +import { updateNodeWorldMatrix } from './update.ts'; + +/** Scratch values of the two functions below: neither allocates. */ +const inverse = new Float64Array(16), + local = new Float64Array(16), + position = new Float64Array(3), + quaternion = new Float64Array(4), + scale = new Float64Array(3); + +/** + * The local matrix that keeps `node` where it stands in the world once it is under `parent`, as + * the reference `attach` computes it: the inverse of `parent`'s world matrix times `node`'s, both + * resolved first. A parent scaled to zero inverts to the zero matrix, as with the reference. The + * result is a scratch the next call overwrites. + */ +export function nodeLocalUnder(tree: TransformTree, node: number, parent: number) { + updateNodeWorldMatrix(tree, node, true, false); + updateNodeWorldMatrix(tree, parent, true, false); + invertMatrix4(inverse, tree.worldViews[parent]); + multiplyMatrix4(local, inverse, tree.worldViews[node]); + return local; +} + +/** + * Writes `m` as `node`'s local pose: its local matrix, and the position, rotation and scale read + * out of it, which an automatic update recomposes. A sheared `m` loses its shear there, as with + * the reference. + */ +export function setNodeLocalPose(tree: TransformTree, node: number, m: ArrayLike) { + decomposeMatrix4(m, position, quaternion, scale); + setNodePosition(tree, node, position[0], position[1], position[2]); + setNodeQuaternion(tree, node, quaternion[0], quaternion[1], quaternion[2], quaternion[3]); + setNodeScale(tree, node, scale[0], scale[1], scale[2]); + setNodeLocalMatrix(tree, node, m); +} diff --git a/packages/sdk-core/src/scene/core/node.test.ts b/packages/sdk-core/src/scene/core/node.test.ts index d7e80736dc..8cc1bf0c97 100644 --- a/packages/sdk-core/src/scene/core/node.test.ts +++ b/packages/sdk-core/src/scene/core/node.test.ts @@ -150,3 +150,27 @@ test('adding children copies nothing: the frozen list is made once, when it is r assert.equal(parent.children.length, 1000); assert.equal(parent.children, parent.children, 'the list is kept until the next change'); }); + +const assertClose = (actual: ArrayLike, expected: ArrayLike) => + Array.from(expected, (value, i) => assert.ok(Math.abs(actual[i] - value) < 1e-12, `[${i}]`)); + +test('attach moves a child under another parent where it stands in the world', () => { + const root = createSceneRoot(); + const s = Math.SQRT1_2; + const from = root.createNode().setPosition(1, 2, 3).setQuaternion(0, s, 0, s).setScale(2, 3, 4); + const to = root.createNode().setPosition(-4, 0, 1).setQuaternion(s, 0, 0, s).setScale(5, 5, 5); + const child = root.createNode().setPosition(1, -1, 2).setScale(1, 2, 0.5); + root.add(from).add(to); + from.add(child); + const world = child.updateWorldMatrix().worldMatrix.slice(); + assert.equal(to.attach(child), to); + assert.equal(child.parent, to); + assert.deepEqual(from.children, []); + assertClose(child.worldMatrix, world); + child.updateWorldMatrix(); + assertClose(child.worldMatrix, world); // the rewritten pose recomposes the same matrix + from.attach(child); + assertClose(child.localMatrix, [1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0.5, 0, 1, -1, 2, 1]); + assert.throws(() => child.attach(from), /TRANSFORM_CYCLE|cycle/); + assert.equal(from.parent, root); +}); diff --git a/packages/sdk-core/src/scene/core/node.ts b/packages/sdk-core/src/scene/core/node.ts index 99e3e7a8c3..bc915df10f 100644 --- a/packages/sdk-core/src/scene/core/node.ts +++ b/packages/sdk-core/src/scene/core/node.ts @@ -7,6 +7,7 @@ import { setNodeScale, } from '../../math/transform-tree/transformTree.ts'; import { removeTransformNode, reparentTransformNode } from '../../math/transform-tree/structure.ts'; +import { nodeLocalUnder, setNodeLocalPose } from '../../math/transform-tree/attach.ts'; import { updateNodeWorldMatrix } from '../../math/transform-tree/update.ts'; import { copySceneNodeState } from './nodeCopy.ts'; import { sceneNodeFail, sceneNodeVisibility } from './nodeError.ts'; @@ -67,7 +68,6 @@ export class SceneNode { this.assertAlive(); return this.state.tree.worldViews[this.index]; } - /** Adds a child. */ add(child: SceneNode) { this.assertCompatible(child); if (child.index === this.state.root?.index) @@ -80,7 +80,6 @@ export class SceneNode { child.parentNode = this; return this; } - /** Removes a child. */ remove(child: SceneNode) { this.assertCompatible(child); if (child.parent !== this) return this; @@ -88,13 +87,11 @@ export class SceneNode { this.detachChild(child); return this; } - /** Removes every child. */ clear() { this.assertAlive(); for (let i = this.childNodes.length - 1; i >= 0; i--) this.remove(this.childNodes[i]); return this; } - /** Moves it under another parent. */ reparent(parent: SceneNode | null) { this.assertAlive(); if (this.index === this.state.root?.index) @@ -103,7 +100,16 @@ export class SceneNode { else this.parent?.remove(this); return this; } - + /** Adds a child where it stands: its world matrix is kept, its local pose rewritten. */ + attach(child: SceneNode) { + this.assertCompatible(child); + const local = nodeLocalUnder(this.state.tree, child.index, this.index); + this.add(child); // An Object3D skips itself, which then keeps its pose. + if (child.parent !== this) return this; + setNodeLocalPose(this.state.tree, child.index, local); + child.updateWorldMatrix(false, true); + return this; + } /** A copy, children too. */ clone(recursive = true, options: SceneNodeOptions = {}) { this.assertAlive(); const clone = this.root.createNode(options); @@ -118,37 +124,31 @@ export class SceneNode { copySceneNodeState(this.state, this, source, recursive); return this; } - /** Sets its position. */ setPosition(x: number, y: number, z: number) { this.assertAlive(); setNodePosition(this.state.tree, this.index, x, y, z); return this; } - /** Sets its rotation. */ setQuaternion(x: number, y: number, z: number, w: number) { this.assertAlive(); setNodeQuaternion(this.state.tree, this.index, x, y, z, w); return this; } - /** Sets its size. */ setScale(x: number, y: number, z: number) { this.assertAlive(); setNodeScale(this.state.tree, this.index, x, y, z); return this; } - /** Sets its local matrix. */ setLocalMatrix(matrix: ArrayLike) { this.assertAlive(); setNodeLocalMatrix(this.state.tree, this.index, matrix); return this; } - /** Whether its matrix follows its pose. */ setAutoUpdate(auto: boolean) { this.assertAlive(); setNodeAutoUpdate(this.state.tree, this.index, auto); return this; } - /** Updates its world matrix. */ updateWorldMatrix(updateParents = true, updateChildren = true) { this.assertAlive(); updateNodeWorldMatrix(this.state.tree, this.index, updateParents, updateChildren); diff --git a/packages/sdk-core/src/world/object/object3d.test.ts b/packages/sdk-core/src/world/object/object3d.test.ts index 36673e8608..8e41fffe70 100644 --- a/packages/sdk-core/src/world/object/object3d.test.ts +++ b/packages/sdk-core/src/world/object/object3d.test.ts @@ -5,6 +5,8 @@ import { Light } from '../light/light.ts'; import { Camera } from '../camera/camera.ts'; import { Mesh } from './mesh.ts'; import { cloneObject } from './clone.ts'; +import { Matrix4 } from '../math/matrix4.ts'; +import { Quaternion } from '../math/quaternion.ts'; // Re-deriving Euler angles from the quaternion would swap (0, y, 0) past ±90° for the equivalent // (π, π − y, π); a later one-axis write would then keep x = z = π and turn the node another way. @@ -71,3 +73,25 @@ test("a clone keeps a light's values, a camera's optics, and shares a mesh's con assert.ok(twin.geometry === lines.geometry && twin.material === lines.material, 'shared'); assert.equal(twin.primitive, 'lineSegments'); }); + +test('attach keeps the world matrix, and position, rotation and scale hold the new pose', () => { + const [from, to, node] = [new Group(), new Group(), new Object3D()]; + from.position.set(1, 2, 3); + from.rotation.set(0, Math.PI / 2, 0); + from.scale.set(2, 3, 4); + to.position.set(-4, 0, 1); + to.rotation.set(Math.PI / 2, 0, 0); + from.add(node); + node.position.set(1, -1, 2); + node.updateWorldMatrix(true, false); + const world = node.matrixWorld.clone(); + assert.equal(to.attach(node), to); + assert.equal(node.parent, to); + const close = (m: Matrix4) => m.elements.every((v, i) => Math.abs(v - world.elements[i]) < 1e-12); + node.updateMatrixWorld(true); + assert.ok(close(node.matrixWorld), 'its world matrix, kept'); + const posed = new Matrix4().compose(node.position, node.quaternion, node.scale); + assert.ok(close(posed.premultiply(to.matrixWorld)), 'its fields, rewritten'); + const turn = new Quaternion().setFromEuler(node.rotation); + assert.ok(Math.abs(Math.abs(turn.dot(node.quaternion)) - 1) < 1e-12, 'its angles follow'); +}); diff --git a/packages/sdk-core/src/world/object/object3d.ts b/packages/sdk-core/src/world/object/object3d.ts index 9cc4c820e0..a968a21298 100644 --- a/packages/sdk-core/src/world/object/object3d.ts +++ b/packages/sdk-core/src/world/object/object3d.ts @@ -35,8 +35,7 @@ export class Object3D extends TransformNode { /** Drawing order among see-through things. */ renderOrder = 0; /** Whether a renderer may skip it outside the view. */ frustumCulled = true; /** Free room for the page's own data. */ userData: Record = {}; - /** The world this node is drawn by; set on attach, cleared on detach. */ - _link: SceneLink | null = null; + /** The world that draws it, or null outside one. */ _link: SceneLink | null = null; constructor() { const slot = reserveSlot(); super(slot.state, slot.id, slot.index, slot.visible); @@ -78,8 +77,7 @@ export class Object3D extends TransformNode { override get children(): readonly Object3D[] { return super.children as readonly Object3D[]; } - /** A viewer looks down `-z`: cameras and lights say so. */ - protected get looksDownNegativeZ() { + /** A viewer looks down `-z`: cameras and lights say so. */ protected get looksDownNegativeZ() { return false; } /** Makes objects children of this node. */ override add(...objects: Object3D[]) { @@ -100,6 +98,11 @@ export class Object3D extends TransformNode { this._link?.structure(this); return this; } + /** Adds `child` where it stands (`SceneNode.attach`). */ override attach(child: Object3D) { + super.attach(child); + applied.fromArray(child.localMatrix).decompose(child.position, child.quaternion, child.scale); + return this; + } /** Frees the node and all below it now, rather than when they are collected. */ override destroy() { this.traverse(uncollectSlot); @@ -143,8 +146,7 @@ export class Object3D extends TransformNode { } return undefined; } - /** Composes the local matrix from the pose. */ - updateMatrix() { + /** Composes the local matrix from the pose. */ updateMatrix() { this.matrix.compose(this.position, this.quaternion, this.scale); this.matrixWorldNeedsUpdate = true; } @@ -192,8 +194,7 @@ export class Object3D extends TransformNode { } } -/** A node that only groups others. */ -export class Group extends Object3D { +/** A node that only groups others. */ export class Group extends Object3D { /** Always `true`: tells a group apart. */ readonly isGroup = true as const; /** The kind of node, `'Group'`. */ override type = 'Group'; } From cb968938ea00a8477d7ee85c3a27f0dfa7ddb864 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:24:40 +0200 Subject: [PATCH 02/10] refactor(sdk): the editor's reparent attaches through the engine (#758) --- scripts/docs-scene-editor.test.ts | 22 +++++++++++++++++++++- site/app/editor/commands.ts | 15 +++------------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/scripts/docs-scene-editor.test.ts b/scripts/docs-scene-editor.test.ts index 8af9b47895..924706752f 100644 --- a/scripts/docs-scene-editor.test.ts +++ b/scripts/docs-scene-editor.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createHistory } from '../site/app/editor/history.ts'; -import { removeCommand } from '../site/app/editor/commands.ts'; +import { poseOf, removeCommand, reparentCommand, samePose } from '../site/app/editor/commands.ts'; import { object } from '../packages/sdk-core/src/world/object/index.ts'; test('the editor history undoes and redoes in order, and forgets past its capacity', () => { @@ -33,3 +33,23 @@ test('a deletion undone puts the object back at its rank and gives the selection assert.deepEqual(parent.children, [a, b, c], 'the sibling order is kept'); assert.deepEqual(selected, [b], 'the selection comes back with the object'); }); + +test('a reparent keeps where the object stands, and undone gives back its parent and pose', () => { + const [from, to, node] = [object.group(), object.group(), object.group()]; + from.position.set(1, 2, 3); + from.scale.set(2, 3, 4); + to.rotation.set(0, Math.PI / 2, 0); + to.position.set(-1, 0, 0); + from.add(node); + node.position.set(1, 1, 1); + const [world, before] = [node.getWorldPosition(), poseOf(node)]; + const stays = () => node.getWorldPosition().distanceTo(world) < 1e-12; + const command = reparentCommand(node, to); + command.redo(); + assert.ok(node.parent === to && !samePose(poseOf(node), before), 'a new local pose'); + assert.ok(stays(), 'the same place in the world'); + command.undo(); + assert.ok(node.parent === from && samePose(poseOf(node), before), 'its parent and pose back'); + command.redo(); + assert.ok(stays()); +}); diff --git a/site/app/editor/commands.ts b/site/app/editor/commands.ts index 8e3ffa9223..ef87e914ea 100644 --- a/site/app/editor/commands.ts +++ b/site/app/editor/commands.ts @@ -59,27 +59,18 @@ export function removeCommand(object: Object3D, reselect: (node: Object3D) => vo } /** - * `object` moved under `parent`, where it keeps the place it had in the world: its new local pose - * is its world matrix seen from the new parent. Undone, it goes back under its old parent (last - * among its siblings) with its old local pose. + * `object` moved under `parent` where it stands in the world (`attach`). Undone, it goes back under + * its old parent (last among its siblings) with its old local pose. */ export function reparentCommand(object: Object3D, parent: Object3D): Command { const from = object.parent!; const before = poseOf(object); - object.updateWorldMatrix(true, false); - parent.updateWorldMatrix(true, false); - const local = parent.matrixWorld.clone().invert().multiply(object.matrixWorld); - const after = poseOf(object); - local.decompose(after.position, after.quaternion, after.scale); return { undo: () => { from.add(object); applyPose(object, before); }, - redo: () => { - parent.add(object); - applyPose(object, after); - }, + redo: () => parent.attach(object), }; } From f6f4bf745103d9bbeb02d400d4dba3541f26a444 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:24:40 +0200 Subject: [PATCH 03/10] docs(sdk): attach in the guide and every translation of the reference (#758) --- docs/SDK.md | 8 ++++++-- site/content/reference/api.ar.json | 2 ++ site/content/reference/api.de.json | 2 ++ site/content/reference/api.es.json | 2 ++ site/content/reference/api.fr.json | 2 ++ site/content/reference/api.hi.json | 2 ++ site/content/reference/api.id.json | 2 ++ site/content/reference/api.it.json | 2 ++ site/content/reference/api.ja.json | 2 ++ site/content/reference/api.ko.json | 2 ++ site/content/reference/api.pt.json | 2 ++ site/content/reference/api.ru.json | 2 ++ site/content/reference/api.tr.json | 2 ++ site/content/reference/api.vi.json | 2 ++ site/content/reference/api.zh.json | 2 ++ 15 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/SDK.md b/docs/SDK.md index bc441e1ae5..442cf60bf2 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -670,8 +670,12 @@ transform foundation, scene-model version `SCENE_MODEL_VERSION` 1: `SceneRoot` a own outside a world. A `SceneRoot` owns one transform hierarchy; nodes created by `root.createNode({ id, visible })` -have stable, root-unique identifiers and are attached with `add` or `reparent`. `remove` and `clear` -detach live nodes, while `destroy` permanently invalidates a whole subtree. `clone` gives the new +have stable, root-unique identifiers and are attached with `add` or `reparent`, which keep the local +pose, or with `attach`, which keeps the node where it stands in the world: `shelf.attach(crate)` +rewrites the crate's local pose from its world matrix seen from the shelf (a sheared result loses +its shear, as with the reference), and an `Object3D`'s `position`, `rotation`, `quaternion` and +`scale` follow. `remove` and `clear` detach live nodes, while `destroy` permanently invalidates a +whole subtree. `clone` gives the new node a fresh identifier unless one is supplied; `copy` keeps the destination identifier. Both reproduce the local pose and optionally the descendants. Recursive copying from an ancestor into its descendant is rejected with `SCENE_COPY_OVERLAP` before either node changes. diff --git a/site/content/reference/api.ar.json b/site/content/reference/api.ar.json index 3f06e52a85..a97c54415f 100644 --- a/site/content/reference/api.ar.json +++ b/site/content/reference/api.ar.json @@ -2134,6 +2134,7 @@ "children": "الأبناء المرفقون بترتيب الإدراج. لا يمكن تعديل القائمة المُعادة.", "add": "يجعل الأجسام أبناءً لهذه العقدة.", "remove": "ينزع الأبناء من هذه العقدة.", + "attach": "يضيف `child` حيث يقف (`SceneNode.attach`).", "destroy": "يحرّر العقدة وكل ما تحتها الآن، بدل انتظار جمعها.", "removeFromParent": "ينزع العقدة من أصلها.", "clone": "عقدة من النوع نفسه بالقيم نفسها؛ ويُنسخ أبناؤها أيضًا ما لم يُطلب غير ذلك.", @@ -3113,6 +3114,7 @@ "remove": "يزيل ابنًا.", "clear": "يزيل كل ابن.", "reparent": "ينقلها تحت أصل آخر.", + "attach": "يضيف ابنًا حيث يقف: تُحفظ مصفوفته في العالم وتُعاد كتابة وضعيته المحلية.", "clone": "نسخة، والأبناء أيضًا.", "copy": "ينسخ القيم والأحفاد؛ يُرفَض التداخل التكراري من السلف إلى الحفيد بشكل ذرّي.", "setPosition": "يضبط موضعها.", diff --git a/site/content/reference/api.de.json b/site/content/reference/api.de.json index 51f98e52f4..3b10bd8a3b 100644 --- a/site/content/reference/api.de.json +++ b/site/content/reference/api.de.json @@ -4250,6 +4250,7 @@ "children": "Angehängte Kinder in Hinzufügereihenfolge. Die zurückgegebene Liste kann nicht verändert werden.", "add": "Macht Objekte zu Kindern dieses Knotens.", "remove": "Entfernt Kinder von diesem Knoten.", + "attach": "Fügt `child` dort hinzu, wo es steht (`SceneNode.attach`).", "destroy": "Gibt den Knoten und alles darunter sofort frei, statt erst bei ihrer Einsammlung.", "removeFromParent": "Entfernt den Knoten von seinem Elternteil.", "clone": "Ein Knoten derselben Art mit denselben Werten; seine Kinder werden mitkopiert, sofern nicht anders gesagt.", @@ -5307,6 +5308,7 @@ "remove": "Entfernt ein Kind.", "clear": "Entfernt jedes Kind.", "reparent": "Platziert ihn unter einem anderen Elternteil.", + "attach": "Fügt ein Kind dort hinzu, wo es steht: Seine Weltmatrix bleibt erhalten, seine lokale Pose wird neu geschrieben.", "clone": "Eine Kopie, Kinder eingeschlossen.", "copy": "Kopiert die Werte und die Nachkommen; eine rekursive Überlappung von einem Vorfahren zu einem Nachkommen wird pauschal abgelehnt.", "setPosition": "Setzt seine Position.", diff --git a/site/content/reference/api.es.json b/site/content/reference/api.es.json index 187e1e0e0a..457a88aa98 100644 --- a/site/content/reference/api.es.json +++ b/site/content/reference/api.es.json @@ -4250,6 +4250,7 @@ "children": "Hijos conectados en orden de adición. La lista devuelta no se puede modificar.", "add": "Convierte objetos en hijos de este nodo.", "remove": "Quita hijos de este nodo.", + "attach": "Añade `child` donde está (`SceneNode.attach`).", "destroy": "Libera el nodo y todo lo que está debajo ahora, en lugar de cuando se recolecten.", "removeFromParent": "Quita el nodo de su padre.", "clone": "Un nodo del mismo tipo con los mismos valores; sus hijos también copiados salvo que se indique lo contrario.", @@ -5307,6 +5308,7 @@ "remove": "Retira un hijo.", "clear": "Retira cada hijo.", "reparent": "Lo coloca bajo otro padre.", + "attach": "Añade un hijo donde está: conserva su matriz en el mundo y reescribe su pose local.", "clone": "Una copia, hijos incluidos.", "copy": "Copia los valores y los descendientes; una superposición recursiva de un ancestro hacia un descendiente se rechaza en bloque.", "setPosition": "Fija su posición.", diff --git a/site/content/reference/api.fr.json b/site/content/reference/api.fr.json index 56a1ef169b..d15d974ddc 100644 --- a/site/content/reference/api.fr.json +++ b/site/content/reference/api.fr.json @@ -4258,6 +4258,7 @@ "children": "Enfants attachés dans l’ordre d’ajout. La liste renvoyée ne peut pas être modifiée.", "add": "Fait des objets les enfants de ce nœud.", "remove": "Retire des enfants de ce nœud.", + "attach": "Ajoute `child` là où il se trouve (`SceneNode.attach`).", "destroy": "Libère le nœud et tout ce qui est dessous tout de suite, plutôt qu’à leur collecte.", "removeFromParent": "Retire le nœud de son parent.", "clone": "Un nœud du même genre avec les mêmes valeurs ; ses enfants copiés aussi sauf avis contraire.", @@ -5315,6 +5316,7 @@ "remove": "Retire un enfant.", "clear": "Retire chaque enfant.", "reparent": "Le place sous un autre parent.", + "attach": "Ajoute un enfant là où il se trouve : sa matrice dans le monde est gardée, sa pose locale réécrite.", "clone": "Une copie, enfants compris.", "copy": "Copie les valeurs et les descendants ; un chevauchement récursif d’un ancêtre vers un descendant est refusé en bloc.", "setPosition": "Règle sa position.", diff --git a/site/content/reference/api.hi.json b/site/content/reference/api.hi.json index 1706fce50c..f088b9ee30 100644 --- a/site/content/reference/api.hi.json +++ b/site/content/reference/api.hi.json @@ -2134,6 +2134,7 @@ "children": "डालने के क्रम में जुड़े बच्चे। लौटाई सूची बदली नहीं जा सकती।", "add": "ऑब्जेक्ट को इस नोड के बच्चे बनाता है।", "remove": "इस नोड से बच्चे हटाता है।", + "attach": "`child` को वहीं जोड़ता है जहाँ वह खड़ा है (`SceneNode.attach`)।", "destroy": "नोड और उसके नीचे सब कुछ अभी मुक्त करता है, उनके संग्रह होने का इंतज़ार किए बिना।", "removeFromParent": "नोड को इसके माता-पिता से हटाता है।", "clone": "उसी प्रकार और उन्हीं मानों वाला नोड; मना न किया जाए तो उसके बच्चे भी कॉपी होते हैं।", @@ -3113,6 +3114,7 @@ "remove": "एक बच्चा हटाता है।", "clear": "हर बच्चा हटाता है।", "reparent": "इसे किसी और माता-पिता के नीचे हिलाता है।", + "attach": "एक बच्चे को वहीं जोड़ता है जहाँ वह खड़ा है: उसका विश्व मैट्रिक्स बना रहता है, उसकी स्थानीय मुद्रा फिर से लिखी जाती है।", "clone": "एक कॉपी, बच्चे भी।", "copy": "मान और वंशज कॉपी करता है; पूर्वज-से-वंशज पुनरावर्ती ओवरलैप एक साथ नकारा जाता है।", "setPosition": "इसकी पोज़िशन तय करता है।", diff --git a/site/content/reference/api.id.json b/site/content/reference/api.id.json index 270071b682..3a3f6ad4ed 100644 --- a/site/content/reference/api.id.json +++ b/site/content/reference/api.id.json @@ -2134,6 +2134,7 @@ "children": "Anak yang terpasang dalam urutan penyisipan. Daftar yang dikembalikan tidak bisa diubah.", "add": "Menjadikan objek anak dari node ini.", "remove": "Melepas anak dari node ini.", + "attach": "Menambahkan `child` di tempat ia berada (`SceneNode.attach`).", "destroy": "Membebaskan simpul dan semua di bawahnya sekarang, bukan saat dikumpulkan.", "removeFromParent": "Melepas node itu dari induknya.", "clone": "Node dengan jenis dan nilai yang sama; anak-anaknya ikut disalin kecuali diminta tidak.", @@ -3113,6 +3114,7 @@ "remove": "Menghapus sebuah anak.", "clear": "Menghapus setiap anak.", "reparent": "Memindahkannya di bawah induk lain.", + "attach": "Menambahkan anak di tempat ia berada: matriks dunianya dipertahankan, pose lokalnya ditulis ulang.", "clone": "Sebuah salinan, anak-anak juga.", "copy": "Menyalin nilai dan keturunan; tumpang tindih rekursif leluhur-ke-keturunan ditolak secara atomik.", "setPosition": "Mengatur posisinya.", diff --git a/site/content/reference/api.it.json b/site/content/reference/api.it.json index 083fcaaca9..c737b780d9 100644 --- a/site/content/reference/api.it.json +++ b/site/content/reference/api.it.json @@ -4250,6 +4250,7 @@ "children": "Figli collegati nell'ordine di aggiunta. La lista restituita non può essere modificata.", "add": "Rende oggetti figli di questo nodo.", "remove": "Rimuove figli da questo nodo.", + "attach": "Aggiunge `child` dove si trova (`SceneNode.attach`).", "destroy": "Libera subito il nodo e tutto ciò che sta sotto, invece che alla loro raccolta.", "removeFromParent": "Rimuove il nodo dal suo genitore.", "clone": "Un nodo dello stesso tipo con gli stessi valori; anche i figli copiati salvo indicazione contraria.", @@ -5307,6 +5308,7 @@ "remove": "Rimuove un figlio.", "clear": "Rimuove ogni figlio.", "reparent": "Lo colloca sotto un altro genitore.", + "attach": "Aggiunge un figlio dove si trova: la sua matrice nel mondo è mantenuta, la sua posa locale riscritta.", "clone": "Una copia, figli inclusi.", "copy": "Copia i valori e i discendenti; una sovrapposizione ricorsiva da un antenato verso un discendente viene rifiutata in blocco.", "setPosition": "Imposta la sua posizione.", diff --git a/site/content/reference/api.ja.json b/site/content/reference/api.ja.json index e7b26c3e89..7b358a3607 100644 --- a/site/content/reference/api.ja.json +++ b/site/content/reference/api.ja.json @@ -2116,6 +2116,7 @@ "children": "挿入順で並んだ、アタッチされた子ノード群。返されるリストは変更できない。", "add": "オブジェクトをこのノードの子にする。", "remove": "このノードから子を外す。", + "attach": "`child`をその場のまま追加する(`SceneNode.attach`)。", "destroy": "ノードとその下のすべてを、回収を待たずに今すぐ解放する。", "removeFromParent": "このノードをその親から外す。", "clone": "同じ種類・同じ値のノード。指定がなければ子も複製される。", @@ -3030,6 +3031,7 @@ "remove": "子を削除する。", "clear": "すべての子を削除する。", "reparent": "別の親の下に動かす。", + "attach": "子をその場のまま追加する。ワールド行列は保たれ、ローカルの姿勢が書き直される。", "clone": "子も含めたコピー。", "copy": "値と子孫をコピーする; 祖先から子孫への再帰的な重なりはアトミックに拒否される。", "setPosition": "その位置を設定する。", diff --git a/site/content/reference/api.ko.json b/site/content/reference/api.ko.json index 1164895580..992f174fc8 100644 --- a/site/content/reference/api.ko.json +++ b/site/content/reference/api.ko.json @@ -2116,6 +2116,7 @@ "children": "삽입 순서대로 붙어 있는 자식들. 반환된 목록은 변경할 수 없다.", "add": "오브젝트들을 이 노드의 자식으로 만든다.", "remove": "이 노드에서 자식들을 뗀다.", + "attach": "`child`를 제자리 그대로 추가한다(`SceneNode.attach`).", "destroy": "노드와 그 아래 모든 것을 수집을 기다리지 않고 지금 해제한다.", "removeFromParent": "이 노드를 그 부모에서 뗀다.", "clone": "같은 종류, 같은 값의 노드. 달리 말하지 않으면 자식도 복사됩니다.", @@ -3030,6 +3031,7 @@ "remove": "자식을 제거한다.", "clear": "모든 자식을 제거한다.", "reparent": "다른 부모 아래로 옮긴다.", + "attach": "자식을 제자리 그대로 추가한다. 월드 행렬은 유지되고 로컬 자세가 다시 쓰인다.", "clone": "자식들까지 포함한 복사본.", "copy": "값과 후손들을 복사한다; 조상에서 후손으로의 재귀적 겹침은 원자적으로 거부된다.", "setPosition": "그 위치를 설정한다.", diff --git a/site/content/reference/api.pt.json b/site/content/reference/api.pt.json index 91f686ead6..ee6564713d 100644 --- a/site/content/reference/api.pt.json +++ b/site/content/reference/api.pt.json @@ -2116,6 +2116,7 @@ "children": "Filhos anexados na ordem de inserção. A lista devolvida não pode ser alterada.", "add": "Torna objetos filhos deste nó.", "remove": "Tira filhos deste nó.", + "attach": "Adiciona `child` onde ele está (`SceneNode.attach`).", "destroy": "Liberta o nó e tudo abaixo dele agora, em vez de quando forem recolhidos.", "removeFromParent": "Tira o nó de seu pai.", "clone": "Um nó do mesmo tipo com os mesmos valores; os seus filhos também copiados salvo indicação em contrário.", @@ -3030,6 +3031,7 @@ "remove": "Remove um filho.", "clear": "Remove cada filho.", "reparent": "Move-o para sob outro pai.", + "attach": "Adiciona um filho onde ele está: a sua matriz no mundo é mantida, a sua pose local reescrita.", "clone": "Uma cópia, filhos também.", "copy": "Copia valores e descendentes; sobreposição recursiva de ancestral para descendente é recusada de forma atômica.", "setPosition": "Define sua posição.", diff --git a/site/content/reference/api.ru.json b/site/content/reference/api.ru.json index 8729514e5e..56a3a589ed 100644 --- a/site/content/reference/api.ru.json +++ b/site/content/reference/api.ru.json @@ -2116,6 +2116,7 @@ "children": "Присоединённые дети в порядке вставки. Возвращённый список нельзя изменять.", "add": "Делает объекты детьми этого узла.", "remove": "Убирает детей с этого узла.", + "attach": "Добавляет `child` там, где он стоит (`SceneNode.attach`).", "destroy": "Освобождает узел и всё под ним сразу, а не при их сборке.", "removeFromParent": "Убирает узел от его родителя.", "clone": "Узел того же вида с теми же значениями; его дети тоже копируются, если не сказано иначе.", @@ -3030,6 +3031,7 @@ "remove": "Удаляет ребёнка.", "clear": "Удаляет каждого ребёнка.", "reparent": "Перемещает его под другого родителя.", + "attach": "Добавляет потомка там, где он стоит: его мировая матрица сохраняется, локальная поза переписывается.", "clone": "Копия, включая детей.", "copy": "Копирует значения и потомков; рекурсивное пересечение предок-потомок атомарно отклоняется.", "setPosition": "Задаёт его позицию.", diff --git a/site/content/reference/api.tr.json b/site/content/reference/api.tr.json index 3f658f0891..d1e7adb209 100644 --- a/site/content/reference/api.tr.json +++ b/site/content/reference/api.tr.json @@ -2116,6 +2116,7 @@ "children": "Ekleme sırasına göre eklenmiş çocuklar. Döndürülen liste değiştirilemez.", "add": "Nesneleri bu düğümün çocukları yapar.", "remove": "Çocukları bu düğümden çıkarır.", + "attach": "`child` öğesini durduğu yerde ekler (`SceneNode.attach`).", "destroy": "Düğümü ve altındaki her şeyi toplanmalarını beklemeden hemen serbest bırakır.", "removeFromParent": "Düğümü ebeveyninden çıkarır.", "clone": "Aynı türde, aynı değerlerde bir düğüm; aksi söylenmedikçe çocukları da kopyalanır.", @@ -3030,6 +3031,7 @@ "remove": "Bir çocuğu kaldırır.", "clear": "Her çocuğu kaldırır.", "reparent": "Onu başka bir ebeveynin altına taşır.", + "attach": "Bir çocuğu durduğu yerde ekler: dünya matrisi korunur, yerel pozu yeniden yazılır.", "clone": "Çocuklar dahil bir kopya.", "copy": "Değerleri ve torunları kopyalar; özyinelemeli ata-torun çakışması atomik olarak reddedilir.", "setPosition": "Pozisyonunu ayarlar.", diff --git a/site/content/reference/api.vi.json b/site/content/reference/api.vi.json index 3206412e24..cd6c32f1b9 100644 --- a/site/content/reference/api.vi.json +++ b/site/content/reference/api.vi.json @@ -2134,6 +2134,7 @@ "children": "Các con đã gắn theo thứ tự chèn vào. Danh sách trả về không thể bị thay đổi.", "add": "Biến các đối tượng thành con của node này.", "remove": "Gỡ các con khỏi node này.", + "attach": "Thêm `child` ngay tại chỗ nó đứng (`SceneNode.attach`).", "destroy": "Giải phóng nút và mọi thứ bên dưới ngay bây giờ, thay vì khi chúng bị thu gom.", "removeFromParent": "Gỡ node khỏi cha của nó.", "clone": "Một nút cùng loại với cùng giá trị; các con của nó cũng được sao chép trừ khi được bảo không.", @@ -3113,6 +3114,7 @@ "remove": "Gỡ một con.", "clear": "Gỡ mọi con.", "reparent": "Di chuyển nó dưới một cha khác.", + "attach": "Thêm một nút con ngay tại chỗ nó đứng: giữ nguyên ma trận thế giới, viết lại tư thế cục bộ.", "clone": "Một bản sao, cả các con nữa.", "copy": "Sao chép các giá trị và hậu duệ; sự chồng lấn đệ quy từ tổ tiên đến hậu duệ bị từ chối một cách nguyên tử.", "setPosition": "Đặt vị trí của nó.", diff --git a/site/content/reference/api.zh.json b/site/content/reference/api.zh.json index 7ce3a4a808..e99259b486 100644 --- a/site/content/reference/api.zh.json +++ b/site/content/reference/api.zh.json @@ -2116,6 +2116,7 @@ "children": "按插入顺序排列的已挂载子节点。返回的列表不可修改。", "add": "把若干对象变成这个节点的子节点。", "remove": "把若干子节点从这个节点上移除。", + "attach": "在 `child` 所在的位置添加它(`SceneNode.attach`)。", "destroy": "立即释放该节点及其下所有节点,而不是等到它们被回收。", "removeFromParent": "把该节点从它的父节点上移除。", "clone": "同类型、同值的节点;除非另有指示,其子节点也会被复制。", @@ -3030,6 +3031,7 @@ "remove": "移除一个子节点。", "clear": "移除所有子节点。", "reparent": "把它移到另一个父节点之下。", + "attach": "在子节点所在的位置添加它:保留其世界矩阵,重写其局部姿态。", "clone": "一份副本,连同子节点一起复制。", "copy": "复制数值和后代节点;递归的祖先—后代重叠关系会被原子性地拒绝。", "setPosition": "设置它的位置。", From d525d0e5c66c78f2de47a5ba92336569b26223ce Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:26:25 +0200 Subject: [PATCH 04/10] test(sdk): pin the core facade bundle after the attach scratch (#758) --- tests/integration/sdk-facade.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/sdk-facade.test.ts b/tests/integration/sdk-facade.test.ts index d08b38ed29..70d8c7610e 100644 --- a/tests/integration/sdk-facade.test.ts +++ b/tests/integration/sdk-facade.test.ts @@ -161,7 +161,7 @@ test('a maths-only bundle keeps baseline bytes and excludes platform modules', a const inputs = Object.keys(proposed.metafile.inputs); assert.ok(inputs.some((path) => path.endsWith('/math/batch/batch.ts'))); assert.ok(!inputs.some((path) => path.includes('/sdk-browser/') || path.includes('/sdk-node/'))); - assert.equal(baseline.outputFiles[0].contents.length, 5_082); + assert.equal(baseline.outputFiles[0].contents.length, 5_203); assert.equal(proposed.outputFiles[0].contents.length, 1_780); assert.equal(browserProposed.outputFiles[0].contents.length, 3_329); assert.ok( From f3cf3b691eee45158131ee2335845c2c5fafc26d Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:35:37 +0200 Subject: [PATCH 05/10] refactor(sdk): attach moves out of the node, the maths-only bundle keeps its size (#758) --- .../src/math/transform-tree/attach.ts | 45 ------------------- packages/sdk-core/src/scene/core/node.test.ts | 4 +- packages/sdk-core/src/scene/core/node.ts | 39 ++++++++-------- .../src/scene/core/nodeAttach.fixture.ts | 7 +++ .../sdk-core/src/scene/core/nodeAttach.ts | 30 +++++++++++++ packages/sdk-core/src/scene/core/nodeError.ts | 11 +++++ .../src/world/object/object3d.test.ts | 8 ++-- .../sdk-core/src/world/object/object3d.ts | 39 ++++++---------- .../sdk-core/src/world/object/objectPose.ts | 41 +++++++++++++++++ tests/integration/sdk-facade.test.ts | 2 +- 10 files changed, 129 insertions(+), 97 deletions(-) delete mode 100644 packages/sdk-core/src/math/transform-tree/attach.ts create mode 100644 packages/sdk-core/src/scene/core/nodeAttach.fixture.ts create mode 100644 packages/sdk-core/src/scene/core/nodeAttach.ts create mode 100644 packages/sdk-core/src/world/object/objectPose.ts diff --git a/packages/sdk-core/src/math/transform-tree/attach.ts b/packages/sdk-core/src/math/transform-tree/attach.ts deleted file mode 100644 index 75f5b4ab1c..0000000000 --- a/packages/sdk-core/src/math/transform-tree/attach.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { multiplyMatrix4 } from '../matrix/matrix4.ts'; -import { invertMatrix4 } from '../matrix/matrix4Inverse.ts'; -import { decomposeMatrix4 } from '../matrix/matrix4Trs.ts'; -import { - setNodeLocalMatrix, - setNodePosition, - setNodeQuaternion, - setNodeScale, - type TransformTree, -} from './transformTree.ts'; -import { updateNodeWorldMatrix } from './update.ts'; - -/** Scratch values of the two functions below: neither allocates. */ -const inverse = new Float64Array(16), - local = new Float64Array(16), - position = new Float64Array(3), - quaternion = new Float64Array(4), - scale = new Float64Array(3); - -/** - * The local matrix that keeps `node` where it stands in the world once it is under `parent`, as - * the reference `attach` computes it: the inverse of `parent`'s world matrix times `node`'s, both - * resolved first. A parent scaled to zero inverts to the zero matrix, as with the reference. The - * result is a scratch the next call overwrites. - */ -export function nodeLocalUnder(tree: TransformTree, node: number, parent: number) { - updateNodeWorldMatrix(tree, node, true, false); - updateNodeWorldMatrix(tree, parent, true, false); - invertMatrix4(inverse, tree.worldViews[parent]); - multiplyMatrix4(local, inverse, tree.worldViews[node]); - return local; -} - -/** - * Writes `m` as `node`'s local pose: its local matrix, and the position, rotation and scale read - * out of it, which an automatic update recomposes. A sheared `m` loses its shear there, as with - * the reference. - */ -export function setNodeLocalPose(tree: TransformTree, node: number, m: ArrayLike) { - decomposeMatrix4(m, position, quaternion, scale); - setNodePosition(tree, node, position[0], position[1], position[2]); - setNodeQuaternion(tree, node, quaternion[0], quaternion[1], quaternion[2], quaternion[3]); - setNodeScale(tree, node, scale[0], scale[1], scale[2]); - setNodeLocalMatrix(tree, node, m); -} diff --git a/packages/sdk-core/src/scene/core/node.test.ts b/packages/sdk-core/src/scene/core/node.test.ts index 8cc1bf0c97..ee6f5437ab 100644 --- a/packages/sdk-core/src/scene/core/node.test.ts +++ b/packages/sdk-core/src/scene/core/node.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { SCENE_MODEL_VERSION, type SceneNode } from './node.ts'; import { createSceneRoot } from './root.ts'; +import { assertClose } from './nodeAttach.fixture.ts'; const hasCode = (code: string) => (error: unknown) => (error as { code?: string }).code === code; @@ -151,9 +152,6 @@ test('adding children copies nothing: the frozen list is made once, when it is r assert.equal(parent.children, parent.children, 'the list is kept until the next change'); }); -const assertClose = (actual: ArrayLike, expected: ArrayLike) => - Array.from(expected, (value, i) => assert.ok(Math.abs(actual[i] - value) < 1e-12, `[${i}]`)); - test('attach moves a child under another parent where it stands in the world', () => { const root = createSceneRoot(); const s = Math.SQRT1_2; diff --git a/packages/sdk-core/src/scene/core/node.ts b/packages/sdk-core/src/scene/core/node.ts index bc915df10f..b1a55c7154 100644 --- a/packages/sdk-core/src/scene/core/node.ts +++ b/packages/sdk-core/src/scene/core/node.ts @@ -7,10 +7,10 @@ import { setNodeScale, } from '../../math/transform-tree/transformTree.ts'; import { removeTransformNode, reparentTransformNode } from '../../math/transform-tree/structure.ts'; -import { nodeLocalUnder, setNodeLocalPose } from '../../math/transform-tree/attach.ts'; import { updateNodeWorldMatrix } from '../../math/transform-tree/update.ts'; +import { attachSceneNode } from './nodeAttach.ts'; import { copySceneNodeState } from './nodeCopy.ts'; -import { sceneNodeFail, sceneNodeVisibility } from './nodeError.ts'; +import { refuseSceneRoot, sceneNodeFail, sceneNodeVisibility } from './nodeError.ts'; import type { SceneNodeOptions, SceneState } from './nodeContracts.ts'; import type { SceneRoot } from './root.ts'; @@ -68,18 +68,18 @@ export class SceneNode { this.assertAlive(); return this.state.tree.worldViews[this.index]; } + /** Adds a child. */ add(child: SceneNode) { this.assertCompatible(child); - if (child.index === this.state.root?.index) - sceneNodeFail('SCENE_ROOT_PARENT', 'A scene root cannot be reparented', {}); - const previous = child.parent; + refuseSceneRoot(this.state, child.index); reparentTransformNode(this.state.tree, child.index, this.index); - previous?.detachChild(child); + child.parentNode?.detachChild(child); this.childNodes.push(child); this.childView = null; child.parentNode = this; return this; } + /** Removes a child. */ remove(child: SceneNode) { this.assertCompatible(child); if (child.parent !== this) return this; @@ -87,29 +87,25 @@ export class SceneNode { this.detachChild(child); return this; } + /** Removes every child. */ clear() { this.assertAlive(); for (let i = this.childNodes.length - 1; i >= 0; i--) this.remove(this.childNodes[i]); return this; } + /** Moves it under another parent. */ reparent(parent: SceneNode | null) { this.assertAlive(); - if (this.index === this.state.root?.index) - sceneNodeFail('SCENE_ROOT_PARENT', 'A scene root cannot be reparented', {}); + refuseSceneRoot(this.state, this.index); if (parent) parent.add(this); else this.parent?.remove(this); return this; } - /** Adds a child where it stands: its world matrix is kept, its local pose rewritten. */ - attach(child: SceneNode) { - this.assertCompatible(child); - const local = nodeLocalUnder(this.state.tree, child.index, this.index); - this.add(child); // An Object3D skips itself, which then keeps its pose. - if (child.parent !== this) return this; - setNodeLocalPose(this.state.tree, child.index, local); - child.updateWorldMatrix(false, true); - return this; + + /** Adds a child where it stands: its world matrix is kept. */ attach(child: SceneNode) { + return attachSceneNode(this, child); } + /** A copy, children too. */ clone(recursive = true, options: SceneNodeOptions = {}) { this.assertAlive(); const clone = this.root.createNode(options); @@ -124,31 +120,37 @@ export class SceneNode { copySceneNodeState(this.state, this, source, recursive); return this; } + /** Sets its position. */ setPosition(x: number, y: number, z: number) { this.assertAlive(); setNodePosition(this.state.tree, this.index, x, y, z); return this; } + /** Sets its rotation. */ setQuaternion(x: number, y: number, z: number, w: number) { this.assertAlive(); setNodeQuaternion(this.state.tree, this.index, x, y, z, w); return this; } + /** Sets its size. */ setScale(x: number, y: number, z: number) { this.assertAlive(); setNodeScale(this.state.tree, this.index, x, y, z); return this; } + /** Sets its local matrix. */ setLocalMatrix(matrix: ArrayLike) { this.assertAlive(); setNodeLocalMatrix(this.state.tree, this.index, matrix); return this; } + /** Whether its matrix follows its pose. */ setAutoUpdate(auto: boolean) { this.assertAlive(); setNodeAutoUpdate(this.state.tree, this.index, auto); return this; } + /** Updates its world matrix. */ updateWorldMatrix(updateParents = true, updateChildren = true) { this.assertAlive(); updateNodeWorldMatrix(this.state.tree, this.index, updateParents, updateChildren); @@ -158,8 +160,7 @@ export class SceneNode { /** Permanently invalidates this handle and every descendant. */ destroy() { this.assertAlive(); - if (this.index === this.state.root?.index) - sceneNodeFail('SCENE_ROOT_DESTROY', 'A scene root cannot be destroyed', {}); + refuseSceneRoot(this.state, this.index, true); this.parent?.detachChild(this); this.invalidate(); removeTransformNode(this.state.tree, this.index); diff --git a/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts b/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts new file mode 100644 index 0000000000..712c23f620 --- /dev/null +++ b/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts @@ -0,0 +1,7 @@ +import assert from 'node:assert/strict'; + +/** `actual` equals `expected` number by number, within the float error of an attach's product. */ +export const assertClose = (actual: ArrayLike, expected: ArrayLike, what = '') => + Array.from(expected, (value, i) => + assert.ok(Math.abs(actual[i] - value) < 1e-12, `${what}[${i}]: ${actual[i]} vs ${value}`), + ); diff --git a/packages/sdk-core/src/scene/core/nodeAttach.ts b/packages/sdk-core/src/scene/core/nodeAttach.ts new file mode 100644 index 0000000000..84a62044e8 --- /dev/null +++ b/packages/sdk-core/src/scene/core/nodeAttach.ts @@ -0,0 +1,30 @@ +import { multiplyMatrix4 } from '../../math/matrix/matrix4.ts'; +import { invertMatrix4 } from '../../math/matrix/matrix4Inverse.ts'; +import { decomposeMatrix4 } from '../../math/matrix/matrix4Trs.ts'; +import type { SceneNode } from './node.ts'; + +/** Scratch of `attachSceneNode`, which allocates nothing; a bundle that never attaches drops it. */ +const local = /* @__PURE__ */ new Float64Array(16), + position = /* @__PURE__ */ new Float64Array(3), + quaternion = /* @__PURE__ */ new Float64Array(4), + scale = /* @__PURE__ */ new Float64Array(3); + +/** + * `parent.attach(child)`, as the reference computes it: the child's new local matrix is the inverse + * of `parent`'s world matrix times its own, both resolved first, so its world matrix is kept. Its + * position, rotation and scale are read out of that matrix: a sheared one loses its shear there, + * and a parent scaled to zero inverts to the zero matrix, both as with the reference. The matrix + * itself is written too, sixteen numbers: under manual update it is the pose. When `parent`'s + * `add` declines the child, as a subclass's may, the child's pose is left as it was. + */ +export function attachSceneNode(parent: T, child: SceneNode): T { + invertMatrix4(local, parent.updateWorldMatrix(true, false).worldMatrix); + multiplyMatrix4(local, local, child.updateWorldMatrix(true, false).worldMatrix as Float64Array); + parent.add(child); + if (child.parent !== parent) return parent; + decomposeMatrix4(local, position, quaternion, scale); + child.setPosition(position[0], position[1], position[2]); + child.setQuaternion(quaternion[0], quaternion[1], quaternion[2], quaternion[3]); + child.setScale(scale[0], scale[1], scale[2]).setLocalMatrix(local).updateWorldMatrix(false, true); + return parent; +} diff --git a/packages/sdk-core/src/scene/core/nodeError.ts b/packages/sdk-core/src/scene/core/nodeError.ts index 15154d867c..0a70bc7e18 100644 --- a/packages/sdk-core/src/scene/core/nodeError.ts +++ b/packages/sdk-core/src/scene/core/nodeError.ts @@ -1,4 +1,5 @@ import { EngineError } from '../../contracts/cache.ts'; +import type { SceneState } from './nodeContracts.ts'; export function sceneNodeFail( code: string, @@ -15,3 +16,13 @@ export function sceneNodeVisibility(value: boolean | undefined, allowDefault = t }); return value ?? true; } + +/** Refuses to reparent (`SCENE_ROOT_PARENT`) or destroy (`SCENE_ROOT_DESTROY`) a scene's root. */ +export function refuseSceneRoot(state: SceneState, index: number, destroyed = false) { + if (index === state.root?.index) + sceneNodeFail( + destroyed ? 'SCENE_ROOT_DESTROY' : 'SCENE_ROOT_PARENT', + `A scene root cannot be ${destroyed ? 'destroyed' : 'reparented'}`, + {}, + ); +} diff --git a/packages/sdk-core/src/world/object/object3d.test.ts b/packages/sdk-core/src/world/object/object3d.test.ts index 8e41fffe70..dac6fff4f7 100644 --- a/packages/sdk-core/src/world/object/object3d.test.ts +++ b/packages/sdk-core/src/world/object/object3d.test.ts @@ -6,6 +6,7 @@ import { Camera } from '../camera/camera.ts'; import { Mesh } from './mesh.ts'; import { cloneObject } from './clone.ts'; import { Matrix4 } from '../math/matrix4.ts'; +import { assertClose } from '../../scene/core/nodeAttach.fixture.ts'; import { Quaternion } from '../math/quaternion.ts'; // Re-deriving Euler angles from the quaternion would swap (0, y, 0) past ±90° for the equivalent @@ -87,11 +88,12 @@ test('attach keeps the world matrix, and position, rotation and scale hold the n const world = node.matrixWorld.clone(); assert.equal(to.attach(node), to); assert.equal(node.parent, to); - const close = (m: Matrix4) => m.elements.every((v, i) => Math.abs(v - world.elements[i]) < 1e-12); node.updateMatrixWorld(true); - assert.ok(close(node.matrixWorld), 'its world matrix, kept'); + assertClose(node.matrixWorld.elements, world.elements, 'its world matrix, kept'); const posed = new Matrix4().compose(node.position, node.quaternion, node.scale); - assert.ok(close(posed.premultiply(to.matrixWorld)), 'its fields, rewritten'); + assertClose(posed.premultiply(to.matrixWorld).elements, world.elements, 'its values, rewritten'); + to.attach(to); + assert.ok(to.parent === null && to.position.x === -4, 'attached to itself: declined, untouched'); const turn = new Quaternion().setFromEuler(node.rotation); assert.ok(Math.abs(Math.abs(turn.dot(node.quaternion)) - 1) < 1e-12, 'its angles follow'); }); diff --git a/packages/sdk-core/src/world/object/object3d.ts b/packages/sdk-core/src/world/object/object3d.ts index a968a21298..c3a9210bda 100644 --- a/packages/sdk-core/src/world/object/object3d.ts +++ b/packages/sdk-core/src/world/object/object3d.ts @@ -1,13 +1,13 @@ import { TransformNode } from './transformNode.ts'; import { collectSlot, reserveSlot, uncollectSlot } from './objectSpace.ts'; import { copyObject } from './objectCopy.ts'; +import { bindPose, readPose } from './objectPose.ts'; import { lookAtNode } from '../../math/transform-tree/lookAt.ts'; import * as read from '../../math/transform-tree/read.ts'; import { Vector3 } from '../math/vector3.ts'; import { Euler } from '../math/euler.ts'; import { Quaternion } from '../math/quaternion.ts'; import { Matrix4 } from '../math/matrix4.ts'; -import { listen } from '../math/observed.ts'; import type { Box3 } from '../math/box3.ts'; import type { SceneLink } from './sceneLink.ts'; export type { SceneLink } from './sceneLink.ts'; @@ -35,30 +35,13 @@ export class Object3D extends TransformNode { /** Drawing order among see-through things. */ renderOrder = 0; /** Whether a renderer may skip it outside the view. */ frustumCulled = true; /** Free room for the page's own data. */ userData: Record = {}; - /** The world that draws it, or null outside one. */ _link: SceneLink | null = null; + /** The world this node is drawn by; set on attach, cleared on detach. */ + _link: SceneLink | null = null; constructor() { const slot = reserveSlot(); super(slot.state, slot.id, slot.index, slot.visible); collectSlot(this, slot); - const pose = () => this._link?.pose(this); - listen(this.position, () => { - this.setPosition(this.position.x, this.position.y, this.position.z); - pose(); - }); - listen(this.scale, () => { - this.setScale(this.scale.x, this.scale.y, this.scale.z); - pose(); - }); - /** Written angles stay as written (`object3d.test.ts`); a quaternion write re-derives them. */ - const turned = (fromAngles: boolean) => { - const q = fromAngles ? this.quaternion.setFromEuler(this.rotation, true) : this.quaternion; - this.setQuaternion(q.x, q.y, q.z, q.w); - if (fromAngles) this.rotation._follow(q); - pose(); - }; - this.rotation._follow(this.quaternion); - listen(this.quaternion, () => turned(false)); - listen(this.rotation, () => turned(true)); + bindPose(this); } /** A node's transform tree, for an owner placing nodes by the thousand (`_link.posed`). */ static _treeOf(node: Object3D) { @@ -77,7 +60,8 @@ export class Object3D extends TransformNode { override get children(): readonly Object3D[] { return super.children as readonly Object3D[]; } - /** A viewer looks down `-z`: cameras and lights say so. */ protected get looksDownNegativeZ() { + /** A viewer looks down `-z`: cameras and lights say so. */ + protected get looksDownNegativeZ() { return false; } /** Makes objects children of this node. */ override add(...objects: Object3D[]) { @@ -98,9 +82,10 @@ export class Object3D extends TransformNode { this._link?.structure(this); return this; } - /** Adds `child` where it stands (`SceneNode.attach`). */ override attach(child: Object3D) { + /** Adds `child` where it stands (`SceneNode.attach`). */ + override attach(child: Object3D) { super.attach(child); - applied.fromArray(child.localMatrix).decompose(child.position, child.quaternion, child.scale); + if (child.parent === this) readPose(child, this.state.tree); return this; } /** Frees the node and all below it now, rather than when they are collected. */ @@ -146,7 +131,8 @@ export class Object3D extends TransformNode { } return undefined; } - /** Composes the local matrix from the pose. */ updateMatrix() { + /** Composes the local matrix from the pose. */ + updateMatrix() { this.matrix.compose(this.position, this.quaternion, this.scale); this.matrixWorldNeedsUpdate = true; } @@ -194,7 +180,8 @@ export class Object3D extends TransformNode { } } -/** A node that only groups others. */ export class Group extends Object3D { +/** A node that only groups others. */ +export class Group extends Object3D { /** Always `true`: tells a group apart. */ readonly isGroup = true as const; /** The kind of node, `'Group'`. */ override type = 'Group'; } diff --git a/packages/sdk-core/src/world/object/objectPose.ts b/packages/sdk-core/src/world/object/objectPose.ts new file mode 100644 index 0000000000..266185d1d8 --- /dev/null +++ b/packages/sdk-core/src/world/object/objectPose.ts @@ -0,0 +1,41 @@ +import { listen } from '../math/observed.ts'; +import type { TransformTree } from '../../math/transform-tree/transformTree.ts'; +import type { Object3D } from './object3d.ts'; + +/** Every write to `node`'s position, rotation, quaternion or scale lands in its slot of the + * transform tree and reaches the world that draws it. */ +export function bindPose(node: Object3D) { + const pose = () => node._link?.pose(node); + listen(node.position, () => { + node.setPosition(node.position.x, node.position.y, node.position.z); + pose(); + }); + listen(node.scale, () => { + node.setScale(node.scale.x, node.scale.y, node.scale.z); + pose(); + }); + /** Written angles stay as written (`object3d.test.ts`); a quaternion write re-derives them. */ + const turned = (fromAngles: boolean) => { + const q = fromAngles ? node.quaternion.setFromEuler(node.rotation, true) : node.quaternion; + node.setQuaternion(q.x, q.y, q.z, q.w); + if (fromAngles) node.rotation._follow(q); + pose(); + }; + node.rotation._follow(node.quaternion); + listen(node.quaternion, () => turned(false)); + listen(node.rotation, () => turned(true)); +} + +/** The other way, after the tree was written first (`attach`): `node`'s values take its slot's + * pose quietly, the angles following the quaternion when read, and the world hears it once. */ +export function readPose(node: Object3D, tree: TransformTree) { + const { position, quaternion, scale } = tree, + at = node.index * 3, + q = node.index * 4; + for (let i = 0; i < 3; i++) { + node.position.elements[i] = position[at + i]; + node.scale.elements[i] = scale[at + i]; + } + node.quaternion.set(quaternion[q], quaternion[q + 1], quaternion[q + 2], quaternion[q + 3], true); + node._link?.pose(node); +} diff --git a/tests/integration/sdk-facade.test.ts b/tests/integration/sdk-facade.test.ts index 70d8c7610e..d08b38ed29 100644 --- a/tests/integration/sdk-facade.test.ts +++ b/tests/integration/sdk-facade.test.ts @@ -161,7 +161,7 @@ test('a maths-only bundle keeps baseline bytes and excludes platform modules', a const inputs = Object.keys(proposed.metafile.inputs); assert.ok(inputs.some((path) => path.endsWith('/math/batch/batch.ts'))); assert.ok(!inputs.some((path) => path.includes('/sdk-browser/') || path.includes('/sdk-node/'))); - assert.equal(baseline.outputFiles[0].contents.length, 5_203); + assert.equal(baseline.outputFiles[0].contents.length, 5_082); assert.equal(proposed.outputFiles[0].contents.length, 1_780); assert.equal(browserProposed.outputFiles[0].contents.length, 3_329); assert.ok( From 16e749098fb24ec08a43d8c4f52b0e62f79ea6cb Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:41:12 +0200 Subject: [PATCH 06/10] fix(sdk): the editor's add command is named apart from attach, the refused root action named (#758) --- packages/sdk-core/src/scene/core/node.ts | 2 +- packages/sdk-core/src/scene/core/nodeError.ts | 10 +++++++--- site/app/editor/actions.ts | 14 ++++---------- site/app/editor/commands.ts | 2 +- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/sdk-core/src/scene/core/node.ts b/packages/sdk-core/src/scene/core/node.ts index b1a55c7154..ddd757528d 100644 --- a/packages/sdk-core/src/scene/core/node.ts +++ b/packages/sdk-core/src/scene/core/node.ts @@ -160,7 +160,7 @@ export class SceneNode { /** Permanently invalidates this handle and every descendant. */ destroy() { this.assertAlive(); - refuseSceneRoot(this.state, this.index, true); + refuseSceneRoot(this.state, this.index, 'destroyed'); this.parent?.detachChild(this); this.invalidate(); removeTransformNode(this.state.tree, this.index); diff --git a/packages/sdk-core/src/scene/core/nodeError.ts b/packages/sdk-core/src/scene/core/nodeError.ts index 0a70bc7e18..a1c4fd0632 100644 --- a/packages/sdk-core/src/scene/core/nodeError.ts +++ b/packages/sdk-core/src/scene/core/nodeError.ts @@ -18,11 +18,15 @@ export function sceneNodeVisibility(value: boolean | undefined, allowDefault = t } /** Refuses to reparent (`SCENE_ROOT_PARENT`) or destroy (`SCENE_ROOT_DESTROY`) a scene's root. */ -export function refuseSceneRoot(state: SceneState, index: number, destroyed = false) { +export function refuseSceneRoot( + state: SceneState, + index: number, + refused: 'reparented' | 'destroyed' = 'reparented', +) { if (index === state.root?.index) sceneNodeFail( - destroyed ? 'SCENE_ROOT_DESTROY' : 'SCENE_ROOT_PARENT', - `A scene root cannot be ${destroyed ? 'destroyed' : 'reparented'}`, + refused === 'destroyed' ? 'SCENE_ROOT_DESTROY' : 'SCENE_ROOT_PARENT', + `A scene root cannot be ${refused}`, {}, ); } diff --git a/site/app/editor/actions.ts b/site/app/editor/actions.ts index 69666aa9c8..0bc13ed242 100644 --- a/site/app/editor/actions.ts +++ b/site/app/editor/actions.ts @@ -1,11 +1,5 @@ import type { Object3D } from '../../../packages/sdk-browser/src/index.ts'; -import { - attachCommand, - isWithin, - removeCommand, - reparentCommand, - valueCommand, -} from './commands.ts'; +import { addCommand, isWithin, removeCommand, reparentCommand, valueCommand } from './commands.ts'; import { build, type AddKind } from './objects.ts'; import type { Session } from './session.ts'; import { buildStarter, type StarterNames } from './starter.ts'; @@ -30,7 +24,7 @@ export function sceneActions( return { add(kind: AddKind) { const node = build(engine, kind, name(kind)); - session.run(attachCommand(node, scene)); + session.run(addCommand(node, scene)); session.select(node); }, remove() { @@ -42,7 +36,7 @@ export function sceneActions( const node = session.selected; const copy = node?.parent && engine.object.clone(node); if (!node?.parent || !copy) return; - session.run(attachCommand(copy, node.parent)); + session.run(addCommand(copy, node.parent)); session.select(copy); }, /** Moves `node` under `parent`, keeping where it stands; never under itself. */ @@ -76,7 +70,7 @@ export function sceneActions( try { const model = await scene.load(url); model.name = label; - session.record(attachCommand(model, scene)); + session.record(addCommand(model, scene)); session.select(model); } catch (error) { failed(error); diff --git a/site/app/editor/commands.ts b/site/app/editor/commands.ts index ef87e914ea..0cce992765 100644 --- a/site/app/editor/commands.ts +++ b/site/app/editor/commands.ts @@ -34,7 +34,7 @@ export const poseCommand = (object: Object3D, before: Pose, after: Pose) => valueCommand((pose: Pose) => applyPose(object, pose), before, after); /** `object` put under `parent`: an object added. */ -export const attachCommand = (object: Object3D, parent: Object3D): Command => ({ +export const addCommand = (object: Object3D, parent: Object3D): Command => ({ undo: () => parent.remove(object), redo: () => parent.add(object), }); From cd05303146df822c97b621fe7f52ba513f16334a Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:48:20 +0200 Subject: [PATCH 07/10] refactor(sdk): lookAt reads the pose back through readPose, attach declines only itself, the closeness fixture checks lengths (#758) --- .../sdk-core/src/scene/core/nodeAttach.fixture.ts | 12 ++++++++---- packages/sdk-core/src/world/object/object3d.ts | 5 +++-- packages/sdk-core/src/world/object/objectPose.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts b/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts index 712c23f620..81c697848b 100644 --- a/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts +++ b/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; /** `actual` equals `expected` number by number, within the float error of an attach's product. */ -export const assertClose = (actual: ArrayLike, expected: ArrayLike, what = '') => - Array.from(expected, (value, i) => - assert.ok(Math.abs(actual[i] - value) < 1e-12, `${what}[${i}]: ${actual[i]} vs ${value}`), - ); +export function assertClose(actual: ArrayLike, expected: ArrayLike, what = '') { + assert.equal(actual.length, expected.length, `${what}: length`); + for (let i = 0; i < expected.length; i++) + assert.ok( + Math.abs(actual[i] - expected[i]) < 1e-12, + `${what}[${i}]: ${actual[i]} vs ${expected[i]}`, + ); +} diff --git a/packages/sdk-core/src/world/object/object3d.ts b/packages/sdk-core/src/world/object/object3d.ts index c3a9210bda..ed3d424208 100644 --- a/packages/sdk-core/src/world/object/object3d.ts +++ b/packages/sdk-core/src/world/object/object3d.ts @@ -84,8 +84,9 @@ export class Object3D extends TransformNode { } /** Adds `child` where it stands (`SceneNode.attach`). */ override attach(child: Object3D) { + if (child === this) return this; super.attach(child); - if (child.parent === this) readPose(child, this.state.tree); + readPose(child, this.state.tree); return this; } /** Frees the node and all below it now, rather than when they are collected. */ @@ -148,7 +149,7 @@ export class Object3D extends TransformNode { else aim.copy(x); const tree = this.state.tree; lookAtNode(tree, this.index, aim.x, aim.y, aim.z, this.up.elements, this.looksDownNegativeZ); - this.quaternion.fromArray(tree.quaternion, this.index * 4); + readPose(this, tree); } /** Turns the node around `axis` by `angle` radians. */ rotateOnAxis(axis: { x: number; y: number; z: number }, angle: number) { diff --git a/packages/sdk-core/src/world/object/objectPose.ts b/packages/sdk-core/src/world/object/objectPose.ts index 266185d1d8..ea4608b4fb 100644 --- a/packages/sdk-core/src/world/object/objectPose.ts +++ b/packages/sdk-core/src/world/object/objectPose.ts @@ -26,7 +26,7 @@ export function bindPose(node: Object3D) { listen(node.rotation, () => turned(true)); } -/** The other way, after the tree was written first (`attach`): `node`'s values take its slot's +/** The other way, after the tree was written first (`attach`, `lookAt`): `node`'s values take its slot's * pose quietly, the angles following the quaternion when read, and the world hears it once. */ export function readPose(node: Object3D, tree: TransformTree) { const { position, quaternion, scale } = tree, From 9bcf2b7c0a956e01f614b51b43faeb15d09fd61d Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:53:32 +0200 Subject: [PATCH 08/10] fix(sdk): attach composes the new local pose in the reference's order (#758) --- packages/sdk-core/src/scene/core/nodeAttach.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/sdk-core/src/scene/core/nodeAttach.ts b/packages/sdk-core/src/scene/core/nodeAttach.ts index 84a62044e8..80cb59afb8 100644 --- a/packages/sdk-core/src/scene/core/nodeAttach.ts +++ b/packages/sdk-core/src/scene/core/nodeAttach.ts @@ -10,16 +10,18 @@ const local = /* @__PURE__ */ new Float64Array(16), scale = /* @__PURE__ */ new Float64Array(3); /** - * `parent.attach(child)`, as the reference computes it: the child's new local matrix is the inverse - * of `parent`'s world matrix times its own, both resolved first, so its world matrix is kept. Its - * position, rotation and scale are read out of that matrix: a sheared one loses its shear there, - * and a parent scaled to zero inverts to the zero matrix, both as with the reference. The matrix - * itself is written too, sixteen numbers: under manual update it is the pose. When `parent`'s - * `add` declines the child, as a subclass's may, the child's pose is left as it was. + * `parent.attach(child)`, as the reference computes it and in its order: the inverse of `parent`'s + * world matrix times the former parent's, times the child's local matrix, all resolved first, so + * its world matrix is kept. Its position, rotation and scale are read out of that matrix: a sheared + * one loses its shear there, and a parent scaled to zero inverts to the zero matrix, both as with + * the reference. The matrix itself is written too, sixteen numbers: under manual update it is the + * pose. When `parent`'s `add` declines the child, as a subclass's may, its pose is left as it was. */ export function attachSceneNode(parent: T, child: SceneNode): T { invertMatrix4(local, parent.updateWorldMatrix(true, false).worldMatrix); - multiplyMatrix4(local, local, child.updateWorldMatrix(true, false).worldMatrix as Float64Array); + const from = child.updateWorldMatrix(true, false).parent; + if (from) multiplyMatrix4(local, local, from.worldMatrix as Float64Array); + multiplyMatrix4(local, local, child.localMatrix as Float64Array); parent.add(child); if (child.parent !== parent) return parent; decomposeMatrix4(local, position, quaternion, scale); From 6e9473d8a56f1b4bf6320fd76182ed22ffa6bda2 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 08:59:26 +0200 Subject: [PATCH 09/10] test(sdk): attach matches the reference's order to the bit, in both update modes (#758) --- .../src/scene/core/nodeAttach.fixture.ts | 12 +++++-- .../src/world/object/object3d.test.ts | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts b/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts index 81c697848b..71e7dd0a9e 100644 --- a/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts +++ b/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts @@ -1,11 +1,17 @@ import assert from 'node:assert/strict'; -/** `actual` equals `expected` number by number, within the float error of an attach's product. */ -export function assertClose(actual: ArrayLike, expected: ArrayLike, what = '') { +/** `actual` equals `expected` number by number, within the float error of an attach's product + * by default, or to the bit with a `tolerance` of 0. */ +export function assertClose( + actual: ArrayLike, + expected: ArrayLike, + what = '', + tolerance = 1e-12, +) { assert.equal(actual.length, expected.length, `${what}: length`); for (let i = 0; i < expected.length; i++) assert.ok( - Math.abs(actual[i] - expected[i]) < 1e-12, + Math.abs(actual[i] - expected[i]) <= tolerance, `${what}[${i}]: ${actual[i]} vs ${expected[i]}`, ); } diff --git a/packages/sdk-core/src/world/object/object3d.test.ts b/packages/sdk-core/src/world/object/object3d.test.ts index dac6fff4f7..18cf19ac5f 100644 --- a/packages/sdk-core/src/world/object/object3d.test.ts +++ b/packages/sdk-core/src/world/object/object3d.test.ts @@ -8,6 +8,9 @@ import { cloneObject } from './clone.ts'; import { Matrix4 } from '../math/matrix4.ts'; import { assertClose } from '../../scene/core/nodeAttach.fixture.ts'; import { Quaternion } from '../math/quaternion.ts'; +import { multiplyMatrix4 } from '../../math/matrix/matrix4.ts'; +import { invertMatrix4 } from '../../math/matrix/matrix4Inverse.ts'; +import { decomposeMatrix4 } from '../../math/matrix/matrix4Trs.ts'; // Re-deriving Euler angles from the quaternion would swap (0, y, 0) past ±90° for the equivalent // (π, π − y, π); a later one-axis write would then keep x = z = π and turn the node another way. @@ -97,3 +100,31 @@ test('attach keeps the world matrix, and position, rotation and scale hold the n const turn = new Quaternion().setFromEuler(node.rotation); assert.ok(Math.abs(Math.abs(turn.dot(node.quaternion)) - 1) < 1e-12, 'its angles follow'); }); + +test("attach gives the reference's pose to the bit: new parent's inverse × old parent × local", () => { + const product = new Float64Array(16); + const [p, q, s] = [new Float64Array(3), new Float64Array(4), new Float64Array(3)]; + const wave = (k: number) => Math.sin(k * 12.9898) * 3; // unrounded values, fixed from run to run + for (let i = 0; i < 16; i++) { + const [from, to, node] = [new Group(), new Group(), new Object3D()]; + let k = i * 27 + 1; + for (const object of [from, to, node]) { + object.position.set(wave(k++), wave(k++), wave(k++)); + object.rotation.set(wave(k++), wave(k++), wave(k++)); + object.scale.set(2 + wave(k++) / 2, 2 + wave(k++) / 2, 2 + wave(k++) / 2); + } + from.add(node); + node.matrixAutoUpdate = i % 2 === 0; // manual update: the matrix is the pose + node.updateMatrix(); + to.updateWorldMatrix(true, false); + node.updateWorldMatrix(true, false); + invertMatrix4(product, to.matrixWorld.elements); + multiplyMatrix4(product, product, from.matrixWorld.elements as Float64Array); + multiplyMatrix4(product, product, node.matrix.elements as Float64Array); + decomposeMatrix4(product, p, q, s); + to.attach(node); + const pose = [...node.position.elements, ...node.quaternion.elements, ...node.scale.elements]; + assertClose(pose, [...p, ...q, ...s], `attach ${i}: position, quaternion, scale`, 0); + if (!node.matrixAutoUpdate) assertClose(node.matrix.elements, product, `attach ${i}`, 0); + } +}); From 911f6709e0ee8b74a1e4f8cdbae56edf86d49ac6 Mon Sep 17 00:00:00 2001 From: Pasquelin Alban Date: Sat, 26 Sep 2026 09:20:17 +0200 Subject: [PATCH 10/10] test(sdk): the attach fixture imports nothing from node, the tests assert its mismatch (#758) --- packages/sdk-core/src/scene/core/node.test.ts | 11 +++++++---- .../src/scene/core/nodeAttach.fixture.ts | 18 +++++++----------- .../sdk-core/src/world/object/object3d.test.ts | 14 +++++++++----- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/sdk-core/src/scene/core/node.test.ts b/packages/sdk-core/src/scene/core/node.test.ts index ee6f5437ab..d9ca2d8723 100644 --- a/packages/sdk-core/src/scene/core/node.test.ts +++ b/packages/sdk-core/src/scene/core/node.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { SCENE_MODEL_VERSION, type SceneNode } from './node.ts'; import { createSceneRoot } from './root.ts'; -import { assertClose } from './nodeAttach.fixture.ts'; +import { mismatch } from './nodeAttach.fixture.ts'; const hasCode = (code: string) => (error: unknown) => (error as { code?: string }).code === code; @@ -164,11 +164,14 @@ test('attach moves a child under another parent where it stands in the world', ( assert.equal(to.attach(child), to); assert.equal(child.parent, to); assert.deepEqual(from.children, []); - assertClose(child.worldMatrix, world); + assert.equal(mismatch(child.worldMatrix, world), null); child.updateWorldMatrix(); - assertClose(child.worldMatrix, world); // the rewritten pose recomposes the same matrix + assert.equal(mismatch(child.worldMatrix, world), null, 'its rewritten pose, recomposed'); from.attach(child); - assertClose(child.localMatrix, [1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0.5, 0, 1, -1, 2, 1]); + assert.equal( + mismatch(child.localMatrix, [1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0.5, 0, 1, -1, 2, 1]), + null, + ); assert.throws(() => child.attach(from), /TRANSFORM_CYCLE|cycle/); assert.equal(from.parent, root); }); diff --git a/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts b/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts index 71e7dd0a9e..1ee2032d89 100644 --- a/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts +++ b/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts @@ -1,17 +1,13 @@ -import assert from 'node:assert/strict'; - -/** `actual` equals `expected` number by number, within the float error of an attach's product - * by default, or to the bit with a `tolerance` of 0. */ -export function assertClose( +/** The first number where `actual` and `expected` differ by more than `tolerance` (the float error + * of an attach's product by default, to the bit with 0), or null when none does: the test asserts. */ +export function mismatch( actual: ArrayLike, expected: ArrayLike, - what = '', tolerance = 1e-12, ) { - assert.equal(actual.length, expected.length, `${what}: length`); + if (actual.length !== expected.length) return `length ${actual.length} vs ${expected.length}`; for (let i = 0; i < expected.length; i++) - assert.ok( - Math.abs(actual[i] - expected[i]) <= tolerance, - `${what}[${i}]: ${actual[i]} vs ${expected[i]}`, - ); + if (!(Math.abs(actual[i] - expected[i]) <= tolerance)) + return `[${i}]: ${actual[i]} vs ${expected[i]}`; + return null; } diff --git a/packages/sdk-core/src/world/object/object3d.test.ts b/packages/sdk-core/src/world/object/object3d.test.ts index 18cf19ac5f..6f2d90e61f 100644 --- a/packages/sdk-core/src/world/object/object3d.test.ts +++ b/packages/sdk-core/src/world/object/object3d.test.ts @@ -6,7 +6,7 @@ import { Camera } from '../camera/camera.ts'; import { Mesh } from './mesh.ts'; import { cloneObject } from './clone.ts'; import { Matrix4 } from '../math/matrix4.ts'; -import { assertClose } from '../../scene/core/nodeAttach.fixture.ts'; +import { mismatch } from '../../scene/core/nodeAttach.fixture.ts'; import { Quaternion } from '../math/quaternion.ts'; import { multiplyMatrix4 } from '../../math/matrix/matrix4.ts'; import { invertMatrix4 } from '../../math/matrix/matrix4Inverse.ts'; @@ -92,9 +92,13 @@ test('attach keeps the world matrix, and position, rotation and scale hold the n assert.equal(to.attach(node), to); assert.equal(node.parent, to); node.updateMatrixWorld(true); - assertClose(node.matrixWorld.elements, world.elements, 'its world matrix, kept'); + assert.equal(mismatch(node.matrixWorld.elements, world.elements), null, 'its world matrix, kept'); const posed = new Matrix4().compose(node.position, node.quaternion, node.scale); - assertClose(posed.premultiply(to.matrixWorld).elements, world.elements, 'its values, rewritten'); + assert.equal( + mismatch(posed.premultiply(to.matrixWorld).elements, world.elements), + null, + 'its values', + ); to.attach(to); assert.ok(to.parent === null && to.position.x === -4, 'attached to itself: declined, untouched'); const turn = new Quaternion().setFromEuler(node.rotation); @@ -124,7 +128,7 @@ test("attach gives the reference's pose to the bit: new parent's inverse × old decomposeMatrix4(product, p, q, s); to.attach(node); const pose = [...node.position.elements, ...node.quaternion.elements, ...node.scale.elements]; - assertClose(pose, [...p, ...q, ...s], `attach ${i}: position, quaternion, scale`, 0); - if (!node.matrixAutoUpdate) assertClose(node.matrix.elements, product, `attach ${i}`, 0); + assert.equal(mismatch(pose, [...p, ...q, ...s], 0), null, `attach ${i}: its values`); + if (!node.matrixAutoUpdate) assert.equal(mismatch(node.matrix.elements, product, 0), null); } });