@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 +115 -0
- package/README.md +199 -0
- package/dist/ThreeViewer.d.ts +69 -0
- package/dist/ThreeViewer.d.ts.map +1 -0
- package/dist/ThreeViewer.js +349 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/package.json +58 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|