1016 lines
38 KiB
JavaScript
1016 lines
38 KiB
JavaScript
/**
|
|
* Inline HTML for the 3D Camera Angle Viewer
|
|
* Contains the complete Three.js scene for camera angle control
|
|
*/
|
|
export const VIEWER_HTML = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
width: 100%;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
background: #0a0a0f;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
|
|
#container {
|
|
width: 100%;
|
|
height: 100%;
|
|
position: relative;
|
|
}
|
|
|
|
#threejs-container {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
/* prompt-preview is now inside color-picker-container */
|
|
|
|
#info-panel {
|
|
position: absolute;
|
|
bottom: 8px;
|
|
left: 8px;
|
|
right: 8px;
|
|
background: rgba(10, 10, 15, 0.9);
|
|
border: 1px solid rgba(233, 61, 130, 0.3);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
font-size: 11px;
|
|
color: #e0e0e0;
|
|
display: flex;
|
|
justify-content: space-around;
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
.param-item {
|
|
text-align: center;
|
|
}
|
|
|
|
.param-label {
|
|
color: #888;
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.param-value {
|
|
color: #E93D82;
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.param-value.elevation {
|
|
color: #00FFD0;
|
|
}
|
|
|
|
.param-value.zoom {
|
|
color: #FFB800;
|
|
}
|
|
|
|
#reset-btn {
|
|
position: absolute;
|
|
right: 8px;
|
|
bottom: 8px;
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 4px;
|
|
border: 1px solid rgba(233, 61, 130, 0.4);
|
|
background: rgba(10, 10, 15, 0.8);
|
|
color: #E93D82;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 14px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
#reset-btn:hover {
|
|
background: rgba(233, 61, 130, 0.2);
|
|
border-color: #E93D82;
|
|
}
|
|
|
|
#reset-btn:active {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
/* Color picker styles */
|
|
#color-picker-container {
|
|
position: absolute;
|
|
top: 8px;
|
|
left: 8px;
|
|
right: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
background: rgba(10, 10, 15, 0.9);
|
|
border: 1px solid rgba(233, 61, 130, 0.3);
|
|
border-radius: 6px;
|
|
padding: 6px 10px;
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
#prompt-preview {
|
|
flex: 1;
|
|
font-size: 11px;
|
|
color: #E93D82;
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
word-break: break-all;
|
|
line-height: 1.4;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
#color-section {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#color-picker-label {
|
|
font-size: 10px;
|
|
color: #888;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
#color-picker {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
background: none;
|
|
}
|
|
|
|
#color-picker::-webkit-color-swatch-wrapper {
|
|
padding: 0;
|
|
}
|
|
|
|
#color-picker::-webkit-color-swatch {
|
|
border: none;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
#color-picker::-moz-color-swatch {
|
|
border: none;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
#color-hex-display {
|
|
font-size: 11px;
|
|
color: #FFB800;
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
font-weight: 600;
|
|
min-width: 60px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<div id="threejs-container"></div>
|
|
<div id="color-picker-container">
|
|
<div id="prompt-preview">light from front, eye level</div>
|
|
<div id="color-section">
|
|
<span id="color-picker-label">Color</span>
|
|
<input type="color" id="color-picker" value="#FFFFFF">
|
|
<span id="color-hex-display">#FFFFFF</span>
|
|
</div>
|
|
</div>
|
|
<div id="info-panel">
|
|
<div class="param-item">
|
|
<div class="param-label">Azimuth</div>
|
|
<div class="param-value" id="h-value">0°</div>
|
|
</div>
|
|
<div class="param-item">
|
|
<div class="param-label">Elevation</div>
|
|
<div class="param-value elevation" id="v-value">0°</div>
|
|
</div>
|
|
<div class="param-item">
|
|
<div class="param-label">Distance</div>
|
|
<div class="param-value zoom" id="z-value">5.0</div>
|
|
</div>
|
|
<button id="reset-btn" title="Reset to defaults">↺</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
<script>
|
|
// State
|
|
let state = {
|
|
azimuth: 0,
|
|
elevation: 0,
|
|
distance: 5,
|
|
lightColor: "#FFFFFF",
|
|
imageUrl: null,
|
|
useDefaultPrompts: false
|
|
};
|
|
|
|
let threeScene = null;
|
|
|
|
// DOM Elements
|
|
const container = document.getElementById('threejs-container');
|
|
const hValueEl = document.getElementById('h-value');
|
|
const vValueEl = document.getElementById('v-value');
|
|
const zValueEl = document.getElementById('z-value');
|
|
const promptPreviewEl = document.getElementById('prompt-preview');
|
|
const colorPicker = document.getElementById('color-picker');
|
|
const colorHexDisplay = document.getElementById('color-hex-display');
|
|
|
|
// Color picker event handler
|
|
colorPicker.addEventListener('input', (e) => {
|
|
state.lightColor = e.target.value;
|
|
colorHexDisplay.textContent = e.target.value.toUpperCase();
|
|
if (threeScene) {
|
|
threeScene.syncFromState();
|
|
}
|
|
sendAngleUpdate();
|
|
});
|
|
|
|
colorPicker.addEventListener('change', (e) => {
|
|
state.lightColor = e.target.value;
|
|
colorHexDisplay.textContent = e.target.value.toUpperCase();
|
|
if (threeScene) {
|
|
threeScene.syncFromState();
|
|
}
|
|
sendAngleUpdate();
|
|
});
|
|
|
|
function generatePromptPreview() {
|
|
const h_angle = state.azimuth % 360;
|
|
let h_direction;
|
|
if (h_angle < 22.5 || h_angle >= 337.5) {
|
|
h_direction = "light from front";
|
|
} else if (h_angle < 67.5) {
|
|
h_direction = "light from front-right";
|
|
} else if (h_angle < 112.5) {
|
|
h_direction = "light from right";
|
|
} else if (h_angle < 157.5) {
|
|
h_direction = "light from back-right";
|
|
} else if (h_angle < 202.5) {
|
|
h_direction = "light from back";
|
|
} else if (h_angle < 247.5) {
|
|
h_direction = "light from back-left";
|
|
} else if (h_angle < 292.5) {
|
|
h_direction = "light from left";
|
|
} else {
|
|
h_direction = "light from front-left";
|
|
}
|
|
|
|
let v_direction;
|
|
if (state.elevation < -15) {
|
|
v_direction = "from below";
|
|
} else if (state.elevation < 15) {
|
|
v_direction = "eye level";
|
|
} else if (state.elevation < 45) {
|
|
v_direction = "from high angle";
|
|
} else if (state.elevation < 75) {
|
|
v_direction = "from above";
|
|
} else {
|
|
v_direction = "overhead";
|
|
}
|
|
|
|
return h_direction + ", " + v_direction;
|
|
}
|
|
|
|
function generateQwenPrompt() {
|
|
const h_angle = state.azimuth % 360;
|
|
const v_angle = state.elevation;
|
|
|
|
// Horizontal mapping
|
|
let h_direction;
|
|
if (h_angle < 22.5 || h_angle >= 337.5) {
|
|
h_direction = "front lighting";
|
|
} else if (h_angle < 67.5) {
|
|
h_direction = "front-right lighting";
|
|
} else if (h_angle < 112.5) {
|
|
h_direction = "right side lighting";
|
|
} else if (h_angle < 157.5) {
|
|
h_direction = "back-right lighting";
|
|
} else if (h_angle < 202.5) {
|
|
h_direction = "back lighting";
|
|
} else if (h_angle < 247.5) {
|
|
h_direction = "back-left lighting";
|
|
} else if (h_angle < 292.5) {
|
|
h_direction = "left side lighting";
|
|
} else {
|
|
h_direction = "front-left lighting";
|
|
}
|
|
|
|
// Vertical mapping for Qwen format
|
|
let v_direction;
|
|
if (v_angle < -15) {
|
|
v_direction = "lighting from below";
|
|
} else if (v_angle < 15) {
|
|
v_direction = "level lighting";
|
|
} else if (v_angle < 75) {
|
|
v_direction = "lighting from above";
|
|
} else {
|
|
v_direction = "top-down lighting";
|
|
}
|
|
|
|
// Distance mapping
|
|
let distance;
|
|
if (state.distance < 3) {
|
|
distance = "strong/close lighting";
|
|
} else if (state.distance < 7) {
|
|
distance = "medium distance lighting";
|
|
} else {
|
|
distance = "soft/far lighting";
|
|
}
|
|
|
|
return h_direction + " " + v_direction + " " + distance;
|
|
}
|
|
|
|
function updateDisplay() {
|
|
hValueEl.textContent = Math.round(state.azimuth) + '°';
|
|
vValueEl.textContent = Math.round(state.elevation) + '°';
|
|
zValueEl.textContent = state.distance.toFixed(1);
|
|
if (state.useDefaultPrompts) {
|
|
promptPreviewEl.textContent = generateQwenPrompt();
|
|
} else {
|
|
promptPreviewEl.textContent = generatePromptPreview();
|
|
}
|
|
}
|
|
|
|
function sendAngleUpdate() {
|
|
window.parent.postMessage({
|
|
type: 'ANGLE_UPDATE',
|
|
horizontal: Math.round(state.azimuth),
|
|
vertical: Math.round(state.elevation),
|
|
zoom: Math.round(state.distance * 10) / 10,
|
|
lightColor: state.lightColor || "#FFFFFF",
|
|
useDefaultPrompts: state.useDefaultPrompts || false
|
|
}, '*');
|
|
}
|
|
|
|
function resetToDefaults() {
|
|
state.azimuth = 0;
|
|
state.elevation = 0;
|
|
state.distance = 5.0;
|
|
state.useDefaultPrompts = false;
|
|
if (threeScene) {
|
|
threeScene.syncFromState();
|
|
}
|
|
updateDisplay();
|
|
sendAngleUpdate();
|
|
}
|
|
|
|
// Reset button handler
|
|
document.getElementById('reset-btn').addEventListener('click', resetToDefaults);
|
|
|
|
function initThreeJS() {
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
// Scene
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x0a0a0f);
|
|
|
|
// Camera (default overview camera)
|
|
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
|
camera.position.set(4, 3.5, 4);
|
|
camera.lookAt(0, 0.3, 0);
|
|
|
|
// Preview camera (placed at camera indicator position, looking at image)
|
|
const previewCamera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100);
|
|
|
|
// Camera view state
|
|
let useCameraView = false;
|
|
let activeCamera = camera;
|
|
|
|
// Renderer
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
renderer.setSize(width, height);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.outputEncoding = THREE.sRGBEncoding;
|
|
container.appendChild(renderer.domElement);
|
|
|
|
// Lighting
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
|
scene.add(ambientLight);
|
|
|
|
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
mainLight.position.set(5, 10, 5);
|
|
scene.add(mainLight);
|
|
|
|
const fillLight = new THREE.DirectionalLight(0xE93D82, 0.3);
|
|
fillLight.position.set(-5, 5, -5);
|
|
scene.add(fillLight);
|
|
|
|
// Grid
|
|
const gridHelper = new THREE.GridHelper(5, 20, 0x1a1a2e, 0x12121a);
|
|
gridHelper.position.y = -0.01;
|
|
scene.add(gridHelper);
|
|
|
|
// Constants
|
|
const CENTER = new THREE.Vector3(0, 0.5, 0);
|
|
const AZIMUTH_RADIUS = 1.8;
|
|
const ELEVATION_RADIUS = 1.4;
|
|
const ELEV_ARC_X = -0.8;
|
|
|
|
// Live values
|
|
let liveAzimuth = state.azimuth;
|
|
let liveElevation = state.elevation;
|
|
let liveDistance = state.distance;
|
|
|
|
// Subject (Image Card) - Like a playing card with front image and back grid
|
|
const cardThickness = 0.02;
|
|
const cardGeo = new THREE.BoxGeometry(1.2, 1.2, cardThickness);
|
|
|
|
// Create grid texture for card back using canvas
|
|
function createGridTexture() {
|
|
const canvas = document.createElement('canvas');
|
|
const size = 256;
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Background
|
|
ctx.fillStyle = '#1a1a2a';
|
|
ctx.fillRect(0, 0, size, size);
|
|
|
|
// Grid lines
|
|
ctx.strokeStyle = '#2a2a3a';
|
|
ctx.lineWidth = 1;
|
|
const gridSize = 16;
|
|
for (let i = 0; i <= size; i += gridSize) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(i, 0);
|
|
ctx.lineTo(i, size);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, i);
|
|
ctx.lineTo(size, i);
|
|
ctx.stroke();
|
|
}
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
texture.repeat.set(4, 4);
|
|
return texture;
|
|
}
|
|
|
|
// Materials: [+X right, -X left, +Y top, -Y bottom, +Z front, -Z back]
|
|
const frontMat = new THREE.MeshBasicMaterial({ color: 0x3a3a4a }); // Front - will show image
|
|
const backMat = new THREE.MeshBasicMaterial({ map: createGridTexture() }); // Back - grid pattern
|
|
const edgeMat = new THREE.MeshBasicMaterial({ color: 0x1a1a2a }); // Edges - darker
|
|
|
|
const cardMaterials = [edgeMat, edgeMat, edgeMat, edgeMat, frontMat, backMat];
|
|
const imagePlane = new THREE.Mesh(cardGeo, cardMaterials);
|
|
imagePlane.position.copy(CENTER);
|
|
scene.add(imagePlane);
|
|
|
|
// Keep reference to front material for image updates
|
|
const planeMat = frontMat;
|
|
|
|
// Frame
|
|
const frameGeo = new THREE.EdgesGeometry(cardGeo);
|
|
const frameMat = new THREE.LineBasicMaterial({ color: 0xE93D82 });
|
|
const imageFrame = new THREE.LineSegments(frameGeo, frameMat);
|
|
imageFrame.position.copy(CENTER);
|
|
scene.add(imageFrame);
|
|
|
|
// Glow ring
|
|
const glowRingGeo = new THREE.RingGeometry(0.55, 0.58, 64);
|
|
const glowRingMat = new THREE.MeshBasicMaterial({
|
|
color: 0xE93D82,
|
|
transparent: true,
|
|
opacity: 0.4,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const glowRing = new THREE.Mesh(glowRingGeo, glowRingMat);
|
|
glowRing.position.set(0, 0.01, 0);
|
|
glowRing.rotation.x = -Math.PI / 2;
|
|
scene.add(glowRing);
|
|
|
|
// Camera Indicator
|
|
const camGeo = new THREE.ConeGeometry(0.15, 0.4, 4);
|
|
const camMat = new THREE.MeshStandardMaterial({
|
|
color: 0xE93D82,
|
|
emissive: 0xE93D82,
|
|
emissiveIntensity: 0.5,
|
|
metalness: 0.8,
|
|
roughness: 0.2
|
|
});
|
|
const cameraIndicator = new THREE.Mesh(camGeo, camMat);
|
|
scene.add(cameraIndicator);
|
|
|
|
const camGlowGeo = new THREE.SphereGeometry(0.08, 16, 16);
|
|
const camGlowMat = new THREE.MeshBasicMaterial({
|
|
color: 0xff6ba8,
|
|
transparent: true,
|
|
opacity: 0.8
|
|
});
|
|
const camGlow = new THREE.Mesh(camGlowGeo, camGlowMat);
|
|
scene.add(camGlow);
|
|
|
|
// Azimuth Ring
|
|
const azRingGeo = new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 100);
|
|
const azRingMat = new THREE.MeshBasicMaterial({
|
|
color: 0xE93D82,
|
|
transparent: true,
|
|
opacity: 0.7
|
|
});
|
|
const azimuthRing = new THREE.Mesh(azRingGeo, azRingMat);
|
|
azimuthRing.rotation.x = Math.PI / 2;
|
|
azimuthRing.position.y = 0.02;
|
|
scene.add(azimuthRing);
|
|
|
|
// Azimuth Handle
|
|
const azHandleGeo = new THREE.SphereGeometry(0.16, 32, 32);
|
|
const azHandleMat = new THREE.MeshStandardMaterial({
|
|
color: 0xE93D82,
|
|
emissive: 0xE93D82,
|
|
emissiveIntensity: 0.6,
|
|
metalness: 0.3,
|
|
roughness: 0.4
|
|
});
|
|
const azimuthHandle = new THREE.Mesh(azHandleGeo, azHandleMat);
|
|
scene.add(azimuthHandle);
|
|
|
|
const azGlowGeo = new THREE.SphereGeometry(0.22, 16, 16);
|
|
const azGlowMat = new THREE.MeshBasicMaterial({
|
|
color: 0xE93D82,
|
|
transparent: true,
|
|
opacity: 0.2
|
|
});
|
|
const azGlow = new THREE.Mesh(azGlowGeo, azGlowMat);
|
|
scene.add(azGlow);
|
|
|
|
// Elevation Arc
|
|
const arcPoints = [];
|
|
for (let i = 0; i <= 32; i++) {
|
|
const angle = (-90 + (180 * i / 32)) * Math.PI / 180;
|
|
arcPoints.push(new THREE.Vector3(
|
|
ELEV_ARC_X,
|
|
ELEVATION_RADIUS * Math.sin(angle) + CENTER.y,
|
|
ELEVATION_RADIUS * Math.cos(angle)
|
|
));
|
|
}
|
|
const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
|
|
const elArcGeo = new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false);
|
|
const elArcMat = new THREE.MeshBasicMaterial({
|
|
color: 0x00FFD0,
|
|
transparent: true,
|
|
opacity: 0.8
|
|
});
|
|
const elevationArc = new THREE.Mesh(elArcGeo, elArcMat);
|
|
scene.add(elevationArc);
|
|
|
|
// Elevation Handle
|
|
const elHandleGeo = new THREE.SphereGeometry(0.16, 32, 32);
|
|
const elHandleMat = new THREE.MeshStandardMaterial({
|
|
color: 0x00FFD0,
|
|
emissive: 0x00FFD0,
|
|
emissiveIntensity: 0.6,
|
|
metalness: 0.3,
|
|
roughness: 0.4
|
|
});
|
|
const elevationHandle = new THREE.Mesh(elHandleGeo, elHandleMat);
|
|
scene.add(elevationHandle);
|
|
|
|
const elGlowGeo = new THREE.SphereGeometry(0.22, 16, 16);
|
|
const elGlowMat = new THREE.MeshBasicMaterial({
|
|
color: 0x00FFD0,
|
|
transparent: true,
|
|
opacity: 0.2
|
|
});
|
|
const elGlow = new THREE.Mesh(elGlowGeo, elGlowMat);
|
|
scene.add(elGlow);
|
|
|
|
// Distance Handle
|
|
const distHandleGeo = new THREE.SphereGeometry(0.15, 32, 32);
|
|
const distHandleMat = new THREE.MeshStandardMaterial({
|
|
color: 0xFFB800,
|
|
emissive: 0xFFB800,
|
|
emissiveIntensity: 0.7,
|
|
metalness: 0.5,
|
|
roughness: 0.3
|
|
});
|
|
const distanceHandle = new THREE.Mesh(distHandleGeo, distHandleMat);
|
|
scene.add(distanceHandle);
|
|
|
|
const distGlowGeo = new THREE.SphereGeometry(0.22, 16, 16);
|
|
const distGlowMat = new THREE.MeshBasicMaterial({
|
|
color: 0xFFB800,
|
|
transparent: true,
|
|
opacity: 0.25
|
|
});
|
|
const distGlow = new THREE.Mesh(distGlowGeo, distGlowMat);
|
|
scene.add(distGlow);
|
|
|
|
// Distance Line
|
|
let distanceTube = null;
|
|
function updateDistanceLine(start, end) {
|
|
if (distanceTube) {
|
|
scene.remove(distanceTube);
|
|
distanceTube.geometry.dispose();
|
|
distanceTube.material.dispose();
|
|
}
|
|
const path = new THREE.LineCurve3(start, end);
|
|
const tubeGeo = new THREE.TubeGeometry(path, 1, 0.025, 8, false);
|
|
const tubeMat = new THREE.MeshBasicMaterial({
|
|
color: state.lightColor || 0xFFB800,
|
|
transparent: true,
|
|
opacity: 0.8
|
|
});
|
|
distanceTube = new THREE.Mesh(tubeGeo, tubeMat);
|
|
scene.add(distanceTube);
|
|
}
|
|
|
|
// Update Visuals
|
|
function updateVisuals() {
|
|
const azRad = (liveAzimuth * Math.PI) / 180;
|
|
const elRad = (liveElevation * Math.PI) / 180;
|
|
const visualDist = 2.6 - (liveDistance / 10) * 2.0;
|
|
|
|
// Camera indicator
|
|
const camX = visualDist * Math.sin(azRad) * Math.cos(elRad);
|
|
const camY = CENTER.y + visualDist * Math.sin(elRad);
|
|
const camZ = visualDist * Math.cos(azRad) * Math.cos(elRad);
|
|
|
|
cameraIndicator.position.set(camX, camY, camZ);
|
|
cameraIndicator.lookAt(CENTER);
|
|
cameraIndicator.rotateX(Math.PI / 2);
|
|
camGlow.position.copy(cameraIndicator.position);
|
|
|
|
// Azimuth handle
|
|
const azX = AZIMUTH_RADIUS * Math.sin(azRad);
|
|
const azZ = AZIMUTH_RADIUS * Math.cos(azRad);
|
|
azimuthHandle.position.set(azX, 0.16, azZ);
|
|
azGlow.position.copy(azimuthHandle.position);
|
|
|
|
// Elevation handle
|
|
const elY = CENTER.y + ELEVATION_RADIUS * Math.sin(elRad);
|
|
const elZ = ELEVATION_RADIUS * Math.cos(elRad);
|
|
elevationHandle.position.set(ELEV_ARC_X, elY, elZ);
|
|
elGlow.position.copy(elevationHandle.position);
|
|
|
|
// Distance handle
|
|
const distT = 0.15 + ((10 - liveDistance) / 10) * 0.7;
|
|
distanceHandle.position.lerpVectors(CENTER, cameraIndicator.position, distT);
|
|
distGlow.position.copy(distanceHandle.position);
|
|
|
|
// Distance line
|
|
updateDistanceLine(CENTER.clone(), cameraIndicator.position.clone());
|
|
|
|
// Update orthographic camera position and orientation
|
|
previewCamera.position.copy(cameraIndicator.position);
|
|
previewCamera.lookAt(CENTER);
|
|
|
|
// Animate glow ring
|
|
glowRing.rotation.z += 0.005;
|
|
}
|
|
|
|
updateVisuals();
|
|
|
|
// Raycaster
|
|
const raycaster = new THREE.Raycaster();
|
|
const mouse = new THREE.Vector2();
|
|
let isDragging = false;
|
|
let dragTarget = null;
|
|
let hoveredHandle = null;
|
|
|
|
function getMousePos(event) {
|
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
}
|
|
|
|
function setHandleScale(handle, glow, scale) {
|
|
handle.scale.setScalar(scale);
|
|
if (glow) glow.scale.setScalar(scale);
|
|
}
|
|
|
|
function onPointerDown(event) {
|
|
getMousePos(event);
|
|
raycaster.setFromCamera(mouse, camera);
|
|
|
|
const handles = [
|
|
{ mesh: azimuthHandle, glow: azGlow, name: 'azimuth' },
|
|
{ mesh: elevationHandle, glow: elGlow, name: 'elevation' },
|
|
{ mesh: distanceHandle, glow: distGlow, name: 'distance' }
|
|
];
|
|
|
|
for (const h of handles) {
|
|
if (raycaster.intersectObject(h.mesh).length > 0) {
|
|
isDragging = true;
|
|
dragTarget = h.name;
|
|
setHandleScale(h.mesh, h.glow, 1.3);
|
|
renderer.domElement.style.cursor = 'grabbing';
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPointerMove(event) {
|
|
getMousePos(event);
|
|
raycaster.setFromCamera(mouse, camera);
|
|
|
|
if (!isDragging) {
|
|
const handles = [
|
|
{ mesh: azimuthHandle, glow: azGlow, name: 'azimuth' },
|
|
{ mesh: elevationHandle, glow: elGlow, name: 'elevation' },
|
|
{ mesh: distanceHandle, glow: distGlow, name: 'distance' }
|
|
];
|
|
|
|
let foundHover = null;
|
|
for (const h of handles) {
|
|
if (raycaster.intersectObject(h.mesh).length > 0) {
|
|
foundHover = h;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (hoveredHandle && hoveredHandle !== foundHover) {
|
|
setHandleScale(hoveredHandle.mesh, hoveredHandle.glow, 1.0);
|
|
}
|
|
|
|
if (foundHover) {
|
|
setHandleScale(foundHover.mesh, foundHover.glow, 1.15);
|
|
renderer.domElement.style.cursor = 'grab';
|
|
hoveredHandle = foundHover;
|
|
} else {
|
|
renderer.domElement.style.cursor = 'default';
|
|
hoveredHandle = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Dragging
|
|
const plane = new THREE.Plane();
|
|
const intersect = new THREE.Vector3();
|
|
|
|
if (dragTarget === 'azimuth') {
|
|
plane.setFromNormalAndCoplanarPoint(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0));
|
|
if (raycaster.ray.intersectPlane(plane, intersect)) {
|
|
let angle = Math.atan2(intersect.x, intersect.z) * (180 / Math.PI);
|
|
if (angle < 0) angle += 360;
|
|
liveAzimuth = Math.max(0, Math.min(360, angle));
|
|
state.azimuth = Math.round(liveAzimuth);
|
|
updateDisplay();
|
|
updateVisuals();
|
|
sendAngleUpdate();
|
|
}
|
|
} else if (dragTarget === 'elevation') {
|
|
const elevPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -ELEV_ARC_X);
|
|
if (raycaster.ray.intersectPlane(elevPlane, intersect)) {
|
|
const relY = intersect.y - CENTER.y;
|
|
const relZ = intersect.z;
|
|
let angle = Math.atan2(relY, relZ) * (180 / Math.PI);
|
|
angle = Math.max(-90, Math.min(90, angle));
|
|
liveElevation = angle;
|
|
state.elevation = Math.round(liveElevation);
|
|
updateDisplay();
|
|
updateVisuals();
|
|
sendAngleUpdate();
|
|
}
|
|
} else if (dragTarget === 'distance') {
|
|
const newDist = 5 - mouse.y * 5;
|
|
liveDistance = Math.max(0, Math.min(10, newDist));
|
|
state.distance = Math.round(liveDistance * 10) / 10;
|
|
updateDisplay();
|
|
updateVisuals();
|
|
sendAngleUpdate();
|
|
}
|
|
}
|
|
|
|
function onPointerUp() {
|
|
if (isDragging) {
|
|
const handles = [
|
|
{ mesh: azimuthHandle, glow: azGlow },
|
|
{ mesh: elevationHandle, glow: elGlow },
|
|
{ mesh: distanceHandle, glow: distGlow }
|
|
];
|
|
handles.forEach(h => setHandleScale(h.mesh, h.glow, 1.0));
|
|
}
|
|
|
|
isDragging = false;
|
|
dragTarget = null;
|
|
renderer.domElement.style.cursor = 'default';
|
|
}
|
|
|
|
// Event listeners
|
|
renderer.domElement.addEventListener('mousedown', onPointerDown);
|
|
renderer.domElement.addEventListener('mousemove', onPointerMove);
|
|
renderer.domElement.addEventListener('mouseup', onPointerUp);
|
|
renderer.domElement.addEventListener('mouseleave', onPointerUp);
|
|
|
|
renderer.domElement.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
onPointerDown({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY });
|
|
}, { passive: false });
|
|
|
|
renderer.domElement.addEventListener('touchmove', (e) => {
|
|
e.preventDefault();
|
|
onPointerMove({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY });
|
|
}, { passive: false });
|
|
|
|
renderer.domElement.addEventListener('touchend', onPointerUp);
|
|
|
|
// Animation loop
|
|
let time = 0;
|
|
let isVisible = true;
|
|
|
|
// Handle visibility change
|
|
document.addEventListener('visibilitychange', () => {
|
|
isVisible = !document.hidden;
|
|
});
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
if (!isVisible) return;
|
|
|
|
time += 0.01;
|
|
|
|
const pulse = 1 + Math.sin(time * 2) * 0.03;
|
|
camGlow.scale.setScalar(pulse);
|
|
glowRing.rotation.z += 0.003;
|
|
|
|
renderer.render(scene, activeCamera);
|
|
}
|
|
animate();
|
|
|
|
// Camera view control function (called from message handler)
|
|
function setCameraView(enabled) {
|
|
useCameraView = enabled;
|
|
if (useCameraView) {
|
|
activeCamera = previewCamera;
|
|
// Hide control elements in camera view
|
|
azimuthRing.visible = false;
|
|
azimuthHandle.visible = false;
|
|
azGlow.visible = false;
|
|
elevationArc.visible = false;
|
|
elevationHandle.visible = false;
|
|
elGlow.visible = false;
|
|
distanceHandle.visible = false;
|
|
distGlow.visible = false;
|
|
if (distanceTube) distanceTube.visible = false;
|
|
cameraIndicator.visible = false;
|
|
camGlow.visible = false;
|
|
glowRing.visible = false;
|
|
gridHelper.visible = false;
|
|
imageFrame.visible = false;
|
|
} else {
|
|
activeCamera = camera;
|
|
// Show control elements
|
|
azimuthRing.visible = true;
|
|
azimuthHandle.visible = true;
|
|
azGlow.visible = true;
|
|
elevationArc.visible = true;
|
|
elevationHandle.visible = true;
|
|
elGlow.visible = true;
|
|
distanceHandle.visible = true;
|
|
distGlow.visible = true;
|
|
if (distanceTube) distanceTube.visible = true;
|
|
cameraIndicator.visible = true;
|
|
camGlow.visible = true;
|
|
glowRing.visible = true;
|
|
gridHelper.visible = true;
|
|
imageFrame.visible = true;
|
|
}
|
|
}
|
|
|
|
// Resize
|
|
function onResize() {
|
|
const w = container.clientWidth;
|
|
const h = container.clientHeight;
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
// Update preview camera
|
|
previewCamera.aspect = w / h;
|
|
previewCamera.updateProjectionMatrix();
|
|
renderer.setSize(w, h);
|
|
}
|
|
window.addEventListener('resize', onResize);
|
|
|
|
// Public API
|
|
threeScene = {
|
|
syncFromState: () => {
|
|
liveAzimuth = state.azimuth;
|
|
liveElevation = state.elevation;
|
|
liveDistance = state.distance;
|
|
updateVisuals();
|
|
updateDisplay();
|
|
// Update color picker display
|
|
if (state.lightColor) {
|
|
colorPicker.value = state.lightColor;
|
|
colorHexDisplay.textContent = state.lightColor.toUpperCase();
|
|
// Update light color on distance handle (light source indicator)
|
|
distHandleMat.color.set(state.lightColor);
|
|
distHandleMat.emissive.set(state.lightColor);
|
|
distGlowMat.color.set(state.lightColor);
|
|
// Update distance line color
|
|
if (distanceTube && distanceTube.material) {
|
|
distanceTube.material.color.set(state.lightColor);
|
|
}
|
|
}
|
|
},
|
|
setCameraView: setCameraView,
|
|
updateImage: (url) => {
|
|
if (url) {
|
|
const img = new Image();
|
|
if (!url.startsWith('data:')) {
|
|
img.crossOrigin = 'anonymous';
|
|
}
|
|
|
|
img.onload = () => {
|
|
const tex = new THREE.Texture(img);
|
|
tex.needsUpdate = true;
|
|
tex.encoding = THREE.sRGBEncoding;
|
|
planeMat.map = tex;
|
|
planeMat.color.set(0xffffff);
|
|
planeMat.needsUpdate = true;
|
|
|
|
const ar = img.width / img.height;
|
|
const maxSize = 1.5;
|
|
let scaleX, scaleY;
|
|
if (ar > 1) {
|
|
scaleX = maxSize;
|
|
scaleY = maxSize / ar;
|
|
} else {
|
|
scaleY = maxSize;
|
|
scaleX = maxSize * ar;
|
|
}
|
|
imagePlane.scale.set(scaleX, scaleY, 1);
|
|
imageFrame.scale.set(scaleX, scaleY, 1);
|
|
};
|
|
|
|
img.onerror = () => {
|
|
planeMat.map = null;
|
|
planeMat.color.set(0xE93D82);
|
|
planeMat.needsUpdate = true;
|
|
};
|
|
|
|
img.src = url;
|
|
} else {
|
|
planeMat.map = null;
|
|
planeMat.color.set(0x3a3a4a);
|
|
planeMat.needsUpdate = true;
|
|
imagePlane.scale.set(1, 1, 1);
|
|
imageFrame.scale.set(1, 1, 1);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Message handler
|
|
window.addEventListener('message', (event) => {
|
|
const data = event.data;
|
|
|
|
if (data.type === 'INIT') {
|
|
state.azimuth = data.horizontal || 0;
|
|
state.elevation = data.vertical || 0;
|
|
state.distance = data.zoom || 5;
|
|
state.lightColor = data.lightColor || "#FFFFFF";
|
|
if (threeScene) {
|
|
threeScene.syncFromState();
|
|
threeScene.setCameraView(data.cameraView || false);
|
|
}
|
|
} else if (data.type === 'SYNC_ANGLES') {
|
|
state.azimuth = data.horizontal || 0;
|
|
state.elevation = data.vertical || 0;
|
|
state.distance = data.zoom || 5;
|
|
state.lightColor = data.lightColor || "#FFFFFF";
|
|
state.useDefaultPrompts = data.useDefaultPrompts || false;
|
|
if (threeScene) {
|
|
threeScene.syncFromState();
|
|
threeScene.setCameraView(data.cameraView || false);
|
|
}
|
|
updateDisplay();
|
|
} else if (data.type === 'UPDATE_IMAGE') {
|
|
state.imageUrl = data.imageUrl;
|
|
if (threeScene) {
|
|
threeScene.updateImage(data.imageUrl);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Initialize
|
|
initThreeJS();
|
|
// updateDisplay() will be called after VIEWER_READY is sent
|
|
// to ensure threeScene is ready
|
|
|
|
// Notify parent that we're ready
|
|
window.parent.postMessage({ type: 'VIEWER_READY' }, '*');
|
|
|
|
// Update display after threeScene is ready
|
|
updateDisplay();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`;
|