rendered paste bodyimport { 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, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); 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> );}