diff --git a/docs/SDK.md b/docs/SDK.md index c11cb28811..4e146f35f9 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/packages/sdk-core/src/scene/core/node.test.ts b/packages/sdk-core/src/scene/core/node.test.ts index d7e80736dc..d9ca2d8723 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 { mismatch } from './nodeAttach.fixture.ts'; const hasCode = (code: string) => (error: unknown) => (error as { code?: string }).code === code; @@ -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); +}); diff --git a/packages/sdk-core/src/scene/core/node.ts b/packages/sdk-core/src/scene/core/node.ts index 99e3e7a8c3..ddd757528d 100644 --- a/packages/sdk-core/src/scene/core/node.ts +++ b/packages/sdk-core/src/scene/core/node.ts @@ -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'; @@ -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; @@ -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); @@ -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); 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..1ee2032d89 --- /dev/null +++ b/packages/sdk-core/src/scene/core/nodeAttach.fixture.ts @@ -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, + expected: ArrayLike, + 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; +} 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..80cb59afb8 --- /dev/null +++ b/packages/sdk-core/src/scene/core/nodeAttach.ts @@ -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(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; +} diff --git a/packages/sdk-core/src/scene/core/nodeError.ts b/packages/sdk-core/src/scene/core/nodeError.ts index 15154d867c..a1c4fd0632 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,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}`, + {}, + ); +} diff --git a/packages/sdk-core/src/world/object/object3d.test.ts b/packages/sdk-core/src/world/object/object3d.test.ts index 36673e8608..6f2d90e61f 100644 --- a/packages/sdk-core/src/world/object/object3d.test.ts +++ b/packages/sdk-core/src/world/object/object3d.test.ts @@ -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. @@ -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); + } +}); diff --git a/packages/sdk-core/src/world/object/object3d.ts b/packages/sdk-core/src/world/object/object3d.ts index 9cc4c820e0..ed3d424208 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'; @@ -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) { @@ -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); @@ -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) { 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..ea4608b4fb --- /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`, `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); +} 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/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 8e3ffa9223..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), }); @@ -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), }; } diff --git a/site/content/reference/api.ar.json b/site/content/reference/api.ar.json index 36eb93afa9..ef83dad7a8 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 5c07e45efe..14455586c7 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 2b892c9b0f..c366de169f 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 ed8149b766..6b5d27f4d9 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 cba32d8df9..353488eb36 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 fef761fb00..21f159f18f 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 6f11caf56d..1d7edf7fa7 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 5d08b131f6..aada4dc850 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 afbb14e907..3b83eef1d0 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 b825f1c43d..0fc6e4faed 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 01122270e1..c753c26ae6 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 efdd559b4b..d918af257a 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 1c0b6c8356..6289954f16 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 6f6723a411..b0b8c43f93 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": "设置它的位置。",