Research-Stack/5-Applications/text-to-cad/viewer/components/CadViewer.js

5449 lines
185 KiB
JavaScript

"use client";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { parseCadRefToken } from "../lib/cadRefs";
import { copyImageBlobToClipboard } from "../lib/clipboard";
import {
annotatePerspectiveSnapshot,
clonePerspectiveSnapshot,
perspectiveSnapshotEqual,
perspectiveSnapshotMatchesScene,
resolvePerspectiveSnapshot
} from "../lib/perspective";
import { VIEWER_PICK_MODE } from "../lib/viewer/constants";
import {
clampSceneModelRadius,
defaultSceneGridRadius,
getLightingScopeRadius,
getSceneScaleSettings,
normalizeSceneScaleMode,
VIEWER_SCENE_SCALE
} from "../lib/viewer/sceneScale";
import { DRAWING_TOOL } from "../lib/workbench/constants";
import { getEnvironmentPresetById, LOOK_FLOOR_MODES } from "../lib/lookSettings";
import ViewPlaneControl from "./viewer/ViewPlaneControl";
import { useViewerDrawingOverlay } from "./viewer/hooks/useViewerDrawingOverlay";
import { useViewerPicking } from "./viewer/hooks/useViewerPicking";
import { useViewerRuntime } from "./viewer/hooks/useViewerRuntime";
const DEFAULT_GRID_DIVISIONS = 28;
const GRID_TARGET_DIVISIONS = 40;
const IDLE_PIXEL_RATIO_CAP = 2;
const INTERACTION_PIXEL_RATIO_CAP = 1.25;
const INTERACTION_IDLE_DELAY_MS = 140;
const DEFAULT_DAMPING_FACTOR = 0.14;
const DEFAULT_ZOOM_SPEED = 4.5;
const COARSE_POINTER_ZOOM_SPEED = 1.6;
const ACCELERATED_WHEEL_ZOOM_SPEED = 10;
const TRACKPAD_PINCH_ZOOM_SPEED = 14;
const COARSE_POINTER_PINCH_ZOOM_SPEED = 2.4;
const KEYBOARD_ORBIT_NUDGE_RAD = Math.PI / 32;
const KEYBOARD_ORBIT_SPEED_RAD_PER_SEC = Math.PI * 0.42;
const KEYBOARD_POLAR_EPSILON = 0.02;
const PREVIEW_AUTO_ROTATE_SPEED = 1.0;
const VIEW_PLANE_ACTIVE_DOT_THRESHOLD = 0.994;
const VIEW_PLANE_TRANSITION_MS = 280;
const DEFAULT_VIEW_PLANE_ORIENTATION = Object.freeze({
x: [1, 0, 0],
y: [0, 1, 0],
z: [0, 0, 1]
});
const MODEL_FRAME_BUFFER = 1.08;
const DEFAULT_VIEW_DIRECTION = [2.1, 1.08, 1.65];
const VIEW_PLANE_DEFAULT_PRESET = {
id: "isometric",
title: "Reset to default isometric view",
direction: DEFAULT_VIEW_DIRECTION,
up: [0, 1, 0]
};
const CAD_EDGE_OPACITY = 0.84;
const CAD_EDGE_THRESHOLD_DEG = 16;
const DRAWING_STROKE_COLOR = "#ef4444";
const DRAWING_STROKE_HALO = "rgba(255, 255, 255, 0.94)";
const DRAWING_STROKE_WIDTH = 4;
const DRAWING_STROKE_HALO_WIDTH = 8;
const DRAWING_ARROW_HEAD_LENGTH = 18;
const DRAWING_MIN_POINT_DISTANCE_PX = 2.5;
const DRAWING_MIN_STROKE_LENGTH_PX = 4;
const DRAWING_ERASE_THRESHOLD_PX = 16;
const DRAWING_FILL_COLOR = "rgba(239, 68, 68, 0.22)";
const DRAWING_GUESSED_FILL_COLOR = "rgba(239, 68, 68, 0.16)";
const DRAWING_FILL_ANALYSIS_MAX_DIMENSION = 420;
const DRAWING_FILL_ANALYSIS_MIN_DIMENSION = 96;
const DRAWING_FILL_CONNECT_GAP_PX = 28;
const DRAWING_FILL_RAY_COUNT = 72;
const DRAWING_FILL_MIN_REGION_PIXELS = 56;
const DRAWING_FILL_MAX_REGION_RATIO = 0.92;
const SURFACE_LINE_COLOR = "#ef4444";
const SURFACE_LINE_UNSUPPORTED_TYPES = new Set(["", "SPHERICAL_SURFACE", "TOROIDAL_SURFACE", "BSPLINE_SURFACE"]);
const BASE_VIEWER_THEME = {
sceneBackground: "#09090b",
surface: "#f4f4f5",
surfaceRoughness: 0.92,
surfaceMetalness: 0.03,
surfaceClearcoat: 0,
surfaceClearcoatRoughness: 0.6,
edge: "#18181b",
edgeThickness: 1,
edgeOpacity: CAD_EDGE_OPACITY,
selected: "#2563eb",
hover: "#0ea5e9",
gridCenter: "#3f3f46",
gridCell: "#27272a",
gridOpacity: 0.16,
stageFloorColor: "#141416",
stageFloorOpacity: 0.78,
stageFloorRoughness: 0.92,
stageFloorMetalness: 0,
stageFloorTransmission: 0,
stageFloorIor: 1.35,
stageFloorThickness: 0.035,
stageFloorAttenuationDistance: 4,
viewPlanePalette: {
axis: {
x: {
front: [250, 88, 79],
back: [122, 32, 28]
},
y: {
front: [92, 233, 123],
back: [30, 99, 46]
},
z: {
front: [84, 131, 255],
back: [30, 53, 126]
}
},
center: {
fill: [252, 215, 74],
stroke: [255, 235, 153]
},
shell: {
inner: [24, 31, 48],
outer: [8, 12, 20],
stroke: [148, 163, 184]
}
}
};
const BACKGROUND_TEXTURE_SIZE = 1024;
const FLOOR_GLOW_TEXTURE_SIZE = 512;
const DEFAULT_LIGHTING = {
toneMappingExposure: 1.08,
hemisphereSky: "#d3dde6",
hemisphereGround: "#090c16",
hemisphereIntensity: 1.62,
keyLightColor: "#d6e0ea",
keyLightIntensity: 0.82,
fillLightColor: "#6b7f95",
fillLightIntensity: 0.46,
rimLightColor: "#6db6e8",
rimLightIntensity: 0.04
};
const DEFAULT_SHADOW_MAP_SIZE = 2048;
const REFERENCE_HOVER_COLOR = "#8dc5ff";
const REFERENCE_SELECTED_COLOR = "#4f9dff";
const REFERENCE_CORNER_COLOR = "#2563eb";
const REFERENCE_HIGHLIGHT_WIDTH_MULTIPLIER = 3;
const REFERENCE_HOVER_HIGHLIGHT_WIDTH_MULTIPLIER = REFERENCE_HIGHLIGHT_WIDTH_MULTIPLIER / 2;
const REFERENCE_HOVER_FILL_OPACITY = 0.3;
const REFERENCE_SELECTED_FILL_OPACITY = 0.24;
const BEND_GUIDE_COLOR = "#f59e0b";
const BEND_GUIDE_WIDTH_MULTIPLIER = 1.35;
const PART_HOVER_OPACITY_BOOST = 0.08;
const PART_SELECTED_OPACITY_BOOST = 0.12;
const URDF_PART_INTRO_STAGGER_MS = 150;
const URDF_PART_INTRO_DURATION_MS = 620;
const URDF_PART_INTRO_INITIAL_SCALE = 0.76;
const URDF_PART_INTRO_MAX_TILT_RAD = Math.PI / 16;
const URDF_PART_INTRO_VISIBILITY_EPSILON = 0.012;
const URDF_PART_INTRO_MIN_TRAVEL = 0.022;
const LOOK_BACKGROUND_TYPES = {
SOLID: "solid",
LINEAR: "linear",
RADIAL: "radial",
TRANSPARENT: "transparent"
};
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
const VIEW_PLANE_FACES = [
{
id: "y",
label: "Y",
title: "Jump to top view",
direction: [0, 1, 0],
up: [0, 0, -1]
},
{
id: "yNeg",
label: "-Y",
title: "Jump to bottom view",
direction: [0, -1, 0],
up: [0, 0, 1]
},
{
id: "z",
label: "Z",
title: "Jump to front view",
direction: [0, 0, 1],
up: [0, 1, 0]
},
{
id: "zNeg",
label: "-Z",
title: "Jump to back view",
direction: [0, 0, -1],
up: [0, 1, 0]
},
{
id: "x",
label: "X",
title: "Jump to right view",
direction: [1, 0, 0],
up: [0, 1, 0]
},
{
id: "xNeg",
label: "-X",
title: "Jump to left view",
direction: [-1, 0, 0],
up: [0, 1, 0]
}
];
const VIEW_PLANE_FACE_BY_ID = Object.fromEntries(VIEW_PLANE_FACES.map((face) => [face.id, face]));
function viewPlaneOrientationEqual(a, b, epsilon = 1e-4) {
if (!a || !b) {
return false;
}
for (const axis of ["x", "y", "z"]) {
const left = a[axis];
const right = b[axis];
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== 3 || right.length !== 3) {
return false;
}
for (let index = 0; index < 3; index += 1) {
if (Math.abs((left[index] || 0) - (right[index] || 0)) > epsilon) {
return false;
}
}
}
return true;
}
function readViewPlaneOrientation(runtime) {
if (!runtime?.THREE || !runtime?.camera) {
return null;
}
const inverseCameraRotation = runtime.camera.quaternion.clone().invert();
const projectAxis = (x, y, z) => {
const projected = new runtime.THREE.Vector3(x, y, z).applyQuaternion(inverseCameraRotation);
return [projected.x, projected.y, projected.z];
};
return {
x: projectAxis(1, 0, 0),
y: projectAxis(0, 1, 0),
z: projectAxis(0, 0, 1)
};
}
function isNumericArray(value, stride = 1) {
return (
(Array.isArray(value) || ArrayBuffer.isView(value)) &&
value.length >= stride &&
value.length % stride === 0
);
}
function emptyLineGeometry(THREE) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute([], 3));
return geometry;
}
function dataUrlToBlob(dataUrl) {
const parts = String(dataUrl || "").split(",");
if (parts.length !== 2) {
throw new Error("Screenshot encoding failed");
}
const mimeMatch = parts[0].match(/data:(.*?);base64/);
const mimeType = mimeMatch?.[1] || "image/png";
const binary = atob(parts[1]);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimeType });
}
function getViewerThemeValue(viewerTheme, key, fallback) {
const value = viewerTheme?.[key];
return value ?? BASE_VIEWER_THEME[key] ?? fallback;
}
function getViewerThemeNumber(viewerTheme, key, fallback) {
const value = Number(getViewerThemeValue(viewerTheme, key, fallback));
return Number.isFinite(value) ? value : fallback;
}
function normalizeFloorMode(value, fallback = LOOK_FLOOR_MODES.STAGE) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "glass") {
return LOOK_FLOOR_MODES.STAGE;
}
return Object.values(LOOK_FLOOR_MODES).includes(normalized)
? normalized
: fallback;
}
function resolveFloorMode(floorSettings = {}) {
return normalizeFloorMode(floorSettings?.mode);
}
function normalizeLookSettingsShape(lookSettings = {}) {
const materials = lookSettings?.materials || {};
const edges = lookSettings?.edges || {};
const background = lookSettings?.background || {};
const floor = lookSettings?.floor || {};
const environment = lookSettings?.environment || {};
const lighting = lookSettings?.lighting || {};
return {
materials: {
defaultColor: String(materials.defaultColor || materials.tintColor || BASE_VIEWER_THEME.surface),
tintStrength: Number.isFinite(Number(materials.tintStrength)) ? clamp(Number(materials.tintStrength), 0, 1) : 0,
saturation: Number.isFinite(Number(materials.saturation)) ? clamp(Number(materials.saturation), 0, 2.5) : 1,
contrast: Number.isFinite(Number(materials.contrast)) ? clamp(Number(materials.contrast), 0, 2.5) : 1,
brightness: Number.isFinite(Number(materials.brightness)) ? clamp(Number(materials.brightness), 0, 2) : 1,
roughness: Number.isFinite(Number(materials.roughness)) ? Number(materials.roughness) : BASE_VIEWER_THEME.surfaceRoughness,
metalness: Number.isFinite(Number(materials.metalness)) ? Number(materials.metalness) : BASE_VIEWER_THEME.surfaceMetalness,
clearcoat: Number.isFinite(Number(materials.clearcoat)) ? Number(materials.clearcoat) : BASE_VIEWER_THEME.surfaceClearcoat,
clearcoatRoughness: Number.isFinite(Number(materials.clearcoatRoughness))
? Number(materials.clearcoatRoughness)
: BASE_VIEWER_THEME.surfaceClearcoatRoughness,
opacity: Number.isFinite(Number(materials.opacity)) ? Number(materials.opacity) : 1,
envMapIntensity: Number.isFinite(Number(materials.envMapIntensity)) ? Number(materials.envMapIntensity) : 1
},
edges: {
enabled: edges?.enabled === true,
color: String(edges.color || BASE_VIEWER_THEME.edge),
thickness: Number.isFinite(Number(edges.thickness))
? clamp(Number(edges.thickness), 0.5, 6)
: BASE_VIEWER_THEME.edgeThickness,
opacity: Number.isFinite(Number(edges.opacity))
? clamp(Number(edges.opacity), 0, 1)
: clamp(BASE_VIEWER_THEME.edgeOpacity, 0, 1)
},
background: {
type: String(background.type || LOOK_BACKGROUND_TYPES.SOLID),
solidColor: String(background.solidColor || BASE_VIEWER_THEME.sceneBackground),
linearStart: String(background.linearStart || "#0d1015"),
linearEnd: String(background.linearEnd || "#2a3240"),
linearAngle: Number.isFinite(Number(background.linearAngle)) ? Number(background.linearAngle) : 180,
radialInner: String(background.radialInner || "#2a3345"),
radialOuter: String(background.radialOuter || "#0b0f15")
},
floor: {
mode: normalizeFloorMode(floor.mode),
color: String(floor.color || BASE_VIEWER_THEME.stageFloorColor),
roughness: Number.isFinite(Number(floor.roughness)) ? clamp(Number(floor.roughness), 0, 1) : 0.72,
reflectivity: Number.isFinite(Number(floor.reflectivity)) ? clamp(Number(floor.reflectivity), 0, 1) : 0.12,
shadowOpacity: Number.isFinite(Number(floor.shadowOpacity)) ? clamp(Number(floor.shadowOpacity), 0, 1) : 0.45,
horizonBlend: Number.isFinite(Number(floor.horizonBlend)) ? clamp(Number(floor.horizonBlend), 0, 1) : 0
},
environment: {
enabled: environment?.enabled !== false,
presetId: String(environment.presetId || ""),
intensity: Number.isFinite(Number(environment.intensity)) ? Number(environment.intensity) : 1,
rotationY: Number.isFinite(Number(environment.rotationY)) ? Number(environment.rotationY) : 0,
useAsBackground: environment?.useAsBackground === true
},
lighting: {
toneMappingExposure: Number.isFinite(Number(lighting?.toneMappingExposure))
? Number(lighting.toneMappingExposure)
: DEFAULT_LIGHTING.toneMappingExposure,
directional: {
enabled: lighting?.directional?.enabled !== false,
color: String(lighting?.directional?.color || "#ffffff"),
intensity: Number.isFinite(Number(lighting?.directional?.intensity)) ? Number(lighting.directional.intensity) : 1,
position: {
x: Number.isFinite(Number(lighting?.directional?.position?.x)) ? Number(lighting.directional.position.x) : 140,
y: Number.isFinite(Number(lighting?.directional?.position?.y)) ? Number(lighting.directional.position.y) : 220,
z: Number.isFinite(Number(lighting?.directional?.position?.z)) ? Number(lighting.directional.position.z) : 140
}
},
spot: {
enabled: lighting?.spot?.enabled === true,
color: String(lighting?.spot?.color || "#ffffff"),
intensity: Number.isFinite(Number(lighting?.spot?.intensity)) ? Number(lighting.spot.intensity) : 1,
angle: Number.isFinite(Number(lighting?.spot?.angle)) ? Number(lighting.spot.angle) : Math.PI / 6,
distance: Number.isFinite(Number(lighting?.spot?.distance)) ? Number(lighting.spot.distance) : 0,
position: {
x: Number.isFinite(Number(lighting?.spot?.position?.x)) ? Number(lighting.spot.position.x) : 160,
y: Number.isFinite(Number(lighting?.spot?.position?.y)) ? Number(lighting.spot.position.y) : 120,
z: Number.isFinite(Number(lighting?.spot?.position?.z)) ? Number(lighting.spot.position.z) : 100
}
},
point: {
enabled: lighting?.point?.enabled === true,
color: String(lighting?.point?.color || "#ffffff"),
intensity: Number.isFinite(Number(lighting?.point?.intensity)) ? Number(lighting.point.intensity) : 1,
distance: Number.isFinite(Number(lighting?.point?.distance)) ? Number(lighting.point.distance) : 0,
position: {
x: Number.isFinite(Number(lighting?.point?.position?.x)) ? Number(lighting.point.position.x) : -120,
y: Number.isFinite(Number(lighting?.point?.position?.y)) ? Number(lighting.point.position.y) : 80,
z: Number.isFinite(Number(lighting?.point?.position?.z)) ? Number(lighting.point.position.z) : 140
}
},
ambient: {
enabled: lighting?.ambient?.enabled === true,
color: String(lighting?.ambient?.color || "#ffffff"),
intensity: Number.isFinite(Number(lighting?.ambient?.intensity)) ? Number(lighting.ambient.intensity) : 0
},
hemisphere: {
enabled: lighting?.hemisphere?.enabled !== false,
skyColor: String(lighting?.hemisphere?.skyColor || "#ffffff"),
groundColor: String(lighting?.hemisphere?.groundColor || "#e6eaef"),
intensity: Number.isFinite(Number(lighting?.hemisphere?.intensity)) ? Number(lighting.hemisphere.intensity) : 1
}
}
};
}
function getPixelRatioCap(cap) {
if (typeof window === "undefined") {
return 1;
}
return Math.min(window.devicePixelRatio || 1, cap);
}
function toThemeArray(value) {
if (Array.isArray(value)) {
return value.filter(Boolean);
}
return value ? [value] : [];
}
function colorToRgba(THREE, value, alpha = 1) {
const color = new THREE.Color(value || "#000000");
return `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${clamp(alpha, 0, 1)})`;
}
function normalizeGradientStops(stops) {
const filteredStops = toThemeArray(stops);
if (!filteredStops.length) {
return [];
}
return filteredStops
.map((stop, index) => {
if (typeof stop === "string") {
return {
offset: filteredStops.length === 1 ? 0 : index / (filteredStops.length - 1),
color: stop
};
}
const fallbackOffset = filteredStops.length === 1 ? 0 : index / (filteredStops.length - 1);
const offset = Number(stop?.offset);
return {
offset: Number.isFinite(offset) ? clamp(offset, 0, 1) : fallbackOffset,
color: stop?.color || stop?.value || "#000000"
};
})
.sort((left, right) => left.offset - right.offset);
}
function createSceneBackgroundTexture(THREE, viewerTheme, lookBackground = null) {
const backgroundType = String(lookBackground?.type || "").trim().toLowerCase();
const useLookBackground = !!backgroundType;
const gradientStops = useLookBackground
? (
backgroundType === LOOK_BACKGROUND_TYPES.LINEAR
? [
{ offset: 0, color: lookBackground.linearStart || "#000000" },
{ offset: 1, color: lookBackground.linearEnd || "#ffffff" }
]
: backgroundType === LOOK_BACKGROUND_TYPES.RADIAL
? [
{ offset: 0, color: lookBackground.radialInner || "#000000" },
{ offset: 1, color: lookBackground.radialOuter || "#ffffff" }
]
: []
)
: normalizeGradientStops(viewerTheme?.sceneBackgroundGradient);
const glowLayers = useLookBackground ? [] : toThemeArray(viewerTheme?.sceneBackgroundGlow);
if (!gradientStops.length && !glowLayers.length) {
return null;
}
const canvas = document.createElement("canvas");
canvas.width = BACKGROUND_TEXTURE_SIZE;
canvas.height = BACKGROUND_TEXTURE_SIZE;
const context = canvas.getContext("2d");
if (!context) {
return null;
}
context.fillStyle = useLookBackground
? lookBackground.solidColor || BASE_VIEWER_THEME.sceneBackground
: viewerTheme?.sceneBackground || BASE_VIEWER_THEME.sceneBackground;
context.fillRect(0, 0, canvas.width, canvas.height);
if (gradientStops.length) {
if (backgroundType === LOOK_BACKGROUND_TYPES.RADIAL) {
const radialGradient = context.createRadialGradient(
canvas.width / 2,
canvas.height / 2,
canvas.width * 0.1,
canvas.width / 2,
canvas.height / 2,
canvas.width * 0.75
);
for (const stop of gradientStops) {
radialGradient.addColorStop(stop.offset, stop.color);
}
context.fillStyle = radialGradient;
context.fillRect(0, 0, canvas.width, canvas.height);
} else {
const angleDeg = Number.isFinite(Number(lookBackground?.linearAngle)) ? Number(lookBackground.linearAngle) : 180;
const angleRad = (angleDeg * Math.PI) / 180;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.max(canvas.width, canvas.height);
const x1 = cx - Math.cos(angleRad) * radius;
const y1 = cy - Math.sin(angleRad) * radius;
const x2 = cx + Math.cos(angleRad) * radius;
const y2 = cy + Math.sin(angleRad) * radius;
const linearGradient = context.createLinearGradient(x1, y1, x2, y2);
for (const stop of gradientStops) {
linearGradient.addColorStop(stop.offset, stop.color);
}
context.fillStyle = linearGradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
}
for (const glowLayer of glowLayers) {
const resolvedX = Number(glowLayer?.x);
const resolvedY = Number(glowLayer?.y);
const x = Number.isFinite(resolvedX) ? clamp(resolvedX, 0, 1) : 0.5;
const y = Number.isFinite(resolvedY) ? clamp(resolvedY, 0, 1) : 0.5;
const radius = Math.max(Number(glowLayer?.radius) || 0, 0.08);
const opacity = clamp(Number(glowLayer?.opacity) || 0, 0, 1);
if (opacity <= 0) {
continue;
}
const centerX = canvas.width * x;
const centerY = canvas.height * y;
const outerRadius = canvas.width * radius;
const glow = context.createRadialGradient(centerX, centerY, 0, centerX, centerY, outerRadius);
glow.addColorStop(0, colorToRgba(THREE, glowLayer?.color || "#ffffff", opacity));
glow.addColorStop(0.5, colorToRgba(THREE, glowLayer?.color || "#ffffff", opacity * 0.3));
glow.addColorStop(1, colorToRgba(THREE, glowLayer?.color || "#ffffff", 0));
context.fillStyle = glow;
context.fillRect(0, 0, canvas.width, canvas.height);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
return texture;
}
function disposeTexture(texture) {
texture?.dispose?.();
}
function applySceneBackground(runtime, viewerTheme, lookBackground = null) {
if (!runtime?.THREE || !runtime?.scene) {
return;
}
if (String(lookBackground?.type || "").toLowerCase() === LOOK_BACKGROUND_TYPES.TRANSPARENT) {
disposeTexture(runtime.sceneBackgroundTexture);
runtime.sceneBackgroundTexture = null;
runtime.scene.background = null;
runtime.renderer?.setClearAlpha?.(0);
return;
}
runtime.renderer?.setClearAlpha?.(1);
disposeTexture(runtime.sceneBackgroundTexture);
runtime.sceneBackgroundTexture = createSceneBackgroundTexture(runtime.THREE, viewerTheme, lookBackground);
if (runtime.sceneBackgroundTexture) {
runtime.scene.background = runtime.sceneBackgroundTexture;
return;
}
runtime.scene.background = new runtime.THREE.Color(
lookBackground?.solidColor || viewerTheme.sceneBackground || BASE_VIEWER_THEME.sceneBackground
);
}
function createSafeColor(THREE, value, fallback = "#000000") {
const normalizedValue = String(value || "").trim();
const normalizedFallback = String(fallback || "#000000").trim();
const colorValue = HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : normalizedFallback;
try {
return new THREE.Color(colorValue);
} catch {
return new THREE.Color(HEX_COLOR_PATTERN.test(normalizedFallback) ? normalizedFallback : "#000000");
}
}
function resolveBackgroundFloorColor(THREE, lookBackground = {}, viewerTheme = BASE_VIEWER_THEME) {
const fallbackColor = viewerTheme?.stageFloorColor || viewerTheme?.sceneBackground || BASE_VIEWER_THEME.stageFloorColor;
const backgroundType = String(lookBackground?.type || "").trim().toLowerCase();
if (backgroundType === LOOK_BACKGROUND_TYPES.LINEAR) {
return createSafeColor(THREE, lookBackground.linearStart, fallbackColor)
.lerp(createSafeColor(THREE, lookBackground.linearEnd, fallbackColor), 0.7);
}
if (backgroundType === LOOK_BACKGROUND_TYPES.RADIAL) {
return createSafeColor(THREE, lookBackground.radialInner, fallbackColor)
.lerp(createSafeColor(THREE, lookBackground.radialOuter, fallbackColor), 0.72);
}
if (backgroundType === LOOK_BACKGROUND_TYPES.SOLID) {
return createSafeColor(THREE, lookBackground.solidColor, fallbackColor);
}
return createSafeColor(THREE, fallbackColor, BASE_VIEWER_THEME.stageFloorColor);
}
function resolveStageFloorGlassFactor(lookSettings = {}) {
const materials = lookSettings?.materials || {};
const environment = lookSettings?.environment || {};
const roughness = clamp(Number(materials.roughness) || 0, 0, 1);
const clearcoat = clamp(Number(materials.clearcoat) || 0, 0, 1);
const envSignal = environment?.enabled
? clamp(((Number(materials.envMapIntensity) || 0) * (Number(environment.intensity) || 0)) / 3, 0, 1)
: 0;
return clamp((clearcoat * 0.5) + ((1 - roughness) * 0.25) + (envSignal * 0.35), 0, 1);
}
function resolveStageFloorColor(THREE, viewerTheme, lookSettings = {}) {
const explicitFloorColor = String(lookSettings?.floor?.color || "").trim();
if (HEX_COLOR_PATTERN.test(explicitFloorColor)) {
return createSafeColor(THREE, explicitFloorColor, viewerTheme?.stageFloorColor || BASE_VIEWER_THEME.stageFloorColor);
}
const backgroundColor = resolveBackgroundFloorColor(THREE, lookSettings?.background, viewerTheme);
const glassFactor = resolveStageFloorGlassFactor(lookSettings);
if (glassFactor >= 0.35) {
const backgroundHsl = {};
backgroundColor.getHSL(backgroundHsl);
const floorColor = backgroundColor.clone();
const lightness = backgroundHsl.l < 0.42
? clamp(backgroundHsl.l + 0.012, 0.06, 0.14)
: clamp(backgroundHsl.l - 0.045, 0.36, 0.78);
floorColor.setHSL(backgroundHsl.h, clamp(backgroundHsl.s * 0.14, 0, 0.04), lightness);
return floorColor;
}
const groundColor = createSafeColor(
THREE,
lookSettings?.lighting?.hemisphere?.groundColor,
viewerTheme?.stageFloorColor || BASE_VIEWER_THEME.stageFloorColor
);
const defaultColor = createSafeColor(THREE, lookSettings?.materials?.defaultColor, "#ffffff");
const floorColor = backgroundColor.clone()
.lerp(groundColor, 0.42 - (glassFactor * 0.24))
.lerp(defaultColor, 0.07 - (glassFactor * 0.04))
.lerp(backgroundColor, glassFactor * 0.5);
const floorHsl = {};
const backgroundHsl = {};
floorColor.getHSL(floorHsl);
backgroundColor.getHSL(backgroundHsl);
const lightness = backgroundHsl.l < 0.42
? clamp(
Math.max(floorHsl.l, backgroundHsl.l + (0.055 - (glassFactor * 0.035))),
0.1 - (glassFactor * 0.04),
0.34 - (glassFactor * 0.13)
)
: clamp(Math.min(floorHsl.l, backgroundHsl.l - 0.075), 0.38, 0.78);
floorColor.setHSL(
floorHsl.h,
clamp(floorHsl.s * (0.58 - (glassFactor * 0.2)), 0.025, 0.38),
lightness
);
return floorColor;
}
function getStageFloorSetting(lookSettings, key, fallback, min = 0, max = 1) {
const value = Number(lookSettings?.floor?.[key]);
return Number.isFinite(value) ? clamp(value, min, max) : fallback;
}
function createStageFloorGlowTexture(THREE, color, opacity) {
const resolvedOpacity = clamp(Number(opacity) || 0, 0, 1);
if (resolvedOpacity <= 0.001 || typeof document === "undefined") {
return null;
}
const canvas = document.createElement("canvas");
canvas.width = FLOOR_GLOW_TEXTURE_SIZE;
canvas.height = FLOOR_GLOW_TEXTURE_SIZE;
const context = canvas.getContext("2d");
if (!context) {
return null;
}
const center = FLOOR_GLOW_TEXTURE_SIZE / 2;
const gradient = context.createRadialGradient(center, center, 0, center, center, center);
gradient.addColorStop(0, colorToRgba(THREE, color, resolvedOpacity));
gradient.addColorStop(0.28, colorToRgba(THREE, color, resolvedOpacity * 0.56));
gradient.addColorStop(0.62, colorToRgba(THREE, color, resolvedOpacity * 0.16));
gradient.addColorStop(1, colorToRgba(THREE, color, 0));
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
return texture;
}
function createStageFloorPlane(THREE, viewerTheme, lookSettings, size, floorY, lift = 0) {
const glassFactor = resolveStageFloorGlassFactor(lookSettings);
const horizonBlend = getStageFloorSetting(lookSettings, "horizonBlend", 0, 0, 1);
const reflectivity = getStageFloorSetting(lookSettings, "reflectivity", 0.12, 0, 1);
const roughness = getStageFloorSetting(
lookSettings,
"roughness",
clamp(getViewerThemeNumber(viewerTheme, "stageFloorRoughness", 0.92) - (glassFactor * 0.48), 0.16, 1),
0,
1
);
const opacity = clamp(
(getViewerThemeNumber(viewerTheme, "stageFloorOpacity", 0.78) - (glassFactor * 0.02)) * (1 - (horizonBlend * 0.3)),
0.62,
1
);
const envMapIntensity = clamp(
(
Number(lookSettings?.materials?.envMapIntensity || 0) *
(lookSettings?.environment?.enabled ? Number(lookSettings?.environment?.intensity || 0) : 0) *
(0.08 + (glassFactor * 0.1))
) + (reflectivity * 0.48),
0,
1.15
);
const material = new THREE.MeshPhysicalMaterial({
color: resolveStageFloorColor(THREE, viewerTheme, lookSettings),
roughness,
metalness: clamp(getViewerThemeNumber(viewerTheme, "stageFloorMetalness", 0) + (reflectivity * 0.06), 0, 0.18),
clearcoat: clamp((reflectivity * 0.58) + (glassFactor * 0.12), 0, 0.9),
clearcoatRoughness: clamp(
roughness * 0.62,
0.04,
0.8
),
reflectivity,
transmission: clamp(
getViewerThemeNumber(viewerTheme, "stageFloorTransmission", BASE_VIEWER_THEME.stageFloorTransmission) +
(glassFactor * 0.005),
0,
0.02
),
ior: getViewerThemeNumber(viewerTheme, "stageFloorIor", BASE_VIEWER_THEME.stageFloorIor),
thickness: getViewerThemeNumber(viewerTheme, "stageFloorThickness", BASE_VIEWER_THEME.stageFloorThickness),
attenuationDistance: getViewerThemeNumber(
viewerTheme,
"stageFloorAttenuationDistance",
BASE_VIEWER_THEME.stageFloorAttenuationDistance
),
transparent: opacity < 0.999 || glassFactor > 0.05,
opacity,
side: THREE.FrontSide,
depthWrite: opacity >= 0.9,
envMapIntensity
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 1, 1), material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(0, floorY + lift, 0);
mesh.scale.set(size, size, 1);
mesh.receiveShadow = false;
mesh.castShadow = false;
mesh.renderOrder = -3;
return mesh;
}
function createStageFloorGlowPlane(THREE, lookSettings, lightingScopeRadius, size, floorY, sceneScaleMode, lift = 0.008) {
const spotLight = lookSettings?.lighting?.spot || {};
if (spotLight.enabled === false) {
return null;
}
const reflectivity = getStageFloorSetting(lookSettings, "reflectivity", 0.12, 0, 1);
const shadowOpacity = getStageFloorSetting(lookSettings, "shadowOpacity", 0.45, 0, 1);
const spotIntensity = Math.max(Number(spotLight.intensity) || 0, 0);
const glowOpacity = clamp(0.025 + (spotIntensity * 0.11) + (reflectivity * 0.32) - (shadowOpacity * 0.06), 0, 0.36);
const texture = createStageFloorGlowTexture(THREE, spotLight.color || "#ffffff", glowOpacity);
if (!texture) {
return null;
}
const sceneScaleSettings = getSceneScaleSettings(sceneScaleMode);
const safeLightingRadius = Math.max(Number(lightingScopeRadius) || 0, getLightingScopeRadius(sceneScaleMode));
const glowSize = Math.min(
size * 0.24,
Math.max(safeLightingRadius * 8, sceneScaleSettings.minGridSize * 3.4)
);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 1,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.FrontSide,
toneMapped: true
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 1, 1), material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(0, floorY + lift, 0);
mesh.scale.set(glowSize * 1.45, glowSize, 1);
mesh.receiveShadow = false;
mesh.castShadow = false;
mesh.renderOrder = -2.8;
return mesh;
}
function createStageShadowPlane(THREE, lookSettings, size, floorY, lift = 0.01) {
const opacity = getStageFloorSetting(lookSettings, "shadowOpacity", 0.45, 0, 1);
if (opacity <= 0.001) {
return null;
}
const material = new THREE.ShadowMaterial({
color: 0x000000,
opacity,
transparent: true,
depthWrite: false
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 1, 1), material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(0, floorY + lift, 0);
mesh.scale.set(size, size, 1);
mesh.receiveShadow = true;
mesh.castShadow = false;
mesh.renderOrder = -2;
return mesh;
}
function updateSpotLightTarget(runtime) {
if (!runtime?.spotLight?.target?.position) {
return;
}
const floorY = Number(runtime.gridFloorY);
const targetY = runtime.floorMode !== LOOK_FLOOR_MODES.NONE && Number.isFinite(floorY) ? floorY : 0;
runtime.spotLight.target.position.set(0, targetY, 0);
runtime.spotLight.target.updateMatrixWorld?.();
}
function getStageFloorSize(radius, sceneScaleMode) {
const sceneScaleSettings = getSceneScaleSettings(sceneScaleMode);
const safeRadius = clampSceneModelRadius(radius, sceneScaleMode);
return Math.max(sceneScaleSettings.minGridSize * 80, safeRadius * 160);
}
function updateStageEffects(runtime, viewerTheme, lookSettings, radius, floorY = 0, floorMode = LOOK_FLOOR_MODES.STAGE) {
if (!runtime?.THREE || !runtime?.stageGroup) {
return;
}
clearSceneGroup(runtime.stageGroup);
if (floorMode !== LOOK_FLOOR_MODES.STAGE) {
return;
}
const floorSize = getStageFloorSize(radius, runtime.sceneScaleMode);
const lightingScopeRadius = getLightingScopeRadius(runtime.sceneScaleMode);
runtime.stageGroup.add(createStageFloorPlane(runtime.THREE, viewerTheme, lookSettings, floorSize, floorY, 0));
const glowPlane = createStageFloorGlowPlane(
runtime.THREE,
lookSettings,
lightingScopeRadius,
floorSize,
floorY,
runtime.sceneScaleMode
);
if (glowPlane) {
runtime.stageGroup.add(glowPlane);
}
const shadowPlane = createStageShadowPlane(runtime.THREE, lookSettings, floorSize, floorY);
if (shadowPlane) {
runtime.stageGroup.add(shadowPlane);
}
}
function isTrackpadLikeWheelEvent(event) {
return event.ctrlKey || (event.deltaMode === 0 && Math.abs(event.deltaY) < 20);
}
function normalizeViewportFrameInsets(value = {}) {
const normalizeInset = (inset) => {
const numericInset = Number(inset);
return Number.isFinite(numericInset) ? Math.max(0, numericInset) : 0;
};
return {
top: normalizeInset(value?.top),
right: normalizeInset(value?.right),
bottom: normalizeInset(value?.bottom),
left: normalizeInset(value?.left)
};
}
function getViewportFrameMetrics(runtime, frameInsets = {}) {
const canvas = runtime?.renderer?.domElement;
const width = Math.max(1, canvas?.clientWidth || canvas?.parentElement?.clientWidth || 1);
const height = Math.max(1, canvas?.clientHeight || canvas?.parentElement?.clientHeight || 1);
const normalizedInsets = normalizeViewportFrameInsets(frameInsets);
const left = clamp(normalizedInsets.left, 0, Math.max(width - 1, 0));
const right = clamp(normalizedInsets.right, 0, Math.max(width - left - 1, 0));
const top = clamp(normalizedInsets.top, 0, Math.max(height - 1, 0));
const bottom = clamp(normalizedInsets.bottom, 0, Math.max(height - top - 1, 0));
const framedWidth = Math.max(1, width - left - right);
const framedHeight = Math.max(1, height - top - bottom);
const centerX = left + framedWidth / 2;
const centerY = top + framedHeight / 2;
return {
width,
height,
framedWidth,
framedHeight,
aspect: framedWidth / framedHeight,
offsetNdcX: (centerX / width) * 2 - 1,
offsetNdcY: 1 - (centerY / height) * 2
};
}
function applyCameraFrameInsets(runtime, frameInsets = {}, { updateProjection = true } = {}) {
const camera = runtime?.camera;
if (!camera?.projectionMatrix?.elements) {
return;
}
if (updateProjection) {
camera.updateProjectionMatrix();
}
const { offsetNdcX, offsetNdcY } = getViewportFrameMetrics(runtime, frameInsets);
camera.projectionMatrix.elements[8] -= offsetNdcX;
camera.projectionMatrix.elements[9] -= offsetNdcY;
if (camera.projectionMatrixInverse?.copy) {
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
}
}
function getFitDistanceForBoundingSphere(camera, radius, sceneScaleMode, frameAspect = camera.aspect) {
const safeRadius = Math.max(radius * MODEL_FRAME_BUFFER, getSceneScaleSettings(sceneScaleMode).minModelRadius);
const verticalHalfFov = (camera.fov * Math.PI) / 360;
const horizontalHalfFov = Math.atan(Math.tan(verticalHalfFov) * Math.max(frameAspect, 1e-3));
const limitingHalfFov = Math.max(Math.min(verticalHalfFov, horizontalHalfFov), 1e-3);
return safeRadius / Math.sin(limitingHalfFov);
}
function easeInOutCubic(t) {
if (t <= 0) {
return 0;
}
if (t >= 1) {
return 1;
}
return t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
}
function easeOutCubic(t) {
if (t <= 0) {
return 0;
}
if (t >= 1) {
return 1;
}
return 1 - ((1 - t) ** 3);
}
function easeOutBack(t) {
if (t <= 0) {
return 0;
}
if (t >= 1) {
return 1;
}
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + (c3 * ((t - 1) ** 3)) + (c1 * ((t - 1) ** 2));
}
function mix(a, b, t) {
return a + ((b - a) * t);
}
function readPerspectiveSnapshot(runtime) {
if (!runtime?.camera || !runtime?.controls) {
return null;
}
return {
position: [runtime.camera.position.x, runtime.camera.position.y, runtime.camera.position.z],
target: [runtime.controls.target.x, runtime.controls.target.y, runtime.controls.target.z],
up: [runtime.camera.up.x, runtime.camera.up.y, runtime.camera.up.z]
};
}
function readScopedPerspectiveSnapshot(runtime, { modelKey = "", sceneScaleMode = "" } = {}) {
return annotatePerspectiveSnapshot(readPerspectiveSnapshot(runtime), {
modelKey,
sceneScaleMode
});
}
function maxDrawingStrokeOrdinal(strokes) {
let maxOrdinal = 0;
for (const stroke of Array.isArray(strokes) ? strokes : []) {
const match = /^stroke-(\d+)$/.exec(String(stroke?.id || ""));
if (!match) {
continue;
}
const nextOrdinal = Number(match[1]);
if (Number.isFinite(nextOrdinal) && nextOrdinal > maxOrdinal) {
maxOrdinal = nextOrdinal;
}
}
return maxOrdinal;
}
function getKeyboardOrbitCommand(event) {
if (!event) {
return null;
}
if (event.key === "ArrowLeft") {
return { direction: "left", keyId: "ArrowLeft" };
}
if (event.key === "ArrowRight") {
return { direction: "right", keyId: "ArrowRight" };
}
if (event.key === "ArrowUp") {
return { direction: "up", keyId: "ArrowUp" };
}
if (event.key === "ArrowDown") {
return { direction: "down", keyId: "ArrowDown" };
}
const key = String(event.key || "").toLowerCase();
if (key === "a" || event.code === "KeyA") {
return { direction: "left", keyId: event.code || "KeyA" };
}
if (key === "d" || event.code === "KeyD") {
return { direction: "right", keyId: event.code || "KeyD" };
}
if (key === "w" || event.code === "KeyW") {
return { direction: "up", keyId: event.code || "KeyW" };
}
if (key === "s" || event.code === "KeyS") {
return { direction: "down", keyId: event.code || "KeyS" };
}
return null;
}
function getKeyboardOrbitAxes(keyboardOrbitState) {
return {
azimuth:
(keyboardOrbitState.directionCounts.right > 0 ? 1 : 0) -
(keyboardOrbitState.directionCounts.left > 0 ? 1 : 0),
polar:
(keyboardOrbitState.directionCounts.down > 0 ? 1 : 0) -
(keyboardOrbitState.directionCounts.up > 0 ? 1 : 0)
};
}
function clearKeyboardOrbitState(keyboardOrbitState) {
if (!keyboardOrbitState) {
return;
}
keyboardOrbitState.pressedKeys.clear();
keyboardOrbitState.directionCounts.left = 0;
keyboardOrbitState.directionCounts.right = 0;
keyboardOrbitState.directionCounts.up = 0;
keyboardOrbitState.directionCounts.down = 0;
keyboardOrbitState.lastFrameTime = 0;
}
function applyOrbitDelta(runtime, azimuthDelta, polarDelta) {
if (!runtime?.THREE || !runtime?.camera || !runtime?.controls) {
return false;
}
if (Math.abs(azimuthDelta) < 1e-6 && Math.abs(polarDelta) < 1e-6) {
return false;
}
const offset = new runtime.THREE.Vector3().copy(runtime.camera.position).sub(runtime.controls.target);
const spherical = new runtime.THREE.Spherical().setFromVector3(offset);
const minPolar = Math.max(
Number.isFinite(runtime.controls.minPolarAngle) ? runtime.controls.minPolarAngle : 0,
KEYBOARD_POLAR_EPSILON
);
const maxPolar = Math.min(
Number.isFinite(runtime.controls.maxPolarAngle) ? runtime.controls.maxPolarAngle : Math.PI,
Math.PI - KEYBOARD_POLAR_EPSILON
);
const nextTheta = spherical.theta + azimuthDelta;
const minAzimuth = Number.isFinite(runtime.controls.minAzimuthAngle) ? runtime.controls.minAzimuthAngle : -Infinity;
const maxAzimuth = Number.isFinite(runtime.controls.maxAzimuthAngle) ? runtime.controls.maxAzimuthAngle : Infinity;
spherical.theta = clamp(normalizeAngleAround(nextTheta, spherical.theta), minAzimuth, maxAzimuth);
spherical.phi = clamp(spherical.phi + polarDelta, minPolar, maxPolar);
spherical.makeSafe();
offset.setFromSpherical(spherical);
runtime.camera.position.copy(runtime.controls.target).add(offset);
runtime.camera.lookAt(runtime.controls.target);
return true;
}
function stepKeyboardOrbit(runtime, timestamp) {
const keyboardOrbitState = runtime?.keyboardOrbitState;
if (!keyboardOrbitState) {
return false;
}
const axes = getKeyboardOrbitAxes(keyboardOrbitState);
if (!axes.azimuth && !axes.polar) {
keyboardOrbitState.lastFrameTime = 0;
return false;
}
if (!keyboardOrbitState.lastFrameTime) {
keyboardOrbitState.lastFrameTime = timestamp;
return false;
}
const deltaSeconds = clamp((timestamp - keyboardOrbitState.lastFrameTime) / 1000, 0, 0.05);
keyboardOrbitState.lastFrameTime = timestamp;
return applyOrbitDelta(
runtime,
axes.azimuth * KEYBOARD_ORBIT_SPEED_RAD_PER_SEC * deltaSeconds,
axes.polar * KEYBOARD_ORBIT_SPEED_RAD_PER_SEC * deltaSeconds
);
}
function cancelCameraTransition(runtime, { scheduleIdle = true } = {}) {
if (!runtime?.cameraTransition) {
return;
}
runtime.cameraTransition = null;
if (runtime.controls) {
runtime.controls.enableDamping = true;
runtime.controls.dampingFactor = DEFAULT_DAMPING_FACTOR;
}
if (scheduleIdle) {
runtime.scheduleIdleQuality?.();
}
}
function applyPerspectiveSnapshot(runtime, perspective, { scheduleIdle = true } = {}) {
const nextPerspective = clonePerspectiveSnapshot(perspective);
if (!runtime?.camera || !runtime?.controls || !nextPerspective) {
return false;
}
cancelCameraTransition(runtime, { scheduleIdle: false });
clearKeyboardOrbitState(runtime.keyboardOrbitState);
runtime.camera.position.set(...nextPerspective.position);
runtime.controls.target.set(...nextPerspective.target);
runtime.camera.up.set(...nextPerspective.up);
runtime.camera.lookAt(runtime.controls.target);
runtime.controls.update();
if (scheduleIdle) {
runtime.scheduleIdleQuality?.();
}
runtime.requestRender?.();
return true;
}
function stepCameraTransition(runtime, timestamp) {
const transition = runtime?.cameraTransition;
if (!transition || !runtime?.THREE || !runtime?.camera || !runtime?.controls) {
return false;
}
const durationMs = Math.max(transition.durationMs, 1);
const progress = clamp((timestamp - transition.startTime) / durationMs, 0, 1);
const eased = easeInOutCubic(progress);
const position = new runtime.THREE.Vector3().lerpVectors(
transition.startPosition,
transition.endPosition,
eased
);
const target = new runtime.THREE.Vector3().lerpVectors(
transition.startTarget,
transition.endTarget,
eased
);
const up = new runtime.THREE.Vector3().lerpVectors(
transition.startUp,
transition.endUp,
eased
);
runtime.camera.position.copy(position);
runtime.controls.target.copy(target);
if (up.lengthSq() > 1e-6) {
runtime.camera.up.copy(up.normalize());
}
runtime.camera.lookAt(target);
if (progress >= 1) {
runtime.cameraTransition = null;
runtime.controls.enableDamping = true;
runtime.controls.dampingFactor = DEFAULT_DAMPING_FACTOR;
runtime.scheduleIdleQuality?.();
return false;
}
return true;
}
function transitionCameraToViewPreset(runtime, preset) {
if (
!runtime?.THREE ||
!runtime?.camera ||
!runtime?.controls ||
!preset ||
!Array.isArray(preset.direction) ||
preset.direction.length !== 3 ||
!Array.isArray(preset.up) ||
preset.up.length !== 3
) {
return false;
}
const currentTarget = runtime.controls.target.clone();
const currentOffset = new runtime.THREE.Vector3().copy(runtime.camera.position).sub(currentTarget);
const fallbackDistance = Math.max(runtime.controls.minDistance || 1, 1);
const currentDistance = currentOffset.length();
const distance = clamp(
Number.isFinite(currentDistance) && currentDistance > 1e-6 ? currentDistance : fallbackDistance,
runtime.controls.minDistance || 0.01,
runtime.controls.maxDistance || Infinity
);
const nextDirection = new runtime.THREE.Vector3(...preset.direction);
if (nextDirection.lengthSq() < 1e-6) {
return false;
}
const nextUp = new runtime.THREE.Vector3(...preset.up);
if (nextUp.lengthSq() < 1e-6) {
return false;
}
nextDirection.normalize();
nextUp.normalize();
runtime.cameraTransition = {
startTime: performance.now(),
durationMs: VIEW_PLANE_TRANSITION_MS,
startPosition: runtime.camera.position.clone(),
endPosition: currentTarget.clone().add(nextDirection.multiplyScalar(distance)),
startTarget: currentTarget.clone(),
endTarget: currentTarget.clone(),
startUp: runtime.camera.up.clone(),
endUp: nextUp
};
runtime.controls.enableDamping = false;
runtime.beginInteraction?.();
runtime.requestRender?.();
return true;
}
function readBoundsVector(THREE, bounds, key) {
const value = bounds?.[key];
if (!Array.isArray(value) || value.length < 3) {
return null;
}
const vector = new THREE.Vector3(
toNumber(value[0], NaN),
toNumber(value[1], NaN),
toNumber(value[2], NaN)
);
return Number.isFinite(vector.x) && Number.isFinite(vector.y) && Number.isFinite(vector.z)
? vector
: null;
}
function readBoundsCenter(THREE, bounds) {
const min = readBoundsVector(THREE, bounds, "min");
const max = readBoundsVector(THREE, bounds, "max");
if (!min || !max) {
return null;
}
return min.add(max).multiplyScalar(0.5);
}
function readBoundsRadius(THREE, bounds) {
const min = readBoundsVector(THREE, bounds, "min");
const max = readBoundsVector(THREE, bounds, "max");
if (!min || !max) {
return 0;
}
return max.sub(min).length() * 0.5;
}
function getActiveViewPlaneFaceId(runtime) {
if (!runtime?.THREE || !runtime?.camera || !runtime?.controls) {
return "";
}
const offset = new runtime.THREE.Vector3().copy(runtime.camera.position).sub(runtime.controls.target);
if (offset.lengthSq() < 1e-6) {
return "";
}
offset.normalize();
let bestId = "";
let bestScore = -Infinity;
for (const face of VIEW_PLANE_FACES) {
const direction = new runtime.THREE.Vector3(...face.direction).normalize();
const score = offset.dot(direction);
if (score > bestScore) {
bestScore = score;
bestId = face.id;
}
}
return bestScore >= VIEW_PLANE_ACTIVE_DOT_THRESHOLD ? bestId : "";
}
function disposeSceneObject(object) {
if (!object) {
return;
}
while (object.children?.length) {
disposeSceneObject(object.children[0]);
}
if (typeof object.userData?.beforeDispose === "function") {
object.userData.beforeDispose(object);
delete object.userData.beforeDispose;
}
object.parent?.remove(object);
object.geometry?.dispose?.();
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
material?.map?.dispose?.();
material?.alphaMap?.dispose?.();
material?.dispose?.();
}
}
function clearSceneGroup(group) {
while (group.children.length) {
disposeSceneObject(group.children[0]);
}
}
function shouldUseDisplayVertexColors(meshData) {
return !!meshData?.has_source_colors && isNumericArray(meshData?.colors, 3);
}
function partUsesDisplayVertexColors(meshData, part) {
if (!shouldUseDisplayVertexColors(meshData)) {
return false;
}
if (part && Object.hasOwn(part, "hasSourceColors")) {
return !!part.hasSourceColors;
}
return true;
}
function createSurfaceMaterial(THREE, viewerTheme, { color, useVertexColors = false } = {}) {
const opacity = Number.isFinite(Number(viewerTheme?.surfaceOpacity))
? Number(viewerTheme.surfaceOpacity)
: 1;
return new THREE.MeshPhysicalMaterial({
color: color || viewerTheme?.surface || BASE_VIEWER_THEME.surface,
roughness: getViewerThemeNumber(viewerTheme, "surfaceRoughness", BASE_VIEWER_THEME.surfaceRoughness),
metalness: getViewerThemeNumber(viewerTheme, "surfaceMetalness", BASE_VIEWER_THEME.surfaceMetalness),
clearcoat: getViewerThemeNumber(viewerTheme, "surfaceClearcoat", BASE_VIEWER_THEME.surfaceClearcoat),
clearcoatRoughness: getViewerThemeNumber(
viewerTheme,
"surfaceClearcoatRoughness",
BASE_VIEWER_THEME.surfaceClearcoatRoughness
),
side: THREE.DoubleSide,
vertexColors: useVertexColors,
transparent: opacity < 0.999,
opacity,
emissive: 0x000000,
emissiveIntensity: 0,
polygonOffset: true,
polygonOffsetFactor: 2,
polygonOffsetUnits: 2
});
}
function readSourceColor(THREE, value) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return null;
}
const expanded = normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
: normalized;
return new THREE.Color(expanded);
}
function shapeSourceColor(THREE, sourceColor, materialSettings = {}, { applyTint = true } = {}) {
const shaped = (sourceColor || new THREE.Color("#ffffff")).clone();
const tintStrength = clamp(Number(materialSettings.tintStrength) || 0, 0, 1);
if (applyTint && tintStrength > 0) {
const tintColor = createSafeColor(THREE, materialSettings.defaultColor || materialSettings.tintColor, "#ffffff");
shaped.lerp(shaped.clone().multiply(tintColor), tintStrength);
}
const saturation = clamp(Number(materialSettings.saturation) || 1, 0, 2.5);
if (Math.abs(saturation - 1) > 1e-4) {
const hsl = {};
shaped.getHSL(hsl);
shaped.setHSL(hsl.h, clamp(hsl.s * saturation, 0, 1), hsl.l);
}
const contrast = clamp(Number(materialSettings.contrast) || 1, 0, 2.5);
const brightness = clamp(Number(materialSettings.brightness) || 1, 0, 2);
shaped.r = clamp(((shaped.r - 0.5) * contrast + 0.5) * brightness, 0, 1);
shaped.g = clamp(((shaped.g - 0.5) * contrast + 0.5) * brightness, 0, 1);
shaped.b = clamp(((shaped.b - 0.5) * contrast + 0.5) * brightness, 0, 1);
return shaped;
}
function shapeSourceColorBuffer(THREE, colors, materialSettings = {}) {
if (!isNumericArray(colors, 3)) {
return colors;
}
const shapedColors = new Float32Array(colors.length);
const color = new THREE.Color();
for (let index = 0; index + 2 < colors.length; index += 3) {
color.setRGB(
clamp(Number(colors[index]) || 0, 0, 1),
clamp(Number(colors[index + 1]) || 0, 0, 1),
clamp(Number(colors[index + 2]) || 0, 0, 1)
);
const shaped = shapeSourceColor(THREE, color, materialSettings);
shapedColors[index] = shaped.r;
shapedColors[index + 1] = shaped.g;
shapedColors[index + 2] = shaped.b;
}
return shapedColors;
}
function resolveSourceBaseColor(THREE, { hasVertexColors = false, sourceColor = null, materialSettings, fallbackColor = "#ffffff" }) {
if (hasVertexColors) {
return new THREE.Color("#ffffff");
}
if (!sourceColor) {
return shapeSourceColor(
THREE,
createSafeColor(THREE, materialSettings?.defaultColor, fallbackColor),
materialSettings,
{ applyTint: false }
);
}
return shapeSourceColor(THREE, sourceColor, materialSettings);
}
function applyMaterialSettingsToRecord(THREE, record, materialSettings) {
if (!record?.material || !materialSettings) {
return;
}
const hasVertexColors = !!record.hasVertexColors;
const nextUseVertexColors = hasVertexColors;
record.useVertexColors = nextUseVertexColors;
record.baseColor = resolveSourceBaseColor(THREE, {
hasVertexColors,
sourceColor: record.sourceColor || null,
materialSettings,
fallbackColor: materialSettings?.defaultColor || BASE_VIEWER_THEME.surface
});
record.material.vertexColors = nextUseVertexColors;
record.material.roughness = clamp(Number(materialSettings.roughness) || 0, 0, 1);
record.material.metalness = clamp(Number(materialSettings.metalness) || 0, 0, 1);
record.material.clearcoat = clamp(Number(materialSettings.clearcoat) || 0, 0, 1);
record.material.clearcoatRoughness = clamp(Number(materialSettings.clearcoatRoughness) || 0, 0, 1);
record.baseOpacity = clamp(Number(materialSettings.opacity) || 0, 0, 1);
record.material.opacity = record.baseOpacity;
record.material.transparent = record.baseOpacity < 0.999;
record.material.envMapIntensity = Math.max(Number(materialSettings.envMapIntensity) || 0, 0);
if (record.material.color && record.baseColor) {
record.material.color.copy(record.baseColor);
}
record.material.needsUpdate = true;
}
function getPartHighlightColors(THREE) {
return {
hoveredSurfaceColor: new THREE.Color(REFERENCE_HOVER_COLOR),
hoveredEdgeColor: new THREE.Color(REFERENCE_HOVER_COLOR),
selectedSurfaceColor: new THREE.Color(REFERENCE_SELECTED_COLOR),
selectedEdgeColor: new THREE.Color(REFERENCE_SELECTED_COLOR)
};
}
function getLineSegmentPositions(geometry) {
const positionAttribute = geometry?.getAttribute?.("position");
const rawPositions = positionAttribute?.array;
if (!positionAttribute?.count || !rawPositions?.length) {
return null;
}
return rawPositions;
}
function getEdgeThickness(edgeSettings = null, viewerTheme = null) {
const fallbackThickness = Number.isFinite(Number(viewerTheme?.edgeThickness))
? Number(viewerTheme.edgeThickness)
: BASE_VIEWER_THEME.edgeThickness;
return Number.isFinite(Number(edgeSettings?.thickness))
? clamp(Number(edgeSettings.thickness), 0.5, 6)
: fallbackThickness;
}
function createScreenSpaceLineSegments(runtime, positions, {
color,
opacity = 1,
lineWidth = BASE_VIEWER_THEME.edgeThickness,
renderOrder = 26,
depthTest = false,
depthWrite = false
} = {}) {
if (
!runtime?.LineSegments2 ||
!runtime?.LineSegmentsGeometry ||
!runtime?.LineMaterial ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
!positions.length
) {
return null;
}
const lineGeometry = new runtime.LineSegmentsGeometry();
lineGeometry.setPositions(positions);
const lineMaterial = new runtime.LineMaterial({
color,
linewidth: lineWidth,
transparent: true,
opacity,
depthTest,
depthWrite,
toneMapped: false,
worldUnits: false
});
runtime.registerScreenSpaceLineMaterial?.(lineMaterial);
const line = new runtime.LineSegments2(lineGeometry, lineMaterial);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.beforeDispose = () => {
runtime.unregisterScreenSpaceLineMaterial?.(lineMaterial);
};
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
function createScreenSpaceLineSegmentsFromGeometry(runtime, geometry, options) {
const positions = getLineSegmentPositions(geometry);
if (!positions) {
return null;
}
return createScreenSpaceLineSegments(runtime, positions, options);
}
function disposeOverlayChild(runtime, child) {
if (!child) {
return;
}
if (typeof child.userData?.beforeDispose === "function") {
child.userData.beforeDispose(child);
delete child.userData.beforeDispose;
}
const materials = Array.isArray(child.material) ? child.material : [child.material];
if (child.userData?.disposeGeometry !== false) {
child.geometry?.dispose?.();
}
if (child.userData?.disposeMaterial !== false) {
for (const material of materials) {
material?.dispose?.();
}
}
}
function clearOverlayGroup(runtime, group) {
while (group?.children?.length) {
const child = group.children[group.children.length - 1];
if (!child) {
continue;
}
group.remove(child);
disposeOverlayChild(runtime, child);
}
if (group) {
group.visible = false;
}
}
function createDisplayEdgeObject(runtime, geometry, viewerTheme, edgeSettings, partId) {
const line = createScreenSpaceLineSegmentsFromGeometry(runtime, geometry, {
color: edgeSettings?.color || viewerTheme?.edge || BASE_VIEWER_THEME.edge,
opacity: Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (viewerTheme?.edgeOpacity ?? BASE_VIEWER_THEME.edgeOpacity ?? CAD_EDGE_OPACITY),
lineWidth: getEdgeThickness(edgeSettings, viewerTheme),
renderOrder: 3,
depthTest: true,
depthWrite: false
});
if (!line) {
return {
edgeMesh: null,
edgeMaterial: null
};
}
line.userData.partId = partId;
return {
edgeMesh: line,
edgeMaterial: line.material
};
}
function applyGeometryNormals(THREE, geometry, normals, recomputeNormals) {
const hasNormals = isNumericArray(normals, 3);
if (!recomputeNormals && hasNormals) {
geometry.setAttribute("normal", new THREE.BufferAttribute(new Float32Array(normals), 3));
return;
}
geometry.computeVertexNormals();
}
function toNumber(value, fallback = 0) {
return Number.isFinite(Number(value)) ? Number(value) : fallback;
}
function buildPartGeometry(THREE, meshData, part, recomputeNormals, materialSettings = {}) {
const vertexOffset = toNumber(part?.vertexOffset, 0);
const vertexCount = toNumber(part?.vertexCount, 0);
const triangleOffset = toNumber(part?.triangleOffset, 0);
const triangleCount = toNumber(part?.triangleCount, 0);
if (vertexCount <= 0 || triangleCount <= 0) {
return null;
}
const positionStart = vertexOffset * 3;
const positionEnd = positionStart + vertexCount * 3;
const localVertices = meshData.vertices.slice(positionStart, positionEnd);
const localColors = partUsesDisplayVertexColors(meshData, part)
? shapeSourceColorBuffer(THREE, meshData.colors.slice(positionStart, positionEnd), materialSettings)
: null;
const localNormals = isNumericArray(meshData.normals, 3) ? meshData.normals.slice(positionStart, positionEnd) : null;
const rawIndices = meshData.indices.slice(triangleOffset * 3, triangleOffset * 3 + triangleCount * 3);
const localIndices = rawIndices.map((index) => index - vertexOffset);
if (!localIndices.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(localVertices), 3));
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(localIndices), 1));
if (localColors && localColors.length === localVertices.length) {
geometry.setAttribute("color", new THREE.BufferAttribute(new Float32Array(localColors), 3));
}
applyGeometryNormals(THREE, geometry, localNormals, recomputeNormals);
geometry.computeBoundingSphere();
return geometry;
}
function buildDisplayEdgeGeometry(THREE, sourceGeometry) {
const edgeGeometry = new THREE.EdgesGeometry(sourceGeometry, CAD_EDGE_THRESHOLD_DEG);
const positions = edgeGeometry.getAttribute("position");
if (positions?.count) {
return edgeGeometry;
}
edgeGeometry.dispose();
return emptyLineGeometry(THREE);
}
function buildEdgeGeometryFromIndices(THREE, vertices, edgeIndices) {
if (!isNumericArray(vertices, 3) || !isNumericArray(edgeIndices, 2)) {
return null;
}
const vertexCount = Math.floor(vertices.length / 3);
const segmentCount = Math.floor(edgeIndices.length / 2);
if (segmentCount <= 0) {
return null;
}
const linePositions = new Float32Array(segmentCount * 6);
let writeOffset = 0;
for (let index = 0; index + 1 < edgeIndices.length; index += 2) {
const a = Number(edgeIndices[index]);
const b = Number(edgeIndices[index + 1]);
if (!Number.isInteger(a) || !Number.isInteger(b) || a < 0 || b < 0 || a >= vertexCount || b >= vertexCount) {
continue;
}
const aOffset = a * 3;
const bOffset = b * 3;
linePositions[writeOffset] = Number(vertices[aOffset]);
linePositions[writeOffset + 1] = Number(vertices[aOffset + 1]);
linePositions[writeOffset + 2] = Number(vertices[aOffset + 2]);
linePositions[writeOffset + 3] = Number(vertices[bOffset]);
linePositions[writeOffset + 4] = Number(vertices[bOffset + 1]);
linePositions[writeOffset + 5] = Number(vertices[bOffset + 2]);
writeOffset += 6;
}
if (!writeOffset) {
return null;
}
const geometry = new THREE.BufferGeometry();
const packedPositions = writeOffset === linePositions.length ? linePositions : linePositions.subarray(0, writeOffset);
geometry.setAttribute("position", new THREE.BufferAttribute(packedPositions, 3));
return geometry;
}
function buildPartEdgeGeometry(THREE, meshData, part, sourceGeometry) {
const edgeIndexOffset = toNumber(part?.edgeIndexOffset, 0);
const edgeIndexCount = toNumber(part?.edgeIndexCount, 0);
const hasExplicitPartEdges = edgeIndexCount >= 2 && isNumericArray(meshData?.edge_indices, 2);
if (hasExplicitPartEdges) {
const partEdgeIndices = typeof meshData.edge_indices.subarray === "function"
? meshData.edge_indices.subarray(edgeIndexOffset, edgeIndexOffset + edgeIndexCount)
: meshData.edge_indices.slice(edgeIndexOffset, edgeIndexOffset + edgeIndexCount);
const explicitGeometry = buildEdgeGeometryFromIndices(THREE, meshData.vertices, partEdgeIndices);
if (explicitGeometry) {
return explicitGeometry;
}
}
return buildDisplayEdgeGeometry(THREE, sourceGeometry);
}
function applyPartTransform(THREE, object3d, transform) {
if (!object3d) {
return;
}
if (!Array.isArray(transform) || transform.length !== 16) {
object3d.matrixAutoUpdate = true;
object3d.position.set(0, 0, 0);
object3d.rotation.set(0, 0, 0);
object3d.scale.set(1, 1, 1);
object3d.updateMatrix();
return;
}
object3d.matrixAutoUpdate = false;
const matrix = object3d.matrix instanceof THREE.Matrix4 ? object3d.matrix : new THREE.Matrix4();
matrix.set(
Number(transform[0]) || 0,
Number(transform[1]) || 0,
Number(transform[2]) || 0,
Number(transform[3]) || 0,
Number(transform[4]) || 0,
Number(transform[5]) || 0,
Number(transform[6]) || 0,
Number(transform[7]) || 0,
Number(transform[8]) || 0,
Number(transform[9]) || 0,
Number(transform[10]) || 0,
Number(transform[11]) || 0,
Number(transform[12]) || 0,
Number(transform[13]) || 0,
Number(transform[14]) || 0,
Number(transform[15]) || 0
);
object3d.matrix = matrix;
object3d.matrixWorldNeedsUpdate = true;
}
function buildPartTransformMatrix(THREE, transform) {
const matrix = new THREE.Matrix4();
if (!Array.isArray(transform) || transform.length !== 16) {
matrix.identity();
return matrix;
}
matrix.set(
Number(transform[0]) || 0,
Number(transform[1]) || 0,
Number(transform[2]) || 0,
Number(transform[3]) || 0,
Number(transform[4]) || 0,
Number(transform[5]) || 0,
Number(transform[6]) || 0,
Number(transform[7]) || 0,
Number(transform[8]) || 0,
Number(transform[9]) || 0,
Number(transform[10]) || 0,
Number(transform[11]) || 0,
Number(transform[12]) || 0,
Number(transform[13]) || 0,
Number(transform[14]) || 0,
Number(transform[15]) || 0
);
return matrix;
}
function applyObjectMatrix(THREE, object3d, matrix) {
if (!object3d || !(matrix instanceof THREE.Matrix4)) {
return;
}
object3d.matrixAutoUpdate = false;
const targetMatrix = object3d.matrix instanceof THREE.Matrix4 ? object3d.matrix : new THREE.Matrix4();
targetMatrix.copy(matrix);
object3d.matrix = targetMatrix;
object3d.matrixWorldNeedsUpdate = true;
}
function buildDisplayRecordIntroMatrix(THREE, record, modelRadius = 1) {
const partCenter = record?.partCenter instanceof THREE.Vector3
? record.partCenter.clone()
: null;
if (!partCenter) {
return null;
}
const introProgress = clamp(Number(record?.introProgress), 0, 1);
if (introProgress >= 0.999) {
return null;
}
const reveal = easeOutCubic(introProgress);
const scale = mix(URDF_PART_INTRO_INITIAL_SCALE, 1, easeOutBack(introProgress));
const partRadius = Math.max(readBoundsRadius(THREE, record?.partBounds), modelRadius * 0.04, 0.012);
const travelDistance = clamp(partRadius * 0.45, URDF_PART_INTRO_MIN_TRAVEL, Math.max(modelRadius * 0.08, 0.05));
const driftDirection = new THREE.Vector3(partCenter.x, (partCenter.y * 0.4) + (modelRadius * 0.1) + 0.08, partCenter.z);
if (driftDirection.lengthSq() < 1e-6) {
driftDirection.set(0.35, 1, 0.25);
}
driftDirection.normalize();
const translation = driftDirection.multiplyScalar(travelDistance * ((1 - reveal) ** 2));
const rotationAxis = new THREE.Vector3(-driftDirection.z, 0, driftDirection.x);
if (rotationAxis.lengthSq() < 1e-6) {
rotationAxis.set(0.2, 0, 1);
}
rotationAxis.normalize();
const rotationAngle = URDF_PART_INTRO_MAX_TILT_RAD * ((1 - reveal) ** 2);
const translateToCenter = new THREE.Matrix4().makeTranslation(partCenter.x, partCenter.y, partCenter.z);
const translateFromCenter = new THREE.Matrix4().makeTranslation(-partCenter.x, -partCenter.y, -partCenter.z);
const offsetMatrix = new THREE.Matrix4().makeTranslation(translation.x, translation.y, translation.z);
const rotationScaleMatrix = new THREE.Matrix4().compose(
new THREE.Vector3(0, 0, 0),
new THREE.Quaternion().setFromAxisAngle(rotationAxis, rotationAngle),
new THREE.Vector3(scale, scale, scale)
);
return offsetMatrix.multiply(translateToCenter).multiply(rotationScaleMatrix).multiply(translateFromCenter);
}
function applyDisplayRecordTransform(THREE, record, modelRadius = 1) {
if (!record) {
return;
}
const baseMatrix = buildPartTransformMatrix(THREE, record.baseTransform);
const introMatrix = buildDisplayRecordIntroMatrix(THREE, record, modelRadius);
const combinedMatrix = introMatrix ? introMatrix.multiply(baseMatrix) : baseMatrix;
applyObjectMatrix(THREE, record.mesh, combinedMatrix);
applyObjectMatrix(THREE, record.edges, combinedMatrix);
}
function applyRuntimeModelBounds(THREE, runtime, bounds, sceneScaleMode) {
const boundsMin = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const boundsMax = Array.isArray(bounds?.max) ? bounds.max : [0, 0, 0];
const radius = clampSceneModelRadius(
new THREE.Vector3(
toNumber(boundsMax[0]) - toNumber(boundsMin[0]),
toNumber(boundsMax[1]) - toNumber(boundsMin[1]),
toNumber(boundsMax[2]) - toNumber(boundsMin[2])
).length() / 2,
sceneScaleMode
);
runtime.modelBounds = {
min: boundsMin,
max: boundsMax
};
runtime.modelRadius = radius;
if (runtime.keyLight?.shadow?.camera) {
const normalizedScaleMode = normalizeSceneScaleMode(sceneScaleMode);
const isUrdfScale = normalizedScaleMode === VIEWER_SCENE_SCALE.URDF;
const shadowScopeRadius = Math.max(radius, getLightingScopeRadius(normalizedScaleMode));
const shadowExtent = Math.max(shadowScopeRadius * 2.8, isUrdfScale ? 0.55 : 60);
const keyLightDistance = typeof runtime.keyLight.position?.length === "function"
? runtime.keyLight.position.length()
: 0;
const shadowFar = Math.max(
shadowScopeRadius * 8,
isUrdfScale ? keyLightDistance + shadowScopeRadius * 6 + 1 : 320
);
runtime.keyLight.shadow.mapSize.set(DEFAULT_SHADOW_MAP_SIZE, DEFAULT_SHADOW_MAP_SIZE);
runtime.keyLight.shadow.bias = -0.00025;
runtime.keyLight.shadow.normalBias = isUrdfScale ? 0.00045 : 0.012;
runtime.keyLight.shadow.radius = isUrdfScale ? 20 : 14;
runtime.keyLight.shadow.camera.left = -shadowExtent;
runtime.keyLight.shadow.camera.right = shadowExtent;
runtime.keyLight.shadow.camera.top = shadowExtent;
runtime.keyLight.shadow.camera.bottom = -shadowExtent;
runtime.keyLight.shadow.camera.near = 0.1;
runtime.keyLight.shadow.camera.far = shadowFar;
runtime.keyLight.shadow.camera.updateProjectionMatrix?.();
}
return {
boundsMin,
boundsMax,
radius
};
}
function applyPartVisualState(THREE, records, {
viewerTheme,
edgeSettings,
hiddenPartIds,
hoveredPartId,
focusedPartId,
selectedPartIds,
showEdges
}) {
const hidden = new Set(Array.isArray(hiddenPartIds) ? hiddenPartIds : []);
const selected = new Set(Array.isArray(selectedPartIds) ? selectedPartIds : []);
const hovered = new Set(
(Array.isArray(hoveredPartId) ? hoveredPartId : [hoveredPartId])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const baseEdgeColor = edgeSettings?.color || viewerTheme?.edge || BASE_VIEWER_THEME.edge;
const defaultSurfaceOpacity = Number.isFinite(Number(viewerTheme?.surfaceOpacity))
? Number(viewerTheme.surfaceOpacity)
: 1;
const focusIds = new Set(
(Array.isArray(focusedPartId) ? focusedPartId : [focusedPartId])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const hasFocus = focusIds.size > 0;
const baseEdgeOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (viewerTheme?.edgeOpacity ?? BASE_VIEWER_THEME.edgeOpacity ?? CAD_EDGE_OPACITY);
const dimmedEdgeOpacity = hasFocus
? Math.max(Math.min(baseEdgeOpacity * 0.28, 0.12), 0.04)
: baseEdgeOpacity;
const {
hoveredSurfaceColor,
hoveredEdgeColor,
selectedSurfaceColor,
selectedEdgeColor
} = getPartHighlightColors(THREE);
for (const record of Array.isArray(records) ? records : []) {
const isHidden = hidden.has(record.partId);
const isSelected = selected.has(record.partId);
const isHovered = !isHidden && hovered.has(record.partId);
const isFocused = !isHidden && hasFocus && focusIds.has(record.partId);
const isDimmed = !isHidden && hasFocus && !isFocused;
const introOpacity = Number.isFinite(Number(record?.introOpacity))
? clamp(Number(record.introOpacity), 0, 1)
: 1;
const introVisible = introOpacity > URDF_PART_INTRO_VISIBILITY_EPSILON;
record.mesh.visible = !isHidden && introVisible;
if (record.edges) {
record.edges.visible = showEdges && !isHidden && introVisible;
}
const baseSurfaceOpacity = Number.isFinite(Number(record.baseOpacity))
? Number(record.baseOpacity)
: defaultSurfaceOpacity;
const dimmedSurfaceOpacity = hasFocus
? Math.max(Math.min(baseSurfaceOpacity * 0.2, 0.24), 0.1)
: baseSurfaceOpacity;
const highlightedSurfaceOpacity = isSelected
? clamp(baseSurfaceOpacity + PART_SELECTED_OPACITY_BOOST, 0, 1)
: isHovered
? clamp(baseSurfaceOpacity + PART_HOVER_OPACITY_BOOST, 0, 1)
: baseSurfaceOpacity;
const nextSurfaceOpacity = (isDimmed ? dimmedSurfaceOpacity : highlightedSurfaceOpacity) * introOpacity;
record.material.transparent = isDimmed || introOpacity < 0.999 || nextSurfaceOpacity < 0.999;
record.material.opacity = nextSurfaceOpacity;
if (record.baseColor && record.material.color) {
record.material.color.copy(
isSelected
? selectedSurfaceColor
: isHovered
? hoveredSurfaceColor
: record.baseColor
);
}
if ("emissive" in record.material && record.material.emissive) {
record.material.emissive.set(
isSelected
? REFERENCE_SELECTED_COLOR
: isHovered
? REFERENCE_HOVER_COLOR
: 0x000000
);
record.material.emissiveIntensity = isSelected
? 0.08
: isHovered
? 0.12
: 0;
}
if (record.edgeMaterial) {
record.edgeMaterial.color.set(
isSelected
? selectedEdgeColor
: isHovered
? hoveredEdgeColor
: baseEdgeColor
);
record.edgeMaterial.opacity = isSelected
? introOpacity
: isHovered
? introOpacity
: isDimmed
? dimmedEdgeOpacity * introOpacity
: baseEdgeOpacity * introOpacity;
}
}
}
function parseTopologyReferenceId(referenceId) {
const normalizedReferenceId = String(referenceId || "").trim();
const parts = normalizedReferenceId.split(":");
if (parts.length >= 3 && (parts[0] === "face" || parts[0] === "edge")) {
const kind = parts[0];
const ordinal = Number(parts[parts.length - 1]);
const partId = parts.slice(1, -1).join(":").trim();
if (!partId || !Number.isInteger(ordinal) || ordinal <= 0) {
return null;
}
return {
id: normalizedReferenceId,
kind,
partId,
ordinal
};
}
}
function createReferenceEdgeGeometryFromPoints(THREE, points) {
if (!Array.isArray(points) || points.length < 2) {
return null;
}
const linePositions = [];
for (let index = 1; index < points.length; index += 1) {
const start = points[index - 1];
const end = points[index];
if (!Array.isArray(start) || !Array.isArray(end) || start.length < 3 || end.length < 3) {
continue;
}
linePositions.push(
Number(start[0]),
Number(start[1]),
Number(start[2]),
Number(end[0]),
Number(end[1]),
Number(end[2])
);
}
if (!linePositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(linePositions), 3));
return geometry;
}
function createReferenceFaceLoopGeometry(THREE, loops) {
if (!Array.isArray(loops) || !loops.length) {
return null;
}
const linePositions = [];
for (const loop of loops) {
if (!Array.isArray(loop) || loop.length < 2) {
continue;
}
for (let index = 0; index < loop.length; index += 1) {
const start = loop[index];
const end = loop[(index + 1) % loop.length];
if (!Array.isArray(start) || !Array.isArray(end) || start.length < 3 || end.length < 3) {
continue;
}
linePositions.push(
Number(start[0]),
Number(start[1]),
Number(start[2]),
Number(end[0]),
Number(end[1]),
Number(end[2])
);
}
}
if (!linePositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(linePositions), 3));
return geometry;
}
function createReferenceFaceBoundaryGeometry(THREE, reference) {
const pickData = reference?.pickData || {};
const loopsMeta = Array.isArray(pickData?.loopsMeta) ? pickData.loopsMeta : [];
const boundaryLoops = loopsMeta
.filter((loopEntry) => Array.isArray(loopEntry?.points) && loopEntry.points.length >= 2)
.map((loopEntry) => loopEntry.points);
if (boundaryLoops.length) {
return createReferenceFaceLoopGeometry(THREE, boundaryLoops);
}
const fallbackLoops = Array.isArray(pickData?.loops)
? pickData.loops.filter((loop) => Array.isArray(loop) && loop.length >= 2)
: [];
return createReferenceFaceLoopGeometry(THREE, fallbackLoops);
}
function selectOuterLoopPoints(pickData = {}) {
const loopsMeta = Array.isArray(pickData?.loopsMeta) ? pickData.loopsMeta : [];
const explicitOuter = loopsMeta.find((loop) => loop?.isOuter && Array.isArray(loop?.points) && loop.points.length >= 3);
if (explicitOuter) {
return explicitOuter.points;
}
const loops = Array.isArray(pickData?.loops) ? pickData.loops : [];
return loops.find((loop) => Array.isArray(loop) && loop.length >= 3) || null;
}
function isFinitePoint2(point) {
return Array.isArray(point) && point.length >= 2 && Number.isFinite(Number(point[0])) && Number.isFinite(Number(point[1]));
}
function isFinitePoint3(point) {
return Array.isArray(point) && point.length >= 3 && Number.isFinite(Number(point[0])) && Number.isFinite(Number(point[1])) && Number.isFinite(Number(point[2]));
}
function distanceSquared2d(a, b) {
const dx = Number(a[0]) - Number(b[0]);
const dy = Number(a[1]) - Number(b[1]);
return dx * dx + dy * dy;
}
function distanceSquared3d(a, b) {
const dx = Number(a[0]) - Number(b[0]);
const dy = Number(a[1]) - Number(b[1]);
const dz = Number(a[2]) - Number(b[2]);
return dx * dx + dy * dy + dz * dz;
}
function sanitizeLoopPair(loop3d, loop2d) {
const count = Math.min(
Array.isArray(loop3d) ? loop3d.length : 0,
Array.isArray(loop2d) ? loop2d.length : 0
);
const points3d = [];
const points2d = [];
for (let index = 0; index < count; index += 1) {
const point3d = loop3d[index];
const point2d = loop2d[index];
if (!isFinitePoint3(point3d) || !isFinitePoint2(point2d)) {
continue;
}
const normalized3d = [Number(point3d[0]), Number(point3d[1]), Number(point3d[2])];
const normalized2d = [Number(point2d[0]), Number(point2d[1])];
const previous3d = points3d[points3d.length - 1];
const previous2d = points2d[points2d.length - 1];
if (previous3d && previous2d) {
const duplicate3d = distanceSquared3d(previous3d, normalized3d) <= 1e-10;
const duplicate2d = distanceSquared2d(previous2d, normalized2d) <= 1e-10;
if (duplicate3d || duplicate2d) {
continue;
}
}
points3d.push(normalized3d);
points2d.push(normalized2d);
}
if (points3d.length >= 3 && distanceSquared3d(points3d[0], points3d[points3d.length - 1]) <= 1e-10) {
points3d.pop();
points2d.pop();
}
if (points3d.length < 3 || points2d.length < 3) {
return null;
}
return { loop3d: points3d, loop2d: points2d };
}
function averagePoint3(points) {
if (!Array.isArray(points) || !points.length) {
return [0, 0, 0];
}
let x = 0;
let y = 0;
let z = 0;
let count = 0;
for (const point of points) {
if (!isFinitePoint3(point)) {
continue;
}
x += Number(point[0]);
y += Number(point[1]);
z += Number(point[2]);
count += 1;
}
if (!count) {
return [0, 0, 0];
}
return [x / count, y / count, z / count];
}
function normalizeVector3(vector) {
const magnitude = length(vector);
if (magnitude <= 1e-9) {
return null;
}
return [vector[0] / magnitude, vector[1] / magnitude, vector[2] / magnitude];
}
function cross3(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]
];
}
function projectLoopsWithFrame(loops, origin, xDir, yDir) {
return loops.map((loop) => loop.map((point) => {
const relative = subtract(point, origin);
return [dot(relative, xDir), dot(relative, yDir)];
}));
}
function projectFaceLoopsTo2d(loops, surface) {
if (!Array.isArray(loops) || !loops.length) {
return null;
}
const sanitizedLoops = loops.map((loop) => (Array.isArray(loop) ? loop.filter(isFinitePoint3) : []));
const hasAnyValidLoop = sanitizedLoops.some((loop) => loop.length >= 3);
if (!hasAnyValidLoop) {
return null;
}
if (surface?.type === "PLANE" && Array.isArray(surface?.origin) && Array.isArray(surface?.xDir) && Array.isArray(surface?.yDir)) {
const origin = surface.origin;
const xDir = surface.xDir;
const yDir = surface.yDir;
return projectLoopsWithFrame(sanitizedLoops, origin, xDir, yDir);
}
if (
surface?.type === "CYLINDRICAL_SURFACE" &&
Array.isArray(surface?.origin) &&
Array.isArray(surface?.axis) &&
Array.isArray(surface?.xDir) &&
Array.isArray(surface?.yDir) &&
Number(surface?.radius) > 0
) {
const origin = surface.origin;
const axis = surface.axis;
const xDir = surface.xDir;
const yDir = surface.yDir;
const radius = Number(surface.radius);
return sanitizedLoops.map((loop) => {
let previousAngle = null;
return loop.map((point) => {
const relative = subtract(point, origin);
const axial = dot(relative, axis);
const radial = [
relative[0] - axis[0] * axial,
relative[1] - axis[1] * axial,
relative[2] - axis[2] * axial
];
const rawAngle = Math.atan2(dot(radial, yDir), dot(radial, xDir));
const angle = previousAngle === null ? rawAngle : normalizeAngleAround(rawAngle, previousAngle);
previousAngle = angle;
return [angle * radius, axial];
});
});
}
const allPoints = sanitizedLoops.flat();
const origin = isFinitePoint3(surface?.origin) ? surface.origin : averagePoint3(allPoints);
let normal = normalizeVector3(surface?.normal || []);
if (!normal) {
for (let index = 2; index < allPoints.length; index += 1) {
const a = allPoints[index - 2];
const b = allPoints[index - 1];
const c = allPoints[index];
const ab = subtract(b, a);
const ac = subtract(c, a);
normal = normalizeVector3(cross3(ab, ac));
if (normal) {
break;
}
}
}
if (!normal) {
normal = [0, 0, 1];
}
let axis = Math.abs(normal[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0];
let xDir = normalizeVector3(cross3(axis, normal));
if (!xDir) {
axis = [1, 0, 0];
xDir = normalizeVector3(cross3(axis, normal));
}
if (!xDir) {
return null;
}
const yDir = normalizeVector3(cross3(normal, xDir));
if (!yDir) {
return null;
}
return projectLoopsWithFrame(sanitizedLoops, origin, xDir, yDir);
}
function buildFallbackFaceFanGeometry(THREE, loop3d, centroid = null) {
if (!Array.isArray(loop3d) || loop3d.length < 3) {
return null;
}
const cleanedLoop = loop3d.filter(isFinitePoint3).map((point) => [Number(point[0]), Number(point[1]), Number(point[2])]);
if (cleanedLoop.length < 3) {
return null;
}
const center = isFinitePoint3(centroid) ? centroid.map((value) => Number(value)) : averagePoint3(cleanedLoop);
const fillPositions = [];
for (let index = 0; index < cleanedLoop.length; index += 1) {
const a = cleanedLoop[index];
const b = cleanedLoop[(index + 1) % cleanedLoop.length];
fillPositions.push(
center[0], center[1], center[2],
a[0], a[1], a[2],
b[0], b[1], b[2]
);
}
if (!fillPositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(fillPositions), 3));
return geometry;
}
function createReferenceFaceFillGeometry(THREE, reference) {
const pickData = reference?.pickData;
const loops3d = Array.isArray(pickData?.loops) ? pickData.loops : [];
if (!loops3d.length) {
return null;
}
const projectedLoops = projectFaceLoopsTo2d(loops3d, pickData?.surface || {});
if (!projectedLoops) {
return buildFallbackFaceFanGeometry(
THREE,
selectOuterLoopPoints(pickData) || loops3d[0],
pickData?.centroid || null
);
}
const loopEntries = loops3d
.map((loop3d, index) => {
const cleaned = sanitizeLoopPair(loop3d, projectedLoops[index] || []);
if (!cleaned) {
return null;
}
return {
index,
...cleaned
};
})
.filter((entry) => entry && Array.isArray(entry.loop3d) && entry.loop3d.length >= 3 && Array.isArray(entry.loop2d) && entry.loop2d.length >= 3);
if (!loopEntries.length) {
return buildFallbackFaceFanGeometry(
THREE,
selectOuterLoopPoints(pickData) || loops3d[0],
pickData?.centroid || null
);
}
const preferredOuterIndex = Number.isInteger(pickData?.outerLoopIndex) ? pickData.outerLoopIndex : 0;
const outerEntry = loopEntries.find((entry) => entry.index === preferredOuterIndex) || loopEntries[0];
if (!outerEntry) {
return null;
}
const contourLoop3d = [...outerEntry.loop3d];
let contour2d = outerEntry.loop2d.map((point) => new THREE.Vector2(Number(point[0]), Number(point[1])));
if (contour2d.length < 3) {
return buildFallbackFaceFanGeometry(THREE, contourLoop3d, pickData?.centroid || null);
}
let contourClockwise = THREE.ShapeUtils.isClockWise(contour2d);
if (contourClockwise) {
contour2d = [...contour2d].reverse();
contourLoop3d.reverse();
contourClockwise = false;
}
const holeEntries = loopEntries.filter((entry) => entry.index !== outerEntry.index);
const holeData = [];
for (const holeEntry of holeEntries) {
let holeLoop3d = [...holeEntry.loop3d];
let hole2d = holeEntry.loop2d.map((point) => new THREE.Vector2(Number(point[0]), Number(point[1])));
if (hole2d.length < 3) {
continue;
}
const holeClockwise = THREE.ShapeUtils.isClockWise(hole2d);
if (holeClockwise === contourClockwise) {
hole2d = [...hole2d].reverse();
holeLoop3d.reverse();
}
holeData.push({
loop2d: hole2d,
loop3d: holeLoop3d
});
}
const triangulated = THREE.ShapeUtils.triangulateShape(
contour2d,
holeData.map((entry) => entry.loop2d)
);
if (!Array.isArray(triangulated) || !triangulated.length) {
return buildFallbackFaceFanGeometry(THREE, contourLoop3d, pickData?.centroid || null);
}
const vertexPoints3d = [
...contourLoop3d,
...holeData.flatMap((entry) => entry.loop3d)
];
if (!vertexPoints3d.length) {
return null;
}
const fillPositions = [];
for (const triangle of triangulated) {
if (!Array.isArray(triangle) || triangle.length !== 3) {
continue;
}
for (const vertexIndex of triangle) {
const point = vertexPoints3d[vertexIndex];
if (!Array.isArray(point) || point.length < 3) {
continue;
}
fillPositions.push(
Number(point[0]),
Number(point[1]),
Number(point[2])
);
}
}
if (!fillPositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(fillPositions), 3));
return geometry;
}
function buildEdgeGeometry(THREE, meshData, sourceGeometry) {
if (isNumericArray(meshData?.edge_indices, 2)) {
const explicitGeometry = buildEdgeGeometryFromIndices(THREE, meshData.vertices, meshData.edge_indices);
if (explicitGeometry) {
return explicitGeometry;
}
}
return buildDisplayEdgeGeometry(THREE, sourceGeometry);
}
function buildEdgePickObjects(THREE, group, references) {
const objects = [];
for (const reference of Array.isArray(references) ? references : []) {
const points = reference?.pickData?.points;
if (!Array.isArray(points) || points.length < 2) {
continue;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(points.flat()), 3));
const material = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0
});
const line = new THREE.Line(geometry, material);
line.userData.referenceId = String(reference?.id || "");
line.userData.partId = String(reference?.partId || "");
line.userData.metric = reference?.pickData?.metric ?? Infinity;
group.add(line);
objects.push(line);
}
group.updateMatrixWorld(true);
return objects;
}
function buildFacePickMesh(THREE, selectorRuntime) {
const proxy = selectorRuntime?.proxy || {};
if (!(proxy.facePositions instanceof Float32Array) || !(proxy.faceIndices instanceof Uint32Array) || !proxy.faceIndices.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(proxy.facePositions, 3));
geometry.setIndex(new THREE.BufferAttribute(proxy.faceIndices, 1));
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
side: THREE.DoubleSide,
transparent: true,
opacity: 0,
depthWrite: false,
toneMapped: false,
colorWrite: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.userData.faceIds = proxy.faceIds || new Uint32Array(0);
mesh.frustumCulled = false;
return mesh;
}
function buildEdgePickLines(THREE, selectorRuntime) {
const proxy = selectorRuntime?.proxy || {};
if (!(proxy.edgePositions instanceof Float32Array) || !(proxy.edgeIndices instanceof Uint32Array) || !proxy.edgeIndices.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(proxy.edgePositions, 3));
geometry.setIndex(new THREE.BufferAttribute(proxy.edgeIndices, 1));
const material = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0,
depthWrite: false,
toneMapped: false,
});
const lines = new THREE.LineSegments(geometry, material);
lines.userData.edgeIds = proxy.edgeIds || new Uint32Array(0);
lines.frustumCulled = false;
return lines;
}
function buildVertexPickPoints(THREE, selectorRuntime) {
const proxy = selectorRuntime?.proxy || {};
if (!(proxy.vertexPositions instanceof Float32Array) || !proxy.vertexPositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(proxy.vertexPositions, 3));
const material = new THREE.PointsMaterial({
color: 0xffffff,
transparent: true,
opacity: 0,
size: 1.5,
sizeAttenuation: false,
depthWrite: false,
toneMapped: false,
});
const points = new THREE.Points(geometry, material);
points.userData.vertexIds = proxy.vertexIds || new Uint32Array(0);
points.frustumCulled = false;
return points;
}
function faceFillOffset(runtime, reference) {
const normal = Array.isArray(reference?.pickData?.normal) ? reference.pickData.normal : null;
if (!runtime?.camera || !runtime?.modelGroup || !normal || normal.length < 3) {
return [0, 0, 0];
}
const normalLength = Math.hypot(normal[0], normal[1], normal[2]);
if (normalLength <= 1e-9) {
return [0, 0, 0];
}
const normalizedNormal = [
normal[0] / normalLength,
normal[1] / normalLength,
normal[2] / normalLength
];
const center = Array.isArray(reference?.pickData?.center) ? reference.pickData.center : [0, 0, 0];
const modelOffset = runtime.modelGroup.position;
const worldCenter = [
Number(center[0] || 0) + Number(modelOffset?.x || 0),
Number(center[1] || 0) + Number(modelOffset?.y || 0),
Number(center[2] || 0) + Number(modelOffset?.z || 0)
];
const toCamera = [
runtime.camera.position.x - worldCenter[0],
runtime.camera.position.y - worldCenter[1],
runtime.camera.position.z - worldCenter[2]
];
const facingSign =
((normalizedNormal[0] * toCamera[0]) + (normalizedNormal[1] * toCamera[1]) + (normalizedNormal[2] * toCamera[2])) >= 0
? 1
: -1;
const magnitude = Math.max(Number(runtime.modelRadius || 1) * 0.00075, 0.015);
return [
normalizedNormal[0] * facingSign * magnitude,
normalizedNormal[1] * facingSign * magnitude,
normalizedNormal[2] * facingSign * magnitude
];
}
function buildFaceFillGeometryFromProxy(runtime, THREE, selectorRuntime, reference) {
const proxy = selectorRuntime?.proxy || {};
const triangleStart = Number(reference?.pickData?.triangleStart || 0);
const triangleCount = Number(reference?.pickData?.triangleCount || 0);
if (!(proxy.facePositions instanceof Float32Array) || !(proxy.faceIndices instanceof Uint32Array) || triangleCount <= 0) {
return null;
}
const indexSlice = proxy.faceIndices.slice(triangleStart * 3, (triangleStart + triangleCount) * 3);
if (!indexSlice.length) {
return null;
}
const offset = faceFillOffset(runtime, reference);
const positions = new Float32Array(indexSlice.length * 3);
let writeOffset = 0;
for (const vertexIndex of indexSlice) {
const sourceIndex = Number(vertexIndex) * 3;
positions[writeOffset] = proxy.facePositions[sourceIndex] + offset[0];
positions[writeOffset + 1] = proxy.facePositions[sourceIndex + 1] + offset[1];
positions[writeOffset + 2] = proxy.facePositions[sourceIndex + 2] + offset[2];
writeOffset += 3;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
return geometry;
}
function buildEdgeLinePositionsFromProxy(selectorRuntime, reference) {
const proxy = selectorRuntime?.proxy || {};
const segmentStart = Number(reference?.pickData?.segmentStart || 0);
const segmentCount = Number(reference?.pickData?.segmentCount || 0);
if (!(proxy.edgePositions instanceof Float32Array) || !(proxy.edgeIndices instanceof Uint32Array) || segmentCount <= 0) {
return null;
}
const indexSlice = proxy.edgeIndices.slice(segmentStart * 2, (segmentStart + segmentCount) * 2);
if (!indexSlice.length) {
return null;
}
const linePositions = new Float32Array(segmentCount * 6);
let writeOffset = 0;
for (let index = 0; index + 1 < indexSlice.length; index += 2) {
const startIndex = indexSlice[index] * 3;
const endIndex = indexSlice[index + 1] * 3;
linePositions[writeOffset] = proxy.edgePositions[startIndex];
linePositions[writeOffset + 1] = proxy.edgePositions[startIndex + 1];
linePositions[writeOffset + 2] = proxy.edgePositions[startIndex + 2];
linePositions[writeOffset + 3] = proxy.edgePositions[endIndex];
linePositions[writeOffset + 4] = proxy.edgePositions[endIndex + 1];
linePositions[writeOffset + 5] = proxy.edgePositions[endIndex + 2];
writeOffset += 6;
}
return writeOffset === linePositions.length ? linePositions : linePositions.subarray(0, writeOffset);
}
function buildAdjacentEdgeLinePositions(selectorRuntime, reference) {
const selectors = Array.isArray(reference?.pickData?.adjacentSelectors) ? reference.pickData.adjacentSelectors : [];
if (!selectors.length) {
return null;
}
const positions = [];
for (const selector of selectors) {
const edgeReference =
selectorRuntime?.referenceByDisplaySelector?.get?.(selector) ||
selectorRuntime?.referenceByNormalizedSelector?.get?.(selector) ||
null;
const edgePositions = buildEdgeLinePositionsFromProxy(selectorRuntime, edgeReference);
if (!edgePositions?.length) {
continue;
}
positions.push(...edgePositions);
}
return positions.length ? positions : null;
}
function buildFaceBoundaryLinePositions(selectorRuntime, reference) {
return buildAdjacentEdgeLinePositions(selectorRuntime, reference);
}
function buildVertexMarkerMesh(runtime, THREE, reference, {
color,
opacity,
renderOrder = 27
} = {}) {
const center = Array.isArray(reference?.pickData?.center) ? reference.pickData.center : null;
if (!center || center.length < 3) {
return null;
}
const radius = Math.max(Number(runtime?.modelRadius || 1) * 0.0045, 0.2);
const geometry = new THREE.SphereGeometry(radius, 16, 16);
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
depthTest: true,
depthWrite: false,
toneMapped: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(center[0], center[1], center[2]);
mesh.renderOrder = renderOrder;
return mesh;
}
function subtract(a, b) {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function length(vector) {
return Math.hypot(vector[0], vector[1], vector[2]);
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeAngleAround(angle, center) {
let adjusted = angle;
while (adjusted - center > Math.PI) {
adjusted -= Math.PI * 2;
}
while (adjusted - center < -Math.PI) {
adjusted += Math.PI * 2;
}
return adjusted;
}
function distancePointToSegment2d(point, start, end) {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) {
return Math.hypot(point[0] - start[0], point[1] - start[1]);
}
const t = clamp(
((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy),
0,
1
);
const projected = [start[0] + dx * t, start[1] + dy * t];
return Math.hypot(point[0] - projected[0], point[1] - projected[1]);
}
function pointInPolygon2d(point, polygon) {
let inside = false;
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index, index += 1) {
const a = polygon[index];
const b = polygon[previous];
if (distancePointToSegment2d(point, a, b) <= 1e-4) {
return true;
}
const intersects =
(a[1] > point[1]) !== (b[1] > point[1]) &&
point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1] || 1e-9) + a[0];
if (intersects) {
inside = !inside;
}
}
return inside;
}
async function canvasToBlob(canvas) {
return new Promise((resolve, reject) => {
if (!canvas) {
reject(new Error("Screenshot capture failed"));
return;
}
if (typeof canvas.toBlob === "function") {
canvas.toBlob((value) => {
if (value) {
resolve(value);
return;
}
reject(new Error("Screenshot capture failed"));
}, "image/png");
return;
}
try {
resolve(dataUrlToBlob(canvas.toDataURL("image/png")));
} catch (captureError) {
reject(captureError);
}
});
}
function flipPixelsVertically(pixels, width, height) {
const rowSize = width * 4;
const flipped = new Uint8ClampedArray(pixels.length);
for (let row = 0; row < height; row += 1) {
const sourceStart = (height - row - 1) * rowSize;
const targetStart = row * rowSize;
flipped.set(pixels.subarray(sourceStart, sourceStart + rowSize), targetStart);
}
return flipped;
}
// Capture through an offscreen render target so normal interaction can keep
// `preserveDrawingBuffer` disabled.
async function buildCompositeScreenshotBlob(runtime, overlayCanvas) {
const renderer = runtime?.renderer;
const scene = runtime?.scene;
const camera = runtime?.camera;
const THREE = runtime?.THREE;
const width = renderer?.domElement?.width || 0;
const height = renderer?.domElement?.height || 0;
if (!renderer || !scene || !camera || !THREE || width <= 0 || height <= 0) {
throw new Error("Screenshot capture failed");
}
const renderTarget = new THREE.WebGLRenderTarget(width, height, {
depthBuffer: true,
stencilBuffer: false
});
renderTarget.texture.colorSpace = renderer.outputColorSpace;
if ("samples" in renderTarget && renderer.capabilities?.isWebGL2) {
renderTarget.samples = 4;
}
const previousRenderTarget = renderer.getRenderTarget();
const previousXrEnabled = renderer.xr?.enabled === true;
const pixelBuffer = new Uint8Array(width * height * 4);
try {
if (renderer.xr) {
renderer.xr.enabled = false;
}
renderer.setRenderTarget(renderTarget);
renderer.render(scene, camera);
renderer.readRenderTargetPixels(renderTarget, 0, 0, width, height, pixelBuffer);
} finally {
renderer.setRenderTarget(previousRenderTarget);
if (renderer.xr) {
renderer.xr.enabled = previousXrEnabled;
}
renderTarget.dispose();
}
const exportCanvas = document.createElement("canvas");
exportCanvas.width = width;
exportCanvas.height = height;
const context = exportCanvas.getContext("2d");
if (!context) {
throw new Error("Screenshot capture failed");
}
const imageData = new ImageData(flipPixelsVertically(pixelBuffer, width, height), width, height);
context.putImageData(imageData, 0, 0);
if (overlayCanvas) {
context.drawImage(overlayCanvas, 0, 0, width, height);
}
return canvasToBlob(exportCanvas);
}
function buildDrawingPoint(event, canvas) {
const rect = canvas.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
return {
x: clamp((event.clientX - rect.left) / width, 0, 1),
y: clamp((event.clientY - rect.top) / height, 0, 1)
};
}
function drawingPointToPixels(point, width, height) {
return [point.x * width, point.y * height];
}
function pointsEqual2d(a, b, epsilon = 1e-4) {
return Math.abs(a[0] - b[0]) <= epsilon && Math.abs(a[1] - b[1]) <= epsilon;
}
function strokeLengthInPixels(stroke, width, height) {
const points = Array.isArray(stroke?.points) ? stroke.points : [];
if (points.length < 2) {
return 0;
}
let total = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = drawingPointToPixels(points[index - 1], width, height);
const current = drawingPointToPixels(points[index], width, height);
total += Math.hypot(current[0] - previous[0], current[1] - previous[1]);
}
return total;
}
function getDrawingStrokePoints(stroke) {
return Array.isArray(stroke?.points)
? stroke.points.filter((point) => point && Number.isFinite(point.x) && Number.isFinite(point.y))
: [];
}
function getFillStrokePoints(stroke) {
return Array.isArray(stroke?.fillPoints)
? stroke.fillPoints.filter((point) => point && Number.isFinite(point.x) && Number.isFinite(point.y))
: [];
}
function isFillStroke(stroke) {
return stroke?.tool === DRAWING_TOOL.FILL;
}
function isSurfaceLineStroke(stroke) {
return stroke?.tool === DRAWING_TOOL.SURFACE_LINE && !!stroke?.surfaceLine;
}
function isClosedDrawingStroke(stroke) {
return stroke?.tool === DRAWING_TOOL.RECTANGLE || stroke?.tool === DRAWING_TOOL.CIRCLE;
}
function isFillBoundaryStroke(stroke) {
return !!stroke && stroke.tool !== DRAWING_TOOL.ERASE && stroke.tool !== DRAWING_TOOL.FILL && stroke.tool !== DRAWING_TOOL.SURFACE_LINE;
}
function drawingToolNeedsTwoPoints(tool) {
return (
tool === DRAWING_TOOL.LINE ||
tool === DRAWING_TOOL.SURFACE_LINE ||
tool === DRAWING_TOOL.ARROW ||
tool === DRAWING_TOOL.DOUBLE_ARROW ||
tool === DRAWING_TOOL.RECTANGLE ||
tool === DRAWING_TOOL.CIRCLE
);
}
function parseFaceToken(copyText) {
return String(parseCadRefToken(copyText)?.token || "").trim();
}
function clonePoint3(point) {
return Array.isArray(point) && point.length >= 3
? [Number(point[0]), Number(point[1]), Number(point[2])]
: null;
}
function clonePoint2(point) {
return Array.isArray(point) && point.length >= 2
? [Number(point[0]), Number(point[1])]
: null;
}
function projectPointToSurfaceUv(surface, point, angleCenter = null) {
if (!surface || !Array.isArray(point) || point.length < 3) {
return null;
}
if (
surface.type === "PLANE" &&
Array.isArray(surface.origin) &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir)
) {
return projectPointToPlane(point, surface);
}
if (
surface.type === "CYLINDRICAL_SURFACE" &&
Array.isArray(surface.origin) &&
Array.isArray(surface.axis) &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir) &&
Number(surface.radius) > 0
) {
return projectPointToCylinder(point, surface, angleCenter);
}
return null;
}
function pointOnSurfaceFromUv(surface, uv) {
if (!surface || !Array.isArray(uv) || uv.length < 2) {
return null;
}
if (
surface.type === "PLANE" &&
Array.isArray(surface.origin) &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir)
) {
return [
surface.origin[0] + surface.xDir[0] * uv[0] + surface.yDir[0] * uv[1],
surface.origin[1] + surface.xDir[1] * uv[0] + surface.yDir[1] * uv[1],
surface.origin[2] + surface.xDir[2] * uv[0] + surface.yDir[2] * uv[1]
];
}
if (
surface.type === "CYLINDRICAL_SURFACE" &&
Array.isArray(surface.origin) &&
Array.isArray(surface.axis) &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir) &&
Number(surface.radius) > 0
) {
const radius = Number(surface.radius);
const theta = uv[0] / radius;
const cosTheta = Math.cos(theta);
const sinTheta = Math.sin(theta);
return [
surface.origin[0] + surface.axis[0] * uv[1] + radius * (surface.xDir[0] * cosTheta + surface.yDir[0] * sinTheta),
surface.origin[1] + surface.axis[1] * uv[1] + radius * (surface.xDir[1] * cosTheta + surface.yDir[1] * sinTheta),
surface.origin[2] + surface.axis[2] * uv[1] + radius * (surface.xDir[2] * cosTheta + surface.yDir[2] * sinTheta)
];
}
return null;
}
function surfaceNormalAtUv(surface, uv) {
if (!surface || !Array.isArray(uv) || uv.length < 2) {
return null;
}
if (surface.type === "PLANE") {
return normalizeVector3(surface.normal || []);
}
if (
surface.type === "CYLINDRICAL_SURFACE" &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir) &&
Number(surface.radius) > 0
) {
const theta = uv[0] / Number(surface.radius);
return normalizeVector3([
surface.xDir[0] * Math.cos(theta) + surface.yDir[0] * Math.sin(theta),
surface.xDir[1] * Math.cos(theta) + surface.yDir[1] * Math.sin(theta),
surface.xDir[2] * Math.cos(theta) + surface.yDir[2] * Math.sin(theta)
]);
}
return null;
}
function buildSurfaceLinePositions(reference, surfaceLine, { segments = 48, offset = 0.04 } = {}) {
const surface = reference?.pickData?.surface || {};
const startPoint = clonePoint3(surfaceLine?.startPoint);
const endPoint = clonePoint3(surfaceLine?.endPoint);
const startUv = clonePoint2(surfaceLine?.startUv);
const endUv = clonePoint2(surfaceLine?.endUv);
if (surface.type === "PLANE" && startPoint && endPoint) {
const normal = normalizeVector3(reference?.pickData?.normal || surface.normal || []);
const project = (point) => normal ? [
point[0] + normal[0] * offset,
point[1] + normal[1] * offset,
point[2] + normal[2] * offset
] : point;
const start = project(startPoint);
const end = project(endPoint);
return [
start[0], start[1], start[2],
end[0], end[1], end[2]
];
}
if (surface.type === "CYLINDRICAL_SURFACE" && startUv && endUv) {
const linePositions = [];
const count = Math.max(4, Math.round(Number(segments) || 48));
let previousPoint = null;
for (let index = 0; index <= count; index += 1) {
const t = index / count;
const uv = [
startUv[0] + (endUv[0] - startUv[0]) * t,
startUv[1] + (endUv[1] - startUv[1]) * t
];
const point = pointOnSurfaceFromUv(surface, uv);
if (!point) {
continue;
}
const normal = surfaceNormalAtUv(surface, uv);
const offsetPoint = normal ? [
point[0] + normal[0] * offset,
point[1] + normal[1] * offset,
point[2] + normal[2] * offset
] : point;
if (previousPoint) {
linePositions.push(
previousPoint[0], previousPoint[1], previousPoint[2],
offsetPoint[0], offsetPoint[1], offsetPoint[2]
);
}
previousPoint = offsetPoint;
}
return linePositions;
}
if (startPoint && endPoint) {
return [
startPoint[0], startPoint[1], startPoint[2],
endPoint[0], endPoint[1], endPoint[2]
];
}
return [];
}
function buildRectanglePixelCorners(start, end) {
return [
[start[0], start[1]],
[end[0], start[1]],
[end[0], end[1]],
[start[0], end[1]]
];
}
function buildCirclePixelPolygon(center, edge, segmentCount = 56) {
const radius = Math.hypot(edge[0] - center[0], edge[1] - center[1]);
if (radius < 1e-4) {
return [center];
}
const segments = Math.max(segmentCount, 12);
const points = [];
for (let index = 0; index <= segments; index += 1) {
const angle = (index / segments) * Math.PI * 2;
points.push([
center[0] + Math.cos(angle) * radius,
center[1] + Math.sin(angle) * radius
]);
}
return points;
}
function getDrawingBoundaryPixelPoints(stroke, width, height) {
const pixelPoints = getDrawingStrokePoints(stroke).map((point) => drawingPointToPixels(point, width, height));
if (pixelPoints.length < 2) {
return pixelPoints;
}
if (stroke.tool === DRAWING_TOOL.RECTANGLE) {
const corners = buildRectanglePixelCorners(pixelPoints[0], pixelPoints[pixelPoints.length - 1]);
return [...corners, corners[0]];
}
if (stroke.tool === DRAWING_TOOL.CIRCLE) {
return buildCirclePixelPolygon(pixelPoints[0], pixelPoints[pixelPoints.length - 1]);
}
return pixelPoints;
}
function drawArrowHead(context, start, end, lineWidth) {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const lengthPx = Math.hypot(dx, dy);
if (lengthPx < 1) {
return;
}
const ux = dx / lengthPx;
const uy = dy / lengthPx;
const angle = Math.PI / 7;
const headLength = Math.max(DRAWING_ARROW_HEAD_LENGTH, lineWidth * 3.25);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const left = [
end[0] - headLength * (ux * cos - uy * sin),
end[1] - headLength * (uy * cos + ux * sin)
];
const right = [
end[0] - headLength * (ux * cos + uy * sin),
end[1] - headLength * (uy * cos - ux * sin)
];
context.beginPath();
context.moveTo(end[0], end[1]);
context.lineTo(left[0], left[1]);
context.moveTo(end[0], end[1]);
context.lineTo(right[0], right[1]);
context.stroke();
}
function drawPointDot(context, point, lineWidth) {
context.beginPath();
context.arc(point[0], point[1], lineWidth * 0.65, 0, Math.PI * 2);
context.fill();
}
function drawPolylineStroke(context, pixelPoints) {
context.beginPath();
pixelPoints.forEach((point, index) => {
if (index === 0) {
context.moveTo(point[0], point[1]);
return;
}
context.lineTo(point[0], point[1]);
});
context.stroke();
}
function drawRectangleStroke(context, start, end) {
const x = Math.min(start[0], end[0]);
const y = Math.min(start[1], end[1]);
const width = Math.abs(end[0] - start[0]);
const height = Math.abs(end[1] - start[1]);
context.strokeRect(x, y, width, height);
}
function drawCircleStroke(context, center, edge) {
const radius = Math.hypot(edge[0] - center[0], edge[1] - center[1]);
context.beginPath();
context.arc(center[0], center[1], radius, 0, Math.PI * 2);
context.stroke();
}
function drawFillStroke(context, stroke, width, height, { color, alpha = 1 }) {
const points = getFillStrokePoints(stroke);
if (points.length < 3) {
return;
}
const pixelPoints = points.map((point) => drawingPointToPixels(point, width, height));
context.save();
context.globalAlpha = alpha;
context.fillStyle = color;
context.beginPath();
pixelPoints.forEach((point, index) => {
if (index === 0) {
context.moveTo(point[0], point[1]);
return;
}
context.lineTo(point[0], point[1]);
});
context.closePath();
context.fill();
context.restore();
}
function drawLineStroke(context, stroke, width, height, { color, lineWidth, alpha = 1 }) {
if (isFillStroke(stroke)) {
return;
}
const points = getDrawingStrokePoints(stroke);
if (!points.length) {
return;
}
const pixelPoints = points.map((point) => drawingPointToPixels(point, width, height));
context.save();
context.globalAlpha = alpha;
context.strokeStyle = color;
context.fillStyle = color;
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = lineWidth;
if (pixelPoints.length === 1) {
drawPointDot(context, pixelPoints[0], lineWidth);
context.restore();
return;
}
if (stroke.tool === DRAWING_TOOL.RECTANGLE) {
drawRectangleStroke(context, pixelPoints[0], pixelPoints[pixelPoints.length - 1]);
context.restore();
return;
}
if (stroke.tool === DRAWING_TOOL.CIRCLE) {
drawCircleStroke(context, pixelPoints[0], pixelPoints[pixelPoints.length - 1]);
context.restore();
return;
}
drawPolylineStroke(context, pixelPoints);
if (stroke.tool === DRAWING_TOOL.ARROW || stroke.tool === DRAWING_TOOL.DOUBLE_ARROW) {
drawArrowHead(context, pixelPoints[pixelPoints.length - 2], pixelPoints[pixelPoints.length - 1], lineWidth);
}
if (stroke.tool === DRAWING_TOOL.DOUBLE_ARROW) {
drawArrowHead(context, pixelPoints[1], pixelPoints[0], lineWidth);
}
context.restore();
}
function createOffscreenCanvas(width, height) {
if (typeof OffscreenCanvas !== "undefined") {
return new OffscreenCanvas(width, height);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas;
}
function getOpenBoundaryEndpoints(stroke, width, height) {
if (!isFillBoundaryStroke(stroke) || isClosedDrawingStroke(stroke)) {
return [];
}
const pixelPoints = getDrawingBoundaryPixelPoints(stroke, width, height);
if (pixelPoints.length < 2) {
return [];
}
const allowSelfConnect = stroke?.tool === DRAWING_TOOL.FREEHAND && pixelPoints.length > 2;
return [
{
strokeId: String(stroke?.id || ""),
point: pixelPoints[0],
allowSelfConnect
},
{
strokeId: String(stroke?.id || ""),
point: pixelPoints[pixelPoints.length - 1],
allowSelfConnect
}
];
}
function pairNearbyBoundaryEndpoints(endpoints, maxDistance) {
if (!Array.isArray(endpoints) || endpoints.length < 2 || maxDistance <= 0) {
return [];
}
const candidates = [];
for (let leftIndex = 0; leftIndex < endpoints.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < endpoints.length; rightIndex += 1) {
const left = endpoints[leftIndex];
const right = endpoints[rightIndex];
const sameStroke = left.strokeId && left.strokeId === right.strokeId;
if (sameStroke && !(left.allowSelfConnect && right.allowSelfConnect)) {
continue;
}
const distance = Math.hypot(left.point[0] - right.point[0], left.point[1] - right.point[1]);
if (distance <= maxDistance) {
candidates.push({ leftIndex, rightIndex, distance });
}
}
}
candidates.sort((left, right) => left.distance - right.distance);
const used = new Set();
const pairs = [];
for (const candidate of candidates) {
if (used.has(candidate.leftIndex) || used.has(candidate.rightIndex)) {
continue;
}
used.add(candidate.leftIndex);
used.add(candidate.rightIndex);
pairs.push([
endpoints[candidate.leftIndex].point,
endpoints[candidate.rightIndex].point
]);
}
return pairs;
}
function buildBoundaryMaskFromStrokes(strokes, width, height, { gapPx = 0, lineWidth = DRAWING_STROKE_WIDTH } = {}) {
const canvas = createOffscreenCanvas(width, height);
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
return null;
}
context.clearRect(0, 0, width, height);
const boundaryStrokes = Array.isArray(strokes) ? strokes.filter(isFillBoundaryStroke) : [];
for (const stroke of boundaryStrokes) {
drawLineStroke(context, stroke, width, height, {
color: "#000000",
lineWidth,
alpha: 1
});
}
if (gapPx > 0) {
const connectors = pairNearbyBoundaryEndpoints(
boundaryStrokes.flatMap((stroke) => getOpenBoundaryEndpoints(stroke, width, height)),
Math.max(gapPx, lineWidth * 1.15)
);
if (connectors.length) {
context.save();
context.strokeStyle = "#000000";
context.lineWidth = lineWidth;
context.lineCap = "round";
for (const [start, end] of connectors) {
context.beginPath();
context.moveTo(start[0], start[1]);
context.lineTo(end[0], end[1]);
context.stroke();
}
context.restore();
}
}
const { data } = context.getImageData(0, 0, width, height);
const mask = new Uint8Array(width * height);
for (let index = 0; index < mask.length; index += 1) {
mask[index] = data[index * 4 + 3] > 20 ? 1 : 0;
}
return mask;
}
function findNearestOpenSeed(boundaryMask, width, height, seedX, seedY, maxRadius = 5) {
const x = clamp(Math.round(seedX), 0, width - 1);
const y = clamp(Math.round(seedY), 0, height - 1);
for (let radius = 0; radius <= maxRadius; radius += 1) {
let best = null;
let bestDistance = Infinity;
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
const px = x + dx;
const py = y + dy;
if (px < 0 || py < 0 || px >= width || py >= height) {
continue;
}
if (boundaryMask[py * width + px]) {
continue;
}
const distance = Math.hypot(dx, dy);
if (distance < bestDistance) {
bestDistance = distance;
best = [px, py];
}
}
}
if (best) {
return best;
}
}
return null;
}
function floodFillInterior(boundaryMask, width, height, seedPoint) {
const start = findNearestOpenSeed(boundaryMask, width, height, seedPoint[0], seedPoint[1]);
if (!start) {
return null;
}
const fillMask = new Uint8Array(width * height);
const queue = new Int32Array(width * height);
let head = 0;
let tail = 0;
const startIndex = start[1] * width + start[0];
queue[tail++] = startIndex;
fillMask[startIndex] = 1;
let area = 0;
let touchesEdge = false;
while (head < tail) {
const index = queue[head++];
area += 1;
const x = index % width;
const y = (index / width) | 0;
if (x === 0 || y === 0 || x === width - 1 || y === height - 1) {
touchesEdge = true;
}
if (x > 0) {
const leftIndex = index - 1;
if (!boundaryMask[leftIndex] && !fillMask[leftIndex]) {
fillMask[leftIndex] = 1;
queue[tail++] = leftIndex;
}
}
if (x + 1 < width) {
const rightIndex = index + 1;
if (!boundaryMask[rightIndex] && !fillMask[rightIndex]) {
fillMask[rightIndex] = 1;
queue[tail++] = rightIndex;
}
}
if (y > 0) {
const upIndex = index - width;
if (!boundaryMask[upIndex] && !fillMask[upIndex]) {
fillMask[upIndex] = 1;
queue[tail++] = upIndex;
}
}
if (y + 1 < height) {
const downIndex = index + width;
if (!boundaryMask[downIndex] && !fillMask[downIndex]) {
fillMask[downIndex] = 1;
queue[tail++] = downIndex;
}
}
}
return {
mask: fillMask,
area,
touchesEdge,
seed: start
};
}
function polygonArea2d(points) {
let area = 0;
for (let index = 0, previous = points.length - 1; index < points.length; previous = index, index += 1) {
area += points[previous][0] * points[index][1] - points[index][0] * points[previous][1];
}
return area / 2;
}
function removeDuplicatePolygonPoints(points) {
const next = [];
for (const point of points) {
if (!next.length || !pointsEqual2d(next[next.length - 1], point)) {
next.push(point);
}
}
if (next.length > 1 && pointsEqual2d(next[0], next[next.length - 1])) {
next.pop();
}
return next;
}
function removeCollinearPolygonPoints(points) {
const loop = removeDuplicatePolygonPoints(points);
if (loop.length < 3) {
return loop;
}
const next = [];
for (let index = 0; index < loop.length; index += 1) {
const previous = loop[(index + loop.length - 1) % loop.length];
const current = loop[index];
const following = loop[(index + 1) % loop.length];
const cross =
(current[0] - previous[0]) * (following[1] - current[1]) -
(current[1] - previous[1]) * (following[0] - current[0]);
if (Math.abs(cross) > 1e-4) {
next.push(current);
}
}
return next.length >= 3 ? next : loop;
}
function downsamplePolygon(points, maxPoints = 160) {
if (points.length <= maxPoints) {
return points;
}
const step = points.length / maxPoints;
const next = [];
let cursor = 0;
for (let index = 0; index < maxPoints; index += 1) {
next.push(points[Math.floor(cursor) % points.length]);
cursor += step;
}
return removeDuplicatePolygonPoints(next);
}
function pointKey(point) {
return `${point[0]},${point[1]}`;
}
function traceMaskLoops(mask, width, height) {
const segments = [];
const adjacency = new Map();
const addSegment = (start, end) => {
const index = segments.length;
segments.push([start, end]);
const key = pointKey(start);
const entries = adjacency.get(key);
if (entries) {
entries.push(index);
return;
}
adjacency.set(key, [index]);
};
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (!mask[y * width + x]) {
continue;
}
if (y === 0 || !mask[(y - 1) * width + x]) {
addSegment([x, y], [x + 1, y]);
}
if (x === width - 1 || !mask[y * width + x + 1]) {
addSegment([x + 1, y], [x + 1, y + 1]);
}
if (y === height - 1 || !mask[(y + 1) * width + x]) {
addSegment([x + 1, y + 1], [x, y + 1]);
}
if (x === 0 || !mask[y * width + x - 1]) {
addSegment([x, y + 1], [x, y]);
}
}
}
const used = new Uint8Array(segments.length);
const loops = [];
for (let index = 0; index < segments.length; index += 1) {
if (used[index]) {
continue;
}
const loop = [];
let currentIndex = index;
let guard = 0;
while (currentIndex !== -1 && !used[currentIndex] && guard < segments.length + 4) {
used[currentIndex] = 1;
const [start, end] = segments[currentIndex];
if (!loop.length) {
loop.push(start);
}
loop.push(end);
if (pointsEqual2d(end, loop[0])) {
break;
}
const nextCandidates = adjacency.get(pointKey(end)) || [];
currentIndex = nextCandidates.find((candidateIndex) => !used[candidateIndex]) ?? -1;
guard += 1;
}
if (loop.length >= 4 && pointsEqual2d(loop[0], loop[loop.length - 1])) {
const normalizedLoop = removeCollinearPolygonPoints(loop.slice(0, -1));
if (normalizedLoop.length >= 3) {
loops.push(normalizedLoop);
}
}
}
return loops;
}
function normalizePolygonPoints(points, width, height) {
return points.map((point) => ({
x: clamp(point[0] / width, 0, 1),
y: clamp(point[1] / height, 0, 1)
}));
}
function buildPolygonFromFilledMask(mask, width, height, seedPoint) {
const loops = traceMaskLoops(mask, width, height);
if (!loops.length) {
return null;
}
const seed = [seedPoint[0] + 0.5, seedPoint[1] + 0.5];
const containingLoops = loops.filter((loop) => pointInPolygon2d(seed, loop));
const sourceLoops = containingLoops.length ? containingLoops : loops;
const chosen = sourceLoops.reduce((best, current) => {
if (!best) {
return current;
}
return Math.abs(polygonArea2d(current)) > Math.abs(polygonArea2d(best)) ? current : best;
}, null);
if (!chosen) {
return null;
}
const simplified = downsamplePolygon(removeCollinearPolygonPoints(chosen));
return simplified.length >= 3 ? normalizePolygonPoints(simplified, width, height) : null;
}
function findNearestValidDistance(distances, index, direction) {
for (let offset = 1; offset < distances.length; offset += 1) {
const nextIndex = (index + direction * offset + distances.length) % distances.length;
if (Number.isFinite(distances[nextIndex])) {
return {
value: distances[nextIndex],
offset
};
}
}
return null;
}
function buildGuessedFillPolygon(boundaryMask, width, height, seedPoint) {
const start = findNearestOpenSeed(boundaryMask, width, height, seedPoint[0], seedPoint[1]);
if (!start) {
return null;
}
const maxDistance = Math.hypot(width, height);
const distances = Array.from({ length: DRAWING_FILL_RAY_COUNT }, () => null);
for (let index = 0; index < DRAWING_FILL_RAY_COUNT; index += 1) {
const angle = (index / DRAWING_FILL_RAY_COUNT) * Math.PI * 2;
const dx = Math.cos(angle);
const dy = Math.sin(angle);
for (let distance = 1; distance < maxDistance; distance += 1) {
const x = Math.round(start[0] + dx * distance);
const y = Math.round(start[1] + dy * distance);
if (x < 0 || y < 0 || x >= width || y >= height) {
break;
}
if (boundaryMask[y * width + x]) {
distances[index] = Math.max(distance - 1.5, 1);
break;
}
}
}
const validDistances = distances.filter(Number.isFinite);
if (validDistances.length < Math.max(12, Math.floor(DRAWING_FILL_RAY_COUNT / 4))) {
return null;
}
const orderedDistances = [...validDistances].sort((left, right) => left - right);
const medianDistance = orderedDistances[Math.floor(orderedDistances.length / 2)] || 1;
const resolvedDistances = distances.map((value, index) => {
if (Number.isFinite(value)) {
return value;
}
const previous = findNearestValidDistance(distances, index, -1);
const next = findNearestValidDistance(distances, index, 1);
if (previous && next) {
const total = previous.offset + next.offset;
return (previous.value * next.offset + next.value * previous.offset) / Math.max(total, 1);
}
if (previous) {
return previous.value;
}
if (next) {
return next.value;
}
return medianDistance;
});
let smoothedDistances = resolvedDistances;
for (let pass = 0; pass < 2; pass += 1) {
smoothedDistances = smoothedDistances.map((value, index) => {
const previous = smoothedDistances[(index + smoothedDistances.length - 1) % smoothedDistances.length];
const next = smoothedDistances[(index + 1) % smoothedDistances.length];
return (previous + value * 2 + next) / 4;
});
}
const polygon = smoothedDistances.map((distance, index) => {
const angle = (index / DRAWING_FILL_RAY_COUNT) * Math.PI * 2;
return [
clamp(start[0] + Math.cos(angle) * distance, 0, width),
clamp(start[1] + Math.sin(angle) * distance, 0, height)
];
});
const simplified = downsamplePolygon(removeCollinearPolygonPoints(polygon), DRAWING_FILL_RAY_COUNT);
if (simplified.length < 3) {
return null;
}
const area = Math.abs(polygonArea2d(simplified));
if (area < DRAWING_FILL_MIN_REGION_PIXELS || area > width * height * DRAWING_FILL_MAX_REGION_RATIO) {
return null;
}
return normalizePolygonPoints(simplified, width, height);
}
function buildFillStrokeAtPoint(point, strokes, canvas) {
const boundaryStrokes = Array.isArray(strokes) ? strokes.filter(isFillBoundaryStroke) : [];
if (!boundaryStrokes.length) {
return null;
}
const canvasWidth = canvas.width || 1;
const canvasHeight = canvas.height || 1;
const maxCanvasDimension = Math.max(canvasWidth, canvasHeight, 1);
const analysisScale = Math.min(1, DRAWING_FILL_ANALYSIS_MAX_DIMENSION / maxCanvasDimension);
const analysisWidth = Math.max(DRAWING_FILL_ANALYSIS_MIN_DIMENSION, Math.round(canvasWidth * analysisScale));
const analysisHeight = Math.max(DRAWING_FILL_ANALYSIS_MIN_DIMENSION, Math.round(canvasHeight * analysisScale));
const seedPoint = [point.x * (analysisWidth - 1), point.y * (analysisHeight - 1)];
const lineWidth = Math.max(3, DRAWING_STROKE_WIDTH * analysisScale + 3);
const gapStrategies = [
{ gapPx: 0, guessed: false },
{ gapPx: DRAWING_FILL_CONNECT_GAP_PX * 0.45 * analysisScale, guessed: true },
{ gapPx: DRAWING_FILL_CONNECT_GAP_PX * analysisScale, guessed: true }
];
for (const strategy of gapStrategies) {
const boundaryMask = buildBoundaryMaskFromStrokes(boundaryStrokes, analysisWidth, analysisHeight, {
gapPx: strategy.gapPx,
lineWidth
});
if (!boundaryMask) {
continue;
}
const filledRegion = floodFillInterior(boundaryMask, analysisWidth, analysisHeight, seedPoint);
if (!filledRegion || filledRegion.area < DRAWING_FILL_MIN_REGION_PIXELS) {
continue;
}
if (filledRegion.touchesEdge || filledRegion.area > analysisWidth * analysisHeight * DRAWING_FILL_MAX_REGION_RATIO) {
continue;
}
const fillPoints = buildPolygonFromFilledMask(
filledRegion.mask,
analysisWidth,
analysisHeight,
filledRegion.seed
);
if (fillPoints?.length >= 3) {
return {
tool: DRAWING_TOOL.FILL,
points: [point],
fillPoints,
guessed: strategy.guessed
};
}
}
const fallbackBoundaryMask = buildBoundaryMaskFromStrokes(boundaryStrokes, analysisWidth, analysisHeight, {
gapPx: DRAWING_FILL_CONNECT_GAP_PX * analysisScale,
lineWidth
});
if (!fallbackBoundaryMask) {
return null;
}
const fillPoints = buildGuessedFillPolygon(fallbackBoundaryMask, analysisWidth, analysisHeight, seedPoint);
if (!fillPoints?.length || fillPoints.length < 3) {
return null;
}
return {
tool: DRAWING_TOOL.FILL,
points: [point],
fillPoints,
guessed: true
};
}
function redrawDrawingCanvas(canvas, strokes, draftStroke = null) {
if (!canvas) {
return;
}
const context = canvas.getContext("2d");
if (!context) {
return;
}
const width = canvas.width;
const height = canvas.height;
context.clearRect(0, 0, width, height);
const allStrokes = Array.isArray(strokes) ? strokes : [];
for (const stroke of allStrokes) {
if (!isFillStroke(stroke)) {
continue;
}
drawFillStroke(context, stroke, width, height, {
color: stroke?.guessed ? DRAWING_GUESSED_FILL_COLOR : DRAWING_FILL_COLOR,
alpha: 1
});
}
for (const stroke of allStrokes) {
if (isSurfaceLineStroke(stroke)) {
continue;
}
drawLineStroke(context, stroke, width, height, {
color: DRAWING_STROKE_HALO,
lineWidth: DRAWING_STROKE_HALO_WIDTH
});
drawLineStroke(context, stroke, width, height, {
color: DRAWING_STROKE_COLOR,
lineWidth: DRAWING_STROKE_WIDTH
});
}
if (draftStroke) {
drawLineStroke(context, draftStroke, width, height, {
color: DRAWING_STROKE_HALO,
lineWidth: DRAWING_STROKE_HALO_WIDTH,
alpha: 0.78
});
drawLineStroke(context, draftStroke, width, height, {
color: DRAWING_STROKE_COLOR,
lineWidth: DRAWING_STROKE_WIDTH,
alpha: 0.9
});
}
}
function distanceToStrokeInPixels(point, stroke, width, height) {
if (isFillStroke(stroke)) {
const pixelPoint = drawingPointToPixels(point, width, height);
const polygon = getFillStrokePoints(stroke).map((entry) => drawingPointToPixels(entry, width, height));
if (polygon.length < 3) {
return Infinity;
}
if (pointInPolygon2d(pixelPoint, polygon)) {
return 0;
}
let minimum = Infinity;
for (let index = 0; index < polygon.length; index += 1) {
const start = polygon[index];
const end = polygon[(index + 1) % polygon.length];
minimum = Math.min(minimum, distancePointToSegment2d(pixelPoint, start, end));
}
return minimum;
}
const points = getDrawingStrokePoints(stroke);
if (!points.length) {
return Infinity;
}
const pixelPoint = drawingPointToPixels(point, width, height);
const pixelPoints = points.map((entry) => drawingPointToPixels(entry, width, height));
if (pixelPoints.length === 1) {
const pointPixels = pixelPoints[0];
return Math.hypot(pixelPoint[0] - pointPixels[0], pixelPoint[1] - pointPixels[1]);
}
if (stroke.tool === DRAWING_TOOL.RECTANGLE) {
const start = pixelPoints[0];
const end = pixelPoints[pixelPoints.length - 1];
const corners = [
[start[0], start[1]],
[end[0], start[1]],
[end[0], end[1]],
[start[0], end[1]]
];
let minimum = Infinity;
for (let index = 0; index < corners.length; index += 1) {
const a = corners[index];
const b = corners[(index + 1) % corners.length];
minimum = Math.min(minimum, distancePointToSegment2d(pixelPoint, a, b));
}
return minimum;
}
if (stroke.tool === DRAWING_TOOL.CIRCLE) {
const center = pixelPoints[0];
const edge = pixelPoints[pixelPoints.length - 1];
const radius = Math.hypot(edge[0] - center[0], edge[1] - center[1]);
return Math.abs(Math.hypot(pixelPoint[0] - center[0], pixelPoint[1] - center[1]) - radius);
}
let minimum = Infinity;
for (let index = 1; index < pixelPoints.length; index += 1) {
const start = pixelPoints[index - 1];
const end = pixelPoints[index];
minimum = Math.min(minimum, distancePointToSegment2d(pixelPoint, start, end));
}
return minimum;
}
function niceGridStep(minimumStep) {
if (!Number.isFinite(minimumStep) || minimumStep <= 0) {
return getSceneScaleSettings(VIEWER_SCENE_SCALE.CAD).minGridSize / DEFAULT_GRID_DIVISIONS;
}
const exponent = Math.floor(Math.log10(minimumStep));
const base = 10 ** exponent;
for (const multiplier of [1, 2, 5, 10]) {
const step = base * multiplier;
if (step >= minimumStep) {
return step;
}
}
return base * 10;
}
function buildGridConfig(radius, sceneScaleMode) {
const desiredSize = getStageFloorSize(radius, sceneScaleMode);
const cellSize = niceGridStep(desiredSize / GRID_TARGET_DIVISIONS);
let divisions = Math.ceil(desiredSize / cellSize);
if (divisions % 2 !== 0) {
divisions += 1;
}
return {
size: Math.max(desiredSize, cellSize * divisions),
divisions: Math.max(DEFAULT_GRID_DIVISIONS, divisions)
};
}
function updateGridHelper(
runtime,
viewerTheme,
radius,
floorY = 0,
sceneScaleMode = VIEWER_SCENE_SCALE.CAD,
floorMode = LOOK_FLOOR_MODES.STAGE
) {
if (!runtime?.THREE || !runtime?.scene) {
return;
}
runtime.gridRadius = radius;
runtime.gridFloorY = floorY;
runtime.floorMode = floorMode;
if (floorMode !== LOOK_FLOOR_MODES.GRID) {
disposeSceneObject(runtime.gridHelper);
runtime.gridHelper = null;
runtime.gridConfig = null;
return;
}
const nextConfig = buildGridConfig(radius, sceneScaleMode);
const currentConfig = runtime.gridConfig;
if (currentConfig && currentConfig.size === nextConfig.size && currentConfig.divisions === nextConfig.divisions) {
runtime.gridHelper?.position.set(0, floorY, 0);
return;
}
disposeSceneObject(runtime.gridHelper);
runtime.gridHelper = new runtime.THREE.GridHelper(
nextConfig.size,
nextConfig.divisions,
viewerTheme?.gridCenter || BASE_VIEWER_THEME.gridCenter,
viewerTheme?.gridCell || BASE_VIEWER_THEME.gridCell
);
const materials = Array.isArray(runtime.gridHelper.material)
? runtime.gridHelper.material
: [runtime.gridHelper.material];
for (const material of materials) {
material.transparent = true;
material.opacity = viewerTheme?.gridOpacity ?? BASE_VIEWER_THEME.gridOpacity;
material.depthWrite = false;
material.toneMapped = false;
}
runtime.gridHelper.position.set(0, floorY, 0);
runtime.scene.add(runtime.gridHelper);
runtime.gridConfig = nextConfig;
}
const CadViewer = forwardRef(function CadViewer({
meshData,
modelKey,
perspective = null,
perspectiveRef = null,
showEdges,
recomputeNormals,
theme = BASE_VIEWER_THEME,
lookSettings = null,
floorModeOverride = "",
previewMode = false,
showViewPlane = true,
viewPlaneOffsetRight = 16,
viewPlaneOffsetBottom = 16,
compactViewPlane = false,
viewportFrameInsets = null,
isLoading = false,
pickMode = VIEWER_PICK_MODE.AUTO,
renderPartsIndividually = false,
partIntroAnimation = null,
sceneScaleMode = VIEWER_SCENE_SCALE.CAD,
pickableParts = [],
hiddenPartIds = [],
selectedPartIds = [],
hoveredPartId = "",
hoveredReferenceId = "",
selectedReferenceIds = [],
selectorRuntime = null,
pickableFaces = [],
pickableEdges = [],
pickableVertices = [],
surfaceLineFaceId = "",
focusedPartId = "",
drawingEnabled = false,
drawingTool = DRAWING_TOOL.FREEHAND,
drawingStrokes = [],
onDrawingStrokesChange,
onPerspectiveChange,
onHoverReferenceChange,
onActivateReference,
onDoubleActivateReference,
onViewerAlertChange
}, ref) {
const normalizedSceneScaleMode = normalizeSceneScaleMode(sceneScaleMode);
const defaultGridRadius = defaultSceneGridRadius(normalizedSceneScaleMode);
const normalizedViewportFrameInsets = useMemo(
() => normalizeViewportFrameInsets(viewportFrameInsets),
[
viewportFrameInsets?.top,
viewportFrameInsets?.right,
viewportFrameInsets?.bottom,
viewportFrameInsets?.left
]
);
const interactionHostRef = useRef(null);
const mountRef = useRef(null);
const drawingCanvasRef = useRef(null);
const drawingDraftRef = useRef(null);
const drawingStrokesRef = useRef(Array.isArray(drawingStrokes) ? drawingStrokes : []);
const drawingChangeRef = useRef(onDrawingStrokesChange);
const perspectiveChangeRef = useRef(onPerspectiveChange);
const viewerAlertChangeRef = useRef(onViewerAlertChange);
const lastEmittedPerspectiveRef = useRef(null);
const suppressPerspectiveEventsRef = useRef(0);
const drawingIdRef = useRef(0);
const runtimeRef = useRef(null);
const viewportFrameInsetsRef = useRef(normalizedViewportFrameInsets);
const framedModelKeyRef = useRef("");
const modelTransformRef = useRef({
modelKey: "",
offset: null
});
const partIntroStateRef = useRef({
frameId: 0,
playedIntroKey: ""
});
const [error, setError] = useState("");
const [viewerReadyTick, setViewerReadyTick] = useState(0);
const [activeViewPlaneFace, setActiveViewPlaneFace] = useState("");
const [viewPlaneOrientation, setViewPlaneOrientation] = useState(DEFAULT_VIEW_PLANE_ORIENTATION);
const activeViewPlaneFaceRef = useRef("");
const previewModeRef = useRef(previewMode);
const viewerTheme = theme || BASE_VIEWER_THEME;
const normalizedLookSettings = useMemo(() => normalizeLookSettingsShape(lookSettings), [lookSettings]);
const resolvedFloorMode = floorModeOverride
? normalizeFloorMode(floorModeOverride, resolveFloorMode(normalizedLookSettings.floor))
: resolveFloorMode(normalizedLookSettings.floor);
const edgesVisible = showEdges && normalizedLookSettings.edges.enabled;
const partVisualStateEnabled =
pickMode === VIEWER_PICK_MODE.PARTS ||
pickMode === VIEWER_PICK_MODE.ASSEMBLY ||
(
pickMode === VIEWER_PICK_MODE.AUTO &&
Array.isArray(pickableParts) &&
pickableParts.length > 0
) ||
!!String(focusedPartId || "").trim();
const partVisualStateRef = useRef({
viewerTheme,
edgeSettings: normalizedLookSettings.edges,
hiddenPartIds: partVisualStateEnabled ? hiddenPartIds : [],
hoveredPartId: partVisualStateEnabled ? hoveredPartId : "",
focusedPartId: partVisualStateEnabled ? focusedPartId : "",
selectedPartIds: partVisualStateEnabled ? selectedPartIds : [],
showEdges: edgesVisible
});
useEffect(() => {
partVisualStateRef.current = {
viewerTheme,
edgeSettings: normalizedLookSettings.edges,
hiddenPartIds: partVisualStateEnabled ? hiddenPartIds : [],
hoveredPartId: partVisualStateEnabled ? hoveredPartId : "",
focusedPartId: partVisualStateEnabled ? focusedPartId : "",
selectedPartIds: partVisualStateEnabled ? selectedPartIds : [],
showEdges: edgesVisible
};
}, [
edgesVisible,
focusedPartId,
hiddenPartIds,
hoveredPartId,
partVisualStateEnabled,
selectedPartIds,
viewerTheme,
normalizedLookSettings.edges
]);
const focusedPartIdValue = String(focusedPartId || "").trim();
const activeSurfaceLineFaceId = String(surfaceLineFaceId || "").trim();
const filteredPickableFaces = useMemo(() => (
focusedPartIdValue
? (Array.isArray(pickableFaces) ? pickableFaces : []).filter((reference) => String(reference?.partId || "").trim() === focusedPartIdValue)
: (Array.isArray(pickableFaces) ? pickableFaces : [])
), [focusedPartIdValue, pickableFaces]);
const filteredPickableEdges = useMemo(() => (
focusedPartIdValue
? (Array.isArray(pickableEdges) ? pickableEdges : []).filter((reference) => String(reference?.partId || "").trim() === focusedPartIdValue)
: (Array.isArray(pickableEdges) ? pickableEdges : [])
), [focusedPartIdValue, pickableEdges]);
const filteredPickableVertices = useMemo(() => (
focusedPartIdValue
? (Array.isArray(pickableVertices) ? pickableVertices : []).filter((reference) => String(reference?.partId || "").trim() === focusedPartIdValue)
: (Array.isArray(pickableVertices) ? pickableVertices : [])
), [focusedPartIdValue, pickableVertices]);
const pickableReferenceMap = useMemo(() => {
if (selectorRuntime?.referenceMap instanceof Map) {
return selectorRuntime.referenceMap;
}
const map = new Map();
for (const reference of [...filteredPickableFaces, ...filteredPickableEdges, ...filteredPickableVertices]) {
const referenceId = String(reference?.id || "").trim();
if (!referenceId) {
continue;
}
map.set(referenceId, reference);
}
return map;
}, [filteredPickableEdges, filteredPickableFaces, filteredPickableVertices, selectorRuntime]);
const pickableFaceReferenceIds = useMemo(
() => new Set(filteredPickableFaces.map((reference) => String(reference?.id || "").trim()).filter(Boolean)),
[filteredPickableFaces]
);
const syncDrawingCanvasSize = (runtime = runtimeRef.current) => {
const canvas = drawingCanvasRef.current;
if (!canvas) {
return null;
}
const rendererCanvas = runtime?.renderer?.domElement;
const width = rendererCanvas?.width || mountRef.current?.clientWidth || 1;
const height = rendererCanvas?.height || mountRef.current?.clientHeight || 1;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
return canvas;
};
const renderDrawingOverlay = () => {
const canvas = syncDrawingCanvasSize();
if (!canvas) {
return;
}
redrawDrawingCanvas(canvas, drawingStrokesRef.current, drawingDraftRef.current);
};
useEffect(() => {
viewportFrameInsetsRef.current = normalizedViewportFrameInsets;
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
applyCameraFrameInsets(runtime, normalizedViewportFrameInsets);
runtime.requestRender?.();
}, [
normalizedViewportFrameInsets.top,
normalizedViewportFrameInsets.right,
normalizedViewportFrameInsets.bottom,
normalizedViewportFrameInsets.left,
viewerReadyTick
]);
const runWithoutPerspectiveEvents = (callback) => {
suppressPerspectiveEventsRef.current += 1;
try {
return callback();
} finally {
suppressPerspectiveEventsRef.current = Math.max(0, suppressPerspectiveEventsRef.current - 1);
}
};
const emitPerspectiveChange = (runtime = runtimeRef.current) => {
const nextPerspective = readScopedPerspectiveSnapshot(runtime, {
modelKey,
sceneScaleMode: normalizedSceneScaleMode
});
if (!nextPerspective) {
return;
}
if (suppressPerspectiveEventsRef.current > 0) {
lastEmittedPerspectiveRef.current = nextPerspective;
return;
}
if (perspectiveSnapshotEqual(lastEmittedPerspectiveRef.current, nextPerspective)) {
return;
}
lastEmittedPerspectiveRef.current = nextPerspective;
perspectiveChangeRef.current?.(nextPerspective);
};
const syncViewPlaneOrientation = (runtime = runtimeRef.current) => {
const nextOrientation = readViewPlaneOrientation(runtime);
if (!nextOrientation) {
return;
}
setViewPlaneOrientation((current) => (
viewPlaneOrientationEqual(current, nextOrientation) ? current : nextOrientation
));
};
const buildSurfaceLineFaceAnchor = (event, canvas, lockedReferenceId = "", startUv = null) => {
const runtime = runtimeRef.current;
if (!runtime?.raycaster || !runtime?.camera || !runtime?.facePickMesh || !selectorRuntime?.faceReferenceByRowIndex) {
return null;
}
const activeLockedReferenceId = String(lockedReferenceId || activeSurfaceLineFaceId).trim();
const rect = canvas.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
runtime.pointer.x = ((event.clientX - rect.left) / width) * 2 - 1;
runtime.pointer.y = -((event.clientY - rect.top) / height) * 2 + 1;
runtime.raycaster.setFromCamera(runtime.pointer, runtime.camera);
const intersections = runtime.raycaster.intersectObject(runtime.facePickMesh, false);
for (const intersection of intersections) {
const triangleIndex = Number(intersection?.faceIndex);
const rowIndex = Number.isInteger(triangleIndex) ? Number(runtime.facePickMesh.userData?.faceIds?.[triangleIndex]) : NaN;
if (!Number.isInteger(rowIndex)) {
continue;
}
const reference = selectorRuntime.faceReferenceByRowIndex.get(rowIndex) || null;
const referenceId = String(reference?.id || "").trim();
if (!referenceId) {
continue;
}
if (activeLockedReferenceId) {
if (referenceId !== activeLockedReferenceId) {
continue;
}
} else if (pickableFaceReferenceIds.size && !pickableFaceReferenceIds.has(referenceId)) {
continue;
}
const surface = reference?.pickData?.surface || {};
if (SURFACE_LINE_UNSUPPORTED_TYPES.has(String(surface.type || "").trim())) {
return null;
}
const localPoint = intersection.object.worldToLocal(intersection.point.clone());
const point = [localPoint.x, localPoint.y, localPoint.z];
const angleCenter = surface.type === "CYLINDRICAL_SURFACE" && Array.isArray(startUv) ? (startUv[0] / Math.max(Number(surface.radius) || 1, 1)) : null;
const uv = projectPointToSurfaceUv(surface, point, angleCenter);
if (!uv) {
return null;
}
return {
screenPoint: buildDrawingPoint(event, canvas),
surfaceLine: {
referenceId,
selector: String(reference?.displaySelector || "").trim(),
normalizedSelector: String(reference?.normalizedSelector || "").trim(),
faceToken: parseFaceToken(reference?.copyText),
partId: String(reference?.partId || "").trim(),
surfaceType: String(surface.type || "").trim(),
startPoint: point,
endPoint: point,
startUv: uv,
endUv: uv
}
};
}
return null;
};
const updateSurfaceLineFaceAnchor = (event, canvas, draftSurfaceLine) => {
const lockedReferenceId = String(draftSurfaceLine?.referenceId || "").trim();
if (!lockedReferenceId) {
return null;
}
const nextAnchor = buildSurfaceLineFaceAnchor(event, canvas, lockedReferenceId, draftSurfaceLine?.startUv);
if (!nextAnchor) {
return null;
}
return {
screenPoint: nextAnchor.screenPoint,
surfaceLine: {
...draftSurfaceLine,
endPoint: nextAnchor.surfaceLine.endPoint,
endUv: nextAnchor.surfaceLine.endUv
}
};
};
const activateViewPlaneFace = (faceId) => {
const runtime = runtimeRef.current;
const face = VIEW_PLANE_FACE_BY_ID[faceId];
if (!runtime || !face) {
return false;
}
activeViewPlaneFaceRef.current = face.id;
setActiveViewPlaneFace(face.id);
return transitionCameraToViewPreset(runtime, face);
};
const activateDefaultViewPlane = () => {
const runtime = runtimeRef.current;
if (!runtime) {
return false;
}
activeViewPlaneFaceRef.current = "";
setActiveViewPlaneFace("");
return transitionCameraToViewPreset(runtime, VIEW_PLANE_DEFAULT_PRESET);
};
useImperativeHandle(ref, () => ({
async captureScreenshot({ filename = "cad-screenshot.png", mode = "download" } = {}) {
const runtime = runtimeRef.current;
if (!runtime?.renderer || !runtime?.scene || !runtime?.camera) {
throw new Error("Viewer not ready");
}
renderDrawingOverlay();
const blobPromise = buildCompositeScreenshotBlob(runtime, drawingCanvasRef.current);
if (mode === "clipboard") {
return await copyImageBlobToClipboard(blobPromise);
}
const blob = await blobPromise;
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = filename;
link.click();
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
return blob;
},
getPerspective() {
return readScopedPerspectiveSnapshot(runtimeRef.current, {
modelKey,
sceneScaleMode: normalizedSceneScaleMode
});
},
focusViewPreset(faceId) {
return activateViewPlaneFace(faceId);
}
}), [modelKey, normalizedSceneScaleMode]);
useEffect(() => {
previewModeRef.current = previewMode;
}, [previewMode]);
useEffect(() => {
drawingChangeRef.current = onDrawingStrokesChange;
}, [onDrawingStrokesChange]);
useEffect(() => {
perspectiveChangeRef.current = onPerspectiveChange;
}, [onPerspectiveChange]);
useEffect(() => {
viewerAlertChangeRef.current = onViewerAlertChange;
}, [onViewerAlertChange]);
useEffect(() => {
drawingStrokesRef.current = Array.isArray(drawingStrokes) ? drawingStrokes : [];
drawingIdRef.current = Math.max(drawingIdRef.current, maxDrawingStrokeOrdinal(drawingStrokesRef.current));
renderDrawingOverlay();
}, [drawingStrokes]);
useViewerRuntime({
mountRef,
runtimeRef,
previewModeRef,
setError,
setViewerReadyTick,
viewerTheme,
syncDrawingCanvasSize,
renderDrawingOverlay,
emitPerspectiveChange,
setActiveViewPlaneFace,
activeViewPlaneFaceRef,
stepCameraTransition,
stepKeyboardOrbit,
getActiveViewPlaneFaceId,
cancelCameraTransition,
clearKeyboardOrbitState,
isTrackpadLikeWheelEvent,
getKeyboardOrbitCommand,
getKeyboardOrbitAxes,
applyOrbitDelta,
getViewerThemeValue,
getPixelRatioCap,
applySceneBackground,
applyCameraFrameInsets,
frameInsetsRef: viewportFrameInsetsRef,
updateGridHelper,
clearSceneGroup,
disposeSceneObject,
disposeTexture,
syncViewPlaneOrientation,
BASE_VIEWER_THEME,
DEFAULT_LIGHTING,
DEFAULT_DAMPING_FACTOR,
DEFAULT_ZOOM_SPEED,
COARSE_POINTER_ZOOM_SPEED,
INTERACTION_PIXEL_RATIO_CAP,
IDLE_PIXEL_RATIO_CAP,
INTERACTION_IDLE_DELAY_MS,
TRACKPAD_PINCH_ZOOM_SPEED,
COARSE_POINTER_PINCH_ZOOM_SPEED,
ACCELERATED_WHEEL_ZOOM_SPEED,
KEYBOARD_ORBIT_NUDGE_RAD,
defaultGridRadius,
sceneScaleMode: normalizedSceneScaleMode,
floorMode: resolvedFloorMode
});
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
runtime.sceneScaleMode = normalizedSceneScaleMode;
}, [normalizedSceneScaleMode]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
applySceneBackground(runtime, viewerTheme, normalizedLookSettings.background);
runtime.renderer.toneMappingExposure = Math.max(normalizedLookSettings.lighting.toneMappingExposure, 0.05);
runtime.hemisphereLight.visible = normalizedLookSettings.lighting.hemisphere.enabled;
runtime.hemisphereLight.color.set(normalizedLookSettings.lighting.hemisphere.skyColor);
runtime.hemisphereLight.groundColor.set(normalizedLookSettings.lighting.hemisphere.groundColor);
runtime.hemisphereLight.intensity = normalizedLookSettings.lighting.hemisphere.intensity;
runtime.ambientLight.visible = normalizedLookSettings.lighting.ambient.enabled;
runtime.ambientLight.color.set(normalizedLookSettings.lighting.ambient.color);
runtime.ambientLight.intensity = normalizedLookSettings.lighting.ambient.intensity;
runtime.keyLight.visible = normalizedLookSettings.lighting.directional.enabled;
runtime.keyLight.color.set(normalizedLookSettings.lighting.directional.color);
runtime.keyLight.intensity = normalizedLookSettings.lighting.directional.intensity;
runtime.keyLight.position.set(
normalizedLookSettings.lighting.directional.position.x,
normalizedLookSettings.lighting.directional.position.y,
normalizedLookSettings.lighting.directional.position.z
);
const fillIntensity = getViewerThemeNumber(viewerTheme, "fillLightIntensity", DEFAULT_LIGHTING.fillLightIntensity);
runtime.fillLight.visible = fillIntensity > 0.0001;
runtime.fillLight.color.set(getViewerThemeValue(viewerTheme, "fillLightColor", DEFAULT_LIGHTING.fillLightColor));
runtime.fillLight.intensity = Math.max(fillIntensity, 0);
const rimIntensity = getViewerThemeNumber(viewerTheme, "rimLightIntensity", DEFAULT_LIGHTING.rimLightIntensity);
runtime.rimLight.visible = rimIntensity > 0.0001;
runtime.rimLight.color.set(getViewerThemeValue(viewerTheme, "rimLightColor", DEFAULT_LIGHTING.rimLightColor));
runtime.rimLight.intensity = Math.max(rimIntensity, 0);
runtime.spotLight.visible = normalizedLookSettings.lighting.spot.enabled;
runtime.spotLight.color.set(normalizedLookSettings.lighting.spot.color);
runtime.spotLight.intensity = normalizedLookSettings.lighting.spot.intensity;
runtime.spotLight.angle = normalizedLookSettings.lighting.spot.angle;
runtime.spotLight.distance = normalizedLookSettings.lighting.spot.distance;
runtime.spotLight.position.set(
normalizedLookSettings.lighting.spot.position.x,
normalizedLookSettings.lighting.spot.position.y,
normalizedLookSettings.lighting.spot.position.z
);
updateSpotLightTarget(runtime);
runtime.pointLight.visible = normalizedLookSettings.lighting.point.enabled;
runtime.pointLight.color.set(normalizedLookSettings.lighting.point.color);
runtime.pointLight.intensity = normalizedLookSettings.lighting.point.intensity;
runtime.pointLight.distance = normalizedLookSettings.lighting.point.distance;
runtime.pointLight.position.set(
normalizedLookSettings.lighting.point.position.x,
normalizedLookSettings.lighting.point.position.y,
normalizedLookSettings.lighting.point.position.z
);
// Keep a single primary shadow; the spot light drives the floor glow/fill.
runtime.keyLight.castShadow = runtime.keyLight.visible;
runtime.spotLight.castShadow = false;
const materialSettings = {
...normalizedLookSettings.materials,
envMapIntensity: normalizedLookSettings.materials.envMapIntensity * (
normalizedLookSettings.environment.enabled ? normalizedLookSettings.environment.intensity : 0
)
};
for (const record of runtime.displayRecords || []) {
applyMaterialSettingsToRecord(runtime.THREE, record, materialSettings);
}
runtime.gridConfig = null;
updateGridHelper(
runtime,
viewerTheme,
runtime.gridRadius ?? defaultGridRadius,
runtime.gridFloorY ?? 0,
normalizedSceneScaleMode,
resolvedFloorMode
);
updateSpotLightTarget(runtime);
if (runtime.hasVisibleModel) {
updateStageEffects(
runtime,
viewerTheme,
normalizedLookSettings,
runtime.gridRadius ?? defaultGridRadius,
runtime.gridFloorY ?? 0,
resolvedFloorMode
);
} else {
clearSceneGroup(runtime.stageGroup);
}
runtime.requestRender();
}, [defaultGridRadius, normalizedLookSettings, normalizedSceneScaleMode, resolvedFloorMode, viewerReadyTick, viewerTheme]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime?.THREE || !runtime?.scene) {
return;
}
let cancelled = false;
const environmentSettings = normalizedLookSettings.environment;
const clearEnvironmentTexture = () => {
runtime.scene.environment = null;
disposeTexture(runtime.environmentTexture);
runtime.environmentTexture = null;
runtime.environmentTextureUrl = "";
};
const applyBackgroundFallback = () => {
clearEnvironmentTexture();
applySceneBackground(runtime, viewerTheme, normalizedLookSettings.background);
runtime.requestRender();
};
const loadAndApplyEnvironment = async () => {
if (!environmentSettings.enabled) {
viewerAlertChangeRef.current?.(null);
applyBackgroundFallback();
return;
}
const preset = getEnvironmentPresetById(environmentSettings.presetId);
const textureUrl = String(preset?.url || "").trim();
if (!textureUrl) {
viewerAlertChangeRef.current?.(null);
applyBackgroundFallback();
return;
}
if (!runtime.environmentTexture || runtime.environmentTextureUrl !== textureUrl) {
const textureLoader = new runtime.THREE.TextureLoader();
if (typeof textureLoader.setCrossOrigin === "function") {
textureLoader.setCrossOrigin("anonymous");
}
const nextTexture = await textureLoader.loadAsync(textureUrl);
if (cancelled) {
nextTexture.dispose?.();
return;
}
nextTexture.mapping = runtime.THREE.EquirectangularReflectionMapping;
nextTexture.colorSpace = runtime.THREE.SRGBColorSpace;
nextTexture.needsUpdate = true;
disposeTexture(runtime.environmentTexture);
runtime.environmentTexture = nextTexture;
runtime.environmentTextureUrl = textureUrl;
}
runtime.scene.environment = runtime.environmentTexture;
viewerAlertChangeRef.current?.(null);
if (runtime.scene.environmentRotation?.set) {
runtime.scene.environmentRotation.set(0, environmentSettings.rotationY, 0);
}
if (environmentSettings.useAsBackground) {
runtime.scene.background = runtime.environmentTexture;
if (runtime.scene.backgroundRotation?.set) {
runtime.scene.backgroundRotation.set(0, environmentSettings.rotationY, 0);
}
} else {
applySceneBackground(runtime, viewerTheme, normalizedLookSettings.background);
}
runtime.requestRender();
};
loadAndApplyEnvironment().catch((error) => {
if (!cancelled) {
applyBackgroundFallback();
viewerAlertChangeRef.current?.({
severity: "warning",
summary: "Environment unavailable",
title: "Environment preset could not be loaded",
message: `Failed to load ${String(getEnvironmentPresetById(environmentSettings.presetId)?.label || "the selected environment preset")}.`,
resolution: "The viewer fell back to the current background settings. Check the network connection or choose another preset."
});
console.error("Failed to apply environment texture", error);
}
});
return () => {
cancelled = true;
};
}, [viewerReadyTick, viewerTheme, normalizedLookSettings.background, normalizedLookSettings.environment]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
if (runtime.interactionState.restoreTimerId) {
window.clearTimeout(runtime.interactionState.restoreTimerId);
runtime.interactionState.restoreTimerId = 0;
}
clearKeyboardOrbitState(runtime.keyboardOrbitState);
runtime.previewOrbitEnabled = !!previewMode;
runtime.controls.autoRotate = !!previewMode;
runtime.controls.autoRotateSpeed = PREVIEW_AUTO_ROTATE_SPEED;
runtime.controls.enabled = true;
runtime.controls.enableDamping = true;
runtime.controls.dampingFactor = DEFAULT_DAMPING_FACTOR;
runtime.interactionState.active = !!previewMode;
if (previewMode) {
cancelCameraTransition(runtime, { scheduleIdle: false });
} else {
runtime.scheduleIdleQuality();
}
runtime.requestRender();
}, [previewMode, viewerReadyTick]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
const {
THREE,
modelGroup,
edgesGroup,
facePickGroup,
edgePickGroup
} = runtime;
const clearDisplayedModel = () => {
cancelCameraTransition(runtime);
clearSceneGroup(runtime.stageGroup);
clearSceneGroup(modelGroup);
clearSceneGroup(edgesGroup);
clearSceneGroup(facePickGroup);
clearSceneGroup(edgePickGroup);
runtime.facePickMesh = null;
runtime.edgePickLines = null;
runtime.edgePickObjects = [];
runtime.displayRecords = [];
runtime.hasVisibleModel = false;
runtime.requestRender();
};
if (isLoading) {
clearDisplayedModel();
setError("");
return;
}
if (!meshData || !isNumericArray(meshData.vertices, 3) || !isNumericArray(meshData.indices, 3)) {
clearDisplayedModel();
return;
}
clearDisplayedModel();
const { camera, controls } = runtime;
const displayRecords = [];
const shouldRenderParts =
renderPartsIndividually ||
Array.isArray(pickableParts) &&
pickableParts.length > 0 &&
(
pickMode === VIEWER_PICK_MODE.PARTS ||
pickMode === VIEWER_PICK_MODE.ASSEMBLY ||
pickMode === VIEWER_PICK_MODE.AUTO ||
!!String(focusedPartId || "").trim()
);
const renderedParts = renderPartsIndividually
? (Array.isArray(meshData?.parts) ? meshData.parts : [])
: pickableParts;
const useVertexColors = shouldUseDisplayVertexColors(meshData);
const materialSettings = {
...normalizedLookSettings.materials,
envMapIntensity: normalizedLookSettings.materials.envMapIntensity * (
normalizedLookSettings.environment.enabled ? normalizedLookSettings.environment.intensity : 0
)
};
if (shouldRenderParts) {
for (const part of renderedParts) {
const geometry = buildPartGeometry(THREE, meshData, part, recomputeNormals, normalizedLookSettings.materials);
if (!geometry) {
continue;
}
const hasVertexColors = !!geometry.getAttribute("color");
const sourceColor = readSourceColor(THREE, part?.color);
const usePartVertexColors = hasVertexColors;
const baseColor = resolveSourceBaseColor(THREE, {
hasVertexColors,
sourceColor,
materialSettings: normalizedLookSettings.materials,
fallbackColor: normalizedLookSettings.materials.defaultColor || viewerTheme?.surface || BASE_VIEWER_THEME.surface
});
const material = createSurfaceMaterial(THREE, viewerTheme, {
color: baseColor,
useVertexColors: usePartVertexColors
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.partId = part.id;
const displayPartTransform = renderPartsIndividually ? part?.transform : null;
applyPartTransform(THREE, mesh, displayPartTransform);
modelGroup.add(mesh);
let edgeMaterial = null;
let edgeMesh = null;
if (edgesVisible) {
const edgeResult = createDisplayEdgeObject(
runtime,
buildPartEdgeGeometry(THREE, meshData, part, geometry),
viewerTheme,
normalizedLookSettings.edges,
part.id
);
edgeMaterial = edgeResult.edgeMaterial;
edgeMesh = edgeResult.edgeMesh;
if (edgeMesh) {
applyPartTransform(THREE, edgeMesh, displayPartTransform);
edgesGroup.add(edgeMesh);
}
}
displayRecords.push({
partId: part.id,
mesh,
edges: edgeMesh,
material,
edgeMaterial,
baseColor,
sourceColor,
baseTransform: displayPartTransform,
introOpacity: 1,
introProgress: 1,
introOrder: Math.max(Math.round(Number(part?.introOrder) || 0), 0),
partCenter: readBoundsCenter(THREE, part.bounds),
partBounds: part.bounds,
hasVertexColors,
useVertexColors: usePartVertexColors
});
}
} else {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(meshData.vertices), 3));
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(meshData.indices), 1));
if (useVertexColors && meshData.colors.length === meshData.vertices.length) {
geometry.setAttribute("color", new THREE.BufferAttribute(shapeSourceColorBuffer(THREE, meshData.colors, normalizedLookSettings.materials), 3));
}
applyGeometryNormals(THREE, geometry, meshData.normals, recomputeNormals);
geometry.computeBoundingSphere();
const hasVertexColors = useVertexColors;
const sourceColor = readSourceColor(THREE, meshData?.sourceColor);
const useModelVertexColors = hasVertexColors;
const baseColor = resolveSourceBaseColor(THREE, {
hasVertexColors,
sourceColor,
materialSettings: normalizedLookSettings.materials,
fallbackColor: normalizedLookSettings.materials.defaultColor || viewerTheme?.surface || BASE_VIEWER_THEME.surface
});
const material = createSurfaceMaterial(THREE, viewerTheme, {
color: baseColor,
useVertexColors: useModelVertexColors
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.partId = "__model__";
modelGroup.add(mesh);
let edgeMaterial = null;
let edgeMesh = null;
if (edgesVisible) {
const edgeResult = createDisplayEdgeObject(
runtime,
buildEdgeGeometry(THREE, meshData, geometry),
viewerTheme,
normalizedLookSettings.edges,
"__model__"
);
edgeMaterial = edgeResult.edgeMaterial;
edgeMesh = edgeResult.edgeMesh;
if (edgeMesh) {
edgesGroup.add(edgeMesh);
}
}
displayRecords.push({
partId: "__model__",
mesh,
edges: edgeMesh,
material,
edgeMaterial,
baseColor,
sourceColor,
baseTransform: null,
introOpacity: 1,
introProgress: 1,
introOrder: 0,
partCenter: readBoundsCenter(THREE, meshData.bounds),
partBounds: meshData.bounds,
hasVertexColors,
useVertexColors: useModelVertexColors
});
}
runtime.displayRecords = displayRecords;
for (const record of runtime.displayRecords) {
applyMaterialSettingsToRecord(THREE, record, materialSettings);
applyDisplayRecordTransform(THREE, record, runtime.modelRadius || 1);
}
runtime.hasVisibleModel = true;
const boundsMin = Array.isArray(meshData.bounds?.min) ? meshData.bounds.min : [0, 0, 0];
const boundsMax = Array.isArray(meshData.bounds?.max) ? meshData.bounds.max : [0, 0, 0];
const center = new THREE.Vector3(
(toNumber(boundsMin[0]) + toNumber(boundsMax[0])) / 2,
(toNumber(boundsMin[1]) + toNumber(boundsMax[1])) / 2,
(toNumber(boundsMin[2]) + toNumber(boundsMax[2])) / 2
);
const { radius } = applyRuntimeModelBounds(THREE, runtime, meshData.bounds, normalizedSceneScaleMode);
updateGridHelper(
runtime,
viewerTheme,
radius,
toNumber(boundsMin[1]) - center.y,
normalizedSceneScaleMode,
resolvedFloorMode
);
updateSpotLightTarget(runtime);
updateStageEffects(runtime, viewerTheme, normalizedLookSettings, radius, runtime.gridFloorY ?? 0, resolvedFloorMode);
const previousTransform = modelTransformRef.current;
if (previousTransform.modelKey !== modelKey || !previousTransform.offset) {
previousTransform.modelKey = modelKey || "";
previousTransform.offset = new THREE.Vector3(-center.x, -center.y, -center.z);
}
modelGroup.position.copy(previousTransform.offset);
edgesGroup.position.copy(previousTransform.offset);
facePickGroup.position.copy(previousTransform.offset);
edgePickGroup.position.copy(previousTransform.offset);
facePickGroup.updateMatrixWorld(true);
edgePickGroup.updateMatrixWorld(true);
applyPartVisualState(THREE, displayRecords, {
viewerTheme,
edgeSettings: normalizedLookSettings.edges,
hiddenPartIds: shouldRenderParts ? hiddenPartIds : [],
hoveredPartId: shouldRenderParts ? hoveredPartId : "",
focusedPartId: shouldRenderParts ? focusedPartId : "",
selectedPartIds: shouldRenderParts ? selectedPartIds : [],
showEdges: edgesVisible
});
modelGroup.updateMatrixWorld(true);
edgesGroup.updateMatrixWorld(true);
camera.near = Math.max(radius / 1200, 0.01);
camera.far = Math.max(radius * 600, 2000);
camera.updateProjectionMatrix();
applyCameraFrameInsets(runtime, viewportFrameInsetsRef.current, { updateProjection: false });
controls.minDistance = Math.max(radius / 2200, 0.02);
controls.maxDistance = Math.max(radius * 140, 50);
controls.zoomSpeed = DEFAULT_ZOOM_SPEED;
runtime.edgePickThreshold = Math.max(radius / 320, 0.65);
if (framedModelKeyRef.current !== (modelKey || "")) {
const nextPerspective = resolvePerspectiveSnapshot(
perspectiveRef ? perspectiveRef.current : undefined,
perspective
);
const nextPerspectiveMatchesScene = perspectiveSnapshotMatchesScene(nextPerspective, {
modelKey,
sceneScaleMode: normalizedSceneScaleMode
});
runWithoutPerspectiveEvents(() => {
if (
!nextPerspectiveMatchesScene ||
!applyPerspectiveSnapshot(runtime, nextPerspective, { scheduleIdle: false })
) {
cancelCameraTransition(runtime);
const frameMetrics = getViewportFrameMetrics(runtime, viewportFrameInsetsRef.current);
const fitDistance = getFitDistanceForBoundingSphere(camera, radius, normalizedSceneScaleMode, frameMetrics.aspect);
const viewDirection = new THREE.Vector3(...DEFAULT_VIEW_DIRECTION).normalize();
camera.position.copy(viewDirection.multiplyScalar(fitDistance));
controls.target.set(0, 0, 0);
controls.update();
runtime.requestRender();
}
});
framedModelKeyRef.current = modelKey || "";
lastEmittedPerspectiveRef.current = readScopedPerspectiveSnapshot(runtime, {
modelKey,
sceneScaleMode: normalizedSceneScaleMode
});
}
setError("");
runtime.requestRender();
}, [
meshData,
modelKey,
perspective,
perspectiveRef,
edgesVisible,
recomputeNormals,
isLoading,
viewerReadyTick,
pickMode,
renderPartsIndividually,
pickableParts,
focusedPartId,
normalizedSceneScaleMode,
resolvedFloorMode,
viewerTheme,
normalizedLookSettings.materials,
normalizedLookSettings.edges
]);
useEffect(() => {
const runtime = runtimeRef.current;
if (
!runtime?.THREE ||
isLoading ||
!renderPartsIndividually ||
!Array.isArray(meshData?.parts) ||
!Array.isArray(runtime.displayRecords) ||
!runtime.displayRecords.length
) {
return;
}
const partsById = new Map(
meshData.parts.map((part) => [String(part?.id || ""), part]).filter(([partId]) => partId)
);
let updated = false;
for (const record of runtime.displayRecords) {
const part = partsById.get(String(record?.partId || ""));
if (!part) {
continue;
}
record.baseTransform = part.transform;
record.partBounds = part.bounds;
record.partCenter = readBoundsCenter(runtime.THREE, part.bounds);
record.introOrder = Math.max(Math.round(Number(part?.introOrder) || 0), 0);
applyDisplayRecordTransform(runtime.THREE, record, runtime.modelRadius || 1);
updated = true;
}
if (!updated) {
return;
}
applyRuntimeModelBounds(runtime.THREE, runtime, meshData.bounds, normalizedSceneScaleMode);
runtime.requestRender();
}, [
meshData?.parts,
meshData?.bounds,
isLoading,
renderPartsIndividually,
normalizedSceneScaleMode,
viewerReadyTick
]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
applyPartVisualState(runtime.THREE, runtime.displayRecords, partVisualStateRef.current);
runtime.requestRender();
}, [viewerReadyTick, partVisualStateEnabled, edgesVisible, focusedPartId, hiddenPartIds, hoveredPartId, pickMode, pickableParts, selectedPartIds, viewerTheme, normalizedLookSettings.edges]);
useEffect(() => {
const runtime = runtimeRef.current;
if (
!runtime?.THREE ||
isLoading ||
!renderPartsIndividually ||
!Array.isArray(runtime.displayRecords) ||
!runtime.displayRecords.length
) {
return;
}
const animationEnabled = partIntroAnimation?.enabled !== false;
const introKey = String(partIntroAnimation?.introKey || "").trim();
const replayToken = Math.max(Number(partIntroAnimation?.replayToken) || 0, 0);
const playbackKey = replayToken ? `${introKey}:replay:${replayToken}` : introKey;
const stopAnimation = () => {
if (partIntroStateRef.current.frameId && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(partIntroStateRef.current.frameId);
}
partIntroStateRef.current.frameId = 0;
};
const snapToVisible = () => {
for (const record of runtime.displayRecords) {
record.introProgress = 1;
record.introOpacity = 1;
applyDisplayRecordTransform(runtime.THREE, record, runtime.modelRadius || 1);
}
applyPartVisualState(runtime.THREE, runtime.displayRecords, partVisualStateRef.current);
runtime.requestRender();
};
stopAnimation();
if (!introKey) {
partIntroStateRef.current.playedIntroKey = "";
snapToVisible();
return stopAnimation;
}
if (!animationEnabled && !replayToken) {
partIntroStateRef.current.playedIntroKey = playbackKey;
snapToVisible();
return stopAnimation;
}
if (partIntroStateRef.current.playedIntroKey === playbackKey) {
snapToVisible();
return stopAnimation;
}
partIntroStateRef.current.playedIntroKey = playbackKey;
const startTime = typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
for (const record of runtime.displayRecords) {
record.introProgress = 0;
record.introOpacity = 0;
applyDisplayRecordTransform(runtime.THREE, record, runtime.modelRadius || 1);
}
applyPartVisualState(runtime.THREE, runtime.displayRecords, partVisualStateRef.current);
runtime.requestRender();
const stepIntro = (timestamp) => {
let hasPendingRecords = false;
for (const record of runtime.displayRecords) {
const introOrder = Math.max(Math.round(Number(record?.introOrder) || 0), 0);
const delayMs = introOrder * URDF_PART_INTRO_STAGGER_MS;
const progress = clamp((timestamp - startTime - delayMs) / URDF_PART_INTRO_DURATION_MS, 0, 1);
record.introProgress = progress;
record.introOpacity = easeOutCubic(clamp(progress / 0.6, 0, 1));
applyDisplayRecordTransform(runtime.THREE, record, runtime.modelRadius || 1);
if (progress < 0.999) {
hasPendingRecords = true;
}
}
applyPartVisualState(runtime.THREE, runtime.displayRecords, partVisualStateRef.current);
runtime.requestRender();
if (hasPendingRecords && typeof requestAnimationFrame === "function") {
partIntroStateRef.current.frameId = requestAnimationFrame(stepIntro);
return;
}
partIntroStateRef.current.frameId = 0;
snapToVisible();
};
if (typeof requestAnimationFrame === "function") {
partIntroStateRef.current.frameId = requestAnimationFrame(stepIntro);
} else {
snapToVisible();
}
return stopAnimation;
}, [
isLoading,
renderPartsIndividually,
viewerReadyTick,
partIntroAnimation?.enabled,
partIntroAnimation?.introKey,
partIntroAnimation?.replayToken
]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime?.THREE || !runtime?.edgePickGroup || !runtime?.facePickGroup || !runtime?.vertexPickGroup) {
return;
}
clearSceneGroup(runtime.facePickGroup);
clearSceneGroup(runtime.edgePickGroup);
clearSceneGroup(runtime.vertexPickGroup);
runtime.facePickMesh = null;
runtime.edgePickLines = null;
runtime.vertexPickPoints = null;
runtime.edgePickObjects = [];
const facePickMesh = buildFacePickMesh(runtime.THREE, selectorRuntime);
if (facePickMesh) {
runtime.facePickMesh = facePickMesh;
runtime.facePickGroup.add(facePickMesh);
}
const edgePickLines = buildEdgePickLines(runtime.THREE, selectorRuntime);
if (edgePickLines) {
runtime.edgePickLines = edgePickLines;
runtime.edgePickGroup.add(edgePickLines);
runtime.edgePickObjects = [edgePickLines];
}
const vertexPickPoints = buildVertexPickPoints(runtime.THREE, selectorRuntime);
if (vertexPickPoints) {
runtime.vertexPickPoints = vertexPickPoints;
runtime.vertexPickGroup.add(vertexPickPoints);
}
if (modelTransformRef.current.offset) {
runtime.facePickGroup.position.copy(modelTransformRef.current.offset);
runtime.edgePickGroup.position.copy(modelTransformRef.current.offset);
runtime.vertexPickGroup.position.copy(modelTransformRef.current.offset);
} else {
runtime.facePickGroup.position.set(0, 0, 0);
runtime.edgePickGroup.position.set(0, 0, 0);
runtime.vertexPickGroup.position.set(0, 0, 0);
}
runtime.facePickGroup.updateMatrixWorld(true);
runtime.edgePickGroup.updateMatrixWorld(true);
runtime.vertexPickGroup.updateMatrixWorld(true);
}, [modelKey, selectorRuntime, viewerReadyTick]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime?.THREE || !runtime?.edgesGroup) {
return;
}
const { THREE, edgesGroup } = runtime;
if (!runtime.surfaceLineGroup || runtime.surfaceLineGroup.parent !== edgesGroup) {
runtime.surfaceLineGroup = new THREE.Group();
runtime.surfaceLineGroup.renderOrder = 21;
edgesGroup.add(runtime.surfaceLineGroup);
}
const lineGroup = runtime.surfaceLineGroup;
clearOverlayGroup(runtime, lineGroup);
const surfaceLineStrokes = (Array.isArray(drawingStrokes) ? drawingStrokes : []).filter(isSurfaceLineStroke);
if (!surfaceLineStrokes.length) {
return () => {
clearOverlayGroup(runtime, lineGroup);
};
}
const lineWidth = Math.max(getEdgeThickness(normalizedLookSettings.edges, viewerTheme) * 1.6, 1.8);
const lineOffset = Math.max(runtime.modelRadius || 0, 1) * 0.0008 + 0.02;
for (const stroke of surfaceLineStrokes) {
const surfaceLine = stroke?.surfaceLine;
const referenceId = String(surfaceLine?.referenceId || "").trim();
const reference = pickableReferenceMap.get(referenceId) || selectorRuntime?.referenceMap?.get(referenceId) || null;
if (!reference) {
continue;
}
const linePositions = buildSurfaceLinePositions(reference, surfaceLine, {
offset: lineOffset
});
if (!linePositions.length) {
continue;
}
const line = createScreenSpaceLineSegments(runtime, linePositions, {
color: SURFACE_LINE_COLOR,
opacity: 0.98,
lineWidth,
renderOrder: 22,
depthTest: true,
depthWrite: false
});
if (line) {
lineGroup.add(line);
}
}
lineGroup.visible = lineGroup.children.length > 0;
runtime.requestRender();
return () => {
clearOverlayGroup(runtime, lineGroup);
};
}, [drawingStrokes, normalizedLookSettings.edges, pickableReferenceMap, selectorRuntime, viewerReadyTick, viewerTheme]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime?.THREE || !runtime?.edgesGroup) {
return;
}
const { THREE, edgesGroup } = runtime;
if (!runtime.bendGuideGroup || runtime.bendGuideGroup.parent !== edgesGroup) {
runtime.bendGuideGroup = new THREE.Group();
runtime.bendGuideGroup.renderOrder = 15;
edgesGroup.add(runtime.bendGuideGroup);
}
const bendGuideGroup = runtime.bendGuideGroup;
clearOverlayGroup(runtime, bendGuideGroup);
if (isLoading || !meshData || !isNumericArray(meshData.guide_line_segments, 6)) {
return () => {
clearOverlayGroup(runtime, bendGuideGroup);
};
}
const bendGuideLine = createScreenSpaceLineSegments(runtime, meshData.guide_line_segments, {
color: BEND_GUIDE_COLOR,
opacity: 0.98,
lineWidth: Math.max(getEdgeThickness(normalizedLookSettings.edges, viewerTheme) * BEND_GUIDE_WIDTH_MULTIPLIER, 1.4),
renderOrder: 16,
depthTest: false,
depthWrite: false
});
if (bendGuideLine) {
bendGuideGroup.add(bendGuideLine);
}
bendGuideGroup.visible = bendGuideGroup.children.length > 0;
runtime.requestRender();
return () => {
clearOverlayGroup(runtime, bendGuideGroup);
};
}, [isLoading, meshData, modelKey, normalizedLookSettings.edges, viewerReadyTick, viewerTheme]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime?.THREE || !runtime?.edgesGroup) {
return;
}
const { THREE, edgesGroup } = runtime;
if (!runtime.partHighlightGroup || runtime.partHighlightGroup.parent !== edgesGroup) {
runtime.partHighlightGroup = new THREE.Group();
runtime.partHighlightGroup.renderOrder = 22;
edgesGroup.add(runtime.partHighlightGroup);
}
const highlightGroup = runtime.partHighlightGroup;
clearOverlayGroup(runtime, highlightGroup);
// Assembly hover and selection use material state only. Building outline
// geometry here is expensive for dense imported components and is not
// needed until a leaf part is inspected for face/edge picking.
highlightGroup.visible = false;
runtime.requestRender();
return () => {
clearOverlayGroup(runtime, highlightGroup);
};
}, [modelKey, viewerReadyTick]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime?.THREE || !runtime?.edgesGroup || !runtime?.modelGroup) {
return;
}
const { THREE, edgesGroup, modelGroup } = runtime;
if (!runtime.referenceHighlightGroup || runtime.referenceHighlightGroup.parent !== edgesGroup) {
runtime.referenceHighlightGroup = new THREE.Group();
runtime.referenceHighlightGroup.renderOrder = 25;
edgesGroup.add(runtime.referenceHighlightGroup);
}
const highlightGroup = runtime.referenceHighlightGroup;
if (!runtime.referenceFaceFillGroup || runtime.referenceFaceFillGroup.parent !== modelGroup) {
runtime.referenceFaceFillGroup = new THREE.Group();
runtime.referenceFaceFillGroup.renderOrder = 24;
modelGroup.add(runtime.referenceFaceFillGroup);
}
const faceFillGroup = runtime.referenceFaceFillGroup;
clearOverlayGroup(runtime, highlightGroup);
clearOverlayGroup(runtime, faceFillGroup);
const baseEdgeThickness = getEdgeThickness(normalizedLookSettings.edges, viewerTheme);
const selectedLineWidth = baseEdgeThickness * REFERENCE_HIGHLIGHT_WIDTH_MULTIPLIER;
const hoveredLineWidth = baseEdgeThickness * REFERENCE_HOVER_HIGHLIGHT_WIDTH_MULTIPLIER;
const seenReferenceIds = new Set();
const orderedReferenceIds = [];
for (const referenceId of Array.isArray(selectedReferenceIds) ? selectedReferenceIds : []) {
const normalizedReferenceId = String(referenceId || "").trim();
if (!normalizedReferenceId || seenReferenceIds.has(normalizedReferenceId)) {
continue;
}
seenReferenceIds.add(normalizedReferenceId);
orderedReferenceIds.push(normalizedReferenceId);
}
const normalizedHoveredReferenceId = String(hoveredReferenceId || "").trim();
if (normalizedHoveredReferenceId && !seenReferenceIds.has(normalizedHoveredReferenceId)) {
orderedReferenceIds.push(normalizedHoveredReferenceId);
}
for (const referenceId of orderedReferenceIds) {
const topologyReference = pickableReferenceMap.get(referenceId) || selectorRuntime?.referenceMap?.get(referenceId) || null;
if (!topologyReference) {
continue;
}
const selectorType = String(topologyReference?.selectorType || "").trim();
if (selectorType !== "face" && selectorType !== "edge" && selectorType !== "vertex") {
continue;
}
const isHovered = referenceId === normalizedHoveredReferenceId;
if (selectorType === "vertex") {
const marker = buildVertexMarkerMesh(runtime, THREE, topologyReference, {
color: REFERENCE_CORNER_COLOR,
opacity: isHovered ? 0.96 : 0.88,
});
if (marker) {
highlightGroup.add(marker);
}
continue;
}
const highlightColor = isHovered ? REFERENCE_HOVER_COLOR : REFERENCE_SELECTED_COLOR;
const linePositions = selectorType === "edge"
? buildEdgeLinePositionsFromProxy(selectorRuntime, topologyReference)
: buildFaceBoundaryLinePositions(selectorRuntime, topologyReference);
if (linePositions?.length) {
const line = createScreenSpaceLineSegments(runtime, linePositions, {
color: highlightColor,
opacity: isHovered ? 1 : 0.98,
lineWidth: isHovered ? hoveredLineWidth : selectedLineWidth,
renderOrder: 26,
depthTest: true,
depthWrite: false
});
if (line) {
highlightGroup.add(line);
}
}
if (selectorType === "face") {
const fillGeometry = buildFaceFillGeometryFromProxy(runtime, THREE, selectorRuntime, topologyReference);
if (fillGeometry) {
const fillMaterial = new THREE.MeshBasicMaterial({
color: highlightColor,
transparent: true,
opacity: isHovered ? REFERENCE_HOVER_FILL_OPACITY : REFERENCE_SELECTED_FILL_OPACITY,
depthTest: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -2,
polygonOffsetUnits: -2,
side: THREE.DoubleSide,
toneMapped: false
});
const fillMesh = new THREE.Mesh(fillGeometry, fillMaterial);
fillMesh.renderOrder = 25;
faceFillGroup.add(fillMesh);
}
}
}
highlightGroup.visible = highlightGroup.children.length > 0;
faceFillGroup.visible = faceFillGroup.children.length > 0;
runtime.requestRender();
return () => {
clearOverlayGroup(runtime, highlightGroup);
clearOverlayGroup(runtime, faceFillGroup);
};
}, [hoveredReferenceId, pickableReferenceMap, selectedReferenceIds, selectorRuntime, viewerReadyTick, viewerTheme, normalizedLookSettings.edges]);
useViewerDrawingOverlay({
drawingCanvasRef,
drawingDraftRef,
drawingStrokesRef,
drawingChangeRef,
drawingIdRef,
drawingEnabled,
drawingTool,
meshData,
previewMode,
viewerReadyTick,
renderDrawingOverlay,
redrawDrawingCanvas,
buildDrawingPoint,
distanceToStrokeInPixels,
strokeLengthInPixels,
drawingToolNeedsTwoPoints,
buildFillStrokeAtPoint,
buildSurfaceLineAnchor: buildSurfaceLineFaceAnchor,
updateSurfaceLineAnchor: updateSurfaceLineFaceAnchor,
drawingEraseThresholdPx: DRAWING_ERASE_THRESHOLD_PX,
drawingMinPointDistancePx: DRAWING_MIN_POINT_DISTANCE_PX,
drawingMinStrokeLengthPx: DRAWING_MIN_STROKE_LENGTH_PX
});
useViewerPicking({
runtimeRef,
mountRef: interactionHostRef,
sceneMountRef: mountRef,
drawingCanvasRef,
previewMode,
pickMode,
selectorRuntime,
pickableFaces: filteredPickableFaces,
pickableEdges: filteredPickableEdges,
pickableVertices: filteredPickableVertices,
focusedPartId: focusedPartIdValue,
onHoverReferenceChange,
onActivateReference,
onDoubleActivateReference,
viewerReadyTick
});
return (
<div ref={interactionHostRef} className="relative h-full w-full">
<div className="h-full w-full" ref={mountRef} />
<canvas
ref={drawingCanvasRef}
className="absolute inset-0 z-10 h-full w-full touch-none"
style={{
pointerEvents: drawingEnabled && !previewMode && !!meshData ? "auto" : "none",
cursor: drawingEnabled && !previewMode && !!meshData
? (drawingTool === DRAWING_TOOL.ERASE ? "cell" : drawingTool === DRAWING_TOOL.FILL ? "copy" : "crosshair")
: "default"
}}
aria-hidden="true"
/>
<ViewPlaneControl
showViewPlane={showViewPlane}
previewMode={previewMode}
isLoading={isLoading}
meshData={meshData}
viewPlaneOffsetRight={viewPlaneOffsetRight}
viewPlaneOffsetBottom={viewPlaneOffsetBottom}
compact={compactViewPlane}
activeViewPlaneFace={activeViewPlaneFace}
viewPlaneFaces={VIEW_PLANE_FACES}
viewPlaneOrientation={viewPlaneOrientation}
viewerTheme={viewerTheme}
activateViewPlaneFace={activateViewPlaneFace}
activateDefaultViewPlane={activateDefaultViewPlane}
/>
{error ? (
<p className="cad-glass-popover pointer-events-none absolute left-4 top-24 z-20 rounded-[10px] border border-[var(--ui-error-bg)] px-4 py-3 text-sm text-[var(--ui-error-text)] shadow-[var(--ui-shadow-soft)] sm:top-20">
{error}
</p>
) : null}
</div>
);
});
export default CadViewer;