@molecule/app-three-viewer-react 1.0.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,115 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work.
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship.
43
+
44
+ "Contribution" shall mean any work of authorship, including the
45
+ original version of the Work and any modifications or additions
46
+ to that Work, that is intentionally submitted to the Licensor for
47
+ inclusion in the Work by the copyright owner or by an individual or
48
+ Legal Entity authorized to submit on behalf of the copyright owner.
49
+
50
+ "Contributor" shall mean Licensor and any individual or Legal Entity
51
+ on behalf of whom a Contribution has been received by the Licensor and
52
+ subsequently incorporated within the Work.
53
+
54
+ 2. Grant of Copyright License. Subject to the terms and conditions of
55
+ this License, each Contributor hereby grants to You a perpetual,
56
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
57
+ copyright license to reproduce, prepare Derivative Works of,
58
+ publicly display, publicly perform, sublicense, and distribute the
59
+ Work and such Derivative Works in Source or Object form.
60
+
61
+ 3. Grant of Patent License. Subject to the terms and conditions of
62
+ this License, each Contributor hereby grants to You a perpetual,
63
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
64
+ patent license to make, have made, use, offer to sell, sell, import,
65
+ and otherwise transfer the Work.
66
+
67
+ 4. Redistribution. You may reproduce and distribute copies of the
68
+ Work or Derivative Works thereof in any medium, with or without
69
+ modifications, and in Source or Object form, provided that You
70
+ meet the following conditions:
71
+
72
+ (a) You must give any other recipients of the Work or
73
+ Derivative Works a copy of this License; and
74
+
75
+ (b) You must cause any modified files to carry prominent notices
76
+ stating that You changed the files; and
77
+
78
+ (c) You must retain, in the Source form of any Derivative Works
79
+ that You distribute, all copyright, patent, trademark, and
80
+ attribution notices from the Source form of the Work,
81
+ excluding those notices that do not pertain to any part of
82
+ the Derivative Works; and
83
+
84
+ (d) If the Work includes a "NOTICE" text file as part of its
85
+ distribution, then any Derivative Works that You distribute must
86
+ include a readable copy of the attribution notices contained
87
+ within such NOTICE file.
88
+
89
+ 5. Submission of Contributions.
90
+
91
+ 6. Trademarks. This License does not grant permission to use the trade
92
+ names, trademarks, service marks, or product names of the Licensor.
93
+
94
+ 7. Disclaimer of Warranty. Unless required by applicable law or
95
+ agreed to in writing, Licensor provides the Work on an "AS IS" BASIS,
96
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.
97
+
98
+ 8. Limitation of Liability. In no event and under no legal theory shall
99
+ any Contributor be liable to You for damages.
100
+
101
+ 9. Accepting Warranty or Additional Liability.
102
+
103
+ Copyright 2026 Molecule Dev, Inc.
104
+
105
+ Licensed under the Apache License, Version 2.0 (the "License");
106
+ you may not use this file except in compliance with the License.
107
+ You may obtain a copy of the License at
108
+
109
+ http://www.apache.org/licenses/LICENSE-2.0
110
+
111
+ Unless required by applicable law or agreed to in writing, software
112
+ distributed under the License is distributed on an "AS IS" BASIS,
113
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
114
+ See the License for the specific language governing permissions and
115
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,199 @@
1
+ <!--
2
+ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
+ Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
+ Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
+ To change this document, edit the module-level JSDoc in src/index.ts.
6
+ Generated: 2026-08-04T00:41:23.223Z
7
+ -->
8
+
9
+ # @molecule/app-three-viewer-react
10
+
11
+ > **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
12
+ > It is written to be read by coding agents as much as by people, and is generated from this
13
+ > package's source — edit `src/index.ts` JSDoc, not this file.
14
+
15
+ 3D model viewer wrapping three.js.
16
+
17
+ Exports `<ThreeViewer>` — a React component that renders GLTF/GLB,
18
+ OBJ, or STL models with orbit controls, lighting presets
19
+ (`studio`/`sunset`/`flat`), auto-fit camera, and full GPU resource
20
+ cleanup on unmount — plus the `resolveFormat()` and
21
+ `disposeObject3D()` helpers and the prop types.
22
+
23
+ ## Quick Start
24
+
25
+ ```tsx
26
+ import { ThreeViewer } from '@molecule/app-three-viewer-react'
27
+
28
+ function ModelPage() {
29
+ // Inline callbacks/arrays are safe — they do not tear down WebGL.
30
+ return (
31
+ <div style={{ height: 480 }}>
32
+ <ThreeViewer
33
+ src="/models/duck.glb"
34
+ lighting="studio"
35
+ autoRotate
36
+ onLoad={() => console.log('loaded')}
37
+ />
38
+ </div>
39
+ )
40
+ }
41
+ ```
42
+
43
+ ## Type
44
+
45
+ `feature`
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ npm install @molecule/app-three-viewer-react @molecule/app-react @molecule/app-ui @molecule/app-ui-react react three
51
+ npm install -D @types/react @types/three
52
+ ```
53
+
54
+ ## API
55
+
56
+ ### Interfaces
57
+
58
+ #### `ThreeViewerProps`
59
+
60
+ Props for the {@link ThreeViewer} component.
61
+
62
+ ```typescript
63
+ interface ThreeViewerProps {
64
+ /** URL or path to the 3D model file. */
65
+ src: string
66
+ /**
67
+ * Model format. If omitted, inferred from the `src` extension. Falls back
68
+ * to `'gltf'` for unknown extensions.
69
+ */
70
+ format?: ThreeViewerFormat
71
+ /** Lighting preset. Defaults to `'studio'`. */
72
+ lighting?: ThreeViewerLighting
73
+ /** Optional CSS background colour for the canvas (CSS string or hex). */
74
+ background?: string
75
+ /** Slowly rotate the camera around the model. Defaults to `false`. */
76
+ autoRotate?: boolean
77
+ /**
78
+ * Override the orbit camera target (world-space x/y/z). When omitted, the
79
+ * target is automatically set to the centre of the model's bounding box.
80
+ */
81
+ cameraTarget?: [number, number, number]
82
+ /** Fired when the model has loaded into the scene. */
83
+ onLoad?: () => void
84
+ /** Fired when loading fails. */
85
+ onError?: (error: Error) => void
86
+ /** Extra classes merged onto the outer wrapper. */
87
+ className?: string
88
+ }
89
+ ```
90
+
91
+ ### Types
92
+
93
+ #### `ThreeViewerFormat`
94
+
95
+ Supported 3D model formats.
96
+
97
+ ```typescript
98
+ type ThreeViewerFormat = 'gltf' | 'glb' | 'obj' | 'stl'
99
+ ```
100
+
101
+ #### `ThreeViewerLighting`
102
+
103
+ Lighting presets — drive the scene's light rig.
104
+
105
+ ```typescript
106
+ type ThreeViewerLighting = 'studio' | 'sunset' | 'flat'
107
+ ```
108
+
109
+ ### Functions
110
+
111
+ #### `disposeObject3D(root)`
112
+
113
+ Walk an Object3D tree and dispose every geometry, material, and texture so
114
+ three.js does not leak GPU resources when the viewer unmounts or swaps
115
+ models.
116
+
117
+ ```typescript
118
+ function disposeObject3D(root: Object3D<Object3DEventMap>): void
119
+ ```
120
+
121
+ - `root` — The root node to dispose.
122
+
123
+ #### `resolveFormat(src, explicit)`
124
+
125
+ Resolve a model format from an explicit prop or the file extension on `src`.
126
+
127
+ ```typescript
128
+ function resolveFormat(src: string, explicit?: ThreeViewerFormat): ThreeViewerFormat
129
+ ```
130
+
131
+ - `src` — The model URL.
132
+ - `explicit` — The explicit `format` prop, if provided.
133
+
134
+ **Returns:** The resolved format.
135
+
136
+ #### `ThreeViewer(props)`
137
+
138
+ 3D model viewer wrapping three.js.
139
+
140
+ Supports GLTF/GLB, OBJ, and STL inputs, orbit controls, and a small set of
141
+ lighting presets. The camera auto-fits to the model's bounding box on
142
+ load. All three.js GPU resources (geometry, materials, textures, the
143
+ renderer itself) are disposed on unmount or when the model source
144
+ (`src`/`format`) or scene structure (`lighting`/`background`) changes.
145
+
146
+ The heavy setup (renderer creation + model download) depends ONLY on those
147
+ inputs; `onLoad`, `onError`, `cameraTarget`, and `autoRotate` are routed
148
+ through refs, so passing them inline (fresh arrows / `[x,y,z]` literals) does
149
+ NOT tear down the WebGL context or re-download the model on a parent
150
+ re-render.
151
+
152
+ ```typescript
153
+ function ThreeViewer(props: ThreeViewerProps): JSX.Element
154
+ ```
155
+
156
+ - `props` — Component props.
157
+
158
+ **Returns:** The rendered viewer.
159
+
160
+ ## Injection Notes
161
+
162
+ ### Requirements
163
+
164
+ Peer dependencies:
165
+
166
+ - `@molecule/app-react` ^1.0.0
167
+ - `@molecule/app-ui` ^1.0.0
168
+ - `@molecule/app-ui-react` ^1.0.0
169
+ - `react` ^18.0.0 || ^19.0.0
170
+
171
+ ### Runtime Dependencies
172
+
173
+ - `@molecule/app-react`
174
+ - `@molecule/app-ui`
175
+ - `@molecule/app-ui-react`
176
+ - `react`
177
+ - `three`
178
+
179
+ - Ships a REAL pinned `three` dependency — a substantial bundle-size
180
+ add. Lazy-load the importing route/component (`React.lazy` / dynamic
181
+ import) so non-3D pages do not pay for it.
182
+ - The heavy setup effect (renderer creation + model download) depends
183
+ ONLY on `src`/`format` (and `lighting`/`background`, which restructure
184
+ the scene). `onLoad`, `onError`, `cameraTarget`, and `autoRotate` are
185
+ routed through refs + light sync effects, so passing them inline (fresh
186
+ arrows / `[x,y,z]` literals) does NOT tear down the WebGL context or
187
+ re-download the model on a parent re-render. Changing `src`/`format`
188
+ reloads the model; changing `cameraTarget`'s value re-aims the camera
189
+ in place.
190
+ - The wrapper is `width/height: 100%` with `min-height: 240px` — give
191
+ the PARENT an explicit height or the canvas collapses to 240px.
192
+ - Requires WebGL in a real browser: do not render during SSR and do
193
+ not expect it to work under jsdom tests. Model URLs must be
194
+ same-origin or CORS-enabled.
195
+ - `format` is inferred from the `src` extension (unknown extensions
196
+ fall back to `gltf`); pass it explicitly for extension-less URLs.
197
+ - Must render inside the app's i18n provider and with a ClassMap bond
198
+ wired (loading/error overlays use `useTranslation()`); translations
199
+ ship in `@molecule/app-locales-feature-three-viewer`.
@@ -0,0 +1,69 @@
1
+ import { type JSX } from 'react';
2
+ import { type Object3D } from 'three';
3
+ /** Supported 3D model formats. */
4
+ export type ThreeViewerFormat = 'gltf' | 'glb' | 'obj' | 'stl';
5
+ /** Lighting presets — drive the scene's light rig. */
6
+ export type ThreeViewerLighting = 'studio' | 'sunset' | 'flat';
7
+ /** Props for the {@link ThreeViewer} component. */
8
+ export interface ThreeViewerProps {
9
+ /** URL or path to the 3D model file. */
10
+ src: string;
11
+ /**
12
+ * Model format. If omitted, inferred from the `src` extension. Falls back
13
+ * to `'gltf'` for unknown extensions.
14
+ */
15
+ format?: ThreeViewerFormat;
16
+ /** Lighting preset. Defaults to `'studio'`. */
17
+ lighting?: ThreeViewerLighting;
18
+ /** Optional CSS background colour for the canvas (CSS string or hex). */
19
+ background?: string;
20
+ /** Slowly rotate the camera around the model. Defaults to `false`. */
21
+ autoRotate?: boolean;
22
+ /**
23
+ * Override the orbit camera target (world-space x/y/z). When omitted, the
24
+ * target is automatically set to the centre of the model's bounding box.
25
+ */
26
+ cameraTarget?: [number, number, number];
27
+ /** Fired when the model has loaded into the scene. */
28
+ onLoad?: () => void;
29
+ /** Fired when loading fails. */
30
+ onError?: (error: Error) => void;
31
+ /** Extra classes merged onto the outer wrapper. */
32
+ className?: string;
33
+ }
34
+ /**
35
+ * Resolve a model format from an explicit prop or the file extension on `src`.
36
+ *
37
+ * @param src - The model URL.
38
+ * @param explicit - The explicit `format` prop, if provided.
39
+ * @returns The resolved format.
40
+ */
41
+ export declare function resolveFormat(src: string, explicit?: ThreeViewerFormat): ThreeViewerFormat;
42
+ /**
43
+ * Walk an Object3D tree and dispose every geometry, material, and texture so
44
+ * three.js does not leak GPU resources when the viewer unmounts or swaps
45
+ * models.
46
+ *
47
+ * @param root - The root node to dispose.
48
+ */
49
+ export declare function disposeObject3D(root: Object3D): void;
50
+ /**
51
+ * 3D model viewer wrapping three.js.
52
+ *
53
+ * Supports GLTF/GLB, OBJ, and STL inputs, orbit controls, and a small set of
54
+ * lighting presets. The camera auto-fits to the model's bounding box on
55
+ * load. All three.js GPU resources (geometry, materials, textures, the
56
+ * renderer itself) are disposed on unmount or when the model source
57
+ * (`src`/`format`) or scene structure (`lighting`/`background`) changes.
58
+ *
59
+ * The heavy setup (renderer creation + model download) depends ONLY on those
60
+ * inputs; `onLoad`, `onError`, `cameraTarget`, and `autoRotate` are routed
61
+ * through refs, so passing them inline (fresh arrows / `[x,y,z]` literals) does
62
+ * NOT tear down the WebGL context or re-download the model on a parent
63
+ * re-render.
64
+ *
65
+ * @param props - Component props.
66
+ * @returns The rendered viewer.
67
+ */
68
+ export declare function ThreeViewer(props: ThreeViewerProps): JSX.Element;
69
+ //# sourceMappingURL=ThreeViewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ThreeViewer.d.ts","sourceRoot":"","sources":["../src/ThreeViewer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,GAAG,EAA+B,MAAM,OAAO,CAAA;AAC7D,OAAO,EASL,KAAK,QAAQ,EAKd,MAAM,OAAO,CAAA;AAUd,kCAAkC;AAClC,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,CAAA;AAE9D,sDAAsD;AACtD,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAA;AAE9D,mDAAmD;AACnD,MAAM,WAAW,gBAAgB;IAC/B,wCAAwC;IACxC,GAAG,EAAE,MAAM,CAAA;IACX;;;OAGG;IACH,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,mBAAmB,CAAA;IAC9B,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IACvC,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB,gCAAgC;IAChC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;IAChC,mDAAmD;IACnD,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,iBAAiB,GAAG,iBAAiB,CAK1F;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CAapD;AA4JD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,GAAG,CAAC,OAAO,CAyNhE"}
@@ -0,0 +1,349 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { AmbientLight, Box3, Color, DirectionalLight, HemisphereLight, Mesh, MeshStandardMaterial, PerspectiveCamera, Scene, Vector3, WebGLRenderer, } from 'three';
4
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
5
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
6
+ import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
7
+ import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
8
+ import { useTranslation } from '@molecule/app-react';
9
+ import { getClassMap } from '@molecule/app-ui';
10
+ import { Spinner } from '@molecule/app-ui-react';
11
+ /**
12
+ * Resolve a model format from an explicit prop or the file extension on `src`.
13
+ *
14
+ * @param src - The model URL.
15
+ * @param explicit - The explicit `format` prop, if provided.
16
+ * @returns The resolved format.
17
+ */
18
+ export function resolveFormat(src, explicit) {
19
+ if (explicit)
20
+ return explicit;
21
+ const m = src.toLowerCase().match(/\.(gltf|glb|obj|stl)(?:\?|#|$)/);
22
+ if (m)
23
+ return m[1];
24
+ return 'gltf';
25
+ }
26
+ /**
27
+ * Walk an Object3D tree and dispose every geometry, material, and texture so
28
+ * three.js does not leak GPU resources when the viewer unmounts or swaps
29
+ * models.
30
+ *
31
+ * @param root - The root node to dispose.
32
+ */
33
+ export function disposeObject3D(root) {
34
+ root.traverse((child) => {
35
+ const mesh = child;
36
+ if (mesh.geometry) {
37
+ mesh.geometry.dispose();
38
+ }
39
+ const mat = mesh.material;
40
+ if (Array.isArray(mat)) {
41
+ for (const m of mat)
42
+ disposeMaterial(m);
43
+ }
44
+ else if (mat) {
45
+ disposeMaterial(mat);
46
+ }
47
+ });
48
+ }
49
+ /**
50
+ * Dispose a material and any of its texture maps.
51
+ *
52
+ * @param material - The material to dispose.
53
+ */
54
+ function disposeMaterial(material) {
55
+ const m = material;
56
+ for (const key of Object.keys(m)) {
57
+ const value = m[key];
58
+ if (value &&
59
+ typeof value === 'object' &&
60
+ 'isTexture' in value &&
61
+ value.isTexture) {
62
+ ;
63
+ value.dispose();
64
+ }
65
+ }
66
+ material.dispose();
67
+ }
68
+ /**
69
+ * Configure a scene with one of the lighting presets.
70
+ *
71
+ * @param scene - The scene to add lights to.
72
+ * @param preset - The preset to apply.
73
+ * @returns The lights added (for cleanup).
74
+ */
75
+ function applyLighting(scene, preset) {
76
+ const lights = [];
77
+ if (preset === 'flat') {
78
+ const ambient = new AmbientLight(0xffffff, 1.0);
79
+ scene.add(ambient);
80
+ lights.push(ambient);
81
+ return lights;
82
+ }
83
+ if (preset === 'sunset') {
84
+ const hemi = new HemisphereLight(0xffb37a, 0x2a1850, 0.7);
85
+ scene.add(hemi);
86
+ lights.push(hemi);
87
+ const sun = new DirectionalLight(0xff8a4a, 1.4);
88
+ sun.position.set(5, 3, 2);
89
+ scene.add(sun);
90
+ lights.push(sun);
91
+ const fill = new DirectionalLight(0x6688cc, 0.4);
92
+ fill.position.set(-4, 2, -3);
93
+ scene.add(fill);
94
+ lights.push(fill);
95
+ return lights;
96
+ }
97
+ // studio (default) — three-point rig
98
+ const ambient = new AmbientLight(0xffffff, 0.4);
99
+ scene.add(ambient);
100
+ lights.push(ambient);
101
+ const key = new DirectionalLight(0xffffff, 1.1);
102
+ key.position.set(5, 5, 5);
103
+ scene.add(key);
104
+ lights.push(key);
105
+ const fill = new DirectionalLight(0xffffff, 0.6);
106
+ fill.position.set(-5, 2, 3);
107
+ scene.add(fill);
108
+ lights.push(fill);
109
+ const back = new DirectionalLight(0xffffff, 0.4);
110
+ back.position.set(0, 4, -5);
111
+ scene.add(back);
112
+ lights.push(back);
113
+ return lights;
114
+ }
115
+ /**
116
+ * Fit a perspective camera so the entire bounding box is in view, and aim
117
+ * orbit controls at the box centre (or an explicit target).
118
+ *
119
+ * @param object - The loaded model root.
120
+ * @param camera - The camera to position.
121
+ * @param controls - The orbit controls to retarget.
122
+ * @param target - Optional explicit camera target.
123
+ */
124
+ function fitCameraToObject(object, camera, controls, target) {
125
+ const box = new Box3().setFromObject(object);
126
+ const size = new Vector3();
127
+ const center = new Vector3();
128
+ box.getSize(size);
129
+ box.getCenter(center);
130
+ const maxDim = Math.max(size.x, size.y, size.z) || 1;
131
+ const fitOffset = 1.6;
132
+ const fovRad = (camera.fov * Math.PI) / 180;
133
+ const distance = (maxDim / (2 * Math.tan(fovRad / 2))) * fitOffset;
134
+ const dir = new Vector3(1, 0.6, 1).normalize();
135
+ camera.position.copy(center).addScaledVector(dir, distance);
136
+ camera.near = Math.max(distance / 1000, 0.01);
137
+ camera.far = distance * 100;
138
+ camera.updateProjectionMatrix();
139
+ controls.target.copy(target ? new Vector3(...target) : center);
140
+ controls.update();
141
+ }
142
+ /**
143
+ * Load a model from `src` using the appropriate loader for `format`.
144
+ *
145
+ * @param src - The model URL.
146
+ * @param format - The resolved format.
147
+ * @returns A promise that resolves to the loaded scene/mesh root.
148
+ */
149
+ function loadModel(src, format) {
150
+ return new Promise((resolve, reject) => {
151
+ if (format === 'gltf' || format === 'glb') {
152
+ const loader = new GLTFLoader();
153
+ loader.load(src, (gltf) => resolve(gltf.scene), undefined, (err) => reject(err instanceof Error ? err : new Error(String(err))));
154
+ return;
155
+ }
156
+ if (format === 'obj') {
157
+ const loader = new OBJLoader();
158
+ loader.load(src, (obj) => resolve(obj), undefined, (err) => reject(err instanceof Error ? err : new Error(String(err))));
159
+ return;
160
+ }
161
+ if (format === 'stl') {
162
+ const loader = new STLLoader();
163
+ loader.load(src, (geometry) => {
164
+ const mesh = new Mesh(geometry, new MeshStandardMaterial({ color: 0xb0b0b0, roughness: 0.6, metalness: 0.1 }));
165
+ resolve(mesh);
166
+ }, undefined, (err) => reject(err instanceof Error ? err : new Error(String(err))));
167
+ return;
168
+ }
169
+ reject(new Error(`Unsupported three-viewer format: ${String(format)}`));
170
+ });
171
+ }
172
+ /**
173
+ * 3D model viewer wrapping three.js.
174
+ *
175
+ * Supports GLTF/GLB, OBJ, and STL inputs, orbit controls, and a small set of
176
+ * lighting presets. The camera auto-fits to the model's bounding box on
177
+ * load. All three.js GPU resources (geometry, materials, textures, the
178
+ * renderer itself) are disposed on unmount or when the model source
179
+ * (`src`/`format`) or scene structure (`lighting`/`background`) changes.
180
+ *
181
+ * The heavy setup (renderer creation + model download) depends ONLY on those
182
+ * inputs; `onLoad`, `onError`, `cameraTarget`, and `autoRotate` are routed
183
+ * through refs, so passing them inline (fresh arrows / `[x,y,z]` literals) does
184
+ * NOT tear down the WebGL context or re-download the model on a parent
185
+ * re-render.
186
+ *
187
+ * @param props - Component props.
188
+ * @returns The rendered viewer.
189
+ */
190
+ export function ThreeViewer(props) {
191
+ const { src, format, lighting = 'studio', background, autoRotate = false, cameraTarget, onLoad, onError, className, } = props;
192
+ const cm = getClassMap();
193
+ const { t } = useTranslation();
194
+ const containerRef = useRef(null);
195
+ const [loading, setLoading] = useState(true);
196
+ const [errorMsg, setErrorMsg] = useState(null);
197
+ // Latest callback + option props kept in refs so the heavy setup effect below
198
+ // depends only on what genuinely forces a rebuild (the model source + scene
199
+ // structure) and never on inline arrows / fresh `[x,y,z]` literals — those get
200
+ // a new identity on every parent render. Routing them through refs stops an
201
+ // unrelated parent re-render from tearing down the WebGL context and
202
+ // re-downloading the model.
203
+ const onLoadRef = useRef(onLoad);
204
+ const onErrorRef = useRef(onError);
205
+ const cameraTargetRef = useRef(cameraTarget);
206
+ const autoRotateRef = useRef(autoRotate);
207
+ // Live handles to the running three.js objects so the cheap prop-sync effects
208
+ // can update them in place — no teardown.
209
+ const cameraRef = useRef(null);
210
+ const controlsRef = useRef(null);
211
+ const modelRef = useRef(null);
212
+ // Cheap sync: keep the callback refs current. Never touches the renderer, so a
213
+ // parent re-render with new inline `onLoad`/`onError` does not rebuild WebGL.
214
+ useEffect(() => {
215
+ onLoadRef.current = onLoad;
216
+ onErrorRef.current = onError;
217
+ }, [onLoad, onError]);
218
+ // Cheap sync: `autoRotate` toggles a live control flag in place.
219
+ useEffect(() => {
220
+ autoRotateRef.current = autoRotate;
221
+ if (controlsRef.current) {
222
+ controlsRef.current.autoRotate = autoRotate;
223
+ }
224
+ }, [autoRotate]);
225
+ // Cheap sync: keep the `cameraTarget` ref current on every identity change so
226
+ // the next model load / re-fit reads the latest value.
227
+ useEffect(() => {
228
+ cameraTargetRef.current = cameraTarget;
229
+ }, [cameraTarget]);
230
+ // Cheap sync: re-aim the camera when the target VALUE changes (identity churn
231
+ // from inline literals is ignored via the joined-value key) — no model reload.
232
+ const cameraTargetKey = cameraTarget ? cameraTarget.join(',') : '';
233
+ useEffect(() => {
234
+ const camera = cameraRef.current;
235
+ const controls = controlsRef.current;
236
+ const model = modelRef.current;
237
+ if (camera && controls && model) {
238
+ fitCameraToObject(model, camera, controls, cameraTargetRef.current);
239
+ }
240
+ }, [cameraTargetKey]);
241
+ // Heavy setup + teardown: creates the renderer/scene and downloads the model.
242
+ // Re-runs ONLY when the model source or scene structure changes.
243
+ useEffect(() => {
244
+ const container = containerRef.current;
245
+ if (!container)
246
+ return;
247
+ let disposed = false;
248
+ const resolved = resolveFormat(src, format);
249
+ const width = container.clientWidth || 1;
250
+ const height = container.clientHeight || 1;
251
+ const scene = new Scene();
252
+ if (background) {
253
+ scene.background = new Color(background);
254
+ }
255
+ const camera = new PerspectiveCamera(45, width / height, 0.1, 1000);
256
+ camera.position.set(0, 0, 5);
257
+ const renderer = new WebGLRenderer({ antialias: true, alpha: !background });
258
+ renderer.setPixelRatio(typeof window !== 'undefined' ? window.devicePixelRatio : 1);
259
+ renderer.setSize(width, height);
260
+ container.appendChild(renderer.domElement);
261
+ const controls = new OrbitControls(camera, renderer.domElement);
262
+ controls.enableDamping = true;
263
+ controls.autoRotate = autoRotateRef.current;
264
+ cameraRef.current = camera;
265
+ controlsRef.current = controls;
266
+ const lights = applyLighting(scene, lighting);
267
+ let model = null;
268
+ let frameHandle = 0;
269
+ /**
270
+ * Per-frame animation loop.
271
+ */
272
+ function animate() {
273
+ if (disposed)
274
+ return;
275
+ controls.update();
276
+ renderer.render(scene, camera);
277
+ frameHandle = requestAnimationFrame(animate);
278
+ }
279
+ /**
280
+ * Handle window resize by adjusting the renderer + camera aspect.
281
+ */
282
+ function handleResize() {
283
+ if (!container || disposed)
284
+ return;
285
+ const w = container.clientWidth || 1;
286
+ const h = container.clientHeight || 1;
287
+ camera.aspect = w / h;
288
+ camera.updateProjectionMatrix();
289
+ renderer.setSize(w, h);
290
+ }
291
+ if (typeof window !== 'undefined') {
292
+ window.addEventListener('resize', handleResize);
293
+ }
294
+ setLoading(true);
295
+ setErrorMsg(null);
296
+ loadModel(src, resolved)
297
+ .then((obj) => {
298
+ if (disposed) {
299
+ disposeObject3D(obj);
300
+ return;
301
+ }
302
+ model = obj;
303
+ modelRef.current = obj;
304
+ scene.add(obj);
305
+ fitCameraToObject(obj, camera, controls, cameraTargetRef.current);
306
+ animate();
307
+ setLoading(false);
308
+ onLoadRef.current?.();
309
+ })
310
+ .catch((err) => {
311
+ if (disposed)
312
+ return;
313
+ const e = err instanceof Error ? err : new Error(String(err));
314
+ setErrorMsg(e.message);
315
+ setLoading(false);
316
+ onErrorRef.current?.(e);
317
+ });
318
+ return () => {
319
+ disposed = true;
320
+ if (typeof window !== 'undefined') {
321
+ window.removeEventListener('resize', handleResize);
322
+ }
323
+ if (frameHandle)
324
+ cancelAnimationFrame(frameHandle);
325
+ controls.dispose();
326
+ for (const light of lights)
327
+ scene.remove(light);
328
+ if (model) {
329
+ scene.remove(model);
330
+ disposeObject3D(model);
331
+ }
332
+ renderer.dispose();
333
+ if (renderer.domElement.parentNode === container) {
334
+ container.removeChild(renderer.domElement);
335
+ }
336
+ cameraRef.current = null;
337
+ controlsRef.current = null;
338
+ modelRef.current = null;
339
+ };
340
+ }, [src, format, lighting, background]);
341
+ const overlayStyle = {
342
+ position: 'absolute',
343
+ inset: 0,
344
+ display: 'grid',
345
+ placeItems: 'center',
346
+ pointerEvents: 'none',
347
+ };
348
+ return (_jsxs("div", { "data-mol-id": "three-viewer", className: cm.cn(className), style: { position: 'relative', width: '100%', height: '100%', minHeight: 240 }, children: [_jsx("div", { ref: containerRef, "data-mol-id": "three-viewer-canvas", "aria-label": t('threeViewer.aria.canvas', {}, { defaultValue: '3D model viewer' }), role: "img", style: { width: '100%', height: '100%' } }), loading && !errorMsg && (_jsx("div", { "data-mol-id": "three-viewer-loading", className: cm.cn(cm.flex({ align: 'center', justify: 'center' })), style: overlayStyle, children: _jsx(Spinner, { label: t('threeViewer.loading', {}, { defaultValue: 'Loading 3D model…' }) }) })), errorMsg && (_jsx("div", { "data-mol-id": "three-viewer-error", role: "alert", className: cm.cn(cm.flex({ align: 'center', justify: 'center' }), cm.sp('p', 3)), style: overlayStyle, children: _jsx("span", { className: cm.textSize('sm'), children: t('threeViewer.error', {}, { defaultValue: 'Failed to load 3D model.' }) }) }))] }));
349
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 3D model viewer wrapping three.js.
3
+ *
4
+ * Exports `<ThreeViewer>` — a React component that renders GLTF/GLB,
5
+ * OBJ, or STL models with orbit controls, lighting presets
6
+ * (`studio`/`sunset`/`flat`), auto-fit camera, and full GPU resource
7
+ * cleanup on unmount — plus the `resolveFormat()` and
8
+ * `disposeObject3D()` helpers and the prop types.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * import { ThreeViewer } from '@molecule/app-three-viewer-react'
13
+ *
14
+ * function ModelPage() {
15
+ * // Inline callbacks/arrays are safe — they do not tear down WebGL.
16
+ * return (
17
+ * <div style={{ height: 480 }}>
18
+ * <ThreeViewer
19
+ * src="/models/duck.glb"
20
+ * lighting="studio"
21
+ * autoRotate
22
+ * onLoad={() => console.log('loaded')}
23
+ * />
24
+ * </div>
25
+ * )
26
+ * }
27
+ * ```
28
+ *
29
+ * @remarks
30
+ * - Ships a REAL pinned `three` dependency — a substantial bundle-size
31
+ * add. Lazy-load the importing route/component (`React.lazy` / dynamic
32
+ * import) so non-3D pages do not pay for it.
33
+ * - The heavy setup effect (renderer creation + model download) depends
34
+ * ONLY on `src`/`format` (and `lighting`/`background`, which restructure
35
+ * the scene). `onLoad`, `onError`, `cameraTarget`, and `autoRotate` are
36
+ * routed through refs + light sync effects, so passing them inline (fresh
37
+ * arrows / `[x,y,z]` literals) does NOT tear down the WebGL context or
38
+ * re-download the model on a parent re-render. Changing `src`/`format`
39
+ * reloads the model; changing `cameraTarget`'s value re-aims the camera
40
+ * in place.
41
+ * - The wrapper is `width/height: 100%` with `min-height: 240px` — give
42
+ * the PARENT an explicit height or the canvas collapses to 240px.
43
+ * - Requires WebGL in a real browser: do not render during SSR and do
44
+ * not expect it to work under jsdom tests. Model URLs must be
45
+ * same-origin or CORS-enabled.
46
+ * - `format` is inferred from the `src` extension (unknown extensions
47
+ * fall back to `gltf`); pass it explicitly for extension-less URLs.
48
+ * - Must render inside the app's i18n provider and with a ClassMap bond
49
+ * wired (loading/error overlays use `useTranslation()`); translations
50
+ * ship in `@molecule/app-locales-feature-three-viewer`.
51
+ *
52
+ * @module
53
+ */
54
+ export * from './ThreeViewer.js';
55
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AAEH,cAAc,kBAAkB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 3D model viewer wrapping three.js.
3
+ *
4
+ * Exports `<ThreeViewer>` — a React component that renders GLTF/GLB,
5
+ * OBJ, or STL models with orbit controls, lighting presets
6
+ * (`studio`/`sunset`/`flat`), auto-fit camera, and full GPU resource
7
+ * cleanup on unmount — plus the `resolveFormat()` and
8
+ * `disposeObject3D()` helpers and the prop types.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * import { ThreeViewer } from '@molecule/app-three-viewer-react'
13
+ *
14
+ * function ModelPage() {
15
+ * // Inline callbacks/arrays are safe — they do not tear down WebGL.
16
+ * return (
17
+ * <div style={{ height: 480 }}>
18
+ * <ThreeViewer
19
+ * src="/models/duck.glb"
20
+ * lighting="studio"
21
+ * autoRotate
22
+ * onLoad={() => console.log('loaded')}
23
+ * />
24
+ * </div>
25
+ * )
26
+ * }
27
+ * ```
28
+ *
29
+ * @remarks
30
+ * - Ships a REAL pinned `three` dependency — a substantial bundle-size
31
+ * add. Lazy-load the importing route/component (`React.lazy` / dynamic
32
+ * import) so non-3D pages do not pay for it.
33
+ * - The heavy setup effect (renderer creation + model download) depends
34
+ * ONLY on `src`/`format` (and `lighting`/`background`, which restructure
35
+ * the scene). `onLoad`, `onError`, `cameraTarget`, and `autoRotate` are
36
+ * routed through refs + light sync effects, so passing them inline (fresh
37
+ * arrows / `[x,y,z]` literals) does NOT tear down the WebGL context or
38
+ * re-download the model on a parent re-render. Changing `src`/`format`
39
+ * reloads the model; changing `cameraTarget`'s value re-aims the camera
40
+ * in place.
41
+ * - The wrapper is `width/height: 100%` with `min-height: 240px` — give
42
+ * the PARENT an explicit height or the canvas collapses to 240px.
43
+ * - Requires WebGL in a real browser: do not render during SSR and do
44
+ * not expect it to work under jsdom tests. Model URLs must be
45
+ * same-origin or CORS-enabled.
46
+ * - `format` is inferred from the `src` extension (unknown extensions
47
+ * fall back to `gltf`); pass it explicitly for extension-less URLs.
48
+ * - Must render inside the app's i18n provider and with a ClassMap bond
49
+ * wired (loading/error overlays use `useTranslation()`); translations
50
+ * ship in `@molecule/app-locales-feature-three-viewer`.
51
+ *
52
+ * @module
53
+ */
54
+ export * from './ThreeViewer.js';
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@molecule/app-three-viewer-react",
3
+ "version": "1.0.0",
4
+ "description": "WebGL 3D model viewer wrapping three.js — supports GLTF/GLB, OBJ, STL with orbit controls + lighting presets",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "molecule",
25
+ "three",
26
+ "viewer",
27
+ "3d",
28
+ "gltf",
29
+ "obj",
30
+ "stl",
31
+ "react"
32
+ ],
33
+ "license": "Apache-2.0",
34
+ "dependencies": {
35
+ "three": "0.185.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@molecule/app-react": "^1.0.0",
39
+ "@molecule/app-ui": "^1.0.0",
40
+ "@molecule/app-ui-react": "^1.0.0",
41
+ "react": "^18.0.0 || ^19.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@molecule/app-i18n": "1.0.0",
45
+ "@molecule/app-react": "1.0.0",
46
+ "@molecule/app-ui": "1.0.0",
47
+ "@molecule/app-ui-react": "1.0.0",
48
+ "@testing-library/react": "16.3.2",
49
+ "@types/node": "26.1.2",
50
+ "@types/react": "19.2.17",
51
+ "@types/three": "0.185.1",
52
+ "jsdom": "30.0.1",
53
+ "react": "19.2.8",
54
+ "react-dom": "19.2.8",
55
+ "typescript": "6.0.3",
56
+ "vitest": "4.1.10"
57
+ }
58
+ }