4843 lines
187 KiB
JavaScript
4843 lines
187 KiB
JavaScript
// Xiong Gaussian Viewer Editor with 3D Gizmo
|
||
// Smooth gizmo interaction - preview transform visually, apply on release
|
||
|
||
// Import unified coordinate transform module
|
||
// Note: coordinate-transform.js must be loaded before this file
|
||
const CT = window.CoordinateTransform;
|
||
|
||
const SPLAT = window.GSPLAT;
|
||
|
||
const viewerContainer = document.getElementById('viewerContainer');
|
||
const canvas = document.getElementById('canvas');
|
||
const selectionCanvas = document.getElementById('selectionCanvas');
|
||
const gizmoCanvas = document.getElementById('gizmoCanvas');
|
||
const selCtx = selectionCanvas.getContext('2d');
|
||
const gizmoCtx = gizmoCanvas.getContext('2d');
|
||
const errorEl = document.getElementById('error');
|
||
const statusText = document.getElementById('statusText');
|
||
const confirmBtn = document.getElementById('confirmBtn');
|
||
const cancelBtn = document.getElementById('cancelBtn');
|
||
const resetBtn = document.getElementById('resetCamera');
|
||
const scaleSlider = document.getElementById('gaussianScaleSlider');
|
||
const scaleInput = document.getElementById('gaussianScaleValue');
|
||
const focalLengthSlider = document.getElementById('focalLengthSlider');
|
||
const focalLengthValue = document.getElementById('focalLengthValue');
|
||
const depthRangeSlider = document.getElementById('depthRangeSlider');
|
||
const depthRangeValue = document.getElementById('depthRangeValue');
|
||
const rollSlider = document.getElementById('rollSlider');
|
||
const rollInput = document.getElementById('rollInput');
|
||
const orbitCenterXInput = document.getElementById('orbitCenterX');
|
||
const orbitCenterYInput = document.getElementById('orbitCenterY');
|
||
const orbitCenterZInput = document.getElementById('orbitCenterZ');
|
||
const useCurrentOrbitCenterBtn = document.getElementById('useCurrentOrbitCenter');
|
||
const applyOrbitCenterBtn = document.getElementById('applyOrbitCenter');
|
||
const clearOrbitCenterBtn = document.getElementById('clearOrbitCenter');
|
||
const pickOrbitCenterBtn = document.getElementById('pickOrbitCenter');
|
||
const deleteBtn = document.getElementById('deleteBtn');
|
||
const invertBtn = document.getElementById('invertBtn');
|
||
const clearSelBtn = document.getElementById('clearSelBtn');
|
||
const renderFrame = document.getElementById('renderFrame');
|
||
// 鐩告満鎺у埗鍏冪礌
|
||
const cameraPosDisplay = document.getElementById('cameraPosDisplay');
|
||
const cursorPosDisplay = document.getElementById('cursorPosDisplay');
|
||
const renderFrameLabel = document.getElementById('renderFrameLabel');
|
||
// 姣斾緥閫夋嫨涓嬫媺鑿滃崟鍏冪礌
|
||
const aspectBtn = document.getElementById('aspectBtn');
|
||
const aspectMenu = document.getElementById('aspectMenu');
|
||
const aspectOptions = document.querySelectorAll('.aspect-option');
|
||
|
||
let scene = null, camera = null, renderer = null, controls = null;
|
||
let currentSplat = null, originalScales = null, originalPositions = null, originalColors = null, originalRotations = null, originalOpacities = null;
|
||
let gaussianScaleCompensation = 1.0, currentScale = 0.3;
|
||
let nodeId = null, viewerParams = {};
|
||
let initialCameraData = null;
|
||
let currentOrbitTarget = null; // 璺熻釜 OrbitControls 褰撳墠瀹為檯 orbit 涓績锛屼笌 syncCameraToViewer 淇濇寔涓€鑷?
|
||
let outputWidth = 1024, outputHeight = 576; // 榛樿16:9
|
||
let originalOutputWidth = 1024, originalOutputHeight = 576; // 鍘熷杈撳叆灏哄
|
||
let backgroundColor = 'black';
|
||
let initialFocalLength = 22; // 鍒濆鐒﹁窛鍊硷紙mm锛?
|
||
let initialDepthRange = 10000; // 鍒濆娣卞害鑼冨洿
|
||
const CAMERA_FAR_ALL = 1e9;
|
||
let currentAspectRatio = 'original'; // 褰撳墠姣斾緥
|
||
const TARGET_ORIGIN =
|
||
window.location.origin && window.location.origin !== "null"
|
||
? window.location.origin
|
||
: "*";
|
||
|
||
initialFocalLength = 16;
|
||
|
||
// Editor state
|
||
let currentTool = 'orbit';
|
||
let selectedIndices = new Set();
|
||
let isSelecting = false;
|
||
let selectionStart = { x: 0, y: 0 };
|
||
let lassoPoints = [];
|
||
|
||
// Gizmo state
|
||
let gizmoCenter = { x: 0, y: 0, z: 0 }; // World position (center of selected points)
|
||
let gizmoScreenPos = { x: 0, y: 0 };
|
||
let activeAxis = null;
|
||
let hoverAxis = null;
|
||
let isDraggingGizmo = false;
|
||
let dragStartPos = { x: 0, y: 0 };
|
||
let dragCurrentPos = { x: 0, y: 0 };
|
||
|
||
// Preview transform (visual only during drag)
|
||
let previewTranslation = { x: 0, y: 0, z: 0 };
|
||
let previewRotationAngle = 0;
|
||
|
||
// Transform base data
|
||
let transformBasePositions = null;
|
||
let transformBaseRotations = null;
|
||
|
||
// Custom orbit state
|
||
let customOrbitEnabled = false;
|
||
let customOrbitDragging = false;
|
||
let customOrbitLastX = 0, customOrbitLastY = 0;
|
||
let customOrbitButton = 0;
|
||
let orbitTarget = { x: 0, y: 0, z: 0 };
|
||
let orbitDistance = 5;
|
||
let orbitYaw = 0, orbitPitch = 0;
|
||
let cameraOffset = { x: 0, y: 0, z: 0 };
|
||
let pickedOrbitDragActive = false;
|
||
let pickedOrbitDragMoved = false;
|
||
let pickedOrbitPivot = null;
|
||
let pickedOrbitLastX = 0;
|
||
let pickedOrbitLastY = 0;
|
||
let pickedOrbitViewDistance = 5;
|
||
let pickedOrbitControlsWasEnabled = false;
|
||
let controlsRightPanDragging = false;
|
||
let controlsRightPanSyncFrames = 0;
|
||
let controlsRightPanStartCameraPos = null;
|
||
let controlsRightPanStartCenter = null;
|
||
let controlsRightPanNeedsFinalize = false;
|
||
let centerPickMode = false;
|
||
let orbitCenterFeedback = null;
|
||
const DEFAULT_INIT_CAMERA_DISTANCE = 6.0;
|
||
const MIN_INIT_CAMERA_DISTANCE = 0.75;
|
||
const INITIAL_CAMERA_DISTANCE_MARGIN = 1.08;
|
||
const DEFAULT_GAUSSIAN_SCALE = 0.3;
|
||
const DEFAULT_CAMERA_DISTANCE = 5;
|
||
const MIN_CONTINUOUS_ZOOM_DISTANCE = 1e-6;
|
||
const ORBIT_CENTER_FEEDBACK_DURATION_MS = 1000;
|
||
const CAMERA_UI_UPDATE_INTERVAL_MS = 80;
|
||
const CAMERA_PANEL_SYNC_INTERVAL_MS = 120;
|
||
|
||
// Gizmo visual settings
|
||
const GIZMO_SIZE = 100;
|
||
const AXIS_COLORS = { x: '#ff4444', y: '#44ff44', z: '#4444ff', view: 'rgba(255,255,255,0.6)' };
|
||
const AXIS_COLORS_BRIGHT = { x: '#ff8888', y: '#88ff88', z: '#8888ff', view: '#ffffff' };
|
||
|
||
function isTrustedParentMessage(event) {
|
||
if (!event || event.source !== window.parent) return false;
|
||
if (TARGET_ORIGIN === "*") {
|
||
return event.origin === "null" || event.origin === "";
|
||
}
|
||
return event.origin === TARGET_ORIGIN;
|
||
}
|
||
|
||
function formatSignatureNumber(value, digits = 3) {
|
||
const num = Number(value);
|
||
return Number.isFinite(num) ? num.toFixed(digits) : '0';
|
||
}
|
||
|
||
function getCenterSignature(center) {
|
||
if (!center) return 'null';
|
||
return [
|
||
formatSignatureNumber(center.x),
|
||
formatSignatureNumber(center.y),
|
||
formatSignatureNumber(center.z),
|
||
].join(',');
|
||
}
|
||
|
||
function getCameraDisplaySignature(params) {
|
||
if (!params) return 'null';
|
||
const orbitCenter = getOrbitCenter() || getActiveOrbitCenter();
|
||
return [
|
||
Math.round(Number(params.azimuth) || 0),
|
||
Math.round(Number(params.elevation) || 0),
|
||
formatSignatureNumber(params.distance, 2),
|
||
Math.round(Number(params.roll) || 0),
|
||
getCenterSignature(orbitCenter),
|
||
].join('|');
|
||
}
|
||
|
||
// ============== Camera Parameter Labels ==============
|
||
// 鏂逛綅瑙掓槧灏?(Azimuth) - 椤烘椂閽堟柟鍚?
|
||
// 0° = front (姝i潰), 90° = right (鍙充晶), 180° = back (鑳岄潰), 270° = left (宸︿晶)
|
||
const AZIMUTH_LABELS = [
|
||
{ min: 0, max: 22.5, label: 'front view' }, // 姝i潰
|
||
{ min: 337.5, max: 360, label: 'front view' }, // 姝i潰
|
||
{ min: 22.5, max: 67.5, label: 'front right side view' }, // 鍙冲墠渚?
|
||
{ min: 67.5, max: 112.5, label: 'right side view' }, // 鍙充晶
|
||
{ min: 112.5, max: 157.5, label: 'back right side view' }, // 鍙冲悗渚?
|
||
{ min: 157.5, max: 202.5, label: 'back view' }, // 鑳岄潰
|
||
{ min: 202.5, max: 247.5, label: 'back left side view' }, // 宸﹀悗渚?
|
||
{ min: 247.5, max: 292.5, label: 'left side view' }, // 宸︿晶
|
||
{ min: 292.5, max: 337.5, label: 'front left side view' } // 宸﹀墠渚?
|
||
];
|
||
|
||
// 浠拌鏄犲皠 (Elevation)
|
||
// 姝h搴?=淇锛堜粠涓婂線涓嬬湅锛夛紝璐熻搴?=浠拌锛堜粠涓嬪線涓婄湅锛?
|
||
const ELEVATION_LABELS = [
|
||
{ min: -30, max: -15, label: 'low angle' }, // 浠拌锛堜粠涓嬪線涓婄湅锛?
|
||
{ min: -15, max: 15, label: 'eye level' }, // 骞宠锛堢溂鐫涢珮搴︼級
|
||
{ min: 15, max: 45, label: 'high angle' }, // 淇锛堢暐寰悜涓嬶級
|
||
{ min: 45, max: 75, label: 'very high angle' }, // 楂樿搴︿刊瑙?
|
||
{ min: 75, max: 91, label: "bird's-eye view" } // 楦熺灠锛堜粠涓婂線涓嬬湅锛?
|
||
];
|
||
|
||
// 缂╂斁/璺濈鏄犲皠 (Zoom)
|
||
const ZOOM_LABELS = [
|
||
{ min: 0, max: 2, label: 'wide shot' },
|
||
{ min: 2, max: 4, label: 'medium-wide shot' },
|
||
{ min: 4, max: 6, label: 'medium shot' },
|
||
{ min: 6, max: 8, label: 'medium-close shot' },
|
||
{ min: 8, max: 10, label: 'close-up shot' }
|
||
];
|
||
|
||
// Camera parameter state
|
||
let cameraParams = {
|
||
azimuth: 0,
|
||
elevation: 0,
|
||
distance: 5,
|
||
roll: 0,
|
||
targetCenter: { x: 0, y: 0, z: 0 },
|
||
orbitCenter: null,
|
||
customOrbitCenter: null,
|
||
hasCache: false,
|
||
};
|
||
const CAMERA_ROLL_DAMPING = 0.18;
|
||
const CAMERA_ROLL_SNAP_EPSILON = 0.02;
|
||
let currentRenderedCameraRoll = 0;
|
||
let targetRenderedCameraRoll = 0;
|
||
let lastCameraPositionForParams = null;
|
||
let lastCameraUiSignature = '';
|
||
let lastCameraUiUpdateAt = 0;
|
||
let lastCameraPanelSignature = '';
|
||
let lastCameraPanelSyncAt = 0;
|
||
|
||
function forceRefreshCameraUi(params = null) {
|
||
const resolvedParams = params || calculateCameraParams();
|
||
if (!resolvedParams) return;
|
||
updateCameraPosDisplay(resolvedParams);
|
||
updateCameraParamsDisplay(resolvedParams);
|
||
lastCameraUiSignature = getCameraDisplaySignature(resolvedParams);
|
||
lastCameraUiUpdateAt = performance.now();
|
||
}
|
||
|
||
function maybeRefreshCameraUi(params = null, now = performance.now()) {
|
||
const resolvedParams = params || calculateCameraParams();
|
||
if (!resolvedParams) return;
|
||
const signature = getCameraDisplaySignature(resolvedParams);
|
||
if (signature === lastCameraUiSignature) return;
|
||
if ((now - lastCameraUiUpdateAt) < CAMERA_UI_UPDATE_INTERVAL_MS) return;
|
||
updateCameraPosDisplay(resolvedParams);
|
||
updateCameraParamsDisplay(resolvedParams);
|
||
lastCameraUiSignature = signature;
|
||
lastCameraUiUpdateAt = now;
|
||
}
|
||
|
||
function forceSyncViewerCameraPanel(params = null) {
|
||
const resolvedParams = params || calculateCameraParams();
|
||
if (!resolvedParams) return;
|
||
syncViewerToCameraPanel(resolvedParams);
|
||
lastCameraPanelSignature = getCameraDisplaySignature(resolvedParams);
|
||
lastCameraPanelSyncAt = performance.now();
|
||
}
|
||
|
||
function maybeSyncViewerCameraPanel(params = null, now = performance.now()) {
|
||
const resolvedParams = params || calculateCameraParams();
|
||
if (!resolvedParams) return;
|
||
const signature = getCameraDisplaySignature(resolvedParams);
|
||
if (signature === lastCameraPanelSignature) return;
|
||
if ((now - lastCameraPanelSyncAt) < CAMERA_PANEL_SYNC_INTERVAL_MS) return;
|
||
syncViewerToCameraPanel(resolvedParams);
|
||
lastCameraPanelSignature = signature;
|
||
lastCameraPanelSyncAt = now;
|
||
}
|
||
|
||
// Camera history list (current session)
|
||
let cameraHistory = [];
|
||
let pendingStartupHistoryEntry = null;
|
||
const HISTORY_MAX_ITEMS = 10;
|
||
const AUTO_ORBIT_CENTER_SAMPLE_LIMIT = 30000;
|
||
const AUTO_ROTATE_ORBIT_CENTER_SAMPLE_LIMIT = 12000;
|
||
const AUTO_ROTATE_ORBIT_CENTER_MAX_DISTANCE_PX = 24;
|
||
const ORBIT_CONTROLS_ORBIT_SPEED = 1.8;
|
||
const ORBIT_CONTROLS_PAN_SPEED = 14.4;
|
||
const ORBIT_CONTROLS_ZOOM_SPEED = 1;
|
||
const CUSTOM_ORBIT_PAN_DISTANCE_FACTOR = 0.016;
|
||
const KEYBOARD_CAMERA_MOVE_DISTANCE_FACTOR = 0.005;
|
||
const KEYBOARD_CAMERA_MOVE_MIN_STEP = 0.01;
|
||
const KEYBOARD_CAMERA_ROTATE_STEP_DEG = 0.6;
|
||
const KEYBOARD_CAMERA_FINE_MULTIPLIER = 0.1;
|
||
const KEYBOARD_CAMERA_KEY_CODES = new Set([
|
||
'KeyW',
|
||
'KeyA',
|
||
'KeyS',
|
||
'KeyD',
|
||
'ArrowUp',
|
||
'ArrowDown',
|
||
'ArrowLeft',
|
||
'ArrowRight',
|
||
'KeyQ',
|
||
'KeyE',
|
||
'KeyR',
|
||
'KeyF',
|
||
'ShiftLeft',
|
||
'ShiftRight',
|
||
]);
|
||
let pressedKeyboardCameraKeys = Object.create(null);
|
||
|
||
// 铏氭嫙濮挎€佺悆 - 绱Н榧犳爣鏃嬭浆瑙掑害
|
||
let virtualOrbitBall = {
|
||
yaw: 0, // 姘村钩鏃嬭浆瑙掑害锛堝姬搴︼級锛屽乏璐熷彸姝?
|
||
pitch: 0, // 鍨傜洿鏃嬭浆瑙掑害锛堝姬搴︼級锛屼笂姝d笅璐?
|
||
lastMouseX: 0,
|
||
lastMouseY: 0,
|
||
isDragging: false,
|
||
initialYaw: 0, // 鍒濆鍋忕Щ锛堢敤浜庨噸缃級
|
||
initialPitch: 0
|
||
};
|
||
|
||
|
||
// ============== Initialization ==============
|
||
|
||
function getBackgroundRGB() {
|
||
switch (backgroundColor) {
|
||
case 'white': return { r: 255, g: 255, b: 255 };
|
||
case 'gray': return { r: 128, g: 128, b: 128 };
|
||
default: return { r: 0, g: 0, b: 0 };
|
||
}
|
||
}
|
||
|
||
function updateBackgroundColor() {
|
||
const bg = getBackgroundRGB();
|
||
canvas.style.backgroundColor = `rgb(${bg.r}, ${bg.g}, ${bg.b})`;
|
||
document.body.style.backgroundColor = `rgb(${bg.r}, ${bg.g}, ${bg.b})`;
|
||
}
|
||
|
||
function updateRenderFrame() {
|
||
if (!renderFrame) return;
|
||
const canvasW = canvas.clientWidth, canvasH = canvas.clientHeight;
|
||
if (outputWidth <= 0 || outputHeight <= 0 || canvasW <= 0 || canvasH <= 0) {
|
||
renderFrame.style.display = 'none';
|
||
return;
|
||
}
|
||
const outputAspect = outputWidth / outputHeight;
|
||
const canvasAspect = canvasW / canvasH;
|
||
|
||
// 鍙栨櫙妗嗙缉灏忓埌80%锛屼袱杈规樉绀烘洿瀹界殑鑼冨洿
|
||
const scaleFactor = 0.8;
|
||
let frameW, frameH, frameX, frameY;
|
||
|
||
if (outputAspect > canvasAspect) {
|
||
frameW = canvasW * scaleFactor;
|
||
frameH = frameW / outputAspect;
|
||
frameX = (canvasW - frameW) / 2;
|
||
frameY = (canvasH - frameH) / 2;
|
||
} else {
|
||
frameH = canvasH * scaleFactor;
|
||
frameW = frameH * outputAspect;
|
||
frameX = (canvasW - frameW) / 2;
|
||
frameY = (canvasH - frameH) / 2;
|
||
}
|
||
|
||
renderFrame.style.display = 'block';
|
||
renderFrame.style.left = frameX + 'px';
|
||
renderFrame.style.top = frameY + 'px';
|
||
renderFrame.style.width = frameW + 'px';
|
||
renderFrame.style.height = frameH + 'px';
|
||
if (renderFrameLabel) renderFrameLabel.textContent = `输出: ${outputWidth}×${outputHeight}`;
|
||
}
|
||
|
||
function setCameraClipRange(nearVal, farVal) {
|
||
if (!camera) return;
|
||
const near = Number(nearVal);
|
||
const far = Number(farVal);
|
||
if (Number.isFinite(near)) {
|
||
camera.near = near;
|
||
if (camera.data) camera.data.near = near;
|
||
}
|
||
if (Number.isFinite(far)) {
|
||
camera.far = far;
|
||
if (camera.data) camera.data.far = far;
|
||
}
|
||
if (typeof camera.update === 'function') {
|
||
camera.update();
|
||
}
|
||
drawOrbitCenterFeedback();
|
||
}
|
||
|
||
function cloneCenter(center) {
|
||
if (!center) return null;
|
||
const x = Number(center.x);
|
||
const y = Number(center.y);
|
||
const z = Number(center.z);
|
||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null;
|
||
return { x, y, z };
|
||
}
|
||
|
||
function getFiniteCameraNumber(value, fallback) {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? numeric : fallback;
|
||
}
|
||
|
||
function getCachedCameraOrbitCenter(cameraState) {
|
||
return cloneCenter(cameraState?.orbitCenter) || cloneCenter(cameraState?.target);
|
||
}
|
||
|
||
function rememberCurrentCameraPositionForParams() {
|
||
if (!camera) {
|
||
lastCameraPositionForParams = null;
|
||
return;
|
||
}
|
||
|
||
lastCameraPositionForParams = {
|
||
x: camera.position.x,
|
||
y: camera.position.y,
|
||
z: camera.position.z,
|
||
};
|
||
}
|
||
|
||
function getCenterDistance(a, b) {
|
||
const ca = cloneCenter(a);
|
||
const cb = cloneCenter(b);
|
||
if (!ca || !cb) return NaN;
|
||
const dx = ca.x - cb.x;
|
||
const dy = ca.y - cb.y;
|
||
const dz = ca.z - cb.z;
|
||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||
}
|
||
|
||
function getCameraViewForwardVector() {
|
||
if (!camera?.rotation) return null;
|
||
const forward = camera.rotation.apply(new SPLAT.Vector3(0, 0, 1));
|
||
const length = Math.sqrt(
|
||
forward.x * forward.x +
|
||
forward.y * forward.y +
|
||
forward.z * forward.z
|
||
);
|
||
if (!Number.isFinite(length) || length < 1e-8) return null;
|
||
return new SPLAT.Vector3(forward.x / length, forward.y / length, forward.z / length);
|
||
}
|
||
|
||
function getCurrentViewTarget(referenceDistance = null) {
|
||
if (!camera) return null;
|
||
const forward = getCameraViewForwardVector();
|
||
if (!forward) return null;
|
||
const fallbackDistance = getCenterDistance(
|
||
{ x: camera.position.x, y: camera.position.y, z: camera.position.z },
|
||
cloneCenter(cameraParams.targetCenter)
|
||
|| cloneCenter(currentOrbitTarget)
|
||
|| cloneCenter(cameraParams.orbitCenter)
|
||
|| getDefaultOrbitCenter()
|
||
);
|
||
const targetDistance = Number.isFinite(Number(referenceDistance)) && Number(referenceDistance) > 1e-4
|
||
? Number(referenceDistance)
|
||
: (Number.isFinite(fallbackDistance) && fallbackDistance > 1e-4
|
||
? fallbackDistance
|
||
: Math.max(0.001, Number(cameraParams.distance) || DEFAULT_CAMERA_DISTANCE));
|
||
const next = {
|
||
x: camera.position.x + forward.x * targetDistance,
|
||
y: camera.position.y + forward.y * targetDistance,
|
||
z: camera.position.z + forward.z * targetDistance,
|
||
};
|
||
return next;
|
||
}
|
||
|
||
function normalizePositiveAngle(angle) {
|
||
const numeric = Number(angle);
|
||
if (!Number.isFinite(numeric)) return 0;
|
||
let normalized = numeric % 360;
|
||
if (normalized < 0) normalized += 360;
|
||
return normalized;
|
||
}
|
||
|
||
function clampNumber(value, min, max) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return min;
|
||
return Math.max(min, Math.min(max, numeric));
|
||
}
|
||
|
||
function clampCameraRoll(value) {
|
||
return clampNumber(value, -90, 90);
|
||
}
|
||
|
||
function getTargetCameraRoll() {
|
||
return clampCameraRoll(cameraParams.roll);
|
||
}
|
||
|
||
function getRenderedCameraRoll() {
|
||
return clampCameraRoll(currentRenderedCameraRoll);
|
||
}
|
||
|
||
function setCameraRollValue(value, options = null) {
|
||
const nextRoll = clampCameraRoll(value);
|
||
cameraParams.roll = nextRoll;
|
||
targetRenderedCameraRoll = nextRoll;
|
||
if (options?.immediate || !Number.isFinite(currentRenderedCameraRoll)) {
|
||
currentRenderedCameraRoll = nextRoll;
|
||
}
|
||
return nextRoll;
|
||
}
|
||
|
||
function updateRenderedCameraRoll() {
|
||
const targetRoll = clampCameraRoll(targetRenderedCameraRoll);
|
||
if (!Number.isFinite(currentRenderedCameraRoll)) {
|
||
currentRenderedCameraRoll = targetRoll;
|
||
return false;
|
||
}
|
||
|
||
const delta = targetRoll - currentRenderedCameraRoll;
|
||
if (Math.abs(delta) <= CAMERA_ROLL_SNAP_EPSILON) {
|
||
const changed = Math.abs(delta) > 1e-6;
|
||
currentRenderedCameraRoll = targetRoll;
|
||
return changed;
|
||
}
|
||
|
||
currentRenderedCameraRoll += delta * CAMERA_ROLL_DAMPING;
|
||
return true;
|
||
}
|
||
|
||
function buildCameraViewQuaternion(target, rollDeg = getRenderedCameraRoll()) {
|
||
if (!camera) return null;
|
||
const center = cloneCenter(target) || getActiveOrbitCenter();
|
||
if (!center) return null;
|
||
|
||
const dx = center.x - camera.position.x;
|
||
const dy = center.y - camera.position.y;
|
||
const dz = center.z - camera.position.z;
|
||
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||
if (!Number.isFinite(len) || len < 1e-6) return null;
|
||
|
||
const baseQuat = SPLAT.Quaternion.FromEuler(
|
||
new SPLAT.Vector3(
|
||
Math.asin(-dy / len),
|
||
Math.atan2(dx / len, dz / len),
|
||
0
|
||
)
|
||
);
|
||
|
||
const safeRoll = clampNumber(rollDeg, -90, 90);
|
||
if (Math.abs(safeRoll) < 0.01) {
|
||
return baseQuat;
|
||
}
|
||
|
||
const axis = new SPLAT.Vector3(dx / len, dy / len, dz / len);
|
||
const rollQuat = SPLAT.Quaternion.FromAxisAngle(axis, CT.degToRad(safeRoll));
|
||
return rollQuat.multiply(baseQuat);
|
||
}
|
||
|
||
function getCameraViewAxes(target, rollDeg = getRenderedCameraRoll()) {
|
||
const quat = buildCameraViewQuaternion(target, rollDeg);
|
||
if (!quat) return null;
|
||
return {
|
||
right: quat.apply(new SPLAT.Vector3(1, 0, 0)),
|
||
up: quat.apply(new SPLAT.Vector3(0, 1, 0)),
|
||
forward: quat.apply(new SPLAT.Vector3(0, 0, 1)),
|
||
};
|
||
}
|
||
|
||
function isTextEntryElement(element) {
|
||
if (!element) return false;
|
||
if (element.isContentEditable) return true;
|
||
const tagName = String(element.tagName || '').toUpperCase();
|
||
return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
||
}
|
||
|
||
function hasViewerKeyboardFocus() {
|
||
return document.activeElement === canvas;
|
||
}
|
||
|
||
function focusViewerCanvas() {
|
||
if (!canvas || hasViewerKeyboardFocus()) return;
|
||
try {
|
||
canvas.focus({ preventScroll: true });
|
||
} catch (error) {
|
||
canvas.focus();
|
||
}
|
||
}
|
||
|
||
function clearKeyboardCameraKeys() {
|
||
pressedKeyboardCameraKeys = Object.create(null);
|
||
syncOrbitControlPrecision();
|
||
}
|
||
|
||
function setKeyboardCameraKeyState(code, isPressed) {
|
||
if (!KEYBOARD_CAMERA_KEY_CODES.has(code)) return;
|
||
pressedKeyboardCameraKeys[code] = isPressed;
|
||
}
|
||
|
||
function isKeyboardCameraKey(code) {
|
||
return KEYBOARD_CAMERA_KEY_CODES.has(code);
|
||
}
|
||
|
||
function isKeyboardCameraCodePressed(code) {
|
||
if (code === 'KeyW') return Boolean(pressedKeyboardCameraKeys.KeyW || pressedKeyboardCameraKeys.ArrowUp);
|
||
if (code === 'KeyS') return Boolean(pressedKeyboardCameraKeys.KeyS || pressedKeyboardCameraKeys.ArrowDown);
|
||
if (code === 'KeyA') return Boolean(pressedKeyboardCameraKeys.KeyA || pressedKeyboardCameraKeys.ArrowLeft);
|
||
if (code === 'KeyD') return Boolean(pressedKeyboardCameraKeys.KeyD || pressedKeyboardCameraKeys.ArrowRight);
|
||
return Boolean(pressedKeyboardCameraKeys[code]);
|
||
}
|
||
|
||
function isPrecisionModifierPressed(event = null) {
|
||
return Boolean(
|
||
event?.shiftKey ||
|
||
isKeyboardCameraCodePressed('ShiftLeft') ||
|
||
isKeyboardCameraCodePressed('ShiftRight')
|
||
);
|
||
}
|
||
|
||
function syncOrbitControlPrecision(event = null) {
|
||
if (!controls) return;
|
||
const speedMultiplier = isPrecisionModifierPressed(event) ? KEYBOARD_CAMERA_FINE_MULTIPLIER : 1;
|
||
controls.zoomSpeed = ORBIT_CONTROLS_ZOOM_SPEED * speedMultiplier;
|
||
controls.orbitSpeed = ORBIT_CONTROLS_ORBIT_SPEED * speedMultiplier;
|
||
controls.panSpeed = ORBIT_CONTROLS_PAN_SPEED * speedMultiplier;
|
||
}
|
||
|
||
function shouldHandleKeyboardCameraControl() {
|
||
return Boolean(
|
||
camera &&
|
||
controls &&
|
||
!customOrbitEnabled &&
|
||
controls.enabled &&
|
||
hasViewerKeyboardFocus() &&
|
||
!centerPickMode &&
|
||
!isDraggingGizmo &&
|
||
!isSelecting &&
|
||
!pickedOrbitDragActive &&
|
||
!customOrbitDragging &&
|
||
!virtualOrbitBall.isDragging &&
|
||
!controlsRightPanDragging &&
|
||
controlsRightPanSyncFrames <= 0 &&
|
||
!controlsRightPanNeedsFinalize &&
|
||
(currentTool === 'orbit' || currentTool === 'pan')
|
||
);
|
||
}
|
||
|
||
function flushControlsToTarget(target) {
|
||
if (!controls) return;
|
||
controls.setCameraTarget(new SPLAT.Vector3(target.x, target.y, target.z));
|
||
const savedDampening = controls.dampening;
|
||
controls.dampening = 1;
|
||
controls.update();
|
||
controls.dampening = savedDampening;
|
||
}
|
||
|
||
function translateCameraAndTarget(delta, targetOverride = null) {
|
||
if (!camera || !controls) return null;
|
||
const baseTarget = cloneCenter(targetOverride)
|
||
|| cloneCenter(getActiveOrbitCenter())
|
||
|| getCurrentViewTarget(getSliderZoom())
|
||
|| getDefaultOrbitCenter();
|
||
if (!baseTarget) return null;
|
||
|
||
camera.position.x += delta.x;
|
||
camera.position.y += delta.y;
|
||
camera.position.z += delta.z;
|
||
|
||
const nextTarget = {
|
||
x: baseTarget.x + delta.x,
|
||
y: baseTarget.y + delta.y,
|
||
z: baseTarget.z + delta.z,
|
||
};
|
||
|
||
currentOrbitTarget = cloneCenter(nextTarget);
|
||
cameraParams.targetCenter = cloneCenter(nextTarget);
|
||
cameraParams.orbitCenter = cloneCenter(nextTarget);
|
||
cameraParams.customOrbitCenter = cloneCenter(nextTarget);
|
||
updateOrbitCenterInputs(nextTarget);
|
||
lastCameraPositionForParams = null;
|
||
flushControlsToTarget(nextTarget);
|
||
return nextTarget;
|
||
}
|
||
|
||
function rotateKeyboardControlledCamera(deltaAzimuth, deltaElevation, deltaRoll, targetOverride = null) {
|
||
const target = cloneCenter(targetOverride)
|
||
|| cloneCenter(getActiveOrbitCenter())
|
||
|| getCurrentViewTarget(getSliderZoom())
|
||
|| getDefaultOrbitCenter();
|
||
if (!target) return false;
|
||
|
||
const pose = computeCameraPoseFromView(target);
|
||
const currentAzimuth = Number.isFinite(Number(pose?.azimuth))
|
||
? Number(pose.azimuth)
|
||
: (Number(cameraParams.azimuth) || 0);
|
||
const currentElevation = Number.isFinite(Number(pose?.elevation))
|
||
? Number(pose.elevation)
|
||
: (Number(cameraParams.elevation) || 0);
|
||
const currentDistance = Number.isFinite(Number(pose?.distance)) && Number(pose.distance) > 1e-4
|
||
? Number(pose.distance)
|
||
: Math.max(0.001, Number(getSliderZoom()) || DEFAULT_CAMERA_DISTANCE);
|
||
|
||
const nextAzimuth = normalizePositiveAngle(currentAzimuth + deltaAzimuth);
|
||
const nextElevation = clampNumber(
|
||
currentElevation + deltaElevation,
|
||
Number.isFinite(Number(CT.ELEVATION_MIN)) ? Number(CT.ELEVATION_MIN) : -30,
|
||
Number.isFinite(Number(CT.ELEVATION_MAX)) ? Number(CT.ELEVATION_MAX) : 90
|
||
);
|
||
const nextRoll = clampNumber((Number(cameraParams.roll) || 0) + deltaRoll, -90, 90);
|
||
|
||
window.syncCameraToViewer(nextAzimuth, nextElevation, currentDistance, target, nextRoll);
|
||
return true;
|
||
}
|
||
|
||
function applyKeyboardCameraControl() {
|
||
if (!shouldHandleKeyboardCameraControl()) return false;
|
||
|
||
const shiftPressed = isKeyboardCameraCodePressed('ShiftLeft') || isKeyboardCameraCodePressed('ShiftRight');
|
||
const speedMultiplier = shiftPressed ? KEYBOARD_CAMERA_FINE_MULTIPLIER : 1;
|
||
let activeTarget = cloneCenter(getActiveOrbitCenter())
|
||
|| getCurrentViewTarget(getSliderZoom())
|
||
|| getDefaultOrbitCenter();
|
||
|
||
const axes = getCameraViewAxes(activeTarget);
|
||
if (axes) {
|
||
const distance = getCenterDistance(camera?.position, activeTarget);
|
||
const moveStep = Math.max(
|
||
(Number.isFinite(distance) ? distance : DEFAULT_CAMERA_DISTANCE) * KEYBOARD_CAMERA_MOVE_DISTANCE_FACTOR,
|
||
KEYBOARD_CAMERA_MOVE_MIN_STEP
|
||
) * speedMultiplier;
|
||
const moveDelta = { x: 0, y: 0, z: 0 };
|
||
|
||
if (isKeyboardCameraCodePressed('KeyW')) {
|
||
moveDelta.x += axes.forward.x * moveStep;
|
||
moveDelta.y += axes.forward.y * moveStep;
|
||
moveDelta.z += axes.forward.z * moveStep;
|
||
}
|
||
if (isKeyboardCameraCodePressed('KeyS')) {
|
||
moveDelta.x -= axes.forward.x * moveStep;
|
||
moveDelta.y -= axes.forward.y * moveStep;
|
||
moveDelta.z -= axes.forward.z * moveStep;
|
||
}
|
||
if (isKeyboardCameraCodePressed('KeyA')) {
|
||
moveDelta.x -= axes.right.x * moveStep;
|
||
moveDelta.y -= axes.right.y * moveStep;
|
||
moveDelta.z -= axes.right.z * moveStep;
|
||
}
|
||
if (isKeyboardCameraCodePressed('KeyD')) {
|
||
moveDelta.x += axes.right.x * moveStep;
|
||
moveDelta.y += axes.right.y * moveStep;
|
||
moveDelta.z += axes.right.z * moveStep;
|
||
}
|
||
if (isKeyboardCameraCodePressed('KeyQ')) {
|
||
moveDelta.y += moveStep;
|
||
}
|
||
if (isKeyboardCameraCodePressed('KeyE')) {
|
||
moveDelta.y -= moveStep;
|
||
}
|
||
|
||
if (Math.abs(moveDelta.x) > 1e-8 || Math.abs(moveDelta.y) > 1e-8 || Math.abs(moveDelta.z) > 1e-8) {
|
||
activeTarget = translateCameraAndTarget(moveDelta, activeTarget) || activeTarget;
|
||
}
|
||
}
|
||
|
||
let azimuthDelta = 0;
|
||
let elevationDelta = 0;
|
||
const rotateStep = KEYBOARD_CAMERA_ROTATE_STEP_DEG * speedMultiplier;
|
||
|
||
if (isKeyboardCameraCodePressed('KeyR')) elevationDelta += rotateStep;
|
||
if (isKeyboardCameraCodePressed('KeyF')) elevationDelta -= rotateStep;
|
||
|
||
if (azimuthDelta || elevationDelta) {
|
||
rotateKeyboardControlledCamera(azimuthDelta, elevationDelta, 0, activeTarget);
|
||
return true;
|
||
}
|
||
|
||
return Boolean(axes && (
|
||
isKeyboardCameraCodePressed('KeyW') ||
|
||
isKeyboardCameraCodePressed('KeyA') ||
|
||
isKeyboardCameraCodePressed('KeyS') ||
|
||
isKeyboardCameraCodePressed('KeyD') ||
|
||
isKeyboardCameraCodePressed('KeyQ') ||
|
||
isKeyboardCameraCodePressed('KeyE')
|
||
));
|
||
}
|
||
|
||
function updateKeyboardShortcutHints() {
|
||
const rectButton = document.querySelector('.tool-btn[data-tool="rect"]');
|
||
if (rectButton) {
|
||
rectButton.title = '矩形选择 (B)';
|
||
}
|
||
|
||
if (statusText) {
|
||
statusText.textContent = 'V:视角 H:平移 B:矩形 L:套索 G:移动 T:旋转 | WASD/方向键:移动 Q上移 E下移 R/F:俯仰 Shift:精细 | DEL:删除';
|
||
}
|
||
|
||
const usageGuide = document.querySelector('.usage-guide-text');
|
||
if (usageGuide) {
|
||
usageGuide.innerHTML = [
|
||
'左键拖动画面旋转,并自动把点击处设为旋转中心;右键拖动画面平移;滚轮前后移动视角。',
|
||
'点击左侧画面后,W/A/S/D 或方向键移动,Q 上移,E 下移,R/F 俯仰,Shift 为 0.1x 精细控制。',
|
||
'Pick 可手动指定旋转中心;右侧历史镜头支持保存、还原和删除;重置相机可回到默认视角。'
|
||
].join('<br>');
|
||
}
|
||
}
|
||
|
||
function syncControlsStateToCurrentView(referenceDistance = null) {
|
||
if (!controls) return null;
|
||
const next = getCurrentViewTarget(referenceDistance);
|
||
if (!next) return null;
|
||
cameraParams.targetCenter = cloneCenter(next);
|
||
controls.setCameraTarget(new SPLAT.Vector3(next.x, next.y, next.z));
|
||
const savedDampening = controls.dampening;
|
||
controls.dampening = 1;
|
||
controls.update();
|
||
controls.dampening = savedDampening;
|
||
return next;
|
||
}
|
||
|
||
function syncOrbitTargetToCurrentView(referenceDistance = null, updateInputs = false) {
|
||
const next = syncControlsStateToCurrentView(referenceDistance);
|
||
if (!next) return null;
|
||
currentOrbitTarget = cloneCenter(next);
|
||
cameraParams.orbitCenter = cloneCenter(next);
|
||
cameraParams.customOrbitCenter = null;
|
||
if (updateInputs) {
|
||
updateOrbitCenterInputs(next);
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function resetCachedCameraParams() {
|
||
cameraParams.azimuth = 0;
|
||
cameraParams.elevation = 0;
|
||
cameraParams.distance = DEFAULT_CAMERA_DISTANCE;
|
||
setCameraRollValue(0, { immediate: true });
|
||
cameraParams.targetCenter = { x: 0, y: 0, z: 0 };
|
||
cameraParams.orbitCenter = null;
|
||
cameraParams.customOrbitCenter = null;
|
||
cameraParams.hasCache = false;
|
||
}
|
||
|
||
function applyCachedCameraState(cameraState) {
|
||
if (!cameraState || typeof cameraState !== 'object') {
|
||
resetCachedCameraParams();
|
||
return false;
|
||
}
|
||
|
||
applyAspectState(cameraState.aspectRatio, cameraState.outputWidth, cameraState.outputHeight);
|
||
|
||
const orbitCenter = getCachedCameraOrbitCenter(cameraState);
|
||
cameraParams.azimuth = getFiniteCameraNumber(cameraState.azimuth, 0);
|
||
cameraParams.elevation = getFiniteCameraNumber(cameraState.elevation, 0);
|
||
cameraParams.distance = getFiniteCameraNumber(cameraState.distance, DEFAULT_CAMERA_DISTANCE);
|
||
setCameraRollValue(getFiniteCameraNumber(cameraState.roll, 0), { immediate: true });
|
||
cameraParams.targetCenter = cloneCenter(orbitCenter) || { x: 0, y: 0, z: 0 };
|
||
cameraParams.orbitCenter = cloneCenter(orbitCenter);
|
||
cameraParams.customOrbitCenter = cloneCenter(orbitCenter);
|
||
cameraParams.hasCache = true;
|
||
return true;
|
||
}
|
||
|
||
function getConfiguredOrbitCenter() {
|
||
return cloneCenter(cameraParams.customOrbitCenter) || cloneCenter(cameraParams.orbitCenter);
|
||
}
|
||
|
||
function getDefaultOrbitCenter() {
|
||
if (currentOrbitTarget) return cloneCenter(currentOrbitTarget);
|
||
if (initialCameraData?.target) {
|
||
return {
|
||
x: Number(initialCameraData.target.x) || 0,
|
||
y: Number(initialCameraData.target.y) || 0,
|
||
z: Number(initialCameraData.target.z) || 0,
|
||
};
|
||
}
|
||
return { x: 0, y: 0, z: 0 };
|
||
}
|
||
|
||
function getPickedOrbitCenter() {
|
||
return pickedOrbitDragActive ? cloneCenter(pickedOrbitPivot) : null;
|
||
}
|
||
|
||
function getActiveOrbitCenter() {
|
||
return getPickedOrbitCenter() || getConfiguredOrbitCenter() || getDefaultOrbitCenter();
|
||
}
|
||
|
||
function updateOrbitCenterInputs(center = null) {
|
||
const c = cloneCenter(center) || getActiveOrbitCenter();
|
||
if (!c) return;
|
||
const setIfIdle = (el, value) => {
|
||
if (!el) return;
|
||
if (document.activeElement === el) return;
|
||
el.value = Number(value).toFixed(3);
|
||
};
|
||
setIfIdle(orbitCenterXInput, c.x);
|
||
setIfIdle(orbitCenterYInput, c.y);
|
||
setIfIdle(orbitCenterZInput, c.z);
|
||
}
|
||
|
||
function showOrbitCenterFeedback(center) {
|
||
const markerCenter = cloneCenter(center);
|
||
if (!markerCenter) return;
|
||
orbitCenterFeedback = {
|
||
center: markerCenter,
|
||
expiresAt: performance.now() + ORBIT_CENTER_FEEDBACK_DURATION_MS,
|
||
};
|
||
}
|
||
|
||
function hasActiveOrbitCenterFeedback(now = performance.now()) {
|
||
if (!orbitCenterFeedback) return false;
|
||
if (now >= orbitCenterFeedback.expiresAt) {
|
||
orbitCenterFeedback = null;
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function readOrbitCenterInputs() {
|
||
if (!orbitCenterXInput || !orbitCenterYInput || !orbitCenterZInput) return null;
|
||
const x = Number(orbitCenterXInput.value);
|
||
const y = Number(orbitCenterYInput.value);
|
||
const z = Number(orbitCenterZInput.value);
|
||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null;
|
||
return { x, y, z };
|
||
}
|
||
|
||
function applyOrbitCenter(nextCenter, options = {}) {
|
||
if (!camera || !controls) return;
|
||
const next = cloneCenter(nextCenter);
|
||
if (!next) return;
|
||
const { persistAsCustom = true, keepAngles = true, showFeedback = false } = options;
|
||
const previousTarget = cloneCenter(cameraParams.targetCenter)
|
||
|| cloneCenter(currentOrbitTarget)
|
||
|| cloneCenter(cameraParams.orbitCenter)
|
||
|| getDefaultOrbitCenter();
|
||
const paramsBefore = calculateCameraParams();
|
||
|
||
if (persistAsCustom) {
|
||
cameraParams.customOrbitCenter = cloneCenter(next);
|
||
}
|
||
cameraParams.orbitCenter = cloneCenter(next);
|
||
cameraParams.targetCenter = cloneCenter(next);
|
||
currentOrbitTarget = cloneCenter(next);
|
||
|
||
controls.setCameraTarget(new SPLAT.Vector3(next.x, next.y, next.z));
|
||
|
||
if (keepAngles && paramsBefore) {
|
||
// Recenter by translating camera with the same delta as target movement.
|
||
// This keeps composition stable and makes picked point move to screen center.
|
||
if (previousTarget) {
|
||
camera.position.x += (next.x - previousTarget.x);
|
||
camera.position.y += (next.y - previousTarget.y);
|
||
camera.position.z += (next.z - previousTarget.z);
|
||
} else {
|
||
const pos = CT.GSplatAdapter.toGSplatPosition(
|
||
paramsBefore.azimuth,
|
||
paramsBefore.elevation,
|
||
paramsBefore.distance,
|
||
next
|
||
);
|
||
camera.position.x = pos.x;
|
||
camera.position.y = pos.y;
|
||
camera.position.z = pos.z;
|
||
}
|
||
lastCameraPositionForParams = null;
|
||
}
|
||
|
||
const savedDampening = controls.dampening;
|
||
controls.dampening = 1;
|
||
controls.update();
|
||
controls.dampening = savedDampening;
|
||
updateOrbitCenterInputs(next);
|
||
if (showFeedback) {
|
||
showOrbitCenterFeedback(next);
|
||
}
|
||
}
|
||
|
||
function updateMainCanvasCursor() {
|
||
if (!canvas) return;
|
||
if (centerPickMode) {
|
||
canvas.style.cursor = 'crosshair';
|
||
return;
|
||
}
|
||
const cursors = { orbit: 'grab', pan: 'move', rect: 'crosshair', lasso: 'crosshair', translate: 'default', rotate: 'default' };
|
||
canvas.style.cursor = cursors[currentTool] || 'default';
|
||
}
|
||
|
||
function setCenterPickMode(enabled) {
|
||
centerPickMode = !!enabled;
|
||
if (pickOrbitCenterBtn) {
|
||
pickOrbitCenterBtn.style.background = centerPickMode ? '#4CAF50' : '';
|
||
pickOrbitCenterBtn.title = centerPickMode
|
||
? 'Pick mode: click a gaussian point to set center (ESC or right click to cancel)'
|
||
: 'Pick center: click this, then click a gaussian point';
|
||
}
|
||
updateMainCanvasCursor();
|
||
}
|
||
|
||
function beginControlsRightPanTracking() {
|
||
// Flush residual orbit inertia before right-pan starts.
|
||
// Without this, camera may still be rotating from prior left-drag,
|
||
// and center tracking can be polluted by non-pan movement.
|
||
if (controls) {
|
||
const savedDampening = controls.dampening;
|
||
controls.dampening = 1;
|
||
controls.update();
|
||
controls.dampening = savedDampening;
|
||
}
|
||
controlsRightPanStartCameraPos = camera
|
||
? { x: camera.position.x, y: camera.position.y, z: camera.position.z }
|
||
: null;
|
||
controlsRightPanStartCenter = cloneCenter(cameraParams.targetCenter)
|
||
|| cloneCenter(currentOrbitTarget)
|
||
|| cloneCenter(cameraParams.orbitCenter)
|
||
|| getDefaultOrbitCenter();
|
||
controlsRightPanNeedsFinalize = true;
|
||
controlsRightPanDragging = true;
|
||
controlsRightPanSyncFrames = 0;
|
||
}
|
||
|
||
function beginPickedOrbitDrag(pivot, clientX, clientY) {
|
||
if (!camera || !controls) return false;
|
||
const nextPivot = cloneCenter(pivot);
|
||
if (!nextPivot) return false;
|
||
|
||
const savedDampening = controls.dampening;
|
||
controls.dampening = 1;
|
||
controls.update();
|
||
controls.dampening = savedDampening;
|
||
|
||
const cameraPos = {
|
||
x: camera.position.x,
|
||
y: camera.position.y,
|
||
z: camera.position.z,
|
||
};
|
||
const baseViewDistance = getCenterDistance(
|
||
cameraPos,
|
||
cloneCenter(cameraParams.targetCenter)
|
||
|| cloneCenter(currentOrbitTarget)
|
||
|| cloneCenter(cameraParams.orbitCenter)
|
||
|| getDefaultOrbitCenter()
|
||
);
|
||
const pivotDistance = getCenterDistance(cameraPos, nextPivot);
|
||
|
||
pickedOrbitDragActive = true;
|
||
pickedOrbitDragMoved = false;
|
||
pickedOrbitPivot = nextPivot;
|
||
pickedOrbitLastX = clientX;
|
||
pickedOrbitLastY = clientY;
|
||
pickedOrbitViewDistance = Number.isFinite(baseViewDistance) && baseViewDistance > 1e-4
|
||
? baseViewDistance
|
||
: (Number.isFinite(pivotDistance) && pivotDistance > 1e-4
|
||
? pivotDistance
|
||
: DEFAULT_CAMERA_DISTANCE);
|
||
currentOrbitTarget = cloneCenter(nextPivot);
|
||
cameraParams.orbitCenter = cloneCenter(nextPivot);
|
||
cameraParams.customOrbitCenter = cloneCenter(nextPivot);
|
||
updateOrbitCenterInputs(nextPivot);
|
||
pickedOrbitControlsWasEnabled = !!controls.enabled;
|
||
controls.enabled = false;
|
||
showOrbitCenterFeedback(nextPivot);
|
||
if (canvas) {
|
||
canvas.style.cursor = 'grabbing';
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function applyWorldRotationAroundPivot(rotationQuat, pivot) {
|
||
if (!camera || !rotationQuat) return;
|
||
const center = cloneCenter(pivot);
|
||
if (!center) return;
|
||
const offset = new SPLAT.Vector3(
|
||
camera.position.x - center.x,
|
||
camera.position.y - center.y,
|
||
camera.position.z - center.z
|
||
);
|
||
const rotatedOffset = rotationQuat.apply(offset);
|
||
camera.position.x = center.x + rotatedOffset.x;
|
||
camera.position.y = center.y + rotatedOffset.y;
|
||
camera.position.z = center.z + rotatedOffset.z;
|
||
camera.rotation = rotationQuat.multiply(camera.rotation).normalize();
|
||
}
|
||
|
||
function rotateCameraAroundPickedPivot(pivot, dx, dy) {
|
||
if (!camera || !pivot) return false;
|
||
const speed = (controls?.orbitSpeed || ORBIT_CONTROLS_ORBIT_SPEED) * 0.003;
|
||
const yawDelta = -dx * speed;
|
||
const pitchDelta = dy * speed;
|
||
if (Math.abs(yawDelta) < 1e-8 && Math.abs(pitchDelta) < 1e-8) {
|
||
return false;
|
||
}
|
||
|
||
if (Math.abs(yawDelta) >= 1e-8) {
|
||
const upAxis = camera.rotation.apply(new SPLAT.Vector3(0, 1, 0)).normalize();
|
||
const yawQuat = SPLAT.Quaternion.FromAxisAngle(upAxis, yawDelta).normalize();
|
||
applyWorldRotationAroundPivot(yawQuat, pivot);
|
||
}
|
||
|
||
if (Math.abs(pitchDelta) >= 1e-8) {
|
||
const rightAxis = camera.rotation.apply(new SPLAT.Vector3(1, 0, 0)).normalize();
|
||
const pitchQuat = SPLAT.Quaternion.FromAxisAngle(rightAxis, pitchDelta).normalize();
|
||
applyWorldRotationAroundPivot(pitchQuat, pivot);
|
||
}
|
||
|
||
camera.update();
|
||
lastCameraPositionForParams = null;
|
||
currentOrbitTarget = cloneCenter(pivot);
|
||
cameraParams.orbitCenter = cloneCenter(pivot);
|
||
cameraParams.customOrbitCenter = cloneCenter(pivot);
|
||
syncControlsStateToCurrentView(pickedOrbitViewDistance);
|
||
return true;
|
||
}
|
||
|
||
function updatePickedOrbitDrag(clientX, clientY) {
|
||
if (!pickedOrbitDragActive || !pickedOrbitPivot) return false;
|
||
const dx = clientX - pickedOrbitLastX;
|
||
const dy = clientY - pickedOrbitLastY;
|
||
pickedOrbitLastX = clientX;
|
||
pickedOrbitLastY = clientY;
|
||
|
||
if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) {
|
||
return false;
|
||
}
|
||
|
||
const rotated = rotateCameraAroundPickedPivot(pickedOrbitPivot, dx, dy);
|
||
if (!rotated) return false;
|
||
|
||
pickedOrbitDragMoved = true;
|
||
return true;
|
||
}
|
||
|
||
function endPickedOrbitDrag() {
|
||
if (!pickedOrbitDragActive) return;
|
||
const committedPivot = cloneCenter(pickedOrbitPivot);
|
||
pickedOrbitDragActive = false;
|
||
pickedOrbitDragMoved = false;
|
||
pickedOrbitPivot = null;
|
||
|
||
if (controls) {
|
||
if (committedPivot) {
|
||
currentOrbitTarget = cloneCenter(committedPivot);
|
||
cameraParams.orbitCenter = cloneCenter(committedPivot);
|
||
cameraParams.customOrbitCenter = cloneCenter(committedPivot);
|
||
updateOrbitCenterInputs(committedPivot);
|
||
syncControlsStateToCurrentView(pickedOrbitViewDistance);
|
||
}
|
||
controls.enabled = pickedOrbitControlsWasEnabled;
|
||
}
|
||
|
||
pickedOrbitLastX = 0;
|
||
pickedOrbitLastY = 0;
|
||
pickedOrbitViewDistance = DEFAULT_CAMERA_DISTANCE;
|
||
pickedOrbitControlsWasEnabled = false;
|
||
updateMainCanvasCursor();
|
||
if (committedPivot) {
|
||
const params = calculateCameraParams();
|
||
forceRefreshCameraUi(params);
|
||
forceSyncViewerCameraPanel(params);
|
||
}
|
||
}
|
||
|
||
function endControlsRightPanTracking() {
|
||
controlsRightPanDragging = false;
|
||
controlsRightPanSyncFrames = 6;
|
||
}
|
||
|
||
function updateOrbitCenterFromPanDelta(prevCameraPos) {
|
||
if (!prevCameraPos || !camera) return;
|
||
const dx = camera.position.x - prevCameraPos.x;
|
||
const dy = camera.position.y - prevCameraPos.y;
|
||
const dz = camera.position.z - prevCameraPos.z;
|
||
if (Math.abs(dx) < 1e-8 && Math.abs(dy) < 1e-8 && Math.abs(dz) < 1e-8) return;
|
||
|
||
const base = cloneCenter(currentOrbitTarget)
|
||
|| cloneCenter(cameraParams.orbitCenter)
|
||
|| getDefaultOrbitCenter();
|
||
const next = {
|
||
x: base.x + dx,
|
||
y: base.y + dy,
|
||
z: base.z + dz,
|
||
};
|
||
|
||
currentOrbitTarget = cloneCenter(next);
|
||
cameraParams.targetCenter = cloneCenter(next);
|
||
cameraParams.orbitCenter = cloneCenter(next);
|
||
cameraParams.customOrbitCenter = cloneCenter(next);
|
||
updateOrbitCenterInputs(next);
|
||
}
|
||
|
||
function finalizeControlsRightPanCenter() {
|
||
if (!controlsRightPanNeedsFinalize || !camera) return;
|
||
const startPos = controlsRightPanStartCameraPos;
|
||
const startCenter = controlsRightPanStartCenter;
|
||
controlsRightPanNeedsFinalize = false;
|
||
controlsRightPanStartCameraPos = null;
|
||
controlsRightPanStartCenter = null;
|
||
if (!startPos || !startCenter) return;
|
||
|
||
const dx = camera.position.x - startPos.x;
|
||
const dy = camera.position.y - startPos.y;
|
||
const dz = camera.position.z - startPos.z;
|
||
if (Math.abs(dx) < 1e-7 && Math.abs(dy) < 1e-7 && Math.abs(dz) < 1e-7) return;
|
||
|
||
// Recenter to nearest gaussian around screen center after each right-pan operation.
|
||
if (recenterOrbitCenterToScreenCenterNearest({ keepAngles: false, persistAsCustom: false })) {
|
||
return;
|
||
}
|
||
|
||
const next = {
|
||
x: startCenter.x + dx,
|
||
y: startCenter.y + dy,
|
||
z: startCenter.z + dz,
|
||
};
|
||
currentOrbitTarget = cloneCenter(next);
|
||
cameraParams.targetCenter = cloneCenter(next);
|
||
cameraParams.orbitCenter = cloneCenter(next);
|
||
cameraParams.customOrbitCenter = null;
|
||
updateOrbitCenterInputs(next);
|
||
}
|
||
|
||
function getRollTargetForRender() {
|
||
if (!camera) return getActiveOrbitCenter();
|
||
const panning = controlsRightPanDragging || controlsRightPanSyncFrames > 0 || controlsRightPanNeedsFinalize;
|
||
if (
|
||
panning &&
|
||
controlsRightPanStartCameraPos &&
|
||
controlsRightPanStartCenter
|
||
) {
|
||
return {
|
||
x: controlsRightPanStartCenter.x + (camera.position.x - controlsRightPanStartCameraPos.x),
|
||
y: controlsRightPanStartCenter.y + (camera.position.y - controlsRightPanStartCameraPos.y),
|
||
z: controlsRightPanStartCenter.z + (camera.position.z - controlsRightPanStartCameraPos.z),
|
||
};
|
||
}
|
||
// Prefer the current view target so picking a new orbit center does not immediately
|
||
// reframe the image before the user actually drags to rotate.
|
||
return cloneCenter(cameraParams.targetCenter) || getActiveOrbitCenter();
|
||
}
|
||
|
||
function findNearestVisiblePointAtScreen(screenX, screenY, maxDistPx = 18, maxSamples = null) {
|
||
if (!currentSplat?.data?.positions || !camera) return null;
|
||
const projectionContext = createProjectionContext();
|
||
if (!projectionContext) return null;
|
||
const positions = currentSplat.data.positions;
|
||
const count = currentSplat.data.vertexCount || (positions.length / 3);
|
||
const sampleTarget = Number.isFinite(Number(maxSamples)) ? Math.max(1, Math.floor(Number(maxSamples))) : null;
|
||
const stride = sampleTarget ? Math.max(1, Math.ceil(count / sampleTarget)) : 1;
|
||
let best = null;
|
||
let bestDist2 = maxDistPx * maxDistPx;
|
||
|
||
for (let i = 0; i < count; i += stride) {
|
||
const px = positions[i * 3];
|
||
const py = positions[i * 3 + 1];
|
||
const pz = positions[i * 3 + 2];
|
||
const projected = projectPointWithContext(px, py, pz, projectionContext);
|
||
if (!projected) continue;
|
||
const dx = projected.x - screenX;
|
||
const dy = projected.y - screenY;
|
||
const dist2 = dx * dx + dy * dy;
|
||
if (dist2 > bestDist2) continue;
|
||
bestDist2 = dist2;
|
||
best = { x: px, y: py, z: pz, index: i };
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
function findNearestVisiblePointToScreenCenter(maxSamples = AUTO_ORBIT_CENTER_SAMPLE_LIMIT) {
|
||
if (!canvas) return null;
|
||
const centerX = (canvas.clientWidth || canvas.width || 0) * 0.5;
|
||
const centerY = (canvas.clientHeight || canvas.height || 0) * 0.5;
|
||
return findNearestVisiblePointAtScreen(centerX, centerY, Number.POSITIVE_INFINITY, maxSamples);
|
||
}
|
||
|
||
function recenterOrbitCenterToScreenCenterNearest(options = {}) {
|
||
const {
|
||
keepAngles = false,
|
||
persistAsCustom = false,
|
||
maxSamples = AUTO_ORBIT_CENTER_SAMPLE_LIMIT,
|
||
showFeedback = false,
|
||
} = options;
|
||
const picked = findNearestVisiblePointToScreenCenter(maxSamples);
|
||
if (!picked) return false;
|
||
if (!persistAsCustom) cameraParams.customOrbitCenter = null;
|
||
applyOrbitCenter(
|
||
{ x: picked.x, y: picked.y, z: picked.z },
|
||
{ persistAsCustom, keepAngles, showFeedback }
|
||
);
|
||
return true;
|
||
}
|
||
|
||
function setupOrbitCenterInteraction() {
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (centerPickMode) {
|
||
if (e.button === 2) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setCenterPickMode(false);
|
||
updateStatus();
|
||
}
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const picked = findNearestVisiblePointAtScreen(
|
||
x,
|
||
y,
|
||
Number.POSITIVE_INFINITY,
|
||
AUTO_ORBIT_CENTER_SAMPLE_LIMIT
|
||
);
|
||
if (!picked) {
|
||
updateStatus();
|
||
if (statusText) statusText.textContent += ' | no point near cursor';
|
||
return;
|
||
}
|
||
applyOrbitCenter(
|
||
{ x: picked.x, y: picked.y, z: picked.z },
|
||
{ persistAsCustom: true, keepAngles: true, showFeedback: true }
|
||
);
|
||
setCenterPickMode(false);
|
||
updateStatus();
|
||
return;
|
||
}
|
||
|
||
if (
|
||
e.button === 0 &&
|
||
!customOrbitEnabled &&
|
||
controls?.enabled &&
|
||
!isDraggingGizmo &&
|
||
!isSelecting &&
|
||
currentTool === 'orbit'
|
||
) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const picked = findNearestVisiblePointAtScreen(
|
||
x,
|
||
y,
|
||
AUTO_ROTATE_ORBIT_CENTER_MAX_DISTANCE_PX,
|
||
AUTO_ROTATE_ORBIT_CENTER_SAMPLE_LIMIT
|
||
);
|
||
if (picked) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
|
||
beginPickedOrbitDrag(
|
||
{ x: picked.x, y: picked.y, z: picked.z },
|
||
e.clientX,
|
||
e.clientY
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (
|
||
e.button === 2 &&
|
||
!customOrbitEnabled &&
|
||
controls?.enabled &&
|
||
!isDraggingGizmo &&
|
||
!isSelecting &&
|
||
(currentTool === 'orbit' || currentTool === 'pan')
|
||
) {
|
||
beginControlsRightPanTracking();
|
||
}
|
||
}, true);
|
||
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!pickedOrbitDragActive || isDraggingGizmo || !camera) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
|
||
updatePickedOrbitDrag(e.clientX, e.clientY);
|
||
}, true);
|
||
|
||
window.addEventListener('mouseup', (e) => {
|
||
if (e.button === 0) endPickedOrbitDrag();
|
||
if (e.button === 2) endControlsRightPanTracking();
|
||
}, true);
|
||
|
||
window.addEventListener('blur', () => {
|
||
endPickedOrbitDrag();
|
||
endControlsRightPanTracking();
|
||
});
|
||
}
|
||
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
#statusBar { display: flex; justify-content: center; background: #252525; padding: 4px 8px; border-top: 1px solid #333; color: #888; font-size: 10px; }
|
||
#controlsBar { display: flex; flex-direction: column; align-items: center; gap: 4px; background: #2a2a2a; padding: 6px 8px; border-top: 1px solid #333; min-width: 420px; }
|
||
.controls-row { display: flex; justify-content: center; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
#controlsBar button { border: none; color: #fff; padding: 5px 10px; border-radius: 4px; cursor: pointer; font-size: 11px; }
|
||
.btn-reset { background: #555; } .btn-cancel { background: #d32f2f; } .btn-confirm { background: #4CAF50; font-weight: bold; }
|
||
.scale-control { display: flex; align-items: center; gap: 4px; }
|
||
.scale-control span { color: #888; font-size: 10px; }
|
||
.scale-control input[type="range"] { width: 60px; background: #333; cursor: pointer; }
|
||
.scale-control input[type="number"] { width: 45px; background: #333; border: 1px solid #555; color: #fff; font-size: 10px; padding: 4px; border-radius: 3px; }
|
||
.focal-control { display: flex; align-items: center; gap: 4px; }
|
||
.focal-control span { color: #888; font-size: 10px; }
|
||
.focal-control input[type="range"] { width: 80px; background: #333; cursor: pointer; }
|
||
.focal-control input[type="number"] { width: 50px; background: #333; border: 1px solid #555; color: #fff; font-size: 10px; padding: 4px; border-radius: 3px; }
|
||
.depth-control { display: flex; align-items: center; gap: 4px; }
|
||
.depth-control span { color: #888; font-size: 10px; }
|
||
.depth-control input[type="range"] { width: 60px; background: #333; cursor: pointer; }
|
||
#depthRangeValue { color: #888; font-size: 10px; min-width: 40px; text-align: right; }
|
||
.tool-btn, .action-btn { background: #444; border: none; color: #fff; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||
.tool-btn:hover, .action-btn:hover { background: #555; }
|
||
.tool-btn.active { background: #4CAF50; }
|
||
.info-panel { position: absolute; top: 8px; left: 8px; background: rgba(0,0,0,0.7); padding: 6px 10px; border-radius: 4px; color: #aaa; font-size: 10px; z-index: 50; }
|
||
.info-panel.hidden { display: none; }
|
||
.camera-control { display: flex; align-items: center; gap: 4px; }
|
||
.camera-control span { color: #888; font-size: 10px; }
|
||
#cameraPosDisplay { color: #6cc; font-size: 10px; min-width: 100px; }
|
||
.cursor-control { display: flex; align-items: center; gap: 4px; }
|
||
.cursor-control span { color: #888; font-size: 10px; }
|
||
#cursorPosDisplay { color: #c6c; font-size: 10px; min-width: 100px; }
|
||
#toggleCameraPanel { background: #555; border: none; color: #fff; padding: 2px 6px; border-radius: 3px; cursor: pointer; font-size: 12px; }
|
||
.camera-panel { position: absolute; top: 50px; right: 10px; background: rgba(40,40,40,0.95); border: 1px solid #555; border-radius: 6px; z-index: 100; min-width: 280px; }
|
||
.camera-panel.hidden { display: none; }
|
||
.camera-panel-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid #555; color: #fff; font-size: 12px; }
|
||
.camera-panel-header button { background: none; border: none; color: #888; cursor: pointer; font-size: 14px; }
|
||
.camera-panel-header button:hover { color: #fff; }
|
||
.camera-panel-content { padding: 10px 12px; }
|
||
.camera-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||
.camera-row:last-child { margin-bottom: 0; }
|
||
.camera-row label { color: #aaa; font-size: 10px; min-width: 100px; }
|
||
.camera-row input[type="range"] { width: 80px; background: #333; cursor: pointer; }
|
||
.camera-row input[type="number"] { width: 50px; background: #333; border: 1px solid #555; color: #fff; font-size: 10px; padding: 3px; border-radius: 3px; }
|
||
.camera-row span { color: #888; font-size: 10px; }
|
||
|
||
/* 鐩告満鍙傛暟闈㈡澘鏍峰紡 */
|
||
.camera-params-panel {
|
||
position: absolute;
|
||
bottom: 10px;
|
||
left: 10px;
|
||
background: rgba(30, 30, 30, 0.95);
|
||
border: 1px solid #555;
|
||
border-radius: 8px;
|
||
z-index: 100;
|
||
min-width: 320px;
|
||
max-width: 400px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||
}
|
||
.camera-params-panel.hidden { display: none; }
|
||
.camera-params-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid #555;
|
||
background: rgba(60, 60, 60, 0.5);
|
||
border-radius: 8px 8px 0 0;
|
||
}
|
||
.camera-params-header h3 {
|
||
color: #fff;
|
||
font-size: 12px;
|
||
margin: 0;
|
||
font-weight: 600;
|
||
}
|
||
.camera-params-header .toggle-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #888;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
}
|
||
.camera-params-header .toggle-btn:hover {
|
||
color: #fff;
|
||
background: rgba(255,255,255,0.1);
|
||
}
|
||
.camera-params-body {
|
||
padding: 10px 12px;
|
||
}
|
||
.camera-params-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
padding: 4px 0;
|
||
}
|
||
.camera-params-row:last-child { margin-bottom: 0; }
|
||
.param-label {
|
||
color: #aaa;
|
||
font-size: 11px;
|
||
min-width: 70px;
|
||
}
|
||
.param-value {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.param-number {
|
||
color: #6cc;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
min-width: 40px;
|
||
text-align: right;
|
||
}
|
||
.param-tag {
|
||
background: #4CAF50;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
white-space: nowrap;
|
||
}
|
||
.param-tag.azimuth { background: #2196F3; }
|
||
.param-tag.elevation { background: #FF9800; }
|
||
.param-tag.zoom { background: #9C27B0; }
|
||
|
||
.camera-description-section {
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid #444;
|
||
}
|
||
.camera-description-section label {
|
||
display: block;
|
||
color: #aaa;
|
||
font-size: 10px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.camera-description-input {
|
||
width: 100%;
|
||
background: #222;
|
||
border: 1px solid #555;
|
||
color: #fff;
|
||
font-size: 11px;
|
||
padding: 8px 10px;
|
||
border-radius: 4px;
|
||
resize: none;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
line-height: 1.4;
|
||
}
|
||
.camera-description-input:focus {
|
||
outline: none;
|
||
border-color: #4CAF50;
|
||
}
|
||
.copy-btn {
|
||
margin-top: 8px;
|
||
background: #4CAF50;
|
||
border: none;
|
||
color: #fff;
|
||
padding: 6px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
width: 100%;
|
||
transition: background 0.2s;
|
||
}
|
||
.copy-btn:hover { background: #45a049; }
|
||
.copy-btn:active { background: #3d8b40; }
|
||
.copy-btn.copied {
|
||
background: #2196F3;
|
||
}
|
||
|
||
/* 鎶樺彔鐘舵€?*/
|
||
.camera-params-panel.collapsed .camera-params-body {
|
||
display: none;
|
||
}
|
||
.camera-params-panel.collapsed {
|
||
min-width: auto;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
function initViewer() {
|
||
try {
|
||
scene = new SPLAT.Scene();
|
||
camera = new SPLAT.Camera();
|
||
renderer = new SPLAT.WebGLRenderer(canvas);
|
||
controls = new SPLAT.OrbitControls(camera, canvas);
|
||
|
||
// 璁剧疆鏈€灏忕缉鏀捐窛绂伙紝闃叉鐩告満绌胯繃鐩爣鐐瑰鑷存柟浣嶈璺冲彉
|
||
// minZoom鎺у埗鐩告満鍒扮洰鏍囩偣鐨勬渶灏忚窛绂?
|
||
controls.minZoom = MIN_CONTINUOUS_ZOOM_DISTANCE;
|
||
// Remove practical zoom-out cap to avoid being stuck after picking a custom orbit center.
|
||
controls.maxZoom = 1000000;
|
||
controls.zoomSpeed = 1; // 鎭㈠榛樿缂╂斁閫熷害
|
||
controls.orbitSpeed = 1.8; // 鎻愰珮宸﹂敭鏃嬭浆閫熷害锛堥伩鍏嶈繃蹇け鎺э級
|
||
controls.panSpeed = ORBIT_CONTROLS_PAN_SPEED;
|
||
controls.dampening = 0.12; // 涓?OrbitControls 榛樿涓€鑷达紝淇濇寔绋冲畾
|
||
|
||
// 璁剧疆鍒濆鐒﹁窛锛?6mm杞崲涓哄儚绱犵劍璺濓級
|
||
controls.panSpeed = ORBIT_CONTROLS_PAN_SPEED;
|
||
controls.zoomSpeed = ORBIT_CONTROLS_ZOOM_SPEED;
|
||
controls.orbitSpeed = ORBIT_CONTROLS_ORBIT_SPEED;
|
||
controls.panSpeed = ORBIT_CONTROLS_PAN_SPEED;
|
||
syncOrbitControlPrecision();
|
||
applyFocalLengthToCamera(initialFocalLength);
|
||
// 鍚屾鏇存柊婊戝潡鏄剧ず鍊?
|
||
focalLengthSlider.value = initialFocalLength;
|
||
focalLengthValue.value = initialFocalLength;
|
||
|
||
// 璁剧疆娣卞害鑼冨洿浠ユ樉绀轰换鎰忚窛绂荤殑楂樻柉
|
||
setCameraClipRange(0.01, CAMERA_FAR_ALL);
|
||
|
||
// 鍏堣缃洰鏍囩偣锛屽啀璁剧疆鐩告満浣嶇疆
|
||
// 浣跨敤getSplatCenter鑾峰彇璺濈鍙栨櫙妗嗕腑蹇冩渶杩戠殑楂樻柉鐐逛綔涓轰腑蹇?
|
||
const initialCenter = getSplatCenter();
|
||
controls.setCameraTarget(new SPLAT.Vector3(initialCenter.x, initialCenter.y, initialCenter.z));
|
||
currentOrbitTarget = cloneCenter(initialCenter);
|
||
cameraParams.orbitCenter = cloneCenter(initialCenter);
|
||
cameraParams.customOrbitCenter = null;
|
||
updateOrbitCenterInputs(initialCenter);
|
||
|
||
// 璁剧疆鍒濆鐩告満浣嶇疆锛岀‘淇濇柟浣嶈涓?搴?
|
||
const initialDistance = getRecommendedInitialCameraDistance();
|
||
camera.position.x = initialCenter.x;
|
||
camera.position.y = initialCenter.y;
|
||
camera.position.z = initialCenter.z - initialDistance;
|
||
|
||
// 鍒濆鍖栫浉鏈哄弬鏁扮姸鎬?
|
||
cameraParams.azimuth = 0;
|
||
cameraParams.elevation = 0;
|
||
cameraParams.distance = DEFAULT_CAMERA_DISTANCE; // 榛樿缂╂斁涓?
|
||
cameraParams.targetCenter = initialCenter;
|
||
|
||
const resize = () => {
|
||
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
|
||
selectionCanvas.width = canvas.clientWidth;
|
||
selectionCanvas.height = canvas.clientHeight;
|
||
gizmoCanvas.width = canvas.clientWidth;
|
||
gizmoCanvas.height = canvas.clientHeight;
|
||
updateRenderFrame();
|
||
// 閲嶆柊璁$畻鐒﹁窛浠ュ尮閰嶅綋鍓嶇敾甯冨昂瀵?
|
||
if (camera) {
|
||
applyFocalLengthToCamera(getCurrentFocalLengthValue(initialFocalLength));
|
||
}
|
||
};
|
||
window.addEventListener('resize', resize);
|
||
resize();
|
||
|
||
// 鍚屾璁℃暟鍣紙鐢ㄤ簬鑺傛祦锛?
|
||
let isFirstFrame = false;
|
||
|
||
const frame = () => {
|
||
// 绗竴甯ф椂纭繚鐩告満浣嶇疆姝g‘锛圤rbitControls鍙兘鍦ㄥ垵濮嬪寲鏃惰鐩栦簡浣嶇疆锛?
|
||
if (isFirstFrame) {
|
||
isFirstFrame = false;
|
||
const initDist = getRecommendedInitialCameraDistance();
|
||
camera.position.x = 0;
|
||
camera.position.y = 0;
|
||
camera.position.z = -initDist;
|
||
cameraParams.azimuth = 0;
|
||
cameraParams.elevation = 0;
|
||
cameraParams.distance = DEFAULT_CAMERA_DISTANCE; // 榛樿缂╂斁涓?
|
||
}
|
||
|
||
if (customOrbitEnabled) updateCameraFromOrbit();
|
||
else if (controls.enabled) {
|
||
const handledKeyboardCameraControl = applyKeyboardCameraControl();
|
||
const shouldSyncPanCenter = controlsRightPanDragging || controlsRightPanSyncFrames > 0;
|
||
if (!handledKeyboardCameraControl) {
|
||
controls.update();
|
||
}
|
||
if (!controlsRightPanDragging && controlsRightPanSyncFrames > 0) {
|
||
controlsRightPanSyncFrames -= 1;
|
||
}
|
||
if (shouldSyncPanCenter && !controlsRightPanDragging && controlsRightPanSyncFrames === 0) {
|
||
finalizeControlsRightPanCenter();
|
||
}
|
||
}
|
||
const rawRotationForRender = camera?.rotation
|
||
? new SPLAT.Quaternion(camera.rotation.x, camera.rotation.y, camera.rotation.z, camera.rotation.w)
|
||
: null;
|
||
updateRenderedCameraRoll();
|
||
applyRollToCamera(customOrbitEnabled ? orbitTarget : getRollTargetForRender());
|
||
renderer.render(scene, camera);
|
||
if (rawRotationForRender) {
|
||
camera.rotation = rawRotationForRender;
|
||
camera.update();
|
||
}
|
||
|
||
// 鏇存柊鐩告満浣嶇疆鏄剧ず
|
||
const params = calculateCameraParams();
|
||
const frameNow = performance.now();
|
||
maybeRefreshCameraUi(params, frameNow);
|
||
|
||
// 鏇存柊鐩告満鍙傛暟鏄剧ず锛堟柟浣嶈銆佷话瑙掋€佺缉鏀撅級
|
||
|
||
// 姣?甯у悓姝ヤ竴娆″埌3D鎺у埗闈㈡澘锛堣妭娴侊級
|
||
maybeSyncViewerCameraPanel(params, frameNow);
|
||
|
||
const shouldDrawTransformGizmo = (currentTool === 'translate' || currentTool === 'rotate') && selectedIndices.size > 0;
|
||
const shouldDrawOverlay = shouldDrawTransformGizmo || hasActiveOrbitCenterFeedback();
|
||
if (shouldDrawTransformGizmo) {
|
||
updateGizmoScreenPosition();
|
||
}
|
||
if (shouldDrawOverlay) {
|
||
drawGizmo();
|
||
} else {
|
||
clearGizmo();
|
||
}
|
||
requestAnimationFrame(frame);
|
||
};
|
||
frame();
|
||
|
||
setupToolbar();
|
||
setupSelectionEvents();
|
||
setupGizmoEvents();
|
||
setupKeyboard();
|
||
setupCustomOrbitControls();
|
||
setupOrbitCenterInteraction();
|
||
setupVirtualOrbitBall();
|
||
updateMainCanvasCursor();
|
||
console.log('[GaussianViewer] Initialized');
|
||
} catch (err) {
|
||
console.error('[GaussianViewer] Init error:', err);
|
||
errorEl.textContent = 'Failed to initialize: ' + err.message;
|
||
errorEl.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
|
||
// ============== Controls Setup ==============
|
||
|
||
function setupCustomOrbitControls() {
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (!customOrbitEnabled || isDraggingGizmo) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
customOrbitDragging = true;
|
||
customOrbitLastX = e.clientX; customOrbitLastY = e.clientY;
|
||
customOrbitButton = e.button;
|
||
}, true);
|
||
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!customOrbitEnabled || !customOrbitDragging || isDraggingGizmo) return;
|
||
const dx = e.clientX - customOrbitLastX, dy = e.clientY - customOrbitLastY;
|
||
const precisionMultiplier = isPrecisionModifierPressed(e) ? KEYBOARD_CAMERA_FINE_MULTIPLIER : 1;
|
||
if (customOrbitButton === 0) {
|
||
orbitYaw -= dx * 0.005 * precisionMultiplier; orbitPitch += dy * 0.005 * precisionMultiplier;
|
||
orbitPitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, orbitPitch));
|
||
} else if (customOrbitButton === 2) {
|
||
// 鍙抽敭鎷栧姩锛氬钩绉昏鍥撅紝鍚屾椂鏇存柊orbitTarget锛堜腑蹇冪偣锛?
|
||
const panSpeed = orbitDistance * CUSTOM_ORBIT_PAN_DISTANCE_FACTOR * precisionMultiplier;
|
||
const panX = dx * panSpeed;
|
||
const panY = dy * panSpeed;
|
||
|
||
// 璁$畻鐩告満鍙虫柟鍚戝拰涓婃柟鍚?
|
||
// 鍙虫柟鍚戯細鍨傜洿浜庤绾匡紝鍦╔Z骞抽潰涓?
|
||
const rightX = Math.cos(orbitYaw);
|
||
const rightZ = Math.sin(orbitYaw);
|
||
|
||
// 鏇存柊orbitTarget锛堜腑蹇冪偣锛夊拰cameraOffset
|
||
// 鍚戝彸鎷栧姩锛坉x>0锛夆啋 涓績鐐瑰悜鍙崇Щ鍔?鈫?鐪嬪埌宸﹁竟鏇村
|
||
orbitTarget.x += rightX * panX;
|
||
orbitTarget.z -= rightZ * panX;
|
||
orbitTarget.y -= panY; // 鍚戜笅鎷栧姩鈫掍腑蹇冪偣涓婄Щ鈫掔湅鍒颁笅鏂规洿澶?
|
||
}
|
||
customOrbitLastX = e.clientX; customOrbitLastY = e.clientY;
|
||
}, true);
|
||
|
||
window.addEventListener('mouseup', () => { customOrbitDragging = false; }, true);
|
||
|
||
canvas.addEventListener('wheel', (e) => {
|
||
if (!customOrbitEnabled || isDraggingGizmo) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
// 鎸変綇Shift鏃跺噺閫?.5鍊?
|
||
const speedMultiplier = isPrecisionModifierPressed(e) ? 0.001 * KEYBOARD_CAMERA_FINE_MULTIPLIER : 0.001;
|
||
orbitDistance += e.deltaY * orbitDistance * speedMultiplier;
|
||
orbitDistance = Math.max(MIN_CONTINUOUS_ZOOM_DISTANCE, orbitDistance);
|
||
}, true);
|
||
|
||
canvas.addEventListener('contextmenu', (e) => { if (customOrbitEnabled) e.preventDefault(); }, true);
|
||
}
|
||
|
||
/**
|
||
* 璁剧疆铏氭嫙濮挎€佺悆 - 璺熻釜榧犳爣鏃嬭浆瑙掑害
|
||
* 宸︽嫋 = 璐熻搴︼紝鍙虫嫋 = 姝h搴?
|
||
* 360搴︿竴寰幆
|
||
*/
|
||
function setupVirtualOrbitBall() {
|
||
// 鐩戝惉canvas涓婄殑榧犳爣浜嬩欢鏉ヨ窡韪棆杞?
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
// 鍙湪orbit宸ュ叿妯″紡涓嬭窡韪?
|
||
if (currentTool !== 'orbit' || isDraggingGizmo) return;
|
||
if (e.button !== 0) return; // 鍙窡韪乏閿?
|
||
|
||
virtualOrbitBall.isDragging = true;
|
||
virtualOrbitBall.lastMouseX = e.clientX;
|
||
virtualOrbitBall.lastMouseY = e.clientY;
|
||
}, true);
|
||
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!virtualOrbitBall.isDragging) return;
|
||
|
||
const dx = e.clientX - virtualOrbitBall.lastMouseX;
|
||
const dy = e.clientY - virtualOrbitBall.lastMouseY;
|
||
const precisionMultiplier = isPrecisionModifierPressed(e) ? KEYBOARD_CAMERA_FINE_MULTIPLIER : 1;
|
||
|
||
// 姘村钩鏃嬭浆锛氬悜宸︽嫋鍔?= 鐪嬪埌鍙宠竟 = 瑙掑害澧炲姞
|
||
// 鐏垫晱搴︼細涓巊splat鎺у埗鍣ㄤ竴鑷?0.003寮у害/鍍忕礌 鈮?0.17搴?鍍忕礌)
|
||
const yawDelta = -dx * 0.003; // 鍙栬礋浣垮悜宸︿负姝?
|
||
virtualOrbitBall.yaw += yawDelta * precisionMultiplier;
|
||
|
||
// 鍨傜洿鏃嬭浆锛氶紶鏍囧悜涓?淇=姝h搴︼紝榧犳爣鍚戜笂=浠拌=璐熻搴?
|
||
// 涓嶅彇鍙峝y锛屽悜涓嬫嫋鍔╠y涓烘鍊硷紝瀵瑰簲姝h搴︼紙淇/鐪嬪埌椤堕儴锛?
|
||
const pitchDelta = dy * 0.003;
|
||
virtualOrbitBall.pitch += pitchDelta * precisionMultiplier;
|
||
|
||
// 闄愬埗浠拌鑼冨洿锛?30搴﹀埌90搴?
|
||
const minPitch = -30 * (Math.PI / 180);
|
||
const maxPitch = 90 * (Math.PI / 180);
|
||
virtualOrbitBall.pitch = Math.max(minPitch, Math.min(maxPitch, virtualOrbitBall.pitch));
|
||
|
||
virtualOrbitBall.lastMouseX = e.clientX;
|
||
virtualOrbitBall.lastMouseY = e.clientY;
|
||
|
||
// 绔嬪嵆鍚屾鍒?D鐩告満鎺у埗闈㈡澘
|
||
}, true);
|
||
|
||
window.addEventListener('mouseup', (e) => {
|
||
if (e.button === 0) {
|
||
virtualOrbitBall.isDragging = false;
|
||
const params = calculateCameraParams();
|
||
forceRefreshCameraUi(params);
|
||
forceSyncViewerCameraPanel(params);
|
||
}
|
||
}, true);
|
||
|
||
// 婊氳疆缂╂斁 - 鐢眊splat OrbitControls澶勭悊
|
||
// 涓嶅啀鍚屾zoom鍊煎埌鏄剧ず锛寊oom鍊煎彧鑳介€氳繃鎵嬪姩杈撳叆鎺у埗
|
||
// 鍙栨秷榧犳爣婊氳疆瀵箊oom鐨勫奖鍝?
|
||
}
|
||
|
||
/**
|
||
* 閲嶇疆铏氭嫙濮挎€佺悆瑙掑害
|
||
*/
|
||
function resetVirtualOrbitBall() {
|
||
virtualOrbitBall.yaw = virtualOrbitBall.initialYaw;
|
||
virtualOrbitBall.pitch = virtualOrbitBall.initialPitch;
|
||
cameraParams.distance = DEFAULT_CAMERA_DISTANCE;
|
||
}
|
||
|
||
function setupToolbar() {
|
||
document.querySelectorAll('.tool-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => setTool(btn.dataset.tool));
|
||
});
|
||
deleteBtn.addEventListener('click', deleteSelected);
|
||
invertBtn.addEventListener('click', invertSelection);
|
||
clearSelBtn.addEventListener('click', clearSelection);
|
||
resetBtn.addEventListener('click', resetCamera);
|
||
|
||
// 婊戞潯婊氳疆璋冩暣鍔熻兘
|
||
const setupSliderWheel = (slider, input, onChange, min, max, step) => {
|
||
slider.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const delta = e.deltaY > 0 ? -step : step;
|
||
let newValue = parseFloat(slider.value) + delta;
|
||
newValue = Math.round(newValue / step) * step;
|
||
newValue = Math.max(min, Math.min(max, newValue));
|
||
if (step < 1) {
|
||
const decimals = (step.toString().split('.')[1] || '').length;
|
||
newValue = Number(newValue.toFixed(decimals));
|
||
}
|
||
slider.value = newValue;
|
||
if (input) input.value = newValue;
|
||
if (onChange) onChange(newValue);
|
||
}, { passive: false });
|
||
};
|
||
|
||
// 缂╂斁鎺у埗浜嬩欢
|
||
scaleSlider.addEventListener('input', (e) => {
|
||
const value = parseFloat(e.target.value);
|
||
updateGaussianScale(value);
|
||
});
|
||
scaleInput.addEventListener('change', (e) => {
|
||
let value = parseFloat(e.target.value);
|
||
value = Math.max(0.1, Math.min(4, value));
|
||
updateGaussianScale(value);
|
||
});
|
||
setupSliderWheel(scaleSlider, scaleInput, updateGaussianScale, 0.1, 4, 0.1);
|
||
|
||
// 鐒﹁窛鎺у埗浜嬩欢
|
||
focalLengthSlider.addEventListener('input', (e) => {
|
||
const value = parseFloat(e.target.value);
|
||
focalLengthValue.value = value;
|
||
updateFocalLength(value);
|
||
});
|
||
focalLengthValue.addEventListener('change', (e) => {
|
||
let value = parseFloat(e.target.value);
|
||
value = Math.max(12, Math.min(150, value));
|
||
focalLengthValue.value = value;
|
||
focalLengthSlider.value = value;
|
||
updateFocalLength(value);
|
||
});
|
||
setupSliderWheel(focalLengthSlider, focalLengthValue, updateFocalLength, 12, 150, 1);
|
||
|
||
// 姘村钩鏍℃鎺у埗浜嬩欢
|
||
if (rollSlider && rollInput) {
|
||
const setRollValue = (rawValue) => {
|
||
let value = parseInt(rawValue, 10);
|
||
if (!Number.isFinite(value)) value = 0;
|
||
value = Math.max(-90, Math.min(90, value));
|
||
setCameraRollValue(value);
|
||
rollInput.value = value;
|
||
rollSlider.value = value;
|
||
};
|
||
rollSlider.addEventListener('input', (e) => {
|
||
setRollValue(e.target.value);
|
||
});
|
||
rollInput.addEventListener('change', (e) => {
|
||
setRollValue(e.target.value);
|
||
});
|
||
setupSliderWheel(rollSlider, rollInput, setRollValue, -90, 90, 1);
|
||
}
|
||
|
||
// 娣卞害鑼冨洿鎺у埗浜嬩欢
|
||
if (useCurrentOrbitCenterBtn) {
|
||
useCurrentOrbitCenterBtn.addEventListener('click', () => {
|
||
const center = getOrbitCenter() || getActiveOrbitCenter();
|
||
updateOrbitCenterInputs(center);
|
||
setCenterPickMode(false);
|
||
});
|
||
}
|
||
|
||
if (applyOrbitCenterBtn) {
|
||
applyOrbitCenterBtn.addEventListener('click', () => {
|
||
const center = readOrbitCenterInputs();
|
||
if (!center) return;
|
||
applyOrbitCenter(center, { persistAsCustom: true, keepAngles: true });
|
||
setCenterPickMode(false);
|
||
updateStatus();
|
||
});
|
||
}
|
||
|
||
if (clearOrbitCenterBtn) {
|
||
clearOrbitCenterBtn.addEventListener('click', () => {
|
||
cameraParams.customOrbitCenter = null;
|
||
cameraParams.orbitCenter = getOrbitCenter() || getDefaultOrbitCenter();
|
||
updateOrbitCenterInputs(cameraParams.orbitCenter);
|
||
setCenterPickMode(false);
|
||
updateStatus();
|
||
});
|
||
}
|
||
|
||
if (pickOrbitCenterBtn) {
|
||
pickOrbitCenterBtn.addEventListener('click', () => {
|
||
setCenterPickMode(!centerPickMode);
|
||
updateStatus();
|
||
});
|
||
}
|
||
|
||
updateKeyboardShortcutHints();
|
||
|
||
const applyDepthRangeValue = (rawValue) => {
|
||
const parsed = parseFloat(rawValue);
|
||
const fallback = parseFloat(depthRangeSlider.value);
|
||
const value = Math.max(0.01, Math.min(5, Number.isFinite(parsed) ? parsed : (Number.isFinite(fallback) ? fallback : 5)));
|
||
depthRangeSlider.value = value;
|
||
if (value >= 5) {
|
||
if (depthRangeValue) depthRangeValue.textContent = '全部';
|
||
updateDepthRange(Infinity);
|
||
} else {
|
||
const depthValue = Math.pow(10, value);
|
||
if (depthRangeValue) depthRangeValue.textContent = depthValue >= 1000 ? (depthValue / 1000).toFixed(1) + 'k' : depthValue.toFixed(0);
|
||
updateDepthRange(depthValue);
|
||
}
|
||
};
|
||
depthRangeSlider.addEventListener('input', (e) => {
|
||
applyDepthRangeValue(e.target.value);
|
||
});
|
||
setupSliderWheel(depthRangeSlider, null, applyDepthRangeValue, 0.01, 5, 0.01);
|
||
// 默认按“全部”显示高斯点,不改变相机姿态/位置。
|
||
applyDepthRangeValue(depthRangeSlider.value);
|
||
|
||
|
||
confirmBtn.addEventListener('click', handleConfirm);
|
||
|
||
// 鐩告満鍙傛暟闈㈡澘浜や簰
|
||
setupCameraParamsPanel();
|
||
cancelBtn.addEventListener('click', handleCancel);
|
||
|
||
// 姣斾緥閫夋嫨涓嬫媺鑿滃崟
|
||
aspectBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
aspectMenu.classList.toggle('show');
|
||
aspectMenu.classList.toggle('hidden');
|
||
});
|
||
|
||
aspectOptions.forEach(option => {
|
||
option.addEventListener('click', (e) => {
|
||
const aspect = e.target.dataset.aspect;
|
||
setAspectRatio(aspect);
|
||
aspectMenu.classList.add('hidden');
|
||
aspectMenu.classList.remove('show');
|
||
});
|
||
});
|
||
|
||
// 鐐瑰嚮鍏朵粬鍦版柟鍏抽棴鑿滃崟
|
||
document.addEventListener('click', () => {
|
||
aspectMenu.classList.add('hidden');
|
||
aspectMenu.classList.remove('show');
|
||
});
|
||
}
|
||
|
||
// 姣斾緥鏄犲皠琛?- 鍙瓨鍌ㄦ瘮渚嬪€?
|
||
const aspectRatios = {
|
||
'21:9': 21/9,
|
||
'16:9': 16/9,
|
||
'3:2': 3/2,
|
||
'4:3': 4/3,
|
||
'1:1': 1,
|
||
'3:4': 3/4,
|
||
'2:3': 2/3,
|
||
'9:16': 9/16
|
||
};
|
||
|
||
function setAspectRatio(aspect) {
|
||
currentAspectRatio = aspect;
|
||
console.log('[GaussianViewer] setAspectRatio:', aspect);
|
||
if (aspect === 'original') {
|
||
outputWidth = originalOutputWidth;
|
||
outputHeight = originalOutputHeight;
|
||
if (aspectBtn) aspectBtn.textContent = '原始 ▼';
|
||
} else {
|
||
const ratio = aspectRatios[aspect];
|
||
if (ratio) {
|
||
// 鍩轰簬鍘熷灏哄锛屾寜鏂版瘮渚嬭皟鏁?
|
||
// 淇濇寔杈冨ぇ鐨勭淮搴︿笉鍙橈紝鎸夋瘮渚嬭绠楀彟涓€涓淮搴?
|
||
if (originalOutputWidth >= originalOutputHeight) {
|
||
// 鍘熷鏄í鍚戠殑
|
||
if (ratio >= 1) {
|
||
// 鏂版瘮渚嬩篃鏄í鍚戯紝淇濇寔瀹藉害锛岃绠楅珮搴?
|
||
outputWidth = originalOutputWidth;
|
||
outputHeight = Math.round(originalOutputWidth / ratio);
|
||
} else {
|
||
// 鏂版瘮渚嬫槸绔栧悜锛屼繚鎸佸搴︼紝璁$畻楂樺害
|
||
outputWidth = originalOutputWidth;
|
||
outputHeight = Math.round(originalOutputWidth / ratio);
|
||
}
|
||
} else {
|
||
// 鍘熷鏄珫鍚戠殑
|
||
if (ratio >= 1) {
|
||
// 鏂版瘮渚嬫槸妯悜锛屼繚鎸侀珮搴︼紝璁$畻瀹藉害
|
||
outputHeight = originalOutputHeight;
|
||
outputWidth = Math.round(originalOutputHeight * ratio);
|
||
} else {
|
||
// 鏂版瘮渚嬩篃鏄珫鍚戯紝淇濇寔楂樺害锛岃绠楀搴?
|
||
outputHeight = originalOutputHeight;
|
||
outputWidth = Math.round(originalOutputHeight * ratio);
|
||
}
|
||
}
|
||
if (aspectBtn) aspectBtn.textContent = aspect + ' ▼';
|
||
}
|
||
}
|
||
console.log('[GaussianViewer] Output size:', outputWidth, 'x', outputHeight, 'ratio:', outputWidth/outputHeight);
|
||
// 鏇存柊閫変腑鐘舵€?
|
||
aspectOptions.forEach(opt => {
|
||
opt.classList.toggle('selected', opt.dataset.aspect === aspect);
|
||
});
|
||
updateRenderFrame();
|
||
}
|
||
|
||
function isKnownAspectRatio(aspect) {
|
||
return typeof aspect === 'string' && (
|
||
aspect === 'original'
|
||
|| Object.prototype.hasOwnProperty.call(aspectRatios, aspect)
|
||
);
|
||
}
|
||
|
||
function applyAspectState(aspect, width, height) {
|
||
if (isKnownAspectRatio(aspect)) {
|
||
setAspectRatio(aspect);
|
||
return true;
|
||
}
|
||
|
||
const nextWidth = Number(width);
|
||
const nextHeight = Number(height);
|
||
if (!Number.isFinite(nextWidth) || nextWidth <= 0 || !Number.isFinite(nextHeight) || nextHeight <= 0) {
|
||
return false;
|
||
}
|
||
|
||
currentAspectRatio = 'custom';
|
||
outputWidth = Math.round(nextWidth);
|
||
outputHeight = Math.round(nextHeight);
|
||
if (aspectBtn) aspectBtn.textContent = `${outputWidth}x${outputHeight} ▼`;
|
||
aspectOptions.forEach((opt) => {
|
||
opt.classList.remove('selected');
|
||
});
|
||
updateRenderFrame();
|
||
return true;
|
||
}
|
||
|
||
function updateCameraPosDisplay(params = null) {
|
||
// 鏄剧ず鐩告満瑙掑害鍙傛暟鑰屼笉鏄綅缃潗鏍?
|
||
// Y = 鏂逛綅瑙掞紙azimuth锛?-360搴︼紝姘村钩鏃嬭浆
|
||
// X = 浠拌锛坋levation锛?30鍒?0搴︼紝淇话瑙?
|
||
// Z = 缂╂斁璺濈宸插彇娑堟樉绀猴紝鏀逛负鎵嬪姩杈撳叆鎺у埗
|
||
params = params || calculateCameraParams();
|
||
if (params && cameraPosDisplay) {
|
||
const x = Math.round(params.elevation) + '°'; // 浠拌锛堜刊浠帮紝X杞达級
|
||
const y = Math.round(params.azimuth) + '°'; // 鏂逛綅瑙掞紙姘村钩锛孻杞达級
|
||
// 鍙樉绀篨鍜孻锛屽彇娑圸鐨勬樉绀?
|
||
cameraPosDisplay.textContent = `X:${x} Y:${y}`;
|
||
}
|
||
}
|
||
|
||
// ============== Camera Parameter Calculation ==============
|
||
|
||
/**
|
||
* 璁$畻鐩告満鍙傛暟锛堟柟浣嶈銆佷话瑙掋€佽窛绂伙級
|
||
* azimuth/elevation 浠呯敱 position 鍐冲畾锛宺oll 浠呯敱 rotation 鍐冲畾锛屼簰涓嶅奖鍝?
|
||
*/
|
||
function calculateCameraParams() {
|
||
if (!camera || !controls) {
|
||
return cameraParams;
|
||
}
|
||
|
||
const target = getActiveOrbitCenter();
|
||
|
||
const px = camera.position.x, py = camera.position.y, pz = camera.position.z;
|
||
const posChanged = !lastCameraPositionForParams ||
|
||
Math.abs(px - lastCameraPositionForParams.x) > 1e-6 ||
|
||
Math.abs(py - lastCameraPositionForParams.y) > 1e-6 ||
|
||
Math.abs(pz - lastCameraPositionForParams.z) > 1e-6;
|
||
|
||
if (posChanged) {
|
||
lastCameraPositionForParams = { x: px, y: py, z: pz };
|
||
const result = CT.GSplatAdapter.fromGSplatPosition(px, py, pz, target);
|
||
cameraParams.azimuth = Math.round(result.azimuth);
|
||
cameraParams.elevation = Math.round(result.elevation);
|
||
}
|
||
|
||
// Keep roll as an explicit user-controlled value.
|
||
// Deriving roll from camera Euler angles causes instability near high pitch/yaw
|
||
// and can feed back into applyRollToCamera(), producing uncontrolled rolling.
|
||
return cameraParams;
|
||
}
|
||
|
||
/**
|
||
* 鑾峰彇楂樻柉鐐逛簯鐨勪腑蹇冪偣
|
||
* 浼樺厛杩斿洖璺濈鍙栨櫙妗嗕腑蹇冩渶杩戠殑楂樻柉鐐?
|
||
*/
|
||
function getSplatCenter() {
|
||
if (!currentSplat?.data?.positions) {
|
||
return { x: 0, y: 0, z: 0 };
|
||
}
|
||
|
||
const positions = currentSplat.data.positions;
|
||
const count = positions.length / 3;
|
||
if (count === 0) return { x: 0, y: 0, z: 0 };
|
||
|
||
// 棣栧厛璁$畻鐐逛簯鐨勫嚑浣曚腑蹇冧綔涓哄弬鑰?
|
||
let boundsCenter = { x: 0, y: 0, z: 0 };
|
||
if (currentSplat.bounds) {
|
||
boundsCenter = currentSplat.bounds.center();
|
||
} else {
|
||
for (let i = 0; i < count; i++) {
|
||
boundsCenter.x += positions[i * 3];
|
||
boundsCenter.y += positions[i * 3 + 1];
|
||
boundsCenter.z += positions[i * 3 + 2];
|
||
}
|
||
boundsCenter.x /= count;
|
||
boundsCenter.y /= count;
|
||
boundsCenter.z /= count;
|
||
}
|
||
|
||
// 鏌ユ壘璺濈鍙栨櫙妗嗕腑蹇冿紙灞忓箷涓績锛夋渶杩戠殑楂樻柉鐐?
|
||
// 鍙栨櫙妗嗕腑蹇冨搴旂殑鏄偣浜戠殑鍓嶆柟涓績浣嶇疆
|
||
// 鎴戜滑浣跨敤鐐逛簯鐨勫墠琛ㄩ潰涓績浣滀负鐩爣
|
||
let minZ = Infinity;
|
||
let frontCenter = { x: boundsCenter.x, y: boundsCenter.y, z: boundsCenter.z };
|
||
|
||
// 鎵惧埌鏈€鍓嶆柟锛圸鏈€灏忥級鐨勪竴缁勭偣锛岃绠楀畠浠殑骞冲潎浣嶇疆
|
||
const frontPoints = [];
|
||
const zThreshold = 0.2; // 允许 20% 的 Z 范围作为“前方”
|
||
|
||
// 鍏堟壘鍒版渶灏廧鍊?
|
||
for (let i = 0; i < count; i++) {
|
||
const z = positions[i * 3 + 2];
|
||
if (z < minZ) minZ = z;
|
||
}
|
||
|
||
// 鏀堕泦鍓嶆柟鐐癸紙Z鍊兼帴杩戞渶灏廧鐨勭偣锛?
|
||
const zRange = currentSplat.bounds ? currentSplat.bounds.size().z * zThreshold : 1.0;
|
||
for (let i = 0; i < count; i++) {
|
||
const z = positions[i * 3 + 2];
|
||
if (z <= minZ + zRange) {
|
||
frontPoints.push({
|
||
x: positions[i * 3],
|
||
y: positions[i * 3 + 1],
|
||
z: z
|
||
});
|
||
}
|
||
}
|
||
|
||
// 濡傛灉鏈夊墠鏂圭偣锛岃绠楀畠浠殑涓績
|
||
if (frontPoints.length > 0) {
|
||
let sumX = 0, sumY = 0, sumZ = 0;
|
||
for (const p of frontPoints) {
|
||
sumX += p.x;
|
||
sumY += p.y;
|
||
sumZ += p.z;
|
||
}
|
||
frontCenter = {
|
||
x: sumX / frontPoints.length,
|
||
y: sumY / frontPoints.length,
|
||
z: sumZ / frontPoints.length
|
||
};
|
||
return frontCenter;
|
||
}
|
||
|
||
return boundsCenter;
|
||
}
|
||
|
||
/**
|
||
* 鑾峰彇鍦烘櫙鍗婂緞锛堢敤浜庤窛绂诲綊涓€鍖栵級
|
||
*/
|
||
function getSceneBoundsSize() {
|
||
if (currentSplat?.bounds) {
|
||
const size = currentSplat.bounds.size();
|
||
return {
|
||
x: Math.abs(Number(size.x) || 0),
|
||
y: Math.abs(Number(size.y) || 0),
|
||
z: Math.abs(Number(size.z) || 0),
|
||
};
|
||
}
|
||
|
||
if (!currentSplat?.data?.positions) {
|
||
return null;
|
||
}
|
||
|
||
const positions = currentSplat.data.positions;
|
||
const count = positions.length / 3;
|
||
if (count === 0) return null;
|
||
|
||
let minX = Infinity, maxX = -Infinity;
|
||
let minY = Infinity, maxY = -Infinity;
|
||
let minZ = Infinity, maxZ = -Infinity;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const x = positions[i * 3];
|
||
const y = positions[i * 3 + 1];
|
||
const z = positions[i * 3 + 2];
|
||
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
|
||
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
|
||
minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
|
||
}
|
||
|
||
return {
|
||
x: maxX - minX,
|
||
y: maxY - minY,
|
||
z: maxZ - minZ,
|
||
};
|
||
}
|
||
|
||
function getSceneRadius() {
|
||
if (!currentSplat?.data?.positions) {
|
||
return 5; // 榛樿鍊?
|
||
}
|
||
|
||
// 濡傛灉鏈塨ounds锛屼娇鐢╞ounds灏哄
|
||
if (currentSplat.bounds) {
|
||
const size = currentSplat.bounds.size();
|
||
return Math.max(size.x, size.y, size.z) / 2;
|
||
}
|
||
|
||
// 鍚﹀垯璁$畻鐐逛簯鐨勮竟鐣?
|
||
const positions = currentSplat.data.positions;
|
||
const count = positions.length / 3;
|
||
if (count === 0) return 5;
|
||
|
||
let minX = Infinity, maxX = -Infinity;
|
||
let minY = Infinity, maxY = -Infinity;
|
||
let minZ = Infinity, maxZ = -Infinity;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const x = positions[i * 3];
|
||
const y = positions[i * 3 + 1];
|
||
const z = positions[i * 3 + 2];
|
||
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
|
||
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
|
||
minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
|
||
}
|
||
|
||
const sizeX = maxX - minX;
|
||
const sizeY = maxY - minY;
|
||
const sizeZ = maxZ - minZ;
|
||
|
||
return Math.max(sizeX, sizeY, sizeZ) / 2 || 5;
|
||
}
|
||
|
||
function getRecommendedInitialCameraDistance() {
|
||
const size = getSceneBoundsSize();
|
||
if (!size) {
|
||
return DEFAULT_INIT_CAMERA_DISTANCE;
|
||
}
|
||
|
||
const viewportWidth = Math.max(canvas?.clientWidth || originalOutputWidth || 512, 1);
|
||
const viewportHeight = Math.max(canvas?.clientHeight || originalOutputHeight || 512, 1);
|
||
const fx = Number(camera?.data?.fx);
|
||
const fy = Number(camera?.data?.fy);
|
||
const halfX = Math.max(size.x, 0) * 0.5;
|
||
const halfY = Math.max(size.y, 0) * 0.5;
|
||
const halfZ = Math.max(size.z, 0) * 0.5;
|
||
|
||
let fitDistance = 0;
|
||
if (Number.isFinite(fx) && fx > 1e-6) {
|
||
fitDistance = Math.max(fitDistance, (halfX * fx) / Math.max(viewportWidth * 0.5, 1));
|
||
}
|
||
if (Number.isFinite(fy) && fy > 1e-6) {
|
||
fitDistance = Math.max(fitDistance, (halfY * fy) / Math.max(viewportHeight * 0.5, 1));
|
||
}
|
||
|
||
const sceneRadius = Number(getSceneRadius());
|
||
if (Number.isFinite(sceneRadius) && sceneRadius > 0) {
|
||
fitDistance = Math.max(fitDistance, sceneRadius * 1.15);
|
||
}
|
||
|
||
fitDistance = (fitDistance + halfZ) * INITIAL_CAMERA_DISTANCE_MARGIN;
|
||
if (!Number.isFinite(fitDistance) || fitDistance <= 0) {
|
||
return DEFAULT_INIT_CAMERA_DISTANCE;
|
||
}
|
||
|
||
return Math.max(MIN_INIT_CAMERA_DISTANCE, fitDistance);
|
||
}
|
||
|
||
function getSceneBoundsCenter() {
|
||
if (currentSplat?.bounds) {
|
||
const c = currentSplat.bounds.center();
|
||
return { x: Number(c.x) || 0, y: Number(c.y) || 0, z: Number(c.z) || 0 };
|
||
}
|
||
if (!currentSplat?.data?.positions) {
|
||
return { x: 0, y: 0, z: 0 };
|
||
}
|
||
const positions = currentSplat.data.positions;
|
||
const count = Math.floor(positions.length / 3);
|
||
if (count <= 0) {
|
||
return { x: 0, y: 0, z: 0 };
|
||
}
|
||
let sx = 0, sy = 0, sz = 0;
|
||
for (let i = 0; i < count; i++) {
|
||
sx += positions[i * 3];
|
||
sy += positions[i * 3 + 1];
|
||
sz += positions[i * 3 + 2];
|
||
}
|
||
return { x: sx / count, y: sy / count, z: sz / count };
|
||
}
|
||
|
||
function computeDefaultOrbitCenter(cameraPos, baseTarget) {
|
||
const cam = cloneCenter(cameraPos);
|
||
const target = cloneCenter(baseTarget);
|
||
if (!cam || !target) return target || cam || { x: 0, y: 0, z: 0 };
|
||
|
||
const vx = target.x - cam.x;
|
||
const vy = target.y - cam.y;
|
||
const vz = target.z - cam.z;
|
||
const baseDist = Math.sqrt(vx * vx + vy * vy + vz * vz);
|
||
if (!(baseDist > 1e-6)) return target;
|
||
|
||
const inv = 1.0 / baseDist;
|
||
const dirX = vx * inv;
|
||
const dirY = vy * inv;
|
||
const dirZ = vz * inv;
|
||
|
||
let desiredDist = baseDist;
|
||
const sceneCenter = getSceneBoundsCenter();
|
||
if (sceneCenter) {
|
||
const toCenterX = sceneCenter.x - cam.x;
|
||
const toCenterY = sceneCenter.y - cam.y;
|
||
const toCenterZ = sceneCenter.z - cam.z;
|
||
const projectedDepth = toCenterX * dirX + toCenterY * dirY + toCenterZ * dirZ;
|
||
if (Number.isFinite(projectedDepth) && projectedDepth > desiredDist) {
|
||
desiredDist = projectedDepth;
|
||
}
|
||
}
|
||
|
||
const sceneRadius = Number(getSceneRadius());
|
||
if (Number.isFinite(sceneRadius) && sceneRadius > 0) {
|
||
desiredDist = Math.max(desiredDist, sceneRadius * 1.8);
|
||
}
|
||
|
||
if (desiredDist <= baseDist + 1e-6) return target;
|
||
// Pull back 50% from the expanded orbit distance to keep rotation feel closer.
|
||
desiredDist = baseDist + (desiredDist - baseDist) * 0.5;
|
||
return {
|
||
x: cam.x + dirX * desiredDist,
|
||
y: cam.y + dirY * desiredDist,
|
||
z: cam.z + dirZ * desiredDist,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 鏍规嵁瑙掑害鑾峰彇鏂逛綅瑙掓爣绛?
|
||
*/
|
||
function getAzimuthLabel(angle) {
|
||
for (const range of AZIMUTH_LABELS) {
|
||
if (angle >= range.min && angle < range.max) {
|
||
return range.label;
|
||
}
|
||
}
|
||
return 'front view'; // 榛樿
|
||
}
|
||
|
||
/**
|
||
* 鏍规嵁瑙掑害鑾峰彇浠拌鏍囩
|
||
*/
|
||
function getElevationLabel(angle) {
|
||
for (const range of ELEVATION_LABELS) {
|
||
if (angle >= range.min && angle < range.max) {
|
||
return range.label;
|
||
}
|
||
}
|
||
return 'eye level'; // 榛樿
|
||
}
|
||
|
||
/**
|
||
* 鏍规嵁璺濈鑾峰彇缂╂斁鏍囩
|
||
*/
|
||
function getZoomLabel(distance) {
|
||
for (const range of ZOOM_LABELS) {
|
||
if (distance >= range.min && distance < range.max) {
|
||
return range.label;
|
||
}
|
||
}
|
||
return 'medium shot'; // 榛樿
|
||
}
|
||
|
||
/**
|
||
* 鐢熸垚鐩告満鎻忚堪鏂囨湰
|
||
* 鏍煎紡: {鏂瑰悜鏍囩}, {浠拌鏍囩}, {缂╂斁鏍囩} (horizontal: {姘村钩瑙拀, vertical: {鍨傜洿瑙拀, zoom: {缂╂斁鍊紏)
|
||
*/
|
||
function getSliderZoom() {
|
||
const v = parseFloat(document.getElementById('zoomInput')?.value ?? document.getElementById('zoomSlider')?.value);
|
||
return isNaN(v) ? (cameraParams.distance || DEFAULT_CAMERA_DISTANCE) : v;
|
||
}
|
||
|
||
function getCurrentFocalLengthValue(fallback = initialFocalLength) {
|
||
const rawValue = parseFloat(focalLengthValue?.value ?? focalLengthSlider?.value);
|
||
return Number.isFinite(rawValue) ? rawValue : fallback;
|
||
}
|
||
|
||
function resetFocalLengthToDefault(options = {}) {
|
||
const { applyToCamera = false, updateInitialCameraData = false } = options;
|
||
const defaultValue = Number.isFinite(Number(initialFocalLength))
|
||
? Number(initialFocalLength)
|
||
: 16;
|
||
|
||
if (focalLengthSlider) focalLengthSlider.value = defaultValue;
|
||
if (focalLengthValue) focalLengthValue.value = defaultValue;
|
||
|
||
if (applyToCamera) {
|
||
applyFocalLengthToCamera(defaultValue, { updateInitialCameraData });
|
||
}
|
||
|
||
return defaultValue;
|
||
}
|
||
|
||
function normalizeSignedAngle(angle) {
|
||
let normalized = Number(angle);
|
||
if (!Number.isFinite(normalized)) return 0;
|
||
normalized = ((normalized + 180) % 360 + 360) % 360 - 180;
|
||
if (Math.abs(normalized) < 1e-9) return 0;
|
||
return normalized;
|
||
}
|
||
|
||
function roundPoseNumber(value, digits = 2) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return 0;
|
||
return Number(num.toFixed(digits));
|
||
}
|
||
|
||
function formatPoseNumber(value, digits = 2) {
|
||
return String(roundPoseNumber(value, digits));
|
||
}
|
||
|
||
function roundPoseInteger(value) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return 0;
|
||
return Math.round(num);
|
||
}
|
||
|
||
function computePoseFromPositionAndTarget(position, targetCenter) {
|
||
const point = cloneCenter(position);
|
||
const target = cloneCenter(targetCenter);
|
||
if (!point || !target) return null;
|
||
|
||
try {
|
||
const pose = CT.GSplatAdapter.fromGSplatPosition(point.x, point.y, point.z, target);
|
||
if (!pose) return null;
|
||
const azimuth = Number(pose.azimuth);
|
||
const elevation = Number(pose.elevation);
|
||
const distance = Number(pose.zoom);
|
||
if (!Number.isFinite(azimuth) || !Number.isFinite(elevation) || !Number.isFinite(distance)) {
|
||
return null;
|
||
}
|
||
return { azimuth, elevation, distance };
|
||
} catch (error) {
|
||
console.warn('[GaussianViewer] Failed to compute pose from position/target:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function generateBaseCameraDescription(params = null) {
|
||
params = params || calculateCameraParams();
|
||
if (!params) return '';
|
||
|
||
const zoom = getSliderZoom();
|
||
const azimuthLabel = getAzimuthLabel(params.azimuth);
|
||
const elevationLabel = getElevationLabel(params.elevation);
|
||
const zoomLabel = getZoomLabel(zoom);
|
||
|
||
return `${azimuthLabel}, ${elevationLabel}, ${zoomLabel} (horizontal: ${Math.round(params.azimuth)}, vertical: ${Math.round(params.elevation)}, zoom: ${zoom.toFixed(1)})`;
|
||
}
|
||
|
||
function buildSixDofPose(params = null) {
|
||
params = params || calculateCameraParams();
|
||
const currentPosition = camera ? {
|
||
x: camera.position.x,
|
||
y: camera.position.y,
|
||
z: camera.position.z,
|
||
} : null;
|
||
const initialPosition = cloneCenter(initialCameraData?.position);
|
||
const initialTarget = cloneCenter(initialCameraData?.target);
|
||
const initialPose = computePoseFromPositionAndTarget(initialPosition, initialTarget);
|
||
|
||
const dx = currentPosition && initialPosition ? (currentPosition.x - initialPosition.x) : 0;
|
||
const dy = currentPosition && initialPosition ? (currentPosition.y - initialPosition.y) : 0;
|
||
const dz = currentPosition && initialPosition ? (currentPosition.z - initialPosition.z) : 0;
|
||
const pitch = Number(params?.elevation) - Number(initialPose?.elevation || 0);
|
||
const yaw = normalizeSignedAngle(Number(params?.azimuth) - Number(initialPose?.azimuth || 0));
|
||
const roll = Number(params?.roll) - Number(initialPose?.roll || 0);
|
||
|
||
return {
|
||
x: roundPoseInteger(dx),
|
||
y: roundPoseInteger(dy),
|
||
z: roundPoseInteger(dz),
|
||
pitch: roundPoseInteger(pitch),
|
||
yaw: roundPoseInteger(yaw),
|
||
roll: roundPoseInteger(roll),
|
||
};
|
||
}
|
||
|
||
function generateSixDofDescription(params = null) {
|
||
const pose = buildSixDofPose(params);
|
||
return `{"x":${formatPoseNumber(pose.x)},"y":${formatPoseNumber(pose.y)},"z":${formatPoseNumber(pose.z)},"pitch":${formatPoseNumber(pose.pitch)},"yaw":${formatPoseNumber(pose.yaw)},"roll":${formatPoseNumber(pose.roll)}}`;
|
||
}
|
||
|
||
function applyFocalLengthToCamera(value, options = {}) {
|
||
if (!camera) return;
|
||
const nextValue = Number.isFinite(Number(value)) ? Number(value) : getCurrentFocalLengthValue();
|
||
const sensorWidth = 36;
|
||
const canvasWidth = canvas.clientWidth || 512;
|
||
const focalPx = (canvasWidth * nextValue) / sensorWidth;
|
||
camera.data.fx = focalPx;
|
||
camera.data.fy = focalPx;
|
||
if (typeof camera.update === 'function') {
|
||
camera.update();
|
||
}
|
||
if (options.updateInitialCameraData && initialCameraData) {
|
||
initialCameraData.fx = focalPx;
|
||
initialCameraData.fy = focalPx;
|
||
}
|
||
}
|
||
|
||
function generateCameraDescription(params = null) {
|
||
params = params || calculateCameraParams();
|
||
if (!params) return '';
|
||
|
||
const baseDescription = generateBaseCameraDescription(params);
|
||
const sixDofDescription = generateSixDofDescription(params);
|
||
return `${baseDescription}\n\n6DoF: ${sixDofDescription}`;
|
||
}
|
||
|
||
/**
|
||
* 鏇存柊鐩告満鍙傛暟鏄剧ず
|
||
*/
|
||
function updateCameraParamsDisplay(params = null) {
|
||
params = params || calculateCameraParams();
|
||
if (!params) return;
|
||
|
||
const azimuthLabel = getAzimuthLabel(params.azimuth);
|
||
const elevationLabel = getElevationLabel(params.elevation);
|
||
const zoomLabel = getZoomLabel(params.distance);
|
||
|
||
// 鏇存柊鏁板€兼樉绀猴紙鏁存暟锛?
|
||
const azimuthValueEl = document.getElementById('azimuthValue');
|
||
const elevationValueEl = document.getElementById('elevationValue');
|
||
const zoomValueEl = document.getElementById('zoomValue');
|
||
|
||
if (azimuthValueEl) azimuthValueEl.textContent = `${params.azimuth}°`;
|
||
if (elevationValueEl) elevationValueEl.textContent = `${params.elevation}°`;
|
||
if (zoomValueEl) zoomValueEl.textContent = params.distance;
|
||
|
||
// 鏇存柊鏍囩鏄剧ず
|
||
const azimuthLabelEl = document.getElementById('azimuthLabel');
|
||
const elevationLabelEl = document.getElementById('elevationLabel');
|
||
const zoomLabelEl = document.getElementById('zoomLabel');
|
||
|
||
if (azimuthLabelEl) azimuthLabelEl.textContent = azimuthLabel;
|
||
if (elevationLabelEl) elevationLabelEl.textContent = elevationLabel;
|
||
if (zoomLabelEl) zoomLabelEl.textContent = zoomLabel;
|
||
|
||
// 鏇存柊瀹屾暣鎻忚堪
|
||
const descriptionEl = document.getElementById('cameraDescription');
|
||
if (descriptionEl) {
|
||
descriptionEl.value = generateCameraDescription(params);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 璁剧疆鐩告満鍙傛暟闈㈡澘浜や簰
|
||
*/
|
||
function setupCameraParamsPanel() {
|
||
const panel = document.getElementById('cameraParamsPanel');
|
||
const toggleBtn = document.getElementById('toggleCameraParams');
|
||
const copyBtn = document.getElementById('copyCameraDesc');
|
||
|
||
// 鎶樺彔/灞曞紑闈㈡澘
|
||
if (toggleBtn && panel) {
|
||
toggleBtn.addEventListener('click', () => {
|
||
panel.classList.toggle('collapsed');
|
||
toggleBtn.textContent = panel.classList.contains('collapsed') ? '+' : '-';
|
||
});
|
||
}
|
||
|
||
// 澶嶅埗鎻忚堪鍒板壀璐存澘
|
||
if (copyBtn) {
|
||
copyBtn.addEventListener('click', async () => {
|
||
const descriptionEl = document.getElementById('cameraDescription');
|
||
if (!descriptionEl) return;
|
||
|
||
const text = descriptionEl.value;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
|
||
// 鏄剧ず澶嶅埗鎴愬姛鍙嶉
|
||
const originalText = copyBtn.textContent;
|
||
copyBtn.textContent = '✓ 已复制!';
|
||
copyBtn.classList.add('copied');
|
||
|
||
setTimeout(() => {
|
||
copyBtn.textContent = originalText;
|
||
copyBtn.classList.remove('copied');
|
||
}, 1500);
|
||
|
||
console.log('[GaussianViewer] Camera description copied:', text);
|
||
} catch (err) {
|
||
console.error('[GaussianViewer] Failed to copy:', err);
|
||
|
||
// 闄嶇骇鏂规锛氶€変腑鏂囨湰
|
||
descriptionEl.select();
|
||
document.execCommand('copy');
|
||
|
||
const originalText = copyBtn.textContent;
|
||
copyBtn.textContent = '✓ 已复制!';
|
||
copyBtn.classList.add('copied');
|
||
|
||
setTimeout(() => {
|
||
copyBtn.textContent = originalText;
|
||
copyBtn.classList.remove('copied');
|
||
}, 1500);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateCursorPosition(e) {
|
||
if (!camera || !canvas) return;
|
||
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mouseX = e.clientX - rect.left;
|
||
const mouseY = e.clientY - rect.top;
|
||
const canvasWidth = canvas.clientWidth;
|
||
const canvasHeight = canvas.clientHeight;
|
||
|
||
// 灏嗛紶鏍囦綅缃浆鎹负褰掍竴鍖栬澶囧潗鏍?(-1鍒?)
|
||
const ndcX = (mouseX / canvasWidth) * 2 - 1;
|
||
const ndcY = -((mouseY / canvasHeight) * 2 - 1); // Y杞寸炕杞?
|
||
|
||
// 浣跨敤鐩告満鎶曞奖鐭╅樀閫嗗彉鎹㈣幏鍙栦笘鐣屽潗鏍?
|
||
// 绠€鍖栬绠楋細鍋囪鍦ㄧ浉鏈哄墠鏂逛竴瀹氳窛绂荤殑骞抽潰涓?
|
||
const fov = camera.data.fx; // 鐒﹁窛锛堝儚绱狅級
|
||
const distance = 5.0; // 鍋囪璺濈鐩告満鐨勬繁搴?
|
||
|
||
// 璁$畻鐩告満鍧愭爣绯讳腑鐨勪綅缃?
|
||
const camX = ndcX * (canvasWidth / (2 * camera.data.fx)) * distance;
|
||
const camY = ndcY * (canvasHeight / (2 * camera.data.fy)) * distance;
|
||
const camZ = -distance; // 鐩告満鏈濆悜-Z鏂瑰悜
|
||
|
||
// 杞崲鍒颁笘鐣屽潗鏍囩郴锛堢畝鍖栫増锛屼笉鑰冭檻鏃嬭浆锛?
|
||
const worldX = camX + camera.position.x;
|
||
const worldY = camY + camera.position.y;
|
||
const worldZ = camZ + camera.position.z;
|
||
|
||
if (cursorPosDisplay) {
|
||
cursorPosDisplay.textContent = `X:${worldX.toFixed(1)} Y:${worldY.toFixed(1)} Z:${worldZ.toFixed(1)}`;
|
||
}
|
||
}
|
||
|
||
function setTool(tool) {
|
||
currentTool = tool;
|
||
document.querySelectorAll('.tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === tool));
|
||
|
||
const isSelectionTool = (tool === 'rect' || tool === 'lasso');
|
||
const isGizmoTool = (tool === 'translate' || tool === 'rotate');
|
||
|
||
if (!customOrbitEnabled) controls.enabled = !isSelectionTool && !isGizmoTool;
|
||
selectionCanvas.style.pointerEvents = isSelectionTool ? 'auto' : 'none';
|
||
|
||
if (!isGizmoTool) clearGizmo();
|
||
if (centerPickMode && tool !== 'orbit' && tool !== 'pan') {
|
||
setCenterPickMode(false);
|
||
}
|
||
|
||
if (tool !== 'orbit' && tool !== 'pan') {
|
||
clearKeyboardCameraKeys();
|
||
syncOrbitControlPrecision();
|
||
} else if (!isTextEntryElement(document.activeElement)) {
|
||
focusViewerCanvas();
|
||
syncOrbitControlPrecision();
|
||
}
|
||
|
||
updateMainCanvasCursor();
|
||
}
|
||
|
||
function setupSelectionEvents() {
|
||
selectionCanvas.addEventListener('mousedown', (e) => {
|
||
if (currentTool !== 'rect' && currentTool !== 'lasso') return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
const rect = selectionCanvas.getBoundingClientRect();
|
||
isSelecting = true;
|
||
selectionStart = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
lassoPoints = [{ ...selectionStart }];
|
||
});
|
||
|
||
selectionCanvas.addEventListener('mousemove', (e) => {
|
||
// 鏇存柊鍏夋爣浣嶇疆鏄剧ず
|
||
updateCursorPosition(e);
|
||
|
||
if (!isSelecting) return;
|
||
const rect = selectionCanvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left, y = e.clientY - rect.top;
|
||
selCtx.clearRect(0, 0, selectionCanvas.width, selectionCanvas.height);
|
||
selCtx.strokeStyle = '#00ff00'; selCtx.fillStyle = 'rgba(0, 255, 0, 0.1)';
|
||
selCtx.lineWidth = 2; selCtx.setLineDash([5, 5]);
|
||
if (currentTool === 'rect') {
|
||
selCtx.strokeRect(selectionStart.x, selectionStart.y, x - selectionStart.x, y - selectionStart.y);
|
||
selCtx.fillRect(selectionStart.x, selectionStart.y, x - selectionStart.x, y - selectionStart.y);
|
||
} else {
|
||
lassoPoints.push({ x, y });
|
||
selCtx.beginPath(); selCtx.moveTo(lassoPoints[0].x, lassoPoints[0].y);
|
||
lassoPoints.forEach(p => selCtx.lineTo(p.x, p.y));
|
||
selCtx.closePath(); selCtx.stroke(); selCtx.fill();
|
||
}
|
||
});
|
||
|
||
selectionCanvas.addEventListener('mouseup', (e) => {
|
||
if (!isSelecting) return;
|
||
isSelecting = false;
|
||
const rect = selectionCanvas.getBoundingClientRect();
|
||
const endX = e.clientX - rect.left, endY = e.clientY - rect.top;
|
||
if (currentTool === 'rect') selectPointsInRect(selectionStart.x, selectionStart.y, endX, endY, e.shiftKey);
|
||
else selectPointsInLasso(lassoPoints, e.shiftKey);
|
||
selCtx.clearRect(0, 0, selectionCanvas.width, selectionCanvas.height);
|
||
lassoPoints = [];
|
||
});
|
||
|
||
selectionCanvas.addEventListener('mouseleave', () => {
|
||
if (isSelecting) { isSelecting = false; selCtx.clearRect(0, 0, selectionCanvas.width, selectionCanvas.height); lassoPoints = []; }
|
||
});
|
||
}
|
||
|
||
function setupKeyboard() {
|
||
if (canvas) {
|
||
canvas.tabIndex = 0;
|
||
canvas.style.outline = 'none';
|
||
canvas.addEventListener('mousedown', () => {
|
||
if (currentTool === 'orbit' || currentTool === 'pan') {
|
||
focusViewerCanvas();
|
||
}
|
||
}, true);
|
||
canvas.addEventListener('wheel', () => {
|
||
if (currentTool === 'orbit' || currentTool === 'pan') {
|
||
focusViewerCanvas();
|
||
}
|
||
}, { passive: true });
|
||
canvas.addEventListener('blur', () => {
|
||
clearKeyboardCameraKeys();
|
||
syncOrbitControlPrecision();
|
||
});
|
||
}
|
||
|
||
if (viewerContainer) {
|
||
viewerContainer.addEventListener('mousedown', (e) => {
|
||
if (!isTextEntryElement(e.target) && (currentTool === 'orbit' || currentTool === 'pan')) {
|
||
focusViewerCanvas();
|
||
}
|
||
}, true);
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (isTextEntryElement(e.target)) return;
|
||
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') {
|
||
setKeyboardCameraKeyState(e.code, true);
|
||
syncOrbitControlPrecision(e);
|
||
return;
|
||
}
|
||
if (shouldHandleKeyboardCameraControl() && isKeyboardCameraKey(e.code)) {
|
||
setKeyboardCameraKeyState(e.code, true);
|
||
syncOrbitControlPrecision(e);
|
||
if (e.code !== 'ShiftLeft' && e.code !== 'ShiftRight') {
|
||
e.preventDefault();
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelected(); }
|
||
else if (e.key === 'Escape') {
|
||
clearKeyboardCameraKeys();
|
||
if (centerPickMode) {
|
||
setCenterPickMode(false);
|
||
updateStatus();
|
||
return;
|
||
}
|
||
clearSelection();
|
||
setTool('orbit');
|
||
}
|
||
else if (e.key === 'i' || e.key === 'I') invertSelection();
|
||
else if (e.key === 'v' || e.key === 'V') setTool('orbit');
|
||
else if (e.key === 'h' || e.key === 'H') setTool('pan');
|
||
else if (e.key === 'b' || e.key === 'B') setTool('rect');
|
||
else if (e.key === 'l' || e.key === 'L') setTool('lasso');
|
||
else if (e.key === 'g' || e.key === 'G') setTool('translate');
|
||
else if (e.key === 't' || e.key === 'T') setTool('rotate');
|
||
});
|
||
|
||
document.addEventListener('keyup', (e) => {
|
||
if (isKeyboardCameraKey(e.code)) {
|
||
setKeyboardCameraKeyState(e.code, false);
|
||
syncOrbitControlPrecision(e);
|
||
}
|
||
});
|
||
|
||
window.addEventListener('blur', () => {
|
||
clearKeyboardCameraKeys();
|
||
syncOrbitControlPrecision();
|
||
});
|
||
}
|
||
|
||
|
||
// ============== Gizmo Drawing ==============
|
||
|
||
function updateGizmoCenter() {
|
||
if (selectedIndices.size === 0 || !currentSplat?.data) return;
|
||
const positions = currentSplat.data.positions;
|
||
let cx = 0, cy = 0, cz = 0;
|
||
for (const idx of selectedIndices) {
|
||
cx += positions[idx * 3]; cy += positions[idx * 3 + 1]; cz += positions[idx * 3 + 2];
|
||
}
|
||
gizmoCenter.x = cx / selectedIndices.size;
|
||
gizmoCenter.y = cy / selectedIndices.size;
|
||
gizmoCenter.z = cz / selectedIndices.size;
|
||
}
|
||
|
||
function updateGizmoScreenPosition() {
|
||
updateGizmoCenter();
|
||
|
||
// Apply preview translation for smooth visual feedback
|
||
const worldPos = {
|
||
x: gizmoCenter.x + previewTranslation.x,
|
||
y: gizmoCenter.y + previewTranslation.y,
|
||
z: gizmoCenter.z + previewTranslation.z
|
||
};
|
||
|
||
const screen = projectPoint(worldPos);
|
||
if (screen) {
|
||
gizmoScreenPos.x = screen.x;
|
||
gizmoScreenPos.y = screen.y;
|
||
}
|
||
}
|
||
|
||
function clearGizmo() {
|
||
gizmoCtx.clearRect(0, 0, gizmoCanvas.width, gizmoCanvas.height);
|
||
}
|
||
|
||
function drawOrbitCenterFeedback(now = performance.now()) {
|
||
if (!hasActiveOrbitCenterFeedback(now)) return;
|
||
const screen = projectPoint(orbitCenterFeedback.center);
|
||
if (!screen) return;
|
||
|
||
const remaining = Math.max(0, orbitCenterFeedback.expiresAt - now);
|
||
const t = remaining / ORBIT_CENTER_FEEDBACK_DURATION_MS;
|
||
const alpha = 0.25 + t * 0.75;
|
||
const radius = 8 + (1 - t) * 6;
|
||
const crossRadius = radius + 5;
|
||
|
||
gizmoCtx.save();
|
||
gizmoCtx.globalAlpha = alpha;
|
||
gizmoCtx.strokeStyle = '#ff3b30';
|
||
gizmoCtx.fillStyle = '#ff3b30';
|
||
gizmoCtx.lineWidth = 2.5;
|
||
gizmoCtx.shadowColor = 'rgba(255, 59, 48, 0.55)';
|
||
gizmoCtx.shadowBlur = 10;
|
||
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.arc(screen.x, screen.y, radius, 0, Math.PI * 2);
|
||
gizmoCtx.stroke();
|
||
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.arc(screen.x, screen.y, 3.5, 0, Math.PI * 2);
|
||
gizmoCtx.fill();
|
||
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(screen.x - crossRadius, screen.y);
|
||
gizmoCtx.lineTo(screen.x + crossRadius, screen.y);
|
||
gizmoCtx.moveTo(screen.x, screen.y - crossRadius);
|
||
gizmoCtx.lineTo(screen.x, screen.y + crossRadius);
|
||
gizmoCtx.stroke();
|
||
|
||
gizmoCtx.restore();
|
||
}
|
||
|
||
function drawGizmo() {
|
||
clearGizmo();
|
||
if (selectedIndices.size > 0) {
|
||
camera.update();
|
||
const viewMatrix = camera.data.viewMatrix?.buffer;
|
||
if (!viewMatrix) {
|
||
drawOrbitCenterFeedback();
|
||
return;
|
||
}
|
||
const cx = gizmoScreenPos.x, cy = gizmoScreenPos.y;
|
||
|
||
if (currentTool === 'translate') drawTranslateGizmo(cx, cy, viewMatrix);
|
||
else if (currentTool === 'rotate') drawRotateGizmo(cx, cy, viewMatrix);
|
||
|
||
// Show transform info during drag
|
||
if (isDraggingGizmo) {
|
||
gizmoCtx.fillStyle = '#fff';
|
||
gizmoCtx.font = 'bold 14px sans-serif';
|
||
gizmoCtx.textAlign = 'center';
|
||
gizmoCtx.shadowColor = '#000';
|
||
gizmoCtx.shadowBlur = 4;
|
||
|
||
let axisName = activeAxis?.toUpperCase() || '';
|
||
if (activeAxis === 'view') axisName = '视角';
|
||
|
||
if (currentTool === 'translate') {
|
||
const dist = Math.sqrt(previewTranslation.x**2 + previewTranslation.y**2 + previewTranslation.z**2);
|
||
gizmoCtx.fillText(`${axisName} 移动: ${dist.toFixed(3)}`, cx, cy - GIZMO_SIZE - 25);
|
||
} else {
|
||
gizmoCtx.fillText(`${axisName} 旋转: ${previewRotationAngle.toFixed(1)}°`, cx, cy - GIZMO_SIZE - 25);
|
||
}
|
||
gizmoCtx.shadowBlur = 0;
|
||
}
|
||
}
|
||
drawOrbitCenterFeedback();
|
||
}
|
||
|
||
function getScreenAxes(viewMatrix, length) {
|
||
const axes = {};
|
||
const vecs = { x: [1,0,0], y: [0,1,0], z: [0,0,1] };
|
||
for (const [name, v] of Object.entries(vecs)) {
|
||
const vx = viewMatrix[0]*v[0] + viewMatrix[4]*v[1] + viewMatrix[8]*v[2];
|
||
const vy = viewMatrix[1]*v[0] + viewMatrix[5]*v[1] + viewMatrix[9]*v[2];
|
||
axes[name] = { x: vx * length, y: -vy * length };
|
||
}
|
||
return axes;
|
||
}
|
||
|
||
function drawTranslateGizmo(cx, cy, viewMatrix) {
|
||
const axes = getScreenAxes(viewMatrix, GIZMO_SIZE);
|
||
|
||
// Draw axis lines and arrows
|
||
for (const axis of ['x', 'y', 'z']) {
|
||
const ax = axes[axis];
|
||
const isActive = activeAxis === axis || hoverAxis === axis;
|
||
const color = isActive ? AXIS_COLORS_BRIGHT[axis] : AXIS_COLORS[axis];
|
||
|
||
// Line
|
||
gizmoCtx.strokeStyle = color;
|
||
gizmoCtx.lineWidth = isActive ? 4 : 2;
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(cx, cy);
|
||
gizmoCtx.lineTo(cx + ax.x, cy + ax.y);
|
||
gizmoCtx.stroke();
|
||
|
||
// Arrow head
|
||
const len = Math.sqrt(ax.x**2 + ax.y**2);
|
||
if (len > 10) {
|
||
const nx = ax.x/len, ny = ax.y/len;
|
||
const arrowSize = isActive ? 14 : 10;
|
||
gizmoCtx.fillStyle = color;
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(cx + ax.x, cy + ax.y);
|
||
gizmoCtx.lineTo(cx + ax.x - nx*arrowSize - ny*arrowSize*0.4, cy + ax.y - ny*arrowSize + nx*arrowSize*0.4);
|
||
gizmoCtx.lineTo(cx + ax.x - nx*arrowSize + ny*arrowSize*0.4, cy + ax.y - ny*arrowSize - nx*arrowSize*0.4);
|
||
gizmoCtx.closePath();
|
||
gizmoCtx.fill();
|
||
}
|
||
}
|
||
|
||
// Draw plane squares
|
||
const planeSize = 0.25;
|
||
const planes = [
|
||
{ name: 'xy', a: 'x', b: 'y', color: 'rgba(255,255,0,0.4)' },
|
||
{ name: 'xz', a: 'x', b: 'z', color: 'rgba(255,0,255,0.4)' },
|
||
{ name: 'yz', a: 'y', b: 'z', color: 'rgba(0,255,255,0.4)' }
|
||
];
|
||
for (const p of planes) {
|
||
const ax = axes[p.a], bx = axes[p.b];
|
||
const isActive = activeAxis === p.name || hoverAxis === p.name;
|
||
gizmoCtx.fillStyle = isActive ? p.color.replace('0.4', '0.7') : p.color;
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(cx + ax.x*planeSize, cy + ax.y*planeSize);
|
||
gizmoCtx.lineTo(cx + ax.x*planeSize + bx.x*planeSize, cy + ax.y*planeSize + bx.y*planeSize);
|
||
gizmoCtx.lineTo(cx + bx.x*planeSize, cy + bx.y*planeSize);
|
||
gizmoCtx.lineTo(cx, cy);
|
||
gizmoCtx.closePath();
|
||
gizmoCtx.fill();
|
||
}
|
||
|
||
// Center sphere
|
||
gizmoCtx.fillStyle = '#ffffff';
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.arc(cx, cy, 6, 0, Math.PI * 2);
|
||
gizmoCtx.fill();
|
||
}
|
||
|
||
function drawRotateGizmo(cx, cy, viewMatrix) {
|
||
const radius = GIZMO_SIZE;
|
||
|
||
// Draw rotation rings
|
||
for (const axis of ['x', 'y', 'z']) {
|
||
const isActive = activeAxis === axis;
|
||
const isHover = hoverAxis === axis;
|
||
const color = (isActive || isHover) ? AXIS_COLORS_BRIGHT[axis] : AXIS_COLORS[axis];
|
||
|
||
// Draw the ring with preview arc if this axis is being dragged
|
||
if (isActive && isDraggingGizmo && Math.abs(previewRotationAngle) > 0.5) {
|
||
drawRotationRingWithPreview(cx, cy, radius, axis, viewMatrix, color);
|
||
} else {
|
||
drawRotationRing(cx, cy, radius, axis, viewMatrix, color, isActive || isHover);
|
||
}
|
||
}
|
||
|
||
// Outer view ring
|
||
const isViewActive = activeAxis === 'view';
|
||
const isViewHover = hoverAxis === 'view';
|
||
const viewHighlight = isViewActive || isViewHover;
|
||
gizmoCtx.strokeStyle = viewHighlight ? '#ffffff' : 'rgba(255,255,255,0.4)';
|
||
gizmoCtx.lineWidth = viewHighlight ? 4 : 2;
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.arc(cx, cy, radius * 1.15, 0, Math.PI * 2);
|
||
gizmoCtx.stroke();
|
||
|
||
// Draw view rotation preview
|
||
if (isViewActive && isDraggingGizmo && Math.abs(previewRotationAngle) > 0.5) {
|
||
drawViewRotationPreview(cx, cy, radius * 1.15);
|
||
}
|
||
|
||
// Center
|
||
gizmoCtx.fillStyle = '#ffff00';
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.arc(cx, cy, 5, 0, Math.PI * 2);
|
||
gizmoCtx.fill();
|
||
}
|
||
|
||
function drawRotationRingWithPreview(cx, cy, radius, axis, viewMatrix, color) {
|
||
const segments = 64;
|
||
const angleRad = previewRotationAngle * Math.PI / 180;
|
||
|
||
// First draw the full ring (dimmed)
|
||
gizmoCtx.strokeStyle = color;
|
||
gizmoCtx.lineWidth = 2;
|
||
gizmoCtx.globalAlpha = 0.3;
|
||
|
||
for (let i = 0; i < segments; i++) {
|
||
const a1 = (i / segments) * Math.PI * 2;
|
||
const a2 = ((i + 1) / segments) * Math.PI * 2;
|
||
|
||
let p1, p2;
|
||
if (axis === 'x') {
|
||
p1 = { x: 0, y: Math.cos(a1), z: Math.sin(a1) };
|
||
p2 = { x: 0, y: Math.cos(a2), z: Math.sin(a2) };
|
||
} else if (axis === 'y') {
|
||
p1 = { x: Math.cos(a1), y: 0, z: Math.sin(a1) };
|
||
p2 = { x: Math.cos(a2), y: 0, z: Math.sin(a2) };
|
||
} else {
|
||
p1 = { x: Math.cos(a1), y: Math.sin(a1), z: 0 };
|
||
p2 = { x: Math.cos(a2), y: Math.sin(a2), z: 0 };
|
||
}
|
||
|
||
const s1x = viewMatrix[0]*p1.x + viewMatrix[4]*p1.y + viewMatrix[8]*p1.z;
|
||
const s1y = viewMatrix[1]*p1.x + viewMatrix[5]*p1.y + viewMatrix[9]*p1.z;
|
||
const s2x = viewMatrix[0]*p2.x + viewMatrix[4]*p2.y + viewMatrix[8]*p2.z;
|
||
const s2y = viewMatrix[1]*p2.x + viewMatrix[5]*p2.y + viewMatrix[9]*p2.z;
|
||
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(cx + s1x * radius, cy - s1y * radius);
|
||
gizmoCtx.lineTo(cx + s2x * radius, cy - s2y * radius);
|
||
gizmoCtx.stroke();
|
||
}
|
||
|
||
gizmoCtx.globalAlpha = 1.0;
|
||
|
||
// Now draw the highlighted arc portion showing the rotation
|
||
// The arc goes from angle 0 to angleRad on the rotation plane
|
||
const arcSegments = Math.max(8, Math.abs(Math.round(previewRotationAngle / 5)));
|
||
const startA = 0;
|
||
const endA = angleRad;
|
||
|
||
// Draw filled arc on the ring
|
||
gizmoCtx.fillStyle = color.replace('#', 'rgba(').replace(/(..)(..)(..)/,
|
||
(m, r, g, b) => `${parseInt(r,16)}, ${parseInt(g,16)}, ${parseInt(b,16)}, 0.4)`);
|
||
|
||
// Build arc path
|
||
const arcPoints = [];
|
||
for (let i = 0; i <= arcSegments; i++) {
|
||
const t = i / arcSegments;
|
||
const a = startA + (endA - startA) * t;
|
||
|
||
let p;
|
||
if (axis === 'x') p = { x: 0, y: Math.cos(a), z: Math.sin(a) };
|
||
else if (axis === 'y') p = { x: Math.cos(a), y: 0, z: Math.sin(a) };
|
||
else p = { x: Math.cos(a), y: Math.sin(a), z: 0 };
|
||
|
||
const sx = viewMatrix[0]*p.x + viewMatrix[4]*p.y + viewMatrix[8]*p.z;
|
||
const sy = viewMatrix[1]*p.x + viewMatrix[5]*p.y + viewMatrix[9]*p.z;
|
||
arcPoints.push({ x: cx + sx * radius, y: cy - sy * radius });
|
||
}
|
||
|
||
// Draw filled sector
|
||
if (arcPoints.length > 1) {
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(cx, cy);
|
||
arcPoints.forEach((p, i) => {
|
||
if (i === 0) gizmoCtx.lineTo(p.x, p.y);
|
||
else gizmoCtx.lineTo(p.x, p.y);
|
||
});
|
||
gizmoCtx.closePath();
|
||
gizmoCtx.fill();
|
||
|
||
// Draw arc outline
|
||
gizmoCtx.strokeStyle = color;
|
||
gizmoCtx.lineWidth = 4;
|
||
gizmoCtx.beginPath();
|
||
arcPoints.forEach((p, i) => {
|
||
if (i === 0) gizmoCtx.moveTo(p.x, p.y);
|
||
else gizmoCtx.lineTo(p.x, p.y);
|
||
});
|
||
gizmoCtx.stroke();
|
||
|
||
// Draw radial lines
|
||
gizmoCtx.lineWidth = 2;
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(cx, cy);
|
||
gizmoCtx.lineTo(arcPoints[0].x, arcPoints[0].y);
|
||
gizmoCtx.moveTo(cx, cy);
|
||
gizmoCtx.lineTo(arcPoints[arcPoints.length - 1].x, arcPoints[arcPoints.length - 1].y);
|
||
gizmoCtx.stroke();
|
||
}
|
||
}
|
||
|
||
function drawViewRotationPreview(cx, cy, radius) {
|
||
const startAngle = Math.atan2(dragStartPos.y - cy, dragStartPos.x - cx);
|
||
const angleRad = previewRotationAngle * Math.PI / 180;
|
||
const endAngle = startAngle + angleRad;
|
||
|
||
// Draw filled arc
|
||
gizmoCtx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(cx, cy);
|
||
gizmoCtx.arc(cx, cy, radius * 0.85, startAngle, endAngle, angleRad < 0);
|
||
gizmoCtx.closePath();
|
||
gizmoCtx.fill();
|
||
|
||
// Draw arc outline
|
||
gizmoCtx.strokeStyle = '#ffffff';
|
||
gizmoCtx.lineWidth = 3;
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.arc(cx, cy, radius * 0.85, startAngle, endAngle, angleRad < 0);
|
||
gizmoCtx.stroke();
|
||
|
||
// Draw radial lines
|
||
gizmoCtx.lineWidth = 2;
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(cx, cy);
|
||
gizmoCtx.lineTo(cx + Math.cos(startAngle) * radius * 0.85, cy + Math.sin(startAngle) * radius * 0.85);
|
||
gizmoCtx.moveTo(cx, cy);
|
||
gizmoCtx.lineTo(cx + Math.cos(endAngle) * radius * 0.85, cy + Math.sin(endAngle) * radius * 0.85);
|
||
gizmoCtx.stroke();
|
||
}
|
||
|
||
function drawRotationRing(cx, cy, radius, axis, viewMatrix, color, isActive) {
|
||
const segments = 64;
|
||
gizmoCtx.strokeStyle = color;
|
||
gizmoCtx.lineWidth = isActive ? 4 : 2;
|
||
|
||
for (let i = 0; i < segments; i++) {
|
||
const a1 = (i / segments) * Math.PI * 2;
|
||
const a2 = ((i + 1) / segments) * Math.PI * 2;
|
||
|
||
let p1, p2;
|
||
if (axis === 'x') {
|
||
p1 = { x: 0, y: Math.cos(a1), z: Math.sin(a1) };
|
||
p2 = { x: 0, y: Math.cos(a2), z: Math.sin(a2) };
|
||
} else if (axis === 'y') {
|
||
p1 = { x: Math.cos(a1), y: 0, z: Math.sin(a1) };
|
||
p2 = { x: Math.cos(a2), y: 0, z: Math.sin(a2) };
|
||
} else {
|
||
p1 = { x: Math.cos(a1), y: Math.sin(a1), z: 0 };
|
||
p2 = { x: Math.cos(a2), y: Math.sin(a2), z: 0 };
|
||
}
|
||
|
||
// Transform to screen
|
||
const s1x = viewMatrix[0]*p1.x + viewMatrix[4]*p1.y + viewMatrix[8]*p1.z;
|
||
const s1y = viewMatrix[1]*p1.x + viewMatrix[5]*p1.y + viewMatrix[9]*p1.z;
|
||
const s1z = viewMatrix[2]*p1.x + viewMatrix[6]*p1.y + viewMatrix[10]*p1.z;
|
||
const s2z = viewMatrix[2]*p2.x + viewMatrix[6]*p2.y + viewMatrix[10]*p2.z;
|
||
const s2x = viewMatrix[0]*p2.x + viewMatrix[4]*p2.y + viewMatrix[8]*p2.z;
|
||
const s2y = viewMatrix[1]*p2.x + viewMatrix[5]*p2.y + viewMatrix[9]*p2.z;
|
||
|
||
// Depth-based alpha
|
||
const avgZ = (s1z + s2z) / 2;
|
||
gizmoCtx.globalAlpha = avgZ > 0 ? 1.0 : 0.25;
|
||
|
||
gizmoCtx.beginPath();
|
||
gizmoCtx.moveTo(cx + s1x * radius, cy - s1y * radius);
|
||
gizmoCtx.lineTo(cx + s2x * radius, cy - s2y * radius);
|
||
gizmoCtx.stroke();
|
||
}
|
||
gizmoCtx.globalAlpha = 1.0;
|
||
}
|
||
|
||
|
||
// ============== Gizmo Interaction ==============
|
||
|
||
function setupGizmoEvents() {
|
||
// Use document level events for reliable capture
|
||
document.addEventListener('mousedown', onGizmoMouseDown, true);
|
||
document.addEventListener('mousemove', onGizmoMouseMove, true);
|
||
document.addEventListener('mouseup', onGizmoMouseUp, true);
|
||
}
|
||
|
||
function onGizmoMouseDown(e) {
|
||
if (currentTool !== 'translate' && currentTool !== 'rotate') return;
|
||
if (selectedIndices.size === 0) return;
|
||
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left, y = e.clientY - rect.top;
|
||
if (x < 0 || x > rect.width || y < 0 || y > rect.height) return;
|
||
|
||
const hitAxis = getGizmoHitAxis(x, y);
|
||
if (!hitAxis) return;
|
||
|
||
activeAxis = hitAxis;
|
||
isDraggingGizmo = true;
|
||
dragStartPos = { x, y };
|
||
dragCurrentPos = { x, y };
|
||
previewTranslation = { x: 0, y: 0, z: 0 };
|
||
previewRotationAngle = 0;
|
||
|
||
// Store base data
|
||
if (currentSplat?.data) {
|
||
transformBasePositions = new Float32Array(currentSplat.data.positions);
|
||
transformBaseRotations = new Float32Array(currentSplat.data.rotations);
|
||
}
|
||
|
||
if (controls) controls.enabled = false;
|
||
canvas.style.cursor = 'grabbing';
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
|
||
function onGizmoMouseMove(e) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left, y = e.clientY - rect.top;
|
||
|
||
if (!isDraggingGizmo) {
|
||
// Hover detection
|
||
if ((currentTool === 'translate' || currentTool === 'rotate') && selectedIndices.size > 0) {
|
||
const newHover = getGizmoHitAxis(x, y);
|
||
if (newHover !== hoverAxis) {
|
||
hoverAxis = newHover;
|
||
canvas.style.cursor = hoverAxis ? 'pointer' : 'default';
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Update current position for preview drawing
|
||
dragCurrentPos = { x, y };
|
||
const dx = x - dragStartPos.x, dy = y - dragStartPos.y;
|
||
|
||
if (currentTool === 'translate') {
|
||
calculatePreviewTranslation(dx, dy);
|
||
} else {
|
||
calculatePreviewRotation(x, y);
|
||
}
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
|
||
function onGizmoMouseUp(e) {
|
||
if (!isDraggingGizmo) return;
|
||
|
||
// Apply the transform to actual data
|
||
if (currentTool === 'translate' && (previewTranslation.x !== 0 || previewTranslation.y !== 0 || previewTranslation.z !== 0)) {
|
||
applyTranslation();
|
||
} else if (currentTool === 'rotate' && Math.abs(previewRotationAngle) > 0.1) {
|
||
applyRotation();
|
||
}
|
||
|
||
// Reset state
|
||
isDraggingGizmo = false;
|
||
activeAxis = null;
|
||
hoverAxis = null;
|
||
previewTranslation = { x: 0, y: 0, z: 0 };
|
||
previewRotationAngle = 0;
|
||
transformBasePositions = null;
|
||
transformBaseRotations = null;
|
||
|
||
if (controls && !customOrbitEnabled) {
|
||
controls.enabled = (currentTool !== 'translate' && currentTool !== 'rotate');
|
||
}
|
||
canvas.style.cursor = 'default';
|
||
}
|
||
|
||
function getGizmoHitAxis(x, y) {
|
||
updateGizmoScreenPosition();
|
||
const cx = gizmoScreenPos.x, cy = gizmoScreenPos.y;
|
||
const dist = Math.sqrt((x - cx)**2 + (y - cy)**2);
|
||
|
||
if (currentTool === 'translate') {
|
||
const viewMatrix = camera.data.viewMatrix?.buffer;
|
||
if (!viewMatrix) return null;
|
||
const axes = getScreenAxes(viewMatrix, GIZMO_SIZE);
|
||
const threshold = 18;
|
||
|
||
// Check axes
|
||
for (const axis of ['x', 'y', 'z']) {
|
||
const ax = axes[axis];
|
||
if (distToSegment(x, y, cx, cy, cx + ax.x, cy + ax.y) < threshold) return axis;
|
||
}
|
||
|
||
// Check planes
|
||
const ps = GIZMO_SIZE * 0.25;
|
||
for (const plane of ['xy', 'xz', 'yz']) {
|
||
const a = axes[plane[0]], b = axes[plane[1]];
|
||
if (pointInQuad(x - cx, y - cy, 0, 0, a.x*ps, a.y*ps, a.x*ps+b.x*ps, a.y*ps+b.y*ps, b.x*ps, b.y*ps)) {
|
||
return plane;
|
||
}
|
||
}
|
||
} else if (currentTool === 'rotate') {
|
||
const radius = GIZMO_SIZE;
|
||
const threshold = 18;
|
||
|
||
// View ring
|
||
if (Math.abs(dist - radius * 1.15) < threshold) return 'view';
|
||
|
||
// Axis rings
|
||
const viewMatrix = camera.data.viewMatrix?.buffer;
|
||
if (viewMatrix) {
|
||
for (const axis of ['x', 'y', 'z']) {
|
||
if (getDistToRing(x, y, cx, cy, radius, axis, viewMatrix) < threshold) return axis;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function distToSegment(px, py, x1, y1, x2, y2) {
|
||
const dx = x2 - x1, dy = y2 - y1;
|
||
const len2 = dx*dx + dy*dy;
|
||
if (len2 < 1) return Math.sqrt((px-x1)**2 + (py-y1)**2);
|
||
const t = Math.max(0, Math.min(1, ((px-x1)*dx + (py-y1)*dy) / len2));
|
||
return Math.sqrt((px - (x1 + t*dx))**2 + (py - (y1 + t*dy))**2);
|
||
}
|
||
|
||
function pointInQuad(px, py, x1, y1, x2, y2, x3, y3, x4, y4) {
|
||
// Simple bounding box check
|
||
const minX = Math.min(x1, x2, x3, x4) - 5, maxX = Math.max(x1, x2, x3, x4) + 5;
|
||
const minY = Math.min(y1, y2, y3, y4) - 5, maxY = Math.max(y1, y2, y3, y4) + 5;
|
||
return px >= minX && px <= maxX && py >= minY && py <= maxY;
|
||
}
|
||
|
||
function getDistToRing(mx, my, cx, cy, radius, axis, viewMatrix) {
|
||
let minDist = Infinity;
|
||
for (let i = 0; i < 32; i++) {
|
||
const a = (i / 32) * Math.PI * 2;
|
||
let p;
|
||
if (axis === 'x') p = { x: 0, y: Math.cos(a), z: Math.sin(a) };
|
||
else if (axis === 'y') p = { x: Math.cos(a), y: 0, z: Math.sin(a) };
|
||
else p = { x: Math.cos(a), y: Math.sin(a), z: 0 };
|
||
|
||
const sx = viewMatrix[0]*p.x + viewMatrix[4]*p.y + viewMatrix[8]*p.z;
|
||
const sy = viewMatrix[1]*p.x + viewMatrix[5]*p.y + viewMatrix[9]*p.z;
|
||
const dist = Math.sqrt((mx - (cx + sx*radius))**2 + (my - (cy - sy*radius))**2);
|
||
if (dist < minDist) minDist = dist;
|
||
}
|
||
return minDist;
|
||
}
|
||
|
||
|
||
// ============== Transform Calculation & Application ==============
|
||
|
||
function calculatePreviewTranslation(dx, dy) {
|
||
camera.update();
|
||
const viewMatrix = camera.data.viewMatrix?.buffer;
|
||
if (!viewMatrix) return;
|
||
|
||
const axes = getScreenAxes(viewMatrix, 1);
|
||
const scale = 0.01; // World units per pixel
|
||
|
||
previewTranslation = { x: 0, y: 0, z: 0 };
|
||
|
||
// Note: dy is negated because screen Y is inverted
|
||
if (activeAxis === 'x' || activeAxis === 'xy' || activeAxis === 'xz') {
|
||
const ax = axes.x, len = Math.sqrt(ax.x**2 + ax.y**2);
|
||
if (len > 0.01) previewTranslation.x = (dx * ax.x - dy * ax.y) / len * scale;
|
||
}
|
||
if (activeAxis === 'y' || activeAxis === 'xy' || activeAxis === 'yz') {
|
||
const ax = axes.y, len = Math.sqrt(ax.x**2 + ax.y**2);
|
||
if (len > 0.01) previewTranslation.y = (dx * ax.x - dy * ax.y) / len * scale;
|
||
}
|
||
if (activeAxis === 'z' || activeAxis === 'xz' || activeAxis === 'yz') {
|
||
const ax = axes.z, len = Math.sqrt(ax.x**2 + ax.y**2);
|
||
if (len > 0.01) previewTranslation.z = (dx * ax.x - dy * ax.y) / len * scale;
|
||
}
|
||
}
|
||
|
||
function calculatePreviewRotation(x, y) {
|
||
const cx = gizmoScreenPos.x, cy = gizmoScreenPos.y;
|
||
|
||
if (activeAxis === 'view') {
|
||
// For view rotation, use simple screen-space angle
|
||
const startAngle = Math.atan2(dragStartPos.y - cy, dragStartPos.x - cx);
|
||
const currentAngle = Math.atan2(y - cy, x - cx);
|
||
let angle = (currentAngle - startAngle) * 180 / Math.PI;
|
||
while (angle > 180) angle -= 360;
|
||
while (angle < -180) angle += 360;
|
||
previewRotationAngle = angle;
|
||
} else {
|
||
// For axis rotation, calculate based on movement along the ring
|
||
// Use the perpendicular direction to the axis for rotation
|
||
camera.update();
|
||
const viewMatrix = camera.data.viewMatrix?.buffer;
|
||
if (!viewMatrix) return;
|
||
|
||
// Get the axis direction in screen space
|
||
let axisDir;
|
||
if (activeAxis === 'x') axisDir = { x: 1, y: 0, z: 0 };
|
||
else if (activeAxis === 'y') axisDir = { x: 0, y: 1, z: 0 };
|
||
else axisDir = { x: 0, y: 0, z: 1 };
|
||
|
||
// Transform axis to screen space
|
||
const screenAxisX = viewMatrix[0]*axisDir.x + viewMatrix[4]*axisDir.y + viewMatrix[8]*axisDir.z;
|
||
const screenAxisY = -(viewMatrix[1]*axisDir.x + viewMatrix[5]*axisDir.y + viewMatrix[9]*axisDir.z);
|
||
|
||
// Calculate rotation based on circular motion around gizmo center
|
||
const startAngle = Math.atan2(dragStartPos.y - cy, dragStartPos.x - cx);
|
||
const currentAngle = Math.atan2(y - cy, x - cx);
|
||
let angle = (currentAngle - startAngle) * 180 / Math.PI;
|
||
|
||
// Determine rotation direction based on axis orientation
|
||
// If axis is pointing towards camera, invert the rotation
|
||
const axisZ = viewMatrix[2]*axisDir.x + viewMatrix[6]*axisDir.y + viewMatrix[10]*axisDir.z;
|
||
if (axisZ < 0) angle = -angle;
|
||
|
||
while (angle > 180) angle -= 360;
|
||
while (angle < -180) angle += 360;
|
||
previewRotationAngle = angle;
|
||
}
|
||
}
|
||
|
||
function applyTranslation() {
|
||
if (!currentSplat?.data || !transformBasePositions) return;
|
||
|
||
const positions = currentSplat.data.positions;
|
||
const tx = previewTranslation.x, ty = previewTranslation.y, tz = previewTranslation.z;
|
||
|
||
for (const idx of selectedIndices) {
|
||
positions[idx * 3] = transformBasePositions[idx * 3] + tx;
|
||
positions[idx * 3 + 1] = transformBasePositions[idx * 3 + 1] + ty;
|
||
positions[idx * 3 + 2] = transformBasePositions[idx * 3 + 2] + tz;
|
||
}
|
||
|
||
// Update GPU
|
||
currentSplat.data.changed = true;
|
||
scene.removeObject(currentSplat);
|
||
scene.addObject(currentSplat);
|
||
|
||
// Update originals
|
||
originalPositions = new Float32Array(positions);
|
||
console.log('[Gizmo] Applied translation:', tx.toFixed(4), ty.toFixed(4), tz.toFixed(4));
|
||
}
|
||
|
||
function applyRotation() {
|
||
if (!currentSplat?.data || !transformBasePositions || !transformBaseRotations) return;
|
||
|
||
const angleRad = previewRotationAngle * Math.PI / 180;
|
||
let rx = 0, ry = 0, rz = 0;
|
||
|
||
if (activeAxis === 'x') rx = angleRad;
|
||
else if (activeAxis === 'y') ry = angleRad;
|
||
else if (activeAxis === 'z') rz = angleRad;
|
||
else if (activeAxis === 'view') {
|
||
camera.update();
|
||
const vm = camera.data.viewMatrix?.buffer;
|
||
if (vm) { rx = -vm[2] * angleRad; ry = -vm[6] * angleRad; rz = -vm[10] * angleRad; }
|
||
}
|
||
|
||
const q = SPLAT.Quaternion.FromEuler(new SPLAT.Vector3(rx, ry, rz));
|
||
const rm = createRotationMatrix(q);
|
||
|
||
const positions = currentSplat.data.positions;
|
||
const rotations = currentSplat.data.rotations;
|
||
const px = gizmoCenter.x, py = gizmoCenter.y, pz = gizmoCenter.z;
|
||
|
||
for (const idx of selectedIndices) {
|
||
// Rotate position around center
|
||
const dx = transformBasePositions[idx*3] - px;
|
||
const dy = transformBasePositions[idx*3+1] - py;
|
||
const dz = transformBasePositions[idx*3+2] - pz;
|
||
|
||
positions[idx*3] = rm[0]*dx + rm[1]*dy + rm[2]*dz + px;
|
||
positions[idx*3+1] = rm[3]*dx + rm[4]*dy + rm[5]*dz + py;
|
||
positions[idx*3+2] = rm[6]*dx + rm[7]*dy + rm[8]*dz + pz;
|
||
|
||
// Rotate gaussian orientation
|
||
const qw = transformBaseRotations[idx*4];
|
||
const qx = transformBaseRotations[idx*4+1];
|
||
const qy = transformBaseRotations[idx*4+2];
|
||
const qz = transformBaseRotations[idx*4+3];
|
||
const oldQ = new SPLAT.Quaternion(qx, qy, qz, qw);
|
||
const newQ = q.multiply(oldQ);
|
||
rotations[idx*4] = newQ.w;
|
||
rotations[idx*4+1] = newQ.x;
|
||
rotations[idx*4+2] = newQ.y;
|
||
rotations[idx*4+3] = newQ.z;
|
||
}
|
||
|
||
// Update GPU
|
||
currentSplat.data.changed = true;
|
||
scene.removeObject(currentSplat);
|
||
scene.addObject(currentSplat);
|
||
|
||
// Update originals
|
||
originalPositions = new Float32Array(positions);
|
||
originalRotations = new Float32Array(rotations);
|
||
console.log('[Gizmo] Applied rotation:', previewRotationAngle.toFixed(1), '° around', activeAxis);
|
||
}
|
||
|
||
function createRotationMatrix(q) {
|
||
const x = q.x, y = q.y, z = q.z, w = q.w;
|
||
const x2 = x+x, y2 = y+y, z2 = z+z;
|
||
const xx = x*x2, xy = x*y2, xz = x*z2;
|
||
const yy = y*y2, yz = y*z2, zz = z*z2;
|
||
const wx = w*x2, wy = w*y2, wz = w*z2;
|
||
return [1-(yy+zz), xy-wz, xz+wy, xy+wz, 1-(xx+zz), yz-wx, xz-wy, yz+wx, 1-(xx+yy)];
|
||
}
|
||
|
||
|
||
// ============== Selection & Point Operations ==============
|
||
|
||
function createProjectionContext() {
|
||
if (!camera || !canvas) return null;
|
||
camera.update();
|
||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||
const fx = Number(camera.data?.fx);
|
||
const fy = Number(camera.data?.fy);
|
||
const m = camera.data.viewMatrix?.buffer;
|
||
if (!m || !Number.isFinite(fx) || !Number.isFinite(fy) || w <= 0 || h <= 0) return null;
|
||
return { w, h, fx, fy, m };
|
||
}
|
||
|
||
function projectPointWithContext(x, y, z, projectionContext) {
|
||
if (!projectionContext) return null;
|
||
const { w, h, fx, fy, m } = projectionContext;
|
||
const vx = m[0]*x + m[4]*y + m[8]*z + m[12];
|
||
const vy = m[1]*x + m[5]*y + m[9]*z + m[13];
|
||
const vz = m[2]*x + m[6]*y + m[10]*z + m[14];
|
||
if (vz <= 0.01) return null;
|
||
return { x: (vx * fx / vz) + w/2, y: (vy * fy / vz) + h/2, depth: vz };
|
||
}
|
||
|
||
function projectPoint(pos, projectionContext = null) {
|
||
if (!pos) return null;
|
||
const context = projectionContext || createProjectionContext();
|
||
if (!context) return null;
|
||
return projectPointWithContext(pos.x, pos.y, pos.z, context);
|
||
}
|
||
|
||
function selectPointsInRect(x1, y1, x2, y2, add) {
|
||
if (!currentSplat?.data?.positions) return;
|
||
camera.update();
|
||
const minX = Math.min(x1,x2), maxX = Math.max(x1,x2), minY = Math.min(y1,y2), maxY = Math.max(y1,y2);
|
||
if (!add) selectedIndices.clear();
|
||
const pos = currentSplat.data.positions, count = pos.length / 3;
|
||
for (let i = 0; i < count; i++) {
|
||
const s = projectPoint({ x: pos[i*3], y: pos[i*3+1], z: pos[i*3+2] });
|
||
if (s && s.x >= minX && s.x <= maxX && s.y >= minY && s.y <= maxY) selectedIndices.add(i);
|
||
}
|
||
updateStatus();
|
||
}
|
||
|
||
function selectPointsInLasso(points, add) {
|
||
if (!currentSplat?.data?.positions || points.length < 3) return;
|
||
camera.update();
|
||
if (!add) selectedIndices.clear();
|
||
const pos = currentSplat.data.positions, count = pos.length / 3;
|
||
for (let i = 0; i < count; i++) {
|
||
const s = projectPoint({ x: pos[i*3], y: pos[i*3+1], z: pos[i*3+2] });
|
||
if (s && isPointInPolygon(s.x, s.y, points)) selectedIndices.add(i);
|
||
}
|
||
updateStatus();
|
||
}
|
||
|
||
function isPointInPolygon(x, y, poly) {
|
||
let inside = false;
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
|
||
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside;
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
function deleteSelected() {
|
||
if (!currentSplat?.data || selectedIndices.size === 0) return;
|
||
const data = currentSplat.data, oldCount = data.vertexCount, newCount = oldCount - selectedIndices.size;
|
||
if (newCount <= 0) return;
|
||
|
||
const newPos = new Float32Array(newCount * 3);
|
||
const newScales = new Float32Array(newCount * 3);
|
||
const newRots = new Float32Array(newCount * 4);
|
||
const newColors = new Uint8Array(newCount * 4);
|
||
|
||
let ni = 0;
|
||
for (let i = 0; i < oldCount; i++) {
|
||
if (selectedIndices.has(i)) continue;
|
||
newPos[ni*3] = data.positions[i*3]; newPos[ni*3+1] = data.positions[i*3+1]; newPos[ni*3+2] = data.positions[i*3+2];
|
||
newScales[ni*3] = data.scales[i*3]; newScales[ni*3+1] = data.scales[i*3+1]; newScales[ni*3+2] = data.scales[i*3+2];
|
||
newRots[ni*4] = data.rotations[i*4]; newRots[ni*4+1] = data.rotations[i*4+1]; newRots[ni*4+2] = data.rotations[i*4+2]; newRots[ni*4+3] = data.rotations[i*4+3];
|
||
newColors[ni*4] = data.colors[i*4]; newColors[ni*4+1] = data.colors[i*4+1]; newColors[ni*4+2] = data.colors[i*4+2]; newColors[ni*4+3] = data.colors[i*4+3];
|
||
ni++;
|
||
}
|
||
|
||
const newSplat = new SPLAT.Splat(new SPLAT.SplatData(newCount, newPos, newRots, newScales, newColors));
|
||
newSplat.position = currentSplat.position; newSplat.rotation = currentSplat.rotation; newSplat.scale = currentSplat.scale;
|
||
scene.removeObject(currentSplat); scene.addObject(newSplat); currentSplat = newSplat;
|
||
originalScales = new Float32Array(newScales); originalPositions = new Float32Array(newPos);
|
||
originalColors = new Uint8Array(newColors); originalRotations = new Float32Array(newRots);
|
||
selectedIndices.clear(); updateStatus();
|
||
}
|
||
|
||
function invertSelection() {
|
||
if (!currentSplat?.data) return;
|
||
const count = currentSplat.data.positions.length / 3;
|
||
const newSel = new Set();
|
||
for (let i = 0; i < count; i++) if (!selectedIndices.has(i)) newSel.add(i);
|
||
selectedIndices = newSel; updateStatus();
|
||
}
|
||
|
||
function clearSelection() {
|
||
selectedIndices.clear(); clearGizmo(); updateStatus();
|
||
}
|
||
|
||
function updateStatus() {
|
||
const count = currentSplat?.data?.positions?.length / 3 || 0;
|
||
const sel = selectedIndices.size;
|
||
let text = `总点数: ${Math.floor(count)}`;
|
||
if (sel > 0) text += ` | 已选中: ${sel}`;
|
||
text += ' | V:视角 H:平移 B:矩形 L:套索 G:移动 T:旋转 | WASD/方向键:移动 Q上移 E下移 R/F:俯仰 Shift:精细 | DEL:删除';
|
||
if (centerPickMode) text += ' | Pick center: click a gaussian point';
|
||
if (statusText) statusText.textContent = text;
|
||
highlightSelection();
|
||
syncViewerToCameraPanel();
|
||
}
|
||
|
||
function highlightSelection() {
|
||
if (!currentSplat?.data?.colors || !originalColors) return;
|
||
const colors = currentSplat.data.colors, count = currentSplat.data.vertexCount;
|
||
let changed = false;
|
||
for (let i = 0; i < count; i++) {
|
||
if (selectedIndices.has(i)) {
|
||
// 鍗婇€忔槑缁胯壊瑕嗙洊锛屼繚鐣欏師鑹插彲瑙?(70%鍘熻壊 + 30%缁胯壊)
|
||
const origR = originalColors[i*4];
|
||
const origG = originalColors[i*4+1];
|
||
const origB = originalColors[i*4+2];
|
||
const origA = originalColors[i*4+3];
|
||
const newR = Math.round(origR * 0.7 + 50 * 0.3);
|
||
const newG = Math.round(origG * 0.7 + 255 * 0.3);
|
||
const newB = Math.round(origB * 0.7 + 50 * 0.3);
|
||
if (colors[i*4] !== newR || colors[i*4+1] !== newG || colors[i*4+2] !== newB) {
|
||
colors[i*4] = newR; colors[i*4+1] = newG; colors[i*4+2] = newB; colors[i*4+3] = origA; changed = true;
|
||
}
|
||
} else {
|
||
if (colors[i*4] !== originalColors[i*4] || colors[i*4+1] !== originalColors[i*4+1] || colors[i*4+2] !== originalColors[i*4+2]) {
|
||
colors[i*4] = originalColors[i*4]; colors[i*4+1] = originalColors[i*4+1]; colors[i*4+2] = originalColors[i*4+2]; colors[i*4+3] = originalColors[i*4+3]; changed = true;
|
||
}
|
||
}
|
||
}
|
||
if (changed) { currentSplat.data.changed = true; scene.removeObject(currentSplat); scene.addObject(currentSplat); }
|
||
}
|
||
|
||
|
||
// ============== Camera & Scale ==============
|
||
|
||
/**
|
||
* 浠ョ敾闈腑蹇冧负杞村簲鐢ㄦ按骞虫牎姝o紙roll锛?
|
||
* 鏍规嵁褰撳墠 X/Y 瑙掑害纭畾鐨勮鍥惧钩闈紝缁曡绾胯酱锛堢浉鏈衡啋鐩爣锛夋棆杞紝闈炲浐瀹氭柟鍚?
|
||
* @param {object} [overrideTarget] - 鍙€夛紝鎸囧畾瑙傚療涓績 {x,y,z}锛屽 orbitTarget
|
||
*/
|
||
function applyRollToCamera(overrideTarget, overrideRollDeg = null) {
|
||
if (!camera) return;
|
||
const target = cloneCenter(overrideTarget) || getActiveOrbitCenter();
|
||
if (!target) return;
|
||
const dx = target.x - camera.position.x;
|
||
const dy = target.y - camera.position.y;
|
||
const dz = target.z - camera.position.z;
|
||
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||
if (len < 0.001) return;
|
||
|
||
// Build a stable base orientation from current camera position and target center.
|
||
// This avoids frame-by-frame roll accumulation that causes uncontrolled spinning.
|
||
const baseQuat = SPLAT.Quaternion.FromEuler(
|
||
new SPLAT.Vector3(
|
||
Math.asin(-dy / len),
|
||
Math.atan2(dx / len, dz / len),
|
||
0
|
||
)
|
||
);
|
||
|
||
const resolvedRoll = overrideRollDeg == null ? getRenderedCameraRoll() : overrideRollDeg;
|
||
const rollDeg = clampCameraRoll(resolvedRoll);
|
||
if (Math.abs(rollDeg) < 0.01) {
|
||
camera.rotation = baseQuat;
|
||
return;
|
||
}
|
||
|
||
const axis = new SPLAT.Vector3(dx / len, dy / len, dz / len);
|
||
const rollQuat = SPLAT.Quaternion.FromAxisAngle(axis, CT.degToRad(rollDeg));
|
||
camera.rotation = rollQuat.multiply(baseQuat);
|
||
}
|
||
|
||
function updateCameraFromOrbit() {
|
||
if (!customOrbitEnabled) return;
|
||
// 涓嶨Splat OrbitControls淇濇寔涓€鑷达紙閫嗘椂閽堟柟鍚戯級锛?
|
||
// x = target.x - distance * sin(yaw) * cos(pitch) // 閫嗘椂閽堝彇鍙?
|
||
// y = target.y - distance * sin(pitch)
|
||
// z = target.z - distance * cos(yaw) * cos(pitch)
|
||
const bx = orbitTarget.x - orbitDistance * Math.sin(orbitYaw) * Math.cos(orbitPitch); // 閫嗘椂閽堝彇鍙?
|
||
const by = orbitTarget.y - orbitDistance * Math.sin(orbitPitch);
|
||
const bz = orbitTarget.z - orbitDistance * Math.cos(orbitYaw) * Math.cos(orbitPitch);
|
||
camera.position.x = bx; camera.position.y = by; camera.position.z = bz;
|
||
const lx = orbitTarget.x - camera.position.x;
|
||
const ly = orbitTarget.y - camera.position.y;
|
||
const lz = orbitTarget.z - camera.position.z;
|
||
const len = Math.sqrt(lx*lx + ly*ly + lz*lz);
|
||
if (len > 0.001) {
|
||
camera.rotation = SPLAT.Quaternion.FromEuler(new SPLAT.Vector3(Math.asin(-ly/len), Math.atan2(lx/len, lz/len), 0));
|
||
}
|
||
camera.update();
|
||
}
|
||
|
||
function resetCamera() {
|
||
customOrbitEnabled = false; controls.enabled = true;
|
||
endControlsRightPanTracking();
|
||
controlsRightPanNeedsFinalize = false;
|
||
controlsRightPanStartCameraPos = null;
|
||
controlsRightPanStartCenter = null;
|
||
setCenterPickMode(false);
|
||
// 閲嶇疆姣斾緥鍒板師濮嬪昂瀵?
|
||
setAspectRatio('original');
|
||
|
||
// 閲嶇疆鐩告満鍒板垵濮嬩綅缃紙浣跨敤淇濆瓨鐨刬nitialCameraData锛?
|
||
if (camera && controls && initialCameraData) {
|
||
// 鐩存帴浣跨敤淇濆瓨鐨勫垵濮嬬浉鏈烘暟鎹紝纭繚姝g‘杩樺師鍒板姞杞芥椂鐨勪綅缃?
|
||
camera.position.x = initialCameraData.position.x;
|
||
camera.position.y = initialCameraData.position.y;
|
||
camera.position.z = initialCameraData.position.z;
|
||
if (initialCameraData.fx) camera.data.fx = initialCameraData.fx;
|
||
if (initialCameraData.fy) camera.data.fy = initialCameraData.fy;
|
||
if (initialCameraData.target) {
|
||
controls.setCameraTarget(initialCameraData.target);
|
||
currentOrbitTarget = { x: initialCameraData.target.x, y: initialCameraData.target.y, z: initialCameraData.target.z };
|
||
}
|
||
const savedDampening = controls.dampening;
|
||
controls.dampening = 1;
|
||
controls.update();
|
||
controls.dampening = savedDampening;
|
||
|
||
cameraParams.targetCenter = {
|
||
x: initialCameraData.target.x,
|
||
y: initialCameraData.target.y,
|
||
z: initialCameraData.target.z
|
||
};
|
||
// 浠庡疄闄呯浉鏈轰綅缃噸鏂拌绠楄搴︼紝閬垮厤纭紪鐮?0/0/5 涓庡垵濮嬪鍙備綅缃笉绗?
|
||
const actualParams = calculateCameraParams();
|
||
if (actualParams) {
|
||
cameraParams.azimuth = actualParams.azimuth;
|
||
cameraParams.elevation = actualParams.elevation;
|
||
}
|
||
cameraParams.distance = DEFAULT_CAMERA_DISTANCE;
|
||
setCameraRollValue(0, { immediate: true });
|
||
cameraParams.customOrbitCenter = null;
|
||
cameraParams.orbitCenter = cloneCenter(currentOrbitTarget) || getDefaultOrbitCenter();
|
||
updateOrbitCenterInputs(cameraParams.orbitCenter);
|
||
}
|
||
if (rollSlider) rollSlider.value = 0;
|
||
if (rollInput) rollInput.value = 0;
|
||
rememberCurrentCameraPositionForParams();
|
||
|
||
// 閲嶇疆楂樻柉缂╂斁婊戝潡鍒伴粯璁ゅ€?.3
|
||
const defaultGaussianScale = DEFAULT_GAUSSIAN_SCALE;
|
||
if (scaleSlider) scaleSlider.value = defaultGaussianScale;
|
||
if (scaleInput) scaleInput.value = defaultGaussianScale;
|
||
updateGaussianScale(defaultGaussianScale);
|
||
|
||
// 閲嶇疆鐒﹁窛婊戝潡鍒板垵濮嬪€?
|
||
resetFocalLengthToDefault({ applyToCamera: true });
|
||
// 閲嶇疆娣卞害鑼冨洿婊戝潡鍒板垵濮嬪€?
|
||
depthRangeSlider.value = 5;
|
||
if (depthRangeValue) depthRangeValue.textContent = '全部';
|
||
updateDepthRange(Infinity);
|
||
// 閲嶇疆铏氭嫙濮挎€佺悆瑙掑害
|
||
resetVirtualOrbitBall();
|
||
|
||
// 浠庡綋鍓嶇浉鏈轰綅缃紙宸茬敱 initialCameraData 鎭㈠锛夊悓姝ュ埌3D鎺у埗闈㈡澘
|
||
syncViewerToCameraPanel();
|
||
|
||
// 鏇存柊鍙充晶3D闈㈡澘鐨勭缉鏀炬粦鍧楀拰杈撳叆妗嗭紙threeScene 鍦ㄥ悓涓€ iframe 绐楀彛鍐咃級
|
||
try {
|
||
if (window.threeScene?.reset) {
|
||
window.threeScene.reset(cameraParams.azimuth, cameraParams.elevation, cameraParams.distance, cameraParams.roll || 0);
|
||
} else {
|
||
const zoomSlider = document.getElementById('zoomSlider');
|
||
const zoomInput = document.getElementById('zoomInput');
|
||
if (zoomSlider) {
|
||
zoomSlider.value = DEFAULT_CAMERA_DISTANCE;
|
||
zoomSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
if (zoomInput) {
|
||
zoomInput.value = DEFAULT_CAMERA_DISTANCE;
|
||
zoomInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[GaussianViewer] Failed to update zoom controls:', e);
|
||
}
|
||
|
||
updateStatus();
|
||
}
|
||
|
||
// 娓呴櫎鍘嗗彶闀滃ご鍒楄〃锛堜緵鍘嗗彶闈㈡澘鐨勯噸缃寜閽皟鐢級
|
||
window.clearCameraHistory = function() {
|
||
cameraHistory = [];
|
||
renderCameraHistory();
|
||
if (typeof nodeId !== 'undefined' && nodeId != null) {
|
||
try {
|
||
window.parent.postMessage({ type: 'RESET_CAMERA_CACHE', nodeId, timestamp: Date.now() }, TARGET_ORIGIN);
|
||
} catch (e) {}
|
||
}
|
||
};
|
||
|
||
function areSameHistoryPose(a, b) {
|
||
if (!a || !b) return false;
|
||
const epsilon = 1e-4;
|
||
return Math.abs((Number(a.azimuth) || 0) - (Number(b.azimuth) || 0)) < epsilon
|
||
&& Math.abs((Number(a.elevation) || 0) - (Number(b.elevation) || 0)) < epsilon
|
||
&& Math.abs((Number(a.distance) || 0) - (Number(b.distance) || 0)) < epsilon
|
||
&& Math.abs((Number(a.roll) || 0) - (Number(b.roll) || 0)) < epsilon
|
||
&& Math.abs((Number(a.scale) || DEFAULT_GAUSSIAN_SCALE) - (Number(b.scale) || DEFAULT_GAUSSIAN_SCALE)) < epsilon
|
||
&& String(a.aspectRatio || 'original') === String(b.aspectRatio || 'original');
|
||
}
|
||
|
||
function computeCameraPoseFromView(targetCenter) {
|
||
if (!camera || !targetCenter) return null;
|
||
try {
|
||
const pose = CT.GSplatAdapter.fromGSplatPosition(
|
||
camera.position.x,
|
||
camera.position.y,
|
||
camera.position.z,
|
||
targetCenter
|
||
);
|
||
if (!pose) return null;
|
||
const azimuth = Number(pose.azimuth);
|
||
const elevation = Number(pose.elevation);
|
||
const distance = Number(pose.zoom);
|
||
if (!Number.isFinite(azimuth) || !Number.isFinite(elevation) || !Number.isFinite(distance)) {
|
||
return null;
|
||
}
|
||
return { azimuth, elevation, distance };
|
||
} catch (e) {
|
||
console.warn('[GaussianViewer] Failed to compute camera pose from view:', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getCurrentCameraSnapshot() {
|
||
const orbitCenter = cloneCenter(getOrbitCenter()) || cloneCenter(getActiveOrbitCenter()) || { x: 0, y: 0, z: 0 };
|
||
const pose = computeCameraPoseFromView(orbitCenter);
|
||
const fallback = calculateCameraParams();
|
||
const azimuth = Number.isFinite(pose?.azimuth) ? pose.azimuth : Number(fallback?.azimuth || 0);
|
||
const elevation = Number.isFinite(pose?.elevation) ? pose.elevation : Number(fallback?.elevation || 0);
|
||
const distance = getSliderZoom();
|
||
const roll = Number(cameraParams?.roll);
|
||
const safeRoll = Number.isFinite(roll) ? roll : 0;
|
||
const safeDistance = Number.isFinite(distance) ? distance : DEFAULT_CAMERA_DISTANCE;
|
||
|
||
return {
|
||
position: camera ? { x: camera.position.x, y: camera.position.y, z: camera.position.z } : null,
|
||
azimuth,
|
||
elevation,
|
||
distance: safeDistance,
|
||
roll: safeRoll,
|
||
scale: Number.isFinite(Number(currentScale)) ? Number(currentScale) : DEFAULT_GAUSSIAN_SCALE,
|
||
aspectRatio: typeof currentAspectRatio === 'string' && currentAspectRatio.trim() ? currentAspectRatio : 'original',
|
||
outputWidth: Number.isFinite(Number(outputWidth)) ? Math.round(Number(outputWidth)) : originalOutputWidth,
|
||
outputHeight: Number.isFinite(Number(outputHeight)) ? Math.round(Number(outputHeight)) : originalOutputHeight,
|
||
description: generateBaseCameraDescription({
|
||
azimuth,
|
||
elevation,
|
||
distance: safeDistance,
|
||
roll: safeRoll,
|
||
}),
|
||
prompt_description: generateCameraDescription({
|
||
azimuth,
|
||
elevation,
|
||
distance: safeDistance,
|
||
roll: safeRoll,
|
||
}),
|
||
sixdof_description: generateSixDofDescription({
|
||
azimuth,
|
||
elevation,
|
||
distance: safeDistance,
|
||
roll: safeRoll,
|
||
}),
|
||
pose6dof: buildSixDofPose({
|
||
azimuth,
|
||
elevation,
|
||
distance: safeDistance,
|
||
roll: safeRoll,
|
||
}),
|
||
orbitCenter: cloneCenter(orbitCenter),
|
||
target: cloneCenter(orbitCenter),
|
||
};
|
||
}
|
||
|
||
function getNextHistorySeq() {
|
||
let maxSeq = 0;
|
||
let hasExplicitSeq = false;
|
||
for (const entry of cameraHistory) {
|
||
const seq = Number(entry?.seq);
|
||
if (!Number.isFinite(seq) || seq <= 0) continue;
|
||
hasExplicitSeq = true;
|
||
if (seq > maxSeq) maxSeq = seq;
|
||
}
|
||
if (!hasExplicitSeq) {
|
||
return cameraHistory.length + 1;
|
||
}
|
||
return Math.floor(maxSeq) + 1;
|
||
}
|
||
|
||
function resequenceCameraHistory(entries) {
|
||
if (!Array.isArray(entries)) return [];
|
||
|
||
const normalized = entries
|
||
.filter((entry) => entry && typeof entry === 'object')
|
||
.map((entry) => ({ ...entry }));
|
||
|
||
let nextSeq = 1;
|
||
for (let index = normalized.length - 1; index >= 0; index -= 1) {
|
||
normalized[index].seq = nextSeq;
|
||
nextSeq += 1;
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function buildCurrentCameraHistoryEntry() {
|
||
return {
|
||
...getCurrentCameraSnapshot(),
|
||
seq: getNextHistorySeq(),
|
||
timestamp: Date.now() / 1000,
|
||
};
|
||
}
|
||
|
||
function buildUpdatedCameraHistoryEntry(entry) {
|
||
const seqRaw = Number(entry?.seq);
|
||
const seq = Number.isFinite(seqRaw) && seqRaw > 0 ? Math.floor(seqRaw) : getNextHistorySeq();
|
||
return {
|
||
...(entry && typeof entry === 'object' ? entry : {}),
|
||
...getCurrentCameraSnapshot(),
|
||
seq,
|
||
timestamp: Date.now() / 1000,
|
||
};
|
||
}
|
||
|
||
function prependCameraHistoryEntry(entry) {
|
||
if (!entry) return false;
|
||
if (cameraHistory.length > 0 && areSameHistoryPose(cameraHistory[0], entry)) {
|
||
return false;
|
||
}
|
||
cameraHistory.unshift(entry);
|
||
if (cameraHistory.length > HISTORY_MAX_ITEMS) {
|
||
cameraHistory = cameraHistory.slice(0, HISTORY_MAX_ITEMS);
|
||
}
|
||
renderCameraHistory();
|
||
return true;
|
||
}
|
||
|
||
window.addCurrentCameraToHistory = function() {
|
||
const entry = buildCurrentCameraHistoryEntry();
|
||
const added = prependCameraHistoryEntry(entry);
|
||
if (!added) return;
|
||
if (typeof nodeId !== 'undefined' && nodeId != null) {
|
||
try {
|
||
window.parent.postMessage({
|
||
type: 'ADD_CAMERA_HISTORY',
|
||
nodeId,
|
||
timestamp: Date.now(),
|
||
cameraState: entry,
|
||
}, TARGET_ORIGIN);
|
||
} catch (e) {
|
||
console.warn('[GaussianViewer] Failed to post ADD_CAMERA_HISTORY:', e);
|
||
}
|
||
}
|
||
};
|
||
|
||
function replaceCameraHistoryEntry(seq, nextEntry) {
|
||
if (!Number.isFinite(seq) || seq <= 0 || !nextEntry) return false;
|
||
|
||
let updated = false;
|
||
cameraHistory = cameraHistory.map((item) => {
|
||
const itemSeq = Math.floor(Number(item?.seq));
|
||
if (!updated && itemSeq === seq) {
|
||
updated = true;
|
||
return {
|
||
...item,
|
||
...nextEntry,
|
||
seq,
|
||
};
|
||
}
|
||
return item;
|
||
});
|
||
|
||
if (updated) {
|
||
renderCameraHistory();
|
||
}
|
||
return updated;
|
||
}
|
||
|
||
window.updateCameraHistoryEntry = async function(entry) {
|
||
const seqRaw = Number(entry?.seq);
|
||
const seq = Number.isFinite(seqRaw) && seqRaw > 0 ? Math.floor(seqRaw) : null;
|
||
if (seq === null) return;
|
||
|
||
const nextEntry = buildUpdatedCameraHistoryEntry(entry);
|
||
const previousHistory = cameraHistory.map((item) => ({ ...item }));
|
||
const updated = replaceCameraHistoryEntry(seq, nextEntry);
|
||
if (!updated) return;
|
||
|
||
if (typeof nodeId === 'undefined' || nodeId == null) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/gaussian_viewer/update_history', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
node_id: String(nodeId),
|
||
seq,
|
||
camera_state: nextEntry,
|
||
}),
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok || result?.success === false) {
|
||
throw new Error(result?.error || `HTTP ${response.status}`);
|
||
}
|
||
|
||
if (Array.isArray(result.history)) {
|
||
cameraHistory = resequenceCameraHistory(result.history.slice(0, HISTORY_MAX_ITEMS));
|
||
renderCameraHistory();
|
||
}
|
||
} catch (e) {
|
||
console.warn('[GaussianViewer] Failed to update history entry:', e);
|
||
cameraHistory = previousHistory;
|
||
renderCameraHistory();
|
||
}
|
||
};
|
||
|
||
window.deleteCameraHistoryEntry = async function(entry) {
|
||
const seqRaw = Number(entry?.seq);
|
||
const seq = Number.isFinite(seqRaw) && seqRaw > 0 ? Math.floor(seqRaw) : null;
|
||
const nextHistory = seq === null
|
||
? cameraHistory.filter((item) => item !== entry)
|
||
: cameraHistory.filter((item) => Math.floor(Number(item?.seq)) !== seq);
|
||
|
||
if (nextHistory.length === cameraHistory.length) {
|
||
return;
|
||
}
|
||
|
||
const previousHistory = cameraHistory.map((item) => ({ ...item }));
|
||
cameraHistory = resequenceCameraHistory(nextHistory);
|
||
renderCameraHistory();
|
||
|
||
if (seq === null || typeof nodeId === 'undefined' || nodeId == null) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/gaussian_viewer/delete_history', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
node_id: String(nodeId),
|
||
seq,
|
||
}),
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok || result?.success === false) {
|
||
throw new Error(result?.error || `HTTP ${response.status}`);
|
||
}
|
||
|
||
if (Array.isArray(result.history)) {
|
||
cameraHistory = resequenceCameraHistory(result.history.slice(0, HISTORY_MAX_ITEMS));
|
||
renderCameraHistory();
|
||
}
|
||
} catch (e) {
|
||
console.warn('[GaussianViewer] Failed to delete history entry:', e);
|
||
cameraHistory = previousHistory;
|
||
renderCameraHistory();
|
||
}
|
||
};
|
||
|
||
function updateFocalLength(value) {
|
||
applyFocalLengthToCamera(value);
|
||
// 灏嗘憚褰辩劍璺?mm)杞崲涓哄儚绱犵劍璺?
|
||
// 鍋囪浼犳劅鍣ㄥ搴︿负36mm(35mm鑳剁墖鏍囧噯)
|
||
// 鍍忕礌鐒﹁窛 = 鐢诲竷瀹藉害 * 鎽勫奖鐒﹁窛 / 浼犳劅鍣ㄥ搴?
|
||
}
|
||
|
||
function updateDepthRange(value) {
|
||
setCameraClipRange(0.01, value === Infinity ? CAMERA_FAR_ALL : value);
|
||
// 鏍规嵁娣卞害鑼冨洿杩囨护楂樻柉鐐圭殑閫忔槑搴︼紙閫忔槑搴﹀瓨鍌ㄥ湪colors鐨刟lpha閫氶亾锛?
|
||
if (currentSplat && currentSplat.data && currentSplat.data.colors && originalColors) {
|
||
const colors = currentSplat.data.colors;
|
||
const positions = currentSplat.data.positions;
|
||
const cameraPosition = camera?.position || { x: 0, y: 0, z: 0 };
|
||
|
||
for (let i = 0; i < currentSplat.data.vertexCount; i++) {
|
||
const posIdx = i * 3;
|
||
const colorIdx = i * 4;
|
||
if (positions && posIdx + 2 < positions.length && colorIdx + 3 < colors.length) {
|
||
// Infinity琛ㄧず鏄剧ず鍏ㄩ儴锛屼笉瑁佸垏
|
||
if (value === Infinity) {
|
||
colors[colorIdx + 3] = originalColors[colorIdx + 3]; // 鎭㈠鍘熷閫忔槑搴?
|
||
} else {
|
||
const dx = positions[posIdx] - cameraPosition.x;
|
||
const dy = positions[posIdx + 1] - cameraPosition.y;
|
||
const dz = positions[posIdx + 2] - cameraPosition.z;
|
||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||
|
||
// 鏍规嵁娣卞害鑼冨洿璋冩暣閫忔槑搴︼紙alpha閫氶亾鏄4涓瓧鑺傦級
|
||
if (dist > value) {
|
||
colors[colorIdx + 3] = 0; // 瓒呭嚭娣卞害鑼冨洿鐨勯珮鏂偣璁句负閫忔槑
|
||
} else {
|
||
colors[colorIdx + 3] = originalColors[colorIdx + 3]; // 鎭㈠鍘熷閫忔槑搴?
|
||
}
|
||
}
|
||
}
|
||
}
|
||
currentSplat.data.changed = true;
|
||
scene.removeObject(currentSplat);
|
||
scene.addObject(currentSplat);
|
||
}
|
||
}
|
||
|
||
function updateGaussianScale(newScale) {
|
||
let value = parseFloat(newScale);
|
||
if (!Number.isFinite(value)) value = currentScale || 1.0;
|
||
value = Math.max(0.1, Math.min(4, value));
|
||
value = Number(value.toFixed(1));
|
||
currentScale = value;
|
||
if (scaleInput) scaleInput.value = value.toFixed(1);
|
||
if (scaleSlider) scaleSlider.value = value.toFixed(1);
|
||
if (currentSplat?.data?.scales && originalScales) {
|
||
const scales = currentSplat.data.scales;
|
||
for (let i = 0; i < Math.min(scales.length, originalScales.length); i++) scales[i] = originalScales[i] * value;
|
||
currentSplat.data.changed = true; scene.removeObject(currentSplat); scene.addObject(currentSplat);
|
||
}
|
||
}
|
||
|
||
function setCameraFromExtrinsics(ext, intr, splat) {
|
||
if (!camera || !controls) return;
|
||
let cx = 0, cy = 0, cz = 0;
|
||
if (ext?.length === 4) {
|
||
const R = [[ext[0][0],ext[0][1],ext[0][2]], [ext[1][0],ext[1][1],ext[1][2]], [ext[2][0],ext[2][1],ext[2][2]]];
|
||
const t = [ext[0][3], ext[1][3], ext[2][3]];
|
||
cx = -(R[0][0]*t[0] + R[1][0]*t[1] + R[2][0]*t[2]);
|
||
cy = -(R[0][1]*t[0] + R[1][1]*t[1] + R[2][1]*t[2]);
|
||
cz = -(R[0][2]*t[0] + R[1][2]*t[1] + R[2][2]*t[2]);
|
||
}
|
||
let tz = 2;
|
||
if (intr?.length >= 2) {
|
||
const fx = intr[0][0], fy = intr[1][1], icx = intr[0][2], icy = intr[1][2];
|
||
const iw = icx * 2, ih = icy * 2, cw = canvas.clientWidth || 512;
|
||
camera.data.fx = fx * cw / iw;
|
||
camera.data.fy = fx * cw / iw;
|
||
gaussianScaleCompensation = iw / cw;
|
||
tz = Math.max(1, fy / ih * 2);
|
||
}
|
||
let tx = 0, ty = 0, oz = tz;
|
||
if (splat?.bounds) { const c = splat.bounds.center(), s = splat.bounds.size(); tz = c.z; oz = (c.z - s.z/2) * 1.5; }
|
||
cy = -cy;
|
||
// 鏃犲鍙傛椂锛屽垵濮嬩綅缃榻愰粯璁ゆ瑙嗗浘锛坅z=0,el=0,zoom=5锛夛紝纭繚閲嶇疆鐩告満鑳借繕鍘熷埌姝や綅缃?
|
||
initialCameraData = {
|
||
position: { x: cx, y: cy, z: cz },
|
||
target: new SPLAT.Vector3(tx, ty, oz),
|
||
fx: camera.data.fx,
|
||
fy: camera.data.fy
|
||
};
|
||
applyFocalLengthToCamera(getCurrentFocalLengthValue(initialFocalLength), { updateInitialCameraData: true });
|
||
camera.position.x = cx; camera.position.y = cy; camera.position.z = cz;
|
||
controls.setCameraTarget(new SPLAT.Vector3(tx, ty, oz));
|
||
currentOrbitTarget = { x: tx, y: ty, z: oz };
|
||
cameraParams.targetCenter = cloneCenter(currentOrbitTarget);
|
||
if (!cameraParams.customOrbitCenter) {
|
||
cameraParams.orbitCenter = cloneCenter(currentOrbitTarget);
|
||
}
|
||
updateOrbitCenterInputs(currentOrbitTarget);
|
||
}
|
||
|
||
|
||
// ============== Load & Save ==============
|
||
|
||
async function loadPLYFromData(arrayBuffer, filename, ext, intr) {
|
||
try {
|
||
while (scene.objects?.length > 0) scene.removeObject(scene.objects[0]);
|
||
currentSplat = null; selectedIndices.clear();
|
||
gaussianScaleCompensation = 1.0;
|
||
if (!cameraParams.hasCache) {
|
||
resetFocalLengthToDefault({ applyToCamera: true });
|
||
}
|
||
if (intr?.length >= 2) gaussianScaleCompensation = intr[0][2] * 2 / (canvas.clientWidth || 512);
|
||
|
||
const blob = new Blob([arrayBuffer], { type: 'application/octet-stream' });
|
||
const url = URL.createObjectURL(blob);
|
||
await SPLAT.PLYLoader.LoadAsync(url, scene);
|
||
URL.revokeObjectURL(url);
|
||
|
||
if (scene.objects?.length > 0) {
|
||
currentSplat = scene.objects[scene.objects.length - 1];
|
||
if (currentSplat.data) {
|
||
if (currentSplat.data.positions) originalPositions = new Float32Array(currentSplat.data.positions);
|
||
if (currentSplat.data.colors) originalColors = new Uint8Array(currentSplat.data.colors);
|
||
if (currentSplat.data.rotations) originalRotations = new Float32Array(currentSplat.data.rotations);
|
||
if (currentSplat.data.scales) {
|
||
originalScales = new Float32Array(currentSplat.data.scales.length);
|
||
for (let i = 0; i < currentSplat.data.scales.length; i++) {
|
||
originalScales[i] = currentSplat.data.scales[i] * gaussianScaleCompensation;
|
||
currentSplat.data.scales[i] = originalScales[i];
|
||
}
|
||
currentSplat.data.changed = true;
|
||
}
|
||
}
|
||
// 鍒濆鍖栭珮鏂缉鏀句负榛樿鍊?.3
|
||
const defaultScale = DEFAULT_GAUSSIAN_SCALE;
|
||
scaleInput.value = defaultScale; currentScale = defaultScale; scaleSlider.value = defaultScale;
|
||
// 搴旂敤榛樿缂╂斁
|
||
updateGaussianScale(defaultScale);
|
||
}
|
||
if (ext || intr) {
|
||
setCameraFromExtrinsics(ext, intr, currentSplat);
|
||
const _sd = controls.dampening; controls.dampening = 1; controls.update(); controls.dampening = _sd;
|
||
} else {
|
||
// 鏃犲鍙傛棤鍐呭弬锛氱浉鏈烘斁鍦ㄦ柟浣嶈0搴︾殑榛樿浣嶇疆锛坺oom=5锛?
|
||
let targetZ = 0;
|
||
if (currentSplat?.bounds) { targetZ = currentSplat.bounds.center().z; }
|
||
const fp = { x: 0, y: 0, z: targetZ - getRecommendedInitialCameraDistance() };
|
||
const baseTarget = { x: 0, y: 0, z: targetZ };
|
||
const defaultOrbitCenter = computeDefaultOrbitCenter(fp, baseTarget);
|
||
const defaultTarget = new SPLAT.Vector3(defaultOrbitCenter.x, defaultOrbitCenter.y, defaultOrbitCenter.z);
|
||
initialCameraData = { position: {x:fp.x, y:fp.y, z:fp.z}, target: defaultTarget, fx: camera.data?.fx, fy: camera.data?.fy };
|
||
camera.position.x = fp.x; camera.position.y = fp.y; camera.position.z = fp.z;
|
||
controls.setCameraTarget(defaultTarget);
|
||
currentOrbitTarget = cloneCenter(defaultOrbitCenter);
|
||
cameraParams.targetCenter = cloneCenter(currentOrbitTarget);
|
||
if (!cameraParams.customOrbitCenter) {
|
||
cameraParams.orbitCenter = cloneCenter(currentOrbitTarget);
|
||
}
|
||
updateOrbitCenterInputs(currentOrbitTarget);
|
||
const savedDampening = controls.dampening;
|
||
controls.dampening = 1;
|
||
controls.update();
|
||
controls.dampening = savedDampening;
|
||
}
|
||
|
||
// 閲嶇疆鎴栬繕鍘熺浉鏈哄弬鏁?
|
||
if (!cameraParams.hasCache) {
|
||
cameraParams.azimuth = 0;
|
||
cameraParams.elevation = 0;
|
||
cameraParams.distance = DEFAULT_CAMERA_DISTANCE;
|
||
setCameraRollValue(0, { immediate: true });
|
||
cameraParams.customOrbitCenter = null;
|
||
cameraParams.targetCenter = cloneCenter(currentOrbitTarget) || getDefaultOrbitCenter();
|
||
cameraParams.orbitCenter = cloneCenter(currentOrbitTarget) || getDefaultOrbitCenter();
|
||
updateOrbitCenterInputs(cameraParams.orbitCenter);
|
||
rememberCurrentCameraPositionForParams();
|
||
syncViewerToCameraPanel();
|
||
} else {
|
||
try {
|
||
if (pendingStartupHistoryEntry) {
|
||
applyCameraHistoryEntry(pendingStartupHistoryEntry);
|
||
} else {
|
||
syncCameraToViewer(
|
||
typeof cameraParams.azimuth === 'number' ? cameraParams.azimuth : 0,
|
||
typeof cameraParams.elevation === 'number' ? cameraParams.elevation : 0,
|
||
typeof cameraParams.distance === 'number' ? cameraParams.distance : DEFAULT_CAMERA_DISTANCE,
|
||
cameraParams.customOrbitCenter || cameraParams.orbitCenter || null,
|
||
typeof cameraParams.roll === 'number' ? cameraParams.roll : 0,
|
||
{ immediateRoll: true }
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.warn('[GaussianViewer] Startup camera restore failed in loadPLYFromData:', e);
|
||
}
|
||
}
|
||
pendingStartupHistoryEntry = null;
|
||
|
||
// Each run starts with full visibility to avoid stale depth clipping from prior sessions.
|
||
if (depthRangeSlider) depthRangeSlider.value = 5;
|
||
if (depthRangeValue) depthRangeValue.textContent = '全部';
|
||
updateDepthRange(Infinity);
|
||
|
||
document.getElementById('infoPanel').classList.remove('hidden');
|
||
document.getElementById('infoContent').innerHTML = `<span style="color:#6cc;">已加载</span><br><span style="color:#888;">${filename}</span>`;
|
||
updateStatus();
|
||
window.parent.postMessage({ type: 'MESH_LOADED', timestamp: Date.now() }, TARGET_ORIGIN);
|
||
} catch (err) {
|
||
console.error('[GaussianViewer] Load error:', err);
|
||
errorEl.textContent = 'Failed to load: ' + err.message;
|
||
errorEl.classList.remove('hidden');
|
||
window.parent.postMessage({ type: 'MESH_ERROR', error: err.message, timestamp: Date.now() }, TARGET_ORIGIN);
|
||
}
|
||
}
|
||
|
||
function captureScreenshot() {
|
||
return new Promise((resolve) => {
|
||
if (!renderer) { resolve(null); return; }
|
||
let rawRotationForCapture = null;
|
||
try {
|
||
if (customOrbitEnabled) updateCameraFromOrbit();
|
||
else {
|
||
controls.update();
|
||
}
|
||
rawRotationForCapture = camera?.rotation
|
||
? new SPLAT.Quaternion(camera.rotation.x, camera.rotation.y, camera.rotation.z, camera.rotation.w)
|
||
: null;
|
||
applyRollToCamera(customOrbitEnabled ? orbitTarget : getRollTargetForRender());
|
||
camera.update();
|
||
renderer.render(scene, camera);
|
||
const gl = renderer.gl || renderer._gl;
|
||
if (!gl) {
|
||
resolve(canvas.toDataURL('image/png'));
|
||
return;
|
||
}
|
||
const gw = gl.drawingBufferWidth, gh = gl.drawingBufferHeight;
|
||
const pixels = new Uint8Array(gw * gh * 4);
|
||
gl.readPixels(0, 0, gw, gh, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||
const bg = getBackgroundRGB();
|
||
const fc = document.createElement('canvas'); fc.width = gw; fc.height = gh;
|
||
const fctx = fc.getContext('2d'), fid = fctx.createImageData(gw, gh);
|
||
for (let y = 0; y < gh; y++) for (let x = 0; x < gw; x++) {
|
||
const si = ((gh-1-y)*gw+x)*4, di = (y*gw+x)*4, a = pixels[si+3]/255;
|
||
fid.data[di] = Math.round(pixels[si]*a + bg.r*(1-a));
|
||
fid.data[di+1] = Math.round(pixels[si+1]*a + bg.g*(1-a));
|
||
fid.data[di+2] = Math.round(pixels[si+2]*a + bg.b*(1-a));
|
||
fid.data[di+3] = 255;
|
||
}
|
||
fctx.putImageData(fid, 0, 0);
|
||
|
||
// 璁$畻绾㈡鍖哄煙 - 浣跨敤涓巙pdateRenderFrame鐩稿悓鐨勯€昏緫
|
||
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||
const oa = outputWidth/outputHeight, ca = cw/ch;
|
||
const scaleFactor = 0.8; // 涓庣孩妗嗘樉绀轰竴鑷?
|
||
|
||
// 璁$畻绾㈡鍦–SS鍍忕礌涓殑灏哄
|
||
let cssFrameW, cssFrameH;
|
||
if (oa > ca) { cssFrameW = cw*scaleFactor; cssFrameH = cssFrameW/oa; }
|
||
else { cssFrameH = ch*scaleFactor; cssFrameW = cssFrameH*oa; }
|
||
|
||
// 灏咰SS鍍忕礌杞崲涓篧ebGL鍍忕礌锛堣€冭檻璁惧鍍忕礌姣旓級
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const frameW = Math.round(cssFrameW * dpr);
|
||
const frameH = Math.round(cssFrameH * dpr);
|
||
const frameX = Math.round((gw - frameW) / 2);
|
||
const frameY = Math.round((gh - frameH) / 2);
|
||
|
||
console.log('[GaussianViewer] Screenshot crop:', frameX, frameY, frameW, frameH, 'output:', outputWidth, 'x', outputHeight);
|
||
|
||
// 鐩存帴鎸夌孩妗嗗尯鍩熻鍒囷紝鏃犲嚭琛€绾?
|
||
const outputCanvas = document.createElement('canvas');
|
||
outputCanvas.width = outputWidth;
|
||
outputCanvas.height = outputHeight;
|
||
const outputCtx = outputCanvas.getContext('2d');
|
||
outputCtx.fillStyle = `rgb(${bg.r},${bg.g},${bg.b})`;
|
||
outputCtx.fillRect(0, 0, outputWidth, outputHeight);
|
||
outputCtx.drawImage(fc, frameX, frameY, frameW, frameH, 0, 0, outputWidth, outputHeight);
|
||
resolve(outputCanvas.toDataURL('image/png'));
|
||
} catch (err) {
|
||
console.error('[GaussianViewer] Screenshot error:', err);
|
||
resolve(null);
|
||
} finally {
|
||
if (rawRotationForCapture) {
|
||
camera.rotation = rawRotationForCapture;
|
||
camera.update();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleConfirm() {
|
||
confirmBtn.disabled = true; confirmBtn.textContent = '处理中...'; cancelBtn.disabled = true;
|
||
console.log('[GaussianViewer] handleConfirm - output size:', outputWidth, 'x', outputHeight);
|
||
const screenshot = await captureScreenshot();
|
||
|
||
// 鎹曡幏3D鐩告満鎺у埗闈㈡澘鎴浘
|
||
let cameraPanelScreenshot = null;
|
||
try {
|
||
cameraPanelScreenshot = window.threeScene?.capture?.() || null;
|
||
} catch (e) { console.warn('[GaussianViewer] 3D panel capture failed:', e); }
|
||
|
||
const latestCameraSnapshot = getCurrentCameraSnapshot();
|
||
window.parent.postMessage({
|
||
type: 'CONFIRM_SELECTION',
|
||
nodeId,
|
||
screenshot,
|
||
cameraPanelScreenshot,
|
||
outputWidth,
|
||
outputHeight,
|
||
params: viewerParams,
|
||
timestamp: Date.now(),
|
||
cameraState: {
|
||
...latestCameraSnapshot,
|
||
scale: currentScale,
|
||
}
|
||
}, TARGET_ORIGIN);
|
||
}
|
||
|
||
function handleCancel() {
|
||
confirmBtn.disabled = true; cancelBtn.disabled = true; cancelBtn.textContent = '取消中...';
|
||
const latestCameraSnapshot = getCurrentCameraSnapshot();
|
||
window.parent.postMessage({
|
||
type: 'CANCEL_SELECTION',
|
||
nodeId,
|
||
timestamp: Date.now(),
|
||
cameraState: latestCameraSnapshot,
|
||
}, TARGET_ORIGIN);
|
||
}
|
||
|
||
window.addEventListener('message', (event) => {
|
||
if (!isTrustedParentMessage(event)) return;
|
||
const { type, data, filename, extrinsics, intrinsics, node_id, params } = event.data || {};
|
||
if (type === 'LOAD_MESH_DATA' && data) {
|
||
nodeId = node_id; viewerParams = params || {};
|
||
// 淇濆瓨鍘熷杈撳叆灏哄
|
||
originalOutputWidth = params?.width || 1024;
|
||
originalOutputHeight = params?.height || 576;
|
||
// 榛樿浣跨敤鍘熷灏哄
|
||
setAspectRatio('original');
|
||
backgroundColor = params?.background || 'black'; updateBackgroundColor();
|
||
confirmBtn.disabled = false; confirmBtn.textContent = '✓确认';
|
||
cancelBtn.disabled = false; cancelBtn.textContent = '✕取消';
|
||
errorEl.classList.add('hidden'); selectedIndices.clear(); setTool('orbit');
|
||
|
||
// 濡傛灉鍚庣浼犲叆浜嗕笂涓€娆$殑 camera_state锛屼紭鍏堢敤瀹冨垵濮嬪寲鐩告満鍙傛暟
|
||
const historyFromServer = Array.isArray(params?.camera_history) ? params.camera_history : [];
|
||
cameraHistory = resequenceCameraHistory(historyFromServer.slice(0, HISTORY_MAX_ITEMS));
|
||
renderCameraHistory();
|
||
pendingStartupHistoryEntry = cameraHistory.length > 0
|
||
? { ...cameraHistory[0] }
|
||
: null;
|
||
|
||
if (pendingStartupHistoryEntry) {
|
||
try {
|
||
applyCachedCameraState(pendingStartupHistoryEntry);
|
||
} catch (e) {
|
||
console.warn('[GaussianViewer] Failed to apply startup history entry:', e);
|
||
pendingStartupHistoryEntry = null;
|
||
resetCachedCameraParams();
|
||
}
|
||
} else {
|
||
resetCachedCameraParams();
|
||
}
|
||
lastCameraPositionForParams = null;
|
||
updateOrbitCenterInputs(cameraParams.customOrbitCenter || cameraParams.orbitCenter || currentOrbitTarget);
|
||
|
||
loadPLYFromData(data, filename || 'gaussian.ply', extrinsics, intrinsics);
|
||
}
|
||
});
|
||
|
||
// ============== Bidirectional Sync with 3D Control Panel ==============
|
||
|
||
/**
|
||
* 浠庣浉鏈轰綅缃拰鏃嬭浆鎺ㄧ畻 OrbitControls 鐨勫疄闄?orbit 涓績锛圦锛?
|
||
* 鐢ㄤ簬鍦?handleConfirm 鏃朵繚瀛樼湡瀹炵殑瑙傚療涓績锛屼互渚夸笅娆$簿纭鍘?
|
||
* 鏃犲钩绉绘椂涓?currentOrbitTarget 瀹屽叏涓€鑷达紱鏈夊钩绉绘椂閫氳繃 camera.rotation 杩戜技鎺ㄧ畻
|
||
*/
|
||
function getOrbitCenter() {
|
||
const picked = getPickedOrbitCenter();
|
||
if (picked) return picked;
|
||
const custom = cloneCenter(cameraParams.customOrbitCenter);
|
||
if (custom) return custom;
|
||
|
||
// Keep orbit center tracking independent from camera Euler decomposition.
|
||
// Roll around the view axis can flip Euler X/Y near high azimuth and cause center drift.
|
||
return cloneCenter(currentOrbitTarget)
|
||
|| cloneCenter(cameraParams.orbitCenter)
|
||
|| getDefaultOrbitCenter();
|
||
}
|
||
|
||
/**
|
||
* 浠?D鎺у埗闈㈡澘鍚屾鐩告満鐘舵€佸埌 Gaussian Viewer
|
||
* @param {number} azimuth - 鏂逛綅瑙?(0-360)
|
||
* @param {number} elevation - 浠拌 (-30 to 90)
|
||
* @param {number} zoom - 缂╂斁璺濈 (0-10)
|
||
* @param {object} [orbitCenter] - 鍙€夛細鎸囧畾 orbit 涓績鐐?{x,y,z}锛屼笉浼犲垯鐢?initialCameraData.target
|
||
* @param {number} [roll] - 姘村钩鏍℃ (-90 to 90)
|
||
*/
|
||
window.syncCameraToViewer = function(azimuth, elevation, zoom, orbitCenter, roll, options = null) {
|
||
if (!camera || !controls) return;
|
||
|
||
const rollVal = typeof roll === 'number' ? roll : (cameraParams.roll || 0);
|
||
setCameraRollValue(rollVal, { immediate: options?.immediateRoll === true });
|
||
|
||
// 浣跨敤浼犲叆鐨?orbitCenter锛堣繕鍘熶繚瀛樻椂鐨勮瀵熶腑蹇冿級锛屽惁鍒欑敤 initialCameraData.target
|
||
const explicitTarget = cloneCenter(orbitCenter);
|
||
if (explicitTarget) {
|
||
cameraParams.customOrbitCenter = cloneCenter(explicitTarget);
|
||
cameraParams.orbitCenter = cloneCenter(explicitTarget);
|
||
}
|
||
|
||
const target = explicitTarget
|
||
|| getConfiguredOrbitCenter()
|
||
|| cloneCenter(currentOrbitTarget)
|
||
|| ((initialCameraData && initialCameraData.target)
|
||
? { x: initialCameraData.target.x, y: initialCameraData.target.y, z: initialCameraData.target.z }
|
||
: { x: 0, y: 0, z: 0 });
|
||
|
||
// 鏇存柊褰撳墠 orbit 涓績璺熻釜锛堜緵 calculateCameraParams / getOrbitCenter 浣跨敤锛?
|
||
currentOrbitTarget = { x: target.x, y: target.y, z: target.z };
|
||
cameraParams.targetCenter = cloneCenter(currentOrbitTarget);
|
||
cameraParams.orbitCenter = cloneCenter(currentOrbitTarget);
|
||
updateOrbitCenterInputs(currentOrbitTarget);
|
||
|
||
// 璁$畻鐩爣鐩告満浣嶇疆
|
||
const position = CT.GSplatAdapter.toGSplatPosition(azimuth, elevation, zoom, target);
|
||
|
||
camera.position.x = position.x;
|
||
camera.position.y = position.y;
|
||
camera.position.z = position.z;
|
||
lastCameraPositionForParams = null;
|
||
|
||
// 鍏抽敭锛氬皢鏂扮浉鏈轰綅缃悓姝ヨ繘 OrbitControls 鍐呴儴鐘舵€侊紙I/d/a/Q锛?
|
||
controls.setCameraTarget(new SPLAT.Vector3(target.x, target.y, target.z));
|
||
|
||
// 涓存椂绂佺敤闃诲凹浠ョ珛鍗冲埌浣嶏紝閬垮厤浠庢棫浣嶇疆鎻掑€煎鑷撮棯鍥?
|
||
const savedDampening = controls.dampening;
|
||
controls.dampening = 1;
|
||
controls.update();
|
||
controls.dampening = savedDampening;
|
||
|
||
|
||
// 鏇存柊鐩告満鍙傛暟鏄剧ず
|
||
cameraParams.azimuth = azimuth;
|
||
cameraParams.elevation = elevation;
|
||
cameraParams.distance = zoom;
|
||
|
||
forceRefreshCameraUi();
|
||
updateStatus();
|
||
|
||
console.log('[GaussianViewer] Synced from 3D panel:', { azimuth, elevation, zoom, roll: cameraParams.roll, orbitCenter: target });
|
||
};
|
||
|
||
/**
|
||
* 浠?Gaussian Viewer 鍚屾鐩告満鐘舵€佸埌3D鎺у埗闈㈡澘
|
||
*/
|
||
function syncViewerToCameraPanel(params = null) {
|
||
params = params || calculateCameraParams();
|
||
if (!params) return;
|
||
|
||
const isPanningCenter = controlsRightPanDragging || controlsRightPanSyncFrames > 0;
|
||
// 鏇存柊3D鎺у埗闈㈡澘锛堝鏋滃瓨鍦級
|
||
// 鍙抽敭骞崇Щ涓績鏃朵笉鎺ㄩ€佽搴︼紝閬垮厤3D闈㈡澘鍦ㄨ儗闈㈤檮杩戞姈鍔?涔辫浆
|
||
if (!isPanningCenter && window.threeScene && window.threeScene.updatePositions) {
|
||
window.threeScene.updatePositions(params.azimuth, params.elevation, params.distance, params.roll);
|
||
}
|
||
|
||
// 鏇存柊棰勮鏄剧ず锛堝彧璇伙級
|
||
const azimuthPreview = document.getElementById('azimuthPreview');
|
||
const elevationPreview = document.getElementById('elevationPreview');
|
||
const zoomSlider = document.getElementById('zoomSlider');
|
||
const zoomInput = document.getElementById('zoomInput');
|
||
|
||
if (azimuthPreview) {
|
||
azimuthPreview.textContent = Math.round(params.azimuth) + '°';
|
||
}
|
||
|
||
if (elevationPreview) {
|
||
elevationPreview.textContent = Math.round(params.elevation) + '°';
|
||
}
|
||
const rollPreview = document.getElementById('rollPreview');
|
||
if (rollPreview) rollPreview.textContent = Math.round(params.roll || 0) + '°';
|
||
if (rollSlider) rollSlider.value = Math.round(params.roll || 0);
|
||
if (rollInput) rollInput.value = Math.round(params.roll || 0);
|
||
|
||
// 缂╂斁璺濈涓嶅啀鑷姩鏇存柊锛屼繚鎸佺敤鎴锋墜鍔ㄨ缃殑鍊?
|
||
// zoomSlider鍜寊oomInput鐨勫€肩敱鐢ㄦ埛鎵嬪姩鎺у埗锛屼笉浠庤繖閲屽悓姝?
|
||
|
||
// 鏇存柊鏄剧ず
|
||
updateOrbitCenterInputs(getOrbitCenter() || getActiveOrbitCenter());
|
||
updateCameraDisplay(params);
|
||
lastCameraPanelSignature = getCameraDisplaySignature(params);
|
||
lastCameraPanelSyncAt = performance.now();
|
||
}
|
||
|
||
/**
|
||
* 鏇存柊鐩告満鏄剧ず淇℃伅
|
||
*/
|
||
function updateCameraDisplay(params) {
|
||
if (!params) return;
|
||
|
||
// 鏇存柊淇℃伅闈㈡澘
|
||
const azimuthDisplay = document.getElementById('azimuthDisplay');
|
||
const elevationDisplay = document.getElementById('elevationDisplay');
|
||
|
||
if (azimuthDisplay) azimuthDisplay.textContent = Math.round(params.azimuth) + '°';
|
||
if (elevationDisplay) elevationDisplay.textContent = Math.round(params.elevation) + '°';
|
||
const rollDisplay = document.getElementById('rollDisplay');
|
||
if (rollDisplay) rollDisplay.textContent = Math.round(params.roll || 0) + '°';
|
||
|
||
// 鏇存柊搴曢儴棰勮锛堝彧璇伙級
|
||
const azimuthPreview = document.getElementById('azimuthPreview');
|
||
const elevationPreview = document.getElementById('elevationPreview');
|
||
const rollPreview = document.getElementById('rollPreview');
|
||
if (azimuthPreview) azimuthPreview.textContent = Math.round(params.azimuth) + '°';
|
||
if (elevationPreview) elevationPreview.textContent = Math.round(params.elevation) + '°';
|
||
if (rollPreview) rollPreview.textContent = Math.round(params.roll || 0) + '°';
|
||
|
||
// 鏇存柊鏍囩
|
||
const azimuthLabel = document.getElementById('azimuthLabelDisplay');
|
||
const elevationLabel = document.getElementById('elevationLabelDisplay');
|
||
|
||
if (azimuthLabel) azimuthLabel.textContent = getAzimuthLabel(params.azimuth);
|
||
if (elevationLabel) elevationLabel.textContent = getElevationLabel(params.elevation);
|
||
|
||
// 鏇存柊鎻忚堪
|
||
const descOutput = document.getElementById('cameraDescriptionOutput');
|
||
if (descOutput) {
|
||
descOutput.value = generateCameraDescription(params);
|
||
}
|
||
}
|
||
|
||
// ============== Camera History (鍘嗗彶闀滃ご) ==============
|
||
|
||
function renderCameraHistory() {
|
||
const listEl = document.getElementById('cameraHistoryList');
|
||
if (!listEl) return;
|
||
|
||
listEl.innerHTML = '';
|
||
|
||
if (!cameraHistory || cameraHistory.length === 0) {
|
||
const empty = document.createElement('div');
|
||
empty.style.color = '#555';
|
||
empty.style.fontSize = '10px';
|
||
empty.textContent = '暂无历史记录';
|
||
listEl.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
const orderedHistory = [...cameraHistory].sort((a, b) => {
|
||
const aSeq = Number(a?.seq);
|
||
const bSeq = Number(b?.seq);
|
||
const aHasSeq = Number.isFinite(aSeq) && aSeq > 0;
|
||
const bHasSeq = Number.isFinite(bSeq) && bSeq > 0;
|
||
if (aHasSeq && bHasSeq) return aSeq - bSeq;
|
||
if (aHasSeq) return 1;
|
||
if (bHasSeq) return -1;
|
||
return 0;
|
||
});
|
||
|
||
orderedHistory.forEach((entry, index) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'history-item';
|
||
|
||
const main = document.createElement('div');
|
||
main.className = 'history-item-main';
|
||
|
||
const angles = document.createElement('div');
|
||
angles.className = 'history-angles';
|
||
const az = Math.round(entry.azimuth ?? 0);
|
||
const el = Math.round(entry.elevation ?? 0);
|
||
const dist = Math.round(entry.distance ?? 5);
|
||
const seqRaw = Number(entry.seq);
|
||
const seq = Number.isFinite(seqRaw) && seqRaw > 0
|
||
? Math.floor(seqRaw)
|
||
: (index + 1);
|
||
angles.textContent = `#${seq} Y:${az}° X:${el}° Zoom:${dist}`;
|
||
|
||
const desc = document.createElement('div');
|
||
desc.className = 'history-desc';
|
||
const aspectLabel = isKnownAspectRatio(entry?.aspectRatio)
|
||
? entry.aspectRatio
|
||
: (
|
||
Number.isFinite(Number(entry?.outputWidth)) && Number.isFinite(Number(entry?.outputHeight))
|
||
? `${Math.round(Number(entry.outputWidth))}x${Math.round(Number(entry.outputHeight))}`
|
||
: ''
|
||
);
|
||
const descriptionText = entry.description || '';
|
||
desc.textContent = aspectLabel
|
||
? (descriptionText ? `${descriptionText} | Ratio:${aspectLabel}` : `Ratio:${aspectLabel}`)
|
||
: descriptionText;
|
||
|
||
main.appendChild(angles);
|
||
main.appendChild(desc);
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'history-item-actions';
|
||
|
||
const btn = document.createElement('button');
|
||
btn.className = 'history-restore-btn';
|
||
btn.textContent = '还原';
|
||
btn.addEventListener('click', () => {
|
||
applyCameraHistoryEntry(entry);
|
||
});
|
||
|
||
const updateBtn = document.createElement('button');
|
||
updateBtn.className = 'history-update-btn';
|
||
updateBtn.textContent = '刷新';
|
||
updateBtn.title = '用当前预览参数更新这条历史镜头';
|
||
updateBtn.addEventListener('click', () => {
|
||
window.updateCameraHistoryEntry(entry);
|
||
});
|
||
|
||
const deleteBtn = document.createElement('button');
|
||
deleteBtn.className = 'history-delete-btn';
|
||
deleteBtn.textContent = 'Del';
|
||
deleteBtn.title = 'Delete this history shot';
|
||
deleteBtn.addEventListener('click', () => {
|
||
window.deleteCameraHistoryEntry(entry);
|
||
});
|
||
|
||
actions.appendChild(updateBtn);
|
||
actions.appendChild(btn);
|
||
actions.appendChild(deleteBtn);
|
||
|
||
item.appendChild(main);
|
||
item.appendChild(actions);
|
||
listEl.appendChild(item);
|
||
});
|
||
}
|
||
|
||
function applyCameraHistoryEntry(entry) {
|
||
const azRaw = Number(entry?.azimuth);
|
||
const elRaw = Number(entry?.elevation);
|
||
const distRaw = Number(entry?.distance);
|
||
const rollRaw = Number(entry?.roll);
|
||
let az = Number.isFinite(azRaw) ? azRaw : 0;
|
||
let el = Number.isFinite(elRaw) ? elRaw : 0;
|
||
let dist = Number.isFinite(distRaw) ? distRaw : 5;
|
||
const roll = Number.isFinite(rollRaw) ? rollRaw : 0;
|
||
const scaleRaw = Number(entry?.scale);
|
||
const scale = Number.isFinite(scaleRaw) ? scaleRaw : DEFAULT_GAUSSIAN_SCALE;
|
||
const historyAspectRatio = typeof entry?.aspectRatio === 'string' ? entry.aspectRatio : null;
|
||
const historyOutputWidth = Number(entry?.outputWidth);
|
||
const historyOutputHeight = Number(entry?.outputHeight);
|
||
|
||
const target = cloneCenter(entry?.orbitCenter)
|
||
|| cloneCenter(entry?.target)
|
||
|| cloneCenter(getOrbitCenter())
|
||
|| getDefaultOrbitCenter();
|
||
const position = cloneCenter(entry?.position);
|
||
applyAspectState(historyAspectRatio, historyOutputWidth, historyOutputHeight);
|
||
|
||
// Ensure OrbitControls path is active.
|
||
customOrbitEnabled = false;
|
||
if (controls) controls.enabled = true;
|
||
|
||
// Prefer exact camera position restore when available.
|
||
if (camera && controls && target && position) {
|
||
setCameraRollValue(roll, { immediate: true });
|
||
cameraParams.customOrbitCenter = cloneCenter(target);
|
||
cameraParams.targetCenter = cloneCenter(target);
|
||
cameraParams.orbitCenter = cloneCenter(target);
|
||
currentOrbitTarget = cloneCenter(target);
|
||
updateOrbitCenterInputs(target);
|
||
|
||
camera.position.x = position.x;
|
||
camera.position.y = position.y;
|
||
camera.position.z = position.z;
|
||
lastCameraPositionForParams = null;
|
||
|
||
controls.setCameraTarget(new SPLAT.Vector3(target.x, target.y, target.z));
|
||
const savedDampening = controls.dampening;
|
||
controls.dampening = 1;
|
||
controls.update();
|
||
controls.dampening = savedDampening;
|
||
|
||
applyRollToCamera(target, getTargetCameraRoll());
|
||
camera.update();
|
||
|
||
const recalculated = calculateCameraParams();
|
||
cameraParams.azimuth = Number.isFinite(recalculated?.azimuth) ? recalculated.azimuth : az;
|
||
cameraParams.elevation = Number.isFinite(recalculated?.elevation) ? recalculated.elevation : el;
|
||
cameraParams.distance = dist;
|
||
setCameraRollValue(roll, { immediate: true });
|
||
updateGaussianScale(scale);
|
||
|
||
syncViewerToCameraPanel();
|
||
updateStatus();
|
||
return;
|
||
}
|
||
|
||
// If precise position exists, derive exact spherical params from it.
|
||
if (target && position) {
|
||
try {
|
||
const precise = CT.GSplatAdapter.fromGSplatPosition(position.x, position.y, position.z, target);
|
||
if (precise) {
|
||
const pAz = Number(precise.azimuth);
|
||
const pEl = Number(precise.elevation);
|
||
if (Number.isFinite(pAz)) az = pAz;
|
||
if (Number.isFinite(pEl)) el = pEl;
|
||
}
|
||
} catch (e) {
|
||
console.warn('[GaussianViewer] Failed to derive precise restore pose:', e);
|
||
}
|
||
}
|
||
|
||
cameraParams.azimuth = az;
|
||
cameraParams.elevation = el;
|
||
cameraParams.distance = dist;
|
||
setCameraRollValue(roll, { immediate: true });
|
||
syncCameraToViewer(az, el, dist, target || null, roll, { immediateRoll: true });
|
||
updateGaussianScale(scale);
|
||
|
||
const zoomSlider = document.getElementById('zoomSlider');
|
||
const zoomInput = document.getElementById('zoomInput');
|
||
if (zoomSlider) zoomSlider.value = String(cameraParams.distance);
|
||
if (zoomInput) zoomInput.value = Number(cameraParams.distance).toFixed(1);
|
||
}
|
||
|
||
// 鐩戝惉鎺у埗鍣ㄥ彉鍖?- 瀹屽叏绂佺敤鍚屾锛?D闈㈡澘瀹屽叏鐙珛鎺у埗
|
||
// Gaussian Viewer鐨勬粴杞€佹嫋鍔ㄧ瓑鎿嶄綔涓嶄細褰卞搷3D鐩告満鎺у埗闈㈡澘
|
||
if (controls) {
|
||
controls.addEventListener('change', () => {
|
||
// 涓嶅啀鍚屾浠讳綍鍙傛暟鍒?D闈㈡澘
|
||
// 3D鐩告満鎺у埗闈㈡澘瀹屽叏鐙珛鎺у埗鏂逛綅瑙掑拰浠拌
|
||
});
|
||
}
|
||
|
||
initViewer();
|
||
// 鍒濆鍖栧悗绔嬪嵆鍚屾鐩告満鐘舵€佸埌3D鎺у埗闈㈡澘
|
||
setTimeout(() => {
|
||
syncViewerToCameraPanel();
|
||
}, 100);
|
||
console.log('[GaussianViewer] Editor ready with 3D sync');
|
||
|
||
|