Initial commit
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 2D Vessel visualization canvas using Three.js
|
||||
* Renders the vessel geometry, flow field, and scalar fields
|
||||
*/
|
||||
|
||||
import { useRef, useMemo, useEffect } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { OrthographicCamera, Line } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import type { GridQueryResponse, DisplayMode, ColorMap } from "../lib/types";
|
||||
import { createColorArray } from "../lib/colors";
|
||||
import { getRange } from "../lib/simulation";
|
||||
|
||||
interface VesselCanvasProps {
|
||||
gridData: GridQueryResponse | null;
|
||||
displayMode: DisplayMode;
|
||||
colorMap: ColorMap;
|
||||
showVelocityArrows: boolean;
|
||||
arrowScale: number;
|
||||
vesselLength: number;
|
||||
vesselRadius: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main vessel canvas with 2D view
|
||||
*/
|
||||
export function VesselCanvas({
|
||||
gridData,
|
||||
displayMode,
|
||||
colorMap,
|
||||
showVelocityArrows,
|
||||
arrowScale,
|
||||
vesselLength,
|
||||
vesselRadius,
|
||||
}: VesselCanvasProps) {
|
||||
return (
|
||||
<Canvas
|
||||
orthographic
|
||||
style={{ background: "#1a1a2e" }}
|
||||
>
|
||||
<OrthographicCamera
|
||||
makeDefault
|
||||
position={[vesselLength / 2, 0, 10]}
|
||||
zoom={2000}
|
||||
near={0.1}
|
||||
far={1000}
|
||||
/>
|
||||
<ambientLight intensity={0.8} />
|
||||
|
||||
{/* Vessel boundary outline */}
|
||||
<VesselBoundary length={vesselLength} radius={vesselRadius} />
|
||||
|
||||
{/* Field visualization */}
|
||||
{gridData && (
|
||||
<>
|
||||
<FieldMesh
|
||||
gridData={gridData}
|
||||
displayMode={displayMode}
|
||||
colorMap={colorMap}
|
||||
/>
|
||||
{showVelocityArrows && (
|
||||
<VelocityArrows
|
||||
gridData={gridData}
|
||||
scale={arrowScale}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Axis indicators */}
|
||||
<AxisLabels length={vesselLength} radius={vesselRadius} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vessel boundary visualization
|
||||
*/
|
||||
function VesselBoundary({
|
||||
length,
|
||||
radius,
|
||||
}: {
|
||||
length: number;
|
||||
radius: number;
|
||||
}) {
|
||||
const points = useMemo(() => {
|
||||
const pts: THREE.Vector3[] = [];
|
||||
const segments = 100;
|
||||
|
||||
// Top wall
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const x = (i / segments) * length;
|
||||
pts.push(new THREE.Vector3(x, radius, 0));
|
||||
}
|
||||
|
||||
// Bottom wall (reverse direction for closed path)
|
||||
for (let i = segments; i >= 0; i--) {
|
||||
const x = (i / segments) * length;
|
||||
pts.push(new THREE.Vector3(x, -radius, 0));
|
||||
}
|
||||
|
||||
// Close the path
|
||||
pts.push(pts[0].clone());
|
||||
|
||||
return pts;
|
||||
}, [length, radius]);
|
||||
|
||||
return (
|
||||
<Line
|
||||
points={points}
|
||||
color="#ffffff"
|
||||
lineWidth={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Field mesh visualization using vertex colors
|
||||
*/
|
||||
function FieldMesh({
|
||||
gridData,
|
||||
displayMode,
|
||||
colorMap,
|
||||
}: {
|
||||
gridData: GridQueryResponse;
|
||||
displayMode: DisplayMode;
|
||||
colorMap: ColorMap;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
const [nx, ny] = gridData.dimensions;
|
||||
const geo = new THREE.PlaneGeometry(
|
||||
gridData.dx * (nx - 1),
|
||||
gridData.dy * (ny - 1),
|
||||
nx - 1,
|
||||
ny - 1
|
||||
);
|
||||
|
||||
// Set vertex positions to match grid
|
||||
const positions = geo.attributes.position.array as Float32Array;
|
||||
for (let j = 0; j < ny; j++) {
|
||||
for (let i = 0; i < nx; i++) {
|
||||
const idx = j * nx + i;
|
||||
const posIdx = idx * 3;
|
||||
positions[posIdx] = gridData.x_min + i * gridData.dx;
|
||||
positions[posIdx + 1] = gridData.y_min + j * gridData.dy;
|
||||
positions[posIdx + 2] = 0;
|
||||
}
|
||||
}
|
||||
geo.attributes.position.needsUpdate = true;
|
||||
|
||||
return geo;
|
||||
}, [gridData]);
|
||||
|
||||
// Update colors based on display mode
|
||||
const colors = useMemo(() => {
|
||||
let values: number[];
|
||||
|
||||
switch (displayMode) {
|
||||
case "velocity":
|
||||
values = gridData.u_field.map((u, i) =>
|
||||
Math.sqrt(u ** 2 + gridData.v_field[i] ** 2)
|
||||
);
|
||||
break;
|
||||
case "pressure":
|
||||
values = gridData.p_field;
|
||||
break;
|
||||
case "wss":
|
||||
// WSS only makes sense at boundaries, use velocity magnitude fallback
|
||||
values = gridData.u_field.map((u, i) =>
|
||||
Math.sqrt(u ** 2 + gridData.v_field[i] ** 2)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
values = gridData.p_field;
|
||||
}
|
||||
|
||||
// Mask out exterior points
|
||||
const maskedValues = values.map((v, i) =>
|
||||
gridData.mask[i] ? v : 0
|
||||
);
|
||||
|
||||
const [min, max] = getRange(maskedValues.filter((_, i) => gridData.mask[i]));
|
||||
return createColorArray(maskedValues, min, max, colorMap);
|
||||
}, [gridData, displayMode, colorMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (geometry) {
|
||||
geometry.setAttribute(
|
||||
"color",
|
||||
new THREE.BufferAttribute(colors, 3)
|
||||
);
|
||||
geometry.attributes.color.needsUpdate = true;
|
||||
}
|
||||
}, [geometry, colors]);
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} geometry={geometry}>
|
||||
<meshBasicMaterial vertexColors side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Velocity arrows using instanced rendering
|
||||
*/
|
||||
function VelocityArrows({
|
||||
gridData,
|
||||
scale,
|
||||
}: {
|
||||
gridData: GridQueryResponse;
|
||||
scale: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.InstancedMesh>(null);
|
||||
|
||||
const { count, matrix, colors } = useMemo(() => {
|
||||
const [nx, ny] = gridData.dimensions;
|
||||
|
||||
// Subsample for performance (every 4th point)
|
||||
const step = 4;
|
||||
const validIndices: number[] = [];
|
||||
|
||||
for (let j = 0; j < ny; j += step) {
|
||||
for (let i = 0; i < nx; i += step) {
|
||||
const idx = j * nx + i;
|
||||
if (gridData.mask[idx]) {
|
||||
validIndices.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = validIndices.length;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const instanceMatrix = new Float32Array(count * 16);
|
||||
const instanceColors = new Float32Array(count * 3);
|
||||
|
||||
const magnitudes = validIndices.map((idx) =>
|
||||
Math.sqrt(gridData.u_field[idx] ** 2 + gridData.v_field[idx] ** 2)
|
||||
);
|
||||
const [minMag, maxMag] = getRange(magnitudes);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = validIndices[i];
|
||||
const x = gridData.x_min + (idx % nx) * gridData.dx;
|
||||
const y = gridData.y_min + Math.floor(idx / nx) * gridData.dy;
|
||||
const u = gridData.u_field[idx];
|
||||
const v = gridData.v_field[idx];
|
||||
const mag = magnitudes[i];
|
||||
|
||||
// Arrow orientation
|
||||
const angle = Math.atan2(v, u);
|
||||
const arrowLength = (mag / (maxMag || 1)) * scale * gridData.dx * 3;
|
||||
|
||||
matrix.makeRotationZ(angle);
|
||||
matrix.scale(new THREE.Vector3(arrowLength, arrowLength * 0.3, 1));
|
||||
matrix.setPosition(x, y, 0.1);
|
||||
matrix.toArray(instanceMatrix, i * 16);
|
||||
|
||||
// Color based on magnitude
|
||||
const t = maxMag > minMag ? (mag - minMag) / (maxMag - minMag) : 0;
|
||||
instanceColors[i * 3] = 1 - t;
|
||||
instanceColors[i * 3 + 1] = t;
|
||||
instanceColors[i * 3 + 2] = 0.5;
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
matrix: instanceMatrix,
|
||||
colors: instanceColors,
|
||||
};
|
||||
}, [gridData, scale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshRef.current) {
|
||||
const mesh = meshRef.current;
|
||||
mesh.instanceMatrix.set(matrix);
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
|
||||
if (mesh.instanceColor) {
|
||||
mesh.instanceColor.set(colors);
|
||||
mesh.instanceColor.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}, [matrix, colors]);
|
||||
|
||||
// Arrow geometry (simple triangle)
|
||||
const arrowGeometry = useMemo(() => {
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(0, -0.5);
|
||||
shape.lineTo(1, 0);
|
||||
shape.lineTo(0, 0.5);
|
||||
shape.lineTo(0.3, 0);
|
||||
shape.closePath();
|
||||
|
||||
return new THREE.ShapeGeometry(shape);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<instancedMesh
|
||||
ref={meshRef}
|
||||
args={[arrowGeometry, undefined, count]}
|
||||
>
|
||||
<meshBasicMaterial vertexColors />
|
||||
</instancedMesh>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Axis labels and scale
|
||||
*/
|
||||
function AxisLabels({
|
||||
length,
|
||||
radius,
|
||||
}: {
|
||||
length: number;
|
||||
radius: number;
|
||||
}) {
|
||||
// X-axis line
|
||||
const xAxisPoints = useMemo(
|
||||
() => [
|
||||
new THREE.Vector3(-0.005, -radius * 1.5, 0),
|
||||
new THREE.Vector3(length + 0.005, -radius * 1.5, 0),
|
||||
],
|
||||
[length, radius]
|
||||
);
|
||||
|
||||
// Y-axis line
|
||||
const yAxisPoints = useMemo(
|
||||
() => [
|
||||
new THREE.Vector3(-0.005, -radius * 1.3, 0),
|
||||
new THREE.Vector3(-0.005, radius * 1.3, 0),
|
||||
],
|
||||
[radius]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Line points={xAxisPoints} color="#666666" lineWidth={1} />
|
||||
<Line points={yAxisPoints} color="#666666" lineWidth={1} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselCanvas;
|
||||
Reference in New Issue
Block a user