Implement patterned glass and maximum-depth distance controls
Add gobang surface refraction and shared photo/depth distance adjustment. Compare V2 Small and Base at input 770 with maximum-depth masks and pixel diagnostics. Record browser QA and update runtime dependencies and model notices.
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
// Relative-depth statistics, not semantic foreground detection or metric distance.
|
||||
export const DEPTH_EVIDENCE = { lowerQuantile: 0.5, upperQuantile: 0.85, fallbackLowerQuantile: 0.05, fallbackUpperQuantile: 0.95, minimumSpan: 12 / 255 };
|
||||
export const DEPTH_REFRACTION = { blend: 0.65, maxCellCorrection: 0.35 };
|
||||
export const AI_DEPTH = { inputSize: 770 };
|
||||
export const AI_DEPTH_MODELS = {
|
||||
small: { id: 'onnx-community/depth-anything-v2-small-ONNX', name: 'Small' },
|
||||
base: { id: 'onnx-community/depth-anything-v2-base-ONNX', name: 'Base' },
|
||||
};
|
||||
export type AiDepthModel = keyof typeof AI_DEPTH_MODELS;
|
||||
export const DEPTH_INSPECTION = { nearThreshold: 0.99 };
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
export const ENVIRONMENT_REFLECTION_STRENGTH = 0.12;
|
||||
|
||||
// Gap to the nearest photo surface, in scene units (UI maps 0–100%).
|
||||
export const PHOTO_GLASS_DISTANCE = { minGap: 0.04, maxGap: 0.60 };
|
||||
|
||||
// Shallow patterned front surface and planar back surface, in scene units.
|
||||
export const GOBANG_GLASS = {
|
||||
grid: 20,
|
||||
heightPerCell: 0.085,
|
||||
flatHalfWidth: 0.07,
|
||||
bevelRound: 0.10,
|
||||
refractiveIndex: 1.5,
|
||||
thickness: 0.008,
|
||||
environmentStrength: 0.45,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as THREE from 'three';
|
||||
import { DepthProcessor } from './DepthProcessor';
|
||||
import type { DepthProvider } from '../types/depth';
|
||||
import type { AiDepthModel } from '../constants/depth';
|
||||
|
||||
type WorkerReply =
|
||||
| { type: 'status'; id: number; message: string }
|
||||
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer }
|
||||
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer; normalizedValues: ArrayBuffer }
|
||||
| { type: 'error'; id: number; message: string };
|
||||
|
||||
type PendingRequest = {
|
||||
@@ -20,12 +21,12 @@ export class AiDepthProvider implements DepthProvider {
|
||||
|
||||
constructor(private readonly onStatus: (message: string) => void) {}
|
||||
|
||||
async getDepthMap(image: HTMLImageElement): Promise<THREE.Texture> {
|
||||
async getDepthMap(image: HTMLImageElement, model: AiDepthModel = 'small'): Promise<THREE.Texture> {
|
||||
if (this.pending) throw new Error('AI depth 추론이 이미 진행 중입니다.');
|
||||
const id = ++this.requestId;
|
||||
return new Promise<THREE.Texture>((resolve, reject) => {
|
||||
this.pending = { id, resolve, reject };
|
||||
void this.captureAndSend(image, id).catch((error: unknown) => {
|
||||
void this.captureAndSend(image, id, model).catch((error: unknown) => {
|
||||
this.fail(id, error instanceof Error ? error : new Error('AI depth 생성에 실패했습니다.'));
|
||||
});
|
||||
});
|
||||
@@ -40,7 +41,7 @@ export class AiDepthProvider implements DepthProvider {
|
||||
pending?.reject(new Error('AI depth 요청이 취소되었습니다.'));
|
||||
}
|
||||
|
||||
private async captureAndSend(image: HTMLImageElement, id: number): Promise<void> {
|
||||
private async captureAndSend(image: HTMLImageElement, id: number, model: AiDepthModel): Promise<void> {
|
||||
const width = image.naturalWidth || image.width;
|
||||
const height = image.naturalHeight || image.height;
|
||||
if (!width || !height) throw new Error('사진 크기를 확인할 수 없습니다.');
|
||||
@@ -56,7 +57,7 @@ export class AiDepthProvider implements DepthProvider {
|
||||
}
|
||||
const worker = this.ensureWorker();
|
||||
try {
|
||||
worker.postMessage({ type: 'infer', id, image: bitmap }, [bitmap]);
|
||||
worker.postMessage({ type: 'infer', id, image: bitmap, model }, [bitmap]);
|
||||
} catch (error) {
|
||||
bitmap.close();
|
||||
throw error;
|
||||
@@ -75,7 +76,10 @@ export class AiDepthProvider implements DepthProvider {
|
||||
const pending = this.pending;
|
||||
this.pending = null;
|
||||
try {
|
||||
pending.resolve(DepthProcessor.fromNormalizedDepth(new Uint8Array(message.values), message.width, message.height));
|
||||
const texture = DepthProcessor.fromNormalizedDepth(new Uint8Array(message.values), message.width, message.height);
|
||||
// Keep unquantized normalized values for inspection; geometry still uses the existing byte texture.
|
||||
texture.userData.normalizedDepth = new Float32Array(message.normalizedValues);
|
||||
pending.resolve(texture);
|
||||
} catch (error) {
|
||||
pending.reject(error instanceof Error ? error : new Error('AI depth 결과를 읽지 못했습니다.'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type * as THREE from 'three';
|
||||
import { DEPTH_INSPECTION } from '../constants/depth';
|
||||
|
||||
/** Diagnostic image-space values; never changes the inferred surface or framing. */
|
||||
export class DepthInspection {
|
||||
private canvas = document.querySelector('#depth-inspection-canvas') as HTMLCanvasElement;
|
||||
private summary = document.querySelector('#depth-inspection-summary') as HTMLElement;
|
||||
private point = document.querySelector('#depth-inspection-point') as HTMLElement;
|
||||
private xInput = document.querySelector('#depth-inspection-x') as HTMLInputElement;
|
||||
private yInput = document.querySelector('#depth-inspection-y') as HTMLInputElement;
|
||||
private image: HTMLImageElement | null = null;
|
||||
private data: ArrayLike<number> | null = null;
|
||||
private normalized: Float32Array | null = null;
|
||||
private overlay: HTMLCanvasElement = document.createElement('canvas');
|
||||
private max = 0;
|
||||
private gap = 0;
|
||||
private scale = 0;
|
||||
|
||||
constructor(signal: AbortSignal) {
|
||||
this.canvas.addEventListener('click', (event) => {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.xInput.value = String(Math.min(this.canvas.width - 1, Math.floor((event.clientX - rect.left) / rect.width * this.canvas.width)));
|
||||
this.yInput.value = String(Math.min(this.canvas.height - 1, Math.floor((event.clientY - rect.top) / rect.height * this.canvas.height)));
|
||||
this.updatePoint();
|
||||
}, { signal });
|
||||
[this.xInput, this.yInput].forEach((input) => input.addEventListener('input', () => this.updatePoint(), { signal }));
|
||||
}
|
||||
|
||||
setDepth(image: unknown, texture: THREE.Texture): void {
|
||||
const { data, width, height } = (texture as THREE.DataTexture).image;
|
||||
this.image = image instanceof HTMLImageElement ? image : null;
|
||||
this.data = data && width * height > 1 ? data : null;
|
||||
this.normalized = texture.userData.normalizedDepth ?? null;
|
||||
this.canvas.hidden = !this.image || !this.data;
|
||||
this.point.textContent = '';
|
||||
if (this.canvas.hidden || !this.data) {
|
||||
this.summary.textContent = '사진과 깊이를 불러오세요.';
|
||||
return;
|
||||
}
|
||||
this.canvas.width = this.overlay.width = width;
|
||||
this.canvas.height = this.overlay.height = height;
|
||||
this.xInput.max = String(width - 1);
|
||||
this.yInput.max = String(height - 1);
|
||||
const context = this.overlay.getContext('2d')!;
|
||||
const mask = context.createImageData(width, height);
|
||||
let exact = 0, byteMax = 0, near = 0, peak = 0;
|
||||
this.max = 0;
|
||||
for (let i = 0; i < width * height; i += 1) {
|
||||
if (Number(this.data[i * 4]) / 255 > this.max) {
|
||||
this.max = Number(this.data[i * 4]) / 255;
|
||||
peak = i;
|
||||
}
|
||||
}
|
||||
let peakValue = -Infinity;
|
||||
for (let i = 0; i < width * height; i += 1) {
|
||||
const rendered = Number(this.data[i * 4]) / 255;
|
||||
const value = this.normalized?.[i] ?? rendered;
|
||||
if (value > peakValue) { peakValue = value; peak = i; }
|
||||
if (value === 1) exact += 1;
|
||||
if (rendered === 1) byteMax += 1;
|
||||
if (value >= DEPTH_INSPECTION.nearThreshold) near += 1;
|
||||
const color = value === 1 ? [255, 55, 180] : rendered === 1 ? [0, 240, 255] : value >= DEPTH_INSPECTION.nearThreshold ? [255, 210, 40] : null;
|
||||
if (color) mask.data.set([...color, 210], i * 4);
|
||||
}
|
||||
context.putImageData(mask, 0, 0);
|
||||
const percent = (count: number) => (100 * count / (width * height)).toFixed(4);
|
||||
this.summary.textContent = `${width}×${height} · 최대 depth=${this.max.toFixed(5)}\n${this.normalized ? '모델' : '입력'} 최대 1: ${exact}px (${percent(exact)}%)\n8비트 depth=1: ${byteMax}px (${percent(byteMax)}%)\ndepth≥${DEPTH_INSPECTION.nearThreshold}: ${near}px (${percent(near)}%)\n최대 위치: (${peak % width}, ${Math.floor(peak / width)})`;
|
||||
this.xInput.value = String(peak % width);
|
||||
this.yInput.value = String(Math.floor(peak / width));
|
||||
this.updatePoint();
|
||||
}
|
||||
|
||||
updateDistance(gap: number, scale: number): void {
|
||||
this.gap = gap;
|
||||
this.scale = scale;
|
||||
this.updatePoint();
|
||||
}
|
||||
|
||||
private updatePoint(): void {
|
||||
if (!this.data || !this.image) return;
|
||||
const x = Math.max(0, Math.min(this.canvas.width - 1, Math.round(Number(this.xInput.value))));
|
||||
const y = Math.max(0, Math.min(this.canvas.height - 1, Math.round(Number(this.yInput.value))));
|
||||
const index = y * this.canvas.width + x;
|
||||
const value = Number(this.data[index * 4]) / 255;
|
||||
const gap = this.gap + (this.max - value) * this.scale;
|
||||
this.point.textContent = `픽셀 (${x}, ${y})\n${this.normalized ? `모델 상대 깊이: ${this.normalized[index].toFixed(6)}\n` : ''}8비트 깊이: ${value.toFixed(6)}\n최대 표면 간격: ${this.gap.toFixed(4)}\n선택 지점 간격: ${gap.toFixed(4)}\n뒤로 물러난 양: ${((this.max - value) * this.scale).toFixed(4)}`;
|
||||
const context = this.canvas.getContext('2d')!;
|
||||
context.drawImage(this.image, 0, 0, this.canvas.width, this.canvas.height);
|
||||
context.drawImage(this.overlay, 0, 0);
|
||||
context.strokeStyle = '#ffffff';
|
||||
context.lineWidth = Math.max(1, this.canvas.width / 250);
|
||||
context.beginPath();
|
||||
context.arc(x + 0.5, y + 0.5, Math.max(3, this.canvas.width / 50), 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
+30
-16
@@ -1,14 +1,15 @@
|
||||
import { pipeline, type DepthEstimationPipelineOutput } from '@huggingface/transformers';
|
||||
import { pipeline, type DepthEstimationOutput as DepthEstimationPipelineOutput } from '@huggingface/transformers';
|
||||
import { AI_DEPTH, AI_DEPTH_MODELS, type AiDepthModel } from '../constants/depth';
|
||||
|
||||
const MODEL_ID = 'onnx-community/depth-anything-v2-small-ONNX';
|
||||
type Device = 'webgpu' | 'wasm';
|
||||
type RequestMessage = { type: 'infer'; id: number; image: ImageBitmap };
|
||||
type RequestMessage = { type: 'infer'; id: number; image: ImageBitmap; model: AiDepthModel };
|
||||
type WorkerReply =
|
||||
| { type: 'status'; id: number; message: string }
|
||||
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer }
|
||||
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer; normalizedValues: ArrayBuffer }
|
||||
| { type: 'error'; id: number; message: string };
|
||||
type Estimator = {
|
||||
(image: OffscreenCanvas): Promise<DepthEstimationPipelineOutput | DepthEstimationPipelineOutput[]>;
|
||||
processor: { image_processor: { size: { width: number; height: number } } };
|
||||
dispose(): Promise<void>;
|
||||
};
|
||||
type Progress = { status: string; progress?: number };
|
||||
@@ -24,6 +25,7 @@ const workerScope = self as unknown as {
|
||||
|
||||
let estimator: Estimator | null = null;
|
||||
let activeDevice: Device | null = null;
|
||||
let activeModel: AiDepthModel | null = null;
|
||||
|
||||
function reply(message: WorkerReply, transfer: Transferable[] = []): void {
|
||||
workerScope.postMessage(message, transfer);
|
||||
@@ -33,9 +35,9 @@ function setStatus(id: number, message: string): void {
|
||||
reply({ type: 'status', id, message });
|
||||
}
|
||||
|
||||
async function loadEstimator(device: Device, id: number): Promise<Estimator> {
|
||||
async function loadEstimator(device: Device, id: number, model: AiDepthModel): Promise<Estimator> {
|
||||
setStatus(id, 'AI 모델 준비 중…');
|
||||
return createPipeline('depth-estimation', MODEL_ID, {
|
||||
const loaded = await createPipeline('depth-estimation', AI_DEPTH_MODELS[model].id, {
|
||||
device,
|
||||
progress_callback: (progress) => {
|
||||
if (progress.status === 'progress') {
|
||||
@@ -45,13 +47,20 @@ async function loadEstimator(device: Device, id: number): Promise<Estimator> {
|
||||
}
|
||||
},
|
||||
});
|
||||
loaded.processor.image_processor.size = { width: AI_DEPTH.inputSize, height: AI_DEPTH.inputSize };
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async function ensureEstimator(id: number): Promise<Estimator> {
|
||||
async function ensureEstimator(id: number, model: AiDepthModel): Promise<Estimator> {
|
||||
if (estimator && activeModel !== model) {
|
||||
await estimator.dispose();
|
||||
estimator = null;
|
||||
}
|
||||
if (estimator) return estimator;
|
||||
activeModel = model;
|
||||
if ('gpu' in navigator) {
|
||||
try {
|
||||
estimator = await loadEstimator('webgpu', id);
|
||||
estimator = await loadEstimator('webgpu', id, model);
|
||||
activeDevice = 'webgpu';
|
||||
return estimator;
|
||||
} catch {
|
||||
@@ -60,12 +69,12 @@ async function ensureEstimator(id: number): Promise<Estimator> {
|
||||
setStatus(id, 'AI 모델을 준비하는 중입니다…');
|
||||
}
|
||||
}
|
||||
estimator = await loadEstimator('wasm', id);
|
||||
estimator = await loadEstimator('wasm', id, model);
|
||||
activeDevice = 'wasm';
|
||||
return estimator;
|
||||
}
|
||||
|
||||
function normalizeDepth(output: DepthEstimationPipelineOutput): { values: Uint8Array; width: number; height: number } {
|
||||
function normalizeDepth(output: DepthEstimationPipelineOutput): { values: Uint8Array; normalizedValues: Float32Array; width: number; height: number } {
|
||||
const { predicted_depth: prediction } = output;
|
||||
const [height, width] = prediction.dims.slice(-2);
|
||||
const data = prediction.data;
|
||||
@@ -83,19 +92,23 @@ function normalizeDepth(output: DepthEstimationPipelineOutput): { values: Uint8A
|
||||
|
||||
const range = max - min;
|
||||
const values = new Uint8Array(width * height);
|
||||
const normalizedValues = new Float32Array(width * height);
|
||||
if (range <= 1e-8) {
|
||||
values.fill(128);
|
||||
normalizedValues.fill(0.5);
|
||||
} else {
|
||||
for (let i = 0; i < values.length; i += 1) {
|
||||
const value = Number(data[i]);
|
||||
// Depth Anything V2 predicts relative inverse depth: larger values are nearer.
|
||||
values[i] = Number.isFinite(value) ? Math.round(((value - min) / range) * 255) : 128;
|
||||
const normalized = Number.isFinite(value) ? (value - min) / range : 0.5;
|
||||
normalizedValues[i] = normalized;
|
||||
values[i] = Math.round(normalized * 255);
|
||||
}
|
||||
}
|
||||
return { values, width, height };
|
||||
return { values, normalizedValues, width, height };
|
||||
}
|
||||
|
||||
async function infer({ id, image }: RequestMessage): Promise<void> {
|
||||
async function infer({ id, image, model: modelKey }: RequestMessage): Promise<void> {
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(image.width, image.height);
|
||||
const context = canvas.getContext('2d');
|
||||
@@ -103,7 +116,7 @@ async function infer({ id, image }: RequestMessage): Promise<void> {
|
||||
context.drawImage(image, 0, 0);
|
||||
image.close();
|
||||
|
||||
let model = await ensureEstimator(id);
|
||||
let model = await ensureEstimator(id, modelKey);
|
||||
setStatus(id, 'AI 깊이를 계산하는 중입니다…');
|
||||
let output: DepthEstimationPipelineOutput;
|
||||
try {
|
||||
@@ -116,7 +129,7 @@ async function infer({ id, image }: RequestMessage): Promise<void> {
|
||||
estimator = null;
|
||||
activeDevice = null;
|
||||
setStatus(id, 'AI 추론을 다시 시도하는 중입니다…');
|
||||
model = await loadEstimator('wasm', id);
|
||||
model = await loadEstimator('wasm', id, modelKey);
|
||||
estimator = model;
|
||||
activeDevice = 'wasm';
|
||||
const predicted = await model(canvas);
|
||||
@@ -127,7 +140,8 @@ async function infer({ id, image }: RequestMessage): Promise<void> {
|
||||
try {
|
||||
const normalized = normalizeDepth(output);
|
||||
const values = normalized.values.buffer as ArrayBuffer;
|
||||
reply({ type: 'result', id, width: normalized.width, height: normalized.height, values }, [values]);
|
||||
const normalizedValues = normalized.normalizedValues.buffer as ArrayBuffer;
|
||||
reply({ type: 'result', id, width: normalized.width, height: normalized.height, values, normalizedValues }, [values, normalizedValues]);
|
||||
} finally {
|
||||
output.predicted_depth.dispose();
|
||||
}
|
||||
|
||||
+41
-11
@@ -10,11 +10,12 @@ import { CameraController } from './input/CameraController';
|
||||
import { GyroController } from './input/GyroController';
|
||||
import { StaticDepthProvider } from './depth/StaticDepthProvider';
|
||||
import { DepthProcessor } from './depth/DepthProcessor';
|
||||
import { DEPTH_REFRACTION } from './constants/depth';
|
||||
import { ENVIRONMENT_REFLECTION_STRENGTH } from './constants/glass';
|
||||
import { AI_DEPTH, AI_DEPTH_MODELS, DEPTH_REFRACTION, type AiDepthModel } from './constants/depth';
|
||||
import { ENVIRONMENT_REFLECTION_STRENGTH, GOBANG_GLASS, PHOTO_GLASS_DISTANCE } from './constants/glass';
|
||||
import { AiDepthProvider } from './depth/AiDepthProvider';
|
||||
import { PetDepthMesh } from './objects/PetDepthMesh';
|
||||
import { DepthDebug } from './depth/DepthDebug';
|
||||
import { DepthInspection } from './depth/DepthInspection';
|
||||
import { WindowReveal } from './objects/WindowReveal';
|
||||
import glassScreenVertex from './shaders/glassScreen.vert?raw';
|
||||
import regionPaletteShader from './shaders/region-palette.frag?raw';
|
||||
@@ -24,6 +25,7 @@ const wrap = document.querySelector('#canvas-wrap') as HTMLElement;
|
||||
const status = document.querySelector('#status') as HTMLElement;
|
||||
const depthProvenance = document.querySelector('#depth-provenance') as HTMLElement;
|
||||
const aiDepthButton = document.querySelector('#ai-depth') as HTMLButtonElement;
|
||||
const aiDepthModelInput = document.querySelector('#ai-depth-model') as HTMLSelectElement;
|
||||
const empty = document.querySelector('#empty') as HTMLElement;
|
||||
const hintElement = document.querySelector('#hint') as HTMLElement;
|
||||
hintElement.style.opacity = '0';
|
||||
@@ -37,8 +39,22 @@ scene.background = new THREE.Color(0x111923);
|
||||
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
|
||||
camera.position.z = 3;
|
||||
const geometry = new THREE.PlaneGeometry(1, 1);
|
||||
const emptyTex = new THREE.DataTexture(new Uint8Array([38, 48, 62, 255]), 1, 1);
|
||||
emptyTex.needsUpdate = true;
|
||||
// A neutral room behind the empty glass, with broad light/dark areas, not a grid.
|
||||
const emptyCanvas = document.createElement('canvas');
|
||||
emptyCanvas.width = emptyCanvas.height = 512;
|
||||
const emptyContext = emptyCanvas.getContext('2d')!;
|
||||
const roomGradient = emptyContext.createLinearGradient(0, 0, 512, 512);
|
||||
roomGradient.addColorStop(0, '#c5ced0');
|
||||
roomGradient.addColorStop(0.55, '#788b91');
|
||||
roomGradient.addColorStop(1, '#39474f');
|
||||
emptyContext.fillStyle = roomGradient;
|
||||
emptyContext.fillRect(0, 0, 512, 512);
|
||||
emptyContext.fillStyle = '#e2e7df';
|
||||
emptyContext.fillRect(75, 50, 115, 340);
|
||||
emptyContext.fillStyle = '#52636b';
|
||||
emptyContext.fillRect(320, 0, 28, 512);
|
||||
const emptyTex = new THREE.CanvasTexture(emptyCanvas);
|
||||
emptyTex.colorSpace = THREE.SRGBColorSpace;
|
||||
const petMat = new THREE.MeshBasicMaterial({ map: emptyTex });
|
||||
const pet = new THREE.Mesh(geometry, petMat);
|
||||
scene.add(pet);
|
||||
@@ -47,11 +63,14 @@ const petDepth = new PetDepthMesh(emptyTex, depthMap, 0.25, 0);
|
||||
const petDepthMaterial = petDepth.material;
|
||||
const depthUniforms = petDepthMaterial.uniforms;
|
||||
scene.add(petDepth);
|
||||
const uniforms = { uSceneTexture: { value: emptyTex as THREE.Texture }, uSceneDepth: { value: emptyTex as THREE.Texture }, uProtectionDepthMap: depthUniforms.uDepthMap, uPetWorldToLocal: { value: new THREE.Matrix4() }, uContentViewProjection: { value: new THREE.Matrix4() }, uContentInverseViewProjection: { value: new THREE.Matrix4() }, uContentCameraPosition: { value: new THREE.Vector3() }, uLightPosition: { value: new THREE.Vector3(0.6, 0.8, 2.6) }, uBackgroundColor: { value: scene.background.clone() }, uGlassZ: { value: 0.6 }, uSourceZ: { value: 0.56 }, uGridSize: { value: 13 }, uMosaicStyle: { value: 0 }, uStrength: { value: 1 }, uGlossStrength: { value: 0.3 }, uCurvature: { value: 1 }, uRegionStrength: { value: 1 }, uRegionAvailable: { value: 0 }, uRegionAtlasSize: { value: new THREE.Vector2(104, 104) }, uRegionGridSize: { value: new THREE.Vector2(13, 13) }, uRegionPaletteSize: { value: new THREE.Vector2(65, 13) }, uRegionPalette: { value: emptyTex as THREE.Texture }, uRegionAtlas: { value: emptyTex as THREE.Texture }, uViewOffset: { value: new THREE.Vector2() }, uPaneRect: { value: new THREE.Vector4(0, 0, 1, 1) }, uResolution: { value: new THREE.Vector2() }, uTime: { value: 0 }, uPetSize: { value: new THREE.Vector2(1, 1) }, uPaneSize: { value: new THREE.Vector2(1, 1) } };
|
||||
const uniforms = { uSceneTexture: { value: emptyTex as THREE.Texture }, uSceneDepth: { value: emptyTex as THREE.Texture }, uProtectionDepthMap: depthUniforms.uDepthMap, uPetWorldToLocal: { value: new THREE.Matrix4() }, uContentViewProjection: { value: new THREE.Matrix4() }, uContentInverseViewProjection: { value: new THREE.Matrix4() }, uContentCameraPosition: { value: new THREE.Vector3() }, uLightPosition: { value: new THREE.Vector3(0.6, 0.8, 2.6) }, uBackgroundColor: { value: scene.background.clone() }, uGlassZ: { value: 0.6 }, uSourceZ: { value: 0.56 }, uGridSize: { value: GOBANG_GLASS.grid }, uMosaicStyle: { value: 2 }, uStrength: { value: 1 }, uGlossStrength: { value: 0.3 }, uCurvature: { value: 1 }, uRegionStrength: { value: 0 }, uRegionAvailable: { value: 0 }, uRegionAtlasSize: { value: new THREE.Vector2(104, 104) }, uRegionGridSize: { value: new THREE.Vector2(13, 13) }, uRegionPaletteSize: { value: new THREE.Vector2(65, 13) }, uRegionPalette: { value: emptyTex as THREE.Texture }, uRegionAtlas: { value: emptyTex as THREE.Texture }, uViewOffset: { value: new THREE.Vector2() }, uPaneRect: { value: new THREE.Vector4(0, 0, 1, 1) }, uResolution: { value: new THREE.Vector2() }, uTime: { value: 0 }, uPetSize: { value: new THREE.Vector2(1, 1) }, uPaneSize: { value: new THREE.Vector2(1, 1) } };
|
||||
const regionHistoryUniform = { value: emptyTex as THREE.Texture };
|
||||
const depthEvidenceUniform = { value: new THREE.Vector2(1, 1) };
|
||||
Object.assign(uniforms, { uRegionHistory: regionHistoryUniform, uDepthEvidenceRange: depthEvidenceUniform,
|
||||
uEnvironmentStrength: { value: ENVIRONMENT_REFLECTION_STRENGTH },
|
||||
uGobangSurface: { value: new THREE.Vector4(GOBANG_GLASS.heightPerCell, GOBANG_GLASS.flatHalfWidth, GOBANG_GLASS.refractiveIndex, GOBANG_GLASS.thickness) },
|
||||
uGobangBevelRound: { value: GOBANG_GLASS.bevelRound },
|
||||
uGobangEnvironmentStrength: { value: GOBANG_GLASS.environmentStrength },
|
||||
uDepthCorrection: { value: new THREE.Vector2(DEPTH_REFRACTION.blend, DEPTH_REFRACTION.maxCellCorrection) } });
|
||||
const glassMat = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader });
|
||||
const glass = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), glassMat);
|
||||
@@ -128,6 +147,7 @@ let baseH = 1;
|
||||
let photoZoom = 1;
|
||||
let photoPanX = 0;
|
||||
let photoPanY = 0;
|
||||
let photoGlassGap = PHOTO_GLASS_DISTANCE.minGap;
|
||||
let pendingSampleFraming = false;
|
||||
let activeTestPhotoHasNoDepth = false;
|
||||
let mode = 'noglass';
|
||||
@@ -143,9 +163,11 @@ let manualDepthBusy = false;
|
||||
let aiDepthBusy = false;
|
||||
let disposed = false;
|
||||
const listenerAbort = new AbortController();
|
||||
const depthInspection = new DepthInspection(listenerAbort.signal);
|
||||
function syncAIDepthButton(): void {
|
||||
aiDepthButton.disabled = active === emptyTex || activeIsVideo || mediaBusy || manualDepthBusy || aiDepthBusy || disposed;
|
||||
aiDepthButton.textContent = aiDepthBusy ? 'AI 깊이 생성 중…' : 'AI 깊이 생성';
|
||||
aiDepthModelInput.disabled = mediaBusy || manualDepthBusy || aiDepthBusy || disposed;
|
||||
}
|
||||
const aiDepthProvider = new AiDepthProvider((message) => { if (!disposed && aiDepthBusy) status.textContent = message; });
|
||||
function invalidateDepthRequests(): void {
|
||||
@@ -157,7 +179,7 @@ function invalidateDepthRequests(): void {
|
||||
}
|
||||
function clearPendingPair(): void { if (pendingPairDepth) pendingPairDepth.dispose(); pendingPairDepth = null; }
|
||||
let maxDepthValue = 0;
|
||||
function contentBaseZ(): number { return uniforms.uGlassZ.value - 0.04 - (maxDepthValue - depthUniforms.uDepthBias.value) * depthUniforms.uDepthScale.value; }
|
||||
function contentBaseZ(): number { return uniforms.uGlassZ.value - photoGlassGap - (maxDepthValue - depthUniforms.uDepthBias.value) * depthUniforms.uDepthScale.value; }
|
||||
function clearPendingTestState(): void { clearPendingPair(); pendingSampleDebugImage = null; pendingSampleFraming = false; pendingTestPhotoHasNoDepth = false; }
|
||||
function applyFraming(): void {
|
||||
const baseZ = contentBaseZ();
|
||||
@@ -170,6 +192,7 @@ function applyFraming(): void {
|
||||
petDepth.position.copy(pet.position);
|
||||
uniforms.uSourceZ.value = baseZ;
|
||||
windowReveal.update(uniforms.uPaneSize.value.x, uniforms.uPaneSize.value.y, baseW * (3 - baseZ) / 3, baseH * (3 - baseZ) / 3, uniforms.uGlassZ.value, baseZ);
|
||||
depthInspection.updateDistance(photoGlassGap, depthUniforms.uDepthScale.value);
|
||||
}
|
||||
function resize(): void {
|
||||
const bounds = wrap.getBoundingClientRect();
|
||||
@@ -228,6 +251,7 @@ function setDepth(texture: THREE.Texture): void {
|
||||
if (data && data.length >= 4) {
|
||||
for (let i = 0; i < data.length; i += 4) maxDepthValue = Math.max(maxDepthValue, Number(data[i]) / 255);
|
||||
}
|
||||
depthInspection.setDepth(active.image, texture);
|
||||
applyFraming();
|
||||
regionSignature = '';
|
||||
if (mode === 'depth') { petMat.map = texture; petMat.needsUpdate = true; }
|
||||
@@ -297,6 +321,8 @@ const glossOutput = document.querySelector('#glossOut') as HTMLOutputElement | n
|
||||
const lightXOutput = document.querySelector('#lightXOut') as HTMLOutputElement | null;
|
||||
const lightYOutput = document.querySelector('#lightYOut') as HTMLOutputElement | null;
|
||||
const cameraOutput = document.querySelector('#cameraOut') as HTMLOutputElement;
|
||||
const photoGlassDistanceInput = document.querySelector('#photo-glass-distance') as HTMLInputElement;
|
||||
const photoGlassDistanceOutput = document.querySelector('#photoGlassDistanceOut') as HTMLOutputElement;
|
||||
const sensitivityOutput = document.querySelector('#sensitivityOut') as HTMLOutputElement;
|
||||
const depthOutput = document.querySelector('#depthOut') as HTMLOutputElement | null;
|
||||
const photoZoomInput = document.querySelector('#photo-zoom') as HTMLInputElement | null;
|
||||
@@ -312,11 +338,13 @@ const effects = [mosaicShader, reededShader, frostedShader];
|
||||
let sensitivity = 1;
|
||||
function setGlossControlsVisibility(): void {
|
||||
const enabled = effectInput.value === 'mosaic';
|
||||
const ref1Mosaic = enabled && mosaicStyleInput.value === '0';
|
||||
if (mosaicStyleLabel) mosaicStyleLabel.style.display = enabled ? 'grid' : 'none';
|
||||
mosaicStyleInput.disabled = !enabled;
|
||||
if (glossControls) { glossControls.hidden = !enabled; glossControls.style.display = enabled ? 'grid' : 'none'; }
|
||||
if (regionFixedNote) regionFixedNote.hidden = !ref1Mosaic;
|
||||
if (regionFixedNote) {
|
||||
regionFixedNote.hidden = !enabled;
|
||||
regionFixedNote.textContent = mosaicStyleInput.value === '2' ? '고방유리 · 표면 요철에 따른 굴절 · 색면 처리 없음' : mosaicStyleInput.value === '0' ? 'Ref1 옵션 2 · 색면 단순화 1 · 굴절 1 (고정)' : '기존 촘촘 유리 · 굴절 1 (고정)';
|
||||
}
|
||||
[glossInput, lightXInput, lightYInput, curvatureInput].forEach((input) => { if (input) input.disabled = !enabled; });
|
||||
}
|
||||
function syncPhotoControls(): void {
|
||||
@@ -331,12 +359,13 @@ async function generateAIDepth(automatic = false): Promise<void> {
|
||||
if (aiDepthButton.disabled || !(active.image instanceof HTMLImageElement)) return;
|
||||
const sourceTexture = active;
|
||||
const sourceImage = active.image;
|
||||
const model = aiDepthModelInput.value as AiDepthModel;
|
||||
const request = ++depthRequest;
|
||||
aiDepthBusy = true;
|
||||
status.textContent = 'AI 모델을 준비하는 중입니다. 처음 실행하면 모델 파일을 다운로드합니다.';
|
||||
syncAIDepthButton();
|
||||
try {
|
||||
const texture = await aiDepthProvider.getDepthMap(sourceImage);
|
||||
const texture = await aiDepthProvider.getDepthMap(sourceImage, model);
|
||||
if (disposed || request !== depthRequest || active !== sourceTexture || activeIsVideo) {
|
||||
texture.dispose();
|
||||
return;
|
||||
@@ -345,7 +374,7 @@ async function generateAIDepth(automatic = false): Promise<void> {
|
||||
depthDebug.clear();
|
||||
activeTestPhotoHasNoDepth = false;
|
||||
depthProvenance.hidden = false;
|
||||
depthProvenance.textContent = 'AI 추정 · Depth Anything V2 Small (상대 깊이, 0=뒤 · 1=앞)';
|
||||
depthProvenance.textContent = `AI 추정 · Depth Anything V2 ${AI_DEPTH_MODELS[model].name} · 입력 ${AI_DEPTH.inputSize} (상대 깊이, 0=뒤 · 1=앞)`;
|
||||
setMode('final');
|
||||
status.textContent = automatic ? '자동 AI 추정 depth를 적용했습니다.' : 'AI 추정 depth를 적용하고 Final 뷰로 돌아왔습니다.';
|
||||
showHint();
|
||||
@@ -375,8 +404,9 @@ modeInput?.addEventListener('change', (event) => setMode((event.target as HTMLSe
|
||||
sensitivityInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); sensitivity = value; sensitivityOutput.value = value.toFixed(2); };
|
||||
depthInput?.addEventListener('input', (event) => { const value = Math.min(Number((event.target as HTMLInputElement).value), 0.4); depthUniforms.uDepthScale.value = value; applyFraming(); regionSignature = ''; if (depthOutput) depthOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
cameraOffsetInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); cameraController.setMaxOffset(value); cameraOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
photoGlassDistanceInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); photoGlassGap = THREE.MathUtils.lerp(PHOTO_GLASS_DISTANCE.minGap, PHOTO_GLASS_DISTANCE.maxGap, value / 100); applyFraming(); photoGlassDistanceOutput.value = `${value.toFixed(0)}%`; }, { signal: listenerAbort.signal });
|
||||
gridInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGridSize.value = value; gridOutput.value = value.toFixed(0); updateRegionTargetSizes(); }, { signal: listenerAbort.signal });
|
||||
mosaicStyleInput.addEventListener('change', (event) => { if (effectInput.value !== 'mosaic') return; const style = Number((event.target as HTMLSelectElement).value); uniforms.uMosaicStyle.value = style; uniforms.uGridSize.value = style === 0 ? 13 : 24; uniforms.uStrength.value = 1; gridInput.value = String(uniforms.uGridSize.value); gridOutput.value = String(uniforms.uGridSize.value); updateRegionTargetSizes(); setGlossControlsVisibility(); }, { signal: listenerAbort.signal });
|
||||
mosaicStyleInput.addEventListener('change', (event) => { if (effectInput.value !== 'mosaic') return; const style = Number((event.target as HTMLSelectElement).value); uniforms.uMosaicStyle.value = style; uniforms.uGridSize.value = style === 0 ? 13 : style === 2 ? GOBANG_GLASS.grid : 24; uniforms.uRegionStrength.value = style === 0 ? 1 : 0; uniforms.uStrength.value = 1; gridInput.value = String(uniforms.uGridSize.value); gridOutput.value = String(uniforms.uGridSize.value); updateRegionTargetSizes(); setGlossControlsVisibility(); }, { signal: listenerAbort.signal });
|
||||
curvatureInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uCurvature.value = value; const output = document.querySelector('#curvatureOut') as HTMLOutputElement | null; if (output) output.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
glossInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGlossStrength.value = value; if (glossOutput) glossOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
lightXInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uLightPosition.value.x = value; if (lightXOutput) lightXOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
|
||||
+86
-1
@@ -7,6 +7,9 @@ uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlos
|
||||
uniform vec2 uViewOffset,uPetSize,uPaneSize;
|
||||
uniform vec2 uDepthEvidenceRange,uDepthCorrection;
|
||||
uniform float uEnvironmentStrength;
|
||||
uniform vec4 uGobangSurface;
|
||||
uniform float uGobangEnvironmentStrength;
|
||||
uniform float uGobangBevelRound;
|
||||
uniform vec4 uPaneRect;
|
||||
uniform sampler2D uRegionPalette;
|
||||
uniform sampler2D uRegionAtlas,uRegionHistory;
|
||||
@@ -159,6 +162,81 @@ void evaluateCell(vec2 cell,vec2 local,vec2 grid,vec2 cellSize,vec2 cellDimensio
|
||||
facetSlope=vec2(0.);
|
||||
}
|
||||
}
|
||||
#ifndef REGION_ATLAS
|
||||
// Broad prism faces, with narrow rounded transitions at the ridge and center.
|
||||
// The height and slope meet at cell boundaries; no outline is painted on top.
|
||||
float gobangProfile(float coordinate,out float derivative){
|
||||
float span=.5-uGobangSurface.y;
|
||||
float t=clamp((abs(coordinate)-uGobangSurface.y)/span,0.,1.);
|
||||
float rounding=uGobangBevelRound,ramp=t-rounding*.5,slope=1.;
|
||||
if(t<rounding){ramp=t*t/(2.*rounding);slope=t/rounding;}
|
||||
else if(t>1.-rounding){ramp=1.-rounding-(1.-t)*(1.-t)/(2.*rounding);slope=(1.-t)/rounding;}
|
||||
derivative=-slope*sign(coordinate)/(span*(1.-rounding));
|
||||
return 1.-ramp/(1.-rounding);
|
||||
}
|
||||
void gobangSurface(vec2 paneUv,vec2 grid,vec2 dimensions,float cellWorld,out float height,out vec3 normal){
|
||||
vec2 local=fract(paneUv*grid)-.5;
|
||||
float dx,dy;
|
||||
float px=gobangProfile(local.x,dx),py=gobangProfile(local.y,dy);
|
||||
// Crossing directional ribs produce rectangular optical pieces. A minimum
|
||||
// of X/Y profiles would instead produce conspicuous diagonal triangles.
|
||||
float shape=.25*px+.55*py+.20*px*py;
|
||||
vec2 shapeDerivative=vec2((.25+.20*py)*dx,(.55+.20*px)*dy);
|
||||
// A small continuous ripple bends the ribs without independent cell crops.
|
||||
vec2 phase=paneUv*grid*6.2831853;
|
||||
float ripple=.008*sin(phase.x*3.+sin(phase.y))*sin(phase.y);
|
||||
vec2 rippleDerivative=.008*6.2831853*vec2(3.*cos(phase.x*3.+sin(phase.y))*sin(phase.y),cos(phase.x*3.+sin(phase.y))*cos(phase.y)*sin(phase.y)+sin(phase.x*3.+sin(phase.y))*cos(phase.y));
|
||||
float amplitude=uGobangSurface.x*cellWorld*clamp(uCurvature,-1.,1.);
|
||||
height=amplitude*(shape+ripple);
|
||||
vec2 gradient=amplitude*(shapeDerivative+rippleDerivative)/dimensions;
|
||||
normal=normalize(vec3(-gradient,1.));
|
||||
}
|
||||
vec4 renderGobang(vec3 incident,vec3 glassHit,vec2 paneUv,vec2 grid,float cellWorld){
|
||||
vec2 dimensions=uPaneSize/grid;
|
||||
float height;vec3 normal;
|
||||
gobangSurface(paneUv,grid,dimensions,cellWorld,height,normal);
|
||||
// Two refinements locate the shallow front surface along this camera ray.
|
||||
vec3 frontHit=glassHit;
|
||||
for(int iteration=0;iteration<2;iteration++){
|
||||
frontHit=uContentCameraPosition+incident*((uGlassZ+height-uContentCameraPosition.z)/safeZ(incident.z));
|
||||
gobangSurface(frontHit.xy/uPaneSize+.5,grid,dimensions,cellWorld,height,normal);
|
||||
}
|
||||
float ior=uGobangSurface.z;
|
||||
vec3 internalRay=refract(incident,normal,1./ior);
|
||||
float backZ=uGlassZ-uGobangSurface.w-uGobangSurface.x*cellWorld;
|
||||
vec3 backHit=frontHit+internalRay*((backZ-frontHit.z)/safeZ(internalRay.z));
|
||||
vec3 exitRay=refract(internalRay,vec3(0.,0.,1.),ior);
|
||||
float transmission=step(.0001,dot(exitRay,exitRay));
|
||||
if(transmission<.5)exitRay=incident;
|
||||
vec3 sourceHit=backHit+exitRay*((uSourceZ-backHit.z)/safeZ(exitRay.z));
|
||||
vec3 straightSource=glassHit+incident*((uSourceZ-glassHit.z)/safeZ(incident.z));
|
||||
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));
|
||||
float paneWeight=smoothstep(0.,max(1./grid.x,1./grid.y)*.75,edgeDistance);
|
||||
float amount=clamp(uStrength,0.,1.)*paneWeight;
|
||||
sourceHit=mix(straightSource,sourceHit,amount);
|
||||
// Reuse the visible-depth solve; hidden surfaces still fall back to the plane.
|
||||
sourceHit=refineSceneHit(backHit,normalize(mix(incident,exitRay,amount)),sourceHit,cellWorld);
|
||||
vec4 result=sampleScene(projectUv(sourceHit));
|
||||
|
||||
// One static room environment; the same surface normal controls its reflection.
|
||||
vec3 viewDirection=-incident,reflected=reflect(incident,normal);
|
||||
float fresnel=.04+.96*pow(1.-clamp(dot(normal,viewDirection),0.,1.),5.);
|
||||
vec3 room=mix(vec3(.09,.13,.17),vec3(.78,.84,.86),smoothstep(-.4,.65,reflected.y));
|
||||
float lightTravel=(uLightPosition.z-frontHit.z)/safeZ(reflected.z);
|
||||
vec2 lightUv=(frontHit.xy+reflected.xy*lightTravel-uLightPosition.xy)/(min(uPaneSize.x,uPaneSize.y)*vec2(.38,.52));
|
||||
vec2 window=1.-smoothstep(vec2(.75),vec2(1.1),abs(lightUv));
|
||||
float windowLight=window.x*window.y*step(0.,lightTravel)*step(.001,reflected.z);
|
||||
room=mix(room,vec3(.94,.97,1.),windowLight);
|
||||
float mullion=1.-smoothstep(.015,.06,abs(reflected.x+.12));
|
||||
room=mix(room,vec3(.12,.16,.20),mullion*.55);
|
||||
float gloss=clamp(uGlossStrength,0.,1.);
|
||||
float reflectionWeight=gloss*uGobangEnvironmentStrength*(.35+.65*fresnel)*amount;
|
||||
reflectionWeight=mix(reflectionWeight,1.,(1.-transmission)*amount);
|
||||
result.rgb=mix(result.rgb,room,reflectionWeight);
|
||||
result.a=1.;
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
void main(){
|
||||
vec2 screenUv=vUv;
|
||||
#ifdef REGION_ATLAS
|
||||
@@ -175,7 +253,7 @@ void main(){
|
||||
#endif
|
||||
return;}
|
||||
vec4 f=uContentInverseViewProjection*vec4(screenUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
|
||||
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid;float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
|
||||
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid;float style=clamp(uMosaicStyle,0.,2.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
|
||||
#ifdef REGION_ATLAS
|
||||
paneUv=atlasPaneUv;grid= uRegionGridSize;gridUv=paneUv*grid;cell=floor(gridUv);local=fract(gridUv)-.5;cellSize=1./grid;cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
|
||||
#endif
|
||||
@@ -187,6 +265,13 @@ void main(){
|
||||
#include <colorspace_fragment>
|
||||
#endif
|
||||
return;}
|
||||
#ifndef REGION_ATLAS
|
||||
if(style>1.5){
|
||||
gl_FragColor=renderGobang(i,gh,paneUv,grid,cellWorld);
|
||||
#include <colorspace_fragment>
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
vec2 cellDimensions=uPaneSize/grid;
|
||||
float opticalTravel=-max(abs(uSourceZ-gh.z),.6);
|
||||
vec2 affineUv,curvedSlope,facetSlope;mat2 transform;
|
||||
|
||||
@@ -3,3 +3,8 @@
|
||||
|
||||
details{border:1px solid #344255;border-radius:8px;padding:10px 12px;background:#111923}details summary{cursor:pointer;color:#dce6f2;font-weight:600}details[open] summary{margin-bottom:8px}.details-content{display:grid;gap:12px;padding-top:4px}.control-note,.control-help{margin:0;color:#9eacbb;font-size:12px}.control-help{margin-top:-7px}
|
||||
.core-optics{gap:12px}
|
||||
#depth-inspection-canvas{width:100%;height:auto;margin:10px 0;cursor:crosshair}
|
||||
#depth-inspection-canvas[hidden]{display:none}
|
||||
#depth-inspection pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;color:#b9c9da}
|
||||
.depth-coordinates{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.depth-coordinates input{width:100%;min-width:0}
|
||||
|
||||
Reference in New Issue
Block a user