Skip to content

Commit 570a658

Browse files
committed
Merge hyperreal into main
# Conflicts: # PORTING_PLAN.md # src/io/stl.rs # src/main.rs # src/mesh/mod.rs # src/polygon.rs # src/sketch/extrudes.rs # src/tests.rs
2 parents 3452d88 + 4dac201 commit 570a658

17 files changed

Lines changed: 235 additions & 58 deletions

PORTING_PLAN.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,32 @@ Those properties map directly to several current `csgrs` pain points: finite
2323
coordinate enforcement, deterministic map/set keys, explicit NaN/infinity
2424
policy, and centralized approximate comparisons.
2525

26+
## Branch and commit discipline during the port
27+
28+
Porting work should happen on a branch named `hyperreal` in each repository
29+
touched by the migration. This applies especially to:
30+
31+
- `csgrs`
32+
- `spade`
33+
- `boolmesh`
34+
- `curvo`
35+
- `voxelis`
36+
37+
The default branches should be treated as integration targets, not active
38+
porting branches. Before changing a repository for the port, switch that
39+
repository to its `hyperreal` branch or create it from the appropriate upstream
40+
base if it does not exist yet.
41+
42+
During the porting period, each successful change should be verified, committed,
43+
and pushed automatically to that repository's `hyperreal` branch before moving
44+
on to the next independent change. Cross-repo work should be split into
45+
repository-local commits so `csgrs`, `spade`, `boolmesh`, `curvo`, and `voxelis`
46+
each retain a reviewable history of the port.
47+
48+
After every successful commit and push, take another implementation turn against
49+
the next incomplete item in this plan. Continue that cycle until the plan is
50+
finished, tests expose a blocker, or a design decision requires human review.
51+
2652
## Target ownership model
2753

2854
The stack should have clear vertical responsibilities:
@@ -40,12 +66,24 @@ csgrs = CSG objects, modeling operations, topology, metadata, IO, and us
4066

4167
- exact rationals
4268
- symbolic and computable reals
69+
- shared reduced expression machinery with symbolic leaves
70+
- standard-real solver variables and formal infinitesimal perturbation leaves
4371
- lazy approximation
4472
- sign, zero, magnitude, and structural facts
4573
- refinement and conservative comparison support
4674

4775
It should not own CSG, polygon, mesh, or CAD-specific geometry types.
4876

77+
The machinery needed for the feature that gives `hyperreal` its name should be
78+
developed together with the machinery needed to map a SolveSpace-style solver
79+
onto `hyperreal`, but the semantics should remain distinct. Both need reduced
80+
expression graphs, symbolic leaves, dependency sets, structural facts,
81+
derivative hooks, and cached evaluation. Solver variables are unknown standard
82+
reals that are bound by an evaluation context during iterative solving.
83+
Infinitesimal perturbations are ordered formal terms used for exact
84+
lexicographic signs, tie-breaking, degeneracy handling, and simulation of
85+
simplicity; they are not ordinary variables to solve for numerically.
86+
4987
### `hyperlattice`
5088

5189
`hyperlattice` should own the general linear algebra layer:
@@ -356,6 +394,36 @@ yet operate on the new numeric stack:
356394
runners, and any other non-`csgrs` geometry implementation used during the
357395
port
358396

397+
External geometry kernels should be handled on their own `hyperreal` branches.
398+
In particular, `spade`, `boolmesh`, `curvo`, and `voxelis` should not receive
399+
long-running uncommitted local patches during the `csgrs` migration. A verified
400+
change in one of those libraries should be committed and pushed to that
401+
library's `hyperreal` branch before `csgrs` is updated to depend on it.
402+
403+
When a needed function is missing from `hyperlattice`, `hyperlimit`,
404+
`hypersolve`, or a future `hyperphysics` crate, the normal path should be to
405+
borrow the algorithm from an appropriately licensed crate, port it to
406+
`hyperreal`-compatible scalar and fact machinery, and extend the correct stack
407+
crate here. Crates such as `nalgebra` are valid sources when their licenses,
408+
attribution requirements, and implementation boundaries are compatible with the
409+
target crate. The goal is not to keep permanent f64-only adapter islands; it is
410+
to move useful, well-understood algorithms into the `hyperreal` stack so they
411+
share consistency, structural facts, exact/perturbed predicate behavior, and
412+
performance work with the rest of the system.
413+
414+
Use this path for crates that should ultimately be ported to `hyperreal` for
415+
consistency and performance:
416+
417+
- linear algebra, transforms, decomposition, and dense/sparse numeric kernels
418+
belong in `hyperlattice`
419+
- robust geometric predicates, carriers, classification helpers, and
420+
degeneracy policies belong in `hyperlimit`
421+
- solver residual, Jacobian, rank, projection, and constraint scheduling
422+
helpers belong in `hypersolve`
423+
- physical simulation, dynamics, collision-response policy, material behavior,
424+
or other non-geometric physics primitives should live in a separate
425+
`hyperphysics` crate if they do not fit the existing ownership boundaries
426+
359427
Keep these adapters private at first. The first milestone is internal
360428
correctness, not public API churn.
361429

@@ -569,6 +637,41 @@ At this point, the semantic boundary should already be correct:
569637
The hyperreal port should therefore focus on backend behavior, performance, and
570638
additional certificates rather than rewriting `csgrs` modeling logic again.
571639

640+
This phase should align with the `hypersolve` / SolveSpace-style symbolic
641+
variable work in `hyperreal`. The shared substrate should support:
642+
643+
- expression nodes with symbolic leaves
644+
- dependency and independence facts
645+
- structural sign, zero, magnitude, and domain facts before full evaluation
646+
- reduced-expression caching across repeated queries
647+
- derivative hooks for solver residuals and, where useful, perturbation
648+
propagation
649+
- bounded simplification so solver equations and infinitesimal series do not
650+
grow without control
651+
652+
The first infinitesimal target should be CAD-useful ordered perturbations, not a
653+
general nonstandard-analysis universe. A practical model is a finite
654+
lexicographic perturbation tower:
655+
656+
```text
657+
standard Real + a1*eps + a2*eps^2 + ...
658+
```
659+
660+
or an equivalent ordered perturbation-term representation. `hyperlimit` should
661+
be able to use these terms to decide predicate signs in degenerate cases without
662+
inventing ad hoc epsilon constants. `hypersolve` should use the same expression
663+
and fact infrastructure for standard-real variables, residuals, Jacobians, and
664+
rank diagnostics. The two paths should share representation, reduction,
665+
caching, and derivative infrastructure while keeping their policy layers
666+
separate:
667+
668+
- solver symbols are bound by an evaluation context and participate in numeric
669+
iteration
670+
- infinitesimal symbols are ordered formal perturbations and participate in
671+
lexicographic comparison
672+
- `csgrs` consumes the resulting classifications and certificates, but still
673+
owns CSG topology, metadata, and modeling policy
674+
572675
### Phase 10: Public API cleanup
573676

574677
After internals are stable:
@@ -1818,6 +1921,13 @@ Show-off examples:
18181921

18191922
## Design rules during the port
18201923

1924+
- Work on the `hyperreal` branch of each affected repository, especially
1925+
`csgrs`, `spade`, `boolmesh`, `curvo`, and `voxelis`.
1926+
- Commit and push each verified successful change to that repository's
1927+
`hyperreal` branch before starting the next independent change.
1928+
- After each successful commit and push, take another implementation turn on the
1929+
next unfinished porting-plan item, continuing until the plan is complete or a
1930+
blocker requires review.
18211931
- Lower crates provide facts; `csgrs` makes modeling decisions.
18221932
- Predicate uncertainty should be represented explicitly until `csgrs` decides
18231933
how to handle it.

src/csg.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pub trait CSG: Sized + Clone {
4848
/// ```
4949
/// use csgrs::mesh::Mesh;
5050
/// use csgrs::csg::CSG;
51-
/// let mesh = Mesh::<()>::cube(1.0, None).translate(2.0, 1.0, -2.0);
51+
/// let mesh = Mesh::<()>::cube(1.0, ()).translate(2.0, 1.0, -2.0);
5252
/// let floated = mesh.float();
5353
/// assert_eq!(floated.bounding_box().mins.z, 0.0);
5454
/// ```

src/errors.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,78 @@
33
use crate::float_types::Real;
44
use nalgebra::Point3;
55

6+
/// Coordinate validation failure for a single point.
7+
#[derive(Clone, Debug, thiserror::Error, PartialEq)]
8+
pub enum PointError {
9+
#[error("point {0:?} has NaN fields")]
10+
NaN(Point3<Real>),
11+
#[error("point {0:?} has infinite fields")]
12+
Infinite(Point3<Real>),
13+
}
14+
615
/// All the possible validation issues we might encounter,
7-
#[derive(Debug, Clone, PartialEq)]
16+
#[derive(Debug, Clone, thiserror::Error, PartialEq)]
17+
#[non_exhaustive]
818
pub enum ValidationError {
919
/// (RepeatedPoint) Two consecutive coords are identical
20+
#[error("point {0:?} is repeated consecutively")]
1021
RepeatedPoint(Point3<Real>),
1122
/// (HoleOutsideShell) A hole is *not* contained by its outer shell
23+
#[error("hole is not contained by its outer shell near {0:?}")]
1224
HoleOutsideShell(Point3<Real>),
1325
/// (NestedHoles) A hole is nested inside another hole
26+
#[error("hole is nested inside another hole near {0:?}")]
1427
NestedHoles(Point3<Real>),
1528
/// (DisconnectedInterior) The interior is disconnected
29+
#[error("interior is disconnected near {0:?}")]
1630
DisconnectedInterior(Point3<Real>),
1731
/// (SelfIntersection) A polygon self‐intersects
32+
#[error("polygon self-intersects near {0:?}")]
1833
SelfIntersection(Point3<Real>),
1934
/// (RingSelfIntersection) A linear ring has a self‐intersection
35+
#[error("linear ring self-intersects near {0:?}")]
2036
RingSelfIntersection(Point3<Real>),
2137
/// (NestedShells) Two outer shells are nested incorrectly
38+
#[error("outer shells are nested incorrectly near {0:?}")]
2239
NestedShells(Point3<Real>),
2340
/// (TooFewPoints) A ring or line has fewer than the minimal #points
41+
#[error("ring or line has too few points near {0:?}")]
2442
TooFewPoints(Point3<Real>),
2543
/// (InvalidCoordinate) The coordinate has a NaN or infinite
44+
#[error("invalid coordinate {0:?}")]
2645
InvalidCoordinate(Point3<Real>),
46+
/// A more precise invalid-coordinate report.
47+
#[error(transparent)]
48+
PointError(#[from] PointError),
2749
/// (RingNotClosed) The ring's first/last points differ
50+
#[error("ring is not closed near {0:?}")]
2851
RingNotClosed(Point3<Real>),
2952
/// (MismatchedVertices) operation requires polygons with same number of vertices
53+
#[error("operation requires polygons with the same number of vertices")]
3054
MismatchedVertices,
55+
/// Operation requires polygons with the same number of vertices.
56+
#[error("operation requires polygons with the same number of vertices, {left} != {right}")]
57+
MismatchedVertexCount { left: usize, right: usize },
3158
/// (IndexOutOfRange) operation requires polygons with same number of vertices
59+
#[error("index out of range")]
3260
IndexOutOfRange,
61+
/// A required index is outside a collection.
62+
#[error("index {index} is out of range for length {len}")]
63+
IndexOutOfRangeWithLen { index: usize, len: usize },
3364
/// (InvalidArguments) operation requires polygons with same number of vertices
65+
#[error("invalid arguments")]
3466
InvalidArguments,
67+
/// A named integer field is below the supported minimum.
68+
#[error("{name} must not be less than {min}")]
69+
FieldLessThan { name: &'static str, min: i32 },
70+
/// A named real field is below the supported minimum.
71+
#[error("{name} must not be less than {min}")]
72+
FieldLessThanFloat { name: &'static str, min: Real },
73+
/// An inconsistency while building a triangle mesh.
74+
#[error("triangle mesh builder error: {0}")]
75+
TriMeshError(String),
3576
/// In general, anything else
77+
#[error("{0}")]
3678
Other(String, Option<Point3<Real>>),
3779
}
3880

src/io/stl.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@ use stl_io;
1212
/// # use csgrs::mesh::Mesh;
1313
/// # use std::error::Error;
1414
/// # fn main() -> Result<(), Box<dyn Error>> {
15-
/// let mesh = Mesh::<()>::cube(1.0, None);
15+
/// let mesh = Mesh::<()>::cube(1.0, ());
1616
/// let bytes = mesh.to_stl_ascii("my_solid");
17-
/// std::fs::create_dir_all("stl")?;
18-
/// std::fs::write("stl/my_solid.stl", bytes)?;
17+
/// assert!(bytes.starts_with("solid my_solid"));
1918
/// # Ok(())
2019
/// # }
2120
/// ```
@@ -49,10 +48,9 @@ pub fn to_stl_ascii<T: Triangulated3D>(shape: &T, name: &str) -> String {
4948
/// # use csgrs::mesh::Mesh;
5049
/// # use std::error::Error;
5150
/// # fn main() -> Result<(), Box<dyn Error>> {
52-
/// let object = Mesh::<()>::cube(1.0, None);
51+
/// let object = Mesh::<()>::cube(1.0, ());
5352
/// let bytes = object.to_stl_binary("my_solid")?;
54-
/// std::fs::create_dir_all("stl")?;
55-
/// std::fs::write("stl/my_solid.stl", bytes)?;
53+
/// assert!(!bytes.is_empty());
5654
/// # Ok(())
5755
/// # }
5856
/// ```

src/mesh/flatten_slice.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ impl<M: Clone + Debug + Send + Sync> Mesh<M> {
9090
/// use csgrs::mesh::plane::Plane;
9191
/// use csgrs::sketch::Sketch;
9292
/// use nalgebra::Vector3;
93-
/// let cylinder = Mesh::<()>::cylinder(1.0, 2.0, 32, None);
93+
/// let cylinder = Mesh::<()>::cylinder(1.0, 2.0, 32, ());
9494
/// let plane_z0 = Plane::from_normal(Vector3::z(), 0.0);
9595
/// let cross_section = cylinder.slice(plane_z0);
9696
/// // `cross_section` will contain:

src/mesh/mod.rs

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! `Mesh` struct and implementations of the `CSGOps` trait for `Mesh`
22
3+
use crate::errors::ValidationError;
34
use crate::float_types::{
45
parry3d::{bounding_volume::Aabb, query::RayCast, shape::Shape},
56
rapier3d::prelude::{
@@ -357,13 +358,13 @@ impl<M: Clone + Send + Sync + Debug> Mesh<M> {
357358
/// ```
358359
/// use csgrs::mesh::Mesh;
359360
/// use core::num::NonZeroU32;
360-
/// let mut cube: Mesh<()> = Mesh::cube(2.0, None);
361+
/// let mut cube: Mesh<()> = Mesh::cube(2.0, ());
361362
/// // subdivide_triangles(1) => each polygon (quad) is triangulated => 2 triangles => each tri subdivides => 4
362363
/// // So each face with 4 vertices => 2 triangles => each becomes 4 => total 8 per face => 6 faces => 48
363364
/// cube.subdivide_triangles_mut(1.try_into().expect("not zero"));
364365
/// assert_eq!(cube.polygons.len(), 48);
365366
///
366-
/// let mut cube: Mesh<()> = Mesh::cube(2.0, None);
367+
/// let mut cube: Mesh<()> = Mesh::cube(2.0, ());
367368
/// cube.subdivide_triangles_mut(2.try_into().expect("not zero"));
368369
/// assert_eq!(cube.polygons.len(), 192);
369370
/// ```
@@ -597,29 +598,34 @@ impl<M: Clone + Send + Sync + Debug> Mesh<M> {
597598
///
598599
/// ## Errors
599600
/// If any 3d polygon has fewer than 3 vertices, or Parry returns a `TriMeshBuilderError`
600-
pub fn to_rapier_shape(&self) -> SharedShape {
601+
pub fn to_rapier_shape(&self) -> Result<SharedShape, ValidationError> {
601602
let (vertices, indices) = self.get_vertices_and_indices();
602-
let trimesh = TriMesh::new(vertices, indices).unwrap();
603-
SharedShape::new(trimesh)
603+
let trimesh = TriMesh::new(vertices, indices)
604+
.map_err(|err| ValidationError::TriMeshError(format!("{err:?}")))?;
605+
Ok(SharedShape::new(trimesh))
604606
}
605607

606608
/// Convert the polygons in this Mesh to a Parry `TriMesh`.\
607609
/// Useful for collision detection.
608610
///
609611
/// ## Errors
610612
/// If any 3d polygon has fewer than 3 vertices, or Parry returns a `TriMeshBuilderError`
611-
pub fn to_trimesh(&self) -> Option<TriMesh> {
613+
pub fn to_trimesh(&self) -> Result<TriMesh, ValidationError> {
612614
let (vertices, indices) = self.get_vertices_and_indices();
613-
TriMesh::new(vertices, indices).ok()
615+
TriMesh::new(vertices, indices)
616+
.map_err(|err| ValidationError::TriMeshError(format!("{err:?}")))
614617
}
615618

616-
fn cached_trimesh(&self) -> Option<&TriMesh> {
619+
fn cached_trimesh(&self) -> Result<&TriMesh, ValidationError> {
617620
self.query_trimesh
618621
.get_or_init(|| {
619622
let (vertices, indices) = self.get_vertices_and_indices();
620623
TriMesh::new(vertices, indices).ok()
621624
})
622625
.as_ref()
626+
.ok_or_else(|| {
627+
ValidationError::TriMeshError("failed to build triangle mesh".into())
628+
})
623629
}
624630

625631
/// Uses Parry to check if a point is inside a `Mesh`'s as a `TriMesh`.\
@@ -633,7 +639,7 @@ impl<M: Clone + Send + Sync + Debug> Mesh<M> {
633639
/// # use csgrs::mesh::Mesh;
634640
/// # use nalgebra::Point3;
635641
/// # use nalgebra::Vector3;
636-
/// let csg_cube = Mesh::<()>::cube(6.0, None);
642+
/// let csg_cube = Mesh::<()>::cube(6.0, ());
637643
///
638644
/// assert!(csg_cube.contains_vertex(&Point3::new(3.0, 3.0, 3.0)));
639645
/// assert!(csg_cube.contains_vertex(&Point3::new(1.0, 2.0, 5.9)));
@@ -652,15 +658,15 @@ impl<M: Clone + Send + Sync + Debug> Mesh<M> {
652658
pub fn mass_properties(
653659
&self,
654660
density: Real,
655-
) -> (Real, Point3<Real>, Unit<Quaternion<Real>>) {
656-
let trimesh = self.cached_trimesh().unwrap();
661+
) -> Result<(Real, Point3<Real>, Unit<Quaternion<Real>>), ValidationError> {
662+
let trimesh = self.cached_trimesh()?;
657663
let mp = trimesh.mass_properties(density);
658664

659-
(
665+
Ok((
660666
mp.mass(),
661667
mp.local_com, // a Point3<Real>
662668
mp.principal_inertia_local_frame, // a Unit<Quaternion<Real>>
663-
)
669+
))
664670
}
665671

666672
/// Create a Rapier rigid body + collider from this Mesh, using
@@ -673,8 +679,8 @@ impl<M: Clone + Send + Sync + Debug> Mesh<M> {
673679
translation: Vector3<Real>,
674680
rotation: Vector3<Real>, // rotation axis scaled by angle (radians)
675681
density: Real,
676-
) -> RigidBodyHandle {
677-
let shape = self.to_rapier_shape();
682+
) -> Result<RigidBodyHandle, ValidationError> {
683+
let shape = self.to_rapier_shape()?;
678684

679685
// Build a Rapier RigidBody
680686
let rb = RigidBodyBuilder::dynamic()
@@ -688,7 +694,7 @@ impl<M: Clone + Send + Sync + Debug> Mesh<M> {
688694
let coll = ColliderBuilder::new(shape).density(density).build();
689695
co_set.insert_with_parent(coll, rb_handle, rb_set);
690696

691-
rb_handle
697+
Ok(rb_handle)
692698
}
693699

694700
/// Convert a Mesh into a Bevy `Mesh`.

src/mesh/sdf.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ impl<M: Clone + Debug + Send + Sync> Mesh<M> {
2222
/// let max_pt = Point3::new( 2.0, 2.0, 2.0);
2323
/// let iso_value = 0.0; // Typically zero for SDF-based surfaces
2424
///
25-
/// let mesh_shape = Mesh::<()>::sdf(my_sdf, resolution, min_pt, max_pt, iso_value, None);
25+
/// let mesh_shape = Mesh::<()>::sdf(my_sdf, resolution, min_pt, max_pt, iso_value, ());
2626
///
2727
/// // Now `mesh_shape` is your polygon mesh as a Mesh you can union, subtract, or export:
2828
/// let _ = std::fs::write("stl/sdf_sphere.stl", mesh_shape.to_stl_binary("sdf_sphere").unwrap());

0 commit comments

Comments
 (0)