use std::collections::HashSet;
use serde::Serialize;
use crate::execution::ArtifactId;
use crate::execution::Metadata;
use crate::execution::Point3d;
use crate::modules::ModuleId;
use crate::std::args::TyF64;
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, ts_rs::TS)]
#[ts(export)]
pub enum Orientation {
Front,
Back,
Left,
Right,
Top,
Bottom,
Isometric,
}
impl Orientation {
pub(crate) fn from_kcl_variant(name: &str) -> Option<Self> {
match name {
"Front" => Some(Orientation::Front),
"Back" => Some(Orientation::Back),
"Left" => Some(Orientation::Left),
"Right" => Some(Orientation::Right),
"Top" => Some(Orientation::Top),
"Bottom" => Some(Orientation::Bottom),
"Isometric" => Some(Orientation::Isometric),
_ => None,
}
}
}
#[cfg(test)]
impl Orientation {
pub(crate) const ALL: [Orientation; 7] = [
Orientation::Front,
Orientation::Back,
Orientation::Left,
Orientation::Right,
Orientation::Top,
Orientation::Bottom,
Orientation::Isometric,
];
pub(crate) fn kcl_name(self) -> &'static str {
match self {
Orientation::Front => "Front",
Orientation::Back => "Back",
Orientation::Left => "Left",
Orientation::Right => "Right",
Orientation::Top => "Top",
Orientation::Bottom => "Bottom",
Orientation::Isometric => "Isometric",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, ts_rs::TS)]
#[ts(export)]
pub enum Visibility {
Show,
Hide,
}
impl Visibility {
pub(crate) fn from_kcl_variant(name: &str) -> Option<Self> {
match name {
"Show" => Some(Visibility::Show),
"Hide" => Some(Visibility::Hide),
_ => None,
}
}
}
#[cfg(test)]
impl Visibility {
pub(crate) const ALL: [Visibility; 2] = [Visibility::Show, Visibility::Hide];
pub(crate) fn kcl_name(self) -> &'static str {
match self {
Visibility::Show => "Show",
Visibility::Hide => "Hide",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, ts_rs::TS)]
#[ts(export)]
pub enum Projection {
Orthographic,
Perspective,
}
impl Projection {
pub(crate) fn from_kcl_variant(name: &str) -> Option<Self> {
match name {
"Orthographic" => Some(Projection::Orthographic),
"Perspective" => Some(Projection::Perspective),
_ => None,
}
}
}
#[cfg(test)]
impl Projection {
pub(crate) const ALL: [Projection; 2] = [Projection::Orthographic, Projection::Perspective];
pub(crate) fn kcl_name(self) -> &'static str {
match self {
Projection::Orthographic => "Orthographic",
Projection::Perspective => "Perspective",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, ts_rs::TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "camelCase")]
#[allow(clippy::large_enum_variant)]
pub enum CameraLook {
Oriented { orientation: Orientation },
Directed { direction: Point3d, up: Point3d },
}
const MIN_DIRECTION_UP_ANGLE_SIN: f64 = 1e-6;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub(crate) enum CameraViewError {
#[error("`direction` must have finite coordinates.")]
NonFiniteDirection,
#[error("`up` must have finite coordinates.")]
NonFiniteUp,
#[error("`target` must have finite coordinates.")]
NonFiniteTarget,
#[error("`distance` must be a finite number.")]
NonFiniteDistance,
#[error("`distance` must be greater than zero.")]
NonPositiveDistance,
#[error("`direction` must be a non-zero vector.")]
ZeroDirection,
#[error("`up` must be a non-zero vector.")]
ZeroUp,
#[error("`direction` and `up` must not be parallel or nearly parallel.")]
DirectionParallelToUp,
}
fn is_finite_point(p: &Point3d) -> bool {
p.x.is_finite() && p.y.is_finite() && p.z.is_finite()
}
fn check_shared_fields(target: Option<&Point3d>, distance: Option<&TyF64>) -> Result<(), CameraViewError> {
if let Some(target) = target
&& !is_finite_point(target)
{
return Err(CameraViewError::NonFiniteTarget);
}
if let Some(distance) = distance {
if !distance.n.is_finite() {
return Err(CameraViewError::NonFiniteDistance);
}
if distance.n <= 0.0 {
return Err(CameraViewError::NonPositiveDistance);
}
}
Ok(())
}
fn norm(v: &Point3d) -> f64 {
f64::sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
}
fn cross(a: &Point3d, b: &Point3d) -> Point3d {
Point3d {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x,
units: None,
}
}
#[derive(Debug, Clone, Serialize, PartialEq, ts_rs::TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct CameraView {
look: CameraLook,
target: Option<Point3d>,
distance: Option<TyF64>,
projection: Projection,
#[serde(skip)]
meta: Vec<Metadata>,
}
impl CameraView {
pub(crate) fn oriented(
orientation: Orientation,
target: Option<Point3d>,
distance: Option<TyF64>,
projection: Option<Projection>,
meta: Vec<Metadata>,
) -> Result<Self, CameraViewError> {
check_shared_fields(target.as_ref(), distance.as_ref())?;
Ok(CameraView {
look: CameraLook::Oriented { orientation },
target,
distance,
projection: projection.unwrap_or(Projection::Orthographic),
meta,
})
}
pub(crate) fn directed(
direction: Point3d,
up: Option<Point3d>,
target: Option<Point3d>,
distance: Option<TyF64>,
projection: Option<Projection>,
meta: Vec<Metadata>,
) -> Result<Self, CameraViewError> {
let up = up.unwrap_or(Point3d {
x: 0.0,
y: 0.0,
z: 1.0,
units: None,
});
if !is_finite_point(&direction) {
return Err(CameraViewError::NonFiniteDirection);
}
if !is_finite_point(&up) {
return Err(CameraViewError::NonFiniteUp);
}
check_shared_fields(target.as_ref(), distance.as_ref())?;
if norm(&direction) == 0.0 {
return Err(CameraViewError::ZeroDirection);
}
if norm(&up) == 0.0 {
return Err(CameraViewError::ZeroUp);
}
let direction = direction.normalize();
let up = up.normalize();
if norm(&cross(&direction, &up)) < MIN_DIRECTION_UP_ANGLE_SIN {
return Err(CameraViewError::DirectionParallelToUp);
}
Ok(CameraView {
look: CameraLook::Directed { direction, up },
target,
distance,
projection: projection.unwrap_or(Projection::Orthographic),
meta,
})
}
pub fn look(&self) -> &CameraLook {
&self.look
}
pub fn target(&self) -> Option<&Point3d> {
self.target.as_ref()
}
pub fn distance(&self) -> Option<&TyF64> {
self.distance.as_ref()
}
pub fn projection(&self) -> Projection {
self.projection
}
pub fn meta(&self) -> &[Metadata] {
&self.meta
}
}
pub(crate) const RESERVED_DEFAULT_VIEW_NAME: &str = "Default View";
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub(crate) enum NamedViewError {
#[error("A view's name must not be empty.")]
EmptyName,
#[error("A view's name must not be only whitespace.")]
WhitespaceOnly,
#[error("A view's name must not start or end with whitespace. Use `string::trim()` to remove it.")]
SurroundingWhitespace,
#[error(
"`{RESERVED_DEFAULT_VIEW_NAME}` is reserved for the view of the scene generated on successful execution of the program. Please give this view a different name."
)]
ReservedName,
#[error(
"A view named `{0}` already exists. Every view needs its own name, and names are compared exactly, so `Front` and `front` are different names."
)]
DuplicateName(String),
}
#[derive(Debug, Clone, Serialize, PartialEq, ts_rs::TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct NamedViewValue {
artifact_id: ArtifactId,
name: String,
camera: CameraView,
baseline: Visibility,
except_ids: Vec<ArtifactId>,
#[serde(skip)]
meta: Vec<Metadata>,
}
impl NamedViewValue {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new<'a>(
artifact_id: ArtifactId,
name: String,
camera: CameraView,
baseline: Visibility,
except_ids: Option<Vec<ArtifactId>>,
declared_in: ModuleId,
existing_views: impl IntoIterator<Item = (ModuleId, &'a str)>,
meta: Vec<Metadata>,
) -> Result<Self, NamedViewError> {
if name.is_empty() {
return Err(NamedViewError::EmptyName);
}
if name.trim().is_empty() {
return Err(NamedViewError::WhitespaceOnly);
}
if name.trim() != name.as_str() {
return Err(NamedViewError::SurroundingWhitespace);
}
if name == RESERVED_DEFAULT_VIEW_NAME {
return Err(NamedViewError::ReservedName);
}
if existing_views
.into_iter()
.any(|(module, existing)| module == declared_in && existing == name.as_str())
{
return Err(NamedViewError::DuplicateName(name));
}
let except_ids = match except_ids {
Some(mut ids) => {
let mut seen = HashSet::with_capacity(ids.len());
ids.retain(|id| seen.insert(*id));
ids
}
None => Vec::new(),
};
Ok(NamedViewValue {
artifact_id,
name,
camera,
baseline,
except_ids,
meta,
})
}
pub fn artifact_id(&self) -> ArtifactId {
self.artifact_id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn camera(&self) -> &CameraView {
&self.camera
}
pub fn baseline(&self) -> Visibility {
self.baseline
}
pub fn except_ids(&self) -> &[ArtifactId] {
&self.except_ids
}
pub fn meta(&self) -> &[Metadata] {
&self.meta
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::docs::kcl_doc::DocData;
use crate::docs::kcl_doc::walk_prelude;
#[test]
fn reserved_default_view_name_matches_typescript() {
let ts_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../src/lang/std/kclNamedViews.ts");
let source =
std::fs::read_to_string(&ts_path).unwrap_or_else(|err| panic!("cannot read {}: {err}", ts_path.display()));
let expected = format!("export const KCL_DEFAULT_VIEW_NAME = '{RESERVED_DEFAULT_VIEW_NAME}'");
assert!(
source.contains(&expected),
"{} should declare `{expected}`. The reserved default view name is repeated on both \
sides of the wasm boundary and they have drifted apart; update the TypeScript literal \
to match this crate's RESERVED_DEFAULT_VIEW_NAME.",
ts_path.display()
);
}
fn kcl_variant_names(type_name: &str) -> Vec<String> {
let std_docs = walk_prelude();
let Some(DocData::Ty(ty)) = std_docs.find_by_name(type_name) else {
panic!("std::view::{type_name} is not a documented type");
};
ty.variants.iter().map(|v| v.name.clone()).collect()
}
#[test]
fn kcl_enum_declarations_match_rust_mirrors() {
let rust_orientation: Vec<&str> = Orientation::ALL.iter().map(|v| v.kcl_name()).collect();
assert_eq!(kcl_variant_names("Orientation"), rust_orientation);
let rust_visibility: Vec<&str> = Visibility::ALL.iter().map(|v| v.kcl_name()).collect();
assert_eq!(kcl_variant_names("Visibility"), rust_visibility);
let rust_projection: Vec<&str> = Projection::ALL.iter().map(|v| v.kcl_name()).collect();
assert_eq!(kcl_variant_names("Projection"), rust_projection);
}
#[test]
fn variant_name_mappings_are_inverses() {
for v in Orientation::ALL {
assert_eq!(Orientation::from_kcl_variant(v.kcl_name()), Some(v));
}
for v in Visibility::ALL {
assert_eq!(Visibility::from_kcl_variant(v.kcl_name()), Some(v));
}
for v in Projection::ALL {
assert_eq!(Projection::from_kcl_variant(v.kcl_name()), Some(v));
}
}
fn dir(x: f64, y: f64, z: f64) -> Point3d {
Point3d { x, y, z, units: None }
}
fn mm(n: f64) -> TyF64 {
use crate::execution::types::NumericTypeExt;
TyF64::new(n, crate::execution::types::NumericType::mm())
}
#[track_caller]
fn directed_rejects(direction: Point3d, up: Option<Point3d>, expected: CameraViewError) {
let actual = CameraView::directed(direction, up, None, None, None, vec![]).unwrap_err();
assert_eq!(actual, expected);
}
#[track_caller]
fn directed_accepts(direction: Point3d, up: Option<Point3d>) {
CameraView::directed(direction, up, None, None, None, vec![]).unwrap();
}
#[track_caller]
fn both_constructors_agree(target: Option<Point3d>, distance: Option<TyF64>, expected: Option<CameraViewError>) {
let from_oriented = CameraView::oriented(Orientation::Front, target, distance.clone(), None, vec![]).err();
let from_directed = CameraView::directed(dir(0.0, 1.0, 0.0), None, target, distance, None, vec![]).err();
assert_eq!(from_oriented, expected);
assert_eq!(from_directed, expected);
}
#[test]
fn directed_rejects_non_finite_vectors() {
use CameraViewError::NonFiniteDirection as BadDir;
use CameraViewError::NonFiniteUp as BadUp;
let ok_dir = dir(0.0, 1.0, 0.0);
directed_rejects(dir(f64::INFINITY, 0.0, 1.0), None, BadDir);
directed_rejects(dir(0.0, f64::INFINITY, 1.0), None, BadDir);
directed_rejects(dir(1.0, 0.0, f64::INFINITY), None, BadDir);
directed_rejects(dir(f64::NEG_INFINITY, 0.0, 1.0), None, BadDir);
directed_rejects(dir(0.0, f64::NEG_INFINITY, 1.0), None, BadDir);
directed_rejects(dir(1.0, 0.0, f64::NEG_INFINITY), None, BadDir);
directed_rejects(dir(f64::NAN, 0.0, 1.0), None, BadDir);
directed_rejects(dir(0.0, f64::NAN, 1.0), None, BadDir);
directed_rejects(dir(1.0, 0.0, f64::NAN), None, BadDir);
directed_rejects(ok_dir, Some(dir(f64::INFINITY, 0.0, 1.0)), BadUp);
directed_rejects(ok_dir, Some(dir(0.0, f64::INFINITY, 1.0)), BadUp);
directed_rejects(ok_dir, Some(dir(0.0, 0.0, f64::INFINITY)), BadUp);
directed_rejects(ok_dir, Some(dir(f64::NEG_INFINITY, 0.0, 1.0)), BadUp);
directed_rejects(ok_dir, Some(dir(0.0, f64::NEG_INFINITY, 1.0)), BadUp);
directed_rejects(ok_dir, Some(dir(0.0, 0.0, f64::NEG_INFINITY)), BadUp);
directed_rejects(ok_dir, Some(dir(f64::NAN, 0.0, 1.0)), BadUp);
directed_rejects(ok_dir, Some(dir(0.0, f64::NAN, 1.0)), BadUp);
directed_rejects(ok_dir, Some(dir(0.0, 0.0, f64::NAN)), BadUp);
}
#[test]
fn both_constructors_agree_on_shared_fields() {
use CameraViewError::NonFiniteDistance as BadDistance;
use CameraViewError::NonFiniteTarget as BadTarget;
use CameraViewError::NonPositiveDistance as NotPositive;
both_constructors_agree(Some(dir(f64::INFINITY, 0.0, 0.0)), None, Some(BadTarget));
both_constructors_agree(Some(dir(0.0, f64::NEG_INFINITY, 0.0)), None, Some(BadTarget));
both_constructors_agree(Some(dir(0.0, 0.0, f64::NAN)), None, Some(BadTarget));
both_constructors_agree(None, Some(mm(f64::INFINITY)), Some(BadDistance));
both_constructors_agree(None, Some(mm(f64::NEG_INFINITY)), Some(BadDistance));
both_constructors_agree(None, Some(mm(f64::NAN)), Some(BadDistance));
both_constructors_agree(None, Some(mm(0.0)), Some(NotPositive));
both_constructors_agree(None, Some(mm(-0.0)), Some(NotPositive));
both_constructors_agree(None, Some(mm(-50.0)), Some(NotPositive));
both_constructors_agree(None, Some(mm(f64::MIN_POSITIVE)), None);
both_constructors_agree(Some(dir(-1.0, -2.0, -3.0)), None, None);
both_constructors_agree(None, None, None);
both_constructors_agree(Some(dir(1.0, 2.0, 3.0)), Some(mm(500.0)), None);
}
#[test]
fn non_finite_is_reported_before_degeneracy() {
directed_rejects(dir(0.0, 0.0, f64::INFINITY), None, CameraViewError::NonFiniteDirection);
directed_rejects(
dir(0.0, 0.0, 0.0),
Some(dir(0.0, 0.0, f64::NAN)),
CameraViewError::NonFiniteUp,
);
both_constructors_agree(
None,
Some(mm(f64::NEG_INFINITY)),
Some(CameraViewError::NonFiniteDistance),
);
}
#[test]
fn error_messages_are_distinct() {
let all = [
CameraViewError::NonFiniteDirection,
CameraViewError::NonFiniteUp,
CameraViewError::NonFiniteTarget,
CameraViewError::NonFiniteDistance,
CameraViewError::NonPositiveDistance,
CameraViewError::ZeroDirection,
CameraViewError::ZeroUp,
CameraViewError::DirectionParallelToUp,
];
let mut messages: Vec<String> = all.iter().map(|e| e.to_string()).collect();
messages.sort_unstable();
let count = messages.len();
messages.dedup();
assert_eq!(messages.len(), count, "every variant needs its own message");
}
#[test]
fn directed_rejects_degenerate_vectors() {
use CameraViewError::DirectionParallelToUp as Parallel;
directed_rejects(dir(0.0, 0.0, 0.0), None, CameraViewError::ZeroDirection);
directed_rejects(dir(1.0, 0.0, 0.0), Some(dir(0.0, 0.0, 0.0)), CameraViewError::ZeroUp);
directed_rejects(dir(0.0, 0.0, 1.0), None, Parallel);
directed_rejects(dir(0.0, 0.0, -1.0), None, Parallel);
directed_rejects(dir(2.0, 2.0, 0.0), Some(dir(-5.0, -5.0, 0.0)), Parallel);
}
#[test]
fn directed_parallel_threshold_boundary() {
directed_rejects(
dir(MIN_DIRECTION_UP_ANGLE_SIN * 0.5, 0.0, 1.0),
None,
CameraViewError::DirectionParallelToUp,
);
directed_accepts(dir(MIN_DIRECTION_UP_ANGLE_SIN * 2.0, 0.0, 1.0), None);
}
fn a_camera() -> CameraView {
CameraView::oriented(Orientation::Front, None, None, None, vec![]).unwrap()
}
fn artifact_id(n: u128) -> ArtifactId {
ArtifactId::new(uuid::Uuid::from_u128(n))
}
fn named_view(
declared_in: ModuleId,
name: &str,
baseline: Visibility,
except_ids: Option<Vec<ArtifactId>>,
existing_views: &[(ModuleId, &str)],
) -> Result<NamedViewValue, NamedViewError> {
NamedViewValue::new(
artifact_id(1),
name.to_owned(),
a_camera(),
baseline,
except_ids,
declared_in,
existing_views.iter().copied(),
vec![],
)
}
fn view_named(name: &str) -> Result<NamedViewValue, NamedViewError> {
named_view(ModuleId::default(), name, Visibility::Show, None, &[])
}
#[test]
fn named_view_rejects_names_it_cannot_identify_a_view_by() {
assert_eq!(view_named("").unwrap_err(), NamedViewError::EmptyName);
assert_eq!(view_named(" Front").unwrap_err(), NamedViewError::SurroundingWhitespace);
assert_eq!(view_named("Front ").unwrap_err(), NamedViewError::SurroundingWhitespace);
assert_eq!(
view_named("\tFront\n").unwrap_err(),
NamedViewError::SurroundingWhitespace
);
assert_eq!(view_named(" ").unwrap_err(), NamedViewError::WhitespaceOnly);
assert_eq!(view_named("\t\n").unwrap_err(), NamedViewError::WhitespaceOnly);
assert_eq!(
view_named(RESERVED_DEFAULT_VIEW_NAME).unwrap_err(),
NamedViewError::ReservedName
);
view_named("Default View 2").unwrap();
view_named("default view").unwrap();
assert_eq!(view_named("Plate only (rev B)").unwrap().name(), "Plate only (rev B)");
}
#[test]
fn named_view_name_is_unique_per_declaring_module() {
let root = ModuleId::default();
let imported = ModuleId::from_usize(1);
let declare =
|name: &str, existing: &[(ModuleId, &str)]| named_view(root, name, Visibility::Show, None, existing);
assert_eq!(
declare("Front", &[(root, "Front")]).unwrap_err(),
NamedViewError::DuplicateName("Front".to_owned())
);
declare("Front", &[(imported, "Front")]).unwrap();
declare("front", &[(root, "Front")]).unwrap();
declare("Front view", &[(root, "Front")]).unwrap();
assert_eq!(
declare("Front", &[(root, "Back"), (imported, "Front"), (root, "Front")]).unwrap_err(),
NamedViewError::DuplicateName("Front".to_owned())
);
}
#[test]
fn named_view_stores_every_visibility_combination() {
let root = ModuleId::default();
let visibility = |baseline, except_ids| named_view(root, "Front", baseline, except_ids, &[]);
for baseline in Visibility::ALL {
let with_list = visibility(baseline, Some(vec![artifact_id(2)])).unwrap();
assert_eq!(with_list.baseline(), baseline);
assert_eq!(with_list.except_ids(), [artifact_id(2)]);
let without_list = visibility(baseline, None).unwrap();
assert_eq!(without_list.baseline(), baseline);
assert!(without_list.except_ids().is_empty());
}
}
#[test]
fn named_view_drops_repeated_except_ids() {
let (a, b, c) = (artifact_id(2), artifact_id(3), artifact_id(4));
let view = named_view(
ModuleId::default(),
"Front",
Visibility::Hide,
Some(vec![b, a, b, c, a, b]),
&[],
)
.unwrap();
assert_eq!(view.except_ids(), [b, a, c]);
}
#[test]
fn named_view_error_messages_are_distinct() {
let all = [
NamedViewError::EmptyName,
NamedViewError::WhitespaceOnly,
NamedViewError::SurroundingWhitespace,
NamedViewError::ReservedName,
NamedViewError::DuplicateName("Front".to_owned()),
];
let mut messages: Vec<String> = all.iter().map(|e| e.to_string()).collect();
messages.sort_unstable();
let count = messages.len();
messages.dedup();
assert_eq!(messages.len(), count, "every variant needs its own message");
assert!(
NamedViewError::ReservedName
.to_string()
.contains(RESERVED_DEFAULT_VIEW_NAME),
"the reserved-name message must name the reserved name"
);
assert!(
NamedViewError::DuplicateName("Front".to_owned())
.to_string()
.contains("`Front`"),
"the duplicate-name message must name the view that collided"
);
}
#[test]
fn directed_normalizes_and_defaults() {
let view = CameraView::directed(dir(0.0, -10.0, 0.0), None, None, None, None, vec![]).unwrap();
let CameraLook::Directed { direction, up } = view.look() else {
panic!("directed constructor must produce CameraLook::Directed");
};
assert_eq!(*direction, dir(0.0, -1.0, 0.0));
assert_eq!(*up, dir(0.0, 0.0, 1.0));
assert_eq!(view.projection(), Projection::Orthographic);
let view =
CameraView::directed(dir(0.0, -1.0, 0.0), Some(dir(0.0, 0.0, 7.0)), None, None, None, vec![]).unwrap();
let CameraLook::Directed { up, .. } = view.look() else {
panic!("directed constructor must produce CameraLook::Directed");
};
assert_eq!(*up, dir(0.0, 0.0, 1.0));
}
}