explodeview 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/explodeview.js +1281 -0
- package/docs/demo/app.js +13561 -0
- package/docs/demo/index.html +2239 -0
- package/docs/demo/manifest.json +6386 -0
- package/package.json +22 -5
- package/src/explodeview.js +612 -1
|
@@ -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}">⟳</button>
|
|
205
|
+
<button class="stpv-cbtn" data-action="stop-rotate" title="${captions.titleStopRotate}">■</button>
|
|
206
|
+
<button class="stpv-cbtn" data-action="free-rotate" title="${captions.titleFreeRotate}">⛺</button>
|
|
207
|
+
<div class="stpv-cdiv"></div>
|
|
208
|
+
<button class="stpv-cbtn" data-action="reset" title="${captions.titleReset}">↺</button>
|
|
209
|
+
<div class="stpv-cdiv"></div>
|
|
210
|
+
<button class="stpv-cbtn stpv-ai-btn" data-action="ai-render" title="AI Render">✨</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">✨ AI Render</div>
|
|
222
|
+
<button class="stpv-ai-close">×</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">📷</span>Product Photo
|
|
241
|
+
</div>
|
|
242
|
+
<div class="stpv-ai-preset" data-preset="marketing">
|
|
243
|
+
<span class="stpv-ai-preset-icon">🌟</span>Marketing Shot
|
|
244
|
+
</div>
|
|
245
|
+
<div class="stpv-ai-preset" data-preset="technical">
|
|
246
|
+
<span class="stpv-ai-preset-icon">🔧</span>Technical
|
|
247
|
+
</div>
|
|
248
|
+
<div class="stpv-ai-preset" data-preset="artistic">
|
|
249
|
+
<span class="stpv-ai-preset-icon">🎨</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">✨ 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">🖼</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
|
+
})();
|