All pastes #2WGMmNGaDg Raw Edit

text paste

public text v1 · immutable
#2WGMmNGaDg ·published 2026-06-10 12:54 UTC
rendered paste body
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera";import { Engine } from "@babylonjs/core/Engines/engine";import { HighlightLayer } from "@babylonjs/core/Layers/highlightLayer";import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight";import { HemisphericLight } from "@babylonjs/core/Lights/hemisphericLight";import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator";import { CascadedShadowGenerator } from "@babylonjs/core/Lights/Shadows/cascadedShadowGenerator";import { ImageProcessingConfiguration } from "@babylonjs/core/Materials/imageProcessingConfiguration";import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial";import { CubeTexture } from "@babylonjs/core/Materials/Textures/cubeTexture";import { RawCubeTexture } from "@babylonjs/core/Materials/Textures/rawCubeTexture";import { DynamicTexture } from "@babylonjs/core/Materials/Textures/dynamicTexture";import { Color3, Color4 } from "@babylonjs/core/Maths/math.color";import { Matrix, Quaternion, Vector3 } from "@babylonjs/core/Maths/math.vector";import { Mesh } from "@babylonjs/core/Meshes/mesh";import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";import { Scene } from "@babylonjs/core/scene";import { SSAO2RenderingPipeline } from "@babylonjs/core/PostProcesses/RenderPipeline/Pipelines/ssao2RenderingPipeline";import { FxaaPostProcess } from "@babylonjs/core/PostProcesses/fxaaPostProcess";import { SceneInstrumentation } from "@babylonjs/core/Instrumentation/sceneInstrumentation";import "@babylonjs/core/Meshes/thinInstanceMesh";// Side-effect imports for Babylon.js v9 tree-shaking// Without these, Vite/Rolldown strips registration of core componentsimport "@babylonjs/core/PostProcesses/RenderPipeline/postProcessRenderPipelineManagerSceneComponent";import "@babylonjs/core/Rendering/edgesRenderer";import "@babylonjs/core/Meshes/instancedMesh";import "@babylonjs/core/Culling/ray";import { useCallback, useEffect, useRef } from "react";import {	computeRackGridLines,	computeSrmGridLines,	detectEntityState,	ENTITY_COLORS,	getDefaultTransparent,	getSrmBackgroundColor,	hashColorHue,	ITEM_FILL_COLOR,	ITEM_LINE_COLOR,	normalizeRotation,	resolveShape3D,} from "@/lib/entityRenderUtils";import type { EntityShape3D } from "@/lib/entityRenderUtils";import { useSimStore } from "@/store/useSimStore";import type { EntityDTO } from "@/types/simulation";import {	buildShapeMesh,	buildCardboardBoxMesh,	buildLoadUnitMesh,	buildToteMesh,	createQuickMat,	clearMaterialCache,	C,} from "../scene3d/meshBuilders";import type { ShapeKind } from "../scene3d/meshBuilders";import { ThinInstanceManager } from "../scene3d/thinInstanceManager";function hexToColor3(	color: string | null | undefined,	fallback: Color3,): Color3 {	if (color == null || color === "") return fallback;	const hex = color.replace("#", "");	if (hex.length < 6) return fallback;	const r = parseInt(hex.substring(0, 2), 16) / 255;	const g = parseInt(hex.substring(2, 4), 16) / 255;	const b = parseInt(hex.substring(4, 6), 16) / 255;	if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b))		return fallback;	return new Color3(r, g, b);}function hexToColor4(hex: string | null | undefined): Color4 {	if (hex == null || hex === "") return new Color4(1, 1, 1, 1);	const h = hex.replace("#", "");	if (h.length < 6) return new Color4(1, 1, 1, 1);	const r = parseInt(h.substring(0, 2), 16) / 255;	const g = parseInt(h.substring(2, 4), 16) / 255;	const b = parseInt(h.substring(4, 6), 16) / 255;	if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b))		return new Color4(1, 1, 1, 1);	return new Color4(r, g, b, 1);}/** Group entity type strings into broad categories for performance analysis. */function categorizeEntityType(type: string): string {	if (type.startsWith("Bin")) return "Bin";	if (type.startsWith("Pallet")) return "Pallet";	if (type.startsWith("Rack")) return "Rack";	if (type.startsWith("Conveyor")) return "Conveyor";	if (type.startsWith("SRM") || type.startsWith("Srm")) return "SRM";	if (type.startsWith("Lift")) return "Lift";	if (type.startsWith("Robot")) return "Robot";	if (type.startsWith("Shuttle")) return "Shuttle";	if (type.startsWith("Connector")) return "Connector";	if (type.startsWith("Virtual")) return "Virtual";	if (type.startsWith("Shape")) return "Shape";	if (type.startsWith("Overhead")) return "Overhead";	if (type.startsWith("Emulator")) return "Emulator";	if (type.startsWith("Symbol")) return "Symbol";	if (type.startsWith("Scanner")) return "Scanner";	if (type.startsWith("Dispatcher")) return "Dispatcher";	if (type.startsWith("Processor")) return "Processor";	if (type.startsWith("Buffer")) return "Buffer";	if (type.startsWith("Diverter")) return "Diverter";	if (type.startsWith("Merge")) return "Merge";	if (type.startsWith("Turntable")) return "Turntable";	if (type.startsWith("Stacker")) return "Stacker";	if (type.startsWith("Destacker")) return "Destacker";	if (type.startsWith("Feeding")) return "Feeding";	if (type.startsWith("Removal")) return "Removal";	if (type.startsWith("Waypoint")) return "Waypoint";	if (type.startsWith("PCX")) return "PCX";	if (type.startsWith("SDXP")) return "SDXP";	if (type.startsWith("TT")) return "TT";	if (type.startsWith("VMax")) return "VMax";	if (type.startsWith("Cuby")) return "Cuby";	if (type.startsWith("Weasel")) return "Weasel";	if (type.startsWith("PPS")) return "PPS";	if (type.startsWith("Pbl")) return "Pbl";	return type;}/** * Convert HSL values to Babylon Color3. * Uses the same algorithm as hashColor() in entityRenderUtils for consistent * per-entity coloring between 2D and 3D. */function hslToColor3(hue: number, sat: number, light: number): Color3 {	const c = (1 - Math.abs(2 * light - 1)) * sat;	const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));	const m = light - c / 2;	let r = 0,		g = 0,		b = 0;	if (hue < 60) { r = c; g = x; }	else if (hue < 120) { r = x; g = c; }	else if (hue < 180) { g = c; b = x; }	else if (hue < 240) { g = x; b = c; }	else if (hue < 300) { r = x; b = c; }	else { r = c; b = x; }	return new Color3(r + m, g + m, b + m);}/** * Per-entity-type PBR material parameters for realistic rendering. * Conveyors → matte industrial (low metallic, medium roughness) * Racks → painted metal (medium metallic, medium roughness) * SRM → machinery (higher metallic, lower roughness) * Robots → smooth surfaces (medium metallic, low roughness) * SCS → electronics (low metallic, medium roughness) * Shuttles → painted (medium metallic, medium roughness) * Scanners → equipment (medium metallic, medium roughness) */function getKindMaterialParams(kind: EntityShape3D): { metallic: number; roughness: number } {	switch (kind) {		case "srm":			return { metallic: 0.2, roughness: 0.45 };  // painted metal		case "robot":			return { metallic: 0.15, roughness: 0.5 };   // painted body		case "shuttle":			return { metallic: 0.2, roughness: 0.45 };   // painted metal		case "rack":			return { metallic: 0.15, roughness: 0.55 };  // painted steel		case "scanner":		case "lift":			return { metallic: 0.15, roughness: 0.5 };   // painted equipment		case "scs":			return { metallic: 0.1, roughness: 0.55 };   // plastic/electronic		case "conveyor":		case "diverter":		case "merge":			return { metallic: 0.05, roughness: 0.6 };   // painted steel		case "processor":		case "dispatcher":			return { metallic: 0.15, roughness: 0.5 };   // painted equipment		case "source":		case "sink":			return { metallic: 0.1, roughness: 0.55 };   // painted		case "turntable":			return { metallic: 0.15, roughness: 0.45 };  // painted metal		case "cylinder":			return { metallic: 0.15, roughness: 0.5 };   // painted		default:			return { metallic: 0.1, roughness: 0.55 };	}}function adjustColorForLayer(color: Color3, layer: number): Color3 {	if (layer === 0) return color;	const shift = layer * 0.04;	return new Color3(		Math.min(1, color.r + shift * 0.5),		Math.min(1, color.g + shift * 0.3),		Math.min(1, color.b - shift * 0.4),	);}function createArrowCone(	head: Vector3,	dir: Vector3,	name: string,	scene: Scene,): Mesh {	const coneLength = 0.35;	const coneRadius = 0.18;	const conePos = head.subtract(dir.scale(coneLength * 0.5));	const cone = MeshBuilder.CreateCylinder(		name,		{			height: coneLength,			diameterTop: 0,			diameterBottom: coneRadius * 2,			tessellation: 8,		},		scene,	);	cone.position = conePos;	const yAxis = Vector3.Up();	const dot = Vector3.Dot(yAxis, dir);	if (Math.abs(dot) > 0.9999) {		if (dot < 0) {			cone.rotation.x = Math.PI;		}	} else {		const axis = Vector3.Cross(yAxis, dir).normalize();		const angle = Math.acos(dot);		cone.rotationQuaternion = Quaternion.RotationAxis(axis, angle);	}	cone.isPickable = false;	return cone;}/** * Create a procedural cube-map environment texture for PBR reflections. * Generates a studio-style lighting environment (soft gradient from * warm gray ceiling → neutral walls → dark cool floor). * This gives PBR materials something to reflect without requiring * an external HDR/EXR file. */function createProceduralEnvTexture(scene: Scene, size = 128): CubeTexture | null {	try {		const faceSize = size;		const faces: ArrayBufferView[] = [];		const faceDataSize = faceSize * faceSize * 4;		for (let face = 0; face < 6; face++) {			const data = new Uint8Array(faceDataSize);			for (let y = 0; y < faceSize; y++) {				for (let x = 0; x < faceSize; x++) {					const idx = (y * faceSize + x) * 4;					const u = x / faceSize;					const v = y / faceSize;					let r: number, g: number, b: number;					switch (face) {						case 2: { // +Y (top/ceiling): warehouse ceiling with bright LED lights							// Base ceiling color (warm white)							r = 180; g = 185; b = 195;							// Simulate industrial LED light strips (rows of bright rectangles)							const stripeY = Math.floor(v * 8);							const stripeX = Math.floor(u * 6);							const inStripe = (v * 8 - stripeY) > 0.45 && (v * 8 - stripeY) < 0.55;							const brightSpot = (u * 6 - stripeX) > 0.3 && (u * 6 - stripeX) < 0.7;							if (inStripe && brightSpot) {								// Very bright warm white LED								const intensity = 0.9 + 0.1 * Math.sin(u * 40) * Math.cos(v * 40);								r = Math.round(255 * intensity);								g = Math.round(245 * intensity);								b = Math.round(230 * intensity);							}							break;						}						case 3: // -Y (bottom/floor): dark warm gray with subtle reflections							r = Math.round(50 + 20 * (1 - v));							g = Math.round(52 + 20 * (1 - v));							b = Math.round(60 + 20 * (1 - v));							break;						default: {							// sides: warehouse walls with warm ambient							const mix = v;							// Top = ceiling warm, mid = wall neutral, bottom = floor dark							const topR = 175, topG = 178, topB = 188;							const midR = 120, midG = 122, midB = 135;							const botR = 45, botG = 48, botB = 58;							if (mix < 0.35) {								const t = mix / 0.35;								r = Math.round(topR + (midR - topR) * t);								g = Math.round(topG + (midG - topG) * t);								b = Math.round(topB + (midB - topB) * t);							} else {								const t = (mix - 0.35) / 0.65;								r = Math.round(midR + (botR - midR) * t);								g = Math.round(midG + (botG - midG) * t);								b = Math.round(midB + (botB - midB) * t);							}							// Subtle horizontal bands (warehouse structural beams)							if (Math.abs(v - 0.35) < 0.02 || Math.abs(v - 0.65) < 0.02) {								r = Math.round(r * 0.85);								g = Math.round(g * 0.85);								b = Math.round(b * 0.85);							}							break;						}					}					data[idx] = Math.min(255, Math.max(0, r));					data[idx + 1] = Math.min(255, Math.max(0, g));					data[idx + 2] = Math.min(255, Math.max(0, b));					data[idx + 3] = 255;				}			}			faces.push(data);		}		const texture = new RawCubeTexture(			scene,			faces,			faceSize,			5, // Engine.TEXTUREFORMAT_RGBA			0, // Engine.TEXTURETYPE_UNSIGNED_BYTE			true, // generateMipMaps for smoother reflections		);		texture.gammaSpace = false;		return texture;	} catch (e) {		console.warn("[Scene3D] Failed to create procedural env texture:", e);		return null;	}}/** * Create a dynamic ground texture with a subtle grid pattern * to give the ground plane visual detail. */function createGroundTexture(scene: Scene): DynamicTexture {	const tex = new DynamicTexture("groundDetail", 512, scene, false);	const ctx = tex.getContext();	const w = tex.getSize().width;	const h = tex.getSize().height;	// Dark industrial floor background	ctx.fillStyle = "#1a1a22";	ctx.fillRect(0, 0, w, h);	// Subtle grid lines every 32px (25 world units at default zoom)	ctx.strokeStyle = "#2a2a38";	ctx.lineWidth = 1;	const step = 32;	for (let x = 0; x <= w; x += step) {		ctx.beginPath();		ctx.moveTo(x, 0);		ctx.lineTo(x, h);		ctx.stroke();	}	for (let y = 0; y <= h; y += step) {		ctx.beginPath();		ctx.moveTo(0, y);		ctx.lineTo(w, y);		ctx.stroke();	}	// Slightly brighter grid lines every 128px (major divisions)	ctx.strokeStyle = "#3a3a4a";	ctx.lineWidth = 1.5;	const majorStep = step * 4;	for (let x = 0; x <= w; x += majorStep) {		ctx.beginPath();		ctx.moveTo(x, 0);		ctx.lineTo(x, h);		ctx.stroke();	}	for (let y = 0; y <= h; y += majorStep) {		ctx.beginPath();		ctx.moveTo(0, y);		ctx.lineTo(w, y);		ctx.stroke();	}	tex.update();	return tex;}export function Scene3D() {	const canvasRef = useRef<HTMLCanvasElement>(null);	const labelRef = useRef<HTMLDivElement>(null);	const engineRef = useRef<Engine | null>(null);	const sceneRef = useRef<Scene | null>(null);	const entityMeshesRef = useRef<Map<string, Mesh>>(new Map());	const itemMeshesRef = useRef<Map<string, Mesh>>(new Map());	const linkMeshRef = useRef<Mesh | null>(null);	const arrowCacheRef = useRef<Map<string, Mesh>>(new Map());	const flowParticlesRef = useRef<Mesh[]>([]);	const materialCacheRef = useRef<Map<string, PBRMaterial>>(new Map());	const selectedIdRef = useRef<string | null>(null);	const highlightRef = useRef<HighlightLayer | null>(null);	const disposedRef = useRef(false);	const groundRef = useRef<Mesh | null>(null);	const dragMeshRef = useRef<Mesh | null>(null);	const dragOrigPosRef = useRef<Vector3 | null>(null);	const isDraggingRef = useRef(false);	const isRotatingRef = useRef(false);	const pointerDownPosRef = useRef({ x: 0, y: 0 });	const rotateOrigYRef = useRef(0);	const rotateStartXRef = useRef(0);	const entitiesRef = useRef<Map<string, EntityDTO>>(new Map());	const entityShapeKindsRef = useRef<Map<string, ShapeKind>>(new Map());	const entitySizesRef = useRef<		Map<string, { sx: number; sy: number; sz: number }>	>(new Map());	const prevPositionsRef = useRef<		Map<string, { px: number; py: number; pz: number; t: number }>	>(new Map());	const overlayMeshesRef = useRef<Map<string, Mesh>>(new Map());	const overlayHashRef = useRef<Map<string, string>>(new Map());	const cameraRef = useRef<ArcRotateCamera | null>(null);	const metricsRef = useRef<HTMLDivElement | null>(null);	const thinManagerRef = useRef<ThinInstanceManager | null>(null);	let frameCounter = 0;	const selectEntity = useSimStore((s) => s.selectEntity);	const selectedEntityId = useSimStore((s) => s.selectedEntityId);	const updateEntity = useSimStore((s) => s.updateEntity);	const addEntity = useSimStore((s) => s.addEntity);	const simTime = useSimStore((s) => s.simTime);	const _simRunning = useSimStore((s) => s.simRunning);	const simTimeRef = useRef(0);	const simTimeWallRef = useRef(0);	useEffect(() => {		simTimeRef.current = simTime;		simTimeWallRef.current = performance.now();	}, [simTime]);	// Sync 3D viewport to store immediately when switching away from 3D	// and restore viewport from 2D when switching TO 3D	const viewMode = useSimStore((s) => s.viewMode);	const prevViewModeRef = useRef<"2d" | "3d">("2d");	useEffect(() => {		if (viewMode === "3d" && prevViewModeRef.current === "2d") {			// Restore viewport from 2D state when entering 3D			const cam = cameraRef.current;			if (cam) {				const vp = useSimStore.getState();				cam.target.set(vp.viewCenterX, 0, vp.viewCenterZ);				cam.radius = 60 / Math.max(vp.viewZoom, 0.1);			}		} else if (viewMode !== "3d") {			// Save current 3D viewport to store when leaving 3D			const cam = cameraRef.current;			if (cam) {				useSimStore.setState({					viewCenterX: cam.target.x,					viewCenterZ: cam.target.z,					viewZoom: 60 / cam.radius,				});			}		}		prevViewModeRef.current = viewMode;	}, [viewMode]);	selectedIdRef.current = selectedEntityId;	const getMaterial = useCallback(		(			scene: Scene,			key: string,			color: Color3,			alpha: number,			wireframe: boolean,			metallic = 0.0,			roughness = 0.7,		): PBRMaterial => {			const cache = materialCacheRef.current;			const matKey = `${key}_${metallic}_${roughness}`;			let mat = cache.get(matKey);			if (mat) {				// LRU: re-insert to move to end (Map preserves insertion order)				cache.delete(matKey);				cache.set(matKey, mat);				return mat;			}			// Cap cache to 500 materials — evict oldest (Map first key) on overflow			if (cache.size >= 500) {				const firstKey = cache.keys().next().value as string | undefined;				if (firstKey) {					const oldMat = cache.get(firstKey);					if (oldMat) {						try {							oldMat.dispose();						} catch {}					}					cache.delete(firstKey);				}			}			mat = new PBRMaterial(matKey, scene);			mat.albedoColor = color;			mat.metallic = metallic;			mat.roughness = roughness;			mat.environmentIntensity = 0.6;			mat.alpha = alpha;			mat.wireframe = wireframe;			mat.freeze();			cache.set(matKey, mat);			return mat;		},		[],	);	useEffect(() => {		const canvas = canvasRef.current;		const labelEl = labelRef.current;		if (!canvas) return;		disposedRef.current = false;		const engine = new Engine(canvas, true, {			preserveDrawingBuffer: false,			stencil: true,			powerPreference: "high-performance",			adaptToDeviceRatio: true,  // auto-scale to device pixel ratio		});		engine.setHardwareScalingLevel(1 / window.devicePixelRatio);  // crisp rendering on HiDPI		engineRef.current = engine;		const scene = new Scene(engine);		// Deep navy blue sky background (matching reference industrial warehouse render)		scene.clearColor = new Color4(0.039, 0.055, 0.157, 1.0); // #0a0e28		scene.ambientColor = new Color3(0.2, 0.2, 0.25);		sceneRef.current = scene;		// ── Procedural environment texture for PBR reflections (IBL) ──		// Higher resolution + higher intensity for realistic metal reflections		const envTex = createProceduralEnvTexture(scene, 1024);		if (envTex) {			scene.environmentTexture = envTex;			scene.environmentIntensity = 1.0;  // IBL strength for edge highlights on metal		}		// ── Fog for depth perception (deep navy blue atmosphere) ──		scene.fogMode = Scene.FOGMODE_EXP2;		scene.fogDensity = 0.0025;		scene.fogColor = new Color3(0.039, 0.055, 0.157); // match sky		// ── Image processing / tone mapping ──		scene.imageProcessingConfiguration.toneMappingEnabled = true;		scene.imageProcessingConfiguration.toneMappingType =			ImageProcessingConfiguration.TONEMAPPING_ACES;		scene.imageProcessingConfiguration.contrast = 1.05;		scene.imageProcessingConfiguration.exposure = 1.1;		scene.imageProcessingConfiguration.vignetteEnabled = true;		scene.imageProcessingConfiguration.vignetteWeight = 0.06;		scene.imageProcessingConfiguration.vignetteCameraFov = 0.85;		const camera = new ArcRotateCamera(			"cam",			-Math.PI / 3,			Math.PI / 4,			60,			new Vector3(0, 0, 0),			scene,		);		camera.attachControl(true);		camera.lowerRadiusLimit = 2;		camera.upperRadiusLimit = 500;		camera.lowerBetaLimit = 0.1;		camera.upperBetaLimit = Math.PI / 2.1;		camera.wheelDeltaPercentage = 0.01;		camera.minZ = 0.1;		camera.maxZ = 1000;		camera.panningSensibility = 40;		camera.angularSensibilityX = 800;		camera.angularSensibilityY = 800;		// Restore viewport from 2D scene (shared XZ plane)		const vp = useSimStore.getState();		camera.target.set(vp.viewCenterX, 0, vp.viewCenterZ);		camera.radius = 60 / Math.max(vp.viewZoom, 0.1);		cameraRef.current = camera;		const thinManager = new ThinInstanceManager();		thinManagerRef.current = thinManager;		// ── Industrial warehouse lighting setup ──		// Main directional light: angled to produce visible shadows on ground		// Keep intensity moderate to avoid shadow getting dirty when SSAO stacks		const sun = new DirectionalLight(			"sun",			new Vector3(-0.5, -1, 0.3).normalize(),			scene,		);		sun.intensity = 1.2;		sun.diffuse = new Color3(0.95, 0.96, 1.0); // cool white LED		sun.specular = new Color3(0.5, 0.52, 0.6); // soft highlight		// Fill light: subtle cool bounce from opposite side		const fill = new DirectionalLight(			"fill",			new Vector3(0.4, -0.6, -0.5).normalize(),			scene,		);		fill.intensity = 0.3;		fill.diffuse = new Color3(0.7, 0.75, 0.9);		fill.specular = new Color3(0.15, 0.18, 0.25);		// Hemisphere light: simulates warehouse ceiling light panels		const hemi = new HemisphericLight("hemi", new Vector3(0, 1, 0), scene);		hemi.intensity = 0.5;		hemi.diffuse = new Color3(0.92, 0.94, 0.98);		hemi.groundColor = new Color3(0.08, 0.09, 0.12);		// ── Cascaded Shadow Map (for large warehouse scenes 100m+) ──		// CSM splits frustum into cascades for consistent shadow quality at all distances		const shadowGen = new CascadedShadowGenerator(2048, sun);		shadowGen.numCascades = 3;		shadowGen.stabilizeCascades = true;		shadowGen.lambda = 0.5;		shadowGen.filter = ShadowGenerator.FILTER_PCF;		shadowGen.setDarkness(0.3);		shadowGen.bias = 0.003;		shadowGen.normalBias = 0.02;		shadowGen.freezeShadowCastersBoundingInfo = true;		// Limit shadow distance to 50m - far racks don't need shadows		shadowGen.setMinMaxDistance(0, 50);		const hl = new HighlightLayer("hl", scene, {			mainTextureFixedSize: 512,			blurHorizontalSize: 1.2,			blurVerticalSize: 1.2,		});		(hl as any).outerGlow = true;		(hl as any).innerGlow = false;		highlightRef.current = hl;		// ── SSAO2 Pipeline (critical for rack depth/layering) ──		// Warehouse scenes need large radius (4-6) because shelf bays are 1-2m wide		// Without AO, rack bays look flat; SSAO2 adds shadow in corners/crevices		const ssao = new SSAO2RenderingPipeline(			"ssao",			scene,			{				ssaoRatio: 0.5,        // half-res for performance				blurRatio: 1.0,        // full-res blur			},			[camera],		);		ssao.totalStrength = 1.5;      // stronger AO for industrial scenes		ssao.radius = 5.0;             // large radius for warehouse-scale geometry		ssao.base = 0.05;              // minimum AO (prevents fully black)		ssao.expensiveBlur = true;     // smoother AO		ssao.samples = 24;             // higher quality for large radius		ssao.maxZ = 150;               // affect geometry within 150m		// ── FXAA Anti-aliasing ──		const fxaa = new FxaaPostProcess("fxaa", 1.0, camera);		// ── Performance Instrumentation ──		const sceneInstr = new SceneInstrumentation(scene);		sceneInstr.captureActiveMeshesEvaluationTime = true;		sceneInstr.captureFrameTime = true;		sceneInstr.captureRenderTime = true;		sceneInstr.captureInterFrameTime = true;		const ground = MeshBuilder.CreateGround(			"__ground__",			{ width: 500, height: 500, subdivisions: 1 },			scene,		);		ground.receiveShadows = true;		const groundMat = new PBRMaterial("groundMat", scene);		// Dark asphalt / smooth concrete (matching reference warehouse render)		groundMat.albedoColor = new Color3(0.12, 0.13, 0.16); // #1e2128 dark asphalt		groundMat.roughness = 0.85; // mostly matte, slight micro-reflectivity		groundMat.metallic = 0.0;		groundMat.environmentIntensity = 0.15; // very subtle environment reflection		ground.material = groundMat;		ground.isPickable = true;		ground.metadata = { isGround: true };		ground.position.y = -0.05;		groundRef.current = ground;		// ─ Yellow floor safety lines along conveyor lanes ──		// These are built per-conveyor during render loop for dynamic positioning		scene.onPointerDown = (_evt, pickResult) => {			if (disposedRef.current) return;			if (_evt.button !== 0) return;			const locked = useSimStore.getState().modelLocked;			if (pickResult?.hit && pickResult.pickedMesh?.metadata?.entityId) {				const mesh = pickResult.pickedMesh;				const entityId = mesh.metadata.entityId as string;				selectEntity(entityId);				if (!locked || _evt.altKey) {					dragMeshRef.current = mesh as Mesh;					dragOrigPosRef.current = (mesh as Mesh).position.clone();					pointerDownPosRef.current = { x: scene.pointerX, y: scene.pointerY };					isDraggingRef.current = false;				}				if (_evt.altKey) {					isRotatingRef.current = true;					rotateStartXRef.current = scene.pointerX;					rotateOrigYRef.current = mesh.rotation.y;					camera.detachControl();				}			} else {				selectEntity(null);				dragMeshRef.current = null;			}		};		scene.onPointerMove = () => {			if (disposedRef.current) return;			const dragMesh = dragMeshRef.current;			if (!dragMesh || dragMesh.isDisposed()) return;			if (isRotatingRef.current) {				const dx = scene.pointerX - rotateStartXRef.current;				dragMesh.rotation.y = rotateOrigYRef.current + dx * 0.02;				return;			}			if (!isDraggingRef.current) {				const dx = scene.pointerX - pointerDownPosRef.current.x;				const dy = scene.pointerY - pointerDownPosRef.current.y;				if (Math.sqrt(dx * dx + dy * dy) > 5) {					isDraggingRef.current = true;					camera.detachControl();				}			}			if (isDraggingRef.current) {				const gnd = groundRef.current;				if (!gnd) return;				try {					const pick = scene.pick(						scene.pointerX,						scene.pointerY,						(m) => m === gnd,					);					if (pick?.pickedPoint) {						dragMesh.position.x = pick.pickedPoint.x;						dragMesh.position.z = pick.pickedPoint.z;					}				} catch {}			}		};		scene.onPointerUp = () => {			if (disposedRef.current) return;			const dragMesh = dragMeshRef.current;			if (isRotatingRef.current && dragMesh) {				const eid = dragMesh.metadata?.entityId as string;				if (eid) {					const rotDeg = (-dragMesh.rotation.y * 180) / Math.PI;					updateEntity(eid, { rotation: rotDeg });				}				camera.attachControl(true);			} else if (isDraggingRef.current && dragMesh) {				const eid = dragMesh.metadata?.entityId as string;				if (eid) {					updateEntity(eid, {						posx: Math.round(dragMesh.position.x * 100) / 100,						posy: Math.round(dragMesh.position.z * 100) / 100,					});				}				camera.attachControl(true);			}			dragMeshRef.current = null;			isDraggingRef.current = false;			isRotatingRef.current = false;		};		engine.runRenderLoop(() => {			try {				if (disposedRef.current) return;				if (scene.isDisposed) return;				if (engine.isDisposed) return;				const storeState = useSimStore.getState();				const entities = storeState.entities;				entitiesRef.current = entities;				// ThinInstance: rebuild if entity set changed, get handled IDs				const thinManager = thinManagerRef.current;				const sc = storeState.serverConfig;				if (thinManager) {					thinManager.checkRebuild(entities, scene);				}				const thinIds = thinManager?.handledIds ?? new Set<string>();				const entityMeshes = entityMeshesRef.current;				const itemMeshes = itemMeshesRef.current;				const currentIds = new Set<string>();				const now = performance.now();				// Performance boundary: cap complex (non-instanced) entities at 2000 per frame.				// ThinInstance entities bypass this cap since they share a single draw call.				const MAX_COMPLEX_PER_FRAME = 2000;				let entityCount = 0;				let complexCount = 0;				// Sync 3D viewport to store every ~10 frames (throttled)				frameCounter++;				if (frameCounter % 10 === 0) {					const cam = cameraRef.current;					if (cam) {						useSimStore							.getState()							.setViewport(cam.target.x, cam.target.z, 60 / cam.radius);					}				}				for (const [id, entity] of entities) {					entityCount++;					currentIds.add(id);					// ThinInstance entities: skip mesh creation/material/overlay					if (thinIds.has(id)) continue;					// Cap complex (non-instanced) entities to protect GPU/CPU					complexCount++;					if (complexCount > MAX_COMPLEX_PER_FRAME) continue;					const activeLayer = useSimStore.getState().activeLayer ?? 0;					const entityLayer = entity.layer ?? 0;					const multiLayers = (entity as unknown as Record<string, unknown>)						.multiLayers as number[] | undefined;					if (						entityLayer !== activeLayer &&						!multiLayers?.includes(activeLayer)					)						continue;					if (						!Number.isFinite(entity.posx) ||						!Number.isFinite(entity.posy) ||						!Number.isFinite(entity.sizex ?? 1) ||						!Number.isFinite(entity.sizey ?? 1)					)						continue;					// Boundary protection: skip entities at extreme positions					// (beyond ±10000 world units) to prevent floating-point					// precision artifacts and camera targeting issues.					if (						Math.abs(entity.posx) > 10000 ||						Math.abs(entity.posy) > 10000 ||						Math.abs(entity.sizex ?? 1) > 10000 ||						Math.abs(entity.sizey ?? 1) > 10000					)						continue;					const sx = Math.max(entity.sizex, 0.1);					const sy = Math.max(entity.sizey, 0.1);					const isz = Number.isFinite(entity.sizez) ? entity.sizez : 0.5;					const sz = Math.max(isz, 0.1);					const height = sz;					const px = entity.posx;					const py = entity.posy;					const pz = Number.isFinite(entity.posz) ? entity.posz : 0;					let mesh = entityMeshes.get(id);					const kind = resolveShape3D(entity.entityType ?? "");					const prevKind = entityShapeKindsRef.current.get(id);					const prevSize = entitySizesRef.current.get(id);					const sizeChanged =						!prevSize ||						prevSize.sx !== sx ||						prevSize.sy !== sy ||						prevSize.sz !== sz;					if (						!mesh ||						mesh.isDisposed() ||						(prevKind !== undefined && prevKind !== kind) ||						sizeChanged					) {						if (mesh && !mesh.isDisposed()) {							hl.removeMesh(mesh as any);							mesh.getChildMeshes().forEach((child) => {								hl.removeMesh(child as any);							});							mesh.dispose();						}						mesh = buildShapeMesh(kind, `e_${id}`, sx, sy, sz, scene, entity);						mesh.metadata = { entityId: id };						mesh.receiveShadows = true;						entityMeshes.set(id, mesh);						entityShapeKindsRef.current.set(id, kind);						entitySizesRef.current.set(id, { sx, sy, sz });						shadowGen.addShadowCaster(mesh);					}					if (!(isDraggingRef.current && selectedIdRef.current === id)) {						const prev = prevPositionsRef.current.get(id);						// For rack/srm: 3D structures are built from ground up, so base Y = pz						// For conveyor/scanner/robot: center vertically at height/2 + pz						// For scs: flat box at Y=0 so overlay renders on top						const meshY =							kind === "rack" || kind === "srm"								? pz								: kind === "scs"									? pz									: height / 2 + pz;						if (prev && now - prev.t < 200) {							const alpha = (now - prev.t) / 200;							mesh.position.x = prev.px + (px - prev.px) * alpha;							mesh.position.z = prev.py + (py - prev.py) * alpha;							mesh.position.y = meshY;						} else {							mesh.position.set(px, meshY, py);						}						prevPositionsRef.current.set(id, { px, py, pz, t: now });					}					mesh.rotation.y =						-(							(Number.isFinite(entity.rotation)								? normalizeRotation(entity.rotation)								: 0) * Math.PI						) / 180;					if (kind !== "box" && kind !== "conveyor") {						mesh.rotation.x = 0;						mesh.rotation.z = 0;					}					const stateInfo = detectEntityState(						entity.state ?? "",						entity.itemCount ?? 0,						entity.items?.some((item) => item.moving) ?? false,					);					const entityProps2 = entity.properties as						| Record<string, unknown>						| undefined;					const isTransparent =						entity.transparent ??						(entityProps2?.transparent as boolean) ??						getDefaultTransparent(kind);					// Java AbstractSRM2.getBackgroundColor: special colors for					// ignoreCompletion (#A9A9A9) and disableStatusMessage (#EE82EE)					const srmProps = entity.properties as						| Record<string, unknown>						| undefined;					const isSRMKind = kind === "srm";					const ignoreCompletion =						isSRMKind && ((srmProps?.ignoreCompletion as boolean) ?? false);					const disableStatusMessage =						isSRMKind && ((srmProps?.disableStatusMessage as boolean) ?? false);					let baseColor: Color3;					const srmBgHex = getSrmBackgroundColor(						ignoreCompletion,						disableStatusMessage,						"",					);					const sc = useSimStore.getState().serverConfig;					if (srmBgHex) {						baseColor = hexToColor3(srmBgHex, Color3.Black());					} else if (stateInfo.isError) {						baseColor = hexToColor3(sc?.entityErrorColor, new Color3(1, 0, 0));					} else if (stateInfo.isMoving) {						baseColor = hexToColor3(							sc?.entityBusyColor,							new Color3(0, 0.392, 1),						);					} else if (stateInfo.hasItems) {						baseColor = hexToColor3(							sc?.entityOccupiedColor1,							new Color3(1, 1, 0),						);					} else if (stateInfo.isInterrupted) {						baseColor = hexToColor3(							sc?.entityInterruptColor,							new Color3(0.753, 0.753, 0.753),						);					} else {						// Check randomItemColors / colorR/G/B/T custom colors (matching 2D)						const props = entity.properties as							| Record<string, unknown>							| undefined;						const colorR = props?.colorR as number | undefined;						const colorG = props?.colorG as number | undefined;						const colorB = props?.colorB as number | undefined;						const colorT = props?.colorT as number | undefined;						if (sc?.randomItemColors && colorR === undefined && colorG === undefined && colorB === undefined) {							// Hash-based per-entity color (matching 2D hashColor)							const hue = hashColorHue(entity.id);							baseColor = hslToColor3(hue, 0.6, 0.65);						} else if (							colorR !== undefined &&							colorG !== undefined &&							colorB !== undefined &&							colorT !== undefined &&							!(colorR === 255 && colorG === 255 && colorB === 255 && colorT === 255)						) {							const r = (colorR ?? 255) / 255;							const g = (colorG ?? 255) / 255;							const b = (colorB ?? 255) / 255;							baseColor = new Color3(r, g, b);						} else {							const idleHex = sc?.entityIdleColor || "#FFFFFF";							baseColor = hexToColor3(								entity.backgroundColor,								hexToColor3(idleHex, Color3.White()),							);						}					}					const color = adjustColorForLayer(baseColor, entity.layer ?? 0);					const wireframeMode = useSimStore.getState().wireframe;					// Determine if entity type uses overlay rendering (grids, waypoints, trays etc.)					// Rack and SRM now have full 3D structures, so overlay grid lines are redundant.					// Keep SCS (waypoints/trays) and conveyor overlays since they convey state info.					const hasOverlay =						kind === "scs" ||						kind === "conveyor";					// SCS entities don't draw an entity box in Java (BinSCS2.onDraw):					// only waypoints, head, and trays.					// Rack and SRM now have full 3D structures, so they're always visible					// (transparent ones use lower alpha material instead of hiding).					// Use isVisible=false so the box mesh is skipped during					// rendering while overlay child meshes (which have their					// own isVisible=true) still render. (Note: visibility=0					// would propagate to children — see Babylon.js docs.)					if (kind === "scs") {						mesh.isVisible = false;					} else if (isTransparent && !wireframeMode && !hasOverlay && kind !== "rack" && kind !== "srm") {						mesh.visibility = 0;						// Still apply highlight if selected.						const isSelected3 = selectedIdRef.current === id;						if (isSelected3 && !hl.hasMesh(mesh as any)) {							hl.addMesh(mesh as any, Color3.Yellow());						} else if (!isSelected3 && hl.hasMesh(mesh as any)) {							hl.removeMesh(mesh as any);						}					} else {						mesh.visibility = 1;						// For transparent rack/SRM 3D structures, use lower alpha						const isTransparentRackSrm = (kind === "rack" || kind === "srm") && isTransparent;						const matAlpha = isTransparentRackSrm ? 0.4 : (isTransparent ? 0.15 : 1);						// Per-entity-type PBR parameters for realistic material look						const metallicRoughness = getKindMaterialParams(kind);						const matKey = `${color.toHexString()}_${matAlpha}_${wireframeMode ? "w" : "s"}_${metallicRoughness.metallic}_${metallicRoughness.roughness}`;						const mat = getMaterial(							scene,							matKey,							color,							matAlpha,							wireframeMode,							metallicRoughness.metallic,							metallicRoughness.roughness,						);						mesh.material = mat;						const isSelected = selectedIdRef.current === id;						if (isSelected) {							if (!hl.hasMesh(mesh as any)) {								hl.addMesh(mesh as any, Color3.Yellow());								mesh.getChildMeshes().forEach((child) => {									hl.addMesh(child as any, Color3.Yellow());								});							}						} else {							if (hl.hasMesh(mesh as any)) {								hl.removeMesh(mesh as any);								mesh.getChildMeshes().forEach((child) => {									hl.removeMesh(child as any);								});							}						}					}					if (entity.items) {						for (const item of entity.items) {							const itemKey = `${id}_item_${item.id}`;							let itemMesh = itemMeshes.get(itemKey);							const isx = Math.max(								Number.isFinite(item.sizex) ? (item.sizex ?? 0.5) : 0.5,								0.05,							);							const isy = Math.max(								Number.isFinite(item.sizey) ? (item.sizey ?? 0.5) : 0.5,								0.05,							);							const isz = Number.isFinite(item.sizez) ? Math.max(item.sizez ?? 0.5, 0.05) : 0.5;							// Determine item type based on dimensions and context							// Large items (height > 0.8) → LU (box on pallet)							// Medium items (height > 0.3, width > 0.6) → cardboard box							// Tall thin items → plastic tote							// Small flat items → simple box							const isLargeLU = isz > 0.8 && isx > 0.6 && isy > 0.6;							const isCardboard = isz > 0.25 && !isLargeLU;							const isTote = isz > isx * 1.3 && isz > isy * 1.3;							const needsRebuild = !itemMesh || itemMesh.isDisposed();							if (needsRebuild) {								if (itemMesh && !itemMesh.isDisposed()) {									itemMesh.dispose();								}								if (isLargeLU) {									itemMesh = buildLoadUnitMesh(										`item_${itemKey}`,										isx,										isy,										isz,										scene,									);								} else if (isCardboard) {									itemMesh = buildCardboardBoxMesh(										`item_${itemKey}`,										isx,										isy,										isz,										scene,									);								} else if (isTote) {									itemMesh = buildToteMesh(										`item_${itemKey}`,										isx,										isy,										isz,										scene,									);								} else {									// Small items: simple box with item color									itemMesh = MeshBuilder.CreateBox(										`item_${itemKey}`,										{ width: isx, height: isz, depth: isy },										scene,									);								}								itemMesh.metadata = { entityId: id };								itemMeshes.set(itemKey, itemMesh);							}							if (!itemMesh) continue;							// Java Item.getGlobalPolygon:						// 1. Get local polygon (item pos + rotation)						// 2. Rotate by entity rotation: cosR*localX - sinR*localY						// 3. Add entity position						// The server computes globalInterpolatedPosx/y/z which includes all transforms.						const entityRotRad = (entity.rotation ?? 0) * Math.PI / 180;						const cosR = Math.cos(entityRotRad);						const sinR = Math.sin(entityRotRad);						let ipx: number;						let ipy: number;						let ipz: number;						if (							item.moving &&							item.moveStartTime != null &&							item.moveStopTime != null &&							item.moveStopTime > item.moveStartTime						) {							const wallElapsed = simTimeWallRef.current								? performance.now() - simTimeWallRef.current								: 0;							const effSimTime = simTimeRef.current + wallElapsed;							const startT = item.moveStartTime;							const stopT = item.moveStopTime;							const f = Math.min(								1,								Math.max(0, (effSimTime - startT) / (stopT - startT)),							);							const sx = item.moveStartx ?? item.posx ?? 0;							const sy = item.moveStarty ?? item.posy ?? 0;							const sz = item.moveStartz ?? item.posz ?? 0;							const ex = item.moveStopx ?? item.posx ?? 0;							const ey = item.moveStopy ?? item.posy ?? 0;							const ez = item.moveStopz ?? item.posz ?? 0;							// Interpolate local position, then rotate by entity rotation (matching Java getGlobalPolygon)							const localX = sx + (ex - sx) * f;							const localY = sy + (ey - sy) * f;							const localZ = sz + (ez - sz) * f;							ipx = px + cosR * localX - sinR * localY;							ipy = py + sinR * localX + cosR * localY;							// For tall structures (rack/SRM): base is at pz, items stack from ground up							// For other entities: center at height/2 + pz							const baseZ = (kind === "rack" || kind === "srm") ? (pz ?? 0) : (pz ?? 0) + height / 2;							ipz = baseZ + isz / 2 + localZ;						} else {							// Use server-computed globalInterpolatedPosx/y when available (includes entity rotation)							if (Number.isFinite(item.globalInterpolatedPosx) && Number.isFinite(item.globalInterpolatedPosy)) {								ipx = item.globalInterpolatedPosx!;								ipy = item.globalInterpolatedPosy!;							} else {								// Fallback: entity pos + rotate(item local pos) — matching Java getGlobalPolygon								const lx = Number.isFinite(item.posx) ? item.posx : 0;								const ly = Number.isFinite(item.posy) ? item.posy : 0;								ipx = px + cosR * lx - sinR * ly;								ipy = py + sinR * lx + cosR * ly;							}							// For tall structures (rack/SRM): base is at pz, items stack from ground up							const baseZ = (kind === "rack" || kind === "srm") ? (pz ?? 0) : (pz ?? 0) + height / 2;							ipz = baseZ + isz / 2 + (Number.isFinite(item.posz) ? item.posz : 0);						}							itemMesh.position.set(ipx, ipz, ipy);							if (item.moving) {								const pulse =									1 +									0.15 *										Math.sin(now * 0.01 + (item.id.charCodeAt(0) || 0) * 0.3);								itemMesh.scaling.setAll(pulse);							} else if (								itemMesh.scaling.x !== 1 ||								itemMesh.scaling.y !== 1 ||								itemMesh.scaling.z !== 1							) {								itemMesh.scaling.setAll(1);							}							// Only apply material/edges to simple box items.							// Realistic models (cardboard, pallet, tote, LU) have their own materials.							const isSimpleBox = itemMesh.getClassName() === "Mesh" && !itemMesh.getChildMeshes().length;							if (isSimpleBox) {								// Java Item.checkInitColors: backGroundColor defaults to core.getItemFillColor() = gray #C0C0C0								// 3D material uses backGroundColor as fill								const itemFillHex = item.backGroundColor || sc?.itemFillColor || ITEM_FILL_COLOR;								const ic = hexToColor3(itemFillHex, new Color3(192 / 255, 192 / 255, 192 / 255));								const imKey = `item_${ic.toHexString()}`;								itemMesh.material = getMaterial(scene, imKey, ic, 1, false);								// Java Item.onDraw uses foreGroundColor for drawPolygon outline.								// In 3D we approximate with box edges rendering.								const fgHex = item.foreGroundColor || sc?.itemLineColor || ITEM_LINE_COLOR;								const fgColor = hexToColor3(fgHex, new Color3(0, 0, 0));								itemMesh.enableEdgesRendering();								itemMesh.edgesWidth = 1;								itemMesh.edgesColor = fgColor.toColor4(1);							}						}					}					// Overlay rendering					const needOverlay =						kind === "conveyor" ||						kind === "rack" ||						kind === "srm" ||						kind === "scs";					const entityProps = entity.properties as						| Record<string, unknown>						| undefined;					const connColor3 = hexToColor3(						sc.connectionColor,						new Color3(128 / 255, 128 / 255, 128 / 255), // Java connectionColor = (128,128,128)					);					const busyColor3 = hexToColor3(						sc.entityBusyColor,						new Color3(0, 100 / 255, 1), // Java entityBusyColor = (0,100,255)					);					let overlay = overlayMeshesRef.current.get(id);					if (needOverlay) {						if (!overlay || overlay.isDisposed() || overlay.parent !== mesh) {							if (overlay && !overlay.isDisposed()) overlay.dispose();							overlay = new Mesh(`overlay_${id}`, scene);							overlay.parent = mesh;							overlayMeshesRef.current.set(id, overlay);							overlayHashRef.current.delete(id);						}						// Ensure overlay is visible even when parent mesh is hidden (e.g. SCS)						overlay.isVisible = true;						// Make overlay children pickable so entities can be selected						// even when the main mesh is hidden (SCS, transparent rack/SRM)						overlay.metadata = { entityId: id };						overlay.isPickable = true;						const wpPos = entity.waypointPositions;						const wpHash = wpPos							? wpPos.map((w) => `${w.x},${w.y}`).join(";")							: "";						const trays = entity.trays;						const trayHash = trays							? trays.map((t) => `${t.trayx}:${t.isNext ? 1 : 0}`).join(";")							: "";						const overlayKey = `${kind}_${entity.transparent}_${entity.entityType}_${entity.foregroundColor}_${entity.backgroundColor}_${entityProps?.showSpace}_${JSON.stringify(entityProps?.coordinates)}_${entityProps?.orientation}_${entityProps?.invertx}_${entityProps?.invertz}_${wpHash}_${entity.traySizex}_${entity.traySizey}_${entity.traySizez}_${entity.trayCountx}_${trayHash}_${entityProps?.placesx}_${entityProps?.placesz}_${entity.posz ?? 0}`;						const prevHash = overlayHashRef.current.get(id);						if (prevHash === overlayKey) {							// overlay unchanged, no rebuild needed						} else {							overlayHashRef.current.set(id, overlayKey);							overlay.getChildMeshes().forEach((c) => c.dispose());							// Overlay Y offset: flat box height = min(sz, 0.1), so top							// face is at Y=0.05. All overlay lines must be above this.							const oY = 0.06;							const oYm = oY - 0.005; // slightly below outline for grid lines							if (kind === "conveyor") {							} else if (kind === "rack") {								const showSpace = entityProps?.showSpace as									| string									| null									| undefined;								const coords = entityProps?.coordinates as									| Array<{											firstx?: number;											lastx?: number;											firstDepth?: number;											lastDepth?: number;											invertx?: boolean;											inverty?: boolean;											invertDepth?: boolean;									  }>									| undefined;								// Java drawRackBounds: outer outline always drawn								// (getForegroundColor = entityLineColor = BLACK by default).								const outlineColor = hexToColor3(									entity.foregroundColor,									C.dark, // entityLineColor default = BLACK in Java								);								const outlineLines: Vector3[][] = [									[										new Vector3(-sx / 2, oY, -sy / 2),										new Vector3(sx / 2, oY, -sy / 2),									],									[										new Vector3(sx / 2, oY, -sy / 2),										new Vector3(sx / 2, oY, sy / 2),									],									[										new Vector3(sx / 2, oY, sy / 2),										new Vector3(-sx / 2, oY, sy / 2),									],									[										new Vector3(-sx / 2, oY, sy / 2),										new Vector3(-sx / 2, oY, -sy / 2),									],								];								const outlineSys = MeshBuilder.CreateLineSystem(									`rack_outline_${id}`,									{ lines: outlineLines },									scene,								);								outlineSys.color = outlineColor;								outlineSys.parent = overlay;								// Grid lines (Java onPlacesDraw → connectionColor)								if (									showSpace &&									showSpace !== "false" &&									showSpace.trim() !== "" &&									coords?.[0]								) {									const c0 = coords[0];									const firstx = c0.firstx ?? 1;									const lastx = c0.lastx ?? 1;									const firstDepth = c0.firstDepth ?? 1;									const lastDepth = c0.lastDepth ?? 1;									const orientation =										(entityProps?.orientation as string) || "x";									const isInvertx = c0.invertx ?? false;									const isInvertDepth = c0.invertDepth ?? false;									const gridLines: Vector3[][] = [];									const { xLines, zLines } = computeRackGridLines({										firstx,										lastx,										firstDepth,										lastDepth,										sizex: sx,										sizey: sy,										orientation,										invertx: isInvertx,										invertDepth: isInvertDepth,									});									for (const offset of xLines) {										gridLines.push([											new Vector3(offset, oYm, -sy / 2),											new Vector3(offset, oYm, sy / 2),										]);									}									for (const offset of zLines) {										gridLines.push([											new Vector3(-sx / 2, oYm, offset),											new Vector3(sx / 2, oYm, offset),										]);									}									if (gridLines.length > 0) {										const gridLineSys = MeshBuilder.CreateLineSystem(											`rack_grid_${id}`,											{ lines: gridLines },											scene,										);										gridLineSys.color = connColor3;										gridLineSys.parent = overlay;									}								}							} else if (kind === "scs") {								// Java BinSCS2: transparent defaults to false (Entity base class)								const scsIsTransparent = entity.transparent ?? false;								// Waypoint connection lines (closed loop, matching Java onDraw)								const wpPositions = entity.waypointPositions;								const trayCountx = Math.min(									entity.trayCountx ?? wpPositions?.length ?? 0,									wpPositions?.length ?? 0,								);								if (wpPositions && wpPositions.length > 1) {									const wpLines: Vector3[][] = [];									for (let i = 0; i < trayCountx; i++) {										const j = (i + 1) % trayCountx;										const wp1 = wpPositions[i];										const wp2 = wpPositions[j];										wpLines.push([											new Vector3(												wp1.x - entity.posx,												oY + 0.04,												wp1.y - entity.posy,											),											new Vector3(												wp2.x - entity.posx,												oY + 0.04,												wp2.y - entity.posy,											),										]);									}									const wpLineSys = MeshBuilder.CreateLineSystem(										`scs_wp_${id}`,										{ lines: wpLines },										scene,									);									wpLineSys.color = connColor3;									wpLineSys.parent = overlay;								}								// Waypoint dots - DISABLED for performance debugging								// Will re-enable with proper optimization once root cause is found								// if (wpPositions && wpPositions.length > 0) {								//   ... ThinInstance code ...								// }								console.debug("SKIP_WAYPOINT_DOTS: ", id, wpPositions?.length ?? 0);								// Head/station (Java onHeadDraw: slightly larger than tray,								// filled + outlined with connectionColor)								const traySX = entity.traySizex ?? 0.5;								const traySY = entity.traySizey ?? 0.5;								const traySZ = entity.traySizez ?? 0.1;								if (wpPositions && wpPositions.length > 0) {									const wp0 = wpPositions[0];									const headBox = MeshBuilder.CreateBox(										`scs_head_${id}`,										{											width: traySX + 0.05,											height: traySZ + 0.02,											depth: traySY + 0.05,										},										scene,									);									headBox.position = new Vector3(										wp0.x - entity.posx,										oY + traySZ / 2,										wp0.y - entity.posy,									);									headBox.material = createQuickMat(scene, connColor3);									headBox.parent = overlay;									headBox.enableEdgesRendering();									headBox.edgesWidth = 1;									headBox.edgesColor = connColor3.toColor4(1);								}								// Tray boxes with InstancedMesh (shared geometry per entity)								// All trays of the SAME entity have the same size → share 1 mesh								const trayBgColor = hexToColor3(									entity.backgroundColor,									C.white,								);								const trayFgColor = hexToColor3(entity.foregroundColor, C.dark);								const trays = entity.trays;								if (trays) {									// Create ONE template tray geometry for this entity									const trayTemplate = MeshBuilder.CreateBox(										`scs_tray_tpl_${id}`,										{ width: traySX, height: traySZ, depth: traySY },										scene,									);									// Set material on template (instances inherit it)									if (!scsIsTransparent) {										trayTemplate.material = createQuickMat(scene, trayBgColor);									}									trayTemplate.setEnabled(false); // hide template									for (const tray of trays) {										const tx = tray.interpolatedPosx - entity.posx;										const tz = tray.interpolatedPosy - entity.posy;										const ty =											oY +											(tray.interpolatedPosz - (entity.posz ?? 0)) +											traySZ / 2;										if (!scsIsTransparent) {											const inst = trayTemplate.createInstance(												`scs_tray_${id}_${tray.trayx}`,											);											inst.position = new Vector3(tx, ty, tz);											inst.parent = overlay;											inst.isPickable = true;										} else {											const hsx = traySX / 2;											const hsy = traySY / 2;											const trayOutline: Vector3[][] = [												[new Vector3(tx - hsx, ty, tz - hsy), new Vector3(tx + hsx, ty, tz - hsy)],												[new Vector3(tx + hsx, ty, tz - hsy), new Vector3(tx + hsx, ty, tz + hsy)],												[new Vector3(tx + hsx, ty, tz + hsy), new Vector3(tx - hsx, ty, tz + hsy)],												[new Vector3(tx - hsx, ty, tz + hsy), new Vector3(tx - hsx, ty, tz - hsy)],											];											const trayLineSys = MeshBuilder.CreateLineSystem(												`scs_tray_${id}_${tray.trayx}`,												{ lines: trayOutline },												scene,											);											trayLineSys.color = trayFgColor;											trayLineSys.parent = overlay;										}										if (tray.isNext) {											const l = traySX / 6;											const nextInd = MeshBuilder.CreateBox(												`scs_next_${id}_${tray.trayx}`,												{ width: l, height: 0.02, depth: l },												scene,											);											nextInd.position = new Vector3(												tx + traySX / 2 - l / 2 - 0.03,												ty + traySZ / 2 + 0.01,												tz + traySY / 2 - l / 2 - 0.03,											);											nextInd.material = createQuickMat(scene, busyColor3);											nextInd.parent = overlay;											nextInd.enableEdgesRendering();											nextInd.edgesWidth = 1;											nextInd.edgesColor = trayFgColor.toColor4(1);										}									}								}							} else if (kind === "srm") {								const etype = (entity.entityType || "").toLowerCase();								const isSCS2Lift = etype === "binscs2lift";								const placesX = (entityProps?.placesx as number) ?? 0;								const placesZ = (entityProps?.placesz as number) ?? 0;								const srmOrientation =									(entityProps?.orientation as string) || "x";								const isInvertx = (entityProps?.invertx as boolean) ?? false;								const isInvertz = (entityProps?.invertz as boolean) ?? false;								// Java AbstractSRM2.onDraw: double outline (outer +								// inner shrunk by 2px). In 3D we approximate with								// two line-system rectangles at slightly different Y.								const srmOutlineColor = hexToColor3(									entity.foregroundColor,									C.dark,								);								const outerLines: Vector3[][] = [									[										new Vector3(-sx / 2, oY, -sy / 2),										new Vector3(sx / 2, oY, -sy / 2),									],									[										new Vector3(sx / 2, oY, -sy / 2),										new Vector3(sx / 2, oY, sy / 2),									],									[										new Vector3(sx / 2, oY, sy / 2),										new Vector3(-sx / 2, oY, sy / 2),									],									[										new Vector3(-sx / 2, oY, sy / 2),										new Vector3(-sx / 2, oY, -sy / 2),									],								];								const outerSys = MeshBuilder.CreateLineSystem(									`srm_outline_${id}`,									{ lines: outerLines },									scene,								);								outerSys.color = srmOutlineColor;								outerSys.parent = overlay;								// Inner outline (shrunk ~0.06 in 3D units ≈ 2px at default zoom)								const inset = 0.06;								const innerLines: Vector3[][] = [									[										new Vector3(-sx / 2 + inset, oY + 0.005, -sy / 2 + inset),										new Vector3(sx / 2 - inset, oY + 0.005, -sy / 2 + inset),									],									[										new Vector3(sx / 2 - inset, oY + 0.005, -sy / 2 + inset),										new Vector3(sx / 2 - inset, oY + 0.005, sy / 2 - inset),									],									[										new Vector3(sx / 2 - inset, oY + 0.005, sy / 2 - inset),										new Vector3(-sx / 2 + inset, oY + 0.005, sy / 2 - inset),									],									[										new Vector3(-sx / 2 + inset, oY + 0.005, sy / 2 - inset),										new Vector3(-sx / 2 + inset, oY + 0.005, -sy / 2 + inset),									],								];								const innerSys = MeshBuilder.CreateLineSystem(									`srm_inner_${id}`,									{ lines: innerLines },									scene,								);								innerSys.color = srmOutlineColor;								innerSys.parent = overlay;								if (isSCS2Lift) {									// Java BinSCS2Lift.onPlacesDraw: single center line									const placeLines: Vector3[][] = [];									const orient = srmOrientation;									if (orient === "east" || orient === "west") {										placeLines.push([											new Vector3(0, oYm, -sy / 2),											new Vector3(0, oYm, sy / 2),										]);									} else {										placeLines.push([											new Vector3(-sx / 2, oYm, 0),											new Vector3(sx / 2, oYm, 0),										]);									}									if (placeLines.length > 0) {										const lineSys = MeshBuilder.CreateLineSystem(											`srm_place_${id}`,											{ lines: placeLines },											scene,										);										lineSys.color = connColor3;										lineSys.parent = overlay;									}								} else {									const placeLines: Vector3[][] = [];									const { xLines, zLines } = computeSrmGridLines({										placesX,										placesZ,										sizex: sx,										sizey: sy,										orientation: srmOrientation,										invertx: isInvertx,										invertz: isInvertz,									});									for (const offset of xLines) {										placeLines.push([											new Vector3(offset, oYm, -sy / 2),											new Vector3(offset, oYm, sy / 2),										]);									}									for (const offset of zLines) {										placeLines.push([											new Vector3(-sx / 2, oYm, offset),											new Vector3(sx / 2, oYm, offset),										]);									}									if (placeLines.length > 0) {										const gridLineSys = MeshBuilder.CreateLineSystem(											`srm_place_${id}`,											{ lines: placeLines },											scene,										);										gridLineSys.color = connColor3;										gridLineSys.parent = overlay;									}								}							}						}						// Selection highlight for entities with hidden main mesh:						// SCS, transparent rack, transparent SRM — the main mesh						// is isVisible=false so highlight the overlay children.						if (							kind === "scs" ||							(kind === "rack" && isTransparent) ||							(kind === "srm" && isTransparent)						) {							const isSel = selectedIdRef.current === id;							const children = overlay.getChildMeshes();							for (const child of children) {								if (isSel && !hl.hasMesh(child as any)) {									hl.addMesh(child as any, Color3.Yellow());								} else if (!isSel && hl.hasMesh(child as any)) {									hl.removeMesh(child as any);								}							}						}					} else {						if (overlay && !overlay.isDisposed()) {							overlay.dispose();							overlayMeshesRef.current.delete(id);						}					}				}				// Second pass: render items for ThinInstance entities				if (thinManager && thinManager.handledIds.size > 0) {					for (const [id, entity] of entities) {						if (!thinIds.has(id)) continue;						if (!entity.items || entity.items.length === 0) continue;						const sx = Math.max(entity.sizex, 0.1);						const sy = Math.max(entity.sizey, 0.1);						const isz = Number.isFinite(entity.sizez) ? entity.sizez : 0.5;						const sz = Math.max(isz, 0.1);						const height = sz;						const px = entity.posx;						const py = entity.posy;						const pz = Number.isFinite(entity.posz) ? entity.posz : 0;						const kind = resolveShape3D(entity.entityType ?? "");						const isSRMKind = kind === "srm";						const entityProps = entity.properties as							| Record<string, unknown>							| undefined;						for (const item of entity.items) {							const itemKey = `${id}_item_${item.id}`;							let itemMesh = itemMeshes.get(itemKey);							const isx = Math.max(								Number.isFinite(item.sizex) ? (item.sizex ?? 0.5) : 0.5,								0.05,							);							const isy = Math.max(								Number.isFinite(item.sizey) ? (item.sizey ?? 0.5) : 0.5,								0.05,							);							const iisz = Number.isFinite(item.sizez) ? Math.max(item.sizez ?? 0.5, 0.05) : 0.5;							const isLargeLU = iisz > 0.8 && isx > 0.6 && isy > 0.6;							const isCardboard = iisz > 0.25 && !isLargeLU;							const isTote = iisz > isx * 1.3 && iisz > isy * 1.3;							const needsRebuild = !itemMesh || itemMesh.isDisposed();							if (needsRebuild) {								if (itemMesh && !itemMesh.isDisposed()) {									itemMesh.dispose();								}								if (isLargeLU) {									itemMesh = buildLoadUnitMesh(										`item_${itemKey}`, isx, isy, iisz, scene,									);								} else if (isCardboard) {									itemMesh = buildCardboardBoxMesh(										`item_${itemKey}`, isx, isy, iisz, scene,									);								} else if (isTote) {									itemMesh = buildToteMesh(										`item_${itemKey}`, isx, isy, iisz, scene,									);								} else {									itemMesh = MeshBuilder.CreateBox(										`item_${itemKey}`,										{ width: isx, height: iisz, depth: isy },										scene,									);								}								itemMesh.metadata = { entityId: id };								itemMeshes.set(itemKey, itemMesh);							}							if (!itemMesh) continue;							const entityRotRad = (entity.rotation ?? 0) * Math.PI / 180;							const cosR = Math.cos(entityRotRad);							const sinR = Math.sin(entityRotRad);							let ipx: number, ipy: number, ipz: number;							if (								item.moving &&								item.moveStartTime != null &&								item.moveStopTime != null &&								item.moveStopTime > item.moveStartTime							) {								const wallElapsed = simTimeWallRef.current									? performance.now() - simTimeWallRef.current									: 0;								const effSimTime = simTimeRef.current + wallElapsed;								const startT = item.moveStartTime;								const stopT = item.moveStopTime;								const f = Math.min(									1, Math.max(0, (effSimTime - startT) / (stopT - startT)),								);								const sx2 = item.moveStartx ?? item.posx ?? 0;								const sy2 = item.moveStarty ?? item.posy ?? 0;								const sz2 = item.moveStartz ?? item.posz ?? 0;								const ex = item.moveStopx ?? item.posx ?? 0;								const ey = item.moveStopy ?? item.posy ?? 0;								const ez = item.moveStopz ?? item.posz ?? 0;								const localX = sx2 + (ex - sx2) * f;								const localY = sy2 + (ey - sy2) * f;								const localZ = sz2 + (ez - sz2) * f;								ipx = px + cosR * localX - sinR * localY;								ipy = py + sinR * localX + cosR * localY;								const baseZ = (kind === "rack" || kind === "srm") ? (pz ?? 0) : (pz ?? 0) + height / 2;								ipz = baseZ + iisz / 2 + localZ;							} else {								if (Number.isFinite(item.globalInterpolatedPosx) && Number.isFinite(item.globalInterpolatedPosy)) {									ipx = item.globalInterpolatedPosx!;									ipy = item.globalInterpolatedPosy!;								} else {									const lx = Number.isFinite(item.posx) ? item.posx : 0;									const ly = Number.isFinite(item.posy) ? item.posy : 0;									ipx = px + cosR * lx - sinR * ly;									ipy = py + sinR * lx + cosR * ly;								}								const baseZ = (kind === "rack" || kind === "srm") ? (pz ?? 0) : (pz ?? 0) + height / 2;								ipz = baseZ + iisz / 2 + (Number.isFinite(item.posz) ? item.posz : 0);							}							itemMesh.position.set(ipx, ipz, ipy);							if (item.moving) {								const pulse = 1 + 0.15 * Math.sin(now * 0.01 + (item.id.charCodeAt(0) || 0) * 0.3);								itemMesh.scaling.setAll(pulse);							} else if (								itemMesh.scaling.x !== 1 ||								itemMesh.scaling.y !== 1 ||								itemMesh.scaling.z !== 1							) {								itemMesh.scaling.setAll(1);							}							const isSimpleBox = itemMesh.getClassName() === "Mesh" && !itemMesh.getChildMeshes().length;							if (isSimpleBox) {								const itemFillHex = item.backGroundColor || sc?.itemFillColor || ITEM_FILL_COLOR;								const ic = hexToColor3(itemFillHex, new Color3(192 / 255, 192 / 255, 192 / 255));								const imKey = `item_${ic.toHexString()}`;								itemMesh.material = getMaterial(scene, imKey, ic, 1, false);								const fgHex = item.foreGroundColor || sc?.itemLineColor || ITEM_LINE_COLOR;								const fgColor = hexToColor3(fgHex, new Color3(0, 0, 0));								itemMesh.enableEdgesRendering();								itemMesh.edgesWidth = 1;								itemMesh.edgesColor = fgColor.toColor4(1);							}						}					}				}				// Update ThinInstance buffers (position + color for instanced entities)				if (thinManager) {					thinManager.update(entities, sc);				}				for (const [id, mesh] of entityMeshes) {					if (!currentIds.has(id)) {						hl.removeMesh(mesh as any);						mesh.dispose();						entityMeshes.delete(id);						entityShapeKindsRef.current.delete(id);						entitySizesRef.current.delete(id);						prevPositionsRef.current.delete(id);						const ov = overlayMeshesRef.current.get(id);						if (ov && !ov.isDisposed()) ov.dispose();						overlayMeshesRef.current.delete(id);						overlayHashRef.current.delete(id);					}				}				const activeEntityIds = new Set(entities.keys());				for (const key of prevPositionsRef.current.keys()) {					if (!activeEntityIds.has(key)) {						prevPositionsRef.current.delete(key);					}				}				const prevItemKeys = new Set(itemMeshes.keys());				const activeItemKeys = new Set<string>();				for (const [, entity] of entities) {					if (entity.items) {						for (const item of entity.items) {							activeItemKeys.add(`${entity.id}_item_${item.id}`);						}					}				}				for (const key of prevItemKeys) {					if (!activeItemKeys.has(key)) {						const m = itemMeshes.get(key);						if (m) {							m.dispose();							itemMeshes.delete(key);						}					}				}				if (linkMeshRef.current && !linkMeshRef.current.isDisposed()) {					linkMeshRef.current.dispose();					linkMeshRef.current = null;				}				const linkLines: Vector3[][] = [];				const currentLinkKeys = new Set<string>();				let arrowMat = scene.getMaterialByName(					"_arrowMat",				) as PBRMaterial | null;				for (const [id, entity] of entities) {					if (!entity.linksOut) continue;					for (const link of entity.linksOut) {						const tgt = entities.get(link.targetId);						if (!tgt) continue;						const linkKey = `${id}->${link.targetId}`;						currentLinkKeys.add(linkKey);						const sz =							(entity.posz ?? 0) + Math.max(entity.sizez ?? 0.5, 0.1) / 2;						const tz = (tgt.posz ?? 0) + Math.max(tgt.sizez ?? 0.5, 0.1) / 2;						const start = new Vector3(entity.posx, sz + 0.3, entity.posy);						const end = new Vector3(tgt.posx, tz + 0.3, tgt.posy);						linkLines.push([start, end]);						const dir = end.subtract(start);						if (dir.length() > 0.001) {							dir.normalize();							if (!arrowMat) {								arrowMat = new PBRMaterial("_arrowMat", scene);								arrowMat.albedoColor = new Color3(0.3, 0.5, 0.8);								arrowMat.emissiveColor = new Color3(0.1, 0.2, 0.4);								arrowMat.metallic = 0.2;								arrowMat.roughness = 0.4;								arrowMat.alpha = 0.8;								arrowMat.freeze();							}							const coneLength = 0.35;							const conePos = end.subtract(dir.scale(coneLength * 0.5));							let arrowMesh = arrowCacheRef.current.get(linkKey);							if (arrowMesh && !arrowMesh.isDisposed()) {								arrowMesh.position.copyFrom(conePos);								const yAxis = Vector3.Up();								const dot = Vector3.Dot(yAxis, dir);								if (Math.abs(dot) > 0.9999) {									arrowMesh.rotationQuaternion = null;									arrowMesh.rotation.x = dot < 0 ? Math.PI : 0;								} else {									const axis = Vector3.Cross(yAxis, dir).normalize();									const angle = Math.acos(dot);									arrowMesh.rotationQuaternion = Quaternion.RotationAxis(										axis,										angle,									);								}							} else {								if (arrowMesh && !arrowMesh.isDisposed()) arrowMesh.dispose();								arrowMesh = createArrowCone(									end.clone(),									dir.clone(),									`arrow_${linkKey}`,									scene,								);								arrowMesh.material = arrowMat;								arrowMesh.isPickable = false;								arrowCacheRef.current.set(linkKey, arrowMesh);							}						}					}				}				const staleArrowKeys: string[] = [];				for (const [key, mesh] of arrowCacheRef.current) {					if (!currentLinkKeys.has(key)) {						if (!mesh.isDisposed()) mesh.dispose();						staleArrowKeys.push(key);					}				}				for (const key of staleArrowKeys) {					arrowCacheRef.current.delete(key);				}				if (linkLines.length > 0) {					const linkMesh = MeshBuilder.CreateLineSystem(						"__links__",						{ lines: linkLines },						scene,					);					let linkMat = scene.getMaterialByName(						"linkMat",					) as PBRMaterial | null;					if (!linkMat) {						linkMat = new PBRMaterial("linkMat", scene);						linkMat.albedoColor = new Color3(0.2, 0.4, 0.7);						linkMat.emissiveColor = new Color3(0.1, 0.2, 0.35);						linkMat.alpha = 0.65;						linkMat.freeze();					}					linkMesh.material = linkMat;					linkMesh.isPickable = false;					linkMeshRef.current = linkMesh;				}				for (const mesh of flowParticlesRef.current) {					mesh.setEnabled(false);				}				if (storeState.simRunning && linkLines.length > 0) {					let flowMat = scene.getMaterialByName(						"_flowMat",					) as PBRMaterial | null;					if (!flowMat) {						flowMat = new PBRMaterial("_flowMat", scene);						flowMat.albedoColor = new Color3(0.0, 0.8, 1.0);						flowMat.emissiveColor = new Color3(0.0, 0.4, 0.6);						flowMat.alpha = 0.9;						flowMat.freeze();					}					let poolIdx = 0;					for (let li = 0; li < linkLines.length; li++) {						const [start, end] = linkLines[li];						const numParticles = 3;						for (let pi = 0; pi < numParticles; pi++) {							const t = (engine.frameId * 0.02 + pi / numParticles) % 1.0;							const pos = start.add(end.subtract(start).scale(t));							let p: Mesh;							if (poolIdx < flowParticlesRef.current.length) {								p = flowParticlesRef.current[poolIdx];							} else {								p = MeshBuilder.CreateSphere(									`_flow_${poolIdx}`,									{ diameter: 0.12, segments: 4 },									scene,								);								p.material = flowMat;								p.isPickable = false;								flowParticlesRef.current.push(p);							}							p.position = pos;							p.setEnabled(true);							poolIdx++;						}					}					for (let i = poolIdx; i < flowParticlesRef.current.length; i++) {						flowParticlesRef.current[i].dispose();					}					flowParticlesRef.current.length = poolIdx;				}				scene.render();				// ── Render Metrics Overlay (every 30 frames) ──				const metricsEl = metricsRef.current;				if (metricsEl && engine.frameId % 30 === 0) {					const fps = engine.getFps();					const totalMeshes = scene.meshes.length;					const activeMeshes = scene.getActiveMeshes().length;					const drawCalls = sceneInstr.drawCallsCounter.current;					const frameTime = sceneInstr.frameTimeCounter.lastSecAverage.toFixed(1);					const renderTime = sceneInstr.renderTimeCounter.lastSecAverage.toFixed(1);					const entityCount = entities.size;					const labelCount = labelEl ? labelEl.querySelectorAll("span").length : 0;					// Entity + Mesh counts by type category (top 8 by mesh count)					// ThinInstance entities count as 1 draw call per group, not per entity.					const typeEnt = new Map<string, number>();					const typeMesh = new Map<string, number>();					const thinCats = new Set<string>();					for (const [id, entity] of entities) {						const cat = categorizeEntityType(entity.entityType ?? "?");						typeEnt.set(cat, (typeEnt.get(cat) ?? 0) + 1);						const mesh = entityMeshes.get(id);						if (mesh && !mesh.isDisposed()) {							let mc = 1;							mc += mesh.getChildMeshes().filter(c => !c.isDisposed()).length;							typeMesh.set(cat, (typeMesh.get(cat) ?? 0) + mc);						} else if (thinIds.has(id)) {							thinCats.add(cat);						}					}					// ThinInstance categories: each counts as 1 mesh (the template)					for (const cat of thinCats) {						if (!typeMesh.has(cat)) {							typeMesh.set(cat, 1);						}					}					const sortedTypes = [...typeMesh.entries()]						.sort((a, b) => b[1] - a[1])						.slice(0, 8);					const typeRows = sortedTypes						.map(([cat, mc]) => {							const ec = typeEnt.get(cat) ?? 0;							return `<div><span style="color:#aac">${mc}</span><span style="color:#666">/${ec}</span> <span style="color:#888">${cat}</span></div>`;						})						.join("");					metricsEl.innerHTML =						`<div style="font-family:'Consolas',monospace;font-size:11px;line-height:1.6">` +						`<div><b style="color:${fps >= 50 ? '#4f8' : fps >= 30 ? '#fa0' : '#f44'}">${Math.round(fps)}</b> <span style="color:#888">FPS</span></div>` +						`<div><span style="color:#aac">${activeMeshes}</span><span style="color:#666">/${totalMeshes}</span> <span style="color:#888">Meshes</span></div>` +						`<div><span style="color:#aac">${drawCalls}</span> <span style="color:#888">DrawCalls</span></div>` +						`<div><span style="color:#aac">${frameTime}</span>ms <span style="color:#888">Frame</span> ` +						`<span style="color:#aac">${renderTime}</span>ms <span style="color:#888">Render</span></div>` +						`<div><span style="color:#aac">${entityCount}</span> <span style="color:#888">Entities</span> ` +						`<span style="color:#aac">${labelCount}</span> <span style="color:#888">Labels</span></div>` +						(typeRows ? `<div style="margin-top:4px;border-top:1px solid rgba(255,255,255,0.08);padding-top:3px">${typeRows}</div>` : "") +						`</div>`;				}				// ── Label LOD (Level of Detail) for entity id text ──				// Near (<20m): full label, larger font				// Mid  (20-50m): entity type only (Conv/Rack/Lift...), medium font				// Far  (>50m): hidden (eliminates visual noise)				if (labelEl && engine.frameId % 3 === 0) {					const viewport = camera.viewport;					const engineW = engine.getRenderWidth();					const engineH = engine.getRenderHeight();					const vp = viewport.toGlobal(engineW, engineH);					const camPos = camera.position;					const parts: string[] = [];					for (const [eid, ent] of entities) {						const wpos = new Vector3(							ent.posx,							(ent.posz ?? 0) + Math.max(ent.sizez ?? 0.5, 0.1) + 0.5,							ent.posy,						);						const dist = Math.max(1, Vector3.Distance(wpos, camPos));						// LOD: skip labels beyond 50m						if (dist > 50) continue;						const screen = Vector3.Project(							wpos,							Matrix.IdentityReadOnly,							scene.getTransformMatrix(),							vp,						);						if (screen.z < 0 || screen.z > 1) continue;						if (							screen.x < -100 ||							screen.x > engineW + 100 ||							screen.y < -100 ||							screen.y > engineH + 100						)							continue;						// LOD: font size & label text based on distance						const isSel = eid === selectedIdRef.current;						let fontSize: number;						let label: string;						if (dist < 15) {							// Near: full label, large font							fontSize = Math.max(10, Math.min(18, 120 / dist));							label = eid.length > 24 ? `${eid.slice(0, 22)}..` : eid;						} else if (dist < 35) {							// Mid: entity type abbreviation							fontSize = Math.max(9, Math.min(13, 80 / dist));							const kind = resolveShape3D(ent.entityType ?? "");							const typeNames: Record<string, string> = {								conveyor: "Conv", diverter: "Dvrt", merge: "Mrg",								lift: "Lift", scanner: "Scan", rack: "Rack",								srm: "SRM", robot: "AGV", stacker: "Stk",								shuttle: "Shl", processor: "Prc", turntable: "Trn",								buffer: "Buf", source: "Src", sink: "Sink",							};							label = typeNames[kind] || kind.slice(0, 4);						} else {							// Far: just a tiny dot marker, no text							continue;						}						const escaped = label							.replace(/&/g, "&amp;")							.replace(/</g, "&lt;")							.replace(/>/g, "&gt;")							.replace(/"/g, "&quot;");						parts.push(							`<span style="position:absolute;left:${screen.x}px;top:${screen.y}px;` +								`transform:translate(-50%,-50%);font-size:${fontSize}px;color:#ccc;` +								`background:${isSel ? "rgba(64,128,255,0.8)" : "rgba(0,0,0,0.5)"};` +								`padding:1px 4px;border-radius:2px;white-space:nowrap;pointer-events:none;` +								`font-family:'Segoe UI',sans-serif;` +								`border:${isSel ? "1px solid rgba(255,255,255,0.3)" : "none"}">${escaped}</span>`,						);					}					labelEl.innerHTML = parts.join("");				}			} catch (err) {				console.error("[Scene3D] render loop error:", err);			}		});		const handleDragOver = (e: DragEvent) => {			e.preventDefault();			if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";		};		const handleDrop = (e: DragEvent) => {			e.preventDefault();			const entityType = e.dataTransfer?.getData("entityType");			if (!entityType) return;			const rect = canvas.getBoundingClientRect();			const x = e.clientX - rect.left;			const y = e.clientY - rect.top;			try {				const pick = scene.pick(x, y, (m) => m === groundRef.current);				if (pick?.pickedPoint) {					addEntity(entityType, pick.pickedPoint.x, pick.pickedPoint.z);				}			} catch {}		};		canvas.addEventListener("dragover", handleDragOver);		canvas.addEventListener("drop", handleDrop);		const onResize = () => engine.resize();		window.addEventListener("resize", onResize);		const resizeObs = new ResizeObserver(() => engine.resize());		resizeObs.observe(canvas);		const onZoomIn = () => {			if (cameraRef.current) cameraRef.current.radius *= 0.8;		};		const onZoomOut = () => {			if (cameraRef.current) cameraRef.current.radius *= 1.25;		};		const onFitView = () => {			if (!cameraRef.current) return;			const { entities, activeLayer } = useSimStore.getState();			if (entities.size === 0) return;			let minX = Infinity,				minZ = Infinity,				maxX = -Infinity,				maxZ = -Infinity;			let found = false;			// Match Java Viewport.fit() logic: consider rotation (getGlobalPolygon)			for (const e of entities.values()) {				if ((e.layer ?? 0) !== activeLayer) continue;				found = true;				const sx = e.sizex ?? 1;				const sy = e.sizey ?? 1;				const rotation = e.rotation ?? 0;				const halfW = sx / 2;				const halfH = sy / 2;				const corners = [					{ x: -halfW, y: -halfH },					{ x: halfW, y: -halfH },					{ x: halfW, y: halfH },					{ x: -halfW, y: halfH },				];				const cosR = Math.cos(rotation);				const sinR = Math.sin(rotation);				for (const corner of corners) {					const rx = corner.x * cosR - corner.y * sinR;					const ry = corner.x * sinR + corner.y * cosR;					const wx = e.posx + rx;					const wz = e.posy + ry; // posy maps to Z in 3D					if (wx < minX) minX = wx;					if (wz < minZ) minZ = wz;					if (wx > maxX) maxX = wx;					if (wz > maxZ) maxZ = wz;				}			}			if (!found) return;			const cx = (minX + maxX) / 2;			const cz = (minZ + maxZ) / 2;			cameraRef.current.target.set(cx, 0, cz);			const span = Math.max(maxX - minX, maxZ - minZ);			cameraRef.current.radius = Math.max(span * 1.5 + 5, 10);		};		const onShiftLeft = () => {			if (cameraRef.current) cameraRef.current.target.x -= 2;		};		const onShiftRight = () => {			if (cameraRef.current) cameraRef.current.target.x += 2;		};		const onShiftUp = () => {			if (cameraRef.current) cameraRef.current.target.z -= 2;		};		const onShiftDown = () => {			if (cameraRef.current) cameraRef.current.target.z += 2;		};		const noop = () => {};		const viewEvents: [string, EventListener][] = [			["zoomIn", onZoomIn],			["zoomOut", onZoomOut],			["fitView", onFitView],			["shiftLeft", onShiftLeft],			["shiftRight", onShiftRight],			["shiftUp", onShiftUp],			["shiftDown", onShiftDown],			["toggleDetailLevel", noop],			["viewLayer", noop],			["toggleShadowView", noop],			["savePointOfView", noop],			["deletePointOfView", noop],		];		for (const [name, handler] of viewEvents) {			window.addEventListener(name, handler);		}		return () => {			disposedRef.current = true;			// Sync current 3D viewport to store before unmount			const cam = cameraRef.current;			if (cam) {				const cx = cam.target.x;				const cz = cam.target.z;				const zoom = 60 / cam.radius;				useSimStore.getState().setViewport(cx, cz, zoom);			}			canvas.removeEventListener("dragover", handleDragOver);			canvas.removeEventListener("drop", handleDrop);			window.removeEventListener("resize", onResize);			for (const [name, handler] of viewEvents) {				window.removeEventListener(name, handler);			}			resizeObs.disconnect();			// Stop render loop FIRST to prevent any further access to scene/meshes			engine.stopRenderLoop();			// Dispose scene (cascades to all meshes, materials, layers)			try {				if (!scene.isDisposed) scene.dispose();			} catch {}			// Dispose engine (releases WebGL context)			try {				if (!engine.isDisposed) engine.dispose();			} catch {}			// Clear all refs (meshes are already disposed via scene.dispose())			flowParticlesRef.current = [];			arrowCacheRef.current.clear();			linkMeshRef.current = null;			highlightRef.current = null;			entityMeshesRef.current.clear();			itemMeshesRef.current.clear();			overlayMeshesRef.current.clear();			overlayHashRef.current.clear();			entityShapeKindsRef.current.clear();			entitySizesRef.current.clear();			prevPositionsRef.current.clear();			materialCacheRef.current.clear();			clearMaterialCache();			thinManagerRef.current?.dispose();			thinManagerRef.current = null;		};	}, [updateEntity, selectEntity, getMaterial, addEntity]);	const serverConfig = useSimStore((s) => s.serverConfig);	useEffect(() => {		const scene = sceneRef.current;		if (!scene || scene.isDisposed) return;		scene.clearColor = hexToColor4(serverConfig.backgroundColor);	}, [serverConfig]);	return (		<div style={{ position: "relative", width: "100%", height: "100%" }}>			<canvas				ref={canvasRef}				aria-label="3D entity canvas"				tabIndex={-1}				style={{					width: "100%",					height: "100%",					display: "block",					outline: "none",					background: serverConfig.backgroundColor || "#ffffff",				}}			/>			<div				ref={labelRef}				style={{					position: "absolute",					inset: 0,					overflow: "hidden",					pointerEvents: "none",				}}			/>			<div				ref={metricsRef}				style={{					position: "absolute",					top: 8,					left: 8,					padding: "6px 10px",					background: "rgba(0,0,0,0.7)",					color: "#aaccee",					fontSize: 11,					fontFamily: "'Consolas',monospace",					borderRadius: 4,					border: "1px solid rgba(255,255,255,0.1)",					pointerEvents: "none",					minWidth: 180,				}}			/>			<div				style={{					position: "absolute",					bottom: 8,					right: 8,					padding: "4px 10px",					background: "rgba(0,0,0,0.6)",					color: "#aaccee",					fontSize: 13,					fontFamily: "'Segoe UI',sans-serif",					borderRadius: 4,					border: "1px solid rgba(255,255,255,0.1)",					pointerEvents: "none",					lineHeight: 1.4,				}}			>				<span style={{ color: "#888", marginRight: 4 }}>TIME</span>				{simTime.toFixed(1)}s			</div>		</div>	);}