Ryan Malloy 646c92324d 3D antenna radiation pattern visualization: analytical models + Three.js web UI
Add analytical radiation pattern models for 5 antenna types (dipole, monopole,
EFHW, loop, patch) driven by S11 impedance measurements. Pure Python math with
closed-form far-field equations — no numpy or simulation dependencies.

New MCP tools:
- radiation_pattern: scan S11 → find resonance → compute 3D pattern
- radiation_pattern_from_data: compute from known impedance (no hardware)
- radiation_pattern_multi: patterns across a frequency band for animation

Web UI (opt-in via MCNANOVNA_WEB_PORT env var):
- Three.js gain-mapped sphere with OrbitControls
- Surface/wireframe/plane cut display modes with teal→amber color ramp
- Smith chart overlay, dBi reference rings, E/H plane cross-sections
- Real-time WebSocket push on new pattern computation
- FastAPI backend shares process with MCP server, zero new core deps

Frontend: Vite + TypeScript + Three.js, built assets committed to webui/static/.
Optional dependencies: fastapi + uvicorn via pip install mcnanovna[webui].
2026-01-31 15:27:19 -07:00

177 lines
5.6 KiB
TypeScript

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
const BG_COLOR = 0x0f172a;
const GRID_COLOR = 0x334155; // slate-700
const RING_COLOR = 0x475569; // slate-600
const LABEL_COLOR = '#94a3b8'; // slate-400
export interface SceneContext {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
labelRenderer: CSS2DRenderer;
controls: OrbitControls;
patternGroup: THREE.Group;
}
export function createScene(container: HTMLElement): SceneContext {
const scene = new THREE.Scene();
scene.background = new THREE.Color(BG_COLOR);
// Camera
const aspect = container.clientWidth / container.clientHeight;
const camera = new THREE.PerspectiveCamera(55, aspect, 0.1, 100);
camera.position.set(3, 2.5, 3);
camera.lookAt(0, 0, 0);
// WebGL renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(renderer.domElement);
// CSS2D renderer for labels
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(container.clientWidth, container.clientHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0';
labelRenderer.domElement.style.left = '0';
labelRenderer.domElement.style.pointerEvents = 'none';
container.appendChild(labelRenderer.domElement);
// Orbit controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 1.5;
controls.maxDistance = 12;
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 8, 5);
scene.add(dirLight);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
dirLight2.position.set(-3, -2, -4);
scene.add(dirLight2);
// Grid on XZ plane
const gridHelper = new THREE.GridHelper(6, 12, GRID_COLOR, GRID_COLOR);
(gridHelper.material as THREE.Material).opacity = 0.3;
(gridHelper.material as THREE.Material).transparent = true;
scene.add(gridHelper);
// Axis lines
addAxisLines(scene);
// Axis labels
addAxisLabel(scene, 'X', new THREE.Vector3(3.3, 0, 0));
addAxisLabel(scene, 'Y', new THREE.Vector3(0, 3.3, 0));
addAxisLabel(scene, 'Z', new THREE.Vector3(0, 0, 3.3));
// Reference dBi rings on XZ plane
addReferenceRings(scene);
// Group for pattern meshes
const patternGroup = new THREE.Group();
scene.add(patternGroup);
// Resize handler
const onResize = () => {
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
labelRenderer.setSize(w, h);
};
window.addEventListener('resize', onResize);
// Animation loop
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
};
animate();
return { scene, camera, renderer, labelRenderer, controls, patternGroup };
}
function addAxisLines(scene: THREE.Scene) {
const len = 3.2;
const xGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(len, 0, 0),
]);
const xMat = new THREE.LineBasicMaterial({ color: 0xef4444, opacity: 0.6, transparent: true });
scene.add(new THREE.Line(xGeo, xMat));
const yGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, len, 0),
]);
const yMat = new THREE.LineBasicMaterial({ color: 0x22c55e, opacity: 0.6, transparent: true });
scene.add(new THREE.Line(yGeo, yMat));
const zGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, len),
]);
const zMat = new THREE.LineBasicMaterial({ color: 0x3b82f6, opacity: 0.6, transparent: true });
scene.add(new THREE.Line(zGeo, zMat));
}
function addAxisLabel(scene: THREE.Scene, text: string, position: THREE.Vector3) {
const el = document.createElement('span');
el.textContent = text;
el.style.color = LABEL_COLOR;
el.style.fontFamily = "'Inter', sans-serif";
el.style.fontSize = '12px';
el.style.fontWeight = '600';
const label = new CSS2DObject(el);
label.position.copy(position);
scene.add(label);
}
function addReferenceRings(scene: THREE.Scene) {
const dbiLevels = [-3, 0, 3, 6];
const minDbi = -3;
const maxDbi = 6;
dbiLevels.forEach((dbi) => {
const normalized = (dbi - minDbi) / (maxDbi - minDbi);
const radius = 0.1 + normalized * 1.9; // map to [0.1, 2.0]
const curve = new THREE.EllipseCurve(0, 0, radius, radius, 0, 2 * Math.PI, false, 0);
const points = curve.getPoints(64);
const geo = new THREE.BufferGeometry().setFromPoints(
points.map((p) => new THREE.Vector3(p.x, 0, p.y))
);
const mat = new THREE.LineBasicMaterial({
color: RING_COLOR,
opacity: 0.35,
transparent: true,
});
const ring = new THREE.Line(geo, mat);
scene.add(ring);
// Label for the ring
const el = document.createElement('span');
el.textContent = `${dbi >= 0 ? '+' : ''}${dbi} dBi`;
el.style.color = '#64748b'; // slate-500
el.style.fontFamily = "'Inter', sans-serif";
el.style.fontSize = '10px';
const label = new CSS2DObject(el);
label.position.set(radius + 0.1, 0, 0);
scene.add(label);
});
}