explodeview 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1281 @@
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
+ /* AI Render button glow */
69
+ .stpv-cbtn.stpv-ai-btn { position:relative; }
70
+ .stpv-cbtn.stpv-ai-btn::after { content:''; position:absolute; inset:-2px; border-radius:7px; background:linear-gradient(135deg,rgba(0,85,164,0.3),rgba(255,209,0,0.3)); opacity:0; transition:opacity 0.3s; z-index:-1; }
71
+ .stpv-cbtn.stpv-ai-btn:hover::after { opacity:1; }
72
+
73
+ /* AI Render slide-out panel */
74
+ .stpv-ai-panel { position:absolute; top:0; right:0; bottom:0; width:340px; max-width:85%; background:rgba(10,14,26,0.96); backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px); border-left:1px solid rgba(255,255,255,0.06); z-index:30; transform:translateX(100%); transition:transform 0.35s cubic-bezier(0.4,0,0.2,1); display:flex; flex-direction:column; }
75
+ .stpv-ai-panel.open { transform:translateX(0); }
76
+ .stpv-ai-panel-header { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid rgba(255,255,255,0.06); flex-shrink:0; }
77
+ .stpv-ai-panel-title { font-size:0.7rem; font-weight:600; letter-spacing:0.12em; text-transform:uppercase; background:linear-gradient(90deg,#4da6ff,#FFD100); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
78
+ .stpv-ai-close { background:none; border:none; color:#667; font-size:1.1rem; cursor:pointer; padding:4px 8px; border-radius:4px; transition:all 0.2s; }
79
+ .stpv-ai-close:hover { color:#fff; background:rgba(255,255,255,0.06); }
80
+ .stpv-ai-body { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:16px; }
81
+ .stpv-ai-body::-webkit-scrollbar { width:4px; }
82
+ .stpv-ai-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); border-radius:2px; }
83
+
84
+ /* AI Panel form elements */
85
+ .stpv-ai-section { display:flex; flex-direction:column; gap:6px; }
86
+ .stpv-ai-label { font-size:0.55rem; font-weight:500; letter-spacing:0.12em; text-transform:uppercase; color:#556; }
87
+ .stpv-ai-textarea { width:100%; min-height:72px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:6px; color:#ccd; font-size:0.75rem; padding:10px 12px; font-family:inherit; resize:vertical; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
88
+ .stpv-ai-textarea:focus { border-color:rgba(0,85,164,0.5); }
89
+ .stpv-ai-textarea::placeholder { color:#334; }
90
+
91
+ .stpv-ai-select { width:100%; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:6px; color:#ccd; font-size:0.72rem; padding:8px 12px; font-family:inherit; outline:none; cursor:pointer; appearance:none; -webkit-appearance:none; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%23556' stroke-width='1.5'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 10px center; box-sizing:border-box; }
92
+ .stpv-ai-select:focus { border-color:rgba(0,85,164,0.5); }
93
+ .stpv-ai-select option { background:#0a0e1a; color:#ccd; }
94
+
95
+ /* Style presets */
96
+ .stpv-ai-presets { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
97
+ .stpv-ai-preset { padding:8px 10px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:6px; cursor:pointer; text-align:center; font-size:0.6rem; letter-spacing:0.06em; color:#667; transition:all 0.2s; }
98
+ .stpv-ai-preset:hover { border-color:rgba(255,255,255,0.15); color:#99a; }
99
+ .stpv-ai-preset.active { border-color:rgba(0,85,164,0.5); background:rgba(0,85,164,0.1); color:#4da6ff; }
100
+ .stpv-ai-preset-icon { font-size:1rem; margin-bottom:3px; display:block; }
101
+
102
+ /* Materials chips */
103
+ .stpv-ai-chips { display:flex; flex-wrap:wrap; gap:4px; }
104
+ .stpv-ai-chip { display:flex; align-items:center; gap:4px; padding:3px 8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); border-radius:12px; font-size:0.55rem; color:#889; }
105
+ .stpv-ai-chip-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
106
+
107
+ /* Generate button */
108
+ .stpv-ai-generate { width:100%; padding:11px 16px; background:linear-gradient(135deg,#0055A4,#003d75); border:1px solid rgba(0,85,164,0.5); border-radius:6px; color:#fff; font-size:0.7rem; font-weight:600; letter-spacing:0.1em; text-transform:uppercase; cursor:pointer; font-family:inherit; transition:all 0.25s; flex-shrink:0; }
109
+ .stpv-ai-generate:hover { background:linear-gradient(135deg,#0066c4,#0055A4); box-shadow:0 4px 20px rgba(0,85,164,0.3); }
110
+ .stpv-ai-generate:disabled { opacity:0.4; cursor:not-allowed; }
111
+ .stpv-ai-generate.generating { animation:stpv-pulse 1.5s ease infinite; }
112
+ @keyframes stpv-pulse { 0%,100%{opacity:1} 50%{opacity:0.6} }
113
+
114
+ /* API key input */
115
+ .stpv-ai-key-input { width:100%; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:6px; color:#ccd; font-size:0.7rem; padding:8px 12px; font-family:monospace; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
116
+ .stpv-ai-key-input:focus { border-color:rgba(0,85,164,0.5); }
117
+ .stpv-ai-key-input::placeholder { color:#334; font-family:inherit; }
118
+ .stpv-ai-key-status { font-size:0.55rem; color:#556; margin-top:2px; }
119
+ .stpv-ai-key-status.ok { color:#4a8; }
120
+ .stpv-ai-key-status.err { color:#c55; }
121
+
122
+ /* Gallery */
123
+ .stpv-ai-gallery { display:flex; flex-direction:column; gap:8px; }
124
+ .stpv-ai-gallery-item { position:relative; border-radius:6px; overflow:hidden; border:1px solid rgba(255,255,255,0.06); cursor:pointer; transition:border-color 0.2s; }
125
+ .stpv-ai-gallery-item:hover { border-color:rgba(255,255,255,0.15); }
126
+ .stpv-ai-gallery-item img { width:100%; display:block; }
127
+ .stpv-ai-gallery-actions { position:absolute; bottom:0; left:0; right:0; padding:6px 8px; background:linear-gradient(transparent,rgba(0,0,0,0.8)); display:flex; gap:4px; justify-content:flex-end; opacity:0; transition:opacity 0.2s; }
128
+ .stpv-ai-gallery-item:hover .stpv-ai-gallery-actions { opacity:1; }
129
+ .stpv-ai-dl-btn { padding:3px 8px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:#dde; font-size:0.5rem; cursor:pointer; font-family:inherit; letter-spacing:0.06em; text-transform:uppercase; transition:all 0.2s; }
130
+ .stpv-ai-dl-btn:hover { background:rgba(0,85,164,0.3); border-color:rgba(0,85,164,0.5); }
131
+
132
+ /* Empty gallery state */
133
+ .stpv-ai-empty { text-align:center; padding:24px 16px; color:#334; }
134
+ .stpv-ai-empty-icon { font-size:2rem; margin-bottom:8px; opacity:0.4; }
135
+ .stpv-ai-empty-text { font-size:0.6rem; line-height:1.6; }
136
+
137
+ /* Preview thumbnail */
138
+ .stpv-ai-preview { border-radius:6px; overflow:hidden; border:1px solid rgba(255,255,255,0.06); }
139
+ .stpv-ai-preview img { width:100%; display:block; }
140
+ .stpv-ai-preview-label { font-size:0.5rem; color:#445; text-align:center; padding:4px; letter-spacing:0.08em; }
141
+ `;
142
+ container.appendChild(style);
143
+ }
144
+
145
+ // ─── HTML Template ───
146
+ function buildUI(container, config) {
147
+ // All captions are customizable via config
148
+ const captions = Object.assign({
149
+ brand: '',
150
+ productName: '3D Viewer',
151
+ loaderTitle: '',
152
+ loaderText: 'Initializing 3D engine...',
153
+ btnOverview: 'Overview',
154
+ btnCollapse: 'Collapse',
155
+ btnExplode: 'Explode',
156
+ btnExpand: '+',
157
+ btnContract: '−',
158
+ btnAutoRotate: '↻',
159
+ btnStopRotate: '■',
160
+ btnFreeRotate: '⚘',
161
+ btnReset: '↺',
162
+ titleExpand: 'Expand explosion',
163
+ titleContract: 'Collapse',
164
+ titleAutoRotate: 'Auto Rotate',
165
+ titleStopRotate: 'Stop rotation',
166
+ titleFreeRotate: 'Free 3D rotate',
167
+ titleReset: 'Reset view',
168
+ }, config.captions || {});
169
+
170
+ // Build display title: "brand productName" or just productName
171
+ const brand = captions.brand || config.brand || '';
172
+ const product = captions.productName || config.productName || '';
173
+ const fullTitle = brand && product ? `${brand} ${product}` : brand || product || '3D Viewer';
174
+
175
+ // Loader title defaults to fullTitle
176
+ const loaderTitle = captions.loaderTitle || fullTitle;
177
+
178
+ // Format brand: bold the last word
179
+ function formatBrand(text) {
180
+ if (!text) return '3D Viewer';
181
+ return text.replace(/(\S+)\s*$/, '<b>$1</b>');
182
+ }
183
+
184
+ container.innerHTML = `
185
+ <div class="stpv">
186
+ <div class="stpv-loader">
187
+ <h2>${formatBrand(loaderTitle)}</h2>
188
+ <div class="stpv-loader-bar"><div class="stpv-loader-fill"></div></div>
189
+ <div class="stpv-loader-text">${captions.loaderText}</div>
190
+ </div>
191
+ <div class="stpv-topbar">
192
+ <div class="stpv-brand">${formatBrand(fullTitle)}</div>
193
+ <div class="stpv-nav">
194
+ <button class="stpv-btn active" data-action="overview">${captions.btnOverview}</button>
195
+ <button class="stpv-btn" data-action="collapse">${captions.btnCollapse}</button>
196
+ <button class="stpv-btn" data-action="explode">${captions.btnExplode}</button>
197
+ </div>
198
+ </div>
199
+ <div class="stpv-leftnav"></div>
200
+ <div class="stpv-controls">
201
+ <button class="stpv-cbtn" data-action="expand" title="${captions.titleExpand}">${captions.btnExpand}</button>
202
+ <button class="stpv-cbtn" data-action="contract" title="${captions.titleContract}">${captions.btnContract}</button>
203
+ <div class="stpv-cdiv"></div>
204
+ <button class="stpv-cbtn active" data-action="auto-rotate" title="${captions.titleAutoRotate}">&#10227;</button>
205
+ <button class="stpv-cbtn" data-action="stop-rotate" title="${captions.titleStopRotate}">&#9632;</button>
206
+ <button class="stpv-cbtn" data-action="free-rotate" title="${captions.titleFreeRotate}">&#9978;</button>
207
+ <div class="stpv-cdiv"></div>
208
+ <button class="stpv-cbtn" data-action="reset" title="${captions.titleReset}">&#8634;</button>
209
+ <div class="stpv-cdiv"></div>
210
+ <button class="stpv-cbtn stpv-ai-btn" data-action="ai-render" title="AI Render">&#10024;</button>
211
+ </div>
212
+ <div class="stpv-info">
213
+ <div class="stpv-info-num"></div>
214
+ <div class="stpv-info-name"></div>
215
+ <div class="stpv-info-sub"></div>
216
+ <div class="stpv-info-detail"></div>
217
+ <div class="stpv-info-line"></div>
218
+ </div>
219
+ <div class="stpv-ai-panel">
220
+ <div class="stpv-ai-panel-header">
221
+ <div class="stpv-ai-panel-title">&#10024; AI Render</div>
222
+ <button class="stpv-ai-close">&times;</button>
223
+ </div>
224
+ <div class="stpv-ai-body">
225
+ <div class="stpv-ai-section">
226
+ <div class="stpv-ai-label">Scene Preview</div>
227
+ <div class="stpv-ai-preview">
228
+ <img class="stpv-ai-preview-img" alt="Current view" />
229
+ <div class="stpv-ai-preview-label">Current camera view will be used as reference</div>
230
+ </div>
231
+ </div>
232
+ <div class="stpv-ai-section">
233
+ <div class="stpv-ai-label">Scene Prompt</div>
234
+ <textarea class="stpv-ai-textarea" placeholder="Modern bike shop, wooden floor, glass walls, natural light, studio photography..."></textarea>
235
+ </div>
236
+ <div class="stpv-ai-section">
237
+ <div class="stpv-ai-label">Style Preset</div>
238
+ <div class="stpv-ai-presets">
239
+ <div class="stpv-ai-preset active" data-preset="product">
240
+ <span class="stpv-ai-preset-icon">&#128247;</span>Product Photo
241
+ </div>
242
+ <div class="stpv-ai-preset" data-preset="marketing">
243
+ <span class="stpv-ai-preset-icon">&#127775;</span>Marketing Shot
244
+ </div>
245
+ <div class="stpv-ai-preset" data-preset="technical">
246
+ <span class="stpv-ai-preset-icon">&#128295;</span>Technical
247
+ </div>
248
+ <div class="stpv-ai-preset" data-preset="artistic">
249
+ <span class="stpv-ai-preset-icon">&#127912;</span>Artistic
250
+ </div>
251
+ </div>
252
+ </div>
253
+ <div class="stpv-ai-section">
254
+ <div class="stpv-ai-label">Detected Materials</div>
255
+ <div class="stpv-ai-chips"></div>
256
+ </div>
257
+ <div class="stpv-ai-section">
258
+ <div class="stpv-ai-label">AI Model</div>
259
+ <select class="stpv-ai-select stpv-ai-model-select">
260
+ <option value="gemini-flash-image">Gemini 2.0 Flash — Image Gen (FREE)</option>
261
+ <option value="gemini-nano-banana">Nano Banana v1 — Gemini 2.5 Flash</option>
262
+ <option value="stability-sdxl">Stability AI — SDXL</option>
263
+ <option value="stability-sd3">Stability AI — SD3</option>
264
+ <option value="openai-dall-e-3">OpenAI — DALL·E 3</option>
265
+ <option value="fal-flux">Fal.ai — FLUX</option>
266
+ <option value="replicate-custom">Replicate — Custom Model</option>
267
+ </select>
268
+ </div>
269
+ <div class="stpv-ai-section">
270
+ <div class="stpv-ai-label">API Key</div>
271
+ <input type="password" class="stpv-ai-key-input" placeholder="Paste your API key..." />
272
+ <div class="stpv-ai-key-hint"></div>
273
+ <div class="stpv-ai-key-status">Key is stored in browser only, never sent to our servers.</div>
274
+ </div>
275
+ <div class="stpv-ai-section">
276
+ <div class="stpv-ai-label">Output Format</div>
277
+ <select class="stpv-ai-select stpv-ai-format-select">
278
+ <option value="png">PNG (lossless)</option>
279
+ <option value="jpg">JPG (smaller file)</option>
280
+ <option value="webp">WebP (modern)</option>
281
+ </select>
282
+ </div>
283
+ <button class="stpv-ai-generate">&#10024; Generate Render</button>
284
+ <div class="stpv-ai-section">
285
+ <div class="stpv-ai-label">Gallery</div>
286
+ <div class="stpv-ai-gallery">
287
+ <div class="stpv-ai-empty">
288
+ <div class="stpv-ai-empty-icon">&#128444;</div>
289
+ <div class="stpv-ai-empty-text">No renders yet.<br/>Configure your scene and hit Generate.</div>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ `;
297
+ injectStyles(container.querySelector('.stpv'));
298
+ }
299
+
300
+ // ─── Main Viewer Class ───
301
+ class STPViewerInstance {
302
+ constructor(container, config) {
303
+ this.el = typeof container === 'string' ? document.querySelector(container) : container;
304
+ this.config = config;
305
+ this.src = config.src.replace(/\/?$/, '/');
306
+ this.assemblies = config.assemblies || [];
307
+ this.parts = [];
308
+ this.asmData = [];
309
+ this.activeAssembly = -1;
310
+ this.explodeAmount = 0;
311
+ this.targetExplode = 0;
312
+ this.dimAmount = 0;
313
+ this.targetDim = 0;
314
+ this.explodeLevel = 0;
315
+ this.manualMode = false;
316
+ this.THREE = null;
317
+ }
318
+
319
+ async start() {
320
+ buildUI(this.el, this.config);
321
+
322
+ // Load Three.js from CDN
323
+ const THREE = await this._loadThree();
324
+ this.THREE = THREE;
325
+
326
+ // Load addons
327
+ const { OrbitControls } = await import(`${THREE_CDN}/examples/jsm/controls/OrbitControls.js`);
328
+ const { STLLoader } = await import(`${THREE_CDN}/examples/jsm/loaders/STLLoader.js`);
329
+ this.OrbitControls = OrbitControls;
330
+ this.STLLoader = STLLoader;
331
+
332
+ // Load config + manifest
333
+ await this._loadData();
334
+
335
+ // Setup scene
336
+ this._setupScene();
337
+ this._setupLights();
338
+ this._setupControls();
339
+ this._bindUI();
340
+ this._initAIRender();
341
+
342
+ // Load parts
343
+ await this._loadParts();
344
+
345
+ // Start render
346
+ this._animate();
347
+
348
+ // Hide loader
349
+ setTimeout(() => {
350
+ this.el.querySelector('.stpv-loader').classList.add('hidden');
351
+ }, 500);
352
+ }
353
+
354
+ async _loadThree() {
355
+ if (window.THREE) return window.THREE;
356
+ const mod = await import(`${THREE_CDN}/build/three.module.js`);
357
+ return mod;
358
+ }
359
+
360
+ async _loadData() {
361
+ // Load manifest
362
+ const mRes = await fetch(this.src + 'manifest.json');
363
+ this.manifest = (await mRes.json()).filter(p => p.fileSize > 2000);
364
+
365
+ // Load assemblies if not provided in config
366
+ if (!this.assemblies.length) {
367
+ try {
368
+ const aRes = await fetch(this.src + 'assemblies.json');
369
+ this.assemblies = await aRes.json();
370
+ } catch (e) {
371
+ // Try config.json
372
+ try {
373
+ const cRes = await fetch(this.src + 'config.json');
374
+ const cfg = await cRes.json();
375
+ this.assemblies = cfg.assemblies || [];
376
+ if (cfg.productName) this.config.productName = cfg.productName;
377
+ } catch (e2) { /* no assemblies */ }
378
+ }
379
+ }
380
+
381
+ // Build left nav
382
+ this._buildLeftNav();
383
+ }
384
+
385
+ _buildLeftNav() {
386
+ const nav = this.el.querySelector('.stpv-leftnav');
387
+ nav.innerHTML = '';
388
+ this.assemblies.forEach((asm, i) => {
389
+ const item = document.createElement('div');
390
+ item.className = 'stpv-nav-item';
391
+ item.style.color = asm.color;
392
+ item.dataset.index = i;
393
+ item.innerHTML = `<div class="stpv-nav-dot" style="background:${asm.color}"></div><div class="stpv-nav-label">${asm.name}</div>`;
394
+ item.addEventListener('click', () => this._selectAssembly(i));
395
+ nav.appendChild(item);
396
+ });
397
+ }
398
+
399
+ _setupScene() {
400
+ const T = this.THREE;
401
+ const wrapper = this.el.querySelector('.stpv');
402
+ const w = wrapper.clientWidth, h = wrapper.clientHeight;
403
+
404
+ this.scene = new T.Scene();
405
+ this.scene.background = new T.Color(0x0a0e1a);
406
+
407
+ this.camera = new T.PerspectiveCamera(35, w / h, 1, 15000);
408
+ this.camera.position.set(3000, 1800, 3000);
409
+
410
+ this.renderer = new T.WebGLRenderer({ antialias: true, powerPreference: 'high-performance', preserveDrawingBuffer: true });
411
+ this.renderer.setSize(w, h);
412
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
413
+ this.renderer.shadowMap.enabled = true;
414
+ this.renderer.toneMapping = T.ACESFilmicToneMapping;
415
+ this.renderer.toneMappingExposure = 2.2;
416
+
417
+ // Insert canvas after loader
418
+ const loader = wrapper.querySelector('.stpv-loader');
419
+ wrapper.insertBefore(this.renderer.domElement, loader);
420
+
421
+ // Ground
422
+ const ground = new T.Mesh(
423
+ new T.PlaneGeometry(12000, 12000),
424
+ new T.MeshStandardMaterial({ color: 0x151a2a, metalness: 0.7, roughness: 0.3 })
425
+ );
426
+ ground.rotation.x = -Math.PI / 2;
427
+ ground.position.y = -500;
428
+ ground.receiveShadow = true;
429
+ this.scene.add(ground);
430
+
431
+ // Spotlight for highlighting
432
+ this.spot = new T.SpotLight(0xffffff, 0, 0, Math.PI / 5, 0.6, 1);
433
+ this.spot.position.set(0, 3000, 0);
434
+ this.scene.add(this.spot);
435
+ this.scene.add(this.spot.target);
436
+
437
+ // Resize observer
438
+ const ro = new ResizeObserver(() => {
439
+ const w2 = wrapper.clientWidth, h2 = wrapper.clientHeight;
440
+ this.camera.aspect = w2 / h2;
441
+ this.camera.updateProjectionMatrix();
442
+ this.renderer.setSize(w2, h2);
443
+ });
444
+ ro.observe(wrapper);
445
+ }
446
+
447
+ _setupLights() {
448
+ const T = this.THREE;
449
+ this.scene.add(new T.AmbientLight(0x8899bb, 1.5));
450
+ this.scene.add(new T.HemisphereLight(0xddeeff, 0x445566, 1.0));
451
+
452
+ const key = new T.DirectionalLight(0xfff5e6, 3.5);
453
+ key.position.set(2000, 3000, 1500);
454
+ key.castShadow = true;
455
+ this.scene.add(key);
456
+
457
+ this.scene.add(Object.assign(new T.DirectionalLight(0xaaccff, 1.8), { position: new T.Vector3(-1500, 1000, -1000) }));
458
+ this.scene.add(Object.assign(new T.DirectionalLight(0xffffff, 1.2), { position: new T.Vector3(0, 500, -2500) }));
459
+ this.scene.add(Object.assign(new T.DirectionalLight(0x0055A4, 0.8), { position: new T.Vector3(-500, 1500, -2000) }));
460
+ this.scene.add(Object.assign(new T.DirectionalLight(0xeeeeff, 0.5), { position: new T.Vector3(0, 4000, 0) }));
461
+ }
462
+
463
+ _setupControls() {
464
+ this.controls = new this.OrbitControls(this.camera, this.renderer.domElement);
465
+ this.controls.enableDamping = true;
466
+ this.controls.dampingFactor = 0.05;
467
+ this.controls.target.set(0, 0, 0);
468
+ this.controls.autoRotate = true;
469
+ this.controls.autoRotateSpeed = 0.4;
470
+ this.controls.minDistance = 800;
471
+ this.controls.maxDistance = 5500;
472
+ }
473
+
474
+ async _loadParts() {
475
+ const T = this.THREE;
476
+ const loader = new this.STLLoader();
477
+ const fill = this.el.querySelector('.stpv-loader-fill');
478
+ const text = this.el.querySelector('.stpv-loader-text');
479
+
480
+ // Model center
481
+ let cx = 0, cy = 0, cz = 0;
482
+ for (const p of this.manifest) { cx += p.center[0]; cy += p.center[1]; cz += p.center[2]; }
483
+ this.modelCenter = new T.Vector3(cx / this.manifest.length, cy / this.manifest.length, cz / this.manifest.length);
484
+
485
+ // Assembly index per part
486
+ const partAsm = new Array(this.manifest.length).fill(-1);
487
+ for (let ai = 0; ai < this.assemblies.length; ai++) {
488
+ const [s, e] = this.assemblies[ai].indices;
489
+ for (let pi = s; pi < e; pi++) partAsm[pi] = ai;
490
+ }
491
+
492
+ // Per-assembly data
493
+ this.asmData = this.assemblies.map(a => ({
494
+ ...a, meshes: [], center: new T.Vector3(), colorObj: new T.Color(a.color),
495
+ }));
496
+
497
+ // Material presets per assembly key
498
+ const matPresets = {};
499
+ for (const asm of this.assemblies) {
500
+ const c = new T.Color(asm.color);
501
+ const hsl = {}; c.getHSL(hsl);
502
+ matPresets[asm.key] = {
503
+ color: new T.Color().setHSL(hsl.h, hsl.s * 0.4, 0.6),
504
+ metalness: 0.45, roughness: 0.35,
505
+ };
506
+ }
507
+
508
+ const total = this.manifest.length;
509
+ let loaded = 0;
510
+
511
+ const batchSize = 25;
512
+ for (let i = 0; i < total; i += batchSize) {
513
+ const batch = this.manifest.slice(i, i + batchSize);
514
+ await Promise.all(batch.map((partInfo, bi) => new Promise(resolve => {
515
+ const idx = i + bi;
516
+ loader.load(this.src + 'parts/' + partInfo.file, geo => {
517
+ geo.computeVertexNormals();
518
+
519
+ const ai = partAsm[idx];
520
+ const preset = ai >= 0 && this.assemblies[ai] ? matPresets[this.assemblies[ai].key] : null;
521
+ const baseColor = preset ? preset.color.clone() : new T.Color(0xb0b8c4);
522
+ const baseMetal = preset ? preset.metalness : 0.4;
523
+ const baseRough = preset ? preset.roughness : 0.35;
524
+
525
+ const mat = new T.MeshStandardMaterial({
526
+ color: baseColor, metalness: baseMetal, roughness: baseRough, side: T.DoubleSide,
527
+ });
528
+ const mesh = new T.Mesh(geo, mat);
529
+ mesh.castShadow = true;
530
+ mesh.receiveShadow = true;
531
+
532
+ const mc = this.modelCenter;
533
+ const oc = new T.Vector3(partInfo.center[0] - mc.x, partInfo.center[2] - mc.z, -(partInfo.center[1] - mc.y));
534
+ geo.translate(-mc.x, -mc.z, mc.y);
535
+
536
+ const dir = oc.clone();
537
+ if (dir.length() < 1) dir.set(0, 1, 0);
538
+ dir.normalize();
539
+
540
+ const vol = partInfo.bbox[0] * partInfo.bbox[1] * partInfo.bbox[2];
541
+ let dist;
542
+ if (vol > 50000000) dist = 250 + Math.random() * 120;
543
+ else if (vol > 1000000) dist = 400 + Math.random() * 300;
544
+ else dist = 600 + Math.random() * 500;
545
+
546
+ mesh.userData = { idx, asmIdx: ai, explodeDir: dir, explodeDist: dist, origCenter: oc, baseColor, baseMetal, baseRough, _currentExplode: 0 };
547
+
548
+ this.scene.add(mesh);
549
+ this.parts.push(mesh);
550
+ if (ai >= 0) this.asmData[ai].meshes.push(mesh);
551
+
552
+ loaded++;
553
+ fill.style.width = (loaded / total * 100) + '%';
554
+ text.textContent = `${loaded} / ${total}`;
555
+ resolve();
556
+ }, undefined, () => { loaded++; resolve(); });
557
+ })));
558
+ }
559
+
560
+ // Compute assembly centers
561
+ for (const ad of this.asmData) {
562
+ if (!ad.meshes.length) continue;
563
+ const c = new T.Vector3();
564
+ for (const m of ad.meshes) c.add(m.userData.origCenter);
565
+ c.divideScalar(ad.meshes.length);
566
+ ad.center.copy(c);
567
+ }
568
+ }
569
+
570
+ _selectAssembly(i) {
571
+ if (this.activeAssembly === i) {
572
+ // Deselect
573
+ this.activeAssembly = -1;
574
+ this.targetDim = 0;
575
+ this._hideInfo();
576
+ } else {
577
+ this.activeAssembly = i;
578
+ this.targetExplode = 1;
579
+ this.targetDim = 1;
580
+ if (this.explodeLevel < 0.5) this.explodeLevel = 1;
581
+ this.manualMode = true;
582
+ this._showInfo(i);
583
+ }
584
+ this._updateNavActive();
585
+ }
586
+
587
+ _updateNavActive() {
588
+ this.el.querySelectorAll('.stpv-nav-item').forEach((item, i) => {
589
+ item.classList.toggle('active', i === this.activeAssembly);
590
+ });
591
+ }
592
+
593
+ _showInfo(i) {
594
+ const asm = this.assemblies[i];
595
+ const info = this.el.querySelector('.stpv-info');
596
+ info.querySelector('.stpv-info-num').textContent = `0${i + 1} / 0${this.assemblies.length}`;
597
+ info.querySelector('.stpv-info-num').style.color = asm.color;
598
+ info.querySelector('.stpv-info-name').textContent = asm.name;
599
+ info.querySelector('.stpv-info-name').style.color = asm.color;
600
+ info.querySelector('.stpv-info-sub').textContent = asm.subtitle || '';
601
+ info.querySelector('.stpv-info-detail').textContent = asm.detail || '';
602
+ info.querySelector('.stpv-info-line').style.background = asm.color;
603
+ info.classList.add('visible');
604
+ }
605
+
606
+ _hideInfo() {
607
+ this.el.querySelector('.stpv-info').classList.remove('visible');
608
+ }
609
+
610
+ _bindUI() {
611
+ const self = this;
612
+
613
+ // Top bar buttons
614
+ this.el.querySelectorAll('.stpv-btn').forEach(btn => {
615
+ btn.addEventListener('click', () => {
616
+ const action = btn.dataset.action;
617
+ self.el.querySelectorAll('.stpv-btn').forEach(b => b.classList.remove('active'));
618
+ btn.classList.add('active');
619
+
620
+ if (action === 'overview') {
621
+ self.manualMode = false;
622
+ self.explodeLevel = 0;
623
+ self.targetExplode = 0;
624
+ self.targetDim = 0;
625
+ self.activeAssembly = -1;
626
+ self._hideInfo();
627
+ self._updateNavActive();
628
+ } else if (action === 'collapse') {
629
+ self.manualMode = true;
630
+ self.targetExplode = 0;
631
+ self.explodeLevel = 0;
632
+ self.targetDim = 0;
633
+ self.activeAssembly = -1;
634
+ self._hideInfo();
635
+ self._updateNavActive();
636
+ } else if (action === 'explode') {
637
+ self.manualMode = true;
638
+ self.targetExplode = 1;
639
+ self.explodeLevel = 1;
640
+ self.targetDim = 0;
641
+ self.activeAssembly = -1;
642
+ self._hideInfo();
643
+ self._updateNavActive();
644
+ }
645
+ });
646
+ });
647
+
648
+ // Right controls
649
+ this.el.querySelectorAll('.stpv-cbtn').forEach(btn => {
650
+ btn.addEventListener('click', () => {
651
+ const action = btn.dataset.action;
652
+
653
+ if (action === 'ai-render') {
654
+ this._toggleAIPanel();
655
+ return;
656
+ }
657
+
658
+ if (action === 'expand') {
659
+ self.explodeLevel = Math.min(3.0, self.explodeLevel + 0.25);
660
+ self.manualMode = true;
661
+ self.targetExplode = Math.min(self.explodeLevel, 1);
662
+ self.targetDim = 0;
663
+ self.activeAssembly = -1;
664
+ self._hideInfo();
665
+ self._updateNavActive();
666
+ } else if (action === 'contract') {
667
+ self.explodeLevel = Math.max(0, self.explodeLevel - 0.25);
668
+ self.manualMode = true;
669
+ self.targetExplode = Math.min(self.explodeLevel, 1);
670
+ self.targetDim = 0;
671
+ self.activeAssembly = -1;
672
+ self._hideInfo();
673
+ self._updateNavActive();
674
+ } else if (action === 'auto-rotate') {
675
+ self.controls.autoRotate = true;
676
+ self.controls.autoRotateSpeed = 0.4;
677
+ self._setRotateActive(btn);
678
+ } else if (action === 'stop-rotate') {
679
+ self.controls.autoRotate = false;
680
+ self._setRotateActive(btn);
681
+ } else if (action === 'free-rotate') {
682
+ self.controls.autoRotate = false;
683
+ self._setRotateActive(btn);
684
+ } else if (action === 'reset') {
685
+ self.camera.position.set(3000, 1800, 3000);
686
+ self.controls.target.set(0, 0, 0);
687
+ self.controls.autoRotate = true;
688
+ self.controls.autoRotateSpeed = 0.4;
689
+ self.manualMode = false;
690
+ self.explodeLevel = 0;
691
+ self.targetExplode = 0;
692
+ self.targetDim = 0;
693
+ self.activeAssembly = -1;
694
+ self._hideInfo();
695
+ self._updateNavActive();
696
+ self._setRotateActive(self.el.querySelector('[data-action="auto-rotate"]'));
697
+ self.el.querySelectorAll('.stpv-btn').forEach(b => b.classList.remove('active'));
698
+ self.el.querySelector('[data-action="overview"]').classList.add('active');
699
+ }
700
+ });
701
+ });
702
+ }
703
+
704
+ _setRotateActive(btn) {
705
+ this.el.querySelectorAll('.stpv-cbtn[data-action*="rotate"]').forEach(b => b.classList.remove('active'));
706
+ btn.classList.add('active');
707
+ }
708
+
709
+ // ─── AI Render System ───
710
+
711
+ _initAIRender() {
712
+ this.aiPanel = this.el.querySelector('.stpv-ai-panel');
713
+ this.aiRenders = [];
714
+ this.aiActivePreset = 'product';
715
+
716
+ // Style preset prompt templates
717
+ this.aiPresets = {
718
+ product: { suffix: 'professional product photography, studio lighting, clean white cyclorama background, soft shadows, 8K, commercial quality' },
719
+ marketing: { suffix: 'lifestyle marketing photography, dramatic lighting, shallow depth of field, premium brand aesthetic, magazine quality, cinematic' },
720
+ technical: { suffix: 'technical documentation rendering, neutral gray background, even lighting, precise detail, engineering visualization, orthographic feel' },
721
+ artistic: { suffix: 'artistic 3D render, volumetric lighting, ray-traced, Octane render style, dramatic atmosphere, creative composition, award-winning CGI' },
722
+ };
723
+
724
+ // Panel toggle
725
+ const closeBtn = this.aiPanel.querySelector('.stpv-ai-close');
726
+ closeBtn.addEventListener('click', () => this._toggleAIPanel(false));
727
+
728
+ // Presets
729
+ this.aiPanel.querySelectorAll('.stpv-ai-preset').forEach(p => {
730
+ p.addEventListener('click', () => {
731
+ this.aiPanel.querySelectorAll('.stpv-ai-preset').forEach(pp => pp.classList.remove('active'));
732
+ p.classList.add('active');
733
+ this.aiActivePreset = p.dataset.preset;
734
+ });
735
+ });
736
+
737
+ // Populate detected materials from assemblies
738
+ this._populateMaterialChips();
739
+
740
+ // Model selector — persist choice
741
+ const modelSel = this.aiPanel.querySelector('.stpv-ai-model-select');
742
+ const savedModel = this._aiStorageGet('ai-model');
743
+ if (savedModel) modelSel.value = savedModel;
744
+ modelSel.addEventListener('change', () => this._aiStorageSet('ai-model', modelSel.value));
745
+
746
+ // API key — persist per provider prefix
747
+ const keyInput = this.aiPanel.querySelector('.stpv-ai-key-input');
748
+ const keyStatus = this.aiPanel.querySelector('.stpv-ai-key-status');
749
+ const keyHint = this.aiPanel.querySelector('.stpv-ai-key-hint');
750
+
751
+ const providerHints = {
752
+ gemini: '🍌 FREE — 500 images/day. Get key at aistudio.google.com',
753
+ stability: 'Get key at platform.stability.ai (~$0.03/render)',
754
+ openai: 'Get key at platform.openai.com (~$0.04/render)',
755
+ fal: 'Get key at fal.ai/dashboard',
756
+ replicate: 'Get key at replicate.com/account',
757
+ };
758
+
759
+ const loadKey = () => {
760
+ const provider = modelSel.value.split('-')[0];
761
+ const saved = this._aiStorageGet('ai-key-' + provider);
762
+ keyInput.value = saved || '';
763
+ keyStatus.className = 'stpv-ai-key-status' + (saved ? ' ok' : '');
764
+ keyStatus.textContent = saved ? 'Key saved for this provider.' : 'Key is stored in browser only, never sent to our servers.';
765
+ keyHint.textContent = providerHints[provider] || '';
766
+ keyHint.style.cssText = 'font-size:0.55rem;color:#4a8;margin-bottom:4px;';
767
+ };
768
+ loadKey();
769
+ modelSel.addEventListener('change', loadKey);
770
+ keyInput.addEventListener('change', () => {
771
+ const provider = modelSel.value.split('-')[0];
772
+ this._aiStorageSet('ai-key-' + provider, keyInput.value);
773
+ loadKey();
774
+ });
775
+
776
+ // Generate button
777
+ const genBtn = this.aiPanel.querySelector('.stpv-ai-generate');
778
+ genBtn.addEventListener('click', () => this._aiGenerate());
779
+ }
780
+
781
+ _toggleAIPanel(forceState) {
782
+ const open = forceState !== undefined ? forceState : !this.aiPanel.classList.contains('open');
783
+ this.aiPanel.classList.toggle('open', open);
784
+ if (open) {
785
+ // Capture current canvas as preview
786
+ this._aiCapturePreview();
787
+ }
788
+ }
789
+
790
+ _aiCapturePreview() {
791
+ // Force a render to ensure the canvas is current
792
+ this.renderer.render(this.scene, this.camera);
793
+ const dataURL = this.renderer.domElement.toDataURL('image/png');
794
+ const img = this.aiPanel.querySelector('.stpv-ai-preview-img');
795
+ img.src = dataURL;
796
+ this._aiCurrentCapture = dataURL;
797
+ }
798
+
799
+ _populateMaterialChips() {
800
+ const chipsEl = this.aiPanel.querySelector('.stpv-ai-chips');
801
+ if (!this.assemblies.length) {
802
+ chipsEl.innerHTML = '<span style="font-size:0.55rem;color:#445">No assemblies detected</span>';
803
+ return;
804
+ }
805
+ chipsEl.innerHTML = '';
806
+ for (const asm of this.assemblies) {
807
+ const chip = document.createElement('span');
808
+ chip.className = 'stpv-ai-chip';
809
+ chip.innerHTML = `<span class="stpv-ai-chip-dot" style="background:${asm.color}"></span>${asm.name}`;
810
+ chipsEl.appendChild(chip);
811
+ }
812
+ }
813
+
814
+ _aiBuildPrompt() {
815
+ const userPrompt = this.aiPanel.querySelector('.stpv-ai-textarea').value.trim();
816
+ const preset = this.aiPresets[this.aiActivePreset];
817
+
818
+ // Build material context from assemblies
819
+ let materialCtx = '';
820
+ if (this.assemblies.length) {
821
+ const matParts = this.assemblies.map(a => {
822
+ const name = (a.name || a.key || '').toLowerCase();
823
+ return name;
824
+ }).filter(Boolean);
825
+ if (matParts.length) materialCtx = 'The object includes components: ' + matParts.join(', ') + '. ';
826
+ }
827
+
828
+ const scene = userPrompt || 'a clean studio environment with professional lighting';
829
+ return `Photorealistic render of an industrial/mechanical product in ${scene}. ${materialCtx}${preset.suffix}`;
830
+ }
831
+
832
+ async _aiGenerate() {
833
+ const genBtn = this.aiPanel.querySelector('.stpv-ai-generate');
834
+ const modelSel = this.aiPanel.querySelector('.stpv-ai-model-select');
835
+ const keyInput = this.aiPanel.querySelector('.stpv-ai-key-input');
836
+ const model = modelSel.value;
837
+ const apiKey = keyInput.value.trim();
838
+
839
+ if (!apiKey) {
840
+ this._aiShowKeyError('Please enter an API key first.');
841
+ keyInput.focus();
842
+ return;
843
+ }
844
+
845
+ // Capture fresh screenshot
846
+ this._aiCapturePreview();
847
+
848
+ const prompt = this._aiBuildPrompt();
849
+ const format = this.aiPanel.querySelector('.stpv-ai-format-select').value;
850
+
851
+ genBtn.disabled = true;
852
+ genBtn.classList.add('generating');
853
+ genBtn.textContent = '⟳ Generating...';
854
+
855
+ try {
856
+ const imageURL = await this._aiCallProvider(model, apiKey, prompt, this._aiCurrentCapture);
857
+ this._aiAddToGallery(imageURL, prompt, model, format);
858
+ } catch (err) {
859
+ console.error('AI Render error:', err);
860
+ this._aiShowKeyError(err.message || 'Generation failed. Check your API key and try again.');
861
+ } finally {
862
+ genBtn.disabled = false;
863
+ genBtn.classList.remove('generating');
864
+ genBtn.textContent = '✨ Generate Render';
865
+ }
866
+ }
867
+
868
+ async _aiCallProvider(model, apiKey, prompt, referenceImage) {
869
+ const provider = model.split('-')[0];
870
+ const imageBase64 = referenceImage.replace(/^data:image\/\w+;base64,/, '');
871
+
872
+ if (provider === 'gemini') {
873
+ return await this._aiCallGemini(model, apiKey, prompt, imageBase64);
874
+ } else if (provider === 'stability') {
875
+ return await this._aiCallStability(model, apiKey, prompt, imageBase64);
876
+ } else if (provider === 'openai') {
877
+ return await this._aiCallOpenAI(apiKey, prompt, imageBase64);
878
+ } else if (provider === 'fal') {
879
+ return await this._aiCallFal(apiKey, prompt, imageBase64);
880
+ } else if (provider === 'replicate') {
881
+ return await this._aiCallReplicate(apiKey, prompt, imageBase64);
882
+ }
883
+ throw new Error('Unknown provider: ' + provider);
884
+ }
885
+
886
+ async _aiCallGemini(model, apiKey, prompt, imageBase64) {
887
+ // Map model dropdown values to Gemini API model names
888
+ const modelMap = {
889
+ 'gemini-flash': 'gemini-2.5-flash-image', // Nano Banana v1
890
+ 'gemini-flash-image': 'gemini-2.5-flash-image', // Nano Banana v1 (alias)
891
+ 'gemini-nano-banana': 'gemini-2.5-flash-image', // Nano Banana v1
892
+ 'gemini-nano-banana-2': 'gemini-3.1-flash-image-preview', // Nano Banana v2
893
+ };
894
+ const geminiModel = modelMap[model] || 'gemini-2.5-flash-image';
895
+ const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`;
896
+
897
+ const body = {
898
+ contents: [{
899
+ parts: [
900
+ {
901
+ inline_data: {
902
+ mime_type: 'image/png',
903
+ data: imageBase64,
904
+ }
905
+ },
906
+ {
907
+ text: `You are a photorealistic product rendering engine. Take this 3D CAD viewer screenshot and transform it into a photorealistic product render. ${prompt}. Output ONLY the rendered image, no text.`
908
+ }
909
+ ]
910
+ }],
911
+ generationConfig: {
912
+ responseModalities: ['TEXT', 'IMAGE'],
913
+ }
914
+ };
915
+
916
+ const res = await fetch(endpoint, {
917
+ method: 'POST',
918
+ headers: { 'Content-Type': 'application/json' },
919
+ body: JSON.stringify(body),
920
+ });
921
+
922
+ if (!res.ok) {
923
+ const errText = await res.text();
924
+ let msg = `Gemini API error ${res.status}`;
925
+ try {
926
+ const errJson = JSON.parse(errText);
927
+ const errCode = errJson?.error?.status || '';
928
+ const errMsg = errJson?.error?.message || '';
929
+ if (res.status === 429 || errCode === 'RESOURCE_EXHAUSTED') {
930
+ msg = 'Free tier quota exhausted. Resets daily at midnight Pacific. Try tomorrow or switch to paid plan.';
931
+ } else if (res.status === 404) {
932
+ msg = `Model "${geminiModel}" not found. It may have been retired.`;
933
+ } else if (res.status === 403) {
934
+ msg = 'API key not authorised for image generation.';
935
+ } else if (errMsg) {
936
+ msg = errMsg.length > 120 ? errMsg.slice(0, 120) + '…' : errMsg;
937
+ }
938
+ } catch (_) {}
939
+ throw new Error(msg);
940
+ }
941
+
942
+ const data = await res.json();
943
+
944
+ // Find the image part in the response
945
+ const candidates = data.candidates || [];
946
+ for (const candidate of candidates) {
947
+ const parts = (candidate.content && candidate.content.parts) || [];
948
+ for (const part of parts) {
949
+ if (part.inlineData && part.inlineData.mimeType && part.inlineData.mimeType.startsWith('image/')) {
950
+ return `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`;
951
+ }
952
+ }
953
+ }
954
+ throw new Error('Gemini returned no image. The model may have declined the request. Try adjusting your prompt.');
955
+ }
956
+
957
+ async _aiCallStability(model, apiKey, prompt, imageBase64) {
958
+ const engineId = model === 'stability-sd3' ? 'sd3-medium' : 'stable-diffusion-xl-1024-v1-0';
959
+ const isSD3 = model === 'stability-sd3';
960
+
961
+ // Convert base64 to blob for image-to-image
962
+ const blob = this._aiBase64ToBlob(imageBase64, 'image/png');
963
+
964
+ const formData = new FormData();
965
+ formData.append('init_image', blob, 'reference.png');
966
+ formData.append('init_image_mode', 'IMAGE_STRENGTH');
967
+ formData.append('image_strength', '0.35');
968
+ formData.append('text_prompts[0][text]', prompt);
969
+ formData.append('text_prompts[0][weight]', '1');
970
+ formData.append('cfg_scale', '7');
971
+ formData.append('samples', '1');
972
+ formData.append('steps', '30');
973
+
974
+ const endpoint = isSD3
975
+ ? 'https://api.stability.ai/v2beta/stable-image/generate/sd3'
976
+ : `https://api.stability.ai/v1/generation/${engineId}/image-to-image`;
977
+
978
+ const headers = {
979
+ 'Authorization': `Bearer ${apiKey}`,
980
+ 'Accept': 'application/json',
981
+ };
982
+
983
+ if (isSD3) {
984
+ // SD3 uses different API shape
985
+ const fd = new FormData();
986
+ fd.append('prompt', prompt);
987
+ fd.append('image', blob, 'reference.png');
988
+ fd.append('strength', '0.4');
989
+ fd.append('mode', 'image-to-image');
990
+ fd.append('output_format', 'png');
991
+
992
+ const res = await fetch(endpoint, { method: 'POST', headers, body: fd });
993
+ if (!res.ok) throw new Error(`Stability API error: ${res.status} ${await res.text()}`);
994
+ const data = await res.json();
995
+ return 'data:image/png;base64,' + data.image;
996
+ }
997
+
998
+ const res = await fetch(endpoint, { method: 'POST', headers, body: formData });
999
+ if (!res.ok) throw new Error(`Stability API error: ${res.status} ${await res.text()}`);
1000
+ const data = await res.json();
1001
+ return 'data:image/png;base64,' + data.artifacts[0].base64;
1002
+ }
1003
+
1004
+ async _aiCallOpenAI(apiKey, prompt, imageBase64) {
1005
+ const res = await fetch('https://api.openai.com/v1/images/edits', {
1006
+ method: 'POST',
1007
+ headers: { 'Authorization': `Bearer ${apiKey}` },
1008
+ body: (() => {
1009
+ const fd = new FormData();
1010
+ fd.append('image', this._aiBase64ToBlob(imageBase64, 'image/png'), 'reference.png');
1011
+ fd.append('prompt', prompt);
1012
+ fd.append('model', 'dall-e-3');
1013
+ fd.append('size', '1024x1024');
1014
+ fd.append('n', '1');
1015
+ return fd;
1016
+ })(),
1017
+ });
1018
+ if (!res.ok) throw new Error(`OpenAI API error: ${res.status} ${await res.text()}`);
1019
+ const data = await res.json();
1020
+ if (data.data[0].b64_json) return 'data:image/png;base64,' + data.data[0].b64_json;
1021
+ return data.data[0].url;
1022
+ }
1023
+
1024
+ async _aiCallFal(apiKey, prompt, imageBase64) {
1025
+ const res = await fetch('https://fal.run/fal-ai/flux/dev/image-to-image', {
1026
+ method: 'POST',
1027
+ headers: {
1028
+ 'Authorization': `Key ${apiKey}`,
1029
+ 'Content-Type': 'application/json',
1030
+ },
1031
+ body: JSON.stringify({
1032
+ prompt,
1033
+ image_url: 'data:image/png;base64,' + imageBase64,
1034
+ strength: 0.65,
1035
+ num_images: 1,
1036
+ image_size: 'landscape_16_9',
1037
+ }),
1038
+ });
1039
+ if (!res.ok) throw new Error(`Fal.ai API error: ${res.status} ${await res.text()}`);
1040
+ const data = await res.json();
1041
+ return data.images[0].url;
1042
+ }
1043
+
1044
+ async _aiCallReplicate(apiKey, prompt, imageBase64) {
1045
+ const res = await fetch('https://api.replicate.com/v1/predictions', {
1046
+ method: 'POST',
1047
+ headers: {
1048
+ 'Authorization': `Bearer ${apiKey}`,
1049
+ 'Content-Type': 'application/json',
1050
+ },
1051
+ body: JSON.stringify({
1052
+ model: 'stability-ai/sdxl',
1053
+ input: {
1054
+ prompt,
1055
+ image: 'data:image/png;base64,' + imageBase64,
1056
+ prompt_strength: 0.65,
1057
+ num_outputs: 1,
1058
+ },
1059
+ }),
1060
+ });
1061
+ if (!res.ok) throw new Error(`Replicate API error: ${res.status} ${await res.text()}`);
1062
+ const prediction = await res.json();
1063
+
1064
+ // Poll for result
1065
+ let result = prediction;
1066
+ while (result.status !== 'succeeded' && result.status !== 'failed') {
1067
+ await new Promise(r => setTimeout(r, 2000));
1068
+ const poll = await fetch(result.urls.get, {
1069
+ headers: { 'Authorization': `Bearer ${apiKey}` },
1070
+ });
1071
+ result = await poll.json();
1072
+ }
1073
+ if (result.status === 'failed') throw new Error('Replicate generation failed');
1074
+ return result.output[0];
1075
+ }
1076
+
1077
+ _aiBase64ToBlob(base64, mime) {
1078
+ const byteChars = atob(base64);
1079
+ const byteArray = new Uint8Array(byteChars.length);
1080
+ for (let i = 0; i < byteChars.length; i++) byteArray[i] = byteChars.charCodeAt(i);
1081
+ return new Blob([byteArray], { type: mime });
1082
+ }
1083
+
1084
+ _aiAddToGallery(imageURL, prompt, model, format) {
1085
+ const gallery = this.aiPanel.querySelector('.stpv-ai-gallery');
1086
+ // Remove empty state
1087
+ const empty = gallery.querySelector('.stpv-ai-empty');
1088
+ if (empty) empty.remove();
1089
+
1090
+ const render = { url: imageURL, prompt, model, format, timestamp: Date.now() };
1091
+ this.aiRenders.unshift(render);
1092
+
1093
+ const item = document.createElement('div');
1094
+ item.className = 'stpv-ai-gallery-item';
1095
+ item.innerHTML = `
1096
+ <img src="${imageURL}" alt="AI Render" />
1097
+ <div class="stpv-ai-gallery-actions">
1098
+ <button class="stpv-ai-dl-btn" data-fmt="png">PNG</button>
1099
+ <button class="stpv-ai-dl-btn" data-fmt="jpg">JPG</button>
1100
+ <button class="stpv-ai-dl-btn" data-fmt="webp">WebP</button>
1101
+ </div>
1102
+ `;
1103
+
1104
+ // Download handlers
1105
+ item.querySelectorAll('.stpv-ai-dl-btn').forEach(btn => {
1106
+ btn.addEventListener('click', (e) => {
1107
+ e.stopPropagation();
1108
+ this._aiDownloadRender(imageURL, btn.dataset.fmt);
1109
+ });
1110
+ });
1111
+
1112
+ gallery.prepend(item);
1113
+ }
1114
+
1115
+ async _aiDownloadRender(imageURL, format) {
1116
+ const canvas = document.createElement('canvas');
1117
+ const img = new Image();
1118
+ img.crossOrigin = 'anonymous';
1119
+
1120
+ await new Promise((resolve, reject) => {
1121
+ img.onload = resolve;
1122
+ img.onerror = reject;
1123
+ img.src = imageURL;
1124
+ });
1125
+
1126
+ canvas.width = img.naturalWidth;
1127
+ canvas.height = img.naturalHeight;
1128
+ const ctx = canvas.getContext('2d');
1129
+ ctx.drawImage(img, 0, 0);
1130
+
1131
+ const mimeMap = { png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp' };
1132
+ const mime = mimeMap[format] || 'image/png';
1133
+ const quality = format === 'png' ? undefined : 0.92;
1134
+
1135
+ const dataURL = canvas.toDataURL(mime, quality);
1136
+ const link = document.createElement('a');
1137
+ link.download = `explodeview-render-${Date.now()}.${format}`;
1138
+ link.href = dataURL;
1139
+ link.click();
1140
+ }
1141
+
1142
+ _aiShowKeyError(msg) {
1143
+ const status = this.aiPanel.querySelector('.stpv-ai-key-status');
1144
+ status.className = 'stpv-ai-key-status err';
1145
+ status.textContent = msg;
1146
+ setTimeout(() => {
1147
+ status.className = 'stpv-ai-key-status';
1148
+ const provider = this.aiPanel.querySelector('.stpv-ai-model-select').value.split('-')[0];
1149
+ const saved = this._aiStorageGet('ai-key-' + provider);
1150
+ status.textContent = saved ? 'Key saved for this provider.' : 'Key is stored in browser only, never sent to our servers.';
1151
+ }, 4000);
1152
+ }
1153
+
1154
+ _aiStorageGet(key) {
1155
+ try { return localStorage.getItem('stpv-' + key); } catch { return null; }
1156
+ }
1157
+
1158
+ _aiStorageSet(key, val) {
1159
+ try { localStorage.setItem('stpv-' + key, val); } catch { /* noop */ }
1160
+ }
1161
+
1162
+ _animate() {
1163
+ const T = this.THREE;
1164
+ const clock = new T.Clock();
1165
+ const lerp = (a, b, t) => a + (b - a) * Math.min(1, t);
1166
+
1167
+ const tick = () => {
1168
+ requestAnimationFrame(tick);
1169
+ const delta = Math.min(clock.getDelta(), 0.05);
1170
+
1171
+ // Smooth transitions
1172
+ this.explodeAmount = lerp(this.explodeAmount, this.targetExplode, delta * 3);
1173
+ this.dimAmount = lerp(this.dimAmount, this.targetDim, delta * 4);
1174
+
1175
+ const scale = this.manualMode ? Math.max(this.explodeLevel, 1) : 1;
1176
+ const hlColor = this.activeAssembly >= 0 ? this.asmData[this.activeAssembly].colorObj : null;
1177
+
1178
+ for (const mesh of this.parts) {
1179
+ const ud = mesh.userData;
1180
+ const isHl = (ud.asmIdx === this.activeAssembly);
1181
+
1182
+ // Per-part explode: highlighted collapses, others stay
1183
+ let partExplode;
1184
+ if (this.activeAssembly >= 0 && this.dimAmount > 0.1) {
1185
+ partExplode = isHl ? this.explodeAmount * (1 - this.dimAmount) : this.explodeAmount;
1186
+ } else {
1187
+ partExplode = this.explodeAmount;
1188
+ }
1189
+ ud._currentExplode = lerp(ud._currentExplode, partExplode, delta * 4);
1190
+
1191
+ const ed = ud.explodeDist * ud._currentExplode * scale;
1192
+ mesh.position.set(ud.explodeDir.x * ed, ud.explodeDir.y * ed, ud.explodeDir.z * ed);
1193
+
1194
+ // Color
1195
+ const mat = mesh.material;
1196
+ if (this.dimAmount > 0.05 && this.activeAssembly >= 0) {
1197
+ if (isHl) {
1198
+ mat.color.lerp(hlColor, delta * 6);
1199
+ mat.emissive.copy(hlColor).multiplyScalar(0.1 * this.dimAmount);
1200
+ mat.metalness = lerp(mat.metalness, Math.min(ud.baseMetal + 0.15, 0.8), delta * 4);
1201
+ mat.roughness = lerp(mat.roughness, Math.max(ud.baseRough - 0.1, 0.1), delta * 4);
1202
+ mat.transparent = false; mat.opacity = 1;
1203
+ } else {
1204
+ mat.color.lerp(new T.Color(0x181820), delta * 5);
1205
+ mat.emissive.setHex(0x000000);
1206
+ mat.transparent = true;
1207
+ mat.opacity = lerp(mat.opacity, 1 - this.dimAmount * 0.7, delta * 4);
1208
+ }
1209
+ } else {
1210
+ mat.color.lerp(ud.baseColor, delta * 3);
1211
+ mat.emissive.lerp(new T.Color(0), delta * 5);
1212
+ mat.metalness = lerp(mat.metalness, ud.baseMetal, delta * 3);
1213
+ mat.roughness = lerp(mat.roughness, ud.baseRough, delta * 3);
1214
+ mat.transparent = false; mat.opacity = 1;
1215
+ }
1216
+ }
1217
+
1218
+ // Spotlight
1219
+ if (this.activeAssembly >= 0 && this.dimAmount > 0.3) {
1220
+ const ac = this.asmData[this.activeAssembly].center;
1221
+ this.spot.intensity = lerp(this.spot.intensity, 4 * this.dimAmount, delta * 3);
1222
+ this.spot.position.lerp(new T.Vector3(ac.x * 0.5, 3000, ac.z * 0.5), delta * 2);
1223
+ this.spot.target.position.lerp(new T.Vector3(ac.x * 0.5, ac.y * 0.5, ac.z * 0.5), delta * 2);
1224
+ } else {
1225
+ this.spot.intensity = lerp(this.spot.intensity, 0, delta * 3);
1226
+ }
1227
+
1228
+ this.controls.update();
1229
+ this.renderer.render(this.scene, this.camera);
1230
+ };
1231
+
1232
+ tick();
1233
+ }
1234
+ }
1235
+
1236
+ // ─── Public API ───
1237
+ window.STPViewer = {
1238
+ init: async function (opts) {
1239
+ const container = typeof opts.container === 'string' ? document.querySelector(opts.container) : opts.container;
1240
+ if (!container) { console.error('STPViewer: container not found'); return; }
1241
+
1242
+ const viewer = new STPViewerInstance(container, {
1243
+ src: opts.src,
1244
+ brand: opts.brand || '',
1245
+ productName: opts.productName || '',
1246
+ assemblies: opts.assemblies || [],
1247
+ captions: opts.captions || {},
1248
+ });
1249
+ await viewer.start();
1250
+ return viewer;
1251
+ },
1252
+
1253
+ // Auto-init from data attributes
1254
+ autoInit: function () {
1255
+ document.querySelectorAll('[data-stp-viewer]').forEach(async el => {
1256
+ const src = el.dataset.stpViewer || el.dataset.src;
1257
+ if (!src) return;
1258
+ const viewer = new STPViewerInstance(el, {
1259
+ src,
1260
+ brand: el.dataset.brand || '',
1261
+ productName: el.dataset.productName || '',
1262
+ assemblies: [],
1263
+ captions: {
1264
+ brand: el.dataset.brand || '',
1265
+ productName: el.dataset.productName || '',
1266
+ loaderTitle: el.dataset.loaderTitle || '',
1267
+ loaderText: el.dataset.loaderText || undefined,
1268
+ },
1269
+ });
1270
+ await viewer.start();
1271
+ });
1272
+ }
1273
+ };
1274
+
1275
+ // Auto-init on DOM ready
1276
+ if (document.readyState === 'loading') {
1277
+ document.addEventListener('DOMContentLoaded', () => STPViewer.autoInit());
1278
+ } else {
1279
+ STPViewer.autoInit();
1280
+ }
1281
+ })();