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
+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>
);
}