saltar al contenido principal
paste
bin
.ca
type · paste · share
⌘
K
Docs
Iniciar sesión
?
← volver a la publicación
›
Editar / bifurcar
Publicación sin título
#2WGMmNGaDg
public / public
nueva versión
anónimo
creado 6 days ago
Caduca en 1 day
82.5 KB
sintaxis:
text
Tus cambios crean una nueva publicación enlazada a esta — la original no se toca.
nueva versión
Tus cambios crean una nueva publicación enlazada a esta — la original no se toca.
Título (opcional)
Nombre de archivo
Sintaxis
text
text
bash
c
cpp
css
diff
dockerfile
go
html
ini
java
javascript
json
kotlin
lua
makefile
markdown
nginx
php
python
ruby
rust
shellscript
sql
swift
toml
typescript
xml
yaml
Visibilidad
Feed público
Acceso
public
Caduca
7 días
10 min
1 hora
1 día
7 días
30 días
90 días
personalizada…
Caducidad personalizada
Nota de cambio
(opcional)
Esta publicación aparecerá en el feed público. Cambia Visibilidad si solo quieres compartirla por enlace.
Crear nueva versión
Cancelar
Pegar o escribir…
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 components import "@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> ); }