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 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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+ [![npm](https://img.shields.io/npm/v/explodeview)](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}">&#10227;</button>
131
+ <button class="stpv-cbtn" data-action="stop-rotate" title="${captions.titleStopRotate}">&#9632;</button>
132
+ <button class="stpv-cbtn" data-action="free-rotate" title="${captions.titleFreeRotate}">&#9978;</button>
133
+ <div class="stpv-cdiv"></div>
134
+ <button class="stpv-cbtn" data-action="reset" title="${captions.titleReset}">&#8634;</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
+ })();