brep: docs for schema 4, and a pcurve lift test

docs/native-brep.md describes schema 4: the records with their SMLib and
STEP names, regions and shells, the loop convention, every edge's uses,
the canonical order (a reversed face walked backwards), each curve and
surface kind's parameterisation, pcurve rules, validation, history, the
SCBREP05 envelope and what the truck adapter writes and refuses.

A test lifts every pcurve of a box, a filleted box with a bore and a
sphere onto its face and checks it is its own edge at both ends and the
middle; with a reversed face's pcurves left in truck's oriented order it
fails by 30 mm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-20 23:17:09 -04:00
parent 2f4d6acacb
commit 02abecd52c
2 changed files with 253 additions and 92 deletions
+31
View File
@@ -169,3 +169,34 @@ fn intersections_and_revolutions_reload_exactly() {
assert_eq!(crate::encode_native_body(&decoded).unwrap(), bytes);
assert_eq!(crate::measure(&decoded).unwrap().volume, crate::measure(&cut).unwrap().volume);
}
/// Each oriented edge's pcurve, lifted onto its face's surface, is its edge: checked at the
/// ends and the middle of the edge's interval, on faces of either orientation.
#[test]
fn every_pcurve_lifts_onto_its_own_edge() {
use mt::{MetricSpace, ParametricCurve, ParametricSurface};
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap();
let bore = crate::cylinder(Vec3::new(10., 10., -1.), Vec3::Z, 3., 12.).unwrap();
let holed = crate::boolean(&rounded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
for (name, body) in [("cube", cube), ("holed", holed), ("sphere", sphere)] {
let record = encode(&body, super::super::pcurve::pcurves).unwrap();
for (f, face) in record.faces.iter().zip(body.solid.face_iter()) {
let surface = face.surface();
for l in &f.loops {
let b::Loop::Edges { edges } = l else { continue };
for u in edges {
let e = &record.edges[u.edge as usize];
let curve = load_edge_curve(e.curve.clone(), e.interval).unwrap();
for s in [0., 0.5, 1.] {
let t = e.interval[0] + s * (e.interval[1] - e.interval[0]);
let [x, y] = super::super::pcurve::eval(&u.pcurve, t).unwrap();
let d = surface.subs(x, y).distance(curve.subs(t));
assert!(d <= e.tolerance, "{name}: edge {} at {s}: {d} mm off (forward face {})", u.edge, face.orientation());
}
}
}
}
}
}
+222 -92
View File
@@ -1,4 +1,4 @@
# Native B-rep, schema 3
# Native B-rep, schema 4
This is SolidCraft's geometry data model in `solidcraft_geom::brep`. It has no
modelling-backend dependency. It is the body encoding of a design's model cache
@@ -7,106 +7,211 @@ replacement for external interchange. The container reader, binary codec and
kernel adapters are separate layers. The truck adapter writes it exactly: a body
decoded by the producer that encoded it is that body, number for number.
## Geometry and topology
The records follow SMLib's flat form, `SmBrepData`. It stores one record per
logical use: a kernel keeps two mated halves of each face use, loop use and edge
use at run time, and it rebuilds them from the one record. The types take STEP's
B-rep names. Each type's documentation names its SMLib record and STEP entity.
Coordinates are IEEE-754 binary64, in millimetres, right-handed Z-up. Angles are
radians. Integer references are unsigned 32-bit shell-local indices, never kernel
addresses or runtime IDs. The schema version is an explicit unsigned 32-bit field.
A body contains ordered shells; a shell contains ordered vertices, edges and faces.
Order is retained for SolidCraft's face provenance and naming. Closed and open
shells are representable; material containment is not inferred from shell order.
## Units and numbers
An edge references its start/end vertices in its curve's forward direction. Each
face contains ordered closed loops of oriented edge uses. A reversed use swaps
edge endpoints. Loops record the physical face-oriented traversal. Face `forward` flips only the
surface normal; it never swaps surface parameters or reverses these stored loops.
Adapters must convert their internal boundary conventions separately. Shared edges are stored once per shell. Closed edges may reference the same endpoint vertex; individual adapters may refuse them.
The validator checks references and connected closed loops. It does not certify
watertightness, geometric coincidence, absence of self-intersections or trustworthy
modelling state; backend extraction has additional requirements.
Coordinates are IEEE-754 binary64, in millimetres, right-handed Z-up. Angles and
angular parameters are radians, always. (SMLib's STEP-style analytic parameters
are degrees; an SMLib adapter converts them, the pcurves on analytic faces
included.) References are unsigned 32-bit indices into the body's own lists,
never kernel addresses or runtime IDs. Tolerances are millimetres.
Curves are tagged mathematical records:
## Topology
- `line`: start/end points, linear parameter from 0 to 1.
- `polyline`: ordered points, segment i spans parameter i to i+1.
- `spline`: full nondecreasing knot vector and Cartesian control points. Degree is
knot count minus control-point count minus one.
- `rational_spline`: the same basis with homogeneous `[x*w,y*w,z*w,w]` coefficients,
retaining the original coefficients rather than normalizing/reapproximating them.
- `circle_arc` and `ellipse_arc`: an orthonormal frame, radius/radii and an
increasing angular range. Position is origin + x*rx*cos(t) + y*ry*sin(t).
- `intersection`: an explicit ordinary 3D polynomial spline (knots and Cartesian
control points), positive fitting tolerance in millimetres, and optional two
shell-local face references as hints. No supporting surfaces, leader, backend
solver or parameter-search convention is required to evaluate it. (Read only;
schema 3 writers store the intersection itself, below.)
- `surface_intersection`: the intersection of two surface records, traced from a
`leader` (a polyline or spline near it, its knots as the producer holds them) to
within `tolerance` mm. The curve is the procedural one, not a fit: a different
kernel must trace it again.
| Record | SMLib | STEP | Holds |
|---|---|---|---|
| `Brep` | `SmBrep` | `MANIFOLD_SOLID_BREP`, `BREP_WITH_VOIDS`, `SHELL_BASED_SURFACE_MODEL` | tolerance, regions, shells, faces, edges, vertices |
| `Region` | `SmRegionData` | (implied by shells) | its shells, `void` |
| `Shell` | `SmShellData` | `CLOSED_SHELL`, `OPEN_SHELL`, `WIRE_SHELL`, `VERTEX_SHELL` | oriented faces and wire edges; or wire edges; or one vertex |
| `OrientedFace` | `SmFaceuseData` | `ORIENTED_FACE` | face, `forward` |
| `Face` | `SmFaceData` | `ADVANCED_FACE` | surface, domain, loops |
| `Loop` | `SmLoopData` | `FACE_BOUND` with `EDGE_LOOP` or `VERTEX_LOOP` | oriented edges, or one vertex |
| `OrientedEdge` | `SmEUData` | `ORIENTED_EDGE` and its `PCURVE` | edge, `forward`, pcurve |
| `Edge` | `SmEdgeData` | `EDGE_CURVE` | curve, interval, start and end vertex, tolerance, uses |
| `EdgeUseRef` | `SmEUData`'s radial fields | — | face, loop, position, `upper` |
| `Vertex` | `SmVertexData` | `VERTEX_POINT` | point, tolerance |
Surfaces include planes (origin and independent parameter axes), polynomial and
rational tensor-product splines, and explicit analytic records. Analytic frames
store origin and orthonormal x/y axes; z is x cross y. Angular ranges are radians:
**Regions.** `regions[0]` is the infinite region. It has no outer shell and is
never a void. Each bounded region lists its outer shell first, a closed face
shell, then its inner shells. A bounded region is material unless `void`. A
closed shell separates two regions, so both list it: it is the outer shell of the
region it encloses and an inner shell of the region around it. An open shell, a
wire shell or a vertex shell lies in one region and only that one lists it. A
shell is closed when every edge its faces use is used at least twice among them.
- cylinder: radius, angular range and axial height range;
- cone: radius at origin, half angle, angular range and height range; radius at
height h is radius_at_origin + h*tan(half_angle);
- sphere: radius, longitude and latitude ranges;
- torus: major/minor radii, longitude and meridian ranges;
- revolution: profile, origin, unit axis and angular range. Rotate the profile
around that axis using the right-hand rule. There is no persisted affine matrix
or backend orientation/parameter-swap flag. (Read only in schema 3.)
- placed revolution: a full revolution of a profile about an origin and unit axis,
then mapped by a column-major affine `matrix` (non-singular), with the normal
reversed unless `forward`. Nothing is baked into the profile.
**Shells.** A face shell lists oriented faces, and the wire edges embedded in it.
A wire shell is a connected graph of edges with no faces. A vertex shell is one
isolated point. One shell record stands for both runtime sides of a shell.
A `plane_through` record is a plane given by three points: parameter (0, 0) at
`origin`, (1, 0) at `u_point` and (0, 1) at `v_point`. Writers use it rather than
the axis form, whose axes would be rounded differences of the points.
**Oriented faces.** A face is used by one oriented face, or by two in different
shells with opposite `forward`. `forward` means the face's normal in that shell is
its surface's natural normal, ∂S/∂u × ∂S/∂v. In a closed shell, normals point out
of the material.
Cylinder/cone positions are origin + h*z + r(h)*(cos(u)*x + sin(u)*y).
Sphere positions are origin + r*(cos(v)*(cos(u)*x + sin(u)*y) + sin(v)*z).
Torus positions are origin + (R+r*cos(v))*(cos(u)*x + sin(u)*y) + r*sin(v)*z.
Analytic normals point outward; face orientation can flip them. Each adapter
must preserve the geometric locus and explicit face-oriented boundaries, even
when its runtime uses a different parameterization.
**Loops.** A face's first loop is its outer loop and is an edge loop. A vertex
loop (a pole, a collapsed boundary) is only ever an inner loop. Loops are stored
relative to the surface's natural normal, whichever side a shell uses: seen from
that normal, the outer loop runs counter-clockwise and inner loops clockwise.
This lets one face sit in two shells without contradicting itself. An edge loop
is a closed chain: each oriented edge starts at the vertex where the one before
it ends, and the last ends where the first starts. An oriented edge runs from
its edge's start to its end when `forward`, otherwise from end to start.
**Edges.** An edge's `interval` is the part of its curve it covers, min < max.
Its `vertices` are at interval min and max; they are equal for a closed edge.
`uses` lists every oriented edge on it, each once: the primary use first (the
first one the canonical walk meets), then the rest in radial order, the
right-hand rule about the edge's direction. A wire edge has no uses. `upper` says
the radial cycle enters that use on the side of the surface's natural normal.
With loops stored relative to the natural normal it follows from the use:
`upper` is always `!forward` of the oriented edge it names.
**Tolerances.** The body, every edge and every vertex carry a tolerance, finite
and positive, never optional. The body's is SMLib's intersection tolerance.
## Canonical order
The lists are in one backend-independent order, and every adapter produces it. A
face's index in `faces` is its face index everywhere: in history, face sources,
paint and persistent names. The same holds for edges and vertices.
1. Regions: the infinite region, then bounded regions in the producer's order.
2. Shells and faces in order of first appearance, walking regions → shells →
oriented faces.
3. Edges in order of first appearance, walking faces → loops (outer first) →
oriented edges, then wire edges walking shells. A face whose first oriented
face is reversed is walked backwards: each of its loops from its last
oriented edge to its first. That is how a reversed face is traversed.
4. Vertices in order of first appearance, walking edges (start, then end), then
vertex loops walking faces → loops, then vertex shells.
Nothing may be listed that the walk does not reach.
## Geometry
Curves and surfaces are unbounded. An edge's bounds are its `interval`, a face's
its loops inside its `domain`. Each kind's parameter is stated exactly. For the
kinds the truck adapter writes (`line`, `spline`, `rational_spline`,
`surface_intersection`, `plane_through`, `placed_revolution`) the parameter is
truck's own evaluation (`ParametricCurve::subs`, `ParametricSurface::subs`).
Analytic frames store an origin and orthonormal x and y axes; z = x × y.
### Curves
| Kind | Point at t | Range of t |
|---|---|---|
| `line` {start, end} | start + t (end − start): 0 at start, 1 at end | unbounded |
| `circle` {frame, radius} | origin + r (cos t x + sin t y) | periodic |
| `ellipse` {frame, radii} | origin + r₀ cos t x + r₁ sin t y | periodic |
| `polyline` {points} | segment i spans [i, i + 1] | [0, n − 1] |
| `spline` {knots, points} | B-spline on the full nondecreasing knot vector; degree = knots − points − 1 | [knots[degree], knots[count]] |
| `rational_spline` {knots, homogeneous} | the same basis, homogeneous `[x·w, y·w, z·w, w]` coefficients as the producer holds them | as `spline` |
| `surface_intersection` {surfaces, leader, tolerance} | the intersection of two surface records, traced from `leader` (a polyline or spline near it) to within `tolerance` mm; t is the leader's | the leader's |
A `surface_intersection` is the procedural curve itself, not a fit: another
kernel traces it again.
### Surfaces
Natural normals are ∂S/∂u × ∂S/∂v.
| Kind | Point at (u, v) | u | v |
|---|---|---|---|
| `plane` {origin, u_axis, v_axis} | origin + u u_axis + v v_axis | unbounded | unbounded |
| `plane_through` {origin, u_point, v_point} | origin + u (u_point − origin) + v (v_point − origin) | unbounded | unbounded |
| `cylinder` {frame, radius} | origin + v z + r (cos u x + sin u y) | angle, periodic | height, unbounded |
| `cone` {frame, radius_at_origin, half_angle} | origin + v z + r(v) (cos u x + sin u y), r(v) = radius_at_origin + v tan(half_angle) | angle, periodic | height, from the apex up |
| `sphere` {frame, radius} | origin + r (cos v (cos u x + sin u y) + sin v z) | longitude, periodic | latitude, [−π/2, π/2] |
| `torus` {frame, major_radius, minor_radius} | origin + (R + r cos v)(cos u x + sin u y) + r sin v z | longitude, periodic | meridian angle, periodic |
| `spline` {u_knots, v_knots, points} | tensor-product B-spline, `points[i][j]` with i along u | u knot domain | v knot domain |
| `rational_spline` {u_knots, v_knots, homogeneous} | the same, homogeneous coefficients | u knot domain | v knot domain |
| `placed_revolution` {profile, origin, axis, matrix, forward} | M · R(angle) · profile(s): the profile revolved about origin and unit axis by the right-hand rule, then mapped by the column-major affine matrix M | `forward`: s, the profile's parameter; else the angle | `forward`: the angle, [0, 2π); else s |
A `plane_through` is a plane given by three points, as its producer holds them;
writers use it rather than the axis form, whose axes would be rounded
differences of the points. A `placed_revolution` keeps the revolution exactly as
its producer holds it (truck's `RevolutedCurve` under a `Processor`): nothing is
baked into the profile, and `forward: false` swaps u and v, which reverses the
natural normal.
### Parameter ranges
An edge interval and each side of a face domain are finite with min < max. In a
periodic direction they may start anywhere and run up to one turn (2π), across
the seam. Otherwise they lie inside the natural range: a spline's knot domain, a
polyline's [0, n − 1], a sphere's latitudes, a cone above its apex.
### Pcurves
Every oriented edge carries its pcurve: the edge in its face's (u, v), as a 2D
B-spline (`Curve2`: `spline`, or `rational_spline` with `[u·w, v·w, w]`
coefficients). There is no polyline or analytic 2D kind. 2D lines and arcs are
exact as degree-1 and rational quadratic splines; anything else is a fitted
B-spline. A pcurve lies on the edge's own interval and runs from interval min to
max, whatever the oriented edge's direction. Its interval must equal the edge's
within 10⁻⁹ of the interval's magnitude (at least 1), room for a linear remap's
rounding. A seam edge has two uses on one face, each with its own pcurve, a
period apart. On a periodic surface a loop's pcurves are unwrapped, so each one
starts where the one before it ends.
## Validation and trust
Schema 3 caps 1,024 shells, 1,000,000 combined topology/geometry items, geometry
nesting at 16, spline degree at 16 and control points per spline dimension at
8,192. Numbers must be finite and bounded in magnitude by 1e12. Knot order, active
spline domain, multiplicities, rectangular grids, positive rational weights,
`Brep::validate` checks structure and resources without a kernel. It caps 1,024
shells, 1,000,000 combined topology and geometry items, geometry nesting at 16,
spline degree at 16 and control points per spline direction at 4,096. Numbers
must be finite and at most 1e12 in magnitude. It checks knot order, active spline
domains, multiplicities, rectangular grids, positive rational weights,
nondegenerate planes, orthonormal frames, unit revolution axes, positive radii,
valid angular/height ranges and intersection face references are checked before an adapter is called. These are resource and structural guards,
not a geometric validity certificate. The binary reader must additionally bound
ring tori, cone dimensions and tolerances. Then the topology: every index in
range; the regions and shells as described above; each face used by one or two
oriented faces; first loops are edge loops and vertex loops are inner; loops are
closed chains through shared vertices; every edge's `uses` lists exactly its uses,
once each, primary first, with `upper` as stated; pcurve intervals; parameter
ranges; and the canonical order.
These are structural and resource guards, not a geometric validity certificate.
The validator does not check that edges lie on their surfaces, that pcurves lift
onto their edges, that a closed shell is watertight or that shells nest as the
regions say. A backend's adapter checks what it needs. The binary reader bounds
bytes, allocation and nesting before creating these structures.
Schema tests build and validate an open triangular face without constructing or
linking a kernel body. Kernel-specific structures must appear only inside adapters;
they must never acquire authority over persisted field names or encodings. A
second backend still requires an implemented and tested adapter before claiming
cross-kernel interoperability.
Schema tests build and validate one minimal body of each form (an open sheet, a
closed shell, a seam edge, a vertex loop with a wire and a vertex shell, a void)
and one fixture per defect, without a kernel.
## History
The runtime history types (`FaceOrigin`, `Change`, `FaceProv`, `Side`,
`EdgeOrigin`, `EdgeProv`, `History`) live in `solidcraft_geom::brep::history`, so
a backend can report history without the kernel crate. Indices are canonical face
and edge indices; operands are numbered by each operation. An operation returns
history beside the B-rep. A backend that cannot report it returns none and never
guesses. The saved form is the envelope's own records (below).
Face-source certificates, operation history, appearances and persistent document
identities belong to a versioned SolidCraft body/state envelope around this geometry.
They must be preserved by the cache codec, not reconstructed from face counts or
floating-point comparisons. A decoded cache remains provisional until authoritative
re-evaluation finishes. The document and original blobs remain sufficient to reopen
when a cache, representation, producer or adapter is unsupported.
identities belong to a versioned SolidCraft envelope around this geometry. They
must be preserved by the cache codec, not reconstructed from face counts or
floating-point comparisons. A decoded cache remains provisional until
authoritative re-evaluation finishes. The document and original blobs remain
sufficient to reopen when a cache, representation, producer or adapter is
unsupported.
## Body binary envelope, version 4
## Body binary envelope, version 5
`SCBREP04` is the eight-byte envelope signature. Geometry schema 3 is nested in
`SCBREP05` is the eight-byte envelope signature. Geometry schema 4 is nested in
`solid`; metadata schema 1 is nested in `metadata`; `runtime` holds what the body
measured about itself that later operations read (its size, from which kernel
tolerances follow, and the orientation mesh's missing-face count), so a decoded
body works to the same tolerances. These are separate version numbers because
each contract can evolve independently. Signatures 01 and 02 were development
prototypes; 02 introduced the explicit geometry schema while 03 also separated
metadata from runtime Rust types and 04 made the encoding exact. Unsupported
versions are cache misses, not design-file migration failures.
prototypes; 02 introduced the explicit geometry schema, 03 separated metadata
from runtime Rust types, 04 made the encoding exact and 05 carries schema 4.
Unsupported versions are cache misses, not design-file migration failures: a
design whose cache holds an older signature opens and is evaluated again.
The envelope uses its own explicitly versioned records for color, paint, authored
boundaries, face-source certificates, operation history and boundary protection.
@@ -125,17 +230,42 @@ keys, unknown tags, non-finite numbers and trailing bytes. Limits per body are
256 MiB, 20,000,000 values, depth 48, 1,000,000 entries per collection and 1 MiB per
string; a design's cache bounds the total (see the container format).
The truck adapter writes every curve and surface as truck holds it: lines,
B-spline and NURBS coefficients, the three points of a plane, a revolution's
profile, axis, matrix and orientation, and an intersection's two surfaces, leader
and tolerance. Decoding builds the same structures without the constructors that
would round them (normalizing an axis, composing a matrix with the identity), and
rebuilds each shell's topology as recorded, including closed edges on one vertex
and seam edges a wire uses twice. On reading it refuses standalone polylines,
unclamped spline domains and non-full angular carrier surfaces in the portable
records it does not write. Circle/ellipse arcs use exact rational quadratic loci;
the adapter may reparameterize them. Normal flips and backend boundary orientation
are converted separately.
## The truck adapter
Truck keeps a face's wires relative to its surface and flips the face with an
orientation flag. That is schema 4's convention: the adapter writes truck's wires
as the loops and the flag as `forward`, with no wire reversed. It numbers faces
in `face_iter` order and edges in first appearance through `edge_iter`, which
walks a reversed face backwards, so the record's indices are the kernel's.
It writes every curve and surface as truck holds it: lines, B-spline and NURBS
coefficients, the three points of a plane, a revolution's profile, axis, matrix
and orientation, and an intersection's two surfaces, leader and tolerance. An
edge's interval is its truck curve's parameter range. Pcurves come from
`native::pcurve`, exact on planes and fitted elsewhere; a face's domain is the
box around its pcurves' control points, clamped to the surface's natural domain
and at most one turn in a periodic direction. Every tolerance is the body's face
tolerance, 10⁻⁶ of its size and at least 10⁻⁵ mm, how far later operations take
its faces to be from its edges.
Regions come from truck's shells. Open shells lie in the infinite region. One
closed shell bounds one material region. With several closed shells, a
tessellated nesting test (the one `Body::lumps` uses) finds which bound material
and which voids, and what lies inside what. If truck's shell order is not the
canonical one (a void listed before another lump), or open and several closed
shells mix, the adapter refuses and the body is a cache miss. So it does for an
edge with more than two uses, whose radial order would need geometry.
Decoding builds the same structures without the constructors that would round
them (normalizing an axis, composing a matrix with the identity), and rebuilds
each shell's topology as recorded, including closed edges on one vertex and seam
edges a wire uses twice. It ignores pcurves, regions and domains, which truck
does not keep. It refuses what truck cannot hold: wire and vertex shells, wire
edges in a face shell, vertex loops, a face used by two shells, standalone
polylines, unclamped spline domains and a spline or intersection edge on part of
its curve. It loads circles and ellipses as exact rational arcs over the edge's
interval, and analytic surfaces as revolutions, which truck parameterizes its
own way.
Hashes prove which document and payload a cache belongs to, not who wrote it: the
container's key ties a cache to the producer, application build and platform that
@@ -143,11 +273,11 @@ computed it, and the decoder applies the structural and resource checks above to
every body. A second backend needs its own tested adapter before cross-kernel
interoperability can be claimed.
The box-and-fillet golden JSON fixture pins the decoded version-4 envelope,
The box-and-fillet golden JSON fixture pins the decoded version-5 envelope,
field names, tags, topology, geometry and metadata. Integer and structural values
are exact; floating coefficients allow 1e-10 relative/absolute platform rounding.
The signature is asserted separately. This protects the wire contract against
accidental runtime-refactor changes.
The signature is asserted separately. `NATIVE_BREP_BLESS=1` rewrites the fixture
for an intended change.
## Bounded provisional display