diff --git a/crates/kernel/src/native/adapter_tests.rs b/crates/kernel/src/native/adapter_tests.rs index 3f51529988..f4e6097ada 100644 --- a/crates/kernel/src/native/adapter_tests.rs +++ b/crates/kernel/src/native/adapter_tests.rs @@ -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()); + } + } + } + } + } +} diff --git a/docs/native-brep.md b/docs/native-brep.md index ab4157d4da..f88c202d0a 100644 --- a/docs/native-brep.md +++ b/docs/native-brep.md @@ -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