M12: headless orbit navigation for 3D models (#6406)

This commit is contained in:
Ken Mantle authored and GitHub committed 2026-11-09 08:48:58 +08:00
1 parent e6b6216fd9
commit 218c2bb21f
13 files changed
+1121 -11

No files matched your search

+8 -1
View File
@@ -288,7 +288,14 @@ Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1);
`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye
(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true.
The model is framed to fill the image and shaded by a headlight on white. U3D models are refused
The model is framed to fill the image and shaded by a headlight on white. `orbit`, instead of
`direction`, frames it as a viewer's orbit control would: `{view, up, front, turn, zoom,
zoom_at, pan}`, all optional. `view` is `iso` (default), `front`, `right`, `top` or `back`
about the `up` axis (default `+z`) and the `front` axis (perpendicular to `up`); `turn` is
`[yaw, pitch]` in degrees, pitch down; `zoom` is a positive factor, the result held within 0.05 to 50, at `zoom_at` `[right, up]`
in picture heights from the centre, where the point under it stays still; `pan` `[right, up]`
moves the target, in model radii, so the model moves the other way. Every view frames the model
at the same size, whatever its direction. U3D models are refused
with an error, and the annotation's own views, lighting and render modes are not applied yet.
## Viewer navigation history
+70 -3
View File
@@ -2,7 +2,9 @@
use pdfcraft_engine::model3d::Model3dKind;
use pdfcraft_prc::PrcFile;
use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render};
use pdfcraft_prc_render::nav::{Axis, NamedView, Orbit, Upright, reach};
use pdfcraft_prc_render::{Bounds, Camera, RenderOptions, WorldMesh, assemble, render};
use serde_json::Value;
use super::Automation;
use super::dispatch::Content;
@@ -22,7 +24,6 @@ impl Automation {
let page = self.page(a)?;
let width = side(a, "width")?;
let height = side(a, "height")?;
let direction = direction(a)?;
let perspective = a.opt_bool("perspective")?.unwrap_or(true);
let doc = self.doc(a)?;
let found = doc.model_3d_annotations(page).map_err(failed)?;
@@ -42,7 +43,14 @@ impl Automation {
let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?;
let meshes = assemble(&prc).map_err(failed)?;
let aspect = f64::from(width) / f64::from(height);
let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?;
let camera = match a.get("orbit") {
Some(_) if a.get("direction").is_some() => return Err(Args::wrong("orbit", "left out when direction is given")),
Some(moves) => orbit_camera(moves, &meshes, perspective, aspect)?,
None => {
let direction = direction(a)?;
Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?
}
};
let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?;
let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?;
Ok(vec![Content::Png { data, width: image.width, height: image.height }])
@@ -71,6 +79,65 @@ fn direction(a: &Args) -> Result<[f64; 3]> {
Ok(out)
}
/// The keys `orbit` takes.
const ORBIT_KEYS: [&str; 7] = ["view", "up", "front", "turn", "zoom", "zoom_at", "pan"];
/// The camera `orbit` names: a named view about an up axis, then a turn, a zoom and a pan, as a
/// viewer's orbit control would apply them.
fn orbit_camera(moves: &Value, meshes: &[WorldMesh], perspective: bool, aspect: f64) -> Result<Camera> {
let wrong = |what: &str| Args::wrong("orbit", what);
let moves = moves.as_object().ok_or_else(|| wrong("an object"))?;
if let Some(key) = moves.keys().find(|k| !ORBIT_KEYS.contains(&k.as_str())) {
return Err(wrong(&format!("an object without {key}; its keys are {}", ORBIT_KEYS.join(", "))));
}
let token = |key: &str| moves.get(key).map(|v| v.as_str().ok_or_else(|| wrong(&format!("{key} as a string"))));
let axis = |key: &str| token(key).map(|t| Axis::from_token(t?).ok_or_else(|| wrong(&format!("{key} as one of +x, +y, +z, -x, -y, -z"))));
let mut upright = Upright::default();
if let Some(up) = axis("up") {
upright = upright.with_up(up?);
}
if let Some(front) = axis("front") {
let front = front?;
if !upright.up().perpendicular(front) {
return Err(wrong("front perpendicular to up"));
}
upright = upright.with_front(front);
}
let view = match token("view") {
None => NamedView::Isometric,
Some(t) => NamedView::from_token(t?).ok_or_else(|| wrong("view as one of iso, front, right, top, back"))?,
};
let mut orbit = Orbit::named(view, perspective, upright);
let bounds = Bounds::of(meshes).ok_or_else(|| failed("the model has no vertex"))?;
let reach = reach(meshes, &bounds);
if let Some(v) = moves.get("turn") {
let [yaw, pitch] = pair(v).ok_or_else(|| wrong("turn as [yaw, pitch] in degrees"))?;
orbit.turn(yaw.to_radians(), pitch.to_radians());
}
if let Some(v) = moves.get("zoom") {
let factor = v.as_f64().filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| wrong("zoom as a positive number"))?;
let at = match moves.get("zoom_at") {
None => [0.0, 0.0],
Some(v) => pair(v).ok_or_else(|| wrong("zoom_at as [right, up] in picture heights"))?,
};
orbit.zoom_at(&bounds, reach, aspect, factor, at);
} else if moves.contains_key("zoom_at") {
return Err(wrong("an object with zoom when it has zoom_at"));
}
if let Some(v) = moves.get("pan") {
orbit.pan_by(pair(v).ok_or_else(|| wrong("pan as [right, up] in model radii"))?);
}
orbit.camera(&bounds, reach, aspect).map_err(failed)
}
/// Two finite numbers.
fn pair(v: &Value) -> Option<[f64; 2]> {
match v.as_array()?.as_slice() {
[a, b] => Some([a.as_f64().filter(|f| f.is_finite())?, b.as_f64().filter(|f| f.is_finite())?]),
_ => None,
}
}
/// Z is up, as CAD models are drawn, unless the view looks along it.
fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] {
if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }
+12 -3
View File
@@ -195,12 +195,21 @@ pub fn tools() -> Vec<ToolDef> {
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
}), &["doc", "page"])),
t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. orbit, in place of direction, frames the model as a viewer's orbit control does (the same size from every side) and applies, in order: view (iso, front, right, top or back; default iso) about up (+x, +y, +z, -x, -y or -z; default +z) with the Front view looking along front (an axis perpendicular to up; default +y, or -z when y is up), then turn [yaw, pitch] in degrees (pitch looks down and stops just short of the up axis), then zoom (a factor, the result held within 0.05 to 50) about the picture point zoom_at [right, up] in picture heights from the centre (default the centre), then pan [right, up] in model radii (it moves the target, so the model moves the other way). perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 },
"width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 },
"direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
"perspective": { "type": "boolean" }
"direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3, "description": "The view direction from the eye towards the model (default [-1, 1, -1]). Mutually exclusive with orbit." },
"perspective": { "type": "boolean" },
"orbit": { "type": "object", "description": "A viewer's orbit moves, applied in the order of these keys; all optional. Mutually exclusive with direction.", "properties": {
"view": { "type": "string", "enum": ["iso", "front", "right", "top", "back"], "description": "The view to start from (default iso)." },
"up": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The model axis that appears upward (default +z)." },
"front": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The axis the Front view looks along, perpendicular to up (default +y, or -z when y is up)." },
"turn": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "[yaw, pitch] in degrees, pitch looking down (default [0, 0])." },
"zoom": { "type": "number", "exclusiveMinimum": 0, "description": "A factor on the fitted view (default 1); the result is held within 0.05 to 50." },
"zoom_at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "The picture point the zoom keeps still, [right, up] in picture heights from the centre (default [0, 0]). Requires zoom." },
"pan": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "Moves the target [right, up] in model radii, so the model moves the other way (default [0, 0]); each held within 1000." }
}, "additionalProperties": false }
}), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
+168
View File
@@ -110,3 +110,171 @@ fn a_model_outside_the_root_is_never_opened_to_be_drawn() {
let doc = open(&mut a, "prc.pdf");
assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok());
}
#[test]
fn an_orbit_frames_the_model_from_a_named_view_and_its_moves() {
let (_dir, mut a) = setup("orbit");
let doc = open(&mut a, "prc.pdf");
let draw = |a: &mut Automation, orbit: Value| orbit_picture(a, doc, false, &orbit);
// The triangle lies in z = 0: the Top view looks down on its face, the Front view along its edge.
let top = draw(&mut a, json!({ "view": "top" }));
assert_eq!(rgb(&top, 32, 24), [190, 192, 200], "the top view sees the face head on");
let front = draw(&mut a, json!({ "view": "front" }));
assert!((0..48).all(|y| rgb(&front, 32, y) == [255, 255, 255]), "the front view sees only its edge");
let tipped = draw(&mut a, json!({ "view": "front", "turn": [0, 30] }));
assert_ne!(rgb(&tipped, 32, 24), [255, 255, 255], "tipped down from the front, the face is in the middle");
// With y up the Front view looks along -z, onto the face.
let y_up = draw(&mut a, json!({ "view": "front", "up": "+y" }));
assert_eq!(rgb(&y_up, 32, 24), [190, 192, 200]);
// Turning the front view down by 90° stops just short of the top view and sees the face.
let turned = draw(&mut a, json!({ "view": "front", "turn": [0, 90] }));
assert_ne!(rgb(&turned, 32, 24), [255, 255, 255]);
// A pan moves the target right, so the model moves left; three radii take it out of the picture.
let (left, right) = extent(&top, 24);
let (moved_left, moved_right) = extent(&draw(&mut a, json!({ "view": "top", "pan": [0.5, 0] })), 24);
assert!(moved_left < left && moved_right < right, "{left}..{right} moved to {moved_left}..{moved_right}");
let panned = draw(&mut a, json!({ "view": "top", "pan": [3, 0] }));
assert!((0..64).all(|x| rgb(&panned, x, 24) == [255, 255, 255]), "the model has left the picture");
// Zooming in about the centre keeps the face under it and leaves no background at the corner.
let zoomed = draw(&mut a, json!({ "view": "top", "zoom": 8 }));
assert_eq!(rgb(&zoomed, 32, 24), [190, 192, 200]);
assert_ne!(rgb(&zoomed, 0, 0), [255, 255, 255], "zoomed in, the face fills the corner too");
}
/// The first and last columns on row `y` that are not background.
fn extent(data: &[u8], y: usize) -> (usize, usize) {
let drawn: Vec<usize> = (0..64).filter(|&x| rgb(data, x, y) != [255, 255, 255]).collect();
(*drawn.first().expect("the model is on the row"), *drawn.last().expect("the model is on the row"))
}
/// The first and last rows on column `x` that are not background.
fn rows(data: &[u8], x: usize) -> (usize, usize) {
let drawn: Vec<usize> = (0..48).filter(|&y| rgb(data, x, y) != [255, 255, 255]).collect();
(*drawn.first().expect("the model is on the column"), *drawn.last().expect("the model is on the column"))
}
/// A 64 by 48 picture of the model through `orbit`.
fn orbit_picture(a: &mut Automation, doc: u64, perspective: bool, orbit: &Value) -> Vec<u8> {
let out =
a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "perspective": perspective, "orbit": orbit })).unwrap();
let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") };
data.clone()
}
#[test]
fn an_empty_orbit_frames_the_isometric_view_in_perspective() {
let (_dir, mut a) = setup("orbit-default");
let doc = open(&mut a, "prc.pdf");
let default = orbit_picture(&mut a, doc, false, &json!({}));
assert_ne!(rgb(&default, 32, 24), [255, 255, 255], "the model is centred");
let (left, right) = extent(&default, 24);
let (high, low) = rows(&default, 32);
assert!(left > 0 && right < 63 && high > 0 && low < 47, "inside the picture: {left}..{right}, {high}..{low}");
assert!(right - left > 16, "and fills it: {left}..{right}");
assert_eq!(default, orbit_picture(&mut a, doc, false, &json!({ "view": "iso", "up": "+z", "front": "+y" })), "the defaults");
let unset = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "orbit": {} })).unwrap();
let Content::Png { data: unset, .. } = &unset[0] else { panic!("expected a PNG") };
assert_eq!(*unset, orbit_picture(&mut a, doc, true, &json!({})), "perspective is the default");
assert_ne!(*unset, default, "and shows");
}
#[test]
fn zoom_at_keeps_the_point_under_it_still() {
let (_dir, mut a) = setup("orbit-zoom-at");
let doc = open(&mut a, "prc.pdf");
let (left, right) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2 })), 24);
// A point 0.2 picture heights off centre stays there, so what was at the centre moves
// 0.2 x (2 - 1) x 48 = 9.6 pixels the other way.
for (at, sign) in [(0.2, -1.0), (-0.2, 1.0)] {
let (l, r) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2, "zoom_at": [at, 0] })), 24);
for (was, now) in [(left, l), (right, r)] {
let moved = now as f64 - was as f64;
assert!((moved - sign * 9.6).abs() <= 1.5, "at {at}: {was} moved to {now}");
}
}
}
#[test]
fn the_zoom_is_held_within_its_range() {
let (_dir, mut a) = setup("orbit-zoom-range");
let doc = open(&mut a, "prc.pdf");
let far = orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.05 }));
assert_eq!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.001 })), "the far end");
assert_ne!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.2 })));
// Panned to just inside the near edge, so a nearer view would move the edge in the picture.
let close = |z: f64| json!({ "view": "top", "zoom": z, "pan": [0, -0.7] });
let near = orbit_picture(&mut a, doc, false, &close(50.0));
assert_eq!(near, orbit_picture(&mut a, doc, false, &close(1000.0)), "the near end");
assert_ne!(near, orbit_picture(&mut a, doc, false, &close(25.0)));
}
#[test]
fn an_orbit_draws_in_perspective_at_the_same_scale() {
let (_dir, mut a) = setup("orbit-perspective");
let doc = open(&mut a, "prc.pdf");
let tipped = json!({ "view": "front", "turn": [0, 30] });
let deep = orbit_picture(&mut a, doc, true, &tipped);
assert_ne!(rgb(&deep, 32, 24), [255, 255, 255]);
assert_ne!(deep, orbit_picture(&mut a, doc, false, &tipped), "foreshortened");
let top = json!({ "view": "top" });
let (left, right) = extent(&orbit_picture(&mut a, doc, true, &top), 24);
let (flat_left, flat_right) = extent(&orbit_picture(&mut a, doc, false, &top), 24);
assert!(left.abs_diff(flat_left) <= 2 && right.abs_diff(flat_right) <= 2, "{left}..{right} against {flat_left}..{flat_right}");
}
#[test]
fn an_orbit_front_yaw_and_upward_pan_each_move_the_picture() {
let (_dir, mut a) = setup("orbit-moves");
let doc = open(&mut a, "prc.pdf");
let mut draw = |orbit: Value| orbit_picture(&mut a, doc, false, &orbit);
// Yawing the Front view by 90 degrees is the Right view, which looks along -x; +x is its opposite.
let right_view = draw(json!({ "view": "right", "turn": [0, 30] }));
assert_eq!(right_view, draw(json!({ "view": "front", "turn": [90, 30] })), "yaw");
assert_eq!(right_view, draw(json!({ "view": "front", "front": "-x", "turn": [0, 30] })), "front -x");
assert_ne!(right_view, draw(json!({ "view": "front", "turn": [0, 30] })));
assert_eq!(draw(json!({ "view": "right", "turn": [180, 30] })), draw(json!({ "view": "front", "front": "+x", "turn": [0, 30] })), "front +x");
// From above, the triangle's apex is up the picture; a quarter yaw lays it along the row,
// twice as wide there.
let top = draw(json!({ "view": "top" }));
let (left, right) = extent(&top, 24);
let (yaw_left, yaw_right) = extent(&draw(json!({ "view": "top", "turn": [90, 0] })), 24);
assert!(yaw_right - yaw_left > (right - left) * 3 / 2, "{left}..{right} yawed to {yaw_left}..{yaw_right}");
// A pan up moves the target up, so the model moves down.
let (high, low) = rows(&top, 32);
let (moved_high, moved_low) = rows(&draw(json!({ "view": "top", "pan": [0, 0.25] })), 32);
assert!(moved_high > high && moved_low >= low, "{high}..{low} moved to {moved_high}..{moved_low}");
}
#[test]
fn a_wrong_orbit_is_a_clear_error() {
let (_dir, mut a) = setup("orbit-bad");
let doc = open(&mut a, "prc.pdf");
for (bad, reason) in [
(json!({ "orbit": { "view": "side" } }), "view as one of"),
(json!({ "orbit": { "view": 1 } }), "view as a string"),
(json!({ "orbit": { "up": "z" } }), "up as one of"),
(json!({ "orbit": { "up": 1 } }), "up as a string"),
(json!({ "orbit": { "front": "y" } }), "front as one of"),
(json!({ "orbit": { "up": "+z", "front": "-z" } }), "front perpendicular to up"),
(json!({ "orbit": { "turn": [1] } }), "turn as [yaw, pitch]"),
(json!({ "orbit": { "turn": [1, 2, 3] } }), "turn as [yaw, pitch]"),
(json!({ "orbit": { "turn": 1 } }), "turn as [yaw, pitch]"),
(json!({ "orbit": { "zoom": 0 } }), "zoom as a positive number"),
(json!({ "orbit": { "zoom": -2 } }), "zoom as a positive number"),
(json!({ "orbit": { "zoom": "a" } }), "zoom as a positive number"),
(json!({ "orbit": { "zoom_at": [0.1, 0] } }), "with zoom when it has zoom_at"),
(json!({ "orbit": { "zoom": 2, "zoom_at": [1] } }), "zoom_at as [right, up]"),
(json!({ "orbit": { "zoom": 2, "zoom_at": [1, 2, 3] } }), "zoom_at as [right, up]"),
(json!({ "orbit": { "pan": [1, "a"] } }), "pan as [right, up]"),
(json!({ "orbit": { "pan": [1] } }), "pan as [right, up]"),
(json!({ "orbit": { "pan": [1, 2, 3] } }), "pan as [right, up]"),
(json!({ "orbit": { "spin": 1 } }), "without spin"),
(json!({ "orbit": 1 }), "an object"),
(json!({ "orbit": {}, "direction": [0, 0, -1] }), "left out when direction is given"),
] {
let mut args = json!({ "doc": doc, "page": 1, "width": 16, "height": 16 });
args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
let err = a.call("model_render", &args).unwrap_err().to_string();
assert!(err.contains("orbit must be") && err.contains(reason), "{bad}: {err}");
}
}
+8
View File
@@ -15,8 +15,16 @@ assemble(&PrcFile) -> Result<Vec<WorldMesh>, RenderError> // every placement's
place(&TriangleMesh, &Matrix, colour, triangle_colours) -> WorldMesh
Camera::fit(&Bounds, direction, up, perspective, aspect) / Camera::fit_meshes(&[WorldMesh], ...)
render(&[WorldMesh], &Camera, &RenderOptions) -> Result<Image, RenderError>
nav::Orbit::named(NamedView, perspective, Upright) / nav::Orbit::looking(direction, up, perspective)
orbit.turn(yaw, pitch) / pan_by([right, up]) / zoom_at(&Bounds, reach, aspect, factor, [right, up]) / anchor_at(&Camera, point) / centre_on / refit
orbit.camera(&Bounds, nav::reach(&[WorldMesh], &Bounds), aspect) -> Result<Camera, RenderError>
```
- `nav` is a viewer's orbit control without the GUI: the fitted view holds the sphere round the model's centre that
holds every drawn vertex, so turning or reshaping the picture never changes the model's size. Once `anchor_at` picks a
model point the view turns about it, and the point keeps its place on the picture. Pitch stays 0.01 rad short of
the up axis, zoom within 0.05–50 of the fitted view, pan within 1,000 model radii; non-finite input is ignored.
- With no product tree, each tessellated mesh is drawn where it lies, in the default colour.
- A triangle's own colour wins over its mesh's, which wins over `RenderOptions::colour`.
- Opaque triangles are drawn first, then translucent ones blended over them; alpha 0 is not drawn.
+3 -1
View File
@@ -2,7 +2,8 @@
//!
//! Layer L3: depends only on `pdfcraft-prc`. [`assemble`] turns a parsed [`pdfcraft_prc::PrcFile`]
//! into world-space meshes; [`render`] draws them from a [`Camera`] with one depth-tested sample per
//! pixel centre and a light at the camera. Single-threaded and GUI-free, so it runs unchanged on wasm32.
//! pixel centre and a light at the camera; [`nav`] turns a viewer's input into that camera.
//! Single-threaded and GUI-free, so it runs unchanged on wasm32.
//! The image size is capped by [`MAX_RENDER_PIXELS`], the drawing work by [`MAX_RENDER_WORK`] and the
//! assembled model by [`MAX_ASSEMBLED_TRIANGLES`] and [`MAX_ASSEMBLED_VERTICES`]; bad input is an
//! error, never a panic.
@@ -10,6 +11,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod assemble;
pub mod nav;
mod raster;
mod render;
mod vec3;
+150
View File
@@ -0,0 +1,150 @@
//! Which of the model's axes points up, and which way the Front view looks.
/// One signed model axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis {
/// +x.
PosX,
/// +y.
PosY,
/// +z.
PosZ,
/// -x.
NegX,
/// -y.
NegY,
/// -z.
NegZ,
}
impl Axis {
/// Every axis, in [`Axis::token`] order `+x +y +z -x -y -z`.
pub const ALL: [Axis; 6] = [Axis::PosX, Axis::PosY, Axis::PosZ, Axis::NegX, Axis::NegY, Axis::NegZ];
/// The unit vector it names.
#[must_use]
pub fn vector(self) -> [f64; 3] {
match self {
Axis::PosX => [1.0, 0.0, 0.0],
Axis::PosY => [0.0, 1.0, 0.0],
Axis::PosZ => [0.0, 0.0, 1.0],
Axis::NegX => [-1.0, 0.0, 0.0],
Axis::NegY => [0.0, -1.0, 0.0],
Axis::NegZ => [0.0, 0.0, -1.0],
}
}
/// Its token: `+x` … `-z`.
#[must_use]
pub fn token(self) -> &'static str {
match self {
Axis::PosX => "+x",
Axis::PosY => "+y",
Axis::PosZ => "+z",
Axis::NegX => "-x",
Axis::NegY => "-y",
Axis::NegZ => "-z",
}
}
/// The axis whose [`Axis::token`] is `token`.
#[must_use]
pub fn from_token(token: &str) -> Option<Axis> {
Axis::ALL.into_iter().find(|a| a.token() == token)
}
/// Whether `other` lies along a different line through the origin.
#[must_use]
pub fn perpendicular(self, other: Axis) -> bool {
self.line() != other.line()
}
/// 0, 1 or 2 for the x, y or z line.
fn line(self) -> u8 {
match self {
Axis::PosX | Axis::NegX => 0,
Axis::PosY | Axis::NegY => 1,
Axis::PosZ | Axis::NegZ => 2,
}
}
/// The Front view's look when this axis is up: along +y for z or x up (the CAD convention),
/// along -z for y up (the convention of y-up exporters).
fn default_front(self) -> Axis {
match self {
Axis::PosY | Axis::NegY => Axis::NegZ,
_ => Axis::PosY,
}
}
}
/// The up axis and the Front view's look direction; always perpendicular.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Upright {
up: Axis,
front: Axis,
}
impl Default for Upright {
/// z up, Front looking along +y.
fn default() -> Self {
Upright { up: Axis::PosZ, front: Axis::PosY }
}
}
impl Upright {
/// The up axis.
#[must_use]
pub fn up(self) -> Axis {
self.up
}
/// The Front view's look direction.
#[must_use]
pub fn front(self) -> Axis {
self.front
}
/// `up` as the up axis, keeping the Front look when it is still perpendicular, else that
/// axis's conventional one.
#[must_use]
pub fn with_up(self, up: Axis) -> Upright {
let front = if up.perpendicular(self.front) { self.front } else { up.default_front() };
Upright { up, front }
}
/// `front` as the Front view's look, or unchanged when it lies along up.
#[must_use]
pub fn with_front(self, front: Axis) -> Upright {
if self.up.perpendicular(front) { Upright { front, ..self } } else { self }
}
/// The Front looks a choice can offer: the four perpendicular to up.
pub fn fronts(self) -> impl Iterator<Item = Axis> {
Axis::ALL.into_iter().filter(move |a| self.up.perpendicular(*a))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_new_up_keeps_a_front_that_still_fits_and_replaces_one_that_does_not() {
let start = Upright::default();
assert_eq!(start.with_up(Axis::PosX).front(), Axis::PosY);
assert_eq!(start.with_up(Axis::PosY).front(), Axis::NegZ);
assert_eq!(start.with_front(Axis::NegZ), start);
assert_eq!(start.fronts().count(), 4);
}
#[test]
fn every_axis_is_a_signed_unit_vector_with_a_token_that_reads_back() {
assert_eq!(Axis::NegY.vector(), [0.0, -1.0, 0.0]);
assert_eq!(Axis::PosZ.vector(), [0.0, 0.0, 1.0]);
for a in Axis::ALL {
assert_eq!(Axis::from_token(a.token()), Some(a));
}
assert_eq!(Axis::from_token("z"), None);
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Camera navigation for a model view, without a GUI: an [`Orbit`] turned, panned and zoomed by a
//! viewer's input (or by `model_render`'s moves) gives the [`crate::Camera`] to draw from, and an
//! [`Upright`] says which model axis points up and which way the Front view looks.
mod axes;
mod orbit;
pub use axes::{Axis, Upright};
pub use orbit::{NamedView, Orbit, PITCH_LIMIT, ZOOM_RANGE, reach};
+398
View File
@@ -0,0 +1,398 @@
//! Orbiting: a turn about an up axis, a zoom and a pan, each relative to the view that fits the
//! model or, once [`Orbit::anchor_at`] picks a model point, relative to that point.
//!
//! The fitted view frames the sphere round the model's centre that holds every vertex
//! ([`reach`]), so turning the model or reshaping the picture never changes its size.
use std::f64::consts::{FRAC_PI_2, TAU};
use super::axes::Upright;
use crate::WorldMesh;
use crate::render::{Bounds, Camera, Projection, RenderError, vertices};
use crate::vec3::{Vec3, add, cross, dot, length, normalize, scale, sub};
/// How far the view may look up or down, radians: kept off ±90° so it never looks along the up axis.
pub const PITCH_LIMIT: f64 = FRAC_PI_2 - 0.01;
/// The zoom range, as a multiple of the fitted view.
pub const ZOOM_RANGE: (f64, f64) = (0.05, 50.0);
/// How far the pan may move the target, in model radii.
const PAN_LIMIT: f64 = 1e3;
/// The perspective field of view, degrees, vertically.
const FOV_Y: f64 = 30.0;
/// The clear space round the fitted sphere, as a multiple of its radius.
const MARGIN: f64 = 1.05;
/// A named view, turned about an [`Upright`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NamedView {
/// From the front, right and above, as an isometric drawing looks.
Isometric,
/// Along the Front direction.
Front,
/// From the right, a quarter turn from Front.
Right,
/// Looking down the up axis, just short of it.
Top,
/// Against the Front direction.
Back,
}
impl NamedView {
/// Every named view, in [`NamedView::token`] order.
pub const ALL: [NamedView; 5] = [NamedView::Isometric, NamedView::Front, NamedView::Right, NamedView::Top, NamedView::Back];
/// Its token: `iso`, `front`, `right`, `top` or `back`.
#[must_use]
pub fn token(self) -> &'static str {
match self {
NamedView::Isometric => "iso",
NamedView::Front => "front",
NamedView::Right => "right",
NamedView::Top => "top",
NamedView::Back => "back",
}
}
/// The view whose [`NamedView::token`] is `token`.
#[must_use]
pub fn from_token(token: &str) -> Option<NamedView> {
NamedView::ALL.into_iter().find(|v| v.token() == token)
}
/// (yaw, pitch) in degrees: yaw 0 looks along Front, positive pitch looks down.
fn angles(self) -> (f64, f64) {
match self {
NamedView::Isometric => (45.0, 35.264),
NamedView::Front => (0.0, 0.0),
NamedView::Right => (90.0, 0.0),
NamedView::Top => (0.0, 89.0),
NamedView::Back => (180.0, 0.0),
}
}
}
/// How far from parallel two unit vectors must be to give a direction across them.
const PARALLEL: f64 = 1e-9;
/// The model point an orbit turns about: its depth along the look, and its offset right and up
/// from the look's axis in model units. An orthographic view shows the height a perspective one
/// would at that depth, so switching projection keeps the scale.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Anchor {
point: Vec3,
distance: f64,
offset: [f64; 2],
}
impl Anchor {
/// The point on the look's axis level with the anchor, for picture axes `right` and `up`.
fn base(&self, right: Vec3, up: Vec3) -> Vec3 {
let [x, y] = self.offset;
sub(sub(self.point, scale(right, x)), scale(up, y))
}
}
/// Yaw 0, pitch 0 looks along `front`, and `up` appears upward; both unit and perpendicular.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Axes {
front: Vec3,
up: Vec3,
}
impl Axes {
/// The unit direction `yaw` round and `pitch` down from `front`.
fn look(&self, yaw: f64, pitch: f64) -> Vec3 {
let (sy, cy) = yaw.sin_cos();
let (sp, cp) = pitch.sin_cos();
let right = cross(self.front, self.up);
add(add(scale(right, -sy * cp), scale(self.front, cy * cp)), scale(self.up, -sp))
}
/// The yaw and pitch [`Axes::look`] turns into `direction`, the pitch held inside
/// [`PITCH_LIMIT`]; `None` for a zero or non-finite direction.
fn angles(&self, direction: Vec3) -> Option<(f64, f64)> {
let d = normalize(direction)?;
let pitch = (-dot(d, self.up)).clamp(-1.0, 1.0).asin();
let yaw = (-dot(d, cross(self.front, self.up))).atan2(dot(d, self.front));
Some((yaw, pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT)))
}
/// The picture's right and up when looking along `dir`; `None` when `dir` lies along up.
fn picture(&self, dir: Vec3) -> Option<(Vec3, Vec3)> {
let right = normalize(cross(dir, self.up))?;
Some((right, cross(right, dir)))
}
}
/// Where the camera is, relative to the fitted view or to the point turned about.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Orbit {
yaw: f64,
pitch: f64,
zoom: f64,
pan: [f64; 2],
perspective: bool,
axes: Axes,
anchor: Option<Anchor>,
}
impl Orbit {
/// `view`, turned about `upright`.
#[must_use]
pub fn named(view: NamedView, perspective: bool, upright: Upright) -> Orbit {
let (yaw, pitch) = view.angles();
let axes = Axes { front: upright.front().vector(), up: upright.up().vector() };
Orbit { yaw: yaw.to_radians(), pitch: pitch.to_radians(), zoom: 1.0, pan: [0.0; 2], perspective, axes, anchor: None }
}
/// Looking along `direction`, turning about `up` with the part along `direction` removed; `None`
/// when either is zero or not finite, or they are parallel.
#[must_use]
pub fn looking(direction: [f64; 3], up: [f64; 3], perspective: bool) -> Option<Orbit> {
let front = normalize(direction)?;
let up = normalize(up)?;
// Parallel unit vectors leave a rounding residue, not a direction.
let across = sub(up, scale(front, dot(up, front)));
if length(across) < PARALLEL {
return None;
}
let up = normalize(across)?;
Some(Orbit { yaw: 0.0, pitch: 0.0, zoom: 1.0, pan: [0.0; 2], perspective, axes: Axes { front, up }, anchor: None })
}
/// Radians round the up axis, in `[0, 2π)` once turned.
#[must_use]
pub fn yaw(&self) -> f64 {
self.yaw
}
/// Radians above the horizon, looking down when positive; within [`PITCH_LIMIT`].
#[must_use]
pub fn pitch(&self) -> f64 {
self.pitch
}
/// The zoom, as a multiple of the fitted view; within [`ZOOM_RANGE`].
#[must_use]
pub fn zoom(&self) -> f64 {
self.zoom
}
/// The target's shift along the picture's right and up, in model radii.
#[must_use]
pub fn pan(&self) -> [f64; 2] {
self.pan
}
/// Whether the projection is perspective rather than orthographic.
#[must_use]
pub fn perspective(&self) -> bool {
self.perspective
}
/// The point turned about, once [`Orbit::anchor_at`] has picked one.
#[must_use]
pub fn anchor(&self) -> Option<[f64; 3]> {
self.anchor.map(|a| a.point)
}
/// Switch projection; an anchored view keeps its scale.
pub fn set_perspective(&mut self, perspective: bool) {
self.perspective = perspective;
}
/// The unit direction the camera looks along.
#[must_use]
pub fn direction(&self) -> [f64; 3] {
self.axes.look(self.yaw, self.pitch)
}
/// Turn by `yaw` round the up axis and `pitch` down, radians; non-finite turns are ignored.
pub fn turn(&mut self, yaw: f64, pitch: f64) {
if yaw.is_finite() && pitch.is_finite() {
self.yaw = (self.yaw + yaw).rem_euclid(TAU);
self.pitch = (self.pitch + pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
}
/// Move the target by `[right, up]` model radii; non-finite moves are ignored.
pub fn pan_by(&mut self, [right, up]: [f64; 2]) {
if right.is_finite() && up.is_finite() {
self.set_pan([self.pan[0] + right, self.pan[1] + up]);
}
}
/// Look along `direction`, fitted again; a direction along the up axis stops just short of it,
/// and a zero or non-finite one changes nothing.
pub fn look_along(&mut self, direction: [f64; 3]) {
if let Some((yaw, pitch)) = self.axes.angles(direction) {
(self.yaw, self.pitch) = (yaw, pitch);
self.refit();
}
}
/// Back to the view that fits the whole model, keeping the direction: no zoom, pan or anchor.
pub fn refit(&mut self) {
(self.zoom, self.pan, self.anchor) = (1.0, [0.0; 2], None);
}
/// Turn about `point` from now on without moving the picture; `camera` is this orbit's
/// camera, and `point` stays where it shows on it. Whether the point was taken: not when it
/// is not finite, or lies at or behind the eye of a perspective view.
pub fn anchor_at(&mut self, camera: &Camera, point: [f64; 3]) -> bool {
let dir = self.direction();
let Some((right, image_up)) = self.axes.picture(dir) else { return false };
let v = sub(point, camera.eye);
let offset = [dot(v, right), dot(v, image_up)];
let distance = match camera.projection {
Projection::Orthographic { height } => height / 2.0 / half_fov().tan(),
Projection::Perspective { .. } => dot(v, dir),
};
let taken = distance.is_finite() && distance > 0.0 && offset.iter().all(|c| c.is_finite());
if taken {
self.anchor = Some(Anchor { point, distance, offset });
(self.zoom, self.pan) = (1.0, [0.0; 2]);
}
taken
}
/// Pan so `point` sits at the picture's centre, keeping the turn and the zoom.
pub fn centre_on(&mut self, bounds: &Bounds, point: [f64; 3]) {
let Some((right, image_up)) = self.axes.picture(self.direction()) else { return };
let centre = self.anchor.map_or_else(|| bounds.centre(), |a| a.base(right, image_up));
let off = sub(point, centre);
let r = radius(bounds);
let pan = [dot(off, right) / r, dot(off, image_up) / r];
if pan.iter().all(|c| c.is_finite()) {
self.set_pan(pan);
}
}
/// Multiply the zoom by `factor` (held within [`ZOOM_RANGE`]), keeping the model point under
/// the pointer where it is on the picture. `offset` is the pointer's position from the
/// picture's centre, right and up, in picture heights; `bounds`, `reach` and `aspect` are as
/// [`Orbit::camera`] takes them. Exact on the plane through the target facing the camera, the
/// plane the pan moves in. A factor that is not positive and finite changes nothing.
pub fn zoom_at(&mut self, bounds: &Bounds, reach: f64, aspect: f64, factor: f64, offset: [f64; 2]) {
if !(factor.is_finite() && factor > 0.0 && offset.iter().all(|c| c.is_finite())) {
return;
}
let before = self.zoom;
let after = (before * factor).clamp(ZOOM_RANGE.0, ZOOM_RANGE.1);
let fitted = Orbit { zoom: 1.0, pan: [0.0; 2], ..*self };
// What one picture height spans at zoom 1, in model radii.
let span = fitted.camera(bounds, reach, aspect).map_or(2.0, |c| visible_height(&c)) / radius(bounds);
let shift = span * (1.0 / before - 1.0 / after);
let [x, y] = offset;
self.set_pan([self.pan[0] + x * shift, self.pan[1] + y * shift]);
self.zoom = after;
}
/// The camera for a model in `bounds` whose every vertex lies within `reach` of its centre
/// ([`reach`]; [`Bounds::radius`] stands in when it is not positive), for a picture of `aspect`
/// (width / height; 1 stands in when it is not positive).
///
/// # Errors
/// [`RenderError::Camera`] when the view does not form: a reach or a position out of the
/// finite range.
pub fn camera(&self, bounds: &Bounds, reach: f64, aspect: f64) -> Result<Camera, RenderError> {
let dir = self.direction();
let (right, image_up) = self.axes.picture(dir).ok_or(RenderError::Camera("the view looks along its up axis"))?;
let up = self.axes.up;
let mut camera = match self.anchor {
Some(a) => about(a, dir, (right, image_up), up, self.perspective),
None => {
let reach = match reach {
r if !r.is_finite() => return Err(RenderError::Camera("the model's reach is out of range")),
r if r > 0.0 => r,
_ => radius(bounds),
};
fitted(bounds.centre(), reach, dir, up, self.perspective, aspect)
}
};
let [x, y] = self.pan;
let target = add(camera.target, scale(add(scale(right, x), scale(image_up, y)), radius(bounds)));
camera.eye = add(target, scale(sub(camera.eye, camera.target), 1.0 / self.zoom));
camera.target = target;
if let Projection::Orthographic { height } = camera.projection {
camera.projection = Projection::Orthographic { height: height / self.zoom };
}
let finite = camera.eye.iter().chain(&camera.target).all(|c| c.is_finite())
&& match camera.projection {
Projection::Orthographic { height } => height.is_finite() && height > 0.0,
Projection::Perspective { .. } => true,
};
if !finite {
return Err(RenderError::Camera("the view is out of range"));
}
camera.basis()?;
Ok(camera)
}
/// The pan, each part held within [`PAN_LIMIT`]; a non-finite pan changes nothing.
fn set_pan(&mut self, pan: [f64; 2]) {
if pan.iter().all(|c| c.is_finite()) {
self.pan = pan.map(|c| c.clamp(-PAN_LIMIT, PAN_LIMIT));
}
}
}
/// How far the farthest vertex the meshes draw lies from `bounds`' centre: the radius of the
/// sphere [`Orbit::camera`] fits. Half the box's diagonal when there is none, and infinite when a
/// distance overflows, which [`Orbit::camera`] refuses.
#[must_use]
pub fn reach(meshes: &[WorldMesh], bounds: &Bounds) -> f64 {
let c = bounds.centre();
let far = vertices(meshes).map(|p| length(sub(p, c))).fold(0.0_f64, |far, d| if d.is_finite() { far.max(d) } else { f64::INFINITY });
if far > 0.0 { far } else { radius(bounds) }
}
/// Half the vertical field of view, radians.
fn half_fov() -> f64 {
(FOV_Y / 2.0).to_radians()
}
/// The camera looking along `dir` that holds the sphere of `reach` round `centre` in a picture of
/// `aspect`, whichever way it looks; only the picture's shorter side changes the model's size.
fn fitted(centre: Vec3, reach: f64, dir: Vec3, up: Vec3, perspective: bool, aspect: f64) -> Camera {
let r = reach * MARGIN;
let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
let half = half_fov().min((half_fov().tan() * aspect).atan());
let (distance, projection) = if perspective {
(r / half.sin(), Projection::Perspective { fov_y: FOV_Y })
} else {
(2.0 * r, Projection::Orthographic { height: 2.0 * r / aspect.min(1.0) })
};
Camera { eye: sub(centre, scale(dir, distance)), target: centre, up, projection }
}
/// The camera looking along `dir` that shows `anchor`'s point at its depth and offset.
fn about(anchor: Anchor, dir: Vec3, (right, image_up): (Vec3, Vec3), up: Vec3, perspective: bool) -> Camera {
let base = anchor.base(right, image_up);
let projection = if perspective {
Projection::Perspective { fov_y: FOV_Y }
} else {
Projection::Orthographic { height: 2.0 * anchor.distance * half_fov().tan() }
};
Camera { eye: sub(base, scale(dir, anchor.distance)), target: base, up, projection }
}
/// The model's radius, or 1 for a model with no extent.
fn radius(bounds: &Bounds) -> f64 {
match bounds.radius() {
r if r.is_finite() && r > 0.0 => r,
_ => 1.0,
}
}
/// How much of the target plane the picture shows vertically, in model units.
fn visible_height(camera: &Camera) -> f64 {
match camera.projection {
Projection::Orthographic { height } => height,
Projection::Perspective { fov_y } => 2.0 * length(sub(camera.eye, camera.target)) * (fov_y.min(179.0).to_radians() / 2.0).tan(),
}
}
#[cfg(test)]
#[path = "orbit_tests.rs"]
mod tests;
+287
View File
@@ -0,0 +1,287 @@
use super::super::axes::Axis;
use super::*;
fn cube() -> Bounds {
Bounds { min: [-1.0; 3], max: [1.0; 3] }
}
fn unit(v: Vec3) -> Vec3 {
normalize(v).unwrap()
}
fn parallel(a: Vec3, b: Vec3) -> bool {
(dot(unit(a), unit(b)) - 1.0).abs() < 1e-9
}
fn gap(c: &Camera) -> f64 {
length(sub(c.eye, c.target))
}
/// Looking along +x and down, with +y up: no named view's axes.
fn corner() -> Orbit {
Orbit::looking([0.6, 0.0, -0.8], [0.0, 1.0, 0.0], true).unwrap()
}
#[test]
fn the_named_views_look_the_way_their_names_say() {
let look = |v| Orbit::named(v, false, Upright::default()).direction();
let front = look(NamedView::Front);
assert!((front[1] - 1.0).abs() < 1e-9, "front looks along +y: {front:?}");
let right = look(NamedView::Right);
assert!((right[0] + 1.0).abs() < 1e-9, "right looks along -x, from +x: {right:?}");
let top = look(NamedView::Top);
assert!(top[2] < -0.99, "top looks down: {top:?}");
let iso = look(NamedView::Isometric);
assert!(iso[0] < 0.0 && iso[1] > 0.0 && iso[2] < 0.0, "{iso:?}");
for v in NamedView::ALL {
assert_eq!(NamedView::from_token(v.token()), Some(v));
}
}
#[test]
fn with_y_up_the_named_views_turn_about_y() {
let upright = Upright::default().with_up(Axis::PosY);
let front = Orbit::named(NamedView::Front, false, upright).direction();
assert!(front[2] < -0.999, "front looks along -z: {front:?}");
let top = Orbit::named(NamedView::Top, false, upright).direction();
assert!(top[1] < -0.99, "top looks down -y: {top:?}");
}
#[test]
fn a_view_of_its_own_looks_along_its_direction_with_its_own_up() {
let camera = corner().camera(&cube(), 1.0, 1.5).unwrap();
assert!(parallel(sub(camera.target, camera.eye), [0.6, 0.0, -0.8]));
assert!(parallel(camera.up, [0.0, 1.0, 0.0]), "{:?}", camera.up);
// An up that is not perpendicular to the look keeps only its perpendicular part.
let tilted = Orbit::looking([1.0, 0.0, 0.0], [1.0, 0.0, 1.0], true).unwrap();
assert!(parallel(tilted.axes.up, [0.0, 0.0, 1.0]));
assert!(parallel(tilted.direction(), [1.0, 0.0, 0.0]));
}
#[test]
fn a_view_with_its_up_along_its_look_or_out_of_range_is_refused() {
assert!(Orbit::looking([0.0, 0.0, 2.0], [0.0, 0.0, 1.0], true).is_none());
assert!(Orbit::looking([0.0; 3], [0.0, 0.0, 1.0], true).is_none());
assert!(Orbit::looking([f64::NAN, 0.0, 1.0], [0.0, 1.0, 0.0], true).is_none());
assert!(Orbit::looking([f64::MAX, f64::MAX, 0.0], [0.0, 0.0, f64::MAX], true).is_some());
for v in [[1.0, 1.0, 1.0], [0.1, 0.7, 0.3], [-3.0, 1e-3, 7.0]] {
assert!(Orbit::looking(v, v, true).is_none(), "{v:?} along itself");
assert!(Orbit::looking(v, scale(v, -2.0), true).is_none(), "{v:?} against itself");
}
}
#[test]
fn zoom_brings_the_eye_closer_and_pan_moves_the_target() {
let bounds = cube();
let mut orbit = Orbit::named(NamedView::Front, true, Upright::default());
let fitted = orbit.camera(&bounds, 1.0, 1.0).unwrap();
orbit.zoom_at(&bounds, 1.0, 1.0, 2.0, [0.0, 0.0]);
orbit.pan_by([0.5, 0.0]);
let moved = orbit.camera(&bounds, 1.0, 1.0).unwrap();
assert!((gap(&moved) - gap(&fitted) / 2.0).abs() < 1e-9);
assert!(moved.target != fitted.target);
assert!(moved.target[2].abs() < 1e-9, "a sideways pan keeps the height");
}
/// The model point on the target plane at `offset` (picture heights, right and up from the centre).
fn under(orbit: &Orbit, bounds: &Bounds, aspect: f64, offset: [f64; 2]) -> Vec3 {
let camera = orbit.camera(bounds, 2.0, aspect).unwrap();
let (right, up) = orbit.axes.picture(orbit.direction()).unwrap();
let h = visible_height(&camera);
add(camera.target, scale(add(scale(right, offset[0]), scale(up, offset[1])), h))
}
#[test]
fn a_wheel_zoom_keeps_the_point_under_the_pointer_still() {
let bounds = Bounds { min: [-1.0, -2.0, -0.5], max: [3.0, 1.0, 2.0] };
for start in
[Orbit::named(NamedView::Isometric, true, Upright::default()), Orbit::named(NamedView::Isometric, false, Upright::default()), corner()]
{
let mut orbit = start;
orbit.pan_by([0.1, -0.2]);
let (aspect, offset) = (1.6, [0.3, -0.15]);
let before = under(&orbit, &bounds, aspect, offset);
orbit.zoom_at(&bounds, 2.0, aspect, 2.5, offset);
let after = under(&orbit, &bounds, aspect, offset);
for (b, a) in before.iter().zip(after) {
assert!((b - a).abs() < 1e-9, "{before:?} vs {after:?}");
}
assert!((orbit.zoom() - 2.5).abs() < 1e-12);
}
}
#[test]
fn a_wheel_zoom_at_the_centre_does_not_pan_and_stays_in_range() {
let mut orbit = Orbit::named(NamedView::Front, true, Upright::default());
orbit.zoom_at(&cube(), 1.0, 1.0, 3.0, [0.0, 0.0]);
assert_eq!(orbit.pan(), [0.0, 0.0]);
orbit.zoom_at(&cube(), 1.0, 1.0, 1e300, [0.0, 0.0]);
assert_eq!(orbit.zoom(), ZOOM_RANGE.1);
orbit.zoom_at(&cube(), 1.0, 1.0, 1e-300, [0.0, 0.0]);
assert_eq!(orbit.zoom(), ZOOM_RANGE.0);
}
#[test]
fn turning_or_widening_the_picture_keeps_the_models_size() {
let bounds = Bounds { min: [-4.0, -1.0, 0.0], max: [4.0, 1.0, 0.5] };
let mut orbit = Orbit::named(NamedView::Isometric, true, Upright::default());
let first = orbit.camera(&bounds, 4.0, 1.5).unwrap();
for yaw in [0.3, 0.8, 0.9] {
orbit.turn(yaw, 0.0);
for aspect in [1.2, 1.5, 3.0] {
let c = orbit.camera(&bounds, 4.0, aspect).unwrap();
assert!((gap(&c) - gap(&first)).abs() < 1e-9, "{yaw} {aspect}");
assert_eq!(c.target, first.target);
}
}
}
#[test]
fn a_turn_holds_the_pitch_off_the_up_axis_and_ignores_non_finite_input() {
let mut orbit = Orbit::named(NamedView::Front, true, Upright::default());
orbit.turn(-1.0, 10.0);
assert_eq!(orbit.pitch(), PITCH_LIMIT);
assert!((0.0..TAU).contains(&orbit.yaw()));
assert!(orbit.camera(&cube(), 1.0, 1.0).is_ok(), "the top-most view still forms");
let held = orbit;
orbit.turn(f64::NAN, 0.0);
orbit.pan_by([f64::INFINITY, 0.0]);
orbit.zoom_at(&cube(), 1.0, 1.0, f64::NAN, [0.0, 0.0]);
orbit.zoom_at(&cube(), 1.0, 1.0, -2.0, [0.0, 0.0]);
orbit.look_along([0.0; 3]);
assert_eq!(orbit, held);
}
#[test]
fn a_far_pan_is_held_and_an_out_of_range_model_is_an_error_not_a_panic() {
let mut orbit = Orbit::named(NamedView::Front, true, Upright::default());
orbit.pan_by([1e300, -1e300]);
assert_eq!(orbit.pan(), [PAN_LIMIT, -PAN_LIMIT]);
let huge = Bounds { min: [-f64::MAX; 3], max: [f64::MAX; 3] };
assert!(orbit.camera(&huge, f64::MAX, 1.0).is_err());
let flat = Bounds { min: [0.0; 3], max: [0.0; 3] };
assert!(orbit.camera(&flat, 0.0, f64::NAN).is_ok(), "an empty extent falls back to a unit radius");
assert!(orbit.camera(&cube(), f64::INFINITY, 1.0).is_err(), "an overflowing reach is refused, not replaced");
assert!(orbit.camera(&cube(), f64::NAN, 1.0).is_err());
// A shift that overflows to NaN (a zoom about a model at the edge of the range) leaves the pan.
orbit.set_pan([f64::NAN, 0.0]);
assert_eq!(orbit.pan(), [PAN_LIMIT, -PAN_LIMIT]);
}
#[test]
fn looking_along_the_up_axis_stops_just_short_of_it_and_refits() {
let mut orbit = Orbit::named(NamedView::Front, true, Upright::default());
orbit.zoom_at(&cube(), 1.0, 1.0, 2.0, [0.1, 0.1]);
orbit.look_along([0.0, 0.0, -1.0]);
assert_eq!(orbit.pitch(), PITCH_LIMIT);
assert_eq!((orbit.zoom(), orbit.pan()), (1.0, [0.0, 0.0]));
}
#[test]
fn an_anchor_keeps_the_picture_then_turns_about_its_point() {
let bounds = cube();
for perspective in [true, false] {
let mut orbit = Orbit::named(NamedView::Isometric, perspective, Upright::default());
orbit.zoom_at(&bounds, 1.5, 1.4, 1.7, [0.0, 0.0]);
orbit.pan_by([0.2, -0.1]);
let before = orbit.camera(&bounds, 1.5, 1.4).unwrap();
let point = add(before.target, scale(orbit.direction(), 0.4));
assert!(orbit.anchor_at(&before, point));
assert_eq!(orbit.anchor(), Some(point));
let after = orbit.camera(&bounds, 1.5, 1.4).unwrap();
if perspective {
for (a, b) in after.eye.iter().zip(before.eye) {
assert!((a - b).abs() < 1e-9);
}
} else {
assert!((visible_height(&after) - visible_height(&before)).abs() < 1e-9);
}
orbit.turn(0.8, 0.4);
let turned = orbit.camera(&bounds, 1.5, 1.4).unwrap();
for (t, p) in turned.target.iter().zip(point) {
assert!((t - p).abs() < 1e-9);
}
}
}
#[test]
fn a_point_behind_the_eye_is_not_turned_about() {
let mut orbit = Orbit::named(NamedView::Front, true, Upright::default());
let camera = orbit.camera(&cube(), 1.0, 1.0).unwrap();
let behind = sub(camera.eye, orbit.direction());
assert!(!orbit.anchor_at(&camera, behind));
assert!(!orbit.anchor_at(&camera, [f64::NAN, 0.0, 0.0]));
assert_eq!(orbit.anchor(), None);
}
#[test]
fn a_refit_forgets_the_point_turned_about() {
let bounds = cube();
let mut orbit = Orbit::named(NamedView::Isometric, true, Upright::default());
let fitted = orbit.camera(&bounds, 1.5, 1.4).unwrap();
assert!(orbit.anchor_at(&fitted, [0.3, 0.2, 0.1]));
orbit.zoom_at(&bounds, 1.5, 1.4, 2.0, [0.0, 0.0]);
orbit.refit();
assert_eq!(orbit.anchor(), None);
assert_eq!(orbit.camera(&bounds, 1.5, 1.4).unwrap(), fitted);
}
#[test]
fn centring_on_a_point_puts_it_at_the_target() {
let bounds = cube();
for mut orbit in [Orbit::named(NamedView::Isometric, true, Upright::default()), corner()] {
let point = [0.5, -0.25, 0.75];
orbit.centre_on(&bounds, point);
let c = orbit.camera(&bounds, 1.0, 1.0).unwrap();
// The target is the point moved along the look, so the point shows at the centre.
assert!(parallel(sub(point, c.eye), sub(c.target, c.eye)) || length(sub(point, c.target)) < 1e-9);
}
}
/// Where `p` shows on `c`'s picture: right and up of the centre, as a share of the visible height
/// at `p`'s depth.
fn shown(c: &Camera, p: Vec3) -> [f64; 2] {
let f = unit(sub(c.target, c.eye));
let r = unit(cross(f, c.up));
let u = cross(r, f);
let v = sub(p, c.eye);
let h = match c.projection {
Projection::Perspective { fov_y } => 2.0 * dot(v, f) * (fov_y / 2.0).to_radians().tan(),
Projection::Orthographic { height } => height,
};
[dot(v, r) / h, dot(v, u) / h]
}
#[test]
fn a_pressed_point_off_centre_keeps_its_place_as_the_view_turns() {
let bounds = cube();
for perspective in [true, false] {
let mut orbit = Orbit::named(NamedView::Isometric, perspective, Upright::default());
orbit.zoom_at(&bounds, 1.5, 1.4, 1.3, [0.0, 0.0]);
let before = orbit.camera(&bounds, 1.5, 1.4).unwrap();
let f = orbit.direction();
let (r, u) = orbit.axes.picture(f).unwrap();
let point = add(before.target, add(add(scale(r, 0.3), scale(u, 0.2)), scale(f, 0.4)));
let at = shown(&before, point);
assert!(orbit.anchor_at(&before, point));
for (yaw, pitch) in [(0.0, 0.0), (0.8, 0.4), (-1.9, -1.1)] {
orbit.turn(yaw, pitch);
let c = orbit.camera(&bounds, 1.5, 1.4).unwrap();
let now = shown(&c, point);
for (n, a) in now.iter().zip(at) {
assert!((n - a).abs() < 1e-9, "{perspective} {now:?} {at:?}");
}
}
}
}
#[test]
fn a_vertex_too_far_to_measure_gives_an_infinite_reach_not_a_near_one() {
let mut mesh = pdfcraft_prc::TriangleMesh::default();
mesh.positions = vec![[-f64::MAX, -f64::MAX, 0.0], [f64::MAX, f64::MAX, 0.0], [f64::MAX, -f64::MAX, 0.0], [0.0, 0.0, 0.0]];
mesh.triangles = vec![[0, 1, 2], [3, 3, 3]];
let meshes = [WorldMesh::new(mesh)];
let bounds = Bounds::of(&meshes).unwrap();
assert_eq!(reach(&meshes, &bounds), f64::INFINITY);
assert!(Orbit::named(NamedView::Front, true, Upright::default()).camera(&bounds, reach(&meshes, &bounds), 1.0).is_err());
}
+1 -1
View File
@@ -86,7 +86,7 @@ impl Bounds {
/// Every corner of the triangles [`render`] draws: those not fully transparent whose three
/// corners exist and are finite.
fn vertices(meshes: &[WorldMesh]) -> impl Iterator<Item = [f64; 3]> + Clone + '_ {
pub(crate) fn vertices(meshes: &[WorldMesh]) -> impl Iterator<Item = [f64; 3]> + Clone + '_ {
meshes
.iter()
.flat_map(|m| m.mesh.triangles.iter().enumerate().filter(|&(t, _)| !invisible(m, t)).filter_map(|(_, tri)| corners(&m.mesh, tri)).flatten())
+5
View File
@@ -3,6 +3,11 @@
/// A point or direction, `[x, y, z]`.
pub(crate) type Vec3 = [f64; 3];
/// `a + b`.
pub(crate) fn add([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 {
[ax + bx, ay + by, az + bz]
}
/// `a - b`.
pub(crate) fn sub([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 {
[ax - bx, ay - by, az - bz]
+2 -2
View File
@@ -9997,8 +9997,8 @@ milestone = "M12"
status = "partial"
commands = []
tools = ["model_render"]
evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"]
notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, with headlight shading. Missing: the 3D view in the app (no activation, no navigation), U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements"
evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/automation/tests/it/model_render.rs::an_orbit_frames_the_model_from_a_named_view_and_its_moves", "crates/prc-render/src/nav/orbit_tests.rs::a_pressed_point_off_centre_keeps_its_place_as_the_view_turns", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"]
notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, or by orbit navigation (named views about a chosen up axis, turn, zoom about a picture point, pan) from prc-render's GUI-free nav module, with headlight shading. Missing: the 3D view in the app (no activation, no on-screen navigation; nav is not wired to a viewer yet), walk and fly navigation, U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements"
[[feature]]
id = "misc.3d-add"