mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
2366 lines
86 KiB
TypeScript
2366 lines
86 KiB
TypeScript
import { Engine } from "@babylonjs/core/Engines/engine.js";
|
|
import { Scene } from "@babylonjs/core/scene.js";
|
|
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera.js";
|
|
import { HemisphericLight } from "@babylonjs/core/Lights/hemisphericLight.js";
|
|
import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight.js";
|
|
import { PointLight } from "@babylonjs/core/Lights/pointLight.js";
|
|
import { SpotLight } from "@babylonjs/core/Lights/spotLight.js";
|
|
import { Vector3, Matrix } from "@babylonjs/core/Maths/math.vector.js";
|
|
import { Color3, Color4 } from "@babylonjs/core/Maths/math.color.js";
|
|
import { Mesh } from "@babylonjs/core/Meshes/mesh.js";
|
|
import { LinesMesh } from "@babylonjs/core/Meshes/linesMesh.js";
|
|
import { TransformNode } from "@babylonjs/core/Meshes/transformNode.js";
|
|
import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder.js";
|
|
import { VertexBuffer } from "@babylonjs/core/Buffers/buffer.js";
|
|
import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial.js";
|
|
import { DynamicTexture } from "@babylonjs/core/Materials/Textures/dynamicTexture.js";
|
|
import { Ray } from "@babylonjs/core/Culling/ray.js";
|
|
import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator.js";
|
|
import "@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent.js";
|
|
import { AutoRotationBehavior } from "@babylonjs/core/Behaviors/Cameras/autoRotationBehavior.js";
|
|
import { ImportMeshAsync } from "@babylonjs/core/Loading/sceneLoader.js";
|
|
import type { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh.js";
|
|
import type { Light } from "@babylonjs/core/Lights/light.js";
|
|
import type { IShadowLight } from "@babylonjs/core/Lights/shadowLight.js";
|
|
import type {
|
|
ModelPreviewSummary,
|
|
ModelEvidence,
|
|
ModelPartSummary,
|
|
CameraConfig,
|
|
LightConfig,
|
|
SceneConfig,
|
|
ThreeDBlockConfig,
|
|
} from "../../domain/models";
|
|
import "./loaders/register";
|
|
import { ensureLoadersRegistered } from "./loaders/register";
|
|
import { loadSTLBuffer } from "./loaders/stl-loader";
|
|
import { loadPLYBuffer } from "./loaders/ply-loader";
|
|
import { setExplode, resetExplode } from "./explode";
|
|
import { setupPicking, type PickingCleanup } from "./picking";
|
|
import { arrayBufferToBase64 } from "../../utils/base64";
|
|
import { isMobile } from "../../utils/device";
|
|
import { getPortableBasename, getPortableDirname, getPortableStem, joinPortablePath } from "../../utils/resolve-path";
|
|
import { OrientationGizmo } from "./orientation-gizmo";
|
|
import { createBabylonDisassemblyController } from "./disassembly";
|
|
import {
|
|
collectBabylonGltfComponentMetadata,
|
|
createBabylonGroupedPartCandidates,
|
|
createBabylonMeshInfoBreakdown,
|
|
createBabylonModelPreviewSummary,
|
|
createBabylonNodePartPreviewSummary,
|
|
createBabylonPartPreviewSummary,
|
|
findBabylonSelectablePartNode,
|
|
getBabylonMeshesPreviewBounds,
|
|
getBabylonNodeDisplayName,
|
|
type BabylonSelectablePartNode,
|
|
getBabylonRenderableMeshes,
|
|
getBabylonRenderablePreviewBounds,
|
|
getBabylonVertexCount,
|
|
} from "./mesh-preview";
|
|
import {
|
|
createPreviewBounds,
|
|
getPreviewBoundsCenter,
|
|
getPreviewBoundsMaxSpan,
|
|
getPreviewBoundsRadius,
|
|
getPreviewBoundsSize,
|
|
type PreviewBounds,
|
|
} from "../preview/bounds";
|
|
import { createPreviewOrbitCameraFit } from "../preview/camera-fit";
|
|
import type { PreviewDisassemblyController } from "../preview/disassembly";
|
|
import {
|
|
createAnnotationViewportProvider,
|
|
formatAnnotationCameraStateKey,
|
|
projectViewportPointToCanvas,
|
|
} from "../preview/annotation-projection";
|
|
import { createPreviewEvidence } from "../preview/evidence";
|
|
import {
|
|
createPreviewLineOfSight,
|
|
isPreviewHitOccluded,
|
|
toPreviewWorldPoint,
|
|
} from "../preview/geometry";
|
|
import {
|
|
createPreviewModelInfoMarkdown,
|
|
createPreviewPartInfoMarkdown,
|
|
} from "../preview/report";
|
|
import type {
|
|
AnnotationViewportProvider,
|
|
PreviewAxis,
|
|
PreviewPickResult,
|
|
PreviewProjectionResult,
|
|
PreviewWorldPoint,
|
|
MeasurementScale,
|
|
MeasurementSnapKind,
|
|
MeasurementUnit,
|
|
MeasurementState,
|
|
CameraZoomState,
|
|
WorkbenchPreview,
|
|
} from "../preview/types";
|
|
import {
|
|
isPreviewLoadInterruptedError,
|
|
throwIfPreviewLoadInterrupted,
|
|
type PreviewLoadOptions,
|
|
} from "../preview/load-control";
|
|
import {
|
|
createMeasurementLabel,
|
|
createMeasurementDraftingLayout,
|
|
createMeasurementGeometryEdgesFromTriangles,
|
|
createMeasurementMarkdown,
|
|
createMeasurementReading as buildMeasurementReading,
|
|
createMeasurementState,
|
|
createMeasurementTrianglesFromIndices,
|
|
drawMeasurementLabelCanvas,
|
|
MEASUREMENT_LABEL_CANVAS,
|
|
normalizeMeasurementUnit,
|
|
sanitizeMeasurementScale,
|
|
scaleMeasurementPointFromBase,
|
|
setMeasurementCanvasActive,
|
|
snapMeasurementPointToGeometry,
|
|
unscaleMeasurementPointToBase,
|
|
type MeasurementGeometrySnapInput,
|
|
type MeasurementSnapEdgeCandidate,
|
|
type MeasurementSnapVertexCandidate,
|
|
type MeasurementReading,
|
|
type MeasurementRecord,
|
|
} from "../preview/measurement";
|
|
|
|
/** Guard against concurrent OBJ loads monkey-patching the same prototype. */
|
|
let objMtlLock: Promise<void> | null = null;
|
|
const OBJ_IMAGE_EXTS = ["jpg", "jpeg", "png", "bmp", "tga", "webp", "tif", "tiff"];
|
|
const OBJ_TEXTURE_RE = /^\s*(map_Kd|map_Ka|map_Ks|map_Ns|map_d|map_bump|bump|disp|decal)\s+(.+)/i;
|
|
const FOCUS_DIM_VISIBILITY = 0.242;
|
|
const FOCUS_WORLD_POINT_ANIMATION_MS = 320;
|
|
const MEASUREMENT_LINE_COLOR = new Color3(0.97, 0.98, 0.99);
|
|
const MEASUREMENT_MARKER_COLOR = new Color3(0.97, 0.98, 0.99);
|
|
const MEASUREMENT_PENDING_COLOR = new Color3(0.96, 0.62, 0.04);
|
|
const MEASUREMENT_HOVER_COLOR = new Color3(1, 1, 1);
|
|
const MEASUREMENT_PREVIEW_COLOR = new Color3(0.9, 0.91, 0.92);
|
|
|
|
type BabylonMeasurementSegment = { start: Vector3; end: Vector3; line: LinesMesh; label: Mesh };
|
|
|
|
function isShadowLight(light: Light): light is IShadowLight {
|
|
const className = light.getClassName();
|
|
return className === "DirectionalLight" || className === "PointLight" || className === "SpotLight";
|
|
}
|
|
|
|
function isGaussianSplattingMesh(mesh: AbstractMesh): boolean {
|
|
return mesh.getClassName() === "GaussianSplattingMesh";
|
|
}
|
|
|
|
function isBabylonMesh(value: unknown): value is AbstractMesh {
|
|
return !!value && typeof value === "object" && "getBoundingInfo" in value;
|
|
}
|
|
|
|
function isBabylonSelectablePartNode(value: unknown): value is BabylonSelectablePartNode {
|
|
return value instanceof TransformNode;
|
|
}
|
|
|
|
function isBabylonNodeDisposed(node: BabylonSelectablePartNode): boolean {
|
|
return typeof node.isDisposed === "function" ? node.isDisposed() : false;
|
|
}
|
|
|
|
function isBabylonNodeOrDescendant(candidate: AbstractMesh, target: BabylonSelectablePartNode): boolean {
|
|
let current: TransformNode | null = candidate;
|
|
while (current) {
|
|
if (current === target) return true;
|
|
current = current.parent as TransformNode | null;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function disposeBabylonLoadedNodes(
|
|
meshes: readonly AbstractMesh[],
|
|
transformNodes: readonly TransformNode[] = [],
|
|
): void {
|
|
for (const mesh of meshes) {
|
|
if (!mesh.isDisposed()) {
|
|
mesh.dispose(true, true);
|
|
}
|
|
}
|
|
for (const node of transformNodes) {
|
|
if (!node.isDisposed()) {
|
|
node.dispose(false, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
function toBabylonVector3(value: { x: number; y: number; z: number }): Vector3 {
|
|
return new Vector3(value.x, value.y, value.z);
|
|
}
|
|
|
|
function clampUnit(value: number): number {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.max(0, Math.min(value, 1));
|
|
}
|
|
|
|
function createMeasurementMarkerMaterial(scene: Scene): StandardMaterial {
|
|
const mat = new StandardMaterial("measure-marker-mat", scene);
|
|
mat.diffuseColor = MEASUREMENT_MARKER_COLOR.clone();
|
|
mat.emissiveColor = MEASUREMENT_MARKER_COLOR.clone();
|
|
mat.specularColor = new Color3(0, 0, 0);
|
|
mat.disableLighting = true;
|
|
mat.alpha = 0.48;
|
|
return mat;
|
|
}
|
|
|
|
function setMeasurementMarkerColor(marker: Mesh, color: Color3): void {
|
|
const mat = marker.material as StandardMaterial | null;
|
|
if (!mat) return;
|
|
mat.diffuseColor = color.clone();
|
|
mat.emissiveColor = color.clone();
|
|
}
|
|
|
|
function firstMtlPath(value: string): string {
|
|
const trimmed = value.replace(/\s+#.*$/, "").trim();
|
|
if (trimmed.startsWith("\"")) {
|
|
const end = trimmed.indexOf("\"", 1);
|
|
if (end > 1) return trimmed.slice(1, end);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function firstTexturePath(value: string): string {
|
|
const tokens = value.trim().split(/\s+/);
|
|
const pathStart = tokens.findIndex((token) => !token.startsWith("-") && !/^[-+]?\d*\.?\d+$/.test(token));
|
|
return tokens.slice(Math.max(0, pathStart)).join(" ").replace(/^"|"$/g, "");
|
|
}
|
|
|
|
function guessTextureMime(path: string): string {
|
|
const ext = path.split(".").pop()?.toLowerCase() ?? "png";
|
|
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
|
|
if (ext === "png") return "image/png";
|
|
if (ext === "bmp") return "image/bmp";
|
|
if (ext === "tga") return "image/x-tga";
|
|
if (ext === "webp") return "image/webp";
|
|
return `image/${ext}`;
|
|
}
|
|
|
|
function buildObjTextureCandidates(modelDir: string, rawPath: string, modelPath: string): string[] {
|
|
const texFilename = getPortableBasename(rawPath);
|
|
const texBase = texFilename.replace(/\.[^.]+$/, "");
|
|
const objBasename = getPortableStem(modelPath);
|
|
const candidates = [
|
|
joinPortablePath(modelDir, rawPath),
|
|
joinPortablePath(modelDir, texFilename),
|
|
];
|
|
if (objBasename) {
|
|
for (const ext of OBJ_IMAGE_EXTS) {
|
|
candidates.push(joinPortablePath(modelDir, `${objBasename}.${ext}`));
|
|
}
|
|
}
|
|
for (const ext of OBJ_IMAGE_EXTS) {
|
|
const alt = `${texBase}.${ext}`;
|
|
if (alt !== texFilename) {
|
|
candidates.push(joinPortablePath(modelDir, alt));
|
|
}
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
// TODO(P2): split this class into loader/camera/light/annotation helpers.
|
|
// Scene class is >1,700 lines and mixes rendering, interaction, and fallback logic (debt: renderer-babylon).
|
|
export class BabylonModelPreview implements WorkbenchPreview {
|
|
private static readonly annotationIdentity = Matrix.Identity();
|
|
private static readonly annotationWorldPoint = Vector3.Zero();
|
|
private static readonly annotationProjection = Vector3.Zero();
|
|
private static readonly annotationDirection = Vector3.Zero();
|
|
private static readonly annotationRay = new Ray(Vector3.Zero(), Vector3.Zero(), 1);
|
|
private engine: Engine;
|
|
private scene: Scene;
|
|
private camera: ArcRotateCamera;
|
|
private rootMesh: Mesh | null = null;
|
|
private loadedMeshes: AbstractMesh[] = [];
|
|
private loadedTransformNodes: TransformNode[] = [];
|
|
private cachedRenderableMeshes: AbstractMesh[] | null = null;
|
|
private cachedRenderableRoot: Mesh | null = null;
|
|
private loadedExt: string = "";
|
|
private rendering = false;
|
|
private contextLost = false;
|
|
private viewportVisible = true;
|
|
private viewportObserver: IntersectionObserver | null = null;
|
|
private cleanupPicking: PickingCleanup | null = null;
|
|
private resizeObs: ResizeObserver;
|
|
private configLights: Light[] = [];
|
|
private shadowGenerator: ShadowGenerator | null = null;
|
|
private groundMesh: Mesh | null = null;
|
|
private gridMesh: Mesh | null = null;
|
|
private axisMeshes: Mesh[] = [];
|
|
private autoRotateBehavior: AutoRotationBehavior | null = null;
|
|
private wireframeEnabled = false;
|
|
private gizmo: OrientationGizmo | null = null;
|
|
private gizmoEnabled = false;
|
|
private disassembly: PreviewDisassemblyController | null = null;
|
|
private focusSelectionEnabled = false;
|
|
private focusedNode: BabylonSelectablePartNode | null = null;
|
|
private readonly originalMeshVisibility = new Map<number, number>();
|
|
private bboxMesh: Mesh | null = null;
|
|
private bboxEnabled = false;
|
|
private currentQuality: "low" | "medium" | "high" = "high";
|
|
private renderScale = 1;
|
|
private resourceWarnings: string[] = [];
|
|
private gltfComponentMetadata = new Map<string, unknown>();
|
|
private animPlaying = false;
|
|
private initialCamera = { alpha: Math.PI / 4, beta: Math.PI / 3, radius: 5, target: Vector3.Zero() };
|
|
private focusWorldPointFrame = 0;
|
|
private _lastPickResult: PreviewPickResult = { mesh: null, pickedPoint: null, screenX: 0, screenY: 0 };
|
|
private _onPickCallbacks: Array<(result: PreviewPickResult) => void> = [];
|
|
private measurementActive = false;
|
|
private measurementScale: MeasurementScale = { x: 1, y: 1, z: 1 };
|
|
private measurementBaseRootScaling = new Vector3(1, 1, 1);
|
|
private measurementBaseBounds: PreviewBounds | null = null;
|
|
private measurementUnit: MeasurementUnit = "mm";
|
|
private measurementSegments: BabylonMeasurementSegment[] = [];
|
|
private measurementMarkers: Mesh[] = [];
|
|
private measurementMarkerPoints: Vector3[] = [];
|
|
private measurementTargetNode: BabylonSelectablePartNode | null = null;
|
|
private measurementTargetMeshes: AbstractMesh[] = [];
|
|
private measurementSnapInputCache: MeasurementGeometrySnapInput | null = null;
|
|
private measurementSnapInputCacheTargetId: number | null = null;
|
|
private measurementSnapInputCacheSignature: string | null = null;
|
|
private measurementSnapKind: MeasurementSnapKind | null = null;
|
|
private readonly measurementObservers = new Set<() => void>();
|
|
private pendingPoint: Vector3 | null = null;
|
|
private pendingMarker: Mesh | null = null;
|
|
private hoveredMarkerIndex = -1;
|
|
private lastPointerClient = { x: 0, y: 0, altKey: false };
|
|
private previewLine: LinesMesh | null = null;
|
|
private readonly cameraZoomObservers = new Set<(state: CameraZoomState | null) => void>();
|
|
private readonly handlePointerMove = (event: PointerEvent) => {
|
|
this.lastPointerClient = { x: event.clientX, y: event.clientY, altKey: event.altKey };
|
|
if (!this.measurementActive) return;
|
|
if (this.pendingPoint) {
|
|
this.updatePreviewLine();
|
|
}
|
|
if (this.measurementMarkers.length === 0) return;
|
|
const canvas = this.engine.getRenderingCanvas();
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = event.clientX - rect.left;
|
|
const y = event.clientY - rect.top;
|
|
const pickResult = this.scene.pick(x, y, (mesh) => this.measurementMarkers.includes(mesh as Mesh));
|
|
const newHover = pickResult.hit ? this.measurementMarkers.indexOf(pickResult.pickedMesh as Mesh) : -1;
|
|
if (newHover !== this.hoveredMarkerIndex) {
|
|
if (this.hoveredMarkerIndex >= 0 && this.hoveredMarkerIndex < this.measurementMarkers.length) {
|
|
const prev = this.measurementMarkers[this.hoveredMarkerIndex];
|
|
if (prev !== this.pendingMarker) {
|
|
prev.scaling.setAll(1);
|
|
setMeasurementMarkerColor(prev, MEASUREMENT_MARKER_COLOR);
|
|
}
|
|
}
|
|
if (newHover >= 0 && newHover < this.measurementMarkers.length) {
|
|
const next = this.measurementMarkers[newHover];
|
|
next.scaling.setAll(1.6);
|
|
setMeasurementMarkerColor(next, MEASUREMENT_HOVER_COLOR);
|
|
}
|
|
this.hoveredMarkerIndex = newHover;
|
|
}
|
|
};
|
|
private readonly preventCanvasWheelScroll = (event: WheelEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
private canRender(): boolean {
|
|
const canvas = this.engine.getRenderingCanvas();
|
|
return !!canvas?.isConnected && canvas.clientWidth > 0 && canvas.clientHeight > 0;
|
|
}
|
|
|
|
private getCameraZoomRange(): { current: number; min: number; max: number } | null {
|
|
if (!this.rootMesh) return null;
|
|
const current = this.camera.radius;
|
|
const lower = this.camera.lowerRadiusLimit;
|
|
const upper = this.camera.upperRadiusLimit;
|
|
const min = typeof lower === "number" && Number.isFinite(lower) && lower > 0
|
|
? lower
|
|
: Math.max(current * 0.08, 0.00001);
|
|
const max = typeof upper === "number" && Number.isFinite(upper) && upper > min
|
|
? upper
|
|
: Math.max(current * 8, min * 10);
|
|
return {
|
|
current: Math.max(min, Math.min(current, max)),
|
|
min,
|
|
max,
|
|
};
|
|
}
|
|
|
|
private notifyCameraZoomChanged(): void {
|
|
if (this.cameraZoomObservers.size === 0) return;
|
|
const state = this.getCameraZoomState();
|
|
for (const observer of this.cameraZoomObservers) {
|
|
observer(state);
|
|
}
|
|
}
|
|
|
|
private ensureDisassemblyController(): PreviewDisassemblyController | null {
|
|
if (!this.rootMesh) {
|
|
return null;
|
|
}
|
|
if (!this.disassembly) {
|
|
this.disassembly = createBabylonDisassemblyController(
|
|
this.scene,
|
|
this.camera,
|
|
this.rootMesh,
|
|
this.getRenderableMeshes(this.rootMesh),
|
|
this.gltfComponentMetadata,
|
|
);
|
|
}
|
|
return this.disassembly;
|
|
}
|
|
|
|
private isDisassemblyActive(): boolean {
|
|
return this.disassembly?.isEnabled() ?? false;
|
|
}
|
|
|
|
constructor(canvas: HTMLCanvasElement) {
|
|
this.engine = new Engine(canvas, true, { preserveDrawingBuffer: true });
|
|
this.scene = new Scene(this.engine);
|
|
this.scene.clearColor = new Color4(0.12, 0.12, 0.14, 1);
|
|
this.scene.setRenderingAutoClearDepthStencil(2, true, true, true);
|
|
|
|
this.camera = new ArcRotateCamera(
|
|
"cam",
|
|
Math.PI / 4,
|
|
Math.PI / 3,
|
|
5,
|
|
Vector3.Zero(),
|
|
this.scene,
|
|
);
|
|
this.camera.attachControl(canvas, true);
|
|
this.camera.lowerRadiusLimit = 0.1;
|
|
this.camera.wheelPrecision = 45;
|
|
this.camera.onViewMatrixChangedObservable.add(() => {
|
|
if (this.measurementSegments.length > 0) {
|
|
this.updateMeasurementOverlayPositions();
|
|
}
|
|
this.notifyCameraZoomChanged();
|
|
});
|
|
canvas.addEventListener("wheel", this.preventCanvasWheelScroll, { passive: false });
|
|
canvas.addEventListener("pointermove", this.handlePointerMove);
|
|
canvas.addEventListener("webglcontextlost", this.handleContextLost);
|
|
canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
|
|
|
|
this.scene.ambientColor = new Color3(0.3, 0.3, 0.3);
|
|
const hemi = new HemisphericLight("default-light", new Vector3(0, 1, 0.5), this.scene);
|
|
hemi.intensity = 1.2;
|
|
|
|
this.resizeObs = new ResizeObserver(() => this.engine.resize());
|
|
this.resizeObs.observe(canvas);
|
|
if (typeof IntersectionObserver !== "undefined") {
|
|
this.viewportObserver = new IntersectionObserver(this.handleViewportIntersection, {
|
|
root: null,
|
|
threshold: [0, 0.01],
|
|
});
|
|
this.viewportObserver.observe(canvas);
|
|
}
|
|
// Force a resize after the canvas is mounted and has layout dimensions
|
|
window.requestAnimationFrame(() => this.engine.resize());
|
|
}
|
|
|
|
async loadModel(
|
|
data: ArrayBuffer,
|
|
ext: string,
|
|
readFile?: (path: string) => Promise<ArrayBuffer>,
|
|
modelPath?: string,
|
|
options?: PreviewLoadOptions,
|
|
): Promise<ModelPreviewSummary> {
|
|
throwIfPreviewLoadInterrupted(options);
|
|
await ensureLoadersRegistered();
|
|
throwIfPreviewLoadInterrupted(options);
|
|
|
|
if (this.rootMesh) {
|
|
disposeBabylonLoadedNodes(this.loadedMeshes, this.loadedTransformNodes);
|
|
this.rootMesh = null;
|
|
this.notifyCameraZoomChanged();
|
|
}
|
|
this.invalidateMeshCache();
|
|
this.loadedMeshes = [];
|
|
this.loadedTransformNodes = [];
|
|
this.disposeMeasurementOverlays(true);
|
|
this.resetMeasurementCalibrationState();
|
|
this.disassembly?.dispose();
|
|
this.disassembly = null;
|
|
this.clearFocusedMesh();
|
|
this.originalMeshVisibility.clear();
|
|
|
|
const extLower = ext.toLowerCase().replace(".", "");
|
|
this.loadedExt = extLower;
|
|
this.resourceWarnings = [];
|
|
throwIfPreviewLoadInterrupted(options);
|
|
this.gltfComponentMetadata = collectBabylonGltfComponentMetadata(data, extLower);
|
|
const scene = this.scene;
|
|
|
|
// Map extension to Babylon SceneLoader file extension
|
|
const extToLoader: Record<string, string> = {
|
|
glb: ".glb",
|
|
gltf: ".gltf",
|
|
stl: ".stl",
|
|
obj: ".obj",
|
|
splat: ".splat",
|
|
ply: ".ply",
|
|
};
|
|
const fileExt = extToLoader[extLower] ?? `.${extLower}`;
|
|
|
|
// Use data URL instead of blob URL — Obsidian's Electron converts
|
|
// blob: URLs to blob:app://... which Babylon's GLTF loader cannot parse.
|
|
throwIfPreviewLoadInterrupted(options);
|
|
const dataUrl = `data:application/octet-stream;base64,${arrayBufferToBase64(data)}`;
|
|
throwIfPreviewLoadInterrupted(options);
|
|
|
|
const assignImportResult = (result: Awaited<ReturnType<typeof ImportMeshAsync>>): void => {
|
|
try {
|
|
throwIfPreviewLoadInterrupted(options);
|
|
} catch (error) {
|
|
disposeBabylonLoadedNodes(result.meshes, result.transformNodes);
|
|
throw error;
|
|
}
|
|
this.loadedMeshes = result.meshes;
|
|
this.loadedTransformNodes = result.transformNodes;
|
|
if (result.meshes.length > 0) this.rootMesh = result.meshes[0] as Mesh;
|
|
};
|
|
|
|
const disposeCurrentLoadAndThrowIfInterrupted = (): void => {
|
|
try {
|
|
throwIfPreviewLoadInterrupted(options);
|
|
} catch (error) {
|
|
disposeBabylonLoadedNodes(this.loadedMeshes, this.loadedTransformNodes);
|
|
this.loadedMeshes = [];
|
|
this.loadedTransformNodes = [];
|
|
this.rootMesh = null;
|
|
this.invalidateMeshCache();
|
|
this.notifyCameraZoomChanged();
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// OBJ: override _loadMTL to read MTL from vault instead of network fetch.
|
|
// Serialized via objMtlLock to prevent concurrent loads from clobbering the prototype.
|
|
if (extLower === "obj" && readFile && modelPath) {
|
|
if (objMtlLock) await objMtlLock;
|
|
throwIfPreviewLoadInterrupted(options);
|
|
let resolveLock!: () => void;
|
|
objMtlLock = new Promise<void>(r => { resolveLock = r; });
|
|
try {
|
|
const { OBJFileLoader } = await import("@babylonjs/loaders/OBJ/objFileLoader.js");
|
|
const proto = OBJFileLoader.prototype as unknown as Record<string, unknown>;
|
|
if (typeof proto._loadMTL !== "function") {
|
|
console.warn("[AI3D] OBJFileLoader._loadMTL not found — MTL vault resolution disabled");
|
|
}
|
|
const originalLoadMTL = proto._loadMTL;
|
|
|
|
// Pre-load MTL content from vault (if exists)
|
|
const objText = new TextDecoder().decode(new Uint8Array(data));
|
|
const mtlMatch = objText.match(/mtllib\s+(.+)/);
|
|
let mtlContent: string | null = null;
|
|
if (mtlMatch && readFile && modelPath) {
|
|
const mtlFilename = firstMtlPath(mtlMatch[1]);
|
|
const modelDir = getPortableDirname(modelPath);
|
|
const mtlPath = joinPortablePath(modelDir, mtlFilename);
|
|
try {
|
|
const mtlData = await readFile(mtlPath);
|
|
throwIfPreviewLoadInterrupted(options);
|
|
const raw = new TextDecoder().decode(new Uint8Array(mtlData));
|
|
const lines = raw.split("\n");
|
|
|
|
// Resolve texture files referenced in MTL from vault.
|
|
// Try: 1) full relative path, 2) same-dir filename,
|
|
// 3) OBJ-name with image extensions (e.g. bat.jpeg),
|
|
// 4) common basecolor/texture names in same dir
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const m = lines[i].match(OBJ_TEXTURE_RE);
|
|
if (!m) continue;
|
|
const rawPath = firstTexturePath(m[2]);
|
|
const candidates = buildObjTextureCandidates(modelDir, rawPath, modelPath);
|
|
let resolved = false;
|
|
for (const cand of candidates) {
|
|
try {
|
|
throwIfPreviewLoadInterrupted(options);
|
|
const texBuf = await readFile(cand);
|
|
throwIfPreviewLoadInterrupted(options);
|
|
const dataUrl = `data:${guessTextureMime(cand)};base64,${arrayBufferToBase64(texBuf)}`;
|
|
lines[i] = `${m[1]} ${dataUrl}`;
|
|
resolved = true;
|
|
break;
|
|
} catch (error) {
|
|
if (isPreviewLoadInterruptedError(error)) throw error;
|
|
/* try next candidate */
|
|
}
|
|
}
|
|
if (!resolved) {
|
|
this.resourceWarnings.push(`OBJ material texture not found: ${rawPath}`);
|
|
lines[i] = ""; // strip — prevents red-black checkerboard
|
|
}
|
|
}
|
|
|
|
// If MTL has no Kd (diffuse color), add default light gray
|
|
const filtered = lines.filter(l => l !== "");
|
|
const hasKd = filtered.some(l => /^\s*Kd\s+/i.test(l));
|
|
if (!hasKd) {
|
|
const nmIdx = filtered.findIndex(l => /^\s*newmtl\s+/i.test(l));
|
|
filtered.splice(nmIdx >= 0 ? nmIdx + 1 : 0, 0, "Kd 0.80 0.80 0.80");
|
|
}
|
|
mtlContent = filtered.join("\n");
|
|
} catch (error) {
|
|
if (isPreviewLoadInterruptedError(error)) throw error;
|
|
this.resourceWarnings.push(`OBJ material library not found: ${mtlPath}`);
|
|
}
|
|
}
|
|
|
|
// Override _loadMTL to use vault content or skip (prevents network fetch)
|
|
proto._loadMTL = function(_url: string, _rootUrl: string, onSuccess: (data: string) => void) {
|
|
const content = mtlContent ?? "";
|
|
onSuccess(content);
|
|
};
|
|
|
|
const result = await ImportMeshAsync(dataUrl, scene, { meshNames: "", pluginExtension: fileExt });
|
|
assignImportResult(result);
|
|
|
|
// Restore original _loadMTL
|
|
proto._loadMTL = originalLoadMTL;
|
|
} catch (e) {
|
|
if (isPreviewLoadInterruptedError(e)) {
|
|
throw e;
|
|
}
|
|
console.error("[AI3D] OBJ load error:", e);
|
|
throw e;
|
|
} finally {
|
|
resolveLock();
|
|
objMtlLock = null;
|
|
}
|
|
} else if (extLower === "stl") {
|
|
// Direct parse — Babylon v9 SceneLoader mishandles data URLs for custom plugins
|
|
const rootMesh = loadSTLBuffer(scene, data);
|
|
try {
|
|
throwIfPreviewLoadInterrupted(options);
|
|
} catch (error) {
|
|
rootMesh?.dispose(false, true);
|
|
throw error;
|
|
}
|
|
this.rootMesh = rootMesh;
|
|
if (this.rootMesh) this.loadedMeshes = [this.rootMesh];
|
|
} else if (extLower === "ply") {
|
|
// Direct parse — same Babylon v9 data-URL issue as STL
|
|
const rootMesh = loadPLYBuffer(scene, data);
|
|
try {
|
|
throwIfPreviewLoadInterrupted(options);
|
|
} catch (error) {
|
|
rootMesh?.dispose(false, true);
|
|
throw error;
|
|
}
|
|
this.rootMesh = rootMesh;
|
|
if (this.rootMesh) this.loadedMeshes = [this.rootMesh];
|
|
} else {
|
|
const result = await ImportMeshAsync(dataUrl, scene, { meshNames: "", pluginExtension: fileExt });
|
|
assignImportResult(result);
|
|
}
|
|
|
|
disposeCurrentLoadAndThrowIfInterrupted();
|
|
|
|
if (!this.rootMesh) {
|
|
throw new Error("No mesh found in model file");
|
|
}
|
|
const rootBounds = this.getRenderableBounds(this.rootMesh);
|
|
this.captureMeasurementBaseState(this.rootMesh, rootBounds);
|
|
|
|
// Disable backface culling on all materials to prevent invisible faces
|
|
// (CAD-converted models often have inconsistent face normals)
|
|
for (const m of this.getRenderableMeshes(this.rootMesh)) {
|
|
if (m.material) {
|
|
m.material.backFaceCulling = false;
|
|
}
|
|
}
|
|
|
|
const fit = createPreviewOrbitCameraFit(rootBounds);
|
|
|
|
this.camera.target = toBabylonVector3(fit.target);
|
|
this.camera.radius = fit.radius;
|
|
this.camera.lowerRadiusLimit = fit.lowerRadiusLimit;
|
|
this.camera.upperRadiusLimit = fit.upperRadiusLimit;
|
|
this.camera.minZ = fit.near;
|
|
this.camera.maxZ = fit.far;
|
|
|
|
this.initialCamera = {
|
|
alpha: this.camera.alpha,
|
|
beta: this.camera.beta,
|
|
radius: this.camera.radius,
|
|
target: this.camera.target.clone(),
|
|
};
|
|
this.notifyCameraZoomChanged();
|
|
|
|
this.startRenderLoop();
|
|
this.engine.resize();
|
|
|
|
this.cleanupPicking?.();
|
|
this.cleanupPicking = setupPicking(this.scene, (result) => {
|
|
if (this.isDisassemblyActive()) return;
|
|
const selectable = result.mesh && this.rootMesh
|
|
? findBabylonSelectablePartNode(
|
|
this.rootMesh,
|
|
result.mesh,
|
|
this.getRenderableMeshes(this.rootMesh),
|
|
this.gltfComponentMetadata,
|
|
)
|
|
: null;
|
|
const previewResult: PreviewPickResult = {
|
|
mesh: selectable,
|
|
pickedPoint: result.pickedPoint,
|
|
screenX: result.screenX,
|
|
screenY: result.screenY,
|
|
modifiers: result.modifiers,
|
|
};
|
|
this._lastPickResult = previewResult;
|
|
if (this.measurementActive) {
|
|
if (result.modifiers?.altKey === true) {
|
|
if (!result.pickedPoint) return;
|
|
this.setMeasurementSnapKind("free");
|
|
this.addMeasurementPoint(toBabylonVector3(result.pickedPoint));
|
|
return;
|
|
}
|
|
if (!this.measurementTargetNode) {
|
|
if (selectable) {
|
|
this.setMeasurementTargetNode(selectable);
|
|
}
|
|
return;
|
|
}
|
|
const targetPoint = this.getMeasurementTargetPickPoint(result.mesh, result.pickedPoint);
|
|
if (!targetPoint) {
|
|
this.setMeasurementSnapKind(null);
|
|
return;
|
|
}
|
|
this.addMeasurementPoint(this.resolveMeasurementPickPoint(targetPoint, false));
|
|
return;
|
|
}
|
|
if (this.focusSelectionEnabled && selectable) {
|
|
this.setFocusedNode(selectable);
|
|
}
|
|
this._onPickCallbacks.forEach(cb => cb(previewResult));
|
|
}, () => !this.focusSelectionEnabled && !this.measurementActive, (mesh) => this.resolvePickHighlightMeshes(mesh));
|
|
this.ensureDisassemblyController();
|
|
|
|
return this.createModelSummary(this.rootMesh);
|
|
}
|
|
|
|
// ── Config application ───────────────────────────────────────────
|
|
|
|
applyConfig(config: ThreeDBlockConfig): void {
|
|
if (config.camera) this.applyCameraConfig(config.camera);
|
|
if (config.lights) this.applyLightConfig(config.lights);
|
|
if (config.scene) this.applySceneConfig(config.scene);
|
|
if (config.stl && this.loadedExt === "stl") {
|
|
if (config.stl.color) this.setSTLColor(config.stl.color);
|
|
if (config.stl.wireframe !== undefined) this.setWireframe(config.stl.wireframe);
|
|
}
|
|
}
|
|
|
|
applyCameraConfig(config: CameraConfig): void {
|
|
const canvas = this.engine.getRenderingCanvas();
|
|
if (!canvas) return;
|
|
|
|
if (config.mode === "orthographic") {
|
|
const radius = this.camera.radius;
|
|
const aspect = canvas.clientWidth / canvas.clientHeight;
|
|
const zoom = config.zoom ?? 1;
|
|
const size = radius / zoom;
|
|
|
|
this.camera.mode = 1; // orthographic
|
|
this.camera.orthoLeft = -size * aspect;
|
|
this.camera.orthoRight = size * aspect;
|
|
this.camera.orthoTop = size;
|
|
this.camera.orthoBottom = -size;
|
|
} else {
|
|
this.camera.mode = 0; // perspective
|
|
if (config.fov) this.camera.fov = (config.fov * Math.PI) / 180;
|
|
}
|
|
|
|
if (config.position) {
|
|
const [x, y, z] = config.position;
|
|
this.camera.setPosition(new Vector3(x, y, z));
|
|
}
|
|
|
|
if (config.lookAt) {
|
|
const [x, y, z] = config.lookAt;
|
|
this.camera.setTarget(new Vector3(x, y, z));
|
|
}
|
|
|
|
if (config.near !== undefined) this.camera.minZ = config.near;
|
|
if (config.far !== undefined) this.camera.maxZ = config.far;
|
|
this.notifyCameraZoomChanged();
|
|
}
|
|
|
|
applyLightConfig(lights: LightConfig[]): void {
|
|
// Dispose previous config lights and shadow generator
|
|
for (const light of this.configLights) {
|
|
light.dispose();
|
|
}
|
|
this.configLights = [];
|
|
this.shadowGenerator?.dispose();
|
|
this.shadowGenerator = null;
|
|
|
|
// Remove the default light when config lights are provided
|
|
const defaultLight = this.scene.getLightByName("default-light");
|
|
if (defaultLight) {
|
|
defaultLight.dispose();
|
|
}
|
|
|
|
for (const cfg of lights) {
|
|
const light = this.createLight(cfg);
|
|
if (light) this.configLights.push(light);
|
|
}
|
|
}
|
|
|
|
private createLight(cfg: LightConfig): Light | null {
|
|
const color = cfg.color ? Color3.FromHexString(cfg.color) : Color3.White();
|
|
const intensity = cfg.intensity ?? 1;
|
|
|
|
switch (cfg.type) {
|
|
case "hemisphere": {
|
|
const ground = cfg.groundColor
|
|
? Color3.FromHexString(cfg.groundColor)
|
|
: new Color3(0.2, 0.2, 0.2);
|
|
const l = new HemisphericLight("hemi", new Vector3(0, 1, 0), this.scene);
|
|
l.diffuse = color;
|
|
l.groundColor = ground;
|
|
l.intensity = intensity;
|
|
return l;
|
|
}
|
|
case "directional": {
|
|
const dir = cfg.position
|
|
? new Vector3(...cfg.position).normalize()
|
|
: new Vector3(-1, -2, -1).normalize();
|
|
const l = new DirectionalLight("dir", dir, this.scene);
|
|
l.diffuse = color;
|
|
l.intensity = intensity;
|
|
if (cfg.castShadow && this.rootMesh) {
|
|
this.setupShadow(l);
|
|
}
|
|
return l;
|
|
}
|
|
case "point": {
|
|
const pos = cfg.position ? new Vector3(...cfg.position) : new Vector3(0, 5, 0);
|
|
const l = new PointLight("point", pos, this.scene);
|
|
l.diffuse = color;
|
|
l.intensity = intensity;
|
|
if (cfg.decay !== undefined) (l as unknown as Record<string, unknown>).decay = cfg.decay;
|
|
return l;
|
|
}
|
|
case "spot": {
|
|
const pos = cfg.position ? new Vector3(...cfg.position) : new Vector3(0, 5, 0);
|
|
const target = cfg.target ? new Vector3(...cfg.target) : Vector3.Zero();
|
|
const dir = target.subtract(pos).normalize();
|
|
const angle = cfg.angle ? (cfg.angle * Math.PI) / 180 : Math.PI / 4;
|
|
const penumbra = cfg.penumbra ?? 0.5;
|
|
const l = new SpotLight("spot", pos, dir, angle, penumbra, this.scene);
|
|
l.diffuse = color;
|
|
l.intensity = intensity;
|
|
if (cfg.decay !== undefined) (l as unknown as Record<string, unknown>).decay = cfg.decay;
|
|
if (cfg.castShadow && this.rootMesh) {
|
|
this.setupShadow(l);
|
|
}
|
|
return l;
|
|
}
|
|
case "attachToCam": {
|
|
const l = new PointLight("cam-light", Vector3.Zero(), this.scene);
|
|
l.diffuse = color;
|
|
l.intensity = intensity;
|
|
l.parent = this.camera;
|
|
return l;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private setupShadow(light: Light): void {
|
|
if (!this.rootMesh) return;
|
|
// ShadowGenerator requires a ShadowLight (DirectionalLight | PointLight | SpotLight).
|
|
// HemisphericLight cannot cast shadows — silently skip.
|
|
if (!isShadowLight(light)) {
|
|
console.warn("[AI3D] Light type does not support shadows:", light.name);
|
|
return;
|
|
}
|
|
const sg = new ShadowGenerator(1024, light);
|
|
sg.useBlurExponentialShadowMap = true;
|
|
sg.blurKernel = 32;
|
|
for (const m of this.getRenderableMeshes(this.rootMesh)) {
|
|
sg.addShadowCaster(m);
|
|
m.receiveShadows = true;
|
|
}
|
|
this.shadowGenerator = sg;
|
|
}
|
|
|
|
applySceneConfig(config: SceneConfig): void {
|
|
if (config.background !== undefined) {
|
|
const c = Color4.FromColor3(Color3.FromHexString(config.background), config.transparent ? 0 : 1);
|
|
this.scene.clearColor = c;
|
|
}
|
|
|
|
if (config.autoRotate) {
|
|
if (!this.autoRotateBehavior) {
|
|
this.autoRotateBehavior = new AutoRotationBehavior();
|
|
this.autoRotateBehavior.idleRotationSpeed = config.autoRotateSpeed ?? 0.5;
|
|
this.autoRotateBehavior.idleRotationWaitTime = 1000;
|
|
this.autoRotateBehavior.idleRotationSpinupTime = 500;
|
|
this.camera.addBehavior(this.autoRotateBehavior);
|
|
} else {
|
|
this.autoRotateBehavior.idleRotationSpeed = config.autoRotateSpeed ?? 0.5;
|
|
}
|
|
}
|
|
|
|
if (config.groundShadow && this.rootMesh) {
|
|
this.createGround();
|
|
}
|
|
|
|
if (config.grid) {
|
|
this.createGrid();
|
|
}
|
|
|
|
if (config.axis) {
|
|
this.createAxis();
|
|
}
|
|
}
|
|
|
|
private createGround(): void {
|
|
if (!this.rootMesh || this.groundMesh) return;
|
|
const bounds = this.getRenderableBounds(this.rootMesh);
|
|
const boundsSize = getPreviewBoundsSize(bounds);
|
|
const size = Math.max(boundsSize.x, boundsSize.z) * 3;
|
|
const y = bounds.min.y;
|
|
|
|
this.groundMesh = MeshBuilder.CreateGround("ground", { width: size, height: size }, this.scene);
|
|
this.groundMesh.position.y = y;
|
|
const mat = new StandardMaterial("ground-mat", this.scene);
|
|
mat.diffuseColor = new Color3(0.15, 0.15, 0.15);
|
|
mat.specularColor = Color3.Black();
|
|
mat.alpha = 0.5;
|
|
this.groundMesh.material = mat;
|
|
this.groundMesh.receiveShadows = true;
|
|
}
|
|
|
|
private createGrid(): void {
|
|
if (!this.rootMesh || this.gridMesh) return;
|
|
const bounds = this.getRenderableBounds(this.rootMesh);
|
|
const boundsSize = getPreviewBoundsSize(bounds);
|
|
const size = Math.max(boundsSize.x, boundsSize.z) * 2;
|
|
const y = bounds.min.y - 0.01;
|
|
|
|
this.gridMesh = MeshBuilder.CreateGround("grid", { width: size, height: size, subdivisions: 20 }, this.scene);
|
|
this.gridMesh.position.y = y;
|
|
const mat = new StandardMaterial("grid-mat", this.scene);
|
|
mat.wireframe = true;
|
|
mat.diffuseColor = new Color3(0.3, 0.3, 0.3);
|
|
mat.emissiveColor = new Color3(0.1, 0.1, 0.1);
|
|
this.gridMesh.material = mat;
|
|
}
|
|
|
|
private createAxis(): void {
|
|
if (!this.rootMesh || this.axisMeshes.length > 0) return;
|
|
const bounds = this.getRenderableBounds(this.rootMesh);
|
|
const len = getPreviewBoundsMaxSpan(bounds) * 1.5;
|
|
const origin = toBabylonVector3(bounds.min);
|
|
const radius = getPreviewBoundsRadius(bounds) * 0.01;
|
|
|
|
const axes: [string, Color3, Vector3][] = [
|
|
["x", Color3.Red(), new Vector3(len, 0, 0)],
|
|
["y", Color3.Green(), new Vector3(0, len, 0)],
|
|
["z", Color3.Blue(), new Vector3(0, 0, len)],
|
|
];
|
|
|
|
for (const [name, color, dir] of axes) {
|
|
const tube = MeshBuilder.CreateTube(`axis-${name}`, {
|
|
path: [origin, origin.add(dir)],
|
|
radius,
|
|
tessellation: 8,
|
|
}, this.scene);
|
|
const mat = new StandardMaterial(`axis-${name}-mat`, this.scene);
|
|
mat.emissiveColor = color;
|
|
mat.diffuseColor = Color3.Black();
|
|
tube.material = mat;
|
|
this.axisMeshes.push(tube);
|
|
}
|
|
}
|
|
|
|
setSTLColor(hex: string): void {
|
|
if (!this.rootMesh) return;
|
|
const color = Color3.FromHexString(hex);
|
|
for (const m of this.getRenderableMeshes(this.rootMesh)) {
|
|
if (m.material && m.material.name === "stl-mat") {
|
|
const mat = m.material as StandardMaterial;
|
|
mat.diffuseColor = color;
|
|
mat.emissiveColor = color.scale(0.1);
|
|
}
|
|
}
|
|
}
|
|
|
|
setWireframe(enabled: boolean): void {
|
|
if (!this.rootMesh) return;
|
|
if (isGaussianSplattingMesh(this.rootMesh)) return;
|
|
this.wireframeEnabled = enabled;
|
|
this.scene.forceWireframe = enabled;
|
|
}
|
|
|
|
toggleWireframe(): boolean {
|
|
this.setWireframe(!this.wireframeEnabled);
|
|
return this.wireframeEnabled;
|
|
}
|
|
|
|
hasAnimations(): boolean {
|
|
return this.scene.animationGroups.length > 0;
|
|
}
|
|
|
|
toggleAnimation(): boolean {
|
|
const groups = this.scene.animationGroups;
|
|
if (groups.length === 0) return false;
|
|
this.animPlaying = !this.animPlaying;
|
|
for (const g of groups) {
|
|
if (this.animPlaying) {
|
|
g.play(true);
|
|
} else {
|
|
g.pause();
|
|
}
|
|
}
|
|
return this.animPlaying;
|
|
}
|
|
|
|
toggleMeasurement(): boolean {
|
|
if (this.measurementActive) {
|
|
this.deactivateMeasurementMode();
|
|
return false;
|
|
}
|
|
if (this.isDisassemblyActive()) {
|
|
this.disassembly?.setEnabled(false);
|
|
}
|
|
const measurementTarget = this.getCurrentMeasurementTargetNode();
|
|
this.cleanupPicking?.clearHighlight();
|
|
this.clearFocusedMesh();
|
|
if (this.focusSelectionEnabled) {
|
|
this.focusSelectionEnabled = false;
|
|
}
|
|
this.measurementActive = true;
|
|
this.setMeasurementTargetNode(measurementTarget, false);
|
|
setMeasurementCanvasActive(this.engine.getRenderingCanvas(), this.measurementActive);
|
|
this.notifyMeasurementsChanged();
|
|
return this.measurementActive;
|
|
}
|
|
|
|
isMeasurementActive(): boolean {
|
|
return this.measurementActive;
|
|
}
|
|
|
|
clearMeasurements(): void {
|
|
this.disposeMeasurementOverlays(false);
|
|
}
|
|
|
|
cancelMeasurement(): void {
|
|
const hadPendingPoint = !!this.pendingPoint;
|
|
this.cancelPendingMeasurement();
|
|
if (hadPendingPoint) {
|
|
this.notifyMeasurementsChanged();
|
|
}
|
|
}
|
|
|
|
private deactivateMeasurementMode(): boolean {
|
|
if (!this.measurementActive) return false;
|
|
this.measurementActive = false;
|
|
setMeasurementCanvasActive(this.engine.getRenderingCanvas(), false);
|
|
this.setMeasurementTargetNode(null, false);
|
|
this.cancelPendingMeasurement();
|
|
this.notifyMeasurementsChanged();
|
|
return true;
|
|
}
|
|
|
|
private disposeMeasurementOverlays(deactivate: boolean): void {
|
|
if (deactivate) {
|
|
this.measurementActive = false;
|
|
setMeasurementCanvasActive(this.engine.getRenderingCanvas(), false);
|
|
this.setMeasurementTargetNode(null, false);
|
|
}
|
|
this.cancelPendingMeasurement(false);
|
|
for (const segment of this.measurementSegments) {
|
|
segment.line.dispose(false, true);
|
|
segment.label.dispose(false, true);
|
|
}
|
|
this.measurementSegments = [];
|
|
for (const marker of this.measurementMarkers) {
|
|
marker.dispose(false, true);
|
|
}
|
|
this.measurementMarkers = [];
|
|
this.measurementMarkerPoints = [];
|
|
this.notifyMeasurementsChanged();
|
|
}
|
|
|
|
|
|
setMeasurementScale(scale: MeasurementScale): void {
|
|
this.measurementScale = sanitizeMeasurementScale(scale);
|
|
this.invalidateMeasurementSnapInputCache();
|
|
this.applyMeasurementModelScale();
|
|
this.updateMeasurementOverlayPositions();
|
|
this.updateMeasurementLabels();
|
|
this.notifyMeasurementsChanged();
|
|
}
|
|
|
|
getMeasurementScale(): MeasurementScale {
|
|
return { ...this.measurementScale };
|
|
}
|
|
|
|
setMeasurementUnit(unit: MeasurementUnit): void {
|
|
this.measurementUnit = normalizeMeasurementUnit(unit);
|
|
this.updateMeasurementLabels();
|
|
this.notifyMeasurementsChanged();
|
|
}
|
|
|
|
getMeasurementUnit(): MeasurementUnit {
|
|
return this.measurementUnit;
|
|
}
|
|
|
|
getMeasurementBounds(): { x: number; y: number; z: number } | null {
|
|
if (!this.rootMesh) return null;
|
|
const bounds = this.measurementBaseBounds ?? this.getRenderableBounds(this.rootMesh);
|
|
if (!bounds) return null;
|
|
return {
|
|
x: bounds.max.x - bounds.min.x,
|
|
y: bounds.max.y - bounds.min.y,
|
|
z: bounds.max.z - bounds.min.z,
|
|
};
|
|
}
|
|
|
|
getMeasurementRecords(): MeasurementRecord[] {
|
|
return this.createMeasurementRecords();
|
|
}
|
|
|
|
getMeasurementState(): MeasurementState {
|
|
return createMeasurementState({
|
|
active: this.measurementActive,
|
|
pending: !!this.pendingPoint,
|
|
records: this.createMeasurementRecords(),
|
|
unit: this.measurementUnit,
|
|
scale: this.getMeasurementScale(),
|
|
bounds: this.getMeasurementBounds(),
|
|
targetLocked: !!this.measurementTargetNode,
|
|
targetName: this.getMeasurementTargetName(),
|
|
snapKind: this.measurementSnapKind,
|
|
});
|
|
}
|
|
|
|
exportMeasurements(): string {
|
|
return createMeasurementMarkdown(this.createMeasurementRecords());
|
|
}
|
|
|
|
observeMeasurements(callback: () => void): () => void {
|
|
this.measurementObservers.add(callback);
|
|
callback();
|
|
return () => {
|
|
this.measurementObservers.delete(callback);
|
|
};
|
|
}
|
|
|
|
updateMeasurementLabels(): void {
|
|
if (this.measurementSegments.length === 0) return;
|
|
const markerSize = this.getMeasurementMarkerSize() * 3.2;
|
|
for (const segment of this.measurementSegments) {
|
|
this.updateMeasurementLineGeometry(segment);
|
|
const labelText = createMeasurementLabel(this.createMeasurementReading(segment.start, segment.end));
|
|
segment.label.dispose(false, true);
|
|
const layout = this.createBabylonMeasurementDraftingLayout(segment.start, segment.end);
|
|
const labelPosition = layout?.labelPosition ?? Vector3.Center(
|
|
this.toMeasurementDisplayPoint(segment.start),
|
|
this.toMeasurementDisplayPoint(segment.end),
|
|
);
|
|
segment.label = this.createMeasurementLabelMesh(labelText, labelPosition, markerSize);
|
|
}
|
|
}
|
|
|
|
private captureMeasurementBaseState(root: Mesh, rootBounds: PreviewBounds | null): void {
|
|
this.measurementBaseRootScaling = root.scaling.clone();
|
|
this.measurementBaseBounds = rootBounds ? createPreviewBounds(rootBounds.min, rootBounds.max) : null;
|
|
this.measurementScale = { x: 1, y: 1, z: 1 };
|
|
}
|
|
|
|
private resetMeasurementCalibrationState(): void {
|
|
this.measurementScale = { x: 1, y: 1, z: 1 };
|
|
this.measurementBaseRootScaling = new Vector3(1, 1, 1);
|
|
this.measurementBaseBounds = null;
|
|
this.measurementMarkerPoints = [];
|
|
this.invalidateMeasurementSnapInputCache();
|
|
}
|
|
|
|
private applyMeasurementModelScale(): void {
|
|
if (!this.rootMesh) return;
|
|
const scale = sanitizeMeasurementScale(this.measurementScale);
|
|
this.rootMesh.scaling = new Vector3(
|
|
this.measurementBaseRootScaling.x * scale.x,
|
|
this.measurementBaseRootScaling.y * scale.y,
|
|
this.measurementBaseRootScaling.z * scale.z,
|
|
);
|
|
this.rootMesh.computeWorldMatrix(true);
|
|
for (const mesh of this.loadedMeshes) {
|
|
mesh.computeWorldMatrix(true);
|
|
}
|
|
this.invalidateMeshCache();
|
|
this.rebuildScaledSceneHelpers();
|
|
this.refitCameraToModel();
|
|
this.startRenderLoop();
|
|
}
|
|
|
|
private rebuildScaledSceneHelpers(): void {
|
|
const hadBoundingBox = !!this.bboxMesh;
|
|
const hadGround = !!this.groundMesh;
|
|
const hadGrid = !!this.gridMesh;
|
|
const hadAxis = this.axisMeshes.length > 0;
|
|
this.bboxMesh?.dispose();
|
|
this.bboxMesh = null;
|
|
this.groundMesh?.dispose();
|
|
this.groundMesh = null;
|
|
this.gridMesh?.dispose();
|
|
this.gridMesh = null;
|
|
for (const axis of this.axisMeshes) axis.dispose();
|
|
this.axisMeshes = [];
|
|
if (hadBoundingBox && this.rootMesh) {
|
|
const bounds = this.getRenderableBounds(this.rootMesh);
|
|
const center = toBabylonVector3(getPreviewBoundsCenter(bounds));
|
|
const size = toBabylonVector3(getPreviewBoundsSize(bounds));
|
|
this.bboxMesh = MeshBuilder.CreateBox("bbox", { width: size.x, height: size.y, depth: size.z }, this.scene);
|
|
this.bboxMesh.position = center;
|
|
const mat = new StandardMaterial("bbox-mat", this.scene);
|
|
mat.wireframe = true;
|
|
mat.emissiveColor = new Color3(1, 1, 0);
|
|
mat.disableLighting = true;
|
|
mat.alpha = 0.6;
|
|
this.bboxMesh.material = mat;
|
|
}
|
|
if (hadGround) this.createGround();
|
|
if (hadGrid) this.createGrid();
|
|
if (hadAxis) this.createAxis();
|
|
}
|
|
|
|
private refitCameraToModel(): void {
|
|
if (!this.rootMesh) return;
|
|
const fit = createPreviewOrbitCameraFit(this.getRenderableBounds(this.rootMesh));
|
|
this.camera.target = toBabylonVector3(fit.target);
|
|
this.camera.radius = fit.radius;
|
|
this.camera.lowerRadiusLimit = fit.lowerRadiusLimit;
|
|
this.camera.upperRadiusLimit = fit.upperRadiusLimit;
|
|
this.camera.minZ = fit.near;
|
|
this.camera.maxZ = fit.far;
|
|
this.initialCamera = {
|
|
alpha: this.camera.alpha,
|
|
beta: this.camera.beta,
|
|
radius: this.camera.radius,
|
|
target: this.camera.target.clone(),
|
|
};
|
|
this.notifyCameraZoomChanged();
|
|
}
|
|
|
|
private getMeasurementPivot(): Vector3 {
|
|
return this.rootMesh?.getAbsolutePosition().clone() ?? Vector3.Zero();
|
|
}
|
|
|
|
private toMeasurementDisplayPoint(point: Vector3): Vector3 {
|
|
const next = scaleMeasurementPointFromBase(point, this.getMeasurementPivot(), this.measurementScale);
|
|
return new Vector3(next.x, next.y, next.z);
|
|
}
|
|
|
|
private toMeasurementBasePoint(point: Vector3): Vector3 {
|
|
const next = unscaleMeasurementPointToBase(point, this.getMeasurementPivot(), this.measurementScale);
|
|
return new Vector3(next.x, next.y, next.z);
|
|
}
|
|
|
|
private updateMeasurementOverlayPositions(): void {
|
|
for (let i = 0; i < this.measurementMarkers.length; i++) {
|
|
const basePoint = this.measurementMarkerPoints[i];
|
|
if (basePoint) {
|
|
this.measurementMarkers[i].position = this.toMeasurementDisplayPoint(basePoint);
|
|
}
|
|
}
|
|
for (const segment of this.measurementSegments) {
|
|
this.updateMeasurementLineGeometry(segment);
|
|
}
|
|
if (this.pendingPoint && this.previewLine) {
|
|
this.updatePreviewLine();
|
|
}
|
|
}
|
|
|
|
private updateMeasurementLineGeometry(segment: BabylonMeasurementSegment): void {
|
|
const layout = this.createBabylonMeasurementDraftingLayout(segment.start, segment.end);
|
|
const lines = layout?.lineSegments ?? [[
|
|
this.toMeasurementDisplayPoint(segment.start),
|
|
this.toMeasurementDisplayPoint(segment.end),
|
|
]];
|
|
segment.line = MeshBuilder.CreateLineSystem("measure-line", {
|
|
lines,
|
|
instance: segment.line,
|
|
}, this.scene);
|
|
segment.line.isPickable = false;
|
|
segment.line.renderingGroupId = 2;
|
|
segment.line.color = MEASUREMENT_LINE_COLOR.clone();
|
|
segment.line.alpha = 1;
|
|
if (layout) {
|
|
segment.label.position = layout.labelPosition;
|
|
}
|
|
}
|
|
|
|
private notifyMeasurementsChanged(): void {
|
|
for (const callback of Array.from(this.measurementObservers)) {
|
|
callback();
|
|
}
|
|
}
|
|
|
|
setAnimationSpeed(speed: number): void {
|
|
for (const g of this.scene.animationGroups) {
|
|
g.speedRatio = speed;
|
|
}
|
|
}
|
|
|
|
captureSnapshot(): string | null {
|
|
const canvas = this.engine.getRenderingCanvas();
|
|
if (!canvas) return null;
|
|
this.scene.render();
|
|
return canvas.toDataURL("image/png");
|
|
}
|
|
|
|
toggleOrientationGizmo(): boolean {
|
|
this.gizmoEnabled = !this.gizmoEnabled;
|
|
if (this.gizmoEnabled && !this.gizmo) {
|
|
this.gizmo = new OrientationGizmo(this.engine, this.camera);
|
|
}
|
|
return this.gizmoEnabled;
|
|
}
|
|
|
|
isOrientationGizmoEnabled(): boolean {
|
|
return this.gizmoEnabled;
|
|
}
|
|
|
|
/**
|
|
* Set render resolution scale directly (1.0 = native).
|
|
* Returns the applied scale value.
|
|
*/
|
|
setRenderScale(scale: number): number {
|
|
const clamped = Math.max(0.25, Math.min(scale, 2.0));
|
|
this.renderScale = clamped;
|
|
const qualityScale = { low: 2, medium: 1.33, high: 1 }[this.currentQuality];
|
|
const mobileBoost = isMobile() ? 1.5 : 1;
|
|
this.engine.setHardwareScalingLevel(qualityScale * mobileBoost / clamped);
|
|
return this.getRenderScale();
|
|
}
|
|
|
|
getRenderScale(): number {
|
|
return Number(this.renderScale.toFixed(2));
|
|
}
|
|
|
|
getCameraZoomState(): CameraZoomState | null {
|
|
const range = this.getCameraZoomRange();
|
|
if (!range) return null;
|
|
const value = (range.max - range.current) / (range.max - range.min);
|
|
const clamped = clampUnit(value);
|
|
return {
|
|
value: clamped,
|
|
percentage: Math.round(clamped * 100),
|
|
};
|
|
}
|
|
|
|
setCameraZoom(value: number): CameraZoomState | null {
|
|
const range = this.getCameraZoomRange();
|
|
if (!range) return null;
|
|
const clamped = clampUnit(value);
|
|
this.camera.radius = range.max - clamped * (range.max - range.min);
|
|
this.startRenderLoop();
|
|
this.notifyCameraZoomChanged();
|
|
return this.getCameraZoomState();
|
|
}
|
|
|
|
observeCameraZoom(callback: (state: CameraZoomState | null) => void): () => void {
|
|
this.cameraZoomObservers.add(callback);
|
|
callback(this.getCameraZoomState());
|
|
return () => {
|
|
this.cameraZoomObservers.delete(callback);
|
|
};
|
|
}
|
|
|
|
getPerformanceSnapshot() {
|
|
return {
|
|
backend: "babylon" as const,
|
|
renderScale: Number((1 / this.engine.getHardwareScalingLevel()).toFixed(2)),
|
|
quality: this.currentQuality,
|
|
meshCount: this.rootMesh ? this.getRenderableMeshes(this.rootMesh).length : 0,
|
|
};
|
|
}
|
|
|
|
toggleBoundingBox(): boolean {
|
|
this.bboxEnabled = !this.bboxEnabled;
|
|
if (this.bboxEnabled) {
|
|
if (!this.rootMesh) return this.bboxEnabled;
|
|
if (this.bboxMesh) this.bboxMesh.dispose();
|
|
const bounds = this.getRenderableBounds(this.rootMesh);
|
|
const center = toBabylonVector3(getPreviewBoundsCenter(bounds));
|
|
const size = toBabylonVector3(getPreviewBoundsSize(bounds));
|
|
|
|
this.bboxMesh = MeshBuilder.CreateBox("bbox", {
|
|
width: size.x, height: size.y, depth: size.z,
|
|
}, this.scene);
|
|
this.bboxMesh.position = center;
|
|
const mat = new StandardMaterial("bbox-mat", this.scene);
|
|
mat.wireframe = true;
|
|
mat.emissiveColor = new Color3(1, 1, 0);
|
|
mat.disableLighting = true;
|
|
mat.alpha = 0.6;
|
|
this.bboxMesh.material = mat;
|
|
} else {
|
|
this.bboxMesh?.dispose();
|
|
this.bboxMesh = null;
|
|
}
|
|
return this.bboxEnabled;
|
|
}
|
|
|
|
toggleFocusSelection(): boolean {
|
|
const nextEnabled = !this.focusSelectionEnabled;
|
|
if (nextEnabled && this.isDisassemblyActive()) {
|
|
this.disassembly?.setEnabled(false);
|
|
}
|
|
if (nextEnabled) {
|
|
this.deactivateMeasurementMode();
|
|
}
|
|
this.focusSelectionEnabled = nextEnabled;
|
|
if (!this.focusSelectionEnabled) {
|
|
this.clearFocusedMesh();
|
|
} else if (this._lastPickResult.mesh) {
|
|
this.setFocusedNode(this._lastPickResult.mesh as BabylonSelectablePartNode);
|
|
}
|
|
return this.focusSelectionEnabled;
|
|
}
|
|
|
|
isFocusSelectionEnabled(): boolean {
|
|
return this.focusSelectionEnabled;
|
|
}
|
|
|
|
toggleDisassembly(): boolean {
|
|
const controller = this.ensureDisassemblyController();
|
|
if (!controller) return false;
|
|
const nextEnabled = !controller.isEnabled();
|
|
if (nextEnabled) {
|
|
this.focusSelectionEnabled = false;
|
|
this.clearFocusedMesh();
|
|
this.deactivateMeasurementMode();
|
|
}
|
|
return controller.setEnabled(nextEnabled);
|
|
}
|
|
|
|
resetDisassembly(): void {
|
|
this.disassembly?.reset();
|
|
}
|
|
|
|
isDisassemblyEnabled(): boolean {
|
|
return this.isDisassemblyActive();
|
|
}
|
|
|
|
// ── Existing API ─────────────────────────────────────────────────
|
|
|
|
setExplode(factor: number, axis: PreviewAxis) {
|
|
if (this.rootMesh) setExplode(this.rootMesh, factor, axis, this.loadedMeshes);
|
|
}
|
|
|
|
resetExplode() {
|
|
if (this.rootMesh) resetExplode(this.rootMesh, this.loadedMeshes);
|
|
}
|
|
|
|
resetView(): void {
|
|
if (this.rootMesh) resetExplode(this.rootMesh, this.loadedMeshes);
|
|
this.resetDisassembly();
|
|
this.clearFocusedMesh();
|
|
this.camera.mode = 0; // perspective
|
|
this.camera.alpha = this.initialCamera.alpha;
|
|
this.camera.beta = this.initialCamera.beta;
|
|
this.camera.radius = this.initialCamera.radius;
|
|
this.camera.target = this.initialCamera.target.clone();
|
|
this.startRenderLoop();
|
|
this.notifyCameraZoomChanged();
|
|
}
|
|
|
|
exportModelInfo(modelPath?: string): string {
|
|
if (!this.rootMesh) return "";
|
|
const renderableMeshes = this.getRenderableMeshes(this.rootMesh);
|
|
const isSplat = isGaussianSplattingMesh(this.rootMesh);
|
|
const summary = this.createModelSummary(this.rootMesh);
|
|
const name = modelPath ? getPortableBasename(modelPath) || summary.rootName : summary.rootName;
|
|
return createPreviewModelInfoMarkdown({
|
|
title: name,
|
|
format: this.loadedExt.toUpperCase(),
|
|
summary,
|
|
meshBreakdown: renderableMeshes.map((mesh) => createBabylonMeshInfoBreakdown(mesh, { isSplat })),
|
|
materialNames: renderableMeshes.map((mesh) => mesh.material?.name),
|
|
});
|
|
}
|
|
|
|
getModelEvidence(): ModelEvidence | null {
|
|
if (!this.rootMesh) return null;
|
|
const renderableMeshes = this.getRenderableMeshes(this.rootMesh);
|
|
const groupedPartCandidates = createBabylonGroupedPartCandidates(
|
|
renderableMeshes,
|
|
this.loadedTransformNodes,
|
|
this.gltfComponentMetadata,
|
|
);
|
|
return createPreviewEvidence({
|
|
summary: this.createModelSummary(this.rootMesh),
|
|
renderableMeshes,
|
|
groupedPartCandidates,
|
|
createMeshPart: (mesh) => createBabylonPartPreviewSummary(mesh, this.gltfComponentMetadata),
|
|
getMeshMaterialNames: (mesh) => [mesh.material?.name],
|
|
resourceWarnings: this.resourceWarnings,
|
|
});
|
|
}
|
|
|
|
getSelectedPartInfo(): ModelPartSummary | null {
|
|
if (!this.rootMesh) return null;
|
|
const node = this.focusedNode ?? (isBabylonSelectablePartNode(this._lastPickResult.mesh) ? this._lastPickResult.mesh : null);
|
|
if (!node || isBabylonNodeDisposed(node)) return null;
|
|
return createBabylonNodePartPreviewSummary(node, this.getRenderableMeshes(this.rootMesh), this.gltfComponentMetadata);
|
|
}
|
|
|
|
exportSelectedPartInfo(): string {
|
|
const part = this.getSelectedPartInfo();
|
|
return part ? createPreviewPartInfoMarkdown(part) : "";
|
|
}
|
|
|
|
getPickWorldPoint(result: PreviewPickResult): PreviewWorldPoint | null {
|
|
if (result.pickedPoint && typeof result.pickedPoint === "object") {
|
|
return toPreviewWorldPoint(result.pickedPoint as { x: number; y: number; z: number });
|
|
}
|
|
|
|
if (this.rootMesh && isBabylonSelectablePartNode(result.mesh)) {
|
|
const part = createBabylonNodePartPreviewSummary(result.mesh, this.getRenderableMeshes(this.rootMesh), this.gltfComponentMetadata);
|
|
return part.center;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
focusWorldPoint(point: PreviewWorldPoint): void {
|
|
const target = new Vector3(point.x, point.y, point.z);
|
|
const start = this.camera.target.clone();
|
|
const startedAt = performance.now();
|
|
|
|
if (this.focusWorldPointFrame) {
|
|
activeWindow.cancelAnimationFrame(this.focusWorldPointFrame);
|
|
this.focusWorldPointFrame = 0;
|
|
}
|
|
|
|
const tick = (now: number) => {
|
|
const t = Math.min(1, Math.max(0, (now - startedAt) / FOCUS_WORLD_POINT_ANIMATION_MS));
|
|
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
this.camera.target = Vector3.Lerp(start, target, ease);
|
|
if (t < 1 && !this.scene.isDisposed) {
|
|
this.focusWorldPointFrame = window.requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
this.focusWorldPointFrame = 0;
|
|
};
|
|
this.focusWorldPointFrame = window.requestAnimationFrame(tick);
|
|
}
|
|
|
|
private getAnnotationCameraStateKey(): string {
|
|
return formatAnnotationCameraStateKey([
|
|
{ value: this.camera.alpha, digits: 3 },
|
|
{ value: this.camera.beta, digits: 3 },
|
|
{ value: this.camera.radius, digits: 3 },
|
|
{ value: this.camera.target.x, digits: 2 },
|
|
{ value: this.camera.target.y, digits: 2 },
|
|
{ value: this.camera.target.z, digits: 2 },
|
|
]);
|
|
}
|
|
|
|
private projectAnnotationWorldPoint(point: PreviewWorldPoint, result: PreviewProjectionResult): boolean {
|
|
const canvas = this.engine.getRenderingCanvas();
|
|
if (!canvas || this.scene.isDisposed) {
|
|
return false;
|
|
}
|
|
|
|
const rw = this.engine.getRenderWidth();
|
|
const rh = this.engine.getRenderHeight();
|
|
if (rw === 0 || rh === 0 || canvas.clientWidth === 0 || canvas.clientHeight === 0) {
|
|
return false;
|
|
}
|
|
|
|
const worldPoint = BabylonModelPreview.annotationWorldPoint;
|
|
worldPoint.set(point.x, point.y, point.z);
|
|
|
|
Vector3.ProjectToRef(
|
|
worldPoint,
|
|
BabylonModelPreview.annotationIdentity,
|
|
this.scene.getTransformMatrix(),
|
|
this.camera.viewport.toGlobal(rw, rh),
|
|
BabylonModelPreview.annotationProjection,
|
|
);
|
|
return projectViewportPointToCanvas(
|
|
BabylonModelPreview.annotationProjection,
|
|
rw,
|
|
rh,
|
|
canvas,
|
|
result,
|
|
);
|
|
}
|
|
|
|
private isAnnotationWorldPointOccluded(point: PreviewWorldPoint): boolean {
|
|
if (this.scene.isDisposed) {
|
|
return false;
|
|
}
|
|
|
|
const lineOfSight = createPreviewLineOfSight(
|
|
toPreviewWorldPoint(this.camera.position),
|
|
point,
|
|
);
|
|
if (!lineOfSight) {
|
|
return false;
|
|
}
|
|
const direction = BabylonModelPreview.annotationDirection;
|
|
const ray = BabylonModelPreview.annotationRay;
|
|
|
|
direction.set(lineOfSight.direction.x, lineOfSight.direction.y, lineOfSight.direction.z);
|
|
ray.origin = this.camera.position;
|
|
ray.direction = direction;
|
|
ray.length = lineOfSight.distance;
|
|
|
|
const pickInfo = this.scene.pickWithRay(ray);
|
|
return !!pickInfo?.hit
|
|
&& isPreviewHitOccluded(pickInfo.distance, lineOfSight.distance, lineOfSight.epsilon);
|
|
}
|
|
|
|
getAnnotationProvider(): AnnotationViewportProvider {
|
|
const canvas = this.engine.getRenderingCanvas();
|
|
if (!canvas) {
|
|
throw new Error("Preview canvas is unavailable");
|
|
}
|
|
return createAnnotationViewportProvider({
|
|
canvas,
|
|
observeRender: (callback) => {
|
|
const obs = this.scene.onAfterRenderCameraObservable.add((camera) => {
|
|
if (camera === this.camera) {
|
|
callback();
|
|
}
|
|
});
|
|
return {
|
|
remove: () => this.scene.onAfterRenderCameraObservable.remove(obs),
|
|
};
|
|
},
|
|
getCameraStateKey: () => this.getAnnotationCameraStateKey(),
|
|
projectWorldPoint: (point, result) => this.projectAnnotationWorldPoint(point, result),
|
|
isWorldPointOccluded: (point) => this.isAnnotationWorldPointOccluded(point),
|
|
});
|
|
}
|
|
|
|
getCanvas(): HTMLCanvasElement | null {
|
|
return this.engine.getRenderingCanvas();
|
|
}
|
|
|
|
getLastPickResult(): PreviewPickResult {
|
|
return this._lastPickResult;
|
|
}
|
|
|
|
onPick(callback: (result: PreviewPickResult) => void): () => void {
|
|
this._onPickCallbacks.push(callback);
|
|
return () => {
|
|
this._onPickCallbacks = this._onPickCallbacks.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply render quality preset and optional resolution scale.
|
|
* - low: 0.5x resolution, no shadow blur
|
|
* - medium: 0.75x resolution, basic shadow blur
|
|
* - high: 1.0x resolution, full shadow blur (default)
|
|
* @param renderScale User-controlled resolution multiplier (1.0 = native).
|
|
* Lower values = less pixels = better performance.
|
|
*/
|
|
setRenderQuality(quality: "low" | "medium" | "high", renderScale = this.renderScale): void {
|
|
this.currentQuality = quality;
|
|
this.renderScale = Math.max(0.25, Math.min(renderScale, 2.0));
|
|
const scaleMap = { low: 2, medium: 1.33, high: 1 };
|
|
const mobileBoost = isMobile() ? 1.5 : 1;
|
|
// hardwareScalingLevel: higher = fewer pixels. renderScale < 1 = fewer pixels.
|
|
const scale = scaleMap[quality] * mobileBoost / this.renderScale;
|
|
this.engine.setHardwareScalingLevel(scale);
|
|
|
|
if (this.shadowGenerator) {
|
|
const blurMap = { low: 0, medium: 16, high: 32 };
|
|
this.shadowGenerator.blurKernel = blurMap[quality];
|
|
if (quality === "low") {
|
|
this.shadowGenerator.useBlurExponentialShadowMap = false;
|
|
this.shadowGenerator.useExponentialShadowMap = true;
|
|
} else {
|
|
this.shadowGenerator.useBlurExponentialShadowMap = true;
|
|
this.shadowGenerator.useExponentialShadowMap = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
this.engine.stopRenderLoop();
|
|
if (this.focusWorldPointFrame) {
|
|
activeWindow.cancelAnimationFrame(this.focusWorldPointFrame);
|
|
this.focusWorldPointFrame = 0;
|
|
}
|
|
this._onPickCallbacks = [];
|
|
this.cameraZoomObservers.clear();
|
|
this.cleanupPicking?.();
|
|
this.cleanupPicking = null;
|
|
this.gizmo?.dispose();
|
|
this.gizmo = null;
|
|
this.disassembly?.dispose();
|
|
this.disassembly = null;
|
|
this.disposeMeasurementOverlays(true);
|
|
this.measurementObservers.clear();
|
|
this.clearFocusedMesh();
|
|
this.originalMeshVisibility.clear();
|
|
this.bboxMesh?.dispose();
|
|
this.bboxMesh = null;
|
|
this.camera.detachControl();
|
|
const canvas = this.engine.getRenderingCanvas();
|
|
canvas?.removeEventListener("wheel", this.preventCanvasWheelScroll);
|
|
canvas?.removeEventListener("pointermove", this.handlePointerMove);
|
|
canvas?.removeEventListener("webglcontextlost", this.handleContextLost);
|
|
canvas?.removeEventListener("webglcontextrestored", this.handleContextRestored);
|
|
this.viewportObserver?.disconnect();
|
|
this.viewportObserver = null;
|
|
this.resizeObs.disconnect();
|
|
this.invalidateMeshCache();
|
|
this.rootMesh = null;
|
|
this.loadedMeshes = [];
|
|
this.loadedTransformNodes = [];
|
|
if (this.autoRotateBehavior) {
|
|
this.camera.removeBehavior(this.autoRotateBehavior);
|
|
this.autoRotateBehavior = null;
|
|
}
|
|
for (const l of this.configLights) l.dispose();
|
|
this.configLights = [];
|
|
this.shadowGenerator?.dispose();
|
|
this.shadowGenerator = null;
|
|
this.groundMesh?.dispose();
|
|
this.groundMesh = null;
|
|
this.gridMesh?.dispose();
|
|
this.gridMesh = null;
|
|
for (const a of this.axisMeshes) a.dispose();
|
|
this.axisMeshes = [];
|
|
this.scene.dispose();
|
|
this.engine.dispose();
|
|
}
|
|
|
|
private startRenderLoop() {
|
|
if (this.rendering || !this.viewportVisible || this.contextLost) return;
|
|
this.rendering = true;
|
|
this.engine.runRenderLoop(() => {
|
|
if (!this.canRender() || !this.viewportVisible || this.contextLost) {
|
|
this.engine.stopRenderLoop();
|
|
this.rendering = false;
|
|
return;
|
|
}
|
|
this.scene.render();
|
|
if (this.gizmo && this.gizmoEnabled) {
|
|
this.gizmo.syncWith(this.camera);
|
|
this.gizmo.render();
|
|
}
|
|
});
|
|
}
|
|
|
|
private readonly handleViewportIntersection = (entries: IntersectionObserverEntry[]) => {
|
|
const visible = entries.some((entry) => entry.isIntersecting);
|
|
if (visible === this.viewportVisible) return;
|
|
this.viewportVisible = visible;
|
|
if (visible) {
|
|
this.startRenderLoop();
|
|
} else if (this.rendering) {
|
|
this.engine.stopRenderLoop();
|
|
this.rendering = false;
|
|
}
|
|
};
|
|
|
|
private readonly handleContextLost = (event: Event) => {
|
|
event.preventDefault();
|
|
this.contextLost = true;
|
|
if (this.rendering) {
|
|
this.engine.stopRenderLoop();
|
|
this.rendering = false;
|
|
}
|
|
};
|
|
|
|
private readonly handleContextRestored = () => {
|
|
this.contextLost = false;
|
|
this.engine.resize();
|
|
this.startRenderLoop();
|
|
};
|
|
|
|
private getRenderableMeshes(root: Mesh): AbstractMesh[] {
|
|
if (this.cachedRenderableMeshes && this.cachedRenderableRoot === root) {
|
|
return this.cachedRenderableMeshes;
|
|
}
|
|
const renderableMeshes = getBabylonRenderableMeshes(root, this.loadedMeshes);
|
|
this.cachedRenderableMeshes = renderableMeshes;
|
|
this.cachedRenderableRoot = root;
|
|
return renderableMeshes;
|
|
}
|
|
|
|
private getRenderableBounds(root: Mesh) {
|
|
return getBabylonRenderablePreviewBounds(root, this.loadedMeshes);
|
|
}
|
|
|
|
private invalidateMeshCache(): void {
|
|
this.cachedRenderableMeshes = null;
|
|
this.cachedRenderableRoot = null;
|
|
}
|
|
|
|
private resolvePickHighlightMeshes(mesh: AbstractMesh): AbstractMesh[] {
|
|
if (!this.rootMesh) return [mesh];
|
|
const renderableMeshes = this.getRenderableMeshes(this.rootMesh);
|
|
const selectable = findBabylonSelectablePartNode(
|
|
this.rootMesh,
|
|
mesh,
|
|
renderableMeshes,
|
|
this.gltfComponentMetadata,
|
|
);
|
|
const selectedMeshes = renderableMeshes.filter((candidate) => isBabylonNodeOrDescendant(candidate, selectable));
|
|
return selectedMeshes.length > 0 ? selectedMeshes : [mesh];
|
|
}
|
|
|
|
private setFocusedNode(node: BabylonSelectablePartNode | null): void {
|
|
if (!this.rootMesh) return;
|
|
const target = node ? this.findSelectableNode(node) : null;
|
|
if (!target || isBabylonNodeDisposed(target)) {
|
|
this.clearFocusedMesh();
|
|
return;
|
|
}
|
|
if (this.focusedNode === target) return;
|
|
|
|
const renderableMeshes = this.getRenderableMeshes(this.rootMesh);
|
|
const selectedMeshes = renderableMeshes.filter((candidate) => isBabylonNodeOrDescendant(candidate, target));
|
|
if (selectedMeshes.length === 0) {
|
|
this.clearFocusedMesh();
|
|
return;
|
|
}
|
|
const selectedMeshSet = new Set(selectedMeshes);
|
|
for (const candidate of renderableMeshes) {
|
|
if (!this.originalMeshVisibility.has(candidate.uniqueId)) {
|
|
this.originalMeshVisibility.set(candidate.uniqueId, candidate.visibility);
|
|
}
|
|
const selected = selectedMeshSet.has(candidate);
|
|
candidate.visibility = selected ? 1 : FOCUS_DIM_VISIBILITY;
|
|
candidate.renderOutline = selected;
|
|
candidate.outlineColor = new Color3(0.18, 0.76, 1);
|
|
candidate.outlineWidth = selected ? 0.045 : 0;
|
|
}
|
|
this.focusedNode = target;
|
|
}
|
|
|
|
private clearFocusedMesh(): void {
|
|
if (!this.rootMesh) {
|
|
this.focusedNode = null;
|
|
return;
|
|
}
|
|
|
|
for (const mesh of this.getRenderableMeshes(this.rootMesh)) {
|
|
const originalVisibility = this.originalMeshVisibility.get(mesh.uniqueId);
|
|
if (originalVisibility !== undefined) {
|
|
mesh.visibility = originalVisibility;
|
|
}
|
|
mesh.renderOutline = false;
|
|
mesh.outlineWidth = 0;
|
|
}
|
|
this.originalMeshVisibility.clear();
|
|
this.focusedNode = null;
|
|
this.applyMeasurementTargetVisual(this.measurementTargetMeshes);
|
|
}
|
|
|
|
private findSelectableNode(node: BabylonSelectablePartNode): BabylonSelectablePartNode | null {
|
|
if (!this.rootMesh) return null;
|
|
const renderableMeshes = this.getRenderableMeshes(this.rootMesh);
|
|
if (renderableMeshes.includes(node as AbstractMesh)) return node;
|
|
if (renderableMeshes.some((mesh) => isBabylonNodeOrDescendant(mesh, node))) return node;
|
|
if (!isBabylonMesh(node)) return null;
|
|
|
|
let parent = node.parent;
|
|
while (parent && "uniqueId" in parent) {
|
|
const parentNode = parent as BabylonSelectablePartNode;
|
|
if (renderableMeshes.includes(parentNode as AbstractMesh)) return parentNode;
|
|
if (renderableMeshes.some((mesh) => isBabylonNodeOrDescendant(mesh, parentNode))) return parentNode;
|
|
parent = parent.parent;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private getCurrentMeasurementTargetNode(): BabylonSelectablePartNode | null {
|
|
const candidate = this.focusedNode
|
|
?? (isBabylonSelectablePartNode(this._lastPickResult.mesh) ? this._lastPickResult.mesh : null);
|
|
if (!candidate || isBabylonNodeDisposed(candidate)) return null;
|
|
return this.findSelectableNode(candidate);
|
|
}
|
|
|
|
private setMeasurementTargetNode(node: BabylonSelectablePartNode | null, notify = true): void {
|
|
this.clearMeasurementTargetVisual(false);
|
|
this.invalidateMeasurementSnapInputCache();
|
|
const target = node && !isBabylonNodeDisposed(node) ? this.findSelectableNode(node) : null;
|
|
if (!target || isBabylonNodeDisposed(target)) {
|
|
this.measurementTargetNode = null;
|
|
this.measurementTargetMeshes = [];
|
|
this.setMeasurementSnapKind(null, false);
|
|
if (notify) {
|
|
this.notifyMeasurementsChanged();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const selectedMeshes = this.getMeasurementTargetMeshes(target);
|
|
if (selectedMeshes.length === 0) {
|
|
this.measurementTargetNode = null;
|
|
this.measurementTargetMeshes = [];
|
|
this.setMeasurementSnapKind(null, false);
|
|
if (notify) {
|
|
this.notifyMeasurementsChanged();
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.measurementTargetNode = target;
|
|
this.measurementTargetMeshes = selectedMeshes;
|
|
this.setMeasurementSnapKind(null, false);
|
|
this.applyMeasurementTargetVisual(selectedMeshes);
|
|
this.scene.render();
|
|
if (notify) {
|
|
this.notifyMeasurementsChanged();
|
|
}
|
|
}
|
|
|
|
private clearMeasurementTargetVisual(render = true): void {
|
|
for (const mesh of this.measurementTargetMeshes) {
|
|
if (mesh.isDisposed()) continue;
|
|
mesh.renderOutline = false;
|
|
mesh.outlineWidth = 0;
|
|
}
|
|
this.measurementTargetMeshes = [];
|
|
if (render && !this.scene.isDisposed) {
|
|
this.scene.render();
|
|
}
|
|
}
|
|
|
|
private applyMeasurementTargetVisual(meshes: readonly AbstractMesh[]): void {
|
|
if (!this.measurementActive || meshes.length === 0) return;
|
|
for (const mesh of meshes) {
|
|
if (mesh.isDisposed()) continue;
|
|
mesh.renderOutline = true;
|
|
mesh.outlineColor = new Color3(0.38, 0.65, 0.98);
|
|
mesh.outlineWidth = 0.045;
|
|
}
|
|
}
|
|
|
|
private setMeasurementSnapKind(kind: MeasurementSnapKind | null, notify = true): void {
|
|
if (this.measurementSnapKind === kind) return;
|
|
this.measurementSnapKind = kind;
|
|
if (notify) {
|
|
this.notifyMeasurementsChanged();
|
|
}
|
|
}
|
|
|
|
private invalidateMeasurementSnapInputCache(): void {
|
|
this.measurementSnapInputCache = null;
|
|
this.measurementSnapInputCacheTargetId = null;
|
|
this.measurementSnapInputCacheSignature = null;
|
|
}
|
|
|
|
private getMeasurementTargetName(): string | null {
|
|
const target = this.measurementTargetNode;
|
|
if (!target || isBabylonNodeDisposed(target)) return null;
|
|
return getBabylonNodeDisplayName(target, target.name || `node-${target.uniqueId}`);
|
|
}
|
|
|
|
private getMeasurementTargetBounds(): PreviewBounds | null {
|
|
if (!this.rootMesh || !this.measurementTargetNode || isBabylonNodeDisposed(this.measurementTargetNode)) {
|
|
return null;
|
|
}
|
|
const target = this.findSelectableNode(this.measurementTargetNode);
|
|
if (!target) return null;
|
|
this.measurementTargetNode = target;
|
|
const selectedMeshes = this.getRenderableMeshes(this.rootMesh)
|
|
.filter((candidate) => isBabylonNodeOrDescendant(candidate, target));
|
|
return getBabylonMeshesPreviewBounds(selectedMeshes);
|
|
}
|
|
|
|
private resolveMeasurementPickPoint(point: Vector3, forceFreePick: boolean): Vector3 {
|
|
if (forceFreePick) {
|
|
this.setMeasurementSnapKind("free");
|
|
return point;
|
|
}
|
|
const snapInput = this.createMeasurementGeometrySnapInput();
|
|
if (!snapInput) {
|
|
this.setMeasurementSnapKind("free");
|
|
return point;
|
|
}
|
|
const snapped = snapMeasurementPointToGeometry(this.toMeasurementPoint(point), snapInput);
|
|
if (!snapped) {
|
|
this.setMeasurementSnapKind("free");
|
|
return point;
|
|
}
|
|
this.setMeasurementSnapKind(snapped.kind);
|
|
return toBabylonVector3(snapped.point);
|
|
}
|
|
|
|
private createMeasurementGeometrySnapInput(): MeasurementGeometrySnapInput | null {
|
|
const target = this.measurementTargetNode;
|
|
if (!this.rootMesh || !target || isBabylonNodeDisposed(target)) return null;
|
|
const meshes = this.getMeasurementTargetMeshes(target);
|
|
if (meshes.length === 0) return null;
|
|
const signature = this.createMeasurementSnapInputSignature(meshes);
|
|
if (
|
|
this.measurementSnapInputCache &&
|
|
this.measurementSnapInputCacheTargetId === target.uniqueId &&
|
|
this.measurementSnapInputCacheSignature === signature
|
|
) {
|
|
return this.measurementSnapInputCache;
|
|
}
|
|
|
|
const vertices: MeasurementSnapVertexCandidate[] = [];
|
|
const edges: MeasurementSnapEdgeCandidate[] = [];
|
|
const targetId = `babylon:${target.uniqueId}`;
|
|
|
|
for (const mesh of meshes) {
|
|
if (mesh.isDisposed()) continue;
|
|
const positions = mesh.getVerticesData(VertexBuffer.PositionKind);
|
|
if (!positions || positions.length < 3) continue;
|
|
const matrix = mesh.computeWorldMatrix(true);
|
|
const objectVertices: PreviewWorldPoint[] = [];
|
|
const vertexCount = Math.floor(positions.length / 3);
|
|
for (let i = 0; i < vertexCount; i++) {
|
|
const local = new Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]);
|
|
const world = Vector3.TransformCoordinates(local, matrix);
|
|
const previewPoint = this.toMeasurementPoint(world);
|
|
objectVertices.push(previewPoint);
|
|
vertices.push({ point: previewPoint, targetId });
|
|
}
|
|
const triangles = createMeasurementTrianglesFromIndices(vertexCount, mesh.getIndices() ?? null);
|
|
edges.push(...createMeasurementGeometryEdgesFromTriangles(objectVertices, triangles, targetId));
|
|
}
|
|
|
|
if (vertices.length === 0 && edges.length === 0) return null;
|
|
const bounds = this.getMeasurementTargetBounds();
|
|
const size = bounds ? getPreviewBoundsSize(bounds) : { x: 1, y: 1, z: 1 };
|
|
const input = {
|
|
vertices,
|
|
edges,
|
|
targetId,
|
|
vertexRadius: Math.max(size.x, size.y, size.z, 0.001) * 0.045,
|
|
};
|
|
this.measurementSnapInputCache = input;
|
|
this.measurementSnapInputCacheTargetId = target.uniqueId;
|
|
this.measurementSnapInputCacheSignature = signature;
|
|
return input;
|
|
}
|
|
|
|
private createMeasurementSnapInputSignature(meshes: readonly AbstractMesh[]): string {
|
|
return meshes.map((mesh) => {
|
|
const geometryId = "geometry" in mesh && mesh.geometry
|
|
? (mesh.geometry as { uniqueId?: number }).uniqueId ?? "geometry"
|
|
: "none";
|
|
return [
|
|
mesh.uniqueId,
|
|
geometryId,
|
|
mesh.getTotalVertices(),
|
|
mesh.getTotalIndices(),
|
|
mesh.computeWorldMatrix(true).asArray().map(formatMeasurementSnapSignatureNumber).join(","),
|
|
].join(":");
|
|
}).join("|");
|
|
}
|
|
|
|
private getMeasurementTargetMeshes(target = this.measurementTargetNode): AbstractMesh[] {
|
|
if (!this.rootMesh || !target || isBabylonNodeDisposed(target)) return [];
|
|
return this.getRenderableMeshes(this.rootMesh)
|
|
.filter((candidate) => !candidate.isDisposed() && isBabylonNodeOrDescendant(candidate, target));
|
|
}
|
|
|
|
private getMeasurementTargetPickPoint(mesh: AbstractMesh | null | undefined, point: Vector3 | null | undefined): Vector3 | null {
|
|
if (!mesh || mesh.isDisposed() || !point) return null;
|
|
return this.measurementTargetMeshes.includes(mesh) ? point.clone() : null;
|
|
}
|
|
|
|
private pickFrontmostMeasurementTargetPoint(clientX: number, clientY: number): Vector3 | null {
|
|
const canvas = this.engine.getRenderingCanvas();
|
|
if (!canvas || this.measurementTargetMeshes.length === 0) return null;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = clientX - rect.left;
|
|
const y = clientY - rect.top;
|
|
const pickResult = this.scene.pick(x, y, (mesh) =>
|
|
mesh !== this.previewLine && !this.measurementMarkers.includes(mesh as Mesh));
|
|
return pickResult?.hit
|
|
? this.getMeasurementTargetPickPoint(pickResult.pickedMesh, pickResult.pickedPoint)
|
|
: null;
|
|
}
|
|
|
|
private createBabylonMeasurementDraftingLayout(start: Vector3, end: Vector3): {
|
|
lineSegments: Vector3[][];
|
|
labelPosition: Vector3;
|
|
} | null {
|
|
const displayStart = this.toMeasurementDisplayPoint(start);
|
|
const displayEnd = this.toMeasurementDisplayPoint(end);
|
|
const markerSize = this.getMeasurementMarkerSize();
|
|
const viewUp = this.camera.upVector?.clone?.() ?? new Vector3(0, 1, 0);
|
|
viewUp.normalize();
|
|
const layout = createMeasurementDraftingLayout(
|
|
this.toMeasurementPoint(displayStart),
|
|
this.toMeasurementPoint(displayEnd),
|
|
{
|
|
viewPosition: this.toMeasurementPoint(this.camera.position),
|
|
viewUp: this.toMeasurementPoint(viewUp),
|
|
offset: markerSize * 4.2,
|
|
extensionGap: markerSize * 0.55,
|
|
extensionOvershoot: markerSize * 0.8,
|
|
arrowLength: markerSize * 2.35,
|
|
arrowWidth: markerSize * 0.78,
|
|
labelGap: markerSize * 1.05,
|
|
},
|
|
);
|
|
if (!layout) return null;
|
|
return {
|
|
lineSegments: layout.lineSegments.map(([left, right]) => [
|
|
toBabylonVector3(left),
|
|
toBabylonVector3(right),
|
|
]),
|
|
labelPosition: toBabylonVector3(layout.labelPoint),
|
|
};
|
|
}
|
|
|
|
private getMeasurementMarkerSize(): number {
|
|
if (!this.rootMesh) return 0.02;
|
|
const bounds = this.getRenderableBounds(this.rootMesh);
|
|
if (!bounds) return 0.02;
|
|
const maxSpan = Math.max(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z, 0.001);
|
|
return maxSpan * 0.018;
|
|
}
|
|
|
|
private cancelPendingMeasurement(markDirty = true): void {
|
|
const pendingMarker = this.pendingMarker;
|
|
const pendingPoint = this.pendingPoint?.clone() ?? null;
|
|
this.pendingPoint = null;
|
|
this.pendingMarker = null;
|
|
this.hoveredMarkerIndex = -1;
|
|
if (pendingPoint) {
|
|
this.setMeasurementSnapKind(null, false);
|
|
}
|
|
this.removePreviewLine();
|
|
|
|
if (pendingMarker && pendingPoint && !this.isMeasurementPointUsed(pendingPoint)) {
|
|
const index = this.measurementMarkers.indexOf(pendingMarker);
|
|
if (index >= 0) {
|
|
this.measurementMarkers.splice(index, 1);
|
|
this.measurementMarkerPoints.splice(index, 1);
|
|
}
|
|
pendingMarker.dispose(false, true);
|
|
} else if (pendingMarker) {
|
|
pendingMarker.scaling.setAll(1);
|
|
setMeasurementMarkerColor(pendingMarker, MEASUREMENT_MARKER_COLOR);
|
|
}
|
|
|
|
if (markDirty) {
|
|
this.scene.render();
|
|
}
|
|
}
|
|
|
|
private isMeasurementPointUsed(point: Vector3): boolean {
|
|
return this.measurementSegments.some((segment) =>
|
|
Vector3.Distance(segment.start, point) < 0.0001 || Vector3.Distance(segment.end, point) < 0.0001);
|
|
}
|
|
|
|
private findNearestMarkerIndex(point: Vector3): number {
|
|
const threshold = this.getMeasurementMarkerSize() * 2.5;
|
|
const displayPoint = this.toMeasurementDisplayPoint(point);
|
|
for (let i = 0; i < this.measurementMarkers.length; i++) {
|
|
const candidate = this.measurementMarkerPoints[i]
|
|
? this.toMeasurementDisplayPoint(this.measurementMarkerPoints[i])
|
|
: this.measurementMarkers[i].position;
|
|
if (Vector3.Distance(candidate, displayPoint) < threshold) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
private addMeasurementPoint(point: Vector3): void {
|
|
const basePoint = this.toMeasurementBasePoint(point);
|
|
const existingIndex = this.findNearestMarkerIndex(basePoint);
|
|
const usePoint = existingIndex >= 0
|
|
? this.measurementMarkerPoints[existingIndex].clone()
|
|
: basePoint;
|
|
|
|
if (this.pendingPoint) {
|
|
if (Vector3.Distance(usePoint, this.pendingPoint) < 0.0001) {
|
|
return;
|
|
}
|
|
if (existingIndex < 0) {
|
|
const size = this.getMeasurementMarkerSize();
|
|
const marker = MeshBuilder.CreateSphere("measure-marker", { diameter: size * 0.76, segments: 12 }, this.scene);
|
|
marker.position = this.toMeasurementDisplayPoint(usePoint);
|
|
marker.isPickable = false;
|
|
marker.material = createMeasurementMarkerMaterial(this.scene);
|
|
marker.renderingGroupId = 2;
|
|
this.measurementMarkers.push(marker);
|
|
this.measurementMarkerPoints.push(usePoint.clone());
|
|
}
|
|
this.createMeasurementSegment(this.pendingPoint, usePoint);
|
|
if (this.pendingMarker) {
|
|
this.pendingMarker.scaling.setAll(1);
|
|
setMeasurementMarkerColor(this.pendingMarker, MEASUREMENT_MARKER_COLOR);
|
|
}
|
|
this.pendingPoint = null;
|
|
this.pendingMarker = null;
|
|
this.removePreviewLine();
|
|
} else {
|
|
if (existingIndex < 0) {
|
|
const size = this.getMeasurementMarkerSize();
|
|
const marker = MeshBuilder.CreateSphere("measure-marker", { diameter: size * 0.76, segments: 12 }, this.scene);
|
|
marker.position = this.toMeasurementDisplayPoint(usePoint);
|
|
marker.isPickable = false;
|
|
marker.material = createMeasurementMarkerMaterial(this.scene);
|
|
marker.renderingGroupId = 2;
|
|
this.measurementMarkers.push(marker);
|
|
this.measurementMarkerPoints.push(usePoint.clone());
|
|
this.pendingMarker = marker;
|
|
} else {
|
|
this.pendingMarker = this.measurementMarkers[existingIndex];
|
|
}
|
|
this.pendingMarker.scaling.setAll(1.6);
|
|
setMeasurementMarkerColor(this.pendingMarker, MEASUREMENT_PENDING_COLOR);
|
|
this.pendingPoint = usePoint;
|
|
this.ensurePreviewLine();
|
|
}
|
|
this.notifyMeasurementsChanged();
|
|
}
|
|
|
|
private createMeasurementSegment(start: Vector3, end: Vector3): void {
|
|
const displayStart = this.toMeasurementDisplayPoint(start);
|
|
const displayEnd = this.toMeasurementDisplayPoint(end);
|
|
const layout = this.createBabylonMeasurementDraftingLayout(start, end);
|
|
const line = MeshBuilder.CreateLineSystem("measure-line", {
|
|
lines: layout?.lineSegments ?? [[displayStart, displayEnd]],
|
|
}, this.scene);
|
|
line.color = MEASUREMENT_LINE_COLOR.clone();
|
|
line.alpha = 1;
|
|
line.isPickable = false;
|
|
line.renderingGroupId = 2;
|
|
|
|
const labelText = createMeasurementLabel(this.createMeasurementReading(start, end));
|
|
const mid = layout?.labelPosition ?? Vector3.Center(displayStart, displayEnd);
|
|
const label = this.createMeasurementLabelMesh(labelText, mid, this.getMeasurementMarkerSize() * 3.2);
|
|
|
|
this.measurementSegments.push({ start: start.clone(), end: end.clone(), line, label });
|
|
}
|
|
|
|
private createMeasurementLabelMesh(text: { primary: string; secondary: string }, position: Vector3, scale: number): Mesh {
|
|
const plane = MeshBuilder.CreatePlane("measure-label", { width: scale * 3.7, height: scale * 1.08 }, this.scene);
|
|
plane.position = position;
|
|
plane.billboardMode = Mesh.BILLBOARDMODE_ALL;
|
|
plane.isPickable = false;
|
|
plane.renderingGroupId = 2;
|
|
|
|
const texture = new DynamicTexture("measure-label-tex", { ...MEASUREMENT_LABEL_CANVAS }, this.scene);
|
|
texture.hasAlpha = true;
|
|
const ctx = texture.getContext() as CanvasRenderingContext2D;
|
|
drawMeasurementLabelCanvas(ctx, text, MEASUREMENT_LABEL_CANVAS.width, MEASUREMENT_LABEL_CANVAS.height);
|
|
texture.update();
|
|
|
|
const mat = new StandardMaterial("measure-label-mat", this.scene);
|
|
mat.diffuseTexture = texture;
|
|
mat.emissiveColor = new Color3(1, 1, 1);
|
|
mat.disableLighting = true;
|
|
mat.disableDepthWrite = true;
|
|
mat.opacityTexture = texture;
|
|
mat.useAlphaFromDiffuseTexture = true;
|
|
plane.material = mat;
|
|
return plane;
|
|
}
|
|
|
|
private ensurePreviewLine(): void {
|
|
if (this.previewLine) return;
|
|
this.previewLine = MeshBuilder.CreateLineSystem("measure-preview", {
|
|
lines: Array.from({ length: 7 }, () => [Vector3.Zero(), Vector3.Zero()]),
|
|
updatable: true,
|
|
}, this.scene);
|
|
this.previewLine.color = MEASUREMENT_PREVIEW_COLOR.clone();
|
|
this.previewLine.alpha = 0.82;
|
|
this.previewLine.isPickable = false;
|
|
this.previewLine.renderingGroupId = 2;
|
|
}
|
|
|
|
private updatePreviewLine(): void {
|
|
if (!this.pendingPoint || !this.previewLine || !this.rootMesh) return;
|
|
const displayStart = this.toMeasurementDisplayPoint(this.pendingPoint);
|
|
const canvas = this.engine.getRenderingCanvas();
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = this.lastPointerClient.x - rect.left;
|
|
const y = this.lastPointerClient.y - rect.top;
|
|
let endPoint: Vector3 | null = null;
|
|
if (this.lastPointerClient.altKey) {
|
|
this.setMeasurementSnapKind("free");
|
|
const pickResult = this.scene.pick(x, y, (mesh) => mesh !== this.previewLine && !this.measurementMarkers.includes(mesh as Mesh));
|
|
endPoint = pickResult.hit && pickResult.pickedPoint
|
|
? this.resolveMeasurementPickPoint(pickResult.pickedPoint, true)
|
|
: displayStart.add(this.scene.createPickingRay(x, y, Matrix.Identity(), this.camera).direction.scale(5));
|
|
} else {
|
|
const targetPoint = this.pickFrontmostMeasurementTargetPoint(this.lastPointerClient.x, this.lastPointerClient.y);
|
|
if (targetPoint) {
|
|
endPoint = this.resolveMeasurementPickPoint(targetPoint, false);
|
|
} else {
|
|
this.setMeasurementSnapKind(null);
|
|
}
|
|
}
|
|
const previewLayout = endPoint
|
|
? this.createBabylonMeasurementDraftingLayout(this.pendingPoint, this.toMeasurementBasePoint(endPoint))
|
|
: null;
|
|
this.previewLine = MeshBuilder.CreateLineSystem("measure-preview", {
|
|
lines: previewLayout?.lineSegments ?? [[displayStart, displayStart]],
|
|
instance: this.previewLine,
|
|
}, this.scene);
|
|
this.previewLine.color = MEASUREMENT_PREVIEW_COLOR.clone();
|
|
this.previewLine.alpha = 0.82;
|
|
this.previewLine.isPickable = false;
|
|
this.previewLine.renderingGroupId = 2;
|
|
}
|
|
|
|
private removePreviewLine(): void {
|
|
if (!this.previewLine) return;
|
|
this.previewLine.dispose();
|
|
this.previewLine = null;
|
|
}
|
|
|
|
private createMeasurementReading(start: Vector3, end: Vector3): MeasurementReading {
|
|
return buildMeasurementReading(
|
|
this.toMeasurementPoint(start),
|
|
this.toMeasurementPoint(end),
|
|
this.measurementScale,
|
|
this.measurementUnit,
|
|
);
|
|
}
|
|
|
|
private createMeasurementRecords(): MeasurementRecord[] {
|
|
return this.measurementSegments.map((segment, index) => ({
|
|
index: index + 1,
|
|
start: this.toMeasurementPoint(segment.start),
|
|
end: this.toMeasurementPoint(segment.end),
|
|
reading: this.createMeasurementReading(segment.start, segment.end),
|
|
}));
|
|
}
|
|
|
|
private toMeasurementPoint(point: Vector3): PreviewWorldPoint {
|
|
return { x: point.x, y: point.y, z: point.z };
|
|
}
|
|
|
|
private createModelSummary(root: Mesh): ModelPreviewSummary {
|
|
const allMeshes = this.getRenderableMeshes(root);
|
|
const isSplat = isGaussianSplattingMesh(root);
|
|
const vertexCount = allMeshes.reduce((total, mesh) => total + getBabylonVertexCount(mesh), 0);
|
|
return createBabylonModelPreviewSummary(
|
|
root.name,
|
|
this.getRenderableBounds(root),
|
|
allMeshes,
|
|
{ splatCount: isSplat ? vertexCount : undefined, resourceWarnings: this.resourceWarnings },
|
|
);
|
|
}
|
|
}
|
|
|
|
function formatMeasurementSnapSignatureNumber(value: number): string {
|
|
return Number.isFinite(value) ? value.toPrecision(10) : String(value);
|
|
}
|
|
|
|
export function createBabylonModelPreview(canvas: HTMLCanvasElement): WorkbenchPreview {
|
|
return new BabylonModelPreview(canvas);
|
|
}
|