Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { applyMeshGeometryDelta, diffMeshGeometryBuffers } from "../../../protocol/mesh-geometry-delta";

View File

@@ -0,0 +1,75 @@
import {
BufferGeometry,
Color,
Float32BufferAttribute,
Group,
Line,
LineBasicMaterial,
type Object3D,
} from "../vendor/three/three.module.js";
import type { GreasePencilDataIR, GreasePencilFrameIR } from "../../../protocol/grease-pencil";
import type { SceneNodeIR } from "../../../protocol/scene-ir";
function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): GreasePencilFrameIR | undefined {
let selected: GreasePencilFrameIR | undefined;
for (const candidate of frames) {
if (candidate.frame <= frame && (!selected || candidate.frame > selected.frame)) selected = candidate;
}
return selected;
}
export function createGreasePencilObject(data: GreasePencilDataIR, frame: number): Object3D | null {
if (data.geometryStatus !== "available") return null;
const group = new Group();
for (const layer of data.layers) {
if (!layer.visible || layer.opacity <= 0) continue;
const drawing = activeFrame(layer.frames, frame)?.drawing;
if (!drawing) continue;
for (const stroke of drawing.strokes) {
if (!stroke.points || stroke.points.length < 2) continue;
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
const positions = new Float32Array(pointCount * 3);
let red = 0;
let green = 0;
let blue = 0;
let opacity = 0;
for (let index = 0; index < pointCount; index++) {
const point = stroke.points[index % stroke.points.length];
positions[index * 3] = point.position[0];
positions[index * 3 + 1] = point.position[2];
positions[index * 3 + 2] = -point.position[1];
}
for (const point of stroke.points) {
const color = point.vertexColor ?? [0.2, 0.2, 0.2, 1];
red += color[0];
green += color[1];
blue += color[2];
opacity += point.opacity * color[3];
}
const divisor = stroke.points.length;
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const material = new LineBasicMaterial({
color: new Color(red / divisor, green / divisor, blue / divisor),
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor)),
transparent: true,
});
group.add(new Line(geometry, material));
}
}
return group.children.length > 0 ? group : null;
}
export function applyGreasePencilTransform(object: Object3D, node: SceneNodeIR): void {
const [x, y, z] = node.transform.translation;
const [rx, ry, rz] = node.transform.rotationEuler;
object.position.set(x, z, -y);
object.rotation.set(rx, rz, -ry);
object.scale.set(...node.transform.scale);
object.name = node.name;
object.traverse((child) => {
child.userData.sceneNodeId = node.id;
child.userData.blenderId = node.id;
child.userData.greasePencilDataId = node.dataId;
});
}

View File

@@ -0,0 +1,122 @@
import { Object3D, PerspectiveCamera, OrthographicCamera, Vector3, type Camera } from "../vendor/three/three.module.js";
import { buildLODCacheKey } from "../../../protocol/lod";
export { buildLODCacheKey };
export interface LODSelectionPolicy {
/** Descending minimum projected pixel heights for level 0, 1, ... */
screenHeightThresholds: readonly number[];
/** Fractional dead-band around a threshold, preventing rapid toggling. */
hysteresis?: number;
}
export interface LODSelectionResult {
level: number;
projectedPixelHeight: number;
}
function validateThresholds(thresholds: readonly number[]): void {
if (thresholds.length === 0 || thresholds.some((value) => !Number.isFinite(value) || value <= 0)) {
throw new Error("LOD screenHeightThresholds must contain positive finite values");
}
for (let index = 1; index < thresholds.length; index++) {
if (thresholds[index] >= thresholds[index - 1]) throw new Error("LOD thresholds must strictly decrease");
}
}
export function selectLODLevel(projectedPixelHeight: number, policy: LODSelectionPolicy, currentLevel?: number): LODSelectionResult {
validateThresholds(policy.screenHeightThresholds);
if (!Number.isFinite(projectedPixelHeight) || projectedPixelHeight < 0) throw new Error("projectedPixelHeight must be finite and non-negative");
const hysteresis = policy.hysteresis ?? 0.08;
if (!Number.isFinite(hysteresis) || hysteresis < 0 || hysteresis >= 0.5) throw new Error("LOD hysteresis must be in [0, 0.5)");
let target = policy.screenHeightThresholds.findIndex((threshold) => projectedPixelHeight >= threshold);
if (target < 0) target = policy.screenHeightThresholds.length - 1;
if (currentLevel === undefined) return { level: target, projectedPixelHeight };
const current = Math.max(0, Math.min(policy.screenHeightThresholds.length - 1, Math.trunc(currentLevel)));
if (target === current || policy.screenHeightThresholds.length === 1) return { level: current, projectedPixelHeight };
if (target > current) {
const boundary = policy.screenHeightThresholds[current];
if (projectedPixelHeight >= boundary * (1 - hysteresis)) return { level: current, projectedPixelHeight };
}
else {
const boundary = policy.screenHeightThresholds[target];
if (projectedPixelHeight < boundary * (1 + hysteresis)) return { level: current, projectedPixelHeight };
}
return { level: target, projectedPixelHeight };
}
export function projectedPixelHeight(radius: number, distance: number, camera: Camera, viewportHeight: number): number {
if (!Number.isFinite(radius) || radius < 0 || !Number.isFinite(distance) || distance <= 0 || viewportHeight <= 0) return 0;
if ((camera as OrthographicCamera).isOrthographicCamera) {
const orthographic = camera as OrthographicCamera;
return (radius * 2 * viewportHeight) / Math.max(0.0001, orthographic.top - orthographic.bottom);
}
const perspective = camera as PerspectiveCamera;
const focalPixels = viewportHeight / (2 * Math.tan((perspective.fov * Math.PI) / 360));
return (radius * 2 * focalPixels) / distance;
}
export interface ThreeLODLevel {
object: Object3D;
screenHeightThreshold: number;
}
interface Entry {
levels: ThreeLODLevel[];
radius: number;
currentLevel?: number;
enabled: boolean;
}
/** Selects already-generated native LOD objects without rebuilding the scene. */
export class ThreeLODAdapter {
private readonly entries = new Map<string, Entry>();
register(meshId: string, levels: readonly ThreeLODLevel[], radius: number): void {
if (!meshId || levels.length === 0) throw new Error("LOD meshId and levels are required");
const normalized = [...levels];
const thresholds = normalized.map((level) => level.screenHeightThreshold);
validateThresholds(thresholds);
this.entries.set(meshId, { levels: normalized, radius: Math.max(0, radius), enabled: true });
normalized.forEach((level, index) => { level.object.visible = index === 0; });
}
unregister(meshId: string): void {
this.entries.delete(meshId);
}
setEnabled(meshId: string, enabled: boolean): void {
const entry = this.entries.get(meshId);
if (!entry) return;
entry.enabled = enabled;
if (!enabled) entry.levels.forEach((level) => { level.object.visible = false; });
}
update(camera: Camera, viewportHeight: number, hysteresis = 0.08): Map<string, LODSelectionResult> {
const result = new Map<string, LODSelectionResult>();
const cameraPosition = new Vector3();
camera.getWorldPosition(cameraPosition);
for (const [meshId, entry] of this.entries) {
if (!entry.enabled) {
entry.levels.forEach((level) => { level.object.visible = false; });
continue;
}
const center = new Vector3();
entry.levels[0].object.getWorldPosition(center);
const distance = Math.max(0.0001, cameraPosition.distanceTo(center));
const projected = projectedPixelHeight(entry.radius, distance, camera, viewportHeight);
const selection = selectLODLevel(projected, {
screenHeightThresholds: entry.levels.map((level) => level.screenHeightThreshold),
hysteresis,
}, entry.currentLevel);
entry.currentLevel = selection.level;
entry.levels.forEach((level, index) => { level.object.visible = index === selection.level; });
result.set(meshId, selection);
}
return result;
}
clear(): void {
this.entries.clear();
}
}

View File

@@ -0,0 +1,174 @@
import {
BufferGeometry,
Color,
Float32BufferAttribute,
Group,
Line,
LineBasicMaterial,
LineSegments,
Mesh,
MeshPhysicalMaterial,
Points,
PointsMaterial,
SphereGeometry,
type Object3D,
} from "../vendor/three/three.module.js";
import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
export type NonMeshElementKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
function blenderPosition(x: number, y: number, z: number): [number, number, number] {
return [x, z, -y];
}
function createControlPointGeometry(points: ArrayLike<number>, start: number, end: number, closed: boolean): BufferGeometry | null {
if (end - start < 2) return null;
const pointCount = end - start + (closed ? 1 : 0);
const positions = new Float32Array(pointCount * 3);
for (let local = 0; local < pointCount; local++) {
const index = closed && local === end - start ? start : start + local;
const point = index * 3;
const target = local * 3;
positions[target] = points[point];
positions[target + 1] = points[point + 2];
positions[target + 2] = -points[point + 1];
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
return geometry;
}
function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = data.controlPoints ?? [], splineOffsets?: ArrayLike<number>): Object3D | null {
if (points.length < 6) return null;
const group = new Group();
const offsets = splineOffsets && splineOffsets.length > 1 ? splineOffsets : data.splineOffsets && data.splineOffsets.length > 1 ? data.splineOffsets : [0, points.length / 3];
const material = new LineBasicMaterial({ color: new Color(0x67b7ff), transparent: true, opacity: 0.95 });
const handlePoints = data.handlePoints;
const handlePointIndices = data.handlePointIndices ?? (handlePoints?.length === (points.length / 3) * 6 ? Array.from({ length: points.length / 3 }, (_, index) => index) : []);
const hasHandles = data.type === "CURVE" && Boolean(handlePoints && handlePointIndices.length > 0 && handlePoints.length === handlePointIndices.length * 6);
const handleLines: number[] = [];
const handlePositions: number[] = [];
const handleIndexMap: number[] = [];
const handleKindMap: NonMeshElementKind[] = [];
if (hasHandles) {
for (let handleIndex = 0; handleIndex < handlePointIndices.length; handleIndex++) {
const pointIndex = handlePointIndices[handleIndex];
const point = pointIndex * 3;
const handle = handleIndex * 6;
const anchor = blenderPosition(points[point], points[point + 1], points[point + 2]);
const left = blenderPosition(handlePoints![handle], handlePoints![handle + 1], handlePoints![handle + 2]);
const right = blenderPosition(handlePoints![handle + 3], handlePoints![handle + 4], handlePoints![handle + 5]);
handleLines.push(anchor[0], anchor[1], anchor[2], left[0], left[1], left[2], anchor[0], anchor[1], anchor[2], right[0], right[1], right[2]);
handlePositions.push(left[0], left[1], left[2], right[0], right[1], right[2]);
handleIndexMap.push(pointIndex, pointIndex);
handleKindMap.push("HANDLE_LEFT", "HANDLE_RIGHT");
}
}
for (let index = 0; index < offsets.length - 1; index++) {
const start = Math.max(0, Math.floor(offsets[index]));
const end = Math.min(points.length / 3, Math.floor(offsets[index + 1]));
const closed = data.cyclicU?.[index] ?? false;
const geometry = createControlPointGeometry(points, start, end, closed);
if (!geometry) continue;
const line = new Line(geometry, material.clone());
group.add(line);
}
const controlGeometry = new BufferGeometry();
const controlPositions = new Float32Array(points.length);
for (let index = 0; index < points.length; index += 3) {
controlPositions.set(blenderPosition(points[index], points[index + 1], points[index + 2]), index);
}
controlGeometry.setAttribute("position", new Float32BufferAttribute(controlPositions, 3));
const controls = new Points(controlGeometry, new PointsMaterial({ color: new Color(0x67b7ff), size: 0.09, sizeAttenuation: true }));
controls.userData.nonMeshDataId = data.id;
controls.userData.nonMeshPointIndexMap = Array.from({ length: points.length / 3 }, (_, index) => index);
controls.userData.nonMeshPointKindMap = Array.from({ length: points.length / 3 }, () => "CONTROL_POINT" as NonMeshElementKind);
group.add(controls);
if (hasHandles) {
const lineGeometry = new BufferGeometry();
lineGeometry.setAttribute("position", new Float32BufferAttribute(handleLines, 3));
const lines = new LineSegments(lineGeometry, new LineBasicMaterial({ color: new Color(0x9a7bff), transparent: true, opacity: 0.65 }));
group.add(lines);
const pointGeometry = new BufferGeometry();
pointGeometry.setAttribute("position", new Float32BufferAttribute(handlePositions, 3));
const handles = new Points(pointGeometry, new PointsMaterial({ color: new Color(0xd7b8ff), size: 0.1, sizeAttenuation: true }));
handles.userData.nonMeshDataId = data.id;
handles.userData.nonMeshPointIndexMap = handleIndexMap;
handles.userData.nonMeshPointKindMap = handleKindMap;
group.add(handles);
}
return group.children.length > 0 ? group : null;
}
function createPointPreview(data: NonMeshDataIR, points: ArrayLike<number> = data.controlPoints ?? []): Object3D | null {
if (points.length < 3) return null;
const positions = new Float32Array(points.length);
for (let index = 0; index < points.length; index += 3) {
positions[index] = points[index];
positions[index + 1] = points[index + 2];
positions[index + 2] = -points[index + 1];
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const pointObject = new Points(geometry, new PointsMaterial({ color: new Color(0xffc36b), size: 0.08, sizeAttenuation: true }));
pointObject.userData.nonMeshDataId = data.id;
pointObject.userData.nonMeshPointOffset = 0;
return pointObject;
}
function createMetaballPreview(data: NonMeshDataIR): Object3D | null {
if (!data.elements || data.elements.length === 0) return null;
const group = new Group();
for (const element of data.elements) {
const [x, y, z] = blenderPosition(...element.position);
const sphere = new Mesh(
new SphereGeometry(Math.max(0.001, element.radius), 16, 10),
new MeshPhysicalMaterial({ color: 0x6eb7ff, roughness: 0.36, metalness: 0.02 }),
);
sphere.position.set(x, y, z);
sphere.scale.set(...(element.scale.map((value) => value > 0 ? value : 1) as [number, number, number]));
sphere.castShadow = true;
sphere.receiveShadow = true;
sphere.userData.nonMeshDataId = data.id;
group.add(sphere);
}
return group;
}
function binaryPreviewData(data: NonMeshDataIR, chunks: readonly NonMeshGeometryChunk[]): { points: Float32Array; offsets?: Uint32Array } | null {
if (data.geometryStatus !== "binary") return null;
const matching = chunks.filter((chunk) => chunk.dataId === data.id).sort((left, right) => left.chunkIndex - right.chunkIndex);
if (matching.length === 0) return null;
const first = matching[0];
if (matching.some((chunk, index) => chunk.chunkIndex !== index || chunk.totalPointCount !== first.totalPointCount)) return null;
const points = new Float32Array(first.totalPointCount * 3);
for (const chunk of matching) points.set(new Float32Array(chunk.positions), chunk.pointOffset * 3);
return { points, offsets: first.curveOffsets ? new Uint32Array(first.curveOffsets) : undefined };
}
export function createNonMeshObject(data: NonMeshDataIR, chunks: readonly NonMeshGeometryChunk[] = []): Object3D | null {
const binary = binaryPreviewData(data, chunks);
if (data.geometryStatus !== "available" && !binary) return null;
const points = binary?.points ?? data.controlPoints;
const offsets = binary?.offsets;
if (data.type === "METABALL") return createMetaballPreview(data);
if (data.type === "POINT_CLOUD" || data.type === "CURVES" || data.type === "HAIR") return points ? createPointPreview(data, points) : null;
if (data.type === "CURVE" || data.type === "SURFACE" || data.type === "FONT") return points ? createCurvePreview(data, points, offsets) : null;
return null;
}
export function applyNonMeshTransform(object: Object3D, node: SceneNodeIR): void {
const [x, y, z] = node.transform.translation;
const [rx, ry, rz] = node.transform.rotationEuler;
object.position.set(x, z, -y);
object.rotation.set(rx, rz, -ry);
object.scale.set(...node.transform.scale);
object.name = node.name;
object.userData.sceneNodeId = node.id;
object.userData.blenderId = node.id;
object.traverse((child) => {
child.userData.sceneNodeId = node.id;
child.userData.blenderId = node.id;
});
}

View File

@@ -0,0 +1,25 @@
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import type { NonMeshElementKind } from "./nonmesh";
export type OffscreenViewportRequest =
| { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number }
| { type: "snapshot"; snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] }
| { type: "textureAssets"; assets: GPUTextureAsset[] }
| { type: "resize"; width: number; height: number; pixelRatio: number }
| { type: "selection"; objectIds: string[] }
| { type: "interaction"; editMode: boolean; selectionMode: MeshElementMode }
| { type: "orbit"; deltaX: number; deltaY: number; zoom: number }
| { type: "pick"; x: number; y: number; additive: boolean }
| { type: "dispose" };
export type OffscreenViewportResponse =
| { type: "ready" }
| { type: "frame"; visiblePixels: number }
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number }
| { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[] }
| { type: "selected"; objectId: string; additive: boolean }
| { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind }
| { type: "error"; message: string };

View File

@@ -0,0 +1,232 @@
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import { cloneMeshGeometryBuffers } from "../../../protocol/mesh-geometry-delta";
import { nonMeshChunkTransferables } from "../../../protocol/nonmesh-binary";
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offscreen-viewport-protocol";
import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr";
import type { NonMeshElementKind } from "./nonmesh";
export interface ViewportBackend {
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
setSelection(objectIds: ReadonlySet<string>): void;
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void;
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void;
dispose(): void;
}
export function supportsOffscreenViewport(canvas: HTMLCanvasElement): boolean {
return typeof canvas.transferControlToOffscreen === "function" && typeof Worker !== "undefined";
}
interface SharedOffscreenBackend {
renderer: OffscreenViewportRenderer;
references: number;
disposeTimer?: number;
}
const sharedBackends = new WeakMap<HTMLCanvasElement, SharedOffscreenBackend>();
export function acquireOffscreenViewportRenderer(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
): OffscreenViewportRenderer {
const existing = sharedBackends.get(canvas);
if (existing) {
if (existing.disposeTimer !== undefined) window.clearTimeout(existing.disposeTimer);
existing.disposeTimer = undefined;
existing.references += 1;
return existing.renderer;
}
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect);
sharedBackends.set(canvas, { renderer, references: 1 });
return renderer;
}
export function releaseOffscreenViewportRenderer(canvas: HTMLCanvasElement, renderer: OffscreenViewportRenderer): void {
const existing = sharedBackends.get(canvas);
if (!existing || existing.renderer !== renderer) return;
existing.references = Math.max(0, existing.references - 1);
if (existing.references > 0) return;
existing.disposeTimer = window.setTimeout(() => {
if (existing.references > 0) return;
existing.renderer.dispose();
sharedBackends.delete(canvas);
}, 0);
}
export class OffscreenViewportRenderer implements ViewportBackend {
private readonly canvas: HTMLCanvasElement;
private readonly worker: Worker;
private readonly resizeObserver: ResizeObserver;
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
private lastSnapshot: SceneSnapshotIR | null = null;
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
private lastNonMeshGeometryBuffers: NonMeshGeometryChunk[] | null = null;
constructor(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
) {
if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable");
this.canvas = canvas;
this.onSelect = onSelect;
this.onElementSelect = onElementSelect;
this.worker = new Worker(new URL("../workers/viewport-render.worker.ts", import.meta.url), { type: "module" });
this.worker.onmessage = (event: MessageEvent<OffscreenViewportResponse>) => this.handleMessage(event.data);
const offscreen = canvas.transferControlToOffscreen();
const request: OffscreenViewportRequest = {
type: "init",
canvas: offscreen,
width: Math.max(1, canvas.clientWidth),
height: Math.max(1, canvas.clientHeight),
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
};
this.worker.postMessage(request, [offscreen]);
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
canvas.addEventListener("pointerdown", this.pointerDown);
canvas.addEventListener("pointermove", this.pointerMove);
canvas.addEventListener("pointerup", this.pointerUp);
canvas.addEventListener("pointercancel", this.pointerUp);
canvas.addEventListener("wheel", this.wheel, { passive: false });
canvas.dataset.rendererBackend = "offscreen-worker";
canvas.dataset.pbrProfile = PBR_PROFILE;
canvas.dataset.toneMapping = PBR_TONE_MAPPING;
canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
}
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
if (snapshot === this.lastSnapshot && geometryBuffers === this.lastGeometryBuffers && nonMeshGeometryBuffers === this.lastNonMeshGeometryBuffers) return;
this.lastSnapshot = snapshot;
const udim = snapshot.images.find((image) => image.tiles && image.tiles.length > 0);
if (udim) {
const gate = gateUDIMImage(udim);
this.canvas.dataset.udimGate = gate.status.toLowerCase();
this.canvas.dataset.udimGateCode = gate.issues[0]?.code ?? "";
}
const worldId = snapshot.scenes[0]?.worldId;
const world = snapshot.worlds.find((candidate) => candidate.id === worldId) ?? snapshot.worlds[0];
if (world?.environmentImageId) {
const gate = gateEnvironmentImage(snapshot.images.find((image) => image.id === world.environmentImageId));
this.canvas.dataset.iblGate = gate.status.toLowerCase();
this.canvas.dataset.iblGateCode = gate.issues[0]?.code ?? "";
}
this.lastGeometryBuffers = geometryBuffers;
this.lastNonMeshGeometryBuffers = nonMeshGeometryBuffers;
const cloned = cloneMeshGeometryBuffers(geometryBuffers);
const nonMeshCloned = nonMeshGeometryBuffers.map((chunk) => ({
...chunk,
positions: chunk.positions.slice(0),
radii: chunk.radii?.slice(0),
curveOffsets: chunk.curveOffsets?.slice(0),
attributes: chunk.attributes.map((attribute) => ({ ...attribute, data: attribute.data.slice(0) })),
}));
const transfer: Transferable[] = [];
for (const payload of cloned) for (const value of Object.values(payload)) if (value instanceof ArrayBuffer) transfer.push(value);
transfer.push(...nonMeshChunkTransferables(nonMeshCloned));
this.worker.postMessage({ type: "snapshot", snapshot, geometryBuffers: cloned, nonMeshGeometryBuffers: nonMeshCloned } satisfies OffscreenViewportRequest, transfer);
}
setTextureAssets(assets: readonly GPUTextureAsset[]): void {
if (assets.length === 0) {
this.canvas.dataset.textureStatus = "none";
this.canvas.dataset.textureLoaded = "0";
this.canvas.dataset.textureBytes = "0";
return;
}
const cloned = assets.map((asset) => ({ ...asset, data: asset.data.slice(0) }));
const transfer: Transferable[] = cloned.map((asset) => asset.data);
this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer);
}
setSelection(objectIds: ReadonlySet<string>): void {
this.worker.postMessage({ type: "selection", objectIds: [...objectIds] } satisfies OffscreenViewportRequest);
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
this.worker.postMessage({ type: "interaction", editMode, selectionMode } satisfies OffscreenViewportRequest);
}
installLODLevels(): void {
// The main-thread renderer remains the adaptive LOD owner; the worker path
// renders the source instanced mesh and retains native frustum culling.
}
private resize(): void {
this.worker.postMessage({
type: "resize",
width: Math.max(1, this.canvas.clientWidth),
height: Math.max(1, this.canvas.clientHeight),
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
} satisfies OffscreenViewportRequest);
}
private pointerDown = (event: PointerEvent): void => {
this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
this.canvas.setPointerCapture(event.pointerId);
};
private pointerMove = (event: PointerEvent): void => {
if (!this.pointer || this.pointer.id !== event.pointerId || (event.buttons & 1) === 0) return;
const deltaX = event.clientX - this.pointer.x;
const deltaY = event.clientY - this.pointer.y;
if (Math.abs(deltaX) + Math.abs(deltaY) > 1) this.pointer.moved = true;
this.pointer.x = event.clientX;
this.pointer.y = event.clientY;
this.worker.postMessage({ type: "orbit", deltaX, deltaY, zoom: 0 } satisfies OffscreenViewportRequest);
};
private pointerUp = (event: PointerEvent): void => {
if (!this.pointer || this.pointer.id !== event.pointerId) return;
if (!this.pointer.moved) {
const bounds = this.canvas.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1;
const y = -((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1;
this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey } satisfies OffscreenViewportRequest);
}
this.pointer = null;
};
private wheel = (event: WheelEvent): void => {
event.preventDefault();
this.worker.postMessage({ type: "orbit", deltaX: 0, deltaY: 0, zoom: event.deltaY } satisfies OffscreenViewportRequest);
};
private handleMessage(message: OffscreenViewportResponse): void {
if (message.type === "selected") this.onSelect?.(message.objectId, message.additive);
else if (message.type === "elementSelected") this.onElementSelect?.(message.meshId, message.mode, message.index, message.additive, message.nonMeshKind);
else if (message.type === "frame") this.canvas.dataset.rendererPixels = String(message.visiblePixels);
else if (message.type === "snapshotStatus") {
this.canvas.dataset.nonMeshCount = String(message.nonMeshCount);
this.canvas.dataset.nonMeshBlockedCount = String(message.nonMeshBlockedCount);
this.canvas.dataset.greasePencilCount = String(message.greasePencilCount);
this.canvas.dataset.greasePencilBlockedCount = String(message.greasePencilBlockedCount);
}
else if (message.type === "textureStatus") {
this.canvas.dataset.textureStatus = message.rejected > 0 ? "blocked" : "ready";
this.canvas.dataset.textureLoaded = String(message.loaded);
this.canvas.dataset.textureBytes = String(message.bytes);
this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? "";
}
else if (message.type === "error") this.canvas.dataset.rendererError = message.message;
}
dispose(): void {
this.resizeObserver.disconnect();
this.canvas.removeEventListener("pointerdown", this.pointerDown);
this.canvas.removeEventListener("pointermove", this.pointerMove);
this.canvas.removeEventListener("pointerup", this.pointerUp);
this.canvas.removeEventListener("pointercancel", this.pointerUp);
this.canvas.removeEventListener("wheel", this.wheel);
this.worker.postMessage({ type: "dispose" } satisfies OffscreenViewportRequest);
this.worker.terminate();
}
}

View File

@@ -0,0 +1,130 @@
import {
ACESFilmicToneMapping,
Color,
DirectionalLight,
DoubleSide,
MeshPhysicalMaterial,
Object3D,
PCFShadowMap,
PointLight,
RectAreaLight,
SRGBColorSpace,
SpotLight,
Vector3,
type Light,
type WebGLRenderer,
} from "../vendor/three/three.module.js";
import type { LightIR, MaterialIR, SceneNodeIR } from "../../../protocol/scene-ir";
export const PBR_PROFILE = "physical-v1";
export const PBR_TONE_MAPPING = "aces";
export const PBR_SHADOW_PROFILE = "pcf-1024";
function clamp(value: number | undefined, minimum: number, maximum: number, fallback: number): number {
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value as number)) : fallback;
}
export function configurePBRRenderer(renderer: WebGLRenderer, exposure = 0): void {
renderer.outputColorSpace = SRGBColorSpace;
renderer.toneMapping = ACESFilmicToneMapping;
renderer.toneMappingExposure = 2 ** clamp(exposure, -8, 8, 0);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = PCFShadowMap;
}
export function createPBRMaterial(definition?: MaterialIR, active = false): MeshPhysicalMaterial {
const baseColor = definition?.baseColor ?? (active ? [0.83, 0.48, 0.29, 1] : [0.55, 0.62, 0.69, 1]);
const emission = definition?.emissionColor ?? [0, 0, 0, 1];
const alpha = clamp(definition?.alpha ?? baseColor[3], 0, 1, 1);
const transmission = clamp(definition?.transmissionWeight, 0, 1, 0);
const material = new MeshPhysicalMaterial({
color: new Color().setRGB(baseColor[0], baseColor[1], baseColor[2]),
roughness: clamp(definition?.roughness, 0, 1, 0.45),
metalness: clamp(definition?.metallic, 0, 1, 0.05),
ior: clamp(definition?.ior, 1, 2.333, 1.45),
// Blender's neutral Specular IOR Level is 0.5; Three's neutral multiplier is 1.0.
specularIntensity: clamp((definition?.specularIORLevel ?? 0.5) * 2, 0, 1, 1),
clearcoat: clamp(definition?.coatWeight, 0, 1, 0),
clearcoatRoughness: clamp(definition?.coatRoughness, 0, 1, 0.03),
transmission,
emissive: new Color().setRGB(emission[0], emission[1], emission[2]),
emissiveIntensity: clamp(definition?.emissionStrength, 0, 1_000_000, 1),
opacity: alpha,
transparent: alpha < 0.999,
depthWrite: alpha >= 0.999,
vertexColors: true,
side: DoubleSide,
});
material.userData.baseEmissive = material.emissive.getHex();
material.userData.baseEmissiveIntensity = material.emissiveIntensity;
material.userData.pbrProfile = PBR_PROFILE;
return material;
}
export function setPBRMaterialSelected(material: MeshPhysicalMaterial, selected: boolean): void {
const baseEmissive = typeof material.userData.baseEmissive === "number" ? material.userData.baseEmissive : 0;
const baseIntensity = typeof material.userData.baseEmissiveIntensity === "number" ? material.userData.baseEmissiveIntensity : 1;
material.emissive.set(selected ? 0x4a1f08 : baseEmissive);
material.emissiveIntensity = selected ? Math.max(0.65, baseIntensity) : baseIntensity;
}
export function blenderLightIntensity(definition: LightIR): number {
return Math.max(0, definition.energy) * 2 ** clamp(definition.exposure, -20, 20, 0) / 10;
}
export function createPBRLight(definition: LightIR): Light {
const color = new Color().setRGB(...definition.color);
const intensity = blenderLightIntensity(definition);
const light = definition.lightType === 1 ? new DirectionalLight(color, intensity) :
definition.lightType === 2 ? new SpotLight(color, intensity, 0, definition.spotAngle, definition.spotBlend, 2) :
definition.lightType === 4 ? new RectAreaLight(color, intensity, definition.areaSize, definition.areaSizeY) :
new PointLight(color, intensity, 0, 2);
light.userData.blenderCastsShadowDefinition = definition.castsShadow ?? true;
light.userData.blenderExposure = definition.exposure ?? 0;
light.userData.blenderTemperature = definition.temperature ?? 6500;
light.userData.blenderUsesTemperature = definition.useTemperature ?? false;
return light;
}
export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Object3D): void {
const [x, y, z] = node.transform.translation;
light.position.set(x, z, -y);
light.rotation.set(node.transform.rotationEuler[0], node.transform.rotationEuler[2], -node.transform.rotationEuler[1]);
if (light instanceof PointLight) {
light.distance = 0;
light.decay = 2;
}
light.userData.blenderCastsShadow = definitionCastsShadow(light);
if ((light instanceof DirectionalLight || light instanceof SpotLight) && light.userData.blenderCastsShadow) {
const target = new Object3D();
const forward = new Vector3(0, -1, 0).applyEuler(light.rotation);
target.position.copy(light.position).add(forward);
light.target = target;
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.03;
light.shadow.camera.near = 0.05;
light.shadow.camera.far = 100;
if (light instanceof DirectionalLight) {
light.shadow.camera.left = -20;
light.shadow.camera.right = 20;
light.shadow.camera.top = 20;
light.shadow.camera.bottom = -20;
}
parent.add(target);
}
else if (light instanceof PointLight && light.userData.blenderCastsShadow) {
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.03;
light.shadow.camera.near = 0.05;
light.shadow.camera.far = 100;
}
}
function definitionCastsShadow(light: Light): boolean {
return typeof light.userData.blenderCastsShadowDefinition === "boolean" ?
light.userData.blenderCastsShadowDefinition : true;
}

View File

@@ -0,0 +1,145 @@
import {
EquirectangularReflectionMapping,
NoColorSpace,
PMREMGenerator,
SRGBColorSpace,
Texture,
type MeshPhysicalMaterial,
type Object3D,
type Scene,
type WebGLRenderer,
} from "../vendor/three/three.module.js";
import type { MaterialIR, SceneSnapshotIR, WorldIR } from "../../../protocol/scene-ir";
import { RenderAssetValidationError, validateGPUTextureAsset, type GPUTextureAsset, type GPUTextureColorSpace, type GPUTextureUsage } from "../../../protocol/render-assets";
export interface TextureUploadStatus {
loaded: number;
rejected: number;
bytes: number;
errors: string[];
errorCodes: string[];
}
function key(imageId: string, usage: GPUTextureUsage): string {
return `${imageId}:${usage}`;
}
function colorSpaceFor(usage: GPUTextureUsage, declared: GPUTextureColorSpace): string {
if (declared === "SRGB" || usage === "BASE_COLOR" || usage === "EMISSIVE") return SRGBColorSpace;
return NoColorSpace;
}
async function decodeTexture(asset: GPUTextureAsset): Promise<Texture> {
if (typeof createImageBitmap !== "function") throw new Error("createImageBitmap is unavailable");
const blob = new Blob([asset.data], { type: asset.mimeType });
const bitmap = await createImageBitmap(blob);
const texture = new Texture(bitmap);
// ImageBitmap pixels are already oriented for the WebGL upload path.
texture.flipY = false;
texture.colorSpace = colorSpaceFor(asset.usage, asset.colorSpace);
texture.name = `${asset.imageId}:${asset.usage}${asset.tileNumber ? `:${asset.tileNumber}` : ""}`;
texture.needsUpdate = true;
return texture;
}
export class GPUTextureStore {
private readonly textures = new Map<string, Texture>();
private readonly assets = new Map<string, GPUTextureAsset>();
private readonly udimTileCounts = new Map<string, number>();
private revision = 0;
getRevision(): number {
return this.revision;
}
get(imageId: string, usage: GPUTextureUsage): Texture | undefined {
return this.textures.get(key(imageId, usage)) ?? this.textures.get(key(imageId, "BASE_COLOR"));
}
getAsset(imageId: string, usage: GPUTextureUsage): GPUTextureAsset | undefined {
return this.assets.get(key(imageId, usage));
}
async upload(assets: readonly GPUTextureAsset[]): Promise<TextureUploadStatus> {
const status: TextureUploadStatus = { loaded: 0, rejected: 0, bytes: 0, errors: [], errorCodes: [] };
const incomingUDIMCounts = new Map<string, number>();
for (const asset of assets) if (asset.usage === "UDIM_TILE") incomingUDIMCounts.set(asset.imageId, (incomingUDIMCounts.get(asset.imageId) ?? 0) + 1);
for (const asset of assets) {
try {
await validateGPUTextureAsset(asset);
if (asset.usage === "UDIM_TILE" && (incomingUDIMCounts.get(asset.imageId) ?? 0) > 1) {
throw new Error(`UDIM multi-tile sampling is blocked for ${asset.imageId}`);
}
const texture = await decodeTexture(asset);
const lookupUsage = asset.usage === "UDIM_TILE" ? "BASE_COLOR" : asset.usage;
const old = this.textures.get(key(asset.imageId, lookupUsage));
old?.dispose();
this.textures.set(key(asset.imageId, lookupUsage), texture);
this.assets.set(key(asset.imageId, lookupUsage), asset);
if (asset.usage === "UDIM_TILE") this.udimTileCounts.set(asset.imageId, (this.udimTileCounts.get(asset.imageId) ?? 0) + 1);
status.loaded += 1;
status.bytes += asset.byteLength;
}
catch (error) {
status.rejected += 1;
status.errors.push(error instanceof Error ? error.message : "Texture upload failed");
status.errorCodes.push(error instanceof RenderAssetValidationError ? error.code : "GPU_TEXTURE_DECODE_FAILED");
}
}
this.revision += 1;
return status;
}
applyMaterial(material: MeshPhysicalMaterial, definition?: MaterialIR): void {
if (!definition) return;
const baseImageId = definition.imageIds?.find((imageId) => imageId !== definition.normalImageId) ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId && node.imageId !== definition.normalImageId)?.imageId;
if (baseImageId) {
const texture = this.get(baseImageId, "BASE_COLOR");
if (texture) material.map = texture;
}
const normalImageId = definition.normalImageId ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId)?.imageId;
if (normalImageId) {
const texture = this.get(normalImageId, "NORMAL");
if (texture) material.normalMap = texture;
}
if (material.map || material.normalMap) material.needsUpdate = true;
}
applySnapshotMaterials(root: Object3D, snapshot: SceneSnapshotIR): void {
const materialById = new Map(snapshot.materials.map((material) => [material.id, material]));
root.traverse((object) => {
const mesh = object as { material?: unknown };
const materialIds = object.userData.materialSlotIds as string[] | undefined;
const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
materials.forEach((material, index) => {
if (!material || typeof material !== "object" || !("isMeshPhysicalMaterial" in material)) return;
this.applyMaterial(material as MeshPhysicalMaterial, materialById.get(materialIds?.[index] ?? ""));
});
});
}
async applyEnvironment(scene: Scene, renderer: WebGLRenderer, world: WorldIR | undefined, background = true): Promise<boolean> {
if (!world?.environmentImageId) return false;
const source = this.get(world.environmentImageId, "ENVIRONMENT");
if (!source) return false;
source.mapping = EquirectangularReflectionMapping;
source.needsUpdate = true;
const pmrem = new PMREMGenerator(renderer);
const target = pmrem.fromEquirectangular(source);
pmrem.dispose();
const oldTarget = scene.userData.pbrIBLTarget as { dispose?: () => void } | undefined;
oldTarget?.dispose?.();
scene.userData.pbrIBLTarget = target;
scene.environment = target.texture;
scene.environmentIntensity = Math.max(0, world.environmentStrength ?? 1);
if (background && (world.backgroundVisible ?? true)) scene.background = source;
return true;
}
dispose(): void {
for (const texture of this.textures.values()) texture.dispose();
this.textures.clear();
this.assets.clear();
this.udimTileCounts.clear();
}
}

View File

@@ -0,0 +1,678 @@
import {
BufferGeometry,
BoxGeometry,
Color,
DirectionalLight,
GridHelper,
Group,
InstancedMesh,
Matrix4,
Mesh,
HemisphereLight,
MeshPhysicalMaterial,
Raycaster,
PerspectiveCamera,
Scene,
type Object3D,
WebGLRenderer,
Float32BufferAttribute,
Uint32BufferAttribute,
Euler,
Quaternion,
Vector2,
Vector3,
} from "../vendor/three/three.module.js";
import { OrbitControls } from "../vendor/three/addons/controls/OrbitControls.js";
import type { MaterialIR, SceneSnapshotIR } from "../../../protocol/scene-ir";
import { applySceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod";
import {
configurePBRLight,
configurePBRRenderer,
createPBRLight,
createPBRMaterial,
PBR_PROFILE,
PBR_SHADOW_PROFILE,
PBR_TONE_MAPPING,
setPBRMaterialSelected,
} from "./pbr";
import { GPUTextureStore } from "./texture-assets";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
import { applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import { applyGreasePencilTransform, createGreasePencilObject } from "./grease-pencil";
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
const groups = new Map<string, string[]>();
for (const node of snapshot.nodes) {
if (node.type !== "MESH" || !node.visible || !node.dataId) continue;
const ids = groups.get(node.dataId) ?? [];
ids.push(node.id);
groups.set(node.dataId, ids);
}
for (const [meshId, ids] of groups) if (ids.length < minimumSize) groups.delete(meshId);
return groups;
}
/** Three.js owns the browser viewport; Blender's Z-up coordinates are adapted at the boundary. */
export class ViewportRenderer {
readonly renderer: WebGLRenderer;
readonly scene: Scene;
readonly camera: PerspectiveCamera;
readonly controls: OrbitControls;
private readonly canvas: HTMLCanvasElement;
private readonly importedRoot = new Group();
private readonly importedLights = new Group();
private readonly resizeObserver: ResizeObserver;
private animationFrame = 0;
private disposed = false;
private currentSnapshot: SceneSnapshotIR | null = null;
private readonly objectByBlenderId = new Map<string, Object3D>();
private readonly instanceIndexByBlenderId = new Map<string, number>();
private readonly lodAdapter = new ThreeLODAdapter();
private readonly textureStore = new GPUTextureStore();
private readonly raycaster = new Raycaster();
private readonly pointer = new Vector2();
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private editMode = false;
private selectionMode: MeshElementMode = "FACE";
constructor(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
) {
this.canvas = canvas;
this.onSelect = onSelect;
this.onElementSelect = onElementSelect;
this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
configurePBRRenderer(this.renderer);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setClearColor(new Color("#25272b"));
this.canvas.dataset.rendererBackend = "webgl-pbr";
this.canvas.dataset.pbrProfile = PBR_PROFILE;
this.canvas.dataset.toneMapping = PBR_TONE_MAPPING;
this.canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
this.scene = new Scene();
this.camera = new PerspectiveCamera(45, 1, 0.01, 1000);
this.camera.position.set(4.5, -4.5, 3.5);
this.controls = new OrbitControls(this.camera, canvas);
this.controls.target.set(0, 0, 0);
this.controls.enableDamping = true;
this.scene.add(new HemisphereLight(0xf2f5ff, 0x3a4149, 0.55));
const keyLight = new DirectionalLight(0xffffff, 2.5);
keyLight.position.set(4, -5, 8);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.bias = -0.0005;
keyLight.shadow.normalBias = 0.03;
this.scene.add(keyLight, keyLight.target);
this.scene.add(new GridHelper(20, 20, 0x60656e, 0x383b42));
this.scene.add(this.importedRoot);
this.scene.add(this.importedLights);
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
this.canvas.addEventListener("click", this.handleClick);
this.resize();
this.renderLoop();
}
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
this.currentSnapshot = snapshot;
const udim = snapshot.images.find((image) => image.tiles && image.tiles.length > 0);
if (udim) {
const gate = gateUDIMImage(udim);
this.canvas.dataset.udimGate = gate.status.toLowerCase();
this.canvas.dataset.udimGateCode = gate.issues[0]?.code ?? "";
}
const worldId = snapshot.scenes[0]?.worldId;
const world = snapshot.worlds.find((candidate) => candidate.id === worldId) ?? snapshot.worlds[0];
if (world?.environmentImageId) {
const gate = gateEnvironmentImage(snapshot.images.find((image) => image.id === world.environmentImageId));
this.canvas.dataset.iblGate = gate.status.toLowerCase();
this.canvas.dataset.iblGateCode = gate.issues[0]?.code ?? "";
}
this.objectByBlenderId.clear();
this.instanceIndexByBlenderId.clear();
this.lodAdapter.clear();
this.clearImportedScene();
this.applyWorld(snapshot);
this.applyCamera(snapshot);
this.populateLights(snapshot);
this.populateNonMesh(snapshot, nonMeshGeometryBuffers);
this.populateGreasePencils(snapshot);
const meshes = new Map(snapshot.meshes.map((mesh) => [mesh.id, mesh]));
const binaryGeometry = new Map(geometryBuffers.map((payload) => [payload.meshId, payload]));
for (const node of snapshot.nodes) {
if (node.type !== "MESH" || !node.visible || !node.dataId) continue;
const summary = meshes.get(node.dataId);
if (!summary) continue;
const materialById = new Map(snapshot.materials.map((material) => [material.id, material]));
const fallbackMaterial = this.createMaterial(undefined, node.id === snapshot.activeObjectId);
const materials = (summary.materialSlotIds ?? [])
.map((id) => materialById.get(id))
.map((material) => this.createMaterial(material, node.id === snapshot.activeObjectId));
if (materials.length === 0) materials.push(fallbackMaterial);
let geometry: BufferGeometry = new BoxGeometry(2, 2, 2);
const payload = binaryGeometry.get(summary.id);
const positionsData = payload ? new Float32Array(payload.positions) : summary.positions;
const indicesData = payload ? new Uint32Array(payload.indices) : summary.indices;
const normalsData = payload?.normals ? new Float32Array(payload.normals) : summary.normals;
const triangleCornerData = payload?.triangleCornerIndices ? new Uint32Array(payload.triangleCornerIndices) : summary.triangleCornerIndices;
const triangleFaceData = payload?.triangleFaceIndices ? new Uint32Array(payload.triangleFaceIndices) : summary.triangleFaceIndices;
const uvData = payload?.uvs ? new Float32Array(payload.uvs) : summary.uvs;
const colorData = payload?.colors ? new Float32Array(payload.colors) : summary.colors;
const triangleMaterialData = payload?.triangleMaterialIndices ? new Uint32Array(payload.triangleMaterialIndices) : summary.triangleMaterialIndices;
if ((summary.geometryStatus === "available" || summary.geometryStatus === "binary") && positionsData && indicesData) {
geometry = new BufferGeometry();
const hasCornerAttributes = Boolean(triangleCornerData && (uvData || colorData));
if (hasCornerAttributes && triangleCornerData) {
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const colors: number[] = [];
for (let index = 0; index < indicesData.length; index++) {
const vertex = indicesData[index] * 3;
positions.push(positionsData[vertex], positionsData[vertex + 2], -positionsData[vertex + 1]);
if (normalsData) {
normals.push(normalsData[vertex], normalsData[vertex + 2], -normalsData[vertex + 1]);
}
const corner = triangleCornerData[index];
if (uvData) uvs.push(uvData[corner * 2], uvData[corner * 2 + 1]);
if (colorData) colors.push(colorData[corner * 4], colorData[corner * 4 + 1], colorData[corner * 4 + 2], colorData[corner * 4 + 3]);
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
if (normals.length > 0) geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
if (uvs.length > 0) geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
if (colors.length > 0) geometry.setAttribute("color", new Float32BufferAttribute(colors, 4));
} else {
const positions = new Array<number>(positionsData.length);
for (let index = 0; index < positionsData.length; index += 3) {
// Blender Z-up/right-handed -> Three.js Y-up/right-handed.
positions[index] = positionsData[index];
positions[index + 1] = positionsData[index + 2];
positions[index + 2] = -positionsData[index + 1];
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
if (normalsData) {
const normals = new Array<number>(normalsData.length);
for (let index = 0; index < normalsData.length; index += 3) {
normals[index] = normalsData[index];
normals[index + 1] = normalsData[index + 2];
normals[index + 2] = -normalsData[index + 1];
}
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
}
geometry.setIndex(new Uint32BufferAttribute(indicesData, 1));
if (!normalsData) geometry.computeVertexNormals();
}
if (triangleMaterialData && materials.length > 1) {
for (let triangle = 0; triangle < triangleMaterialData.length; triangle++) {
const materialIndex = Math.min(triangleMaterialData[triangle], materials.length - 1);
geometry.addGroup(triangle * 3, 3, materialIndex);
}
}
}
const mesh = new Mesh(geometry, materials.length === 1 ? materials[0] : materials);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.name = node.name;
// Summary-only meshes use a bounded proxy until their transferable geometry is available.
const [x, y, z] = node.transform.translation;
mesh.position.set(x, z, -y);
mesh.rotation.set(node.transform.rotationEuler[0], node.transform.rotationEuler[2], -node.transform.rotationEuler[1]);
mesh.scale.set(...node.transform.scale);
mesh.userData.sceneNodeId = node.id;
mesh.userData.blenderId = node.id;
mesh.userData.meshId = summary.id;
mesh.userData.materialSlotIds = summary.materialSlotIds ?? [];
mesh.userData.revision = snapshot.revision;
mesh.userData.vertexCount = summary.vertexCount;
mesh.userData.sourcePositions = positionsData ? Array.from(positionsData) : undefined;
mesh.userData.sourceIndices = indicesData ? Array.from(indicesData) : undefined;
mesh.userData.triangleFaceIndices = triangleFaceData ? Array.from(triangleFaceData) : undefined;
mesh.userData.edgeVertexIndices = payload?.edgeVertexIndices
? Array.from(new Uint32Array(payload.edgeVertexIndices))
: summary.edgeVertexIndices;
this.importedRoot.add(mesh);
this.objectByBlenderId.set(node.id, mesh);
}
this.coalesceMeshInstances(snapshot);
if (this.importedRoot.children.length > 0) {
this.controls.target.set(0, 0, 0);
}
}
private populateNonMesh(snapshot: SceneSnapshotIR, nonMeshGeometryBuffers: readonly NonMeshGeometryChunk[]): void {
const dataById = new Map((snapshot.nonMeshData ?? []).map((data) => [data.id, data]));
let previewCount = 0;
let blockedCount = 0;
for (const node of snapshot.nodes) {
if (!node.visible || !node.dataId || node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA") continue;
const data = dataById.get(node.dataId);
if (!data) continue;
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
if (!object) {
blockedCount++;
continue;
}
applyNonMeshTransform(object, node);
object.traverse((child) => {
child.userData.sceneNodeId = node.id;
child.userData.blenderId = node.id;
child.userData.nonMeshDataId = data.id;
});
this.importedRoot.add(object);
this.objectByBlenderId.set(node.id, object);
previewCount++;
}
this.canvas.dataset.nonMeshCount = String(previewCount);
this.canvas.dataset.nonMeshBlockedCount = String(blockedCount);
}
private populateGreasePencils(snapshot: SceneSnapshotIR): void {
const dataById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
let previewCount = 0;
let blockedCount = 0;
for (const node of snapshot.nodes) {
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
const data = dataById.get(node.dataId);
if (!data) continue;
const object = createGreasePencilObject(data, snapshot.frame.current);
if (!object) {
blockedCount++;
continue;
}
applyGreasePencilTransform(object, node);
this.importedRoot.add(object);
this.objectByBlenderId.set(node.id, object);
previewCount++;
}
this.canvas.dataset.greasePencilCount = String(previewCount);
this.canvas.dataset.greasePencilBlockedCount = String(blockedCount);
}
setTextureAssets(assets: readonly GPUTextureAsset[]): void {
if (assets.length === 0) {
this.canvas.dataset.textureStatus = "none";
this.canvas.dataset.textureLoaded = "0";
this.canvas.dataset.textureBytes = "0";
return;
}
void this.textureStore.upload(assets).then((status) => {
this.canvas.dataset.textureStatus = status.rejected > 0 ? "blocked" : "ready";
this.canvas.dataset.textureLoaded = String(status.loaded);
this.canvas.dataset.textureBytes = String(status.bytes);
this.canvas.dataset.textureErrorCode = status.errorCodes[0] ?? "";
if (!this.currentSnapshot) return;
this.textureStore.applySnapshotMaterials(this.importedRoot, this.currentSnapshot);
const worldId = this.currentSnapshot.scenes[0]?.worldId;
const world = this.currentSnapshot.worlds.find((candidate) => candidate.id === worldId) ?? this.currentSnapshot.worlds[0];
void this.textureStore.applyEnvironment(this.scene, this.renderer, world, true).then((ready) => {
this.canvas.dataset.iblStatus = ready ? "ready" : world?.environmentImageId ? "blocked" : "none";
});
});
}
applyDelta(delta: SceneDelta, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
if (!this.currentSnapshot) throw new Error("Cannot apply a SceneDelta before a snapshot");
const next = applySceneDelta(this.currentSnapshot, delta);
const hasLifecycleChanges = Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length ||
delta.meshes || delta.materials || delta.cameras || delta.lights || delta.animations);
if (hasLifecycleChanges) {
this.setSnapshot(next, geometryBuffers, nonMeshGeometryBuffers);
return;
}
for (const change of delta.nodes?.updated ?? []) {
const object = this.objectByBlenderId.get(change.id);
if (!object) continue;
const instanceIndex = this.instanceIndexByBlenderId.get(change.id);
if (object instanceof InstancedMesh && instanceIndex !== undefined) {
const node = next.nodes.find((candidate) => candidate.id === change.id);
if (node) object.setMatrixAt(instanceIndex, this.instanceMatrix(node.transform, node.visible));
object.instanceMatrix.needsUpdate = true;
}
else if (change.visible !== undefined) object.visible = change.visible;
if (change.visible !== undefined && typeof object.userData.meshId === "string") this.lodAdapter.setEnabled(object.userData.meshId, change.visible);
if (change.transform && !(object instanceof InstancedMesh)) {
const [x, y, z] = change.transform.translation;
object.position.set(x, z, -y);
object.rotation.set(change.transform.rotationEuler[0], change.transform.rotationEuler[2], -change.transform.rotationEuler[1]);
object.scale.set(...change.transform.scale);
}
object.userData.revision = next.revision;
}
this.currentSnapshot = next;
}
setSelection(objectIds: ReadonlySet<string>): void {
const visitedInstances = new Set<InstancedMesh>();
for (const [objectId, object] of this.objectByBlenderId) {
if (object instanceof InstancedMesh) {
if (visitedInstances.has(object)) continue;
visitedInstances.add(object);
const ids = object.userData.instanceNodeIds as string[];
for (let index = 0; index < ids.length; index++) {
object.setColorAt(index, new Color(objectIds.has(ids[index]) ? 0xf08a45 : 0xffffff));
}
if (object.instanceColor) object.instanceColor.needsUpdate = true;
continue;
}
if (!(object instanceof Mesh)) continue;
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
if (!(material instanceof MeshPhysicalMaterial)) continue;
setPBRMaterialSelected(material, objectIds.has(objectId));
}
}
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
this.editMode = editMode;
this.selectionMode = selectionMode;
}
registerLOD(meshId: string, levels: readonly ThreeLODLevel[], radius: number): void {
this.lodAdapter.register(meshId, levels, radius);
}
unregisterLOD(meshId: string): void {
this.lodAdapter.unregister(meshId);
}
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void {
const source = [...this.objectByBlenderId.values()].find((object) => object.userData.meshId === meshId);
if (!(source instanceof Mesh) || source instanceof InstancedMesh || levels.length === 0) return;
const created: ThreeLODLevel[] = [];
const sourceMaterials = Array.isArray(source.material) ? source.material : [source.material];
source.geometry.computeBoundingSphere();
const radius = source.geometry.boundingSphere?.radius ?? 1;
for (const level of levels) {
const payload = level.geometryBuffers.find((geometry) => geometry.meshId === level.meshId) ?? level.geometryBuffers[0];
if (!payload) continue;
const mesh = new Mesh(this.createLODGeometry(payload, sourceMaterials.length), sourceMaterials);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.name = `${source.name} LOD ${level.level}`;
mesh.position.copy(source.position);
mesh.rotation.copy(source.rotation);
mesh.scale.copy(source.scale);
mesh.userData.sceneNodeId = source.userData.sceneNodeId;
mesh.userData.blenderId = source.userData.blenderId;
mesh.userData.meshId = meshId;
this.importedRoot.add(mesh);
created.push({ object: mesh, screenHeightThreshold: Math.max(24, 768 / 2 ** level.level) });
}
if (created.length === 0) return;
source.visible = false;
this.lodAdapter.register(meshId, created, radius);
}
updateLODSelection(hysteresis = 0.08): Map<string, LODSelectionResult> {
return this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight), hysteresis);
}
private createLODGeometry(payload: MeshGeometryBuffer, materialCount: number): BufferGeometry {
const positionsData = new Float32Array(payload.positions);
const indicesData = new Uint32Array(payload.indices);
const normalsData = payload.normals ? new Float32Array(payload.normals) : undefined;
const cornerData = payload.triangleCornerIndices ? new Uint32Array(payload.triangleCornerIndices) : undefined;
const uvData = payload.uvs ? new Float32Array(payload.uvs) : undefined;
const colorData = payload.colors ? new Float32Array(payload.colors) : undefined;
const materialData = payload.triangleMaterialIndices ? new Uint32Array(payload.triangleMaterialIndices) : undefined;
const geometry = new BufferGeometry();
if (cornerData && (uvData || colorData)) {
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const colors: number[] = [];
for (let index = 0; index < indicesData.length; index++) {
const vertex = indicesData[index] * 3;
positions.push(positionsData[vertex], positionsData[vertex + 2], -positionsData[vertex + 1]);
if (normalsData) normals.push(normalsData[vertex], normalsData[vertex + 2], -normalsData[vertex + 1]);
const corner = cornerData[index];
if (uvData) uvs.push(uvData[corner * 2], uvData[corner * 2 + 1]);
if (colorData) colors.push(colorData[corner * 4], colorData[corner * 4 + 1], colorData[corner * 4 + 2], colorData[corner * 4 + 3]);
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
if (normals.length > 0) geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
if (uvs.length > 0) geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
if (colors.length > 0) geometry.setAttribute("color", new Float32BufferAttribute(colors, 4));
}
else {
const positions = new Array<number>(positionsData.length);
for (let index = 0; index < positionsData.length; index += 3) {
positions[index] = positionsData[index];
positions[index + 1] = positionsData[index + 2];
positions[index + 2] = -positionsData[index + 1];
}
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
if (normalsData) {
const normals = new Array<number>(normalsData.length);
for (let index = 0; index < normalsData.length; index += 3) {
normals[index] = normalsData[index];
normals[index + 1] = normalsData[index + 2];
normals[index + 2] = -normalsData[index + 1];
}
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
}
geometry.setIndex(new Uint32BufferAttribute(indicesData, 1));
if (!normalsData) geometry.computeVertexNormals();
}
if (materialData && materialCount > 1) {
for (let triangle = 0; triangle < materialData.length; triangle++) geometry.addGroup(triangle * 3, 3, Math.min(materialData[triangle], materialCount - 1));
}
return geometry;
}
private instanceMatrix(transform: SceneSnapshotIR["nodes"][number]["transform"], visible = true): Matrix4 {
const [x, y, z] = transform.translation;
const [rx, ry, rz] = transform.rotationEuler;
const scale = visible ? new Vector3(...transform.scale) : new Vector3(0, 0, 0);
return new Matrix4().compose(
new Vector3(x, z, -y),
new Quaternion().setFromEuler(new Euler(rx, rz, -ry)),
scale,
);
}
private coalesceMeshInstances(snapshot: SceneSnapshotIR): void {
const instanceGroups = collectMeshInstanceGroups(snapshot);
for (const [meshId, nodeIds] of instanceGroups) {
const meshes = nodeIds.map((id) => this.objectByBlenderId.get(id)).filter((object): object is Mesh => object instanceof Mesh && !(object instanceof InstancedMesh));
if (meshes.length !== nodeIds.length || meshes.length < 2) continue;
const first = meshes[0];
const instances = new InstancedMesh(first.geometry, first.material, meshes.length);
instances.castShadow = true;
instances.receiveShadow = true;
instances.name = `${first.name} (${meshes.length} instances)`;
instances.frustumCulled = true;
instances.userData = { ...first.userData, blenderId: undefined, instanceNodeIds: [...nodeIds], meshId };
for (let index = 0; index < nodeIds.length; index++) {
const node = snapshot.nodes.find((candidate) => candidate.id === nodeIds[index]);
if (!node) continue;
instances.setMatrixAt(index, this.instanceMatrix(node.transform, node.visible));
instances.setColorAt(index, new Color(0xffffff));
this.objectByBlenderId.set(node.id, instances);
this.instanceIndexByBlenderId.set(node.id, index);
}
instances.instanceMatrix.needsUpdate = true;
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
for (let index = 0; index < meshes.length; index++) {
const mesh = meshes[index];
this.importedRoot.remove(mesh);
if (index === 0) continue;
mesh.geometry.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) material.dispose();
}
this.importedRoot.add(instances);
}
this.canvas.dataset.instanceGroups = String(instanceGroups.size);
}
private clearImportedScene(): void {
while (this.importedRoot.children.length > 0) {
const child = this.importedRoot.children.pop();
if (!child) continue;
child.traverse((object) => {
const mesh = object as Mesh;
if (mesh.geometry && typeof mesh.geometry.dispose === "function") mesh.geometry.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) {
if (material && typeof material.dispose === "function") material.dispose();
}
});
}
while (this.importedLights.children.length > 0) {
const child = this.importedLights.children.pop();
if (child && "dispose" in child && typeof child.dispose === "function") child.dispose();
}
}
private createMaterial(definition: MaterialIR | undefined, active: boolean): MeshPhysicalMaterial {
return createPBRMaterial(definition, active);
}
private applyWorld(snapshot: SceneSnapshotIR): void {
const sceneDefinition = snapshot.scenes.find((scene) => scene.id === snapshot.sceneId) ?? snapshot.scenes[0];
const worldId = snapshot.scenes[0]?.worldId;
const world = snapshot.worlds.find((candidate) => candidate.id === worldId) ?? snapshot.worlds[0];
this.scene.background = world ? new Color().setRGB(...world.color) : new Color("#25272b");
configurePBRRenderer(this.renderer, sceneDefinition?.colorManagement?.exposure ?? world?.exposure ?? 0);
this.canvas.dataset.viewTransform = sceneDefinition?.colorManagement?.viewTransform ?? "";
this.canvas.dataset.viewLook = sceneDefinition?.colorManagement?.look ?? "";
this.canvas.dataset.mist = world?.mist?.enabled ? "metadata-only" : "disabled";
}
private applyCamera(snapshot: SceneSnapshotIR): void {
const cameraObjectId = snapshot.scenes[0]?.cameraObjectId;
const cameraNode = snapshot.nodes.find((node) => node.id === cameraObjectId && node.type === "CAMERA");
const definition = snapshot.cameras.find((camera) => camera.id === cameraNode?.dataId);
if (!cameraNode || !definition) return;
const sensor = definition.sensorFit === 2 ? definition.sensorHeightMm : definition.sensorWidthMm;
const fov = (2 * Math.atan((sensor / Math.max(0.001, definition.lensMm)) / 2) * 180) / Math.PI;
this.camera.fov = definition.projection === "ORTHOGRAPHIC" ? 45 : fov;
this.camera.near = Math.max(0.0001, definition.near);
this.camera.far = Math.max(this.camera.near + 0.001, definition.far);
this.camera.filmGauge = sensor;
this.camera.filmOffset = definition.shift[0] * sensor;
this.camera.updateProjectionMatrix();
}
private populateLights(snapshot: SceneSnapshotIR): void {
const lights = new Map(snapshot.lights.map((light) => [light.id, light]));
for (const node of snapshot.nodes) {
if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue;
const definition = lights.get(node.dataId);
if (!definition) continue;
const light = createPBRLight(definition);
configurePBRLight(light, node, this.importedLights);
light.name = node.name;
light.userData.sceneNodeId = node.id;
light.userData.blenderId = node.id;
light.userData.revision = snapshot.revision;
this.importedLights.add(light);
this.objectByBlenderId.set(node.id, light);
}
}
private resize(): void {
const width = Math.max(1, this.canvas.clientWidth);
const height = Math.max(1, this.canvas.clientHeight);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
private handleClick = (event: MouseEvent): void => {
const bounds = this.canvas.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return;
this.pointer.set(
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
);
this.raycaster.setFromCamera(this.pointer, this.camera);
const hit = this.raycaster.intersectObjects(this.importedRoot.children, true)
.find((intersection) => typeof intersection.object.userData.blenderId === "string" || Array.isArray(intersection.object.userData.instanceNodeIds));
if (!hit) return;
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
const nonMeshDataId = hit.object.userData.nonMeshDataId;
if (typeof nonMeshDataId === "string" && hit.index !== undefined) {
const indexMap = hit.object.userData.nonMeshPointIndexMap as number[] | undefined;
const pointIndex = indexMap?.[hit.index] ?? Math.max(0, Math.floor(hit.object.userData.nonMeshPointOffset ?? 0) + hit.index);
const kindMap = hit.object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
this.onElementSelect?.(nonMeshDataId, "VERT", pointIndex, additive, kindMap?.[hit.index] ?? "CONTROL_POINT");
return;
}
const meshId = hit.object.userData.meshId;
const triangle = hit.faceIndex ?? -1;
const sourceIndices = hit.object.userData.sourceIndices as number[] | undefined;
if (this.editMode && typeof meshId === "string" && triangle >= 0 && sourceIndices) {
const triangleVertices = sourceIndices.slice(triangle * 3, triangle * 3 + 3);
let selectedIndex = -1;
if (this.selectionMode === "FACE") {
const triangleFaces = hit.object.userData.triangleFaceIndices as number[] | undefined;
selectedIndex = triangleFaces?.[triangle] ?? triangle;
}
else if (this.selectionMode === "VERT") {
const positions = hit.object.userData.sourcePositions as number[] | undefined;
if (positions) {
const localHit = hit.object.worldToLocal(hit.point.clone());
const local = [localHit.x, -localHit.z, localHit.y];
selectedIndex = triangleVertices.reduce((best, vertex) => {
if (best < 0) return vertex;
const distance = (positions[vertex * 3] - local[0]) ** 2 + (positions[vertex * 3 + 1] - local[1]) ** 2 + (positions[vertex * 3 + 2] - local[2]) ** 2;
const bestDistance = (positions[best * 3] - local[0]) ** 2 + (positions[best * 3 + 1] - local[1]) ** 2 + (positions[best * 3 + 2] - local[2]) ** 2;
return distance < bestDistance ? vertex : best;
}, -1);
}
}
else {
const edges = hit.object.userData.edgeVertexIndices as number[] | undefined;
if (edges) {
const candidates = [[triangleVertices[0], triangleVertices[1]], [triangleVertices[1], triangleVertices[2]], [triangleVertices[2], triangleVertices[0]]];
selectedIndex = candidates.reduce((found, pair) => {
if (found >= 0) return found;
const low = Math.min(pair[0], pair[1]);
const high = Math.max(pair[0], pair[1]);
for (let edge = 0; edge < edges.length / 2; edge++) {
if (Math.min(edges[edge * 2], edges[edge * 2 + 1]) === low && Math.max(edges[edge * 2], edges[edge * 2 + 1]) === high) return edge;
}
return -1;
}, -1);
}
}
if (selectedIndex >= 0) this.onElementSelect?.(meshId, this.selectionMode, selectedIndex, additive);
return;
}
const instanceIds = hit.object.userData.instanceNodeIds as string[] | undefined;
const objectId = instanceIds && hit.instanceId !== undefined ? instanceIds[hit.instanceId] : hit.object.userData.blenderId;
if (typeof objectId === "string") this.onSelect?.(objectId, additive);
};
private renderLoop = (): void => {
if (this.disposed) return;
this.controls.update();
this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight));
this.renderer.render(this.scene, this.camera);
this.animationFrame = window.requestAnimationFrame(this.renderLoop);
};
dispose(): void {
this.disposed = true;
window.cancelAnimationFrame(this.animationFrame);
this.resizeObserver.disconnect();
this.canvas.removeEventListener("click", this.handleClick);
this.controls.dispose();
this.lodAdapter.clear();
this.clearImportedScene();
this.textureStore.dispose();
this.renderer.dispose();
}
}