explodeview 0.2.1 → 1.0.1

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.
@@ -64,6 +64,80 @@
64
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
65
  .stpv-info-detail { font-size:0.6rem; color:rgba(255,255,255,0.35); margin-top:8px; line-height:1.6; }
66
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; }
67
141
  `;
68
142
  container.appendChild(style);
69
143
  }
@@ -132,6 +206,8 @@
132
206
  <button class="stpv-cbtn" data-action="free-rotate" title="${captions.titleFreeRotate}">&#9978;</button>
133
207
  <div class="stpv-cdiv"></div>
134
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>
135
211
  </div>
136
212
  <div class="stpv-info">
137
213
  <div class="stpv-info-num"></div>
@@ -140,6 +216,82 @@
140
216
  <div class="stpv-info-detail"></div>
141
217
  <div class="stpv-info-line"></div>
142
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>
143
295
  </div>
144
296
  `;
145
297
  injectStyles(container.querySelector('.stpv'));
@@ -185,6 +337,7 @@
185
337
  this._setupLights();
186
338
  this._setupControls();
187
339
  this._bindUI();
340
+ this._initAIRender();
188
341
 
189
342
  // Load parts
190
343
  await this._loadParts();
@@ -254,7 +407,7 @@
254
407
  this.camera = new T.PerspectiveCamera(35, w / h, 1, 15000);
255
408
  this.camera.position.set(3000, 1800, 3000);
256
409
 
257
- this.renderer = new T.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
410
+ this.renderer = new T.WebGLRenderer({ antialias: true, powerPreference: 'high-performance', preserveDrawingBuffer: true });
258
411
  this.renderer.setSize(w, h);
259
412
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
260
413
  this.renderer.shadowMap.enabled = true;
@@ -497,6 +650,11 @@
497
650
  btn.addEventListener('click', () => {
498
651
  const action = btn.dataset.action;
499
652
 
653
+ if (action === 'ai-render') {
654
+ this._toggleAIPanel();
655
+ return;
656
+ }
657
+
500
658
  if (action === 'expand') {
501
659
  self.explodeLevel = Math.min(3.0, self.explodeLevel + 0.25);
502
660
  self.manualMode = true;
@@ -548,6 +706,459 @@
548
706
  btn.classList.add('active');
549
707
  }
550
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
+
551
1162
  _animate() {
552
1163
  const T = this.THREE;
553
1164
  const clock = new T.Clock();