Compare commits

...

2 Commits

Author SHA1 Message Date
bherbruck f56114219b STEP: delete the step-import crate; the goldens and step-export read through the record's Part 21 parser (#14194)
* Move geom's pcurve, label and check into solidcore::eval (pure git mv)

crates/geom/src/brep/{pcurve,pcurve_tests,label,check}.rs move unchanged to
solidcore/crates/solidcore/src/eval/. The next commits retype them to the kernel's evaluation
types and switch their callers; this commit is the move alone so their history follows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval: pcurve, label and check on the kernel's evaluation types

The moved files take eval::geometry's Curve, Curve2, Surface and Frame (the record's kinds,
field for field) and the kernel's evaluator, in place of the record's types and
solidcraft-geom's evaluator. What reads a record as a record leaves: the structural checks
(counts, budgets, a use's interval in its curve's natural range), the edge and face readers
(edge_curve_interval, form_turns on a Face) and the trim request types go to solidcore-ops,
which converts. Results are unchanged: the pcurve keeps its own pole test (the evaluator's
speed rule), knot span search and B-spline cut, not the kernel's collapse; the 80 eval tests
pass, the pcurve suite among them.

Lint-driven rewrites that keep each result: checked indexing, no pre-reserving from input counts,
the loop rotation without a remainder, a clamp that cannot panic on a reversed domain. The
hostile-record cases that depend on the record's checks are kept as no-panic checks here and as
refusals in ops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-ops: pcurve, label and record_check over the record

The record's side of the moved kernel routines: each checks the record's structure as the
record's validation does (one budget per face, a use's interval in its curve's natural range, a
given pcurve as a 2D B-spline), converts through solidcore-convert, calls solidcore::eval and
converts back. The trim request types (TrimFace, TrimEdge, TrimUse, Trims) hold records, so they
live here. No arithmetic: ops' float-arithmetic and geometry-method lints pass unchanged.

The hostile-record refusals the record checks give (empty and non-finite intervals, a NaN
coordinate, a circle of no radius) are tested here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Call pcurve, label and the record checks through solidcore-ops

geom, kernel, smlib and step call solidcore_ops::{pcurve, label, record_check} where they called
solidcraft_geom::brep::{pcurve, label, check}, which moved into solidcore::eval. One
implementation of each remains, in the kernel; geom's brep module no longer declares them.
solidcore-ops becomes a workspace dependency of the four crates (the seam check's public list
already allows it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move geom's record agreement check into solidcore::eval (pure git mv)

crates/geom/src/brep/geometry.rs moves unchanged to solidcore/crates/solidcore/src/eval/agreement.rs
(the kernel's eval already has a geometry module). The next commit retypes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval::agreement on a plain view; ops reads a record into it

The moved check reads a record through agreement::View (edges, vertices, faces and their uses,
in the kernel's evaluation types) in place of the record's Brep, which the kernel never sees.
solidcore-ops' record_check::agreement converts a record's curves, surfaces and pcurves into the
view and calls it; Mismatch, Problem and Record keep their wording. The pole test is the
pcurve's speed rule, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check record agreement through solidcore-ops

kernel, smlib, step and geom's tests call solidcore_ops::record_check::agreement where they
called solidcraft_geom::brep::geometry::check, which moved into solidcore::eval::agreement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave kernel/src/tess on geom's pcurve path until the tess move

crates/kernel/src/tess is moving into solidcore-tess in its own pull request, so its three
pcurve::eval call sites are not edited here: geom::brep::pcurve re-exports
solidcore_ops::pcurve::eval for them, and goes when the tess move switches them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore/bench/step: the STEP reader bake-off harness

fetch.sh builds the corpus at run time from pinned sources (SolidCraft's STEP tests,
dan335/step-conformance, solidcore's fixtures, cadmpeg's fixtures, the NIST PMI and STEP Tools
sets through their SHA-256 pinned fetch scripts), folded by SHA-256; nothing downloaded is
committed. run.py runs readers in the solidcraft-cli eval shape with a timeout and writes
per-file.tsv and summary.md: opened, refused, timed out, all bodies closed, reference volumes
within 0.1% (references.tsv, from formulas), and wall time.

On main cc55f59bf2: 319 files, 195 opened, 135 with every body closed, 23 of 24 reference
volumes, median 0.20 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move SolidCraft's STEP reader into solidcore (pure git mv)

crates/step moves unchanged to solidcore/crates/solidcore-step, and its Part 21 parser to
solidcore-record/src/step/p21.rs, where it takes the place of record's earlier port of this same
file (from a5b71ca2). This commit is the move alone, so the files keep their history and
authors; the next commits make them build in their new places and switch SolidCraft to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-record: the Part 21 parser keeps record's hardening

The moved parser takes back what record's port added in review (#2591): typed errors with the
same messages, entities in id order (BTreeMap), capped parameter and record counts, linear-time
decoding of unterminated escapes, a byte order mark skipped. The file's content is record's as
before the move; its history now runs through SolidCraft's parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: the moved reader as a solidcore workspace crate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: evaluate and form records through the kernel

The moved reader called solidcraft-geom's record evaluator, exact forms and vector helpers. It
now calls the kernel's: solidcore::eval's CurveEval, SurfaceEval, face_flux and form, through a
private `kernel` module that converts records with solidcore-convert, and the kernel's vector
measures on the record's Vec3 (`vec`). geom's form was a copy of the kernel's, function for
function; the face box (`extent`) is the kernel's with poles taken exactly. The B-spline cut is
the kernel's (eval::pcurve::segment), the pole test the pcurve's speed rule, both made public
for this. Profiling spans, which were SolidCraft's, are left out.

The crate keeps SolidCraft's formatting (its own rustfmt.toml) and lints under solidcore's hard
rules: no unsafe, unwrap, expect, panic or unchecked indexing (the 33 indexing sites are
rewritten with iterators and checked access, results unchanged). The step-conformance test reads
SolidCraft's tests/step-conformance where the tree has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* SolidCraft reads and writes STEP through solidcore-step

crates/kernel's STEP import (step_in), STEP export (step_out) and IGES export (iges_out) call
solidcore_step where they called solidcraft_step; crates/step is gone. The Part 21 parse error is
the record's typed error, shown with the same message. solidcore-step joins the crates
SolidCraft may use directly (xtask/solidcore-public.txt) until its build is kernel primitives
under an ops recipe (RFC 0003, 1.6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: bound faces with the pcurves' pole test (form::extent_by)

The bake-off showed the reader on the kernel's form::extent refusing 7 STEP Tools parts
(304-310 boxy_with_*: "loop encloses no (u, v) area") and reading the large parts 2-8x slower
(rear 37.8 s against 18.4 s): extent's exact collapse test classifies the boundary points
differently from the speed rule the reader's faces were always bounded with, and costs a
collapse search per point (the cost in #13655). extent's body now takes the pole test
(form::extent_by; extent keeps the exact one), and the reader passes the pcurves' speed rule.
On those files the result and time are base's again: boxy_with_cylindricity 174839.178,
nist_ftc_06 3323556.516, rear 14.0 s against 16.3 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Kernel rig: drop the gone crates/step paths

crates/step moved to solidcore/crates/solidcore-step, which the shared core (PATHS-ALL:
solidcore/**) already covers: the import and crash areas' PATHS and kernel-rig.yml drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a seam round the tube for a torus face winding around its axis

A torus face bounded by two circles about its axis (a half torus between its rims, the fillet
round a bore) was refused: "a face winding around a torus ... without a seam edge". It now gets
a seam as a band on a cylinder or sphere does, a tube circle at a vertex's angle, running from
the loop the face lies above up the tube to the other loop, so on the face's side whichever half
of the tube that is. On the bake-off 9 bodies that were open shells close: torus_ring (4934.802,
its reference), turbine, nist_ctc_05 (both editions), nist_ftc_06 rd, nist_ftc_11 (both),
nist_stc_06, nist_stc_10; every body closed goes 135 -> 145, reference volumes 23 -> 24 of 24.

bench/step/run.py takes a probe-shaped reader (LABEL=probe:COMMAND, one JSON line) for a
library without SolidCraft's CLI, and runs every reader on a file together so they meet the same
load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: short entity names, POLY_LOOP, FACETED_BREP and plain FACE

What solidcore's step-import read and this reader refused:
- Part 21 short names (CRTPNT, FCSRF, PLYLP, ...) read as their long names, in the record's
  parser (step-import's table, #2582). STEP Tools' clip and socks are written that way.
- A POLY_LOOP bound is a closed polygon: one vertex per point and one line edge per side, an edge
  shared by every loop with the same two points.
- A FACETED_BREP is a manifold solid of such faces (calo, cylcub).
- A plain FACE, with no surface, lies on the plane of its loops (Newell's normal, refused where
  the loop leaves the plane by more than the tolerance).

Tests: a faceted tetrahedron closes with 6 shared edges; short names parse to long ones; the
torus band seam from the commit before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: read topology outside representations, lines by their vertices, inner loops by area

Topology-first reading, as solidcore's step-import and step-brep read files (#2583):
- Topology no representation lists (bare geometry files): crates/kernel reads the file's
  unlisted solids, then its unlisted shells and faces (each face a one-face sheet), through
  solidcore_step::topology::roots, in the file's one representation context. A representation's
  own shell and face items stay out (a presentation's are not bodies).
- A LINE edge whose vertices lie off its LINE (a direction or magnitude written wrong) is the
  segment between its vertices, which the faces it bounds share; other curves still refuse a far
  vertex.
- An inner loop written the way its outer loop runs, on a face whose loops close in (u, v), is
  run the other way.
- More Part 21 short names (product structure, representations, units, FACETED_BREP).

Bake-off (319 files, 220 opened by some reader): opened 195 -> 212, every body closed 135 ->
148, reference volumes 24 of 24 (core 22); median time unchanged (0.123 s -> 0.119 s).
kernel-cases/import 35/35; the kernel's step_in, IGES and lump tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a sphere face bounded by vertex loops at its poles is the whole sphere

A face of a sphere whose bounds are vertex loops alone, at its poles (cadmpeg's
ap242_vertex_loop), was refused as "a face without an edge loop". It is the whole sphere: its
loop becomes a meridian from the south pole to the north and back, which the slit completion
keeps as its seam, as a ball bounded by its seam alone already is. Any other face without an
edge loop is still refused. Test: a ball bounded by vertex loops at its poles closes with one
seam edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a face SMLib refuses is named and left out; a face used twice is one piece

Opening a file never fails over faces that cannot be made: the face is left out, the rest come in
as an open body, and the import's warnings name each face left out with why.
- SMLib's refusal of a record face by its index ("flat arrays: face 90: a NURBS form that is not
  its analytic surface") now names the file's face (face #1202), so it is left out like a face
  the reader refuses. NIST CTC 02 (AP203) opens: 480 of its 487 faces as an open body, the 4
  faces 622 mm off their surfaces and the one SMLib refuses named in the warnings.
- A face the solid's shells use more than once (cadmpeg br02: one face as the outer shell and two
  voids) is read once, and its further uses are named as left out.
- One face read alone is the sheet itself (nothing to stitch).

bench/step/run.py counts the files opened with faces left out, from the import warnings.
Test: a face SMLib refuses by its index is named as the file's face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a solid whose only face cannot be made says which face, why and how far

A file whose one face cannot be made (cadmpeg's pc01, pc03 and tp06 non-coplanar fixtures:
edges 1 to 10 mm off the face's surface) has nothing consistent to open. Its error now reads
"the solid's only face, #30, cannot be made: an edge lies 1 mm off the face's surface" in place of
the count of faces whose pcurves failed. Test: a disk whose circle lies 1.5 mm off its plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: an unclamped B-spline curve is read clamped to its domain

A closed curve written as an unclamped B-spline (uniform knots past its ends, its first points
repeated at its end: cadmpeg's N04 recipes, periodic writers) had no closed-form pcurve on a
plane, so its faces were left out: "no exact pcurve of a B-spline on a plane". It is now read
as the same curve over [knots[p], knots[n]], clamped there by exact knot insertion
(eval::pcurve::segment), as every evaluator and pcurve takes a spline. recipe N04a and N04b open
as 3 closed solids each (5000 and 6000 mm³, as solidcore's step-brep reads them).

Test: a disk bounded by an unclamped periodic quadratic reads, its edge clamped to [0, 4].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a seam round the axis for a torus face winding round its tube

A torus face bounded by two circles round its tube (an elbow's wall between its two ends) was
refused: "a loop winding around a surface more than once or in v". Its loops wind in v, so it
gets a seam the other way about: a circle about the axis at a vertex's angle round the tube,
running from the loop that winds up the tube towards −u (the face lies to the left of its loops)
to the other. band_seam and crossing take the parameter the loops wind in. In STEP Tools'
jack_in_the_box 14 bodies that were open shells close (34 -> 48 of 56).

Test: a quarter elbow closes with a quarter-circle seam about its axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move step-brep's export mapping into step-export, its fixtures into solidcore-step (pure git mv)

brep_to_model (a kernel body as step-import's model, for the writer) moves to step-export, which
RFC 0003 item 1.7 measures against SolidCraft's writer; the two STEP fixtures move to
solidcore-step's tests. The move alone, so their history follows them; the next commit deletes
step-brep's import side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete step-brep's STEP import: one STEP reader, solidcore-step

solidcore had two STEP readers; solidcore-step (SolidCraft's, moved in #13979) now reads what
step-brep's topology-first build read, but the faces it would have to build inconsistently
(#14038's gate). step-brep's import side goes: its build, constructor and UV trims (lib.rs,
constructor.rs, pcurves.rs) and their tests. What it kept for the writer, brep_to_model, is
step-export's now (RFC 0003 item 1.7); its round-trip cases that read back through the deleted
importer go with it. The panic-site list drops step-brep's rows and moves brep_to_model's.

Ported, with their names: the Discord fillet operand reads as one closed solid, and a face no
shell lists is a one-face sheet (crates/kernel step_in tests); rod_doubled_edge names the face
of its folded rim (solidcore-step). The fixtures moved with their history. The reading cases of
step-brep's tests, written by Brennen Herbruck (topology_first, issue_2580_winding_faces,
issue_2581_faceted_brep, issue_2583_topology_first, discord_fillet), are covered by
solidcore-step's tests of seams, tori, faceted solids, unlisted topology and the bake-off.

step-import stays for the goldens and step-export until #14123. Core's code for the 7
jack_in_the_box bodies still open (#14130) is at 2a96a046ca.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: port step-brep's import panic fuzz onto solidcore-step

step-brep's tests/panic_fuzz.rs (#2490, by Brennen Herbruck) went with its import. Its cases
come back with their names:
- panic_fuzz_step_import (solidcore-step): the same mutations (truncation, flipped bytes,
  hostile numbers, broken and cyclic references, dropped and repeated lines, swapped entity
  names, deep nesting) of the Discord fillet and tetra fixtures and of primitives this crate's
  writer writes. Each is parsed, read into records, checked as the kernel takes them, made into
  kernel bodies and written again. Also entity_edit_lands_on_an_entity_name, and a check that
  the written bases read back as solids, so the mutations reach the whole path.
- panic_fuzz_step_flat_decode (step-import, whose flat arrays it decodes).

Run: 3 seeds of 20 000 import cases and 5 000 flat cases, 0 findings, 0 hangs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: rustfmt the panic fuzz harness's doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* panic fuzz harness: keep solidcore's formatting where solidcore-step includes it

solidcore-step formats with SolidCraft's rustfmt.toml, which reached the harness through its
#[path] include; the include is #[rustfmt::skip] now, and the harness is solidcore's as on main
but its doc naming the fuzzes that share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP and pcurves: topology short names; a use's interval refused when empty or not finite

From review:
- The record's parser maps the AP203 short names of the topology too (ADVFC, EDGCRV, EDGLP,
  FCBND, MNSLBR, ORNEDG, VRTPNT): a file written with them had its faces refused.
- solidcore::eval::pcurve refuses a use whose interval is empty, reversed or not finite before
  anything else: a given pcurve was checked against the interval, which a NaN end passed, so
  exact_pcurves returned it as a pcurve. Tests: those intervals are Invalid with and without a
  given pcurve; VRTPNT and FCBND read as their long names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-record: short names checked against ISO's short-names file

Every pair of the table is as http://standards.iso.org/iso/10303/tech/short_names/short-names.txt
gives it, the source cited beside it. One was wrong, from step-import's table: CMCRSG is
COMPOSITE_CURVE_SEGMENT, not COMPOSITE_CURVE (CMPCRV, added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: retire the step-import crate; one STEP parser, the record's p21

step-import's Part 21 parser is gone: the goldens' SMLib comparison, the
--step-corpus run and step-export read through solidcore-record's p21. The
neutral model moves to solidcore-record::step::model (plain data), the mapper
from p21 to that model moves to step-export (it computes frames and angles, so
it cannot live in the record), and the constructor script and flat arrays move
into the goldens crate, their only users.

p21's Exchange now keeps the HEADER's file name, authors and so on (step-export
reads them back). The brep_constructor golden is unchanged (185 of 185 cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cargo.lock: solidcraft-step is now solidcore-step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io: the model cache key no longer names crates/step

The reader moved to solidcore/crates/solidcore-step, which the cache already keys as part of the solidcore tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: a whole turn a rounding short of a quarter line has its form from it (#12760)

Port of #13535's fix in crates/geom's form to the kernel's form, which the STEP reader now uses: a cylinder, cone, sphere or torus face of one whole turn whose seam reads back a rounding short of a quarter line started its form a quarter early, and a tube read back through STEP measured 14 % over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* pcurve docs: a pcurve without a closed form is refused, not fitted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench/step: a malformed --reader is an error; a reader's bad JSON is a refusal, not a crash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: SMLib's first refusal names the file's face, on a turned sheet too

A solid SMLib refused by a face's record index ("flat arrays: face 12") had that index named as a file face only on the reads that left faces out, not the first, so where every face read alone nothing was left out and the faces were stitched instead. solid_part names it, with the face order of the record SMLib was given (a sheet's turned faces reorder it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench/step: a reader or corpus file that cannot be run is a crashed row, not a stopped run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a styled or layered loose shell or face is still read

roots() took any reference to a shell or face as its topology's, so a bare face a STYLED_ITEM or a layer assignment names was skipped and the file read as having no bodies. A presentation's references (styles, layers, invisibility) no longer count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:27:56 -05:00
austin62jake 2215a9a50a Ruled Surface: input and output schemas for surface.ruled (#13134) (#13135)
surface.ruled gets SurfaceRuledParams in a new schema/surface_ruled.rs and replies
with FeatureResult. mode and corners are keyword types in schema/types.rs with the
handler's messages; edges, direction, face and far get types of their own that keep
its messages (far is read in order, so the first bad item gives the same message).
angle and direction are checked whenever given; which mode needs them stays the
handler's.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 17:16:14 +00:00
45 changed files with 1078 additions and 1397 deletions
+5 -1
View File
@@ -20,6 +20,7 @@ use crate::schema::surface::{
UnstitchParams, UntrimParams,
};
use crate::schema::surface_blend::BlendParams;
use crate::schema::surface_ruled::SurfaceRuledParams;
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
@@ -35,6 +36,7 @@ const SURFACE_TRIM_CONTRACT: TypedContract<SurfaceTrimParams> = TypedContract::n
const SURFACE_SWEEP_CONTRACT: TypedContract<SurfaceSweepParams> = TypedContract::new();
const SURFACE_LOFT_CONTRACT: TypedContract<SurfaceLoftParams> = TypedContract::new();
const BLEND_CONTRACT: TypedContract<BlendParams> = TypedContract::new();
const SURFACE_RULED_CONTRACT: TypedContract<SurfaceRuledParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
@@ -91,9 +93,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&OFFSET_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"),
CommandSpec::new("surface.ruled", "Ruled Surface", ruled)
CommandSpec::new("surface.ruled", "Ruled Surface", crate::checked!("surface.ruled", ruled))
.at("SURFACE", "CREATE")
.icon("surface_ruled")
.contract(&SURFACE_RULED_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back); far?: [null|[[x,y,z]]] (per chain edge, the interior control points of a free strip's far curve, as the default curve's handles list them; null: the default). A constant ruling, or one keeping its angle to an arc's axis, gives an exact plane, cylinder or cone; where the rulings turn freely along an edge, the strip is the exact ruled surface to a far curve of its own (from corner to corner, fair, near the rulings' ends). A new surface body"),
CommandSpec::new("surface.blend", "Blend Surface", crate::checked!("surface.blend", blend))
.at("SURFACE", "CREATE")
+1
View File
@@ -137,6 +137,7 @@ pub mod surface_analysis;
pub mod surface_blend;
pub mod surface_boundary;
pub mod surface_match;
pub mod surface_ruled;
pub mod timeline_clipboard;
pub mod ui_prefs;
pub mod view_navigation;
+211
View File
@@ -0,0 +1,211 @@
//! Contract for Ruled Surface (`surface.ruled`) in `cmd/surface.rs` (#13134). It replies with the
//! plain feature reply, [`super::features::FeatureResult`].
//!
//! The handler reads `angle` only in angle mode and `direction` only in angle and direction
//! modes, and says when either is missing there. The contract checks their form whenever they are
//! given and leaves which mode needs them to the handler. Whether the body exists, the points lie
//! on a chain of its edges and on a face, and the expressions evaluate are the handler's to check.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use solidcore_record::Vec3;
// `Vec3Geom::len`, which a direction's check measures as the handler does.
use solidcraft_geom::prelude::*;
use super::check::whole;
use super::types::{AngleExpr, FeatureCommon, LengthExpr, Point3, RuledCornersParam, RuledModeParam, feature_common};
use crate::params;
/// Make a surface body: a strip that reaches `distance` out from a chain of a body's edges, along
/// rulings set by `mode`. A constant ruling, or one keeping its angle to an arc's axis, gives an
/// exact plane, cylinder or cone; where the rulings turn freely along an edge, the strip is the
/// exact ruled surface to a far curve of its own.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SurfaceRuledParams {
/// The body whose edges the strip starts from, by name.
#[serde(deserialize_with = "body")]
pub body: String,
pub edges: RuledEdges,
/// How far the strip reaches from the edges.
pub distance: LengthExpr,
#[serde(default)]
pub mode: Option<RuledModeParam>,
/// Angle mode: the rulings' angle from `direction`, else from the face's normal, turned away
/// from the face (90 deg from the normal is tangent). Required in angle mode; read in no
/// other.
#[serde(default)]
pub angle: Option<AngleExpr>,
#[serde(default)]
pub direction: Option<RuledDirection>,
#[serde(default)]
pub face: Option<RuledFace>,
/// Make the strip on the other side. Default: false.
#[serde(default)]
pub flip: Option<bool>,
#[serde(default)]
pub corners: Option<RuledCornersParam>,
#[serde(default)]
pub far: Option<RuledFar>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// The handler's message for `body` when it isn't text.
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`body` is required"))),
}
}
/// The handler's message for `edges` it can't read: not a list, no points, or over 10,000.
const EDGES_MESSAGE: &str = "`edges` must list points on a chain of the body's edges";
/// The handler's message for `far` it can't read.
const FAR_MESSAGE: &str = "`far` must be a list, per edge, of null or [[x, y, z], ...]";
/// Points on a chain of the body's edges, 1 to 10,000, as `params::vec3` reads each. The handler
/// drops an item that isn't a point and takes the rest; the contract refuses it, with the
/// handler's message for a list it can't read.
#[derive(Clone, Debug, PartialEq)]
pub struct RuledEdges(pub Vec<Vec3>);
impl<'de> Deserialize<'de> for RuledEdges {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let points = v.as_array().and_then(|items| items.iter().map(params::vec3).collect::<Option<Vec<_>>>());
match points {
Some(points) if !points.is_empty() && points.len() <= 10_000 => Ok(RuledEdges(points)),
_ => Err(D::Error::custom(whole(EDGES_MESSAGE))),
}
}
}
impl JsonSchema for RuledEdges {
fn schema_name() -> Cow<'static, str> {
"RuledEdges".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "Points in mm on a chain of the body's edges, open or closed: 1 to 10,000.",
"type": "array",
"items": point,
"minItems": 1,
"maxItems": 10000
})
}
}
/// The rulings' direction in direction mode, or the reference the angle is measured from in angle
/// mode (a pull direction for a draft): not zero. Read in those modes only. The schema can't say
/// "not zero", so a zero direction matches it and the check refuses it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RuledDirection(pub Vec3);
impl<'de> Deserialize<'de> for RuledDirection {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
// As the handler reads it: a point whose length is over 1e-12.
params::vec3(&Value::deserialize(d)?)
.filter(|v| v.len() > 1e-12)
.map(RuledDirection)
.ok_or_else(|| D::Error::custom(whole("`direction` must be a non-zero [x, y, z]")))
}
}
impl JsonSchema for RuledDirection {
fn schema_name() -> Cow<'static, str> {
"RuledDirection".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = generator.subschema_for::<Point3>();
schema.insert(
"description".into(),
"Direction mode: the rulings' direction; angle mode: the reference the angle is measured from (a pull direction for a draft). Not zero. Required in direction mode; read in no other mode but angle.".into(),
);
schema
}
}
/// A point on the face the rulings' directions come from.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RuledFace(pub Vec3);
impl<'de> Deserialize<'de> for RuledFace {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
params::vec3(&Value::deserialize(d)?).map(RuledFace).ok_or_else(|| D::Error::custom(whole("`face` must be [x, y, z] on a face")))
}
}
impl JsonSchema for RuledFace {
fn schema_name() -> Cow<'static, str> {
"RuledFace".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = generator.subschema_for::<Point3>();
schema.insert(
"description".into(),
"A point in mm on the face the rulings' directions come from. Default: the face beside the most picked edges.".into(),
);
schema
}
}
/// A free strip's far-curve handles, per chain edge in order: `null` for the default curve, or the
/// interior control points as the default curve's handles list them. At most 10,000 edges and
/// 100,000 points each. Read in order, as the handler reads it, so the first item it can't read
/// gives the message.
#[derive(Clone, Debug, PartialEq)]
pub struct RuledFar(pub Vec<Option<Vec<Vec3>>>);
impl<'de> Deserialize<'de> for RuledFar {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let lists = v.as_array().filter(|a| a.len() <= 10_000).ok_or_else(|| D::Error::custom(whole(FAR_MESSAGE)))?;
let mut far = Vec::with_capacity(lists.len());
for list in lists {
far.push(match list {
Value::Null => None,
Value::Array(points) if points.len() <= 100_000 => Some(
points
.iter()
.map(params::vec3)
.collect::<Option<Vec<_>>>()
.ok_or_else(|| D::Error::custom(whole("`far` handles must be [x, y, z]")))?,
),
_ => return Err(D::Error::custom(whole(FAR_MESSAGE))),
});
}
Ok(RuledFar(far))
}
}
impl JsonSchema for RuledFar {
fn schema_name() -> Cow<'static, str> {
"RuledFar".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "A free strip's far-curve handles, per chain edge in order: null for the default curve, or the interior control points in mm, as the default curve's handles list them.",
"type": "array",
"maxItems": 10000,
"items": {"anyOf": [{"type": "null"}, {"type": "array", "maxItems": 100000, "items": point}]}
})
}
}
#[cfg(test)]
#[path = "surface_ruled_tests.rs"]
mod tests;
@@ -0,0 +1,188 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::SurfaceRuledParams;
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<SurfaceRuledParams>(),
&[
"angle",
"body",
"body_name",
"body_names",
"corners",
"direction",
"distance",
"edges",
"face",
"far",
"flip",
"mode",
"name",
"participants",
],
);
}
/// Points on the four top edges of [`boxed`]'s `Box`, a chain round its top.
fn rim() -> Value {
json!([[5, 0, 10], [10, 5, 10], [5, 10, 10], [0, 5, 10]])
}
/// A point on `Box`'s top face.
fn top() -> Value {
json!([5, 5, 10])
}
/// A 10 mm cube `Box` from the origin, at `level`.
fn boxed(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"})).unwrap_or_else(|e| panic!("{e}"));
s
}
/// Calls that fail today give the same message in strict, each on a fresh [`boxed`]: what the
/// contract checks (the body, the edges, the keywords, the direction, the face and the far
/// handles) and what it leaves to the handler (a body the design lacks, points off its edges or
/// faces, a distance or angle that doesn't evaluate or has the wrong unit, a zero distance, and
/// the angle or direction a mode needs). Left out: a missing `edges`, and a `distance` that isn't
/// a number or text, whose messages differ on purpose, as for every converted command.
#[test]
fn strict_keeps_todays_errors() {
let with = |extra: Value| {
let mut p = json!({"body": "Box", "edges": rim(), "distance": 2});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
};
let cases = [
json!({}),
json!({"body": 5, "edges": rim(), "distance": 2}),
json!({"body": null, "edges": rim(), "distance": 2}),
json!({"body": "Nope", "edges": rim(), "distance": 2}),
with(json!({"edges": 5})),
with(json!({"edges": []})),
with(json!({"edges": null})),
with(json!({"edges": ["x"]})),
with(json!({"edges": vec![[5, 0, 10]; 10_001]})),
with(json!({"edges": [[500, 500, 500]]})),
json!({"body": "Box", "edges": rim()}),
with(json!({"distance": "nonsense"})),
with(json!({"distance": "30 deg"})),
with(json!({"distance": 0})),
with(json!({"mode": "sideways"})),
with(json!({"mode": "SIDEWAYS"})),
with(json!({"mode": "angle"})),
with(json!({"mode": "angle", "angle": null})),
with(json!({"mode": "angle", "angle": "5 mm"})),
with(json!({"mode": "angle", "angle": 45, "direction": [1, 2]})),
with(json!({"mode": "direction"})),
with(json!({"mode": "direction", "direction": [0, 0, 0]})),
with(json!({"mode": "direction", "direction": "up"})),
with(json!({"corners": "square"})),
with(json!({"face": "top"})),
with(json!({"far": 5})),
with(json!({"far": [5]})),
with(json!({"far": [["x"]]})),
with(json!({"far": [null, [[1, 2]]]})),
];
for p in cases {
let error = |level: ParamCheck| boxed(level).execute("surface.ruled", &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
}
/// #13134: the contract takes what its input schema allows: each call's check finds no issue
/// exactly when the call matches the schema. A call in angle mode without `angle` matches it; the
/// handler refuses it. The one place the schema can't follow the check is a zero `direction`,
/// which it can't say isn't allowed; a last call shows it.
#[test]
fn the_check_takes_what_the_schema_allows() {
let schema = input_schema_for::<SurfaceRuledParams>();
let schema = schema.as_value();
let p = |extra: Value| {
let mut p = json!({"body": "B", "edges": [[1, 2, 3]], "distance": 2});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
};
for (p, takes) in [
(p(json!({})), true),
(
p(
json!({"edges": [{"x": 1, "y": 2, "z": 3}], "distance": "2 mm", "mode": "tangent", "face": [1, 2, 3], "flip": true, "corners": "round", "name": "S", "body_name": "Strip"}),
),
true,
),
(p(json!({"mode": "angle", "angle": "45 deg", "direction": [0, 0, 1]})), true),
(p(json!({"mode": "direction", "direction": {"x": 0, "y": -1, "z": 0}, "corners": "miter"})), true),
(p(json!({"far": [null, [[1, 2, 3], [4, 5, 6]], []], "body_names": ["Strip"], "participants": ["*"]})), true),
(p(json!({"mode": null, "angle": null, "direction": null, "face": null, "flip": null, "corners": null, "far": null})), true),
(p(json!({"mode": "angle"})), true),
(json!({"edges": [[1, 2, 3]], "distance": 2}), false),
(p(json!({"body": 1})), false),
(p(json!({"edges": []})), false),
(p(json!({"edges": 5})), false),
(p(json!({"edges": [[1, 2]]})), false),
(p(json!({"edges": [[1, 2, 3], "x"]})), false),
(json!({"body": "B", "edges": [[1, 2, 3]]}), false),
(p(json!({"distance": true})), false),
(p(json!({"mode": "sideways"})), false),
(p(json!({"mode": 3})), false),
(p(json!({"angle": true})), false),
(p(json!({"direction": [1, 2]})), false),
(p(json!({"direction": "up"})), false),
(p(json!({"face": "top"})), false),
(p(json!({"flip": "yes"})), false),
(p(json!({"corners": "square"})), false),
(p(json!({"far": 5})), false),
(p(json!({"far": [5]})), false),
(p(json!({"far": [["x"]]})), false),
(p(json!({"plane": "XY"})), false),
] {
let issues = crate::schema::issues("surface.ruled", &p);
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
let zero = p(json!({"mode": "direction", "direction": [0, 0, 0]}));
assert!(!crate::schema::issues("surface.ruled", &zero).is_empty() && conforms(schema, schema, &zero), "{zero}");
}
/// #13134: Ruled Surface replies with `FeatureResult`, with the keys it gave before, in each mode
/// and with each optional key: in Warn, without warnings, so its contract takes each call. Each
/// call makes one surface body and changes no other.
#[test]
fn ruled_surface_replies_with_feature_result() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
let calls = [
json!({"body": "Box", "edges": rim(), "distance": "2 mm", "mode": "tangent", "face": top(), "body_name": "Flange"}),
json!({"body": "Box", "edges": rim(), "distance": 2, "face": top()}),
json!({"body": "Box", "edges": rim(), "distance": 2, "mode": "ANGLE", "angle": "45 deg", "corners": "round", "face": top(), "name": "Draft"}),
json!({"body": "Box", "edges": [[5, 0, 10]], "distance": 2, "mode": "direction", "direction": [0, -1, 0], "body_name": "Lip"}),
json!({"body": "Box", "edges": rim(), "distance": 2, "face": top(), "flip": true, "corners": "miter", "far": [null, null, null, null]}),
json!({"body": "Box", "edges": rim(), "distance": 2, "face": top(), "mode": null, "angle": null, "direction": null, "body_names": ["Rim"]}),
];
for p in calls {
let mut s = boxed(ParamCheck::Warn);
let r = s.execute("surface.ruled", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, before, "{p}: {r}");
let count = |key: &str| r[key].as_array().map_or(usize::MAX, Vec::len);
assert_eq!((count("created_bodies"), count("changed_bodies"), count("removed_bodies")), (1, 0, 0), "{p}: {r}");
}
}
+16
View File
@@ -535,6 +535,22 @@ keyword_param!(
"emboss or deboss",
"Whether the profiles are raised from the face (emboss, the default) or sunk into it (deboss). Any case."
);
keyword_param!(
/// `surface.ruled`'s `mode`.
RuledModeParam,
"mode",
&["normal", "tangent", "angle", "direction"],
"normal, tangent, angle or direction",
"Which way the rulings run from each edge: normal (the default: along the normal of the face beside the edge), tangent (continuing that face), angle (`angle` from `direction`, else from the face's normal, turned away from the face) or direction (along `direction`). Matching ignores case."
);
keyword_param!(
/// `surface.ruled`'s `corners`.
RuledCornersParam,
"corners",
&["mitre", "miter", "round"],
"mitre or round",
"How the strip turns the chain's sharp corners: mitre (the default; miter is an alias) or round. Inside corners are always cut back. Matching ignores case."
);
keyword_param!(
/// `solid.fillet`'s `type`.
FilletTypeParam,
-1
View File
@@ -25,4 +25,3 @@ solid.thread
surface.extrude
surface.patch
surface.revolve
surface.ruled
+12
View File
@@ -2502,6 +2502,18 @@ fn a_face_no_shell_lists_is_a_one_face_sheet() {
let b = &imp.bodies[0];
assert!(!b.closed && b.body.face_count() == 1, "{:?}", imp.warnings);
assert!(rel(measure(&b.body).unwrap().area, 31.5) < 1e-9, "{}", measure(&b.body).unwrap().area);
// Styled and on a layer, it is still a face no shell lists: a presentation's reference is not
// the topology's.
let styled = text.replace(
"#12=FACE_SURFACE('',(#7),#11,.T.);",
"#12=FACE_SURFACE('',(#7),#11,.T.);
#13=PRESENTATION_STYLE_ASSIGNMENT(());
#14=STYLED_ITEM('',(#13),#12);
#15=PRESENTATION_LAYER_ASSIGNMENT('1','',(#12));",
);
let imp = step_import(&styled).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
assert_eq!(imp.bodies[0].body.face_count(), 1, "{:?}", imp.warnings);
}
/// SMLib's refusal of a face by its index in the record (NIST CTC 02's face whose NURBS form is
Generated Vendored
+3 -9
View File
@@ -107,9 +107,10 @@ dependencies = [
"serde",
"serde_json",
"solidcore",
"solidcore-record",
"solidcore-ssi",
"ssi",
"step-import",
"step-export",
"toml",
]
@@ -378,14 +379,7 @@ name = "step-export"
version = "0.1.0"
dependencies = [
"solidcore",
"step-import",
]
[[package]]
name = "step-import"
version = "0.1.0"
dependencies = [
"solidcore",
"solidcore-record",
]
[[package]]
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/solidcore", "crates/solidcore-record", "crates/solidcore-convert", "crates/solidcore-tess", "crates/goldens", "crates/nurbs-grid-mesh", "crates/ssi", "crates/solidcore-ssi", "crates/step-export", "crates/step-import", "crates/xtask", "crates/solidcore-ops", "crates/solidcore-step", "crates/diagnostics"]
members = ["crates/solidcore", "crates/solidcore-record", "crates/solidcore-convert", "crates/solidcore-tess", "crates/goldens", "crates/nurbs-grid-mesh", "crates/ssi", "crates/solidcore-ssi", "crates/step-export", "crates/xtask", "crates/solidcore-ops", "crates/solidcore-step", "crates/diagnostics"]
default-members = ["crates/solidcore"]
[workspace.package]
+4 -2
View File
@@ -13,8 +13,10 @@ solidcore = { path = "../solidcore" }
ssi = { path = "../ssi" }
# The `solidcore-ssi` engine of --ssi-corpus (`ssi` behind the port's inputs and output).
solidcore-ssi = { path = "../solidcore-ssi" }
# The clean-room STEP importer of --step-corpus.
step-import = { path = "../step-import" }
# The STEP reader of --step-corpus: the record's Part 21 parser and neutral model, and step-export's
# mapper from one to the other.
solidcore-record = { path = "../solidcore-record" }
step-export = { path = "../step-export" }
serde = { version = "1", features = ["derive"] }
# float_roundtrip: parse decimals exactly (correctly rounded, as C strtod), not to within 1 ULP
serde_json = { version = "1", features = ["float_roundtrip"] }
+4 -5
View File
@@ -431,8 +431,7 @@ nice -n 10 cargo run --profile goldens -p goldens -- --step-corpus 'ap203/block*
nice -n 10 cargo run --profile goldens -p goldens -- --step-corpus --constructor --jobs 4
```
* **Import.** `crates/step-import` (clean room, from the public ISO 10303-21 syntax and
the Part 41/42 entity definitions; no other STEP implementation consulted) parses
* **Import.** `solidcore-record`'s Part 21 parser (`step::p21`) and `step-export`'s mapper (`map`) parse
each file and maps its `MANIFOLD_SOLID_BREP`, `BREP_WITH_VOIDS`, `FACETED_BREP` and
`SHELL_BASED_SURFACE_MODEL` bodies to a plain-data model in millimetres. A body that
reaches an unsupported entity is skipped with `unsupported: <ENTITY>`. Assembly
@@ -444,15 +443,15 @@ nice -n 10 cargo run --profile goldens -p goldens -- --step-corpus --constructor
and counts, manifold test and volume again.
* **`--constructor`.** The body is built instead through `SmBrepConstructor` (unit
`topology.brep_constructor`, `src/constructor.rs`) from the script
`step_import::script::from_body`; the `construct` stage replaces `faces` and `stitch`.
`goldens::script::from_body`; the `construct` stage replaces `faces` and `stitch`.
* **Verdicts** (per body): `BUILT` (every stage ran, whatever its status), `DEFERRED`
(a stage reached unported code; the unit is shown), `CRASH` (the child crashed, timed
out or wrote no record), `SKIPPED` (no B-rep body in the file).
* Each body runs in its own child process (`--step-case <file> <body> <out.json>`).
Output: one line per body, a `STEP-CORPUS` summary line, and
`bin/step-corpus/report.json` (per body: source counts, stages, verdict).
* `cargo run -p step-import --example survey -- corpus/steptools` lists what the
importer maps per file; `--example dump_body -- <file> <body> [--flat]` prints one body.
* `cargo run -p goldens --example step_survey -- corpus/steptools` lists what the
importer maps per file; `--example step_dump_body -- <file> <body> [--flat]` prints one body.
## Notes
@@ -17,7 +17,7 @@ fn main() {
eprintln!("cannot read {file}");
std::process::exit(2);
};
let Some(body) = step_import::import(&bytes, &step_import::Limits::default())
let Some(body) = step_export::import(&bytes, &step_export::Limits::default())
.ok()
.and_then(|m| m.bodies.into_iter().nth(index))
else {
@@ -44,7 +44,7 @@ fn main() {
(Some(a), Some(c)) if (a - c).abs() <= 1e-6 * a.abs().max(c.abs()).max(1e-300) => "",
_ => " <<",
};
let exact = step_import::script::step_face_area(&body, face, 512);
let exact = goldens::script::step_face_area(&body, face, 512);
let close = |x: &Option<f64>| match (x, exact) {
(Some(a), Some(e)) => (a - e).abs() <= 1e-4 * e.abs().max(1e-300),
_ => false,
@@ -11,7 +11,7 @@ fn main() {
.and_then(|f| std::fs::read(f).ok())
.and_then(|b| {
let index = args.get(1)?.parse::<usize>().ok()?;
step_import::import(&b, &step_import::Limits::default())
step_export::import(&b, &step_export::Limits::default())
.ok()?
.bodies
.into_iter()
@@ -21,14 +21,13 @@ fn main() {
eprintln!("usage: constructor_script_ops <file.stp> <body> <op>...");
std::process::exit(2);
};
let script =
step_import::script::from_body(&body, step_import::script::FromBodyOptions::default());
let script = goldens::script::from_body(&body, goldens::script::FromBodyOptions::default());
for i in args.iter().skip(2).filter_map(|x| x.parse::<usize>().ok()) {
let Some(op) = script.ops.get(i) else {
continue;
};
println!("op {i}: {op:?}");
if let step_import::script::Op::AdjustStepUvDomain { surface, .. } = op {
if let goldens::script::Op::AdjustStepUvDomain { surface, .. } = op {
println!(" surface {:?}", script.surfaces.get(*surface));
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
//! Diagnostics for `goldens --step-corpus`: the independent area estimate of one
//! STEP body ([`step_import::script::step_face_area`] summed over its faces), or which
//! STEP body ([`goldens::script::step_face_area`] summed over its faces), or which
//! faces have no analytic estimate.
//!
//! `cargo run --profile goldens -p goldens --example step_body_area -- <file.stp> <body>`
@@ -15,7 +15,7 @@ fn main() {
};
let body = std::fs::read(file)
.ok()
.and_then(|b| step_import::import(&b, &step_import::Limits::default()).ok())
.and_then(|b| step_export::import(&b, &step_export::Limits::default()).ok())
.and_then(|m| m.bodies.into_iter().nth(index));
let Some(body) = body else {
eprintln!("no body {index} in {file}");
@@ -24,7 +24,7 @@ fn main() {
let mut total = 0.0;
let mut missing = 0usize;
for f in &body.faces {
match step_import::script::step_face_area(&body, f, 2048) {
match goldens::script::step_face_area(&body, f, 2048) {
Some(a) => total += a,
None => missing += 1,
}
@@ -1,7 +1,7 @@
//! Prints one body of a STEP file: its debug form, or with `--flat` its flat encoding
//! (one number per line), for debugging the importer and the C++ shim.
//!
//! `cargo run -p step-import --example dump_body -- <file.stp> <body index> [--flat]`
//! `cargo run -p goldens --example step_dump_body -- <file.stp> <body index> [--flat]`
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -10,7 +10,7 @@ fn main() {
std::process::exit(2);
};
let bytes = std::fs::read(path).unwrap_or_default();
let model = match step_import::import(&bytes, &step_import::Limits::default()) {
let model = match step_export::import(&bytes, &step_export::Limits::default()) {
Ok(m) => m,
Err(e) => {
eprintln!("parse error: {e}");
@@ -26,7 +26,7 @@ fn main() {
std::process::exit(1);
};
if args.iter().any(|a| a == "--flat") {
for x in step_import::flat::encode(body) {
for x in goldens::flat::encode(body) {
println!("{x:?}");
}
} else {
+3 -3
View File
@@ -12,7 +12,7 @@ fn main() {
for file in std::env::args().skip(1) {
let Some(model) = std::fs::read(&file)
.ok()
.and_then(|b| step_import::import(&b, &step_import::Limits::default()).ok())
.and_then(|b| step_export::import(&b, &step_export::Limits::default()).ok())
else {
println!("{file}: unreadable");
continue;
@@ -22,8 +22,8 @@ fn main() {
for (ei, e) in body.edges.iter().enumerate() {
n += 1;
let (Some(p0), Some(p1)) = (
step_import::script::edge_point(e, 0.0),
step_import::script::edge_point(e, 1.0),
goldens::script::edge_point(e, 0.0),
goldens::script::edge_point(e, 1.0),
) else {
continue;
};
+2 -3
View File
@@ -82,7 +82,7 @@ fn main() {
};
let Some(body) = std::fs::read(file)
.ok()
.and_then(|b| step_import::import(&b, &step_import::Limits::default()).ok())
.and_then(|b| step_export::import(&b, &step_export::Limits::default()).ok())
.and_then(|m| m.bodies.into_iter().nth(bi))
else {
eprintln!("no body {bi}");
@@ -93,8 +93,7 @@ fn main() {
report("stitch recipe (alone)", &k, &mut b, f);
}
}
let script =
step_import::script::from_body(&body, step_import::script::FromBodyOptions::default());
let script = goldens::script::from_body(&body, goldens::script::FromBodyOptions::default());
let (k, b, _) = goldens::constructor::build_brep(&script);
if let Some(mut b) = b {
if let Some(&f) = solidcore::topology::brep::p1::faces(&b, None)
@@ -1,7 +1,7 @@
//! Imports every `.stp`/`.step` file under a directory and prints one line per file:
//! bodies, skipped bodies with reasons, and topology counts.
//!
//! `cargo run -p step-import --example survey -- corpus/steptools`
//! `cargo run -p goldens --example step_survey -- corpus/steptools`
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -35,7 +35,7 @@ fn main() {
for p in list {
let bytes = std::fs::read(&p).unwrap_or_default();
let name = p.strip_prefix(&dir).unwrap_or(&p).display().to_string();
match step_import::import(&bytes, &step_import::Limits::default()) {
match step_export::import(&bytes, &step_export::Limits::default()) {
Err(e) => println!("{name}: PARSE ERROR {e}"),
Ok(m) => {
let (f, e, v): (usize, usize, usize) = m.bodies.iter().fold((0, 0, 0), |a, b| {
+3 -3
View File
@@ -1,4 +1,4 @@
//! The Rust side of constructor scripts ([`step_import::script`]): runs a script on
//! The Rust side of constructor scripts ([`crate::script`]): runs a script on
//! [`BrepConstructor`] and reports the blocks the reference's constructor shim reported:
//! the geometry statuses, each call's status, a crash marker where upstream dereferences
//! null (the port's [`SolidError::InvalidInput`] of the constructor), the B-rep dump (mode
@@ -32,8 +32,8 @@ use solidcore::topology::{
};
use solidcore::SolidError;
use step_import::model as sm;
use step_import::script::{Node, Op, Orient, RegionRef, Script, ScriptCurve};
use crate::script::{Node, Op, Orient, RegionRef, Script, ScriptCurve};
use solidcore_record::step::model as sm;
use crate::step::{make_curve, make_surface};
@@ -25,11 +25,11 @@
//!
//! `kind` is 0 manifold solid, 1 solid with voids, 2 faceted, 3 shell model.
use crate::map::{check_bspline_curve, check_knots};
use crate::model::{
use solidcore_record::step::model::{
BSplineCurve, BSplineSurface, Body, BodyKind, Bound, Curve, Edge, Face, Frame, Loop,
OrientedEdge, Shell, Surface, Vertex, P3,
};
use step_export::map::{check_bspline_curve, check_knots};
/// First word of an encoded body.
pub const MAGIC: f64 = 21.0;
@@ -590,7 +590,7 @@ impl Rd<'_> {
let (u_knots, u_mults) = self.knots(nuk)?;
let (v_knots, v_mults) = self.knots(nvk)?;
let fits = |k: &[f64], m: &[u32], n: usize, d: usize| {
crate::geom::expand_knots(k, m, n + d + 1).is_some()
step_export::geom::expand_knots(k, m, n + d + 1).is_some()
};
if nu < ud + 1
|| nv < vd + 1
+2
View File
@@ -19,6 +19,7 @@ pub mod cases {
pub mod compare;
pub mod constructor;
pub mod fixtures;
pub mod flat;
pub mod golden;
pub mod jobs;
pub mod json;
@@ -28,6 +29,7 @@ pub(crate) mod own_reader;
pub mod plan;
pub mod rng;
pub mod runner;
pub mod script;
pub mod solidcraft;
pub mod soundness;
pub mod ssi;
@@ -45,8 +45,8 @@
//! once (the call takes it over).
use crate::flat::{decode_curve, decode_surface, encode_curve, encode_surface};
use crate::geom::{bspline_point, dot, scale, sub};
use crate::model::{Body, BodyKind, Bound, Curve, Surface, P3};
use solidcore_record::step::model::{Body, BodyKind, Bound, Curve, Surface, P3};
use step_export::geom::{bspline_point, dot, scale, sub};
/// First word of an encoded script.
pub const MAGIC: f64 = 31.0;
@@ -607,7 +607,7 @@ pub fn from_body(body: &Body, options: FromBodyOptions) -> Script {
domain: [0.0; 4],
});
// outer loop first
let mut loops: Vec<&crate::model::Loop> =
let mut loops: Vec<&solidcore_record::step::model::Loop> =
face.loops.iter().filter(|l| l.outer).collect();
loops.extend(face.loops.iter().filter(|l| !l.outer));
for l in loops {
@@ -740,7 +740,7 @@ const EDGE_SAMPLES: usize = 32;
/// The point of edge `e` at fraction `s` of its parameter range, from `t_start` to
/// `t_end` (the mapper orders a periodic range in the edge's sense: `t_end > t_start`
/// along the curve, `t_end < t_start` against it).
pub fn edge_point(e: &crate::model::Edge, s: f64) -> Option<P3> {
pub fn edge_point(e: &solidcore_record::step::model::Edge, s: f64) -> Option<P3> {
let (t0, t1) = (e.t_start, e.t_end);
match &e.curve {
Curve::Line {
@@ -765,7 +765,7 @@ fn add(a: P3, b: P3) -> P3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn conic(f: &crate::model::Frame, a: f64, b: f64, deg: f64) -> P3 {
fn conic(f: &solidcore_record::step::model::Frame, a: f64, b: f64, deg: f64) -> P3 {
let r = deg.to_radians();
add(
f.origin,
@@ -786,7 +786,7 @@ fn unwrap(prev: f64, x: f64) -> f64 {
/// vertex loops add their point in the directions that are not periodic).
/// `None` for a face without loops and for surfaces whose STEP parameters are not
/// analytic here (revolution, extrusion, B-spline).
pub fn step_uv_domain(body: &Body, face: &crate::model::Face) -> Option<[f64; 4]> {
pub fn step_uv_domain(body: &Body, face: &solidcore_record::step::model::Face) -> Option<[f64; 4]> {
let map: ParamMap = match &face.surface {
Surface::Plane { frame } => {
let f = *frame;
@@ -945,7 +945,11 @@ fn axis_range(loops: &[Vec<f64>], periodic: bool, extra: &[f64]) -> Option<(f64,
/// cone, `r^2 cos v` for a sphere, `r (R + r cos v)` for a torus). Exact up to the
/// polygon approximation of the loops in parameter space. `None` where
/// [`step_uv_domain`] has no parameters.
pub fn step_face_area(body: &Body, face: &crate::model::Face, samples: usize) -> Option<f64> {
pub fn step_face_area(
body: &Body,
face: &solidcore_record::step::model::Face,
samples: usize,
) -> Option<f64> {
step_uv_domain(body, face)?;
let (f, map): (AreaElement, ParamMap) = match &face.surface {
Surface::Plane { frame } => {
+12 -14
View File
@@ -1,9 +1,9 @@
//! `--step-corpus`: imports real-world STEP files with the clean-room `step-import`
//! crate and builds every B-rep body with solidcore.
//! `--step-corpus`: imports real-world STEP files with `solidcore-record`'s
//! Part 21 parser and `step-export`'s mapper, and builds every B-rep body with solidcore.
//!
//! The corpus is STEP Tools' AP203/AP214 sample set, fetched into `corpus/steptools/`
//! by `scripts/fetch-steptools-corpus.sh` (All Rights Reserved; never committed). Each
//! body is encoded ([`step_import::flat::encode`]) and decoded again, and built from the
//! body is encoded ([`crate::flat::encode`]) and decoded again, and built from the
//! decoded [`Body`] ([`build_rust`]): per face its surface and the bounded 3D curves of
//! its edge loops into `CreateFacesWithCurves`, then `SmApiStitchIntoSolid`, counts,
//! `SmApiBrepIsManifoldSolid`, area and volume, `SmApiHealBrep`, and counts, manifold
@@ -15,7 +15,7 @@
//! part of CI (the files are not in the repository).
//!
//! With `--constructor` each body is built instead through `SmBrepConstructor`
//! ([`step_import::script::from_body`], run by [`crate::constructor`]): shared edges
//! ([`crate::script::from_body`], run by [`crate::constructor`]): shared edges
//! joined by the importer, face orientations from the file, no stitching; the stages
//! after construction are the same (`construct` replaces `faces` and `stitch`).
@@ -58,7 +58,7 @@ use solidcore::topology::topology_traverser::Marks;
use solidcore::topology::{Brep, FaceId};
use solidcore::{EntityRef, SolidError};
use step_import::model::{self as sm, Body, Bound};
use solidcore_record::step::model::{self as sm, Body, Bound};
use crate::ssi::{exit_detail, glob_match};
use crate::HarnessError;
@@ -416,7 +416,7 @@ fn counts(k: &mut Kernel, b: &Brep) -> Result<Vec<f64>, SolidError> {
/// Diagnostics: face `fi` of `body` built alone in its own B-rep by the stitch
/// recipe's `CreateFacesWithCurves` (Rust port): the kernel, the B-rep and the faces
/// made; `None` where a step fails. With `adjust_domain` the surface is first given the
/// face's STEP parameter box ([`step_import::script::step_uv_domain`]) by
/// face's STEP parameter box ([`crate::script::step_uv_domain`]) by
/// `AdjustSTEPUVDomain`, as the constructor recipe does.
pub fn build_face_alone(
body: &Body,
@@ -433,7 +433,7 @@ pub fn build_face_alone(
.ok()?;
let mut srf = make_surface(&k, &face.surface).ok()?;
if adjust_domain {
if let Some(d) = step_import::script::step_uv_domain(body, face) {
if let Some(d) = crate::script::step_uv_domain(body, face) {
let d = solidcore::geom::extent2d::Extent2d::from_bounds(d[0], d[1], d[2], d[3]);
srf.adjust_step_uv_domain(&k, &d).ok()?;
}
@@ -479,8 +479,7 @@ pub fn face_areas(
) -> Vec<(Option<f64>, Option<f64>)> {
use solidcore::mass::integrator::properties::face_area;
let mut out = Vec::new();
let script =
step_import::script::from_body(body, step_import::script::FromBodyOptions::default());
let script = crate::script::from_body(body, crate::script::FromBodyOptions::default());
let (k2, built, _) = crate::constructor::build_brep(&script);
let mut built = built;
let ctor_faces: Vec<FaceId> = built
@@ -701,8 +700,7 @@ fn constructor_record(out: &[f64]) -> Result<Record, String> {
/// Builds `body` through the Rust `BrepConstructor`.
pub fn build_rust_constructor(body: &Body) -> Record {
let script =
step_import::script::from_body(body, step_import::script::FromBodyOptions::default());
let script = crate::script::from_body(body, crate::script::FromBodyOptions::default());
let mut notes = Vec::new();
let (out, deferred) = crate::constructor::run(&script, 1, ACCURACY, &mut notes);
let mut rec = constructor_record(&out).unwrap_or_else(|e| Record {
@@ -716,7 +714,7 @@ pub fn build_rust_constructor(body: &Body) -> Record {
fn load_body(path: &Path, index: usize) -> Result<Body, String> {
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let model = step_import::import(&bytes, &step_import::Limits::default())
let model = step_export::import(&bytes, &step_export::Limits::default())
.map_err(|e| format!("parse: {e}"))?;
let body = model
.bodies
@@ -724,7 +722,7 @@ fn load_body(path: &Path, index: usize) -> Result<Body, String> {
.nth(index)
.ok_or_else(|| format!("no body {index}"))?;
// Built from the decoded encoding, so the build sees the encoding's numbers.
step_import::flat::decode(&step_import::flat::encode(&body))
crate::flat::decode(&crate::flat::encode(&body))
}
/// `--step-case <file> <body> <out.json>`: one body.
@@ -991,7 +989,7 @@ fn run_corpus_inner(root: &Path, opts: &CorpusOptions) -> Result<(), HarnessErro
for (fi, f) in files.iter().enumerate() {
let rel = corpus_rel(f, &dir);
let bytes = std::fs::read(f).map_err(|e| HarnessError::new(format!("{rel}: {e}")))?;
match step_import::import(&bytes, &step_import::Limits::default()) {
match step_export::import(&bytes, &step_export::Limits::default()) {
Err(e) => file_entries.push(json!({"file": rel, "parse": e.to_string(), "bodies": []})),
Ok(m) => {
for s in &m.skipped {
@@ -2,7 +2,7 @@
//! checked against its golden. Rust: `solidcore::topology::brep_constructor` through
//! [`crate::constructor`].
//!
//! A case is a constructor script ([`step_import::script`]): the calls an importer makes,
//! A case is a constructor script ([`crate::script`]): the calls an importer makes,
//! over a table of curves and surfaces. The runner runs it and reports every call's status,
//! where upstream crashes on a null dereference (the port's constructor
//! [`solidcore::SolidError::InvalidInput`]), and the B-rep built: its whole graph in
@@ -14,7 +14,7 @@
//! Built-in cases: solids from prisms (cubes, polygons, boxes with square and polygonal
//! holes through them), cylinders (seam edges), a vertex-loop sphere, a box with a void,
//! open shells, a non-manifold fan of three faces on one edge, the hand-written STEP
//! tetrahedron and disk of `step-import`, each with limits from vertex points or curve
//! tetrahedron and disk of `step-export`'s import tests, each with limits from vertex points or curve
//! parameters, reversed faces, and as separate edges stitched by `EndShell(TRUE)`; faces
//! bounded by lifted UV lines and B-splines; and malformed call sequences (calls out of
//! state, null curves, decreasing limits, missing curves). Generated cases: random shapes,
@@ -26,11 +26,11 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use step_import::model::{
use solidcore_record::step::model::{
BSplineCurve, Body, BodyKind, Bound, Curve, Edge, Face, Frame, Loop, OrientedEdge, Shell,
Surface, Vertex, P3,
};
use step_import::script::{
use crate::script::{
self, FromBodyOptions, Node, Op, Orient, RegionRef, Script, ScriptCurve,
};
@@ -573,7 +573,7 @@ fn bspline_face(bump: bool) -> Body {
cubic_edge(3, 2, row(3)),
cubic_edge(0, 3, col(0)),
],
Surface::BSpline(step_import::model::BSplineSurface {
Surface::BSpline(solidcore_record::step::model::BSplineSurface {
u_degree: 3,
v_degree: 3,
points: net.clone(),
@@ -620,7 +620,7 @@ fn reverse_plane(b: &mut Body, fi: usize) {
const TETRA_STEP: &str = include_str!("topology_brep_constructor_tetra.stp");
fn step_bodies() -> Vec<Body> {
step_import::import(TETRA_STEP.as_bytes(), &step_import::Limits::default())
step_export::import(TETRA_STEP.as_bytes(), &step_export::Limits::default())
.map(|m| m.bodies)
.unwrap_or_default()
}
+34
View File
@@ -0,0 +1,34 @@
//! The flat encoding of a body ([`goldens::flat`]): its header and vertices, the round trip
//! through the validating decoder, and the refusals, on the hand-written tetrahedron and disk
//! file that `solidcore-record`'s import tests read.
#![allow(clippy::unwrap_used, clippy::indexing_slicing)] // test
use goldens::flat;
use step_export::{import, Limits};
#[test]
fn flat_encoding_of_the_hand_written_file() {
let text = include_str!("../../step-export/src/tetra_disk.stp");
let m = import(text.as_bytes(), &Limits::default()).unwrap();
let t = &m.bodies[0];
// The flat encoding starts with its header and covers every vertex.
let flat = flat::encode(t);
assert_eq!(&flat[..7], &[21.0, 1.0, 0.0, 4.0, 6.0, 4.0, 1.0]);
assert_eq!(&flat[7..10], &[0.0, 0.0, 0.0]);
// The encoding round-trips through the validating decoder.
for b in &m.bodies {
let e = flat::encode(b);
assert_eq!(flat::encode(&flat::decode(&e).unwrap()), e);
}
// Truncations, bad indices and non-finite numbers are refused.
for cut in 0..flat.len() {
assert!(flat::decode(&flat[..cut]).is_err());
}
let mut bad = flat.clone();
bad[3] = 1e18;
assert!(flat::decode(&bad).is_err());
let mut bad = flat.clone();
bad[8] = f64::NAN;
assert!(flat::decode(&bad).is_err());
}
+90
View File
@@ -0,0 +1,90 @@
//! A panic fuzz of `flat::decode` (#2490), ported from step-brep's `tests/panic_fuzz.rs` with its
//! cases when step-brep's import was deleted (RFC 0003, 1.6): a body's flat array with values
//! replaced by hostile ones (counts, kinds and coordinates alike) or cut short. A result of `Err`
//! is fine; a panic fails the test with the reproducing seed.
//!
//! The harness is solidcore's (`crates/solidcore/tests/support/panic_fuzz.rs`).
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
#[path = "../../solidcore/tests/support/panic_fuzz.rs"]
mod panic_fuzz;
use panic_fuzz::{assert_no_findings, fuzz, Family, Rng, Settings};
use solidcore::CancelToken;
use step_export::Limits;
/// The files whose bodies are encoded: the STEP fixtures step-brep's fuzz started from.
fn bases() -> &'static [&'static [u8]] {
&[
include_bytes!("../../solidcore-step/tests/fixtures/discord-2026-10-30-fillet.step"),
include_bytes!("../src/units/topology_brep_constructor_tetra.stp"),
]
}
/// `flat::decode` on a body's array with values replaced by hostile ones (counts,
/// kinds and coordinates alike) or the array cut short.
#[derive(Clone, Debug)]
struct FlatCase {
base: usize,
edits: Vec<(f64, f64)>,
truncate: Option<f64>,
}
struct FlatDecode;
impl Family for FlatDecode {
type Input = FlatCase;
fn name(&self) -> &'static str {
"step-flat"
}
fn generate(&self, r: &mut Rng) -> FlatCase {
FlatCase {
base: r.below(bases().len()),
edits: (0..=r.below(4))
.map(|_| {
let v = if r.chance(0.5) {
r.pick(panic_fuzz::HOSTILE)
} else {
r.pick(&[-1.0, 0.0, 1.0, 2.0, 3.0, 7.0, 1e9, 4294967296.0, 0.5, 1e19])
};
(r.unit(), v)
})
.collect(),
truncate: r.chance(0.3).then(|| r.unit()),
}
}
fn run(case: FlatCase, _cancel: &CancelToken) {
let Ok(model) = step_export::import(bases()[case.base], &Limits::default()) else {
return;
};
for body in &model.bodies {
let mut flat = goldens::flat::encode(body);
if flat.is_empty() {
continue;
}
for &(at, v) in &case.edits {
let i = ((at * flat.len() as f64) as usize).min(flat.len() - 1);
flat[i] = v;
}
if let Some(at) = case.truncate {
flat.truncate((at * flat.len() as f64) as usize);
}
let _ = goldens::flat::decode(&flat);
}
}
}
#[test]
fn panic_fuzz_step_flat_decode() {
let findings = fuzz(&FlatDecode, &Settings::for_family("step-flat", 200));
assert_no_findings("step-flat", &findings);
}
+2 -2
View File
@@ -19,7 +19,7 @@
)] // test helpers
use goldens::step::{build_rust_constructor, Record};
use step_import::model::{
use solidcore_record::step::model::{
BSplineCurve, Body, BodyKind, Bound, Curve, Edge, Face, Frame, Loop, OrientedEdge, Shell,
Surface, Vertex, P3,
};
@@ -195,7 +195,7 @@ fn doubled_edge_cube(sag: f64, b_reversed: bool, b_curved: bool) -> Body {
.collect(),
};
// Both kernels build from the decoded encoding, as `--step-case` does.
step_import::flat::decode(&step_import::flat::encode(&body)).unwrap()
goldens::flat::decode(&goldens::flat::encode(&body)).unwrap()
}
fn stage<'a>(r: &'a Record, name: &str) -> &'a [f64] {
+6 -2
View File
@@ -1,9 +1,13 @@
//! STEP (ISO 10303) as data: the exchange structure a file is read into ([`p21`]), the units
//! of its representation contexts ([`units`]), its Part 42 curves and surfaces ([`geom`])
//! as the entity reader ([`read`]) maps them, and its product structure ([`product`]). Building
//! bodies from it is `solidcore-ops`' job (docs/layers.md §2, "Importers are solidcore's").
//! as the entity reader ([`read`]) maps them, its product structure ([`product`]), and the
//! neutral plain-data B-rep model its bodies are mapped to ([`model`]). Building bodies from it
//! is `solidcore-ops`' job (docs/layers.md §2, "Importers are solidcore's").
pub mod geom;
pub mod model;
pub mod p21;
pub mod product;
pub mod read;
pub mod units;
pub use model::Model;
@@ -368,7 +368,7 @@ pub struct FilePcurves {
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Model {
/// Part 21 header metadata.
pub header: crate::part21::Header,
pub header: crate::step::p21::Header,
/// `FILE_SCHEMA` names.
pub schemas: Vec<String>,
/// Bodies in entity id order. Includes a one-face open-shell
+57 -3
View File
@@ -9,9 +9,8 @@
//! capped, nothing is allocated from a count read out of the file (lists grow as their items are
//! read), and every refusal is an [`Error`], never a panic.
//!
//! Origin: SolidCraft's `crates/step/src/p21.rs` at `a5b71ca2` (MIT OR Apache-2.0). With the STEP
//! merge (#2577) this becomes solidcore's one Part 21 implementation; `step-import`'s `part21.rs`
//! goes with #2585.
//! Origin: SolidCraft's `crates/step/src/p21.rs` at `a5b71ca2` (MIT OR Apache-2.0). This is
//! solidcore's one Part 21 implementation (`step-import`'s `part21.rs` is gone, #14123).
//!
//! Deviations from SolidCraft: [`Exchange::entities`] is a `BTreeMap` (was a `HashMap`), so
//! iterating it is in id order on every platform; what is stored and which definition wins a
@@ -198,9 +197,34 @@ impl Entity {
}
}
/// The HEADER section's file description and file name, as written.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Header {
/// `FILE_DESCRIPTION` lines.
pub descriptions: Vec<String>,
/// `FILE_DESCRIPTION` implementation level.
pub implementation_level: String,
/// `FILE_NAME` name.
pub file_name: String,
/// `FILE_NAME` time stamp.
pub time_stamp: String,
/// `FILE_NAME` authors.
pub authors: Vec<String>,
/// `FILE_NAME` organizations.
pub organizations: Vec<String>,
/// `FILE_NAME` preprocessor version.
pub preprocessor_version: String,
/// `FILE_NAME` originating system.
pub originating_system: String,
/// `FILE_NAME` authorization.
pub authorization: String,
}
/// A parsed exchange file.
#[derive(Clone, Debug, Default)]
pub struct Exchange {
/// The HEADER's description and file name, field by field.
pub header: Header,
/// `FILE_SCHEMA` names.
pub schemas: Vec<String>,
/// `FILE_NAME` originating system.
@@ -808,9 +832,39 @@ fn parse_limited(
.filter_map(|p| p.as_str())
.collect::<Vec<_>>()
.join(" ");
ex.header.descriptions = v
.iter()
.filter_map(|p| p.as_str().map(str::to_string))
.collect();
}
if let Some(level) = params.get(1).and_then(Param::as_str) {
level.clone_into(&mut ex.header.implementation_level);
}
}
"FILE_NAME" => {
let string = |i: usize| {
params
.get(i)
.and_then(Param::as_str)
.unwrap_or("")
.to_string()
};
let strings = |i: usize| -> Vec<String> {
params
.get(i)
.and_then(Param::as_list)
.unwrap_or(&[])
.iter()
.filter_map(|p| p.as_str().map(str::to_string))
.collect()
};
ex.header.file_name = string(0);
ex.header.time_stamp = string(1);
ex.header.authors = strings(2);
ex.header.organizations = strings(3);
ex.header.preprocessor_version = string(4);
ex.header.originating_system = string(5);
ex.header.authorization = string(6);
ex.preprocessor = params
.get(4)
.and_then(Param::as_str)
+5 -2
View File
@@ -198,7 +198,9 @@ pub fn is_body_item(name: &str) -> bool {
/// The topology a file holds outside any representation, as bodies to read (a file of bare
/// geometry, or one whose topology no shape representation lists): every solid or surface model
/// no other entity refers to, then every shell no solid, model or other shell lists, then every
/// face no shell lists (each a one-face sheet), in id order within each. As solidcore's
/// face no shell lists (each a one-face sheet), in id order within each. A presentation's reference
/// (a style, a layer, an invisibility) does not count: a styled loose shell is still unlisted. As
/// solidcore's
/// step-import read such files (#2583, topology first): what the file's topology holds is read
/// whether or not a product structure names it.
pub fn roots(ex: &Exchange) -> Vec<u64> {
@@ -215,9 +217,10 @@ pub fn roots(ex: &Exchange) -> Vec<u64> {
_ => {}
}
}
let presentation = |name: &str| name.ends_with("STYLED_ITEM") || matches!(name, "PRESENTATION_LAYER_ASSIGNMENT" | "INVISIBILITY");
let mut referenced = HashSet::new();
for e in ex.entities.values() {
for r in &e.records {
for r in e.records.iter().filter(|r| !presentation(&r.name)) {
for p in &r.params {
refer(p, &mut referenced, 0);
}
+1 -1
View File
@@ -6,7 +6,7 @@
//! cancelled, and a case that still does not return is left running).
//!
//! Shared by `tests/panic_fuzz.rs` (the kernel's entry points), solidcore-step's
//! `tests/panic_fuzz.rs` (STEP import) and step-import's (its flat arrays). Environment:
//! `tests/panic_fuzz.rs` (STEP import) and goldens' `tests/flat_panic_fuzz.rs` (the flat arrays). Environment:
//! - `SOLIDCORE_PANIC_FUZZ_CASES`: cases per family (default: each family's CI count).
//! - `SOLIDCORE_PANIC_FUZZ_SEED`: the base seed (default 2490).
//! - `SOLIDCORE_PANIC_FUZZ_HANG_SECS`: the hang limit per case (default 60 s in a debug
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "step-export"
description = "ISO 10303-21 writer for the neutral STEP B-rep model"
description = "ISO 10303-21 writer for the neutral STEP B-rep model, and the mapper that reads a Part 21 file into that model"
edition.workspace = true
license.workspace = true
version.workspace = true
@@ -8,7 +8,7 @@ publish = false
[dependencies]
solidcore = { path = "../solidcore" }
step-import = { path = "../step-import" }
solidcore-record = { path = "../solidcore-record" }
[lints]
workspace = true
+31 -27
View File
@@ -1,4 +1,4 @@
//! Convert a `SolidCore` B-rep into the neutral [`step_import::Model`].
//! Convert a `SolidCore` B-rep into the neutral [`solidcore_record::step::Model`].
//!
//! This is the export counterpart of the STEP reader's build. It maps exact `SolidCore`
//! topology and analytic/B-spline geometry to the neutral model that
@@ -23,7 +23,7 @@ use solidcore::nurbs::b_spline_curve::BSplineCurve;
use solidcore::topology::brep::p2::is_sheet;
use solidcore::topology::edgeuse::p1 as eu;
use solidcore::topology::{Brep, VertexId};
use step_import::model::{
use solidcore_record::step::model::{
Body, BodyKind, Bound, Curve as ModelCurve, Edge, Face, Frame, Loop, Model, OrientedEdge,
Shell, Surface as ModelSurface, Vertex,
};
@@ -87,13 +87,15 @@ fn frame(
fn bspline_to_model(curve: &BSplineCurve) -> Result<ModelCurve, ExportModelError> {
let (points, weights) = curve.control_polygon().map_err(|e| err(e.to_string()))?;
let (knots, mults) = curve.knots(None).map_err(|e| err(e.to_string()))?;
Ok(ModelCurve::BSpline(step_import::model::BSplineCurve {
degree: curve.degree().map_err(|e| err(e.to_string()))? as u32,
points: points.into_iter().map(point).collect(),
weights: (!weights.is_empty()).then_some(weights),
knots,
mults: mults.into_iter().map(|m| m as u32).collect(),
}))
Ok(ModelCurve::BSpline(
solidcore_record::step::model::BSplineCurve {
degree: curve.degree().map_err(|e| err(e.to_string()))? as u32,
points: points.into_iter().map(point).collect(),
weights: (!weights.is_empty()).then_some(weights),
knots,
mults: mults.into_iter().map(|m| m as u32).collect(),
},
))
}
fn approximate_crv_on_surf(
@@ -223,24 +225,26 @@ fn bspline_surface(
Some(net.weights.chunks(v_count).map(<[f64]>::to_vec).collect())
};
let canonical = bspline.canonical().map_err(|e| err(e.to_string()))?;
Ok(ModelSurface::BSpline(step_import::model::BSplineSurface {
u_degree: canonical.u_degree as u32,
v_degree: canonical.v_degree as u32,
points,
weights,
u_knots: canonical.u_knots,
u_mults: canonical
.u_multiplicities
.into_iter()
.map(|m| m as u32)
.collect(),
v_knots: canonical.v_knots,
v_mults: canonical
.v_multiplicities
.into_iter()
.map(|m| m as u32)
.collect(),
}))
Ok(ModelSurface::BSpline(
solidcore_record::step::model::BSplineSurface {
u_degree: canonical.u_degree as u32,
v_degree: canonical.v_degree as u32,
points,
weights,
u_knots: canonical.u_knots,
u_mults: canonical
.u_multiplicities
.into_iter()
.map(|m| m as u32)
.collect(),
v_knots: canonical.v_knots,
v_mults: canonical
.v_multiplicities
.into_iter()
.map(|m| m as u32)
.collect(),
},
))
}
/// Which of `surface`'s STEP parameters (u, v) are angles (ISO 10303-42): a cylinder's
@@ -1,17 +1,20 @@
//! Small vector helpers and the curve evaluation the mapper needs to find the
//! parameters of an edge's vertices on its curve.
use crate::model::{BSplineCurve, Frame, P3};
use solidcore_record::step::model::{BSplineCurve, Frame, P3};
pub(crate) fn sub(a: P3, b: P3) -> P3 {
/// `a - b`.
pub fn sub(a: P3, b: P3) -> P3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
pub(crate) fn scale(a: P3, s: f64) -> P3 {
/// `a * s`.
pub fn scale(a: P3, s: f64) -> P3 {
[a[0] * s, a[1] * s, a[2] * s]
}
pub(crate) fn dot(a: P3, b: P3) -> f64 {
/// The dot product.
pub fn dot(a: P3, b: P3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
@@ -63,7 +66,7 @@ pub(crate) fn build_frame(origin: P3, axis: Option<P3>, ref_dir: Option<P3>) ->
}
/// Expanded knot vector, or `None` when the multiplicities do not fit.
pub(crate) fn expand_knots(knots: &[f64], mults: &[u32], expected: usize) -> Option<Vec<f64>> {
pub fn expand_knots(knots: &[f64], mults: &[u32], expected: usize) -> Option<Vec<f64>> {
let mut total: usize = 0;
for &m in mults {
total = total.checked_add(m as usize)?;
@@ -84,7 +87,7 @@ pub(crate) fn expand_knots(knots: &[f64], mults: &[u32], expected: usize) -> Opt
/// refuses with [`check_bspline_curve`](crate::map::check_bspline_curve) (fewer than
/// degree + 1 points, knots not increasing, multiplicities or weights out of range),
/// which a model built by hand may carry.
pub(crate) fn bspline_point(c: &BSplineCurve, t: f64) -> Option<P3> {
pub fn bspline_point(c: &BSplineCurve, t: f64) -> Option<P3> {
let p = c.degree as usize;
let n = c.points.len();
crate::map::check_bspline_curve(c).ok()?;
+94
View File
@@ -0,0 +1,94 @@
//! `import` on a hand-written file: a tetrahedron and a disk.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // tests
use crate::map::{import, Limits};
use solidcore_record::step::model::{self, Bound, Curve, Surface};
/// A tetrahedron (0,0,0) (10,0,0) (0,10,0) (0,0,10), written by hand, in
/// centimetres (so every length maps x10), plus a disk face with a circular edge.
const TETRA: &str = include_str!("tetra_disk.stp");
#[test]
fn imports_hand_written_tetrahedron() {
let m = import(TETRA.as_bytes(), &Limits::default()).unwrap();
assert!(m.skipped.is_empty(), "{:?}", m.skipped);
assert_eq!(m.bodies.len(), 2);
let t = &m.bodies[0];
assert_eq!(t.step_id, 101);
assert_eq!(t.unit_mm, 10.0);
assert!((t.uncertainty_mm.unwrap() - 1e-4).abs() < 1e-18);
assert_eq!(
(
t.shells.len(),
t.faces.len(),
t.edges.len(),
t.vertices.len()
),
(1, 4, 6, 4)
);
assert!(t.shells[0].closed);
let v12 = t.vertices.iter().find(|v| v.step_id == 12).unwrap();
assert_eq!(v12.point, [10.0, 0.0, 0.0]);
// Edge #53 runs (10,0,0) -> (0,10,0) along a line of magnitude 10 sqrt 2.
let e = &t.edges[t.edges.iter().position(|e| e.step_id == 53).unwrap()];
assert!((e.t_start - 0.0).abs() < 1e-12 && (e.t_end - 1.0).abs() < 1e-12);
let Curve::Line { magnitude, .. } = e.curve else {
panic!()
};
assert!((magnitude - 10.0 * 2f64.sqrt()).abs() < 1e-12);
let f = &t.faces[0];
assert!(!f.same_sense);
let Surface::Plane { frame } = f.surface else {
panic!()
};
assert_eq!(frame.z, [0.0, 0.0, 1.0]);
let Bound::Edges(es) = &f.loops[0].bound else {
panic!()
};
assert_eq!(es.len(), 3);
assert!(es[0].forward && !es[1].forward);
// Every edge is used twice, once in each direction.
for (i, _) in t.edges.iter().enumerate() {
let uses: Vec<bool> = t
.faces
.iter()
.flat_map(|f| match &f.loops[0].bound {
Bound::Edges(es) => es.clone(),
Bound::Vertex(_) => vec![],
})
.filter(|oe| oe.edge == i)
.map(|oe| oe.forward)
.collect();
assert_eq!(uses.len(), 2);
assert_ne!(uses[0], uses[1]);
}
// The disk: a closed circle edge spanning one period from angle 0.
let d = &m.bodies[1];
assert_eq!(d.kind, model::BodyKind::ShellModel);
assert!(!d.shells[0].closed);
let e = &d.edges[0];
assert_eq!((e.start, e.end), (0, 0));
assert!(e.t_start.abs() < 1e-12 && (e.t_end - 360.0).abs() < 1e-12);
let Curve::Circle { radius, frame } = e.curve else {
panic!()
};
assert_eq!(radius, 10.0);
assert_eq!(frame.origin, [40.0, 0.0, 0.0]);
}
#[test]
fn no_brep_and_garbage() {
let m = import(
b"ISO-10303-21;HEADER;ENDSEC;DATA;#1=PRODUCT('a','b','',());ENDSEC;END-ISO-10303-21;",
&Limits::default(),
)
.unwrap();
assert!(m.bodies.is_empty() && m.skipped.is_empty());
assert!(import(b"\x00\xff garbage", &Limits::default()).is_err());
}
+22 -12
View File
@@ -1,4 +1,5 @@
//! ISO 10303-21 writer for the neutral [`step_import::Model`].
//! ISO 10303-21 writer for the neutral [`solidcore_record::step::Model`], and the mapper ([`map`])
//! that reads a Part 21 exchange ([`solidcore_record::step::p21`]) into it.
//!
//! The writer emits one `MANIFOLD_SOLID_BREP` per manifold body (one closed shell,
//! planar faces, line edges) and one `FACETED_BREP` per faceted body, together with a
@@ -38,12 +39,22 @@
/// A kernel body as the neutral model this writer writes (moved from step-brep).
pub mod brep_to_model;
pub mod geom;
pub mod map;
pub mod validate;
pub use map::{import, Limits};
#[cfg(test)]
mod import_tests;
pub use brep_to_model::{brep_to_model, ExportModelError};
use std::collections::BTreeMap;
use std::fmt::{self, Write as _};
use step_import::model::{Body, BodyKind, Bound, Curve, Frame, Model, PcurveKey, Surface};
use solidcore_record::step::model::{
Body, BodyKind, Bound, Curve, Frame, Model, PcurveKey, Surface,
};
/// Default distance, in millimetres, within which faceted vertices are welded.
pub const WELD_TOLERANCE_MM: f64 = 1e-6;
@@ -446,7 +457,7 @@ fn write_surface(
fn write_bspline_surface(
entities: &mut Entities,
points: &mut PointIds,
surface: &step_import::model::BSplineSurface,
surface: &solidcore_record::step::model::BSplineSurface,
) -> Result<u64, ExportError> {
let mut ids = Vec::with_capacity(surface.points.len());
for row in &surface.points {
@@ -563,7 +574,7 @@ fn write_curve(
fn write_bspline_curve(
entities: &mut Entities,
points: &mut PointIds,
curve: &step_import::model::BSplineCurve,
curve: &solidcore_record::step::model::BSplineCurve,
) -> Result<u64, ExportError> {
let ids = curve
.points
@@ -1394,8 +1405,8 @@ fn refs(ids: &[u64]) -> String {
#[cfg(test)]
mod tests {
use super::*;
use step_import::model::{Edge, Face, Loop, OrientedEdge, Shell, Vertex};
use step_import::part21::Header;
use solidcore_record::step::model::{Edge, Face, Loop, OrientedEdge, Shell, Vertex};
use solidcore_record::step::p21::Header;
fn line(origin: [f64; 3], dir: [f64; 3], magnitude: f64) -> Curve {
Curve::Line {
@@ -1520,7 +1531,7 @@ mod tests {
diagnostics: Vec::new(),
assembly_instances: 0,
pcurves: BTreeMap::new(),
file_pcurves: step_import::model::FilePcurves::default(),
file_pcurves: solidcore_record::step::model::FilePcurves::default(),
}
}
@@ -1650,7 +1661,7 @@ mod tests {
diagnostics: Vec::new(),
assembly_instances: 0,
pcurves: BTreeMap::new(),
file_pcurves: step_import::model::FilePcurves::default(),
file_pcurves: solidcore_record::step::model::FilePcurves::default(),
}
}
@@ -1688,8 +1699,7 @@ mod tests {
}
fn reimport(text: &str) -> Model {
let model = step_import::import(text.as_bytes(), &step_import::Limits::default())
.expect("re-import written STEP");
let model = import(text.as_bytes(), &Limits::default()).expect("re-import written STEP");
assert!(model.skipped.is_empty(), "{:?}", model.skipped);
model
}
@@ -2041,7 +2051,7 @@ mod tests {
magnitude: 3.0,
},
];
let bspline = step_import::model::BSplineSurface {
let bspline = solidcore_record::step::model::BSplineSurface {
u_degree: 1,
v_degree: 1,
points: vec![
@@ -2071,7 +2081,7 @@ mod tests {
semi1: 2.0,
semi2: 1.0,
},
Curve::BSpline(step_import::model::BSplineCurve {
Curve::BSpline(solidcore_record::step::model::BSplineCurve {
degree: 1,
points: vec![[0.0; 3], [1.0, 0.0, 0.0]],
weights: None,
@@ -21,11 +21,69 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use crate::geom::{self, build_frame, conic_angle, dist, unit};
use crate::model::{
use solidcore_record::step::model::{
BSplineCurve, BSplineSurface, Body, BodyKind, Bound, Curve, Curve2d, Diagnostic, Edge, Face,
FilePcurves, Frame, Loop, Model, OrientedEdge, Pcurve, Shell, Skipped, Surface, Vertex, P3,
};
use crate::part21::{Instance, Limits, Param, Record, StepFile};
use solidcore_record::step::p21::{self, Entity, Exchange as StepFile, Param, Record};
/// Bounds the mapper applies to untrusted input (the parser's own are [`p21`]'s `MAX_*`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Deepest chain of references the mapper follows from a representation item
/// (e.g. surface of revolution -> curve -> ...). Cycles stop here.
pub max_ref_depth: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_ref_depth: 64 }
}
}
/// An entity instance as the mapper reads it: one record, or the parts of a complex instance.
#[derive(Debug, Clone, Copy)]
enum Instance<'a> {
Simple(&'a Record),
Complex(&'a [Record]),
}
impl<'a> Instance<'a> {
fn of(entity: &'a Entity) -> Self {
match entity.records.as_slice() {
[record] => Self::Simple(record),
records => Self::Complex(records),
}
}
fn record(self, name: &str) -> Option<&'a Record> {
match self {
Self::Simple(r) => (r.name == name).then_some(r),
Self::Complex(rs) => rs.iter().find(|r| r.name == name),
}
}
fn type_name(self) -> String {
match self {
Self::Simple(r) => r.name.clone(),
Self::Complex(rs) => rs
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join("+"),
}
}
}
/// Reads `bytes` as a Part 21 file and maps its bodies to the neutral [`Model`].
///
/// # Errors
/// The [`p21::Error`] when the text is not a readable exchange structure.
pub fn import(bytes: &[u8], limits: &Limits) -> Result<Model, p21::Error> {
let text = String::from_utf8_lossy(bytes);
let file = p21::parse(&text)?;
Ok(map_file(&file, limits))
}
/// Why a body could not be mapped.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -97,10 +155,11 @@ struct Ctx {
}
impl<'a> Mapper<'a> {
fn inst(&self, id: u64) -> R<&'a Instance> {
fn inst(&self, id: u64) -> R<Instance<'a>> {
self.file
.entities
.get(&id)
.map(Instance::of)
.map_or_else(|| invalid(format!("dangling reference #{id}")), Ok)
}
@@ -272,7 +331,7 @@ impl<'a> Mapper<'a> {
/// Representation item id -> context id, from every representation's items.
fn item_contexts(&self) -> BTreeMap<u64, u64> {
let mut out = BTreeMap::new();
for inst in self.file.entities.values() {
for inst in self.file.entities.values().map(Instance::of) {
let rec = match inst {
Instance::Simple(r) if r.name.ends_with("REPRESENTATION") => r,
Instance::Complex(rs) => match rs.iter().find(|r| r.name == "REPRESENTATION") {
@@ -1210,7 +1269,11 @@ fn polygon_plane(pts: &[P3]) -> R<Surface> {
Ok(Surface::Plane { frame })
}
pub(crate) fn check_bspline_curve(b: &BSplineCurve) -> R<()> {
/// Checks a B-spline curve is well formed.
///
/// # Errors
/// [`MapError::Invalid`] naming the fault.
pub fn check_bspline_curve(b: &BSplineCurve) -> R<()> {
let n = b.points.len();
let p = b.degree as usize;
if n < p + 1 {
@@ -1228,7 +1291,11 @@ pub(crate) fn check_bspline_curve(b: &BSplineCurve) -> R<()> {
Ok(())
}
pub(crate) fn check_knots(knots: &[f64], mults: &[u32], degree: usize) -> R<()> {
/// Checks a knot vector increases and its multiplicities fit `degree`.
///
/// # Errors
/// [`MapError::Invalid`] naming the fault.
pub fn check_knots(knots: &[f64], mults: &[u32], degree: usize) -> R<()> {
if knots
.windows(2)
.any(|w| matches!(w, [a, b] if a.partial_cmp(b) != Some(std::cmp::Ordering::Less)))
@@ -1448,7 +1515,7 @@ fn trim_parameter(p: &Param) -> Option<f64> {
p.as_list()?
.iter()
.find_map(|select| match select {
Param::Typed(name, value) if name == "PARAMETER_VALUE" => value.as_f64(),
Param::Typed(name, _) if name == "PARAMETER_VALUE" => select.as_f64(),
Param::Real(_) | Param::Int(_) => select.as_f64(),
_ => None,
})
@@ -1484,7 +1551,7 @@ fn scale_curve_2d(curve: Curve2d, su: f64, sv: f64) -> Curve2d {
/// The `#id` of face `id`'s surface (through `ORIENTED_FACE`s), when it has one.
fn face_surface_id(file: &StepFile, id: u64, max_depth: usize) -> Option<u64> {
let base = resolve_face(file, id, max_depth)?;
let Instance::Simple(record) = file.entities.get(&base)? else {
let Instance::Simple(record) = file.entities.get(&base).map(Instance::of)? else {
return None;
};
if !matches!(record.name.as_str(), "ADVANCED_FACE" | "FACE_SURFACE") {
@@ -1539,12 +1606,16 @@ fn collect_dangling_references(file: &StepFile, diagnostics: &mut Vec<Diagnostic
visit(item, missing);
}
}
Param::Typed(_, value) => visit(value, missing),
Param::Typed(_, values) => {
for value in values {
visit(value, missing);
}
}
_ => {}
}
}
let mut seen = BTreeSet::new();
for (id, inst) in &file.entities {
for (id, inst) in file.entities.iter().map(|(id, e)| (id, Instance::of(e))) {
let records: &[Record] = match inst {
Instance::Simple(record) => std::slice::from_ref(record),
Instance::Complex(records) => records,
@@ -1570,7 +1641,7 @@ fn collect_dangling_references(file: &StepFile, diagnostics: &mut Vec<Diagnostic
fn resolve_face(file: &StepFile, id: u64, max_depth: usize) -> Option<u64> {
let mut current = id;
for _ in 0..=max_depth {
let Instance::Simple(record) = file.entities.get(&current)? else {
let Instance::Simple(record) = file.entities.get(&current).map(Instance::of)? else {
return Some(current);
};
if record.name != "ORIENTED_FACE" {
@@ -1587,7 +1658,7 @@ fn resolve_face(file: &StepFile, id: u64, max_depth: usize) -> Option<u64> {
/// Every face id a shell references, plus the `ORIENTED_FACE` wrapper ids themselves.
fn shell_referenced_faces(file: &StepFile, max_depth: usize) -> BTreeSet<u64> {
let mut referenced = BTreeSet::new();
for instance in file.entities.values() {
for instance in file.entities.values().map(Instance::of) {
let Instance::Simple(record) = instance else {
continue;
};
@@ -1613,7 +1684,7 @@ fn shell_referenced_faces(file: &StepFile, max_depth: usize) -> BTreeSet<u64> {
/// The inner face of every `ORIENTED_FACE` (mapped through the wrapper instead).
fn oriented_face_targets(file: &StepFile) -> BTreeSet<u64> {
let mut targets = BTreeSet::new();
for instance in file.entities.values() {
for instance in file.entities.values().map(Instance::of) {
let Instance::Simple(record) = instance else {
continue;
};
@@ -1638,7 +1709,7 @@ fn map_loose_faces(
let referenced = shell_referenced_faces(m.file, m.limits.max_ref_depth);
let inner = oriented_face_targets(m.file);
let mut candidates = Vec::new();
for (id, instance) in &m.file.entities {
for (id, instance) in m.file.entities.iter().map(|(id, e)| (id, Instance::of(e))) {
let Instance::Simple(record) = instance else {
continue;
};
@@ -1683,7 +1754,7 @@ fn map_loose_faces(
.file
.entities
.get(&id)
.map_or_else(String::new, Instance::type_name),
.map_or_else(String::new, |e| Instance::of(e).type_name()),
reason: error.to_string(),
}),
}
@@ -1692,7 +1763,7 @@ fn map_loose_faces(
// loose-face heuristic above sees no candidate. If the file has no solid bodies at
// all, treat every face as a sheet rather than returning an empty model.
if model.bodies.is_empty() {
for (id, instance) in &m.file.entities {
for (id, instance) in m.file.entities.iter().map(|(id, e)| (id, Instance::of(e))) {
let Instance::Simple(record) = instance else {
continue;
};
@@ -1733,7 +1804,7 @@ pub fn map_file(file: &StepFile, limits: &Limits) -> Model {
// Fallback units when an item is in no representation: those of the file's only
// context with a unit assignment, if there is exactly one kind.
let mut all_units: Vec<Units> = Vec::new();
for (id, inst) in &file.entities {
for (id, inst) in file.entities.iter().map(|(id, e)| (id, Instance::of(e))) {
if inst.record("GLOBAL_UNIT_ASSIGNED_CONTEXT").is_some() {
if let Ok(u) = m.context_units(*id) {
if !all_units.contains(&u) {
@@ -1765,7 +1836,7 @@ pub fn map_file(file: &StepFile, limits: &Limits) -> Model {
),
});
}
for (id, inst) in &file.entities {
for (id, inst) in file.entities.iter().map(|(id, e)| (id, Instance::of(e))) {
let rec = match inst {
Instance::Simple(r) => r,
Instance::Complex(_) => {
@@ -1819,11 +1890,13 @@ pub fn map_file(file: &StepFile, limits: &Limits) -> Model {
#[cfg(test)]
mod tests {
use super::*;
use crate::part21::parse;
use solidcore_record::step::p21::parse;
fn model(data: &str) -> Model {
let text = format!("ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));\nENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n");
let f = parse(text.as_bytes(), &Limits::default()).unwrap();
let text = format!(
"ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));\nENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n"
);
let f = parse(&text).unwrap();
map_file(&f, &Limits::default())
}
+101
View File
@@ -0,0 +1,101 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('hand-written test'),'2;1');
FILE_NAME('tetra','2026-10-25T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));
ENDSEC;
DATA;
#1=CARTESIAN_POINT('',(0.,0.,0.));
#2=CARTESIAN_POINT('',(1.,0.,0.));
#3=CARTESIAN_POINT('',(0.,1.,0.));
#4=CARTESIAN_POINT('',(0.,0.,1.));
#11=VERTEX_POINT('',#1);
#12=VERTEX_POINT('',#2);
#13=VERTEX_POINT('',#3);
#14=VERTEX_POINT('',#4);
#20=DIRECTION('',(1.,0.,0.));
#21=DIRECTION('',(0.,1.,0.));
#22=DIRECTION('',(0.,0.,1.));
#23=DIRECTION('',(-1.,1.,0.));
#24=DIRECTION('',(-1.,0.,1.));
#25=DIRECTION('',(0.,-1.,1.));
#30=LINE('',#1,#40);
#31=LINE('',#1,#41);
#32=LINE('',#1,#42);
#33=LINE('',#2,#43);
#34=LINE('',#2,#44);
#35=LINE('',#3,#45);
#40=VECTOR('',#20,1.);
#41=VECTOR('',#21,1.);
#42=VECTOR('',#22,1.);
#43=VECTOR('',#23,1.4142135623730951);
#44=VECTOR('',#24,1.4142135623730951);
#45=VECTOR('',#25,1.4142135623730951);
#50=EDGE_CURVE('',#11,#12,#30,.T.);
#51=EDGE_CURVE('',#11,#13,#31,.T.);
#52=EDGE_CURVE('',#11,#14,#32,.T.);
#53=EDGE_CURVE('',#12,#13,#33,.T.);
#54=EDGE_CURVE('',#12,#14,#34,.T.);
#55=EDGE_CURVE('',#13,#14,#35,.T.);
/* bottom z=0, normal -Z: 0 -> 2 -> 1 */
#60=EDGE_LOOP('',(#61,#62,#63));
#61=ORIENTED_EDGE('',*,*,#51,.T.);
#62=ORIENTED_EDGE('',*,*,#53,.F.);
#63=ORIENTED_EDGE('',*,*,#50,.F.);
#64=FACE_OUTER_BOUND('',#60,.T.);
#65=AXIS2_PLACEMENT_3D('',#1,#22,#20);
#66=PLANE('',#65);
#67=ADVANCED_FACE('',(#64),#66,.F.);
/* y=0, normal -Y: 0 -> 1 -> 3 */
#70=EDGE_LOOP('',(#71,#72,#73));
#71=ORIENTED_EDGE('',*,*,#50,.T.);
#72=ORIENTED_EDGE('',*,*,#54,.T.);
#73=ORIENTED_EDGE('',*,*,#52,.F.);
#74=FACE_OUTER_BOUND('',#70,.T.);
#75=AXIS2_PLACEMENT_3D('',#1,#21,$);
#76=PLANE('',#75);
#77=ADVANCED_FACE('',(#74),#76,.F.);
/* x=0, normal -X: 0 -> 3 -> 2 */
#80=EDGE_LOOP('',(#81,#82,#83));
#81=ORIENTED_EDGE('',*,*,#52,.T.);
#82=ORIENTED_EDGE('',*,*,#55,.F.);
#83=ORIENTED_EDGE('',*,*,#51,.F.);
#84=FACE_OUTER_BOUND('',#80,.T.);
#85=AXIS2_PLACEMENT_3D('',#1,#20,$);
#86=PLANE('',#85);
#87=ADVANCED_FACE('',(#84),#86,.F.);
/* slanted, normal (1,1,1): 1 -> 2 -> 3 */
#90=EDGE_LOOP('',(#91,#92,#93));
#91=ORIENTED_EDGE('',*,*,#53,.T.);
#92=ORIENTED_EDGE('',*,*,#55,.T.);
#93=ORIENTED_EDGE('',*,*,#54,.F.);
#94=FACE_OUTER_BOUND('',#90,.T.);
#95=DIRECTION('',(1.,1.,1.));
#96=AXIS2_PLACEMENT_3D('',#2,#95,$);
#97=PLANE('',#96);
#98=ADVANCED_FACE('',(#94),#97,.T.);
#100=CLOSED_SHELL('',(#67,#77,#87,#98));
#101=MANIFOLD_SOLID_BREP('tetra',#100);
/* a disk: one closed circular edge on a plane, an open shell */
#110=CARTESIAN_POINT('',(5.,0.,0.));
#111=VERTEX_POINT('',#110);
#112=CARTESIAN_POINT('',(4.,0.,0.));
#113=AXIS2_PLACEMENT_3D('',#112,#22,#20);
#114=CIRCLE('',#113,1.);
#115=EDGE_CURVE('',#111,#111,#114,.T.);
#116=ORIENTED_EDGE('',*,*,#115,.T.);
#117=EDGE_LOOP('',(#116));
#118=FACE_BOUND('',#117,.T.);
#119=PLANE('',#113);
#120=ADVANCED_FACE('',(#118),#119,.T.);
#121=OPEN_SHELL('',(#120));
#122=SHELL_BASED_SURFACE_MODEL('',(#121));
#200=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.));
#201=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));
#202=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-05),#200,'distance_accuracy_value','');
#203=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#202))GLOBAL_UNIT_ASSIGNED_CONTEXT((#200,#201))REPRESENTATION_CONTEXT('',''));
#204=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101,#65),#203);
#205=MANIFOLD_SURFACE_SHAPE_REPRESENTATION('',(#122),#203);
ENDSEC;
END-ISO-10303-21;
@@ -7,8 +7,8 @@
use std::fmt;
use crate::model::{Body, Model};
use crate::part21::{Instance, Param, Record, StepFile};
use solidcore_record::step::model::{Body, Model};
use solidcore_record::step::p21::{Exchange as StepFile, Param, Record};
/// One validation problem.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -38,7 +38,11 @@ fn visit(param: &Param, missing: &mut Vec<u64>) {
visit(item, missing);
}
}
Param::Typed(_, value) => visit(value, missing),
Param::Typed(_, values) => {
for value in values {
visit(value, missing);
}
}
_ => {}
}
}
@@ -46,10 +50,7 @@ fn visit(param: &Param, missing: &mut Vec<u64>) {
fn dangling_references(file: &StepFile) -> Vec<ValidationError> {
let mut errors = Vec::new();
for (id, instance) in &file.entities {
let records: &[Record] = match instance {
Instance::Simple(record) => std::slice::from_ref(record),
Instance::Complex(records) => records,
};
let records: &[Record] = &instance.records;
for record in records {
for param in &record.params {
let mut missing = Vec::new();
@@ -95,7 +96,7 @@ fn validate_body(body: &Body) -> Vec<ValidationError> {
for face in &body.faces {
for loop_ in &face.loops {
match &loop_.bound {
crate::model::Bound::Edges(edges) => {
solidcore_record::step::model::Bound::Edges(edges) => {
for edge in edges {
if edge.edge >= body.edges.len() {
errors.push(ValidationError {
@@ -108,7 +109,7 @@ fn validate_body(body: &Body) -> Vec<ValidationError> {
}
}
}
&crate::model::Bound::Vertex(vertex) => {
&solidcore_record::step::model::Bound::Vertex(vertex) => {
if vertex >= body.vertices.len() {
errors.push(ValidationError {
step_id: Some(body.step_id),
@@ -145,13 +146,13 @@ pub fn validate(file: &StepFile, model: &Model) -> Vec<ValidationError> {
#[cfg(test)]
mod tests {
use super::*;
use crate::map::map_file;
use crate::part21::{parse, Limits};
use crate::map::{map_file, Limits};
use solidcore_record::step::p21::parse;
#[test]
fn reports_dangling_references() {
let text = "ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));\nENDSEC;\nDATA;\n#1=PRODUCT('p','p','',(#9));\nENDSEC;\nEND-ISO-10303-21;\n";
let file = parse(text.as_bytes(), &Limits::default()).unwrap();
let file = parse(text).unwrap();
let model = map_file(&file, &Limits::default());
assert!(validate(&file, &model)
.iter()
@@ -163,7 +164,7 @@ mod tests {
// A face with no shell is mapped as a one-face open-shell sheet body; its
// synthetic shell must still validate.
let text = "ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));\nENDSEC;\nDATA;\n#6=FACE('',(#7));#7=FACE_OUTER_BOUND('',#8,.T.);#8=POLY_LOOP('',(#9,#10,#11));#9=CARTESIAN_POINT('',(0.,0.,0.));#10=CARTESIAN_POINT('',(3.,0.,0.));#11=CARTESIAN_POINT('',(0.,4.,0.));\nENDSEC;\nEND-ISO-10303-21;\n";
let file = parse(text.as_bytes(), &Limits::default()).unwrap();
let file = parse(text).unwrap();
let model = map_file(&file, &Limits::default());
assert_eq!(model.bodies.len(), 1);
assert!(
@@ -176,13 +177,13 @@ mod tests {
#[test]
fn reports_a_vertex_loop_of_a_missing_vertex() {
let text = "ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));\nENDSEC;\nDATA;\n#6=FACE('',(#7));#7=FACE_OUTER_BOUND('',#8,.T.);#8=POLY_LOOP('',(#9,#10,#11));#9=CARTESIAN_POINT('',(0.,0.,0.));#10=CARTESIAN_POINT('',(3.,0.,0.));#11=CARTESIAN_POINT('',(0.,4.,0.));\nENDSEC;\nEND-ISO-10303-21;\n";
let file = parse(text.as_bytes(), &Limits::default()).unwrap();
let file = parse(text).unwrap();
let mut model = map_file(&file, &Limits::default());
let nv = model.bodies[0].vertices.len();
let vertex_loop = |v| crate::model::Loop {
let vertex_loop = |v| solidcore_record::step::model::Loop {
outer: false,
orientation: true,
bound: crate::model::Bound::Vertex(v),
bound: solidcore_record::step::model::Bound::Vertex(v),
};
// A vertex loop of an existing vertex is consistent.
model.bodies[0].faces[0].loops.push(vertex_loop(nv - 1));
@@ -199,7 +200,7 @@ mod tests {
#[test]
fn accepts_a_consistent_model() {
let file = parse(b"ISO-10303-21;HEADER;FILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));ENDSEC;DATA;ENDSEC;END-ISO-10303-21;", &Limits::default()).unwrap();
let file = parse("ISO-10303-21;HEADER;FILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));ENDSEC;DATA;ENDSEC;END-ISO-10303-21;").unwrap();
let model = map_file(&file, &Limits::default());
assert!(validate(&file, &model).is_empty());
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "step-import"
description = "Clean-room ISO 10303-21 (STEP) reader mapping the AP203/AP214 B-rep subset to a neutral plain-data model"
edition.workspace = true
license.workspace = true
version.workspace = true
publish = false
[dependencies]
[lints]
workspace = true
[dev-dependencies]
solidcore = { path = "../solidcore" }
-284
View File
@@ -1,284 +0,0 @@
//! Clean-room ISO 10303-21 (STEP) reader for the AP203/AP214 B-rep subset.
//!
//! [`import`] parses a Part 21 file ([`part21`]) and maps its B-rep bodies
//! ([`map`]) to a neutral, plain-data [`Model`] ([`model`]): shells, faces with
//! orientation, loops of oriented edges, vertices, and analytic or B-spline geometry,
//! in millimetres with every placement resolved. A face that no shell references (a
//! hand-written or geometrically bounded sheet) becomes a one-face open-shell body, so
//! sheet-only files do not report "no B-rep bodies". [`flat::encode`] turns a body into
//! one `f64` array for the C++ side of the difftest, so both kernels build from the
//! same numbers. Nothing here depends on either kernel.
//!
//! Written from the public ISO 10303-21 file syntax and the ISO 10303-41/42 entity
//! definitions used by AP203 and AP214 only; no other STEP implementation was
//! consulted. Inputs are untrusted: see [`Limits`]. Unsupported entities make the
//! body that reaches them [`model::Skipped`] with `unsupported: <ENTITY>`; nothing
//! panics.
pub mod flat;
mod geom;
pub mod map;
pub mod model;
pub mod part21;
pub mod script;
pub mod validate;
pub use model::Model;
pub use part21::{Limits, ParseError};
// Some AP203 files use the Part 21 short entity names. The parser stores the raw
// name, so normalize the handful of geometry entities needed by the mapper.
// Every pair as ISO's short-names file gives it
// (<http://standards.iso.org/iso/10303/tech/short_names/short-names.txt>).
const ALIASES: [(&str, &str); 12] = [
("FCSRF", "FACE_SURFACE"),
("FCOTBN", "FACE_OUTER_BOUND"),
("PLYLP", "POLY_LOOP"),
("CRTPNT", "CARTESIAN_POINT"),
("DRCTN", "DIRECTION"),
("A2PL3D", "AXIS2_PLACEMENT_3D"),
("CLSSHL", "CLOSED_SHELL"),
("BSSWK", "B_SPLINE_SURFACE_WITH_KNOTS"),
("BSCWK", "B_SPLINE_CURVE_WITH_KNOTS"),
("TRMCRV", "TRIMMED_CURVE"),
("CMCRSG", "COMPOSITE_CURVE_SEGMENT"),
("CMPCRV", "COMPOSITE_CURVE"),
];
/// Parses `bytes` and maps every B-rep body.
pub fn import(bytes: &[u8], limits: &Limits) -> Result<Model, ParseError> {
let normalized = long_names(&String::from_utf8_lossy(bytes), &ALIASES);
let file = part21::parse(normalized.as_bytes(), limits)?;
Ok(map::map_file(&file, limits))
}
/// `text` with each instance written with a short name in `aliases` written with its long name.
fn long_names(text: &str, aliases: &[(&str, &str)]) -> String {
let mut normalized = text.to_owned();
for (short, long) in aliases {
normalized = normalized.replace(&format!("={short}("), &format!("={long}("));
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
/// Short names read as ISO's short-names file gives them: `CMCRSG` is a composite curve's
/// segment, `CMPCRV` the composite curve (the table had `CMCRSG` as the curve).
#[test]
fn composite_curve_short_names_read_as_iso_gives_them() {
let text = long_names(
"#1=CMCRSG(.CONTINUOUS.,.T.,#2);#3=CMPCRV('',(#1),.F.);",
&ALIASES,
);
assert!(
text.contains("#1=COMPOSITE_CURVE_SEGMENT(") && text.contains("#3=COMPOSITE_CURVE("),
"{text}"
);
}
use crate::model::{Bound, Curve, Surface};
/// A tetrahedron (0,0,0) (10,0,0) (0,10,0) (0,0,10), written by hand, in
/// centimetres (so every length maps x10), plus a disk face with a circular edge.
const TETRA: &str = "ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('hand-written test'),'2;1');
FILE_NAME('tetra','2026-10-25T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));
ENDSEC;
DATA;
#1=CARTESIAN_POINT('',(0.,0.,0.));
#2=CARTESIAN_POINT('',(1.,0.,0.));
#3=CARTESIAN_POINT('',(0.,1.,0.));
#4=CARTESIAN_POINT('',(0.,0.,1.));
#11=VERTEX_POINT('',#1);
#12=VERTEX_POINT('',#2);
#13=VERTEX_POINT('',#3);
#14=VERTEX_POINT('',#4);
#20=DIRECTION('',(1.,0.,0.));
#21=DIRECTION('',(0.,1.,0.));
#22=DIRECTION('',(0.,0.,1.));
#23=DIRECTION('',(-1.,1.,0.));
#24=DIRECTION('',(-1.,0.,1.));
#25=DIRECTION('',(0.,-1.,1.));
#30=LINE('',#1,#40);
#31=LINE('',#1,#41);
#32=LINE('',#1,#42);
#33=LINE('',#2,#43);
#34=LINE('',#2,#44);
#35=LINE('',#3,#45);
#40=VECTOR('',#20,1.);
#41=VECTOR('',#21,1.);
#42=VECTOR('',#22,1.);
#43=VECTOR('',#23,1.4142135623730951);
#44=VECTOR('',#24,1.4142135623730951);
#45=VECTOR('',#25,1.4142135623730951);
#50=EDGE_CURVE('',#11,#12,#30,.T.);
#51=EDGE_CURVE('',#11,#13,#31,.T.);
#52=EDGE_CURVE('',#11,#14,#32,.T.);
#53=EDGE_CURVE('',#12,#13,#33,.T.);
#54=EDGE_CURVE('',#12,#14,#34,.T.);
#55=EDGE_CURVE('',#13,#14,#35,.T.);
/* bottom z=0, normal -Z: 0 -> 2 -> 1 */
#60=EDGE_LOOP('',(#61,#62,#63));
#61=ORIENTED_EDGE('',*,*,#51,.T.);
#62=ORIENTED_EDGE('',*,*,#53,.F.);
#63=ORIENTED_EDGE('',*,*,#50,.F.);
#64=FACE_OUTER_BOUND('',#60,.T.);
#65=AXIS2_PLACEMENT_3D('',#1,#22,#20);
#66=PLANE('',#65);
#67=ADVANCED_FACE('',(#64),#66,.F.);
/* y=0, normal -Y: 0 -> 1 -> 3 */
#70=EDGE_LOOP('',(#71,#72,#73));
#71=ORIENTED_EDGE('',*,*,#50,.T.);
#72=ORIENTED_EDGE('',*,*,#54,.T.);
#73=ORIENTED_EDGE('',*,*,#52,.F.);
#74=FACE_OUTER_BOUND('',#70,.T.);
#75=AXIS2_PLACEMENT_3D('',#1,#21,$);
#76=PLANE('',#75);
#77=ADVANCED_FACE('',(#74),#76,.F.);
/* x=0, normal -X: 0 -> 3 -> 2 */
#80=EDGE_LOOP('',(#81,#82,#83));
#81=ORIENTED_EDGE('',*,*,#52,.T.);
#82=ORIENTED_EDGE('',*,*,#55,.F.);
#83=ORIENTED_EDGE('',*,*,#51,.F.);
#84=FACE_OUTER_BOUND('',#80,.T.);
#85=AXIS2_PLACEMENT_3D('',#1,#20,$);
#86=PLANE('',#85);
#87=ADVANCED_FACE('',(#84),#86,.F.);
/* slanted, normal (1,1,1): 1 -> 2 -> 3 */
#90=EDGE_LOOP('',(#91,#92,#93));
#91=ORIENTED_EDGE('',*,*,#53,.T.);
#92=ORIENTED_EDGE('',*,*,#55,.T.);
#93=ORIENTED_EDGE('',*,*,#54,.F.);
#94=FACE_OUTER_BOUND('',#90,.T.);
#95=DIRECTION('',(1.,1.,1.));
#96=AXIS2_PLACEMENT_3D('',#2,#95,$);
#97=PLANE('',#96);
#98=ADVANCED_FACE('',(#94),#97,.T.);
#100=CLOSED_SHELL('',(#67,#77,#87,#98));
#101=MANIFOLD_SOLID_BREP('tetra',#100);
/* a disk: one closed circular edge on a plane, an open shell */
#110=CARTESIAN_POINT('',(5.,0.,0.));
#111=VERTEX_POINT('',#110);
#112=CARTESIAN_POINT('',(4.,0.,0.));
#113=AXIS2_PLACEMENT_3D('',#112,#22,#20);
#114=CIRCLE('',#113,1.);
#115=EDGE_CURVE('',#111,#111,#114,.T.);
#116=ORIENTED_EDGE('',*,*,#115,.T.);
#117=EDGE_LOOP('',(#116));
#118=FACE_BOUND('',#117,.T.);
#119=PLANE('',#113);
#120=ADVANCED_FACE('',(#118),#119,.T.);
#121=OPEN_SHELL('',(#120));
#122=SHELL_BASED_SURFACE_MODEL('',(#121));
#200=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.));
#201=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));
#202=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-05),#200,'distance_accuracy_value','');
#203=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#202))GLOBAL_UNIT_ASSIGNED_CONTEXT((#200,#201))REPRESENTATION_CONTEXT('',''));
#204=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101,#65),#203);
#205=MANIFOLD_SURFACE_SHAPE_REPRESENTATION('',(#122),#203);
ENDSEC;
END-ISO-10303-21;
";
#[test]
fn imports_hand_written_tetrahedron() {
let m = import(TETRA.as_bytes(), &Limits::default()).unwrap();
assert!(m.skipped.is_empty(), "{:?}", m.skipped);
assert_eq!(m.bodies.len(), 2);
let t = &m.bodies[0];
assert_eq!(t.step_id, 101);
assert_eq!(t.unit_mm, 10.0);
assert!((t.uncertainty_mm.unwrap() - 1e-4).abs() < 1e-18);
assert_eq!(
(
t.shells.len(),
t.faces.len(),
t.edges.len(),
t.vertices.len()
),
(1, 4, 6, 4)
);
assert!(t.shells[0].closed);
let v12 = t.vertices.iter().find(|v| v.step_id == 12).unwrap();
assert_eq!(v12.point, [10.0, 0.0, 0.0]);
// Edge #53 runs (10,0,0) -> (0,10,0) along a line of magnitude 10 sqrt 2.
let e = &t.edges[t.edges.iter().position(|e| e.step_id == 53).unwrap()];
assert!((e.t_start - 0.0).abs() < 1e-12 && (e.t_end - 1.0).abs() < 1e-12);
let Curve::Line { magnitude, .. } = e.curve else {
panic!()
};
assert!((magnitude - 10.0 * 2f64.sqrt()).abs() < 1e-12);
let f = &t.faces[0];
assert!(!f.same_sense);
let Surface::Plane { frame } = f.surface else {
panic!()
};
assert_eq!(frame.z, [0.0, 0.0, 1.0]);
let Bound::Edges(es) = &f.loops[0].bound else {
panic!()
};
assert_eq!(es.len(), 3);
assert!(es[0].forward && !es[1].forward);
// Every edge is used twice, once in each direction.
for (i, _) in t.edges.iter().enumerate() {
let uses: Vec<bool> = t
.faces
.iter()
.flat_map(|f| match &f.loops[0].bound {
Bound::Edges(es) => es.clone(),
Bound::Vertex(_) => vec![],
})
.filter(|oe| oe.edge == i)
.map(|oe| oe.forward)
.collect();
assert_eq!(uses.len(), 2);
assert_ne!(uses[0], uses[1]);
}
// The disk: a closed circle edge spanning one period from angle 0.
let d = &m.bodies[1];
assert_eq!(d.kind, model::BodyKind::ShellModel);
assert!(!d.shells[0].closed);
let e = &d.edges[0];
assert_eq!((e.start, e.end), (0, 0));
assert!(e.t_start.abs() < 1e-12 && (e.t_end - 360.0).abs() < 1e-12);
let Curve::Circle { radius, frame } = e.curve else {
panic!()
};
assert_eq!(radius, 10.0);
assert_eq!(frame.origin, [40.0, 0.0, 0.0]);
// The flat encoding starts with its header and covers every vertex.
let flat = flat::encode(t);
assert_eq!(&flat[..7], &[21.0, 1.0, 0.0, 4.0, 6.0, 4.0, 1.0]);
assert_eq!(&flat[7..10], &[0.0, 0.0, 0.0]);
// The encoding round-trips through the validating decoder.
for b in &m.bodies {
let e = flat::encode(b);
assert_eq!(flat::encode(&flat::decode(&e).unwrap()), e);
}
// Truncations, bad indices and non-finite numbers are refused.
for cut in 0..flat.len() {
assert!(flat::decode(&flat[..cut]).is_err());
}
let mut bad = flat.clone();
bad[3] = 1e18;
assert!(flat::decode(&bad).is_err());
let mut bad = flat.clone();
bad[8] = f64::NAN;
assert!(flat::decode(&bad).is_err());
}
#[test]
fn no_brep_and_garbage() {
let m = import(
b"ISO-10303-21;HEADER;ENDSEC;DATA;#1=PRODUCT('a','b','',());ENDSEC;END-ISO-10303-21;",
&Limits::default(),
)
.unwrap();
assert!(m.bodies.is_empty() && m.skipped.is_empty());
assert!(import(b"\x00\xff garbage", &Limits::default()).is_err());
}
}
-918
View File
@@ -1,918 +0,0 @@
//! ISO 10303-21 ("Part 21", the STEP clear-text exchange structure) reader.
//!
//! Written from the published file syntax: a `HEADER` section of instances without
//! ids, one or more `DATA` sections of `#id = INSTANCE;` lines, where an instance is
//! simple (`NAME(params)`) or complex (`(A(params) B(params) ...)`), parameters are
//! integers, reals, strings, enumerations (`.T.`), binaries (`"..."`), references
//! (`#n`), unset (`$`), derived (`*`), lists and typed values (`NAME(param)`).
//! Comments are `/* ... */`.
//!
//! Untrusted input: the entity count and list nesting are bounded by [`Limits`], a
//! duplicate id is an error, and nothing is allocated from a count read out of the
//! file (lists grow as their items are read). Strings are kept raw (the `\X\`,
//! `\X2\` encodings are not decoded; no geometry depends on them).
use std::collections::BTreeMap;
use std::fmt;
/// Bounds applied to untrusted input.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Most `#id = ...` instances accepted in one file.
pub max_entities: usize,
/// Deepest nesting of parameter lists and typed parameters.
pub max_list_depth: usize,
/// Deepest chain of references the mapper follows from a representation item
/// (e.g. surface of revolution -> curve -> ...). Cycles stop here.
pub max_ref_depth: usize,
/// Most parameters in one list (and most items in any mapped list).
pub max_list_len: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_entities: 4_000_000,
max_list_depth: 32,
max_ref_depth: 64,
max_list_len: 1_000_000,
}
}
}
/// A Part 21 syntax or limit error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
/// 1-based line of the offending token.
pub line: usize,
/// What went wrong.
pub message: String,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
impl std::error::Error for ParseError {}
/// One parameter value.
#[derive(Debug, Clone, PartialEq)]
pub enum Param {
/// An integer.
Int(i64),
/// A real.
Real(f64),
/// A string, raw (quotes removed, `''` unescaped, control directives kept).
Str(String),
/// An enumeration value without its dots, e.g. `T`, `MILLI`.
Enum(String),
/// A binary value, raw hex digits.
Binary(String),
/// A reference `#n`.
Ref(u64),
/// `$`: unset optional attribute.
Null,
/// `*`: attribute derived in a subtype.
Derived,
/// `( ... )`.
List(Vec<Param>),
/// `NAME(value)`, e.g. `LENGTH_MEASURE(25.4)`.
Typed(String, Box<Param>),
}
impl Param {
/// The number, for an integer or a real (or a typed number such as
/// `LENGTH_MEASURE(1.)`).
pub fn as_f64(&self) -> Option<f64> {
match self {
Param::Real(x) => Some(*x),
Param::Int(i) => Some(*i as f64),
Param::Typed(_, p) => p.as_f64(),
_ => None,
}
}
/// The reference id.
pub fn as_ref_id(&self) -> Option<u64> {
match self {
Param::Ref(r) => Some(*r),
_ => None,
}
}
/// The list items.
pub fn as_list(&self) -> Option<&[Param]> {
match self {
Param::List(l) => Some(l),
_ => None,
}
}
/// The enumeration value.
pub fn as_enum(&self) -> Option<&str> {
match self {
Param::Enum(e) => Some(e),
_ => None,
}
}
}
/// One entity record: a name and its parameters.
#[derive(Debug, Clone, PartialEq)]
pub struct Record {
/// Entity name, upper case as written.
pub name: String,
/// Parameters in order.
pub params: Vec<Param>,
}
/// One instance of the data section.
#[derive(Debug, Clone, PartialEq)]
pub enum Instance {
/// `NAME(params)`.
Simple(Record),
/// `(A(params) B(params) ...)`: one record per supertype/subtype, in file order.
Complex(Vec<Record>),
}
impl Instance {
/// The record named `name`: the simple record if it has that name, or that part
/// of a complex instance.
pub fn record(&self, name: &str) -> Option<&Record> {
match self {
Instance::Simple(r) => (r.name == name).then_some(r),
Instance::Complex(rs) => rs.iter().find(|r| r.name == name),
}
}
/// The simple record's name, or for a complex instance the names joined by `+`.
pub fn type_name(&self) -> String {
match self {
Instance::Simple(r) => r.name.clone(),
Instance::Complex(rs) => rs
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join("+"),
}
}
}
/// The metadata from the Part 21 `HEADER` section.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Header {
/// `FILE_DESCRIPTION` descriptions.
pub descriptions: Vec<String>,
/// `FILE_DESCRIPTION` implementation level.
pub implementation_level: String,
/// `FILE_NAME` name.
pub file_name: String,
/// `FILE_NAME` time stamp.
pub time_stamp: String,
/// `FILE_NAME` authors.
pub authors: Vec<String>,
/// `FILE_NAME` organizations.
pub organizations: Vec<String>,
/// `FILE_NAME` preprocessor version.
pub preprocessor_version: String,
/// `FILE_NAME` originating system.
pub originating_system: String,
/// `FILE_NAME` authorization.
pub authorization: String,
}
/// A parsed exchange file.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StepFile {
/// Header metadata.
pub header: Header,
/// `FILE_SCHEMA` names, e.g. `CONFIG_CONTROL_DESIGN` (AP203) or
/// `AUTOMOTIVE_DESIGN` (AP214).
pub schemas: Vec<String>,
/// Every data-section instance by id.
pub entities: BTreeMap<u64, Instance>,
}
#[derive(Debug, Clone, PartialEq)]
enum Tok {
Keyword(String),
Id(u64),
Int(i64),
Real(f64),
Str(String),
Enum(String),
Binary(String),
LParen,
RParen,
Comma,
Semi,
Eq,
Dollar,
Star,
Eof,
}
struct Lexer<'a> {
s: &'a [u8],
pos: usize,
line: usize,
}
impl<'a> Lexer<'a> {
fn err<T>(&self, message: impl Into<String>) -> Result<T, ParseError> {
Err(ParseError {
line: self.line,
message: message.into(),
})
}
fn peek_byte(&self) -> Option<u8> {
self.s.get(self.pos).copied()
}
fn skip_ws(&mut self) -> Result<(), ParseError> {
loop {
match self.peek_byte() {
Some(b'\n') => {
self.line += 1;
self.pos += 1;
}
Some(c) if c.is_ascii_whitespace() => self.pos += 1,
Some(b'/') if self.s.get(self.pos + 1) == Some(&b'*') => {
self.pos += 2;
loop {
match self.peek_byte() {
None => return self.err("unterminated comment"),
Some(b'*') if self.s.get(self.pos + 1) == Some(&b'/') => {
self.pos += 2;
break;
}
Some(b'\n') => {
self.line += 1;
self.pos += 1;
}
Some(_) => self.pos += 1,
}
}
}
_ => return Ok(()),
}
}
}
fn take_while(&mut self, f: impl Fn(u8) -> bool) -> &'a [u8] {
let start = self.pos;
while self.peek_byte().is_some_and(&f) {
self.pos += 1;
}
self.s.get(start..self.pos).unwrap_or_default()
}
fn next(&mut self) -> Result<Tok, ParseError> {
self.skip_ws()?;
let Some(c) = self.peek_byte() else {
return Ok(Tok::Eof);
};
let single = match c {
b'(' => Some(Tok::LParen),
b')' => Some(Tok::RParen),
b',' => Some(Tok::Comma),
b';' => Some(Tok::Semi),
b'=' => Some(Tok::Eq),
b'$' => Some(Tok::Dollar),
b'*' => Some(Tok::Star),
_ => None,
};
if let Some(t) = single {
self.pos += 1;
return Ok(t);
}
match c {
b'#' => {
self.pos += 1;
let d = self.take_while(|b| b.is_ascii_digit());
let n = std::str::from_utf8(d)
.ok()
.and_then(|s| s.parse::<u64>().ok());
match n {
Some(n) => Ok(Tok::Id(n)),
None => self.err("bad entity id"),
}
}
b'\'' => {
self.pos += 1;
let mut out = Vec::new();
loop {
match self.peek_byte() {
None => return self.err("unterminated string"),
Some(b'\'') => {
if self.s.get(self.pos + 1) == Some(&b'\'') {
out.push(b'\'');
self.pos += 2;
} else {
self.pos += 1;
break;
}
}
Some(b) => {
if b == b'\n' {
self.line += 1;
}
out.push(b);
self.pos += 1;
}
}
}
Ok(Tok::Str(String::from_utf8_lossy(&out).into_owned()))
}
b'"' => {
self.pos += 1;
let d = self.take_while(|b| b != b'"');
let v = String::from_utf8_lossy(d).into_owned();
if self.peek_byte() != Some(b'"') {
return self.err("unterminated binary");
}
self.pos += 1;
Ok(Tok::Binary(v))
}
b'.' if self
.s
.get(self.pos + 1)
.is_some_and(u8::is_ascii_alphabetic) =>
{
self.pos += 1;
let d = self.take_while(|b| b.is_ascii_alphanumeric() || b == b'_');
let v = String::from_utf8_lossy(d).into_owned();
if self.peek_byte() != Some(b'.') {
return self.err("unterminated enumeration");
}
self.pos += 1;
Ok(Tok::Enum(v))
}
b'+' | b'-' | b'.' | b'0'..=b'9' => self.number(),
b'!' | b'A'..=b'Z' | b'a'..=b'z' | b'_' => {
let d = self.take_while(|b| {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'!'
});
Ok(Tok::Keyword(
String::from_utf8_lossy(d).to_ascii_uppercase(),
))
}
other => self.err(format!("unexpected character 0x{other:02x}")),
}
}
fn number(&mut self) -> Result<Tok, ParseError> {
let start = self.pos;
if matches!(self.peek_byte(), Some(b'+' | b'-')) {
self.pos += 1;
}
let mut digits = !self.take_while(|b| b.is_ascii_digit()).is_empty();
let mut real = false;
if self.peek_byte() == Some(b'.') {
real = true;
self.pos += 1;
digits |= !self.take_while(|b| b.is_ascii_digit()).is_empty();
}
if matches!(self.peek_byte(), Some(b'E' | b'e')) {
real = true;
self.pos += 1;
if matches!(self.peek_byte(), Some(b'+' | b'-')) {
self.pos += 1;
}
self.take_while(|b| b.is_ascii_digit());
}
let text = self
.s
.get(start..self.pos)
.and_then(|b| std::str::from_utf8(b).ok())
.unwrap_or("");
if real && !digits {
// `.`, `-.`, `-.E3`: no mantissa digit, not a number (would parse as 0).
return self.err(format!("bad real '{text}'"));
}
if real {
// Rust's parser rejects "1." followed by an exponent only in some forms;
// normalise "1.E5" / "1." to "1.0E5" / "1.0".
let fixed = text.replacen(".E", ".0E", 1).replacen(".e", ".0e", 1);
let fixed = if fixed.ends_with('.') {
format!("{fixed}0")
} else {
fixed
};
match fixed.parse::<f64>() {
Ok(x) if x.is_finite() => Ok(Tok::Real(x)),
_ => self.err(format!("bad real '{text}'")),
}
} else {
match text.parse::<i64>() {
Ok(i) => Ok(Tok::Int(i)),
Err(_) => self.err(format!("bad integer '{text}'")),
}
}
}
}
struct Parser<'a> {
lx: Lexer<'a>,
tok: Tok,
limits: Limits,
}
impl<'a> Parser<'a> {
fn new(s: &'a [u8], limits: Limits) -> Result<Self, ParseError> {
let mut lx = Lexer { s, pos: 0, line: 1 };
let tok = lx.next()?;
Ok(Self { lx, tok, limits })
}
fn bump(&mut self) -> Result<Tok, ParseError> {
let next = self.lx.next()?;
Ok(std::mem::replace(&mut self.tok, next))
}
fn expect(&mut self, t: &Tok, what: &str) -> Result<(), ParseError> {
if &self.tok == t {
self.bump()?;
Ok(())
} else {
self.lx
.err(format!("expected {what}, found {:?}", self.tok))
}
}
fn keyword(&mut self) -> Result<String, ParseError> {
match self.bump()? {
Tok::Keyword(k) => Ok(k),
other => self.lx.err(format!("expected a keyword, found {other:?}")),
}
}
/// `( p, p, ... )` with the opening paren current.
fn list(&mut self, depth: usize) -> Result<Vec<Param>, ParseError> {
if depth > self.limits.max_list_depth {
return self.lx.err("parameter lists nested too deeply");
}
self.expect(&Tok::LParen, "'('")?;
let mut out = Vec::new();
if self.tok == Tok::RParen {
self.bump()?;
return Ok(out);
}
loop {
if out.len() >= self.limits.max_list_len {
return self.lx.err("parameter list too long");
}
out.push(self.param(depth)?);
match self.bump()? {
Tok::Comma => {}
Tok::RParen => return Ok(out),
other => return self.lx.err(format!("expected ',' or ')', found {other:?}")),
}
}
}
fn param(&mut self, depth: usize) -> Result<Param, ParseError> {
if depth > self.limits.max_list_depth {
return self.lx.err("parameter lists nested too deeply");
}
Ok(match self.tok.clone() {
Tok::LParen => Param::List(self.list(depth + 1)?),
Tok::Keyword(k) => {
self.bump()?;
self.expect(&Tok::LParen, "'(' after a typed parameter")?;
let inner = self.param(depth + 1)?;
self.expect(&Tok::RParen, "')' closing a typed parameter")?;
Param::Typed(k, Box::new(inner))
}
Tok::Id(i) => {
self.bump()?;
Param::Ref(i)
}
Tok::Int(i) => {
self.bump()?;
Param::Int(i)
}
Tok::Real(x) => {
self.bump()?;
Param::Real(x)
}
Tok::Str(s) => {
self.bump()?;
Param::Str(s)
}
Tok::Enum(e) => {
self.bump()?;
Param::Enum(e)
}
Tok::Binary(b) => {
self.bump()?;
Param::Binary(b)
}
Tok::Dollar => {
self.bump()?;
Param::Null
}
Tok::Star => {
self.bump()?;
Param::Derived
}
other => return self.lx.err(format!("unexpected {other:?} in parameters")),
})
}
fn record(&mut self) -> Result<Record, ParseError> {
let name = self.keyword()?;
let params = self.list(1)?;
Ok(Record { name, params })
}
fn instance(&mut self) -> Result<Instance, ParseError> {
if self.tok == Tok::LParen {
self.bump()?;
let mut parts = Vec::new();
while self.tok != Tok::RParen {
if parts.len() >= self.limits.max_list_len {
return self.lx.err("complex instance too long");
}
parts.push(self.record()?);
}
self.bump()?;
if parts.is_empty() {
return self.lx.err("empty complex instance");
}
Ok(Instance::Complex(parts))
} else {
Ok(Instance::Simple(self.record()?))
}
}
}
/// Parses a whole exchange file.
pub fn parse(bytes: &[u8], limits: &Limits) -> Result<StepFile, ParseError> {
let mut p = Parser::new(bytes, *limits)?;
let mut out = StepFile::default();
match p.bump()? {
Tok::Keyword(k) if k == "ISO-10303-21" => {}
other => {
return p
.lx
.err(format!("not a Part 21 file (starts with {other:?})"))
}
}
p.expect(&Tok::Semi, "';'")?;
loop {
let kw = match p.bump()? {
Tok::Keyword(k) => k,
Tok::Eof => return p.lx.err("missing END-ISO-10303-21"),
other => return p.lx.err(format!("expected a section, found {other:?}")),
};
match kw.as_str() {
"END-ISO-10303-21" => {
p.expect(&Tok::Semi, "';'")?;
return Ok(out);
}
"HEADER" => {
p.expect(&Tok::Semi, "';'")?;
while p.tok != Tok::Keyword("ENDSEC".into()) {
let r = p.record()?;
p.expect(&Tok::Semi, "';' after a header entity")?;
match r.name.as_str() {
"FILE_SCHEMA" => {
if let Some(Param::List(l)) = r.params.first() {
for s in l {
if let Param::Str(s) = s {
out.schemas.push(s.clone());
}
}
}
}
"FILE_DESCRIPTION" => {
if let Some(Param::List(l)) = r.params.first() {
out.header.descriptions = l
.iter()
.filter_map(|p| match p {
Param::Str(s) => Some(s.clone()),
_ => None,
})
.collect();
}
if let Some(Param::Str(s)) = r.params.get(1) {
out.header.implementation_level.clone_from(s);
}
}
"FILE_NAME" => {
let string = |i: usize| match r.params.get(i) {
Some(Param::Str(s)) => s.clone(),
_ => String::new(),
};
let strings = |i: usize| match r.params.get(i) {
Some(Param::List(l)) => l
.iter()
.filter_map(|p| match p {
Param::Str(s) => Some(s.clone()),
_ => None,
})
.collect(),
_ => Vec::new(),
};
out.header.file_name = string(0);
out.header.time_stamp = string(1);
out.header.authors = strings(2);
out.header.organizations = strings(3);
out.header.preprocessor_version = string(4);
out.header.originating_system = string(5);
out.header.authorization = string(6);
}
_ => {}
}
}
p.bump()?;
p.expect(&Tok::Semi, "';'")?;
}
"DATA" => {
// Edition 3 allows DATA('name', (schemas)); skip the parameters.
if p.tok == Tok::LParen {
p.list(1)?;
}
p.expect(&Tok::Semi, "';'")?;
loop {
match p.bump()? {
Tok::Keyword(k) if k == "ENDSEC" => break,
Tok::Id(id) => {
p.expect(&Tok::Eq, "'='")?;
let inst = p.instance()?;
p.expect(&Tok::Semi, "';' after an instance")?;
if out.entities.len() >= limits.max_entities {
return p
.lx
.err(format!("more than {} entities", limits.max_entities));
}
if out.entities.insert(id, inst).is_some() {
return p.lx.err(format!("duplicate entity #{id}"));
}
}
other => return p.lx.err(format!("expected '#id =', found {other:?}")),
}
}
p.expect(&Tok::Semi, "';'")?;
}
other => return p.lx.err(format!("unknown section {other}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TINY: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('x'),'2;1');\n\
FILE_NAME('t','2026-01-01',(''),(''),'','','');\nFILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));\n\
ENDSEC;\nDATA;\n/* a comment */\n#1=CARTESIAN_POINT('it''s',(1.,-2.5E-1,3));\n\
#2=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\n\
#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#2);\n#4=X($,\"0F\",((1,2),()));\n\
ENDSEC;\nEND-ISO-10303-21;\n";
#[test]
fn parses_tiny_file() {
let f = parse(TINY.as_bytes(), &Limits::default()).unwrap();
assert_eq!(f.schemas, vec!["CONFIG_CONTROL_DESIGN".to_string()]);
assert_eq!(f.header.file_name, "t");
assert_eq!(f.header.time_stamp, "2026-01-01");
assert_eq!(f.header.descriptions, vec!["x".to_string()]);
assert_eq!(f.header.implementation_level, "2;1");
assert_eq!(f.entities.len(), 4);
let Instance::Simple(r) = &f.entities[&1] else {
panic!()
};
assert_eq!(r.name, "CARTESIAN_POINT");
assert_eq!(r.params[0], Param::Str("it's".into()));
assert_eq!(
r.params[1],
Param::List(vec![Param::Real(1.0), Param::Real(-0.25), Param::Int(3)])
);
let c = &f.entities[&2];
assert!(c.record("SI_UNIT").is_some());
assert_eq!(c.type_name(), "LENGTH_UNIT+NAMED_UNIT+SI_UNIT");
let Instance::Simple(r) = &f.entities[&3] else {
panic!()
};
assert_eq!(r.params[0].as_f64(), Some(25.4));
let Instance::Simple(r) = &f.entities[&4] else {
panic!()
};
assert_eq!(r.params[0], Param::Null);
assert_eq!(r.params[1], Param::Binary("0F".into()));
}
#[test]
fn rejects_bad_input() {
let lim = Limits::default();
assert!(parse(b"", &lim).is_err());
assert!(parse(
b"ISO-10303-21;\nDATA;\n#1=A(;\nENDSEC;END-ISO-10303-21;",
&lim
)
.is_err());
assert!(parse(
b"ISO-10303-21;DATA;#1=A();#1=B();ENDSEC;END-ISO-10303-21;",
&lim
)
.is_err());
assert!(parse(b"ISO-10303-21;DATA;#1=A('x);ENDSEC;END-ISO-10303-21;", &lim).is_err());
let deep = format!(
"ISO-10303-21;DATA;#1=A({}{});ENDSEC;END-ISO-10303-21;",
"(".repeat(100),
")".repeat(100)
);
assert!(parse(deep.as_bytes(), &lim).is_err());
let few = Limits {
max_entities: 1,
..lim
};
assert!(parse(
b"ISO-10303-21;DATA;#1=A();#2=B();ENDSEC;END-ISO-10303-21;",
&few
)
.is_err());
}
#[test]
fn number_forms() {
let f = parse(
b"ISO-10303-21;DATA;#1=A(1.,1.E2,-.5,+3,1.5e-3);ENDSEC;END-ISO-10303-21;",
&Limits::default(),
)
.unwrap();
let Instance::Simple(r) = &f.entities[&1] else {
panic!()
};
let v: Vec<f64> = r.params.iter().map(|p| p.as_f64().unwrap()).collect();
assert_eq!(v, vec![1.0, 100.0, -0.5, 3.0, 1.5e-3]);
}
#[test]
fn rejects_numbers_without_digits() {
// A real needs at least one mantissa digit; these read as 0 before.
let accepted: Vec<String> = [".", "-.", "+.", ".E5", "-.e-3", "(1.,.,3.)"]
.into_iter()
.filter_map(|bad| {
let src = format!("ISO-10303-21;DATA;#1=A({bad});ENDSEC;END-ISO-10303-21;");
let f = parse(src.as_bytes(), &Limits::default()).ok()?;
Some(format!("`{bad}` -> {:?}", f.entities[&1]))
})
.collect();
assert!(accepted.is_empty(), "accepted: {accepted:#?}");
// The error names the token and its line.
let e = parse(
b"ISO-10303-21;\nDATA;\n#1=A(.);\nENDSEC;END-ISO-10303-21;",
&Limits::default(),
)
.unwrap_err();
assert_eq!(e.line, 3);
assert!(e.message.contains("bad real '.'"), "{e}");
}
#[test]
fn typed_nesting_refusal_at_small_configured_limit() {
// Every entry route reaches the same parameter parser. Keep these inputs small:
// this tests the documented refusal, not an actual stack overflow.
let cases = [
("simple", 2, "DATA;#1=X(T(T(1)));ENDSEC;"),
("complex", 2, "DATA;#1=(X(T(T(1)))Y());ENDSEC;"),
("header", 2, "HEADER;X(T(T(1)));ENDSEC;DATA;ENDSEC;"),
("data parameters", 3, "DATA('name',(T(T(1))));ENDSEC;"),
];
let mut accepted = Vec::new();
for (name, max_list_depth, section) in cases {
let lim = Limits {
max_list_depth,
..Limits::default()
};
let input = format!("ISO-10303-21;{section}END-ISO-10303-21;");
match parse(input.as_bytes(), &lim) {
Ok(_) => accepted.push(name),
Err(error) => {
assert_eq!(error.message, "parameter lists nested too deeply", "{name}");
}
}
}
assert!(
accepted.is_empty(),
"over-limit typed parameters accepted: {accepted:?}"
);
}
#[test]
fn typed_nesting_accepted_boundary() {
let lim = Limits {
max_list_depth: 2,
..Limits::default()
};
let expected = Param::Typed("T".into(), Box::new(Param::Int(1)));
for body in ["#1=X(T(1));", "#1=(X(T(1))Y());"] {
let input = format!("ISO-10303-21;DATA;{body}ENDSEC;END-ISO-10303-21;");
let f = parse(input.as_bytes(), &lim).unwrap();
let r = f.entities[&1].record("X").unwrap();
assert_eq!(r.params, vec![expected.clone()]);
assert_eq!(r.params[0].as_f64(), Some(1.0));
}
let f = parse(
b"ISO-10303-21;DATA;#1=X((1));ENDSEC;END-ISO-10303-21;",
&lim,
)
.unwrap();
assert_eq!(
f.entities[&1].record("X").unwrap().params,
vec![Param::List(vec![Param::Int(1)])]
);
for (max_list_depth, section) in [
(2, "HEADER;X(T(1));ENDSEC;DATA;ENDSEC;"),
(3, "DATA('name',(T(1)));ENDSEC;"),
] {
let lim = Limits {
max_list_depth,
..lim
};
let input = format!("ISO-10303-21;{section}END-ISO-10303-21;");
assert!(parse(input.as_bytes(), &lim).is_ok());
}
}
#[test]
fn typed_nesting_mixed_bypass() {
let lim = Limits {
max_list_depth: 3,
..Limits::default()
};
// Outer list 1, nested list 2, typed leaf 3: exactly at the bound.
let ok = parse(
b"ISO-10303-21;DATA;#1=X((T(1)));ENDSEC;END-ISO-10303-21;",
&lim,
)
.unwrap();
assert_eq!(
ok.entities[&1].record("X").unwrap().params,
vec![Param::List(vec![Param::Typed(
"T".into(),
Box::new(Param::Int(1))
)])]
);
// One more typed wrapper must not bypass the bound after entering a list.
let deep = parse(
b"ISO-10303-21;DATA;#1=X((T(T(1))));ENDSEC;END-ISO-10303-21;",
&lim,
);
assert_eq!(
deep.unwrap_err().message,
"parameter lists nested too deeply"
);
}
#[test]
fn typed_nesting_preserves_existing_list_boundary() {
let lim = Limits {
max_list_depth: 2,
..Limits::default()
};
assert!(parse(
b"ISO-10303-21;DATA;#1=X((1));ENDSEC;END-ISO-10303-21;",
&lim
)
.is_ok());
let error = parse(
b"ISO-10303-21;DATA;#1=X(((1)));ENDSEC;END-ISO-10303-21;",
&lim,
)
.unwrap_err();
assert_eq!(error.message, "parameter lists nested too deeply");
}
#[test]
fn typed_nesting_default_32_wrappers() {
let lim = Limits::default();
// 31 typed wrappers accepted, 32 rejected
let mut s = String::from("ISO-10303-21;DATA;#1=X(");
for _ in 0..31 {
s.push_str("T(");
}
s.push('1');
for _ in 0..31 {
s.push(')');
}
s.push_str(");ENDSEC;END-ISO-10303-21;");
assert!(parse(s.as_bytes(), &lim).is_ok());
let mut s2 = String::from("ISO-10303-21;DATA;#1=X(");
for _ in 0..32 {
s2.push_str("T(");
}
s2.push('1');
for _ in 0..32 {
s2.push(')');
}
s2.push_str(");ENDSEC;END-ISO-10303-21;");
assert!(parse(s2.as_bytes(), &lim).is_err());
}
}
+5 -10
View File
@@ -1122,13 +1122,8 @@ crates/step-export/src/lib.rs write_bspline_surface with_capacity 1 open Vec::wi
crates/step-export/src/lib.rs write_bspline_surface with_capacity 2 open Vec::with_capacity(row.len())
crates/step-export/src/lib.rs write_pcurve_edge with_capacity 1 open Vec::with_capacity(pcurves.len())
crates/step-export/src/lib.rs write_pcurve_edge with_capacity 2 open Vec::with_capacity(uv.len())
crates/step-import/src/flat.rs Rd::bspline_surface int-div 1 proven self.d.len().saturating_sub(self.i) / 3 constant non-zero divisor
crates/step-import/src/flat.rs Rd::count int-div 1 proven left / min_words.max(1) divisor is max(.., >= 1)
crates/step-import/src/geom.rs bspline_point f64::clamp 1 proven clamp(lo, hi) check_bspline_curve just above: n > p, knots increasing, no NaN, so lo <= hi
crates/step-import/src/map.rs Mapper::poly_loop int-rem 1 proven (k + 1) % vs.len() vs has one entry per point, fewer than 3 refused
crates/step-import/src/map.rs check_knots windows 1 proven windows(2) constant non-zero size
crates/step-import/src/map.rs implied_knots int-div 1 proven (n - 1) / dd dd == 0 returns Err just above
crates/step-import/src/map.rs polygon_plane int-rem 1 proven (k + 1) % pts.len() inside iteration over pts, so len() > 0
crates/step-import/src/script.rs Rd::count int-div 1 proven left / min_words.max(1) divisor is max(.., >= 1)
crates/step-import/src/script.rs step_face_area f64::clamp 1 proven clamp(-1.0, 1.0) constant bounds, min <= max
crates/step-import/src/script.rs step_uv_domain f64::clamp 1 proven clamp(-1.0, 1.0) constant bounds, min <= max
crates/step-export/src/geom.rs bspline_point f64::clamp 1 proven clamp(lo, hi) check_bspline_curve just above: n > p, knots increasing, no NaN, so lo <= hi
crates/step-export/src/map.rs Mapper::poly_loop int-rem 1 proven (k + 1) % vs.len() vs has one entry per point, fewer than 3 refused
crates/step-export/src/map.rs check_knots windows 1 proven windows(2) constant non-zero size
crates/step-export/src/map.rs implied_knots int-div 1 proven (n - 1) / dd dd == 0 returns Err just above
crates/step-export/src/map.rs polygon_plane int-rem 1 proven (k + 1) % pts.len() inside iteration over pts, so len() > 0
1 # Panic-prone calls in library code that no deny lint catches (#2490). Written by
1122 crates/step-export/src/lib.rs
1123 crates/step-export/src/lib.rs
1124 crates/step-export/src/lib.rs
1125 crates/step-import/src/flat.rs crates/step-export/src/geom.rs
1126 crates/step-import/src/flat.rs crates/step-export/src/map.rs
1127 crates/step-import/src/geom.rs crates/step-export/src/map.rs
1128 crates/step-import/src/map.rs crates/step-export/src/map.rs
1129 crates/step-import/src/map.rs crates/step-export/src/map.rs
crates/step-import/src/map.rs
crates/step-import/src/map.rs
crates/step-import/src/script.rs
crates/step-import/src/script.rs
crates/step-import/src/script.rs
+6 -2
View File
@@ -45,7 +45,7 @@ from pathlib import Path
LIST = Path("plan/panic-sites.tsv")
# solidcore and the STEP crates SolidCraft links. nurbs-grid-mesh is not here: nothing
# depends on it, and docs/layers.md has it merged into solidcore-tess and then deleted.
CRATES = ["solidcore", "step-import", "step-export"]
CRATES = ["solidcore", "step-export"]
COLUMNS = ["file", "fn", "pattern", "n", "status", "code", "reason"]
# clippy `disallowed-methods` path -> pattern name. Only calls that can panic on their
@@ -418,7 +418,11 @@ def run_clippy(root):
for crate in CRATES:
cmd += ["-p", crate]
# a renamed or misspelt lint would otherwise pass with no sites at all
cmd += ["--", "-D", "unknown_lints"] + [arg for lint in LINTS for arg in ("-W", lint)]
# --cap-lints warn: a workspace crate a listed crate depends on but this list does not
# cover (solidcore-record, which denies disallowed_methods in its own source) is checked
# too, and its denies must not fail the run; its sites are not rows.
cmd += ["--", "--cap-lints", "warn", "-D", "unknown_lints"]
cmd += [arg for lint in LINTS for arg in ("-W", lint)]
env = dict(os.environ, CLIPPY_CONF_DIR=tmp)
res = subprocess.run(cmd, cwd=root, env=env, stdout=subprocess.PIPE, text=True)
if res.returncode != 0: