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].
177 lines
5.6 KiB
TypeScript
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);
|
|
});
|
|
}
|