mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 15:57:49 +00:00
The Materials tab and threeD.mapGraphic now map graphics onto Revolve's swept side, unrolled flat: x runs around the axis (the angle times the profile's distance from the axis at that point) and y along the profile. A graphic keeps its size at every distance from the axis, so it reaches farther around where the profile comes nearer the axis. Each face of the side draws the part that falls on it: a graphic narrows toward the silhouette, follows the profile along its length, is shaded with the surface (several lights included) and is hidden where the side faces away. A part that runs past the end of the profile reaches the curved rim and stops there. The middle of the side faces the viewer with the axis on either edge. On a compound profile or a group the graphics go on the longest contour. - three-d: with Revolve::map_surface set, side faces carry a SurfaceCell (corners on the page and on the unrolled side, whether the outside faces the viewer) and a closed profile off the axis takes an anchor where its side is cut; without it the faces are as before. revolve_surface_size gives the side's size for a new graphic's fit. - effects: Revolve takes `graphics` (front only); surface_parts cuts and maps them per face; curves are cut into straight pieces one at a time, and commands refuse graphics past the point limits; switching between Extrude, Plane and Revolve keeps the front's graphics. - The Materials tab hides Face for Revolve; params text, docs/mcp.md, docs/three-d.md and the progress docs describe the change. - i18n: the complete-message catalogs translate the three new refusals in place of the planar-faces refusal they replace, and ja, es, zh-hans and zh-hant translate the Materials tab's label.
378 lines
21 KiB
Rust
378 lines
21 KiB
Rust
use super::*;
|
||
|
||
mod caps;
|
||
mod classic;
|
||
mod lighting;
|
||
mod multiple_lights;
|
||
mod profile_limits;
|
||
mod smooth;
|
||
|
||
#[test]
|
||
fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() {
|
||
for rotation in [-170.0f64, -45.0, 0.0, 83.0, 167.0] {
|
||
for height in [0.0f64, 15.0, 45.0, 80.0] {
|
||
for behind in [false, true] {
|
||
let o = Revolve { light_rotation: rotation, light_height: height, light_behind: behind, ..Default::default() };
|
||
let direction = o.light_direction();
|
||
assert!((direction[0].hypot(direction[1]).hypot(direction[2]) - 1.0).abs() < 1e-12);
|
||
assert_eq!(direction[2] < 0.0, behind && height > 0.0);
|
||
for ([center, u, v], angle) in o.light_orbits().into_iter().zip([rotation, height]) {
|
||
let (s, c) = angle.to_radians().sin_cos();
|
||
for i in 0..3 {
|
||
assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12);
|
||
}
|
||
}
|
||
let [r, h] = o.light_at_on_side(Point::new(direction[0], direction[1]));
|
||
assert!((r - rotation).abs() < 1e-9 && (h - height).abs() < 1e-6, "{rotation}, {height} -> {r}, {h}");
|
||
}
|
||
}
|
||
}
|
||
// Dragged to the centre: straight in front, the Rotation kept; past the rim: at 0°.
|
||
let o = Revolve { light_rotation: 135.0, ..Default::default() };
|
||
assert_eq!(o.light_at_on_side(Point::new(0.0, 0.0)), [135.0, 90.0]);
|
||
let [r, h] = o.light_at_on_side(Point::new(0.0, -3.0));
|
||
assert!((r - 90.0).abs() < 1e-12 && h == 0.0);
|
||
let [r, h] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY));
|
||
assert!(r.is_finite() && h.is_finite());
|
||
}
|
||
|
||
/// An effect from before Rotation and Height (azimuth and elevation) gets the same light:
|
||
/// the same direction, so the same shading.
|
||
#[test]
|
||
fn an_earlier_azimuth_and_elevation_give_the_same_light() {
|
||
let old = |azimuth: f64, elevation: f64| {
|
||
let (sl, cl) = elevation.to_radians().sin_cos();
|
||
let (sa, ca) = azimuth.to_radians().sin_cos();
|
||
[cl * sa, -sl, cl * ca]
|
||
};
|
||
for azimuth in [-360.0f64, -170.0, -45.0, 0.0, 90.0, 135.0, 179.0] {
|
||
for elevation in [-90.0f64, -60.0, -1.0, 0.0, 30.0, 45.0, 89.0, 90.0] {
|
||
let (light_rotation, light_height, light_behind) = light_from_azimuth_elevation(azimuth, elevation);
|
||
assert!((-180.0..=180.0).contains(&light_rotation) && (0.0..=90.0).contains(&light_height));
|
||
let new = Revolve { light_rotation, light_height, light_behind, ..Default::default() }.light_direction();
|
||
let want = old(azimuth, elevation);
|
||
for i in 0..3 {
|
||
assert!((new[i] - want[i]).abs() < 1e-12, "{azimuth}, {elevation}: {new:?} against {want:?}");
|
||
}
|
||
}
|
||
}
|
||
// The default light is the earlier default's.
|
||
let (r, h, behind) = light_from_azimuth_elevation(-45.0, 45.0);
|
||
assert!((r - DEFAULT_LIGHT.0).abs() < 1e-12 && (h - DEFAULT_LIGHT.1).abs() < 1e-12 && !behind, "{r} {h}");
|
||
}
|
||
|
||
/// Softness wraps the light further round the object; without it, the earlier shading. Ambient
|
||
/// Light runs to 200 %, never past the art's own colour.
|
||
#[test]
|
||
fn softness_wraps_the_light_and_ambient_reaches_200_percent() {
|
||
let o = Revolve::default().sanitized();
|
||
let l = V3(0.0, 0.0, 1.0);
|
||
let n = |deg: f64| {
|
||
let (s, c) = deg.to_radians().sin_cos();
|
||
V3(s, 0.0, c)
|
||
};
|
||
for deg in [0.0, 30.0, 60.0, 89.0, 120.0] {
|
||
let lambert = (o.ambient + o.light_intensity * n(deg).dot(l).max(0.0)).clamp(0.0, 1.0);
|
||
assert!((brightness(&o, n(deg), l) - lambert).abs() < 1e-12);
|
||
}
|
||
let soft = Revolve { light_softness: 1.0, ..o };
|
||
assert!(brightness(&soft, n(120.0), l) > brightness(&o, n(120.0), l) + 0.1, "lit past 90°");
|
||
assert!((brightness(&soft, n(180.0), l) - o.ambient).abs() < 1e-12, "nothing straight away");
|
||
assert!((brightness(&soft, n(0.0), l) - brightness(&o, n(0.0), l)).abs() < 1e-12, "the same facing the light");
|
||
let bright = Revolve { ambient: 2.0, ..o };
|
||
assert_eq!(brightness(&bright, n(180.0), l), 1.0);
|
||
assert_eq!(
|
||
Revolve { ambient: 5.0, light_softness: -1.0, light_height: 120.0, ..o }.sanitized(),
|
||
Revolve { ambient: 2.0, light_softness: 0.0, light_height: 90.0, ..o }
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() {
|
||
let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() };
|
||
let seed = V3(0.3, -0.5, 0.7);
|
||
let before = o.rotate(seed);
|
||
let planes = o.rotation_planes();
|
||
for (axis, [u, v]) in planes.iter().enumerate() {
|
||
let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2]));
|
||
assert!((normal.dot(normal) - 1.0).abs() < 1e-12);
|
||
let mut changed = o;
|
||
match axis {
|
||
0 => changed.rotation_x += 0.0001,
|
||
1 => changed.rotation_y += 0.0001,
|
||
_ => changed.rotation_z += 0.0001,
|
||
}
|
||
let actual = changed.rotate(seed).sub(before);
|
||
let expected = normal.cross(before);
|
||
let step = 0.0001f64.to_radians();
|
||
for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] {
|
||
assert!((a - b).abs() < 1e-5);
|
||
}
|
||
}
|
||
assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0));
|
||
}
|
||
#[test]
|
||
fn guides_match_projection_and_dragged_light_matches_shading_direction() {
|
||
let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() };
|
||
assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]);
|
||
assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]);
|
||
let [light_rotation, light_height] = o.light_at_on_side(Point::new(0.3, -0.4));
|
||
let [x, y, z] = Revolve { light_rotation, light_height, ..o }.light_direction();
|
||
assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0);
|
||
let [rotation, height] = o.light_at_on_side(Point::new(2.0, -2.0));
|
||
assert!(rotation.is_finite() && height == 0.0);
|
||
}
|
||
fn cylinder() -> (PathData, Rect) {
|
||
let mut bp = BezPath::new();
|
||
bp.move_to((40.0, 10.0));
|
||
bp.line_to((40.0, 90.0));
|
||
(PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0))
|
||
}
|
||
#[test]
|
||
fn cylinder_has_expected_silhouette_depth_order_and_shading() {
|
||
let (p, b) = cylinder();
|
||
let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
|
||
let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect();
|
||
assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6);
|
||
assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6);
|
||
assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth));
|
||
assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4));
|
||
}
|
||
#[test]
|
||
fn edge_offset_sweep_and_rotation_change_the_surface() {
|
||
let (p, b) = cylinder();
|
||
let base = revolve(&p, b, Revolve::default()).unwrap();
|
||
for o in [
|
||
Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() },
|
||
Revolve { angle: 120.0, ..Default::default() },
|
||
Revolve { rotation_z: 60.0, perspective: 40.0, ..Default::default() },
|
||
] {
|
||
assert_ne!(base, revolve(&p, b, o).unwrap());
|
||
}
|
||
assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty());
|
||
}
|
||
/// The angle that stands for an earlier perspective strength puts the eye where that strength
|
||
/// did (4 × the half size / strength), so files from before the angle look as they did.
|
||
#[test]
|
||
fn the_angle_for_an_earlier_strength_puts_the_eye_where_it_was() {
|
||
for strength in [0.05, 0.3, 0.6, 1.0] {
|
||
for size in [1.0, 37.5, 900.0] {
|
||
let eye = eye_distance(perspective_from_strength(strength), size).unwrap();
|
||
assert!((eye - 4.0 * size / strength).abs() < 1e-9 * eye, "{strength} {size}: {eye}");
|
||
}
|
||
}
|
||
assert_eq!(perspective_from_strength(0.0), 0.0);
|
||
assert!((perspective_from_strength(1.0) - 51.318).abs() < 1e-3, "{}", perspective_from_strength(1.0));
|
||
}
|
||
|
||
/// 0° to 160°: none at 0°, beyond either end held there, and at 160° the eye is still outside
|
||
/// the sphere the object's box fits in.
|
||
#[test]
|
||
fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() {
|
||
assert_eq!(eye_distance(0.0, 50.0), None);
|
||
assert_eq!(eye_distance(-20.0, 50.0), None);
|
||
assert_eq!(eye_distance(f64::NAN, 50.0), None);
|
||
assert_eq!(eye_distance(500.0, 50.0), eye_distance(160.0, 50.0));
|
||
let widest = eye_distance(160.0, 50.0).unwrap();
|
||
assert!(widest > 3f64.sqrt() * 50.0 && widest < 1.02 * 3f64.sqrt() * 50.0, "{widest}");
|
||
// Wider is closer.
|
||
let eyes: Vec<f64> = [10.0, 45.0, 90.0, 160.0].iter().map(|a| eye_distance(*a, 50.0).unwrap()).collect();
|
||
assert!(eyes.windows(2).all(|w| w[0] > w[1]), "{eyes:?}");
|
||
let (p, b) = cylinder();
|
||
let wide = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 160.0, ..Default::default() }).unwrap();
|
||
assert!(wide.iter().flat_map(|f| &f.points).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||
assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap());
|
||
}
|
||
#[test]
|
||
fn side_faces_lie_on_the_unrolled_surface_and_caps_do_not() {
|
||
let (p, b) = cylinder();
|
||
// Without graphics no face carries a place on the surface.
|
||
let plain = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
|
||
assert!(plain.iter().all(|f| f.surface.is_none()));
|
||
let faces = revolve(&p, b, Revolve { map_surface: true, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
|
||
assert_eq!(faces.len(), plain.len());
|
||
assert!(faces.iter().filter(|f| f.cap).all(|f| f.surface.is_none()));
|
||
let side: Vec<_> = faces.iter().filter(|f| !f.cap).collect();
|
||
assert!(!side.is_empty() && side.iter().all(|f| f.surface.as_ref().is_some_and(|c| c.uv.len() == c.page.len() && c.uv.len() == 3)));
|
||
for f in &side {
|
||
let cell = f.surface.as_ref().unwrap();
|
||
// Straight on, x around the axis is the angle from the front times the radius 40: a corner
|
||
// at page x lies at 40 asin(x / 40) on the front half; y runs 40 each way from the middle,
|
||
// downward.
|
||
for (q, uv) in cell.page.iter().zip(&cell.uv) {
|
||
assert!((uv.y - (q.y - 50.0)).abs() < 1e-9, "{q:?} {uv:?}");
|
||
if cell.outward {
|
||
assert!((uv.x - 40.0 * (q.x / 40.0).clamp(-1.0, 1.0).asin()).abs() < 1e-6, "{q:?} {uv:?}");
|
||
}
|
||
}
|
||
// The outside faces the viewer on the front half only.
|
||
let mean = cell.uv.iter().map(|p| p.x).sum::<f64>() / cell.uv.len() as f64;
|
||
assert_eq!(cell.outward, mean.abs() < 40.0 * std::f64::consts::FRAC_PI_2, "{mean}");
|
||
}
|
||
// A quarter sweep's middle is the middle of the sweep, 45° around the axis.
|
||
let quarter = revolve(&p, b, Revolve { map_surface: true, angle: 90.0, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
|
||
let xs: Vec<f64> = quarter.iter().filter_map(|f| f.surface.as_ref()).flat_map(|c| c.uv.iter().map(|p| p.x)).collect();
|
||
let (lo, hi) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(*x), hi.max(*x)));
|
||
let reach = 40.0 * std::f64::consts::FRAC_PI_4;
|
||
assert!((lo + reach).abs() < 1e-9 && (hi - reach).abs() < 1e-9, "{lo} {hi}");
|
||
let (w, h) = revolve_surface_size(&p, b, Revolve::default()).unwrap();
|
||
assert!((w - 40.0 * std::f64::consts::PI).abs() < 1e-9 && (h - 80.0).abs() < 1e-3, "{w} {h}");
|
||
assert_eq!(revolve_surface_size(&p, Rect::new(40.0, 10.0, 40.0, 90.0), Revolve::default()), None);
|
||
// A curve that starts and ends on the axis but bows away from it sweeps a side with a length.
|
||
let mut bow = BezPath::new();
|
||
bow.move_to((0.0, 0.0));
|
||
bow.curve_to((40.0, 0.0), (40.0, 80.0), (0.0, 80.0));
|
||
let (w, h) = revolve_surface_size(&PathData::from_bezpath(&bow), Rect::new(0.0, 0.0, 30.0, 80.0), Revolve::default()).unwrap();
|
||
assert!((w - 30.0 * std::f64::consts::PI).abs() < 1e-9 && h > 80.0, "{w} {h}");
|
||
}
|
||
#[test]
|
||
fn the_side_keeps_its_length_around_the_axis_at_every_distance_from_it() {
|
||
// A step: 40 from the axis, 20 between y 40 and 60, then 40 again.
|
||
let mut bp = BezPath::new();
|
||
bp.move_to((40.0, 10.0));
|
||
for p in [(40.0, 40.0), (20.0, 40.0), (20.0, 60.0), (40.0, 60.0), (40.0, 90.0)] {
|
||
bp.line_to(p);
|
||
}
|
||
let (p, b) = (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0));
|
||
let faces = revolve(&p, b, Revolve { map_surface: true, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
|
||
let (mut narrow, mut wide) = (0.0f64, 0.0f64);
|
||
for cell in faces.iter().filter_map(|f| f.surface.as_ref()).filter(|c| c.outward) {
|
||
// The faces of the narrow part run from y 40 to 60; those of the wide parts reach past
|
||
// them; the steps between lie at y 40 or 60 only.
|
||
let at = |y: f64| cell.page.iter().any(|q| (q.y - y).abs() < 1e-9);
|
||
let radius = if at(40.0) && at(60.0) {
|
||
Some(20.0)
|
||
} else if cell.page.iter().any(|q| q.y < 40.0 - 1e-9 || q.y > 60.0 + 1e-9) {
|
||
Some(40.0)
|
||
} else {
|
||
None
|
||
};
|
||
for (q, uv) in cell.page.iter().zip(&cell.uv) {
|
||
// Straight on, a corner at page x that is r from the axis lies r asin(x / r) around it.
|
||
let fits = |r: f64| (uv.x - r * (q.x / r).clamp(-1.0, 1.0).asin()).abs() < 1e-6;
|
||
match radius {
|
||
Some(r) => {
|
||
assert!(fits(r), "{r}: {q:?} {uv:?}");
|
||
let reach = if r < 30.0 { &mut narrow } else { &mut wide };
|
||
*reach = reach.max(uv.x.abs());
|
||
}
|
||
None => assert!(fits(20.0) || fits(40.0), "{q:?} {uv:?}"),
|
||
}
|
||
}
|
||
}
|
||
// The half seen from the front reaches a quarter turn each way: 10π at 20 from the axis, 20π
|
||
// at 40.
|
||
let quarter = std::f64::consts::FRAC_PI_2;
|
||
assert!((narrow - 20.0 * quarter).abs() < 1.0 && (wide - 40.0 * quarter).abs() < 2.0, "{narrow} {wide}");
|
||
}
|
||
#[test]
|
||
fn with_the_axis_on_the_right_the_middle_of_the_side_faces_the_viewer() {
|
||
let mut bp = BezPath::new();
|
||
bp.move_to((0.0, 10.0));
|
||
bp.line_to((0.0, 90.0));
|
||
let (p, b) = (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0));
|
||
let q = 40.0 * std::f64::consts::FRAC_PI_4;
|
||
// The side's reach around the axis either way of its middle: for 270° the middle is 225°, in
|
||
// the middle of the part from 180° to 270° that faces the viewer; a quarter turn faces away,
|
||
// and its middle is half the sweep.
|
||
for (angle, lo_expected, hi_expected) in [(270.0, -q, 5.0 * q), (90.0, -q, q)] {
|
||
let o = Revolve { map_surface: true, edge: Edge::Right, angle, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() };
|
||
let xs: Vec<f64> = revolve(&p, b, o).unwrap().iter().filter_map(|f| f.surface.as_ref()).flat_map(|c| c.uv.iter().map(|p| p.x)).collect();
|
||
let (lo, hi) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(*x), hi.max(*x)));
|
||
assert!((lo - lo_expected).abs() < 1e-6 && (hi - hi_expected).abs() < 1e-6, "{angle}: {lo} {hi}");
|
||
}
|
||
// A new graphic fits the part of the sweep that faces the viewer: 90° of a 270° sweep, 20° of
|
||
// a 200° one, all of a quarter turn that faces away.
|
||
for (angle, seen) in [(270.0f64, 90.0f64), (200.0, 20.0), (90.0, 90.0), (360.0, 180.0)] {
|
||
let (w, _) = revolve_surface_size(&p, b, Revolve { edge: Edge::Right, angle, ..Default::default() }).unwrap();
|
||
assert!((w - 40.0 * seen.to_radians()).abs() < 1e-9, "{angle}: {w}");
|
||
}
|
||
// A full turn: straight on, the axis is at x 40, a corner at page x lies 40 asin((x - 40) / 40)
|
||
// around it, and the front half shows its outside.
|
||
let faces = revolve(&p, b, Revolve { map_surface: true, edge: Edge::Right, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
|
||
let cells: Vec<_> = faces.iter().filter_map(|f| f.surface.as_ref()).collect();
|
||
assert!(cells.iter().any(|c| c.outward));
|
||
for cell in &cells {
|
||
let mean = cell.uv.iter().map(|p| p.x).sum::<f64>() / cell.uv.len() as f64;
|
||
assert_eq!(cell.outward, mean.abs() < 40.0 * std::f64::consts::FRAC_PI_2, "{mean}");
|
||
if cell.outward {
|
||
for (q, uv) in cell.page.iter().zip(&cell.uv) {
|
||
assert!((uv.x - 40.0 * ((q.x - 40.0) / 40.0).clamp(-1.0, 1.0).asin()).abs() < 1e-6, "{q:?} {uv:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#[test]
|
||
fn only_the_longest_contour_of_a_compound_profile_carries_the_side_surface() {
|
||
let mut bp = BezPath::new();
|
||
bp.move_to((40.0, 10.0));
|
||
bp.line_to((40.0, 90.0));
|
||
bp.move_to((20.0, 30.0));
|
||
bp.line_to((20.0, 50.0));
|
||
let (p, b) = (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0));
|
||
let faces = revolve(&p, b, Revolve { map_surface: true, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
|
||
let short = |f: &Face| f.points.iter().all(|q| q.y > 29.0 && q.y < 51.0);
|
||
assert!(faces.iter().any(|f| !f.cap && short(f)) && faces.iter().any(|f| !f.cap && !short(f)));
|
||
for f in faces.iter().filter(|f| !f.cap) {
|
||
assert_eq!(f.surface.is_some(), !short(f), "{:?}", f.points);
|
||
}
|
||
// The side's size is that contour's: with the longest contour 10 from the axis and a shorter
|
||
// one 40 from it, the width is half a turn at 10 and the length the longest contour's 80.
|
||
let mut bp = BezPath::new();
|
||
bp.move_to((10.0, 10.0));
|
||
bp.line_to((10.0, 90.0));
|
||
bp.move_to((40.0, 30.0));
|
||
bp.line_to((40.0, 50.0));
|
||
let (w, h) = revolve_surface_size(&PathData::from_bezpath(&bp), b, Revolve::default()).unwrap();
|
||
assert!((w - 10.0 * std::f64::consts::PI).abs() < 1e-9 && (h - 80.0).abs() < 1e-3, "{w} {h}");
|
||
}
|
||
/// The square ring of [`a_ring_profile_is_measured_from_its_outer_side_at_mid_height`] with an
|
||
/// anchor in the middle of its inner side.
|
||
fn split_square() -> [Point; 6] {
|
||
[(20.0, 30.0), (40.0, 30.0), (40.0, 50.0), (20.0, 50.0), (20.0, 40.0), (20.0, 30.0)].map(|(x, y)| Point::new(x, y))
|
||
}
|
||
#[test]
|
||
fn a_ring_profile_is_measured_from_its_outer_side_at_mid_height() {
|
||
// A square from 20 to 40 from the axis, starting at its top inner corner and running to the
|
||
// right first (down the outer side).
|
||
let ring = [(20.0, 30.0), (40.0, 30.0), (40.0, 50.0), (20.0, 50.0), (20.0, 30.0)].map(|(x, y)| Point::new(x, y));
|
||
assert_eq!(along_profile(&ring, 0.0, true), vec![[-30.0, -10.0], [-10.0, 10.0], [10.0, 30.0], [30.0, 50.0]]);
|
||
// Wound the other way the outer side still runs down the page as y increases. The cut is at
|
||
// an inner corner, so each piece's y runs one way across it.
|
||
let back = [(20.0, 30.0), (20.0, 50.0), (40.0, 50.0), (40.0, 30.0), (20.0, 30.0)].map(|(x, y)| Point::new(x, y));
|
||
assert_eq!(along_profile(&back, 0.0, true), vec![[50.0, 30.0], [30.0, 10.0], [10.0, -10.0], [-10.0, -30.0]]);
|
||
// The cut falls half way along a long inner side: Revolve puts an anchor there first, and y
|
||
// then runs half the contour's length each way.
|
||
let tall = [(20.0, 0.0), (40.0, 0.0), (40.0, 100.0), (20.0, 100.0), (20.0, 0.0)].map(|(x, y)| Point::new(x, y));
|
||
assert_eq!(ring_cut(&tall, 0.0), Some((3, 0.5)));
|
||
assert_eq!(ring_cut(&ring, 0.0), Some((3, 0.5)));
|
||
assert_eq!(ring_cut(&split_square(), 0.0), None);
|
||
assert_eq!(along_profile(&split_square(), 0.0, true), vec![[-30.0, -10.0], [-10.0, 10.0], [10.0, 30.0], [30.0, 40.0], [-40.0, -30.0]]);
|
||
let split = [(20.0, 0.0), (40.0, 0.0), (40.0, 100.0), (20.0, 100.0), (20.0, 50.0), (20.0, 0.0)].map(|(x, y)| Point::new(x, y));
|
||
assert_eq!(along_profile(&split, 0.0, true), vec![[-70.0, -50.0], [-50.0, 50.0], [50.0, 70.0], [70.0, 120.0], [-120.0, -70.0]]);
|
||
let mut bp = BezPath::new();
|
||
bp.move_to((20.0, 0.0));
|
||
for p in [(40.0, 0.0), (40.0, 100.0), (20.0, 100.0)] {
|
||
bp.line_to(p);
|
||
}
|
||
bp.close_path();
|
||
let (path, bounds, o) =
|
||
(PathData::from_bezpath(&bp), Rect::new(20.0, 0.0, 40.0, 100.0), Revolve { map_surface: true, offset: 20.0, ..Default::default() });
|
||
let faces = revolve(&path, bounds, o).unwrap();
|
||
// Without graphics the profile takes no anchor at the cut: its inner side stays one edge.
|
||
let plain = revolve(&path, bounds, Revolve { map_surface: false, ..o }).unwrap();
|
||
assert!(plain.len() < faces.len() && plain.iter().all(|f| f.surface.is_none()), "{} {}", plain.len(), faces.len());
|
||
let ys: Vec<f64> = faces.iter().filter_map(|f| f.surface.as_ref()).flat_map(|c| c.uv.iter().map(|p| p.y)).collect();
|
||
assert!(!ys.is_empty() && ys.iter().all(|y| y.abs() <= 120.0 + 1e-9), "{ys:?}");
|
||
let (w, h) = revolve_surface_size(&path, bounds, o).unwrap();
|
||
assert!((h - 240.0).abs() < 1e-6 && (w - 40.0 * std::f64::consts::PI).abs() < 1e-9, "{w} {h}");
|
||
}
|
||
#[test]
|
||
fn malformed_and_degenerate_inputs_are_bounded() {
|
||
let (p, b) = cylinder();
|
||
let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap();
|
||
assert!(f.len() <= 258);
|
||
assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err());
|
||
assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty());
|
||
}
|