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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +541 -427
app.py CHANGED
@@ -131,17 +131,6 @@ 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
- """
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,9 +151,6 @@ def infer_lighting_edit(
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,7 +184,6 @@ def infer_lighting_edit(
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,26 +202,30 @@ def update_dimensions_on_upload(image):
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: 450px; position: relative; background: linear-gradient(180deg, #0d1117 0%, #161b22 50%, #21262d 100%); border-radius: 12px; overflow: hidden; border: 1px solid #30363d;">
229
- <div id="prompt-overlay" style="position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.85); padding: 10px 20px; border-radius: 8px; font-family: 'Courier New', monospace; font-size: 13px; color: #58a6ff; white-space: nowrap; z-index: 10; border: 1px solid #30363d; box-shadow: 0 4px 12px rgba(0,0,0,0.4);"></div>
230
- <div id="controls-legend" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.75); padding: 8px 12px; border-radius: 6px; font-family: sans-serif; font-size: 11px; color: #c9d1d9; z-index: 10; border: 1px solid #30363d;">
231
- <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
232
- <span style="width: 12px; height: 12px; background: #ffd700; border-radius: 50%; display: inline-block;"></span>
233
- <span>Azimuth (Direction)</span>
234
  </div>
235
- <div style="display: flex; align-items: center; gap: 6px;">
236
- <span style="width: 12px; height: 12px; background: #4dabf7; border-radius: 50%; display: inline-block;"></span>
237
- <span>Elevation (Height)</span>
238
  </div>
239
  </div>
 
 
 
 
 
240
  </div>
241
  """
242
 
@@ -244,77 +233,97 @@ class LightingControl3D(gr.HTML):
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
- scene.background = new THREE.Color(0x0d1117);
258
 
259
- // Add fog for depth
260
- scene.fog = new THREE.Fog(0x0d1117, 8, 20);
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
- const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
263
- camera.position.set(4.5, 3, 4.5);
264
- camera.lookAt(0, 0.75, 0);
265
 
266
- const renderer = new THREE.WebGLRenderer({ antialias: true });
267
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
268
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
269
  renderer.shadowMap.enabled = true;
270
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
271
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
272
- renderer.toneMappingExposure = 1.2;
273
  wrapper.insertBefore(renderer.domElement, wrapper.firstChild);
274
 
275
- // Ambient lighting
276
- const ambientLight = new THREE.AmbientLight(0x404050, 0.3);
277
- scene.add(ambientLight);
 
 
278
 
279
- // Ground plane with better material
280
- const groundGeometry = new THREE.PlaneGeometry(12, 12);
281
- const groundMaterial = new THREE.MeshStandardMaterial({
282
  color: 0x1a1a2e,
283
- roughness: 0.8,
284
- metalness: 0.2
 
285
  });
286
- const ground = new THREE.Mesh(groundGeometry, groundMaterial);
287
- ground.rotation.x = -Math.PI / 2;
288
- ground.position.y = 0;
289
- ground.receiveShadow = true;
290
- scene.add(ground);
 
 
 
 
 
 
 
 
 
 
291
 
292
- // Shadow-only plane
293
  const shadowPlane = new THREE.Mesh(
294
- new THREE.PlaneGeometry(12, 12),
295
- new THREE.ShadowMaterial({ opacity: 0.4 })
296
  );
297
  shadowPlane.rotation.x = -Math.PI / 2;
298
- shadowPlane.position.y = 0.001;
299
  shadowPlane.receiveShadow = true;
300
  scene.add(shadowPlane);
301
 
302
- // Grid with better styling
303
- const gridHelper = new THREE.GridHelper(10, 20, 0x30363d, 0x21262d);
304
- gridHelper.position.y = 0.002;
305
- scene.add(gridHelper);
306
-
307
  // Constants
308
- const CENTER = new THREE.Vector3(0, 0.75, 0);
309
- const BASE_DISTANCE = 2.5;
310
- const AZIMUTH_RADIUS = 2.4;
311
- const ELEVATION_RADIUS = 1.8;
312
 
313
  // State
314
  let azimuthAngle = props.value?.azimuth || 0;
315
  let elevationAngle = props.value?.elevation || 0;
316
 
317
- // Mappings
318
  const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
319
  const elevationSteps = [-90, 0, 90];
320
  const azimuthNames = {
@@ -328,98 +337,109 @@ class LightingControl3D(gr.HTML):
328
  return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
329
  }
330
 
331
- // Create placeholder texture (camera icon style)
332
  function createPlaceholderTexture() {
333
- const canvas = document.createElement('canvas');
334
- canvas.width = 256;
335
- canvas.height = 256;
336
- const ctx = canvas.getContext('2d');
337
 
338
- // Background gradient
339
- const gradient = ctx.createLinearGradient(0, 0, 256, 256);
340
- gradient.addColorStop(0, '#2d333b');
341
- gradient.addColorStop(1, '#22272e');
342
- ctx.fillStyle = gradient;
343
  ctx.fillRect(0, 0, 256, 256);
344
 
345
  // Border
346
- ctx.strokeStyle = '#444c56';
347
- ctx.lineWidth = 4;
348
- ctx.strokeRect(10, 10, 236, 236);
349
-
350
- // Camera icon
351
- ctx.fillStyle = '#58a6ff';
352
- ctx.beginPath();
353
- ctx.roundRect(78, 98, 100, 70, 8);
354
- ctx.fill();
355
-
356
- // Lens
357
- ctx.fillStyle = '#2d333b';
358
- ctx.beginPath();
359
- ctx.arc(128, 133, 25, 0, Math.PI * 2);
360
- ctx.fill();
361
-
362
- ctx.fillStyle = '#58a6ff';
363
- ctx.beginPath();
364
- ctx.arc(128, 133, 18, 0, Math.PI * 2);
365
- ctx.fill();
366
 
367
- // Flash
368
- ctx.fillStyle = '#58a6ff';
369
- ctx.beginPath();
370
- ctx.roundRect(138, 88, 30, 15, 3);
371
- ctx.fill();
372
-
373
- // Text
374
- ctx.fillStyle = '#8b949e';
375
- ctx.font = '16px sans-serif';
376
  ctx.textAlign = 'center';
377
- ctx.fillText('Upload Image', 128, 200);
 
 
 
 
378
 
379
- return new THREE.CanvasTexture(canvas);
380
  }
381
 
382
- // Target image plane
383
  let currentTexture = createPlaceholderTexture();
384
  const planeMaterial = new THREE.MeshStandardMaterial({
385
  map: currentTexture,
386
  side: THREE.DoubleSide,
387
- roughness: 0.4,
388
  metalness: 0.1
389
  });
390
- let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
391
- targetPlane.position.copy(CENTER);
 
 
 
 
392
  targetPlane.receiveShadow = true;
393
  targetPlane.castShadow = true;
394
- scene.add(targetPlane);
395
 
396
- // Frame around the image
397
- const frameGeometry = new THREE.BoxGeometry(1.3, 1.3, 0.05);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  const frameMaterial = new THREE.MeshStandardMaterial({
399
- color: 0x30363d,
400
- roughness: 0.5,
401
- metalness: 0.3
402
  });
403
  const frame = new THREE.Mesh(frameGeometry, frameMaterial);
404
- frame.position.set(CENTER.x, CENTER.y, -0.03);
405
- scene.add(frame);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
- // Function to update texture from image URL
408
  function updateTextureFromUrl(url) {
409
  if (!url) {
410
  planeMaterial.map = createPlaceholderTexture();
411
  planeMaterial.needsUpdate = true;
412
- scene.remove(targetPlane);
413
- scene.remove(frame);
414
- targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
415
- targetPlane.position.copy(CENTER);
416
- targetPlane.receiveShadow = true;
417
- targetPlane.castShadow = true;
418
- scene.add(targetPlane);
419
-
420
- frame.geometry = new THREE.BoxGeometry(1.3, 1.3, 0.05);
421
- frame.position.set(CENTER.x, CENTER.y, -0.03);
422
- scene.add(frame);
423
  return;
424
  }
425
 
@@ -434,153 +454,156 @@ class LightingControl3D(gr.HTML):
434
  const img = texture.image;
435
  if (img && img.width && img.height) {
436
  const aspect = img.width / img.height;
437
- const maxSize = 1.5;
438
- let planeWidth, planeHeight;
439
- if (aspect > 1) {
440
- planeWidth = maxSize;
441
- planeHeight = maxSize / aspect;
442
- } else {
443
- planeHeight = maxSize;
444
- planeWidth = maxSize * aspect;
445
- }
446
- scene.remove(targetPlane);
447
- scene.remove(frame);
448
 
449
- targetPlane = new THREE.Mesh(
450
- new THREE.PlaneGeometry(planeWidth, planeHeight),
451
- planeMaterial
452
- );
453
- targetPlane.position.copy(CENTER);
454
  targetPlane.receiveShadow = true;
455
  targetPlane.castShadow = true;
456
- scene.add(targetPlane);
457
-
458
- frame.geometry = new THREE.BoxGeometry(planeWidth + 0.1, planeHeight + 0.1, 0.05);
459
- frame.position.set(CENTER.x, CENTER.y, -0.03);
460
- scene.add(frame);
461
  }
462
- }, undefined, (err) => {
463
- console.error('Failed to load texture:', err);
464
  });
465
  }
466
 
467
- if (props.imageUrl) {
468
- updateTextureFromUrl(props.imageUrl);
469
- }
470
 
471
  // =============================================
472
- // SOFTBOX LIGHT - Red box with white diffuser
473
  // =============================================
474
  const lightGroup = new THREE.Group();
475
 
476
- // Main softbox frame (red)
477
- const softboxDepth = 0.4;
478
- const softboxWidth = 0.7;
479
- const softboxHeight = 0.7;
 
 
480
 
481
- // Back panel (red)
482
- const backPanel = new THREE.Mesh(
483
- new THREE.BoxGeometry(softboxWidth, softboxHeight, 0.05),
484
- new THREE.MeshStandardMaterial({
485
- color: 0xcc2222,
486
- roughness: 0.7,
487
- metalness: 0.3
488
- })
489
  );
490
- backPanel.position.z = softboxDepth / 2;
491
- lightGroup.add(backPanel);
492
 
493
- // Side panels (red, tapered)
494
- const sideGeometry = new THREE.BoxGeometry(0.04, softboxHeight, softboxDepth);
495
- const sideMaterial = new THREE.MeshStandardMaterial({
496
- color: 0xaa1111,
497
- roughness: 0.6,
498
- metalness: 0.2
499
  });
 
 
 
 
 
 
500
 
501
- const leftSide = new THREE.Mesh(sideGeometry, sideMaterial);
502
- leftSide.position.set(-softboxWidth/2 + 0.02, 0, 0);
503
- lightGroup.add(leftSide);
 
 
 
 
 
 
 
 
 
 
 
 
 
504
 
505
- const rightSide = new THREE.Mesh(sideGeometry, sideMaterial);
506
- rightSide.position.set(softboxWidth/2 - 0.02, 0, 0);
507
- lightGroup.add(rightSide);
 
 
 
 
 
 
508
 
509
- // Top and bottom panels (red)
510
- const tbGeometry = new THREE.BoxGeometry(softboxWidth, 0.04, softboxDepth);
 
 
 
 
 
511
 
512
- const topPanel = new THREE.Mesh(tbGeometry, sideMaterial);
513
- topPanel.position.set(0, softboxHeight/2 - 0.02, 0);
514
- lightGroup.add(topPanel);
 
 
 
 
515
 
516
- const bottomPanel = new THREE.Mesh(tbGeometry, sideMaterial);
517
- bottomPanel.position.set(0, -softboxHeight/2 + 0.02, 0);
518
- lightGroup.add(bottomPanel);
 
 
 
 
 
 
 
 
519
 
520
- // White diffuser front (emissive white)
521
- const diffuserMaterial = new THREE.MeshStandardMaterial({
522
- color: 0xffffff,
523
- emissive: 0xffffff,
524
- emissiveIntensity: 2.0,
525
- roughness: 0.2,
526
- metalness: 0,
527
- transparent: true,
528
- opacity: 0.95
529
  });
530
 
531
- const diffuser = new THREE.Mesh(
532
- new THREE.PlaneGeometry(softboxWidth - 0.08, softboxHeight - 0.08),
533
- diffuserMaterial
534
  );
535
- diffuser.position.z = -softboxDepth / 2 + 0.02;
536
- lightGroup.add(diffuser);
537
-
538
- // Inner glow effect
539
- const innerGlow = new THREE.Mesh(
540
- new THREE.PlaneGeometry(softboxWidth - 0.15, softboxHeight - 0.15),
541
- new THREE.MeshBasicMaterial({
542
- color: 0xffffff,
543
- transparent: true,
544
- opacity: 0.8
545
- })
546
  );
547
- innerGlow.position.z = -softboxDepth / 2 + 0.01;
548
- lightGroup.add(innerGlow);
549
 
550
- // Mounting bracket
551
- const bracket = new THREE.Mesh(
552
- new THREE.CylinderGeometry(0.03, 0.03, 0.15, 8),
553
- new THREE.MeshStandardMaterial({
554
- color: 0x333333,
555
- roughness: 0.5,
556
- metalness: 0.8
557
- })
558
  );
559
- bracket.rotation.x = Math.PI / 2;
560
- bracket.position.z = softboxDepth / 2 + 0.075;
561
- lightGroup.add(bracket);
562
 
563
- // Ball joint
564
- const ballJoint = new THREE.Mesh(
565
- new THREE.SphereGeometry(0.05, 16, 16),
566
- new THREE.MeshStandardMaterial({
567
- color: 0x222222,
568
- roughness: 0.4,
569
- metalness: 0.9
570
- })
571
  );
572
- ballJoint.position.z = softboxDepth / 2 + 0.15;
573
- lightGroup.add(ballJoint);
574
 
575
- // SpotLight for actual lighting
576
- const spotLight = new THREE.SpotLight(0xffffff, 15, 12, Math.PI / 4, 0.5, 1);
577
- spotLight.position.set(0, 0, -softboxDepth / 2);
578
  spotLight.castShadow = true;
579
- spotLight.shadow.mapSize.width = 1024;
580
- spotLight.shadow.mapSize.height = 1024;
581
  spotLight.shadow.camera.near = 0.5;
582
- spotLight.shadow.camera.far = 15;
583
- spotLight.shadow.bias = -0.002;
584
  lightGroup.add(spotLight);
585
 
586
  const lightTarget = new THREE.Object3D();
@@ -588,163 +611,241 @@ class LightingControl3D(gr.HTML):
588
  scene.add(lightTarget);
589
  spotLight.target = lightTarget;
590
 
591
- // Point light for softer fill
592
- const pointLight = new THREE.PointLight(0xffffff, 3, 8);
593
- pointLight.position.set(0, 0, -softboxDepth / 2 - 0.1);
594
- lightGroup.add(pointLight);
595
-
596
  scene.add(lightGroup);
597
 
598
  // =============================================
599
- // YELLOW: Azimuth ring and handle
600
  // =============================================
 
 
 
 
 
 
 
 
 
 
 
601
  const azimuthRing = new THREE.Mesh(
602
- new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.035, 16, 64),
603
- new THREE.MeshStandardMaterial({
604
- color: 0xffd700,
605
- emissive: 0xffd700,
606
- emissiveIntensity: 0.4,
607
- roughness: 0.3,
608
- metalness: 0.6
609
- })
610
  );
611
- azimuthRing.rotation.x = Math.PI / 2;
612
- azimuthRing.position.y = 0.05;
613
- scene.add(azimuthRing);
614
 
615
- // Azimuth direction markers
616
- azimuthSteps.forEach(angle => {
617
  const rad = THREE.MathUtils.degToRad(angle);
 
 
 
 
618
  const marker = new THREE.Mesh(
619
- new THREE.SphereGeometry(0.06, 8, 8),
620
  new THREE.MeshStandardMaterial({
621
- color: 0xffd700,
622
- emissive: 0xffd700,
623
- emissiveIntensity: 0.2
 
 
624
  })
625
  );
626
- marker.position.set(
627
- AZIMUTH_RADIUS * Math.sin(rad),
628
- 0.05,
629
- AZIMUTH_RADIUS * Math.cos(rad)
630
- );
631
- scene.add(marker);
 
 
 
 
 
632
  });
633
 
634
- const azimuthHandle = new THREE.Mesh(
635
- new THREE.SphereGeometry(0.16, 24, 24),
 
 
 
 
 
 
 
636
  new THREE.MeshStandardMaterial({
637
- color: 0xffd700,
638
- emissive: 0xffd700,
639
- emissiveIntensity: 0.6,
640
- roughness: 0.2,
641
- metalness: 0.7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  })
643
  );
644
- azimuthHandle.userData.type = 'azimuth';
645
- scene.add(azimuthHandle);
 
 
 
646
 
647
  // =============================================
648
- // BLUE: Elevation arc and handle
649
  // =============================================
 
 
 
650
  const arcPoints = [];
651
- for (let i = 0; i <= 48; i++) {
652
- const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 48));
653
  arcPoints.push(new THREE.Vector3(
654
- -0.8,
655
- ELEVATION_RADIUS * Math.sin(angle) + CENTER.y,
656
  ELEVATION_RADIUS * Math.cos(angle)
657
  ));
658
  }
659
  const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
660
  const elevationArc = new THREE.Mesh(
661
- new THREE.TubeGeometry(arcCurve, 48, 0.035, 12, false),
662
  new THREE.MeshStandardMaterial({
663
- color: 0x4dabf7,
664
- emissive: 0x4dabf7,
665
- emissiveIntensity: 0.4,
666
- roughness: 0.3,
667
- metalness: 0.6
668
  })
669
  );
670
- scene.add(elevationArc);
671
 
672
- // Elevation markers
673
  elevationSteps.forEach(angle => {
674
  const rad = THREE.MathUtils.degToRad(angle);
 
 
 
 
675
  const marker = new THREE.Mesh(
676
- new THREE.SphereGeometry(0.06, 8, 8),
677
  new THREE.MeshStandardMaterial({
678
- color: 0x4dabf7,
679
- emissive: 0x4dabf7,
680
- emissiveIntensity: 0.2
 
 
681
  })
682
  );
683
- marker.position.set(
684
- -0.8,
685
- ELEVATION_RADIUS * Math.sin(rad) + CENTER.y,
686
- ELEVATION_RADIUS * Math.cos(rad)
687
- );
688
- scene.add(marker);
689
  });
690
 
691
- const elevationHandle = new THREE.Mesh(
692
- new THREE.SphereGeometry(0.16, 24, 24),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
  new THREE.MeshStandardMaterial({
694
- color: 0x4dabf7,
695
- emissive: 0x4dabf7,
696
- emissiveIntensity: 0.6,
697
- roughness: 0.2,
698
- metalness: 0.7
 
 
 
 
 
 
 
 
 
 
 
 
699
  })
700
  );
701
- elevationHandle.userData.type = 'elevation';
702
- scene.add(elevationHandle);
 
 
 
703
 
704
  // =============================================
705
- // REFRESH BUTTON (styled button, not symbol)
706
  // =============================================
707
- const refreshBtn = document.createElement('button');
708
- refreshBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg><span style="margin-left: 6px;">Reset</span>';
709
- refreshBtn.style.cssText = `
 
 
 
 
 
 
 
 
710
  position: absolute;
711
- top: 10px;
712
- right: 10px;
713
- background: linear-gradient(135deg, #238636 0%, #2ea043 100%);
714
  color: white;
715
  border: none;
716
- padding: 8px 14px;
717
- border-radius: 6px;
718
  cursor: pointer;
719
  z-index: 10;
720
  font-size: 13px;
721
- font-weight: 500;
722
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
723
  display: flex;
724
  align-items: center;
725
- box-shadow: 0 2px 8px rgba(0,0,0,0.3);
726
- transition: all 0.2s ease;
 
 
727
  `;
728
- refreshBtn.onmouseenter = () => {
729
- refreshBtn.style.background = 'linear-gradient(135deg, #2ea043 0%, #3fb950 100%)';
730
- refreshBtn.style.transform = 'translateY(-1px)';
731
- refreshBtn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
732
  };
733
- refreshBtn.onmouseleave = () => {
734
- refreshBtn.style.background = 'linear-gradient(135deg, #238636 0%, #2ea043 100%)';
735
- refreshBtn.style.transform = 'translateY(0)';
736
- refreshBtn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
737
  };
738
- wrapper.appendChild(refreshBtn);
739
 
740
- refreshBtn.addEventListener('click', () => {
741
- // Animate reset
742
  const startAz = azimuthAngle;
743
  const startEl = elevationAngle;
744
  const startTime = Date.now();
745
 
746
  function animateReset() {
747
- const t = Math.min((Date.now() - startTime) / 400, 1);
748
  const ease = 1 - Math.pow(1 - t, 4);
749
 
750
  let azDiff = 0 - startAz;
@@ -782,21 +883,29 @@ class LightingControl3D(gr.HTML):
782
  lightGroup.position.set(lightX, lightY, lightZ);
783
  lightGroup.lookAt(CENTER);
784
 
785
- azimuthHandle.position.set(
 
786
  AZIMUTH_RADIUS * Math.sin(azRad),
787
- 0.05,
788
  AZIMUTH_RADIUS * Math.cos(azRad)
789
  );
790
- elevationHandle.position.set(
791
- -0.8,
 
 
 
792
  ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y,
793
  ELEVATION_RADIUS * Math.cos(elRad)
794
  );
795
 
796
- // Update prompt
797
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
798
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
799
- let prompt = '💡 Light source from';
 
 
 
 
800
  if (elSnap !== 0) {
801
  prompt += ' ' + elevationNames[String(elSnap)];
802
  } else {
@@ -818,25 +927,39 @@ class LightingControl3D(gr.HTML):
818
  const mouse = new THREE.Vector2();
819
  let isDragging = false;
820
  let dragTarget = null;
821
- let dragStartMouse = new THREE.Vector2();
822
  const intersection = new THREE.Vector3();
823
 
824
  const canvas = renderer.domElement;
825
 
826
- canvas.addEventListener('mousedown', (e) => {
827
  const rect = canvas.getBoundingClientRect();
828
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
829
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
830
 
831
  raycaster.setFromCamera(mouse, camera);
832
- const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
833
 
834
  if (intersects.length > 0) {
 
 
 
 
 
 
 
 
 
 
835
  isDragging = true;
836
- dragTarget = intersects[0].object;
837
- dragTarget.material.emissiveIntensity = 1.2;
838
- dragTarget.scale.setScalar(1.3);
839
- dragStartMouse.copy(mouse);
 
 
 
 
 
840
  canvas.style.cursor = 'grabbing';
841
  }
842
  });
@@ -850,13 +973,13 @@ class LightingControl3D(gr.HTML):
850
  raycaster.setFromCamera(mouse, camera);
851
 
852
  if (dragTarget.userData.type === 'azimuth') {
853
- const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
854
  if (raycaster.ray.intersectPlane(plane, intersection)) {
855
  azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
856
  if (azimuthAngle < 0) azimuthAngle += 360;
857
  }
858
  } else if (dragTarget.userData.type === 'elevation') {
859
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0.8);
860
  if (raycaster.ray.intersectPlane(plane, intersection)) {
861
  const relY = intersection.y - CENTER.y;
862
  const relZ = intersection.z;
@@ -869,15 +992,20 @@ class LightingControl3D(gr.HTML):
869
  }
870
  updatePositions();
871
  } else {
872
- raycaster.setFromCamera(mouse, camera);
873
- const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
874
- [azimuthHandle, elevationHandle].forEach(h => {
875
- h.material.emissiveIntensity = 0.6;
 
 
876
  h.scale.setScalar(1);
877
  });
878
- if (intersects.length > 0) {
879
- intersects[0].object.material.emissiveIntensity = 0.9;
880
- intersects[0].object.scale.setScalar(1.15);
 
 
 
881
  canvas.style.cursor = 'grab';
882
  } else {
883
  canvas.style.cursor = 'default';
@@ -887,10 +1015,12 @@ class LightingControl3D(gr.HTML):
887
 
888
  const onMouseUp = () => {
889
  if (dragTarget) {
890
- dragTarget.material.emissiveIntensity = 0.6;
 
 
891
  dragTarget.scale.setScalar(1);
892
 
893
- // Snap and animate
894
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
895
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
896
 
@@ -898,7 +1028,7 @@ class LightingControl3D(gr.HTML):
898
  const startTime = Date.now();
899
 
900
  function animateSnap() {
901
- const t = Math.min((Date.now() - startTime) / 200, 1);
902
  const ease = 1 - Math.pow(1 - t, 3);
903
 
904
  let azDiff = targetAz - startAz;
@@ -924,74 +1054,73 @@ class LightingControl3D(gr.HTML):
924
  canvas.addEventListener('mouseup', onMouseUp);
925
  canvas.addEventListener('mouseleave', onMouseUp);
926
 
927
- // Touch support for mobile
928
  canvas.addEventListener('touchstart', (e) => {
929
  e.preventDefault();
930
  const touch = e.touches[0];
931
- const rect = canvas.getBoundingClientRect();
932
- mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
933
- mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
934
-
935
- raycaster.setFromCamera(mouse, camera);
936
- const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
937
-
938
- if (intersects.length > 0) {
939
  isDragging = true;
940
- dragTarget = intersects[0].object;
941
- dragTarget.material.emissiveIntensity = 1.2;
942
- dragTarget.scale.setScalar(1.3);
943
- dragStartMouse.copy(mouse);
 
944
  }
945
  }, { passive: false });
946
 
947
  canvas.addEventListener('touchmove', (e) => {
948
  e.preventDefault();
 
 
949
  const touch = e.touches[0];
950
  const rect = canvas.getBoundingClientRect();
951
  mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
952
  mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
953
 
954
- if (isDragging && dragTarget) {
955
- raycaster.setFromCamera(mouse, camera);
956
-
957
- if (dragTarget.userData.type === 'azimuth') {
958
- const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
959
- if (raycaster.ray.intersectPlane(plane, intersection)) {
960
- azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
961
- if (azimuthAngle < 0) azimuthAngle += 360;
962
- }
963
- } else if (dragTarget.userData.type === 'elevation') {
964
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0.8);
965
- if (raycaster.ray.intersectPlane(plane, intersection)) {
966
- const relY = intersection.y - CENTER.y;
967
- const relZ = intersection.z;
968
- elevationAngle = THREE.MathUtils.clamp(
969
- THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)),
970
- -90,
971
- 90
972
- );
973
- }
974
  }
975
- updatePositions();
976
  }
 
977
  }, { passive: false });
978
 
979
- canvas.addEventListener('touchend', (e) => {
980
- e.preventDefault();
981
- onMouseUp();
982
- }, { passive: false });
983
-
984
- canvas.addEventListener('touchcancel', (e) => {
985
- e.preventDefault();
986
- onMouseUp();
987
- }, { passive: false });
988
 
989
  // Initial update
990
  updatePositions();
991
 
992
- // Render loop
 
 
 
993
  function render() {
994
  requestAnimationFrame(render);
 
 
 
 
 
 
 
995
  renderer.render(scene, camera);
996
  }
997
  render();
@@ -1003,17 +1132,6 @@ class LightingControl3D(gr.HTML):
1003
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
1004
  }).observe(wrapper);
1005
 
1006
- // Store update functions for external calls
1007
- wrapper._updateFromProps = (newVal) => {
1008
- if (newVal && typeof newVal === 'object') {
1009
- azimuthAngle = newVal.azimuth ?? azimuthAngle;
1010
- elevationAngle = newVal.elevation ?? elevationAngle;
1011
- updatePositions();
1012
- }
1013
- };
1014
-
1015
- wrapper._updateTexture = updateTextureFromUrl;
1016
-
1017
  // Watch for prop changes
1018
  let lastImageUrl = props.imageUrl;
1019
  let lastValue = JSON.stringify(props.value);
@@ -1049,7 +1167,7 @@ class LightingControl3D(gr.HTML):
1049
  css = '''
1050
  #col-container { max-width: 1200px; margin: 0 auto; }
1051
  .dark .progress-text { color: white !important; }
1052
- #lighting-3d-control { min-height: 450px; }
1053
  .slider-row { display: flex; gap: 10px; align-items: center; }
1054
  #main-title h1 {font-size: 2.4em !important;}
1055
  '''
@@ -1062,19 +1180,19 @@ with gr.Blocks(css=css) as demo:
1062
  with gr.Column(scale=1):
1063
  image = gr.Image(label="Input Image", type="pil", height=300)
1064
 
1065
- gr.Markdown("### 3D Lighting Control")
1066
- gr.Markdown("*Drag the colored handles: 🟡 Azimuth (Direction), 🔵 Elevation (Height)*")
1067
 
1068
  lighting_3d = LightingControl3D(
1069
  value={"azimuth": 0, "elevation": 0},
1070
  elem_id="lighting-3d-control"
1071
  )
1072
- run_btn = gr.Button("Generate Image", variant="primary", size="lg")
1073
 
1074
  gr.Markdown("### Slider Controls")
1075
 
1076
  azimuth_slider = gr.Slider(
1077
- label="Azimuth (Horizontal Rotation)",
1078
  minimum=0,
1079
  maximum=315,
1080
  step=45,
@@ -1083,7 +1201,7 @@ with gr.Blocks(css=css) as demo:
1083
  )
1084
 
1085
  elevation_slider = gr.Slider(
1086
- label="Elevation (Vertical Angle)",
1087
  minimum=-90,
1088
  maximum=90,
1089
  step=90,
@@ -1111,12 +1229,10 @@ with gr.Blocks(css=css) as demo:
1111
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
1112
 
1113
  def update_prompt_from_sliders(azimuth, elevation):
1114
- """Update prompt preview when sliders change."""
1115
  prompt = build_lighting_prompt(azimuth, elevation)
1116
  return prompt
1117
 
1118
  def sync_3d_to_sliders(lighting_value):
1119
- """Sync 3D control changes to sliders."""
1120
  if lighting_value and isinstance(lighting_value, dict):
1121
  az = lighting_value.get('azimuth', 0)
1122
  el = lighting_value.get('elevation', 0)
@@ -1125,11 +1241,9 @@ with gr.Blocks(css=css) as demo:
1125
  return gr.update(), gr.update(), gr.update()
1126
 
1127
  def sync_sliders_to_3d(azimuth, elevation):
1128
- """Sync slider changes to 3D control."""
1129
  return {"azimuth": azimuth, "elevation": elevation}
1130
 
1131
  def update_3d_image(image):
1132
- """Update the 3D component with the uploaded image."""
1133
  if image is None:
1134
  return gr.update(imageUrl=None)
1135
 
 
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
  height: int = 1024,
152
  width: int = 1024,
153
  ):
 
 
 
154
  global loaded
155
  progress = gr.Progress(track_tqdm=True)
156
 
 
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
  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
  (() => {
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;
322
 
323
  // State
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
  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,
375
  side: THREE.DoubleSide,
376
+ roughness: 0.3,
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();
442
  planeMaterial.needsUpdate = true;
 
 
 
 
 
 
 
 
 
 
 
443
  return;
444
  }
445
 
 
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();
 
611
  scene.add(lightTarget);
612
  spotLight.target = lightTarget;
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;
 
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
  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
  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;
 
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
 
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
  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
  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
  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);
 
1167
  css = '''
1168
  #col-container { max-width: 1200px; margin: 0 auto; }
1169
  .dark .progress-text { color: white !important; }
1170
+ #lighting-3d-control { min-height: 500px; }
1171
  .slider-row { display: flex; gap: 10px; align-items: center; }
1172
  #main-title h1 {font-size: 2.4em !important;}
1173
  '''
 
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
 
1194
  azimuth_slider = gr.Slider(
1195
+ label="🟡 Azimuth (Horizontal Rotation)",
1196
  minimum=0,
1197
  maximum=315,
1198
  step=45,
 
1201
  )
1202
 
1203
  elevation_slider = gr.Slider(
1204
+ label="🔵 Elevation (Vertical Angle)",
1205
  minimum=-90,
1206
  maximum=90,
1207
  step=90,
 
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
  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