const { useEffect, useMemo, useRef, useState } = React; const MATERIAL_PRESETS = { drywall: { label: "Drywall", surfaceLossDb: 3.5, clutterLossDb: 1.2, diffractionWeight: 0.45, color: "#d8c6ab", metalness: 0.05, roughness: 0.92, description: "Light partitions or interior walls." }, glass: { label: "Glass", surfaceLossDb: 2.8, clutterLossDb: 0.8, diffractionWeight: 0.35, color: "#7db7d9", metalness: 0.05, roughness: 0.2, description: "Windows or facades with lower blocking." }, wood: { label: "Wood", surfaceLossDb: 4.5, clutterLossDb: 1.3, diffractionWeight: 0.45, color: "#9f6d3d", metalness: 0.06, roughness: 0.84, description: "Stud walls, shelving, timber surfaces." }, brick: { label: "Brick", surfaceLossDb: 8.5, clutterLossDb: 2.0, diffractionWeight: 0.65, color: "#b85a3c", metalness: 0.08, roughness: 0.88, description: "Exterior walls or dense interior masonry." }, concrete: { label: "Concrete", surfaceLossDb: 12, clutterLossDb: 2.6, diffractionWeight: 0.85, color: "#91969c", metalness: 0.12, roughness: 0.9, description: "Heavy slabs or reinforced core walls." }, metal: { label: "Metal", surfaceLossDb: 18, clutterLossDb: 3.5, diffractionWeight: 1.1, color: "#9cadbb", metalness: 0.72, roughness: 0.28, description: "Racks, containers, shields, or sheet metal." } }; const DEFAULT_PATTERN = { omniGainDb: 2.2, peakGainDbi: 15, horizontalBeamwidthDeg: 65, verticalBeamwidthDeg: 8, sidelobeAttenuationDb: 30, maxAttenuationDb: 30, frontBackDb: 25 }; const DEFAULT_SIM = { frequencyMHz: 3500, maxRangeM: 250, widthSamples: 240, heightSamples: 180, minDbm: -120, maxDbm: -45, passThresholdDbm: -95, noiseFloorDbm: -140, hitMergeToleranceM: 0.12 }; const DEFAULT_TRANSFORM = { upAxis: "Z", scale: 1, offsetX: 0, offsetY: 0, offsetZ: 0, rotateX: 0, rotateY: 0, rotateZ: 0 }; const DEFAULT_SLICE = { startX: -20, startY: 0, endX: 20, endY: 0, bottomZ: 0, topZ: 30 }; const COLOR_STOPS = [ { t: 0.0, color: [20, 39, 110] }, { t: 0.22, color: [42, 132, 196] }, { t: 0.5, color: [31, 191, 131] }, { t: 0.74, color: [245, 183, 38] }, { t: 1.0, color: [224, 62, 54] } ]; let antennaCounter = 1; let meshBvhPatched = false; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function formatNumber(value, digits = 1) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric.toFixed(digits) : "0"; } function safeNumber(value, fallback = 0) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : fallback; } function uid() { try { return crypto.randomUUID(); } catch (error) { return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`; } } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function createAntenna(overrides = {}) { const index = antennaCounter; antennaCounter += 1; return { id: uid(), label: `TX ${index}`, x: 0, y: 0, z: 12, powerDbm: 30, type: "directional", azimuthDeg: 0, tiltDeg: 4, ...overrides }; } function suggestSliceFromBounds(bounds) { if (!bounds) { return { ...DEFAULT_SLICE }; } const dx = bounds.max.x - bounds.min.x; const dy = bounds.max.y - bounds.min.y; const centerX = (bounds.min.x + bounds.max.x) * 0.5; const centerY = (bounds.min.y + bounds.max.y) * 0.5; const minZ = bounds.min.z; const maxZ = bounds.max.z + 4; if (dx >= dy) { return { startX: bounds.min.x, startY: centerY, endX: bounds.max.x, endY: centerY, bottomZ: minZ, topZ: maxZ }; } return { startX: centerX, startY: bounds.min.y, endX: centerX, endY: bounds.max.y, bottomZ: minZ, topZ: maxZ }; } function sliceInfo(slice) { const dx = slice.endX - slice.startX; const dy = slice.endY - slice.startY; const length = Math.hypot(dx, dy); const dirX = length > 0 ? dx / length : 1; const dirY = length > 0 ? dy / length : 0; const headingRad = Math.atan2(dirY, dirX); return { dx, dy, length, dirX, dirY, headingRad }; } function lerpColor(from, to, t) { return [ Math.round(from[0] + (to[0] - from[0]) * t), Math.round(from[1] + (to[1] - from[1]) * t), Math.round(from[2] + (to[2] - from[2]) * t) ]; } function colorMap(value, minDbm, maxDbm) { const denom = Math.max(1e-6, maxDbm - minDbm); const t = clamp((value - minDbm) / denom, 0, 1); for (let index = 1; index < COLOR_STOPS.length; index += 1) { const prev = COLOR_STOPS[index - 1]; const next = COLOR_STOPS[index]; if (t <= next.t) { const local = (t - prev.t) / Math.max(1e-6, next.t - prev.t); const rgb = lerpColor(prev.color, next.color, local); return [rgb[0], rgb[1], rgb[2], 255]; } } const last = COLOR_STOPS[COLOR_STOPS.length - 1].color; return [last[0], last[1], last[2], 255]; } function dbmToMw(dbm) { return Math.pow(10, dbm / 10); } function mwToDbm(mw) { return 10 * Math.log10(Math.max(1e-15, mw)); } function wavelengthM(frequencyMHz) { return 299792458 / (frequencyMHz * 1e6); } function fsplDb(frequencyMHz, distanceM) { return 32.44 + 20 * Math.log10(Math.max(1e-6, distanceM) / 1000) + 20 * Math.log10(frequencyMHz); } function fresnelNu(h, d1, d2, lambda) { return h * Math.sqrt(2 * (d1 + d2) / Math.max(1e-9, lambda * d1 * d2)); } function knifeEdgeLossDb(nu) { if (nu <= -0.78) { return 0; } return 6.9 + 20 * Math.log10(Math.sqrt((nu - 0.1) * (nu - 0.1) + 1) + nu - 0.1); } function azimuthElevationErrors(tx, rx, azimuthDeg, tiltDeg) { const delta = rx.clone().sub(tx); const range = delta.length(); const azimuth = ((Math.atan2(delta.y, delta.x) * 180) / Math.PI + 360) % 360; const elevation = (Math.atan2(delta.z, Math.hypot(delta.x, delta.y)) * 180) / Math.PI; const azimuthErrorDeg = ((azimuth - azimuthDeg + 540) % 360) - 180; const elevationErrorDeg = elevation - -tiltDeg; return { azimuthErrorDeg, elevationErrorDeg, range }; } function directionalGainDb(pattern, azimuthErrorDeg, elevationErrorDeg) { const horizontalLoss = Math.min( pattern.maxAttenuationDb, 12 * Math.pow(azimuthErrorDeg / Math.max(1e-6, pattern.horizontalBeamwidthDeg), 2) ); const verticalLoss = Math.min( pattern.sidelobeAttenuationDb, 12 * Math.pow(elevationErrorDeg / Math.max(1e-6, pattern.verticalBeamwidthDeg), 2) ); const totalLoss = Math.min(pattern.maxAttenuationDb, horizontalLoss + verticalLoss); const backLoss = Math.abs(azimuthErrorDeg) > 90 ? pattern.frontBackDb : 0; return pattern.peakGainDbi - Math.max(totalLoss, backLoss); } function antennaGainDb(antenna, pattern, tx, rx) { if (antenna.type === "omni") { return pattern.omniGainDb; } const { azimuthErrorDeg, elevationErrorDeg } = azimuthElevationErrors( tx, rx, antenna.azimuthDeg, antenna.tiltDeg ); return directionalGainDb(pattern, azimuthErrorDeg, elevationErrorDeg); } function patchMeshBvh() { if (meshBvhPatched || !window.MeshBVHLib || !window.THREE) { return; } const { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } = window.MeshBVHLib; if (computeBoundsTree && !THREE.BufferGeometry.prototype.computeBoundsTree) { THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; } if (disposeBoundsTree && !THREE.BufferGeometry.prototype.disposeBoundsTree) { THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree; } if (acceleratedRaycast && THREE.Mesh.prototype.raycast !== acceleratedRaycast) { THREE.Mesh.prototype.raycast = acceleratedRaycast; } meshBvhPatched = true; } function disposeMaterial(material) { if (!material) { return; } if (Array.isArray(material)) { material.forEach(disposeMaterial); return; } if (material.map) { material.map.dispose(); } material.dispose(); } function disposeObject3D(root) { if (!root) { return; } root.traverse((node) => { if (node.isMesh) { if (node.geometry && node.geometry.disposeBoundsTree) { node.geometry.disposeBoundsTree(); } if (node.geometry) { node.geometry.dispose(); } disposeMaterial(node.material); } }); } function applyUniformModelMaterial(root, materialPreset) { if (!root) { return; } root.traverse((node) => { if (!node.isMesh) { return; } disposeMaterial(node.material); node.material = new THREE.MeshStandardMaterial({ color: materialPreset.color, metalness: materialPreset.metalness, roughness: materialPreset.roughness, side: THREE.DoubleSide, transparent: false }); if (node.geometry) { node.geometry.computeVertexNormals(); } }); } function dedupeHits(hits, toleranceM) { const unique = []; for (const hit of hits || []) { if (!Number.isFinite(hit.distance)) { continue; } const previous = unique[unique.length - 1]; if (previous && Math.abs(hit.distance - previous.distance) < toleranceM) { continue; } unique.push(hit); } return unique; } function createSliceHelperGroup(slice) { const group = new THREE.Group(); const info = sliceInfo(slice); const height = Math.max(0.1, slice.topZ - slice.bottomZ); const midX = (slice.startX + slice.endX) * 0.5; const midY = (slice.startY + slice.endY) * 0.5; const midZ = (slice.bottomZ + slice.topZ) * 0.5; const planeGeometry = new THREE.PlaneGeometry(Math.max(0.1, info.length), height); const planeMaterial = new THREE.MeshBasicMaterial({ color: 0x7dd3fc, transparent: true, opacity: 0.12, side: THREE.DoubleSide }); const plane = new THREE.Mesh(planeGeometry, planeMaterial); plane.position.set(midX, midY, midZ); plane.rotation.x = Math.PI / 2; plane.rotation.z = info.headingRad; group.add(plane); const outlineGeometry = new THREE.EdgesGeometry(planeGeometry); const outline = new THREE.LineSegments( outlineGeometry, new THREE.LineBasicMaterial({ color: 0xf8fafc, transparent: true, opacity: 0.8 }) ); outline.position.copy(plane.position); outline.rotation.copy(plane.rotation); group.add(outline); const baselineGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(slice.startX, slice.startY, slice.bottomZ), new THREE.Vector3(slice.endX, slice.endY, slice.bottomZ) ]); const baseline = new THREE.Line( baselineGeometry, new THREE.LineBasicMaterial({ color: 0xf59e0b }) ); group.add(baseline); return group; } function createHeatmapPlane(canvas, slice) { const info = sliceInfo(slice); const height = Math.max(0.1, slice.topZ - slice.bottomZ); const midX = (slice.startX + slice.endX) * 0.5; const midY = (slice.startY + slice.endY) * 0.5; const midZ = (slice.bottomZ + slice.topZ) * 0.5; const normal = new THREE.Vector3(-info.dirY, info.dirX, 0); const offset = normal.lengthSq() > 0 ? normal.normalize().multiplyScalar(0.05) : new THREE.Vector3(); const geometry = new THREE.PlaneGeometry(Math.max(0.1, info.length), height); const texture = new THREE.CanvasTexture(canvas); texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; texture.repeat.set(1, -1); texture.offset.set(0, 1); const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: 0.92, side: THREE.DoubleSide, depthWrite: false }); const mesh = new THREE.Mesh(geometry, material); mesh.position.set(midX + offset.x, midY + offset.y, midZ + offset.z); mesh.rotation.x = Math.PI / 2; mesh.rotation.z = info.headingRad; return mesh; } function NumberField({ label, value, onChange, step = "any", min, max }) { return ( ); } function SelectField({ label, value, onChange, children }) { return ( ); } function App() { const previewRef = useRef(null); const heatmapCanvasRef = useRef(null); const scenarioInputRef = useRef(null); const sceneApiRef = useRef(null); const resultRef = useRef(null); const liveStateRef = useRef({}); const simTokenRef = useRef(0); const [materialKey, setMaterialKey] = useState("concrete"); const [pattern, setPattern] = useState(DEFAULT_PATTERN); const [simulation, setSimulation] = useState(DEFAULT_SIM); const [transform, setTransform] = useState(DEFAULT_TRANSFORM); const [slice, setSlice] = useState(DEFAULT_SLICE); const [antennas, setAntennas] = useState([ createAntenna({ x: 0, y: -12, z: 14, azimuthDeg: 0, tiltDeg: 6 }) ]); const [status, setStatus] = useState("Load an OBJ file, pick a material, then render a vertical RSRP slice."); const [progress, setProgress] = useState(0); const [isRunning, setIsRunning] = useState(false); const [pickMode, setPickMode] = useState(null); const [modelInfo, setModelInfo] = useState({ name: "No OBJ loaded yet", triangles: 0, meshCount: 0, bounds: null }); const [summary, setSummary] = useState(null); const [probe, setProbe] = useState(null); const [staleResult, setStaleResult] = useState(false); const sliceLength = useMemo(() => sliceInfo(slice).length, [slice]); const materialPreset = MATERIAL_PRESETS[materialKey]; useEffect(() => { liveStateRef.current = { pickMode, slice, antennas }; }, [pickMode, slice, antennas]); useEffect(() => { patchMeshBvh(); const preview = previewRef.current; if (!preview) { return undefined; } const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); preview.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0c1624); const camera = new THREE.PerspectiveCamera(58, 1, 0.1, 20000); camera.position.set(80, -120, 70); const controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.target.set(0, 0, 10); const ambient = new THREE.HemisphereLight(0xf7fafc, 0x1e293b, 1.15); scene.add(ambient); const sun = new THREE.DirectionalLight(0xffffff, 1.1); sun.position.set(110, -60, 160); scene.add(sun); const rim = new THREE.DirectionalLight(0x7dd3fc, 0.55); rim.position.set(-130, 90, 50); scene.add(rim); const grid = new THREE.GridHelper(1000, 100, 0x2d5f77, 0x234055); grid.rotation.x = Math.PI / 2; scene.add(grid); const axes = new THREE.AxesHelper(25); scene.add(axes); const modelGroup = new THREE.Group(); const helperGroup = new THREE.Group(); const antennaGroup = new THREE.Group(); const resultGroup = new THREE.Group(); scene.add(modelGroup); scene.add(helperGroup); scene.add(antennaGroup); scene.add(resultGroup); const api = { renderer, scene, camera, controls, modelGroup, helperGroup, antennaGroup, resultGroup, bbox: new THREE.Box3(), meshes: [], modelRoot: null, antennaMarkers: new Map(), sliceHelper: null, heatmapMesh: null, raycaster: new THREE.Raycaster(), dragStart: null, dragged: false }; function resize() { const width = preview.clientWidth || 1; const height = preview.clientHeight || 1; renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); } function animate() { api.animationFrame = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } function screenToWorld(event) { const rect = renderer.domElement.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * 2 - 1; const y = -((event.clientY - rect.top) / rect.height) * 2 + 1; api.raycaster.setFromCamera({ x, y }, camera); if (api.meshes.length) { const modelHit = api.raycaster.intersectObjects(api.meshes, true)[0]; if (modelHit) { return modelHit.point.clone(); } } const groundZ = api.bbox && Number.isFinite(api.bbox.min.z) ? api.bbox.min.z : 0; const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -groundZ); const point = new THREE.Vector3(); if (api.raycaster.ray.intersectPlane(plane, point)) { return point.clone(); } return null; } function removeNearestAntenna(point) { setAntennas((current) => { if (!current.length) { return current; } let bestId = current[0].id; let bestDistance = Number.POSITIVE_INFINITY; current.forEach((antenna) => { const distance = Math.hypot(antenna.x - point.x, antenna.y - point.y, antenna.z - point.z); if (distance < bestDistance) { bestDistance = distance; bestId = antenna.id; } }); setStatus(`Removed the nearest antenna to (${formatNumber(point.x)}, ${formatNumber(point.y)}).`); setStaleResult(true); return current.filter((antenna) => antenna.id !== bestId); }); } renderer.domElement.addEventListener("pointerdown", (event) => { api.dragStart = { x: event.clientX, y: event.clientY }; api.dragged = false; }); renderer.domElement.addEventListener("pointermove", (event) => { if (!api.dragStart) { return; } const dx = event.clientX - api.dragStart.x; const dy = event.clientY - api.dragStart.y; if (Math.hypot(dx, dy) > 6) { api.dragged = true; } }); renderer.domElement.addEventListener("pointerup", (event) => { if (api.dragged) { api.dragStart = null; api.dragged = false; return; } const point = screenToWorld(event); api.dragStart = null; api.dragged = false; if (!point) { return; } const currentState = liveStateRef.current; if (currentState.pickMode === "sliceStart") { setSlice((current) => ({ ...current, startX: point.x, startY: point.y })); setPickMode(null); setStatus(`Slice start locked to (${formatNumber(point.x)}, ${formatNumber(point.y)}).`); setStaleResult(true); return; } if (currentState.pickMode === "sliceEnd") { setSlice((current) => ({ ...current, endX: point.x, endY: point.y })); setPickMode(null); setStatus(`Slice end locked to (${formatNumber(point.x)}, ${formatNumber(point.y)}).`); setStaleResult(true); return; } if (event.shiftKey) { const nextAntenna = createAntenna({ x: Number(point.x.toFixed(2)), y: Number(point.y.toFixed(2)), z: Number((point.z + 2).toFixed(2)) }); setAntennas((current) => [...current, nextAntenna]); setStatus(`Added antenna at (${formatNumber(point.x)}, ${formatNumber(point.y)}, ${formatNumber(point.z + 2)}).`); setStaleResult(true); return; } if (event.altKey) { removeNearestAntenna(point); } }); resize(); animate(); sceneApiRef.current = api; window.addEventListener("resize", resize); return () => { cancelAnimationFrame(api.animationFrame); window.removeEventListener("resize", resize); if (api.modelRoot) { disposeObject3D(api.modelRoot); } antennaGroup.clear(); helperGroup.clear(); resultGroup.clear(); renderer.dispose(); if (renderer.domElement.parentNode === preview) { preview.removeChild(renderer.domElement); } sceneApiRef.current = null; }; }, []); useEffect(() => { const api = sceneApiRef.current; if (!api) { return; } const existing = api.sliceHelper; if (existing) { api.helperGroup.remove(existing); existing.traverse((node) => { if (node.geometry) { node.geometry.dispose(); } if (node.material) { disposeMaterial(node.material); } }); api.sliceHelper = null; } const helper = createSliceHelperGroup(slice); api.helperGroup.add(helper); api.sliceHelper = helper; }, [slice]); useEffect(() => { const api = sceneApiRef.current; if (!api) { return; } const activeIds = new Set(); antennas.forEach((antenna) => { activeIds.add(antenna.id); let marker = api.antennaMarkers.get(antenna.id); if (!marker) { marker = new THREE.Group(); const sphere = new THREE.Mesh( new THREE.SphereGeometry(0.9, 18, 18), new THREE.MeshStandardMaterial({ color: antenna.type === "omni" ? 0xf59e0b : 0xef4444 }) ); marker.add(sphere); const mast = new THREE.Mesh( new THREE.CylinderGeometry(0.08, 0.08, 1.8, 8), new THREE.MeshStandardMaterial({ color: 0xe2e8f0 }) ); mast.position.z = -0.95; marker.add(mast); const arrow = new THREE.ArrowHelper( new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0), 3.2, antenna.type === "omni" ? 0xf59e0b : 0x38bdf8, 0.8, 0.5 ); marker.userData.arrow = arrow; marker.add(arrow); api.antennaGroup.add(marker); api.antennaMarkers.set(antenna.id, marker); } marker.position.set(antenna.x, antenna.y, antenna.z); if (marker.userData.arrow) { const azimuthRad = (antenna.azimuthDeg * Math.PI) / 180; const direction = new THREE.Vector3(Math.cos(azimuthRad), Math.sin(azimuthRad), 0); marker.userData.arrow.setDirection(direction.normalize()); marker.userData.arrow.visible = antenna.type === "directional"; } const sphere = marker.children[0]; if (sphere && sphere.material) { sphere.material.color.set(antenna.type === "omni" ? 0xf59e0b : 0xef4444); } }); Array.from(api.antennaMarkers.entries()).forEach(([id, marker]) => { if (activeIds.has(id)) { return; } api.antennaGroup.remove(marker); marker.traverse((node) => { if (node.geometry) { node.geometry.dispose(); } if (node.material) { disposeMaterial(node.material); } }); api.antennaMarkers.delete(id); }); }, [antennas]); useEffect(() => { const api = sceneApiRef.current; if (!api || !api.modelRoot) { return; } applyUniformModelMaterial(api.modelRoot, MATERIAL_PRESETS[materialKey]); }, [materialKey]); function clearHeatmapVisual() { const api = sceneApiRef.current; if (!api || !api.heatmapMesh) { return; } api.resultGroup.remove(api.heatmapMesh); if (api.heatmapMesh.geometry) { api.heatmapMesh.geometry.dispose(); } if (api.heatmapMesh.material) { disposeMaterial(api.heatmapMesh.material); } api.heatmapMesh = null; } function markDirty() { clearHeatmapVisual(); if (resultRef.current) { setStaleResult(true); } } function updateTransformField(key, value) { setTransform((current) => ({ ...current, [key]: key === "upAxis" ? value : safeNumber(value, current[key]) })); markDirty(); } function updatePatternField(key, value) { setPattern((current) => ({ ...current, [key]: safeNumber(value, current[key]) })); markDirty(); } function updateSimulationField(key, value) { setSimulation((current) => ({ ...current, [key]: safeNumber(value, current[key]) })); markDirty(); } function updateSliceField(key, value) { setSlice((current) => ({ ...current, [key]: safeNumber(value, current[key]) })); markDirty(); } function updateAntenna(antennaId, key, value) { setAntennas((current) => current.map((antenna) => { if (antenna.id !== antennaId) { return antenna; } return { ...antenna, [key]: key === "type" || key === "label" ? value : safeNumber(value, antenna[key]) }; }) ); markDirty(); } function removeAntenna(antennaId) { setAntennas((current) => current.filter((antenna) => antenna.id !== antennaId)); setStatus("Removed antenna row."); markDirty(); } function addManualAntenna() { const bounds = modelInfo.bounds; const centerX = bounds ? (bounds.min.x + bounds.max.x) * 0.5 : 0; const centerY = bounds ? (bounds.min.y + bounds.max.y) * 0.5 : 0; const topZ = bounds ? bounds.max.z + 2 : 12; setAntennas((current) => [ ...current, createAntenna({ x: Number(centerX.toFixed(2)), y: Number(centerY.toFixed(2)), z: Number(topZ.toFixed(2)) }) ]); setStatus("Added a centered antenna row."); markDirty(); } function frameModel() { const api = sceneApiRef.current; if (!api) { return; } api.modelGroup.updateMatrixWorld(true); api.bbox.setFromObject(api.modelGroup); if (!Number.isFinite(api.bbox.min.x)) { return; } const size = new THREE.Vector3(); const center = new THREE.Vector3(); api.bbox.getSize(size); api.bbox.getCenter(center); const radius = Math.max(size.x, size.y, size.z, 10); api.camera.position.set(center.x + radius * 1.6, center.y - radius * 1.7, center.z + radius * 1.15); api.controls.target.copy(center); api.controls.update(); } function refreshModelMetadata(api, fileName) { api.bbox.setFromObject(api.modelGroup); const triangles = api.meshes.reduce((sum, mesh) => { const position = mesh.geometry && mesh.geometry.attributes && mesh.geometry.attributes.position; return sum + (position ? Math.floor(position.count / 3) : 0); }, 0); const bounds = Number.isFinite(api.bbox.min.x) ? { min: { x: api.bbox.min.x, y: api.bbox.min.y, z: api.bbox.min.z }, max: { x: api.bbox.max.x, y: api.bbox.max.y, z: api.bbox.max.z } } : null; setModelInfo({ name: fileName || "OBJ model", triangles, meshCount: api.meshes.length, bounds }); } function rebuildMeshList(api) { api.meshes = []; api.modelGroup.updateMatrixWorld(true); api.modelGroup.traverse((node) => { if (!node.isMesh || !node.geometry) { return; } if (!node.geometry.boundingBox) { node.geometry.computeBoundingBox(); } if (!node.geometry.boundingSphere) { node.geometry.computeBoundingSphere(); } if (node.geometry.computeBoundsTree && !node.geometry.boundsTree) { try { node.geometry.computeBoundsTree(); } catch (error) { console.warn("Bounds tree build skipped:", error); } } api.meshes.push(node); }); } function applyTransformToModel() { const api = sceneApiRef.current; if (!api || !api.modelRoot) { setStatus("Load an OBJ file before applying transforms."); return; } api.modelGroup.position.set(transform.offsetX, transform.offsetY, transform.offsetZ); api.modelGroup.rotation.set(0, 0, 0); api.modelGroup.scale.setScalar(Math.max(1e-6, transform.scale)); if (transform.upAxis === "Y") { api.modelGroup.rotateX(Math.PI / 2); } api.modelGroup.rotateX((transform.rotateX * Math.PI) / 180); api.modelGroup.rotateY((transform.rotateY * Math.PI) / 180); api.modelGroup.rotateZ((transform.rotateZ * Math.PI) / 180); rebuildMeshList(api); refreshModelMetadata(api, modelInfo.name); frameModel(); setStatus("Applied model transform and rebuilt the raycast mesh index."); clearHeatmapVisual(); markDirty(); } async function loadObjFile(file) { const api = sceneApiRef.current; if (!api || !file) { return; } try { const lowerName = String(file.name || "").toLowerCase(); if (!lowerName.endsWith(".obj")) { throw new Error("Please upload an OBJ file."); } const text = await file.text(); const loader = new THREE.OBJLoader(); const root = loader.parse(text); if (api.modelRoot) { disposeObject3D(api.modelRoot); } while (api.modelGroup.children.length) { api.modelGroup.remove(api.modelGroup.children[0]); } api.modelRoot = root; api.modelGroup.add(root); applyUniformModelMaterial(root, MATERIAL_PRESETS[materialKey]); api.modelGroup.position.set(transform.offsetX, transform.offsetY, transform.offsetZ); api.modelGroup.rotation.set(0, 0, 0); api.modelGroup.scale.setScalar(Math.max(1e-6, transform.scale)); if (transform.upAxis === "Y") { api.modelGroup.rotateX(Math.PI / 2); } api.modelGroup.rotateX((transform.rotateX * Math.PI) / 180); api.modelGroup.rotateY((transform.rotateY * Math.PI) / 180); api.modelGroup.rotateZ((transform.rotateZ * Math.PI) / 180); rebuildMeshList(api); refreshModelMetadata(api, file.name); const bounds = api.meshes.length ? { min: { x: api.bbox.min.x, y: api.bbox.min.y, z: api.bbox.min.z }, max: { x: api.bbox.max.x, y: api.bbox.max.y, z: api.bbox.max.z } } : null; if (bounds) { setSlice(suggestSliceFromBounds(bounds)); } frameModel(); setStatus(`Loaded ${file.name}. Shift-click adds antennas and Alt-click removes the nearest one.`); clearHeatmapVisual(); resultRef.current = null; setSummary(null); setProbe(null); setStaleResult(false); } catch (error) { console.error(error); setStatus(`Error loading OBJ: ${error.message}`); } } function exportScenario() { const payload = { exportedAt: new Date().toISOString(), materialKey, pattern, simulation, transform, slice, antennas }; downloadBlob( new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" }), "rf-rsrp-slice-scenario.json" ); } async function importScenario(file) { if (!file) { return; } try { const payload = JSON.parse(await file.text()); if (payload.materialKey && MATERIAL_PRESETS[payload.materialKey]) { setMaterialKey(payload.materialKey); } if (payload.pattern) { setPattern((current) => ({ ...current, ...payload.pattern })); } if (payload.simulation) { setSimulation((current) => ({ ...current, ...payload.simulation })); } if (payload.transform) { setTransform((current) => ({ ...current, ...payload.transform })); } if (payload.slice) { setSlice((current) => ({ ...current, ...payload.slice })); } if (Array.isArray(payload.antennas) && payload.antennas.length) { setAntennas( payload.antennas.map((antenna, index) => ({ ...createAntenna(), ...antenna, label: antenna.label || `TX ${index + 1}` })) ); } setStatus("Scenario imported. If the model is already loaded, rerun the slice after reviewing the settings."); markDirty(); } catch (error) { console.error(error); setStatus(`Scenario import failed: ${error.message}`); } } function cancelSimulation() { simTokenRef.current += 1; setIsRunning(false); setProgress(0); setStatus("Current slice render canceled."); } function drawHeatmapToScreen(sourceCanvas) { const canvas = heatmapCanvasRef.current; if (!canvas || !sourceCanvas) { return; } canvas.width = sourceCanvas.width; canvas.height = sourceCanvas.height; const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(sourceCanvas, 0, 0); } async function runSimulation() { const api = sceneApiRef.current; if (!api || !api.meshes.length) { setStatus("Load an OBJ model first."); return; } if (!antennas.length) { setStatus("Add at least one antenna before running the slice."); return; } const sliceMeta = sliceInfo(slice); if (sliceMeta.length < 0.1) { setStatus("The slice line is too short. Move the slice start or end point."); return; } const width = Math.max(40, Math.min(520, Math.round(simulation.widthSamples))); const height = Math.max(40, Math.min(420, Math.round(simulation.heightSamples))); const frequencyMHz = simulation.frequencyMHz; const maxRangeM = simulation.maxRangeM; const minDbm = simulation.minDbm; const maxDbm = simulation.maxDbm; const passThresholdDbm = simulation.passThresholdDbm; const noiseFloorDbm = simulation.noiseFloorDbm; const toleranceM = simulation.hitMergeToleranceM; const material = MATERIAL_PRESETS[materialKey]; const lambda = wavelengthM(frequencyMHz); const token = simTokenRef.current + 1; simTokenRef.current = token; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); const image = ctx.createImageData(width, height); const dbmGrid = new Float32Array(width * height); const blockageGrid = new Uint16Array(width * height); const txIndexGrid = new Int16Array(width * height); txIndexGrid.fill(-1); const raycaster = api.raycaster; raycaster.firstHitOnly = false; const tx = new THREE.Vector3(); const rx = new THREE.Vector3(); const rayDirection = new THREE.Vector3(); let minSeen = Number.POSITIVE_INFINITY; let maxSeen = Number.NEGATIVE_INFINITY; let sumSeenDbm = 0; let passCount = 0; let obstructedCount = 0; let crossingSum = 0; setSummary(null); setProbe(null); setIsRunning(true); setProgress(0.01); setStatus("Tracing the slice through the OBJ scene..."); const total = width * height; const updateEvery = Math.max(600, Math.floor(total / 70)); const heightSpan = Math.max(0.1, slice.topZ - slice.bottomZ); for (let iy = 0; iy < height; iy += 1) { const verticalT = 1 - (iy + 0.5) / height; const z = slice.bottomZ + verticalT * heightSpan; for (let ix = 0; ix < width; ix += 1) { if (simTokenRef.current !== token) { return; } const horizontalT = (ix + 0.5) / width; const distanceAlongSlice = horizontalT * sliceMeta.length; const x = slice.startX + sliceMeta.dirX * distanceAlongSlice; const y = slice.startY + sliceMeta.dirY * distanceAlongSlice; rx.set(x, y, z); let bestRxDbm = noiseFloorDbm; let bestCrossings = 0; let bestAntennaIndex = -1; antennas.forEach((antenna, antennaIndex) => { tx.set(antenna.x, antenna.y, antenna.z); const range = tx.distanceTo(rx); if (range < 0.05 || range > maxRangeM) { return; } const gainDb = antennaGainDb(antenna, pattern, tx, rx); const freeSpaceLossDb = fsplDb(frequencyMHz, range); rayDirection.copy(rx).sub(tx).normalize(); raycaster.set(tx, rayDirection); raycaster.near = 0.05; raycaster.far = range - 0.02; const hits = dedupeHits(raycaster.intersectObjects(api.meshes, true), toleranceM); const crossings = hits.length; const surfaceLossDb = crossings * material.surfaceLossDb; const clutterLossDb = crossings > 0 ? material.clutterLossDb * Math.log2(crossings + 1) : 0; let diffractionLossDb = 0; if (crossings > 0) { const firstHit = hits[0].point; const d1 = firstHit.distanceTo(tx); const d2 = firstHit.distanceTo(rx); const zLine = tx.z + (rx.z - tx.z) * (d1 / Math.max(1e-9, d1 + d2)); const obstacleHeight = firstHit.z - zLine; const nu = fresnelNu(obstacleHeight, d1, d2, lambda); diffractionLossDb = knifeEdgeLossDb(nu) * material.diffractionWeight; } const rxDbm = antenna.powerDbm + gainDb - freeSpaceLossDb - surfaceLossDb - clutterLossDb - diffractionLossDb; if (rxDbm > bestRxDbm) { bestRxDbm = rxDbm; bestCrossings = crossings; bestAntennaIndex = antennaIndex; } }); const finalDbm = clamp(bestRxDbm, noiseFloorDbm, maxDbm + 20); const gridIndex = iy * width + ix; dbmGrid[gridIndex] = finalDbm; blockageGrid[gridIndex] = bestCrossings; txIndexGrid[gridIndex] = bestAntennaIndex; if (bestCrossings > 0) { obstructedCount += 1; } crossingSum += bestCrossings; minSeen = Math.min(minSeen, finalDbm); maxSeen = Math.max(maxSeen, finalDbm); sumSeenDbm += finalDbm; if (finalDbm >= passThresholdDbm) { passCount += 1; } const pixelIndex = gridIndex * 4; const [r, g, b, a] = colorMap(finalDbm, minDbm, maxDbm); image.data[pixelIndex] = r; image.data[pixelIndex + 1] = g; image.data[pixelIndex + 2] = b; image.data[pixelIndex + 3] = a; if (gridIndex % updateEvery === 0) { setProgress(gridIndex / total); await new Promise((resolve) => requestAnimationFrame(resolve)); } } } if (simTokenRef.current !== token) { return; } ctx.putImageData(image, 0, 0); drawHeatmapToScreen(canvas); clearHeatmapVisual(); api.heatmapMesh = createHeatmapPlane(canvas, slice); api.resultGroup.add(api.heatmapMesh); const avgDbm = sumSeenDbm / Math.max(1, total); const coveragePct = (100 * passCount) / Math.max(1, total); const obstructedPct = (100 * obstructedCount) / Math.max(1, total); const avgCrossings = crossingSum / Math.max(1, total); resultRef.current = { canvas, dbmGrid, blockageGrid, txIndexGrid, width, height, slice: { ...slice }, sliceMeta, simulation: { ...simulation }, antennas: antennas.map((antenna) => ({ ...antenna })), summary: { minDbm: minSeen, avgDbm, maxDbm: maxSeen, coveragePct, obstructedPct, avgCrossings } }; setSummary(resultRef.current.summary); setProgress(1); setIsRunning(false); setStaleResult(false); setStatus( `Slice complete. ${width}x${height} cells across ${formatNumber(sliceMeta.length)} m by ${formatNumber(heightSpan)} m.` ); } function exportPng() { const result = resultRef.current; if (!result || !result.canvas) { setStatus("Run a slice first so there is a heatmap to export."); return; } result.canvas.toBlob((blob) => { if (!blob) { return; } downloadBlob(blob, "rf-rsrp-slice.png"); }, "image/png"); } function handleHeatmapMove(event) { const result = resultRef.current; const canvas = heatmapCanvasRef.current; if (!result || !canvas) { return; } const rect = canvas.getBoundingClientRect(); const u = clamp((event.clientX - rect.left) / rect.width, 0, 0.999999); const v = clamp((event.clientY - rect.top) / rect.height, 0, 0.999999); const ix = Math.floor(u * result.width); const iy = Math.floor(v * result.height); const index = iy * result.width + ix; const distanceAlongSlice = ((ix + 0.5) / result.width) * result.sliceMeta.length; const z = result.slice.bottomZ + (1 - (iy + 0.5) / result.height) * (result.slice.topZ - result.slice.bottomZ); const x = result.slice.startX + result.sliceMeta.dirX * distanceAlongSlice; const y = result.slice.startY + result.sliceMeta.dirY * distanceAlongSlice; setProbe({ x, y, z, dbm: result.dbmGrid[index], crossings: result.blockageGrid[index], antennaLabel: result.txIndexGrid[index] >= 0 && result.antennas[result.txIndexGrid[index]] ? result.antennas[result.txIndexGrid[index]].label : "none" }); } function handleHeatmapLeave() { setProbe(null); } return (
This version keeps the rack app's Hostinger-ready structure, but the simulator is focused on one job: load an OBJ, treat every surface as the material you choose, place antennas, then render a 2D RSRP slice through the model.
Upload an OBJ and line it up with your RF coordinate frame.
Every triangle is treated as {materialPreset.label}. Rays use free-space loss plus per-surface attenuation, clutter buildup, and a first-edge diffraction penalty.
Pick two points on the model or enter coordinates directly.
Directional antennas use the global sector template below. Omni ignores azimuth and tilt.
Shift-click in the preview to place one quickly. Alt-click removes the nearest.
Orbit with the mouse. The cyan panel is the active slice. Shift-click places antennas.
Distance runs left to right along the slice line. Height runs bottom to top.