feat: adicionar super manual didatico com fluxos 2d e sheet panel

This commit is contained in:
2026-07-09 23:40:06 +00:00
parent 9fece3f174
commit 40e73e734a
34 changed files with 1443 additions and 390 deletions
+31 -71
View File
@@ -25,11 +25,6 @@ export interface Bar3DInput {
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,
@@ -37,94 +32,60 @@ function BarModel({
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
// Ação do vento deve rotacionar a barra em seu próprio eixo longitudinal (roll).
// O eixo longitudinal na nossa geometria é o X.
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;
// A rotação deve ser negativa para que o vento (vindo de -Z) atinja as faces corretas conforme a NBR.
const barRotationX = -alphaRad;
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} />
<group>
{/* Seta do Vento (Fixa Globalmente, soprando em +Z) */}
<group position={[0, 0, barRadius + 0.5]}>
<mesh position={[0, 0, 0.4]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.02, 0.02, 0.8, 8]} />
<meshStandardMaterial color="#22c55e" />
</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 position={[0, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.08, 0.2, 8]} />
<meshStandardMaterial color="#22c55e" />
</mesh>
</group>
{/* Barra Rotacionada (Roll) */}
<group rotation={[barRotationX, 0, 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>
</group>
)}
) : (
<SectionShape section={section ?? 'placa'} width={width ?? 0.1} length={length} color={barColor} thickness={barThickness} />
)}
{/* Eixos de referência locais da barra */}
<axesHelper args={[length * 0.5]} />
</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]}
position={[0, -barRadius * 2 - 0.4, 0]}
fontSize={0.3}
color="#1e40af"
anchorX="center"
@@ -132,7 +93,6 @@ function BarModel({
>
α={alpha}° | Cx={cx.toFixed(2)}
</Text>
{center && null}
</group>
);
}
+11 -80
View File
@@ -3,6 +3,7 @@ import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { WindArrow } from './WindArrow';
export interface Bridge3DInput {
/** Maior vão Lₚ (m) */
@@ -21,6 +22,8 @@ export interface Bridge3DInput {
fxPerLength: number;
/** Força de sustentação por unidade de comprimento Fz (kN/m) */
fzPerLength: number;
/** Ângulo de ataque do vento (graus) */
alpha: number;
}
function BridgeModel({
@@ -29,8 +32,7 @@ function BridgeModel({
deckHeight,
heg,
cx,
fxPerLength,
fzPerLength,
alpha,
}: Bridge3DInput) {
const halfL = lp / 2;
const halfW = width / 2;
@@ -47,13 +49,6 @@ function BridgeModel({
// 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) */}
@@ -89,32 +84,15 @@ function BridgeModel({
<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"
{/* Seta de Vento (bate no meio do tabuleiro) */}
<WindArrow
target={[0, deckY + deckThickness / 2, 0]}
direction={[0, Math.sin((alpha * Math.PI) / 180), Math.cos((alpha * Math.PI) / 180)]}
scale={4}
/>
<Text
position={[0, deckY + deckThickness + 1.0, 0]}
fontSize={0.6}
position={[0, deckY + deckThickness + 1.5, 0]}
fontSize={1.2}
color="#1e40af"
anchorX="center"
anchorY="bottom"
@@ -125,54 +103,7 @@ function BridgeModel({
);
}
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;
+1
View File
@@ -186,6 +186,7 @@ export default function Dynamics3DViewer(props: Dynamics3DInput) {
return (
<SceneCanvas
frameloop="always"
shadows
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [cameraDistance, cameraDistance * 0.5, cameraDistance], fov: 45 }}
+31 -84
View File
@@ -3,6 +3,7 @@ import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { WindArrow } from './WindArrow';
export interface IsolatedRoof3DInput {
/** Tipo de cobertura: 'shed' (uma água) ou 'gable' (duas águas) */
@@ -11,7 +12,9 @@ export interface IsolatedRoof3DInput {
theta: number;
/** Altura livre dos suportes (m) */
height: number;
/** Profundidade da cobertura (m) — dimensão perpendicular à seção */
/** Largura da cobertura (m) — dimensão transversal (b) */
width: number;
/** Profundidade da cobertura (m) — dimensão longitudinal (l) */
depth: number;
/** Cpe barlavento (sobre a face exposta ao vento) */
cpeWindward: number;
@@ -23,8 +26,6 @@ export interface IsolatedRoof3DInput {
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;
@@ -36,6 +37,7 @@ function IsolatedRoofModel({
type,
theta,
height,
width,
depth,
cpeWindward,
cpeLeeward,
@@ -43,12 +45,11 @@ function IsolatedRoofModel({
}: IsolatedRoof3DInput) {
const thetaRad = (theta * Math.PI) / 180;
const halfDepth = depth / 2;
const halfWidth = width / 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);
@@ -60,29 +61,29 @@ function IsolatedRoofModel({
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 },
{ pos: [-halfWidth, height / 2, -halfDepth], h: height },
{ pos: [halfWidth, height / 2, -halfDepth], h: height },
{ pos: [-halfWidth, (height + h_diff) / 2, halfDepth], h: height + h_diff },
{ pos: [halfWidth, (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 },
{ pos: [-halfWidth, height / 2, -halfDepth], h: height },
{ pos: [halfWidth, height / 2, -halfDepth], h: height },
{ pos: [-halfWidth, height / 2, halfDepth], h: height },
{ pos: [halfWidth, height / 2, halfDepth], h: height },
{ pos: [-halfWidth, (height + h_half) / 2, 0], h: height + h_half },
{ pos: [halfWidth, (height + h_half) / 2, 0], h: height + h_half },
);
}
return list;
}, [type, depth, height, h_diff, h_half, halfDepth]);
}, [type, depth, width, height, h_diff, h_half, halfDepth, halfWidth]);
return (
<group>
{/* Solo translúcido */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
<planeGeometry args={[depth * 2, depth * 2]} />
<planeGeometry args={[width * 1.5, depth * 1.5]} />
<meshStandardMaterial color="#94a3b8" transparent opacity={0.15} />
</mesh>
@@ -97,7 +98,7 @@ function IsolatedRoofModel({
castShadow
receiveShadow
>
<boxGeometry args={[depth, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={windwardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
{/* Metade Sotavento (Z > 0) */}
@@ -107,7 +108,7 @@ function IsolatedRoofModel({
castShadow
receiveShadow
>
<boxGeometry args={[depth, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={leewardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
</group>
@@ -121,7 +122,7 @@ function IsolatedRoofModel({
castShadow
receiveShadow
>
<boxGeometry args={[depth, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={windwardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
{/* Água Direita / Sotavento (Z > 0) */}
@@ -131,7 +132,7 @@ function IsolatedRoofModel({
castShadow
receiveShadow
>
<boxGeometry args={[depth, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={leewardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
</group>
@@ -145,12 +146,10 @@ function IsolatedRoofModel({
</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"
{/* Vetor de vento (Seta verde/azul batendo no elemento) */}
<WindArrow
target={new THREE.Vector3(0, centerY, 0)}
direction={new THREE.Vector3(0, 0, 1)}
/>
{/* === LINHAS DE COTA (CAD-Style) === */}
@@ -208,8 +207,7 @@ function IsolatedRoofModel({
{/* Rótulo Superior */}
<Text
position={[0, centerY + arrowLen + 0.8, 0]}
fontSize={0.4}
position={[0, centerY + 3, 0]}
color="#1a202c"
anchorX="center"
anchorY="bottom"
@@ -220,75 +218,23 @@ function IsolatedRoofModel({
);
}
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,
width,
depth,
cpeWindward,
cpeLeeward,
cpeTop,
forceKN,
}: IsolatedRoof3DInput) {
const cameraDistance = Math.max(depth * 1.3, height * 1.5, 8);
const cameraDistance = Math.max(Math.max(width, depth) * 1.3, height * 1.5, 8);
const fallback = (
<FallbackDiagram
type="isolatedRoof"
props={{ type, theta, height, depth, cpeWindward, cpeLeeward, cpeTop, forceKN }}
props={{ type, theta, height, width, depth, cpeWindward, cpeLeeward, cpeTop, forceKN }}
/>
);
@@ -311,6 +257,7 @@ export default function IsolatedRoof3DViewer({
type={type}
theta={theta}
height={height}
width={width}
depth={depth}
cpeWindward={cpeWindward}
cpeLeeward={cpeLeeward}
+8 -67
View File
@@ -3,6 +3,7 @@ import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { WindArrow } from './WindArrow';
export interface Sign3DInput {
/** Comprimento (m) */
@@ -22,10 +23,8 @@ export interface Sign3DInput {
}
/**
* Converte kN para um comprimento visual proporcional no eixo 3D.
* 1 kN = 0.25 m de seta (escala calibrada para visualização).
* Módulo 3D para visualização de Muros e Placas Isoladas.
*/
const forceToLength = (kN: number): number => Math.min(Math.max(kN * 0.25, 0.5), 8);
function SignModel({
length,
@@ -33,13 +32,11 @@ function SignModel({
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 - α
@@ -114,12 +111,10 @@ function SignModel({
Resultante (e = {applicationPoint.toFixed(2)}m)
</Text>
{/* Vetor de força resultante */}
<ForceArrow
start={arrowStart}
{/* Vetor de vento (Seta verde/azul batendo no elemento) */}
<WindArrow
target={arrowStart}
direction={arrowDir}
length={arrowLen}
color={forceKN >= 0 ? '#ef4444' : '#3b82f6'}
/>
{/* === LINHAS DE COTA (CAD-Style Dimensions) === */}
@@ -154,7 +149,7 @@ function SignModel({
</group>
{/* Cota de Comprimento (l) */}
<group position={[0.4, baseY + height / 2, 0]}>
<group position={[-0.4, baseY + height / 2, 0]}>
{/* Linha horizontal longitudinal */}
<mesh position={[0, 0, 0]}>
<boxGeometry args={[0.015, 0.015, length]} />
@@ -172,8 +167,8 @@ function SignModel({
</mesh>
{/* Texto do comprimento */}
<Text
position={[0.15, 0, 0]}
rotation={[0, Math.PI / 2, 0]}
position={[-0.15, 0, 0]}
rotation={[0, -Math.PI / 2, 0]}
fontSize={0.25}
color="#475569"
anchorX="center"
@@ -197,61 +192,7 @@ function SignModel({
);
}
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,
+6 -69
View File
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { OrbitControls, Grid, Environment, Text } from '@react-three/drei';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import { WindArrow } from './WindArrow';
import FallbackDiagram from '../FallbackDiagram';
export interface Tower3DInput {
@@ -190,31 +191,11 @@ function TowerModel({
);
})}
{/* 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"
/>
);
})}
{/* Seta de Vento única atingindo o meio da torre */}
<WindArrow
target={[0, height / 2, 0]}
direction={forceDir}
/>
{/* Labels indicativos */}
<mesh position={[baseWidth / 2 + 0.5, 0.5, 0]}>
@@ -234,50 +215,6 @@ function TowerModel({
);
}
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);
+59
View File
@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import * as THREE from 'three';
export interface WindArrowProps {
/** Ponto onde a ponta da seta do vento toca (ex: centro geométrico da estrutura) */
target: THREE.Vector3 | [number, number, number];
/** Direção de onde o vento vem (e para onde ele vai). Vetor normalizado. */
direction: THREE.Vector3 | [number, number, number];
/** Fator de escala da seta (default: 1) */
scale?: number;
}
export function WindArrow({ target, direction, scale = 1 }: WindArrowProps) {
const length = 2.5 * scale; // Tamanho visual fixo
const color = '#3b82f6'; // Azul padrão do vento
const targetVec = target instanceof THREE.Vector3 ? target : new THREE.Vector3(...target);
const dirVec = direction instanceof THREE.Vector3 ? direction : new THREE.Vector3(...direction);
const end = targetVec;
const base = useMemo(
() => new THREE.Vector3(end.x - dirVec.x * length, end.y - dirVec.y * length, end.z - dirVec.z * length),
[end, dirVec, length],
);
const headLen = 0.3 * scale;
const headRadius = 0.1 * scale;
const shaftRadius = 0.04 * scale;
const midPoint = useMemo(
() => new THREE.Vector3((base.x + end.x) / 2, (base.y + end.y) / 2, (base.z + end.z) / 2),
[base, end],
);
const shaftLength = length - headLen;
const rotation = useMemo(() => {
const dir = new THREE.Vector3().subVectors(end, base).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];
}, [base, 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>
);
}