diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1c4b12..876b46e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,15 @@ with the difference that unordered results are returned as `NAN`) - Fix a bug in the x86 JIT evaluator's implementation of interval `abs` - Add generic `TransformedShape`, representing a shape transformed by a 4x4 - homogeneous matrix - - This replaces `RenderConfig::mat` as the way to handle rotation / scale / - translation / perspective transforms, e.g. for interactive visualization - (where you don't want to remap the underlying shape) - - It's a more general solution: for example, we can use the same type to - change bounds for meshing (by translating + scaling the underlying model). + homogeneous matrix. This replaces `RenderConfig::mat` as the flexible + strategy for rotation / scale / translation / perspective transforms, e.g. for + interactive visualization (where you don't want to remap the underlying shape) +- Introduce a new `Bounds` type, representing an X/Y/Z region of interest for + rendering or meshing. This overlaps somewhat with `TransformedShape`, but + it's ergonomic to specify render region instead of having to do the matrix + math every time. + - Replaced `RenderConfig::mat` with a new `bounds` member. + - Added a new `bounds` member to `mesh::Settings`, for octree construction - Move `Interval` and `Grad` to `fidget::types` module, instead of `fidget::eval::types`. - Fix an edge case in meshing where nearly-planar surfaces could produce diff --git a/demo/src/main.rs b/demo/src/main.rs index 505e2a28..7d0b8a4e 100644 --- a/demo/src/main.rs +++ b/demo/src/main.rs @@ -129,6 +129,7 @@ fn run3d( image_size: settings.size as usize, tile_sizes: S::tile_sizes_3d().to_vec(), threads: settings.threads, + ..Default::default() }; let shape = shape.apply_transform(mat.into()); @@ -206,6 +207,7 @@ fn run2d( image_size: settings.size as usize, tile_sizes: S::tile_sizes_2d().to_vec(), threads: settings.threads, + ..Default::default() }; if sdf { let mut image = vec![]; @@ -250,6 +252,7 @@ fn run_mesh( threads: settings.threads, min_depth: settings.depth, max_depth: settings.max_depth.unwrap_or(settings.depth), + ..Default::default() }; let octree = fidget::mesh::Octree::build(&shape, settings); mesh = octree.walk_dual(settings); diff --git a/fidget/benches/mesh.rs b/fidget/benches/mesh.rs index 671570e3..4a99e301 100644 --- a/fidget/benches/mesh.rs +++ b/fidget/benches/mesh.rs @@ -18,6 +18,7 @@ pub fn colonnade_octree_thread_sweep(c: &mut Criterion) { min_depth: 6, max_depth: 6, threads, + ..Default::default() }; #[cfg(feature = "jit")] group.bench_function(BenchmarkId::new("jit", threads), move |b| { @@ -42,6 +43,7 @@ pub fn colonnade_mesh(c: &mut Criterion) { min_depth: 8, max_depth: 8, threads: 8, + ..Default::default() }; let octree = &fidget::mesh::Octree::build(shape_vm, cfg); diff --git a/fidget/benches/render.rs b/fidget/benches/render.rs index f173be20..4355850e 100644 --- a/fidget/benches/render.rs +++ b/fidget/benches/render.rs @@ -20,6 +20,7 @@ pub fn prospero_size_sweep(c: &mut Criterion) { image_size: size, tile_sizes: fidget::vm::VmShape::tile_sizes_2d().to_vec(), threads: 8, + ..Default::default() }; group.bench_function(BenchmarkId::new("vm", size), move |b| { b.iter(|| { @@ -38,6 +39,7 @@ pub fn prospero_size_sweep(c: &mut Criterion) { image_size: size, tile_sizes: fidget::jit::JitShape::tile_sizes_2d().to_vec(), threads: 8, + ..Default::default() }; group.bench_function(BenchmarkId::new("jit", size), move |b| { b.iter(|| { @@ -67,6 +69,7 @@ pub fn prospero_thread_sweep(c: &mut Criterion) { image_size: 1024, tile_sizes: fidget::vm::VmShape::tile_sizes_2d().to_vec(), threads, + ..Default::default() }; group.bench_function(BenchmarkId::new("vm", threads), move |b| { b.iter(|| { @@ -84,6 +87,7 @@ pub fn prospero_thread_sweep(c: &mut Criterion) { image_size: 1024, tile_sizes: fidget::jit::JitShape::tile_sizes_2d().to_vec(), threads, + ..Default::default() }; group.bench_function(BenchmarkId::new("jit", threads), move |b| { b.iter(|| { diff --git a/fidget/src/core/mod.rs b/fidget/src/core/mod.rs index b092ab99..881a69c3 100644 --- a/fidget/src/core/mod.rs +++ b/fidget/src/core/mod.rs @@ -58,6 +58,7 @@ pub use context::Context; pub mod compiler; pub mod eval; +pub mod shape; pub mod types; pub mod vm; diff --git a/fidget/src/core/shape/bounds.rs b/fidget/src/core/shape/bounds.rs new file mode 100644 index 00000000..914ab297 --- /dev/null +++ b/fidget/src/core/shape/bounds.rs @@ -0,0 +1,126 @@ +use nalgebra::{ + allocator::Allocator, Const, DefaultAllocator, DimNameAdd, DimNameSub, + DimNameSum, OVector, Transform, U1, +}; + +/// A bounded region in space, typically used as a render region +/// +/// Right now, all spatial operations take place in a cubical region, so we +/// specify bounds as a center point and region size. +#[derive(Copy, Clone, Debug)] +pub struct Bounds { + /// Center of the bounds + pub center: OVector>, + + /// Size of the bounds in each direction + /// + /// The full bounds are given by `[center - size, center + size]` on each + /// axis. + pub size: f32, +} + +impl Default for Bounds { + /// By default, the bounds are the `[-1, +1]` region + fn default() -> Self { + let center = OVector::>::zeros(); + Self { center, size: 1.0 } + } +} + +impl Bounds +where + Const: DimNameAdd, + DefaultAllocator: + Allocator, U1>, DimNameSum, U1>>, + as DimNameAdd>>::Output: DimNameSub, +{ + /// Returns a homogeneous transform matrix for these bounds + /// + /// When this matrix is applied, the `[-1, +1]` region (used for all + /// rendering operations) will be remapped to the original bounds. + pub fn transform(&self) -> Transform { + let mut t = nalgebra::Translation::::identity(); + t.vector = self.center / self.size; + + let mut out = Transform::::default(); + out.matrix_mut().append_scaling_mut(self.size); + out *= t; + + out + } +} + +#[cfg(test)] +mod test { + use super::*; + use nalgebra::{Point2, Vector2}; + + #[test] + fn bounds_default() { + let b = Bounds::default(); + let t = b.transform(); + assert_eq!( + t.transform_point(&Point2::new(-1.0, -1.0)), + Point2::new(-1.0, -1.0) + ); + assert_eq!( + t.transform_point(&Point2::new(0.5, 0.0)), + Point2::new(0.5, 0.0) + ); + } + + #[test] + fn bounds_scale() { + let b = Bounds { + center: Vector2::zeros(), + size: 0.5, + }; + let t = b.transform(); + assert_eq!( + t.transform_point(&Point2::new(-1.0, -1.0)), + Point2::new(-0.5, -0.5) + ); + assert_eq!( + t.transform_point(&Point2::new(1.0, 0.0)), + Point2::new(0.5, 0.0) + ); + } + + #[test] + fn bounds_translate() { + let b = Bounds { + center: Vector2::new(1.0, 2.0), + size: 1.0, + }; + let t = b.transform(); + assert_eq!( + t.transform_point(&Point2::new(-1.0, -1.0)), + Point2::new(0.0, 1.0) + ); + assert_eq!( + t.transform_point(&Point2::new(1.0, 0.0)), + Point2::new(2.0, 2.0) + ); + } + + #[test] + fn bounds_translate_scale() { + let b = Bounds { + center: Vector2::new(0.5, 0.5), + size: 0.5, + }; + let t = b.transform(); + assert_eq!( + t.transform_point(&Point2::new(0.0, 0.0)), + Point2::new(0.5, 0.5) + ); + assert_eq!( + t.transform_point(&Point2::new(-1.0, -1.0)), + Point2::new(0.0, 0.0) + ); + assert_eq!( + t.transform_point(&Point2::new(1.0, 1.0)), + Point2::new(1.0, 1.0) + ); + } +} diff --git a/fidget/src/core/shape/mod.rs b/fidget/src/core/shape/mod.rs new file mode 100644 index 00000000..6d86a1dd --- /dev/null +++ b/fidget/src/core/shape/mod.rs @@ -0,0 +1,3 @@ +//! Shape-specific data types +mod bounds; +pub use bounds::Bounds; diff --git a/fidget/src/mesh/mod.rs b/fidget/src/mesh/mod.rs index 394bee08..84fb94c8 100644 --- a/fidget/src/mesh/mod.rs +++ b/fidget/src/mesh/mod.rs @@ -26,7 +26,12 @@ //! //! let (node, ctx) = fidget::rhai::eval("sphere(0, 0, 0, 0.6).call(x, y, z)")?; //! let shape = VmShape::new(&ctx, node)?; -//! let settings = Settings { threads: 8, min_depth: 4, max_depth: 4 }; +//! let settings = Settings { +//! threads: 8, +//! min_depth: 4, +//! max_depth: 4, +//! ..Default::default() +//! }; //! let o = Octree::build(&shape, settings); //! let mesh = o.walk_dual(settings); //! @@ -37,6 +42,8 @@ //! # Ok::<(), fidget::Error>(()) //! ``` +use crate::shape::Bounds; + mod builder; mod cell; mod dc; @@ -92,4 +99,18 @@ pub struct Settings { /// /// This is **much slower**. pub max_depth: u8, + + /// Bounds for meshing + pub bounds: Bounds<3>, +} + +impl Default for Settings { + fn default() -> Self { + Self { + threads: 4, + min_depth: 3, + max_depth: 3, + bounds: Default::default(), + } + } } diff --git a/fidget/src/mesh/octree.rs b/fidget/src/mesh/octree.rs index 7d88363d..46eb6115 100644 --- a/fidget/src/mesh/octree.rs +++ b/fidget/src/mesh/octree.rs @@ -126,8 +126,27 @@ impl Octree { /// Builds an octree to the given depth /// - /// The shape is evaluated on the region `[-1, 1]` on all axes + /// The shape is evaluated on the region specified by `settings.bounds`. pub fn build(shape: &S, settings: Settings) -> Self { + // Transform the shape given our bounds + let t = settings.bounds.transform(); + if t == nalgebra::Transform::identity() { + Self::build_inner(shape, settings) + } else { + let shape = shape.clone().apply_transform(t.into()); + let mut out = Self::build_inner(&shape, settings); + + // Apply the transform from [-1, +1] back to model space + for v in &mut out.verts { + let p: nalgebra::Point3 = v.pos.into(); + let q = t.transform_point(&p); + v.pos = q.coords; + } + out + } + } + + fn build_inner(shape: &S, settings: Settings) -> Self { let eval = Arc::new(EvalGroup::new(shape.clone())); let mut octree = if settings.threads == 0 { @@ -1347,19 +1366,29 @@ mod test { context::bound::{self, BoundContext, BoundNode}, eval::{EzShape, MathShape}, mesh::types::{Edge, X, Y, Z}, + shape::Bounds, vm::VmShape, }; + use nalgebra::Vector3; use std::collections::BTreeMap; const DEPTH0_SINGLE_THREAD: Settings = Settings { min_depth: 0, max_depth: 0, threads: 0, + bounds: Bounds { + center: Vector3::new(0.0, 0.0, 0.0), + size: 1.0, + }, }; const DEPTH1_SINGLE_THREAD: Settings = Settings { min_depth: 1, max_depth: 1, threads: 0, + bounds: Bounds { + center: Vector3::new(0.0, 0.0, 0.0), + size: 1.0, + }, }; fn sphere( @@ -1536,6 +1565,7 @@ mod test { min_depth: 5, max_depth: 5, threads, + ..Default::default() }; let octree = Octree::build(&shape, settings); let sphere_mesh = octree.walk_dual(settings); @@ -1699,6 +1729,7 @@ mod test { min_depth: 2, max_depth: 2, threads, + ..Default::default() }; let octree = Octree::build(&shape, settings); @@ -1763,6 +1794,7 @@ mod test { min_depth: 1, max_depth: 1, threads, + ..Default::default() }; let octree = Octree::build(&tape, settings); assert_eq!( @@ -1784,6 +1816,7 @@ mod test { min_depth: 5, max_depth: 5, threads, + ..Default::default() }; let octree = Octree::build(&tape, settings); let mesh = octree.walk_dual(settings); @@ -1881,6 +1914,7 @@ mod test { min_depth: 4, max_depth: 4, threads: 0, + ..Default::default() }; let octree = Octree::build(&shape, settings).walk_dual(settings); @@ -1889,4 +1923,25 @@ mod test { assert!(n > 0.7 && n < 0.8, "invalid vertex at {v:?}: {n}"); } } + + #[test] + fn test_octree_bounds() { + let ctx = BoundContext::new(); + let shape = sphere(&ctx, [1.0; 3], 0.25); + + let shape: VmShape = shape.convert(); + let center = Vector3::new(1.0, 1.0, 1.0); + let settings = Settings { + min_depth: 4, + max_depth: 4, + threads: 0, + bounds: Bounds { size: 0.5, center }, + }; + + let octree = Octree::build(&shape, settings).walk_dual(settings); + for v in octree.vertices.iter() { + let n = (v - center).norm(); + assert!(n > 0.2 && n < 0.3, "invalid vertex at {v:?}: {n}"); + } + } } diff --git a/fidget/src/render/config.rs b/fidget/src/render/config.rs index 433ee47c..8bcd8eea 100644 --- a/fidget/src/render/config.rs +++ b/fidget/src/render/config.rs @@ -1,4 +1,4 @@ -use crate::{eval::Shape, render::RenderMode, Error}; +use crate::{eval::Shape, render::RenderMode, shape::Bounds, Error}; use nalgebra::{ allocator::Allocator, Const, DefaultAllocator, DimNameAdd, DimNameSub, DimNameSum, U1, @@ -18,6 +18,9 @@ pub struct RenderConfig { /// Number of threads to use; 8 by default pub threads: usize, + + /// Bounds of the rendered image, in shape coordinates + pub bounds: Bounds, } impl Default for RenderConfig { @@ -29,6 +32,7 @@ impl Default for RenderConfig { _ => vec![128, 64, 32, 16, 8], }, threads: 8, + bounds: Default::default(), } } } @@ -81,11 +85,17 @@ where U1, >>::Buffer, >::from_element(-1.0); - let mat = nalgebra::Transform::::identity() - .matrix() - .append_scaling(2.0 / image_size as f32) - .append_scaling(scale) - .append_translation(&v); + + // Build a matrix which transforms from pixel coordinates to [-1, +1] + let mut mat = + nalgebra::Transform::::identity() + .matrix() + .append_scaling(2.0 / image_size as f32) + .append_scaling(scale) + .append_translation(&v); + + // The bounds transform matrix goes from [-1, +1] to model coordinates + mat = self.bounds.transform().matrix() * mat; ( AlignedRenderConfig { @@ -218,6 +228,7 @@ mod test { image_size: 512, tile_sizes: vec![64, 32], threads: 8, + ..Default::default() }; let (aligned, mat) = config.align(); assert_eq!(aligned.image_size, config.image_size); @@ -240,6 +251,7 @@ mod test { image_size: 575, tile_sizes: vec![64, 32], threads: 8, + ..Default::default() }; let (aligned, mat) = config.align(); assert_eq!(aligned.orig_image_size, 575); @@ -262,4 +274,64 @@ mod test { Point2::new(1.0, 1.0) ); } + + #[test] + fn test_bounded_config() { + // Simple alignment + let config: RenderConfig<2> = RenderConfig { + image_size: 512, + tile_sizes: vec![64, 32], + threads: 8, + bounds: Bounds { + center: nalgebra::Vector2::new(0.5, 0.5), + size: 0.5, + }, + }; + let (aligned, mat) = config.align(); + assert_eq!(aligned.image_size, config.image_size); + assert_eq!(aligned.tile_sizes, config.tile_sizes); + assert_eq!(aligned.threads, config.threads); + assert_eq!( + mat.transform_point(&Point2::new(0.0, 0.0)), + Point2::new(0.0, 0.0) + ); + assert_eq!( + mat.transform_point(&Point2::new(512.0, 0.0)), + Point2::new(1.0, 0.0) + ); + assert_eq!( + mat.transform_point(&Point2::new(512.0, 512.0)), + Point2::new(1.0, 1.0) + ); + + let config: RenderConfig<2> = RenderConfig { + image_size: 575, + tile_sizes: vec![64, 32], + threads: 8, + bounds: Bounds { + center: nalgebra::Vector2::new(0.5, 0.5), + size: 0.5, + }, + }; + let (aligned, mat) = config.align(); + assert_eq!(aligned.orig_image_size, 575); + assert_eq!(aligned.image_size, 576); + assert_eq!(aligned.tile_sizes, config.tile_sizes); + assert_eq!(aligned.threads, config.threads); + assert_eq!( + mat.transform_point(&Point2::new(0.0, 0.0)), + Point2::new(0.0, 0.0) + ); + assert_eq!( + mat.transform_point(&Point2::new(config.image_size as f32, 0.0)), + Point2::new(1.0, 0.0) + ); + assert_eq!( + mat.transform_point(&Point2::new( + config.image_size as f32, + config.image_size as f32 + )), + Point2::new(1.0, 1.0) + ); + } } diff --git a/fidget/src/render/render2d.rs b/fidget/src/render/render2d.rs index 17c62f28..a18dc3b8 100644 --- a/fidget/src/render/render2d.rs +++ b/fidget/src/render/render2d.rs @@ -405,6 +405,7 @@ mod test { use super::*; use crate::{ eval::{MathShape, Shape}, + shape::Bounds, vm::{GenericVmShape, VmShape}, Context, }; @@ -416,9 +417,14 @@ mod test { "/../models/quarter.vm" )); - fn render_and_compare(shape: S, expected: &'static str) { + fn render_and_compare_with_bounds( + shape: S, + expected: &'static str, + bounds: Bounds<2>, + ) { let cfg = RenderConfig::<2> { image_size: 32, + bounds, ..RenderConfig::default() }; let out = cfg.run(shape, &BitRenderMode).unwrap(); @@ -440,6 +446,10 @@ mod test { } } + fn render_and_compare(shape: S, expected: &'static str) { + render_and_compare_with_bounds(shape, expected, Bounds::default()) + } + fn check_hi() { let (ctx, root) = Context::from_text(HI.as_bytes()).unwrap(); let shape = S::new(&ctx, root).unwrap(); @@ -522,6 +532,52 @@ mod test { render_and_compare(shape, EXPECTED); } + fn check_hi_bounded() { + let (ctx, root) = Context::from_text(HI.as_bytes()).unwrap(); + let shape = S::new(&ctx, root).unwrap(); + const EXPECTED: &str = " + .XXX............................ + .XXX............................ + .XXX............................ + .XXX............................ + .XXX............................ + .XXX............................ + .XXX............................ + .XXX....................XXX..... + .XXX...................XXXXX.... + .XXX...................XXXXX.... + .XXX...................XXXX..... + .XXX............................ + .XXX............................ + .XXX............................ + .XXX..XXXXXX............XXX..... + .XXXXXXXXXXXXX..........XXX..... + .XXXXXXXXXXXXXXX........XXX..... + .XXXXXX....XXXXX........XXX..... + .XXXXX.......XXXX.......XXX..... + .XXXX.........XXX.......XXX..... + .XXX..........XXXX......XXX..... + .XXX...........XXX......XXX..... + .XXX...........XXX......XXX..... + .XXX...........XXX......XXX..... + .XXX...........XXX......XXX..... + .XXX...........XXX......XXX..... + .XXX...........XXX......XXX..... + .XXX...........XXX......XXX..... + .XXX...........XXX......XXX..... + .XXX...........XXX......XXX..... + .XXX...........XXX......XXX..... + ................................"; + render_and_compare_with_bounds( + shape, + EXPECTED, + Bounds { + center: nalgebra::Vector2::new(0.5, 0.5), + size: 0.5, + }, + ); + } + fn check_quarter() { let (ctx, root) = Context::from_text(QUARTER.as_bytes()).unwrap(); let shape = S::new(&ctx, root).unwrap(); @@ -593,6 +649,22 @@ mod test { check_hi_transformed::(); } + #[test] + fn render_hi_bounded_vm() { + check_hi_bounded::(); + } + + #[test] + fn render_hi_bounded_vm3() { + check_hi_bounded::>(); + } + + #[cfg(feature = "jit")] + #[test] + fn render_hi_bounded_jit() { + check_hi_bounded::(); + } + #[test] fn render_quarter_vm() { check_quarter::(); diff --git a/viewer/src/main.rs b/viewer/src/main.rs index 96b47934..c64554f7 100644 --- a/viewer/src/main.rs +++ b/viewer/src/main.rs @@ -5,7 +5,7 @@ use eframe::egui; use env_logger::Env; use fidget::render::RenderConfig; use log::{debug, error, info}; -use nalgebra::{Transform3, Vector3}; +use nalgebra::{Vector2, Vector3}; use notify::Watcher; use std::{error::Error, path::Path}; @@ -149,23 +149,15 @@ fn render( ) { match mode { RenderMode::TwoD(camera, mode) => { - let mat = Transform3::from_matrix_unchecked( - Transform3::identity() - .matrix() - .append_scaling(camera.scale) - .append_translation(&Vector3::new( - camera.offset.x, - camera.offset.y, - 0.0, - )), - ); - let config = RenderConfig { image_size, tile_sizes: S::tile_sizes_2d().to_vec(), threads: 8, + bounds: fidget::shape::Bounds { + center: Vector2::new(camera.offset.x, camera.offset.y), + size: camera.scale, + }, }; - let shape = shape.apply_transform(mat.into()); match mode { TwoDMode::Color => { @@ -212,23 +204,15 @@ fn render( } } RenderMode::ThreeD(camera, mode) => { - let mat = Transform3::from_matrix_unchecked( - Transform3::identity() - .matrix() - .append_scaling(camera.scale) - .append_translation(&Vector3::new( - camera.offset.x, - camera.offset.y, - 0.0, - )), - ); - let config = RenderConfig { image_size, tile_sizes: S::tile_sizes_2d().to_vec(), threads: 8, + bounds: fidget::shape::Bounds { + center: Vector3::new(camera.offset.x, camera.offset.y, 0.0), + size: camera.scale, + }, }; - let shape = shape.apply_transform(mat.into()); let (depth, color) = fidget::render::render3d(shape, &config); match mode { ThreeDMode::Color => {