Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions packages/sdk-core/src/scene/core/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { mismatch } from './nodeAttach.fixture.ts';

const hasCode = (code: string) => (error: unknown) => (error as { code?: string }).code === code;

Expand Down Expand Up @@ -150,3 +151,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');
});

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, []);
assert.equal(mismatch(child.worldMatrix, world), null);
child.updateWorldMatrix();
assert.equal(mismatch(child.worldMatrix, world), null, 'its rewritten pose, recomposed');
from.attach(child);
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);
});
19 changes: 10 additions & 9 deletions packages/sdk-core/src/scene/core/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import {
} from '../../math/transform-tree/transformTree.ts';
import { removeTransformNode, reparentTransformNode } from '../../math/transform-tree/structure.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';

Expand Down Expand Up @@ -70,11 +71,9 @@ export class SceneNode {

/** 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;
Expand All @@ -97,13 +96,16 @@ export class SceneNode {

/** 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. */ attach(child: SceneNode) {
return attachSceneNode(this, child);
}

/** A copy, children too. */ clone(recursive = true, options: SceneNodeOptions = {}) {
this.assertAlive();
const clone = this.root.createNode(options);
Expand Down Expand Up @@ -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, 'destroyed');
this.parent?.detachChild(this);
this.invalidate();
removeTransformNode(this.state.tree, this.index);
Expand Down
13 changes: 13 additions & 0 deletions packages/sdk-core/src/scene/core/nodeAttach.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** 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<number>,
expected: ArrayLike<number>,
tolerance = 1e-12,
) {
if (actual.length !== expected.length) return `length ${actual.length} vs ${expected.length}`;
for (let i = 0; i < expected.length; i++)
if (!(Math.abs(actual[i] - expected[i]) <= tolerance))
return `[${i}]: ${actual[i]} vs ${expected[i]}`;
return null;
}
32 changes: 32 additions & 0 deletions packages/sdk-core/src/scene/core/nodeAttach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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 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<T extends SceneNode>(parent: T, child: SceneNode): T {
invertMatrix4(local, parent.updateWorldMatrix(true, false).worldMatrix);
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);
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;
}
15 changes: 15 additions & 0 deletions packages/sdk-core/src/scene/core/nodeError.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EngineError } from '../../contracts/cache.ts';
import type { SceneState } from './nodeContracts.ts';

export function sceneNodeFail(
code: string,
Expand All @@ -15,3 +16,17 @@ 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,
refused: 'reparented' | 'destroyed' = 'reparented',
) {
if (index === state.root?.index)
sceneNodeFail(
refused === 'destroyed' ? 'SCENE_ROOT_DESTROY' : 'SCENE_ROOT_PARENT',
`A scene root cannot be ${refused}`,
{},
);
}
61 changes: 61 additions & 0 deletions packages/sdk-core/src/world/object/object3d.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ 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 { 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';
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.
Expand Down Expand Up @@ -71,3 +77,58 @@ 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);
node.updateMatrixWorld(true);
assert.equal(mismatch(node.matrixWorld.elements, world.elements), null, 'its world matrix, kept');
const posed = new Matrix4().compose(node.position, node.quaternion, node.scale);
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);
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];
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);
}
});
31 changes: 10 additions & 21 deletions packages/sdk-core/src/world/object/object3d.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -41,25 +41,7 @@ export class Object3D extends TransformNode {
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) {
Expand Down Expand Up @@ -100,6 +82,13 @@ export class Object3D extends TransformNode {
this._link?.structure(this);
return this;
}
/** Adds `child` where it stands (`SceneNode.attach`). */
override attach(child: Object3D) {
if (child === this) return this;
super.attach(child);
readPose(child, this.state.tree);
return this;
}
/** Frees the node and all below it now, rather than when they are collected. */
override destroy() {
this.traverse(uncollectSlot);
Expand Down Expand Up @@ -160,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) {
Expand Down
41 changes: 41 additions & 0 deletions packages/sdk-core/src/world/object/objectPose.ts
Original file line number Diff line number Diff line change
@@ -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`, `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,
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);
}
22 changes: 21 additions & 1 deletion scripts/docs-scene-editor.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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());
});
Loading
Loading