@octanejs/three 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,49 @@
1
+ Octane Three binding
2
+ ====================
3
+
4
+ MIT License
5
+
6
+ Copyright (c) 2026 Dominic Gannaway
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+
26
+ React Three Fiber-derived algorithms and types
27
+ ===============================================
28
+
29
+ MIT License
30
+
31
+ Copyright (c) 2019-2025 Poimandres
32
+
33
+ Permission is hereby granted, free of charge, to any person obtaining a copy
34
+ of this software and associated documentation files (the "Software"), to deal
35
+ in the Software without restriction, including without limitation the rights
36
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
37
+ copies of the Software, and to permit persons to whom the Software is
38
+ furnished to do so, subject to the following conditions:
39
+
40
+ The above copyright notice and this permission notice shall be included in all
41
+ copies or substantial portions of the Software.
42
+
43
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
45
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
46
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
47
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
48
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
49
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # `@octanejs/three`
2
+
3
+ An experimental React Three Fiber 9-compatible web renderer for Octane. Octane
4
+ keeps ownership of component execution, hooks, context, Suspense, refs, and
5
+ effects; this package supplies the Three-specific host layer.
6
+
7
+ Milestones 0, 2, 3, 4, 5, and 6 are implemented on top of Octane's renderer SDK
8
+ foundation. The current technical preview includes:
9
+
10
+ - the serializable compiler preset, renderer entry point, renderer-local Three
11
+ intrinsic types, and pinned upstream export/test crosswalk;
12
+ - the Three constructor catalogue, object and constructor-component `extend`
13
+ forms, `primitive`, and constructor `args`;
14
+ - real Three host objects with prop diff/application, automatic, string, and
15
+ function attachments, ordered placement and moves, reconstruction, retained
16
+ visibility, lifecycle/ref delivery, and ownership-aware disposal; and
17
+ - the DOM `Canvas` boundary, programmatic roots, promise-returning renderer
18
+ configuration, the callable root store, camera/scene/raycaster setup,
19
+ resize/DPR/viewport state, shadows and color configuration;
20
+ - one shared `always`/`demand`/`never` frame loop, global frame effects,
21
+ `useStore`, `useThree`, `useFrame`, `useGraph`, managed-instance helpers, and
22
+ the deterministic `@octanejs/three/testing` harness;
23
+ - R3F-compatible ray and pointer events, including 3D bubbling, hit ordering,
24
+ propagation, hover transitions, missed clicks, pointer capture, custom event
25
+ managers, external DOM sources, and coordinate prefixes;
26
+ - a Suspense-aware `useLoader` cache with constructor and instance loaders,
27
+ scalar and array inputs, extensions, progress, GLTF graph augmentation,
28
+ `preload`, `clear`, and cached error routing;
29
+ - retained Three Suspense and Activity visibility, ownership-safe teardown, and
30
+ client-side Three-to-DOM pending/error projection;
31
+ - same-renderer `createPortal` placement into borrowed `Object3D` targets,
32
+ R3F-shaped state/event enclaves, nested context retention, one shared frame
33
+ loop, physical Three event bubbling, and root-scoped target teardown; and
34
+ - public behavior, prepared-driver, and same-source compiled scene evidence
35
+ against R3F 9.6.1 with the exact Three r172 oracle.
36
+
37
+ Full Canvas SSR/hydration adoption, XR, OffscreenCanvas lifecycle, and live HMR
38
+ behavior follow in later milestones.
39
+
40
+ Three deliberate correctness fixes differ from R3F 9.6.1:
41
+
42
+ - removing a pierced prop such as `material-color` resets the nested material
43
+ property, instead of writing the default to a same-named leaf on the root
44
+ object; and
45
+ - reconstructing a captured or hovered object rewrites every stored
46
+ intersection to the replacement, so subsequent captured events reach the
47
+ live handler instead of retaining the retired object; and
48
+ - retained Activity subtrees are excluded from recursive raycasts while
49
+ hidden, rather than allowing an interactive visible ancestor to pierce an
50
+ invisible descendant.
51
+
52
+ ## Compiler configuration
53
+
54
+ DOM remains the application renderer. Scene modules opt into Three through the
55
+ shared renderer preset and the `*.three.tsrx` convention:
56
+
57
+ ```ts
58
+ import { defineConfig } from '@octanejs/vite-plugin';
59
+ import { threeRenderers } from '@octanejs/three/config';
60
+
61
+ export default defineConfig({
62
+ compiler: {
63
+ renderers: threeRenderers,
64
+ },
65
+ });
66
+ ```
67
+
68
+ The same serializable `threeRenderers` value can be supplied to the Rsbuild and
69
+ low-level Rspack integrations. Vite and Rsbuild additionally own application
70
+ SSR and hydration; the Rspack plugin owns compilation and HMR only.
71
+
72
+ The preset selects `@octanejs/three/renderer`, keeps Three scene modules
73
+ client-only on the server, ignores authored text inside scenes, exposes a
74
+ renderer-local intrinsic catalogue without merging Three tags into DOM JSX,
75
+ and declares `Canvas.children` as the DOM-to-Three renderer boundary.
76
+
77
+ ## Canvas and scene modules
78
+
79
+ The application remains a normal DOM Octane app. Only scene modules use the
80
+ `.three.tsrx` convention:
81
+
82
+ ```tsx
83
+ // App.tsrx
84
+ import { Canvas } from '@octanejs/three';
85
+ import { Scene } from './Scene.three.tsrx';
86
+
87
+ export function App() @{
88
+ <Canvas frameloop="demand">
89
+ <Scene />
90
+ </Canvas>
91
+ }
92
+ ```
93
+
94
+ ```tsx
95
+ // Scene.three.tsrx
96
+ import { useFrame } from '@octanejs/three';
97
+ import { useRef } from '@octanejs/three/renderer';
98
+
99
+ export function Scene() @{
100
+ const mesh = useRef(null);
101
+ useFrame((_state, delta) => (mesh.current.rotation.x += delta));
102
+ <mesh ref={mesh}>
103
+ <boxGeometry args={[1, 1, 1]} />
104
+ <meshBasicMaterial color="hotpink" />
105
+ </mesh>
106
+ }
107
+ ```
108
+
109
+ The low-level API follows Octane's component-plus-props root convention. Both
110
+ synchronous and asynchronous renderer factories settle before the component
111
+ can execute:
112
+
113
+ ```ts
114
+ import { createRoot } from '@octanejs/three';
115
+
116
+ const root = createRoot(canvas);
117
+ await root.configure({ frameloop: 'never', dpr: 1 });
118
+ root.render(Scene, { color: 'hotpink' });
119
+ root.store.getState().advance(1 / 60);
120
+ ```
121
+
122
+ Tests can inject the WebGL-free deterministic harness from
123
+ `@octanejs/three/testing`; it drives the same root, host commits, hooks, and
124
+ public `advance()` loop as an application. Its awaitable `fireEvent()` helper
125
+ directly invokes the latest committed handler and settles scheduled work when
126
+ raycasting itself is not under test.
127
+
128
+ `Canvas` installs the default web event manager. Use `eventSource` to subscribe
129
+ through another element and `eventPrefix` (`offset`, `client`, `page`, `layer`,
130
+ or `screen`) to choose the coordinate pair. Programmatic roots can supply a
131
+ custom `events(store)` manager factory and update it through `state.setEvents()`.
132
+
133
+ `useStore()` returns the upstream-compatible callable store. Because a later
134
+ `store(selector)` call is a dynamic function call, the compiler cannot assign
135
+ that call its own lexical hook slot; keep that compatibility form unconditional
136
+ and in stable order. Prefer `useStore(selector, equality?)` or
137
+ `useThree(selector, equality?)` when using Octane's conditional-hook semantics.
138
+
139
+ ## Portals
140
+
141
+ `createPortal` keeps its children in the authored Octane owner/context tree but
142
+ places their Three hosts below a borrowed `Object3D`. The optional state layer
143
+ matches R3F's portal model: it has its own scene, raycaster, pointer, and event
144
+ priority while sharing the outer root's interaction registry and frame loop.
145
+
146
+ ```tsx
147
+ import { createPortal, useThree } from '@octanejs/three';
148
+
149
+ function Overlay() @{
150
+ const scene = useThree((state) => state.scene);
151
+ <group name={scene.name + '-overlay'} />
152
+ }
153
+
154
+ export function Scene(props) @{
155
+ <>
156
+ {createPortal(<Overlay />, props.overlayTarget, {
157
+ events: { priority: 2 },
158
+ })}
159
+ </>
160
+ }
161
+ ```
162
+
163
+ Portal targets are borrowed and never disposed by Octane. A managed target must
164
+ belong to the same root; a local `Object3D` target also cannot cross a commit
165
+ transport. Pointer hits use the portal layer's camera/raycaster, then bubble
166
+ through physical `Object3D.parent` ancestry as they do in R3F. Component errors,
167
+ effects, context, and scheduling continue to follow logical Octane ownership.
168
+
169
+ ## Assets, Suspense, and errors
170
+
171
+ `useLoader` follows the R3F v9 cache contract. A loader constructor is
172
+ instantiated once, while an existing loader instance is used directly. The
173
+ loader identity and normalized input form the cache key; extensions and
174
+ progress callbacks configure the first request for that key.
175
+
176
+ ```tsx
177
+ import { useLoader } from '@octanejs/three';
178
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
179
+
180
+ function Model() @{
181
+ const gltf = useLoader(GLTFLoader, '/model.glb');
182
+ <primitive object={gltf.scene} />
183
+ }
184
+
185
+ export function Scene() @{
186
+ @try {
187
+ <Model />
188
+ } @pending {
189
+ <group name="loading-model" />
190
+ } @catch (error) {
191
+ <group name={'model-error:' + error.message} />
192
+ }
193
+ }
194
+
195
+ useLoader.preload(GLTFLoader, '/model.glb');
196
+ // Later, when the next read must issue a fresh request:
197
+ useLoader.clear(GLTFLoader, '/model.glb');
198
+ ```
199
+
200
+ `clear` evicts the exact cache entry; it does not abort a request or dispose
201
+ the resolved asset. Declarative Three resources remain owned by their mounted
202
+ host tree, while objects passed through `primitive` remain caller-owned. A
203
+ root-level Three suspension or render error is projected through `Canvas` to
204
+ the nearest client DOM `@pending` or `@catch` arm. Streaming server fallback
205
+ and Canvas hydration remain Milestone 7 work.
206
+
207
+ ## Compatibility target
208
+
209
+ The compatibility baseline is `@react-three/fiber@9.6.1` at commit
210
+ `2a528745e9aa7c9e6cca41e404b59d45cf0d0cc7`, with `three@0.172.0` as the exact
211
+ behavioral oracle. React Native/Expo, R3F 10's WebGPU/TSL APIs, and Drei are
212
+ separate follow-on efforts.
213
+
214
+ See [`docs/three-port-plan.md`](../../docs/three-port-plan.md) for the delivery
215
+ phases and [`UPSTREAM.md`](./UPSTREAM.md) for source and license provenance.
package/UPSTREAM.md ADDED
@@ -0,0 +1,58 @@
1
+ # Upstream React Three Fiber audit
2
+
3
+ This port targets the immutable React Three Fiber release `v9.6.1`:
4
+
5
+ - repository: `https://github.com/pmndrs/react-three-fiber`;
6
+ - tag commit: `2a528745e9aa7c9e6cca41e404b59d45cf0d0cc7`;
7
+ - package: `@react-three/fiber@9.6.1`;
8
+ - exact Three behavior and type oracle: `three@0.172.0` and
9
+ `@types/three@0.172.0`.
10
+
11
+ The upstream package accepts `three >=0.156`, but this port initially supports
12
+ only the pinned r172 lane. The broader peer range is gated on a real
13
+ minimum-version validation lane.
14
+
15
+ ## Source boundary
16
+
17
+ The package adapts the R3F web/core behavior rather than its React renderer
18
+ shell:
19
+
20
+ - `packages/fiber/src/three-types.ts` informs renderer-local intrinsic and math
21
+ shorthand types;
22
+ - `packages/fiber/src/core/reconciler.tsx` informs catalogue, reconstruction,
23
+ ordering, attachment, disposal, and interaction-transfer behavior;
24
+ - `packages/fiber/src/core/utils.tsx` informs Three prop resolution and
25
+ application;
26
+ - `packages/fiber/src/core/store.ts`, `renderer.tsx`, `loop.ts`, `events.ts`, and
27
+ `hooks.tsx` inform their corresponding package-owned subsystems;
28
+ - `packages/fiber/src/web/Canvas.tsx` informs the DOM shell while Octane's
29
+ declared renderer boundary replaces R3F's second React root and context
30
+ bridge.
31
+
32
+ React Reconciler, ReactDOM, `scheduler`, `its-fine`, `react-use-measure`,
33
+ `use-sync-external-store`, and `suspend-react` are not runtime dependencies of
34
+ the port. React and R3F are development-only differential oracles.
35
+
36
+ The case-level export/test crosswalk must classify upstream behavior as
37
+ behavioral, differential, browser-only, type/package evidence, or not
38
+ applicable. It is release evidence, not permission to copy Fiber ownership
39
+ internals into Octane.
40
+
41
+ Milestone 5 follows the pinned `useLoader` observable contract: constructor
42
+ loaders are singleton instances, cache identity combines the loader and
43
+ normalized input, GLTF-shaped results receive the public graph map, and
44
+ `preload`/`clear` share that same key. Cache eviction does not imply asset
45
+ disposal; disposal follows Octane's declarative host ownership instead.
46
+
47
+ Milestone 6 follows the pinned `createPortal` contract: the supplied
48
+ `Object3D` is a borrowed physical target, the portal store has an immediate
49
+ `previousRoot` plus local scene/raycaster/pointer/event state, and pointer
50
+ events bubble through physical Three ancestry. Octane's universal portal range
51
+ retains logical context, error, effect, and scheduling ownership without
52
+ embedding React Reconciler or representing the target as a fake host child.
53
+
54
+ ## License provenance
55
+
56
+ React Three Fiber is MIT-licensed, Copyright 2019–2025 Poimandres. Adapted
57
+ algorithms and types retain that notice in [`LICENSE`](./LICENSE). Three is a
58
+ peer dependency under its own MIT license; no Three source is vendored here.
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@octanejs/three",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "octane": {
10
+ "hookSlots": {
11
+ "manual": [
12
+ "src"
13
+ ]
14
+ }
15
+ },
16
+ "description": "React Three Fiber-compatible web bindings for Octane's universal renderer.",
17
+ "author": {
18
+ "name": "Dominic Gannaway",
19
+ "email": "dg@domgan.com"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/octanejs/octane.git",
27
+ "directory": "packages/three"
28
+ },
29
+ "main": "src/index.ts",
30
+ "module": "src/index.ts",
31
+ "types": "src/index.ts",
32
+ "files": [
33
+ "src",
34
+ "LICENSE",
35
+ "README.md",
36
+ "UPSTREAM.md"
37
+ ],
38
+ "exports": {
39
+ ".": "./src/index.ts",
40
+ "./core": "./src/core/index.ts",
41
+ "./renderer": "./src/renderer.ts",
42
+ "./config": "./src/config.ts",
43
+ "./testing": "./src/testing.ts",
44
+ "./intrinsics": "./src/intrinsics.ts",
45
+ "./intrinsics/jsx-runtime": "./src/intrinsics.ts"
46
+ },
47
+ "dependencies": {
48
+ "@types/three": "0.172.0",
49
+ "zustand": "^5.0.0"
50
+ },
51
+ "peerDependencies": {
52
+ "three": ">=0.172.0 <0.173.0",
53
+ "octane": "0.1.7"
54
+ },
55
+ "devDependencies": {
56
+ "@react-three/fiber": "9.6.1",
57
+ "@rsbuild/core": "^2.1.5",
58
+ "@rspack/core": "^2.1.3",
59
+ "@tsrx/react": "^0.2.37",
60
+ "esbuild": "^0.28.1",
61
+ "playwright": "^1.61.0",
62
+ "react": "^19.2.0",
63
+ "react-dom": "^19.2.0",
64
+ "three": "0.172.0",
65
+ "typescript": "^5.9.3",
66
+ "vite": "^8.0.16",
67
+ "vitest": "^4.1.9",
68
+ "@octanejs/rsbuild-plugin": "0.1.2",
69
+ "@octanejs/rspack-plugin": "0.1.2",
70
+ "octane": "0.1.7",
71
+ "@octanejs/vite-plugin": "0.1.7"
72
+ },
73
+ "scripts": {
74
+ "test": "pnpm upstream:check && vitest run --root ../.. --project three",
75
+ "upstream:check": "node scripts/check-upstream-crosswalk.mjs"
76
+ }
77
+ }
package/src/config.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Serializable compiler metadata for Three-rendered TSRX modules.
3
+ *
4
+ * This module intentionally imports neither Three nor an Octane runtime. Vite,
5
+ * Rspack, Rsbuild, and language tooling can load the same data without creating
6
+ * renderer state or evaluating browser-only code.
7
+ */
8
+ export const THREE_RENDERER_ID = 'three';
9
+
10
+ export const threeRenderer = {
11
+ module: '@octanejs/three/renderer',
12
+ target: 'universal',
13
+ server: 'client-only',
14
+ intrinsics: '@octanejs/three/intrinsics',
15
+ text: 'ignore',
16
+ capabilities: ['local-host-callback', 'visibility', 'portal'],
17
+ } as const;
18
+
19
+ export const threeRendererRegistry = {
20
+ [THREE_RENDERER_ID]: threeRenderer,
21
+ } as const;
22
+
23
+ export const threeRendererRules = [
24
+ {
25
+ include: '**/*.three.tsrx',
26
+ renderer: THREE_RENDERER_ID,
27
+ },
28
+ ] as const;
29
+
30
+ export const threeRendererBoundaries = {
31
+ '@octanejs/three': {
32
+ Canvas: {
33
+ ownerRenderer: 'dom',
34
+ childRenderer: THREE_RENDERER_ID,
35
+ prop: 'children',
36
+ server: 'omit-child',
37
+ },
38
+ },
39
+ } as const;
40
+
41
+ export const threeRenderers = {
42
+ registry: threeRendererRegistry,
43
+ rules: threeRendererRules,
44
+ boundaries: threeRendererBoundaries,
45
+ } as const;
46
+
47
+ /** Short compatibility name for app config files. */
48
+ export const renderers = threeRenderers;
49
+
50
+ export default threeRenderers;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Three attachment path handling.
3
+ *
4
+ * Adapted from React Three Fiber v9.6.1's string attachment and automatic
5
+ * geometry/material behavior:
6
+ * https://github.com/pmndrs/react-three-fiber/blob/2a528745e9aa7c9e6cca41e404b59d45cf0d0cc7/packages/fiber/src/core/utils.tsx#L286-L319
7
+ * https://github.com/pmndrs/react-three-fiber/blob/2a528745e9aa7c9e6cca41e404b59d45cf0d0cc7/packages/fiber/src/core/utils.tsx#L525-L529
8
+ *
9
+ * Function attachments use Octane's post-accept local-callback channel, so
10
+ * this module intentionally owns string and automatic attachments only.
11
+ */
12
+ import { inspectAppliedThreePropsPath, resolveProperty } from './props.js';
13
+
14
+ const ARRAY_INDEX = /-\d+$/;
15
+
16
+ export type EffectiveAttachment = string | null | undefined;
17
+
18
+ export interface AttachmentState<Parent extends object = object, Child = unknown> {
19
+ readonly kind: 'string';
20
+ readonly parent: Parent;
21
+ readonly child: Child;
22
+ readonly path: string;
23
+ readonly root: any;
24
+ readonly key: string;
25
+ readonly previous: unknown;
26
+ active: boolean;
27
+ }
28
+
29
+ function isPropertyRoot(value: unknown): value is Record<string, any> {
30
+ return value !== null && (typeof value === 'object' || typeof value === 'function');
31
+ }
32
+
33
+ function requirePropertyRoot(value: unknown, path: string): asserts value is Record<string, any> {
34
+ if (!isPropertyRoot(value)) {
35
+ throw new Error(
36
+ `@octanejs/three: Cannot attach to ${JSON.stringify(path)} because its parent is not an object.`,
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Return the authored attachment, or R3F's automatic geometry/material target.
43
+ * An explicit null suppresses automatic attachment.
44
+ */
45
+ export function getEffectiveAttachment(
46
+ object: unknown,
47
+ authored: EffectiveAttachment = undefined,
48
+ ): EffectiveAttachment {
49
+ if (authored !== undefined) return authored;
50
+ if ((object as { isBufferGeometry?: boolean } | null)?.isBufferGeometry === true) {
51
+ return 'geometry';
52
+ }
53
+ if ((object as { isMaterial?: boolean } | null)?.isMaterial === true) return 'material';
54
+ return undefined;
55
+ }
56
+
57
+ /** Validate a final string attachment target without mutating its parent. */
58
+ export function validateStringAttachment(
59
+ parent: object,
60
+ path: string,
61
+ overrides: Readonly<Record<string, unknown>> = {},
62
+ ): void {
63
+ if (path.length === 0) throw new TypeError('@octanejs/three: attach must not be empty.');
64
+
65
+ // Indexed attachments first replace the target property with an array when
66
+ // necessary, so only the array property's parent must already be object-like.
67
+ const targetPath = ARRAY_INDEX.test(path) ? path.replace(ARRAY_INDEX, '') : path;
68
+ const status = inspectAppliedThreePropsPath(parent, targetPath, overrides);
69
+ if (status === 'valid') return;
70
+ const reason =
71
+ status === 'uncertain'
72
+ ? 'a custom setter makes its final parent shape uncertain'
73
+ : 'its parent is not an object';
74
+ throw new Error(`@octanejs/three: Cannot attach to ${JSON.stringify(path)} because ${reason}.`);
75
+ }
76
+
77
+ /** Attach a child at a direct or dash-pierced Three property path. */
78
+ export function attachString<Parent extends object, Child>(
79
+ parent: Parent,
80
+ child: Child,
81
+ path: string,
82
+ ): AttachmentState<Parent, Child> {
83
+ if (path.length === 0) throw new TypeError('@octanejs/three: attach must not be empty.');
84
+
85
+ // R3F's `material-0` convention creates the target array when necessary.
86
+ if (ARRAY_INDEX.test(path)) {
87
+ const arrayPath = path.replace(ARRAY_INDEX, '');
88
+ const arrayTarget = resolveProperty(parent, arrayPath);
89
+ requirePropertyRoot(arrayTarget.root, arrayPath);
90
+ if (!Array.isArray(arrayTarget.root[arrayTarget.key])) {
91
+ arrayTarget.root[arrayTarget.key] = [];
92
+ }
93
+ }
94
+
95
+ const target = resolveProperty(parent, path);
96
+ requirePropertyRoot(target.root, path);
97
+ const previous = target.root[target.key];
98
+ target.root[target.key] = child;
99
+ return {
100
+ kind: 'string',
101
+ parent,
102
+ child,
103
+ path,
104
+ root: target.root,
105
+ key: target.key,
106
+ previous,
107
+ active: true,
108
+ };
109
+ }
110
+
111
+ /** Restore the property value captured by `attachString`, exactly once. */
112
+ export function detachAttachment(state: AttachmentState): void {
113
+ if (!state.active) return;
114
+ state.active = false;
115
+ if (state.previous === undefined) delete state.root[state.key];
116
+ else state.root[state.key] = state.previous;
117
+ }