mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
5449 lines
185 KiB
JavaScript
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;
|