Export the current 3D view as 2D DXF with optional hidden lines (#1449)

Adds File > Export > Current View as DXF: the visible bodies projected from the current camera onto 2D LINE/LWPOLYLINE entities, with hidden edges omitted or dashed.

Co-authored-by: cronos <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-15 20:04:25 -04:00
1 parent 845f4913cf
commit c361630954
20 files changed
+798 -3

No files matched your search

+2
View File
@@ -74,6 +74,7 @@ mod sketch_modify;
mod sketch_project;
mod surface;
mod surface_analysis;
mod view_dxf;
mod view_navigation;
use serde::Serialize;
@@ -265,6 +266,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(libraries::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(view_dxf::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v
+101
View File
@@ -0,0 +1,101 @@
//! A drawing of the visible bodies from a supplied viewport camera. No screen pixels, grid,
//! selection highlights or tessellation diagonals are exported.
#[path = "view_dxf_projection.rs"]
mod projection;
use serde_json::{Value, json};
use solidcraft_io::{DxfEntity, DxfLayer, DxfLinetype, DxfShape};
use solidcraft_render::Camera;
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::{Result, Session};
const CMD: &str = "file.export_view_dxf";
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current View as DXF", export)
.icon("export")
.noundo()
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); path?: .dxf file (else returns dxf text). Visible placed bodies only; 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers, mm; perspective projected onto the camera target plane; no viewport crop or overlays.")];
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cam: Camera =
serde_json::from_value(p.get("camera").cloned().ok_or_else(|| bad(CMD, "camera is required"))?).map_err(|_| bad(CMD, "invalid camera"))?;
if !cam.is_valid()
|| !(0.0..std::f64::consts::PI).contains(&cam.fov)
|| cam.pitch.abs() > std::f64::consts::FRAC_PI_2
|| cam.yaw.abs() > 1e6
|| cam.roll.abs() > 1e6
{
return Err(bad(CMD, "invalid camera"));
}
let dashed = match p.get("hidden") {
None => false,
Some(Value::String(v)) if v == "omit" => false,
Some(Value::String(v)) if v == "dashed" => true,
_ => return Err(bad(CMD, "hidden must be omit or dashed")),
};
let tolerance = match p.get("tolerance") {
None => 0.01,
Some(v) => v.as_f64().filter(|t| (0.0001..=1.0).contains(t)).ok_or_else(|| bad(CMD, "tolerance must be 0.0001..1 mm"))?,
};
let path = match p.get("path") {
None => None,
Some(_) => Some(str_(p, "path").filter(|v| !v.trim().is_empty() && v.len() < 4096).ok_or_else(|| bad(CMD, "invalid path"))?),
};
if let Some(path) = path {
solidcraft_io::vfs::check(path).map_err(|e| crate::EngineError::Other(e.to_string()))?;
}
let world = s.world_state();
let mut meshes = Vec::new();
let mut count = 0usize;
for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) {
let mesh = body.body.reference_display_mesh_angular(tolerance, solidcraft_kernel::DISPLAY_ANGLE)?;
count = count.saturating_add(mesh.triangles.len()).saturating_add(mesh.edges.iter().map(Vec::len).sum::<usize>());
if count > 1_000_000 {
return Err(bad(CMD, "view too complex to export; hide bodies or increase tolerance"));
}
if mesh.triangles.is_empty() {
return Err(bad(CMD, format!("{} has no exportable surface", body.name)));
}
meshes.push(mesh);
}
if meshes.is_empty() {
return Err(bad(CMD, "no visible bodies to export"));
}
let lines = projection::drawing(&meshes, &cam).map_err(|e| bad(CMD, e))?;
let mut visible = 0;
let mut hidden = 0;
let mut entities = Vec::new();
for line in lines {
if line.hidden && !dashed {
continue;
}
let layer = if line.hidden {
hidden += 1;
"HIDDEN"
} else {
visible += 1;
"VISIBLE"
};
entities.push(DxfEntity { layer: layer.into(), shape: DxfShape::Line(line.a, line.b) });
}
let entities = projection::connect(entities);
let mut layers = vec![DxfLayer::new("VISIBLE", 7)];
if dashed {
layers.push(DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) });
}
let text = solidcraft_io::write_dxf_drawing(&layers, &entities);
let mut result = json!({"visible":visible,"hidden":hidden,"units":"mm","tolerance":tolerance,"projection":if cam.fov > 0.0 {"perspective"} else {"orthographic"}});
if let Some(path) = path {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(e.to_string()))?;
result["path"] = json!(path);
} else {
result["dxf"] = json!(text);
}
Ok(result)
}
#[cfg(test)]
#[path = "view_dxf_tests.rs"]
mod tests;
@@ -0,0 +1,342 @@
//! Vector hidden-line removal: intersect each projected edge with triangle half planes and
//! its depth plane, then union occluded parameter intervals. A BVH bounds the candidate work.
use solidcraft_geom::{Aabb3, Mesh, Vec2, Vec3};
use solidcraft_io::{DxfEntity, DxfShape};
use solidcraft_render::Camera;
use std::collections::BTreeMap;
/// Keep the hidden curves connected: restarting a dash on every short chord would make a
/// small circular hole look solid. Degree-two joins continue; junctions end the polyline.
pub(super) fn connect(entities: Vec<DxfEntity>) -> Vec<DxfEntity> {
let mut result = Vec::new();
let mut lines = Vec::new();
for entity in entities {
match entity {
DxfEntity { layer, shape: DxfShape::Line(a, b) } if layer == "HIDDEN" => lines.push((a, b)),
e => result.push(e),
}
}
let extent = lines.iter().flat_map(|(a, b)| [a, b]).fold(1.0_f64, |s, p| s.max(p.x.abs()).max(p.y.abs()));
let eps = extent * 1e-8;
let key = |p: Vec2| [(p.x / eps).round() as i64, (p.y / eps).round() as i64];
let mut nodes: BTreeMap<[i64; 2], Vec<usize>> = BTreeMap::new();
for (i, (a, b)) in lines.iter().enumerate() {
nodes.entry(key(*a)).or_default().push(i);
nodes.entry(key(*b)).or_default().push(i);
}
let mut used = vec![false; lines.len()];
for (i, (a, b)) in lines.iter().enumerate() {
if used.get(i).copied().unwrap_or(true) {
continue;
}
if let Some(v) = used.get_mut(i) {
*v = true;
}
let mut points = vec![*a, *b];
for _ in 0..2 {
while let Some(end) = points.last().copied() {
let Some(neighbours) = nodes.get(&key(end)).filter(|n| n.len() == 2) else { break };
let Some(next) = neighbours.iter().copied().find(|i| used.get(*i) == Some(&false)) else { break };
let Some((a, b)) = lines.get(next) else { break };
if let Some(v) = used.get_mut(next) {
*v = true;
}
points.push(if key(*a) == key(end) { *b } else { *a });
}
points.reverse();
}
result.push(DxfEntity { layer: "HIDDEN".into(), shape: DxfShape::Polyline { points } });
}
result
}
pub(super) struct Line {
pub a: Vec2,
pub b: Vec2,
pub hidden: bool,
}
fn xy(p: Vec3) -> Vec2 {
Vec2::new(p.x, p.y)
}
fn cross(a: Vec2, b: Vec2) -> f64 {
a.x * b.y - a.y * b.x
}
fn bounds(points: impl IntoIterator<Item = Vec3>) -> Aabb3 {
let mut b = Aabb3::EMPTY;
for p in points {
b.add(p);
}
b
}
fn overlap(a: &Aabb3, b: &Aabb3) -> bool {
a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y
}
struct Triangle {
p: [Vec3; 3],
bounds: Aabb3,
}
struct Tree {
bounds: Aabb3,
children: Option<(Box<Tree>, Box<Tree>)>,
triangles: Vec<Triangle>,
}
impl Tree {
fn new(mut triangles: Vec<Triangle>) -> Self {
let b = triangles.iter().fold(Aabb3::EMPTY, |b, t| b.union(&t.bounds));
if triangles.len() <= 12 {
return Self { bounds: b, children: None, triangles };
}
let x = b.size().x > b.size().y;
let middle = triangles.len() / 2;
triangles.select_nth_unstable_by(middle, |a, b| {
let c = |t: &Triangle| if x { t.bounds.center().x } else { t.bounds.center().y };
c(a).total_cmp(&c(b))
});
let right = triangles.split_off(middle);
Self { bounds: b, children: Some((Box::new(Self::new(triangles)), Box::new(Self::new(right)))), triangles: Vec::new() }
}
fn intervals(&self, a: Vec3, b: Vec3, bx: &Aabb3, eps: f64, out: &mut Vec<(f64, f64)>, budget: &mut usize) -> Result<(), &'static str> {
*budget = budget.checked_sub(1).ok_or("view too complex to export; hide some bodies")?;
if !overlap(&self.bounds, bx) || self.bounds.max.z <= a.z.min(b.z) + eps {
return Ok(());
}
if let Some((left, right)) = &self.children {
left.intervals(a, b, bx, eps, out, budget)?;
right.intervals(a, b, bx, eps, out, budget)?;
} else {
for t in &self.triangles {
if overlap(&t.bounds, bx)
&& let Some(span) = occluded(a, b, t.p, eps)
{
out.push(span);
}
}
}
Ok(())
}
}
/// Clip [lo,hi] by the linear inequality f(t) >= 0.
fn clip(lo: &mut f64, hi: &mut f64, f0: f64, f1: f64) -> bool {
if f0 < 0.0 && f1 < 0.0 {
return false;
}
if (f0 < 0.0) != (f1 < 0.0) {
let at = f0 / (f0 - f1);
if f0 < 0.0 {
*lo = lo.max(at);
} else {
*hi = hi.min(at);
}
}
*hi > *lo
}
fn occluded(a: Vec3, b: Vec3, t: [Vec3; 3], eps: f64) -> Option<(f64, f64)> {
let [p, q, r] = t;
let area = cross(xy(q - p), xy(r - p));
if area.abs() < 1e-20 {
return None;
}
let (mut lo, mut hi) = (0.0, 1.0);
for (u, v) in [(p, q), (q, r), (r, p)] {
let side = |w: Vec3| cross(xy(v - u), xy(w - u)) * area.signum();
if !clip(&mut lo, &mut hi, side(a), side(b)) {
return None;
}
}
let depth = |w: Vec3| {
let s = cross(xy(w - p), xy(r - p)) / area;
let u = cross(xy(q - p), xy(w - p)) / area;
p.z + s * (q.z - p.z) + u * (r.z - p.z) - w.z - eps
};
clip(&mut lo, &mut hi, depth(a), depth(b)).then_some((lo, hi))
}
fn key(p: Vec3, eps: f64) -> [i64; 3] {
[(p.x / eps).round() as i64, (p.y / eps).round() as i64, (p.z / eps).round() as i64]
}
/// Merge collinear projected intervals, giving visible strokes precedence. This also removes
/// a rear edge underneath only part of a visible edge, and rejoins subdivided silhouettes.
fn merge_lines(lines: Vec<Line>, eps: f64) -> Vec<Line> {
type Events = (Vec2, Vec2, Vec<(f64, i32, i32)>);
let mut groups: BTreeMap<[i64; 3], Events> = BTreeMap::new();
for line in lines {
let delta = line.b - line.a;
let mut dir = delta / delta.len();
if (dir.x.abs() > 1e-8 && dir.x < 0.0) || (dir.x.abs() <= 1e-8 && dir.y < 0.0) {
dir = -dir;
}
let offset = cross(dir, line.a);
let k = [(dir.x / 1e-8).round() as i64, (dir.y / 1e-8).round() as i64, (offset / eps).round() as i64];
let (origin, dir, events) = groups.entry(k).or_insert((line.a, dir, Vec::new()));
let (a, b) = ((line.a - *origin).dot(*dir), (line.b - *origin).dot(*dir));
let (v, h) = if line.hidden { (0, 1) } else { (1, 0) };
events.push((a.min(b), v, h));
events.push((a.max(b), -v, -h));
}
let mut result = Vec::new();
for (_, (origin, dir, mut events)) in groups {
events.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let (mut visible, mut hidden, mut last) = (0, 0, 0.0);
let mut path: Option<Line> = None;
for (at, v, h) in events {
if at - last > eps {
if visible > 0 || hidden > 0 {
let is_hidden = visible == 0;
let a = origin + dir * last;
let b = origin + dir * at;
if let Some(p) = path.as_mut().filter(|p| p.hidden == is_hidden && p.b.dist(a) <= eps) {
p.b = b;
} else {
if let Some(p) = path.take() {
result.push(p);
}
path = Some(Line { a, b, hidden: is_hidden });
}
} else if let Some(p) = path.take() {
result.push(p);
}
}
visible += v;
hidden += h;
last = at;
}
if let Some(p) = path {
result.push(p);
}
}
result
}
/// B-rep edges plus curved silhouettes. Triangle adjacency is welded by position because
/// different faces can have different vertex indices along their shared boundary.
fn edges(mesh: &Mesh, cam: &Camera, eps: f64) -> Vec<(Vec3, Vec3)> {
let mut result = Vec::new();
for (_, e) in mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)) {
result.extend(e.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))));
}
let mut adjacent: BTreeMap<_, (Vec3, Vec3, bool, bool, usize)> = BTreeMap::new();
for indices in &mesh.triangles {
let Some([a, b, c]) = mesh.tri(indices) else { continue };
let n = (b - a).cross(c - a);
let toward = if cam.fov > 0.0 { cam.eye() - (a + b + c) / 3.0 } else { cam.basis().2 };
let front = n.dot(toward) > 0.0;
for (a, b) in [(a, b), (b, c), (c, a)] {
let (ka, kb) = (key(a, eps), key(b, eps));
let k = if ka < kb { (ka, kb) } else { (kb, ka) };
let e = adjacent.entry(k).or_insert((a, b, false, false, 0));
e.2 |= front;
e.3 |= !front;
e.4 += 1;
}
}
for (_, (a, b, front, back, n)) in adjacent {
if (front && back) || n == 1 {
result.push((a, b));
}
}
result
}
pub(super) fn drawing(meshes: &[Mesh], cam: &Camera) -> Result<Vec<Line>, &'static str> {
let (right, up, back) = cam.basis();
let scale = meshes.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.bounds())).diagonal().max(1.0);
let eps = scale * 1e-8;
let project = |p: Vec3| -> Result<Vec3, &'static str> {
if !p.is_finite() {
return Err("model contains invalid coordinates");
}
let d = p - cam.target;
let z = d.dot(back);
let projected = if cam.fov > 0.0 {
let w = cam.distance - z;
if w <= cam.distance * 1e-6 {
return Err("model crosses the camera eye plane; zoom out before exporting");
}
Vec3::new(d.dot(right) * cam.distance / w, d.dot(up) * cam.distance / w, 1.0 / w)
} else {
Vec3::new(d.dot(right), d.dot(up), z)
};
if !projected.is_finite() || projected.len() > 1e12 {
return Err("projected coordinates exceed the export range");
}
Ok(projected)
};
let mut triangles = Vec::new();
let mut segments = Vec::new();
for m in meshes {
for t in &m.triangles {
if let Some([a, b, c]) = m.tri(t) {
let p = [project(a)?, project(b)?, project(c)?];
triangles.push(Triangle { p, bounds: bounds(p) });
}
}
for (a, b) in edges(m, cam, eps) {
let (a, b) = (project(a)?, project(b)?);
if xy(a).dist(xy(b)) > eps {
segments.push((a, b));
}
}
}
let tree = Tree::new(triangles);
let depth_eps = if cam.fov > 0.0 { 1e-8 / cam.distance } else { eps };
let mut result = Vec::new();
let mut budget = 20_000_000usize;
for (a, b) in segments {
let mut spans = Vec::new();
tree.intervals(a, b, &bounds([a, b]), depth_eps, &mut spans, &mut budget)?;
spans.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f64, f64)> = Vec::new();
for (lo, hi) in spans {
if let Some(last) = merged.last_mut().filter(|last| lo <= last.1 + 1e-9) {
last.1 = last.1.max(hi);
} else {
merged.push((lo, hi));
}
}
let mut emit = |lo: f64, hi: f64, hidden: bool| {
let (a, b) = (xy(a + (b - a) * lo), xy(a + (b - a) * hi));
if a.dist(b) <= eps {
return;
}
result.push(Line { a, b, hidden });
};
let mut cursor = 0.0;
for (lo, hi) in merged {
emit(cursor, lo, false);
emit(lo, hi, true);
cursor = hi;
}
emit(cursor, 1.0, false);
if result.len() > 1_000_000 {
return Err("view too complex to export; hide some bodies");
}
}
Ok(merge_lines(result, eps))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_visible_edge_overrides_only_the_overlapping_part_of_a_hidden_edge() {
let lines =
vec![Line { a: Vec2::ZERO, b: Vec2::new(10., 0.), hidden: true }, Line { a: Vec2::new(3., 0.), b: Vec2::new(7., 0.), hidden: false }];
let merged = merge_lines(lines, 1e-8);
assert_eq!(merged.len(), 3);
let length = |hidden| merged.iter().filter(|l| l.hidden == hidden).map(|l| l.a.dist(l.b)).sum::<f64>();
assert!((length(false) - 4.).abs() < 1e-8 && (length(true) - 6.).abs() < 1e-8);
}
#[test]
fn a_line_is_split_at_triangle_boundaries_and_depth_crossings() {
let t = [Vec3::new(0., 0., 1.), Vec3::new(2., 0., 1.), Vec3::new(0., 2., 1.)];
let (lo, hi) = occluded(Vec3::new(-1., 0.5, 0.), Vec3::new(3., 0.5, 0.), t, 0.).unwrap();
assert!((lo - 0.25).abs() < 1e-9 && (hi - 0.625).abs() < 1e-9);
assert!(occluded(Vec3::new(0., 0.5, 2.), Vec3::new(1., 0.5, 2.), t, 0.).is_none());
let (lo, hi) = occluded(Vec3::new(0., 0.5, 0.), Vec3::new(1., 0.5, 2.), t, 0.).unwrap();
assert!(lo.abs() < 1e-9 && (hi - 0.5).abs() < 1e-9);
}
}
+102
View File
@@ -0,0 +1,102 @@
use super::*;
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_render::StandardView;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
fn sample() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"body_name":"Plate"}));
// A blind hole on the bottom, completely hidden from above.
run(&mut s, "solid.cylinder", json!({"radius":3,"height":4,"base":[10,10,0],"operation":"cut","targets":["Plate"]}));
s
}
fn camera(view: StandardView) -> Camera {
let mut c = Camera { target: Vec3::new(20., 15., 5.), fov: 0., distance: 150., ..Default::default() };
c.set_view(view);
c
}
fn export(s: &mut Session, c: Camera, hidden: &str) -> Value {
run(s, CMD, json!({"camera":c,"hidden":hidden}))
}
#[test]
fn view_dxf_blind_back_hole_is_omitted_or_dashed_and_front_hole_is_visible() {
let mut s = sample();
let undo = s.undo.len();
let doc = s.doc.clone();
let top = export(&mut s, camera(StandardView::Top), "omit");
let dashed = export(&mut s, camera(StandardView::Top), "dashed");
assert!(top["visible"].as_u64().unwrap() > 0);
assert_eq!(top["hidden"], 0);
assert!(dashed["hidden"].as_u64().unwrap() > 12, "{dashed}");
assert_eq!(top["visible"], dashed["visible"]);
assert!(!top["dxf"].as_str().unwrap().contains("HIDDEN"));
assert!(dashed["dxf"].as_str().unwrap().contains("LWPOLYLINE"));
assert!(dashed["dxf"].as_str().unwrap().contains("128\n"));
let bottom = export(&mut s, camera(StandardView::Bottom), "omit");
assert!(bottom["visible"].as_u64().unwrap() > top["visible"].as_u64().unwrap());
assert_eq!(s.undo.len(), undo);
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
}
#[test]
fn view_dxf_respects_hidden_bodies_mm_and_camera_roll() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"body_name":"Plate"}));
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[100,0,0],"body_name":"Other"}));
run(&mut s, "browser.visibility", json!({"items":["Other"],"visible":false}));
let mut c = camera(StandardView::Top);
let drawing = export(&mut s, c, "omit");
let size = |v: &Value| {
let mut points = Vec::new();
for g in solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap() {
if let solidcraft_io::Geom2::Line(a, b) = g {
points.extend([a, b]);
}
}
let min = points.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
let max = points.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
max - min
};
let d = size(&drawing);
assert!((d.x - 40.).abs() < 0.001 && (d.y - 30.).abs() < 0.001, "{d:?}");
c.roll = std::f64::consts::FRAC_PI_2;
let d = size(&export(&mut s, c, "omit"));
assert!((d.x - 30.).abs() < 0.001 && (d.y - 40.).abs() < 0.001, "{d:?}");
run(&mut s, "browser.visibility", json!({"items":["Plate"],"visible":false}));
assert!(s.execute(CMD, &json!({"camera":c})).is_err());
}
#[test]
fn view_dxf_cylinders_have_side_silhouettes_and_perspective_is_supported() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20}));
let mut c = camera(StandardView::Front);
c.target = Vec3::new(0., 0., 10.);
let v = export(&mut s, c, "omit");
let lines = solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap();
assert!(lines.iter().filter(|g| matches!(g,solidcraft_io::Geom2::Line(a,b) if (a.y-b.y).abs()>19.9)).count() >= 2);
c.fov = 0.6;
let v = export(&mut s, c, "dashed");
assert_eq!(v["projection"], "perspective");
assert!(v["visible"].as_u64().unwrap() > 0);
}
#[test]
fn view_dxf_rejects_invalid_options_and_near_eye_geometry_without_panicking() {
let mut s = sample();
for extra in [json!({"hidden":"all"}), json!({"hidden":true}), json!({"tolerance":0}), json!({"tolerance":"x"}), json!({"path":false})] {
let mut p = extra;
p["camera"] = json!(camera(StandardView::Top));
assert!(s.execute(CMD, &p).is_err(), "{p}");
}
assert!(s.execute(CMD, &json!({})).is_err());
let mut c = camera(StandardView::Top);
c.fov = 0.5;
c.distance = 1.;
assert!(s.execute(CMD, &json!({"camera":c})).is_err());
c.fov = 4.;
assert!(s.execute(CMD, &json!({"camera":c})).is_err());
}