pub mod exterior;
pub mod interior;
pub use exterior::build_exterior_scene;
pub use interior::build_interior_scene;
#[derive(Debug, Clone)]
pub struct SceneCamera {
pub azimuth: f64,
pub elevation: f64,
pub scale: f64,
pub center: (f64, f64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CameraPreset {
FrontRight,
FrontLeft,
TopDown,
LowAngle,
Section,
}
impl CameraPreset {
pub fn all() -> &'static [CameraPreset] {
&[
Self::FrontRight,
Self::FrontLeft,
Self::TopDown,
Self::LowAngle,
Self::Section,
]
}
pub fn label(&self) -> &'static str {
match self {
Self::FrontRight => "Front-Right",
Self::FrontLeft => "Front-Left",
Self::TopDown => "Top-Down",
Self::LowAngle => "Low Angle",
Self::Section => "Section",
}
}
pub fn to_camera(&self, svg_width: f64, svg_height: f64, scene_scale: f64) -> SceneCamera {
let (az, el) = match self {
Self::FrontRight => (30.0, 30.0),
Self::FrontLeft => (150.0, 30.0),
Self::TopDown => (0.0, 85.0),
Self::LowAngle => (30.0, 15.0),
Self::Section => (0.0, 0.0),
};
SceneCamera {
azimuth: az,
elevation: el,
scale: scene_scale,
center: (svg_width / 2.0, svg_height / 2.0),
}
}
}
#[derive(Debug, Clone)]
pub struct SceneFace {
pub vertices: Vec<(f64, f64, f64)>,
pub fill: String,
pub stroke: String,
pub stroke_width: f64,
pub opacity: f64,
pub dash: Option<String>,
pub label: Option<(String, f64)>,
}
impl SceneFace {
pub fn quad(
v0: (f64, f64, f64),
v1: (f64, f64, f64),
v2: (f64, f64, f64),
v3: (f64, f64, f64),
fill: &str,
stroke: &str,
opacity: f64,
) -> Self {
Self {
vertices: vec![v0, v1, v2, v3],
fill: fill.to_string(),
stroke: stroke.to_string(),
stroke_width: 1.0,
opacity,
dash: None,
label: None,
}
}
pub fn with_stroke_width(mut self, w: f64) -> Self {
self.stroke_width = w;
self
}
pub fn with_dash(mut self, d: &str) -> Self {
self.dash = Some(d.to_string());
self
}
pub fn with_label(mut self, text: &str, font_size: f64) -> Self {
self.label = Some((text.to_string(), font_size));
self
}
}
pub fn project(camera: &SceneCamera, p: (f64, f64, f64)) -> (f64, f64) {
let az = camera.azimuth.to_radians();
let el = camera.elevation.to_radians();
let (x, y, z) = p;
let sx = (x * az.cos() - y * az.sin()) * camera.scale;
let sy = (-(x * az.sin() + y * az.cos()) * el.sin() + z * el.cos()) * camera.scale;
(camera.center.0 + sx, camera.center.1 - sy)
}
fn view_depth(camera: &SceneCamera, p: (f64, f64, f64)) -> f64 {
let az = camera.azimuth.to_radians();
let el = camera.elevation.to_radians();
let (x, y, z) = p;
(x * az.sin() + y * az.cos()) * el.cos() + z * el.sin()
}
fn face_depth(camera: &SceneCamera, face: &SceneFace) -> f64 {
if face.vertices.is_empty() {
return 0.0;
}
let sum: f64 = face.vertices.iter().map(|&v| view_depth(camera, v)).sum();
sum / face.vertices.len() as f64
}
pub fn render_scene_svg(
faces: &[SceneFace],
camera: &SceneCamera,
svg_width: f64,
svg_height: f64,
bg: &str,
) -> String {
let mut indices: Vec<usize> = (0..faces.len()).collect();
indices.sort_by(|&a, &b| {
face_depth(camera, &faces[a])
.partial_cmp(&face_depth(camera, &faces[b]))
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {svg_width} {svg_height}" preserveAspectRatio="xMidYMid meet">"#
);
svg.push_str(&format!(
r#"<rect width="{svg_width}" height="{svg_height}" fill="{bg}"/>"#
));
for &idx in &indices {
let face = &faces[idx];
if face.vertices.is_empty() {
continue;
}
let projected: Vec<(f64, f64)> =
face.vertices.iter().map(|&v| project(camera, v)).collect();
let points: String = projected
.iter()
.map(|(x, y)| format!("{:.1},{:.1}", x, y))
.collect::<Vec<_>>()
.join(" ");
let dash_attr = face
.dash
.as_ref()
.map(|d| format!(r#" stroke-dasharray="{d}""#))
.unwrap_or_default();
svg.push_str(&format!(
r#"<polygon points="{points}" fill="{}" stroke="{}" stroke-width="{:.1}" opacity="{:.2}"{dash_attr}/>"#,
face.fill, face.stroke, face.stroke_width, face.opacity
));
if let Some((ref text, font_size)) = face.label {
let cx: f64 = projected.iter().map(|p| p.0).sum::<f64>() / projected.len() as f64;
let cy: f64 = projected.iter().map(|p| p.1).sum::<f64>() / projected.len() as f64;
svg.push_str(&format!(
r#"<text x="{cx:.1}" y="{cy:.1}" fill="{}" font-size="{font_size:.0}" text-anchor="middle" dominant-baseline="middle">{text}</text>"#,
face.stroke
));
}
}
svg.push_str("</svg>");
svg
}
pub fn fit_scale(
bbox_x: f64,
bbox_y: f64,
bbox_z: f64,
svg_width: f64,
svg_height: f64,
camera: &SceneCamera,
) -> f64 {
let hx = bbox_x / 2.0;
let hy = bbox_y / 2.0;
let corners = [
(-hx, -hy, 0.0),
(hx, -hy, 0.0),
(-hx, hy, 0.0),
(hx, hy, 0.0),
(-hx, -hy, bbox_z),
(hx, -hy, bbox_z),
(-hx, hy, bbox_z),
(hx, hy, bbox_z),
];
let unit_cam = SceneCamera {
scale: 1.0,
center: (0.0, 0.0),
..*camera
};
let projected: Vec<(f64, f64)> = corners.iter().map(|&c| project(&unit_cam, c)).collect();
let min_x = projected.iter().map(|p| p.0).fold(f64::MAX, f64::min);
let max_x = projected.iter().map(|p| p.0).fold(f64::MIN, f64::max);
let min_y = projected.iter().map(|p| p.1).fold(f64::MAX, f64::min);
let max_y = projected.iter().map(|p| p.1).fold(f64::MIN, f64::max);
let extent_x = (max_x - min_x).max(0.1);
let extent_y = (max_y - min_y).max(0.1);
let margin = 0.85; let scale_x = svg_width * margin / extent_x;
let scale_y = svg_height * margin / extent_y;
scale_x.min(scale_y)
}
pub fn reflectance_to_rgb(rho: f64) -> String {
let v = (rho.clamp(0.0, 1.0) * 200.0 + 40.0).round() as u8; format!("rgb({v},{v},{v})")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_project_top_down() {
let cam = CameraPreset::TopDown.to_camera(500.0, 400.0, 20.0);
let p = project(&cam, (5.0, 0.0, 0.0));
assert!((p.0 - 250.0).abs() < 200.0, "x={}", p.0);
}
#[test]
fn test_project_origin_at_center() {
let cam = SceneCamera {
azimuth: 0.0,
elevation: 45.0,
scale: 10.0,
center: (250.0, 200.0),
};
let p = project(&cam, (0.0, 0.0, 0.0));
assert!((p.0 - 250.0).abs() < 0.01);
assert!((p.1 - 200.0).abs() < 0.01);
}
#[test]
fn test_render_empty() {
let cam = CameraPreset::FrontRight.to_camera(500.0, 400.0, 20.0);
let svg = render_scene_svg(&[], &cam, 500.0, 400.0, "#fff");
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn test_fit_scale_positive() {
let cam = CameraPreset::FrontRight.to_camera(500.0, 400.0, 1.0);
let s = fit_scale(10.0, 8.0, 3.0, 500.0, 400.0, &cam);
assert!(s > 0.0, "scale = {s}");
assert!(s < 100.0, "scale = {s}");
}
}