Replace project with current server release and Windows setup

Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
This commit is contained in:
2026-10-09 16:43:35 +09:00
parent 71c8d3b7ff
commit e358e24436
642 changed files with 35202 additions and 17642 deletions
+83
View File
@@ -0,0 +1,83 @@
import type { TelemetryBody, TelemetryResult, TelemetryTransport } from './Transport';
type TrackerOptions = {
visitorId: string | undefined;
makeDogId: (visitorId: string, rawName: string) => Promise<string>;
transport: TelemetryTransport;
onCount: (count: number) => void;
};
type Journey = { token: number; controller: AbortController; hash: Promise<string>; scheduled: boolean };
/** Collect only explicitly successful personal-photo journeys, off the UI path. */
export function createArrivalTracker({ visitorId, makeDogId, transport, onCount }: TrackerOptions) {
const lifetime = new AbortController();
let started = false;
let generation = 0;
let journey: Journey | undefined;
let countSequence = 0;
let appliedCountSequence = 0;
async function request(path: string, body: TelemetryBody | undefined, signal: AbortSignal): Promise<TelemetryResult> {
try { return await transport.request(path, body, signal); } catch { return { ok: false }; }
}
async function refreshCount(): Promise<void> {
const sequence = ++countSequence;
const result = await request('/api/arrivals/count', undefined, lifetime.signal);
const count = result.count;
if (lifetime.signal.aborted || !result.ok || sequence <= appliedCountSequence ||
typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0) return;
appliedCountSequence = sequence;
try { onCount(count); } catch { /* A removed presentation node cannot break a journey. */ }
}
function invalidate(): void {
generation++;
// Cancellation prevents future attempts; a server write already accepted stays counted.
journey?.controller.abort();
journey = undefined;
}
function current(candidate: Journey): boolean {
return !lifetime.signal.aborted && !candidate.controller.signal.aborted &&
journey === candidate && generation === candidate.token;
}
async function register(candidate: Journey): Promise<void> {
if (!current(candidate)) return;
const dogId = await candidate.hash;
if (!current(candidate) || !/^[0-9a-f]{64}$/.test(dogId)) return;
const result = await request('/api/arrivals', { dog_id: dogId }, candidate.controller.signal);
if (result.ok && current(candidate)) await refreshCount();
}
return {
start(): void {
if (started || lifetime.signal.aborted) return;
started = true;
if (visitorId) void request('/api/visits', { visitor_id: visitorId }, lifetime.signal);
void refreshCount();
},
begin(origin: 'personal' | 'sample', rawName: string): number {
invalidate();
const token = generation;
if (lifetime.signal.aborted || origin !== 'personal' || !visitorId) return token;
// Invoke hashing immediately with immutable strings, never read live name state later.
let hash: Promise<string>;
try { hash = Promise.resolve(makeDogId(visitorId, rawName)).catch(() => ''); }
catch { hash = Promise.resolve(''); }
journey = { token, controller: new AbortController(), hash, scheduled: false };
return token;
},
invalidate,
arrived(token: number): void {
if (!journey || journey.token !== token || !current(journey) || journey.scheduled) return;
journey.scheduled = true;
void register(journey).catch(() => { /* No unhandled telemetry rejection reaches the app. */ });
},
dispose(): void {
invalidate();
lifetime.abort();
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import { normalizePetName } from '../state/PetName';
export const VISITOR_ID_STORAGE_KEY = 'behind-glass.visitor-id.v1';
type VisitorStorage = Pick<Storage, 'getItem' | 'setItem'>;
type IdentityOptions = { getStorage: () => VisitorStorage | undefined; randomUUID?: () => string };
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
// The options object belongs to one collector. No storage means one page-only ID,
// never a fingerprint or a persisted client-side "already counted" flag.
const collectorIds = new WeakMap<IdentityOptions, string | undefined>();
export function getVisitorId(options: IdentityOptions): string | undefined {
if (collectorIds.has(options)) return collectorIds.get(options);
let storage: VisitorStorage | undefined;
let id: string | undefined;
try {
storage = options.getStorage();
const stored = storage?.getItem(VISITOR_ID_STORAGE_KEY);
if (stored && UUID.test(stored)) id = stored;
} catch { /* Storage access is optional. */ }
if (!id) {
try {
const generated = options.randomUUID?.();
if (generated && UUID.test(generated)) id = generated;
} catch { /* Without secure randomness, telemetry stays inert. */ }
if (id) {
try { storage?.setItem(VISITOR_ID_STORAGE_KEY, id); } catch { /* Keep this page's ID. */ }
}
}
collectorIds.set(options, id);
return id;
}
/** The only name-derived value that may leave the browser is this private hash. */
export async function makeDogId(visitorId: string, rawName: string, digest?: SubtleCrypto['digest']): Promise<string> {
try {
if (!digest) return '';
const name = normalizePetName(rawName) || '멍멍이';
const bytes = new TextEncoder().encode(JSON.stringify([1, visitorId, name]));
const result = new Uint8Array(await digest('SHA-256', bytes));
if (result.length !== 32) return '';
return Array.from(result, byte => byte.toString(16).padStart(2, '0')).join('');
} catch { return ''; }
}
+100
View File
@@ -0,0 +1,100 @@
export type TelemetryResult = { ok: boolean; count?: number };
export type TelemetryBody = Record<string, string>;
export type TelemetryTransport = {
request(path: string, body?: TelemetryBody, signal?: AbortSignal): Promise<TelemetryResult>;
};
type TransportOptions = {
fetch?: typeof globalThis.fetch;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
timeoutMs?: number;
retryDelays?: number[];
};
function abortable<T>(work: () => Promise<T>, signal: AbortSignal): Promise<T> {
let cancel: () => void;
return new Promise<T>((resolve, reject) => {
cancel = () => reject(new Error('Telemetry cancelled'));
if (signal.aborted) { cancel(); return; }
signal.addEventListener('abort', cancel, { once: true });
try { Promise.resolve(work()).then(resolve, reject); } catch (error) { reject(error); }
}).finally(() => signal.removeEventListener('abort', cancel));
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise(resolve => {
const finish = () => { clearTimeout(timer); signal?.removeEventListener('abort', finish); resolve(); };
const timer = setTimeout(finish, ms);
if (signal?.aborted) finish();
else signal?.addEventListener('abort', finish, { once: true });
});
}
export function createTransport({
fetch: fetchRequest = (path, init) => globalThis.fetch(path, init),
sleep = delay,
timeoutMs = 3000,
retryDelays = [500, 1500],
}: TransportOptions = {}): TelemetryTransport {
const delays = retryDelays.slice(0, 2);
async function attempt(path: string, body: string | undefined, signal?: AbortSignal) {
const controller = new AbortController();
let response: Response | undefined;
const cancel = () => controller.abort();
signal?.addEventListener('abort', cancel, { once: true });
if (signal?.aborted) cancel();
const timer = setTimeout(cancel, timeoutMs);
try {
return await abortable(async () => {
response = await fetchRequest(path, {
method: body === undefined ? 'GET' : 'POST',
body,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
signal: controller.signal,
credentials: 'same-origin',
cache: 'no-store',
});
if (response.status === 429 || response.status >= 500 && response.status <= 599) return { result: { ok: false }, retry: true };
if (body !== undefined) return { result: { ok: response.status === 204 }, retry: false };
if (response.status !== 200) return { result: { ok: false }, retry: false };
let payload: unknown;
try { payload = await response.json(); }
catch (error) {
if (error instanceof SyntaxError) return { result: { ok: false }, retry: false };
throw error; // A connection can fail after the response headers have arrived.
}
const count = payload && typeof payload === 'object' && 'count' in payload ? payload.count : undefined;
const valid = typeof count === 'number' && Number.isSafeInteger(count) && count >= 0;
return { result: valid ? { ok: true, count } : { ok: false }, retry: false };
}, controller.signal);
} catch { return { result: { ok: false }, retry: !signal?.aborted }; }
finally {
// Header-only outcomes still own a live response stream. Close it before
// detaching cancellation or starting another attempt; never await cleanup.
if (response?.body && !response.bodyUsed) {
controller.abort();
try { void Promise.resolve(response.body.cancel()).catch(() => {}); } catch { /* Already aborted. */ }
}
clearTimeout(timer);
signal?.removeEventListener('abort', cancel);
}
}
return {
async request(path, body, signal) {
try {
// Serialize before the first await; edits during retries cannot change the event.
const payload = body === undefined ? undefined : JSON.stringify(body);
for (let index = 0; index <= delays.length; index++) {
if (signal?.aborted) return { ok: false };
const { result, retry } = await attempt(path, payload, signal);
if (signal?.aborted) return { ok: false };
if (!retry || index === delays.length) return result;
if (signal) await abortable(() => sleep(delays[index], signal), signal);
else await sleep(delays[index]);
}
} catch { /* Telemetry must never reject into media or rendering code. */ }
return { ok: false };
},
};
}
+1 -6
View File
@@ -1,10 +1,5 @@
// 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 AI_DEPTH = { inputSize: 770, modelId: 'onnx-community/depth-anything-v2-small-ONNX', modelName: 'Small' };
export const DEPTH_INSPECTION = { nearThreshold: 0.99 };
+3 -2
View File
@@ -1,7 +1,8 @@
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 };
// Original 0–100% gap mapping in scene units; the UI exposes only 0–21%.
// Keep value / 100: the UI maximum is a 0.1576 gap, not maxGap.
export const PHOTO_GLASS_DISTANCE = { minGap: 0.04, maxGap: 0.60, defaultPercent: 16 };
// Shallow patterned front surface and planar back surface, in scene units.
export const GOBANG_GLASS = {
+79
View File
@@ -0,0 +1,79 @@
import type { CutoutResult } from './types';
/** Alpha is never destructively thresholded. Thresholds are for bounds/review only. */
export function analyzeCutout(alpha: Uint8Array, width: number, height: number): Pick<CutoutResult, 'bounds' | 'largeComponentCount' | 'warnings'> {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || alpha.length !== width * height)
throw new Error('누끼 크기를 읽지 못했어요. 다른 사진으로 다시 시도해 주세요.');
let x0 = width, y0 = height, x1 = 0, y1 = 0, count = 0, opaque = 0;
for (let i = 0; i < alpha.length; i++) {
if (alpha[i] === 255)
opaque++;
if (alpha[i] >= 13) {
const x = i % width, y = Math.floor(i / width);
x0 = Math.min(x0, x);
y0 = Math.min(y0, y);
x1 = Math.max(x1, x + 1);
y1 = Math.max(y1, y + 1);
count++;
}
}
if (!count || opaque === alpha.length)
throw new Error('강아지와 배경을 구분하지 못했어요. 다른 사진으로 다시 시도해 주세요.');
const seen = new Uint8Array(alpha.length), queue = new Int32Array(count), components: number[] = [];
for (let i = 0; i < alpha.length; i++) {
if (seen[i] || alpha[i] < 13)
continue;
let head = 0, tail = 1;
queue[0] = i;
seen[i] = 1;
while (head < tail) {
const p = queue[head++], x = p % width, y = Math.floor(p / width);
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++) {
const xx = x + dx, yy = y + dy;
if (xx < 0 || xx >= width || yy < 0 || yy >= height)
continue;
const n = yy * width + xx;
if (!seen[n] && alpha[n] >= 13) {
seen[n] = 1;
queue[tail++] = n;
}
}
}
components.push(tail);
}
const largeComponentCount = components.filter(n => n >= count * .05).length;
const warnings: CutoutResult['warnings'] = [];
if (largeComponentCount > 1)
warnings.push('multiple-foreground');
if (count < 16)
warnings.push('tiny-foreground');
const padding = Math.ceil(Math.max(width, height) * .01);
return { bounds: { x0: Math.max(0, x0 - padding), y0: Math.max(0, y0 - padding), x1: Math.min(width, x1 + padding), y1: Math.min(height, y1 + padding) }, largeComponentCount, warnings };
}
export function resampleBilinear(input: ArrayLike<number>, width: number, height: number, targetWidth: number, targetHeight: number): Float32Array {
if (input.length !== width * height || Math.min(width, height, targetWidth, targetHeight) < 1)
throw new Error('Invalid image dimensions');
const result = new Float32Array(targetWidth * targetHeight);
for (let y = 0; y < targetHeight; y++)
for (let x = 0; x < targetWidth; x++) {
const xx = Math.max(0, Math.min(width - 1, (x + .5) * width / targetWidth - .5)), yy = Math.max(0, Math.min(height - 1, (y + .5) * height / targetHeight - .5)), x0 = Math.floor(xx), y0 = Math.floor(yy), x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1), fx = xx - x0, fy = yy - y0;
result[y * targetWidth + x] = (input[y0 * width + x0] * (1 - fx) + input[y0 * width + x1] * fx) * (1 - fy) + (input[y1 * width + x0] * (1 - fx) + input[y1 * width + x1] * fx) * fy;
}
return result;
}
export function normalizeMask(data: ArrayLike<number>, width: number, height: number, targetWidth: number, targetHeight: number): Uint8Array {
if (data.length !== width * height)
throw new Error('모델 누끼 크기가 맞지 않아요.');
let min = Infinity, max = -Infinity;
for (let i = 0; i < data.length; i++) {
const n = Number(data[i]);
if (!Number.isFinite(n))
throw new Error('모델 누끼 값이 유효하지 않아요.');
min = Math.min(min, n);
max = Math.max(max, n);
}
if (max - min <= 1e-8)
throw new Error('모델이 강아지를 구분하지 못했어요.');
const sampled = resampleBilinear(data, width, height, targetWidth, targetHeight);
return Uint8Array.from(sampled, n => Math.round(255 * Math.max(0, Math.min(1, (n - min) / (max - min)))));
}
+136
View File
@@ -0,0 +1,136 @@
import { createPhotoSnapshot } from '../input/PhotoSnapshot';
import { CutoutProvider } from './CutoutProvider';
import { analyzeCutout } from './CutoutMask';
import type { PreparedCutout } from './types';
export type { PreparedCutout, CutoutBounds, CutoutRun, CutoutWarning } from './types';
type PendingPreparation = {
id: number;
controller: AbortController;
onProgress: (message: string) => void;
};
/** Explicit, lazy experimental preparation only. The UI must show this result
* and obtain acceptance before applying it. No scene, texture or depth changes
* are performed here. Each result owns both preview URLs until dispose(). */
export class CutoutPreparation {
private nextId = 0;
private pending: PendingPreparation | null = null;
private disposed = false;
private readonly results = new Set<PreparedCutout>();
private readonly provider = new CutoutProvider((id, message) => {
if (this.pending?.id === id && !this.pending.controller.signal.aborted)
this.pending.onProgress(message);
});
async prepare(file: File, onProgress: (message: string) => void = () => {}, signal?: AbortSignal): Promise<PreparedCutout> {
if (this.disposed) throw new Error('누끼 준비가 종료됐어요.');
this.cancel();
signal?.throwIfAborted();
const controller = new AbortController();
const pending: PendingPreparation = { id: ++this.nextId, controller, onProgress };
this.pending = pending;
const abort = () => controller.abort(signal?.reason);
signal?.addEventListener('abort', abort, { once: true });
let snapshot: Awaited<ReturnType<typeof createPhotoSnapshot>> | null = null;
let originalUrl = '', cutoutUrl = '';
const originalImage = new Image(), cutoutImage = new Image();
let released = false;
let result: PreparedCutout | null = null;
const release = () => {
if (released) return;
released = true;
for (const image of [originalImage, cutoutImage]) {
image.onload = image.onerror = null;
image.removeAttribute('src');
}
if (originalUrl) URL.revokeObjectURL(originalUrl);
if (cutoutUrl) URL.revokeObjectURL(cutoutUrl);
if (result) this.results.delete(result);
};
const canvas = document.createElement('canvas');
try {
onProgress('사진 크기와 방향을 준비하는 중…');
snapshot = await createPhotoSnapshot(file, pending.id, controller.signal);
controller.signal.throwIfAborted();
const cutout = await this.provider.run(snapshot, controller.signal);
controller.signal.throwIfAborted();
canvas.width = snapshot.width;
canvas.height = snapshot.height;
const context = canvas.getContext('2d');
if (!context) throw new Error('사진 미리보기를 준비할 수 없어요.');
context.drawImage(snapshot.bitmap, 0, 0);
// Keep the complete bounded RGB/alpha before touching the mask.
const originalBlob = await toPNG(canvas);
controller.signal.throwIfAborted();
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
const alpha = new Uint8Array(cutout.alpha.length);
for (let i = 0; i < alpha.length; i++) {
alpha[i] = Math.round(pixels.data[i * 4 + 3] * cutout.alpha[i] / 255);
pixels.data[i * 4 + 3] = alpha[i];
}
const analysis = analyzeCutout(alpha, snapshot.width, snapshot.height);
context.putImageData(pixels, 0, 0);
const cutoutBlob = await toPNG(canvas);
controller.signal.throwIfAborted();
// PNG + <img> decoding avoids the previous SVG Blob bitmap path.
originalUrl = URL.createObjectURL(originalBlob);
cutoutUrl = URL.createObjectURL(cutoutBlob);
await decodePreview(originalImage, originalUrl, controller.signal);
await decodePreview(cutoutImage, cutoutUrl, controller.signal);
controller.signal.throwIfAborted();
result = { ...cutout, alpha, ...analysis, requiresReview: true,
originalBlob, cutoutBlob, originalUrl, cutoutUrl, originalImage, cutoutImage,
dispose: release };
this.results.add(result);
return result;
} catch (error) {
release();
throw error;
} finally {
snapshot?.bitmap.close();
canvas.width = canvas.height = 1;
signal?.removeEventListener('abort', abort);
if (this.pending === pending) this.pending = null;
}
}
/** Cancel only unfinished preparation. Already delivered results remain
* usable until their consumer releases them or this instance is disposed. */
cancel(): void {
const pending = this.pending;
this.pending = null;
pending?.controller.abort();
this.provider.cancel();
}
/** Final teardown, including any delivered result the UI still owns. */
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.cancel();
this.provider.dispose();
for (const result of this.results) result.dispose();
}
}
function toPNG(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => canvas.toBlob(blob => {
if (!blob || blob.type !== 'image/png') reject(new Error('PNG 사진 미리보기를 만들지 못했어요.'));
else resolve(blob);
}, 'image/png'));
}
function decodePreview(image: HTMLImageElement, url: string, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
image.onload = image.onerror = null;
signal.removeEventListener('abort', abort);
};
const abort = () => { cleanup(); reject(new DOMException('사진 준비를 취소했어요.', 'AbortError')); };
image.onload = () => { cleanup(); resolve(); };
image.onerror = () => { cleanup(); reject(new Error('PNG 사진 미리보기를 읽지 못했어요.')); };
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort(); else image.src = url;
});
}
+114
View File
@@ -0,0 +1,114 @@
import type { PhotoSnapshot } from '../types/photo';
import type { CutoutResult, CutoutRun } from './types';
import { analyzeCutout } from './CutoutMask';
type Reply = {
type: 'status';
id: number;
message: string;
} | {
type: 'fallback';
id: number;
} | {
type: 'error';
id: number;
message: string;
} | {
type: 'result';
id: number;
width: number;
height: number;
alpha: ArrayBuffer;
run: CutoutRun;
};
type Pending = {
snapshot: PhotoSnapshot;
signal: AbortSignal;
abort: () => void;
fallback: boolean;
resolve: (result: CutoutResult) => void;
reject: (error: Error) => void;
};
export class CutoutProvider {
private worker: Worker | null = null;
private disposed = false;
private pending: Pending | null = null;
private readonly onStatus: (requestId: number, message: string) => void;
constructor(onStatus: (requestId: number, message: string) => void = () => {}) { this.onStatus = onStatus; }
run(snapshot: PhotoSnapshot, signal: AbortSignal): Promise<CutoutResult> {
if (this.disposed) return Promise.reject(new Error('누끼 준비가 종료됐어요.'));
this.cancel();
signal.throwIfAborted();
return new Promise((resolve, reject) => { const abort = () => this.cancel(); const pending: Pending = { snapshot, signal, abort, fallback: false, resolve, reject }; this.pending = pending; signal.addEventListener('abort', abort, { once: true }); void this.send(pending).catch(error => { if (this.pending === pending)
this.fail(error); }); });
}
cancel(): void { const p = this.pending; this.pending = null; this.worker?.terminate(); this.worker = null; if (p) {
p.signal.removeEventListener('abort', p.abort);
p.reject(new DOMException('사진 준비를 취소했어요.', 'AbortError'));
} }
dispose(): void { this.disposed = true; this.cancel(); }
private fail(error: unknown): void { const p = this.pending; if (!p)
return; this.pending = null; this.worker?.terminate(); this.worker = null; p.signal.removeEventListener('abort', p.abort); p.reject(error instanceof Error ? error : new Error('누끼를 준비하지 못했어요.')); }
private async send(p: Pending): Promise<void> {
const bitmap = await createImageBitmap(p.snapshot.bitmap);
if (this.pending !== p || p.signal.aborted) {
bitmap.close();
return;
}
let worker: Worker;
try {
worker = new Worker(new URL('./cutout.worker.ts', import.meta.url), { type: 'module' });
}
catch (error) {
bitmap.close();
throw error;
}
this.worker = worker;
worker.onmessage = (event: MessageEvent<Reply>) => {
if (this.worker !== worker || this.pending !== p || event.data.id !== p.snapshot.requestId)
return;
const m = event.data;
if (m.type === 'status') {
this.onStatus(m.id, m.message);
return;
}
worker.terminate();
this.worker = null;
if (m.type === 'fallback') {
if (p.fallback) {
this.fail(new Error('이 기기에서 누끼를 계산하지 못했어요.'));
return;
}
p.fallback = true;
void this.send(p).catch(error => { if (this.pending === p)
this.fail(error); });
return;
}
if (m.type === 'error') {
this.fail(new Error(m.message));
return;
}
try {
if (m.width !== p.snapshot.width || m.height !== p.snapshot.height)
throw new Error('누끼 좌표가 사진과 맞지 않아요.');
const alpha = new Uint8Array(m.alpha), analysis = analyzeCutout(alpha, m.width, m.height);
this.pending = null;
p.signal.removeEventListener('abort', p.abort);
p.resolve({ requestId: m.id, width: m.width, height: m.height, alpha, ...analysis, run: m.run });
}
catch (error) {
this.fail(error);
}
};
worker.onerror = event => { if (this.worker === worker && this.pending === p)
this.fail(new Error(event.message || '누끼 작업이 중단됐어요.')); };
worker.onmessageerror = () => { if (this.worker === worker && this.pending === p)
this.fail(new Error('누끼 결과를 읽지 못했어요.')); };
try {
worker.postMessage({ type: 'infer', id: p.snapshot.requestId, image: bitmap, forceWasm: p.fallback }, [bitmap]);
}
catch (error) {
bitmap.close();
throw error;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
/** Input is the canonical RGB resized to the model resolution by the caller. */
export function preprocessU2Net(rgba: Uint8ClampedArray, width: number, height: number): Float32Array {
const size = width * height;
if (rgba.length !== size * 4 || size < 1)
throw new Error('Invalid U2Net RGB dimensions');
let maximum = 0;
for (let i = 0; i < size; i++)
for (let c = 0; c < 3; c++)
maximum = Math.max(maximum, rgba[i * 4 + c]);
const divisor = Math.max(maximum, 1e-6), mean = [.485, .456, .406], std = [.229, .224, .225], data = new Float32Array(size * 3);
for (let c = 0; c < 3; c++)
for (let i = 0; i < size; i++)
data[c * size + i] = (rgba[i * 4 + c] / divisor - mean[c]) / std[c];
return data;
}
+32
View File
@@ -0,0 +1,32 @@
import * as ort from 'onnxruntime-web/webgpu';
import { preprocessU2Net } from './U2NetPreprocess';
import { normalizeMask } from './CutoutMask';
import type { CutoutRun } from './types';
const MODEL='/models/u2netp/model.onnx';
const MODEL_SHA='309c8469258dda742793dce0ebea8e6dd393174f89934733ecc8b14c76f4ddd8';
const RUNTIME='1.31.0-dev.20260914-8d85527a0';
ort.env.wasm.wasmPaths=`https://cdn.jsdelivr.net/npm/onnxruntime-web@${RUNTIME}/dist/`;
ort.env.wasm.numThreads=1;
const scope=self as unknown as {postMessage(message:unknown,transfer?:Transferable[]):void;addEventListener(type:'message',listener:(event:MessageEvent<{type:string;id:number;image:ImageBitmap;forceWasm?:boolean}>)=>void):void};
scope.addEventListener('message',event=>{if(event.data.type==='infer')void infer(event.data);});
async function infer({id,image,forceWasm}:{id:number;image:ImageBitmap;forceWasm?:boolean}):Promise<void>{
const started=performance.now();let session:ort.InferenceSession|null=null,input:ort.Tensor|null=null,outputs:ort.InferenceSession.ReturnType|null=null,backend:'webgpu'|'wasm'='wasm';
try{
const width=image.width,height=image.height;
scope.postMessage({type:'status',id,message:'실험용 누끼 모델을 준비하는 중…'});
const response=await fetch(MODEL,{cache:'force-cache'});if(!response.ok)throw new Error('누끼 모델을 불러오지 못했어요. 다시 시도해 주세요.');
const bytes=await response.arrayBuffer();const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),v=>v.toString(16).padStart(2,'0')).join('');if(hash!==MODEL_SHA)throw new Error('누끼 모델 파일을 확인하지 못했어요.');
if(!forceWasm){try{const gpu=(navigator as Navigator&{gpu?:{requestAdapter():Promise<object|null>}}).gpu;if(await gpu?.requestAdapter())backend='webgpu';}catch{/* A real adapter is required before requesting GPU inference. */}}
const canvas=new OffscreenCanvas(320,320),context=canvas.getContext('2d');if(!context)throw new Error('이 브라우저에서 사진을 읽지 못했어요.');context.drawImage(image,0,0,320,320);
input=new ort.Tensor('float32',preprocessU2Net(context.getImageData(0,0,320,320).data,320,320),[1,3,320,320]);
let preparationMs=0,inferenceMs=0;
try{session=await ort.InferenceSession.create(bytes,{executionProviders:[backend],graphOptimizationLevel:'all'});preparationMs=performance.now()-started;scope.postMessage({type:'status',id,message:'강아지만 남기는 중… 결과는 다음 화면에서 꼭 확인해 주세요.'});const t=performance.now();outputs=await session.run({[session.inputNames[0]]:input});inferenceMs=performance.now()-t;}
catch(error){if(backend==='webgpu'){await session?.release().catch(()=>{});session=null;scope.postMessage({type:'fallback',id});return;}throw error;}
const output=outputs[session.outputNames[0]];if(output.type!=='float32')throw new Error('누끼 모델의 결과 형식이 맞지 않아요.');
const dimensions=output.dims.slice(-2),alpha=normalizeMask(output.data as Float32Array,dimensions[1],dimensions[0],width,height);
Object.values(outputs).forEach(t=>t.dispose());outputs=null;input.dispose();input=null;await session.release();session=null;
const run:CutoutRun={model:'U2Netp · experimental foreground',revision:MODEL_SHA,backend,downloadBytes:bytes.byteLength,preparationMs,inferenceMs,totalMs:performance.now()-started};
scope.postMessage({type:'result',id,width,height,alpha:alpha.buffer,run},[alpha.buffer]);
}catch(error){scope.postMessage({type:'error',id,message:error instanceof Error?error.message:'누끼를 만들지 못했어요. 다른 사진이나 기존 방식을 선택해 주세요.'});}
finally{image.close();input?.dispose();if(outputs)Object.values(outputs).forEach(t=>t.dispose());await session?.release().catch(()=>{});}
}
+36
View File
@@ -0,0 +1,36 @@
/** Bounds use top-left image coordinates with exclusive right/bottom edges. */
export type CutoutBounds = { x0: number; y0: number; x1: number; y1: number };
export type CutoutWarning = 'multiple-foreground' | 'tiny-foreground';
export type CutoutRun = {
model: string;
revision: string;
backend: 'webgpu' | 'wasm';
downloadBytes: number;
preparationMs: number;
inferenceMs: number;
totalMs: number;
};
export type CutoutResult = {
requestId: number;
width: number;
height: number;
alpha: Uint8Array;
bounds: CutoutBounds;
largeComponentCount: number;
warnings: CutoutWarning[];
run: CutoutRun;
};
/** A review candidate, never permission to apply it or start depth estimation.
* Keep originalImage/originalBlob as the depth RGB input. cutoutImage contains
* the same RGB with the predicted alpha multiplied by any input alpha.
* The consumer keeps this alive until all its textures/images are replaced. */
export type PreparedCutout = CutoutResult & {
requiresReview: true;
originalBlob: Blob;
cutoutBlob: Blob;
originalUrl: string;
cutoutUrl: string;
originalImage: HTMLImageElement;
cutoutImage: HTMLImageElement;
dispose(): void;
};
+40 -10
View File
@@ -1,15 +1,20 @@
import * as THREE from 'three';
import { DepthProcessor } from './DepthProcessor';
import type { DepthProvider } from '../types/depth';
import type { AiDepthModel } from '../constants/depth';
import type { DepthProvider, AiDepthRun } from '../types/depth';
type WorkerReply =
| { type: 'status'; id: number; message: string }
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer; normalizedValues: ArrayBuffer }
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer; normalizedValues: ArrayBuffer; run: AiDepthRun }
| { type: 'fallback'; id: number; preparationMs: number; inferenceMs: number }
| { type: 'error'; id: number; message: string };
type PendingRequest = {
id: number;
started: number;
image: HTMLImageElement;
fallbackAttempted: boolean;
preparationMs: number;
inferenceMs: number;
resolve: (texture: THREE.Texture) => void;
reject: (error: Error) => void;
};
@@ -21,12 +26,12 @@ export class AiDepthProvider implements DepthProvider {
constructor(private readonly onStatus: (message: string) => void) {}
async getDepthMap(image: HTMLImageElement, model: AiDepthModel = 'small'): Promise<THREE.Texture> {
async getDepthMap(image: HTMLImageElement): 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, model).catch((error: unknown) => {
this.pending = { id, started: performance.now(), image, fallbackAttempted: false, preparationMs: 0, inferenceMs: 0, resolve, reject };
void this.captureAndSend(image, id).catch((error: unknown) => {
this.fail(id, error instanceof Error ? error : new Error('AI depth 생성에 실패했습니다.'));
});
});
@@ -38,10 +43,10 @@ export class AiDepthProvider implements DepthProvider {
this.requestId += 1;
this.worker?.terminate();
this.worker = null;
pending?.reject(new Error('AI depth 요청이 취소되었습니다.'));
pending?.reject(new DOMException('AI depth 요청이 취소되었습니다.', 'AbortError'));
}
private async captureAndSend(image: HTMLImageElement, id: number, model: AiDepthModel): Promise<void> {
private async captureAndSend(image: HTMLImageElement, id: number, forceWasm = false): Promise<void> {
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (!width || !height) throw new Error('사진 크기를 확인할 수 없습니다.');
@@ -55,9 +60,9 @@ export class AiDepthProvider implements DepthProvider {
bitmap.close();
return;
}
const worker = this.ensureWorker();
try {
worker.postMessage({ type: 'infer', id, image: bitmap, model }, [bitmap]);
const worker = this.ensureWorker();
worker.postMessage({ type: 'infer', id, image: bitmap, ...(forceWasm ? { forceWasm: true } : {}) }, [bitmap]);
} catch (error) {
bitmap.close();
throw error;
@@ -68,6 +73,7 @@ export class AiDepthProvider implements DepthProvider {
if (this.worker) return this.worker;
const worker = new Worker(new URL('./aiDepth.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event: MessageEvent<WorkerReply>) => {
if (this.worker !== worker) return;
const message = event.data;
if (this.pending?.id !== message.id) return;
if (message.type === 'status') {
@@ -75,23 +81,47 @@ export class AiDepthProvider implements DepthProvider {
} else if (message.type === 'result') {
const pending = this.pending;
this.pending = null;
// Both result buffers are transferred into this realm. Release the
// inference runtime before the caller starts the full-screen GL scene.
// A manual new inference uses cached files in a fresh worker.
this.resetWorker(worker);
try {
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);
// Main-thread wall time includes capture, worker messaging, preparation and inference.
texture.userData.aiDepthRun = { ...message.run, preparationMs: pending.preparationMs + message.run.preparationMs, inferenceMs: pending.inferenceMs + message.run.inferenceMs, totalMs: performance.now() - pending.started } satisfies AiDepthRun;
pending.resolve(texture);
} catch (error) {
pending.reject(error instanceof Error ? error : new Error('AI depth 결과를 읽지 못했습니다.'));
}
} else if (message.type === 'fallback') {
const pending = this.pending;
this.resetWorker(worker);
if (pending.fallbackAttempted) {
this.fail(message.id, new Error('이 기기에서 AI 깊이를 계산하지 못했습니다. 다시 시도하거나 평면 사진으로 볼 수 있어요.'));
return;
}
pending.fallbackAttempted = true;
pending.preparationMs += message.preparationMs;
pending.inferenceMs += message.inferenceMs;
this.onStatus('다른 길을 찾고 있어요. 조금만 더 기다려 주세요…');
void this.captureAndSend(pending.image, pending.id, true).catch((error: unknown) => {
this.fail(pending.id, error instanceof Error ? error : new Error('AI depth 생성에 실패했습니다.'));
});
} else {
// A failed runtime can keep a rejected session chain; manual retry must be fresh too.
this.resetWorker(worker);
this.fail(message.id, new Error(message.message));
}
};
worker.onerror = (event) => {
if (this.worker !== worker) return;
this.resetWorker(worker);
this.fail(this.pending?.id ?? -1, new Error(event.message || 'AI depth worker 오류가 발생했습니다.'));
};
worker.onmessageerror = () => {
if (this.worker !== worker) return;
this.resetWorker(worker);
this.fail(this.pending?.id ?? -1, new Error('AI depth worker 결과를 읽지 못했습니다.'));
};
+80 -52
View File
@@ -1,11 +1,12 @@
import { pipeline, type DepthEstimationOutput as DepthEstimationPipelineOutput } from '@huggingface/transformers';
import { AI_DEPTH, AI_DEPTH_MODELS, type AiDepthModel } from '../constants/depth';
import { AI_DEPTH } from '../constants/depth';
import type { AiDepthPreset, AiDepthRun } from '../types/depth';
type Device = 'webgpu' | 'wasm';
type RequestMessage = { type: 'infer'; id: number; image: ImageBitmap; model: AiDepthModel };
type RequestMessage = { type: 'infer'; id: number; image: ImageBitmap; forceWasm?: boolean };
type WorkerReply =
| { type: 'status'; id: number; message: string }
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer; normalizedValues: ArrayBuffer }
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer; normalizedValues: ArrayBuffer; run: AiDepthRun }
| { type: 'fallback'; id: number; preparationMs: number; inferenceMs: number }
| { type: 'error'; id: number; message: string };
type Estimator = {
(image: OffscreenCanvas): Promise<DepthEstimationPipelineOutput | DepthEstimationPipelineOutput[]>;
@@ -14,7 +15,8 @@ type Estimator = {
};
type Progress = { status: string; progress?: number };
type PipelineFactory = (task: string, model: string, options: {
device: Device;
device: AiDepthPreset['device'];
dtype: AiDepthPreset['dtype'];
progress_callback: (progress: Progress) => void;
}) => Promise<Estimator>;
const createPipeline = pipeline as unknown as PipelineFactory;
@@ -24,8 +26,7 @@ const workerScope = self as unknown as {
};
let estimator: Estimator | null = null;
let activeDevice: Device | null = null;
let activeModel: AiDepthModel | null = null;
let activePreset: AiDepthPreset | null = null;
function reply(message: WorkerReply, transfer: Transferable[] = []): void {
workerScope.postMessage(message, transfer);
@@ -35,15 +36,16 @@ function setStatus(id: number, message: string): void {
reply({ type: 'status', id, message });
}
async function loadEstimator(device: Device, id: number, model: AiDepthModel): Promise<Estimator> {
setStatus(id, 'AI 모델 준비 중…');
const loaded = await createPipeline('depth-estimation', AI_DEPTH_MODELS[model].id, {
device,
async function loadEstimator(preset: AiDepthPreset, id: number): Promise<Estimator> {
setStatus(id, '출발 준비 중…');
const loaded = await createPipeline('depth-estimation', AI_DEPTH.modelId, {
device: preset.device,
dtype: preset.dtype,
progress_callback: (progress) => {
if (progress.status === 'progress') {
setStatus(id, `AI 모델 다운로드 중 · ${Math.round(progress.progress ?? 0)}%`);
setStatus(id, `출발 준비 중 · ${Math.round(progress.progress ?? 0)}%`);
} else if (progress.status === 'initiate' || progress.status === 'download') {
setStatus(id, 'AI 모델 다운로드 중…');
setStatus(id, '출발 준비 중…');
}
},
});
@@ -51,26 +53,15 @@ async function loadEstimator(device: Device, id: number, model: AiDepthModel): P
return loaded;
}
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, model);
activeDevice = 'webgpu';
return estimator;
} catch {
if (estimator) await estimator.dispose();
estimator = null;
setStatus(id, 'AI 모델을 준비하는 중입니다…');
}
}
estimator = await loadEstimator('wasm', id, model);
activeDevice = 'wasm';
async function ensureEstimator(id: number, preset: AiDepthPreset): Promise<Estimator> {
if (estimator && activePreset?.device === preset.device && activePreset.dtype === preset.dtype) return estimator;
// Dispose the previous session before allocating a fallback execution preset.
const previous = estimator;
estimator = null;
activePreset = null;
if (previous) await previous.dispose();
estimator = await loadEstimator(preset, id);
activePreset = { ...preset };
return estimator;
}
@@ -108,45 +99,82 @@ function normalizeDepth(output: DepthEstimationPipelineOutput): { values: Uint8A
return { values, normalizedValues, width, height };
}
async function infer({ id, image, model: modelKey }: RequestMessage): Promise<void> {
async function initialPreset(forceWasm = false): Promise<AiDepthPreset> {
const wasm: AiDepthPreset = { device: 'wasm', dtype: 'q8' };
if (forceWasm) return wasm;
if (estimator && activePreset) return { ...activePreset };
try {
// Some Android browsers expose navigator.gpu but cannot supply an adapter.
const gpu = (navigator as Navigator & { gpu?: { requestAdapter?: (options: { powerPreference: string }) => Promise<object | null> } }).gpu;
const adapter = await gpu?.requestAdapter?.({ powerPreference: 'high-performance' });
if (adapter) return { device: 'webgpu', dtype: 'fp32' };
} catch {
// An unsupported/blocked adapter is a reason to start with CPU, not fail the photo.
}
return wasm;
}
async function infer({ id, image, forceWasm }: RequestMessage): Promise<void> {
const started = performance.now();
let preparationMs = 0;
let inferenceMs = 0;
try {
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext('2d');
if (!context) throw new Error('사진을 처리할 수 없습니다.');
context.drawImage(image, 0, 0);
image.close();
let model = await ensureEstimator(id, modelKey);
setStatus(id, 'AI 깊이를 계산하는 중입니다…');
const adapterStarted = performance.now();
const actualPreset = await initialPreset(forceWasm);
preparationMs += performance.now() - adapterStarted;
const attempt = async (executionPreset: AiDepthPreset): Promise<DepthEstimationPipelineOutput> => {
const preparationStarted = performance.now();
let model: Estimator;
try {
model = await ensureEstimator(id, executionPreset);
} finally {
// Include failed loading and session disposal; cached files may still need preparation.
preparationMs += performance.now() - preparationStarted;
}
setStatus(id, '내비게이션으로 길을 찾는 중…');
const inferenceStarted = performance.now();
try {
const predicted = await model(canvas);
if (Array.isArray(predicted)) throw new Error('단일 사진 결과를 읽을 수 없습니다.');
return predicted;
} finally {
inferenceMs += performance.now() - inferenceStarted;
}
};
let output: DepthEstimationPipelineOutput;
try {
const predicted = await model(canvas);
if (Array.isArray(predicted)) throw new Error('단일 사진 결과를 읽을 수 없습니다.');
output = predicted;
output = await attempt(actualPreset);
} catch (error) {
if (activeDevice !== 'webgpu') throw error;
await model.dispose();
if (actualPreset.device !== 'webgpu') throw error;
// Transformers.js keeps rejected initialization/inference chains in this realm.
// Dispose here, then let the provider replace this worker before trying WASM.
const previous = estimator;
estimator = null;
activeDevice = null;
setStatus(id, 'AI 추론을 다시 시도하는 중입니다…');
model = await loadEstimator('wasm', id, modelKey);
estimator = model;
activeDevice = 'wasm';
const predicted = await model(canvas);
if (Array.isArray(predicted)) throw new Error('단일 사진 결과를 읽을 수 없습니다.');
output = predicted;
activePreset = null;
const disposalStarted = performance.now();
try { await previous?.dispose(); } catch { /* Worker termination is the final cleanup. */ }
preparationMs += performance.now() - disposalStarted;
reply({ type: 'fallback', id, preparationMs, inferenceMs });
return;
}
try {
const normalized = normalizeDepth(output);
const values = normalized.values.buffer as ArrayBuffer;
const normalizedValues = normalized.normalizedValues.buffer as ArrayBuffer;
reply({ type: 'result', id, width: normalized.width, height: normalized.height, values, normalizedValues }, [values, normalizedValues]);
const run: AiDepthRun = { model: 'small', ...actualPreset, preparationMs, inferenceMs, totalMs: performance.now() - started };
reply({ type: 'result', id, width: normalized.width, height: normalized.height, values, normalizedValues, run }, [values, normalizedValues]);
} finally {
output.predicted_depth.dispose();
}
} catch (error) {
reply({ type: 'error', id, message: error instanceof Error ? error.message : 'AI depth 생성에 실패했습니다.' });
} finally {
image.close();
}
}
+9
View File
@@ -0,0 +1,9 @@
/** Device-local, reversible camera experiment. Photos and optical settings are untouched. */
export type CameraComposition = 'wall' | 'legacy';
const KEY = 'behind-glass-camera-composition-v32';
export function readCameraComposition(storage?:Pick<Storage,'getItem'>):CameraComposition {
try {return storage?.getItem(KEY)==='legacy'?'legacy':'wall';} catch {return 'wall';}
}
export function saveCameraComposition(storage:Pick<Storage,'setItem'>|undefined,mode:CameraComposition):boolean {
try {if(!storage)return false;storage.setItem(KEY,mode);return true;} catch {return false;}
}
+93 -4
View File
@@ -1,9 +1,98 @@
import * as THREE from 'three';
import type {CameraComposition} from './CameraComposition';
import {roomComposition} from '../objects/RoomComposition';
export class CameraController {
readonly target = new THREE.Vector2(); readonly current = new THREE.Vector2();
constructor(private readonly camera: THREE.PerspectiveCamera, private maxOffset = 0.5) {}
setMaxOffset(value: number): void { this.maxOffset = THREE.MathUtils.clamp(value, 0, 0.9); }
add(x: number, y: number): void { this.target.set(THREE.MathUtils.clamp(this.target.x + x, -1, 1), THREE.MathUtils.clamp(this.target.y + y, -1, 1)); }
private composition:CameraComposition='legacy';
setCompositionMode(mode:CameraComposition):void {this.composition=mode;}
private room: {width:number;height:number;glassZ:number}|null=null;
private readonly neutralNear:number;
private readonly neutralFov:number;
constructor(private readonly camera: THREE.PerspectiveCamera, private maxOffset = 0.75) {this.neutralNear=camera.near;this.neutralFov=camera.fov;}
/** The house uses the same two-axis gesture on a bounded approach path. */
setRoomBounds(width:number,height:number,glassZ:number,enabled=true):void {
if(![width,height,glassZ].every(Number.isFinite)||width<=0||height<=0)return;
this.room=enabled?{width,height,glassZ}:null;
// A very short panoramic opening puts the floor close to neutral eye height.
// Fit near to that real clearance; preserve the anchored projection center.
const floorDepth=height/2+.24*Math.min(width,height),near=enabled?Math.min(this.neutralNear,floorDepth*.35,roomComposition(width,height,glassZ).halfWidth*.1):this.neutralNear;
if(this.camera.near!==near){
const px=this.camera.projectionMatrix.elements[8],py=this.camera.projectionMatrix.elements[9];
this.camera.near=near;this.camera.updateProjectionMatrix();
this.camera.projectionMatrix.elements[8]=px;this.camera.projectionMatrix.elements[9]=py;
this.camera.projectionMatrixInverse.copy(this.camera.projectionMatrix).invert();
}
}
private setFov(value:number):void {
if(this.camera.fov===value)return;
const px=this.camera.projectionMatrix.elements[8],py=this.camera.projectionMatrix.elements[9];
this.camera.fov=value;this.camera.updateProjectionMatrix();
this.camera.projectionMatrix.elements[8]=px;this.camera.projectionMatrix.elements[9]=py;
this.camera.projectionMatrixInverse.copy(this.camera.projectionMatrix).invert();
}
get isMoving():boolean {return this.target.distanceToSquared(this.current)>1e-12;}
setMaxOffset(value: number): void { if (Number.isFinite(value)) this.maxOffset = THREE.MathUtils.clamp(value, 0.35, 0.90); }
add(x: number, y: number): void { if(Number.isFinite(x)&&Number.isFinite(y))this.target.set(THREE.MathUtils.clamp(this.target.x + x, -1, 1), THREE.MathUtils.clamp(this.target.y + y, -1, 1)); }
setView(x: number, y: number): void { if (Number.isFinite(x) && Number.isFinite(y)) this.target.set(THREE.MathUtils.clamp(x, -1, 1), THREE.MathUtils.clamp(y, -1, 1)); }
/** Stop residual smoothing so pinch anchors stay under the fingers. */
hold(): void { this.target.copy(this.current); }
reset(): void { this.target.set(0, 0); this.current.set(0, 0); }
update(dt: number, sensitivity: number): THREE.Vector2 { this.current.lerp(this.target, 1 - Math.exp(-8 * dt)); const view = this.current.clone().multiplyScalar(sensitivity); view.x = THREE.MathUtils.clamp(view.x, -1, 1); view.y = THREE.MathUtils.clamp(view.y, -1, 1); this.camera.position.set(view.x * this.maxOffset, view.y * this.maxOffset, 3); this.camera.lookAt(0, 0, 0); return view; }
update(dt: number, sensitivity: number): THREE.Vector2 {
this.current.lerp(this.target, 1 - Math.exp(-8 * Math.max(0,dt)));
if(!this.isMoving)this.current.copy(this.target);
const view = this.current.clone().multiplyScalar(sensitivity);
view.x = THREE.MathUtils.clamp(view.x, -1, 1); view.y = THREE.MathUtils.clamp(view.y, -1, 1);
if(!this.room){this.setFov(this.neutralFov);this.camera.position.set(view.x*this.maxOffset,view.y*this.maxOffset,3);this.camera.lookAt(0,0,0);return view;}
const {width,height,glassZ}=this.room,scale=Math.min(width,height);
const strength=this.maxOffset/.75,travel=Math.max(Math.abs(view.x),Math.abs(view.y));
// Approach is coupled to the existing lean, not zoom or a new walking control.
// Smoothstep leaves neutral motion gentle and arrives at the same safe wall distance.
const phase=THREE.MathUtils.clamp((travel-.15)/.85*strength,0,1),approach=phase*phase*(3-2*phase);
const front=glassZ+.4471*scale+.24;
const floor=-height/2-.24*scale;
const lateral=Math.max(.75,width/2+.20*scale)*strength;
const lower=Math.min(this.maxOffset,-floor*.5);
this.camera.position.set(view.x*lateral,view.y*(view.y<0?lower:this.maxOffset),THREE.MathUtils.lerp(3,front,approach));
if(this.composition==='wall') {
const horizontal=THREE.MathUtils.smoothstep(Math.abs(view.x)*strength,.35,1);
const bounds=roomComposition(width,height,glassZ),side=Math.sign(view.x);
const eyeY=Math.max(bounds.floor+.35*bounds.unit,view.y*Math.min(this.maxOffset*.4,bounds.unit*.3));
const portrait=1-THREE.MathUtils.smoothstep(this.camera.aspect,.7,1);
const wallEye=new THREE.Vector3(side*.65*bounds.halfWidth,eyeY,glassZ+.6*bounds.unit);
const gaze=new THREE.Vector3(-side,0,-Math.tan(THREE.MathUtils.degToRad(25)));
const wallAim=wallEye.clone().addScaledVector(gaze,1.65*bounds.halfWidth);
// Keep the close landscape pose and neutral pet size. Only late sideways
// travel in portrait retreats enough to share the narrow frame with the art.
wallEye.z+=2.6*bounds.unit*portrait;
if(portrait>0){
// Match the shallow wall's actual painting center. Bisect the two target
// directions, not their world positions, so the nearby aperture keeps its share.
const picture=new THREE.Vector3(-side*(bounds.halfWidth-.018*bounds.unit),Math.min(.08*height,bounds.floor+1.05*bounds.unit),glassZ+.07*scale+.5*bounds.unit);
const towardDoor=new THREE.Vector3(0,0,glassZ).sub(wallEye).normalize();
const towardPicture=picture.sub(wallEye).normalize();
const portraitAim=wallEye.clone().addScaledVector(towardDoor.add(towardPicture).normalize(),1.65*bounds.halfWidth);
wallAim.lerp(portraitAim,portrait);
}
this.camera.position.lerp(wallEye,horizontal);
this.camera.position.x=THREE.MathUtils.clamp(this.camera.position.x,-.78*bounds.halfWidth,.78*bounds.halfWidth);
const aim=new THREE.Vector3(0,0,glassZ*approach).lerp(wallAim,horizontal);
this.setFov(THREE.MathUtils.lerp(this.neutralFov,64,portrait*horizontal));this.camera.lookAt(aim);return view;
}
// Late horizontal input opens into the room, preserving the central pet/door
// experience and vertical approach. A modest portrait-only lens ramp and
// bounded retreat expose the corner without the earlier extreme close-up.
const horizontal=THREE.MathUtils.smoothstep(Math.abs(view.x)*strength,.35,1);
const bounds=roomComposition(width,height,glassZ),side=Math.sign(view.x);
const portrait=1-THREE.MathUtils.smoothstep(this.camera.aspect,.7,1.4);
const eyeY=Math.min(0,bounds.floor+.8*bounds.unit);
const cornerEye=new THREE.Vector3(side*.75*bounds.halfWidth,Math.max(bounds.floor+.4*bounds.unit,eyeY+view.y*Math.min(this.maxOffset*.4,bounds.unit*.3)),glassZ+THREE.MathUtils.lerp(2,3.7,portrait)*bounds.unit);
this.camera.position.lerp(cornerEye,horizontal);
this.camera.position.x=THREE.MathUtils.clamp(this.camera.position.x,-.78*bounds.halfWidth,.78*bounds.halfWidth);
const aim=new THREE.Vector3(0,0,glassZ*approach).lerp(new THREE.Vector3(-side*.68*bounds.halfWidth,eyeY-THREE.MathUtils.lerp(.2,-.05,portrait)*bounds.unit+view.y*.12*bounds.unit,glassZ+.1*scale),horizontal);
const roomFov=THREE.MathUtils.lerp(this.neutralFov,60,portrait);
this.setFov(THREE.MathUtils.lerp(this.neutralFov,roomFov,horizontal));
this.camera.lookAt(aim);
return view;
}
}
+157 -18
View File
@@ -1,29 +1,168 @@
export type GyroState = 'off' | 'permission' | 'waiting' | 'active' | 'paused' | 'unavailable';
type View = { x: number; y: number };
type Rotation = { x: number; y: number; z: number; w: number };
const radians = Math.PI / 180;
const clamp = (value: number): number => Math.max(-1, Math.min(1, value));
// DeviceOrientation uses intrinsic Z-X'-Y'' rotations. Relative quaternions avoid
// beta wrapping and equivalent Euler-angle flips around an upright phone.
function rotation(alpha: number, beta: number, gamma: number): Rotation {
const a = alpha * radians / 2, b = beta * radians / 2, g = gamma * radians / 2;
const ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b), cg = Math.cos(g), sg = Math.sin(g);
return { x: ca * sb * cg - sa * cb * sg, y: ca * cb * sg + sa * sb * cg, z: sa * cb * cg + ca * sb * sg, w: ca * cb * cg - sa * sb * sg };
}
function relativeTilt(base: Rotation, pose: Rotation): View {
const x = base.w * pose.x - base.x * pose.w - base.y * pose.z + base.z * pose.y;
const y = base.w * pose.y + base.x * pose.z - base.y * pose.w - base.z * pose.x;
const z = base.w * pose.z - base.x * pose.y + base.y * pose.x - base.z * pose.w;
const w = base.w * pose.w + base.x * pose.x + base.y * pose.y + base.z * pose.z;
const length = Math.hypot(x, y, z);
const scale = length < 1e-9 ? 0 : 2 * Math.atan2(length, Math.abs(w)) / length * (w < 0 ? -1 : 1) / (35 * radians);
return { x: y * scale, y: x * scale };
}
/** Opt-in, local-only input. CameraController owns the existing time-based smoothing. */
export class GyroController {
state: GyroState = 'off';
private enabled = false;
private pending = false;
private generation = 0;
private paused = false;
private disposed = false;
private hasOutput = false;
private hasReading = false;
private base: { beta: number; gamma: number } | null = null;
constructor(private readonly setView: (x: number, y: number) => void, private readonly status: (message: string) => void) {}
private base: Rotation | null = null;
private intentPose: Rotation | null = null;
private baseView: View = { x: 0, y: 0 };
private timer: number | undefined;
private angle = 0;
private readonly orientation = window.screen?.orientation;
constructor(
private readonly setView: (x: number, y: number) => void,
private readonly status: (state: GyroState, message: string) => void,
private readonly getView: () => View,
private readonly onIntent: () => void = () => {},
) {
document.addEventListener('visibilitychange', this.visibility);
window.addEventListener('pagehide', this.pagehide);
window.addEventListener('orientationchange', this.reorient);
this.orientation?.addEventListener('change', this.reorient);
}
private report(state: GyroState, message: string): void { this.state = state; this.status(state, message); }
private clearTimer(): void { if (this.timer !== undefined) window.clearTimeout(this.timer); this.timer = undefined; }
private waitForReading(): void {
this.clearTimer();
// The API is change-driven: silence after a proved reading can mean stillness.
if (this.hasReading) return;
this.timer = window.setTimeout(() => this.fail('기울기 신호를 받지 못했어요. 드래그로 둘러보세요.'), 4000);
}
private readAngle(): number {
const value = window.screen?.orientation?.angle ?? (window as Window & { orientation?: number }).orientation ?? 0;
return Number.isFinite(value) ? value : 0;
}
private detach(): void { window.removeEventListener('deviceorientation', this.onOrientation); this.clearTimer(); }
private listen(): void {
if (!this.enabled || document.hidden) return;
this.angle = this.readAngle();
window.addEventListener('deviceorientation', this.onOrientation);
this.report(this.hasReading ? 'active' : 'waiting', this.hasReading ? '기울기 켜짐 · 현재 자세에서 이어갈게요.' : '휴대폰을 편하게 들고 잠깐 기다려 주세요.');
this.waitForReading();
}
private stop(): void {
++this.generation; this.pending = false; this.enabled = false; this.base = null; this.intentPose = null; this.hasOutput = false; this.hasReading = false; this.detach();
}
private fail(message: string): void { this.stop(); this.report('unavailable', message); }
/** Explicitly turn off without ever requesting permission or toggling back on. */
disconnect(): void {
if(this.disposed)return;
this.stop();this.paused=false;this.baseView={x:0,y:0};
this.report('off','기울기는 꺼져 있어요. 드래그로 둘러볼 수 있어요.');
}
/** Call directly from a click: Safari permission is requested before the first await. */
async toggle(): Promise<boolean> {
if (this.enabled || this.pending) { this.dispose(); return false; }
if (!window.isSecureContext || !('DeviceOrientationEvent' in window)) { this.status('자이로스코프를 사용할 수 없습니다.'); return false; }
const generation = ++this.generation; this.pending = true;
if (this.disposed) return false;
if (this.enabled || this.pending) { this.stop(); this.report('off', '기울기 조작을 껐어요. 드래그로 둘러보세요.'); return false; }
if (!window.isSecureContext || !window.DeviceOrientationEvent) { this.fail('이 환경에서는 기울기를 사용할 수 없어요. 드래그로 둘러보세요.'); return false; }
const generation = ++this.generation;
this.pending = true;
try {
const device = window.DeviceOrientationEvent as typeof DeviceOrientationEvent & { requestPermission?: () => Promise<string> };
this.status('센서 권한을 기다리는 중…');
if (device.requestPermission && await device.requestPermission() !== 'granted') throw new Error('권한 거절됨');
if (generation !== this.generation) return false;
this.base = null; this.hasReading = false; this.pending = false; this.enabled = true; window.addEventListener('deviceorientation', this.onOrientation); this.status('센서값을 기다리는 중…'); return true;
} catch (error) { this.pending = false; if (generation === this.generation) this.status(`자이로스코프를 사용할 수 없습니다 (${(error as Error).message})`); return false; }
if (typeof device.requestPermission === 'function') {
this.report('permission', '브라우저에서 동작 및 방향 접근을 선택해 주세요.');
const permission = await device.requestPermission();
if (generation !== this.generation) return false;
if (permission !== 'granted') { this.fail('기울기 접근이 허용되지 않았어요. 드래그로 둘러보세요.'); return false; }
}
if (generation !== this.generation || this.disposed) return false;
if (document.hidden) { this.stop(); this.report('off', '화면으로 돌아온 뒤 기울여 보기를 눌러 주세요.'); return false; }
this.pending = false; this.enabled = true; this.base = null; this.intentPose = null; this.baseView = { x: 0, y: 0 }; this.hasOutput = false;
this.listen(); return true;
} catch {
if (generation === this.generation) this.fail('기울기 접근을 시작하지 못했어요. 드래그로 둘러보세요.');
return false;
}
}
reset(): void { this.base = null; }
dispose(): void { ++this.generation; this.pending = false; this.enabled = false; this.base = null; window.removeEventListener('deviceorientation', this.onOrientation); }
private onOrientation = (event: DeviceOrientationEvent): void => {
if (!this.enabled || event.beta == null || event.gamma == null) return;
if (!this.base) this.base = { beta: event.beta, gamma: event.gamma };
const angle = (screen.orientation?.angle ?? 0) * Math.PI / 180;
const x = event.gamma - this.base.gamma; const y = event.beta - this.base.beta;
this.setView((x * Math.cos(angle) + y * Math.sin(angle)) / 35, (y * Math.cos(angle) - x * Math.sin(angle)) / 35); if (!this.hasReading) { this.hasReading = true; this.status('자이로스코프 활성'); }
/** Pause for scene overlays and any pointer gesture; resumption never replays old motion. */
setPaused(paused: boolean): void {
if (this.paused === paused) return;
this.paused = paused;
if (!this.enabled) return;
this.rebase();
if (paused) this.report('paused', '기울기 켜짐 · 화면 조작을 마치면 이어져요.');
else if (!document.hidden) { this.report(this.hasReading ? 'active' : 'waiting', '현재 자세를 기준으로 이어갈게요.'); this.waitForReading(); }
}
private rebase(): void {
this.base = null; this.intentPose = null;
this.baseView = this.hasOutput ? this.getView() : { x: 0, y: 0 };
}
/** Keep keyboard/manual input when the next sensor reading arrives. */
syncManualView(): void { this.hasOutput = true; this.rebase(); }
reset(): void {
this.base = null; this.intentPose = null; this.baseView = { x: 0, y: 0 }; this.hasOutput = false;
if (this.enabled && !document.hidden) { this.report(this.hasReading ? 'active' : 'waiting', '현재 자세를 정면으로 맞출게요.'); this.waitForReading(); }
}
private reorient = (): void => {
if (!this.enabled) return;
this.angle = this.readAngle(); this.rebase();
if (!document.hidden) this.waitForReading();
};
private visibility = (): void => {
if (document.hidden) {
if (this.pending) { this.stop(); this.report('off', '화면으로 돌아온 뒤 기울여 보기를 눌러 주세요.'); }
else if (this.enabled) { this.detach(); this.rebase(); this.report('paused', '기울기 켜짐 · 화면으로 돌아오면 이어져요.'); }
} else if (this.enabled) { this.rebase(); this.listen(); }
};
private pagehide = (): void => { this.stop(); this.report('off', '다시 기울여 보기를 눌러 시작할 수 있어요.'); };
private onOrientation = (event: DeviceOrientationEvent): void => {
if (!this.enabled || document.hidden) return;
const { alpha, beta, gamma } = event;
if (alpha === null || beta === null || gamma === null || ![alpha, beta, gamma].every(Number.isFinite)) return;
this.clearTimer(); this.hasReading = true;
const angle = this.readAngle();
if (angle !== this.angle) { this.angle = angle; this.rebase(); }
const pose = rotation(alpha, beta, gamma);
if (this.paused) { this.base = null; this.intentPose = null; if (this.state !== 'paused') this.report('paused', '기울기 켜짐 · 설정을 닫고 휴대폰을 살짝 기울여 보세요.'); return; }
if (!this.base) this.base = pose;
if (!this.intentPose) this.intentPose = pose;
const intent = relativeTilt(this.intentPose, pose);
// About 4.2 degrees from the last deliberate pose, not sensor jitter or
// holding a tilted phone. This only teaches hints; camera mapping is untouched.
if (Math.hypot(intent.x, intent.y) > .12) { this.intentPose = pose; this.onIntent(); }
const tilt = relativeTilt(this.base, pose), screenAngle = this.angle * radians;
this.setView(clamp(this.baseView.x + tilt.x * Math.cos(screenAngle) + tilt.y * Math.sin(screenAngle)), clamp(this.baseView.y + tilt.y * Math.cos(screenAngle) - tilt.x * Math.sin(screenAngle)));
this.hasOutput = true;
if (this.state !== 'active') this.report('active', '기울기 켜짐 · 현재 자세가 기준이에요.');
};
dispose(): void {
this.stop(); this.disposed = true;
document.removeEventListener('visibilitychange', this.visibility);
window.removeEventListener('pagehide', this.pagehide);
window.removeEventListener('orientationchange', this.reorient);
this.orientation?.removeEventListener('change', this.reorient);
}
}
+21
View File
@@ -0,0 +1,21 @@
/** Reads only an explicit browser paste event; never requests clipboard access. */
export function initImagePaste(root: Document, signal: AbortSignal, enabled: () => boolean, onImage: (file: File) => boolean): void {
const editable = (target: EventTarget | null): boolean => {
const element = target as HTMLElement | null;
return Boolean(element?.isContentEditable || element?.closest?.('input, textarea, select, [role="textbox"], [contenteditable]:not([contenteditable="false"])'));
};
root.addEventListener('paste', event => {
if (signal.aborted || event.defaultPrevented || !enabled() || root.querySelector?.('dialog[open]')) return;
if (editable(event.target) || editable(root.activeElement) || event.composedPath?.().some(editable)) return;
const data = event.clipboardData;
if (!data) return;
let file: File | null = null;
for (const item of Array.from(data.items ?? [])) {
if (item.kind !== 'file' || !item.type.startsWith('image/')) continue;
const candidate = item.getAsFile();
if (candidate?.type.startsWith('image/')) { file = candidate; break; }
}
if (!file) file = Array.from(data.files ?? []).find(candidate => candidate.type.startsWith('image/')) ?? null;
if (file && onImage(file)) event.preventDefault();
}, { signal });
}
+30 -19
View File
@@ -1,10 +1,13 @@
import * as THREE from 'three';
import { createWorkingPhotoImage, type WorkingPhotoImage } from './WorkingPhotoImage';
export type MediaReady = (texture: THREE.Texture, aspect: number, video: HTMLVideoElement | null) => void;
export class MediaLoader {
private token = 0;
private activeUrl = '';
private pendingPhoto: AbortController | null = null;
private activePhoto: WorkingPhotoImage | null = null;
private pendingUrl = '';
private pendingVideo: HTMLVideoElement | null = null;
private activeVideo: HTMLVideoElement | null = null;
@@ -12,30 +15,37 @@ export class MediaLoader {
constructor(private readonly onReady: MediaReady, private readonly onStatus: (message: string) => void) {}
cancelPending(): void { ++this.token; this.clearPending(); this.retryVideo = null; }
cancelPending(preserveActiveVideoRetry = false): void { ++this.token; this.clearPending(); this.retryVideo = preserveActiveVideoRetry ? this.activeVideo : null; }
load(file: File): void {
const token = ++this.token;
this.clearPending();
this.retryVideo = null;
const url = URL.createObjectURL(file);
this.pendingUrl = url;
if (file.type.startsWith('image/')) {
const image = new Image();
image.onload = () => {
if (token !== this.token) return this.disposeUrl(url);
if (this.pendingUrl === url) this.pendingUrl = '';
const texture = new THREE.Texture(image);
const abort = new AbortController();
this.pendingPhoto = abort;
void createWorkingPhotoImage(file, token, abort.signal).then(photo => {
if (token !== this.token || abort.signal.aborted) { photo.dispose(); return; }
this.pendingPhoto = null;
const texture = new THREE.Texture(photo.image);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
this.replaceActive(url, null);
this.onReady(texture, image.width / image.height, null);
// Resize/diagnostics may still draw the previous image inside onReady.
const previous = this.replaceActive('', null);
this.activePhoto = photo;
try { this.onReady(texture, photo.image.width / photo.image.height, null); }
catch (error) { texture.dispose(); photo.dispose(); if (this.activePhoto === photo) this.activePhoto = null; throw error; }
finally { previous?.dispose(); }
if (token === this.token) this.onStatus('');
};
image.onerror = () => { if (token === this.token) this.onStatus('이미지를 읽지 못했습니다.'); this.disposeUrl(url); };
image.src = url;
}).catch((error: unknown) => {
if (token !== this.token || abort.signal.aborted) return;
this.pendingPhoto = null;
this.onStatus(error instanceof Error ? error.message : '이미지를 읽지 못했습니다.');
});
return;
}
const url = URL.createObjectURL(file);
this.pendingUrl = url;
const video = document.createElement('video');
this.pendingVideo = video;
video.muted = true; video.loop = true; video.playsInline = true; video.preload = 'auto'; video.src = url;
@@ -57,9 +67,10 @@ export class MediaLoader {
if (this.pendingUrl === url) this.pendingUrl = '';
if (this.pendingVideo === video) this.pendingVideo = null;
const texture = new THREE.VideoTexture(video); texture.colorSpace = THREE.SRGBColorSpace;
this.replaceActive(url, video);
const previous = this.replaceActive(url, video);
this.retryVideo = video;
this.onReady(texture, video.videoWidth / video.videoHeight, video);
try { this.onReady(texture, video.videoWidth / video.videoHeight, video); }
finally { previous?.dispose(); }
if (token === this.token && this.activeVideo === video) this.onStatus('');
video.play().catch(() => {
if (token === this.token && video === this.activeVideo) this.onStatus('자동 재생이 차단되었습니다. 화면을 눌러 재생하세요.');
@@ -68,10 +79,10 @@ export class MediaLoader {
video.load();
}
retry(): void { const video = this.retryVideo; const token = this.token; video?.play().catch(() => { if (token === this.token && video === this.activeVideo) this.onStatus('재생할 수 없습니다.'); }); }
retry(): void { const video = this.retryVideo; const token = this.token; video?.play().then(() => { if (token === this.token && video === this.activeVideo) this.onStatus(''); }).catch(() => { if (token === this.token && video === this.activeVideo) this.onStatus('재생할 수 없습니다.'); }); }
dispose(): void { ++this.token; this.clearPending(); this.retryVideo = null; if (this.activeVideo) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); this.activeVideo = null; } this.disposeUrl(this.activeUrl); this.activeUrl = ''; }
private replaceActive(url: string, video: HTMLVideoElement | null): void { if (this.activeVideo && this.activeVideo !== video) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); } if (this.activeUrl && this.activeUrl !== url) this.disposeUrl(this.activeUrl); this.activeUrl = url; this.activeVideo = video; this.retryVideo = video; }
private clearPending(): void { if (this.pendingVideo) { this.pendingVideo.onloadeddata = null; this.pendingVideo.onerror = null; this.pendingVideo.pause(); this.pendingVideo.removeAttribute('src'); this.pendingVideo.load(); this.pendingVideo = null; } if (this.pendingUrl) this.disposeUrl(this.pendingUrl); this.pendingUrl = ''; }
dispose(): void { ++this.token; this.clearPending(); this.retryVideo = null; if (this.activeVideo) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); this.activeVideo = null; } this.disposeUrl(this.activeUrl); this.activeUrl = ''; this.activePhoto?.dispose(); this.activePhoto = null; }
private replaceActive(url: string, video: HTMLVideoElement | null): WorkingPhotoImage | null { const previous = this.activePhoto; this.activePhoto = null; if (this.activeVideo && this.activeVideo !== video) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); } if (this.activeUrl && this.activeUrl !== url) this.disposeUrl(this.activeUrl); this.activeUrl = url; this.activeVideo = video; this.retryVideo = video; return previous; }
private clearPending(): void { this.pendingPhoto?.abort(); this.pendingPhoto = null; if (this.pendingVideo) { this.pendingVideo.onloadeddata = null; this.pendingVideo.onerror = null; this.pendingVideo.pause(); this.pendingVideo.removeAttribute('src'); this.pendingVideo.load(); this.pendingVideo = null; } if (this.pendingUrl) this.disposeUrl(this.pendingUrl); this.pendingUrl = ''; }
private disposeUrl(url: string): void { if (url) URL.revokeObjectURL(url); }
}
+149
View File
@@ -0,0 +1,149 @@
export const PHOTO_HEADER_BYTES = 256 * 1024;
export type PhotoMetadata = {
width: number;
height: number;
orientation: number | null;
};
/** Bounded metadata read, never an original-image upload or full encoded-file copy. */
export async function readPhotoMetadata(file: Blob): Promise<PhotoMetadata | null> {
if (typeof file.slice !== 'function')
return null;
try {
const buffer = await file.slice(0, PHOTO_HEADER_BYTES).arrayBuffer(), b = new Uint8Array(buffer), v = new DataView(buffer);
let width = 0, height = 0;
let orientation: number | null = null;
if (b.length >= 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) {
width = v.getUint32(16);
height = v.getUint32(20);
orientation = await chunkOrientation(file, b, true);
}
else if (b.length >= 30 && String.fromCharCode(...b.slice(0, 4)) === 'RIFF' && String.fromCharCode(...b.slice(8, 12)) === 'WEBP') {
const type = String.fromCharCode(...b.slice(12, 16));
orientation = type !== 'VP8X' || !(b[20] & 8) ? 1 : await chunkOrientation(file, b, false);
if (type === 'VP8X') {
width = 1 + b[24] + (b[25] << 8) + (b[26] << 16);
height = 1 + b[27] + (b[28] << 8) + (b[29] << 16);
}
else if (type === 'VP8 ' && b[23] === 157 && b[24] === 1 && b[25] === 42) {
width = v.getUint16(26, true) & 16383;
height = v.getUint16(28, true) & 16383;
}
else if (type === 'VP8L' && b[20] === 47) {
width = 1 + ((b[22] & 63) << 8) + b[21];
height = 1 + ((b[24] & 15) << 10) + (b[23] << 2) + (b[22] >> 6);
}
}
else if (b.length >= 4 && b[0] === 255 && b[1] === 216) {
for (let p = 2; p + 4 <= b.length;) {
if (b[p] !== 255)
break;
const marker = b[p + 1];
if (marker === 217 || marker === 218) {
orientation ??= 1;
break;
}
if (marker === 255) {
p++;
continue;
}
const length = v.getUint16(p + 2), end = p + 2 + length;
if (length < 2 || end > b.length)
break;
if (marker === 225 && length >= 16 && String.fromCharCode(...b.slice(p + 4, p + 10)) === 'Exif\0\0') {
const base = p + 10, little = v.getUint16(base) === 0x4949;
if (v.getUint16(base + 2, little) === 42) {
const ifd = base + v.getUint32(base + 4, little);
if (ifd >= base && ifd + 2 <= end) {
const entries = v.getUint16(ifd, little);
for (let n = 0; n < entries; n++) {
const at = ifd + 2 + n * 12;
if (at + 12 > end)
break;
if (v.getUint16(at, little) === 0x112 && v.getUint16(at + 2, little) === 3 && v.getUint32(at + 4, little) === 1) {
const o = v.getUint16(at + 8, little);
if (o >= 1 && o <= 8)
orientation = o;
}
}
}
}
}
if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker) && length >= 7) {
height = v.getUint16(p + 5);
width = v.getUint16(p + 7);
}
p = end;
}
}
// Keep browser-supported GIF/BMP input working without an unbounded probe.
else if (b.length >= 10 && String.fromCharCode(...b.slice(0, 3)) === 'GIF') {
width = v.getUint16(6, true); height = v.getUint16(8, true); orientation = 1;
}
else if (b.length >= 26 && b[0] === 66 && b[1] === 77) {
const headerSize = v.getUint32(14, true);
if (headerSize === 12) { width = v.getUint16(18, true); height = v.getUint16(20, true); }
else if (headerSize >= 40) { width = v.getInt32(18, true); height = Math.abs(v.getInt32(22, true)); }
orientation = 1;
}
if (width < 1 || height < 1 || !Number.isSafeInteger(width * height))
return null;
return orientation !== null && orientation >= 5 ? { width: height, height: width, orientation } : { width, height, orientation };
}
catch {
return null;
}
}
/** Read only chunk headers and EXIF, skipping encoded image payloads (including
* EXIF after a many-megabyte PNG IDAT/WebP VP8 chunk). Incomplete scans stay
* unknown: never resize using an unverified unrotated aspect ratio. */
async function chunkOrientation(file: Blob, prefix: Uint8Array, png: boolean): Promise<number | null> {
let budget = PHOTO_HEADER_BYTES;
const read = async (offset: number, length: number): Promise<Uint8Array | null> => {
if (length > budget || offset < 0 || offset + length > file.size) return null;
if (offset + length <= prefix.length) return prefix.subarray(offset, offset + length);
budget -= length;
return new Uint8Array(await file.slice(offset, offset + length).arrayBuffer());
};
const length = png ? file.size : Math.min(file.size, new DataView(prefix.buffer, prefix.byteOffset, prefix.byteLength).getUint32(4, true) + 8);
let offset = png ? 8 : 12;
for (let chunks = 0; chunks < 512 && offset + 8 <= length; chunks++) {
const header = await read(offset, 8);
if (!header || header.length < 8) return null;
const v = new DataView(header.buffer, header.byteOffset, header.byteLength);
const size = v.getUint32(png ? 0 : 4, !png);
const type = String.fromCharCode(...header.subarray(png ? 4 : 0, png ? 8 : 4));
const next = offset + 8 + size + (png ? 4 : size % 2);
if (next > length || next <= offset) return null;
if (type === (png ? 'eXIf' : 'EXIF')) {
const data = await read(offset + 8, size);
return data ? exifOrientation(data) : null;
}
if (png && type === 'IEND') return 1;
offset = next;
}
return !png && offset === length ? 1 : null;
}
function exifOrientation(bytes: Uint8Array): number | null {
const base = String.fromCharCode(...bytes.subarray(0, 6)) === 'Exif\0\0' ? 6 : 0;
if (bytes.length < base + 8) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const order = view.getUint16(base);
if (order !== 0x4949 && order !== 0x4d4d) return null;
const little = order === 0x4949;
if (view.getUint16(base + 2, little) !== 42) return null;
const ifd = base + view.getUint32(base + 4, little);
if (ifd < base + 8 || ifd + 2 > bytes.length) return null;
const count = view.getUint16(ifd, little);
for (let n = 0; n < count; n++) {
const at = ifd + 2 + n * 12;
if (at + 12 > bytes.length) return null;
if (view.getUint16(at, little) === 0x112) {
if (view.getUint16(at + 2, little) !== 3 || view.getUint32(at + 4, little) !== 1) return null;
const orientation = view.getUint16(at + 8, little);
return orientation >= 1 && orientation <= 8 ? orientation : null;
}
}
return 1;
}
+96
View File
@@ -0,0 +1,96 @@
import type { PhotoSnapshot } from '../types/photo';
import { readPhotoMetadata } from './PhotoMetadata';
export const PHOTO_WORKING_LONG_EDGE = 1024;
export const PHOTO_MAX_SOURCE_PIXELS = 64_000_000;
export const PHOTO_MAX_SOURCE_EDGE = 32768;
export function photoWorkingSize(width: number, height: number): { width: number; height: number } {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1)
throw new Error('사진 크기를 확인할 수 없어요.');
if (width > PHOTO_MAX_SOURCE_EDGE || height > PHOTO_MAX_SOURCE_EDGE || width * height > PHOTO_MAX_SOURCE_PIXELS)
throw new Error('사진이 너무 커요. 긴 변 8,000픽셀 이하로 줄인 사본을 선택해 주세요.');
const scale = Math.min(1, PHOTO_WORKING_LONG_EDGE / Math.max(width, height));
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
}
/** One oriented working image for display and depth. Never modifies File.
* Resized decode is a browser hint, not a bound on internal decoder peak RAM. */
export async function createPhotoSnapshot(file: File, requestId: number, signal: AbortSignal): Promise<PhotoSnapshot> {
signal.throwIfAborted();
const header = await readPhotoMetadata(file);
signal.throwIfAborted();
const target = header ? photoWorkingSize(header.width, header.height) : null;
if (typeof createImageBitmap !== 'function')
throw new Error('이 브라우저에서 사진 준비를 지원하지 않아요. 최신 브라우저에서 다시 열어 주세요.');
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image', premultiplyAlpha: 'none',
...(target && header?.orientation !== null ? { resizeWidth: target.width, resizeHeight: target.height, resizeQuality: 'high' as const } : {}) });
} catch {
signal.throwIfAborted();
// Some browsers decode images but do not support Blob resize options.
bitmap = await decodeViaImage(file, signal);
}
try {
signal.throwIfAborted();
const size = photoWorkingSize(bitmap.width, bitmap.height);
if (size.width !== bitmap.width || size.height !== bitmap.height) {
const old = bitmap;
let resized: ImageBitmap | null = null;
try {
resized = await createImageBitmap(old, { resizeWidth: size.width, resizeHeight: size.height, resizeQuality: 'high', premultiplyAlpha: 'none' });
} catch { /* Older browsers may reject resize options. */ }
if (resized && (resized.width !== size.width || resized.height !== size.height)) {
if (resized !== old) resized.close();
resized = null; // Unknown dictionary options can also be silently ignored.
}
if (!resized) {
signal.throwIfAborted();
resized = await canvasBitmap(old, size.width, size.height);
}
bitmap = resized;
if (bitmap !== old) old.close();
}
signal.throwIfAborted();
if (Math.max(bitmap.width, bitmap.height) > PHOTO_WORKING_LONG_EDGE)
throw new Error('브라우저가 사진 크기를 줄이지 못했어요. 작은 사본을 선택해 주세요.');
return { requestId, width: bitmap.width, height: bitmap.height, bitmap };
} catch (error) {
bitmap.close();
throw error;
}
}
async function canvasBitmap(source: CanvasImageSource, width: number, height: number): Promise<ImageBitmap> {
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
try {
const context = canvas.getContext('2d');
if (!context) throw new Error('사진을 준비할 수 없어요.');
context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high';
context.drawImage(source, 0, 0, width, height);
return await createImageBitmap(canvas, { premultiplyAlpha: 'none' });
} finally { canvas.width = canvas.height = 1; }
}
async function decodeViaImage(file: File, signal: AbortSignal): Promise<ImageBitmap> {
const image = new Image(), url = URL.createObjectURL(file);
try {
await new Promise<void>((resolve, reject) => {
const cleanup = () => { image.onload = image.onerror = null; signal.removeEventListener('abort', abort); };
const abort = () => { cleanup(); reject(new DOMException('사진 준비를 취소했어요.', 'AbortError')); };
image.onload = () => { cleanup(); resolve(); };
image.onerror = () => { cleanup(); reject(new Error('사진을 읽지 못했어요. HEIC 등 지원되지 않는 형식은 JPEG 또는 PNG 사본으로 선택해 주세요.')); };
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort(); else image.src = url;
});
signal.throwIfAborted();
const size = photoWorkingSize(image.naturalWidth || image.width, image.naturalHeight || image.height);
const bitmap = await canvasBitmap(image, size.width, size.height);
if (signal.aborted) { bitmap.close(); signal.throwIfAborted(); }
return bitmap;
} finally {
image.onload = image.onerror = null; image.removeAttribute('src'); URL.revokeObjectURL(url);
}
}
+196 -7
View File
@@ -1,10 +1,199 @@
import type { SceneAnchor } from './SceneZoomController';
export interface ZoomGestureOptions {
getZoom: () => number;
setZoom: (zoom: number, from: SceneAnchor, to: SceneAnchor) => void;
/** Gates all scene gestures, including the existing one-pointer view drag. */
isEnabled?: () => boolean;
/** Optional comparison pane bounds in client coordinates. Null means outside a pane. */
getBounds?: (point: SceneAnchor) => { left: number; top: number; width: number; height: number } | null;
/** Optional handle-only subject drag; a second touch cancels it before pinch. */
subjectDrag?: { begin: (point: SceneAnchor) => boolean; move: (point: SceneAnchor) => void; end: (cancelled: boolean) => void };
onGestureChange?: (active: boolean, pinching: boolean) => void;
}
interface ActivePointer { x: number; y: number; type: string }
export class PointerController {
private activeId: number | null = null;
private lastX = 0; private lastY = 0;
private readonly pointers = new Map<number, ActivePointer>();
private pinchDistance = 0;
private hasDragged = false;
constructor(private readonly element: HTMLElement, private readonly setView: (x: number, y: number) => void, private readonly onDrag?: () => void) { element.addEventListener('pointerdown', this.down); element.addEventListener('pointermove', this.move); element.addEventListener('pointerup', this.end); element.addEventListener('pointercancel', this.end); }
private down = (event: PointerEvent): void => { this.activeId = event.pointerId; this.lastX = event.clientX; this.lastY = event.clientY; this.element.setPointerCapture(event.pointerId); };
private move = (event: PointerEvent): void => { if (event.pointerId !== this.activeId) return; const dx = event.clientX - this.lastX; const dy = event.clientY - this.lastY; if (dx === 0 && dy === 0) return; if (!this.hasDragged) { this.hasDragged = true; this.element.classList.add('is-dragging'); this.onDrag?.(); } const r = this.element.getBoundingClientRect(); this.setView(dx / r.width, -dy / r.height); this.lastX = event.clientX; this.lastY = event.clientY; };
private end = (event: PointerEvent): void => { if (event.pointerId !== this.activeId) return; this.activeId = null; this.hasDragged = false; this.element.classList.remove('is-dragging'); if (this.element.hasPointerCapture(event.pointerId)) this.element.releasePointerCapture(event.pointerId); };
dispose(): void { this.element.removeEventListener('pointerdown', this.down); this.element.removeEventListener('pointermove', this.move); this.element.removeEventListener('pointerup', this.end); this.element.removeEventListener('pointercancel', this.end); }
private draggingSubject = false;
private suppressSinglePointer = false;
private endSubject(cancelled: boolean): void {
if (!this.draggingSubject) return;
this.draggingSubject = false;
this.zoom?.subjectDrag?.end(cancelled);
}
constructor(
private readonly element: HTMLElement,
private readonly setView: (x: number, y: number) => void,
private readonly onDrag?: () => void,
private readonly zoom?: ZoomGestureOptions,
) {
element.addEventListener('pointerdown', this.down);
element.addEventListener('pointermove', this.move);
element.addEventListener('pointerup', this.end);
element.addEventListener('pointercancel', this.end);
element.addEventListener('lostpointercapture', this.end);
element.addEventListener('wheel', this.wheel, { passive: false });
}
get isInteracting(): boolean { return this.pointers.size > 0; }
private syncGesture(): void { this.zoom?.onGestureChange?.(this.isInteracting, this.pointers.size === 2); }
private isEnabled(): boolean {
if (this.zoom?.isEnabled?.() !== false) return true;
this.cancelGesture();
return false;
}
private distance(): number {
const [a, b] = this.pointers.values();
return a && b ? Math.hypot(b.x - a.x, b.y - a.y) : 0;
}
private midpoint(): SceneAnchor {
const [a, b] = this.pointers.values();
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
private anchor(point: SceneAnchor): SceneAnchor | null {
const rect = this.zoom?.getBounds ? this.zoom.getBounds(point) : this.element.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0 || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
// Captured fingers may leave the canvas; the projection controller bounds the crop.
return { x: (point.x - rect.left) / rect.width * 2 - 1, y: 1 - (point.y - rect.top) / rect.height * 2 };
}
private markDragged(): void {
if (this.hasDragged) return;
this.hasDragged = true;
this.element.classList.add('is-dragging');
this.onDrag?.();
}
private zoomBy(factor: number, from: SceneAnchor, to: SceneAnchor = from): boolean {
if (!this.zoom || !Number.isFinite(factor) || factor <= 0) return false;
const current = this.zoom.getZoom();
if (!Number.isFinite(current)) return false;
const next = Math.max(1, Math.min(3, current * factor));
if (next === current && from.x === to.x && from.y === to.y) return false;
this.zoom.setZoom(next, from, to);
return true;
}
private down = (event: PointerEvent): void => {
if(event.pointerType!=='touch'&&event.button!==0)return;
if (!this.isEnabled() || this.pointers.has(event.pointerId) || this.pointers.size >= 2) return;
if (this.zoom?.getBounds) {
const rect = this.zoom.getBounds({ x: event.clientX, y: event.clientY });
if (!rect || event.clientX < rect.left || event.clientX > rect.left + rect.width || event.clientY < rect.top || event.clientY > rect.top + rect.height) return;
}
// Only a pair of touch contacts pinches; extra contacts cannot replace it.
const first = this.pointers.values().next().value;
if (first && (!this.zoom || first.type !== 'touch' || event.pointerType !== 'touch')) return;
this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType });
try {
this.element.setPointerCapture(event.pointerId);
} catch {
// The browser may have already canceled this pointer before capture.
this.pointers.delete(event.pointerId);
}
if (this.pointers.size === 1 && this.pointers.has(event.pointerId)) {
this.suppressSinglePointer = false;
this.draggingSubject = this.zoom?.subjectDrag?.begin({ x: event.clientX, y: event.clientY }) ?? false;
} else if (this.pointers.size === 2 && this.draggingSubject) {
this.endSubject(true);
this.suppressSinglePointer = true;
}
this.pinchDistance = this.distance();
this.syncGesture();
};
private move = (event: PointerEvent): void => {
if (!this.isEnabled()) return;
const pointer = this.pointers.get(event.pointerId);
if (!pointer) return;
const oldMidpoint = this.pointers.size === 2 ? this.midpoint() : null;
const dx = event.clientX - pointer.x;
const dy = event.clientY - pointer.y;
pointer.x = event.clientX;
pointer.y = event.clientY;
if (this.pointers.size === 2) {
const distance = this.distance();
const from = oldMidpoint && this.anchor(oldMidpoint);
const to = this.anchor(this.midpoint());
if (from && to && this.pinchDistance > 0 && distance > 0 && this.zoomBy(distance / this.pinchDistance, from, to)) this.markDragged();
// Rebase even at zoom limits, so reversing the pinch responds immediately.
this.pinchDistance = distance;
return;
}
if (this.suppressSinglePointer || (dx === 0 && dy === 0)) return;
if (this.draggingSubject) {
this.markDragged();
this.zoom?.subjectDrag?.move({ x: event.clientX, y: event.clientY });
return;
}
const rect = this.zoom?.getBounds ? this.zoom.getBounds({ x: event.clientX, y: event.clientY }) : this.element.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return;
this.markDragged();
this.setView(dx / rect.width, -dy / rect.height);
};
private wheel = (event: WheelEvent): void => {
if (!this.isEnabled() || !this.zoom || event.ctrlKey || !Number.isFinite(event.deltaY) || event.deltaY === 0) return;
const anchor = this.anchor({ x: event.clientX, y: event.clientY });
if (!anchor) return;
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? this.element.getBoundingClientRect().height : 1;
const exponent = Math.max(-20, Math.min(20, -event.deltaY * unit * 0.0015));
event.preventDefault();
if (this.zoomBy(Math.exp(exponent), anchor)) this.onDrag?.();
};
private releaseCapture(pointerId: number): void {
try {
if (this.element.hasPointerCapture(pointerId)) this.element.releasePointerCapture(pointerId);
} catch {
// Capture can be lost automatically on cancel or element removal.
}
}
private end = (event: PointerEvent): void => {
if (!this.pointers.delete(event.pointerId)) return;
this.endSubject(event.type !== 'pointerup');
this.pinchDistance = 0;
if (this.pointers.size === 0) {
this.hasDragged = false;
this.element.classList.remove('is-dragging');
}
// The remaining pointer already stores its latest position, avoiding a jump.
this.releaseCapture(event.pointerId);
this.syncGesture();
};
/** Call when opening an overlay, even if no subsequent pointer event arrives. */
cancelGesture(): void {
this.endSubject(true);
this.suppressSinglePointer = false;
const ids = [...this.pointers.keys()];
this.pointers.clear();
this.pinchDistance = 0;
this.hasDragged = false;
this.element.classList.remove('is-dragging');
ids.forEach(id => this.releaseCapture(id));
if (ids.length) this.syncGesture();
}
dispose(): void {
this.element.removeEventListener('pointerdown', this.down);
this.element.removeEventListener('pointermove', this.move);
this.element.removeEventListener('pointerup', this.end);
this.element.removeEventListener('pointercancel', this.end);
this.element.removeEventListener('lostpointercapture', this.end);
this.element.removeEventListener('wheel', this.wheel);
this.cancelGesture();
}
}
+38
View File
@@ -0,0 +1,38 @@
import * as THREE from 'three';
/** Screen-space anchors in canvas NDC: left/bottom -1, right/top +1. */
export interface SceneAnchor { x: number; y: number }
const center: SceneAnchor = { x: 0, y: 0 };
/** Affine projection crop shared by the photo, glass and frame; never moves geometry. */
export class SceneZoomController {
value = 1;
readonly offset = new THREE.Vector2();
constructor(private readonly camera: THREE.PerspectiveCamera) {}
setZoom(value: number, from: SceneAnchor = center, to: SceneAnchor = from): void {
if (![value, from.x, from.y, to.x, to.y].every(Number.isFinite)) return;
const next = THREE.MathUtils.clamp(value, 1, 3);
const ratio = next / this.value;
const limit = next - 1;
// screen = zoom * originalScreen + offset. Keep the old anchor under the new one.
// Bounds keep the crop within the original 1x viewport and recenter exactly at 1x.
this.offset.set(
THREE.MathUtils.clamp(to.x - ratio * (from.x - this.offset.x), -limit, limit),
THREE.MathUtils.clamp(to.y - ratio * (from.y - this.offset.y), -limit, limit),
);
if (next === 1) this.offset.set(0, 0);
this.value = next;
this.updateProjection();
}
/** Reapply after aspect changes, including the inverse used by unprojection. */
updateProjection(): void {
this.camera.zoom = this.value;
this.camera.updateProjectionMatrix();
this.camera.projectionMatrix.elements[8] -= this.offset.x;
this.camera.projectionMatrix.elements[9] -= this.offset.y;
this.camera.projectionMatrixInverse.copy(this.camera.projectionMatrix).invert();
}
}
+44
View File
@@ -0,0 +1,44 @@
/** One rendered mechanical position, advanced only by the scene's existing RAF. */
export class WindowSlideController {
private position = 0;
private motion: { from: number; to: number; startedAt: number } | null = null;
readonly durationMs = 350;
get value(): number { return this.position; }
get target(): number { return this.motion?.to ?? this.position; }
get isAnimating(): boolean { return this.motion !== null; }
/** Direct manipulation is immediate and takes ownership from any animation. */
set(value: number): void {
if (!Number.isFinite(value)) return;
this.cancel();
this.position = Math.max(0, Math.min(1, value));
}
moveTo(value: number, now: number, reducedMotion = false): void {
if (!Number.isFinite(value) || !Number.isFinite(now)) return;
const to = Math.max(0, Math.min(1, value));
if (reducedMotion || to === this.position) { this.set(to); return; }
if (this.motion?.to === to) return;
// Never sample an unrendered timestamp on reversal: start exactly where
// the sash, glass, shader and handle were last presented together.
this.motion = { from: this.position, to, startedAt: now };
}
update(now: number, enabled = true, reducedMotion = false): number {
if (!enabled) this.cancel();
const motion = this.motion;
if (!motion || !Number.isFinite(now)) return this.position;
if (reducedMotion || now >= motion.startedAt + this.durationMs) {
this.set(motion.to);
return this.position;
}
const t = Math.max(0, (now - motion.startedAt) / this.durationMs);
const eased = t * t * (3 - 2 * t);
this.position = motion.from + (motion.to - motion.from) * eased;
return this.position;
}
/** Hold the last rendered value; disabling never resumes an old trajectory. */
cancel(): void { this.motion = null; }
}
+46
View File
@@ -0,0 +1,46 @@
import { createPhotoSnapshot } from './PhotoSnapshot';
export type WorkingPhotoImage = { image: HTMLImageElement; url: string; dispose(): void };
/** The photo renderer, inspection and journey preview share one bounded DOM
* image. PNG is a lossless adapter for those existing <img> consumers, not a
* target byte-size compressor; transparent input keeps its alpha. */
export async function createWorkingPhotoImage(file: File, id: number, signal: AbortSignal): Promise<WorkingPhotoImage> {
const snapshot = await createPhotoSnapshot(file, id, signal);
const canvas = document.createElement('canvas');
canvas.width = snapshot.width; canvas.height = snapshot.height;
let url = '';
const image = new Image();
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
image.onload = image.onerror = null;
image.removeAttribute('src');
if (url) URL.revokeObjectURL(url);
};
try {
signal.throwIfAborted();
const context = canvas.getContext('2d');
if (!context) throw new Error('사진을 준비할 수 없어요.');
context.drawImage(snapshot.bitmap, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error('작업용 사진을 만들지 못했어요.')), 'image/png'));
signal.throwIfAborted();
url = URL.createObjectURL(blob);
await new Promise<void>((resolve, reject) => {
const cleanup = () => { image.onload = image.onerror = null; signal.removeEventListener('abort', abort); };
const abort = () => { cleanup(); reject(new DOMException('사진 준비를 취소했어요.', 'AbortError')); };
image.onload = () => { cleanup(); resolve(); };
image.onerror = () => { cleanup(); reject(new Error('작업용 사진을 읽지 못했어요.')); };
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort(); else image.src = url;
});
signal.throwIfAborted();
return { image, url, dispose };
} catch (error) {
dispose(); throw error;
} finally {
snapshot.bitmap.close();
canvas.width = canvas.height = 1;
}
}
+792 -204
View File
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import type { ThemeId } from '../ui/themes';
import type {CameraComposition} from '../input/CameraComposition';
import {roomComposition} from './RoomComposition';
import {GOBANG_GLASS} from '../constants/glass';
import type {GlassShape} from '../rendering/PatternedGlassGeometry';
type PaneProfile=Pick<GlassShape,'grid'|'curvature'>;
const maximumPaneProfile:PaneProfile={grid:10,curvature:1};
export const HOUSE_ASSET_URL='/assets/window/grandma-house-surround.glb?v=2';
/** Make space around the complete photo; never alter its UV/crop or zoom state. */
export const houseViewScale=(theme:ThemeId):number=>theme==='wood'?.82:1;
type HouseState='idle'|'loading'|'ready'|'error';
const roles=new Set(['HouseCurtain','HouseCurtainEmbroidery','HouseEntranceMat','HousePaintedTrim','HousePlaster','HouseSlippers','HouseThreshold']);
const edge=(value:number,half:number,target:number,scale:number):number=>THREE.MathUtils.clamp(value,-half,half)*target/half+Math.sign(value)*Math.max(Math.abs(value)-half,0)*scale;
/** Finite camera chamber: the original detail stays at the aperture, its outer
* boundaries continue to connected walls instead of ending in a visible slab. */
export const houseRoomBounds=roomComposition;
function chamberGeometry(bounds:ReturnType<typeof houseRoomBounds>,glassZ:number,width:number,height:number,pane:PaneProfile):THREE.BufferGeometry {
const {halfWidth:x,floor,ceiling,front,rear}=bounds,t=.12,h=ceiling-floor,d=rear-front;
const boxes=[[-x-t/2,(floor+ceiling)/2,(front+rear)/2,t,h,d],
[x+t/2,(floor+ceiling)/2,(front+rear)/2,t,h,d],
[0,(floor+ceiling)/2,rear+t/2,2*x+2*t,h,t],
[0,ceiling+t/2,(front+rear)/2,2*x+2*t,t,d+t]];
// The movable sash used to hide these gaps. This fixed lining meets the
// measured fixed-wood/painted-return section while leaving the pane intact.
const s=Math.min(width,height),ix=width/2+.002*s,iy=height/2+.002*s,ox=width/2+.11*s,oy=height/2+.12*s;
// The sash slides across this fixed lining. Keep its whole thickness in
// front of the pane, including the .008 ripple and either curvature sign.
// The aperture's XY boundary is deliberately unchanged. The top/bottom
// strips cannot meet a horizontally sliding pane, so keep their original
// depth to preserve the existing reveal seal at very shallow angles.
const cell=Math.min(width/pane.grid,height/Math.max(1,pane.grid*height/width));
const paneEnvelope=GOBANG_GLASS.heightPerCell*cell*1.008*Math.abs(THREE.MathUtils.clamp(pane.curvature,-1,1));
const lipDepth=.002*s,lipZ=glassZ+paneEnvelope+.001*s+lipDepth/2;
boxes.push([-(ix+ox)/2,0,lipZ,ox-ix,2*oy,lipDepth],[(ix+ox)/2,0,lipZ,ox-ix,2*oy,lipDepth],
[0,(iy+oy)/2,glassZ+.001*s,2*ix,oy-iy,lipDepth],[0,-(iy+oy)/2,glassZ+.001*s,2*ix,oy-iy,lipDepth]);
const parts=boxes.map(([px,py,pz,w,h,d])=>new THREE.BoxGeometry(w,h,d).translate(px,py,pz-glassZ));
const geometry=mergeGeometries(parts)!;parts.forEach(g=>g.dispose());
// glTF UV orientation: plaster is the atlas left column / upper row.
const uv=geometry.getAttribute('uv');for(let i=0;i<uv.count;i++)uv.setXY(i,.006+.488*uv.getX(i),.506+.488*uv.getY(i));
geometry.setAttribute('color',new THREE.Float32BufferAttribute(new Float32Array(geometry.getAttribute('position').count*3).fill(1),3));
geometry.computeTangents();return geometry;
}
/** Small code-native landscape print in a wooden frame. No raster asset or request. */
function roomPicture(name:string):THREE.Mesh {
const parts:THREE.BufferGeometry[]=[];
const add=(geometry:THREE.BufferGeometry,color:number)=>{
const c=new THREE.Color(color),values=new Float32Array(geometry.getAttribute('position').count*3);
for(let i=0;i<values.length;i+=3)c.toArray(values,i);
geometry.setAttribute('color',new THREE.BufferAttribute(values,3));parts.push(geometry);
};
add(new THREE.BoxGeometry(.38,.46,.035),0x795238);
add(new THREE.BoxGeometry(.335,.415,.008).translate(0,0,.022),0xe5d9bc);
add(new THREE.PlaneGeometry(.27,.31).translate(0,0,.027),0xb8c7c4);
const hill=new THREE.Shape();hill.moveTo(-.135,-.155);hill.lineTo(-.135,-.025);hill.quadraticCurveTo(-.04,.09,.04,-.04);hill.quadraticCurveTo(.09,-.08,.135,.015);hill.lineTo(.135,-.155);hill.closePath();
add(new THREE.ShapeGeometry(hill).translate(0,0,.028),0x788975);
add(new THREE.CircleGeometry(.031,24).translate(.066,.075,.029),0xe9d7a6);
// Non-indexed geometry keeps the varied primitives in one draw and material.
const flat=parts.map(g=>g.index?g.toNonIndexed():g),geometry=mergeGeometries(flat)!;
new Set([...parts,...flat]).forEach(g=>g.dispose());
const mesh=new THREE.Mesh(geometry,new THREE.MeshStandardMaterial({vertexColors:true,roughness:.92}));mesh.name=name;return mesh;
}
function disposeHouse(root:THREE.Object3D):void {
const geometries=new Set<THREE.BufferGeometry>(),materials=new Set<THREE.Material>(),textures=new Set<THREE.Texture>();
root.traverse(object=>{if(!(object instanceof THREE.Mesh))return;geometries.add(object.geometry);for(const material of Array.isArray(object.material)?object.material:[object.material])materials.add(material);});
materials.forEach(material=>Object.values(material).forEach(value=>{if(value instanceof THREE.Texture)textures.add(value);}));
geometries.forEach(geometry=>geometry.dispose());materials.forEach(material=>material.dispose());textures.forEach(texture=>texture.dispose());
new Set([...textures].map(texture=>texture.image)).forEach(image=>{if(typeof image?.close==='function')image.close();});
root.removeFromParent();
}
/** Authored fixed room geometry only: no photo, optical pane, camera or light. */
export function prepareHouseAsset(root:THREE.Group) {
if(!root.getObjectByName('GrandmaHouseSurround'))throw new Error('Unrecognized house hierarchy');
root.updateMatrixWorld(true);const inverse=new THREE.Matrix4().copy(root.matrixWorld).invert();
const meshes:{mesh:THREE.Mesh;positions:Float32Array;normals:Float32Array;tangents:Float32Array|null;center:THREE.Vector3;rigid:boolean}[]=[];
root.traverse(object=>{
if(!(object instanceof THREE.Mesh))return;
if(!roles.has(object.name))throw new Error('Unrecognized house component');
const transform=new THREE.Matrix4().multiplyMatrices(inverse,object.matrixWorld);
if(!transform.equals(new THREE.Matrix4()))object.geometry.applyMatrix4(transform);
object.position.set(0,0,0);object.quaternion.identity();object.scale.set(1,1,1);object.updateMatrix();
object.geometry.computeBoundingBox();
meshes.push({mesh:object,positions:new Float32Array(object.geometry.getAttribute('position').array),normals:new Float32Array(object.geometry.getAttribute('normal').array),tangents:object.geometry.hasAttribute('tangent')?new Float32Array(object.geometry.getAttribute('tangent').array):null,center:object.geometry.boundingBox!.getCenter(new THREE.Vector3()),rigid:object.name==='HouseSlippers'});
});
if(meshes.length!==7||meshes.reduce((n,m)=>n+(m.mesh.geometry.index?.count??0)/3,0)!==20014)throw new Error('Unrecognized house geometry');
const plaster=meshes.find(source=>source.mesh.name==='HousePlaster')!.mesh;
const chamber=new THREE.Mesh(new THREE.BufferGeometry(),plaster.material);chamber.name='HouseRoomShell';root.add(chamber);
const pictures=[roomPicture('RoomPictureLeft'),roomPicture('RoomPictureRight')];root.add(...pictures);
let disposed=false;
return {
resize(width:number,height:number,glassZ:number,clearanceScale=Math.min(width,height),wallPeek=false,pane:PaneProfile=maximumPaneProfile):void {
if(![width,height,glassZ].every(Number.isFinite)||width<=0||height<=0)throw new Error('Invalid house aperture');
const scale=Math.min(width,height),normal=new THREE.Vector3(),tangent=new THREE.Vector3();
const bounds=houseRoomBounds(width,height,glassZ);
const depthScale=Math.min(wallPeek?.2*scale:scale,Math.max(.001,clearanceScale));bounds.front=glassZ+.35*depthScale;
const xStart=width/2+.8*scale,yStart=height/2+.49*scale,zStart=1.30*depthScale;
const extendX=(x:number)=>Math.abs(x)<=1.3?edge(x,.5,width/2,scale):Math.sign(x)*(xStart+(Math.abs(x)-1.3)/.3*(bounds.halfWidth-xStart));
const extendY=(y:number)=>y<=1.15?edge(y,.66,height/2,scale):yStart+(y-1.15)/.24*(bounds.ceiling-yStart);
const extendZ=(z:number)=>z<=1.3?z*depthScale:zStart+(z-1.3)/.67*(bounds.rear-glassZ-zStart);
for(const source of meshes) {
const p=source.mesh.geometry.getAttribute('position'),n=source.mesh.geometry.getAttribute('normal'),t=source.mesh.geometry.getAttribute('tangent'),c=source.center;
const cx=edge(c.x,.5,width/2,scale),cy=edge(c.y,.66,height/2,scale);
for(let i=0;i<p.count;i++) {
const x=source.positions[i*3],y=source.positions[i*3+1],z=source.positions[i*3+2];
p.setXYZ(i,source.rigid?cx+(x-c.x)*scale:extendX(x),source.rigid?cy+(y-c.y)*scale:extendY(y),extendZ(z));
// Preserve Blender's authored weighted/smooth normals, not split-topology
// recomputation. This edge warp has a positive diagonal local Jacobian.
const sx=source.rigid?scale:Math.abs(x)>1.3?(bounds.halfWidth-xStart)/.3:Math.abs(x)>.5?scale:width;
const sy=source.rigid?scale:y>1.15?(bounds.ceiling-yStart)/.24:Math.abs(y)>.66?scale:height/1.32;
const sz=z>1.3?(bounds.rear-glassZ-zStart)/.67:depthScale;
normal.fromArray(source.normals,i*3);
if(sx!==scale||sy!==scale||sz!==scale)normal.set(normal.x/sx,normal.y/sy,normal.z/sz).normalize();
n.setXYZ(i,normal.x,normal.y,normal.z);
if(t&&source.tangents) {
tangent.fromArray(source.tangents,i*4);
if(sx!==scale||sy!==scale||sz!==scale){tangent.set(tangent.x*sx,tangent.y*sy,tangent.z*sz);tangent.addScaledVector(normal,-tangent.dot(normal)).normalize();}
t.setXYZW(i,tangent.x,tangent.y,tangent.z,source.tangents[i*4+3]);
}
}
p.needsUpdate=true;n.needsUpdate=true;if(t)t.needsUpdate=true;
source.mesh.geometry.computeBoundingBox();source.mesh.geometry.computeBoundingSphere();
}
chamber.geometry.dispose();chamber.geometry=chamberGeometry(bounds,glassZ,width,height,pane);
pictures.forEach((picture,index)=>{const side=index===0?-1:1;picture.scale.setScalar(bounds.unit);picture.rotation.y=-side*Math.PI/2;picture.position.set(side*(bounds.halfWidth-.018*bounds.unit),Math.min(.08*height,bounds.floor+1.05*bounds.unit),bounds.front-glassZ+.5*bounds.unit);});
root.position.z=glassZ;root.updateMatrixWorld(true);
},
dispose():void {if(disposed)return;disposed=true;disposeHouse(root);}
};
}
async function loadOriginal(signal:AbortSignal):Promise<THREE.Group> {
const response=await fetch(HOUSE_ASSET_URL,{signal});if(!response.ok)throw new Error('House surround load failed');
return (await new GLTFLoader().parseAsync(await response.arrayBuffer(),'')).scene;
}
/** Small asset adapter in the existing frame pass, with one cancellable owner. */
export class BlenderHouseSurround {
private root:THREE.Group|null=null;
private asset:ReturnType<typeof prepareHouseAsset>|null=null;
private size:[number,number,number,number]=[1,1.32,.6,1];
private pane:PaneProfile=maximumPaneProfile;
private theme:ThemeId='wood';
private composition:CameraComposition='legacy';
setCompositionMode(mode:CameraComposition):void {this.composition=mode;this.asset?.resize(...this.size,mode==='wall',this.pane);}
private state:HouseState='idle';
private pending:Promise<void>|null=null;
private disposed=false;
private readonly abort=new AbortController();
constructor(private readonly scene:THREE.Scene,private readonly onState:(state:HouseState)=>void,private readonly loader:(signal:AbortSignal)=>Promise<THREE.Group>=loadOriginal){}
get isReady():boolean {return this.state==='ready';}
apply(theme:ThemeId):void {this.theme=theme;if(this.root)this.root.visible=theme==='wood';}
resize(width:number,height:number,glassZ:number,clearanceScale=Math.min(width,height),pane:PaneProfile=maximumPaneProfile):void {this.size=[width,height,glassZ,clearanceScale];this.pane={...pane};this.asset?.resize(...this.size,this.composition==='wall',this.pane);}
load():Promise<void> {
if(this.disposed||this.state==='ready')return Promise.resolve();if(this.pending)return this.pending;
this.state='loading';this.onState(this.state);
this.pending=this.loader(this.abort.signal).then(root=>{
if(this.disposed){disposeHouse(root);return;}
try {this.asset=prepareHouseAsset(root);this.asset.resize(...this.size,this.composition==='wall',this.pane);}
catch(error){disposeHouse(root);this.asset=null;throw error;}
this.root=root;root.name='blender-house-surround';this.scene.add(root);this.apply(this.theme);this.state='ready';this.onState(this.state);
}).catch(()=>{if(!this.disposed){this.state='error';this.onState(this.state);}}).finally(()=>{this.pending=null;});
return this.pending;
}
dispose():void {if(this.disposed)return;this.disposed=true;this.abort.abort();this.asset?.dispose();this.asset=null;this.root=null;}
}
/** The final frame pass clears depth in both renderers. Seed the intact photo's
* actual displaced surface so rear paving cannot paint over it afterward. */
export function createHousePhotoOccluder(source:THREE.Mesh<THREE.BufferGeometry,THREE.ShaderMaterial>) {
const material=new THREE.ShaderMaterial({uniforms:source.material.uniforms,vertexShader:source.material.vertexShader,
fragmentShader:'uniform sampler2D uTexture; uniform bool uUsePhotoAlpha; varying vec2 vUv; void main(){if(uUsePhotoAlpha && texture2D(uTexture,vUv).a < .5) discard; gl_FragColor=vec4(0.);}',colorWrite:false,depthWrite:true,depthTest:true});
const mesh=new THREE.Mesh(source.geometry,material);
mesh.name='house-photo-depth-occluder';mesh.renderOrder=-1;mesh.frustumCulled=false;mesh.matrixAutoUpdate=false;mesh.visible=false;
let disposed=false;
return {
mesh,
sync(visible:boolean):void {source.updateMatrixWorld(true);mesh.matrix.copy(source.matrixWorld);mesh.matrixWorldNeedsUpdate=true;mesh.visible=visible;},
dispose():void {if(disposed)return;disposed=true;mesh.removeFromParent();material.dispose();}
};
}
+159
View File
@@ -0,0 +1,159 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import type { ThemeId } from '../ui/themes';
export const WINDOW_ASSET_URL='/assets/window/grandma-wood-sash.glb?v=2';
/** Keep the full asymmetric imported surround inside the neutral viewport.
* Use the same fit before/after load and for both themes to avoid a late photo jump. */
export function fitWindowScale(viewAspect:number,photoAspect:number):number {
const small=Math.min(photoAspect,1),tangent=Math.tan(THREE.MathUtils.degToRad(20)),margin=.96;
const widthLimit=margin*viewAspect*tangent*2.4/(.8*(photoAspect/2+.122*small+margin*viewAspect*tangent*.113*small));
const heightLimit=margin*tangent*2.4/(.8*(.5+.1815*small+margin*tangent*.113*small));
return Math.min(1.78*viewAspect/photoAspect,1.45,widthLimit,heightLimit);
}
type FrameState='idle'|'loading'|'ready'|'error';
interface Component {ids:number[];axis:number;center:number;length:number;beam:boolean;hardware:boolean}
interface SourceMesh {mesh:THREE.Mesh;positions:Float32Array;uvs:Float32Array;components:Component[]}
/** The delivered atlas has eight U strips and nonperiodic longitudinal grain. */
export function configureWoodMaterial(material:THREE.MeshStandardMaterial):void {
for(const texture of new Set([material.map,material.normalMap,material.roughnessMap,material.metalnessMap])) {
if(!texture)continue;
texture.wrapS=THREE.ClampToEdgeWrapping;texture.wrapT=THREE.MirroredRepeatWrapping;texture.needsUpdate=true;
}
material.onBeforeCompile=shader=>{
const chunk=THREE.ShaderChunk.normal_fragment_maps.replace('mapN.xy *= normalScale;',
'mapN.y *= 1.0 - 2.0 * mod(floor(vNormalMapUv.y), 2.0);\n\tmapN.xy *= normalScale;');
shader.fragmentShader=shader.fragmentShader.replace('#include <normal_fragment_maps>',chunk);
};
material.customProgramCacheKey=()=> 'wood-mirrored-grain-r169-v1';
}
function disposeObject(root:THREE.Object3D):void {
const geometries=new Set<THREE.BufferGeometry>(),materials=new Set<THREE.Material>(),textures=new Set<THREE.Texture>();
root.traverse(object=>{if(!(object instanceof THREE.Mesh))return;geometries.add(object.geometry);for(const material of Array.isArray(object.material)?object.material:[object.material])materials.add(material);});
materials.forEach(material=>Object.values(material).forEach(value=>{if(value instanceof THREE.Texture)textures.add(value);}));
geometries.forEach(geometry=>geometry.dispose());materials.forEach(material=>material.dispose());textures.forEach(texture=>texture.dispose());
const images=new Set([...textures].map(texture=>texture.image));
images.forEach(image=>{if(typeof image?.close==='function')image.close();});
root.removeFromParent();
}
function components(mesh:THREE.Mesh):Component[] {
const p=mesh.geometry.getAttribute('position'),index=mesh.geometry.index;
if(!index)throw new Error('Window asset must have indexed triangles');
const parent=Array.from({length:p.count},(_,i)=>i),positions=new Map<string,number>();
const find=(i:number):number=>{while(parent[i]!==i){parent[i]=parent[parent[i]];i=parent[i];}return i;};
const union=(a:number,b:number):void=>{parent[find(a)]=find(b);};
for(let i=0;i<p.count;i++) {
const key=[p.getX(i),p.getY(i),p.getZ(i)].map(v=>v.toFixed(6)).join(',');
const previous=positions.get(key);if(previous!==undefined)union(i,previous);else positions.set(key,i);
}
for(let i=0;i<index.count;i+=3){union(index.getX(i),index.getX(i+1));union(index.getX(i),index.getX(i+2));}
const groups=new Map<number,number[]>();
for(let i=0;i<p.count;i++){const id=find(i);if(!groups.has(id))groups.set(id,[]);groups.get(id)!.push(i);}
return [...groups.values()].map(ids=>{
const min=[Infinity,Infinity,Infinity],max=[-Infinity,-Infinity,-Infinity];
ids.forEach(i=>{for(let k=0;k<3;k++){const v=p.getComponent(i,k);min[k]=Math.min(min[k],v);max[k]=Math.max(max[k],v);}});
const axis=max[0]-min[0]>max[1]-min[1]?0:1,length=max[axis]-min[axis],center=(max[axis]+min[axis])/2;
const hardware=mesh.name==='MovableSash_Brass'||(mesh.name==='MovableSash_Patina'&&min[2]>.05);
const beam=length>.9;
if(beam&&ids.some(i=>Math.min(Math.abs(p.getComponent(i,axis)-min[axis]),Math.abs(p.getComponent(i,axis)-max[axis]))>.00301))throw new Error('Unrecognized window beam topology');
return {ids,axis,center,length,beam,hardware};
});
}
const edge=(value:number,half:number,target:number,scale:number):number=>THREE.MathUtils.clamp(value,-half,half)*target/half+Math.sign(value)*Math.max(Math.abs(value)-half,0)*scale;
/** Asset-specific cap-preserving deformation. Every resize starts from immutable authored data. */
export function prepareFrameAsset(root:THREE.Group) {
if(!root.getObjectByName('FixedSurround')||!root.getObjectByName('MovableSash')||!root.getObjectByName('BrassLatch'))throw new Error('Unrecognized window hierarchy');
root.updateMatrixWorld(true);const inverse=new THREE.Matrix4().copy(root.matrixWorld).invert();
const meshes:SourceMesh[]=[];
root.traverse(object=>{
if(!(object instanceof THREE.Mesh))return;
object.geometry.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inverse,object.matrixWorld));
object.position.set(0,0,0);object.quaternion.identity();object.scale.set(1,1,1);object.updateMatrix();
const groups=components(object),expected:Record<string,number>={FixedSurround_Wood:10,MovableSash_Wood:12,MovableSash_Patina:7,MovableSash_Brass:5};
if(groups.length!==expected[object.name])throw new Error('Unrecognized window components');
meshes.push({mesh:object,positions:new Float32Array(object.geometry.getAttribute('position').array),uvs:new Float32Array(object.geometry.getAttribute('uv').array),components:groups});
if(object.name.endsWith('_Wood'))for(const material of Array.isArray(object.material)?object.material:[object.material])configureWoodMaterial(material as THREE.MeshStandardMaterial);
});
if(meshes.length!==4||meshes.reduce((n,m)=>n+m.mesh.geometry.index!.count/3,0)!==6988)throw new Error('Unrecognized window geometry');
const sash=root.getObjectByName('MovableSash')!;
const pocketPlane=new THREE.Plane(new THREE.Vector3(1,0,0),1);
// GLTF shares wood between fixed and moving pieces. Clone only the moving
// materials so the fixed pocket casing never inherits the clipping plane.
const fixedMaterials=new Set<THREE.Material>();root.getObjectByName('FixedSurround')!.traverse(object=>{if(object instanceof THREE.Mesh)for(const material of Array.isArray(object.material)?object.material:[object.material])fixedMaterials.add(material);});
const movingMaterials=new Map<THREE.Material,THREE.Material>();
sash.traverse(object=>{if(!(object instanceof THREE.Mesh))return;
const clone=(material:THREE.Material)=>{let next=movingMaterials.get(material);if(!next){next=fixedMaterials.has(material)?material.clone():material;next.clippingPlanes=[pocketPlane];if(object.name.endsWith('_Wood'))configureWoodMaterial(next as THREE.MeshStandardMaterial);movingMaterials.set(material,next);}return next;};
object.material=Array.isArray(object.material)?object.material.map(clone):clone(object.material);
});
let disposed=false,openness=0,apertureWidth=1;
const applyOpen=()=>{sash.position.x=openness===0?0:-apertureWidth*openness;root.updateMatrixWorld(true);};
return {
setOpen(value:number):void {if(!Number.isFinite(value))return;openness=THREE.MathUtils.clamp(value,0,1);applyOpen();},
handlePosition(target:THREE.Vector3):THREE.Vector3 {return root.getObjectByName('BrassLatch')!.getWorldPosition(target);},
resize(width:number,height:number,glassZ:number):void {
if(!Number.isFinite(width)||!Number.isFinite(height)||width<=0||height<=0)throw new Error('Invalid window aperture');
const scale=Math.min(width,height);apertureWidth=width;pocketPlane.constant=width/2+.05*scale;
for(const source of meshes) {
const p=source.mesh.geometry.getAttribute('position'),uv=source.mesh.geometry.getAttribute('uv');
for(const component of source.components)for(const i of component.ids) {
const original=[source.positions[i*3],source.positions[i*3+1],source.positions[i*3+2]];
const point=[edge(original[0],.5,width/2,scale),edge(original[1],.66,height/2,scale),original[2]*scale];
let v=source.uvs[i*2+1];
if(component.hardware) {
point[0]=width/2+.026*scale+(original[0]-.526)*scale;
point[1]=-.235*height/1.32+(original[1]+.235)*scale;
point[2]=original[2]*scale;
} else if(component.beam) {
const axis=component.axis,canonical=axis===0?1:1.32,target=axis===0?width:height;
const sign=original[axis]<component.center?-1:1;
point[axis]=scale*original[axis]+sign*(target-scale*canonical)/2;
if(source.mesh.name.endsWith('_Wood'))v-=sign*.96/component.length*(target/scale-canonical)/2;
}
p.setXYZ(i,point[0],point[1],point[2]);uv.setXY(i,source.uvs[i*2],v);
}
p.needsUpdate=true;uv.needsUpdate=true;source.mesh.geometry.computeBoundingBox();source.mesh.geometry.computeBoundingSphere();
}
root.getObjectByName('BrassLatch')!.position.set(width/2+.026*scale,-.235*height/1.32,.058*scale);
root.position.z=glassZ;applyOpen();
},
dispose():void {if(disposed)return;disposed=true;disposeObject(root);}
};
}
async function loadOriginal(signal:AbortSignal):Promise<THREE.Group> {
const response=await fetch(WINDOW_ASSET_URL,{signal});if(!response.ok)throw new Error('Window asset load failed');
return (await new GLTFLoader().parseAsync(await response.arrayBuffer(),'')).scene;
}
export class BlenderWindowFrame {
private root:THREE.Group|null=null;
private asset:ReturnType<typeof prepareFrameAsset>|null=null;
private size:[number,number,number]=[1,1,.6];
private theme:ThemeId='wood';
private state:FrameState='idle';
private openness=0;
private pending:Promise<void>|null=null;
private disposed=false;
private readonly abort=new AbortController();
constructor(private readonly scene:THREE.Scene,private readonly onState:(active:boolean,state:FrameState)=>void,private readonly loader:(signal:AbortSignal)=>Promise<THREE.Group>=loadOriginal){}
private notify():void {const visible=this.theme==='wood'&&this.state==='ready';if(this.root)this.root.visible=visible;this.onState(visible,this.state);}
apply(theme:ThemeId):void {this.theme=theme;this.notify();}
get isReady():boolean {return this.state==='ready';}
get isWood():boolean {return this.theme==='wood';}
setOpen(value:number):void {if(!Number.isFinite(value))return;this.openness=THREE.MathUtils.clamp(value,0,1);this.asset?.setOpen(this.openness);}
handlePosition(target:THREE.Vector3):THREE.Vector3|null {return this.asset?.handlePosition(target)??null;}
resize(width:number,height:number,glassZ:number):void {this.size=[width,height,glassZ];this.asset?.resize(...this.size);}
load():Promise<void> {
if(this.disposed||this.state==='ready')return Promise.resolve();if(this.pending)return this.pending;
this.state='loading';this.notify();
this.pending=this.loader(this.abort.signal).then(root=>{
if(this.disposed){disposeObject(root);return;}
try {this.asset=prepareFrameAsset(root);this.asset.resize(...this.size);this.asset.setOpen(this.openness);}
catch(error){disposeObject(root);this.asset=null;throw error;}
this.root=root;root.name='blender-window-frame';this.scene.add(root);this.state='ready';this.notify();
}).catch(()=>{if(!this.disposed){this.state='error';this.notify();}}).finally(()=>{this.pending=null;});
return this.pending;
}
dispose():void {if(this.disposed)return;this.disposed=true;this.abort.abort();this.asset?.dispose();this.asset=null;this.root=null;}
}
+260
View File
@@ -0,0 +1,260 @@
import * as THREE from 'three';
type Point3 = [number, number, number];
export interface CourtyardBox {
name: string;
min: Point3;
max: Point3;
/** 0 concrete, 1 block wall, 2 wood, 3 plaster, 4 blue-green base, 5 roof/cap. */
kind: number;
}
const BOX_COUNT = 12;
/** A bounded reference-inspired surrogate, not a reconstruction of the private photographs. */
export function createCourtyardLayout(width: number, height: number, glassZ: number): CourtyardBox[] {
if (![width, height, glassZ].every(Number.isFinite)) throw new RangeError('Courtyard dimensions must be finite');
if (width <= 0 || height <= 0) throw new RangeError('Courtyard width and height must be positive');
const s = Math.min(width, height), span = Math.max(width, height);
const floor = -height / 2 - .1 * s;
const back = glassZ - 3 * height;
const left = -Math.max(width * 1.5, height * .8), right = -.12 * width;
const deckFront = glassZ - .55 * height, deckBack = glassZ - 1.4 * height;
const deckTop = floor + .28 * s, legWidth = .075 * s;
const buildingX = Math.max(width * .66, height * .4);
const buildingFront = glassZ - .95 * height;
const boxes: CourtyardBox[] = [
{ name: 'ground', min: [-40 * span, floor - .14 * s, glassZ - 80 * span], max: [40 * span, floor, glassZ - .015 * s], kind: 0 },
{ name: 'boundary-wall', min: [-3 * span, floor, back - .13 * s], max: [3 * span, floor + .69 * height, back], kind: 1 },
{ name: 'boundary-cap', min: [-3 * span, floor + .69 * height, back - .16 * s], max: [3 * span, floor + .725 * height, back + .025 * s], kind: 5 },
{ name: 'left-return', min: [-2.5 * span - .13 * s, floor, back], max: [-2.5 * span, floor + .69 * height, glassZ - .3 * height], kind: 1 },
{ name: 'side-building', min: [buildingX, floor, back - 1.8 * height], max: [buildingX + 3 * span, floor + 2.8 * height, buildingFront], kind: 3 },
{ name: 'blue-building-base', min: [buildingX - .016 * s, floor, back - 1.8 * height], max: [buildingX + 3 * span, floor + .33 * height, buildingFront + .016 * s], kind: 4 },
{ name: 'building-eaves', min: [buildingX - .1 * s, floor + 2.8 * height, back - 1.85 * height], max: [buildingX + 3.1 * span, floor + 2.86 * height, buildingFront + .1 * s], kind: 5 },
{ name: 'wood-platform', min: [left, deckTop - .055 * s, deckBack], max: [right, deckTop, deckFront], kind: 2 }
];
for (const x of [left + .1 * s, right - .1 * s - legWidth]) {
for (const z of [deckBack + .08 * s, deckFront - .08 * s - legWidth]) {
boxes.push({ name: `platform-leg-${boxes.length - 8}`, min: [x, floor, z], max: [x + legWidth, deckTop - .055 * s, z + legWidth], kind: 2 });
}
}
return boxes;
}
const yardUniformDeclarations = /* glsl */ `
uniform float uYardEnabled;
uniform vec3 uYardDimensions;
uniform vec3 uYardSkyColor;
uniform vec4 uYardDogContact;
uniform vec3 uYardBoxMin[${BOX_COUNT}];
uniform vec3 uYardBoxMax[${BOX_COUNT}];
uniform float uYardBoxKind[${BOX_COUNT}];
`;
/** Included verbatim by raster and B: no separate photo, lights, shadow maps, or color approximation. */
export const courtyardSurfaceShader = /* glsl */ `
float bYardNoise(vec2 p) {
vec2 cell=floor(p), f=fract(p);
f=f*f*(3.0-2.0*f);
vec4 h=fract(sin(vec4(dot(cell,vec2(127.1,311.7)),dot(cell+vec2(1.0,0.0),vec2(127.1,311.7)),
dot(cell+vec2(0.0,1.0),vec2(127.1,311.7)),dot(cell+vec2(1.0),vec2(127.1,311.7))))*43758.5453);
return mix(mix(h.x,h.y,f.x),mix(h.z,h.w,f.x),f.y);
}
vec3 bYardSurfaceColor(vec3 point,vec3 normal,float kind) {
float scale=min(uYardDimensions.x,uYardDimensions.y);
vec3 p=(point-vec3(0.0,-uYardDimensions.y*.5-.1*scale,uYardDimensions.z))/scale;
vec2 face=abs(normal.y)>.5?p.xz:(abs(normal.x)>.5?p.zy:p.xy);
float broad=bYardNoise(face*3.7), fine=bYardNoise(face*65.0);
vec3 base=vec3(.30,.285,.245);
if(kind<.5) {
base*=.88+.16*broad+.07*fine;
// Fixed world-space shade under the deck and alongside the building, shared by both views.
vec2 lo=uYardBoxMin[7].xz/scale, hi=uYardBoxMax[7].xz/scale;
vec2 footprint=smoothstep(lo-vec2(.10),lo+vec2(.02),point.xz/scale)
*(1.0-smoothstep(hi-vec2(.02),hi+vec2(.10),point.xz/scale));
float deckShade=footprint.x*footprint.y;
float sideShade=smoothstep(uYardBoxMin[4].x/scale-.7,uYardBoxMin[4].x/scale+.05,point.x/scale);
base*=1.0-.38*deckShade;
base*=1.0-.24*sideShade;
vec2 contact=(point.xz-uYardDogContact.xy)/max(.001,uYardDogContact.z);
base*=1.0-uYardDogContact.w*exp(-dot(contact,contact)*2.0);
} else if(kind<1.5) {
vec2 block=vec2(face.x*3.4+mod(floor(face.y*5.0),2.0)*.5,face.y*5.0);
vec2 edge=min(fract(block),1.0-fract(block));
float joint=1.0-smoothstep(.018,.04,min(edge.x,edge.y));
base=mix(vec3(.32,.335,.30)*(.89+.15*broad+.04*fine),vec3(.20,.215,.195),joint*.65);
base*=.80+.20*smoothstep(0.0,.5,p.y);
} else if(kind<2.5) {
float grain=sin(face.x*105.0+sin(face.y*4.2)*1.8)*.035+sin(face.x*291.0+face.y*1.4)*.017;
float plank=fract(p.x*7.0);
float seam=1.0-smoothstep(.014,.038,min(plank,1.0-plank));
base=vec3(.39,.215,.095)*(.94+.12*broad+grain);
base*=1.0-.30*seam;
} else if(kind<3.5) {
base=vec3(.56,.575,.57)*(.96+.055*broad+.016*fine);
} else if(kind<4.5) {
base=vec3(.16,.36,.38)*(.85+.21*broad+.045*fine);
base*=.75+.25*smoothstep(0.0,.18,p.y);
} else {
base=vec3(.145,.17,.19)*(.92+.11*broad);
}
vec3 sun=normalize(vec3(-.48,.82,.30));
float daylight=.62+.54*max(dot(normal,sun),0.0);
return base*daylight*vec3(1.035,1.015,.975);
}
`;
/** Ray must be world-space; returned distance is in world units. Sky is an enabled hit at 1e19. */
export const courtyardShader = yardUniformDeclarations + courtyardSurfaceShader + /* glsl */ `
bool bYardBoxHit(vec3 origin,vec3 ray,vec3 boxMin,vec3 boxMax,out float distance,out vec3 normal) {
float nearDistance=-1e19,farDistance=1e19;
vec3 nearNormal=vec3(0.0),farNormal=vec3(0.0);
for(int axis=0;axis<3;axis++) {
if(abs(ray[axis])<1e-8) {
if(origin[axis]<boxMin[axis]||origin[axis]>boxMax[axis]) return false;
} else {
float a=(boxMin[axis]-origin[axis])/ray[axis],b=(boxMax[axis]-origin[axis])/ray[axis];
float lo=min(a,b),hi=max(a,b);
if(lo>nearDistance) {
nearDistance=lo; nearNormal=vec3(0.0); nearNormal[axis]=-sign(ray[axis]);
}
if(hi<farDistance) {
farDistance=hi; farNormal=vec3(0.0); farNormal[axis]=sign(ray[axis]);
}
}
}
if(nearDistance>farDistance||farDistance<1e-5) return false;
bool outside=nearDistance>=1e-5;
distance=outside?nearDistance:farDistance;
normal=outside?nearNormal:farNormal;
return true;
}
bool bCourtyardHit(vec3 origin,vec3 ray,out float distance,out vec3 color) {
distance=1e19; color=uYardSkyColor;
if(uYardEnabled<.5||dot(ray,ray)<1e-16) return false;
vec3 direction=normalize(ray),closestNormal=vec3(0.0);
float closestKind=-1.0;
for(int box=0;box<${BOX_COUNT};box++) {
float candidateDistance; vec3 candidateNormal;
if(bYardBoxHit(origin,direction,uYardBoxMin[box],uYardBoxMax[box],candidateDistance,candidateNormal)
&&candidateDistance<distance) {
distance=candidateDistance; closestNormal=candidateNormal; closestKind=uYardBoxKind[box];
}
}
if(closestKind>=0.0) color=bYardSurfaceColor(origin+direction*distance,closestNormal,closestKind);
return true;
}
`;
function createGeometry(boxes: CourtyardBox[]): THREE.BufferGeometry {
const template = new THREE.BoxGeometry(1, 1, 1).toNonIndexed();
const sourcePosition = template.getAttribute('position'), sourceNormal = template.getAttribute('normal');
const count = sourcePosition.count;
const positions = new Float32Array(boxes.length * count * 3);
const normals = new Float32Array(positions.length);
const kinds = new Float32Array(boxes.length * count);
boxes.forEach((box, index) => {
for (let vertex = 0; vertex < count; vertex++) {
const offset = (index * count + vertex) * 3;
for (let axis = 0; axis < 3; axis++) {
positions[offset + axis] = box.min[axis] + (sourcePosition.getComponent(vertex, axis) + .5) * (box.max[axis] - box.min[axis]);
normals[offset + axis] = sourceNormal.getComponent(vertex, axis);
}
kinds[index * count + vertex] = box.kind;
}
});
template.dispose();
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geometry.setAttribute('aYardKind', new THREE.BufferAttribute(kinds, 1));
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
return geometry;
}
/** B can allocate its disabled schema without constructing or displaying courtyard geometry. */
export function createCourtyardUniforms() {
return {
uYardEnabled: { value: 0 },
uYardDogContact: {value:new THREE.Vector4(0,0,.1,0)},
uYardDimensions: { value: new THREE.Vector3(1, 1, .6) },
uYardSkyColor: { value: new THREE.Color(.27, .55, .82) },
uYardBoxMin: { value: Array.from({ length: BOX_COUNT }, () => new THREE.Vector3()) },
uYardBoxMax: { value: Array.from({ length: BOX_COUNT }, () => new THREE.Vector3()) },
uYardBoxKind: { value: Array.from({ length: BOX_COUNT }, () => 0) }
};
}
/** Add to the same world as the photo; keep this group's transform at identity. */
export class ExperimentalCourtyard {
readonly group = new THREE.Group();
readonly uniforms = createCourtyardUniforms();
private readonly material: THREE.ShaderMaterial;
private readonly mesh: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>;
private disposed = false;
constructor() {
this.group.name = 'experimental-courtyard';
this.group.visible = false;
this.material = new THREE.ShaderMaterial({
name: 'unified-courtyard-surfaces', uniforms: this.uniforms, side: THREE.DoubleSide,
vertexShader: /* glsl */ `
attribute float aYardKind;
varying vec3 vYardPoint;
varying vec3 vYardNormal;
varying float vYardKind;
void main() {
vec4 world=modelMatrix*vec4(position,1.0);
vYardPoint=world.xyz; vYardNormal=mat3(modelMatrix)*normal; vYardKind=aYardKind;
gl_Position=projectionMatrix*viewMatrix*world;
}
`,
fragmentShader: yardUniformDeclarations + courtyardSurfaceShader + /* glsl */ `
varying vec3 vYardPoint;
varying vec3 vYardNormal;
varying float vYardKind;
void main() {
gl_FragColor=vec4(bYardSurfaceColor(vYardPoint,normalize(vYardNormal),vYardKind),1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`
});
this.mesh = new THREE.Mesh(new THREE.BufferGeometry(), this.material);
this.mesh.name = 'courtyard-merged-boxes';
this.group.add(this.mesh);
this.resize(1, 1, .6);
}
resize(width: number, height: number, glassZ: number): void {
if (this.disposed) return;
const boxes = createCourtyardLayout(width, height, glassZ);
const geometry = createGeometry(boxes);
this.uniforms.uYardDimensions.value.set(width, height, glassZ);
boxes.forEach((box, index) => {
this.uniforms.uYardBoxMin.value[index].fromArray(box.min);
this.uniforms.uYardBoxMax.value[index].fromArray(box.max);
this.uniforms.uYardBoxKind.value[index] = box.kind;
});
this.mesh.geometry.dispose();
this.mesh.geometry = geometry;
}
setDogContact(x:number,z:number,radius:number,opacity:number):void {
this.uniforms.uYardDogContact.value.set(x,z,Math.max(.001,radius),THREE.MathUtils.clamp(opacity,0,1));
}
setEnabled(enabled: boolean): void {
this.group.visible = enabled && !this.disposed;
this.uniforms.uYardEnabled.value = this.group.visible ? 1 : 0;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.setEnabled(false);
this.group.removeFromParent();
this.mesh.geometry.dispose();
this.material.dispose();
}
}
+25
View File
@@ -0,0 +1,25 @@
import {fitWindowScale} from './BlenderWindowFrame';
export const PEEKING_DOOR_ASPECT=.74;
export interface MaskBounds {x:number;y:number;width:number;height:number}
/** Door geometry is independent of the uploaded photograph. The legacy photo aperture
* remains available for conservative foreground-detail clearance. The runtime
* corner camera follows the physical door dimensions. */
export function peekingLayout(viewAspect:number,photoAspect:number) {
const scale=fitWindowScale(viewAspect,PEEKING_DOOR_ASPECT)*.82;
const legacy=fitWindowScale(viewAspect,photoAspect)*.82;
const width=scale*PEEKING_DOOR_ASPECT*.8,height=scale*.8;
const cameraBounds:[number,number,number]=[legacy*photoAspect*.8,legacy*.8,.6];
return {width,height,cameraBounds,clearanceScale:Math.min(width,height,...cameraBounds.slice(0,2))};
}
/** No pose synthesis: place the actual selected silhouette. Default crops the
* lower edge behind the sill; manual XY remains available even at scale 1. */
export function placePeekingDog(layout:{width:number;height:number},aspect:number,bounds:MaskBounds,z:number,zoom:number,panX:number,panY:number) {
const h=layout.height,w=layout.width;
const objectHeight=Math.min(.48*h,.43*w/(Math.max(.01,bounds.width)*aspect/Math.max(.01,bounds.height)))*zoom;
const height=objectHeight/Math.max(.01,bounds.height),width=height*aspect;
const centerX=.24*w+panX*.35*w,top=-.56*h+objectHeight+panY*.35*h;
const x=centerX-(bounds.x+bounds.width/2-.5)*width;
const y=top-(.5-bounds.y)*height;
return {x,y,z,width,height};
}
+19
View File
@@ -0,0 +1,19 @@
import * as THREE from 'three';
export class PeekingDoorGrid {
readonly group=new THREE.Group();
private readonly pocket=new THREE.Plane(new THREE.Vector3(1,0,0),1);
private readonly material=new THREE.MeshStandardMaterial({color:0x9d7956,roughness:.86,clippingPlanes:[this.pocket]});
private readonly geometry=new THREE.BoxGeometry(1,1,1);
private readonly vertical=new THREE.Mesh(this.geometry,this.material);
private readonly horizontal=new THREE.Mesh(this.geometry,this.material);
private width=1;private open=0;
constructor(){this.group.name='experimental-structural-door-grid';this.group.add(this.vertical,this.horizontal);this.group.visible=false;}
resize(width:number,height:number,glassZ:number):void {
this.width=width;const s=Math.min(width,height),rail=.065*s;
this.vertical.scale.set(rail,height,.11*s);this.vertical.position.set(0,0,glassZ+.038*s);
this.horizontal.scale.set(width,rail,.11*s);this.horizontal.position.set(0,.015*height,glassZ+.038*s);
this.pocket.constant=width/2+.05*s;this.setOpen(this.open);
}
setOpen(value:number):void {this.open=THREE.MathUtils.clamp(value,0,1);this.group.position.x=this.open===0?0:-this.width*this.open;}
dispose():void{this.group.removeFromParent();this.geometry.dispose();this.material.dispose();}
}
+2 -1
View File
@@ -11,8 +11,9 @@ export class PetDepthMesh extends THREE.Mesh<THREE.BufferGeometry, THREE.ShaderM
uDepthScale: { value: THREE.MathUtils.clamp(depthScale, 0, 0.4) },
uDepthBias: { value: depthBias },
uIsVideo: { value: false },
uUsePhotoAlpha: { value: false },
};
const material = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader });
const material = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader, alphaToCoverage: true });
super(geometry, material);
}
+5
View File
@@ -0,0 +1,5 @@
/** Shared physical chamber bounds for the camera and the fixed room scenery. */
export function roomComposition(width:number,height:number,glassZ:number) {
const scale=Math.min(width,height),halfWidth=Math.max(width/2+.95*scale,height*.55),unit=halfWidth/1.45;
return {halfWidth,unit,floor:-height/2-.24*scale,ceiling:Math.max(2.3,height/2+.73*scale),front:glassZ+.35*scale,rear:Math.max(4.2,glassZ+4.8*unit)};
}
+134
View File
@@ -0,0 +1,134 @@
import * as THREE from 'three';
import type { ThemeId } from '../ui/themes';
import type { WindowReveal } from './WindowReveal';
/** Original deterministic wood pixels. Across-grain variation is much faster than along-grain variation. */
function woodTexture(horizontal: boolean): THREE.DataTexture {
const width = 128, height = 1024;
const pixels = new Uint8Array(width * height * 4);
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
const u = x / width, v = y / height;
const bend = Math.sin(v * 9 + .8) * .018 + Math.sin(v * 23 + u * 5) * .004;
const grain = Math.sin((u + bend) * 185) * 3.8 + Math.sin((u + bend * .6) * 493) * 2;
const growth = Math.sin((u + bend) * 32 + Math.sin(v * 5) * .3) * 6;
const pores = Math.pow(Math.max(0, Math.sin((u + bend) * 791)), 18) * 6;
const noise = (Math.sin(x * 17.17 + y * 91.13) * 43758.5453) % 1;
const polished = Math.sin(u * Math.PI) * 5;
const shade = grain + growth - pores + noise * 1.5 + polished;
const i = (y * width + x) * 4;
pixels[i] = 109 + shade; pixels[i + 1] = 59 + shade * .66; pixels[i + 2] = 32 + shade * .4; pixels[i + 3] = 255;
}
const texture = new THREE.DataTexture(pixels, width, height, THREE.RGBAFormat);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.magFilter = THREE.LinearFilter; texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.generateMipmaps = true;
if (horizontal) { texture.center.set(.5, .5); texture.rotation = Math.PI / 2; }
texture.needsUpdate = true;
return texture;
}
export class WindowFinish {
private theme:ThemeId='wood';
private assetActive=false;
private readonly details = new THREE.Group();
private readonly textures = [woodTexture(false), woodTexture(true)];
private readonly wood: THREE.MeshStandardMaterial[][];
private readonly reveals: THREE.MeshStandardMaterial[];
private readonly materials: THREE.Material[] = [];
private readonly geometry: THREE.ExtrudeGeometry;
private readonly hardwareGeometry = new THREE.BoxGeometry(1, 1, 1);
private readonly screwGeometry = new THREE.CylinderGeometry(.5, .5, .2, 12);
private readonly beads: THREE.Mesh[];
private readonly grooves: THREE.Mesh[];
private readonly shadows: THREE.Mesh[];
private readonly panel: THREE.Mesh;
private readonly sill: THREE.Mesh;
private readonly latch: THREE.Group;
// Neutral room fill keeps linen/plaster honest; the warmer upper-right key
// supplies the daylight direction. Both lights also feed B's reveal tracer.
private readonly ambient = new THREE.AmbientLight(0xf4f6fa, 1.55);
private readonly light = new THREE.DirectionalLight(0xfff0df, 2.35);
private readonly frames: THREE.Mesh[];
private readonly reveal: WindowReveal;
private readonly boothMaterials: THREE.MeshBasicMaterial[];
private readonly boothReveal: THREE.MeshBasicMaterial;
constructor(scene: THREE.Scene, frames: THREE.Mesh[], reveal: WindowReveal,
boothMaterials: THREE.MeshBasicMaterial[], boothReveal: THREE.MeshBasicMaterial) {
this.frames = frames; this.reveal = reveal; this.boothMaterials = boothMaterials; this.boothReveal = boothReveal;
this.wood = this.textures.map(map => [0xd6c3b4, 0xe1caba, 0xa79988, 0xf0dcc6, 0xffffff, 0x9c8575].map(color =>
new THREE.MeshStandardMaterial({ color, map, bumpMap: map, bumpScale: .002, roughness: .43, metalness: 0 })));
this.reveals = this.textures.map(map => new THREE.MeshStandardMaterial({ color: 0xbca18b, map, roughness: .62, side: THREE.DoubleSide }));
const groove = new THREE.MeshBasicMaterial({ color: 0x382516 });
const shadow = new THREE.MeshBasicMaterial({ color: 0x382719, transparent: true, opacity: .18, depthWrite: false });
const brass = new THREE.MeshStandardMaterial({ color: 0x9e8147, roughness: .44, metalness: .65 });
const screw = new THREE.MeshStandardMaterial({ color: 0x625537, roughness: .58, metalness: .6 });
this.materials.push(...this.wood.flat(), ...this.reveals, groove, shadow, brass, screw);
const shape = new THREE.Shape(); shape.moveTo(-.46, -.46); shape.lineTo(.46, -.46); shape.lineTo(.46, .46); shape.lineTo(-.46, .46); shape.closePath();
this.geometry = new THREE.ExtrudeGeometry(shape, { depth: .8, bevelEnabled: true, bevelSize: .04, bevelThickness: .1, bevelSegments: 3, steps: 1 });
this.geometry.translate(0, 0, -.4);
// Extrusion UVs are in object space; map them into the same 0..1 face region as the frame boxes.
const uv = this.geometry.getAttribute('uv'); for (let i = 0; i < uv.count; i++) uv.setXY(i, uv.getX(i) + .5, uv.getY(i) + .5);
this.beads = Array.from({ length: 4 }, (_, i) => new THREE.Mesh(this.geometry, this.wood[i < 2 ? 0 : 1][4]));
this.grooves = Array.from({ length: 4 }, () => new THREE.Mesh(this.hardwareGeometry, groove));
this.shadows = Array.from({ length: 4 }, () => new THREE.Mesh(this.hardwareGeometry, shadow));
this.panel = new THREE.Mesh(this.geometry, this.wood[0][4]); this.panel.name = 'lower-door-panel';
this.sill = new THREE.Mesh(this.geometry, this.wood[1][4]); this.sill.name = 'sill';
this.latch = new THREE.Group(); this.latch.name = 'brass-latch';
const plate = new THREE.Mesh(this.geometry, brass); plate.scale.set(.025, .078, .007);
const catchPiece = new THREE.Mesh(this.geometry, brass); catchPiece.scale.set(.016, .032, .013); catchPiece.position.z = .009;
const slot = new THREE.Mesh(this.hardwareGeometry, groove); slot.scale.set(.003, .019, .002); slot.position.set(0, .002, .017);
this.latch.add(plate, catchPiece, slot);
for (const y of [-.028, .028]) { const pin = new THREE.Mesh(this.screwGeometry, screw); pin.rotation.x = Math.PI / 2; pin.scale.set(.005, .005, .005); pin.position.set(0, y, .006); this.latch.add(pin); }
this.details.add(...this.shadows, this.panel, this.sill, ...this.grooves, ...this.beads, this.latch);
this.light.position.set(3, 4, 5);
scene.add(this.details, this.ambient, this.light);
}
apply(theme: ThemeId): void {
this.theme=theme;
const wood = theme === 'wood';
this.frames.forEach((frame, i) => { frame.visible=!wood||!this.assetActive; frame.material = wood ? this.wood[i < 2 ? 0 : 1] : this.boothMaterials; });
this.reveal.meshes.forEach((mesh, i) => { mesh.material = wood ? this.reveals[i < 2 ? 0 : 1] : this.boothReveal; });
this.details.visible = wood&&!this.assetActive;
this.ambient.visible = this.light.visible = wood;
}
setAssetActive(active:boolean):void {this.assetActive=active;this.apply(this.theme);}
update(width: number, height: number, glassZ: number, thickness: number, depth: number): void {
const front = glassZ + depth * .78;
const bead = thickness * .3;
this.beads.forEach((mesh, i) => {
mesh.position.set(i < 2 ? (i === 0 ? -1 : 1) * (width + bead) / 2 : 0, i < 2 ? 0 : (i === 2 ? 1 : -1) * (height + bead) / 2, front);
mesh.scale.set(i < 2 ? bead : width + 2 * bead, i < 2 ? height : bead, depth * .23);
});
this.grooves.forEach((mesh, i) => {
mesh.position.copy(this.beads[i].position);
if (i < 2) mesh.position.x += (i === 0 ? -1 : 1) * bead * .63; else mesh.position.y += (i === 2 ? 1 : -1) * bead * .63;
mesh.position.z -= depth * .13;
mesh.scale.set(i < 2 ? thickness * .05 : width + 2 * thickness, i < 2 ? height : thickness * .05, .002);
});
this.shadows.forEach((mesh, i) => {
mesh.position.copy(this.beads[i].position);
if (i < 2) mesh.position.x += (i === 0 ? -1 : 1) * thickness * .65; else mesh.position.y += (i === 2 ? 1 : -1) * thickness * .65;
mesh.position.z = glassZ - depth * .48;
mesh.scale.set(i < 2 ? thickness * 1.65 : width + 2.7 * thickness, i < 2 ? height + thickness : thickness * 1.65, .002);
});
const panelHeight = Math.min(.16, height * .12);
this.panel.position.set(0, -(height / 2 + thickness + panelHeight / 2), glassZ + depth * .05);
this.panel.scale.set(width + thickness * 2, panelHeight, depth * .75);
this.sill.position.set(0, -(height / 2 + thickness + panelHeight), glassZ + depth * .26);
this.sill.scale.set(width + thickness * 2.5, thickness * .75, depth * 1.35);
this.latch.position.set((width + thickness) / 2, -height * .17, front + depth * .12);
this.latch.scale.setScalar(Math.min(width, height) / 1.1);
}
dispose(): void {
this.details.removeFromParent(); this.ambient.removeFromParent(); this.light.removeFromParent();
this.geometry.dispose(); this.hardwareGeometry.dispose(); this.screwGeometry.dispose();
this.materials.forEach(material => material.dispose()); this.textures.forEach(texture => texture.dispose());
}
}
+2 -1
View File
@@ -5,10 +5,11 @@ export class WindowReveal {
private readonly geometries: THREE.BufferGeometry[];
constructor(material: THREE.Material) {
this.geometries = Array.from({ length: 4 }, () => {
this.geometries = Array.from({ length: 4 }, (_, side) => {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(12), 3));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(side < 2 ? [0, 0, 0, 1, 1, 1, 1, 0] : [0, 0, 1, 0, 1, 1, 0, 1], 2));
geometry.computeVertexNormals();
return geometry;
});
+17
View File
@@ -0,0 +1,17 @@
export interface PaneRect { x: number; y: number; width: number; height: number }
/** Keep the projection aspect from entry. Letterboxing changes, photo crop does not. */
export function comparisonLayout(width: number, height: number, aspect: number): { panes: [PaneRect, PaneRect]; stacked: boolean } {
const stacked = width < 720;
const margin = 10, gap = stacked ? 38 : 16;
const top = Math.min(106, height * .22), bottom = Math.min(140, height * .28);
const areaW = Math.max(2, width - 2 * margin), areaH = Math.max(2, height - top - bottom);
const slotW = stacked ? areaW : (areaW - gap) / 2;
const slotH = stacked ? (areaH - gap) / 2 : areaH;
const paneW = Math.max(1, Math.min(slotW, slotH * aspect));
const paneH = paneW / aspect;
const x = margin + (slotW - paneW) / 2, y = top + (slotH - paneH) / 2;
return { panes: [{ x, y, width: paneW, height: paneH }, { x: x + (stacked ? 0 : slotW + gap), y: y + (stacked ? slotH + gap : 0), width: paneW, height: paneH }], stacked };
}
export function paneAt(panes: readonly PaneRect[], x: number, y: number): PaneRect | null {
return panes.find(p => x >= p.x && x <= p.x + p.width && y >= p.y && y <= p.y + p.height) ?? null;
}
+20
View File
@@ -0,0 +1,20 @@
export interface DrawCounts { calls: number; triangles: number }
interface PaneMetrics extends DrawCounts { cpuMs: number }
/** performance.now measures CPU submission only, not GPU execution. No gl.finish stall. */
export class ComparisonMetrics {
private values: Record<'a'|'b', PaneMetrics> = { a: {cpuMs:0,calls:0,triangles:0}, b:{cpuMs:0,calls:0,triangles:0} };
private previous: number | null = null;
private cadence: number | null = null;
record(pane: 'a'|'b', cpuMs: number, counts: DrawCounts): void {
this.values[pane] = { cpuMs, calls: counts.calls, triangles: counts.triangles };
}
frame(now: number): void {
if (this.previous !== null && now > this.previous) {
const interval=now-this.previous;
this.cadence=this.cadence === null ? interval : this.cadence*.9+interval*.1;
}
this.previous=now;
}
snapshot() { return { a:{...this.values.a}, b:{...this.values.b}, frameMs:this.cadence }; }
reset(): void { this.previous=null; this.cadence=null; }
}
+13
View File
@@ -0,0 +1,13 @@
/** Input is already sRGB straight-alpha bytes, bottom row first from WebGL. */
export function prepareExportPixels(bytes:Uint8Array,width:number,height:number,crop:boolean) {
if(!Number.isInteger(width)||!Number.isInteger(height)||width<1||height<1||bytes.length!==width*height*4)throw new Error('저장 이미지 크기가 올바르지 않아요.');
let left=0,right=width-1,bottom=0,top=height-1;
if(crop){
left=width;right=-1;bottom=height;top=-1;
for(let y=0;y<height;y++)for(let x=0;x<width;x++)if(bytes[(y*width+x)*4+3]>0){left=Math.min(left,x);right=Math.max(right,x);bottom=Math.min(bottom,y);top=Math.max(top,y);}
if(right<left)throw new Error('현재 구도에서는 사진이 보이지 않아요. 시점을 조금 바꿔 주세요.');
}
const w=right-left+1,h=top-bottom+1,data=new Uint8ClampedArray(w*h*4);
for(let y=0;y<h;y++)data.set(bytes.subarray(((top-y)*width+left)*4,((top-y)*width+right+1)*4),y*w*4);
return {width:w,height:h,data};
}
+210
View File
@@ -0,0 +1,210 @@
import * as THREE from 'three';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
import { GOBANG_GLASS } from '../constants/glass';
import { createPatternedGlassGeometry, sampleGobangSurface, type GlassShape } from './PatternedGlassGeometry';
import { attachMeshRefraction } from './MeshRefraction';
import type {ExperimentalCourtyard} from '../objects/ExperimentalCourtyard';
// r169 init recreates its lookup tables, so cache their CPU data once across toggles.
// dispose() below releases their GPU allocations each time B exits.
let areaLightTextures: THREE.Texture[] | null = null;
/** Default standalone B renderer. Actual glass vertices feed the explicitly custom exit/photo trace.
* PetDepthMesh's shared shader only displaces/colors the photo; it is not glass refraction.
*/
export class MeshGlassRenderer {
readonly scene = new THREE.Scene();
readonly material = new THREE.MeshPhysicalMaterial({
color:0xffffff, metalness:0, roughness:.04, transmission:1, opacity:1,
transparent:false, ior:GOBANG_GLASS.refractiveIndex, thickness:GOBANG_GLASS.thickness,
side:THREE.FrontSide, attenuationDistance:Infinity, envMapIntensity:.45,
});
readonly glass = new THREE.Mesh(new THREE.BufferGeometry(),this.material);
private readonly photo: THREE.Mesh;
private courtyard:ExperimentalCourtyard|null=null;
syncCourtyard(courtyard:ExperimentalCourtyard|null):void {
if(this.courtyard&&this.courtyard!==courtyard)this.courtyard.group.visible=false;
this.courtyard=courtyard;
this.refraction.uniforms.uYardEnabled.value=courtyard?1:0;
if(!courtyard)return;
for(const [name,uniform] of Object.entries(courtyard.uniforms)){
const target=(this.refraction.uniforms as unknown as Record<string,THREE.IUniform>)[name];if(target)target.value=uniform.value;
}
this.scene.add(courtyard.group);courtyard.group.visible=true;
}
private readonly refraction: ReturnType<typeof attachMeshRefraction>;
private backLocalZ=0;
private readonly pocketPlane=new THREE.Plane(new THREE.Vector3(1,0,0),1);
/** Translation is shared by rasterized entry vertices and analytic back/side exits. */
setSlide(offsetX:number):void { if(Number.isFinite(offsetX))this.glass.position.x=offsetX===0?0:offsetX; }
private output: THREE.WebGLRenderTarget | null = null;
private photoExport=false;
private readonly internalTargets = new Set<THREE.WebGLRenderTarget>();
private readonly light = new THREE.RectAreaLight(0xf0f7ff,3,1,1);
private readonly environment: THREE.DataTexture;
private signature='';
private thicknessMap: THREE.DataTexture | null=null;
private isDisposed=false;
private failed=false;
readonly environmentOnly: boolean;
reducedSampling=false;
triangleCount=0;
geometryBytes=0;
thicknessRange:[number,number]=[0,0];
constructor(private readonly renderer: THREE.WebGLRenderer, private readonly depthSource: THREE.Mesh) {
// Share the one uploaded RGB/depth and the existing 128×128 displaced surface.
this.photo=new THREE.Mesh(depthSource.geometry,depthSource.material);
this.photo.frustumCulled=false;
this.refraction=attachMeshRefraction(this.material,depthSource);
// r169 invokes this once in its stock opaque transmission prepass, then in B output.
// Capture via the public render-target API so exit can free that otherwise hidden target.
this.photo.onBeforeRender=()=>{
const target=renderer.getRenderTarget();
if(target && target!==this.output) this.internalTargets.add(target);
};
this.material.clippingPlanes=[this.pocketPlane];
this.scene.add(this.photo,this.glass,this.light);
if (!areaLightTextures) {
RectAreaLightUniformsLib.init();
const ltc=THREE.UniformsLib as unknown as Record<string,THREE.Texture>;
areaLightTextures=[ltc.LTC_FLOAT_1,ltc.LTC_FLOAT_2,ltc.LTC_HALF_1,ltc.LTC_HALF_2].filter(Boolean);
}
areaLightTextures.forEach(texture=>{texture.needsUpdate=true;});
// Pinned r169 has an upstream missing LTC_HALF_2 assignment. Do not use that
// branch on GPUs without float-linear sampling; retain the environment reflection.
this.environmentOnly=!renderer.extensions.has('OES_texture_float_linear');
this.light.visible=!this.environmentOnly;
this.environment=createRoomEnvironment();
this.scene.environment=this.environment;
}
sync(shape:GlassShape,glassZ:number,lightPosition:THREE.Vector3,background:THREE.Color):void {
this.scene.background=background;
this.photo.position.copy(this.depthSource.position);this.photo.quaternion.copy(this.depthSource.quaternion);this.photo.scale.copy(this.depthSource.scale);
this.photo.visible=true;
this.light.position.copy(lightPosition);this.light.lookAt(0,0,glassZ);
this.light.width=Math.min(shape.width,shape.height)*.76;this.light.height=Math.min(shape.width,shape.height)*1.04;
this.glass.position.z=glassZ;
this.pocketPlane.constant=shape.width/2+.05*Math.min(shape.width,shape.height);
const signature=[shape.width,shape.height,shape.grid,shape.curvature].join('|');
if(signature===this.signature){this.refraction.sync(shape,glassZ+this.backLocalZ,background,this.glass.position.x);return;}
const built=createPatternedGlassGeometry(shape);
this.backLocalZ=built.backZ;
this.glass.geometry.dispose();this.glass.geometry=built.geometry;
this.triangleCount=built.geometry.index!.count/3;this.reducedSampling=built.reducedSampling;
this.geometryBytes=Object.values(built.geometry.attributes).reduce((n,a)=>n+a.array.byteLength,0)+built.geometry.index!.array.byteLength;
this.thicknessRange=[built.minThickness,built.maxThickness];
const cell=Math.min(shape.width/shape.grid,shape.height/Math.max(1,shape.grid*shape.height/shape.width));
const maxThickness=GOBANG_GLASS.thickness+GOBANG_GLASS.heightPerCell*cell*(1+1.008*Math.abs(shape.curvature));
const width=Math.min(256,Math.max(32,Math.ceil(shape.grid*10))),height=Math.min(256,Math.max(32,Math.ceil(shape.grid*shape.height/shape.width*10)));
const data=new Uint8Array(width*height*4);
for(let y=0;y<height;y++)for(let x=0;x<width;x++) {
const thickness=sampleGobangSurface((x+.5)/width,(y+.5)/height,shape).height-built.backZ;
const i=(y*width+x)*4; data[i]=255;data[i+1]=Math.round(255*THREE.MathUtils.clamp(thickness/maxThickness,0,1));data[i+2]=255;data[i+3]=255;
}
this.thicknessMap?.dispose();this.thicknessMap=new THREE.DataTexture(data,width,height);
this.thicknessMap.minFilter=THREE.LinearFilter;this.thicknessMap.magFilter=THREE.LinearFilter;this.thicknessMap.needsUpdate=true;
this.material.thickness=maxThickness;this.material.thicknessMap=this.thicknessMap;this.material.needsUpdate=true;
this.signature=signature;
this.refraction.sync(shape,glassZ+this.backLocalZ,background,this.glass.position.x);
}
syncReveal(meshes:readonly THREE.Mesh[],scene:THREE.Scene):void {this.refraction.syncReveal(meshes,scene);}
render(camera:THREE.Camera,frameScene:THREE.Scene,showGlass:boolean,output:THREE.WebGLRenderTarget|null=null,showPhoto=true):void {
if(this.failed)throw new Error('Mesh refraction shader failed; retry or restore is required');
this.output=output; this.photo.visible=true;this.refraction.uniforms.uBShowPhoto.value=showPhoto;
this.glass.visible=showGlass;
if(this.courtyard)this.courtyard.group.visible=showPhoto&&!this.photoExport;
this.renderer.setScissorTest(false);this.renderer.setRenderTarget(output);
// Keep this shared photo drawable for the public onBeforeRender target hook,
// including Glass Only. Suppress its writes rather than visibility so the
// otherwise hidden Three transmission target still has a tracked owner.
const photoMaterial=this.photo.material as THREE.Material;
const colorWrite=photoMaterial.colorWrite,depthWrite=photoMaterial.depthWrite;
if(!showPhoto){photoMaterial.colorWrite=false;photoMaterial.depthWrite=false;}
let shaderFailed=false;
const previousShaderError=this.renderer.debug.onShaderError;
this.renderer.debug.onShaderError=(...args)=>{shaderFailed=true;previousShaderError?.(...args);};
try {this.renderer.render(this.scene,camera);}
catch(error){this.failed=true;throw error;}
finally {this.renderer.debug.onShaderError=previousShaderError;photoMaterial.colorWrite=colorWrite;photoMaterial.depthWrite=depthWrite;}
if(shaderFailed){this.failed=true;throw new Error('Mesh exit/photo refraction shader compilation failed');}
if (this.photoExport || (!showGlass && !(this.courtyard&&showPhoto))) return;
const autoClear=this.renderer.autoClear;
// The B pass already wrote the real pane/photo/courtyard depth. Keep it:
// rear reveal is already sampled by refraction and must not paint a second,
// straight-view wood silhouette over the pane. Near-side frame still wins
// its normal depth test, and uncovered sliding-door areas stay drawable.
try {this.renderer.autoClear=false;this.renderer.render(frameScene,camera);}
finally {this.renderer.autoClear=autoClear;}
}
/** Synchronous isolation only; never changes camera, geometry, slide or shared textures. */
renderForExport(camera:THREE.Camera,frameScene:THREE.Scene,output:THREE.WebGLRenderTarget,isolated:boolean):void {
const background=this.scene.background, previousOutput=this.output;
const previousExport=this.photoExport, previousUniform=this.refraction.uniforms.uBPhotoExport.value;
const photoMaterial=this.photo.material as THREE.Material;
const coverage=photoMaterial.alphaToCoverage;
const yardVisible=this.courtyard?.group.visible;
const autoClear=this.renderer.autoClear, frameBackground=frameScene.background;
const writes=new Map<THREE.Material,boolean>();
try {
this.photoExport=isolated;this.refraction.uniforms.uBPhotoExport.value=isolated;
if(isolated)this.scene.background=null;
photoMaterial.alphaToCoverage=false;
if(isolated){
// Retain current visibility: objects become transparent blockers, never
// reveal previously hidden photo or become colored pixels in the PNG.
frameScene.traverseVisible(object=>{
const materials=(object as THREE.Mesh).material;if(!materials)return;
for(const material of Array.isArray(materials)?materials:[materials]){if(!writes.has(material)){writes.set(material,material.colorWrite);material.colorWrite=false;}}
});
try {
frameScene.background=null;this.renderer.autoClear=true;
this.renderer.setScissorTest(false);this.renderer.setRenderTarget(output);
this.renderer.render(frameScene,camera);
} finally {for(const [material,value] of writes)material.colorWrite=value;frameScene.background=frameBackground;}
this.renderer.autoClear=false;
}
this.render(camera,frameScene,true,output,true);
} finally {
for(const [material,value] of writes)material.colorWrite=value;
frameScene.background=frameBackground;this.renderer.autoClear=autoClear;
this.scene.background=background;this.output=previousOutput;
this.photoExport=previousExport;this.refraction.uniforms.uBPhotoExport.value=previousUniform;
photoMaterial.alphaToCoverage=coverage;
if(this.courtyard&&yardVisible!==undefined)this.courtyard.group.visible=yardVisible;
}
}
restore():void {
this.failed=false;
// Three recreates programs/textures after context restore. Target contents
// are transient; keep source photo/depth and the chosen B implementation.
this.internalTargets.forEach(target=>target.dispose());this.internalTargets.clear();
this.material.needsUpdate=true;this.environment.needsUpdate=true;
if(this.thicknessMap)this.thicknessMap.needsUpdate=true;
areaLightTextures?.forEach(texture=>{texture.needsUpdate=true;});
}
dispose():void {
if(this.isDisposed)return;this.isDisposed=true;
if (!this.renderer.getContext().isContextLost()) {this.renderer.setRenderTarget(null);this.renderer.setScissorTest(false);}
this.internalTargets.forEach(t=>t.dispose());this.internalTargets.clear();
this.glass.geometry.dispose();this.material.dispose();this.thicknessMap?.dispose();this.environment.dispose();
areaLightTextures?.forEach(texture=>texture.dispose());
this.photo.onBeforeRender=()=>{};
this.scene.clear();
// Shared photo geometry/material/textures and the main renderer belong to main.ts.
}
}
function createRoomEnvironment():THREE.DataTexture {
const width=128,height=64,data=new Float32Array(width*height*4);
const dark=new THREE.Color().setRGB(.09,.13,.17),bright=new THREE.Color().setRGB(.78,.84,.86),mullion=new THREE.Color().setRGB(.12,.16,.20);
const color=new THREE.Color();
for(let y=0;y<height;y++)for(let x=0;x<width;x++) {
const latitude=((y+.5)/height-.5)*Math.PI,longitude=((x+.5)/width-.5)*Math.PI*2;
const dy=Math.sin(latitude),dx=Math.cos(latitude)*Math.cos(longitude);
color.copy(dark).lerp(bright,THREE.MathUtils.smoothstep(dy,-.4,.65));
color.lerp(mullion,(1-THREE.MathUtils.smoothstep(Math.abs(dx+.12),.015,.06))*.55);
const i=(y*width+x)*4;data[i]=color.r;data[i+1]=color.g;data[i+2]=color.b;data[i+3]=1;
}
const texture=new THREE.DataTexture(data,width,height,THREE.RGBAFormat,THREE.FloatType);
texture.mapping=THREE.EquirectangularReflectionMapping;texture.needsUpdate=true;
return texture;
}
+255
View File
@@ -0,0 +1,255 @@
import * as THREE from 'three';
import {courtyardShader,createCourtyardUniforms} from '../objects/ExperimentalCourtyard';
import type { GlassShape } from './PatternedGlassGeometry';
/** B-only extension. Entry comes from the rasterized glass mesh, not A's screen shader.
* The photo intersection traverses the same 128×128 triangles as PetDepthMesh.
* Direct RGB sampling at the hit UV also covers surfaces hidden in the camera buffer.
*/
export const meshRefractionShader = /* glsl */`
uniform sampler2D uBPhoto;
uniform sampler2D uBDepthMap;
uniform mat4 uBPhotoWorldToLocal;
uniform float uBDepthScale;
uniform float uBDepthBias;
uniform float uBPhotoSegments;
uniform float uBBackZ;
uniform vec2 uBPaneHalfSize;
uniform float uBPaneCenterX;
uniform vec3 uBBackground;
uniform bool uBIsVideo;
uniform bool uBUsePhotoAlpha;
${courtyardShader}
uniform bool uBShowPhoto;
uniform bool uBPhotoExport;
uniform bool uBRevealEnabled;
uniform vec3 uBRevealVertices[16];
uniform vec2 uBRevealUvs[16];
uniform vec3 uBRevealColors[4];
uniform float uBRevealUnlit[4];
uniform float uBRevealHasMap[4];
uniform sampler2D uBRevealMapVertical;
uniform sampler2D uBRevealMapHorizontal;
uniform mat3 uBRevealMapTransformVertical;
uniform mat3 uBRevealMapTransformHorizontal;
uniform vec3 uBRevealAmbient;
uniform vec3 uBRevealLightColor;
uniform vec3 uBRevealLightDirection;
float bRevealTriangle(vec3 origin,vec3 ray,int a,int b,int c,out vec2 uv) {
vec3 e1=uBRevealVertices[b]-uBRevealVertices[a],e2=uBRevealVertices[c]-uBRevealVertices[a];
vec3 p=cross(ray,e2);float det=dot(e1,p);
if(abs(det)<1e-9)return 1e20; // WindowReveal uses DoubleSide.
vec3 delta=origin-uBRevealVertices[a];float u=dot(delta,p)/det;
vec3 q=cross(delta,e1);float v=dot(ray,q)/det;
if(u<-.00001||v<-.00001||u+v>1.00001)return 1e20;
float t=dot(e2,q)/det;if(t<.000001)return 1e20;
uv=uBRevealUvs[a]*(1.-u-v)+uBRevealUvs[b]*u+uBRevealUvs[c]*v;
return t;
}
bool bRevealHit(vec3 origin,vec3 ray,out float distance,out vec3 color) {
distance=1e20;color=uBBackground;if(!uBRevealEnabled)return false;
for(int wall=0;wall<4;wall++) {
int i=wall*4;vec2 uv0,uv1;
float t0=bRevealTriangle(origin,ray,i,i+1,i+2,uv0),t1=bRevealTriangle(origin,ray,i,i+2,i+3,uv1);
float t=min(t0,t1);if(t>=distance)continue;
distance=t;vec2 uv=t0<t1?uv0:uv1;
vec3 texel=vec3(1.);
if(uBRevealHasMap[wall]>.5) {
if(wall<2)texel=textureLod(uBRevealMapVertical,(uBRevealMapTransformVertical*vec3(uv,1.)).xy,0.).rgb;
else texel=textureLod(uBRevealMapHorizontal,(uBRevealMapTransformHorizontal*vec3(uv,1.)).xy,0.).rgb;
}
vec3 n=normalize(cross(uBRevealVertices[i+1]-uBRevealVertices[i],uBRevealVertices[i+2]-uBRevealVertices[i]));
if(dot(n,ray)>0.)n=-n;
// Same diffuse map/tint and fixed room lights as the actual recess. This
// intentionally omits secondary glossy bounces; never invent photo pixels.
vec3 irradiance=uBRevealAmbient+uBRevealLightColor*max(0.,dot(n,uBRevealLightDirection));
color=texel*uBRevealColors[wall]*mix(irradiance/3.14159265359,vec3(1.),uBRevealUnlit[wall]);
}
return distance<1e19;
}
float bTriangle(vec3 origin, vec3 ray, vec3 a, vec3 b, vec3 c) {
vec3 e1=b-a,e2=c-a,p=cross(ray,e2);
float det=dot(e1,p);
// Match the photo's FrontSide material; no invented back surface.
if(det<=1e-9)return 1e20;
vec3 s=origin-a;float u=dot(s,p)/det;
if(u<-.00001||u>1.00001)return 1e20;
vec3 q=cross(s,e1);float v=dot(ray,q)/det;
if(v<-.00001||u+v>1.00001)return 1e20;
float t=dot(e2,q)/det;return t>=0.?t:1e20;
}
vec3 bPhotoVertex(vec2 grid) {
vec2 uv=grid/uBPhotoSegments;
// Explicit base LOD matches PetDepthMesh's vertex texture lookup. Implicit
// derivatives are undefined in divergent ray-traversal control flow.
float depth=textureLod(uBDepthMap,uv,0.).r;
return vec3(uv-.5,(depth-uBDepthBias)*uBDepthScale);
}
bool bSlab(float origin,float ray,float lo,float hi,inout float nearT,inout float farT) {
if(abs(ray)<1e-9)return origin>=lo&&origin<=hi;
float a=(lo-origin)/ray,b=(hi-origin)/ray;
nearT=max(nearT,min(a,b));farT=min(farT,max(a,b));return farT>=nearT;
}
float bPhotoCoverageWeight(float coverage,float alpha){return (1.-coverage)*alpha;}
bool bPhotoHit(vec3 worldOrigin,vec3 worldRay,out vec2 hitUv,out float hitDistance,out vec4 hitColor,float backgroundDistance) {
hitDistance=1e20;hitColor=vec4(0.);float lastDistance=-1.;
vec3 origin=(uBPhotoWorldToLocal*vec4(worldOrigin,1.)).xyz;
vec3 ray=(uBPhotoWorldToLocal*vec4(worldRay,0.)).xyz;
float nearT=0.,farT=1e20;
float lo=-uBDepthBias*uBDepthScale,hi=(1.-uBDepthBias)*uBDepthScale;
if(!bSlab(origin.x,ray.x,-.5,.5,nearT,farT)||
!bSlab(origin.y,ray.y,-.5,.5,nearT,farT)||
!bSlab(origin.z,ray.z,min(lo,hi),max(lo,hi),nearT,farT))return false;
vec2 point=(origin+ray*nearT).xy;
vec2 direction=sign(ray.xy);
vec2 cell=clamp(floor((point+.5)*uBPhotoSegments+direction*.00001),vec2(0.),vec2(uBPhotoSegments-1.));
vec2 nextT=vec2(1e20),delta=vec2(1e20);
if(abs(ray.x)>=1e-9){nextT.x=((cell.x+step(0.,ray.x))/uBPhotoSegments-.5-origin.x)/ray.x;delta.x=1./(uBPhotoSegments*abs(ray.x));}
if(abs(ray.y)>=1e-9){nextT.y=((cell.y+step(0.,ray.y))/uBPhotoSegments-.5-origin.y)/ray.y;delta.y=1./(uBPhotoSegments*abs(ray.y));}
// A ray crosses at most 128+128+1 cells. This is exact triangle traversal,
// not fixed-distance ray marching that can skip a narrow foreground feature.
for(int iteration=0;iteration<257;iteration++) {
vec3 ll=bPhotoVertex(cell),lr=bPhotoVertex(cell+vec2(1.,0.));
vec3 ul=bPhotoVertex(cell+vec2(0.,1.)),ur=bPhotoVertex(cell+vec2(1.,1.));
// PlaneGeometry's diagonal is lower-left → upper-right.
float t0=bTriangle(origin,ray,ul,ll,ur),t1=bTriangle(origin,ray,ll,lr,ur);
float endT=min(farT,min(nextT.x,nextT.y));
for(int sampleIndex=0;sampleIndex<2;sampleIndex++){
float t=sampleIndex==0?min(t0,t1):max(t0,t1);
if(t>=nearT-.00001&&t<=endT+.00001&&t<backgroundDistance&&t<1e19&&t>lastDistance+.00001){
vec2 candidate=clamp((origin+ray*t).xy+.5,vec2(0.),vec2(1.));
vec4 sampleColor=textureLod(uBPhoto,candidate,0.);
float alpha=uBUsePhotoAlpha?sampleColor.a:1.;
if(alpha>.003){
if(uBIsVideo)sampleColor.rgb=mix(sampleColor.rgb/12.92,pow((sampleColor.rgb+.055)/1.055,vec3(2.4)),step(vec3(.04045),sampleColor.rgb));
float weight=bPhotoCoverageWeight(hitColor.a,alpha);
hitColor.rgb+=sampleColor.rgb*weight;hitColor.a+=weight;
if(hitDistance>1e19){hitDistance=t;hitUv=candidate;}
if(hitColor.a>=.999)return true;
}
lastDistance=t;
}
}
if(endT>=farT-.000001||endT>=backgroundDistance)return hitColor.a>0.;
bool crossX=nextT.x<=nextT.y;
bool crossY=nextT.y<=nextT.x;
nearT=endT;
if(crossX){cell.x+=direction.x;nextT.x+=delta.x;}
if(crossY){cell.y+=direction.y;nextT.y+=delta.y;}
if(any(lessThan(cell,vec2(0.)))||any(greaterThanEqual(cell,vec2(uBPhotoSegments))))return hitColor.a>0.;
}
return hitColor.a>0.;
}
bool bExitGlass(vec3 entry,vec3 ray,out vec3 exitPoint,out vec3 inwardNormal) {
float distance=1e20;inwardNormal=vec3(0.,0.,1.);
if(ray.z< -1e-9){float t=(uBBackZ-entry.z)/ray.z;if(t>1e-7)distance=t;}
if(abs(ray.x)>1e-9){float side=sign(ray.x),t=(side*uBPaneHalfSize.x+uBPaneCenterX-entry.x)/ray.x;if(t>1e-7&&t<distance){distance=t;inwardNormal=vec3(-side,0.,0.);}}
if(abs(ray.y)>1e-9){float side=sign(ray.y),t=(side*uBPaneHalfSize.y-entry.y)/ray.y;if(t>1e-7&&t<distance){distance=t;inwardNormal=vec3(0.,-side,0.);}}
if(distance>=1e19)return false;
exitPoint=entry+ray*distance;return true;
}
vec4 bTracePhotoRay(vec3 entry,vec3 normal,vec3 viewDirection,float ior) {
if(!uBShowPhoto)return vec4(uBBackground,1.);
vec3 internalRay=refract(-viewDirection,normalize(normal),1./ior);
vec3 exitPoint,inwardNormal;
if(!bExitGlass(entry,internalRay,exitPoint,inwardNormal))return uBPhotoExport?vec4(0.):vec4(uBBackground,1.);
vec3 outgoing=refract(internalRay,inwardNormal,ior);
if(dot(outgoing,outgoing)<1e-8)return uBPhotoExport?vec4(0.):vec4(0.,0.,0.,-1.);
vec2 hitUv;float photoDistance,revealDistance;vec3 revealColor;
bool revealHit=bRevealHit(exitPoint,outgoing,revealDistance,revealColor);
float backgroundDistance=revealHit?revealDistance:1e20;
vec3 backgroundColor=revealHit?revealColor:uBBackground;
float yardDistance;vec3 yardColor;
if(bCourtyardHit(exitPoint,outgoing,yardDistance,yardColor) && yardDistance<backgroundDistance){backgroundDistance=yardDistance;backgroundColor=yardColor;}
vec4 photoColor;
bool photoHit=bPhotoHit(exitPoint,outgoing,hitUv,photoDistance,photoColor,backgroundDistance);
// Keep existing blockers so isolation never invents photo behind a reveal.
if(uBPhotoExport) {
if(!photoHit || backgroundDistance<photoDistance || photoColor.a<=.003)return vec4(0.);
return vec4(photoColor.rgb/photoColor.a,photoColor.a);
}
if(!photoHit || backgroundDistance<photoDistance)return vec4(backgroundColor,1.);
return vec4(photoColor.rgb+backgroundColor*(1.-photoColor.a),1.);
}
`;
const transmissionFragment = /* glsl */`
#ifdef USE_TRANSMISSION
material.transmission=transmission;
material.transmissionAlpha=1.;
vec3 bView=normalize(cameraPosition-vWorldPosition);
vec3 bNormal=inverseTransformDirection(normal,viewMatrix);
vec4 bLight=bTracePhotoRay(vWorldPosition,bNormal,bView,ior);
if(bLight.a<0.) {
// Outside current shallow-pattern viewing limits: a conservative single
// environment-reflection fallback for TIR, not a fabricated photo hit.
totalDiffuse=vec3(0.);
#ifdef USE_ENVMAP
totalSpecular=max(totalSpecular,getIBLRadiance(geometryViewDir,normal,material.roughness));
#endif
} else {
vec3 bFresnel=EnvironmentBRDF(bNormal,bView,material.specularColor,material.specularF90,material.roughness);
totalDiffuse=mix(totalDiffuse,(1.-bFresnel)*bLight.rgb,material.transmission);
}
#endif
`;
export function attachMeshRefraction(material: THREE.MeshPhysicalMaterial, source: THREE.Mesh) {
const uniforms = {
...createCourtyardUniforms(),
uBPhoto: {value:null as THREE.Texture|null}, uBDepthMap:{value:null as THREE.Texture|null},
uBPhotoWorldToLocal:{value:new THREE.Matrix4()},uBDepthScale:{value:0},uBDepthBias:{value:0},
uBPhotoSegments:{value:128},uBBackZ:{value:0},uBPaneHalfSize:{value:new THREE.Vector2()},
uBRevealEnabled:{value:false},
uBRevealVertices:{value:Array.from({length:16},()=>new THREE.Vector3())},uBRevealUvs:{value:Array.from({length:16},()=>new THREE.Vector2())},
uBRevealColors:{value:Array.from({length:4},()=>new THREE.Color())},uBRevealUnlit:{value:[0,0,0,0]},uBRevealHasMap:{value:[0,0,0,0]},
uBRevealMapVertical:{value:null as THREE.Texture|null},uBRevealMapHorizontal:{value:null as THREE.Texture|null},
uBRevealMapTransformVertical:{value:new THREE.Matrix3()},uBRevealMapTransformHorizontal:{value:new THREE.Matrix3()},
uBRevealAmbient:{value:new THREE.Color(0,0,0)},uBRevealLightColor:{value:new THREE.Color(0,0,0)},uBRevealLightDirection:{value:new THREE.Vector3()},
uBPaneCenterX:{value:0},uBBackground:{value:new THREE.Color()},uBIsVideo:{value:false},uBUsePhotoAlpha:{value:false},uBShowPhoto:{value:true},uBPhotoExport:{value:false},
};
material.onBeforeCompile=shader=>{
Object.assign(shader.uniforms,uniforms);
if(!shader.fragmentShader.includes('#include <transmission_pars_fragment>')||!shader.fragmentShader.includes('#include <transmission_fragment>'))throw new Error('Unsupported Three transmission shader');
shader.fragmentShader=shader.fragmentShader
.replace('#include <transmission_pars_fragment>','#include <transmission_pars_fragment>\n'+meshRefractionShader)
.replace('#include <transmission_fragment>',transmissionFragment)
.replace('#include <opaque_fragment>', '#include <opaque_fragment>\n#ifdef USE_TRANSMISSION\nif(uBPhotoExport){gl_FragColor.a = clamp(bLight.a,0.,1.);if(gl_FragColor.a==0.)gl_FragColor.rgb=vec3(0.);}\n#endif');
};
material.customProgramCacheKey=()=> 'mesh-exit-photo-reveal-courtyard-export-v5';
const lightPosition=new THREE.Vector3(),lightTarget=new THREE.Vector3();
return {uniforms,syncReveal(meshes:readonly THREE.Mesh[],scene:THREE.Scene):void {
uniforms.uBRevealEnabled.value=meshes.length===4&&meshes.every(mesh=>mesh.geometry?.getAttribute('position')?.count===4);
if(!uniforms.uBRevealEnabled.value)return;
for(let wall=0;wall<4;wall++) {
const mesh=meshes[wall],p=mesh.geometry.getAttribute('position'),uv=mesh.geometry.getAttribute('uv');mesh.updateWorldMatrix(true,false);
for(let i=0;i<4;i++){uniforms.uBRevealVertices.value[wall*4+i].fromBufferAttribute(p,i).applyMatrix4(mesh.matrixWorld);uniforms.uBRevealUvs.value[wall*4+i].set(uv.getX(i),uv.getY(i));}
const material=mesh.material as THREE.MeshStandardMaterial;
uniforms.uBRevealColors.value[wall].copy(material.color);
uniforms.uBRevealUnlit.value[wall]=(material as unknown as THREE.MeshBasicMaterial).isMeshBasicMaterial?1:0;
uniforms.uBRevealHasMap.value[wall]=material.map?1:0;
if(wall===0||wall===2){const map=material.map;map?.updateMatrix();
(wall===0?uniforms.uBRevealMapVertical:uniforms.uBRevealMapHorizontal).value=map??(source.material as THREE.ShaderMaterial).uniforms.uTexture.value;
(wall===0?uniforms.uBRevealMapTransformVertical:uniforms.uBRevealMapTransformHorizontal).value.copy(map?.matrix??new THREE.Matrix3());
}
}
uniforms.uBRevealAmbient.value.setRGB(0,0,0);uniforms.uBRevealLightColor.value.setRGB(0,0,0);
scene.traverseVisible(object=>{
if(object instanceof THREE.AmbientLight)uniforms.uBRevealAmbient.value.setRGB(uniforms.uBRevealAmbient.value.r+object.color.r*object.intensity,uniforms.uBRevealAmbient.value.g+object.color.g*object.intensity,uniforms.uBRevealAmbient.value.b+object.color.b*object.intensity);
if(object instanceof THREE.DirectionalLight){uniforms.uBRevealLightColor.value.copy(object.color).multiplyScalar(object.intensity);object.getWorldPosition(lightPosition);object.target.getWorldPosition(lightTarget);uniforms.uBRevealLightDirection.value.copy(lightPosition).sub(lightTarget).normalize();}
});
},sync(shape:GlassShape,backZ:number,background:THREE.Color,paneCenterX=0):void {
const u=(source.material as THREE.ShaderMaterial).uniforms;
source.updateWorldMatrix(true,false);
uniforms.uBPhotoWorldToLocal.value.copy(source.matrixWorld).invert();
uniforms.uBPhoto.value=u.uTexture.value;uniforms.uBDepthMap.value=u.uDepthMap.value;
uniforms.uBDepthScale.value=u.uDepthScale.value;uniforms.uBDepthBias.value=u.uDepthBias.value;
uniforms.uBIsVideo.value=u.uIsVideo?.value ?? false;
uniforms.uBUsePhotoAlpha.value=u.uUsePhotoAlpha?.value ?? false;
uniforms.uBPaneCenterX.value=paneCenterX;
uniforms.uBBackZ.value=backZ;uniforms.uBPaneHalfSize.value.set(shape.width*.5,shape.height*.5);
uniforms.uBBackground.value.copy(background);
}};
}
+155
View File
@@ -0,0 +1,155 @@
import * as THREE from 'three';
import mosaicShader from '../shaders/mosaic.frag?raw';
import reededShader from '../shaders/reeded.frag?raw';
import frostedShader from '../shaders/frosted.frag?raw';
import glassScreenVertex from '../shaders/glassScreen.vert?raw';
import regionPaletteShader from '../shaders/region-palette.frag?raw';
import regionHistoryShader from '../shaders/region-history.frag?raw';
interface OriginalState {mode:string;active:THREE.Texture;activeIsVideo:boolean;effect:string;style:string}
interface OriginalOptions {
renderer:THREE.WebGLRenderer;scene:THREE.Scene;frameScene:THREE.Scene;camera:THREE.Camera;
petDepth:THREE.Mesh;uniforms:Record<string,THREE.IUniform>;depthUniforms:Record<string,THREE.IUniform>;
emptyTex:THREE.Texture;regionHistoryUniform:THREE.IUniform;state:()=>OriginalState;
}
/** Preserved A screen-space optics. Created only for an explicit diagnostic/A-B request. */
export function createOriginalGlassRenderer(options:OriginalOptions) {
const {renderer,scene,frameScene,camera,petDepth,uniforms,depthUniforms,emptyTex,regionHistoryUniform,state}=options;
const glassScene=new THREE.Scene();
const glassMat = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader });
const glass = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), glassMat);
glassScene.add(glass);
const atlasScene = new THREE.Scene();
const atlasMaterial = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader, defines: { REGION_ATLAS: 1 }, toneMapped: false });
const atlasMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), atlasMaterial);
atlasScene.add(atlasMesh);
const paletteScene = new THREE.Scene();
const paletteUniforms = { uRegionAtlas: { value: null as THREE.Texture | null }, uRegionAtlasSize: uniforms.uRegionAtlasSize, uRegionGridSize: uniforms.uRegionGridSize };
const paletteMaterial = new THREE.ShaderMaterial({ uniforms: paletteUniforms, vertexShader: glassScreenVertex, fragmentShader: regionPaletteShader, toneMapped: false });
const paletteMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), paletteMaterial);
paletteScene.add(paletteMesh);
const postCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderTarget = new THREE.WebGLRenderTarget(1, 1, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: true });
renderTarget.depthTexture = new THREE.DepthTexture(1, 1, THREE.UnsignedIntType);
renderTarget.depthTexture.format = THREE.DepthFormat;
uniforms.uSceneDepth.value = renderTarget.depthTexture;
const regionAtlasTarget = new THREE.WebGLRenderTarget(104, 104, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: false, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter });
const regionPaletteTarget = new THREE.WebGLRenderTarget(65, 13, { colorSpace: THREE.NoColorSpace, type: THREE.UnsignedByteType, depthBuffer: false, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter });
const splitHistoryTargets = [0, 1].map(() => new THREE.WebGLRenderTarget(104, 104, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: false, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter }));
const historyScene = new THREE.Scene();
const historyUniforms = { uRegionPalette: { value: regionPaletteTarget.texture }, uPreviousHistory: { value: splitHistoryTargets[0].texture }, uRegionAtlasSize: uniforms.uRegionAtlasSize, uRegionGridSize: uniforms.uRegionGridSize, uRegionPaletteSize: uniforms.uRegionPaletteSize, uHistoryBlend: { value: 1 } };
const historyMaterial = new THREE.ShaderMaterial({ uniforms: historyUniforms, vertexShader: glassScreenVertex, fragmentShader: regionHistoryShader, toneMapped: false });
const historyMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), historyMaterial);
historyScene.add(historyMesh);
uniforms.uRegionPalette.value = regionPaletteTarget.texture;
paletteUniforms.uRegionAtlas.value = regionAtlasTarget.texture;
let regionSignature = '';
let splitHistorySignature = '';
let splitHistoryRead = 0;
let splitHistoryInitialized = false;
function updateRegionTargetSizes(): void {
const gridX = Math.max(1, Math.ceil(uniforms.uGridSize.value));
const gridYValue = Math.max(1, uniforms.uGridSize.value * uniforms.uPaneSize.value.y / Math.max(uniforms.uPaneSize.value.x, 0.001));
const gridY = Math.ceil(gridYValue);
const atlasWidth = gridX * 8, atlasHeight = gridY * 8, paletteWidth = gridX * 5, paletteHeight = gridY;
const available = Math.max(atlasWidth, atlasHeight, paletteWidth, paletteHeight) <= renderer.capabilities.maxTextureSize;
const changed = uniforms.uRegionAvailable.value !== (available ? 1 : 0) || (available && (regionAtlasTarget.width !== atlasWidth || regionAtlasTarget.height !== atlasHeight || regionPaletteTarget.width !== paletteWidth || regionPaletteTarget.height !== paletteHeight || splitHistoryTargets[0].width !== atlasWidth || splitHistoryTargets[0].height !== atlasHeight)) || uniforms.uRegionGridSize.value.x !== uniforms.uGridSize.value || uniforms.uRegionGridSize.value.y !== gridYValue;
if (available && (regionAtlasTarget.width !== atlasWidth || regionAtlasTarget.height !== atlasHeight)) regionAtlasTarget.setSize(atlasWidth, atlasHeight);
if (available && (regionPaletteTarget.width !== paletteWidth || regionPaletteTarget.height !== paletteHeight)) regionPaletteTarget.setSize(paletteWidth, paletteHeight);
for (const target of splitHistoryTargets) if (available && (target.width !== atlasWidth || target.height !== atlasHeight)) target.setSize(atlasWidth, atlasHeight);
uniforms.uRegionAvailable.value = available ? 1 : 0;
uniforms.uRegionAtlasSize.value.set(available ? atlasWidth : regionAtlasTarget.width, available ? atlasHeight : regionAtlasTarget.height);
uniforms.uRegionGridSize.value.set(uniforms.uGridSize.value, gridYValue);
uniforms.uRegionPaletteSize.value.set(available ? paletteWidth : regionPaletteTarget.width, available ? paletteHeight : regionPaletteTarget.height);
paletteUniforms.uRegionAtlas.value = regionAtlasTarget.texture;
uniforms.uRegionPalette.value = regionPaletteTarget.texture;
uniforms.uRegionAtlas.value = regionAtlasTarget.texture;
if (changed) regionSignature = '';
}
function currentRegionSignature(): string {
const {mode,active,activeIsVideo,effect,style}=state();
const view = uniforms.uContentViewProjection.value.elements.map((value: number) => value.toFixed(4)).join(',');
const object = petDepth.matrixWorld.elements.map((value: number) => value.toFixed(4)).join(',');
const depth = depthUniforms.uDepthMap.value;
return [mode, active.uuid, depth.uuid, view, object, uniforms.uGridSize.value, uniforms.uStrength.value, uniforms.uCurvature.value, uniforms.uMosaicStyle.value, uniforms.uPaneSize.value.x, uniforms.uPaneSize.value.y, depthUniforms.uDepthScale.value, depthUniforms.uDepthBias.value, renderTarget.width, renderTarget.height, regionAtlasTarget.width, regionAtlasTarget.height].join('|');
}
function currentSplitHistorySignature(): string {
const {mode,active,effect,style}=state();
const object = petDepth.matrixWorld.elements.map((value: number) => value.toFixed(4)).join(',');
const depth = depthUniforms.uDepthMap.value;
return [mode, effect, style, active.uuid, depth.uuid, uniforms.uRegionAvailable.value, uniforms.uGridSize.value, uniforms.uMosaicStyle.value, uniforms.uStrength.value, uniforms.uRegionStrength.value, uniforms.uCurvature.value, uniforms.uGlassZ.value, uniforms.uSourceZ.value, uniforms.uPaneSize.value.x, uniforms.uPaneSize.value.y, object, depthUniforms.uDepthScale.value, depthUniforms.uDepthBias.value, renderTarget.width, renderTarget.height, regionAtlasTarget.width, regionAtlasTarget.height, regionPaletteTarget.width, regionPaletteTarget.height].join('|');
}
function clearSplitHistory(): void {
const previousTarget = renderer.getRenderTarget();
const clearColor = renderer.getClearColor(new THREE.Color());
const clearAlpha = renderer.getClearAlpha();
renderer.setClearColor(0x000000, 0);
for (const target of splitHistoryTargets) {
renderer.setRenderTarget(target);
renderer.clear(true, false, false);
}
renderer.setRenderTarget(previousTarget);
renderer.setClearColor(clearColor, clearAlpha);
splitHistoryRead = 0;
splitHistoryInitialized = false;
regionHistoryUniform.value = splitHistoryTargets[0].texture;
}
function updateSplitHistory(dt: number): void {
const {mode,effect,style}=state();
const signature = currentSplitHistorySignature();
if (signature !== splitHistorySignature) {
clearSplitHistory();
splitHistorySignature = signature;
}
const enabled = (mode === 'final' || mode === 'glass') && effect === 'mosaic' && style === '0' && uniforms.uRegionAvailable.value > 0.5 && uniforms.uRegionStrength.value > 0.001 && uniforms.uStrength.value > 0.001;
if (!enabled) return;
const writeIndex = 1 - splitHistoryRead;
historyUniforms.uPreviousHistory.value = splitHistoryTargets[splitHistoryRead].texture;
historyUniforms.uHistoryBlend.value = splitHistoryInitialized ? 1 - Math.exp(-dt / 0.07) : 1;
renderer.setRenderTarget(splitHistoryTargets[writeIndex]);
renderer.render(historyScene, postCamera);
renderer.setRenderTarget(null);
splitHistoryRead = writeIndex;
splitHistoryInitialized = true;
regionHistoryUniform.value = splitHistoryTargets[splitHistoryRead].texture;
}
function updateRegionPalette(): void {
const {effect,style,activeIsVideo}=state();
const enabled = effect === 'mosaic' && style === '0' && uniforms.uRegionAvailable.value > 0.5 && uniforms.uRegionStrength.value > 0.001 && uniforms.uStrength.value > 0.001;
if (!enabled) return;
const signature = currentRegionSignature();
if (!activeIsVideo && signature === regionSignature) return;
renderer.setRenderTarget(regionAtlasTarget);
renderer.render(atlasScene, postCamera);
renderer.setRenderTarget(regionPaletteTarget);
renderer.render(paletteScene, postCamera);
renderer.setRenderTarget(null);
regionSignature = signature;
}
function render(dt: number, output: THREE.WebGLRenderTarget | null = null): void {
const {mode}=state();
if (mode === 'final' || mode === 'glass') {
renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); uniforms.uSceneTexture.value = renderTarget.texture;
updateRegionPalette();
updateSplitHistory(dt);
renderer.setRenderTarget(output); renderer.render(glassScene, postCamera);
const previousAutoClear = renderer.autoClear; renderer.autoClear = false; renderer.clearDepth(); renderer.render(frameScene, camera); renderer.autoClear = previousAutoClear;
} else { updateSplitHistory(dt); renderer.setRenderTarget(output); renderer.render(scene, camera); }
}
let disposed=false;
return {
render,
setEffect(effect:string):void {glassMat.fragmentShader=effect==='reeded'?reededShader:effect==='frosted'?frostedShader:mosaicShader;glassMat.needsUpdate=true;},
resize(width:number,height:number):void {renderTarget.setSize(width,height);updateRegionTargetSizes();},
invalidate():void {regionSignature = ''; splitHistorySignature = ''; splitHistoryInitialized = false;},
dispose():void {
if(disposed)return;disposed=true;
renderTarget.dispose();regionAtlasTarget.dispose();regionPaletteTarget.dispose();splitHistoryTargets.forEach(target=>target.dispose());
for(const mesh of [glass,atlasMesh,paletteMesh,historyMesh])mesh.geometry.dispose();
for(const material of [glassMat,atlasMaterial,paletteMaterial,historyMaterial])material.dispose();
glassScene.clear();atlasScene.clear();paletteScene.clear();historyScene.clear();
}
};
}
export type OriginalGlassRenderer=ReturnType<typeof createOriginalGlassRenderer>;
+74
View File
@@ -0,0 +1,74 @@
import * as THREE from 'three';
import { GOBANG_GLASS } from '../constants/glass';
export interface GlassShape { width: number; height: number; grid: number; curvature: number }
function profile(coordinate: number): [number,number] {
const span=.5-GOBANG_GLASS.flatHalfWidth, rounding=GOBANG_GLASS.bevelRound;
const t=THREE.MathUtils.clamp((Math.abs(coordinate)-GOBANG_GLASS.flatHalfWidth)/span,0,1);
let 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; }
return [1-ramp/(1-rounding),-slope*Math.sign(coordinate)/(span*(1-rounding))];
}
/** Exact CPU counterpart of A's gobangSurface; normals derive from this same height. */
export function sampleGobangSurface(u: number, v: number, shape: GlassShape) {
const gx=shape.grid, gy=Math.max(1,gx*shape.height/shape.width), cell=Math.min(shape.width/gx,shape.height/gy);
const [px,dx]=profile(((u*gx)%1+1)%1-.5), [py,dy]=profile(((v*gy)%1+1)%1-.5);
const x=u*gx*Math.PI*2,y=v*gy*Math.PI*2, phase=x*3+Math.sin(y);
const ripple=.008*Math.sin(phase)*Math.sin(y);
const rx=.008*Math.PI*2*3*Math.cos(phase)*Math.sin(y);
const ry=.008*Math.PI*2*(Math.cos(phase)*Math.cos(y)*Math.sin(y)+Math.sin(phase)*Math.cos(y));
const amplitude=GOBANG_GLASS.heightPerCell*cell*THREE.MathUtils.clamp(shape.curvature,-1,1);
return { height:amplitude*(.25*px+.55*py+.20*px*py+ripple), dx:amplitude*((.25+.20*py)*dx+rx)/(shape.width/gx), dy:amplitude*((.55+.20*px)*dy+ry)/(shape.height/gy) };
}
function axis(cells: number, maxSegments: number): number[] {
// Include plateau and rounded-ridge breakpoints, rather than missing them on a coarse uniform grid.
const knots=[0,.043,.387,.43,.57,.613,.957];
if(Math.ceil(cells)*knots.length>maxSegments) return Array.from({length:maxSegments+1},(_,i)=>i/maxSegments);
const result:number[]=[];
for(let cell=0;cell<Math.ceil(cells);cell++) for(const k of knots) { const p=(cell+k)/cells; if(p<1) result.push(p); }
result.push(1); return result;
}
export function createPatternedGlassGeometry(shape: GlassShape) {
if (![shape.width,shape.height,shape.grid,shape.curvature].every(Number.isFinite) || shape.width<=0 || shape.height<=0 || shape.grid<1) throw new Error('Invalid glass dimensions');
const cell=Math.min(shape.width/shape.grid,shape.height/Math.max(1,shape.grid*shape.height/shape.width)), backZ=-GOBANG_GLASS.thickness-GOBANG_GLASS.heightPerCell*cell;
const gridY=Math.max(1,shape.grid*shape.height/shape.width);
const ratio=shape.height/shape.width;
const nx=Math.max(8,Math.min(240,Math.floor(Math.sqrt(40000/ratio))));
const ny=Math.max(8,Math.min(240,Math.floor(40000/nx)));
const xs=axis(shape.grid,nx), ys=axis(gridY,ny), width=xs.length;
const positions:number[]=[],normals:number[]=[],uvs:number[]=[],indices:number[]=[];
let minThickness=Infinity,maxThickness=0;
function vertex(x:number,y:number,z:number,nx:number,ny:number,nz:number,u:number,v:number):number {
const index=positions.length/3; positions.push(x,y,z);normals.push(nx,ny,nz);uvs.push(u,v);return index;
}
for(const v of ys) for(const u of xs) {
const s=sampleGobangSurface(u,v,shape),n=new THREE.Vector3(-s.dx,-s.dy,1).normalize();
vertex((u-.5)*shape.width,(v-.5)*shape.height,s.height,n.x,n.y,n.z,u,v);
const thickness=s.height-backZ;minThickness=Math.min(minThickness,thickness);maxThickness=Math.max(maxThickness,thickness);
}
for(let y=0;y<ys.length-1;y++)for(let x=0;x<xs.length-1;x++){ const a=y*width+x,b=a+1,c=a+width,d=c+1;indices.push(a,b,d,a,d,c); }
// CCW front perimeter. Duplicate seams retain the distinct side and rear normals.
const border:number[]=[];
for(let x=0;x<width;x++)border.push(x);
for(let y=1;y<ys.length;y++)border.push(y*width+width-1);
for(let x=width-2;x>=0;x--)border.push((ys.length-1)*width+x);
for(let y=ys.length-2;y>0;y--)border.push(y*width);
const rearCenter=vertex(0,0,backZ,0,0,-1,.5,.5);
const rear=border.map(i=>vertex(positions[i*3],positions[i*3+1],backZ,0,0,-1,uvs[i*2],uvs[i*2+1]));
for(let k=0;k<border.length;k++) {
const next=(k+1)%border.length,a=border[k],b=border[next];
indices.push(rearCenter,rear[next],rear[k]);
const ax=positions[a*3],ay=positions[a*3+1],bx=positions[b*3],by=positions[b*3+1];
const n=new THREE.Vector3(by-ay,ax-bx,0).normalize();
const p=vertex(ax,ay,positions[a*3+2],n.x,n.y,0,uvs[a*2],uvs[a*2+1]);
const q=vertex(bx,by,positions[b*3+2],n.x,n.y,0,uvs[b*2],uvs[b*2+1]);
const r=vertex(ax,ay,backZ,n.x,n.y,0,uvs[a*2],uvs[a*2+1]);
const s=vertex(bx,by,backZ,n.x,n.y,0,uvs[b*2],uvs[b*2+1]);
indices.push(p,r,s,p,s,q);
}
const geometry=new THREE.BufferGeometry();
geometry.setAttribute('position',new THREE.Float32BufferAttribute(positions,3));
geometry.setAttribute('normal',new THREE.Float32BufferAttribute(normals,3));
geometry.setAttribute('uv',new THREE.Float32BufferAttribute(uvs,2));geometry.setIndex(indices);geometry.computeBoundingSphere();
return {geometry,backZ,minThickness,maxThickness,frontSegments:[xs.length-1,ys.length-1],reducedSampling:Math.ceil(shape.grid)*7>nx || Math.ceil(gridY)*7>ny};
}
+34
View File
@@ -0,0 +1,34 @@
import * as THREE from 'three';
import type { PaneRect } from './ComparisonLayout';
/** Optional A/B output compositor. It never owns either renderer or shared photo. */
export class PhysicalGlassComparison {
readonly targetA = new THREE.WebGLRenderTarget(1,1,{type:THREE.HalfFloatType});
readonly targetB = new THREE.WebGLRenderTarget(1,1,{type:THREE.HalfFloatType});
private readonly blitMaterial = new THREE.MeshBasicMaterial({toneMapped:false,depthTest:false,depthWrite:false});
private readonly blit = new THREE.Mesh(new THREE.PlaneGeometry(2,2),this.blitMaterial);
private readonly blitScene = new THREE.Scene();
private readonly blitCamera = new THREE.OrthographicCamera(-1,1,1,-1,0,1);
private disposed=false;
constructor(private readonly renderer:THREE.WebGLRenderer,private readonly background:THREE.Color) {this.blitScene.add(this.blit);}
resize(width:number,height:number):void {for(const target of [this.targetA,this.targetB]) if(target.width!==width||target.height!==height)target.setSize(width,height);}
composite(panes:readonly PaneRect[],canvasHeight:number):void {
this.renderer.setRenderTarget(null);this.renderer.setScissorTest(false);
const clearColor=this.renderer.getClearColor(new THREE.Color()), clearAlpha=this.renderer.getClearAlpha();
this.renderer.setClearColor(this.background,1);this.renderer.clear();
this.renderer.setClearColor(clearColor,clearAlpha);
this.renderer.setScissorTest(true);
try {
for(let i=0;i<panes.length;i++) {
const p=panes[i];this.renderer.setViewport(p.x,canvasHeight-p.y-p.height,p.width,p.height);
this.renderer.setScissor(p.x,canvasHeight-p.y-p.height,p.width,p.height);
this.blitMaterial.map=i===0?this.targetA.texture:this.targetB.texture;
this.renderer.render(this.blitScene,this.blitCamera);
}
} finally {this.renderer.setScissorTest(false);this.renderer.setViewport(0,0,this.renderer.domElement.clientWidth,canvasHeight);}
}
dispose():void {
if(this.disposed)return;this.disposed=true;
if(!this.renderer.getContext().isContextLost()){this.renderer.setRenderTarget(null);this.renderer.setScissorTest(false);}
this.targetA.dispose();this.targetB.dispose();this.blit.geometry.dispose();this.blitMaterial.dispose();this.blitScene.clear();
}
}
+34
View File
@@ -0,0 +1,34 @@
export interface RenderDemandState {
/** Exact visible state, excluding clocks and render-target output textures. */
signature: string;
/** Actual motion in progress; video frame changes are tracked in signature. */
continuous: boolean;
/** Original A's split palette uses an existing 70ms exponential history. */
temporal: boolean;
}
/** Keep input bookkeeping live while avoiding identical GPU submissions.
* Resolution, geometry, lighting and optical shaders are unchanged.
*/
export class RenderDemand {
private dirty = true;
private signature = '';
private historySeconds = 0;
invalidate(): void { this.dirty = true; }
shouldRender(dt: number, state: RenderDemandState): boolean {
const changed = this.dirty || state.signature !== this.signature;
if (!state.temporal) this.historySeconds = 0;
else if (changed || state.continuous) this.historySeconds = 0.7;
const render = changed || state.continuous || this.historySeconds > 0;
if (!render) return false;
this.dirty = false;
this.signature = state.signature;
// Ten time constants leave <0.005% of the previous history. Count only
// submitted frames, using the same bounded dt as the existing renderer.
const elapsed = Number.isFinite(dt) ? Math.max(0, Math.min(dt, 0.1)) : 0;
this.historySeconds = Math.max(0, this.historySeconds - elapsed);
return true;
}
}
+32
View File
@@ -0,0 +1,32 @@
import * as THREE from 'three';
import {prepareExportPixels} from './ExportPixels';
export type ExportKind='photo'|'scene';
/** r169 uses linear shader output for targets; SRGB8_ALPHA8 encodes exactly once. */
export async function captureScenePNG(renderer:THREE.WebGLRenderer,root:Document,kind:ExportKind,signal:AbortSignal,draw:(target:THREE.WebGLRenderTarget)=>void):Promise<Blob> {
const check=()=>{if(signal.aborted)throw new DOMException('Cancelled','AbortError');if(renderer.getContext().isContextLost())throw new Error('화면 연결이 복구된 뒤 다시 저장해 주세요.');};
check();
const source=renderer.getDrawingBufferSize(new THREE.Vector2());
const scale=Math.min(1,(kind==='photo'?1024:2048)/Math.max(source.x,source.y));
const width=Math.max(1,Math.round(source.x*scale)),height=Math.max(1,Math.round(source.y*scale));
const target=new THREE.WebGLRenderTarget(width,height,{type:THREE.UnsignedByteType,format:THREE.RGBAFormat,colorSpace:THREE.SRGBColorSpace,depthBuffer:true,samples:0});
const previous=renderer.getRenderTarget(),viewport=renderer.getViewport(new THREE.Vector4()),scissor=renderer.getScissor(new THREE.Vector4()),scissorTest=renderer.getScissorTest();
const clearColor=renderer.getClearColor(new THREE.Color()),clearAlpha=renderer.getClearAlpha(),autoClear=renderer.autoClear;
try {
const bytes=new Uint8Array(width*height*4);
try {
renderer.setClearColor(0x000000,0);renderer.autoClear=true;
draw(target);
} finally {
renderer.setRenderTarget(previous);renderer.setViewport(viewport);renderer.setScissor(scissor);renderer.setScissorTest(scissorTest);renderer.setClearColor(clearColor,clearAlpha);renderer.autoClear=autoClear;
}
check();
await renderer.readRenderTargetPixelsAsync(target,0,0,width,height,bytes);
check();
const pixels=prepareExportPixels(bytes,width,height,kind==='photo');
const canvas=root.createElement('canvas');canvas.width=pixels.width;canvas.height=pixels.height;
const context=canvas.getContext('2d');if(!context)throw new Error('이미지를 저장할 수 없어요. 다시 시도해 주세요.');
const output=context.createImageData(pixels.width,pixels.height);output.data.set(pixels.data);context.putImageData(output,0,0);
const blob=await new Promise<Blob>((resolve,reject)=>canvas.toBlob(value=>value?resolve(value):reject(new Error('이미지 파일을 만들지 못했어요.')),'image/png'));
check();return blob;
} finally {target.dispose();}
}
+4 -2
View File
@@ -1,10 +1,12 @@
uniform sampler2D uSceneTexture; uniform mat4 uContentViewProjection; uniform mat4 uContentInverseViewProjection; uniform vec3 uContentCameraPosition,uBackgroundColor; uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uTime; uniform vec2 uViewOffset,uResolution,uPetSize,uPaneSize;
uniform sampler2D uSceneTexture; uniform mat4 uContentViewProjection; uniform mat4 uContentInverseViewProjection; uniform vec3 uContentCameraPosition,uBackgroundColor; uniform float uSceneZoom; uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uTime; uniform vec2 uSceneOffset,uViewOffset,uResolution,uPetSize,uPaneSize;
varying vec2 vUv;
// Preserve the existing 1x material coordinates as the common projection magnifies and translates.
vec2 patternScreenUv(vec2 uv){return (uv-.5-uSceneOffset*.5)/max(uSceneZoom,1.)+.5;}
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;} vec4 sampleScene(vec2 uv){if(any(lessThan(uv,vec2(0.)))||any(greaterThan(uv,vec2(1.))))return vec4(.005605,.009721,.016807,1.);return texture2D(uSceneTexture,uv);} float hash(vec2 p){return fract(sin(dot(p,vec2(41.3,17.9)))*15731.743);} float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.)),f.x),f.y);} float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;} vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
void main(){if(uStrength<=.001){gl_FragColor=texture2D(uSceneTexture,vUv);
#include <colorspace_fragment>
return;}vec2 q=vUv*2.-1.;vec4 fh=uContentInverseViewProjection*vec4(q,1.,1.);float fwDen=abs(fh.w)<.0001?(fh.w<0.?-.0001:.0001):fh.w;vec3 fw=fh.xyz/fwDen;vec3 i=normalize(fw-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;vec2 paneUv=gh.xy/uPaneSize+.5;if(any(lessThan(paneUv,vec2(0.)))||any(greaterThan(paneUv,vec2(1.)))){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}vec2 st=vUv*(uResolution/max(uResolution.y,1.)),np=st*9.+uViewOffset*.6,nn=vec2(noise(np),noise(np+vec2(23.7)))-vec2(.5);vec3 r=bend(i,normalize(vec3(nn*.32,1.)));vec3 sh=gh+i*((uSourceZ-gh.z)/safeZ(i.z)),bh=gh+r*((uSourceZ-gh.z)/safeZ(r.z));vec2 ru=vUv+projectUv(bh)-projectUv(sh),center=mix(vUv,ru,clamp(uStrength,0.,1.)),rad=vec2(.008)*clamp(uStrength,0.,1.);vec4 c=vec4(0.);c+=sampleScene(center+vec2(-rad.x,-rad.y));c+=sampleScene(center+vec2(0.,-rad.y));c+=sampleScene(center+vec2(rad.x,-rad.y));c+=sampleScene(center+vec2(-rad.x,0.));c+=sampleScene(center);c+=sampleScene(center+vec2(rad.x,0.));c+=sampleScene(center+vec2(-rad.x,rad.y));c+=sampleScene(center+vec2(0.,rad.y));c+=sampleScene(center+vec2(rad.x,rad.y));c/=9.;c.a=1.;gl_FragColor=c;
return;}vec2 st=patternScreenUv(vUv)*(uResolution/max(uResolution.y,1.)),np=st*9.+uViewOffset*.6,nn=vec2(noise(np),noise(np+vec2(23.7)))-vec2(.5);vec3 r=bend(i,normalize(vec3(nn*.32,1.)));vec3 sh=gh+i*((uSourceZ-gh.z)/safeZ(i.z)),bh=gh+r*((uSourceZ-gh.z)/safeZ(r.z));vec2 ru=vUv+projectUv(bh)-projectUv(sh),center=mix(vUv,ru,clamp(uStrength,0.,1.)),rad=vec2(.008)*uSceneZoom*clamp(uStrength,0.,1.);vec4 c=vec4(0.);c+=sampleScene(center+vec2(-rad.x,-rad.y));c+=sampleScene(center+vec2(0.,-rad.y));c+=sampleScene(center+vec2(rad.x,-rad.y));c+=sampleScene(center+vec2(-rad.x,0.));c+=sampleScene(center);c+=sampleScene(center+vec2(rad.x,0.));c+=sampleScene(center+vec2(-rad.x,rad.y));c+=sampleScene(center+vec2(0.,rad.y));c+=sampleScene(center+vec2(rad.x,rad.y));c/=9.;c.a=1.;gl_FragColor=c;
#include <colorspace_fragment>
}
+3 -1
View File
@@ -1,5 +1,6 @@
uniform sampler2D uTexture;
uniform bool uIsVideo;
uniform bool uUsePhotoAlpha;
varying vec2 vUv;
vec3 srgbToLinear(vec3 value) {
@@ -9,7 +10,8 @@ vec3 srgbToLinear(vec3 value) {
void main() {
vec4 color = texture2D(uTexture, vUv);
if (uIsVideo) color.rgb = srgbToLinear(color.rgb);
color.a = 1.0;
if(uUsePhotoAlpha) { if(color.a < .003) discard; }
else color.a = 1.0;
gl_FragColor = color;
#include <colorspace_fragment>
}
+4 -2
View File
@@ -1,10 +1,12 @@
uniform sampler2D uSceneTexture; uniform mat4 uContentViewProjection; uniform mat4 uContentInverseViewProjection; uniform vec3 uContentCameraPosition,uBackgroundColor; uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uTime; uniform vec2 uViewOffset,uResolution,uPetSize,uPaneSize;
uniform sampler2D uSceneTexture; uniform mat4 uContentViewProjection; uniform mat4 uContentInverseViewProjection; uniform vec3 uContentCameraPosition,uBackgroundColor; uniform float uSceneZoom; uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uTime; uniform vec2 uSceneOffset,uViewOffset,uResolution,uPetSize,uPaneSize;
varying vec2 vUv;
// Preserve the existing 1x material coordinates as the common projection magnifies and translates.
vec2 patternScreenUv(vec2 uv){return (uv-.5-uSceneOffset*.5)/max(uSceneZoom,1.)+.5;}
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;} vec4 sampleScene(vec2 uv){if(any(lessThan(uv,vec2(0.)))||any(greaterThan(uv,vec2(1.))))return vec4(.005605,.009721,.016807,1.);return texture2D(uSceneTexture,uv);} float hash(float x){return fract(sin(x*91.7)*43758.5453);} float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;} vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
void main(){if(uStrength<=.001){gl_FragColor=texture2D(uSceneTexture,vUv);
#include <colorspace_fragment>
return;}vec2 q=vUv*2.-1.;vec4 fh=uContentInverseViewProjection*vec4(q,1.,1.);float fwDen=abs(fh.w)<.0001?(fh.w<0.?-.0001:.0001):fh.w;vec3 fw=fh.xyz/fwDen;vec3 i=normalize(fw-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;vec2 paneUv=gh.xy/uPaneSize+.5;if(any(lessThan(paneUv,vec2(0.)))||any(greaterThan(paneUv,vec2(1.)))){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}float count=max(1.,uGridSize*uPetSize.x/uPetSize.y),coord=vUv.x*count,cell=floor(coord),phase=fract(coord)*6.2831853,ridge=sin(phase+uViewOffset.x*.9),variation=mix(.92,1.08,hash(cell));vec3 r=bend(i,normalize(vec3(-ridge*.34*variation,0.,1.)));vec3 sh=gh+i*((uSourceZ-gh.z)/safeZ(i.z)),bh=gh+r*((uSourceZ-gh.z)/safeZ(r.z));vec2 ru=vUv+projectUv(bh)-projectUv(sh);vec2 uv=mix(vUv,ru,clamp(uStrength,0.,1.));vec4 c=sampleScene(uv);c.a=1.;gl_FragColor=c;
return;}float count=max(1.,uGridSize*uPetSize.x/uPetSize.y),coord=patternScreenUv(vUv).x*count,cell=floor(coord),phase=fract(coord)*6.2831853,ridge=sin(phase+uViewOffset.x*.9),variation=mix(.92,1.08,hash(cell));vec3 r=bend(i,normalize(vec3(-ridge*.34*variation,0.,1.)));vec3 sh=gh+i*((uSourceZ-gh.z)/safeZ(i.z)),bh=gh+r*((uSourceZ-gh.z)/safeZ(r.z));vec2 ru=vUv+projectUv(bh)-projectUv(sh);vec2 uv=mix(vUv,ru,clamp(uStrength,0.,1.));vec4 c=sampleScene(uv);c.a=1.;gl_FragColor=c;
#include <colorspace_fragment>
}
+49
View File
@@ -0,0 +1,49 @@
/** A local presentation preference only. Never pass this state to media/inference. */
export const PET_NAME_STORAGE_KEY = 'behind-glass.pet-name.v1';
export const PET_NAME_MAX_LENGTH = 20;
export function normalizePetName(value: string): string {
return Array.from(value.normalize('NFC').trim()).slice(0, PET_NAME_MAX_LENGTH).join('');
}
export function petNameForms(value: string) {
const name = normalizePetName(value) || '멍멍이';
const last = name.codePointAt(name.length - 1) ?? 0;
const hasFinalConsonant = last >= 0xac00 && last <= 0xd7a3 && (last - 0xac00) % 28 !== 0;
const display = hasFinalConsonant ? `${name}이` : name;
return { display, object: `${display}를`, subject: `${display}가`, possessive: `${display}의` };
}
type NameStorage = Pick<Storage, 'getItem' | 'setItem'>;
export type PetNameStore = ReturnType<typeof createPetNameStore>;
export function createPetNameStore(getStorage: () => NameStorage | undefined) {
let name = '';
let storage: NameStorage | undefined;
let storageUnavailable = false;
const listeners = new Set<() => void>();
try {
storage = getStorage();
storageUnavailable = !storage;
name = normalizePetName(storage?.getItem(PET_NAME_STORAGE_KEY) ?? '');
} catch { storageUnavailable = true; }
return {
get name(): string { return name; },
get storageUnavailable(): boolean { return storageUnavailable; },
setName(value: string): void {
const next = normalizePetName(value);
if (next === name) return;
name = next;
try {
storage?.setItem(PET_NAME_STORAGE_KEY, name);
storageUnavailable = !storage;
} catch { storageUnavailable = true; }
listeners.forEach(listener => listener());
},
subscribe(listener: () => void, signal: AbortSignal): void {
if (signal.aborted) return;
listeners.add(listener);
signal.addEventListener('abort', () => listeners.delete(listener), { once: true });
}
};
}
+1389 -9
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -8,3 +8,11 @@ export interface DepthSettings {
scale: number;
bias: number;
}
export type AiDepthPreset = { device: 'webgpu' | 'wasm'; dtype: 'fp32' | 'q8' };
export type AiDepthRun = AiDepthPreset & {
model: 'small';
preparationMs: number;
inferenceMs: number;
totalMs: number;
};
+7
View File
@@ -0,0 +1,7 @@
/** A bounded, orientation-corrected copy of the complete selected photo. */
export type PhotoSnapshot = {
requestId: number;
width: number;
height: number;
bitmap: ImageBitmap;
};
+13
View File
@@ -0,0 +1,13 @@
const knownCounts = new WeakSet<HTMLElement>();
/** A failed read leaves the last truthful value visible; an unknown total stays hidden. */
export function renderArrivalCount(elements: HTMLElement[], count: number | undefined): void {
const valid = typeof count === 'number' && Number.isSafeInteger(count) && count >= 0;
for (const element of elements) {
if (valid) {
element.textContent = `지금까지 ${count.toLocaleString('ko-KR')}번 도착했어요`;
element.hidden = false;
knownCounts.add(element);
} else if (!knownCounts.has(element)) element.hidden = true;
}
}
+39
View File
@@ -0,0 +1,39 @@
/** Small, manual photo-entry guidance. Owns no media or scene state. */
export function initEntryNotices(root: Document, signal: AbortSignal): void {
const get = (id: string) => root.querySelector<HTMLElement>(`#${id}`)!;
const region = get('entry-notices');
const slides = ['photo-tip', 'privacy-tip', 'paste-tip'].map(get);
const desktop = root.defaultView?.matchMedia?.('(hover: hover) and (pointer: fine)');
let index = 0;
let start: { id: number; x: number; y: number } | null = null;
const count = () => desktop?.matches ? 3 : 2;
const render = (announce = false): void => {
const total = count();
if (index >= total) index = 0;
slides.forEach((slide, i) => {
slide.hidden = i >= total;
slide.dataset.active = String(i === index);
slide.setAttribute('aria-hidden', String(i !== index));
});
get('notice-position').textContent = `${index + 1} / ${total}`;
if (announce) get('notice-status').textContent = `${index + 1} / ${total}. ${slides[index].textContent}`;
};
const step = (delta: number): void => { index = (index + delta + count()) % count(); render(true); };
get('notice-prev').addEventListener('click', () => step(-1), { signal });
get('notice-next').addEventListener('click', () => step(1), { signal });
desktop?.addEventListener('change', () => { start = null; get('notice-status').textContent = ''; render(); }, { signal });
region.addEventListener('pointerdown', event => {
if (!event.isPrimary) { start = null; return; }
if (event.pointerType !== 'touch' || (event.target as Element)?.closest?.('button')) return;
start = { id: event.pointerId, x: event.clientX, y: event.clientY };
}, { signal });
region.addEventListener('pointerup', event => {
const origin = start; start = null;
if (!origin || origin.id !== event.pointerId) return;
const dx = event.clientX - origin.x, dy = event.clientY - origin.y;
if (Math.abs(dx) >= 40 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);
}, { signal });
region.addEventListener('pointercancel', () => { start = null; }, { signal });
signal.addEventListener('abort', () => { start = null; }, { once: true });
render();
}
+386
View File
@@ -0,0 +1,386 @@
import { createPetNameStore, normalizePetName, petNameForms, PET_NAME_MAX_LENGTH, type PetNameStore } from '../state/PetName';
import { THEMES, THEME_STORAGE_KEY, type ThemeId } from './themes';
export type ExperienceState = 'entry' | 'loading' | 'ready' | 'error';
/** Presentation only: owns no photo, inference, optical or camera state. */
export function initExperienceUI(root: Document, signal: AbortSignal, onTheme: (theme: ThemeId) => void, onArrivalEnd: () => void = () => {}, onSample: (selected: string) => void = () => {}, petName: PetNameStore = createPetNameStore(() => root.defaultView?.localStorage), onReturnHome: () => void = () => {}, onPhotoAdjustChange: (active:boolean) => void = () => {}) {
const get = <T extends HTMLElement>(id: string): T => root.querySelector<T>(`#${id}`)!;
const experience = get('experience');
const welcome = get('welcome');
const overlay = get('flow-overlay');
const title = get('flow-title');
const detail = get('flow-detail');
const photo = get<HTMLImageElement>('flow-photo');
const journey = get('journey');
const flowActions = get('flow-actions');
const retry = get<HTMLButtonElement>('flow-retry');
const flat = get<HTMLButtonElement>('flow-flat');
const dialog = get<HTMLDialogElement>('settings-dialog');
const sampleDialog = get<HTMLDialogElement>('sample-dialog');
const creditsDialog = get<HTMLDialogElement>('credits-dialog');
const exportDialog = get<HTMLDialogElement>('export-dialog');
const menu = get<HTMLDialogElement>('scene-menu');
const menuOpener = get<HTMLButtonElement>('scene-menu-open');
const homeDialog = get<HTMLDialogElement>('home-dialog');
const photoAdjustPanel = get('photo-adjust-panel');
let photoAdjusting = false;
const firstGuide = get<HTMLDialogElement>('first-guide');
const sampleChoices = [...root.querySelectorAll<HTMLButtonElement>('[data-sample-choice]')];
let selectedSample = 'dog5';
let sampleName = '누렁이';
let sampleReturnFocus: HTMLElement | null = null;
const select = get<HTMLSelectElement>('theme-select');
const canvas = get('canvas-wrap');
let state: ExperienceState = 'entry';
let renderLost = false;
let theme: ThemeId = 'wood';
let mediaOrigin: 'personal' | 'sample' = 'personal';
let returnFocus: HTMLElement | null = null;
const view = root.defaultView;
const reducedMotion = view?.matchMedia?.('(prefers-reduced-motion: reduce)');
let hasPreview = false;
let driving = false;
let arrivalTimer: number | undefined;
let arrivalVersion = 0;
let arriving = false;
let storage: Storage | undefined;
try { storage = root.defaultView?.localStorage; const saved = storage?.getItem(THEME_STORAGE_KEY); if (saved === 'booth') theme = saved; } catch { /* Storage can be disabled in a private browser. */ }
const composing = new Set<HTMLInputElement>();
const entryStepKey = 'behind-glass.entry-completed.v1';
let entryStep: 'name' | 'photo' = petName.name ? 'photo' : 'name';
try { if (storage?.getItem(entryStepKey) === '1') entryStep = 'photo'; } catch { /* Session state still works. */ }
const syncEntryStep = (): void => {
welcome.dataset.step = entryStep;
get('entry-name-step').hidden = entryStep !== 'name';
get('entry-photo-step').hidden = entryStep !== 'photo';
get('entry-notices').hidden = entryStep !== 'photo';
get('entry-name-edit').hidden = entryStep !== 'photo';
};
const finishNameStep = (): void => {
petName.setName(get<HTMLInputElement>('entry-pet-name').value);
entryStep = 'photo';
try { storage?.setItem(entryStepKey, '1'); } catch { /* No repeated question within this session. */ }
syncEntryStep(); get('entry-photo-button').focus({ preventScroll: true });
};
get('entry-name-next').addEventListener('click', finishNameStep, { signal });
get('entry-name-skip').addEventListener('click', () => { get<HTMLInputElement>('entry-pet-name').value = ''; finishNameStep(); }, { signal });
get('entry-name-edit').addEventListener('click', () => { entryStep = 'name'; syncEntryStep(); get('entry-pet-name').focus({ preventScroll: true }); }, { signal });
get('entry-pet-name').addEventListener('keydown', event => { if ((event as KeyboardEvent).key === 'Enter' && !(event as KeyboardEvent).isComposing && (event as KeyboardEvent).keyCode !== 229 && !composing.has(get<HTMLInputElement>('entry-pet-name'))) { event.preventDefault(); finishNameStep(); } }, { signal });
syncEntryStep();
const isBlocked = (): boolean => renderLost || state !== 'ready' || arriving || dialog.open || sampleDialog.open || creditsDialog.open || exportDialog.open || firstGuide.open || menu.open || homeDialog.open;
const syncBlocked = (): void => {
canvas.inert = isBlocked();
get('photo-adjust-open').hidden = isBlocked() || photoAdjusting;
};
const stopPhotoAdjust = (): void => {
if(!photoAdjusting)return;
photoAdjusting=false;photoAdjustPanel.hidden=true;delete experience.dataset.photoAdjust;
canvas.setAttribute('aria-label','사진 미리보기. 드래그하거나 방향키로 시점을 움직이세요');
canvas.setAttribute('aria-describedby','scene-instructions');
onPhotoAdjustChange(false);syncBlocked();
};
const startPhotoAdjust = (): void => {
if(signal.aborted||isBlocked()||photoAdjusting)return;
photoAdjusting=true;photoAdjustPanel.hidden=false;experience.dataset.photoAdjust='true';
canvas.setAttribute('aria-label','사진 위치 조절. 드래그하거나 방향키로 사진을 옮기세요');
canvas.setAttribute('aria-describedby','photo-adjust-help');
onPhotoAdjustChange(true);syncBlocked();canvas.focus({preventScroll:true});
};
get('photo-adjust-done').addEventListener('click',()=>{stopPhotoAdjust();if(!isBlocked())get('photo-adjust-open').focus({preventScroll:true});},{signal});
root.addEventListener('keydown',event=>{if((event as KeyboardEvent).key==='Escape'&&photoAdjusting){event.preventDefault();stopPhotoAdjust();if(!isBlocked())get('photo-adjust-open').focus({preventScroll:true});}},{signal});
const closeHome = (restoreFocus=false): void => {
if(!homeDialog.open)return;
homeDialog.close();syncBlocked();
if(restoreFocus&&!signal.aborted&&!renderLost&&state!=='entry')get('sample-home').focus({preventScroll:true});
};
get('home-cancel').addEventListener('click',()=>closeHome(true),{signal});
homeDialog.addEventListener('cancel',event=>{event.preventDefault();closeHome(true);},{signal});
homeDialog.addEventListener('click',event=>{if(event.target!==homeDialog)return;const b=homeDialog.getBoundingClientRect();if(event.clientX<b.left||event.clientX>b.right||event.clientY<b.top||event.clientY>b.bottom)closeHome(true);},{signal});
homeDialog.addEventListener('close',()=>{if(!homeDialog.open)syncBlocked();},{signal});
const closeMenu = (restoreFocus = false): void => {
if (!menu.open) return;
menu.close();
menuOpener.setAttribute('aria-expanded', 'false');
syncBlocked();
if (restoreFocus && !signal.aborted && !renderLost && state !== 'entry') menuOpener.focus({preventScroll:true});
};
menuOpener.addEventListener('click', () => {
if (signal.aborted || renderLost || state === 'entry' || menu.open || dialog.open || sampleDialog.open || creditsDialog.open || exportDialog.open || firstGuide.open || homeDialog.open) return;
stopPhotoAdjust();
if(arriving)hideArrival(false);
menu.showModal(); menuOpener.setAttribute('aria-expanded', 'true'); syncBlocked(); get('scene-menu-close').focus();
}, {signal});
get('scene-menu-close').addEventListener('click', () => closeMenu(true), {signal});
menu.addEventListener('cancel', event => {event.preventDefault();closeMenu(true);}, {signal});
menu.addEventListener('click', event => {
if (event.target !== menu) return;
const b=menu.getBoundingClientRect();
if(event.clientX<b.left||event.clientX>b.right||event.clientY<b.top||event.clientY>b.bottom)closeMenu(true);
}, {signal});
menu.addEventListener('close', () => {
// Native close is queued: an old event must not disturb a newly opened sheet.
if(menu.open)return;
menuOpener.setAttribute('aria-expanded','false');syncBlocked();
}, {signal});
for(const id of ['export-open','scene-photo-change','settings-open','scene-zoom-reset','comparison-exit','scene-guide-replay','sample-home']) {
// Capture phase preserves the existing action and its user activation (file picker).
get(id).addEventListener('click', () => closeMenu(), {capture:true,signal});
}
const stopArrival = (): void => {
arrivalVersion += 1;
arriving = false;
if (arrivalTimer !== undefined) view?.clearTimeout(arrivalTimer);
arrivalTimer = undefined;
};
const syncMotion = (): void => {
overlay.dataset.motion = reducedMotion?.matches ? 'paused' : 'running';
};
const hideArrival = (completed = true): void => {
const wasArriving = arriving;
stopArrival();
if (state === 'ready') overlay.hidden = true;
syncBlocked();
if (wasArriving && completed && !isBlocked()) {
canvas.focus({ preventScroll: true });
onArrivalEnd();
}
};
// Copy updates never enter the media/AI state machine or reset journey timers.
const syncSampleControls = (): void => {
const sample = mediaOrigin === 'sample' && state !== 'entry';
get('scene-photo-change').hidden = sample || state === 'entry';
get('settings-open').hidden = sample || state === 'entry';
get('credits-open').hidden = state !== 'entry';
get('export-open').hidden = sample || state !== 'ready';
get('sample-home').hidden = state === 'entry';
menuOpener.hidden = state === 'entry';
get('scene-guide-replay').hidden = sample || state !== 'ready' || theme !== 'wood';
};
const syncNameCopy = (): void => {
const name = petNameForms(petName.name);
get('welcome-name').textContent = `${name.subject} 왔어요`;
get('entry-photo-button').textContent = `${name.display} 사진 고르기`;
get('image').setAttribute('aria-label', `${name.display} 사진 선택`);
get('pet-stage').setAttribute('aria-label', mediaOrigin === 'sample' ? '유리문 너머의 샘플 사진' : `유리문 너머의 ${name.display}`);
photo.alt = mediaOrigin === 'sample' ? '선택한 샘플 사진' : `선택한 ${name.display} 사진`;
const journeyName = petNameForms(mediaOrigin === 'sample' ? sampleName : petName.name);
if (state === 'error') title.textContent = '사진을 준비하지 못했어요';
else if (overlay.dataset.journey === 'unpacking') title.textContent = '짐을 푸는 중…';
else if (overlay.dataset.journey === 'arrived') title.textContent = theme === 'wood'
? `${journeyName.subject} 할머니 집에 도착했어요` : `${journeyName.possessive} 포토부스에 도착했어요`;
else title.textContent = theme === 'wood'
? `${journeyName.object} 할머니 집으로 데려가는 중…` : `${journeyName.possessive} 포토부스를 준비하는 중…`;
};
const nameInputs = [get<HTMLInputElement>('entry-pet-name'), get<HTMLInputElement>('settings-pet-name')];
const syncNameSkip = (): void => { get('entry-name-skip').hidden = Boolean(get<HTMLInputElement>('entry-pet-name').value.trim()); };
const syncName = (): void => {
for (const input of nameInputs) {
// Do not move the caret or interrupt Korean composition while typing.
if (root.activeElement !== input && !composing.has(input)) input.value = petName.name;
}
for (const prefix of ['entry', 'settings']) {
get(`${prefix}-name-notice`).hidden = !petName.storageUnavailable;
get(`${prefix}-pet-name`).setAttribute('aria-describedby', `${prefix}-name-help${petName.storageUnavailable ? ` ${prefix}-name-notice` : ''}`);
}
syncNameCopy(); syncNameSkip();
};
for (const input of nameInputs) {
input.value = petName.name;
const commit = (normalizeField = false): void => {
if (composing.has(input)) return;
petName.setName(input.value);
if (normalizeField || Array.from(input.value.normalize('NFC').trim()).length > PET_NAME_MAX_LENGTH) input.value = normalizePetName(input.value);
};
input.addEventListener('compositionstart', () => composing.add(input), { signal });
input.addEventListener('compositionend', () => { composing.delete(input); commit(true); syncNameSkip(); }, { signal });
input.addEventListener('input', event => { if (!(event as InputEvent).isComposing) commit(); syncNameSkip(); }, { signal });
input.addEventListener('change', () => commit(true), { signal });
input.addEventListener('blur', () => commit(true), { signal });
}
petName.subscribe(syncName, signal);
syncName();
const applyTheme = (): void => {
root.documentElement.dataset.theme = theme;
select.value = theme;
onTheme(theme);
syncNameCopy(); syncSampleControls();
root.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEMES[theme].background);
};
const close = (): void => { if (dialog.open) dialog.close(); };
let creditsReturnFocus = false;
const closeCredits = (restoreFocus = true): void => {
if (!restoreFocus) creditsReturnFocus = false;
if (creditsDialog.open) creditsDialog.close();
};
get('credits-open').addEventListener('click', () => {
if (state !== 'entry' || renderLost || signal.aborted || creditsDialog.open || sampleDialog.open || dialog.open) return;
creditsReturnFocus = true;
creditsDialog.showModal(); syncBlocked(); get('credits-close').focus();
}, { signal });
get('credits-close').addEventListener('click', () => closeCredits(), { signal });
creditsDialog.addEventListener('cancel', event => { event.preventDefault(); closeCredits(); }, { signal });
creditsDialog.addEventListener('click', event => {
if (event.target !== creditsDialog) return;
const bounds = creditsDialog.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) closeCredits();
}, { signal });
creditsDialog.addEventListener('close', () => {
syncBlocked();
if (creditsReturnFocus && state === 'entry' && !signal.aborted) get('credits-open').focus();
creditsReturnFocus = false;
}, { signal });
const closeSamples = (restoreFocus = true): void => {
if (!restoreFocus) sampleReturnFocus = null;
if (sampleDialog.open) sampleDialog.close();
};
const openSamples = (opener: HTMLElement): void => {
if (renderLost || signal.aborted) return;
if (arriving) hideArrival(false);
stopPhotoAdjust(); closeHome(); closeMenu(); closeCredits(false); close();
sampleReturnFocus = opener;
sampleDialog.showModal();
syncBlocked();
(sampleChoices.find(button => button.dataset.sampleChoice === selectedSample) ?? get('sample-close')).focus();
};
sampleChoices.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.sampleChoice === selectedSample)));
sampleChoices.forEach(button => button.addEventListener('click', () => {
selectedSample = button.dataset.sampleChoice!;
sampleChoices.forEach(choice => choice.setAttribute('aria-pressed', String(choice === button)));
}, { signal }));
get('sample-confirm').addEventListener('click', () => {
if (!sampleDialog.open || renderLost || signal.aborted) return;
closeSamples(false);
onSample(selectedSample);
}, { signal });
get('sample-close').addEventListener('click', () => closeSamples(), { signal });
get('flow-samples').addEventListener('click', () => openSamples(get('flow-samples')), { signal });
sampleDialog.addEventListener('cancel', event => { event.preventDefault(); closeSamples(); }, { signal });
sampleDialog.addEventListener('click', event => {
if (event.target !== sampleDialog) return;
const bounds = sampleDialog.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) closeSamples();
}, { signal });
sampleDialog.addEventListener('close', () => {
syncBlocked();
if (sampleReturnFocus?.isConnected) sampleReturnFocus.focus();
sampleReturnFocus = null;
}, { signal });
const open = (opener: HTMLElement): void => {
if (state === 'entry' || mediaOrigin === 'sample') return;
if (arriving) hideArrival(false);
stopPhotoAdjust(); closeHome(); closeSamples(false);
closeMenu();
returnFocus = opener === get('settings-open') ? menuOpener : opener;
dialog.showModal();
syncBlocked();
get('settings-close').focus();
};
get('settings-open').addEventListener('click', () => open(get('settings-open')), { signal });
get('settings-close').addEventListener('click', close, { signal });
get('entry-samples').addEventListener('click', () => openSamples(get('entry-samples')), { signal });
dialog.addEventListener('cancel', event => { event.preventDefault(); close(); }, { signal });
dialog.addEventListener('click', event => {
if (event.target !== dialog) return;
const bounds = dialog.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) close();
}, { signal });
dialog.addEventListener('close', () => { syncBlocked(); if (returnFocus?.isConnected) returnFocus.focus(); }, { signal });
select.addEventListener('change', () => {
const next: ThemeId = select.value === 'booth' ? 'booth' : 'wood';
if (next === theme) return;
theme = next;
applyTheme();
try { storage?.setItem(THEME_STORAGE_KEY, theme); } catch { /* Appearance still works if storage is unavailable/full. */ }
}, { signal });
reducedMotion?.addEventListener('change', () => {
syncMotion();
if (reducedMotion.matches && arriving) hideArrival();
}, { signal });
const setState = (next: ExperienceState, hasPhoto = false, canPlayVideo = false): void => {
const arrived = next === 'ready' && driving && hasPreview && !renderLost;
stopArrival();
driving = false;
state = next;
stopPhotoAdjust(); closeHome(); closeMenu();
if (next !== 'entry') closeCredits(false);
if(exportDialog.open)exportDialog.close();
if(firstGuide.open)firstGuide.close();
if (next === 'loading' || next === 'error') { close(); closeSamples(false); }
experience.dataset.flow = next;
welcome.hidden = next !== 'entry';
overlay.hidden = next === 'entry' || next === 'ready';
overlay.dataset.journey = arrived ? 'arrived' : 'idle';
detail.hidden = arrived;
flowActions.hidden = next !== 'error';
get('flow-samples').hidden = next !== 'error';
retry.hidden = next !== 'error' || !(hasPhoto || canPlayVideo);
retry.textContent = canPlayVideo ? '동영상 재생하기' : '다시 시도하기';
flat.hidden = next !== 'error' || !hasPhoto;
syncNameCopy();
syncSampleControls();
overlay.setAttribute('aria-busy', String(next === 'loading'));
syncMotion();
if (arrived) {
// AI is already complete. These short presentation beats never drive
// progress or keep inference busy; reduced motion reveals immediately.
if (!reducedMotion?.matches && view?.setTimeout) {
arriving = true;
overlay.hidden = false;
const version = arrivalVersion;
arrivalTimer = view.setTimeout(() => {
if (version !== arrivalVersion) return;
overlay.dataset.journey = 'unpacking';
title.textContent = '짐을 푸는 중…';
arrivalTimer = view.setTimeout(() => { if (version === arrivalVersion) hideArrival(); }, 1500);
}, 1350);
}
}
syncBlocked();
if (arrived && !isBlocked()) {canvas.focus({ preventScroll: true });onArrivalEnd();}
};
get('sample-home').addEventListener('click', () => {
if(signal.aborted||renderLost||state==='entry'||homeDialog.open||dialog.open||sampleDialog.open||exportDialog.open||firstGuide.open)return;
stopPhotoAdjust();closeMenu();homeDialog.showModal();syncBlocked();get('home-cancel').focus();
},{signal});
get('home-confirm').addEventListener('click', () => {
if (signal.aborted || renderLost || state === 'entry' || !homeDialog.open) return;
closeHome();
onReturnHome();
returnFocus = null; sampleReturnFocus = null;
if(exportDialog.open)exportDialog.close();
if(firstGuide.open)firstGuide.close();
close(); closeSamples(false); stopArrival();
driving = false; hasPreview = false; photo.src = ''; photo.hidden = true; journey.hidden = true;
mediaOrigin = 'personal'; entryStep = 'photo'; syncEntryStep();
setState('entry');
get('entry-photo-button').focus({ preventScroll: true });
}, { signal });
signal.addEventListener('abort', () => { stopPhotoAdjust(); closeHome(); closeMenu(); if(firstGuide.open)firstGuide.close(); closeCredits(false); closeSamples(false); stopArrival(); driving = false; overlay.dataset.journey = 'idle'; overlay.hidden = true; }, { once: true });
applyTheme();
setState('entry');
return {
setState, isBlocked, startPhotoAdjust, stopPhotoAdjust, isPhotoAdjusting:()=>photoAdjusting,
canAdjustPhoto():boolean {return !signal.aborted&&!renderLost&&state==='ready'&&!arriving&&!dialog.open&&!sampleDialog.open&&!firstGuide.open&&!homeDialog.open&&!exportDialog.open;},
isGuideOpen:()=>firstGuide.open, refreshBlocking:syncBlocked,
canShowFirstGuide():boolean {return state==='ready'&&mediaOrigin==='personal'&&theme==='wood'&&!photoAdjusting&&!isBlocked();},
canExportPhoto():boolean {return !signal.aborted&&!renderLost&&state==='ready'&&mediaOrigin==='personal'&&!arriving&&!dialog.open&&!sampleDialog.open&&!firstGuide.open&&!homeDialog.open&&!photoAdjusting;},
setSampleName(name: string): void { sampleName = name; syncNameCopy(); },
canAcceptPhotoPaste(): boolean { return !signal.aborted && !renderLost && !dialog.open && !sampleDialog.open && !creditsDialog.open && !exportDialog.open && !firstGuide.open && !menu.open && !homeDialog.open && !photoAdjusting && (state === 'entry' ? entryStep === 'photo' : mediaOrigin === 'personal'); },
setMediaOrigin(origin: 'personal' | 'sample'): void { stopPhotoAdjust(); mediaOrigin = origin; syncNameCopy(); syncSampleControls(); },
startJourney(): void {
setState('loading');
if (renderLost || signal.aborted) return;
driving = true;
overlay.dataset.journey = 'driving';
syncMotion();
},
setRenderLost(lost: boolean): void {
renderLost = lost;
if (lost) { stopPhotoAdjust(); closeHome(); closeMenu(); if(firstGuide.open)firstGuide.close(); if(exportDialog.open)exportDialog.close(); closeCredits(false); close(); closeSamples(false); stopArrival(); driving = false; overlay.dataset.journey = 'idle'; if (state === 'ready') overlay.hidden = true; syncMotion(); }
syncBlocked();
},
setDetail(message: string): void { detail.textContent = message; },
setPhoto(src: string): void { stopPhotoAdjust(); stopArrival(); driving = false; overlay.dataset.journey = 'idle'; if (state === 'ready') overlay.hidden = true; hasPreview = Boolean(src); photo.src = src; photo.hidden = !src; journey.hidden = !src; syncMotion(); syncBlocked(); }
};
}
+54
View File
@@ -0,0 +1,54 @@
import type {ExportKind} from '../rendering/SceneExport';
/** Explicit preview/download/share; never uploads or automatically saves a file. */
export function initExportUI(root:Document,signal:AbortSignal,allowed:()=>boolean,capture:(kind:ExportKind,signal:AbortSignal)=>Promise<Blob>,onModalChange:()=>void) {
const get=<T extends HTMLElement>(id:string)=>root.querySelector<T>(`#${id}`)!;
const dialog=get<HTMLDialogElement>('export-dialog'),opener=get<HTMLButtonElement>('export-open');
const photo=get<HTMLButtonElement>('export-photo'),scene=get<HTMLButtonElement>('export-scene');
const preview=get<HTMLImageElement>('export-preview'),status=get('export-status');
const download=get<HTMLAnchorElement>('export-download'),share=get<HTMLButtonElement>('export-share');
const urls=root.defaultView!.URL,nav=root.defaultView!.navigator;
let version=0,controller:AbortController|null=null,url:string|null=null,file:File|null=null,busy=false;
const clearResult=()=>{
if(url)urls.revokeObjectURL(url);url=null;file=null;preview.removeAttribute('src');preview.hidden=true;
download.removeAttribute('href');download.hidden=true;share.hidden=true;share.disabled=false;
};
const invalidate=()=>{version++;controller?.abort();controller=null;busy=false;photo.disabled=false;scene.disabled=false;clearResult();};
const close=()=>{if(dialog.open)dialog.close();else invalidate();};
opener.addEventListener('click',()=>{
if(signal.aborted||!allowed()||dialog.open)return;
invalidate();status.textContent='마음에 드는 현재 구도를 저장해 보세요';dialog.showModal();onModalChange();photo.focus();
},{signal});
const start=async(kind:ExportKind)=>{
if(signal.aborted||!dialog.open||busy||!allowed())return;
invalidate();busy=true;photo.disabled=true;scene.disabled=true;const request=version;controller=new AbortController();
status.textContent='지금 모습을 사진으로 담는 중…';dialog.setAttribute('aria-busy','true');
try {
const blob=await capture(kind,controller.signal);
if(request!==version||signal.aborted||!dialog.open||!allowed())return;
file=new File([blob],kind==='photo'?'behind-glass-photo.png':'behind-glass-scene.png',{type:'image/png'});
url=urls.createObjectURL(blob);preview.src=url;preview.hidden=false;preview.alt=kind==='photo'?'현재 구도의 굴절사진 저장 미리보기':'방을 포함한 현재 화면 저장 미리보기';
download.href=url;download.download=file.name;download.hidden=false;
try{share.hidden=!(typeof nav.share==='function'&&nav.canShare?.({files:[file]}));}catch{share.hidden=true;}
status.textContent=kind==='photo'?'체크무늬 부분은 투명하게 저장돼요':'버튼과 안내 문구 없이 저장돼요';
} catch(error) {
if(request===version&&!signal.aborted&&dialog.open)status.textContent=error instanceof Error?error.message:'사진을 만들지 못했어요. 다시 시도해 주세요.';
} finally {
if(request===version){busy=false;photo.disabled=false;scene.disabled=false;dialog.setAttribute('aria-busy','false');}
}
};
photo.addEventListener('click',()=>{void start('photo');},{signal});scene.addEventListener('click',()=>{void start('scene');},{signal});
share.addEventListener('click',async()=>{
if(!file||busy||share.disabled||!dialog.open||signal.aborted)return;
const request=version;share.disabled=true;
try{await nav.share({files:[file]});}
catch(error){if(request===version&&(error as Error)?.name!=='AbortError')status.textContent='공유를 열지 못했어요. 내려받기를 이용해 주세요.';}
finally{if(request===version)share.disabled=false;}
},{signal});
get('export-close').addEventListener('click',close,{signal});
dialog.addEventListener('cancel',event=>{event.preventDefault();close();},{signal});
dialog.addEventListener('click',event=>{if(event.target!==dialog)return;const b=dialog.getBoundingClientRect();if(event.clientX<b.left||event.clientX>b.right||event.clientY<b.top||event.clientY>b.bottom)close();},{signal});
dialog.addEventListener('close',()=>{invalidate();dialog.setAttribute('aria-busy','false');onModalChange();if(!signal.aborted&&allowed()&&!opener.hidden)(root.querySelector<HTMLElement>('#scene-menu-open') ?? opener).focus();},{signal});
signal.addEventListener('abort',()=>{invalidate();if(dialog.open)dialog.close();},{once:true});
clearResult();
return {cancel:close,setAvailable(value:boolean){opener.disabled=!value;}};
}
+49
View File
@@ -0,0 +1,49 @@
import {driver,type Driver,type DriveStep} from 'driver.js';
const GUIDE_KEY='behind-glass.first-guide.v1',RECENTER_KEY='behind-glass.recenter-guide.v1';
/** A nonmodal marker preserves the app's existing blocking/cancellation contract.
* Driver owns the visible spotlight; underlying app stays inert, never auto-clicked. */
export function initFirstGuide(root:Document,signal:AbortSignal,mobile:boolean,eligible:()=>boolean,onChange:()=>void,createTour:typeof driver=driver){
const get=<T extends HTMLElement>(id:string)=>root.querySelector<T>(`#${id}`)!;
const marker=get<HTMLDialogElement>('first-guide'),app=get('experience'),view=root.defaultView;
let done=false,recenterDone=false,tour:Driver|null=null,active=false,ending=false,priorInert=false,kind:'main'|'recenter'='main';
try{done=view?.localStorage.getItem(GUIDE_KEY)==='1';recenterDone=view?.localStorage.getItem(RECENTER_KEY)==='1';}catch{/* session-only state */}
const visible=(id:string):HTMLElement|undefined=>{const node=root.querySelector<HTMLElement>(`#${id}`);if(!node||node.closest('[hidden]'))return;const r=node.getBoundingClientRect(),style=view?.getComputedStyle?.(node);return r.width>0&&r.height>0&&style?.visibility!=='hidden'&&style?.display!=='none'?node:undefined;};
const cleanup=()=>{if(!active)return;active=false;tour=null;app.inert=priorInert;if(marker.open)marker.close();onChange();};
const stop=(completed=false)=>{
if(!active&&!tour)return;
ending=true;
if(completed){if(kind==='main')done=true;else recenterDone=true;try{view?.localStorage.setItem(kind==='main'?GUIDE_KEY:RECENTER_KEY,'1');}catch{/* keep session state */}}
const current=tour;tour=null;
try{current?.destroy();}finally{cleanup();ending=false;onChange();}
};
const start=(steps:DriveStep[],type:'main'|'recenter')=>{
if(signal.aborted||active||ending||!eligible())return;
kind=type;priorInert=app.inert;active=true;marker.show();app.inert=true;onChange();
try{
tour=createTour({steps,animate:!view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches,overlayColor:'#201a13',overlayOpacity:.62,stagePadding:9,stageRadius:9,popoverOffset:12,
allowScroll:false,smoothScroll:false,disableActiveInteraction:true,skipMissingElement:true,overlayClickBehavior:'none',allowClose:true,showProgress:type==='main',progressText:'{{current}} / {{total}}',
nextBtnText:'다음',prevBtnText:'이전',doneBtnText:type==='main'?'시작하기':'알겠어요',closeBtnLabel:'안내 건너뛰기',popoverClass:'behind-glass-tour',
onNextClick:()=>{if(tour?.isLastStep())stop(true);else tour?.moveNext();},onDoneClick:()=>stop(true),onCloseClick:()=>stop(true),onDestroyStarted:()=>stop(true),onDestroyed:cleanup,
onPopoverRender:popover=>{popover.closeButton.textContent='건너뛰기';const content=root.createElement('div');content.className='guide-popover-content';content.append(popover.closeButton,popover.title,popover.description,popover.footer);popover.wrapper.append(content);},
});
tour.drive();
}catch{stop(false);}
};
marker.addEventListener('close',()=>{if(!marker.open)stop(false);},{signal});
marker.addEventListener('cancel',event=>{event.preventDefault();stop(true);},{signal});
const refresh=()=>{if(!active)return;const target=tour?.getActiveElement?.();if(target&&target.id&&!visible(target.id)){stop(false);return;}tour?.refresh();};
view?.visualViewport?.addEventListener('resize',refresh,{signal});view?.visualViewport?.addEventListener('scroll',refresh,{signal});
signal.addEventListener('abort',()=>stop(false),{once:true});
return {
start(force=false){
if((done&&!force)||signal.aborted)return;
const steps:DriveStep[]=[
{element:()=>visible('guide-scene-target')!,popover:{title:'유리 너머를 둘러보세요',description:mobile?'손가락으로 드래그해서 시점을 바꾸고, 두 손가락으로 확대해 보세요.':'드래그해서 시점을 바꾸고, 마우스 휠로 확대해 보세요.',side:'bottom',align:'center'}},
{element:()=>visible('door-toggle')!,popover:{title:'문도 열어볼까요?',description:'이 버튼으로 문을 열고 닫을 수 있어요. 문 손잡이를 좌우로 밀어도 돼요.',side:'top',align:'start'}},
...(mobile?[{element:()=>visible('gyro')!,popover:{title:'휴대폰을 기울여 보세요',description:'안내를 마친 뒤 이 버튼을 눌러 연결해 주세요. 기울기를 쓰지 않아도 드래그로 둘러볼 수 있어요.',side:'bottom' as const,align:'end' as const}}]:[]),
];
start(steps,'main');
},
recenter(){if(!mobile||!done||recenterDone||!visible('gyro-recenter'))return;start([{element:()=>visible('gyro-recenter')!,popover:{title:'편한 자세로 중앙 맞추기',description:'휴대폰을 편하게 든 뒤 이 버튼을 누르면 지금 자세가 새로운 기준이 돼요. 확대 정도는 그대로 유지돼요.',side:'top',align:'end'}}],'recenter');},
};
}
+8
View File
@@ -0,0 +1,8 @@
/** Device-class hint only; actual sensor/permission checks remain in GyroController. */
export function isMobileDevice(nav?: { userAgent?: string; maxTouchPoints?: number; userAgentData?: { mobile?: boolean } }): boolean {
if (!nav) return false;
if (nav.userAgentData?.mobile === true) return true;
const agent = nav.userAgent ?? '';
return /Android|iPhone|iPad|iPod/i.test(agent)
|| (/Macintosh|MacIntel/i.test(agent) && (nav.maxTouchPoints ?? 0) > 1);
}
+232
View File
@@ -0,0 +1,232 @@
import type { CutoutPreparation, PreparedCutout } from '../cutout/CutoutPreparation';
export type PeekingExperimentState = { available: boolean; enabled: boolean; busy: boolean };
export type PeekingExperimentCallbacks = {
onAccept(result: PreparedCutout): void;
onToggle(enabled: boolean): void;
};
type Review = { controller: AbortController; preparation: CutoutPreparation | null; result: PreparedCutout | null };
/** An opt-in review boundary only: no depth inference, scene changes or journey.
* Accepted results become the caller's responsibility, including dispose(). */
export function initPeekingExperimentUI(root: Document, signal: AbortSignal, callbacks: PeekingExperimentCallbacks) {
// Retired from consumer settings; preserve the internal lifecycle adapter only.
const panel = root.querySelector<HTMLElement>('#internal-controls')!;
const settings = root.querySelector<HTMLDialogElement>('#settings-dialog')!;
const lifetime = new AbortController();
const listen = { signal: lifetime.signal };
const element = <K extends keyof HTMLElementTagNameMap>(tag: K, id: string, text = ''): HTMLElementTagNameMap[K] => {
const node = root.createElement(tag);
node.id = id;
node.textContent = text;
return node;
};
const button = (id: string, text: string): HTMLButtonElement => {
const node = element('button', id, text);
node.type = 'button';
return node;
};
const section = element('section', 'peeking-experiment');
section.className = 'peeking-experiment';
section.setAttribute('aria-labelledby', 'peeking-title');
const heading = element('h3', 'peeking-title', '마당에서 빼꼼');
const limitations = element('p', 'peeking-limitations', '가벼운 누끼 실험이라 털이 잘리거나 배경이 남을 수 있어요. 사진 속 자세를 새로 만들지 않으며, 옆에서 보면 납작해 보일 수 있어요.');
const routeNote = element('p', 'peeking-route-note', '할머니집 + B에서 보여요. A·포토부스·비교에서는 잠시 원본 사진을 보여줘요.');
const choose = button('peeking-choose', '마당에서 빼꼼 · 실험');
choose.setAttribute('aria-describedby', 'peeking-limitations peeking-route-note');
const picker = element('input', 'peeking-photo');
picker.type = 'file';
picker.accept = 'image/*';
picker.hidden = true;
picker.setAttribute('aria-label', '마당 실험에 사용할 사진 선택');
const toggle = button('peeking-toggle', '원본 사진으로 돌아가기');
section.append(heading, limitations, routeNote, choose, picker, toggle);
panel.append(section);
const dialog = element('dialog', 'peeking-preview-dialog');
dialog.className = 'peeking-preview-dialog';
dialog.setAttribute('aria-labelledby', 'peeking-preview-title');
dialog.setAttribute('aria-describedby', 'peeking-preview-note');
const title = element('h2', 'peeking-preview-title', '누끼를 먼저 확인해 주세요');
const note = element('p', 'peeking-preview-note', '털·귀·발과 남은 배경을 확인해 주세요. 괜찮을 때만 마당에 배치해요.');
const progress = element('p', 'peeking-progress');
progress.setAttribute('role', 'status');
progress.setAttribute('aria-live', 'polite');
progress.setAttribute('aria-atomic', 'true');
const error = element('p', 'peeking-error');
error.setAttribute('role', 'alert');
const comparison = element('div', 'peeking-comparison');
comparison.className = 'peeking-comparison';
const original = element('img', 'peeking-original');
original.alt = '배경을 포함한 원본 사진';
const cutout = element('img', 'peeking-cutout');
cutout.alt = '배경 제거 결과, 체크무늬는 투명한 부분';
for (const [image, label, suffix] of [[original, '원본 사진', 'original'], [cutout, '누끼 · 체크무늬는 투명', 'cutout']] as const) {
const figure = element('figure', `peeking-figure-${suffix}`);
const surface = element('div', `peeking-surface-${suffix}`);
surface.className = 'peeking-preview-surface';
surface.append(image);
figure.append(surface, element('figcaption', `peeking-caption-${suffix}`, label));
comparison.append(figure);
}
const warning = element('p', 'peeking-warning');
const actions = element('div', 'peeking-actions');
actions.className = 'peeking-actions';
const accept = button('peeking-accept', '이 누끼로 배치');
const retry = button('peeking-retry', '다시 시도하기');
const another = button('peeking-another', '다른 사진 선택');
const dismiss = button('peeking-cancel', '취소');
actions.append(accept, retry, another, dismiss);
dialog.append(title, note, progress, error, comparison, warning, actions);
root.body.append(dialog);
let state: PeekingExperimentState = { available: false, enabled: false, busy: false };
let active: Review | null = null;
let selectedFile: File | null = null;
let disposed = false;
const sync = (): void => {
choose.disabled = state.busy || disposed;
toggle.hidden = !state.available;
toggle.disabled = !state.available || state.busy || disposed;
toggle.textContent = state.enabled ? '원본 사진으로 돌아가기' : '마당 실험 다시 보기';
toggle.setAttribute('aria-pressed', String(state.enabled));
accept.disabled = !active?.result || state.busy || disposed;
};
const clearPreview = (): void => {
original.removeAttribute('src');
cutout.removeAttribute('src');
comparison.hidden = true;
warning.hidden = true;
warning.textContent = '';
error.hidden = true;
error.textContent = '';
retry.hidden = true;
dialog.setAttribute('aria-busy', 'false');
sync();
};
const close = (): void => {
if (dialog.open) dialog.close();
if (!disposed && settings.open && choose.isConnected && !choose.disabled) choose.focus({ preventScroll: true });
};
const cancel = (): void => {
const previous = active;
active = null;
selectedFile = null;
previous?.controller.abort();
previous?.result?.dispose();
previous?.preparation?.dispose();
clearPreview();
progress.textContent = '';
close();
};
const start = async (file: File): Promise<void> => {
if (disposed || state.busy || !settings.open) return;
cancel();
selectedFile = file;
const review: Review = { controller: new AbortController(), preparation: null, result: null };
active = review;
const current = (): boolean => !disposed && active === review && !review.controller.signal.aborted && settings.open;
progress.textContent = '누끼 미리보기를 준비하는 중…';
dialog.setAttribute('aria-busy', 'true');
dialog.showModal();
dismiss.focus({ preventScroll: true });
sync();
try {
// Neither the preparation module nor its model is loaded by normal photo use.
const { CutoutPreparation } = await import('../cutout/CutoutPreparation');
if (!current()) return;
review.preparation = new CutoutPreparation();
const result = await review.preparation.prepare(file, message => {
if (current()) progress.textContent = message;
}, review.controller.signal);
if (!current()) { result.dispose(); review.preparation.dispose(); return; }
review.result = result;
original.src = result.originalUrl;
cutout.src = result.cutoutUrl;
comparison.hidden = false;
const warnings: string[] = [];
if (result.warnings.includes('multiple-foreground')) warnings.push('여러 대상이 남아 있을 수 있어요.');
if (result.warnings.includes('tiny-foreground')) warnings.push('남은 대상이 아주 작아요. 다른 사진이 더 나을 수 있어요.');
warning.textContent = warnings.join(' ');
warning.hidden = warnings.length === 0;
progress.textContent = '결과를 확인한 뒤 직접 배치해 주세요.';
dialog.setAttribute('aria-busy', 'false');
sync();
} catch (reason) {
if (!current()) return;
review.preparation?.dispose();
review.preparation = null;
error.textContent = reason instanceof Error ? reason.message : '누끼를 준비하지 못했어요. 다시 시도하거나 다른 사진을 골라 주세요.';
error.hidden = false;
retry.hidden = false;
progress.textContent = '사진은 아직 바뀌지 않았어요.';
dialog.setAttribute('aria-busy', 'false');
sync();
}
};
const choosePhoto = (): void => {
if (disposed || state.busy || !settings.open) return;
cancel();
picker.value = '';
picker.click();
};
choose.addEventListener('click', choosePhoto, listen);
another.addEventListener('click', choosePhoto, listen);
picker.addEventListener('change', () => {
const file = picker.files?.[0];
picker.value = '';
if (file) void start(file);
}, listen);
retry.addEventListener('click', () => { if (selectedFile) void start(selectedFile); }, listen);
dismiss.addEventListener('click', cancel, listen);
dialog.addEventListener('cancel', event => { event.preventDefault(); event.stopPropagation(); cancel(); }, listen);
dialog.addEventListener('close', () => {
// close is queued by browsers. A previous dialog close must not cancel a
// newer review that has already reopened the dialog.
if (!dialog.open && active) cancel();
}, listen);
settings.addEventListener('close', () => { if (!settings.open) cancel(); }, listen);
accept.addEventListener('click', () => {
if (disposed || state.busy || !active?.result || !active.preparation) return;
const { result, preparation } = active;
active = null;
selectedFile = null;
let released = false;
// Preparation owns every result it creates. Transfer that owner as part of
// the disposable value rather than revoking live images on UI teardown.
const transferred: PreparedCutout = { ...result, dispose(): void {
if (released) return;
released = true;
result.dispose();
preparation.dispose();
} };
clearPreview();
close();
callbacks.onAccept(transferred);
}, listen);
toggle.addEventListener('click', () => {
if (!disposed && state.available && !state.busy) callbacks.onToggle(!state.enabled);
}, listen);
const dispose = (): void => {
if (disposed) return;
disposed = true;
cancel();
lifetime.abort();
signal.removeEventListener('abort', dispose);
section.remove();
dialog.remove();
};
signal.addEventListener('abort', dispose, { once: true });
clearPreview();
if (signal.aborted) dispose();
return {
cancel,
dispose,
setState(next: PeekingExperimentState): void {
if (disposed) return;
state = { ...next };
sync();
},
};
}
+41
View File
@@ -0,0 +1,41 @@
/** Presentation adapters only. The existing controls remain the renderer's state entry points. */
export function initPhotoBoothUI(root: Document, signal: AbortSignal): void {
const tabs = [...root.querySelectorAll<HTMLButtonElement>('[data-tool]')];
const panels = [...root.querySelectorAll<HTMLElement>('[data-panel]')];
const cards = [...root.querySelectorAll<HTMLButtonElement>('[data-pattern]')];
const pattern = root.querySelector<HTMLSelectElement>('#mosaic-style')!;
const effect = root.querySelector<HTMLSelectElement>('#effect')!;
const cardGroup = root.querySelector<HTMLElement>('#pattern-cards')!;
const selectTab = (index: number): void => {
tabs.forEach((tab, current) => {
tab.setAttribute('aria-selected', String(current === index));
tab.tabIndex = current === index ? 0 : -1;
});
panels.forEach(panel => { panel.hidden = panel.dataset.panel !== tabs[index].dataset.tool; });
};
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => selectTab(index), { signal });
tab.addEventListener('keydown', event => {
const next = event.key === 'ArrowRight' ? (index + 1) % tabs.length
: event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length
: event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : -1;
if (next < 0) return;
event.preventDefault();
selectTab(next);
tabs[next].focus();
}, { signal });
});
const syncCards = (): void => {
cards.forEach(card => card.setAttribute('aria-pressed', String(card.dataset.pattern === pattern.value)));
cardGroup.hidden = effect.value !== 'mosaic';
};
cards.forEach(card => card.addEventListener('click', () => {
if (pattern.value === card.dataset.pattern) return;
pattern.value = card.dataset.pattern!;
pattern.dispatchEvent(new Event('change', { bubbles: true }));
}, { signal }));
pattern.addEventListener('change', syncCards, { signal });
effect.addEventListener('change', syncCards, { signal });
selectTab(0);
syncCards();
}
+52
View File
@@ -0,0 +1,52 @@
/** Keeps a lost WebGL canvas from becoming an unexplained browser placeholder. */
export class RenderRecovery {
lost = false;
private readonly visibility: string;
constructor(
private readonly canvas: HTMLCanvasElement,
private readonly dialog: HTMLDialogElement,
private readonly reloadButton: HTMLButtonElement,
private readonly onLost: () => void,
private readonly onRestored: () => void,
private readonly reload: () => void,
) {
this.visibility = canvas.style.visibility;
canvas.addEventListener('webglcontextlost', this.contextLost);
canvas.addEventListener('webglcontextrestored', this.contextRestored);
dialog.addEventListener('cancel', this.preventDismiss);
reloadButton.addEventListener('click', this.reload);
}
private preventDismiss = (event: Event): void => { event.preventDefault(); };
private contextLost = (event: Event): void => {
event.preventDefault(); // Permit the browser/Three.js to restore the context.
if (this.lost) return;
this.lost = true;
this.canvas.style.visibility = 'hidden';
this.onLost();
if (!this.dialog.open) this.dialog.showModal();
this.reloadButton.focus();
};
private contextRestored = (): void => {
if (!this.lost) return;
try {
// Three.js registered its own restoration listener first. App-owned cached
// render-target contents must also be invalidated before the next frame.
this.onRestored();
} catch {
// Keep the explicit reload choice, never launch an automatic reload loop.
return;
}
this.lost = false;
this.canvas.style.visibility = this.visibility;
if (this.dialog.open) this.dialog.close();
};
dispose(): void {
this.canvas.removeEventListener('webglcontextlost', this.contextLost);
this.canvas.removeEventListener('webglcontextrestored', this.contextRestored);
this.dialog.removeEventListener('cancel', this.preventDismiss);
this.reloadButton.removeEventListener('click', this.reload);
if (this.dialog.open) this.dialog.close();
}
}
+48
View File
@@ -0,0 +1,48 @@
export type HintKind = 'drag' | 'tilt' | 'door-open' | 'door-close';
export interface HintContext { enabled: boolean; tiltActive: boolean; sliding: boolean; doorOpen: number; reducedMotion: boolean }
export interface SceneHint { kind: HintKind; phase: 'in' | 'hold' | 'out'; text: string }
const text: Record<HintKind, string> = {
drag: '드래그해서 유리 너머를 둘러보세요',
tilt: '휴대폰을 살짝 기울여 둘러보세요',
'door-open': '손잡이를 왼쪽으로 밀면 열려요',
'door-close': '손잡이를 오른쪽으로 밀면 닫혀요',
};
/** Session-only teaching state. Existing UI RAF supplies time; no timers or GL invalidation. */
export class SceneHints {
private learned = new Set<HintKind>();
private shown = new Map<HintKind, number>();
private sequence = 0;
private ready = false;
private due = Infinity;
private active: { kind: HintKind; start: number; reduced: boolean } | null = null;
interact(now: number, kind?: HintKind): void {
if (kind) this.learned.add(kind);
this.active = null;
this.due = now + 5000;
}
update(now: number, context: HintContext): SceneHint | null {
if (!context.enabled) { this.ready = false; this.active = null; this.due = Infinity; return null; }
if (!this.ready) { this.ready = true; this.due = now + 5000; }
const candidates: HintKind[] = ['drag'];
if (context.tiltActive) candidates.push('tilt');
if (context.sliding) candidates.push(context.doorOpen > .001 ? 'door-close' : 'door-open');
const eligible = candidates.filter(kind => !this.learned.has(kind));
if (this.active && !eligible.includes(this.active.kind)) { this.active = null; this.due = now + 5000; }
if (!eligible.length) { this.due = Infinity; return null; }
if (!Number.isFinite(this.due)) this.due = now + 5000;
if (!this.active && now >= this.due) {
// Teach each available gesture before repeating an unlearned one.
const kind = eligible.sort((a, b) => (this.shown.get(a) ?? -1) - (this.shown.get(b) ?? -1))[0];
this.shown.set(kind, this.sequence++);
this.active = { kind, start: now, reduced: context.reducedMotion };
}
if (!this.active) return null;
const age = now - this.active.start;
const reduced = context.reducedMotion || this.active.reduced;
if (age >= (reduced ? 3000 : 4400)) { this.active = null; this.due = now + 18000; return null; }
return { kind: this.active.kind, text: text[this.active.kind], phase: reduced ? 'hold' : age < 600 ? 'in' : age < 3600 ? 'hold' : 'out' };
}
}
+26
View File
@@ -0,0 +1,26 @@
/* Scoped to the explicit experiment; the existing photo controls stay intact. */
.peeking-experiment { margin-top:20px; padding-top:18px; border-top:1px solid var(--line, #cfc3ad); }
.peeking-experiment h3 { margin:0 0 8px; font-size:16px; }
.peeking-experiment p { margin:8px 0 12px; font-size:13px; line-height:1.65; color:var(--ink, #3e3429); }
.peeking-experiment button { width:100%; min-height:44px; padding:10px 12px; margin-top:8px; font-size:14px; }
.peeking-experiment [hidden],.peeking-preview-dialog [hidden] { display:none !important; }
.peeking-preview-dialog { width:min(760px, calc(100% - 32px)); max-width:760px; max-height:calc(100dvh - 32px); margin:auto; overflow:auto; overscroll-behavior:contain; padding:24px; border:1px solid var(--line, #cfc3ad); border-radius:12px; background:var(--cream, #f7f1e5); color:var(--ink, #3e3429); box-sizing:border-box; }
.peeking-preview-dialog::backdrop { background:#201a13a6; }
.peeking-preview-dialog h2 { margin:0 0 10px; font-size:20px; line-height:1.4; }
.peeking-preview-dialog p { font-size:14px; line-height:1.6; overflow-wrap:anywhere; }
.peeking-preview-dialog #peeking-error { color:#862d25; }
.peeking-comparison { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin:18px 0; }
.peeking-comparison figure { min-width:0; margin:0; }
.peeking-preview-surface { display:grid; place-items:center; aspect-ratio:1; overflow:hidden; border:1px solid #aa9c85; border-radius:6px; background-color:#fff; background-image:repeating-conic-gradient(#e3dfd5 0% 25%, transparent 0% 50%); background-size:20px 20px; }
.peeking-preview-surface img { display:block; width:100%; height:100%; min-width:0; min-height:0; object-fit:contain; }
.peeking-comparison figcaption { margin-top:7px; font-size:13px; line-height:1.4; }
.peeking-actions { display:flex; flex-wrap:wrap; gap:10px; margin-top:20px; }
.peeking-actions button { min-height:44px; padding:10px 14px; font-size:14px; }
.peeking-actions #peeking-accept { background:#ede4d2; color:#3e3429; border-color:#aa9674; }
.peeking-experiment button:focus-visible,.peeking-preview-dialog button:focus-visible { outline:3px solid #765534; outline-offset:3px; }
@media(max-width:480px) {
.peeking-preview-dialog { width:calc(100% - 20px); max-height:calc(100dvh - 20px); padding:18px; }
.peeking-comparison { gap:8px; }
.peeking-comparison figcaption { font-size:12px; }
.peeking-actions button { flex:1 1 calc(50% - 10px); }
}
+6
View File
@@ -0,0 +1,6 @@
export type ThemeId = 'wood' | 'booth';
export const THEME_STORAGE_KEY = 'behind-glass:theme';
export const THEMES = {
wood: { name: '할머니집 창가', background: '#e6dfcf', room: ['#b5ac94', '#8b8c73', '#6e705d'], light: '#ddd8b8', mullion: '#787b65' },
booth: { name: '통통 포토부스', background: '#f7f0e5', room: ['#eadfed', '#d2c2d8', '#b4a2c1'], light: '#fff7e6', mullion: '#af99ba' }
} as const;