Files
cadSet/cad-agent-studio/src/viewer-cadjs/common/renderMeshScene.js
T
2026-07-27 17:24:04 +08:00

1056 lines
36 KiB
JavaScript

import * as THREE from "three";
import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import {
displayModeForcesEdges,
displayModeIsWireframe,
displayModeShowsEdges,
displayModeShowsThroughEdges,
CAMERA_PROJECTION,
normalizeDisplaySettings,
normalizeCameraProjection,
resolveDisplayEdgeSettings
} from "./displaySettings.js";
import {
buildModel
} from "./cadScene.js";
import {
applyDisplayRecordTransform
} from "./displayRecordTransform.js";
import {
resolveTopologyDisplayEdgeRuntimes,
shouldRenderTopologyDisplayEdges,
shouldUseRecordTopologyEdgeTransforms
} from "./topologyDisplayEdgeRuntime.js";
import {
syncScreenSpaceLineMaterialResolution
} from "./renderEdges.js";
import {
syncTopologyDisplayEdgeLine
} from "../lib/viewer/topologyDisplayEdgeLine.js";
import {
applyExplodedViewProgress,
clearExplodedViewRecords,
createExplodedViewRecordStates,
explodedViewBoundsFromStates
} from "../lib/viewer/explodedView.js";
import {
addFloor as addSharedFloor,
applyEnvironment as applySharedEnvironment,
applyLighting as applySharedLighting,
boundsCorners as sharedBoundsCorners,
boundsFromVertices as sharedBoundsFromVertices,
centerAndRadiusFromBounds as sharedCenterAndRadiusFromBounds,
colorTextureFromBackground as sharedColorTextureFromBackground,
configurePngRenderer,
createSharedRenderOptions,
drawBurnedInLabel as drawSharedBurnedInLabel,
fitPerspectiveCamera as fitSharedPerspectiveCamera,
fitOrthographicCamera,
frameHalfHeightForView as sharedFrameHalfHeightForView,
framePadding as sharedFramePadding,
inferRenderSceneScale,
lockedFrameHalfHeight as sharedLockedFrameHalfHeight,
normalizeRenderSceneScale as normalizeSharedRenderSceneScale,
outputSize as sharedOutputSize,
RENDER_SCENE_SCALE,
RENDER_VIEW_PRESETS,
rendererDataUrlWithOptionalLabel as sharedRendererDataUrlWithOptionalLabel,
resolveRenderView,
resolveAppearanceSettings,
shouldBurnInViewLabels as sharedShouldBurnInViewLabels
} from "./renderOptions.js";
import {
cameraSpecUsesPerspectiveProjection
} from "./camera.js";
const DEFAULT_RENDER_SCALE = 1;
const DEFAULT_RENDER_THEME_ID = "workbench";
const RENDER_SCENE_SCALE_SETTINGS = Object.freeze({
[RENDER_SCENE_SCALE.CAD]: Object.freeze({
minBoundsSpan: 1,
minModelRadius: 1,
minFloorSize: 100,
minCameraDistance: 10,
minCameraFar: 1000
}),
[RENDER_SCENE_SCALE.URDF]: Object.freeze({
minBoundsSpan: 0.05,
minModelRadius: 0.05,
minFloorSize: 0.5,
minCameraDistance: 0.5,
minCameraFar: 10
})
});
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function toArray(value) {
return Array.isArray(value) ? value : [];
}
function normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeRenderSceneScale(value) {
return normalizeSharedRenderSceneScale(value);
}
function resolveRenderSceneScale(job = {}, meshData = {}) {
const explicit = String(job.render?.scale || job.render?.sceneScale || job.render?.sceneScaleMode || job.scale || job.sceneScale || "").trim().toLowerCase();
return inferRenderSceneScale({
explicit,
kind: job.resolved?.kind || job.kind,
parts: meshData?.parts
});
}
function resolveAppearance(job = {}) {
return resolveAppearanceSettings(job, { defaultThemeId: DEFAULT_RENDER_THEME_ID });
}
function resolveView(camera = "iso") {
return resolveRenderView(camera, RENDER_VIEW_PRESETS, { strict: true });
}
function boundsFromVertices(vertices) {
return sharedBoundsFromVertices(vertices);
}
function centerAndRadiusFromBounds(bounds, sceneScale = RENDER_SCENE_SCALE.CAD) {
return sharedCenterAndRadiusFromBounds(bounds, sceneScale, RENDER_SCENE_SCALE_SETTINGS);
}
function colorTextureFromBackground(background, width, height) {
return sharedColorTextureFromBackground(background, width, height);
}
async function applyEnvironment(scene, themeSettings, warnings) {
return applySharedEnvironment(scene, themeSettings, warnings);
}
function applyLighting(scene, themeSettings) {
return applySharedLighting(scene, themeSettings);
}
function mergeBoundsList(boundsList) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
let count = 0;
for (const bounds of Array.isArray(boundsList) ? boundsList : []) {
if (!bounds || !Array.isArray(bounds.min) || !Array.isArray(bounds.max)) {
continue;
}
count += 1;
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], toFiniteNumber(bounds.min[axis]));
max[axis] = Math.max(max[axis], toFiniteNumber(bounds.max[axis]));
}
}
return count > 0 && min.every(Number.isFinite) && max.every(Number.isFinite) ? { min, max } : null;
}
function addFloor(scene, bounds, themeSettings, sceneScale = RENDER_SCENE_SCALE.CAD) {
return addSharedFloor(scene, bounds, themeSettings, sceneScale, RENDER_SCENE_SCALE_SETTINGS);
}
function boundsCorners(bounds) {
return sharedBoundsCorners(bounds);
}
function framePadding(job = {}) {
return sharedFramePadding(job);
}
function frameHalfHeightForView(view, bounds, width, height, padding = 0.12, sceneScale = RENDER_SCENE_SCALE.CAD) {
return sharedFrameHalfHeightForView(view, bounds, width, height, padding, sceneScale, RENDER_SCENE_SCALE_SETTINGS);
}
function fitCamera(camera, view, bounds, width, height, lockedHalfHeight = null, padding = 0.12, sceneScale = RENDER_SCENE_SCALE.CAD) {
return fitOrthographicCamera(camera, view, bounds, width, height, {
lockedHalfHeight,
padding,
sceneScale,
settingsByScale: RENDER_SCENE_SCALE_SETTINGS
});
}
function fitPerspectiveCamera(camera, cameraSpec, bounds, width, height, sceneScale = RENDER_SCENE_SCALE.CAD) {
return fitSharedPerspectiveCamera(camera, cameraSpec, bounds, width, height, {
sceneScale,
settingsByScale: RENDER_SCENE_SCALE_SETTINGS,
strict: true
});
}
function lockedFrameHalfHeight(outputs, bounds, width, height, job, sceneScale = RENDER_SCENE_SCALE.CAD) {
return sharedLockedFrameHalfHeight(outputs, bounds, width, height, job, sceneScale, RENDER_SCENE_SCALE_SETTINGS);
}
function outputSize(output, job) {
return sharedOutputSize(output, job);
}
function configureRenderer(width, height, job, themeSettings) {
return configurePngRenderer(width, height, job, themeSettings, { defaultRenderScale: DEFAULT_RENDER_SCALE });
}
function shouldBurnInViewLabels(job = {}) {
return sharedShouldBurnInViewLabels(job);
}
function drawBurnedInLabel(context, label, width, height, {
corner = "top-left",
fill = "#111827",
background = "rgba(255, 255, 255, 0.9)",
border = "rgba(17, 24, 39, 0.42)"
} = {}) {
return drawSharedBurnedInLabel(context, label, width, height, {
corner,
fill,
background,
border
});
}
function rendererDataUrlWithOptionalLabel(renderer, label, job) {
return sharedRendererDataUrlWithOptionalLabel(renderer, label, job);
}
function tightFrameEnabled(job = {}) {
return normalizeBoolean(job.render?.tightFrame, normalizeBoolean(job.tightFrame, true));
}
export function projectedVisibleGeometryFrame(records, camera) {
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0).normalize();
const screenUp = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1).normalize();
const point = new THREE.Vector3();
const min = { x: Infinity, y: Infinity };
const max = { x: -Infinity, y: -Infinity };
let count = 0;
for (const record of Array.isArray(records) ? records : []) {
const mesh = record?.mesh;
const position = mesh?.geometry?.getAttribute?.("position");
if (!mesh?.visible || !position || position.count <= 0) {
continue;
}
mesh.updateWorldMatrix?.(true, false);
for (let index = 0; index < position.count; index += 1) {
point.fromBufferAttribute(position, index).applyMatrix4(mesh.matrixWorld);
const x = point.dot(right);
const y = point.dot(screenUp);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
continue;
}
min.x = Math.min(min.x, x);
min.y = Math.min(min.y, y);
max.x = Math.max(max.x, x);
max.y = Math.max(max.y, y);
count += 1;
}
}
if (!count || ![min.x, min.y, max.x, max.y].every(Number.isFinite)) {
return null;
}
return {
centerX: (min.x + max.x) / 2,
centerY: (min.y + max.y) / 2,
spanX: Math.max(max.x - min.x, 1e-6),
spanY: Math.max(max.y - min.y, 1e-6),
count
};
}
function applyTightOrthographicFrame(camera, records, width, height, padding, zoom = 1) {
camera.updateMatrixWorld(true);
const frame = projectedVisibleGeometryFrame(records, camera);
if (!frame) {
return null;
}
const aspect = Math.max(width / Math.max(height, 1), 0.01);
const safeContentScale = Math.max(1 - (padding * 2), 0.1);
const halfHeight = Math.max(
frame.spanY / (2 * safeContentScale),
frame.spanX / (2 * aspect * safeContentScale),
1e-6
) / Math.max(toFiniteNumber(zoom, 1), 1e-6);
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0).normalize();
const screenUp = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1).normalize();
const currentCenterX = camera.position.dot(right);
const currentCenterY = camera.position.dot(screenUp);
camera.position
.addScaledVector(right, frame.centerX - currentCenterX)
.addScaledVector(screenUp, frame.centerY - currentCenterY);
camera.top = halfHeight;
camera.bottom = -halfHeight;
camera.left = -halfHeight * aspect;
camera.right = halfHeight * aspect;
camera.updateProjectionMatrix();
camera.updateMatrixWorld(true);
return {
...frame,
halfHeight
};
}
function resolveSectionPlane(section = {}) {
const plane = String(section.plane || "XY").toUpperCase();
if (Array.isArray(section.normal) && section.normal.length >= 3) {
const normal = new THREE.Vector3(section.normal[0], section.normal[1], section.normal[2]).normalize();
const at = Array.isArray(section.at) && section.at.length >= 3
? new THREE.Vector3(section.at[0], section.at[1], section.at[2])
: new THREE.Vector3();
const helper = Math.abs(normal.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0);
const u = new THREE.Vector3().crossVectors(helper, normal).normalize();
const v = new THREE.Vector3().crossVectors(normal, u).normalize();
return { normal, at, u, v };
}
const offset = toFiniteNumber(section.offset, 0);
if (plane === "XZ") {
return {
normal: new THREE.Vector3(0, 1, 0),
at: new THREE.Vector3(0, offset, 0),
u: new THREE.Vector3(1, 0, 0),
v: new THREE.Vector3(0, 0, 1)
};
}
if (plane === "YZ") {
return {
normal: new THREE.Vector3(1, 0, 0),
at: new THREE.Vector3(offset, 0, 0),
u: new THREE.Vector3(0, 1, 0),
v: new THREE.Vector3(0, 0, 1)
};
}
return {
normal: new THREE.Vector3(0, 0, 1),
at: new THREE.Vector3(0, 0, offset),
u: new THREE.Vector3(1, 0, 0),
v: new THREE.Vector3(0, 1, 0)
};
}
function sectionSegments(meshData, section = {}) {
const vertices = meshData.vertices || new Float32Array(0);
const indices = meshData.indices || new Uint32Array(0);
const { normal, at, u, v } = resolveSectionPlane(section);
const point = new THREE.Vector3();
const tri = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
const segments = [];
const signedDistance = (candidate) => normal.dot(new THREE.Vector3().subVectors(candidate, at));
const project = (candidate) => {
const relative = new THREE.Vector3().subVectors(candidate, at);
return [relative.dot(u), relative.dot(v)];
};
for (let index = 0; index + 2 < indices.length; index += 3) {
for (let corner = 0; corner < 3; corner += 1) {
const vertexIndex = Number(indices[index + corner]) * 3;
tri[corner].set(vertices[vertexIndex], vertices[vertexIndex + 1], vertices[vertexIndex + 2]);
}
const distances = tri.map((corner) => signedDistance(corner));
const intersections = [];
for (const [a, b] of [[0, 1], [1, 2], [2, 0]]) {
const da = distances[a];
const db = distances[b];
if (Math.abs(da) < 1e-7) {
intersections.push(tri[a].clone());
}
if ((da < 0 && db > 0) || (da > 0 && db < 0)) {
const t = da / (da - db);
point.copy(tri[a]).lerp(tri[b], t);
intersections.push(point.clone());
}
}
if (intersections.length >= 2) {
segments.push([project(intersections[0]), project(intersections[1])]);
}
}
return segments;
}
function sectionBounds(segments) {
const xs = [];
const ys = [];
for (const segment of segments) {
for (const point of segment) {
xs.push(point[0]);
ys.push(point[1]);
}
}
if (!xs.length) {
return { minX: -1, minY: -1, maxX: 1, maxY: 1 };
}
return {
minX: Math.min(...xs),
minY: Math.min(...ys),
maxX: Math.max(...xs),
maxY: Math.max(...ys)
};
}
function sectionPlaneLabel(section = {}) {
const plane = String(section.plane || "XY").toUpperCase();
const offset = toFiniteNumber(section.offset, 0);
if (Array.isArray(section.normal) && section.normal.length >= 3) {
const normal = section.normal.map((value) => Number(value).toFixed(3)).join(", ");
const at = Array.isArray(section.at) && section.at.length >= 3
? section.at.map((value) => Number(value).toFixed(3)).join(", ")
: "0.000, 0.000, 0.000";
return `CUT N[${normal}] @ [${at}]`;
}
const axis = plane === "YZ" ? "X" : plane === "XZ" ? "Y" : "Z";
return `SECTION ${plane} @ ${axis}=${offset.toFixed(3)}`;
}
function segmentEndpointKey(point, precision = 1000) {
return `${Math.round(point[0] * precision)}:${Math.round(point[1] * precision)}`;
}
function loopsFromSegments(segments) {
const edges = segments.map((segment, index) => ({
index,
a: segment[0],
b: segment[1],
aKey: segmentEndpointKey(segment[0]),
bKey: segmentEndpointKey(segment[1])
}));
const byKey = new Map();
for (const edge of edges) {
for (const key of [edge.aKey, edge.bKey]) {
if (!byKey.has(key)) {
byKey.set(key, []);
}
byKey.get(key).push(edge);
}
}
const used = new Set();
const loops = [];
for (const edge of edges) {
if (used.has(edge.index)) {
continue;
}
used.add(edge.index);
const startKey = edge.aKey;
let currentKey = edge.bKey;
const points = [edge.a, edge.b];
for (let guard = 0; guard < edges.length; guard += 1) {
if (currentKey === startKey) {
break;
}
const next = (byKey.get(currentKey) || []).find((candidate) => !used.has(candidate.index));
if (!next) {
break;
}
used.add(next.index);
const nextPoint = next.aKey === currentKey ? next.b : next.a;
currentKey = next.aKey === currentKey ? next.bKey : next.aKey;
points.push(nextPoint);
}
if (points.length >= 3 && currentKey === startKey) {
loops.push(points);
}
}
return loops;
}
function sectionTransform(segments, width, height, paddingRatio = 0.12) {
const { minX, minY, maxX, maxY } = sectionBounds(segments);
const spanX = Math.max(maxX - minX, 1);
const spanY = Math.max(maxY - minY, 1);
const padding = Math.max(20, Math.min(width, height) * paddingRatio);
const scale = Math.min((width - padding * 2) / spanX, (height - padding * 2) / spanY);
const ox = (width - spanX * scale) / 2 - minX * scale;
const oy = (height + spanY * scale) / 2 + minY * scale;
return { minX, minY, maxX, maxY, spanX, spanY, scale, ox, oy };
}
function traceSectionLoops(context, loops, transform) {
for (const loop of loops) {
if (!loop.length) {
continue;
}
context.moveTo(loop[0][0] * transform.scale + transform.ox, transform.oy - loop[0][1] * transform.scale);
for (let index = 1; index < loop.length; index += 1) {
context.lineTo(loop[index][0] * transform.scale + transform.ox, transform.oy - loop[index][1] * transform.scale);
}
context.closePath();
}
}
function drawSectionHatching(context, width, height) {
context.save();
context.strokeStyle = "rgba(17, 24, 39, 0.2)";
context.lineWidth = 1;
const spacing = 14;
for (let offset = -height; offset < width + height; offset += spacing) {
context.beginPath();
context.moveTo(offset, height);
context.lineTo(offset + height, 0);
context.stroke();
}
context.restore();
}
function drawSectionCenterlines(context, transform, width, height) {
const centerX = ((transform.minX + transform.maxX) / 2) * transform.scale + transform.ox;
const centerY = transform.oy - ((transform.minY + transform.maxY) / 2) * transform.scale;
context.save();
context.strokeStyle = "rgba(239, 68, 68, 0.75)";
context.lineWidth = 1.5;
context.setLineDash([10, 8, 2, 8]);
context.beginPath();
context.moveTo(Math.max(0, centerX), 0);
context.lineTo(Math.max(0, centerX), height);
context.moveTo(0, Math.max(0, centerY));
context.lineTo(width, Math.max(0, centerY));
context.stroke();
context.restore();
}
function drawSectionLocator(context, section, bounds, width, height) {
const plane = resolveSectionPlane(section);
const corners = boundsCorners(bounds);
const values = corners.map((corner) => corner.dot(plane.normal));
const min = Math.min(...values);
const max = Math.max(...values);
const atValue = plane.at.dot(plane.normal);
const locatorWidth = Math.max(170, Math.min(width * 0.24, 260));
const locatorHeight = Math.max(78, Math.min(height * 0.16, 130));
const margin = Math.max(18, Math.round(Math.min(width, height) * 0.024));
const x = width - margin - locatorWidth;
const y = height - margin - locatorHeight;
const pad = 16;
const trackX = x + pad;
const trackY = y + locatorHeight / 2;
const trackWidth = locatorWidth - pad * 2;
const t = clamp((atValue - min) / Math.max(max - min, 1e-9), 0, 1);
const cutX = trackX + t * trackWidth;
context.save();
context.fillStyle = "rgba(255, 255, 255, 0.92)";
context.strokeStyle = "rgba(17, 24, 39, 0.42)";
context.lineWidth = 1;
context.beginPath();
context.roundRect(x, y, locatorWidth, locatorHeight, 8);
context.fill();
context.stroke();
context.fillStyle = "#111827";
context.font = "700 13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
context.fillText("CUT LOCATOR", x + pad, y + 10);
context.strokeStyle = "#9ca3af";
context.lineWidth = 8;
context.lineCap = "round";
context.beginPath();
context.moveTo(trackX, trackY);
context.lineTo(trackX + trackWidth, trackY);
context.stroke();
context.strokeStyle = "#ef4444";
context.lineWidth = 3;
context.beginPath();
context.moveTo(cutX, trackY - 22);
context.lineTo(cutX, trackY + 22);
context.stroke();
context.font = "600 12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
context.fillStyle = "#ef4444";
context.fillText(sectionPlaneLabel(section).replace(/^SECTION\s+/, ""), x + pad, y + locatorHeight - 22);
context.restore();
}
function renderSectionSvg(segments, edgeColor = "#132232") {
const { minX, minY, maxX, maxY } = sectionBounds(segments);
const padding = 4;
const viewBox = [
minX - padding,
minY - padding,
Math.max(maxX - minX + padding * 2, 1),
Math.max(maxY - minY + padding * 2, 1)
].map((value) => Number(value).toFixed(4)).join(" ");
const lines = segments.map((segment) => (
`<path d="M ${segment[0][0].toFixed(4)} ${segment[0][1].toFixed(4)} L ${segment[1][0].toFixed(4)} ${segment[1][1].toFixed(4)}"/>`
)).join("");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="none" stroke="${edgeColor}" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round">${lines}</svg>`;
}
function renderSectionPng(segments, width, height, themeSettings, {
edgeSettings = null,
transparent = false,
section = {},
bounds = null,
viewLabels = false
} = {}) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
const background = themeSettings.background || {};
if (!transparent) {
context.fillStyle = background.solidColor || "#ffffff";
context.fillRect(0, 0, width, height);
}
const transform = sectionTransform(segments, width, height);
const loops = loopsFromSegments(segments);
if (loops.length) {
context.save();
context.beginPath();
traceSectionLoops(context, loops, transform);
context.fillStyle = "rgba(209, 213, 219, 0.72)";
context.fill("evenodd");
context.clip("evenodd");
drawSectionHatching(context, width, height);
context.restore();
}
drawSectionCenterlines(context, transform, width, height);
context.strokeStyle = edgeSettings?.color || "#132232";
context.lineWidth = 3;
context.lineCap = "round";
context.lineJoin = "round";
for (const segment of segments) {
context.beginPath();
context.moveTo(segment[0][0] * transform.scale + transform.ox, transform.oy - segment[0][1] * transform.scale);
context.lineTo(segment[1][0] * transform.scale + transform.ox, transform.oy - segment[1][1] * transform.scale);
context.stroke();
}
if (bounds) {
drawSectionLocator(context, section, bounds, width, height);
}
if (viewLabels) {
drawBurnedInLabel(context, sectionPlaneLabel(section), width, height);
}
return canvas.toDataURL("image/png");
}
export function listRenderableParts(meshData) {
return toArray(meshData.parts).map((part, index) => ({
id: String(part?.id || part?.occurrenceId || `part:${index}`),
occurrenceId: String(part?.occurrenceId || part?.id || ""),
name: String(part?.name || part?.label || part?.id || `Part ${index + 1}`),
label: String(part?.label || part?.name || part?.id || `Part ${index + 1}`),
triangleCount: Math.max(0, Math.floor(toFiniteNumber(part?.triangleCount, 0))),
vertexCount: Math.max(0, Math.floor(toFiniteNumber(part?.vertexCount, 0))),
bounds: part?.bounds || null
}));
}
export function renderJobContext(meshData, job = {}) {
const mode = String(job.mode || "view").trim().toLowerCase();
const theme = resolveAppearance(job);
const sceneScale = resolveRenderSceneScale(job, meshData);
const sourceKind = String(job.resolved?.kind || job.kind || meshData?.sourceFormat || "").trim().toLowerCase();
const stepDisplayEnabled = sourceKind === "step" || sourceKind === "stp";
const displaySettings = stepDisplayEnabled
? normalizeDisplaySettings(job.display)
: normalizeDisplaySettings({ projection: CAMERA_PROJECTION.PERSPECTIVE });
const displayMode = displaySettings.mode;
const bounds = meshData.bounds || boundsFromVertices(meshData.vertices || []);
const outputs = toArray(job.outputs).length ? toArray(job.outputs) : [{ path: job.output || "", camera: job.camera || "iso" }];
const warnings = [];
const sharedRenderOptions = createSharedRenderOptions({
themeSettings: theme,
display: displaySettings,
sceneScale,
clip: displaySettings.clip,
selection: job.selection || null,
floor: theme.floor || null,
background: theme.background || null,
lighting: theme.lighting || null,
renderScale: job.render?.renderScale ?? job.renderScale ?? DEFAULT_RENDER_SCALE
});
const baseEdgeSettings = resolveDisplayEdgeSettings(displaySettings);
const edgeSettings = {
...baseEdgeSettings,
enabled: displayModeForcesEdges(displayMode) ? true : baseEdgeSettings.enabled,
depthTest: displayModeShowsThroughEdges(displayMode) ? false : baseEdgeSettings.depthTest
};
const wireframeMode = displayModeIsWireframe(displayMode);
const edgesVisible = stepDisplayEnabled && displayModeShowsEdges(displayMode, edgeSettings);
const selectorRuntime = job.stepParameters?.selectorRuntime || job.selectorRuntime || null;
const displayEdgeRuntime = job.stepParameters?.displayEdgeRuntime || job.displayEdgeRuntime || null;
const topologyDisplayEdgesVisible = shouldRenderTopologyDisplayEdges({
edgesVisible,
wireframeMode,
cadEdgeSource: stepDisplayEnabled,
displayEdgeRuntime,
selectorRuntime,
edgeSettings
});
const sceneTheme = topologyDisplayEdgesVisible
? {
...theme,
edges: {
...edgeSettings,
enabled: false
}
}
: {
...theme,
edges: edgesVisible
? edgeSettings
: {
...edgeSettings,
enabled: false
}
};
return {
mode,
theme,
sceneTheme,
sceneScale,
sourceKind,
stepDisplayEnabled,
displaySettings,
displayMode,
wireframeMode,
edgesVisible,
bounds,
outputs,
warnings,
sharedRenderOptions,
edgeSettings,
selectorRuntime,
displayEdgeRuntime,
topologyDisplayEdgesVisible
};
}
export function modelOptionsForRenderJob(context, job = {}) {
const selection = job.selection || {};
const filterSelection = context.mode === "view" || context.mode === "orbit"
? {
hide: selection.hide
}
: selection;
return {
theme: context.sceneTheme,
displayMode: context.displayMode,
applyDisplayModeEdgePolicy: !context.topologyDisplayEdgesVisible,
scale: context.sceneScale,
clip: context.sharedRenderOptions.clip,
silhouette: context.topologyDisplayEdgesVisible && context.edgeSettings.silhouette === true,
renderPartsIndividually: true,
selection: {
...(job.selection || {}),
showEdges: context.edgesVisible
},
edgeRendering: {
mode: "screen-space",
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
LineMaterial
},
callbacks: {
onWarning: (warning) => {
const message = String(warning?.message || warning?.title || "").trim();
if (message) {
context.warnings.push(message);
}
}
},
filterSelection
};
}
export function renderModel(_THREE, model, viewportOptions = {}) {
if (!model?.root) {
throw new Error("renderModel requires a model returned by buildModel");
}
const job = viewportOptions.job || {};
const context = viewportOptions.context || renderJobContext(model.meshData, job);
const sceneBuildStarted = performance.now();
const firstSize = outputSize(context.outputs[0] || {}, job);
const renderer = configureRenderer(firstSize.width, firstSize.height, job, context.theme);
const scene = new THREE.Scene();
if (normalizeBoolean(job.render?.transparent, false) || context.theme.background?.type === "transparent") {
scene.background = null;
renderer.setClearColor(new THREE.Color("#000000"), 0);
} else {
scene.background = colorTextureFromBackground(context.theme.background || {}, firstSize.width, firstSize.height);
}
const ready = applyEnvironment(scene, context.theme, context.warnings);
applyLighting(scene, context.theme);
Object.assign(model.runtime, {
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
LineMaterial
});
scene.add(model.root);
addFloor(scene, model.bounds || context.bounds, context.theme, context.sceneScale);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.001, 10000);
const perspectiveCamera = new THREE.PerspectiveCamera(48, firstSize.width / Math.max(firstSize.height, 1), 0.1, 50000);
return {
THREE: _THREE || THREE,
model,
scene,
renderer,
orthographicCamera,
perspectiveCamera,
context,
sceneBuildStarted,
ready,
dispose() {
model.dispose?.();
renderer.dispose?.();
}
};
}
function displayRecordPartIds(displayRecords = []) {
return Array.from(new Set(
toArray(displayRecords)
.map((record) => String(record?.partId || "").trim())
.filter((partId) => partId && partId !== "__model__")
));
}
function applyViewportExplodedView(viewport, bounds) {
const { context, model, THREE: RuntimeTHREE } = viewport;
const settings = context.displaySettings?.exploded;
const THREEImpl = RuntimeTHREE || THREE;
const resetExplodedView = () => {
clearExplodedViewRecords(model.displayRecords);
for (const record of model.displayRecords) {
applyDisplayRecordTransform(THREEImpl, record);
}
model.root?.updateMatrixWorld?.(true);
};
if (settings?.enabled !== true) {
resetExplodedView();
return bounds;
}
const states = createExplodedViewRecordStates(
THREEImpl,
model.displayRecords,
bounds,
settings
);
if (!states.length) {
resetExplodedView();
return bounds;
}
applyExplodedViewProgress(THREEImpl, states, 1);
for (const record of model.displayRecords) {
applyDisplayRecordTransform(THREEImpl, record);
}
model.root?.updateMatrixWorld?.(true);
return settings.autoFrame === false
? bounds
: explodedViewBoundsFromStates(THREEImpl, states, bounds, 1);
}
function syncViewportTopologyDisplayEdges(viewport) {
const { context, model } = viewport;
const renderedPartIds = displayRecordPartIds(model.displayRecords);
const baseEdgeRuntimes = resolveTopologyDisplayEdgeRuntimes({
selectorRuntime: context.selectorRuntime,
displayEdgeRuntime: context.displayEdgeRuntime,
displayRecords: model.displayRecords,
transformDisplayEdges: false
});
const transformByRecord = shouldUseRecordTopologyEdgeTransforms({
transformDetected: baseEdgeRuntimes.transformCount > 0,
topologyDisplayEdgesVisible: context.topologyDisplayEdgesVisible,
displayEdgeRuntime: context.displayEdgeRuntime,
displayRecords: model.displayRecords
});
const edgeRuntimes = transformByRecord
? baseEdgeRuntimes
: resolveTopologyDisplayEdgeRuntimes({
selectorRuntime: context.selectorRuntime,
displayEdgeRuntime: context.displayEdgeRuntime,
displayRecords: model.displayRecords
});
syncTopologyDisplayEdgeLine(
model.runtime,
transformByRecord ? context.displayEdgeRuntime : edgeRuntimes.topologyRuntime,
{
visible: context.topologyDisplayEdgesVisible,
edgeSettings: renderedPartIds.length
? {
...context.edgeSettings,
includePartIds: renderedPartIds
}
: context.edgeSettings,
viewerTheme: model.runtime?.baseTheme,
transformByRecord,
displayRecords: model.displayRecords
}
);
}
export async function captureModel(viewport, captureOptions = {}) {
const job = captureOptions.job || {};
const context = viewport.context || renderJobContext(viewport.model.meshData, job);
const meshData = viewport.model.meshData;
const {
mode,
theme,
sceneScale,
bounds,
outputs,
warnings,
edgeSettings
} = context;
const modelBounds = viewport.model?.bounds || meshData.bounds || bounds;
if (mode === "list") {
return {
ok: true,
mode,
parts: listRenderableParts(meshData),
bounds: modelBounds,
warnings
};
}
if (mode === "section") {
const section = job.section || {};
const segments = sectionSegments(meshData, section);
return {
ok: true,
mode,
outputs: outputs.map((output) => {
const { width, height } = outputSize(output, job);
const format = String(output.format || job.section?.format || "").toLowerCase() || (
String(output.path || "").toLowerCase().endsWith(".svg") ? "svg" : "png"
);
if (format === "svg") {
return {
path: String(output.path || ""),
mimeType: "image/svg+xml",
text: renderSectionSvg(segments, edgeSettings.color)
};
}
return {
path: String(output.path || ""),
width,
height,
mimeType: "image/png",
dataUrl: renderSectionPng(segments, width, height, theme, {
edgeSettings,
transparent: normalizeBoolean(job.render?.transparent, false),
section,
bounds: modelBounds,
viewLabels: shouldBurnInViewLabels(job)
})
};
}),
section: {
segmentCount: segments.length
},
warnings
};
}
await viewport.ready;
const sceneBuildMs = performance.now() - viewport.sceneBuildStarted;
const lockFraming = normalizeBoolean(job.render?.lockFraming, normalizeBoolean(job.lockFraming, false));
const padding = framePadding(job);
const boundsByOutput = new Map();
const parametersForOutput = (output) => (
output.stepParameters ||
job.stepParameters ||
null
);
for (const output of outputs) {
const parameters = parametersForOutput(output);
const effectiveBounds = parameters
? viewport.model.update({ stepParameters: parameters }).bounds
: viewport.model.update({ stepParameters: null }).bounds;
boundsByOutput.set(output, applyViewportExplodedView(viewport, effectiveBounds));
}
const lockedBounds = lockFraming
? mergeBoundsList(outputs.map((output) => boundsByOutput.get(output))) || bounds
: null;
const firstSize = outputSize(outputs[0] || {}, job);
const lockedHalfHeight = lockFraming ? lockedFrameHalfHeight(outputs, lockedBounds, firstSize.width, firstSize.height, job, sceneScale) : null;
const renderedOutputs = [];
const renderStarted = performance.now();
for (const output of outputs) {
const parameters = parametersForOutput(output);
const { width, height } = outputSize(output, job);
viewport.renderer.setSize(width, height, false);
const baseOutputBounds = parameters
? viewport.model.update({ stepParameters: parameters }).bounds
: viewport.model.update({ stepParameters: null }).bounds;
const outputBounds = applyViewportExplodedView(viewport, baseOutputBounds);
syncViewportTopologyDisplayEdges(viewport);
syncScreenSpaceLineMaterialResolution(viewport.model.runtime.screenSpaceLineMaterials, width, height);
const cameraSpec = output.camera || job.camera || "iso";
const displayProjection = normalizeCameraProjection(
context.displaySettings?.projection,
CAMERA_PROJECTION.ORTHOGRAPHIC
);
const usePerspectiveCamera = displayProjection === CAMERA_PROJECTION.PERSPECTIVE ||
cameraSpecUsesPerspectiveProjection(cameraSpec, {
presets: RENDER_VIEW_PRESETS,
strict: true
});
const cameraView = usePerspectiveCamera ? null : resolveView(cameraSpec);
const resolvedCamera = usePerspectiveCamera
? fitPerspectiveCamera(viewport.perspectiveCamera, cameraSpec, lockedBounds || outputBounds, width, height, sceneScale)
: fitCamera(viewport.orthographicCamera, cameraView, lockedBounds || outputBounds, width, height, lockedHalfHeight, padding, sceneScale);
const renderCamera = usePerspectiveCamera ? viewport.perspectiveCamera : viewport.orthographicCamera;
if (!usePerspectiveCamera && !lockFraming && tightFrameEnabled(job)) {
viewport.scene.updateMatrixWorld(true);
applyTightOrthographicFrame(renderCamera, viewport.model.displayRecords, width, height, padding, cameraView?.zoom);
}
viewport.renderer.render(viewport.scene, renderCamera);
const viewLabel = String(output.viewLabel || output.label || resolvedCamera.name || "").toUpperCase();
renderedOutputs.push({
path: String(output.path || ""),
camera: resolvedCamera.name,
width,
height,
mimeType: "image/png",
dataUrl: rendererDataUrlWithOptionalLabel(viewport.renderer, viewLabel, job)
});
}
const renderMs = performance.now() - renderStarted;
return {
ok: true,
mode,
outputs: renderedOutputs,
timings: {
sceneBuildMs,
renderMs,
meshCount: viewport.model.displayRecords.length || listRenderableParts(meshData).length || 1
},
warnings
};
}
export async function renderMeshJob(meshData, job = {}) {
const context = renderJobContext(meshData, job);
const model = buildModel(THREE, meshData, modelOptionsForRenderJob(context, job));
if (context.mode === "list" || context.mode === "section") {
try {
return await captureModel({ model, context }, { job });
} finally {
model.dispose();
}
}
const viewport = renderModel(THREE, model, { job, context });
try {
return await captureModel(viewport, { job });
} finally {
viewport.dispose();
}
}