prithivMLmods commited on
Commit
4f91385
·
verified ·
1 Parent(s): 63ccd8e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +446 -596
app.py CHANGED
@@ -131,6 +131,17 @@ def snap_to_nearest(value, options):
131
  return min(options, key=lambda x: abs(x - value))
132
 
133
  def build_lighting_prompt(azimuth: float, elevation: float) -> str:
 
 
 
 
 
 
 
 
 
 
 
134
  azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
135
  elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
136
 
@@ -151,6 +162,9 @@ def infer_lighting_edit(
151
  height: int = 1024,
152
  width: int = 1024,
153
  ):
 
 
 
154
  global loaded
155
  progress = gr.Progress(track_tqdm=True)
156
 
@@ -184,6 +198,7 @@ def infer_lighting_edit(
184
  return result, seed, prompt
185
 
186
  def update_dimensions_on_upload(image):
 
187
  if image is None:
188
  return 1024, 1024
189
  original_width, original_height = image.size
@@ -202,30 +217,26 @@ def update_dimensions_on_upload(image):
202
  class LightingControl3D(gr.HTML):
203
  """
204
  A 3D lighting control component using Three.js.
205
- Features a Fresnel Spotlight with Barn Doors design.
 
206
  """
207
  def __init__(self, value=None, imageUrl=None, **kwargs):
208
  if value is None:
209
  value = {"azimuth": 0, "elevation": 0}
210
 
211
  html_template = """
212
- <div id="lighting-control-wrapper" style="width: 100%; height: 500px; position: relative; background: linear-gradient(180deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); border-radius: 16px; overflow: hidden; border: 2px solid #e94560;">
213
- <div id="info-panel" style="position: absolute; top: 12px; left: 12px; background: rgba(15, 52, 96, 0.9); padding: 12px 16px; border-radius: 10px; font-family: 'Segoe UI', sans-serif; font-size: 12px; color: #eee; z-index: 10; border: 1px solid #e94560; backdrop-filter: blur(10px);">
214
- <div style="font-weight: bold; margin-bottom: 8px; color: #e94560; font-size: 13px;">🎬 Studio Controls</div>
215
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
216
- <span style="width: 14px; height: 14px; background: linear-gradient(135deg, #feca57, #ff9f43); border-radius: 3px; display: inline-block; box-shadow: 0 0 8px #feca57;"></span>
217
- <span>Azimuth Ring</span>
218
  </div>
219
- <div style="display: flex; align-items: center; gap: 8px;">
220
- <span style="width: 14px; height: 14px; background: linear-gradient(135deg, #54a0ff, #2e86de); border-radius: 3px; display: inline-block; box-shadow: 0 0 8px #54a0ff;"></span>
221
- <span>Elevation Arc</span>
222
  </div>
223
  </div>
224
- <div id="angle-display" style="position: absolute; top: 12px; right: 70px; background: rgba(15, 52, 96, 0.9); padding: 10px 14px; border-radius: 10px; font-family: 'Courier New', monospace; font-size: 12px; color: #54a0ff; z-index: 10; border: 1px solid #54a0ff; backdrop-filter: blur(10px);">
225
- <div>Az: <span id="az-value">0°</span></div>
226
- <div>El: <span id="el-value">0°</span></div>
227
- </div>
228
- <div id="prompt-overlay" style="position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); background: linear-gradient(135deg, rgba(233, 69, 96, 0.9), rgba(15, 52, 96, 0.9)); padding: 12px 24px; border-radius: 25px; font-family: 'Segoe UI', sans-serif; font-size: 14px; font-weight: 500; color: #fff; white-space: nowrap; z-index: 10; box-shadow: 0 4px 20px rgba(233, 69, 96, 0.4); backdrop-filter: blur(10px);"></div>
229
  </div>
230
  """
231
 
@@ -233,89 +244,75 @@ class LightingControl3D(gr.HTML):
233
  (() => {
234
  const wrapper = element.querySelector('#lighting-control-wrapper');
235
  const promptOverlay = element.querySelector('#prompt-overlay');
236
- const azDisplay = element.querySelector('#az-value');
237
- const elDisplay = element.querySelector('#el-value');
238
 
 
239
  const initScene = () => {
240
  if (typeof THREE === 'undefined') {
241
  setTimeout(initScene, 100);
242
  return;
243
  }
244
 
245
- // Scene setup with studio environment
246
  const scene = new THREE.Scene();
247
 
248
- // Gradient background using canvas
249
- const canvas = document.createElement('canvas');
250
- canvas.width = 512;
251
- canvas.height = 512;
252
- const ctx = canvas.getContext('2d');
253
- const gradient = ctx.createRadialGradient(256, 256, 0, 256, 256, 400);
254
  gradient.addColorStop(0, '#1a1a2e');
255
  gradient.addColorStop(0.5, '#16213e');
256
- gradient.addColorStop(1, '#0f3460');
257
- ctx.fillStyle = gradient;
258
- ctx.fillRect(0, 0, 512, 512);
259
- const bgTexture = new THREE.CanvasTexture(canvas);
260
  scene.background = bgTexture;
261
 
262
- scene.fog = new THREE.FogExp2(0x0f3460, 0.08);
263
-
264
- const camera = new THREE.PerspectiveCamera(55, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
265
- camera.position.set(5, 3.5, 5);
266
- camera.lookAt(0, 0.8, 0);
267
 
268
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
269
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
270
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
271
  renderer.shadowMap.enabled = true;
272
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
273
- renderer.toneMapping = THREE.ACESFilmicToneMapping;
274
- renderer.toneMappingExposure = 1.0;
275
- wrapper.insertBefore(renderer.domElement, wrapper.firstChild);
276
-
277
- // Ambient and fill lights
278
- scene.add(new THREE.AmbientLight(0x404060, 0.4));
279
- const fillLight = new THREE.DirectionalLight(0x6060ff, 0.2);
280
- fillLight.position.set(-5, 3, -5);
281
- scene.add(fillLight);
282
-
283
- // Studio floor with reflective material
284
- const floorGeometry = new THREE.CircleGeometry(6, 64);
285
- const floorMaterial = new THREE.MeshStandardMaterial({
286
- color: 0x1a1a2e,
287
- roughness: 0.3,
288
- metalness: 0.7,
289
- envMapIntensity: 0.5
290
- });
291
- const floor = new THREE.Mesh(floorGeometry, floorMaterial);
292
- floor.rotation.x = -Math.PI / 2;
293
- floor.receiveShadow = true;
294
- scene.add(floor);
295
-
296
- // Decorative floor rings
297
- for (let i = 1; i <= 4; i++) {
298
- const ring = new THREE.Mesh(
299
- new THREE.RingGeometry(i * 1.2 - 0.02, i * 1.2 + 0.02, 64),
300
- new THREE.MeshBasicMaterial({ color: 0xe94560, transparent: true, opacity: 0.3 })
301
- );
302
- ring.rotation.x = -Math.PI / 2;
303
- ring.position.y = 0.005;
304
- scene.add(ring);
305
  }
306
 
307
- // Shadow plane
308
- const shadowPlane = new THREE.Mesh(
309
- new THREE.CircleGeometry(6, 64),
310
- new THREE.ShadowMaterial({ opacity: 0.5 })
311
- );
312
- shadowPlane.rotation.x = -Math.PI / 2;
313
- shadowPlane.position.y = 0.002;
314
- shadowPlane.receiveShadow = true;
315
- scene.add(shadowPlane);
 
316
 
317
  // Constants
318
- const CENTER = new THREE.Vector3(0, 0.8, 0);
319
  const BASE_DISTANCE = 2.8;
320
  const AZIMUTH_RADIUS = 2.6;
321
  const ELEVATION_RADIUS = 2.0;
@@ -324,6 +321,7 @@ class LightingControl3D(gr.HTML):
324
  let azimuthAngle = props.value?.azimuth || 0;
325
  let elevationAngle = props.value?.elevation || 0;
326
 
 
327
  const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
328
  const elevationSteps = [-90, 0, 90];
329
  const azimuthNames = {
@@ -337,38 +335,45 @@ class LightingControl3D(gr.HTML):
337
  return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
338
  }
339
 
340
- // Placeholder texture
341
  function createPlaceholderTexture() {
342
- const c = document.createElement('canvas');
343
- c.width = 256;
344
- c.height = 256;
345
- const ctx = c.getContext('2d');
346
 
347
- const grad = ctx.createLinearGradient(0, 0, 256, 256);
348
- grad.addColorStop(0, '#16213e');
349
- grad.addColorStop(1, '#0f3460');
350
- ctx.fillStyle = grad;
351
  ctx.fillRect(0, 0, 256, 256);
352
 
353
- // Border
354
- ctx.strokeStyle = '#e94560';
355
- ctx.lineWidth = 6;
356
- ctx.strokeRect(8, 8, 240, 240);
 
 
 
 
 
 
 
 
 
357
 
358
- // Icon
359
- ctx.fillStyle = '#e94560';
360
- ctx.font = 'bold 60px Arial';
361
  ctx.textAlign = 'center';
362
- ctx.fillText('📷', 128, 130);
363
 
364
- ctx.fillStyle = '#aaa';
365
- ctx.font = '14px Arial';
366
- ctx.fillText('Drop Image Here', 128, 180);
367
 
368
- return new THREE.CanvasTexture(c);
369
  }
370
 
371
- // Target image with frame
372
  let currentTexture = createPlaceholderTexture();
373
  const planeMaterial = new THREE.MeshStandardMaterial({
374
  map: currentTexture,
@@ -377,65 +382,42 @@ class LightingControl3D(gr.HTML):
377
  metalness: 0.1
378
  });
379
 
380
- // Image stand/easel
381
- const standGroup = new THREE.Group();
382
 
383
  let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 1.4), planeMaterial);
384
- targetPlane.position.set(0, 0, 0.02);
385
  targetPlane.receiveShadow = true;
386
- targetPlane.castShadow = true;
387
- standGroup.add(targetPlane);
388
-
389
- // Ornate frame
390
- const frameShape = new THREE.Shape();
391
- const fw = 0.78, fh = 0.78, ft = 0.08;
392
- frameShape.moveTo(-fw, -fh);
393
- frameShape.lineTo(fw, -fh);
394
- frameShape.lineTo(fw, fh);
395
- frameShape.lineTo(-fw, fh);
396
- frameShape.lineTo(-fw, -fh);
397
-
398
- const hole = new THREE.Path();
399
- hole.moveTo(-fw + ft, -fh + ft);
400
- hole.lineTo(fw - ft, -fh + ft);
401
- hole.lineTo(fw - ft, fh - ft);
402
- hole.lineTo(-fw + ft, fh - ft);
403
- hole.lineTo(-fw + ft, -fh + ft);
404
- frameShape.holes.push(hole);
405
-
406
- const frameGeometry = new THREE.ExtrudeGeometry(frameShape, { depth: 0.06, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01 });
407
- const frameMaterial = new THREE.MeshStandardMaterial({
408
- color: 0xc9a227,
409
- roughness: 0.3,
410
- metalness: 0.8
411
- });
412
- const frame = new THREE.Mesh(frameGeometry, frameMaterial);
413
- frame.rotation.x = 0;
414
- frame.position.z = -0.03;
415
- standGroup.add(frame);
416
 
417
- // Easel legs
418
- const legMat = new THREE.MeshStandardMaterial({ color: 0x4a3728, roughness: 0.7 });
419
- const legGeo = new THREE.CylinderGeometry(0.03, 0.04, 1.6, 8);
 
420
 
421
- const leg1 = new THREE.Mesh(legGeo, legMat);
422
- leg1.position.set(-0.4, -0.8, -0.15);
423
- leg1.rotation.x = 0.15;
424
- standGroup.add(leg1);
425
 
426
- const leg2 = new THREE.Mesh(legGeo, legMat);
427
- leg2.position.set(0.4, -0.8, -0.15);
428
- leg2.rotation.x = 0.15;
429
- standGroup.add(leg2);
430
 
431
- const leg3 = new THREE.Mesh(legGeo, legMat);
432
- leg3.position.set(0, -0.8, 0.25);
433
- leg3.rotation.x = -0.2;
434
- standGroup.add(leg3);
435
 
436
- standGroup.position.copy(CENTER);
437
- scene.add(standGroup);
438
 
 
439
  function updateTextureFromUrl(url) {
440
  if (!url) {
441
  planeMaterial.map = createPlaceholderTexture();
@@ -451,159 +433,183 @@ class LightingControl3D(gr.HTML):
451
  planeMaterial.map = texture;
452
  planeMaterial.needsUpdate = true;
453
 
 
454
  const img = texture.image;
455
  if (img && img.width && img.height) {
456
  const aspect = img.width / img.height;
457
  const maxSize = 1.4;
458
- let pw, ph;
459
- if (aspect > 1) { pw = maxSize; ph = maxSize / aspect; }
460
- else { ph = maxSize; pw = maxSize * aspect; }
 
 
 
 
 
461
 
462
- standGroup.remove(targetPlane);
463
- targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(pw, ph), planeMaterial);
464
- targetPlane.position.set(0, 0, 0.02);
 
 
465
  targetPlane.receiveShadow = true;
466
- targetPlane.castShadow = true;
467
- standGroup.add(targetPlane);
468
  }
 
 
469
  });
470
  }
471
 
472
- if (props.imageUrl) updateTextureFromUrl(props.imageUrl);
 
 
473
 
474
- // =============================================
475
- // FRESNEL SPOTLIGHT WITH BARN DOORS
476
- // =============================================
477
  const lightGroup = new THREE.Group();
478
 
479
- // Main housing (cylindrical body)
480
- const housingMat = new THREE.MeshStandardMaterial({
481
- color: 0x2d2d2d,
482
- roughness: 0.4,
483
- metalness: 0.8
484
- });
485
-
486
- // Body
487
- const body = new THREE.Mesh(
488
- new THREE.CylinderGeometry(0.25, 0.3, 0.5, 32),
489
- housingMat
490
- );
491
- body.rotation.x = Math.PI / 2;
492
- lightGroup.add(body);
493
-
494
- // Front ring (chrome)
495
- const ringMat = new THREE.MeshStandardMaterial({
496
- color: 0xcccccc,
497
- roughness: 0.2,
498
- metalness: 0.9
499
- });
500
- const frontRing = new THREE.Mesh(
501
- new THREE.TorusGeometry(0.28, 0.025, 16, 32),
502
- ringMat
503
- );
504
- frontRing.position.z = -0.25;
505
- lightGroup.add(frontRing);
506
 
507
- // Fresnel lens (glass-like)
508
- const lensMat = new THREE.MeshStandardMaterial({
509
- color: 0xffffff,
510
- emissive: 0xffffee,
511
- emissiveIntensity: 3.0,
512
- roughness: 0.1,
513
- metalness: 0,
514
- transparent: true,
515
- opacity: 0.9
516
  });
517
- const lens = new THREE.Mesh(
518
- new THREE.CircleGeometry(0.22, 32),
519
- lensMat
520
  );
521
- lens.position.z = -0.26;
522
- lightGroup.add(lens);
523
-
524
- // Fresnel rings on lens
525
- for (let i = 1; i <= 4; i++) {
526
- const fresnelRing = new THREE.Mesh(
527
- new THREE.RingGeometry(i * 0.045, i * 0.045 + 0.008, 32),
528
- new THREE.MeshBasicMaterial({ color: 0xffffcc, transparent: true, opacity: 0.5 })
529
- );
530
- fresnelRing.position.z = -0.261;
531
- lightGroup.add(fresnelRing);
532
- }
533
 
534
- // Barn doors (4 flaps)
535
- const barnDoorMat = new THREE.MeshStandardMaterial({
536
- color: 0x1a1a1a,
537
- roughness: 0.6,
538
  metalness: 0.5,
539
  side: THREE.DoubleSide
540
  });
541
 
542
- const doorWidth = 0.35, doorHeight = 0.25;
543
- const doorPositions = [
544
- { pos: [0, 0.32, -0.15], rot: [-0.4, 0, 0] }, // Top
545
- { pos: [0, -0.32, -0.15], rot: [0.4, 0, 0] }, // Bottom
546
- { pos: [0.32, 0, -0.15], rot: [0, 0, 0.4] }, // Right
547
- { pos: [-0.32, 0, -0.15], rot: [0, 0, -0.4] } // Left
548
- ];
549
-
550
- doorPositions.forEach((d, i) => {
551
- const isVertical = i < 2;
552
- const door = new THREE.Mesh(
553
- new THREE.BoxGeometry(isVertical ? doorWidth : 0.02, isVertical ? 0.02 : doorHeight, 0.2),
554
- barnDoorMat
555
- );
556
- door.position.set(...d.pos);
557
- door.rotation.set(...d.rot);
558
- door.castShadow = true;
559
- lightGroup.add(door);
560
- });
561
-
562
- // Yoke mount
563
- const yokeMat = new THREE.MeshStandardMaterial({
564
- color: 0x444444,
565
- roughness: 0.5,
566
- metalness: 0.7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  });
568
-
569
- const yokeArm1 = new THREE.Mesh(
570
- new THREE.BoxGeometry(0.04, 0.5, 0.04),
571
- yokeMat
572
  );
573
- yokeArm1.position.set(-0.35, 0, 0.1);
574
- lightGroup.add(yokeArm1);
575
-
576
- const yokeArm2 = new THREE.Mesh(
577
- new THREE.BoxGeometry(0.04, 0.5, 0.04),
578
- yokeMat
579
- );
580
- yokeArm2.position.set(0.35, 0, 0.1);
581
- lightGroup.add(yokeArm2);
582
-
583
- const yokeTop = new THREE.Mesh(
584
- new THREE.BoxGeometry(0.74, 0.04, 0.04),
585
- yokeMat
586
- );
587
- yokeTop.position.set(0, 0.25, 0.1);
588
- lightGroup.add(yokeTop);
589
-
590
- // Hanging mount
591
- const mountPole = new THREE.Mesh(
592
- new THREE.CylinderGeometry(0.02, 0.02, 0.3, 8),
593
- yokeMat
594
- );
595
- mountPole.position.set(0, 0.4, 0.1);
596
- lightGroup.add(mountPole);
597
-
598
- // SpotLight
599
- const spotLight = new THREE.SpotLight(0xffffee, 20, 15, Math.PI / 5, 0.3, 1);
600
- spotLight.position.set(0, 0, -0.3);
 
 
 
 
 
 
 
 
 
601
  spotLight.castShadow = true;
602
- spotLight.shadow.mapSize.width = 2048;
603
- spotLight.shadow.mapSize.height = 2048;
604
  spotLight.shadow.camera.near = 0.5;
605
- spotLight.shadow.camera.far = 20;
606
- spotLight.shadow.bias = -0.001;
607
  lightGroup.add(spotLight);
608
 
609
  const lightTarget = new THREE.Object3D();
@@ -613,262 +619,131 @@ class LightingControl3D(gr.HTML):
613
 
614
  scene.add(lightGroup);
615
 
616
- // =============================================
617
- // AZIMUTH CONTROL - Golden compass ring
618
- // =============================================
619
- const azimuthGroup = new THREE.Group();
620
-
621
- // Main ring with glow
622
- const azRingMat = new THREE.MeshStandardMaterial({
623
- color: 0xfeca57,
624
- emissive: 0xff9f43,
625
- emissiveIntensity: 0.5,
626
- roughness: 0.2,
627
- metalness: 0.8
628
  });
629
-
630
  const azimuthRing = new THREE.Mesh(
631
- new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.045, 24, 128),
632
- azRingMat
633
  );
634
- azimuthGroup.add(azimuthRing);
 
 
635
 
636
- // Direction markers with labels
637
- azimuthSteps.forEach((angle, idx) => {
 
638
  const rad = THREE.MathUtils.degToRad(angle);
639
- const x = AZIMUTH_RADIUS * Math.sin(rad);
640
- const z = AZIMUTH_RADIUS * Math.cos(rad);
641
-
642
- // Diamond marker
643
- const marker = new THREE.Mesh(
644
- new THREE.OctahedronGeometry(0.08, 0),
645
- new THREE.MeshStandardMaterial({
646
- color: 0xfeca57,
647
- emissive: 0xff9f43,
648
- emissiveIntensity: 0.3,
649
- roughness: 0.2,
650
- metalness: 0.8
651
- })
652
- );
653
- marker.position.set(x, 0, z);
654
  marker.rotation.y = -rad;
655
- azimuthGroup.add(marker);
656
-
657
- // Tick lines pointing to center
658
- const tickGeo = new THREE.BufferGeometry().setFromPoints([
659
- new THREE.Vector3(x * 0.92, 0, z * 0.92),
660
- new THREE.Vector3(x * 0.85, 0, z * 0.85)
661
- ]);
662
- const tick = new THREE.Line(tickGeo, new THREE.LineBasicMaterial({ color: 0xfeca57, transparent: true, opacity: 0.6 }));
663
- azimuthGroup.add(tick);
664
  });
665
 
666
- azimuthGroup.rotation.x = Math.PI / 2;
667
- azimuthGroup.position.y = 0.08;
668
- scene.add(azimuthGroup);
669
-
670
- // Azimuth handle - Arrow/pointer shape
671
- const azHandleGroup = new THREE.Group();
672
-
673
- const azPointer = new THREE.Mesh(
674
- new THREE.ConeGeometry(0.12, 0.25, 4),
675
  new THREE.MeshStandardMaterial({
676
- color: 0xfeca57,
677
- emissive: 0xff9f43,
678
- emissiveIntensity: 0.7,
679
- roughness: 0.15,
680
- metalness: 0.9
681
- })
682
- );
683
- azPointer.rotation.x = Math.PI / 2;
684
- azPointer.rotation.z = Math.PI / 4;
685
- azHandleGroup.add(azPointer);
686
-
687
- const azSphere = new THREE.Mesh(
688
- new THREE.SphereGeometry(0.1, 16, 16),
689
- new THREE.MeshStandardMaterial({
690
- color: 0xffffff,
691
- emissive: 0xfeca57,
692
- emissiveIntensity: 0.5,
693
- roughness: 0.1,
694
- metalness: 0.9
695
  })
696
  );
697
- azSphere.position.z = 0.15;
698
- azHandleGroup.add(azSphere);
699
-
700
- azHandleGroup.userData.type = 'azimuth';
701
- scene.add(azHandleGroup);
702
 
703
- // =============================================
704
- // ELEVATION CONTROL - Blue arc with slider
705
- // =============================================
706
- const elevationGroup = new THREE.Group();
707
-
708
- // Main arc
709
  const arcPoints = [];
710
- for (let i = 0; i <= 64; i++) {
711
- const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 64));
712
  arcPoints.push(new THREE.Vector3(
713
- 0,
714
- ELEVATION_RADIUS * Math.sin(angle),
715
  ELEVATION_RADIUS * Math.cos(angle)
716
  ));
717
  }
718
  const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
719
  const elevationArc = new THREE.Mesh(
720
- new THREE.TubeGeometry(arcCurve, 64, 0.04, 16, false),
721
  new THREE.MeshStandardMaterial({
722
- color: 0x54a0ff,
723
- emissive: 0x2e86de,
724
- emissiveIntensity: 0.5,
725
- roughness: 0.2,
726
- metalness: 0.8
727
  })
728
  );
729
- elevationGroup.add(elevationArc);
730
 
731
- // Tick marks and labels
 
732
  elevationSteps.forEach(angle => {
733
  const rad = THREE.MathUtils.degToRad(angle);
734
- const y = ELEVATION_RADIUS * Math.sin(rad);
735
- const z = ELEVATION_RADIUS * Math.cos(rad);
736
-
737
- // Cube marker
738
- const marker = new THREE.Mesh(
739
- new THREE.BoxGeometry(0.12, 0.12, 0.12),
740
- new THREE.MeshStandardMaterial({
741
- color: 0x54a0ff,
742
- emissive: 0x2e86de,
743
- emissiveIntensity: 0.4,
744
- roughness: 0.2,
745
- metalness: 0.8
746
- })
747
  );
748
- marker.position.set(0, y, z);
749
- marker.rotation.x = rad;
750
- marker.rotation.y = Math.PI / 4;
751
- elevationGroup.add(marker);
752
  });
753
 
754
- // Support pole
755
- const supportPole = new THREE.Mesh(
756
- new THREE.CylinderGeometry(0.025, 0.025, ELEVATION_RADIUS * 2.2, 8),
757
- new THREE.MeshStandardMaterial({ color: 0x333344, roughness: 0.5, metalness: 0.6 })
758
- );
759
- supportPole.position.set(0, CENTER.y, 0);
760
- elevationGroup.add(supportPole);
761
-
762
- elevationGroup.position.set(-1.0, CENTER.y, 0);
763
- scene.add(elevationGroup);
764
-
765
- // Elevation handle - Spherical slider
766
- const elHandleGroup = new THREE.Group();
767
-
768
- const elSphere = new THREE.Mesh(
769
- new THREE.SphereGeometry(0.14, 24, 24),
770
  new THREE.MeshStandardMaterial({
771
- color: 0x54a0ff,
772
- emissive: 0x2e86de,
773
- emissiveIntensity: 0.7,
774
- roughness: 0.15,
775
- metalness: 0.9
776
  })
777
  );
778
- elHandleGroup.add(elSphere);
 
779
 
780
- const elRing = new THREE.Mesh(
781
- new THREE.TorusGeometry(0.18, 0.025, 16, 32),
782
- new THREE.MeshStandardMaterial({
783
- color: 0xffffff,
784
- emissive: 0x54a0ff,
785
- emissiveIntensity: 0.4,
786
- roughness: 0.1,
787
- metalness: 0.9
788
- })
789
- );
790
- elRing.rotation.x = Math.PI / 2;
791
- elHandleGroup.add(elRing);
792
-
793
- elHandleGroup.userData.type = 'elevation';
794
- scene.add(elHandleGroup);
795
-
796
- // =============================================
797
- // RESET BUTTON - Styled button
798
- // =============================================
799
- const resetBtn = document.createElement('button');
800
- resetBtn.innerHTML = `
801
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
802
- <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
803
- <path d="M21 3v5h-5"/>
804
- <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
805
- <path d="M3 21v-5h5"/>
806
- </svg>
807
- <span>Reset Position</span>
808
- `;
809
- resetBtn.style.cssText = `
810
  position: absolute;
811
- top: 12px;
812
- right: 12px;
813
- background: linear-gradient(135deg, #e94560 0%, #c62a47 100%);
814
  color: white;
815
  border: none;
816
- padding: 10px 16px;
817
  border-radius: 8px;
818
  cursor: pointer;
819
  z-index: 10;
820
- font-size: 13px;
821
  font-weight: 600;
822
  font-family: 'Segoe UI', sans-serif;
823
- display: flex;
824
- align-items: center;
825
- gap: 8px;
826
- box-shadow: 0 4px 15px rgba(233, 69, 96, 0.4);
827
- transition: all 0.25s ease;
828
- border: 1px solid rgba(255,255,255,0.1);
829
  `;
830
- resetBtn.onmouseenter = () => {
831
- resetBtn.style.background = 'linear-gradient(135deg, #ff6b6b 0%, #e94560 100%)';
832
- resetBtn.style.transform = 'translateY(-2px) scale(1.02)';
833
- resetBtn.style.boxShadow = '0 6px 20px rgba(233, 69, 96, 0.5)';
834
  };
835
- resetBtn.onmouseleave = () => {
836
- resetBtn.style.background = 'linear-gradient(135deg, #e94560 0%, #c62a47 100%)';
837
- resetBtn.style.transform = 'translateY(0) scale(1)';
838
- resetBtn.style.boxShadow = '0 4px 15px rgba(233, 69, 96, 0.4)';
839
  };
840
- wrapper.appendChild(resetBtn);
841
 
842
- resetBtn.addEventListener('click', () => {
843
- const startAz = azimuthAngle;
844
- const startEl = elevationAngle;
845
- const startTime = Date.now();
846
-
847
- function animateReset() {
848
- const t = Math.min((Date.now() - startTime) / 500, 1);
849
- const ease = 1 - Math.pow(1 - t, 4);
850
-
851
- let azDiff = 0 - startAz;
852
- if (azDiff > 180) azDiff -= 360;
853
- if (azDiff < -180) azDiff += 360;
854
- azimuthAngle = startAz + azDiff * ease;
855
- if (azimuthAngle < 0) azimuthAngle += 360;
856
- if (azimuthAngle >= 360) azimuthAngle -= 360;
857
-
858
- elevationAngle = startEl + (0 - startEl) * ease;
859
-
860
- updatePositions();
861
-
862
- if (t < 1) {
863
- requestAnimationFrame(animateReset);
864
- } else {
865
- azimuthAngle = 0;
866
- elevationAngle = 0;
867
- updatePositions();
868
- updatePropsAndTrigger();
869
- }
870
- }
871
- animateReset();
872
  });
873
 
874
  function updatePositions() {
@@ -883,29 +758,17 @@ class LightingControl3D(gr.HTML):
883
  lightGroup.position.set(lightX, lightY, lightZ);
884
  lightGroup.lookAt(CENTER);
885
 
886
- // Update azimuth handle
887
- azHandleGroup.position.set(
888
- AZIMUTH_RADIUS * Math.sin(azRad),
889
- 0.08,
890
- AZIMUTH_RADIUS * Math.cos(azRad)
891
- );
892
- azHandleGroup.rotation.y = -azRad;
893
-
894
- // Update elevation handle
895
- elHandleGroup.position.set(
896
- -1.0,
897
  ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y,
898
  ELEVATION_RADIUS * Math.cos(elRad)
899
  );
900
 
901
- // Update displays
902
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
903
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
904
-
905
- azDisplay.textContent = Math.round(azimuthAngle) + '°';
906
- elDisplay.textContent = Math.round(elevationAngle) + '°';
907
-
908
- let prompt = '🎬 Light from';
909
  if (elSnap !== 0) {
910
  prompt += ' ' + elevationNames[String(elSnap)];
911
  } else {
@@ -927,39 +790,25 @@ class LightingControl3D(gr.HTML):
927
  const mouse = new THREE.Vector2();
928
  let isDragging = false;
929
  let dragTarget = null;
 
930
  const intersection = new THREE.Vector3();
931
 
932
  const canvas = renderer.domElement;
933
 
934
- function getIntersectedHandle(e) {
935
  const rect = canvas.getBoundingClientRect();
936
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
937
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
938
 
939
  raycaster.setFromCamera(mouse, camera);
940
- const intersects = raycaster.intersectObjects([azHandleGroup, elHandleGroup], true);
941
 
942
  if (intersects.length > 0) {
943
- let obj = intersects[0].object;
944
- while (obj.parent && !obj.userData.type) obj = obj.parent;
945
- return obj;
946
- }
947
- return null;
948
- }
949
-
950
- canvas.addEventListener('mousedown', (e) => {
951
- const handle = getIntersectedHandle(e);
952
- if (handle && handle.userData.type) {
953
  isDragging = true;
954
- dragTarget = handle;
955
-
956
- // Highlight effect
957
- dragTarget.children.forEach(child => {
958
- if (child.material) {
959
- child.material.emissiveIntensity = 1.5;
960
- }
961
- });
962
- dragTarget.scale.setScalar(1.25);
963
  canvas.style.cursor = 'grabbing';
964
  }
965
  });
@@ -973,39 +822,30 @@ class LightingControl3D(gr.HTML):
973
  raycaster.setFromCamera(mouse, camera);
974
 
975
  if (dragTarget.userData.type === 'azimuth') {
976
- const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.08);
977
  if (raycaster.ray.intersectPlane(plane, intersection)) {
978
  azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
979
  if (azimuthAngle < 0) azimuthAngle += 360;
980
  }
981
  } else if (dragTarget.userData.type === 'elevation') {
982
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 1.0);
983
  if (raycaster.ray.intersectPlane(plane, intersection)) {
984
  const relY = intersection.y - CENTER.y;
985
  const relZ = intersection.z;
986
- elevationAngle = THREE.MathUtils.clamp(
987
- THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)),
988
- -90,
989
- 90
990
- );
991
  }
992
  }
993
  updatePositions();
994
  } else {
995
- // Hover effects
996
- const handle = getIntersectedHandle(e);
997
- [azHandleGroup, elHandleGroup].forEach(h => {
998
- h.children.forEach(child => {
999
- if (child.material) child.material.emissiveIntensity = 0.5;
1000
- });
1001
  h.scale.setScalar(1);
1002
  });
1003
-
1004
- if (handle && handle.userData.type) {
1005
- handle.children.forEach(child => {
1006
- if (child.material) child.material.emissiveIntensity = 0.9;
1007
- });
1008
- handle.scale.setScalar(1.1);
1009
  canvas.style.cursor = 'grab';
1010
  } else {
1011
  canvas.style.cursor = 'default';
@@ -1015,12 +855,10 @@ class LightingControl3D(gr.HTML):
1015
 
1016
  const onMouseUp = () => {
1017
  if (dragTarget) {
1018
- dragTarget.children.forEach(child => {
1019
- if (child.material) child.material.emissiveIntensity = 0.5;
1020
- });
1021
  dragTarget.scale.setScalar(1);
1022
 
1023
- // Snap animation
1024
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
1025
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
1026
 
@@ -1028,7 +866,7 @@ class LightingControl3D(gr.HTML):
1028
  const startTime = Date.now();
1029
 
1030
  function animateSnap() {
1031
- const t = Math.min((Date.now() - startTime) / 250, 1);
1032
  const ease = 1 - Math.pow(1 - t, 3);
1033
 
1034
  let azDiff = targetAz - startAz;
@@ -1054,73 +892,70 @@ class LightingControl3D(gr.HTML):
1054
  canvas.addEventListener('mouseup', onMouseUp);
1055
  canvas.addEventListener('mouseleave', onMouseUp);
1056
 
1057
- // Touch support
1058
  canvas.addEventListener('touchstart', (e) => {
1059
  e.preventDefault();
1060
  const touch = e.touches[0];
1061
- const fakeEvent = { clientX: touch.clientX, clientY: touch.clientY };
1062
- const handle = getIntersectedHandle(fakeEvent);
1063
- if (handle && handle.userData.type) {
 
 
 
 
 
1064
  isDragging = true;
1065
- dragTarget = handle;
1066
- dragTarget.children.forEach(child => {
1067
- if (child.material) child.material.emissiveIntensity = 1.5;
1068
- });
1069
- dragTarget.scale.setScalar(1.25);
1070
  }
1071
  }, { passive: false });
1072
 
1073
  canvas.addEventListener('touchmove', (e) => {
1074
  e.preventDefault();
1075
- if (!isDragging || !dragTarget) return;
1076
-
1077
  const touch = e.touches[0];
1078
  const rect = canvas.getBoundingClientRect();
1079
  mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
1080
  mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
1081
 
1082
- raycaster.setFromCamera(mouse, camera);
1083
-
1084
- if (dragTarget.userData.type === 'azimuth') {
1085
- const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.08);
1086
- if (raycaster.ray.intersectPlane(plane, intersection)) {
1087
- azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
1088
- if (azimuthAngle < 0) azimuthAngle += 360;
1089
- }
1090
- } else if (dragTarget.userData.type === 'elevation') {
1091
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 1.0);
1092
- if (raycaster.ray.intersectPlane(plane, intersection)) {
1093
- const relY = intersection.y - CENTER.y;
1094
- const relZ = intersection.z;
1095
- elevationAngle = THREE.MathUtils.clamp(
1096
- THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)),
1097
- -90,
1098
- 90
1099
- );
1100
  }
 
1101
  }
1102
- updatePositions();
1103
  }, { passive: false });
1104
 
1105
- canvas.addEventListener('touchend', (e) => { e.preventDefault(); onMouseUp(); }, { passive: false });
1106
- canvas.addEventListener('touchcancel', (e) => { e.preventDefault(); onMouseUp(); }, { passive: false });
 
 
 
 
 
 
 
1107
 
1108
  // Initial update
1109
  updatePositions();
1110
 
1111
- // Animation variables
1112
- let time = 0;
1113
-
1114
- // Render loop with subtle animations
1115
  function render() {
1116
  requestAnimationFrame(render);
1117
- time += 0.01;
1118
-
1119
- // Subtle lens pulse
1120
- if (lens) {
1121
- lens.material.emissiveIntensity = 2.5 + Math.sin(time * 2) * 0.5;
1122
- }
1123
-
1124
  renderer.render(scene, camera);
1125
  }
1126
  render();
@@ -1132,6 +967,17 @@ class LightingControl3D(gr.HTML):
1132
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
1133
  }).observe(wrapper);
1134
 
 
 
 
 
 
 
 
 
 
 
 
1135
  // Watch for prop changes
1136
  let lastImageUrl = props.imageUrl;
1137
  let lastValue = JSON.stringify(props.value);
@@ -1180,14 +1026,14 @@ with gr.Blocks(css=css) as demo:
1180
  with gr.Column(scale=1):
1181
  image = gr.Image(label="Input Image", type="pil", height=300)
1182
 
1183
- gr.Markdown("### 🎬 Studio Lighting Control")
1184
- gr.Markdown("*Drag handles: 🟡 **Azimuth** (compass ring) 🔵 **Elevation** (vertical arc)*")
1185
 
1186
  lighting_3d = LightingControl3D(
1187
  value={"azimuth": 0, "elevation": 0},
1188
  elem_id="lighting-3d-control"
1189
  )
1190
- run_btn = gr.Button("🎬 Generate Image", variant="primary", size="lg")
1191
 
1192
  gr.Markdown("### Slider Controls")
1193
 
@@ -1229,10 +1075,12 @@ with gr.Blocks(css=css) as demo:
1229
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
1230
 
1231
  def update_prompt_from_sliders(azimuth, elevation):
 
1232
  prompt = build_lighting_prompt(azimuth, elevation)
1233
  return prompt
1234
 
1235
  def sync_3d_to_sliders(lighting_value):
 
1236
  if lighting_value and isinstance(lighting_value, dict):
1237
  az = lighting_value.get('azimuth', 0)
1238
  el = lighting_value.get('elevation', 0)
@@ -1241,9 +1089,11 @@ with gr.Blocks(css=css) as demo:
1241
  return gr.update(), gr.update(), gr.update()
1242
 
1243
  def sync_sliders_to_3d(azimuth, elevation):
 
1244
  return {"azimuth": azimuth, "elevation": elevation}
1245
 
1246
  def update_3d_image(image):
 
1247
  if image is None:
1248
  return gr.update(imageUrl=None)
1249
 
 
131
  return min(options, key=lambda x: abs(x - value))
132
 
133
  def build_lighting_prompt(azimuth: float, elevation: float) -> str:
134
+ """
135
+ Build a lighting prompt from azimuth and elevation values.
136
+
137
+ Args:
138
+ azimuth: Horizontal rotation in degrees (0-360)
139
+ elevation: Vertical angle in degrees (-90 to 90)
140
+
141
+ Returns:
142
+ Formatted prompt string for the LoRA
143
+ """
144
+ # Snap to nearest valid values
145
  azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
146
  elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
147
 
 
162
  height: int = 1024,
163
  width: int = 1024,
164
  ):
165
+ """
166
+ Edit the lighting of an image using Qwen Image Edit 2511 with multi-angle lighting LoRA.
167
+ """
168
  global loaded
169
  progress = gr.Progress(track_tqdm=True)
170
 
 
198
  return result, seed, prompt
199
 
200
  def update_dimensions_on_upload(image):
201
+ """Compute recommended dimensions preserving aspect ratio."""
202
  if image is None:
203
  return 1024, 1024
204
  original_width, original_height = image.size
 
217
  class LightingControl3D(gr.HTML):
218
  """
219
  A 3D lighting control component using Three.js.
220
+ Outputs: { azimuth: number, elevation: number }
221
+ Accepts imageUrl prop to display user's uploaded image on the plane.
222
  """
223
  def __init__(self, value=None, imageUrl=None, **kwargs):
224
  if value is None:
225
  value = {"azimuth": 0, "elevation": 0}
226
 
227
  html_template = """
228
+ <div id="lighting-control-wrapper" style="width: 100%; height: 500px; position: relative; background: linear-gradient(180deg, #1a1a2e 0%, #16213e 50%, #0f0f23 100%); border-radius: 12px; overflow: hidden; border: 2px solid #333;">
229
+ <div id="prompt-overlay" style="position: absolute; bottom: 15px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.85); padding: 10px 20px; border-radius: 10px; font-family: 'Segoe UI', sans-serif; font-size: 14px; color: #00ff88; white-space: nowrap; z-index: 10; border: 1px solid #00ff88; box-shadow: 0 0 15px rgba(0,255,136,0.3);"></div>
230
+ <div id="legend-overlay" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.8); padding: 10px 15px; border-radius: 8px; font-family: 'Segoe UI', sans-serif; font-size: 12px; color: #fff; z-index: 10; border: 1px solid #444;">
231
+ <div style="display: flex; align-items: center; margin-bottom: 6px;">
232
+ <span style="display: inline-block; width: 14px; height: 14px; background: #FFD700; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 8px #FFD700;"></span>
233
+ <span>Azimuth (Direction)</span>
234
  </div>
235
+ <div style="display: flex; align-items: center;">
236
+ <span style="display: inline-block; width: 14px; height: 14px; background: #4169E1; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 8px #4169E1;"></span>
237
+ <span>Elevation (Height)</span>
238
  </div>
239
  </div>
 
 
 
 
 
240
  </div>
241
  """
242
 
 
244
  (() => {
245
  const wrapper = element.querySelector('#lighting-control-wrapper');
246
  const promptOverlay = element.querySelector('#prompt-overlay');
 
 
247
 
248
+ // Wait for THREE to load
249
  const initScene = () => {
250
  if (typeof THREE === 'undefined') {
251
  setTimeout(initScene, 100);
252
  return;
253
  }
254
 
255
+ // Scene setup
256
  const scene = new THREE.Scene();
257
 
258
+ // Create gradient background
259
+ const canvas2 = document.createElement('canvas');
260
+ canvas2.width = 512;
261
+ canvas2.height = 512;
262
+ const ctx2 = canvas2.getContext('2d');
263
+ const gradient = ctx2.createLinearGradient(0, 0, 0, 512);
264
  gradient.addColorStop(0, '#1a1a2e');
265
  gradient.addColorStop(0.5, '#16213e');
266
+ gradient.addColorStop(1, '#0f0f23');
267
+ ctx2.fillStyle = gradient;
268
+ ctx2.fillRect(0, 0, 512, 512);
269
+ const bgTexture = new THREE.CanvasTexture(canvas2);
270
  scene.background = bgTexture;
271
 
272
+ const camera = new THREE.PerspectiveCamera(45, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
273
+ camera.position.set(5, 4, 5);
274
+ camera.lookAt(0, 0.5, 0);
 
 
275
 
276
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
277
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
278
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
279
  renderer.shadowMap.enabled = true;
280
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
281
+ wrapper.insertBefore(renderer.domElement, promptOverlay);
282
+
283
+ // Ambient light
284
+ scene.add(new THREE.AmbientLight(0xffffff, 0.15));
285
+
286
+ // Ground plane with checkerboard pattern
287
+ function createCheckerTexture() {
288
+ const size = 512;
289
+ const canvas = document.createElement('canvas');
290
+ canvas.width = size;
291
+ canvas.height = size;
292
+ const ctx = canvas.getContext('2d');
293
+ const tileSize = 32;
294
+ for (let i = 0; i < size / tileSize; i++) {
295
+ for (let j = 0; j < size / tileSize; j++) {
296
+ ctx.fillStyle = (i + j) % 2 === 0 ? '#2a2a3a' : '#1a1a2a';
297
+ ctx.fillRect(i * tileSize, j * tileSize, tileSize, tileSize);
298
+ }
299
+ }
300
+ return new THREE.CanvasTexture(canvas);
 
 
 
 
 
 
 
 
 
 
 
 
301
  }
302
 
303
+ const groundMat = new THREE.MeshStandardMaterial({
304
+ map: createCheckerTexture(),
305
+ roughness: 0.8,
306
+ metalness: 0.2
307
+ });
308
+ const ground = new THREE.Mesh(new THREE.PlaneGeometry(12, 12), groundMat);
309
+ ground.rotation.x = -Math.PI / 2;
310
+ ground.position.y = 0;
311
+ ground.receiveShadow = true;
312
+ scene.add(ground);
313
 
314
  // Constants
315
+ const CENTER = new THREE.Vector3(0, 0.75, 0);
316
  const BASE_DISTANCE = 2.8;
317
  const AZIMUTH_RADIUS = 2.6;
318
  const ELEVATION_RADIUS = 2.0;
 
321
  let azimuthAngle = props.value?.azimuth || 0;
322
  let elevationAngle = props.value?.elevation || 0;
323
 
324
+ // Mappings
325
  const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
326
  const elevationSteps = [-90, 0, 90];
327
  const azimuthNames = {
 
335
  return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
336
  }
337
 
338
+ // Create placeholder texture
339
  function createPlaceholderTexture() {
340
+ const canvas = document.createElement('canvas');
341
+ canvas.width = 256;
342
+ canvas.height = 256;
343
+ const ctx = canvas.getContext('2d');
344
 
345
+ // Background
346
+ ctx.fillStyle = '#2a2a3a';
 
 
347
  ctx.fillRect(0, 0, 256, 256);
348
 
349
+ // Grid pattern
350
+ ctx.strokeStyle = '#3a3a4a';
351
+ ctx.lineWidth = 1;
352
+ for (let i = 0; i < 256; i += 32) {
353
+ ctx.beginPath();
354
+ ctx.moveTo(i, 0);
355
+ ctx.lineTo(i, 256);
356
+ ctx.stroke();
357
+ ctx.beginPath();
358
+ ctx.moveTo(0, i);
359
+ ctx.lineTo(256, i);
360
+ ctx.stroke();
361
+ }
362
 
363
+ // Upload icon
364
+ ctx.fillStyle = '#666';
365
+ ctx.font = '48px Arial';
366
  ctx.textAlign = 'center';
367
+ ctx.fillText('📷', 128, 120);
368
 
369
+ ctx.fillStyle = '#888';
370
+ ctx.font = '16px Arial';
371
+ ctx.fillText('Upload Image', 128, 160);
372
 
373
+ return new THREE.CanvasTexture(canvas);
374
  }
375
 
376
+ // Target image plane with frame
377
  let currentTexture = createPlaceholderTexture();
378
  const planeMaterial = new THREE.MeshStandardMaterial({
379
  map: currentTexture,
 
382
  metalness: 0.1
383
  });
384
 
385
+ // Create frame group
386
+ const targetGroup = new THREE.Group();
387
 
388
  let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 1.4), planeMaterial);
 
389
  targetPlane.receiveShadow = true;
390
+ targetGroup.add(targetPlane);
391
+
392
+ // Frame around the image
393
+ const frameMat = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.5, metalness: 0.8 });
394
+ const frameThickness = 0.05;
395
+ const frameDepth = 0.08;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
 
397
+ // Top frame
398
+ const frameTop = new THREE.Mesh(new THREE.BoxGeometry(1.5, frameThickness, frameDepth), frameMat);
399
+ frameTop.position.set(0, 0.725, 0);
400
+ targetGroup.add(frameTop);
401
 
402
+ // Bottom frame
403
+ const frameBottom = new THREE.Mesh(new THREE.BoxGeometry(1.5, frameThickness, frameDepth), frameMat);
404
+ frameBottom.position.set(0, -0.725, 0);
405
+ targetGroup.add(frameBottom);
406
 
407
+ // Left frame
408
+ const frameLeft = new THREE.Mesh(new THREE.BoxGeometry(frameThickness, 1.5, frameDepth), frameMat);
409
+ frameLeft.position.set(-0.725, 0, 0);
410
+ targetGroup.add(frameLeft);
411
 
412
+ // Right frame
413
+ const frameRight = new THREE.Mesh(new THREE.BoxGeometry(frameThickness, 1.5, frameDepth), frameMat);
414
+ frameRight.position.set(0.725, 0, 0);
415
+ targetGroup.add(frameRight);
416
 
417
+ targetGroup.position.copy(CENTER);
418
+ scene.add(targetGroup);
419
 
420
+ // Function to update texture from image URL
421
  function updateTextureFromUrl(url) {
422
  if (!url) {
423
  planeMaterial.map = createPlaceholderTexture();
 
433
  planeMaterial.map = texture;
434
  planeMaterial.needsUpdate = true;
435
 
436
+ // Adjust plane aspect ratio
437
  const img = texture.image;
438
  if (img && img.width && img.height) {
439
  const aspect = img.width / img.height;
440
  const maxSize = 1.4;
441
+ let planeWidth, planeHeight;
442
+ if (aspect > 1) {
443
+ planeWidth = maxSize;
444
+ planeHeight = maxSize / aspect;
445
+ } else {
446
+ planeHeight = maxSize;
447
+ planeWidth = maxSize * aspect;
448
+ }
449
 
450
+ targetGroup.remove(targetPlane);
451
+ targetPlane = new THREE.Mesh(
452
+ new THREE.PlaneGeometry(planeWidth, planeHeight),
453
+ planeMaterial
454
+ );
455
  targetPlane.receiveShadow = true;
456
+ targetGroup.add(targetPlane);
 
457
  }
458
+ }, undefined, (err) => {
459
+ console.error('Failed to load texture:', err);
460
  });
461
  }
462
 
463
+ if (props.imageUrl) {
464
+ updateTextureFromUrl(props.imageUrl);
465
+ }
466
 
467
+ // ========== SOFTBOX LIGHT ==========
 
 
468
  const lightGroup = new THREE.Group();
469
 
470
+ // Softbox outer frame (RED)
471
+ const softboxDepth = 0.4;
472
+ const softboxWidth = 0.7;
473
+ const softboxHeight = 0.9;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
 
475
+ // Back panel (red)
476
+ const backPanelMat = new THREE.MeshStandardMaterial({
477
+ color: 0xcc2222,
478
+ roughness: 0.3,
479
+ metalness: 0.6
 
 
 
 
480
  });
481
+ const backPanel = new THREE.Mesh(
482
+ new THREE.BoxGeometry(softboxWidth, softboxHeight, 0.05),
483
+ backPanelMat
484
  );
485
+ backPanel.position.z = -softboxDepth / 2;
486
+ lightGroup.add(backPanel);
 
 
 
 
 
 
 
 
 
 
487
 
488
+ // Side panels (red with slight taper effect)
489
+ const sideMat = new THREE.MeshStandardMaterial({
490
+ color: 0xaa1111,
491
+ roughness: 0.4,
492
  metalness: 0.5,
493
  side: THREE.DoubleSide
494
  });
495
 
496
+ // Left side
497
+ const leftSideGeo = new THREE.BufferGeometry();
498
+ const leftVerts = new Float32Array([
499
+ -softboxWidth/2, -softboxHeight/2, -softboxDepth/2,
500
+ -softboxWidth/2, softboxHeight/2, -softboxDepth/2,
501
+ -softboxWidth/2 - 0.1, softboxHeight/2 + 0.1, softboxDepth/2,
502
+ -softboxWidth/2 - 0.1, -softboxHeight/2 - 0.1, softboxDepth/2,
503
+ ]);
504
+ const leftIndices = [0, 1, 2, 0, 2, 3];
505
+ leftSideGeo.setAttribute('position', new THREE.BufferAttribute(leftVerts, 3));
506
+ leftSideGeo.setIndex(leftIndices);
507
+ leftSideGeo.computeVertexNormals();
508
+ const leftSide = new THREE.Mesh(leftSideGeo, sideMat);
509
+ lightGroup.add(leftSide);
510
+
511
+ // Right side
512
+ const rightSideGeo = new THREE.BufferGeometry();
513
+ const rightVerts = new Float32Array([
514
+ softboxWidth/2, -softboxHeight/2, -softboxDepth/2,
515
+ softboxWidth/2, softboxHeight/2, -softboxDepth/2,
516
+ softboxWidth/2 + 0.1, softboxHeight/2 + 0.1, softboxDepth/2,
517
+ softboxWidth/2 + 0.1, -softboxHeight/2 - 0.1, softboxDepth/2,
518
+ ]);
519
+ const rightIndices = [0, 2, 1, 0, 3, 2];
520
+ rightSideGeo.setAttribute('position', new THREE.BufferAttribute(rightVerts, 3));
521
+ rightSideGeo.setIndex(rightIndices);
522
+ rightSideGeo.computeVertexNormals();
523
+ const rightSide = new THREE.Mesh(rightSideGeo, sideMat);
524
+ lightGroup.add(rightSide);
525
+
526
+ // Top side
527
+ const topSideGeo = new THREE.BufferGeometry();
528
+ const topVerts = new Float32Array([
529
+ -softboxWidth/2, softboxHeight/2, -softboxDepth/2,
530
+ softboxWidth/2, softboxHeight/2, -softboxDepth/2,
531
+ softboxWidth/2 + 0.1, softboxHeight/2 + 0.1, softboxDepth/2,
532
+ -softboxWidth/2 - 0.1, softboxHeight/2 + 0.1, softboxDepth/2,
533
+ ]);
534
+ const topIndices = [0, 1, 2, 0, 2, 3];
535
+ topSideGeo.setAttribute('position', new THREE.BufferAttribute(topVerts, 3));
536
+ topSideGeo.setIndex(topIndices);
537
+ topSideGeo.computeVertexNormals();
538
+ const topSide = new THREE.Mesh(topSideGeo, sideMat);
539
+ lightGroup.add(topSide);
540
+
541
+ // Bottom side
542
+ const bottomSideGeo = new THREE.BufferGeometry();
543
+ const bottomVerts = new Float32Array([
544
+ -softboxWidth/2, -softboxHeight/2, -softboxDepth/2,
545
+ softboxWidth/2, -softboxHeight/2, -softboxDepth/2,
546
+ softboxWidth/2 + 0.1, -softboxHeight/2 - 0.1, softboxDepth/2,
547
+ -softboxWidth/2 - 0.1, -softboxHeight/2 - 0.1, softboxDepth/2,
548
+ ]);
549
+ const bottomIndices = [0, 2, 1, 0, 3, 2];
550
+ bottomSideGeo.setAttribute('position', new THREE.BufferAttribute(bottomVerts, 3));
551
+ bottomSideGeo.setIndex(bottomIndices);
552
+ bottomSideGeo.computeVertexNormals();
553
+ const bottomSide = new THREE.Mesh(bottomSideGeo, sideMat);
554
+ lightGroup.add(bottomSide);
555
+
556
+ // Front diffuser panel (WHITE, glowing)
557
+ const diffuserMat = new THREE.MeshStandardMaterial({
558
+ color: 0xffffff,
559
+ emissive: 0xffffff,
560
+ emissiveIntensity: 1.5,
561
+ roughness: 0.9,
562
+ metalness: 0,
563
+ transparent: true,
564
+ opacity: 0.95
565
  });
566
+ const diffuserPanel = new THREE.Mesh(
567
+ new THREE.PlaneGeometry(softboxWidth + 0.2, softboxHeight + 0.2),
568
+ diffuserMat
 
569
  );
570
+ diffuserPanel.position.z = softboxDepth / 2;
571
+ lightGroup.add(diffuserPanel);
572
+
573
+ // Frame around diffuser (red trim)
574
+ const trimMat = new THREE.MeshStandardMaterial({ color: 0xdd3333, roughness: 0.3, metalness: 0.7 });
575
+ const trimThickness = 0.04;
576
+
577
+ const trimTop = new THREE.Mesh(new THREE.BoxGeometry(softboxWidth + 0.28, trimThickness, 0.06), trimMat);
578
+ trimTop.position.set(0, (softboxHeight + 0.2) / 2 + trimThickness/2, softboxDepth/2);
579
+ lightGroup.add(trimTop);
580
+
581
+ const trimBottom = new THREE.Mesh(new THREE.BoxGeometry(softboxWidth + 0.28, trimThickness, 0.06), trimMat);
582
+ trimBottom.position.set(0, -(softboxHeight + 0.2) / 2 - trimThickness/2, softboxDepth/2);
583
+ lightGroup.add(trimBottom);
584
+
585
+ const trimLeft = new THREE.Mesh(new THREE.BoxGeometry(trimThickness, softboxHeight + 0.28, 0.06), trimMat);
586
+ trimLeft.position.set(-(softboxWidth + 0.2) / 2 - trimThickness/2, 0, softboxDepth/2);
587
+ lightGroup.add(trimLeft);
588
+
589
+ const trimRight = new THREE.Mesh(new THREE.BoxGeometry(trimThickness, softboxHeight + 0.28, 0.06), trimMat);
590
+ trimRight.position.set((softboxWidth + 0.2) / 2 + trimThickness/2, 0, softboxDepth/2);
591
+ lightGroup.add(trimRight);
592
+
593
+ // Stand/mount pole
594
+ const poleMat = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.4, metalness: 0.8 });
595
+ const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.5, 8), poleMat);
596
+ pole.position.set(0, -softboxHeight/2 - 0.25, 0);
597
+ lightGroup.add(pole);
598
+
599
+ // Mount bracket
600
+ const bracket = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.08, 0.15), poleMat);
601
+ bracket.position.set(0, -softboxHeight/2 - 0.04, 0);
602
+ lightGroup.add(bracket);
603
+
604
+ // Spotlight (WHITE light)
605
+ const spotLight = new THREE.SpotLight(0xffffff, 15, 12, Math.PI / 4, 0.8, 1);
606
+ spotLight.position.set(0, 0, softboxDepth / 2 + 0.1);
607
  spotLight.castShadow = true;
608
+ spotLight.shadow.mapSize.width = 1024;
609
+ spotLight.shadow.mapSize.height = 1024;
610
  spotLight.shadow.camera.near = 0.5;
611
+ spotLight.shadow.camera.far = 500;
612
+ spotLight.shadow.bias = -0.005;
613
  lightGroup.add(spotLight);
614
 
615
  const lightTarget = new THREE.Object3D();
 
619
 
620
  scene.add(lightGroup);
621
 
622
+ // ========== YELLOW: Azimuth ring ==========
623
+ const azimuthRingMat = new THREE.MeshStandardMaterial({
624
+ color: 0xFFD700,
625
+ emissive: 0xFFD700,
626
+ emissiveIntensity: 0.4,
627
+ roughness: 0.3,
628
+ metalness: 0.7
 
 
 
 
 
629
  });
 
630
  const azimuthRing = new THREE.Mesh(
631
+ new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.05, 16, 64),
632
+ azimuthRingMat
633
  );
634
+ azimuthRing.rotation.x = Math.PI / 2;
635
+ azimuthRing.position.y = 0.02;
636
+ scene.add(azimuthRing);
637
 
638
+ // Direction markers on azimuth ring
639
+ const markerMat = new THREE.MeshStandardMaterial({ color: 0xFFD700, emissive: 0xFFD700, emissiveIntensity: 0.3 });
640
+ azimuthSteps.forEach(angle => {
641
  const rad = THREE.MathUtils.degToRad(angle);
642
+ const marker = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.02, 0.15), markerMat);
643
+ marker.position.set(AZIMUTH_RADIUS * Math.sin(rad), 0.02, AZIMUTH_RADIUS * Math.cos(rad));
 
 
 
 
 
 
 
 
 
 
 
 
 
644
  marker.rotation.y = -rad;
645
+ scene.add(marker);
 
 
 
 
 
 
 
 
646
  });
647
 
648
+ const azimuthHandle = new THREE.Mesh(
649
+ new THREE.SphereGeometry(0.2, 24, 24),
 
 
 
 
 
 
 
650
  new THREE.MeshStandardMaterial({
651
+ color: 0xFFD700,
652
+ emissive: 0xFFD700,
653
+ emissiveIntensity: 0.6,
654
+ roughness: 0.2,
655
+ metalness: 0.8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  })
657
  );
658
+ azimuthHandle.userData.type = 'azimuth';
659
+ scene.add(azimuthHandle);
 
 
 
660
 
661
+ // ========== BLUE: Elevation arc ==========
 
 
 
 
 
662
  const arcPoints = [];
663
+ for (let i = 0; i <= 48; i++) {
664
+ const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 48));
665
  arcPoints.push(new THREE.Vector3(
666
+ -AZIMUTH_RADIUS - 0.3,
667
+ ELEVATION_RADIUS * Math.sin(angle) + CENTER.y,
668
  ELEVATION_RADIUS * Math.cos(angle)
669
  ));
670
  }
671
  const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
672
  const elevationArc = new THREE.Mesh(
673
+ new THREE.TubeGeometry(arcCurve, 48, 0.05, 12, false),
674
  new THREE.MeshStandardMaterial({
675
+ color: 0x4169E1,
676
+ emissive: 0x4169E1,
677
+ emissiveIntensity: 0.4,
678
+ roughness: 0.3,
679
+ metalness: 0.7
680
  })
681
  );
682
+ scene.add(elevationArc);
683
 
684
+ // Elevation markers
685
+ const elevMarkerMat = new THREE.MeshStandardMaterial({ color: 0x4169E1, emissive: 0x4169E1, emissiveIntensity: 0.3 });
686
  elevationSteps.forEach(angle => {
687
  const rad = THREE.MathUtils.degToRad(angle);
688
+ const marker = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.08, 0.08), elevMarkerMat);
689
+ marker.position.set(
690
+ -AZIMUTH_RADIUS - 0.3,
691
+ ELEVATION_RADIUS * Math.sin(rad) + CENTER.y,
692
+ ELEVATION_RADIUS * Math.cos(rad)
 
 
 
 
 
 
 
 
693
  );
694
+ scene.add(marker);
 
 
 
695
  });
696
 
697
+ const elevationHandle = new THREE.Mesh(
698
+ new THREE.SphereGeometry(0.2, 24, 24),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  new THREE.MeshStandardMaterial({
700
+ color: 0x4169E1,
701
+ emissive: 0x4169E1,
702
+ emissiveIntensity: 0.6,
703
+ roughness: 0.2,
704
+ metalness: 0.8
705
  })
706
  );
707
+ elevationHandle.userData.type = 'elevation';
708
+ scene.add(elevationHandle);
709
 
710
+ // ========== REFRESH BUTTON ==========
711
+ const refreshBtn = document.createElement('button');
712
+ refreshBtn.innerHTML = '🔄 Reset';
713
+ refreshBtn.style.cssText = `
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  position: absolute;
715
+ top: 10px;
716
+ right: 10px;
717
+ background: linear-gradient(135deg, #ff4500, #cc3700);
718
  color: white;
719
  border: none;
720
+ padding: 10px 18px;
721
  border-radius: 8px;
722
  cursor: pointer;
723
  z-index: 10;
724
+ font-size: 14px;
725
  font-weight: 600;
726
  font-family: 'Segoe UI', sans-serif;
727
+ box-shadow: 0 4px 15px rgba(255, 69, 0, 0.4);
728
+ transition: all 0.3s ease;
 
 
 
 
729
  `;
730
+ refreshBtn.onmouseover = () => {
731
+ refreshBtn.style.background = 'linear-gradient(135deg, #ff6633, #dd4400)';
732
+ refreshBtn.style.transform = 'scale(1.05)';
733
+ refreshBtn.style.boxShadow = '0 6px 20px rgba(255, 69, 0, 0.5)';
734
  };
735
+ refreshBtn.onmouseout = () => {
736
+ refreshBtn.style.background = 'linear-gradient(135deg, #ff4500, #cc3700)';
737
+ refreshBtn.style.transform = 'scale(1)';
738
+ refreshBtn.style.boxShadow = '0 4px 15px rgba(255, 69, 0, 0.4)';
739
  };
740
+ wrapper.appendChild(refreshBtn);
741
 
742
+ refreshBtn.addEventListener('click', () => {
743
+ azimuthAngle = 0;
744
+ elevationAngle = 0;
745
+ updatePositions();
746
+ updatePropsAndTrigger();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  });
748
 
749
  function updatePositions() {
 
758
  lightGroup.position.set(lightX, lightY, lightZ);
759
  lightGroup.lookAt(CENTER);
760
 
761
+ azimuthHandle.position.set(AZIMUTH_RADIUS * Math.sin(azRad), 0.02, AZIMUTH_RADIUS * Math.cos(azRad));
762
+ elevationHandle.position.set(
763
+ -AZIMUTH_RADIUS - 0.3,
 
 
 
 
 
 
 
 
764
  ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y,
765
  ELEVATION_RADIUS * Math.cos(elRad)
766
  );
767
 
768
+ // Update prompt
769
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
770
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
771
+ let prompt = '💡 Light source from';
 
 
 
 
772
  if (elSnap !== 0) {
773
  prompt += ' ' + elevationNames[String(elSnap)];
774
  } else {
 
790
  const mouse = new THREE.Vector2();
791
  let isDragging = false;
792
  let dragTarget = null;
793
+ let dragStartMouse = new THREE.Vector2();
794
  const intersection = new THREE.Vector3();
795
 
796
  const canvas = renderer.domElement;
797
 
798
+ canvas.addEventListener('mousedown', (e) => {
799
  const rect = canvas.getBoundingClientRect();
800
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
801
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
802
 
803
  raycaster.setFromCamera(mouse, camera);
804
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
805
 
806
  if (intersects.length > 0) {
 
 
 
 
 
 
 
 
 
 
807
  isDragging = true;
808
+ dragTarget = intersects[0].object;
809
+ dragTarget.material.emissiveIntensity = 1.2;
810
+ dragTarget.scale.setScalar(1.4);
811
+ dragStartMouse.copy(mouse);
 
 
 
 
 
812
  canvas.style.cursor = 'grabbing';
813
  }
814
  });
 
822
  raycaster.setFromCamera(mouse, camera);
823
 
824
  if (dragTarget.userData.type === 'azimuth') {
825
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.02);
826
  if (raycaster.ray.intersectPlane(plane, intersection)) {
827
  azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
828
  if (azimuthAngle < 0) azimuthAngle += 360;
829
  }
830
  } else if (dragTarget.userData.type === 'elevation') {
831
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), AZIMUTH_RADIUS + 0.3);
832
  if (raycaster.ray.intersectPlane(plane, intersection)) {
833
  const relY = intersection.y - CENTER.y;
834
  const relZ = intersection.z;
835
+ elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
 
 
 
 
836
  }
837
  }
838
  updatePositions();
839
  } else {
840
+ raycaster.setFromCamera(mouse, camera);
841
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
842
+ [azimuthHandle, elevationHandle].forEach(h => {
843
+ h.material.emissiveIntensity = 0.6;
 
 
844
  h.scale.setScalar(1);
845
  });
846
+ if (intersects.length > 0) {
847
+ intersects[0].object.material.emissiveIntensity = 0.9;
848
+ intersects[0].object.scale.setScalar(1.15);
 
 
 
849
  canvas.style.cursor = 'grab';
850
  } else {
851
  canvas.style.cursor = 'default';
 
855
 
856
  const onMouseUp = () => {
857
  if (dragTarget) {
858
+ dragTarget.material.emissiveIntensity = 0.6;
 
 
859
  dragTarget.scale.setScalar(1);
860
 
861
+ // Snap and animate
862
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
863
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
864
 
 
866
  const startTime = Date.now();
867
 
868
  function animateSnap() {
869
+ const t = Math.min((Date.now() - startTime) / 200, 1);
870
  const ease = 1 - Math.pow(1 - t, 3);
871
 
872
  let azDiff = targetAz - startAz;
 
892
  canvas.addEventListener('mouseup', onMouseUp);
893
  canvas.addEventListener('mouseleave', onMouseUp);
894
 
895
+ // Touch support for mobile
896
  canvas.addEventListener('touchstart', (e) => {
897
  e.preventDefault();
898
  const touch = e.touches[0];
899
+ const rect = canvas.getBoundingClientRect();
900
+ mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
901
+ mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
902
+
903
+ raycaster.setFromCamera(mouse, camera);
904
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
905
+
906
+ if (intersects.length > 0) {
907
  isDragging = true;
908
+ dragTarget = intersects[0].object;
909
+ dragTarget.material.emissiveIntensity = 1.2;
910
+ dragTarget.scale.setScalar(1.4);
911
+ dragStartMouse.copy(mouse);
 
912
  }
913
  }, { passive: false });
914
 
915
  canvas.addEventListener('touchmove', (e) => {
916
  e.preventDefault();
 
 
917
  const touch = e.touches[0];
918
  const rect = canvas.getBoundingClientRect();
919
  mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
920
  mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
921
 
922
+ if (isDragging && dragTarget) {
923
+ raycaster.setFromCamera(mouse, camera);
924
+
925
+ if (dragTarget.userData.type === 'azimuth') {
926
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.02);
927
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
928
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
929
+ if (azimuthAngle < 0) azimuthAngle += 360;
930
+ }
931
+ } else if (dragTarget.userData.type === 'elevation') {
932
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), AZIMUTH_RADIUS + 0.3);
933
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
934
+ const relY = intersection.y - CENTER.y;
935
+ const relZ = intersection.z;
936
+ elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
937
+ }
 
 
938
  }
939
+ updatePositions();
940
  }
 
941
  }, { passive: false });
942
 
943
+ canvas.addEventListener('touchend', (e) => {
944
+ e.preventDefault();
945
+ onMouseUp();
946
+ }, { passive: false });
947
+
948
+ canvas.addEventListener('touchcancel', (e) => {
949
+ e.preventDefault();
950
+ onMouseUp();
951
+ }, { passive: false });
952
 
953
  // Initial update
954
  updatePositions();
955
 
956
+ // Render loop
 
 
 
957
  function render() {
958
  requestAnimationFrame(render);
 
 
 
 
 
 
 
959
  renderer.render(scene, camera);
960
  }
961
  render();
 
967
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
968
  }).observe(wrapper);
969
 
970
+ // Store update functions for external calls
971
+ wrapper._updateFromProps = (newVal) => {
972
+ if (newVal && typeof newVal === 'object') {
973
+ azimuthAngle = newVal.azimuth ?? azimuthAngle;
974
+ elevationAngle = newVal.elevation ?? elevationAngle;
975
+ updatePositions();
976
+ }
977
+ };
978
+
979
+ wrapper._updateTexture = updateTextureFromUrl;
980
+
981
  // Watch for prop changes
982
  let lastImageUrl = props.imageUrl;
983
  let lastValue = JSON.stringify(props.value);
 
1026
  with gr.Column(scale=1):
1027
  image = gr.Image(label="Input Image", type="pil", height=300)
1028
 
1029
+ gr.Markdown("### 3D Lighting Control")
1030
+ gr.Markdown("*Drag the colored handles: 🟡 Azimuth (Direction), 🔵 Elevation (Height)*")
1031
 
1032
  lighting_3d = LightingControl3D(
1033
  value={"azimuth": 0, "elevation": 0},
1034
  elem_id="lighting-3d-control"
1035
  )
1036
+ run_btn = gr.Button("Generate Image", variant="primary", size="lg")
1037
 
1038
  gr.Markdown("### Slider Controls")
1039
 
 
1075
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
1076
 
1077
  def update_prompt_from_sliders(azimuth, elevation):
1078
+ """Update prompt preview when sliders change."""
1079
  prompt = build_lighting_prompt(azimuth, elevation)
1080
  return prompt
1081
 
1082
  def sync_3d_to_sliders(lighting_value):
1083
+ """Sync 3D control changes to sliders."""
1084
  if lighting_value and isinstance(lighting_value, dict):
1085
  az = lighting_value.get('azimuth', 0)
1086
  el = lighting_value.get('elevation', 0)
 
1089
  return gr.update(), gr.update(), gr.update()
1090
 
1091
  def sync_sliders_to_3d(azimuth, elevation):
1092
+ """Sync slider changes to 3D control."""
1093
  return {"azimuth": azimuth, "elevation": elevation}
1094
 
1095
  def update_3d_image(image):
1096
+ """Update the 3D component with the uploaded image."""
1097
  if image is None:
1098
  return gr.update(imageUrl=None)
1099