feat: unified 0 and 90 degree PDF envelope and category descriptors

This commit is contained in:
2026-07-08 19:52:34 +00:00
commit 9fece3f174
170 changed files with 27177 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
import { useMemo } from 'react';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
export interface Bar3DInput {
/** Tipo de seção */
barType: 'flat' | 'circular';
/** Forma (apenas flat): 'placa' | 'l' | 't' | 'i' | 'rectangle' */
section?: 'placa' | 'l' | 't' | 'i' | 'rectangle';
/** Diâmetro (apenas circular, m) */
diameter?: number;
/** Largura da seção (flat, m) */
width?: number;
/** Comprimento da barra (m) */
length: number;
/** Ângulo de incidência (graus) — 0° = face plana contra o vento */
alpha: number;
/** Força Fx (kN) */
fxKN: number;
/** Força Fy (kN) */
fyKN: number;
/** Coeficiente Cx (apenas visualização) */
cx: number;
}
/**
* Converte kN para um comprimento visual proporcional no eixo 3D.
*/
const forceToLength = (kN: number): number => Math.min(Math.max(Math.abs(kN) * 0.3, 0.3), 4);
function BarModel({
barType,
section,
diameter,
width,
length,
alpha,
fxKN,
fyKN,
cx,
}: Bar3DInput) {
const barRadius = barType === 'circular' ? (diameter ?? 0.05) / 2 : Math.min(width ?? 0.1, 0.08) / 2;
const barThickness = barType === 'circular' ? barRadius : barRadius * 0.5;
// Cor baseada em Cx
const barColor = useMemo(() => {
const intensity = Math.min(1, Math.abs(cx) / 2.5);
const hue = 215 - intensity * 215;
return new THREE.Color(`hsl(${hue}, ${65 + intensity * 25}%, ${45 - intensity * 10}%)`);
}, [cx]);
// Rotação da barra em torno do eixo Y (alinhada com eixo X inicialmente)
// Direção do vento é +X; α é o ângulo da face da barra em relação ao vento
const alphaRad = (alpha * Math.PI) / 180;
const barRotation = -alphaRad; // rotação em torno do eixo Y para alinhar a face
// Direção do vetor de força resultante (na direção da força calculada)
const forceMag = Math.sqrt(fxKN * fxKN + fyKN * fyKN);
const forceAngle = Math.atan2(fyKN, fxKN);
const arrowLen = forceToLength(forceMag);
// Centro da barra (origem)
const center = new THREE.Vector3(0, 0, 0);
// Posição da ponta da seta
const arrowEnd = useMemo(
() => new THREE.Vector3(
Math.cos(forceAngle) * arrowLen,
Math.sin(forceAngle) * arrowLen,
0,
),
[forceAngle, arrowLen],
);
const arrowMid = useMemo(
() => new THREE.Vector3(arrowEnd.x / 2, arrowEnd.y / 2, 0),
[arrowEnd],
);
const quat = useMemo(() => {
const dir = arrowEnd.clone().normalize();
const q = new THREE.Quaternion();
q.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
return new THREE.Euler().setFromQuaternion(q);
}, [arrowEnd]);
const headLen = 0.25;
return (
<group rotation={[0, barRotation, 0]}>
{/* Eixo principal da barra ao longo do eixo X */}
{barType === 'circular' ? (
<mesh position={[0, 0, 0]} rotation={[0, 0, Math.PI / 2]} castShadow>
<cylinderGeometry args={[barRadius, barRadius, length, 16]} />
<meshStandardMaterial color={barColor} roughness={0.4} metalness={0.3} />
</mesh>
) : (
<SectionShape section={section ?? 'placa'} width={width ?? 0.1} length={length} color={barColor} thickness={barThickness} />
)}
{/* Eixos de referência */}
<axesHelper args={[length * 0.5]} />
{/* Vetor de força (resultante) */}
{forceMag > 0.01 && (
<group>
{arrowLen - headLen > 0.01 && (
<mesh position={arrowMid.toArray()} rotation={[quat.x, quat.y, quat.z]} castShadow>
<cylinderGeometry args={[0.04, 0.04, arrowLen - headLen, 10]} />
<meshStandardMaterial color="#ef4444" />
</mesh>
)}
<mesh position={arrowEnd.toArray()} rotation={[quat.x, quat.y, quat.z]} castShadow>
<coneGeometry args={[0.1, headLen, 10]} />
<meshStandardMaterial color="#ef4444" />
</mesh>
</group>
)}
{/* Marca de origem */}
<mesh position={[0, 0, 0]} castShadow>
<sphereGeometry args={[0.06, 12, 12]} />
<meshStandardMaterial color="#fbbf24" emissive="#fbbf24" emissiveIntensity={0.4} />
</mesh>
<axesHelper args={[length * 0.3]} />
<Text
position={[0, -barRadius * 2 - 0.3, 0]}
fontSize={0.3}
color="#1e40af"
anchorX="center"
anchorY="top"
>
α={alpha}° | Cx={cx.toFixed(2)}
</Text>
{center && null}
</group>
);
}
function SectionShape({
section,
width,
length,
color,
thickness,
}: {
section: 'placa' | 'l' | 't' | 'i' | 'rectangle';
width: number;
length: number;
color: THREE.Color;
thickness: number;
}) {
switch (section) {
case 'placa':
return (
<mesh position={[0, 0, 0]} castShadow>
<boxGeometry args={[length, width, thickness]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
);
case 'l':
return (
<group>
<mesh position={[0, width / 2 - thickness / 2, width / 2 - thickness / 2]} castShadow>
<boxGeometry args={[length, thickness, width]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
<mesh position={[0, 0, 0]} castShadow>
<boxGeometry args={[length, width, thickness]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
</group>
);
case 't':
return (
<group>
<mesh position={[0, width / 2 - thickness / 2, 0]} castShadow>
<boxGeometry args={[length, thickness, width]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
<mesh position={[0, 0, 0]} castShadow>
<boxGeometry args={[length, width, thickness]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
</group>
);
case 'i':
return (
<group>
<mesh position={[0, width / 2 - thickness / 2, 0]} castShadow>
<boxGeometry args={[length, thickness, width]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
<mesh position={[0, 0, 0]} castShadow>
<boxGeometry args={[length, width - thickness, thickness]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
<mesh position={[0, -width / 2 + thickness / 2, 0]} castShadow>
<boxGeometry args={[length, thickness, width]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
</group>
);
case 'rectangle':
return (
<mesh position={[0, 0, 0]} castShadow>
<boxGeometry args={[length, width, thickness]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
);
}
}
export default function Bar3DViewer(input: Bar3DInput) {
const { length, width, diameter } = input;
const size = Math.max(length * 0.6, (width ?? diameter ?? 0.1) * 8);
const fallback = (
<FallbackDiagram
type="bar"
props={input}
/>
);
return (
<SceneCanvas
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [size, size * 0.6, size], fov: 45 }}
fallback={fallback}
>
<ambientLight intensity={0.6} />
<directionalLight position={[size, size, size]} intensity={1.2} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
<BarModel {...input} />
<Grid infiniteGrid fadeDistance={size * 2} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -size * 0.3, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI - 0.05} />
<Environment preset="city" />
</SceneCanvas>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useMemo } from 'react';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
export interface Bridge3DInput {
/** Maior vão Lₚ (m) */
lp: number;
/** Largura do tabuleiro B (m) */
width: number;
/** Altura do tabuleiro z (m) */
deckHeight: number;
/** Altura equivalente H_eq (m) — soma de áreas expostas por metro */
heg: number;
/** Coeficiente de arrasto Cx (adimensional) */
cx: number;
/** Coeficiente de sustentação Cz (adimensional) */
cz: number;
/** Força de arrasto por unidade de comprimento Fx (kN/m) */
fxPerLength: number;
/** Força de sustentação por unidade de comprimento Fz (kN/m) */
fzPerLength: number;
}
function BridgeModel({
lp,
width,
deckHeight,
heg,
cx,
fxPerLength,
fzPerLength,
}: Bridge3DInput) {
const halfL = lp / 2;
const halfW = width / 2;
const deckThickness = Math.max(heg, 0.8);
const deckY = deckHeight;
// Cor do tabuleiro baseada em Cx
const deckColor = useMemo(() => {
const intensity = Math.min(1, Math.abs(cx) / 3);
const hue = 200 - intensity * 60;
return new THREE.Color(`hsl(${hue}, ${55 + intensity * 30}%, ${50 - intensity * 8}%)`);
}, [cx]);
// Pilar heights: posicionar 3 pilares ao longo do vão
const pillarHeights = useMemo(() => [deckY - 0.5, deckY - 0.5, deckY - 0.5], [deckY]);
// Vetor de força (Fx horizontal)
const fxLen = Math.min(Math.max(Math.abs(fxPerLength) * 0.5, 0.3), 4);
const fxDir = fxPerLength >= 0 ? 1 : -1;
// Vetor de força (Fz vertical)
const fzLen = Math.min(Math.max(Math.abs(fzPerLength) * 0.5, 0.3), 4);
const fzDir = fzPerLength >= 0 ? 1 : -1;
return (
<group>
{/* Tabuleiro (deck) */}
<mesh position={[0, deckY, 0]} castShadow receiveShadow>
<boxGeometry args={[lp, deckThickness, width]} />
<meshStandardMaterial color={deckColor} roughness={0.5} />
</mesh>
{/* Guarda-rodas/barreira lateral */}
<mesh position={[0, deckY + deckThickness / 2 + 0.3, halfW - 0.15]} castShadow>
<boxGeometry args={[lp, 0.5, 0.1]} />
<meshStandardMaterial color="#94a3b8" roughness={0.7} />
</mesh>
<mesh position={[0, deckY + deckThickness / 2 + 0.3, -halfW + 0.15]} castShadow>
<boxGeometry args={[lp, 0.5, 0.1]} />
<meshStandardMaterial color="#94a3b8" roughness={0.7} />
</mesh>
{/* Pilares (3 ao longo do comprimento) */}
{pillarHeights.map((h, i) => {
const x = i === 0 ? -halfL + halfL * 0.3 : i === 1 ? 0 : halfL - halfL * 0.3;
return (
<mesh key={`pillar-${i}`} position={[x, h / 2, 0]} castShadow receiveShadow>
<boxGeometry args={[1.5, h, 1.5]} />
<meshStandardMaterial color="#64748b" roughness={0.7} />
</mesh>
);
})}
{/* Solo / água */}
<mesh position={[0, -0.5, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[lp * 1.6, width * 3]} />
<meshStandardMaterial color="#60a5fa" opacity={0.4} transparent roughness={0.3} />
</mesh>
{/* Vetor Cx (horizontal) */}
<ForceArrow
start={[-halfL * 0.6, deckY + deckThickness + 0.3, halfW + 0.5]}
direction={[fxDir, 0, 0]}
length={fxLen}
color="#ef4444"
/>
{/* Vetor Cz (vertical) */}
<ForceArrow
start={[halfL * 0.6, deckY + deckThickness + 0.3, halfW + 0.5]}
direction={[0, fzDir, 0]}
length={fzLen}
color="#3b82f6"
/>
{/* Vetor no centro também para destacar */}
<ForceArrow
start={[0, deckY + deckThickness + 0.3, 0]}
direction={[fxDir, 0, 0]}
length={fxLen * 0.7}
color="#ef4444"
/>
<Text
position={[0, deckY + deckThickness + 1.0, 0]}
fontSize={0.6}
color="#1e40af"
anchorX="center"
anchorY="bottom"
>
Lp={lp}m | B={width}m | Cx={cx.toFixed(2)}
</Text>
</group>
);
}
function ForceArrow({
start,
direction,
length,
color,
}: {
start: [number, number, number];
direction: [number, number, number];
length: number;
color: string;
}) {
const startVec = useMemo(() => new THREE.Vector3(...start), [start]);
const dirVec = useMemo(() => new THREE.Vector3(...direction), [direction]);
const end = useMemo(
() => new THREE.Vector3(
startVec.x + dirVec.x * length,
startVec.y + dirVec.y * length,
startVec.z + dirVec.z * length,
),
[startVec, dirVec, length],
);
const mid = useMemo(
() => new THREE.Vector3().addVectors(startVec, end).multiplyScalar(0.5),
[startVec, end],
);
const quat = useMemo(() => {
const dir = new THREE.Vector3().subVectors(end, startVec).normalize();
const q = new THREE.Quaternion();
q.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
return new THREE.Euler().setFromQuaternion(q);
}, [startVec, end]);
const headLen = 0.3;
return (
<group>
{length - headLen > 0.01 && (
<mesh position={mid.toArray()} rotation={[quat.x, quat.y, quat.z]} castShadow>
<cylinderGeometry args={[0.06, 0.06, length - headLen, 10]} />
<meshStandardMaterial color={color} />
</mesh>
)}
<mesh position={end.toArray()} rotation={[quat.x, quat.y, quat.z]} castShadow>
<coneGeometry args={[0.15, headLen, 10]} />
<meshStandardMaterial color={color} />
</mesh>
</group>
);
}
export default function Bridge3DViewer(input: Bridge3DInput) {
const { lp, deckHeight, width } = input;
const dist = Math.max(lp * 0.6, deckHeight * 2);
const fallback = (
<FallbackDiagram
type="bridge"
props={input}
/>
);
return (
<SceneCanvas
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [dist * 0.8, deckHeight + width, dist], fov: 45 }}
fallback={fallback}
>
<ambientLight intensity={0.6} />
<directionalLight position={[lp, deckHeight * 3, width * 3]} intensity={1.2} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
<BridgeModel {...input} />
<Grid infiniteGrid fadeDistance={lp * 0.5} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
</SceneCanvas>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useMemo } from 'react';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
export interface Cylinder3DInput {
diameter: number;
height: number;
/** Cpe profile ao longo da circunferência (0° a 180°) */
cpeProfile: { angle: number; cpe: number }[];
cpi: number;
}
function cylinderColor(cpe: number, cpi: number): THREE.Color {
const p = cpe - cpi;
const intensity = Math.min(1, Math.abs(p) / 1.2);
if (p > 0) {
return new THREE.Color(`hsl(${215 - intensity * 10}, ${70 + intensity * 25}%, ${Math.max(35, 65 - intensity * 25)}%)`);
}
return new THREE.Color(`hsl(0, ${70 + intensity * 25}%, ${Math.max(40, 65 - intensity * 20)}%)`);
}
function CylinderModel({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
const segments = 64;
const radius = diameter / 2;
// Espelha o cpeProfile para cobrir de 0° a 360°
const fullCpeProfile = useMemo(() => {
if (cpeProfile.length === 0) return [];
const arr = [...cpeProfile];
const step = cpeProfile.length > 1 ? cpeProfile[1].angle - cpeProfile[0].angle : 10;
// Espelha de 180° a 360°
for (let angle = 180 + step; angle < 360; angle += step) {
const mirroredAngle = 360 - angle;
const closest = cpeProfile.find(p => Math.abs(p.angle - mirroredAngle) < 0.1) || cpeProfile[cpeProfile.length - 1];
arr.push({ angle, cpe: closest.cpe });
}
// Fecha o ciclo em 360° (igual a 0°)
arr.push({ angle: 360, cpe: cpeProfile[0].cpe });
return arr;
}, [cpeProfile]);
// Cria faces individuais com cor independente por ângulo
const faces = useMemo(() => {
const arr: { angle: number; cpe: number; color: THREE.Color }[] = [];
for (let i = 0; i < fullCpeProfile.length - 1; i++) {
const a = fullCpeProfile[i];
const b = fullCpeProfile[i + 1];
const angleMid = (a.angle + b.angle) / 2;
const cpeMid = (a.cpe + b.cpe) / 2;
arr.push({ angle: angleMid, cpe: cpeMid, color: cylinderColor(cpeMid, cpi) });
}
return arr;
}, [fullCpeProfile, cpi]);
return (
<group>
{/* Paredes Verticais do Cilindro */}
{faces.map((face, idx) => {
if (fullCpeProfile.length <= idx + 1) return null;
const stepAngle = fullCpeProfile[1].angle - fullCpeProfile[0].angle;
const a0 = (face.angle - stepAngle / 2) * Math.PI / 180;
const a1 = (face.angle + stepAngle / 2) * Math.PI / 180;
const x0 = Math.cos(a0) * radius;
const z0 = Math.sin(a0) * radius;
const x1 = Math.cos(a1) * radius;
const z1 = Math.sin(a1) * radius;
// Normais dos vértices
const nx0 = Math.cos(a0);
const nz0 = Math.sin(a0);
const nx1 = Math.cos(a1);
const nz1 = Math.sin(a1);
// Array com os 6 vértices para formar dois triângulos (um quad completo)
const vertices = new Float32Array([
x0, 0, z0,
x1, 0, z1,
x1, height, z1,
x0, 0, z0,
x1, height, z1,
x0, height, z0,
]);
const normals = new Float32Array([
nx0, 0, nz0,
nx1, 0, nz1,
nx1, 0, nz1,
nx0, 0, nz0,
nx1, 0, nz1,
nx0, 0, nz0,
]);
return (
<mesh key={idx} castShadow receiveShadow>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[vertices, 3]}
/>
<bufferAttribute
attach="attributes-normal"
args={[normals, 3]}
/>
</bufferGeometry>
<meshStandardMaterial color={face.color} opacity={0.9} transparent roughness={0.4} side={THREE.DoubleSide} />
</mesh>
);
})}
{/* Tampa superior sólida */}
<mesh position={[0, height, 0]} rotation={[-Math.PI / 2, 0, 0]} castShadow receiveShadow>
<circleGeometry args={[radius, segments]} />
<meshStandardMaterial color={cylinderColor(cpeProfile[cpeProfile.length - 1].cpe, cpi)} opacity={0.8} transparent side={THREE.DoubleSide} roughness={0.4} />
</mesh>
{/* Anéis de detalhe (bordas do cilindro) */}
<mesh position={[0, height + 0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[radius - 0.03, radius + 0.03, segments]} />
<meshStandardMaterial color="#2d3748" roughness={0.5} />
</mesh>
<mesh position={[0, 0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[radius - 0.03, radius + 0.03, segments]} />
<meshStandardMaterial color="#2d3748" roughness={0.5} />
</mesh>
{/* Seta indicativa de direção do vento */}
<group position={[-radius - 2.5, height / 2, 0]} rotation={[0, 0, -Math.PI / 2]}>
<mesh castShadow>
<coneGeometry args={[0.3, 0.8, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<mesh position={[0, -0.6, 0]} castShadow>
<cylinderGeometry args={[0.1, 0.1, 1.2, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<Text
position={[0, -1.5, 0]}
rotation={[Math.PI / 2, 0, 0]}
fontSize={0.4}
color="#3b82f6"
anchorX="center"
anchorY="middle"
>
Vento
</Text>
</group>
{/* Texto de Informação */}
<Text
position={[0, height + 0.8, 0]}
fontSize={Math.max(0.3, Math.min(0.6, diameter / 10))}
color="#1a202c"
anchorX="center"
anchorY="bottom"
>
Alt = {height}m | Diâm = {diameter}m
</Text>
</group>
);
}
export default function Cylinder3DViewer({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
const fallback = (
<FallbackDiagram
type="cylinder"
props={{ diameter, height, cpeProfile, cpi }}
/>
);
const maxDim = Math.max(diameter, height);
return (
<SceneCanvas
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [diameter * 1.5, height * 1.2, diameter * 1.5], fov: 40 }}
fallback={fallback}
>
<ambientLight intensity={0.7} />
<directionalLight
position={[diameter * 1.5, height * 2.5, diameter * 1.5]}
intensity={1.2}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
shadow-camera-far={maxDim * 10}
shadow-camera-left={-maxDim}
shadow-camera-right={maxDim}
shadow-camera-top={maxDim}
shadow-camera-bottom={-maxDim}
/>
<CylinderModel diameter={diameter} height={height} cpeProfile={cpeProfile} cpi={cpi} />
<Grid infiniteGrid fadeDistance={maxDim * 5} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
</SceneCanvas>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { useMemo } from 'react';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
export interface Dome3DInput {
diameter: number;
rise: number;
wallHeight: number;
cpi: number;
cpeBarlavento: number;
cpeTopo: number;
cpeLateral: number;
}
function domeColor(cpe: number, cpi: number): THREE.Color {
const p = cpe - cpi;
const intensity = Math.min(1, Math.abs(p) / 1.5);
if (p > 0) {
return new THREE.Color(`hsl(${215 - intensity * 10}, ${70 + intensity * 25}%, ${Math.max(35, 60 - intensity * 25)}%)`);
}
return new THREE.Color(`hsl(0, ${70 + intensity * 25}%, ${Math.max(40, 60 - intensity * 20)}%)`);
}
function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cpeLateral }: Dome3DInput) {
const radius = diameter / 2;
const segments = 64;
// Cúpula (casca esférica) — gerada por segmentos de 0° a 360° para fechar o domo
const domeGeoms = useMemo(() => {
const arr: { startTheta: number; endTheta: number; color: THREE.Color }[] = [];
// Divide a circunferência completa (360°) em 6 zonas (simétricas)
// 0° a 60°: Barlavento
// 60° a 120°: Topo
// 120° a 180°: Lateral
// 180° a 240°: Lateral (espelhado)
// 240° a 300°: Topo (espelhado)
// 300° a 360°: Barlavento (espelhado)
const zones = [
{ fromDeg: 0, toDeg: 60, cpe: cpeBarlavento },
{ fromDeg: 60, toDeg: 120, cpe: cpeTopo },
{ fromDeg: 120, toDeg: 180, cpe: cpeLateral },
{ fromDeg: 180, toDeg: 240, cpe: cpeLateral },
{ fromDeg: 240, toDeg: 300, cpe: cpeTopo },
{ fromDeg: 300, toDeg: 360, cpe: cpeBarlavento },
];
for (const z of zones) {
arr.push({
startTheta: (z.fromDeg * Math.PI) / 180,
endTheta: (z.toDeg * Math.PI) / 180,
color: domeColor(z.cpe, cpi),
});
}
return arr;
}, [cpeBarlavento, cpeTopo, cpeLateral, cpi]);
// Raio da esfera da calota esférica baseada na flecha (rise) e raio da base (radius)
const rSphere = useMemo(() => {
return (radius * radius + rise * rise) / (2 * rise);
}, [radius, rise]);
return (
<group>
{/* Parede cilíndrica inferior */}
<mesh position={[0, wallHeight / 2, 0]} castShadow receiveShadow>
<cylinderGeometry args={[radius, radius, wallHeight, segments, 1, false]} />
<meshStandardMaterial color="#cbd5e1" opacity={0.8} transparent roughness={0.4} />
</mesh>
{/* Detalhes de anéis metálicos nas bordas */}
<mesh position={[0, 0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[radius - 0.03, radius + 0.03, segments]} />
<meshStandardMaterial color="#2d3748" roughness={0.5} />
</mesh>
<mesh position={[0, wallHeight, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[radius - 0.03, radius + 0.03, segments]} />
<meshStandardMaterial color="#2d3748" roughness={0.5} />
</mesh>
{/* Cúpula de cobertura (Spherical Cap) segmentada */}
{domeGeoms.map((zone, idx) => {
const phiSteps = 16;
const segments2 = 16;
const vertices: number[] = [];
const normals: number[] = [];
const indices: number[] = [];
const phiStart = zone.startTheta;
const phiRange = zone.endTheta - zone.startTheta;
for (let i = 0; i <= phiSteps; i++) {
const phi = phiStart + (i / phiSteps) * phiRange;
for (let j = 0; j <= segments2; j++) {
const t = j / segments2;
const y = t * rise;
// Equação da esfera da calota
const yLocal = (rSphere - rise) + y;
const r = Math.sqrt(Math.max(0, rSphere * rSphere - yLocal * yLocal));
const x = r * Math.cos(phi);
const z = r * Math.sin(phi);
vertices.push(x, wallHeight + y, z);
// Normal analítica perfeita da esfera
normals.push(x / rSphere, yLocal / rSphere, z / rSphere);
}
}
for (let i = 0; i < phiSteps; i++) {
for (let j = 0; j < segments2; j++) {
const a = i * (segments2 + 1) + j;
const b = (i + 1) * (segments2 + 1) + j;
const c = (i + 1) * (segments2 + 1) + (j + 1);
const d = i * (segments2 + 1) + (j + 1);
indices.push(a, b, c, a, c, d);
}
}
return (
<mesh key={idx} castShadow receiveShadow>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[new Float32Array(vertices), 3]} />
<bufferAttribute attach="attributes-normal" args={[new Float32Array(normals), 3]} />
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
</bufferGeometry>
<meshStandardMaterial color={zone.color} opacity={0.92} transparent roughness={0.4} side={THREE.DoubleSide} />
</mesh>
);
})}
{/* Seta indicativa de direção do vento */}
<group position={[radius + 2.5, wallHeight / 2, 0]} rotation={[0, 0, Math.PI / 2]}>
<mesh castShadow>
<coneGeometry args={[0.3, 0.8, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<mesh position={[0, -0.6, 0]} castShadow>
<cylinderGeometry args={[0.1, 0.1, 1.2, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<Text
position={[0, -1.5, 0]}
rotation={[Math.PI / 2, 0, 0]}
fontSize={0.4}
color="#3b82f6"
anchorX="center"
anchorY="middle"
>
Vento
</Text>
</group>
{/* Texto informativo */}
<Text
position={[0, wallHeight + rise + 0.8, 0]}
fontSize={Math.max(0.3, Math.min(0.6, diameter / 12))}
color="#1a202c"
anchorX="center"
anchorY="bottom"
>
Diâm = {diameter}m | Flecha = {rise}m
</Text>
</group>
);
}
export default function Dome3DViewer(props: Dome3DInput) {
const fallback = (
<FallbackDiagram
type="dome"
props={props}
/>
);
const maxDim = Math.max(props.diameter, props.wallHeight + props.rise);
return (
<SceneCanvas
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [props.diameter * 1.5, (props.wallHeight + props.rise) * 1.5, props.diameter * 1.5], fov: 40 }}
fallback={fallback}
>
<ambientLight intensity={0.7} />
<directionalLight
position={[props.diameter * 1.5, (props.wallHeight + props.rise) * 2.5, props.diameter * 1.5]}
intensity={1.2}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
shadow-camera-far={maxDim * 10}
shadow-camera-left={-maxDim}
shadow-camera-right={maxDim}
shadow-camera-top={maxDim}
shadow-camera-bottom={-maxDim}
/>
<DomeModel {...props} />
<Grid infiniteGrid fadeDistance={maxDim * 5} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
</SceneCanvas>
);
}
+208
View File
@@ -0,0 +1,208 @@
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
export interface Dynamics3DInput {
/** Altura da estrutura (m) */
height: number;
/** Frequência natural f₁ (Hz) */
freq: number;
/** Velocidade do vento (m/s) */
windSpeed: number;
/** Número de Scruton */
scruton: number;
/** Tipo de seção */
sectionShape: string;
/** Tamanho da seção (m) */
sectionSize: number;
/** Mostrar rua de vórtices */
showVortexStreet: boolean;
/** Mostrar modo de oscilação */
showModeShape: boolean;
}
const SCALE = 0.15;
function OscillatingBuilding({
height,
freq,
scruton,
sectionShape,
sectionSize,
showModeShape,
}: {
height: number;
freq: number;
scruton: number;
sectionShape: string;
sectionSize: number;
showModeShape: boolean;
}) {
const groupRef = useRef<THREE.Group>(null);
const timeRef = useRef(0);
const hScaled = height * SCALE;
const wScaled = sectionSize * SCALE;
useFrame((_, delta) => {
timeRef.current += delta;
if (groupRef.current && showModeShape) {
const amplitude = Math.min(0.3, 0.1 / Math.max(scruton, 0.1));
const displacement = amplitude * Math.sin(2 * Math.PI * freq * timeRef.current);
groupRef.current.position.x = displacement;
groupRef.current.rotation.z = displacement * 0.02;
}
});
const sectionColor = '#3b82f6';
return (
<group ref={groupRef}>
{sectionShape === 'circle' ? (
<mesh position={[0, hScaled / 2, 0]} castShadow>
<cylinderGeometry args={[wScaled / 2, wScaled / 2, hScaled, 16]} />
<meshStandardMaterial color={sectionColor} transparent opacity={0.7} />
</mesh>
) : (
<mesh position={[0, hScaled / 2, 0]} castShadow>
<boxGeometry args={[wScaled, hScaled, wScaled]} />
<meshStandardMaterial color={sectionColor} transparent opacity={0.7} />
</mesh>
)}
{showModeShape && (
<group>
{[0, 0.25, 0.5, 0.75, 1].map((frac, i, arr) => {
if (i === arr.length - 1) return null;
const y0 = frac * hScaled;
const y1 = arr[i + 1] * hScaled;
const amp = 0.03;
return (
<mesh key={`mode-${i}`} position={[amp * Math.sin(frac * Math.PI), (y0 + y1) / 2, 0]}>
<cylinderGeometry args={[0.01, 0.01, y1 - y0, 4]} />
<meshStandardMaterial color="#ef4444" />
</mesh>
);
})}
</group>
)}
<mesh position={[-wScaled - 0.3, hScaled / 2, 0]}>
<boxGeometry args={[0.02, hScaled, 0.02]} />
<meshStandardMaterial color="#94a3b8" />
</mesh>
</group>
);
}
function VortexStreet({
windSpeed,
height,
sectionSize,
}: {
windSpeed: number;
height: number;
sectionSize: number;
}) {
const hScaled = height * SCALE;
const wScaled = sectionSize * SCALE;
return (
<group>
{Array.from({ length: 12 }).map((_, i) => {
const x = wScaled / 2 + 0.5 + i * 0.5;
const sign = i % 2 === 0 ? 1 : -1;
const y = hScaled / 2 + sign * wScaled * 0.4 * (1 + i * 0.05);
const opacity = Math.max(0.1, 0.8 - i * 0.06);
return (
<mesh key={`vortex-${i}`} position={[x, y, 0]}>
<sphereGeometry args={[0.06, 8, 8]} />
<meshStandardMaterial color="#a855f7" transparent opacity={opacity} />
</mesh>
);
})}
<mesh position={[windSpeed * SCALE * 0.5 + 1.5, hScaled / 2, 0]} rotation={[0, 0, -Math.PI / 2]}>
<cylinderGeometry args={[0.03, 0.03, 2, 8]} />
<meshStandardMaterial color="#22c55e" />
</mesh>
<mesh position={[windSpeed * SCALE * 0.5 + 2.5, hScaled / 2, 0]} rotation={[0, 0, -Math.PI / 2]}>
<coneGeometry args={[0.08, 0.2, 8]} />
<meshStandardMaterial color="#22c55e" />
</mesh>
</group>
);
}
function DynamicsModel(props: Dynamics3DInput) {
return (
<group>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial color="#94a3b8" transparent opacity={0.15} />
</mesh>
<OscillatingBuilding
height={props.height}
freq={props.freq}
scruton={props.scruton}
sectionShape={props.sectionShape}
sectionSize={props.sectionSize}
showModeShape={props.showModeShape}
/>
{props.showVortexStreet && (
<VortexStreet
windSpeed={props.windSpeed}
height={props.height}
sectionSize={props.sectionSize}
/>
)}
<Text
position={[0, props.height * SCALE + 0.8, 0]}
fontSize={0.5}
color="#1e40af"
anchorX="center"
anchorY="bottom"
>
h={props.height}m | f={props.freq}Hz | Sc={props.scruton.toFixed(1)}
</Text>
</group>
);
}
export default function Dynamics3DViewer(props: Dynamics3DInput) {
const cameraDistance = Math.max(props.height * SCALE * 2, 6);
const fallback = (
<FallbackDiagram
type="dynamics"
props={props}
/>
);
return (
<SceneCanvas
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [cameraDistance, cameraDistance * 0.5, cameraDistance], fov: 45 }}
fallback={fallback}
>
<ambientLight intensity={0.6} />
<directionalLight
position={[10, 15, 10]}
intensity={1.2}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<DynamicsModel {...props} />
<Grid infiniteGrid fadeDistance={50} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
</SceneCanvas>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { useMemo } from 'react';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
export interface IsolatedRoof3DInput {
/** Tipo de cobertura: 'shed' (uma água) ou 'gable' (duas águas) */
type: 'shed' | 'gable';
/** Inclinação θ (graus) */
theta: number;
/** Altura livre dos suportes (m) */
height: number;
/** Profundidade da cobertura (m) — dimensão perpendicular à seção */
depth: number;
/** Cpe barlavento (sobre a face exposta ao vento) */
cpeWindward: number;
/** Cpe sotavento (face oposta) */
cpeLeeward: number;
/** Cpe sob a face superior (sucção) */
cpeTop: number;
/** Força resultante na cobertura (kN) */
forceKN: number;
}
const forceToLength = (kN: number): number => Math.min(Math.max(Math.abs(kN) * 0.15, 0.5), 6);
function pressureColor(cpe: number): THREE.Color {
const clamped = Math.max(-2.5, Math.min(1.5, cpe));
const t = (clamped + 2.5) / 4.0;
const h = 240 - t * 240; // azul -> vermelho
return new THREE.Color(`hsl(${h}, 75%, 50%)`);
}
function IsolatedRoofModel({
type,
theta,
height,
depth,
cpeWindward,
cpeLeeward,
forceKN,
}: IsolatedRoof3DInput) {
const thetaRad = (theta * Math.PI) / 180;
const halfDepth = depth / 2;
const windwardColor = useMemo(() => pressureColor(cpeWindward), [cpeWindward]);
const leewardColor = useMemo(() => pressureColor(cpeLeeward), [cpeLeeward]);
const arrowLen = forceToLength(forceKN);
const h_diff = depth * Math.tan(thetaRad);
const h_half = (depth / 2) * Math.tan(thetaRad);
// Altura média da cobertura no centro geométrico
const centerY = type === 'shed' ? height + h_diff / 2 : height + h_half / 2;
// Definição das colunas de suporte (pilares)
const pillars = useMemo(() => {
const list: { pos: [number, number, number]; h: number }[] = [];
if (type === 'shed') {
list.push(
{ pos: [-halfDepth, height / 2, -halfDepth], h: height },
{ pos: [halfDepth, height / 2, -halfDepth], h: height },
{ pos: [-halfDepth, (height + h_diff) / 2, halfDepth], h: height + h_diff },
{ pos: [halfDepth, (height + h_diff) / 2, halfDepth], h: height + h_diff },
);
} else {
list.push(
{ pos: [-halfDepth, height / 2, -halfDepth], h: height },
{ pos: [halfDepth, height / 2, -halfDepth], h: height },
{ pos: [-halfDepth, height / 2, halfDepth], h: height },
{ pos: [halfDepth, height / 2, halfDepth], h: height },
{ pos: [-halfDepth, (height + h_half) / 2, 0], h: height + h_half },
{ pos: [halfDepth, (height + h_half) / 2, 0], h: height + h_half },
);
}
return list;
}, [type, depth, height, h_diff, h_half, halfDepth]);
return (
<group>
{/* Solo translúcido */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
<planeGeometry args={[depth * 2, depth * 2]} />
<meshStandardMaterial color="#94a3b8" transparent opacity={0.15} />
</mesh>
{/* === COBERTURA (PAINÉIS 3D SÓLIDOS) === */}
{type === 'shed' ? (
// Uma água (Shed): dividida em metade barlavento e metade sotavento
<group>
{/* Metade Barlavento (Z < 0) */}
<mesh
position={[0, height + h_diff / 4, -depth / 4]}
rotation={[-thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[depth, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={windwardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
{/* Metade Sotavento (Z > 0) */}
<mesh
position={[0, height + (3 * h_diff) / 4, depth / 4]}
rotation={[-thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[depth, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={leewardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
</group>
) : (
// Duas águas (Gable)
<group>
{/* Água Esquerda / Barlavento (Z < 0) */}
<mesh
position={[0, height + h_half / 2, -depth / 4]}
rotation={[-thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[depth, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={windwardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
{/* Água Direita / Sotavento (Z > 0) */}
<mesh
position={[0, height + h_half / 2, depth / 4]}
rotation={[thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[depth, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={leewardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
</group>
)}
{/* Pilares de Suporte */}
{pillars.map((p, i) => (
<mesh key={`pillar-${i}`} position={p.pos} castShadow>
<cylinderGeometry args={[0.06, 0.06, p.h, 16]} />
<meshStandardMaterial color="#475569" roughness={0.5} />
</mesh>
))}
{/* Seta de força resultante (sucção para cima) */}
<ForceArrow
start={new THREE.Vector3(0, centerY, 0)}
direction={new THREE.Vector3(0, 1, 0)}
length={arrowLen}
color="#ef4444"
/>
{/* === LINHAS DE COTA (CAD-Style) === */}
{/* Cota de Altura (h) */}
<group position={[-halfDepth - 0.4, 0, -halfDepth]}>
<mesh position={[0, height / 2, 0]}>
<boxGeometry args={[0.015, height, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
<mesh position={[0, height, 0]}>
<boxGeometry args={[0.1, 0.015, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
<mesh position={[0, 0, 0]}>
<boxGeometry args={[0.1, 0.015, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
<Text
position={[-0.15, height / 2, 0]}
rotation={[0, -Math.PI / 2, 0]}
fontSize={0.25}
color="#475569"
anchorX="center"
anchorY="middle"
>
h = {height}m
</Text>
</group>
{/* Cota de Profundidade/Span (d) */}
<group position={[halfDepth + 0.4, height / 2, 0]}>
<mesh position={[0, 0, 0]}>
<boxGeometry args={[0.015, 0.015, depth]} />
<meshStandardMaterial color="#64748b" />
</mesh>
<mesh position={[0, 0, halfDepth]}>
<boxGeometry args={[0.1, 0.015, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
<mesh position={[0, 0, -halfDepth]}>
<boxGeometry args={[0.1, 0.015, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
<Text
position={[0.15, 0, 0]}
rotation={[0, Math.PI / 2, 0]}
fontSize={0.25}
color="#475569"
anchorX="center"
anchorY="middle"
>
d = {depth}m
</Text>
</group>
{/* Rótulo Superior */}
<Text
position={[0, centerY + arrowLen + 0.8, 0]}
fontSize={0.4}
color="#1a202c"
anchorX="center"
anchorY="bottom"
>
θ={theta}° | F = {forceKN.toFixed(1)} kN
</Text>
</group>
);
}
function ForceArrow({
start,
direction,
length,
color,
}: {
start: THREE.Vector3;
direction: THREE.Vector3;
length: number;
color: string;
}) {
const end = useMemo(
() => new THREE.Vector3(start.x + direction.x * length, start.y + direction.y * length, start.z + direction.z * length),
[start, direction, length],
);
const headLen = 0.3;
const headRadius = 0.1;
const shaftRadius = 0.04;
const midPoint = useMemo(
() => new THREE.Vector3((start.x + end.x) / 2, (start.y + end.y) / 2, (start.z + end.z) / 2),
[start, end],
);
const shaftLength = length - headLen;
const rotation = useMemo(() => {
const dir = new THREE.Vector3().subVectors(end, start).normalize();
const quat = new THREE.Quaternion();
quat.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
const euler = new THREE.Euler().setFromQuaternion(quat);
return [euler.x, euler.y, euler.z] as [number, number, number];
}, [start, end]);
return (
<group>
{shaftLength > 0 && (
<mesh position={midPoint.toArray()} rotation={rotation} castShadow>
<cylinderGeometry args={[shaftRadius, shaftRadius, shaftLength, 12]} />
<meshStandardMaterial color={color} />
</mesh>
)}
<mesh
position={[end.x, end.y, end.z]}
rotation={rotation}
castShadow
>
<coneGeometry args={[headRadius, headLen, 12]} />
<meshStandardMaterial color={color} />
</mesh>
</group>
);
}
export default function IsolatedRoof3DViewer({
type,
theta,
height,
depth,
cpeWindward,
cpeLeeward,
cpeTop,
forceKN,
}: IsolatedRoof3DInput) {
const cameraDistance = Math.max(depth * 1.3, height * 1.5, 8);
const fallback = (
<FallbackDiagram
type="isolatedRoof"
props={{ type, theta, height, depth, cpeWindward, cpeLeeward, cpeTop, forceKN }}
/>
);
return (
<SceneCanvas
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [cameraDistance, cameraDistance * 0.8, cameraDistance], fov: 40 }}
fallback={fallback}
>
<ambientLight intensity={0.7} />
<directionalLight
position={[depth * 1.5, height * 3, depth * 1.5]}
intensity={1.2}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<IsolatedRoofModel
type={type}
theta={theta}
height={height}
depth={depth}
cpeWindward={cpeWindward}
cpeLeeward={cpeLeeward}
cpeTop={cpeTop}
forceKN={forceKN}
/>
<Grid infiniteGrid fadeDistance={cameraDistance * 5} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
</SceneCanvas>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { useMemo } from 'react';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
export interface Sign3DInput {
/** Comprimento (m) */
length: number;
/** Altura hₐ (m) */
height: number;
/** Distância do solo (m) */
groundClearance: number;
/** Ângulo de incidência (graus) */
alpha: 0 | 50 | 90;
/** Coeficiente de força Cf */
cf: number;
/** Força resultante F (kN) */
forceKN: number;
/** Excentricidade e (m) */
applicationPoint: number;
}
/**
* Converte kN para um comprimento visual proporcional no eixo 3D.
* 1 kN = 0.25 m de seta (escala calibrada para visualização).
*/
const forceToLength = (kN: number): number => Math.min(Math.max(kN * 0.25, 0.5), 8);
function SignModel({
length,
height,
groundClearance,
alpha,
cf,
forceKN,
applicationPoint,
}: Sign3DInput) {
const baseY = groundClearance;
const topY = baseY + height;
const halfL = length / 2;
const arrowLen = forceToLength(forceKN);
// Direção da seta no plano XZ (α é o ângulo de incidência do vento relativo à superfície)
// O ângulo em relação à normal da placa (eixo X) é 90 - α
const angleToNormalRad = ((90 - alpha) * Math.PI) / 180;
const arrowDir = useMemo(() => new THREE.Vector3(Math.cos(angleToNormalRad), 0, Math.sin(angleToNormalRad)), [angleToNormalRad]);
// Posição da seta no plano da placa (inicia no ponto de aplicação com a excentricidade ao longo de Z)
const arrowStart = useMemo(
() => new THREE.Vector3(0, baseY + height / 2, applicationPoint),
[applicationPoint, height, baseY],
);
// Cor da placa baseada no Cf (mais vermelho = mais carga)
const plateColor = useMemo(() => {
const intensity = Math.min(1, Math.abs(cf) / 2.0);
const hue = 220 - intensity * 220; // azul → vermelho
return new THREE.Color(`hsl(${hue}, ${60 + intensity * 30}%, ${50 - intensity * 10}%)`);
}, [cf]);
// Pontas de extremidade (placas de extremidade opcionais)
const endPlates = cf >= 1.3 && cf <= 2.0;
return (
<group>
{/* Placa principal */}
<mesh position={[0, (baseY + topY) / 2, 0]} castShadow receiveShadow>
<boxGeometry args={[0.1, height, length]} />
<meshStandardMaterial color={plateColor} opacity={0.8} transparent roughness={0.4} side={THREE.DoubleSide} />
</mesh>
{/* Placas de extremidade (retornos aerodinâmicos nas pontas) */}
{endPlates && (
<>
<mesh position={[0, (baseY + topY) / 2, halfL]} castShadow>
<boxGeometry args={[0.3, height * 0.95, 0.04]} />
<meshStandardMaterial color="#64748b" opacity={0.6} transparent side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, (baseY + topY) / 2, -halfL]} castShadow>
<boxGeometry args={[0.3, height * 0.95, 0.04]} />
<meshStandardMaterial color="#64748b" opacity={0.6} transparent side={THREE.DoubleSide} />
</mesh>
</>
)}
{/* Linha do solo (base translúcida) */}
<mesh position={[0, 0, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[length * 1.5, length * 1.5]} />
<meshStandardMaterial color="#94a3b8" opacity={0.15} transparent />
</mesh>
{/* Eixo horizontal de referência de direção do vento */}
<mesh position={[0, 0.005, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[length * 1.5, 0.03]} />
<meshStandardMaterial color="#475569" opacity={0.5} transparent />
</mesh>
{/* Marca da excentricidade (Ponto de Aplicação da Resultante) */}
<mesh position={[0, (baseY + topY) / 2, applicationPoint]} castShadow>
<sphereGeometry args={[0.08, 16, 16]} />
<meshStandardMaterial color="#fbbf24" emissive="#fbbf24" emissiveIntensity={0.6} />
</mesh>
{/* Rótulo explicativo para o ponto de aplicação */}
<Text
position={[0.2, (baseY + topY) / 2, applicationPoint]}
rotation={[0, Math.PI / 2, 0]}
fontSize={0.15}
color="#d97706"
anchorX="left"
anchorY="middle"
>
Resultante (e = {applicationPoint.toFixed(2)}m)
</Text>
{/* Vetor de força resultante */}
<ForceArrow
start={arrowStart}
direction={arrowDir}
length={arrowLen}
color={forceKN >= 0 ? '#ef4444' : '#3b82f6'}
/>
{/* === LINHAS DE COTA (CAD-Style Dimensions) === */}
{/* Cota de Altura (h) */}
<group position={[0, 0, -halfL - 0.4]}>
{/* Linha vertical */}
<mesh position={[0, (baseY + topY) / 2, 0]}>
<boxGeometry args={[0.015, height, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
{/* Traço superior */}
<mesh position={[0, topY, 0]}>
<boxGeometry args={[0.1, 0.015, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
{/* Traço inferior */}
<mesh position={[0, baseY, 0]}>
<boxGeometry args={[0.1, 0.015, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
{/* Texto da altura */}
<Text
position={[-0.15, (baseY + topY) / 2, 0]}
rotation={[0, -Math.PI / 2, 0]}
fontSize={0.25}
color="#475569"
anchorX="center"
anchorY="middle"
>
h = {height}m
</Text>
</group>
{/* Cota de Comprimento (l) */}
<group position={[0.4, baseY + height / 2, 0]}>
{/* Linha horizontal longitudinal */}
<mesh position={[0, 0, 0]}>
<boxGeometry args={[0.015, 0.015, length]} />
<meshStandardMaterial color="#64748b" />
</mesh>
{/* Traço frontal */}
<mesh position={[0, 0, halfL]}>
<boxGeometry args={[0.1, 0.015, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
{/* Traço traseiro */}
<mesh position={[0, 0, -halfL]}>
<boxGeometry args={[0.1, 0.015, 0.015]} />
<meshStandardMaterial color="#64748b" />
</mesh>
{/* Texto do comprimento */}
<Text
position={[0.15, 0, 0]}
rotation={[0, Math.PI / 2, 0]}
fontSize={0.25}
color="#475569"
anchorX="center"
anchorY="middle"
>
= {length}m
</Text>
</group>
{/* Texto Informativo Superior */}
<Text
position={[0, topY + 0.6, 0]}
fontSize={0.4}
color="#1a202c"
anchorX="center"
anchorY="bottom"
>
Muro / Placa Isolada | Cf = {cf.toFixed(2)}
</Text>
</group>
);
}
function ForceArrow({
start,
direction,
length,
color,
}: {
start: THREE.Vector3;
direction: THREE.Vector3;
length: number;
color: string;
}) {
const end = useMemo(
() => new THREE.Vector3(start.x + direction.x * length, start.y, start.z + direction.z * length),
[start, direction, length],
);
const headLen = 0.3;
const headRadius = 0.1;
const shaftRadius = 0.04;
// Cilindro principal (haste)
const midPoint = useMemo(
() => new THREE.Vector3((start.x + end.x) / 2, (start.y + end.y) / 2, (start.z + end.z) / 2),
[start, end],
);
const shaftLength = length - headLen;
// Rotação do cilindro (apontar de start para end)
const rotation = useMemo(() => {
const dir = new THREE.Vector3().subVectors(end, start).normalize();
const quat = new THREE.Quaternion();
quat.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
const euler = new THREE.Euler().setFromQuaternion(quat);
return [euler.x, euler.y, euler.z] as [number, number, number];
}, [start, end]);
return (
<group>
{shaftLength > 0 && (
<mesh position={midPoint.toArray()} rotation={rotation} castShadow>
<cylinderGeometry args={[shaftRadius, shaftRadius, shaftLength, 12]} />
<meshStandardMaterial color={color} />
</mesh>
)}
{/* Ponta da seta (cone) */}
<mesh
position={[end.x, end.y, end.z]}
rotation={rotation}
castShadow
>
<coneGeometry args={[headRadius, headLen, 12]} />
<meshStandardMaterial color={color} />
</mesh>
</group>
);
}
export default function Sign3DViewer({
length,
height,
groundClearance,
alpha,
cf,
forceKN,
applicationPoint,
}: Sign3DInput) {
const fallback = (
<FallbackDiagram
type="sign"
props={{ length, height, groundClearance, alpha, cf, forceKN, applicationPoint }}
/>
);
const maxDim = Math.max(length, height);
return (
<SceneCanvas
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [length * 1.3, height * 1.5, length * 1.3], fov: 40 }}
fallback={fallback}
>
<ambientLight intensity={0.7} />
<directionalLight position={[length * 1.5, height * 2.5, length * 1.5]} intensity={1.2} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
<SignModel
length={length}
height={height}
groundClearance={groundClearance}
alpha={alpha}
cf={cf}
forceKN={forceKN}
applicationPoint={applicationPoint}
/>
<Grid infiniteGrid fadeDistance={maxDim * 5} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
</SceneCanvas>
);
}
+307
View File
@@ -0,0 +1,307 @@
import { useMemo } from 'react';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
export interface Tower3DInput {
/** Forma da seção */
section: 'square' | 'triangular';
/** Tipo de barras */
barType: 'flat' | 'circular';
/** Largura da base (m) */
baseWidth: number;
/** Altura total (m) */
height: number;
/** Número de tramos verticais (modulos) */
panels: number;
/** Índice de área exposta φ */
phi: number;
/** Ângulo de incidência do vento (graus) */
alphaWind: 0 | 45 | 90;
/** Força total estimada na torre (kN) */
forceKN: number;
}
/**
* Gera os vértices (nós) e barras de uma torre reticulada proceduralmente.
*
* Para torre quadrada: 4 montantes + diagonais em X + travessas horizontais.
* Para torre triangular: 3 montantes + diagonais em cada face.
*/
interface TowerGeometry {
nodes: THREE.Vector3[];
members: { start: number; end: number; type: 'leg' | 'diagonal' | 'horizontal' }[];
}
function buildTowerGeometry(
section: 'square' | 'triangular',
panels: number,
baseWidth: number,
totalHeight: number,
): TowerGeometry {
const halfW = baseWidth / 2;
const panelH = totalHeight / panels;
const nodes: THREE.Vector3[] = [];
const members: TowerGeometry['members'] = [];
// Base ring (nível 0)
const baseCorners =
section === 'square'
? [
[-halfW, -halfW],
[halfW, -halfW],
[halfW, halfW],
[-halfW, halfW],
]
: [
[0, -halfW],
[halfW * Math.cos(Math.PI / 6), halfW * Math.sin(Math.PI / 6)],
[-halfW * Math.cos(Math.PI / 6), halfW * Math.sin(Math.PI / 6)],
];
baseCorners.forEach(([x, z]) => {
nodes.push(new THREE.Vector3(x, 0, z));
});
const baseNodeCount = baseCorners.length;
// Níveis superiores
for (let p = 1; p <= panels; p++) {
baseCorners.forEach(([x, z]) => {
nodes.push(new THREE.Vector3(x, p * panelH, z));
});
}
// Montantes (legs) — conectam cada canto em todos os níveis
for (let corner = 0; corner < baseNodeCount; corner++) {
for (let p = 0; p < panels; p++) {
members.push({
start: p * baseNodeCount + corner,
end: (p + 1) * baseNodeCount + corner,
type: 'leg',
});
}
}
// Travessas horizontais em cada nível
for (let p = 0; p <= panels; p++) {
for (let i = 0; i < baseNodeCount; i++) {
members.push({
start: p * baseNodeCount + i,
end: p * baseNodeCount + ((i + 1) % baseNodeCount),
type: 'horizontal',
});
}
}
// Diagonais em cada painel
for (let p = 0; p < panels; p++) {
for (let i = 0; i < baseNodeCount; i++) {
members.push({
start: p * baseNodeCount + i,
end: (p + 1) * baseNodeCount + ((i + 1) % baseNodeCount),
type: 'diagonal',
});
members.push({
start: p * baseNodeCount + ((i + 1) % baseNodeCount),
end: (p + 1) * baseNodeCount + i,
type: 'diagonal',
});
}
}
return { nodes, members };
}
function pressureColor(phi: number, forceKN: number): THREE.Color {
const intensity = Math.min(1, (phi * forceKN) / 30);
const hue = 220 - intensity * 220;
return new THREE.Color(`hsl(${hue}, ${60 + intensity * 30}%, ${50 - intensity * 10}%)`);
}
function TowerModel({
section,
barType,
baseWidth,
height,
panels,
phi,
alphaWind,
forceKN,
}: Tower3DInput) {
const geometry = useMemo(
() => buildTowerGeometry(section, panels, baseWidth, height),
[section, panels, baseWidth, height],
);
const barRadius = barType === 'circular' ? 0.04 : 0.03;
const barColor = pressureColor(phi, forceKN);
// Direção do vetor de força
const alphaRad = (alphaWind * Math.PI) / 180;
const forceDir = useMemo(
() => new THREE.Vector3(Math.cos(alphaRad), 0, Math.sin(alphaRad)),
[alphaRad],
);
return (
<group>
{/* Solo */}
<mesh position={[0, 0, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[baseWidth * 4, baseWidth * 4]} />
<meshStandardMaterial color="#94a3b8" opacity={0.2} transparent />
</mesh>
{/* Nós (esferas pequenas) */}
{geometry.nodes.map((node, idx) => (
<mesh key={`node-${idx}`} position={node.toArray()} castShadow>
<sphereGeometry args={[barRadius * 1.4, 8, 8]} />
<meshStandardMaterial color="#475569" />
</mesh>
))}
{/* Barras */}
{geometry.members.map((m, idx) => {
const start = geometry.nodes[m.start];
const end = geometry.nodes[m.end];
const midpoint = new THREE.Vector3()
.addVectors(start, end)
.multiplyScalar(0.5);
const length = start.distanceTo(end);
const dir = new THREE.Vector3().subVectors(end, start).normalize();
// Rotação para alinhar cilindro com direção start→end
const quat = new THREE.Quaternion();
quat.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
const euler = new THREE.Euler().setFromQuaternion(quat);
const color =
m.type === 'leg'
? '#1e293b'
: m.type === 'horizontal'
? '#64748b'
: barColor;
return (
<mesh key={`bar-${idx}`} position={midpoint.toArray()} rotation={[euler.x, euler.y, euler.z]} castShadow>
<cylinderGeometry args={[barRadius, barRadius, length, 6]} />
<meshStandardMaterial color={color} />
</mesh>
);
})}
{/* Vetores de força distribuídos pelos tramos (meio de cada tramo) */}
{Array.from({ length: panels }).map((_, i) => {
const pHeight = height / panels;
const startY = (i + 0.5) * pHeight; // Altura no meio do tramo
const pForce = forceKN / panels; // Força por tramo
// Escala da seta menor para ficar visualmente agradável
const pArrowLen = Math.min(Math.max(pForce * 0.1, 0.5), 2.5);
// Calcular o ponto inicial para que a ponta da seta encoste na face (baseWidth / 2)
const endX = -forceDir.x * (baseWidth / 2);
const endZ = -forceDir.z * (baseWidth / 2);
const startX = endX - forceDir.x * pArrowLen;
const startZ = endZ - forceDir.z * pArrowLen;
return (
<ForceArrow
key={`force-${i}`}
start={[startX, startY, startZ]}
direction={forceDir}
length={pArrowLen}
color="#ef4444"
/>
);
})}
{/* Labels indicativos */}
<mesh position={[baseWidth / 2 + 0.5, 0.5, 0]}>
<boxGeometry args={[0.02, 0.02, 0.02]} />
<meshStandardMaterial color="#fbbf24" />
</mesh>
<Text
position={[0, height + 1.0, 0]}
fontSize={0.5}
color="#1e40af"
anchorX="center"
anchorY="bottom"
>
h={height}m | base={baseWidth}m | φ={phi.toFixed(2)}
</Text>
</group>
);
}
function ForceArrow({
start,
direction,
length,
color,
}: {
start: [number, number, number];
direction: THREE.Vector3;
length: number;
color: string;
}) {
const startVec = useMemo(() => new THREE.Vector3(...start), [start]);
const end = useMemo(
() => new THREE.Vector3(startVec.x + direction.x * length, startVec.y, startVec.z + direction.z * length),
[startVec, direction, length],
);
const mid = useMemo(
() => new THREE.Vector3((startVec.x + end.x) / 2, (startVec.y + end.y) / 2, (startVec.z + end.z) / 2),
[startVec, end],
);
const quat = useMemo(() => {
const dir = new THREE.Vector3().subVectors(end, startVec).normalize();
const q = new THREE.Quaternion();
q.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
return new THREE.Euler().setFromQuaternion(q);
}, [startVec, end]);
const headLen = 0.3;
return (
<group>
{length - headLen > 0 && (
<mesh position={mid.toArray()} rotation={[quat.x, quat.y, quat.z]} castShadow>
<cylinderGeometry args={[0.05, 0.05, length - headLen, 10]} />
<meshStandardMaterial color={color} />
</mesh>
)}
<mesh position={end.toArray()} rotation={[quat.x, quat.y, quat.z]} castShadow>
<coneGeometry args={[0.12, headLen, 10]} />
<meshStandardMaterial color={color} />
</mesh>
</group>
);
}
export default function Tower3DViewer(input: Tower3DInput) {
const { baseWidth, height } = input;
const dist = Math.max(baseWidth * 3, height * 1.2);
const fallback = (
<FallbackDiagram
type="tower"
props={input}
/>
);
return (
<SceneCanvas
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [dist, height * 0.6, dist], fov: 45 }}
fallback={fallback}
>
<ambientLight intensity={0.6} />
<directionalLight position={[dist, height, dist]} intensity={1.2} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
<TowerModel {...input} />
<Grid infiniteGrid fadeDistance={height * 2} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
</SceneCanvas>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useMemo } from 'react';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
export interface Vault3DInput {
span: number;
length: number;
rise: number;
cpi: number;
cpeProfile: Record<string, number>;
}
function vaultColor(cpe: number, cpi: number): THREE.Color {
const p = cpe - cpi;
const intensity = Math.min(1, Math.abs(p) / 1.5);
if (p > 0) {
return new THREE.Color(`hsl(${215 - intensity * 10}, ${70 + intensity * 25}%, ${Math.max(35, 60 - intensity * 25)}%)`);
}
return new THREE.Color(`hsl(0, ${70 + intensity * 25}%, ${Math.max(40, 60 - intensity * 20)}%)`);
}
function VaultModel({ span, length, rise, cpi, cpeProfile }: Vault3DInput) {
const segments = 64;
const points = useMemo(() => {
const pts: THREE.Vector3[] = [];
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const x = -span / 2 + t * span;
const y = rise * Math.sin(t * Math.PI);
pts.push(new THREE.Vector3(x, y, 0));
}
return pts;
}, [span, rise, segments]);
// Divide em zonas (1, 2, 3, 4, 5, 6)
const zones = useMemo(() => {
const arr: { cpe: number; startIdx: number; endIdx: number }[] = [];
const zoneSize = segments / 6;
for (let z = 0; z < 6; z++) {
const startIdx = Math.floor(z * zoneSize);
const endIdx = Math.floor((z + 1) * zoneSize);
const key = `zone${z + 1}`;
arr.push({ cpe: cpeProfile[key] ?? -0.5, startIdx, endIdx });
}
return arr;
}, [cpeProfile, segments]);
// Shape para fechar os tímpanos (paredes frontais/traseiras em arco)
const archShape = useMemo(() => {
const s = new THREE.Shape();
s.moveTo(-span / 2, 0);
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const x = -span / 2 + t * span;
const y = rise * Math.sin(t * Math.PI);
s.lineTo(x, y);
}
s.lineTo(span / 2, 0);
s.closePath();
return s;
}, [span, rise, segments]);
return (
<group>
{/* Casca da Abóbada */}
{zones.map((zone, idx) => {
const color = vaultColor(zone.cpe, cpi);
const verts: number[] = [];
for (let i = zone.startIdx; i <= zone.endIdx; i++) {
verts.push(points[i].x, points[i].y, points[i].z);
verts.push(points[i].x, points[i].y, length);
}
const indices: number[] = [];
for (let i = 0; i < (zone.endIdx - zone.startIdx); i++) {
const a = i * 2;
const b = i * 2 + 1;
const c = i * 2 + 2;
const d = i * 2 + 3;
indices.push(a, b, c, b, d, c);
}
return (
<mesh key={idx} castShadow receiveShadow>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[new Float32Array(verts), 3]} />
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
</bufferGeometry>
<meshStandardMaterial color={color} opacity={0.92} transparent side={THREE.DoubleSide} roughness={0.4} />
</mesh>
);
})}
{/* Tímpano Traseiro (Z = 0) */}
<mesh position={[0, 0, 0]} castShadow receiveShadow>
<shapeGeometry args={[archShape]} />
<meshStandardMaterial color="#cbd5e1" opacity={0.7} transparent side={THREE.DoubleSide} roughness={0.5} />
</mesh>
{/* Tímpano Frontal (Z = length) */}
<mesh position={[0, 0, length]} castShadow receiveShadow>
<shapeGeometry args={[archShape]} />
<meshStandardMaterial color="#cbd5e1" opacity={0.7} transparent side={THREE.DoubleSide} roughness={0.5} />
</mesh>
{/* Rótulo de dimensões */}
<Text
position={[0, rise + 0.6, length / 2]}
fontSize={Math.max(0.3, Math.min(0.6, span / 20))}
color="#1a202c"
anchorX="center"
anchorY="bottom"
>
Vão = {span}m | Compr = {length}m | Flecha = {rise}m
</Text>
</group>
);
}
export default function Vault3DViewer({ span, length, rise, cpi, cpeProfile }: Vault3DInput) {
const fallback = (
<FallbackDiagram
type="vault"
props={{ span, length, rise, cpi, cpeProfile }}
/>
);
const maxDimension = Math.max(span, length, rise);
return (
<SceneCanvas
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [span * 1.2, rise * 1.5, length * 1.2], fov: 40 }}
fallback={fallback}
>
<ambientLight intensity={0.7} />
<directionalLight
position={[span, rise * 3, length * 1.5]}
intensity={1.2}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<VaultModel span={span} length={length} rise={rise} cpi={cpi} cpeProfile={cpeProfile} />
<Grid infiniteGrid fadeDistance={maxDimension * 5} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
</SceneCanvas>
);
}