explodeview 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 +21 -0
- package/README.md +217 -0
- package/bin/explodeview-process.py +306 -0
- package/package.json +38 -0
- package/src/explodeview.js +670 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sachin Kumar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# ExplodeView
|
|
2
|
+
|
|
3
|
+
**Turn any STEP/CAD file into an interactive 3D exploded-view on your website.**
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.npmjs.com/package/explodeview)
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<img src="docs/hero.gif" alt="ExplodeView Demo" width="720" />
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
ExplodeView takes STEP/STP CAD assembly files and creates embeddable, interactive 3D viewers with:
|
|
13
|
+
|
|
14
|
+
- **Exploded views** — blow apart assemblies to show internal components
|
|
15
|
+
- **Sub-assembly highlighting** — click any assembly to isolate and inspect it
|
|
16
|
+
- **Realistic materials** — brushed steel, matte plastic, rubber, metallic finishes
|
|
17
|
+
- **Full controls** — zoom, rotate, collapse/expand, auto-orbit
|
|
18
|
+
- **Customizable branding** — your logo, colors, captions
|
|
19
|
+
- **Zero dependencies** — single 27KB script, loads Three.js from CDN
|
|
20
|
+
- **Responsive** — works on desktop, tablet, mobile
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### 1. Process your STEP file
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install cadquery OCP
|
|
28
|
+
python3 bin/explodeview-process.py input.step output/ --name "My Product"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Embed in your page
|
|
32
|
+
|
|
33
|
+
```html
|
|
34
|
+
<div data-stp-viewer="/output/"
|
|
35
|
+
data-brand="Your Brand"
|
|
36
|
+
data-product-name="Product Name"
|
|
37
|
+
style="width:100%; height:600px">
|
|
38
|
+
</div>
|
|
39
|
+
<script src="https://unpkg.com/explodeview"></script>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That's it. Two lines.
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
### npm
|
|
47
|
+
```bash
|
|
48
|
+
npm install explodeview
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### CDN
|
|
52
|
+
```html
|
|
53
|
+
<script src="https://unpkg.com/explodeview"></script>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Self-hosted
|
|
57
|
+
Download `dist/explodeview.js` and serve it from your own server.
|
|
58
|
+
|
|
59
|
+
## Processing STEP Files
|
|
60
|
+
|
|
61
|
+
The CLI tool converts STEP/STP files into web-ready assets:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python3 bin/explodeview-process.py <input.step> <output_dir> [options]
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
--name Product display name
|
|
68
|
+
--brand Brand name
|
|
69
|
+
--tolerance Mesh quality (default: 0.5, lower = finer)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Requirements:** Python 3.8+ with `cadquery` and `OCP` packages.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install cadquery OCP
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Output structure:**
|
|
79
|
+
```
|
|
80
|
+
output/
|
|
81
|
+
├── parts/ # Individual STL meshes per solid
|
|
82
|
+
├── manifest.json # Part metadata (centers, bounding boxes)
|
|
83
|
+
├── assemblies.json # Auto-detected assembly grouping
|
|
84
|
+
└── config.json # Viewer configuration
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## JavaScript API
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
// Programmatic initialization
|
|
91
|
+
const viewer = await STPViewer.init({
|
|
92
|
+
container: '#my-viewer',
|
|
93
|
+
src: '/path/to/processed/assets/',
|
|
94
|
+
brand: 'Your Brand',
|
|
95
|
+
productName: 'Product Name',
|
|
96
|
+
assemblies: [], // auto-loaded from assemblies.json
|
|
97
|
+
captions: {
|
|
98
|
+
brand: 'Your Brand',
|
|
99
|
+
productName: 'Product Name',
|
|
100
|
+
loaderTitle: 'Loading...',
|
|
101
|
+
loaderText: 'Preparing 3D model...',
|
|
102
|
+
btnOverview: 'Overview',
|
|
103
|
+
btnCollapse: 'Collapse',
|
|
104
|
+
btnExplode: 'Explode',
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Custom Assemblies
|
|
110
|
+
|
|
111
|
+
Override auto-detected assemblies with your own grouping:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
STPViewer.init({
|
|
115
|
+
container: '#viewer',
|
|
116
|
+
src: '/assets/',
|
|
117
|
+
assemblies: [
|
|
118
|
+
{
|
|
119
|
+
key: 'frame',
|
|
120
|
+
name: 'MAIN FRAME',
|
|
121
|
+
subtitle: 'Structural Steel',
|
|
122
|
+
detail: 'Load-bearing frame assembly.',
|
|
123
|
+
color: '#0055A4',
|
|
124
|
+
indices: [0, 150], // solid index range
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
key: 'motor',
|
|
128
|
+
name: 'DRIVE UNIT',
|
|
129
|
+
subtitle: 'Electric Motor Assembly',
|
|
130
|
+
color: '#FFD100',
|
|
131
|
+
indices: [150, 200],
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Custom Materials
|
|
138
|
+
|
|
139
|
+
Each assembly can have custom material properties in the assemblies JSON:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"key": "covers",
|
|
144
|
+
"name": "PROTECTIVE COVERS",
|
|
145
|
+
"color": "#2A2A30",
|
|
146
|
+
"material": {
|
|
147
|
+
"metalness": 0.0,
|
|
148
|
+
"roughness": 0.85
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Features
|
|
154
|
+
|
|
155
|
+
| Feature | Free (MIT) | Pro |
|
|
156
|
+
|---------|-----------|-----|
|
|
157
|
+
| 3D exploded view | Yes | Yes |
|
|
158
|
+
| Assembly highlighting | Yes | Yes |
|
|
159
|
+
| Collapse/expand controls | Yes | Yes |
|
|
160
|
+
| Auto-rotate | Yes | Yes |
|
|
161
|
+
| Custom branding | Yes | Yes |
|
|
162
|
+
| Embed on your site | Yes | Yes |
|
|
163
|
+
| STEP processing CLI | Yes | Yes |
|
|
164
|
+
| Cloud processing API | — | Yes |
|
|
165
|
+
| Priority support | — | Yes |
|
|
166
|
+
| Custom materials | — | Yes |
|
|
167
|
+
| Animation export (video) | — | Yes |
|
|
168
|
+
| White-label (remove branding) | — | Yes |
|
|
169
|
+
| AR/VR export | — | Coming |
|
|
170
|
+
|
|
171
|
+
## Who is this for?
|
|
172
|
+
|
|
173
|
+
- **Manufacturing companies** — showcase products on your website
|
|
174
|
+
- **E-commerce** — interactive product pages that convert
|
|
175
|
+
- **Engineering docs** — maintenance and assembly manuals
|
|
176
|
+
- **Sales teams** — impressive presentations and proposals
|
|
177
|
+
- **Education** — teach mechanical engineering concepts
|
|
178
|
+
|
|
179
|
+
## Examples
|
|
180
|
+
|
|
181
|
+
### Full-page viewer
|
|
182
|
+
```html
|
|
183
|
+
<div id="viewer"
|
|
184
|
+
data-stp-viewer="/assets/"
|
|
185
|
+
data-brand="cycleWASH"
|
|
186
|
+
data-product-name="Station Basic"
|
|
187
|
+
style="width:100vw; height:100vh">
|
|
188
|
+
</div>
|
|
189
|
+
<script src="https://unpkg.com/explodeview"></script>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Embedded in a product page
|
|
193
|
+
```html
|
|
194
|
+
<div class="product-3d"
|
|
195
|
+
data-stp-viewer="/assets/my-machine/"
|
|
196
|
+
data-brand="ACME Corp"
|
|
197
|
+
data-product-name="Widget Pro 3000"
|
|
198
|
+
style="width:100%; height:500px; border-radius:12px; overflow:hidden">
|
|
199
|
+
</div>
|
|
200
|
+
<script src="https://unpkg.com/explodeview"></script>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Browser Support
|
|
204
|
+
|
|
205
|
+
Chrome 90+, Firefox 90+, Safari 15+, Edge 90+
|
|
206
|
+
|
|
207
|
+
## Contributing
|
|
208
|
+
|
|
209
|
+
PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
MIT — free for personal and commercial use.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
**Built by [Sachin Kumar](https://github.com/sachin)** — creator of [cycleWASH](https://cyclewash.com), the world's first automated bicycle washing station.
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
STP Viewer — Process a STEP file into viewer-ready assets.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 process-stp.py <input.step> <output_dir> [--assemblies auto|manual]
|
|
7
|
+
|
|
8
|
+
Output:
|
|
9
|
+
<output_dir>/
|
|
10
|
+
parts/ — Individual STL files per solid
|
|
11
|
+
manifest.json — Part metadata (centers, bounding boxes, file sizes)
|
|
12
|
+
assemblies.json — Assembly grouping (auto-detected from STEP hierarchy)
|
|
13
|
+
viewer.html — Standalone preview page
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
import os
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import argparse
|
|
22
|
+
|
|
23
|
+
def main():
|
|
24
|
+
parser = argparse.ArgumentParser(description='Process STEP file for 3D viewer widget')
|
|
25
|
+
parser.add_argument('input', help='Path to .step/.stp file')
|
|
26
|
+
parser.add_argument('output', help='Output directory for viewer assets')
|
|
27
|
+
parser.add_argument('--name', default=None, help='Product display name')
|
|
28
|
+
parser.add_argument('--brand', default='', help='Brand name')
|
|
29
|
+
parser.add_argument('--tolerance', type=float, default=0.5, help='Mesh tolerance (lower = finer)')
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
if not os.path.exists(args.input):
|
|
33
|
+
print(f"Error: File not found: {args.input}")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
parts_dir = os.path.join(args.output, 'parts')
|
|
37
|
+
os.makedirs(parts_dir, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
product_name = args.name or os.path.splitext(os.path.basename(args.input))[0]
|
|
40
|
+
|
|
41
|
+
print(f"╔══════════════════════════════════════╗")
|
|
42
|
+
print(f"║ STP Viewer — Processing STEP File ║")
|
|
43
|
+
print(f"╚══════════════════════════════════════╝")
|
|
44
|
+
print(f" Input: {args.input}")
|
|
45
|
+
print(f" Output: {args.output}")
|
|
46
|
+
print(f" Name: {product_name}")
|
|
47
|
+
print()
|
|
48
|
+
|
|
49
|
+
# ── Step 1: Extract solids ──
|
|
50
|
+
print("[1/4] Loading STEP file...")
|
|
51
|
+
from OCP.STEPControl import STEPControl_Reader
|
|
52
|
+
from OCP.TopExp import TopExp_Explorer
|
|
53
|
+
from OCP.TopAbs import TopAbs_SOLID
|
|
54
|
+
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
|
55
|
+
from OCP.BRepBndLib import BRepBndLib
|
|
56
|
+
from OCP.Bnd import Bnd_Box
|
|
57
|
+
from OCP.StlAPI import StlAPI_Writer
|
|
58
|
+
from OCP.IFSelect import IFSelect_RetDone
|
|
59
|
+
|
|
60
|
+
reader = STEPControl_Reader()
|
|
61
|
+
status = reader.ReadFile(args.input)
|
|
62
|
+
if status != IFSelect_RetDone:
|
|
63
|
+
print(f" Error: Failed to read STEP file (status={status})")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
reader.TransferRoots()
|
|
67
|
+
shape = reader.OneShape()
|
|
68
|
+
|
|
69
|
+
exp = TopExp_Explorer(shape, TopAbs_SOLID)
|
|
70
|
+
solids = []
|
|
71
|
+
while exp.More():
|
|
72
|
+
solids.append(exp.Current())
|
|
73
|
+
exp.Next()
|
|
74
|
+
|
|
75
|
+
print(f" Found {len(solids)} solids")
|
|
76
|
+
|
|
77
|
+
# ── Step 2: Export STL parts ──
|
|
78
|
+
print(f"[2/4] Exporting {len(solids)} parts...")
|
|
79
|
+
manifest = []
|
|
80
|
+
for idx, solid in enumerate(solids):
|
|
81
|
+
bbox = Bnd_Box()
|
|
82
|
+
BRepBndLib.Add_s(solid, bbox)
|
|
83
|
+
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
|
84
|
+
center = [(xmin+xmax)/2, (ymin+ymax)/2, (zmin+zmax)/2]
|
|
85
|
+
size = [xmax-xmin, ymax-ymin, zmax-zmin]
|
|
86
|
+
|
|
87
|
+
BRepMesh_IncrementalMesh(solid, args.tolerance, False, args.tolerance, True)
|
|
88
|
+
|
|
89
|
+
filename = f"part_{idx:04d}.stl"
|
|
90
|
+
filepath = os.path.join(parts_dir, filename)
|
|
91
|
+
writer = StlAPI_Writer()
|
|
92
|
+
writer.Write(solid, filepath)
|
|
93
|
+
|
|
94
|
+
file_size = os.path.getsize(filepath) if os.path.exists(filepath) else 0
|
|
95
|
+
|
|
96
|
+
manifest.append({
|
|
97
|
+
"index": idx,
|
|
98
|
+
"file": filename,
|
|
99
|
+
"center": [round(c, 2) for c in center],
|
|
100
|
+
"bbox": [round(s, 2) for s in size],
|
|
101
|
+
"fileSize": file_size,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (idx + 1) % 50 == 0 or idx == len(solids) - 1:
|
|
105
|
+
print(f" Exported {idx + 1}/{len(solids)}")
|
|
106
|
+
|
|
107
|
+
# Filter degenerate parts
|
|
108
|
+
valid = [p for p in manifest if p['fileSize'] > 2000]
|
|
109
|
+
print(f" Valid parts: {len(valid)} (filtered {len(manifest) - len(valid)} degenerate)")
|
|
110
|
+
|
|
111
|
+
manifest_path = os.path.join(args.output, 'manifest.json')
|
|
112
|
+
with open(manifest_path, 'w') as f:
|
|
113
|
+
json.dump(manifest, f, indent=2)
|
|
114
|
+
|
|
115
|
+
# ── Step 3: Auto-detect assemblies from STEP hierarchy ──
|
|
116
|
+
print("[3/4] Detecting assemblies from STEP hierarchy...")
|
|
117
|
+
assemblies = detect_assemblies(args.input, manifest)
|
|
118
|
+
|
|
119
|
+
asm_path = os.path.join(args.output, 'assemblies.json')
|
|
120
|
+
with open(asm_path, 'w') as f:
|
|
121
|
+
json.dump(assemblies, f, indent=2)
|
|
122
|
+
|
|
123
|
+
print(f" Found {len(assemblies)} assemblies:")
|
|
124
|
+
for a in assemblies:
|
|
125
|
+
count = a['indices'][1] - a['indices'][0]
|
|
126
|
+
print(f" {a['name']}: {count} parts [{a['indices'][0]}-{a['indices'][1]})")
|
|
127
|
+
|
|
128
|
+
# ── Step 4: Generate config ──
|
|
129
|
+
print("[4/4] Generating viewer config...")
|
|
130
|
+
config = {
|
|
131
|
+
"productName": product_name,
|
|
132
|
+
"brand": args.brand,
|
|
133
|
+
"totalParts": len(valid),
|
|
134
|
+
"totalSolids": len(manifest),
|
|
135
|
+
"assemblies": assemblies,
|
|
136
|
+
}
|
|
137
|
+
config_path = os.path.join(args.output, 'config.json')
|
|
138
|
+
with open(config_path, 'w') as f:
|
|
139
|
+
json.dump(config, f, indent=2)
|
|
140
|
+
|
|
141
|
+
# Copy viewer files
|
|
142
|
+
widget_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
143
|
+
dist_dir = os.path.join(widget_dir, 'dist')
|
|
144
|
+
if os.path.exists(dist_dir):
|
|
145
|
+
for fname in os.listdir(dist_dir):
|
|
146
|
+
src = os.path.join(dist_dir, fname)
|
|
147
|
+
dst = os.path.join(args.output, fname)
|
|
148
|
+
if os.path.isfile(src):
|
|
149
|
+
shutil.copy2(src, dst)
|
|
150
|
+
print(f" Copied viewer files to {args.output}")
|
|
151
|
+
|
|
152
|
+
print()
|
|
153
|
+
print(f"✓ Done! Assets ready in: {args.output}")
|
|
154
|
+
print(f" Embed in your page:")
|
|
155
|
+
print(f' <div id="stp-viewer" data-src="{args.output}"></div>')
|
|
156
|
+
print(f' <script src="{args.output}/stp-viewer.js"></script>')
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def detect_assemblies(step_file, manifest):
|
|
160
|
+
"""Parse STEP file to extract assembly hierarchy and map to solid indices."""
|
|
161
|
+
with open(step_file, 'r', errors='ignore') as f:
|
|
162
|
+
content = f.read()
|
|
163
|
+
|
|
164
|
+
# Parse NAUO (assembly relationships)
|
|
165
|
+
nauo_entries = re.findall(
|
|
166
|
+
r"NEXT_ASSEMBLY_USAGE_OCCURRENCE\s*\([^,]*,[^,]*,[^,]*,\s*#(\d+)\s*,\s*#(\d+)\s*,",
|
|
167
|
+
content, re.DOTALL
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
children = {}
|
|
171
|
+
for parent, child in nauo_entries:
|
|
172
|
+
if parent not in children:
|
|
173
|
+
children[parent] = []
|
|
174
|
+
children[parent].append(child)
|
|
175
|
+
|
|
176
|
+
# PD -> Product name mapping
|
|
177
|
+
pdf_to_prod = {}
|
|
178
|
+
for m in re.finditer(r"#(\d+)\s*=\s*PRODUCT_DEFINITION_FORMATION\s*\([^,]*,[^,]*,\s*#(\d+)\s*\)", content):
|
|
179
|
+
pdf_to_prod[m.group(1)] = m.group(2)
|
|
180
|
+
|
|
181
|
+
pd_to_pdf = {}
|
|
182
|
+
for m in re.finditer(r"#(\d+)\s*=\s*PRODUCT_DEFINITION\s*\([^,]*,[^,]*,\s*#(\d+)\s*,", content):
|
|
183
|
+
pd_to_pdf[m.group(1)] = m.group(2)
|
|
184
|
+
|
|
185
|
+
prod_names = {}
|
|
186
|
+
for m in re.finditer(r"#(\d+)\s*=\s*PRODUCT\s*\(\s*'([^']*)'", content):
|
|
187
|
+
prod_names[m.group(1)] = m.group(2)
|
|
188
|
+
|
|
189
|
+
pd_to_name = {}
|
|
190
|
+
for pd_id, pdf_id in pd_to_pdf.items():
|
|
191
|
+
prod_id = pdf_to_prod.get(pdf_id)
|
|
192
|
+
if prod_id and prod_id in prod_names:
|
|
193
|
+
pd_to_name[pd_id] = prod_names[prod_id]
|
|
194
|
+
|
|
195
|
+
# Find root
|
|
196
|
+
root = None
|
|
197
|
+
for pd_id, name in pd_to_name.items():
|
|
198
|
+
if pd_id in children and len(children[pd_id]) > 5:
|
|
199
|
+
root = pd_id
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
if not root:
|
|
203
|
+
# Fallback: spatial grouping
|
|
204
|
+
return auto_group_spatial(manifest)
|
|
205
|
+
|
|
206
|
+
# Get unique top-level children
|
|
207
|
+
top_kids = []
|
|
208
|
+
seen_names = set()
|
|
209
|
+
for kid in children.get(root, []):
|
|
210
|
+
name = pd_to_name.get(kid, '')
|
|
211
|
+
if name and name not in seen_names:
|
|
212
|
+
seen_names.add(name)
|
|
213
|
+
top_kids.append(kid)
|
|
214
|
+
|
|
215
|
+
if len(top_kids) < 2:
|
|
216
|
+
return auto_group_spatial(manifest)
|
|
217
|
+
|
|
218
|
+
# DFS to count leaf instances per top-level assembly
|
|
219
|
+
assemblies_set = set(children.keys())
|
|
220
|
+
|
|
221
|
+
def count_leaves(pd_id, visited=None):
|
|
222
|
+
if visited is None:
|
|
223
|
+
visited = set()
|
|
224
|
+
if pd_id in visited:
|
|
225
|
+
return 1
|
|
226
|
+
visited.add(pd_id)
|
|
227
|
+
kids = children.get(pd_id, [])
|
|
228
|
+
if not kids:
|
|
229
|
+
return 1
|
|
230
|
+
total = 0
|
|
231
|
+
for c in kids:
|
|
232
|
+
total += count_leaves(c, visited)
|
|
233
|
+
return total
|
|
234
|
+
|
|
235
|
+
# Build assembly ranges by DFS order
|
|
236
|
+
assemblies = []
|
|
237
|
+
current_idx = 0
|
|
238
|
+
|
|
239
|
+
# Palette for auto-coloring
|
|
240
|
+
palette = [
|
|
241
|
+
"#0055A4", "#FFD100", "#3A7FBF", "#FFBA00",
|
|
242
|
+
"#003D7A", "#FFC629", "#6699CC", "#FF8844",
|
|
243
|
+
"#44AA88", "#CC5577", "#8866DD", "#55BBAA",
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
for i, kid in enumerate(top_kids):
|
|
247
|
+
name = pd_to_name.get(kid, f'Assembly {i+1}')
|
|
248
|
+
leaf_count = count_leaves(kid)
|
|
249
|
+
|
|
250
|
+
# Clean up name: remove part numbers, decode unicode escapes
|
|
251
|
+
display_name = name
|
|
252
|
+
display_name = re.sub(r'\\X\\[A-Fa-f0-9]{2}', '', display_name)
|
|
253
|
+
display_name = re.sub(r'\d{4}\.\w+-[\d.]+-\d+_', '', display_name)
|
|
254
|
+
display_name = display_name.strip('_ ').upper() or f'ASSEMBLY {i+1}'
|
|
255
|
+
|
|
256
|
+
assemblies.append({
|
|
257
|
+
"key": name,
|
|
258
|
+
"name": display_name,
|
|
259
|
+
"subtitle": "",
|
|
260
|
+
"detail": "",
|
|
261
|
+
"color": palette[i % len(palette)],
|
|
262
|
+
"indices": [current_idx, current_idx + leaf_count],
|
|
263
|
+
})
|
|
264
|
+
current_idx += leaf_count
|
|
265
|
+
|
|
266
|
+
# Handle remaining unassigned parts
|
|
267
|
+
total_parts = len(manifest)
|
|
268
|
+
if current_idx < total_parts:
|
|
269
|
+
assemblies.append({
|
|
270
|
+
"key": "Other",
|
|
271
|
+
"name": "OTHER COMPONENTS",
|
|
272
|
+
"subtitle": "",
|
|
273
|
+
"detail": "",
|
|
274
|
+
"color": palette[len(assemblies) % len(palette)],
|
|
275
|
+
"indices": [current_idx, total_parts],
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
return assemblies
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def auto_group_spatial(manifest):
|
|
282
|
+
"""Fallback: group parts by spatial position."""
|
|
283
|
+
valid = [p for p in manifest if p['fileSize'] > 2000]
|
|
284
|
+
n = len(valid)
|
|
285
|
+
if n == 0:
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
# Simple approach: split into roughly equal groups by index
|
|
289
|
+
group_size = max(1, n // 6)
|
|
290
|
+
palette = ["#0055A4", "#FFD100", "#3A7FBF", "#FFBA00", "#003D7A", "#FFC629"]
|
|
291
|
+
assemblies = []
|
|
292
|
+
for i in range(0, n, group_size):
|
|
293
|
+
end = min(i + group_size, n)
|
|
294
|
+
assemblies.append({
|
|
295
|
+
"key": f"group_{len(assemblies)}",
|
|
296
|
+
"name": f"ASSEMBLY {len(assemblies) + 1}",
|
|
297
|
+
"subtitle": "",
|
|
298
|
+
"detail": "",
|
|
299
|
+
"color": palette[len(assemblies) % len(palette)],
|
|
300
|
+
"indices": [i, end],
|
|
301
|
+
})
|
|
302
|
+
return assemblies
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == '__main__':
|
|
306
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "explodeview",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Embeddable 3D exploded-view viewer for STEP/CAD files. Turn any CAD assembly into an interactive product showcase.",
|
|
5
|
+
"main": "src/explodeview.js",
|
|
6
|
+
"types": "src/explodeview.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "npx serve examples -p 3333",
|
|
9
|
+
"build": "cp src/explodeview.js dist/explodeview.js && echo 'Built to dist/'",
|
|
10
|
+
"process": "python3 bin/explodeview-process.py"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"3d-viewer",
|
|
14
|
+
"step-file",
|
|
15
|
+
"cad",
|
|
16
|
+
"exploded-view",
|
|
17
|
+
"three.js",
|
|
18
|
+
"manufacturing",
|
|
19
|
+
"product-page",
|
|
20
|
+
"embed",
|
|
21
|
+
"engineering",
|
|
22
|
+
"assembly"
|
|
23
|
+
],
|
|
24
|
+
"author": "Sachin Kumar",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"homepage": "https://github.com/sachin/explodeview",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/sachin/explodeview.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/sachin/explodeview/issues"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"src/explodeview.js",
|
|
36
|
+
"bin/explodeview-process.py"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STP Viewer Widget — Embeddable 3D exploded-view viewer for STEP files
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* <div id="stp-viewer" data-src="/path/to/processed/assets/"></div>
|
|
6
|
+
* <script src="/path/to/stp-viewer.js"></script>
|
|
7
|
+
*
|
|
8
|
+
* Or programmatic:
|
|
9
|
+
* STPViewer.init({ container: '#my-div', src: '/assets/', brand: 'cycleWASH' });
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
(function () {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0';
|
|
16
|
+
|
|
17
|
+
// ─── Styles ───
|
|
18
|
+
function injectStyles(container) {
|
|
19
|
+
const style = document.createElement('style');
|
|
20
|
+
style.textContent = `
|
|
21
|
+
.stpv { position:relative; width:100%; height:100%; min-height:500px; background:#0a0e1a; overflow:hidden; font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif; color:#fff; --blue:#0055A4; --yellow:#FFD100; }
|
|
22
|
+
.stpv canvas { display:block; width:100%; height:100%; }
|
|
23
|
+
|
|
24
|
+
/* Loader */
|
|
25
|
+
.stpv-loader { position:absolute; inset:0; background:#0a0e1a; display:flex; flex-direction:column; align-items:center; justify-content:center; z-index:100; transition:opacity 1s; }
|
|
26
|
+
.stpv-loader.hidden { opacity:0; pointer-events:none; }
|
|
27
|
+
.stpv-loader h2 { font-size:1.5rem; font-weight:200; letter-spacing:0.3em; }
|
|
28
|
+
.stpv-loader h2 b { font-weight:700; color:var(--yellow); }
|
|
29
|
+
.stpv-loader-bar { width:200px; height:2px; background:#1a1a2a; margin-top:1.2rem; border-radius:1px; overflow:hidden; }
|
|
30
|
+
.stpv-loader-fill { height:100%; width:0%; background:linear-gradient(90deg,var(--blue),var(--yellow)); transition:width 0.2s; }
|
|
31
|
+
.stpv-loader-text { font-size:0.6rem; color:#445; letter-spacing:0.12em; margin-top:0.8rem; }
|
|
32
|
+
|
|
33
|
+
/* Top bar */
|
|
34
|
+
.stpv-topbar { position:absolute; top:0; left:0; right:0; height:44px; display:flex; align-items:center; justify-content:space-between; padding:0 16px; background:rgba(0,0,0,0.5); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); border-bottom:1px solid rgba(255,255,255,0.06); z-index:20; }
|
|
35
|
+
.stpv-brand { font-size:0.75rem; font-weight:200; letter-spacing:0.2em; text-transform:uppercase; }
|
|
36
|
+
.stpv-brand b { font-weight:700; color:var(--yellow); }
|
|
37
|
+
.stpv-nav { display:flex; gap:4px; }
|
|
38
|
+
.stpv-btn { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); color:#889; padding:5px 14px; font-size:0.6rem; letter-spacing:0.1em; text-transform:uppercase; cursor:pointer; border-radius:4px; font-family:inherit; transition:all 0.25s; }
|
|
39
|
+
.stpv-btn:hover { background:rgba(0,85,164,0.12); border-color:rgba(0,85,164,0.4); color:#fff; }
|
|
40
|
+
.stpv-btn.active { background:rgba(0,85,164,0.18); border-color:rgba(0,85,164,0.5); color:#4da6ff; }
|
|
41
|
+
|
|
42
|
+
/* Left nav */
|
|
43
|
+
.stpv-leftnav { position:absolute; left:12px; top:50%; transform:translateY(-50%); z-index:15; display:flex; flex-direction:column; gap:3px; }
|
|
44
|
+
.stpv-nav-item { display:flex; align-items:center; gap:8px; padding:7px 10px; cursor:pointer; border:1px solid rgba(255,255,255,0.05); border-radius:5px; background:rgba(0,0,0,0.3); backdrop-filter:blur(6px); min-width:140px; transition:all 0.3s; }
|
|
45
|
+
.stpv-nav-item:hover { border-color:rgba(255,255,255,0.12); }
|
|
46
|
+
.stpv-nav-item.active { border-color:currentColor; background:rgba(0,85,164,0.1); }
|
|
47
|
+
.stpv-nav-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; transition:all 0.3s; }
|
|
48
|
+
.stpv-nav-item.active .stpv-nav-dot { width:9px; height:9px; box-shadow:0 0 8px currentColor; }
|
|
49
|
+
.stpv-nav-label { font-size:0.5rem; letter-spacing:0.08em; text-transform:uppercase; color:rgba(255,255,255,0.2); font-weight:500; transition:all 0.3s; white-space:nowrap; }
|
|
50
|
+
.stpv-nav-item.active .stpv-nav-label { color:inherit; font-weight:700; }
|
|
51
|
+
|
|
52
|
+
/* Right controls */
|
|
53
|
+
.stpv-controls { position:absolute; right:12px; top:50%; transform:translateY(-50%); z-index:15; display:flex; flex-direction:column; gap:3px; padding:6px; background:rgba(0,0,0,0.35); backdrop-filter:blur(10px); border:1px solid rgba(255,255,255,0.05); border-radius:8px; }
|
|
54
|
+
.stpv-cbtn { width:32px; height:32px; border-radius:5px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); color:#778; font-size:0.9rem; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:all 0.25s; font-family:inherit; }
|
|
55
|
+
.stpv-cbtn:hover { background:rgba(0,85,164,0.12); border-color:rgba(0,85,164,0.35); color:#fff; }
|
|
56
|
+
.stpv-cbtn.active { background:rgba(0,85,164,0.18); border-color:rgba(0,85,164,0.45); color:#4da6ff; }
|
|
57
|
+
.stpv-cdiv { width:20px; height:1px; background:rgba(255,255,255,0.06); margin:1px auto; }
|
|
58
|
+
|
|
59
|
+
/* Assembly info overlay */
|
|
60
|
+
.stpv-info { position:absolute; left:12px; bottom:16px; z-index:15; pointer-events:none; opacity:0; transform:translateY(10px); transition:all 0.5s; max-width:280px; }
|
|
61
|
+
.stpv-info.visible { opacity:1; transform:translateY(0); }
|
|
62
|
+
.stpv-info-num { font-size:0.5rem; letter-spacing:0.3em; font-weight:500; }
|
|
63
|
+
.stpv-info-name { font-size:1.5rem; font-weight:800; letter-spacing:0.02em; line-height:1.1; margin-top:4px; text-shadow:0 2px 10px rgba(0,0,0,0.5); }
|
|
64
|
+
.stpv-info-sub { font-size:0.7rem; font-weight:300; color:rgba(255,255,255,0.55); margin-top:4px; letter-spacing:0.04em; text-shadow:0 1px 4px rgba(0,0,0,0.4); }
|
|
65
|
+
.stpv-info-detail { font-size:0.6rem; color:rgba(255,255,255,0.35); margin-top:8px; line-height:1.6; }
|
|
66
|
+
.stpv-info-line { width:30px; height:2px; margin-top:8px; border-radius:1px; }
|
|
67
|
+
`;
|
|
68
|
+
container.appendChild(style);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── HTML Template ───
|
|
72
|
+
function buildUI(container, config) {
|
|
73
|
+
// All captions are customizable via config
|
|
74
|
+
const captions = Object.assign({
|
|
75
|
+
brand: '',
|
|
76
|
+
productName: '3D Viewer',
|
|
77
|
+
loaderTitle: '',
|
|
78
|
+
loaderText: 'Initializing 3D engine...',
|
|
79
|
+
btnOverview: 'Overview',
|
|
80
|
+
btnCollapse: 'Collapse',
|
|
81
|
+
btnExplode: 'Explode',
|
|
82
|
+
btnExpand: '+',
|
|
83
|
+
btnContract: '−',
|
|
84
|
+
btnAutoRotate: '↻',
|
|
85
|
+
btnStopRotate: '■',
|
|
86
|
+
btnFreeRotate: '⚘',
|
|
87
|
+
btnReset: '↺',
|
|
88
|
+
titleExpand: 'Expand explosion',
|
|
89
|
+
titleContract: 'Collapse',
|
|
90
|
+
titleAutoRotate: 'Auto Rotate',
|
|
91
|
+
titleStopRotate: 'Stop rotation',
|
|
92
|
+
titleFreeRotate: 'Free 3D rotate',
|
|
93
|
+
titleReset: 'Reset view',
|
|
94
|
+
}, config.captions || {});
|
|
95
|
+
|
|
96
|
+
// Build display title: "brand productName" or just productName
|
|
97
|
+
const brand = captions.brand || config.brand || '';
|
|
98
|
+
const product = captions.productName || config.productName || '';
|
|
99
|
+
const fullTitle = brand && product ? `${brand} ${product}` : brand || product || '3D Viewer';
|
|
100
|
+
|
|
101
|
+
// Loader title defaults to fullTitle
|
|
102
|
+
const loaderTitle = captions.loaderTitle || fullTitle;
|
|
103
|
+
|
|
104
|
+
// Format brand: bold the last word
|
|
105
|
+
function formatBrand(text) {
|
|
106
|
+
if (!text) return '3D Viewer';
|
|
107
|
+
return text.replace(/(\S+)\s*$/, '<b>$1</b>');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
container.innerHTML = `
|
|
111
|
+
<div class="stpv">
|
|
112
|
+
<div class="stpv-loader">
|
|
113
|
+
<h2>${formatBrand(loaderTitle)}</h2>
|
|
114
|
+
<div class="stpv-loader-bar"><div class="stpv-loader-fill"></div></div>
|
|
115
|
+
<div class="stpv-loader-text">${captions.loaderText}</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="stpv-topbar">
|
|
118
|
+
<div class="stpv-brand">${formatBrand(fullTitle)}</div>
|
|
119
|
+
<div class="stpv-nav">
|
|
120
|
+
<button class="stpv-btn active" data-action="overview">${captions.btnOverview}</button>
|
|
121
|
+
<button class="stpv-btn" data-action="collapse">${captions.btnCollapse}</button>
|
|
122
|
+
<button class="stpv-btn" data-action="explode">${captions.btnExplode}</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="stpv-leftnav"></div>
|
|
126
|
+
<div class="stpv-controls">
|
|
127
|
+
<button class="stpv-cbtn" data-action="expand" title="${captions.titleExpand}">${captions.btnExpand}</button>
|
|
128
|
+
<button class="stpv-cbtn" data-action="contract" title="${captions.titleContract}">${captions.btnContract}</button>
|
|
129
|
+
<div class="stpv-cdiv"></div>
|
|
130
|
+
<button class="stpv-cbtn active" data-action="auto-rotate" title="${captions.titleAutoRotate}">⟳</button>
|
|
131
|
+
<button class="stpv-cbtn" data-action="stop-rotate" title="${captions.titleStopRotate}">■</button>
|
|
132
|
+
<button class="stpv-cbtn" data-action="free-rotate" title="${captions.titleFreeRotate}">⛺</button>
|
|
133
|
+
<div class="stpv-cdiv"></div>
|
|
134
|
+
<button class="stpv-cbtn" data-action="reset" title="${captions.titleReset}">↺</button>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="stpv-info">
|
|
137
|
+
<div class="stpv-info-num"></div>
|
|
138
|
+
<div class="stpv-info-name"></div>
|
|
139
|
+
<div class="stpv-info-sub"></div>
|
|
140
|
+
<div class="stpv-info-detail"></div>
|
|
141
|
+
<div class="stpv-info-line"></div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
`;
|
|
145
|
+
injectStyles(container.querySelector('.stpv'));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Main Viewer Class ───
|
|
149
|
+
class STPViewerInstance {
|
|
150
|
+
constructor(container, config) {
|
|
151
|
+
this.el = typeof container === 'string' ? document.querySelector(container) : container;
|
|
152
|
+
this.config = config;
|
|
153
|
+
this.src = config.src.replace(/\/?$/, '/');
|
|
154
|
+
this.assemblies = config.assemblies || [];
|
|
155
|
+
this.parts = [];
|
|
156
|
+
this.asmData = [];
|
|
157
|
+
this.activeAssembly = -1;
|
|
158
|
+
this.explodeAmount = 0;
|
|
159
|
+
this.targetExplode = 0;
|
|
160
|
+
this.dimAmount = 0;
|
|
161
|
+
this.targetDim = 0;
|
|
162
|
+
this.explodeLevel = 0;
|
|
163
|
+
this.manualMode = false;
|
|
164
|
+
this.THREE = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async start() {
|
|
168
|
+
buildUI(this.el, this.config);
|
|
169
|
+
|
|
170
|
+
// Load Three.js from CDN
|
|
171
|
+
const THREE = await this._loadThree();
|
|
172
|
+
this.THREE = THREE;
|
|
173
|
+
|
|
174
|
+
// Load addons
|
|
175
|
+
const { OrbitControls } = await import(`${THREE_CDN}/examples/jsm/controls/OrbitControls.js`);
|
|
176
|
+
const { STLLoader } = await import(`${THREE_CDN}/examples/jsm/loaders/STLLoader.js`);
|
|
177
|
+
this.OrbitControls = OrbitControls;
|
|
178
|
+
this.STLLoader = STLLoader;
|
|
179
|
+
|
|
180
|
+
// Load config + manifest
|
|
181
|
+
await this._loadData();
|
|
182
|
+
|
|
183
|
+
// Setup scene
|
|
184
|
+
this._setupScene();
|
|
185
|
+
this._setupLights();
|
|
186
|
+
this._setupControls();
|
|
187
|
+
this._bindUI();
|
|
188
|
+
|
|
189
|
+
// Load parts
|
|
190
|
+
await this._loadParts();
|
|
191
|
+
|
|
192
|
+
// Start render
|
|
193
|
+
this._animate();
|
|
194
|
+
|
|
195
|
+
// Hide loader
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
this.el.querySelector('.stpv-loader').classList.add('hidden');
|
|
198
|
+
}, 500);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async _loadThree() {
|
|
202
|
+
if (window.THREE) return window.THREE;
|
|
203
|
+
const mod = await import(`${THREE_CDN}/build/three.module.js`);
|
|
204
|
+
return mod;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async _loadData() {
|
|
208
|
+
// Load manifest
|
|
209
|
+
const mRes = await fetch(this.src + 'manifest.json');
|
|
210
|
+
this.manifest = (await mRes.json()).filter(p => p.fileSize > 2000);
|
|
211
|
+
|
|
212
|
+
// Load assemblies if not provided in config
|
|
213
|
+
if (!this.assemblies.length) {
|
|
214
|
+
try {
|
|
215
|
+
const aRes = await fetch(this.src + 'assemblies.json');
|
|
216
|
+
this.assemblies = await aRes.json();
|
|
217
|
+
} catch (e) {
|
|
218
|
+
// Try config.json
|
|
219
|
+
try {
|
|
220
|
+
const cRes = await fetch(this.src + 'config.json');
|
|
221
|
+
const cfg = await cRes.json();
|
|
222
|
+
this.assemblies = cfg.assemblies || [];
|
|
223
|
+
if (cfg.productName) this.config.productName = cfg.productName;
|
|
224
|
+
} catch (e2) { /* no assemblies */ }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build left nav
|
|
229
|
+
this._buildLeftNav();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_buildLeftNav() {
|
|
233
|
+
const nav = this.el.querySelector('.stpv-leftnav');
|
|
234
|
+
nav.innerHTML = '';
|
|
235
|
+
this.assemblies.forEach((asm, i) => {
|
|
236
|
+
const item = document.createElement('div');
|
|
237
|
+
item.className = 'stpv-nav-item';
|
|
238
|
+
item.style.color = asm.color;
|
|
239
|
+
item.dataset.index = i;
|
|
240
|
+
item.innerHTML = `<div class="stpv-nav-dot" style="background:${asm.color}"></div><div class="stpv-nav-label">${asm.name}</div>`;
|
|
241
|
+
item.addEventListener('click', () => this._selectAssembly(i));
|
|
242
|
+
nav.appendChild(item);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_setupScene() {
|
|
247
|
+
const T = this.THREE;
|
|
248
|
+
const wrapper = this.el.querySelector('.stpv');
|
|
249
|
+
const w = wrapper.clientWidth, h = wrapper.clientHeight;
|
|
250
|
+
|
|
251
|
+
this.scene = new T.Scene();
|
|
252
|
+
this.scene.background = new T.Color(0x0a0e1a);
|
|
253
|
+
|
|
254
|
+
this.camera = new T.PerspectiveCamera(35, w / h, 1, 15000);
|
|
255
|
+
this.camera.position.set(3000, 1800, 3000);
|
|
256
|
+
|
|
257
|
+
this.renderer = new T.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
|
258
|
+
this.renderer.setSize(w, h);
|
|
259
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
260
|
+
this.renderer.shadowMap.enabled = true;
|
|
261
|
+
this.renderer.toneMapping = T.ACESFilmicToneMapping;
|
|
262
|
+
this.renderer.toneMappingExposure = 2.2;
|
|
263
|
+
|
|
264
|
+
// Insert canvas after loader
|
|
265
|
+
const loader = wrapper.querySelector('.stpv-loader');
|
|
266
|
+
wrapper.insertBefore(this.renderer.domElement, loader);
|
|
267
|
+
|
|
268
|
+
// Ground
|
|
269
|
+
const ground = new T.Mesh(
|
|
270
|
+
new T.PlaneGeometry(12000, 12000),
|
|
271
|
+
new T.MeshStandardMaterial({ color: 0x151a2a, metalness: 0.7, roughness: 0.3 })
|
|
272
|
+
);
|
|
273
|
+
ground.rotation.x = -Math.PI / 2;
|
|
274
|
+
ground.position.y = -500;
|
|
275
|
+
ground.receiveShadow = true;
|
|
276
|
+
this.scene.add(ground);
|
|
277
|
+
|
|
278
|
+
// Spotlight for highlighting
|
|
279
|
+
this.spot = new T.SpotLight(0xffffff, 0, 0, Math.PI / 5, 0.6, 1);
|
|
280
|
+
this.spot.position.set(0, 3000, 0);
|
|
281
|
+
this.scene.add(this.spot);
|
|
282
|
+
this.scene.add(this.spot.target);
|
|
283
|
+
|
|
284
|
+
// Resize observer
|
|
285
|
+
const ro = new ResizeObserver(() => {
|
|
286
|
+
const w2 = wrapper.clientWidth, h2 = wrapper.clientHeight;
|
|
287
|
+
this.camera.aspect = w2 / h2;
|
|
288
|
+
this.camera.updateProjectionMatrix();
|
|
289
|
+
this.renderer.setSize(w2, h2);
|
|
290
|
+
});
|
|
291
|
+
ro.observe(wrapper);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_setupLights() {
|
|
295
|
+
const T = this.THREE;
|
|
296
|
+
this.scene.add(new T.AmbientLight(0x8899bb, 1.5));
|
|
297
|
+
this.scene.add(new T.HemisphereLight(0xddeeff, 0x445566, 1.0));
|
|
298
|
+
|
|
299
|
+
const key = new T.DirectionalLight(0xfff5e6, 3.5);
|
|
300
|
+
key.position.set(2000, 3000, 1500);
|
|
301
|
+
key.castShadow = true;
|
|
302
|
+
this.scene.add(key);
|
|
303
|
+
|
|
304
|
+
this.scene.add(Object.assign(new T.DirectionalLight(0xaaccff, 1.8), { position: new T.Vector3(-1500, 1000, -1000) }));
|
|
305
|
+
this.scene.add(Object.assign(new T.DirectionalLight(0xffffff, 1.2), { position: new T.Vector3(0, 500, -2500) }));
|
|
306
|
+
this.scene.add(Object.assign(new T.DirectionalLight(0x0055A4, 0.8), { position: new T.Vector3(-500, 1500, -2000) }));
|
|
307
|
+
this.scene.add(Object.assign(new T.DirectionalLight(0xeeeeff, 0.5), { position: new T.Vector3(0, 4000, 0) }));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
_setupControls() {
|
|
311
|
+
this.controls = new this.OrbitControls(this.camera, this.renderer.domElement);
|
|
312
|
+
this.controls.enableDamping = true;
|
|
313
|
+
this.controls.dampingFactor = 0.05;
|
|
314
|
+
this.controls.target.set(0, 0, 0);
|
|
315
|
+
this.controls.autoRotate = true;
|
|
316
|
+
this.controls.autoRotateSpeed = 0.4;
|
|
317
|
+
this.controls.minDistance = 800;
|
|
318
|
+
this.controls.maxDistance = 5500;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async _loadParts() {
|
|
322
|
+
const T = this.THREE;
|
|
323
|
+
const loader = new this.STLLoader();
|
|
324
|
+
const fill = this.el.querySelector('.stpv-loader-fill');
|
|
325
|
+
const text = this.el.querySelector('.stpv-loader-text');
|
|
326
|
+
|
|
327
|
+
// Model center
|
|
328
|
+
let cx = 0, cy = 0, cz = 0;
|
|
329
|
+
for (const p of this.manifest) { cx += p.center[0]; cy += p.center[1]; cz += p.center[2]; }
|
|
330
|
+
this.modelCenter = new T.Vector3(cx / this.manifest.length, cy / this.manifest.length, cz / this.manifest.length);
|
|
331
|
+
|
|
332
|
+
// Assembly index per part
|
|
333
|
+
const partAsm = new Array(this.manifest.length).fill(-1);
|
|
334
|
+
for (let ai = 0; ai < this.assemblies.length; ai++) {
|
|
335
|
+
const [s, e] = this.assemblies[ai].indices;
|
|
336
|
+
for (let pi = s; pi < e; pi++) partAsm[pi] = ai;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Per-assembly data
|
|
340
|
+
this.asmData = this.assemblies.map(a => ({
|
|
341
|
+
...a, meshes: [], center: new T.Vector3(), colorObj: new T.Color(a.color),
|
|
342
|
+
}));
|
|
343
|
+
|
|
344
|
+
// Material presets per assembly key
|
|
345
|
+
const matPresets = {};
|
|
346
|
+
for (const asm of this.assemblies) {
|
|
347
|
+
const c = new T.Color(asm.color);
|
|
348
|
+
const hsl = {}; c.getHSL(hsl);
|
|
349
|
+
matPresets[asm.key] = {
|
|
350
|
+
color: new T.Color().setHSL(hsl.h, hsl.s * 0.4, 0.6),
|
|
351
|
+
metalness: 0.45, roughness: 0.35,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const total = this.manifest.length;
|
|
356
|
+
let loaded = 0;
|
|
357
|
+
|
|
358
|
+
const batchSize = 25;
|
|
359
|
+
for (let i = 0; i < total; i += batchSize) {
|
|
360
|
+
const batch = this.manifest.slice(i, i + batchSize);
|
|
361
|
+
await Promise.all(batch.map((partInfo, bi) => new Promise(resolve => {
|
|
362
|
+
const idx = i + bi;
|
|
363
|
+
loader.load(this.src + 'parts/' + partInfo.file, geo => {
|
|
364
|
+
geo.computeVertexNormals();
|
|
365
|
+
|
|
366
|
+
const ai = partAsm[idx];
|
|
367
|
+
const preset = ai >= 0 && this.assemblies[ai] ? matPresets[this.assemblies[ai].key] : null;
|
|
368
|
+
const baseColor = preset ? preset.color.clone() : new T.Color(0xb0b8c4);
|
|
369
|
+
const baseMetal = preset ? preset.metalness : 0.4;
|
|
370
|
+
const baseRough = preset ? preset.roughness : 0.35;
|
|
371
|
+
|
|
372
|
+
const mat = new T.MeshStandardMaterial({
|
|
373
|
+
color: baseColor, metalness: baseMetal, roughness: baseRough, side: T.DoubleSide,
|
|
374
|
+
});
|
|
375
|
+
const mesh = new T.Mesh(geo, mat);
|
|
376
|
+
mesh.castShadow = true;
|
|
377
|
+
mesh.receiveShadow = true;
|
|
378
|
+
|
|
379
|
+
const mc = this.modelCenter;
|
|
380
|
+
const oc = new T.Vector3(partInfo.center[0] - mc.x, partInfo.center[2] - mc.z, -(partInfo.center[1] - mc.y));
|
|
381
|
+
geo.translate(-mc.x, -mc.z, mc.y);
|
|
382
|
+
|
|
383
|
+
const dir = oc.clone();
|
|
384
|
+
if (dir.length() < 1) dir.set(0, 1, 0);
|
|
385
|
+
dir.normalize();
|
|
386
|
+
|
|
387
|
+
const vol = partInfo.bbox[0] * partInfo.bbox[1] * partInfo.bbox[2];
|
|
388
|
+
let dist;
|
|
389
|
+
if (vol > 50000000) dist = 250 + Math.random() * 120;
|
|
390
|
+
else if (vol > 1000000) dist = 400 + Math.random() * 300;
|
|
391
|
+
else dist = 600 + Math.random() * 500;
|
|
392
|
+
|
|
393
|
+
mesh.userData = { idx, asmIdx: ai, explodeDir: dir, explodeDist: dist, origCenter: oc, baseColor, baseMetal, baseRough, _currentExplode: 0 };
|
|
394
|
+
|
|
395
|
+
this.scene.add(mesh);
|
|
396
|
+
this.parts.push(mesh);
|
|
397
|
+
if (ai >= 0) this.asmData[ai].meshes.push(mesh);
|
|
398
|
+
|
|
399
|
+
loaded++;
|
|
400
|
+
fill.style.width = (loaded / total * 100) + '%';
|
|
401
|
+
text.textContent = `${loaded} / ${total}`;
|
|
402
|
+
resolve();
|
|
403
|
+
}, undefined, () => { loaded++; resolve(); });
|
|
404
|
+
})));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Compute assembly centers
|
|
408
|
+
for (const ad of this.asmData) {
|
|
409
|
+
if (!ad.meshes.length) continue;
|
|
410
|
+
const c = new T.Vector3();
|
|
411
|
+
for (const m of ad.meshes) c.add(m.userData.origCenter);
|
|
412
|
+
c.divideScalar(ad.meshes.length);
|
|
413
|
+
ad.center.copy(c);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
_selectAssembly(i) {
|
|
418
|
+
if (this.activeAssembly === i) {
|
|
419
|
+
// Deselect
|
|
420
|
+
this.activeAssembly = -1;
|
|
421
|
+
this.targetDim = 0;
|
|
422
|
+
this._hideInfo();
|
|
423
|
+
} else {
|
|
424
|
+
this.activeAssembly = i;
|
|
425
|
+
this.targetExplode = 1;
|
|
426
|
+
this.targetDim = 1;
|
|
427
|
+
if (this.explodeLevel < 0.5) this.explodeLevel = 1;
|
|
428
|
+
this.manualMode = true;
|
|
429
|
+
this._showInfo(i);
|
|
430
|
+
}
|
|
431
|
+
this._updateNavActive();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
_updateNavActive() {
|
|
435
|
+
this.el.querySelectorAll('.stpv-nav-item').forEach((item, i) => {
|
|
436
|
+
item.classList.toggle('active', i === this.activeAssembly);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
_showInfo(i) {
|
|
441
|
+
const asm = this.assemblies[i];
|
|
442
|
+
const info = this.el.querySelector('.stpv-info');
|
|
443
|
+
info.querySelector('.stpv-info-num').textContent = `0${i + 1} / 0${this.assemblies.length}`;
|
|
444
|
+
info.querySelector('.stpv-info-num').style.color = asm.color;
|
|
445
|
+
info.querySelector('.stpv-info-name').textContent = asm.name;
|
|
446
|
+
info.querySelector('.stpv-info-name').style.color = asm.color;
|
|
447
|
+
info.querySelector('.stpv-info-sub').textContent = asm.subtitle || '';
|
|
448
|
+
info.querySelector('.stpv-info-detail').textContent = asm.detail || '';
|
|
449
|
+
info.querySelector('.stpv-info-line').style.background = asm.color;
|
|
450
|
+
info.classList.add('visible');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
_hideInfo() {
|
|
454
|
+
this.el.querySelector('.stpv-info').classList.remove('visible');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
_bindUI() {
|
|
458
|
+
const self = this;
|
|
459
|
+
|
|
460
|
+
// Top bar buttons
|
|
461
|
+
this.el.querySelectorAll('.stpv-btn').forEach(btn => {
|
|
462
|
+
btn.addEventListener('click', () => {
|
|
463
|
+
const action = btn.dataset.action;
|
|
464
|
+
self.el.querySelectorAll('.stpv-btn').forEach(b => b.classList.remove('active'));
|
|
465
|
+
btn.classList.add('active');
|
|
466
|
+
|
|
467
|
+
if (action === 'overview') {
|
|
468
|
+
self.manualMode = false;
|
|
469
|
+
self.explodeLevel = 0;
|
|
470
|
+
self.targetExplode = 0;
|
|
471
|
+
self.targetDim = 0;
|
|
472
|
+
self.activeAssembly = -1;
|
|
473
|
+
self._hideInfo();
|
|
474
|
+
self._updateNavActive();
|
|
475
|
+
} else if (action === 'collapse') {
|
|
476
|
+
self.manualMode = true;
|
|
477
|
+
self.targetExplode = 0;
|
|
478
|
+
self.explodeLevel = 0;
|
|
479
|
+
self.targetDim = 0;
|
|
480
|
+
self.activeAssembly = -1;
|
|
481
|
+
self._hideInfo();
|
|
482
|
+
self._updateNavActive();
|
|
483
|
+
} else if (action === 'explode') {
|
|
484
|
+
self.manualMode = true;
|
|
485
|
+
self.targetExplode = 1;
|
|
486
|
+
self.explodeLevel = 1;
|
|
487
|
+
self.targetDim = 0;
|
|
488
|
+
self.activeAssembly = -1;
|
|
489
|
+
self._hideInfo();
|
|
490
|
+
self._updateNavActive();
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Right controls
|
|
496
|
+
this.el.querySelectorAll('.stpv-cbtn').forEach(btn => {
|
|
497
|
+
btn.addEventListener('click', () => {
|
|
498
|
+
const action = btn.dataset.action;
|
|
499
|
+
|
|
500
|
+
if (action === 'expand') {
|
|
501
|
+
self.explodeLevel = Math.min(3.0, self.explodeLevel + 0.25);
|
|
502
|
+
self.manualMode = true;
|
|
503
|
+
self.targetExplode = Math.min(self.explodeLevel, 1);
|
|
504
|
+
self.targetDim = 0;
|
|
505
|
+
self.activeAssembly = -1;
|
|
506
|
+
self._hideInfo();
|
|
507
|
+
self._updateNavActive();
|
|
508
|
+
} else if (action === 'contract') {
|
|
509
|
+
self.explodeLevel = Math.max(0, self.explodeLevel - 0.25);
|
|
510
|
+
self.manualMode = true;
|
|
511
|
+
self.targetExplode = Math.min(self.explodeLevel, 1);
|
|
512
|
+
self.targetDim = 0;
|
|
513
|
+
self.activeAssembly = -1;
|
|
514
|
+
self._hideInfo();
|
|
515
|
+
self._updateNavActive();
|
|
516
|
+
} else if (action === 'auto-rotate') {
|
|
517
|
+
self.controls.autoRotate = true;
|
|
518
|
+
self.controls.autoRotateSpeed = 0.4;
|
|
519
|
+
self._setRotateActive(btn);
|
|
520
|
+
} else if (action === 'stop-rotate') {
|
|
521
|
+
self.controls.autoRotate = false;
|
|
522
|
+
self._setRotateActive(btn);
|
|
523
|
+
} else if (action === 'free-rotate') {
|
|
524
|
+
self.controls.autoRotate = false;
|
|
525
|
+
self._setRotateActive(btn);
|
|
526
|
+
} else if (action === 'reset') {
|
|
527
|
+
self.camera.position.set(3000, 1800, 3000);
|
|
528
|
+
self.controls.target.set(0, 0, 0);
|
|
529
|
+
self.controls.autoRotate = true;
|
|
530
|
+
self.controls.autoRotateSpeed = 0.4;
|
|
531
|
+
self.manualMode = false;
|
|
532
|
+
self.explodeLevel = 0;
|
|
533
|
+
self.targetExplode = 0;
|
|
534
|
+
self.targetDim = 0;
|
|
535
|
+
self.activeAssembly = -1;
|
|
536
|
+
self._hideInfo();
|
|
537
|
+
self._updateNavActive();
|
|
538
|
+
self._setRotateActive(self.el.querySelector('[data-action="auto-rotate"]'));
|
|
539
|
+
self.el.querySelectorAll('.stpv-btn').forEach(b => b.classList.remove('active'));
|
|
540
|
+
self.el.querySelector('[data-action="overview"]').classList.add('active');
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
_setRotateActive(btn) {
|
|
547
|
+
this.el.querySelectorAll('.stpv-cbtn[data-action*="rotate"]').forEach(b => b.classList.remove('active'));
|
|
548
|
+
btn.classList.add('active');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
_animate() {
|
|
552
|
+
const T = this.THREE;
|
|
553
|
+
const clock = new T.Clock();
|
|
554
|
+
const lerp = (a, b, t) => a + (b - a) * Math.min(1, t);
|
|
555
|
+
|
|
556
|
+
const tick = () => {
|
|
557
|
+
requestAnimationFrame(tick);
|
|
558
|
+
const delta = Math.min(clock.getDelta(), 0.05);
|
|
559
|
+
|
|
560
|
+
// Smooth transitions
|
|
561
|
+
this.explodeAmount = lerp(this.explodeAmount, this.targetExplode, delta * 3);
|
|
562
|
+
this.dimAmount = lerp(this.dimAmount, this.targetDim, delta * 4);
|
|
563
|
+
|
|
564
|
+
const scale = this.manualMode ? Math.max(this.explodeLevel, 1) : 1;
|
|
565
|
+
const hlColor = this.activeAssembly >= 0 ? this.asmData[this.activeAssembly].colorObj : null;
|
|
566
|
+
|
|
567
|
+
for (const mesh of this.parts) {
|
|
568
|
+
const ud = mesh.userData;
|
|
569
|
+
const isHl = (ud.asmIdx === this.activeAssembly);
|
|
570
|
+
|
|
571
|
+
// Per-part explode: highlighted collapses, others stay
|
|
572
|
+
let partExplode;
|
|
573
|
+
if (this.activeAssembly >= 0 && this.dimAmount > 0.1) {
|
|
574
|
+
partExplode = isHl ? this.explodeAmount * (1 - this.dimAmount) : this.explodeAmount;
|
|
575
|
+
} else {
|
|
576
|
+
partExplode = this.explodeAmount;
|
|
577
|
+
}
|
|
578
|
+
ud._currentExplode = lerp(ud._currentExplode, partExplode, delta * 4);
|
|
579
|
+
|
|
580
|
+
const ed = ud.explodeDist * ud._currentExplode * scale;
|
|
581
|
+
mesh.position.set(ud.explodeDir.x * ed, ud.explodeDir.y * ed, ud.explodeDir.z * ed);
|
|
582
|
+
|
|
583
|
+
// Color
|
|
584
|
+
const mat = mesh.material;
|
|
585
|
+
if (this.dimAmount > 0.05 && this.activeAssembly >= 0) {
|
|
586
|
+
if (isHl) {
|
|
587
|
+
mat.color.lerp(hlColor, delta * 6);
|
|
588
|
+
mat.emissive.copy(hlColor).multiplyScalar(0.1 * this.dimAmount);
|
|
589
|
+
mat.metalness = lerp(mat.metalness, Math.min(ud.baseMetal + 0.15, 0.8), delta * 4);
|
|
590
|
+
mat.roughness = lerp(mat.roughness, Math.max(ud.baseRough - 0.1, 0.1), delta * 4);
|
|
591
|
+
mat.transparent = false; mat.opacity = 1;
|
|
592
|
+
} else {
|
|
593
|
+
mat.color.lerp(new T.Color(0x181820), delta * 5);
|
|
594
|
+
mat.emissive.setHex(0x000000);
|
|
595
|
+
mat.transparent = true;
|
|
596
|
+
mat.opacity = lerp(mat.opacity, 1 - this.dimAmount * 0.7, delta * 4);
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
mat.color.lerp(ud.baseColor, delta * 3);
|
|
600
|
+
mat.emissive.lerp(new T.Color(0), delta * 5);
|
|
601
|
+
mat.metalness = lerp(mat.metalness, ud.baseMetal, delta * 3);
|
|
602
|
+
mat.roughness = lerp(mat.roughness, ud.baseRough, delta * 3);
|
|
603
|
+
mat.transparent = false; mat.opacity = 1;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Spotlight
|
|
608
|
+
if (this.activeAssembly >= 0 && this.dimAmount > 0.3) {
|
|
609
|
+
const ac = this.asmData[this.activeAssembly].center;
|
|
610
|
+
this.spot.intensity = lerp(this.spot.intensity, 4 * this.dimAmount, delta * 3);
|
|
611
|
+
this.spot.position.lerp(new T.Vector3(ac.x * 0.5, 3000, ac.z * 0.5), delta * 2);
|
|
612
|
+
this.spot.target.position.lerp(new T.Vector3(ac.x * 0.5, ac.y * 0.5, ac.z * 0.5), delta * 2);
|
|
613
|
+
} else {
|
|
614
|
+
this.spot.intensity = lerp(this.spot.intensity, 0, delta * 3);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
this.controls.update();
|
|
618
|
+
this.renderer.render(this.scene, this.camera);
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
tick();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ─── Public API ───
|
|
626
|
+
window.STPViewer = {
|
|
627
|
+
init: async function (opts) {
|
|
628
|
+
const container = typeof opts.container === 'string' ? document.querySelector(opts.container) : opts.container;
|
|
629
|
+
if (!container) { console.error('STPViewer: container not found'); return; }
|
|
630
|
+
|
|
631
|
+
const viewer = new STPViewerInstance(container, {
|
|
632
|
+
src: opts.src,
|
|
633
|
+
brand: opts.brand || '',
|
|
634
|
+
productName: opts.productName || '',
|
|
635
|
+
assemblies: opts.assemblies || [],
|
|
636
|
+
captions: opts.captions || {},
|
|
637
|
+
});
|
|
638
|
+
await viewer.start();
|
|
639
|
+
return viewer;
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
// Auto-init from data attributes
|
|
643
|
+
autoInit: function () {
|
|
644
|
+
document.querySelectorAll('[data-stp-viewer]').forEach(async el => {
|
|
645
|
+
const src = el.dataset.stpViewer || el.dataset.src;
|
|
646
|
+
if (!src) return;
|
|
647
|
+
const viewer = new STPViewerInstance(el, {
|
|
648
|
+
src,
|
|
649
|
+
brand: el.dataset.brand || '',
|
|
650
|
+
productName: el.dataset.productName || '',
|
|
651
|
+
assemblies: [],
|
|
652
|
+
captions: {
|
|
653
|
+
brand: el.dataset.brand || '',
|
|
654
|
+
productName: el.dataset.productName || '',
|
|
655
|
+
loaderTitle: el.dataset.loaderTitle || '',
|
|
656
|
+
loaderText: el.dataset.loaderText || undefined,
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
await viewer.start();
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
// Auto-init on DOM ready
|
|
665
|
+
if (document.readyState === 'loading') {
|
|
666
|
+
document.addEventListener('DOMContentLoaded', () => STPViewer.autoInit());
|
|
667
|
+
} else {
|
|
668
|
+
STPViewer.autoInit();
|
|
669
|
+
}
|
|
670
|
+
})();
|