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 (
    <label className="block">
      <span className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
        {label}
      </span>
      <input
        type="number"
        className="w-full rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-teal-700 focus:ring-2 focus:ring-teal-700/15"
        value={value}
        step={step}
        min={min}
        max={max}
        onChange={(event) => onChange(event.target.value)}
      />
    </label>
  );
}

function SelectField({ label, value, onChange, children }) {
  return (
    <label className="block">
      <span className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
        {label}
      </span>
      <select
        className="w-full rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-teal-700 focus:ring-2 focus:ring-teal-700/15"
        value={value}
        onChange={(event) => onChange(event.target.value)}
      >
        {children}
      </select>
    </label>
  );
}

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 (
    <div className="min-h-full px-4 py-4 md:px-5 md:py-5">
      <div className="mx-auto flex max-w-[1800px] flex-col gap-4">
        <section className="glass-panel overflow-hidden rounded-[28px]">
          <div className="flex flex-col gap-4 p-5 md:flex-row md:items-end md:justify-between md:p-6">
            <div className="space-y-3">
              <div className="inline-flex items-center gap-2 rounded-full bg-[var(--teal-soft)] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-teal-900">
                RF RSRP Slice Studio
              </div>
              <div>
                <h1 className="text-3xl font-bold tracking-tight text-slate-900 md:text-[2.3rem]">
                  OBJ-based vertical signal slices with uniform material attenuation
                </h1>
                <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600 md:text-base">
                  This version keeps the rack app&apos;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.
                </p>
              </div>
            </div>

            <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
              <button
                className="soft-button rounded-2xl bg-slate-950 px-4 py-3 text-sm font-semibold text-white"
                onClick={runSimulation}
                disabled={isRunning}
              >
                {isRunning ? "Rendering..." : "Run Slice"}
              </button>
              <button
                className="soft-button rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-slate-900"
                onClick={exportPng}
              >
                Export PNG
              </button>
              <button
                className="soft-button rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-slate-900"
                onClick={exportScenario}
              >
                Save Scenario JSON
              </button>
              <button
                className="soft-button rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-slate-900"
                onClick={() => scenarioInputRef.current && scenarioInputRef.current.click()}
              >
                Load Scenario JSON
              </button>
            </div>
          </div>

          <div className="border-t border-slate-200/70 bg-white/45 px-5 py-4 md:px-6">
            <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
              <div className="flex flex-wrap gap-3">
                <div className="metric-card px-4 py-3">
                  <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</div>
                  <div className="mt-1 text-sm text-slate-800">{status}</div>
                </div>
                <div className="metric-card px-4 py-3">
                  <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Model</div>
                  <div className="mt-1 text-sm text-slate-800">{modelInfo.name}</div>
                </div>
                <div className="metric-card px-4 py-3">
                  <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Material</div>
                  <div className="mt-1 text-sm text-slate-800">{materialPreset.label}</div>
                </div>
                <div className="metric-card px-4 py-3">
                  <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slice Length</div>
                  <div className="mt-1 text-sm text-slate-800">{formatNumber(sliceLength)} m</div>
                </div>
              </div>

              {isRunning ? (
                <div className="w-full max-w-md">
                  <div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
                    <span>Render progress</span>
                    <span>{Math.round(progress * 100)}%</span>
                  </div>
                  <div className="h-2 overflow-hidden rounded-full bg-slate-200">
                    <div className="h-full bg-teal-700 transition-all" style={{ width: `${clamp(progress * 100, 0, 100)}%` }} />
                  </div>
                  <button className="mt-3 text-sm font-semibold text-slate-900 underline" onClick={cancelSimulation}>
                    Cancel current run
                  </button>
                </div>
              ) : null}
            </div>
          </div>

          <input
            ref={scenarioInputRef}
            type="file"
            accept=".json,application/json"
            className="hidden"
            onChange={(event) => importScenario(event.target.files && event.target.files[0])}
          />
        </section>

        <div className="grid gap-4 xl:grid-cols-[430px_minmax(0,1fr)]">
          <section className="glass-panel rounded-[28px] p-4 md:p-5">
            <div className="space-y-4">
              <div className="rounded-[22px] border border-slate-200/80 bg-white/70 p-4">
                <div className="mb-3 flex items-center justify-between">
                  <div>
                    <h2 className="text-lg font-bold text-slate-900">Model</h2>
                    <p className="text-sm text-slate-600">Upload an OBJ and line it up with your RF coordinate frame.</p>
                  </div>
                  <button
                    className="soft-button rounded-full bg-slate-950 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white"
                    onClick={frameModel}
                  >
                    Frame View
                  </button>
                </div>

                <div className="space-y-3">
                  <label className="block rounded-2xl border border-dashed border-slate-300 bg-white/70 px-3 py-3">
                    <span className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
                      OBJ file
                    </span>
                    <input type="file" accept=".obj" onChange={(event) => loadObjFile(event.target.files && event.target.files[0])} />
                  </label>

                  <div className="grid gap-3 sm:grid-cols-2">
                    <SelectField
                      label="Model up axis"
                      value={transform.upAxis}
                      onChange={(value) => updateTransformField("upAxis", value)}
                    >
                      <option value="Z">Z up</option>
                      <option value="Y">Y up</option>
                    </SelectField>
                    <NumberField label="Scale to meters" value={transform.scale} step="0.001" onChange={(value) => updateTransformField("scale", value)} />
                  </div>

                  <div className="grid gap-3 sm:grid-cols-3">
                    <NumberField label="Offset X" value={transform.offsetX} step="0.1" onChange={(value) => updateTransformField("offsetX", value)} />
                    <NumberField label="Offset Y" value={transform.offsetY} step="0.1" onChange={(value) => updateTransformField("offsetY", value)} />
                    <NumberField label="Offset Z" value={transform.offsetZ} step="0.1" onChange={(value) => updateTransformField("offsetZ", value)} />
                  </div>

                  <div className="grid gap-3 sm:grid-cols-3">
                    <NumberField label="Rotate X" value={transform.rotateX} step="1" onChange={(value) => updateTransformField("rotateX", value)} />
                    <NumberField label="Rotate Y" value={transform.rotateY} step="1" onChange={(value) => updateTransformField("rotateY", value)} />
                    <NumberField label="Rotate Z" value={transform.rotateZ} step="1" onChange={(value) => updateTransformField("rotateZ", value)} />
                  </div>

                  <button
                    className="soft-button w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-slate-900"
                    onClick={applyTransformToModel}
                  >
                    Apply transform
                  </button>
                </div>

                <div className="mt-4 grid gap-3 sm:grid-cols-3">
                  <div className="metric-card p-3">
                    <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meshes</div>
                    <div className="mt-1 text-lg font-bold text-slate-900">{modelInfo.meshCount}</div>
                  </div>
                  <div className="metric-card p-3">
                    <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Triangles</div>
                    <div className="mt-1 text-lg font-bold text-slate-900">{modelInfo.triangles.toLocaleString()}</div>
                  </div>
                  <div className="metric-card p-3">
                    <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Hostinger</div>
                    <div className="mt-1 text-lg font-bold text-slate-900">Ready</div>
                  </div>
                </div>

                {modelInfo.bounds ? (
                  <div className="mt-3 rounded-2xl bg-slate-950 px-4 py-3 text-xs text-slate-200">
                    <div className="font-mono-ui">
                      X {formatNumber(modelInfo.bounds.min.x)} to {formatNumber(modelInfo.bounds.max.x)} m
                    </div>
                    <div className="font-mono-ui">
                      Y {formatNumber(modelInfo.bounds.min.y)} to {formatNumber(modelInfo.bounds.max.y)} m
                    </div>
                    <div className="font-mono-ui">
                      Z {formatNumber(modelInfo.bounds.min.z)} to {formatNumber(modelInfo.bounds.max.z)} m
                    </div>
                  </div>
                ) : null}
              </div>

              <div className="rounded-[22px] border border-slate-200/80 bg-white/70 p-4">
                <h2 className="text-lg font-bold text-slate-900">Material and propagation</h2>
                <p className="mt-1 text-sm text-slate-600">
                  Every triangle is treated as <span className="font-semibold">{materialPreset.label}</span>. Rays use
                  free-space loss plus per-surface attenuation, clutter buildup, and a first-edge diffraction penalty.
                </p>

                <div className="mt-4 space-y-3">
                  <SelectField label="Uniform surface material" value={materialKey} onChange={(value) => { setMaterialKey(value); markDirty(); }}>
                    {Object.entries(MATERIAL_PRESETS).map(([key, preset]) => (
                      <option key={key} value={key}>
                        {preset.label}
                      </option>
                    ))}
                  </SelectField>

                  <div className="rounded-2xl bg-[var(--amber-soft)] px-4 py-3 text-sm text-slate-700">
                    <div className="font-semibold text-slate-900">{materialPreset.label}</div>
                    <div className="mt-1">{materialPreset.description}</div>
                    <div className="mt-2 font-mono-ui text-xs">
                      {materialPreset.surfaceLossDb.toFixed(1)} dB per surface crossing, {materialPreset.clutterLossDb.toFixed(1)} dB
                      clutter gain, {materialPreset.diffractionWeight.toFixed(2)}x knife-edge weighting
                    </div>
                  </div>

                  <div className="grid gap-3 sm:grid-cols-2">
                    <NumberField label="Frequency MHz" value={simulation.frequencyMHz} step="1" onChange={(value) => updateSimulationField("frequencyMHz", value)} />
                    <NumberField label="Max range m" value={simulation.maxRangeM} step="1" onChange={(value) => updateSimulationField("maxRangeM", value)} />
                  </div>

                  <div className="grid gap-3 sm:grid-cols-2">
                    <NumberField label="Color min dBm" value={simulation.minDbm} step="1" onChange={(value) => updateSimulationField("minDbm", value)} />
                    <NumberField label="Color max dBm" value={simulation.maxDbm} step="1" onChange={(value) => updateSimulationField("maxDbm", value)} />
                  </div>

                  <div className="grid gap-3 sm:grid-cols-2">
                    <NumberField label="Pass threshold dBm" value={simulation.passThresholdDbm} step="1" onChange={(value) => updateSimulationField("passThresholdDbm", value)} />
                    <NumberField label="Noise floor dBm" value={simulation.noiseFloorDbm} step="1" onChange={(value) => updateSimulationField("noiseFloorDbm", value)} />
                  </div>
                </div>
              </div>

              <div className="rounded-[22px] border border-slate-200/80 bg-white/70 p-4">
                <div className="flex items-center justify-between">
                  <div>
                    <h2 className="text-lg font-bold text-slate-900">Vertical slice</h2>
                    <p className="text-sm text-slate-600">Pick two points on the model or enter coordinates directly.</p>
                  </div>
                  <div className="flex gap-2">
                    <button
                      className={`soft-button rounded-full px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${pickMode === "sliceStart" ? "bg-teal-800 text-white" : "bg-white text-slate-900"}`}
                      onClick={() => setPickMode((current) => (current === "sliceStart" ? null : "sliceStart"))}
                    >
                      Pick start
                    </button>
                    <button
                      className={`soft-button rounded-full px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${pickMode === "sliceEnd" ? "bg-teal-800 text-white" : "bg-white text-slate-900"}`}
                      onClick={() => setPickMode((current) => (current === "sliceEnd" ? null : "sliceEnd"))}
                    >
                      Pick end
                    </button>
                  </div>
                </div>

                <div className="mt-4 grid gap-3 sm:grid-cols-2">
                  <NumberField label="Start X" value={slice.startX} step="0.1" onChange={(value) => updateSliceField("startX", value)} />
                  <NumberField label="Start Y" value={slice.startY} step="0.1" onChange={(value) => updateSliceField("startY", value)} />
                  <NumberField label="End X" value={slice.endX} step="0.1" onChange={(value) => updateSliceField("endX", value)} />
                  <NumberField label="End Y" value={slice.endY} step="0.1" onChange={(value) => updateSliceField("endY", value)} />
                </div>

                <div className="mt-3 grid gap-3 sm:grid-cols-2">
                  <NumberField label="Bottom Z" value={slice.bottomZ} step="0.1" onChange={(value) => updateSliceField("bottomZ", value)} />
                  <NumberField label="Top Z" value={slice.topZ} step="0.1" onChange={(value) => updateSliceField("topZ", value)} />
                </div>

                <div className="mt-3 grid gap-3 sm:grid-cols-2">
                  <NumberField label="Distance samples" value={simulation.widthSamples} step="1" onChange={(value) => updateSimulationField("widthSamples", value)} />
                  <NumberField label="Height samples" value={simulation.heightSamples} step="1" onChange={(value) => updateSimulationField("heightSamples", value)} />
                </div>

                <button
                  className="soft-button mt-4 w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-slate-900"
                  onClick={() => {
                    if (!modelInfo.bounds) {
                      setStatus("Load a model first so the slice can reset from its bounds.");
                      return;
                    }
                    setSlice(suggestSliceFromBounds(modelInfo.bounds));
                    setStatus("Reset the slice from the loaded model bounds.");
                    markDirty();
                  }}
                >
                  Reset slice from model bounds
                </button>
              </div>

              <div className="rounded-[22px] border border-slate-200/80 bg-white/70 p-4">
                <h2 className="text-lg font-bold text-slate-900">Antenna pattern</h2>
                <p className="mt-1 text-sm text-slate-600">Directional antennas use the global sector template below. Omni ignores azimuth and tilt.</p>

                <div className="mt-4 grid gap-3 sm:grid-cols-2">
                  <NumberField label="Omni gain dBi" value={pattern.omniGainDb} step="0.1" onChange={(value) => updatePatternField("omniGainDb", value)} />
                  <NumberField label="Peak gain dBi" value={pattern.peakGainDbi} step="0.1" onChange={(value) => updatePatternField("peakGainDbi", value)} />
                  <NumberField label="Horizontal BW deg" value={pattern.horizontalBeamwidthDeg} step="0.1" onChange={(value) => updatePatternField("horizontalBeamwidthDeg", value)} />
                  <NumberField label="Vertical BW deg" value={pattern.verticalBeamwidthDeg} step="0.1" onChange={(value) => updatePatternField("verticalBeamwidthDeg", value)} />
                  <NumberField label="Side-lobe cap dB" value={pattern.sidelobeAttenuationDb} step="0.1" onChange={(value) => updatePatternField("sidelobeAttenuationDb", value)} />
                  <NumberField label="Front-back dB" value={pattern.frontBackDb} step="0.1" onChange={(value) => updatePatternField("frontBackDb", value)} />
                </div>
              </div>

              <div className="rounded-[22px] border border-slate-200/80 bg-white/70 p-4">
                <div className="mb-3 flex items-center justify-between">
                  <div>
                    <h2 className="text-lg font-bold text-slate-900">Antennas</h2>
                    <p className="text-sm text-slate-600">Shift-click in the preview to place one quickly. Alt-click removes the nearest.</p>
                  </div>
                  <button
                    className="soft-button rounded-full bg-slate-950 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white"
                    onClick={addManualAntenna}
                  >
                    Add row
                  </button>
                </div>

                <div className="space-y-3">
                  {antennas.map((antenna) => (
                    <div key={antenna.id} className="rounded-3xl border border-slate-200/80 bg-white/90 p-3">
                      <div className="mb-3 flex items-center justify-between gap-3">
                        <input
                          className="w-full rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 text-sm font-semibold text-slate-900 outline-none transition focus:border-teal-700 focus:ring-2 focus:ring-teal-700/15"
                          value={antenna.label}
                          onChange={(event) => updateAntenna(antenna.id, "label", event.target.value)}
                        />
                        <button
                          className="soft-button rounded-full bg-rose-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-700"
                          onClick={() => removeAntenna(antenna.id)}
                        >
                          Remove
                        </button>
                      </div>

                      <div className="grid gap-3 sm:grid-cols-2">
                        <SelectField label="Type" value={antenna.type} onChange={(value) => updateAntenna(antenna.id, "type", value)}>
                          <option value="directional">Directional</option>
                          <option value="omni">Omni</option>
                        </SelectField>
                        <NumberField label="Power dBm" value={antenna.powerDbm} step="0.1" onChange={(value) => updateAntenna(antenna.id, "powerDbm", value)} />
                        <NumberField label="X" value={antenna.x} step="0.1" onChange={(value) => updateAntenna(antenna.id, "x", value)} />
                        <NumberField label="Y" value={antenna.y} step="0.1" onChange={(value) => updateAntenna(antenna.id, "y", value)} />
                        <NumberField label="Z" value={antenna.z} step="0.1" onChange={(value) => updateAntenna(antenna.id, "z", value)} />
                        <NumberField label="Azimuth deg" value={antenna.azimuthDeg} step="1" onChange={(value) => updateAntenna(antenna.id, "azimuthDeg", value)} />
                        <NumberField label="Tilt deg" value={antenna.tiltDeg} step="1" onChange={(value) => updateAntenna(antenna.id, "tiltDeg", value)} />
                      </div>
                    </div>
                  ))}
                </div>
              </div>

              <div className="rounded-[22px] border border-slate-200/80 bg-slate-950 p-4 text-slate-200">
                <h2 className="text-lg font-bold text-white">What this solver is doing</h2>
                <ul className="mt-3 space-y-2 text-sm leading-6 text-slate-300">
                  <li>Direct rays are traced from each antenna to each slice pixel.</li>
                  <li>Every surface hit is treated as the selected material and adds attenuation.</li>
                  <li>The first obstruction also adds a knife-edge diffraction penalty instead of a flat NLOS drop.</li>
                  <li>The output uses best-server RSRP, which is usually the clearest read for coverage work.</li>
                </ul>
              </div>
            </div>
          </section>

          <section className="grid gap-4">
            <div className="glass-panel overflow-hidden rounded-[28px]">
              <div className="flex items-center justify-between border-b border-slate-200/70 px-5 py-4 text-slate-900">
                <div>
                  <h2 className="text-xl font-bold">3D preview</h2>
                  <p className="mt-1 text-sm text-slate-600">
                    Orbit with the mouse. The cyan panel is the active slice. Shift-click places antennas.
                  </p>
                </div>
                <div className="rounded-full bg-slate-950 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white">
                  {pickMode ? `Picking ${pickMode === "sliceStart" ? "slice start" : "slice end"}` : "Preview live"}
                </div>
              </div>
              <div className="preview-shell subtle-grid h-[420px] w-full md:h-[520px]" ref={previewRef} />
            </div>

            <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
              <div className="glass-panel rounded-[28px] p-4 md:p-5">
                <div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
                  <div>
                    <h2 className="text-xl font-bold text-slate-900">RSRP slice output</h2>
                    <p className="mt-1 text-sm text-slate-600">
                      Distance runs left to right along the slice line. Height runs bottom to top.
                    </p>
                  </div>
                  <div className="flex flex-wrap gap-2">
                    <div className="rounded-full bg-[var(--teal-soft)] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-teal-900">
                      {staleResult ? "Needs rerun" : "Current"}
                    </div>
                    <div className="rounded-full bg-[var(--amber-soft)] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-amber-900">
                      Best server RSRP
                    </div>
                  </div>
                </div>

                <div className="mt-4 rounded-[24px] border border-slate-200/80 bg-white p-3">
                  <canvas
                    ref={heatmapCanvasRef}
                    className="heatmap-canvas cursor-crosshair"
                    onMouseMove={handleHeatmapMove}
                    onMouseLeave={handleHeatmapLeave}
                  />
                </div>

                <div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto_auto] md:items-center">
                  <div>
                    <div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
                      <span>{simulation.minDbm} dBm</span>
                      <span>{simulation.maxDbm} dBm</span>
                    </div>
                    <div className="signal-gradient h-3 rounded-full" />
                  </div>
                  <button
                    className="soft-button rounded-2xl bg-slate-950 px-4 py-3 text-sm font-semibold text-white"
                    onClick={runSimulation}
                    disabled={isRunning}
                  >
                    {isRunning ? "Rendering..." : "Refresh Slice"}
                  </button>
                  <button
                    className="soft-button rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-slate-900"
                    onClick={exportPng}
                  >
                    Save image
                  </button>
                </div>
              </div>

              <div className="glass-panel rounded-[28px] p-4 md:p-5">
                <h2 className="text-xl font-bold text-slate-900">Readout</h2>

                {summary ? (
                  <div className="mt-4 space-y-3">
                    <div className="metric-card p-4">
                      <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Signal envelope</div>
                      <div className="mt-2 grid gap-2 text-sm text-slate-800">
                        <div className="flex justify-between"><span>Min</span><span className="font-semibold">{formatNumber(summary.minDbm)} dBm</span></div>
                        <div className="flex justify-between"><span>Average</span><span className="font-semibold">{formatNumber(summary.avgDbm)} dBm</span></div>
                        <div className="flex justify-between"><span>Max</span><span className="font-semibold">{formatNumber(summary.maxDbm)} dBm</span></div>
                      </div>
                    </div>

                    <div className="metric-card p-4">
                      <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Coverage quality</div>
                      <div className="mt-2 grid gap-2 text-sm text-slate-800">
                        <div className="flex justify-between">
                          <span>At or above {simulation.passThresholdDbm} dBm</span>
                          <span className="font-semibold">{formatNumber(summary.coveragePct)}%</span>
                        </div>
                        <div className="flex justify-between">
                          <span>Obstructed pixels</span>
                          <span className="font-semibold">{formatNumber(summary.obstructedPct)}%</span>
                        </div>
                        <div className="flex justify-between">
                          <span>Average crossings</span>
                          <span className="font-semibold">{formatNumber(summary.avgCrossings, 2)}</span>
                        </div>
                      </div>
                    </div>
                  </div>
                ) : (
                  <div className="mt-4 rounded-3xl border border-dashed border-slate-300 bg-white/70 p-5 text-sm leading-6 text-slate-600">
                    Run the slice to populate the coverage summary and pixel readout.
                  </div>
                )}

                <div className="mt-4 metric-card p-4">
                  <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Probe</div>
                  {probe ? (
                    <div className="mt-2 grid gap-2 text-sm text-slate-800">
                      <div className="flex justify-between"><span>X</span><span className="font-mono-ui">{formatNumber(probe.x)}</span></div>
                      <div className="flex justify-between"><span>Y</span><span className="font-mono-ui">{formatNumber(probe.y)}</span></div>
                      <div className="flex justify-between"><span>Z</span><span className="font-mono-ui">{formatNumber(probe.z)}</span></div>
                      <div className="flex justify-between"><span>RSRP</span><span className="font-semibold">{formatNumber(probe.dbm)} dBm</span></div>
                      <div className="flex justify-between"><span>Surface crossings</span><span className="font-semibold">{probe.crossings}</span></div>
                      <div className="flex justify-between"><span>Best antenna</span><span className="font-semibold">{probe.antennaLabel}</span></div>
                    </div>
                  ) : (
                    <div className="mt-2 text-sm text-slate-600">Hover the heatmap to inspect the slice point under the cursor.</div>
                  )}
                </div>

                <div className="mt-4 rounded-3xl bg-slate-950 p-4 text-sm leading-6 text-slate-300">
                  <div className="font-semibold text-white">Reference</div>
                  <div className="mt-2">
                    NVIDIA&apos;s Sionna radio-map ideas influenced the measurement-surface workflow here, but this browser
                    build stays lightweight by using a single-path attenuation model instead of a full multi-bounce RF
                    solver.
                  </div>
                </div>
              </div>
            </div>
          </section>
        </div>
      </div>
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
