Compare commits

..

3 Commits

Author SHA1 Message Date
Brennen Herbruck b08773ea16 Merge remote-tracking branch 'origin/main' into cancel-check 2026-11-10 12:54:19 -05:00
Brennen Herbruck d38e8e39b2 Cancel stops SMLib's full check and the tight box (solidcore#3247)
The full validity check of a feature's result was most of a long boolean's
time (64% of #3962's 20-turn coil cut) and ran with no token: Cancel left it
running for seconds and it returned a result. solidcore now polls the token
once per entity in the check's topology walk, in the coincident-topology
search and in the tight bounding box.

SolidCraft runs the check and the box under the work's token where the body
is the call's own: the step result or copy a deferred feature check takes
(which now carries the token of the work that detached it onto its thread),
and a copy measured for the tight box. A call on a body others read still
runs to its end. A stopped smlib call returns the typed Error::Cancelled and
never a result solidcore finished first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:54:06 -05:00
Brennen Herbruck c5a33b2f71 solidcore-ops: cancel ops calls with a scoped token (solidcore#3247)
solidcore_ops::cancel::within runs work under a CancelToken. Every ops call
it makes on that thread runs with the token on the session's kernel, whose
long loops already poll it; once cancelled, the running call stops at the
kernel's next check and later calls are refused before they wait for the
kernel, each with Error::Cancelled.

A cancelled call leaves no partial state: an edited body is put back by the
session's rollback, no result is returned even if the kernel finished
first, the kernel is replaced as after a panic, and nothing reaches the
failure callback. Nested scopes stop with either token
(CancelToken::either in the kernel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:25:19 -05:00
58 changed files with 1623 additions and 1097 deletions
+1 -5
View File
@@ -20,7 +20,6 @@ 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};
@@ -36,7 +35,6 @@ 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)
@@ -93,11 +91,9 @@ 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", crate::checked!("surface.ruled", ruled))
CommandSpec::new("surface.ruled", "Ruled Surface", 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,7 +137,6 @@ 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
@@ -1,211 +0,0 @@
//! 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;
@@ -1,188 +0,0 @@
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,22 +535,6 @@ 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,3 +25,4 @@ solid.thread
surface.extrude
surface.patch
surface.revolve
surface.ruled
+8 -1
View File
@@ -1046,7 +1046,14 @@ impl Body {
if let Some(b) = *slot {
return Ok(b);
}
let (lo, hi) = self.smlib()?.bounds().map_err(|e| KernelError::smlib("SMLib bounds", e))?;
// Under a token (an evaluation that can be cancelled), measured on a copy the token can
// stop part way: a large body's tight box takes a good part of a second, and a call
// stopped part way may leave half-built caches on the body it reads.
let measured = match solidcraft_smlib::stop::current() {
Some(_) => self.smlib()?.try_clone().and_then(solidcraft_smlib::Brep::bounds_owned),
None => self.smlib()?.bounds(),
};
let (lo, hi) = measured.map_err(|e| KernelError::smlib("SMLib bounds", e))?;
let b = Aabb3 { min: v3(lo), max: v3(hi) };
*slot = Some(b);
Ok(b)
-12
View File
@@ -2502,18 +2502,6 @@ 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
+52 -10
View File
@@ -183,8 +183,8 @@ pub fn check_kept(body: &Body) -> Result<()> {
return Ok(());
}
let step = body.unchecked.as_deref().and_then(|u| u.take(&body.record).map(|r| (u, r)));
let (what, check) = match &step {
Some((u, r)) => (u.what.as_str(), check_result(r, u.how)),
let (what, check) = match step {
Some((u, r)) => (u.what.as_str(), check_result_owned(r, u.how)),
None => ("SMLib", body.smlib()?.check(solidcraft_smlib::CheckLevel::All).map_err(|e| format!("check: {e}"))),
};
verdict(what, check)
@@ -199,8 +199,26 @@ fn verdict(what: &str, check: std::result::Result<solidcraft_smlib::CheckReport,
}
}
/// [`check_kept`] of a body, ready to run on another thread ([`detach_kept`]).
pub struct KeptCheck(Kept);
/// [`check_result`] of a step's result nobody else reads, which it takes: the token over this
/// thread's work stops it ([`solidcraft_smlib::Brep::check_owned`]).
fn check_result_owned(r: solidcraft_smlib::Brep, how: Checked) -> std::result::Result<solidcraft_smlib::CheckReport, String> {
fn failed(what: &'static str) -> impl Fn(&dyn std::fmt::Display) -> String {
move |e| format!("{what}: {e}")
}
match how {
Checked::Combined if !r.is_manifold_solid().map_err(|e| failed("check")(&e))? => {
let record = r.to_native().map_err(|e| failed("export")(&e))?;
drop(r);
let split = solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| failed("the separate solids")(&e))?;
split.check_owned(solidcraft_smlib::CheckLevel::All).map_err(|e| failed("check")(&e))
}
_ => r.check_owned(solidcraft_smlib::CheckLevel::All).map_err(|e| failed("check")(&e)),
}
}
/// [`check_kept`] of a body, ready to run on another thread ([`detach_kept`]), under the token
/// of the work that detached it: stopping that work stops the check too.
pub struct KeptCheck(Kept, Option<solidcraft_smlib::stop::Stop>);
enum Kept {
/// A mesh body: nothing to check.
@@ -215,22 +233,31 @@ enum Kept {
/// form is copied now, so the check reads nothing the operations after it use and can run on
/// another thread beside them ([`KeptCheck::run`]).
pub fn detach_kept(body: &Body) -> KeptCheck {
let stop = solidcraft_smlib::stop::current();
if body.is_mesh() {
return KeptCheck(Kept::Nothing);
return KeptCheck(Kept::Nothing, stop);
}
if let Some((u, result)) = body.unchecked.as_deref().and_then(|u| u.take(&body.record).map(|r| (u, r))) {
return KeptCheck(Kept::Step { what: u.what.clone(), how: u.how, result });
return KeptCheck(Kept::Step { what: u.what.clone(), how: u.how, result }, stop);
}
KeptCheck(Kept::Copy(body.smlib().and_then(|b| b.try_clone().map_err(|e| KernelError::smlib("SMLib copy", e)))))
KeptCheck(Kept::Copy(body.smlib().and_then(|b| b.try_clone().map_err(|e| KernelError::smlib("SMLib copy", e)))), stop)
}
impl KeptCheck {
/// The check: [`check_kept`]'s result for the body it was detached from.
pub fn run(self) -> Result<()> {
match self.0 {
let KeptCheck(kept, stop) = self;
solidcraft_smlib::stop::scoped(stop, || kept.run())
}
}
impl Kept {
fn run(self) -> Result<()> {
match self {
Kept::Nothing => Ok(()),
Kept::Step { what, how, result } => verdict(&what, check_result(&result, how)),
Kept::Copy(copy) => verdict("SMLib", copy?.check(solidcraft_smlib::CheckLevel::All).map_err(|e| format!("check: {e}"))),
// The step's result and the copy are the check's own: the work's token stops it.
Kept::Step { what, how, result } => verdict(&what, check_result_owned(result, how)),
Kept::Copy(copy) => verdict("SMLib", copy?.check_owned(solidcraft_smlib::CheckLevel::All).map_err(|e| format!("check: {e}"))),
}
}
}
@@ -251,6 +278,21 @@ mod tests {
assert_eq!(step(), Step::Check);
}
/// A check detached under a token runs under it on whatever thread runs it (the feature
/// check beside the evaluation): stopping the work stops the check (solidcore#3247).
#[test]
fn a_detached_check_stops_with_the_work_that_detached_it() {
let body = crate::cylinder(solidcore_record::Vec3::new(0.0, 0.0, 0.0), solidcore_record::Vec3::Z, 3.0, 10.0).unwrap();
let stop = solidcraft_smlib::stop::Stop::new();
let detached = stop.within(|| detach_kept(&body));
stop.stop();
let r = crate::par::kernel_thread("check").spawn(move || detached.run()).unwrap().join().unwrap();
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("cancelled")), "{r:?}");
let free = solidcraft_smlib::stop::Stop::new();
let detached = free.within(|| detach_kept(&body));
assert!(detached.run().is_ok(), "a token never stopped changes nothing");
}
#[test]
fn the_mode_reaches_the_kernels_worker_threads() {
let items = [0, 1, 2, 3];
+26
View File
@@ -55,12 +55,38 @@ impl Brep {
pub fn check(&self, level: CheckLevel) -> Result<CheckReport> {
core::check(self.core(), level)
}
/// [`Brep::check`] of a body nobody else reads, which it takes: the token over this thread's
/// work ([`crate::stop`]) stops it ([`crate::Error::Cancelled`]). A check left part way may
/// leave the body's caches half-built, so it runs only on a body it consumes.
pub fn check_owned(self, level: CheckLevel) -> Result<CheckReport> {
core::check_with(self.core(), level, true)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A stopped token stops the check of a body the check owns, and never the check of a body
/// others read (solidcore#3247): stopped part way, it could leave that body's caches half-built.
#[test]
fn a_stopped_token_stops_an_owned_check_and_never_a_shared_one() {
let b = Brep::make_box([0.0; 3], [10.0, 20.0, 30.0]).unwrap();
let mine = b.try_clone().unwrap();
let stop = crate::stop::Stop::new();
stop.stop();
let shared = stop.within(|| b.check(CheckLevel::All));
assert!(shared.is_ok_and(|r| r.is_valid()));
let owned = stop.within(|| mine.check_owned(CheckLevel::All));
assert!(matches!(owned, Err(crate::Error::Cancelled("check"))), "{owned:?}");
let mine = b.try_clone().unwrap();
assert!(mine.check_owned(CheckLevel::All).is_ok_and(|r| r.is_valid()), "no token, no stop");
let mine = b.try_clone().unwrap();
let boxed = stop.within(|| mine.bounds_owned());
assert!(matches!(boxed, Err(crate::Error::Cancelled("bounds"))), "{boxed:?}");
}
#[test]
fn a_box_is_valid_at_every_level() {
let b = Brep::make_box([1.0, 2.0, 3.0], [10.0, 20.0, 30.0]).unwrap();
+27 -3
View File
@@ -53,7 +53,9 @@ pub(crate) mod trim;
mod trim_tests;
pub(crate) use convert::{from_arrays, to_arrays};
pub(crate) use measure::{area, bounds, check, classify_point, edge_face_distance, is_manifold_solid, mass_properties, solid_count};
pub(crate) use measure::{
area, bounds, bounds_with, check, check_with, classify_point, edge_face_distance, is_manifold_solid, mass_properties, solid_count,
};
/// A solidcore body. Its own lock: solidcore's operations take `&mut Brep`, even to measure.
pub(crate) struct Body(pub(crate) Mutex<solidcore::topology::Brep>);
@@ -476,7 +478,7 @@ pub(crate) fn run_on<T: dump::Answer>(
) -> Result<T> {
let stop = crate::stop::token();
if stop.as_ref().is_some_and(CancelToken::is_cancelled) {
return Err(Error::Solidcore(format!("{call}: cancelled")));
return Err(Error::Cancelled(call));
}
call_on(call, inputs, stop, f)
}
@@ -519,9 +521,14 @@ fn call_on<T: dump::Answer>(
};
drop(in_flight);
// Stopped, it is no failure to look into, and solidcore may have left the kernel part way.
// No result either, even one solidcore finished first: the caller never has to ask whether
// it came in time (as solidcore-ops' session answers).
if stop.is_some_and(|s| s.is_cancelled()) {
put_kernel(None);
return r;
return match r {
Err(Error::Internal(e)) => Err(Error::Internal(e)),
_ => Err(Error::Cancelled(call)),
};
}
let why = match &r {
Ok(v) => v.refusal(),
@@ -566,6 +573,23 @@ pub(crate) fn with_body<T: dump::Answer>(
call_on(call, &[("brep", body)], None, |k| f(k, &mut body.0.lock().unwrap_or_else(PoisonError::into_inner)))
}
/// [`with_body`] on a body only this call reads (one the caller consumes): the token over this
/// thread's work stops it, since what a stopped call leaves half-changed is never read again.
pub(crate) fn with_own_body<T: dump::Answer>(
call: &'static str,
body: &Body,
f: impl FnOnce(&mut Kernel, &mut solidcore::topology::Brep) -> std::result::Result<T, solidcore::SolidError>,
) -> Result<T> {
if body.0.is_poisoned() {
return Err(Error::Internal(format!("{call}: the body was left unfinished by an earlier panic")));
}
let stop = crate::stop::token();
if stop.as_ref().is_some_and(CancelToken::is_cancelled) {
return Err(Error::Cancelled(call));
}
call_on(call, &[("brep", body)], stop, |k| f(k, &mut body.0.lock().unwrap_or_else(PoisonError::into_inner)))
}
/// An axis-aligned box from its lower corner (`SmApiCreateBox`, as `smlib_make_box` checks it:
/// every edge length positive).
pub(crate) fn make_box(origin: [f64; 3], size: [f64; 3]) -> Result<Body> {
+19 -4
View File
@@ -159,7 +159,13 @@ pub(crate) fn area(body: &Body, relative_accuracy: f64) -> Result<f64> {
/// `smlib_brep_bounds`: `SmBrep::CalculateTightBoundingBox`.
pub(crate) fn bounds(body: &Body) -> Result<([f64; 3], [f64; 3])> {
with_body("bounds", body, |k, b| {
bounds_with(body, false)
}
/// [`bounds`], stopped by the token over this thread's work when `stoppable` (a body the
/// caller consumes: [`super::with_own_body`]).
pub(crate) fn bounds_with(body: &Body, stoppable: bool) -> Result<([f64; 3], [f64; 3])> {
let run = |k: &mut Kernel, b: &mut solidcore::topology::Brep| {
let x = brep_bounding_box(k, b, true)?;
let (lo, hi) = (x.min(), x.max());
let (lo, hi) = ([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
@@ -167,7 +173,8 @@ pub(crate) fn bounds(body: &Body) -> Result<([f64; 3], [f64; 3])> {
return Err(invalid("no box"));
}
Ok((lo, hi))
})
};
if stoppable { super::with_own_body("bounds", body, run) } else { with_body("bounds", body, run) }
}
/// `smlib_brep_edge_face_distance`: SMLib's own edge/face gap (`SmEdgeuse::CalcMaxEdgeFaceGap`'s
@@ -303,12 +310,18 @@ impl<T> super::dump::Answer for std::result::Result<T, Refusal> {
/// owners' identities the check cannot know, fails the check with "not supported yet": the check
/// never reports a body valid on fewer tests than the C++ kernel runs.
pub(crate) fn check(body: &Body, level: CheckLevel) -> Result<CheckReport> {
check_with(body, level, false)
}
/// [`check`], stopped by the token over this thread's work when `stoppable` (a body the caller
/// consumes: [`super::with_own_body`]).
pub(crate) fn check_with(body: &Body, level: CheckLevel, stoppable: bool) -> Result<CheckReport> {
let lv = match level {
CheckLevel::Basic => SolidcoreCheckLevel::Level0,
CheckLevel::Geometry => SolidcoreCheckLevel::Level1,
CheckLevel::All => SolidcoreCheckLevel::Level2,
};
let c = match with_body("check", body, |k, b| {
let checked = |k: &mut Kernel, b: &mut solidcore::topology::Brep| {
let naming = b.naming();
if naming == NamingScheme::Unknown {
return Ok(Err(Refusal::Naming(naming)));
@@ -317,7 +330,9 @@ pub(crate) fn check(body: &Body, level: CheckLevel) -> Result<CheckReport> {
Err(SolidError::Unported { upstream, unit }) => Ok(Err(Refusal::Unported(upstream, unit))),
r => r.map(Ok),
}
})? {
};
let ran = if stoppable { super::with_own_body("check", body, checked) } else { with_body("check", body, checked) };
let c = match ran? {
Ok(c) => c,
Err(Refusal::Unported(upstream, unit)) => {
return Err(Error::Solidcore(format!("smlib_brep_check: {upstream}: not supported yet: solidcore has not ported it (unit {unit})")));
+4
View File
@@ -90,6 +90,9 @@ pub enum Error {
/// A step of SMLib solidcore has not ported yet, refused rather than built another way: what
/// is missing, and the solidcore issue that ports it.
Unported(&'static str),
/// The work's token was stopped ([`stop::Stop::stop`]) before or during the call: no result,
/// even one solidcore finished first. Never a failure of the operation on its input.
Cancelled(&'static str),
}
/// Why a blend has no room, as solidcore names its refusal (`NoRoomReason`): the reasons a
@@ -227,6 +230,7 @@ impl std::fmt::Display for Error {
Error::Solidcore(e) => write!(f, "solidcore: {e}"),
Error::Internal(e) => write!(f, "solidcore failed internally in {e}"),
Error::Unported(what) => write!(f, "not supported yet: {what}"),
Error::Cancelled(call) => write!(f, "{call}: cancelled"),
}
}
}
+6
View File
@@ -67,6 +67,12 @@ impl Brep {
core::bounds(self.core())
}
/// [`Brep::bounds`] of a body nobody else reads, which it takes: the token over this
/// thread's work ([`crate::stop`]) stops it ([`crate::Error::Cancelled`]).
pub fn bounds_owned(self) -> Result<([f64; 3], [f64; 3])> {
core::bounds_with(self.core(), true)
}
/// How far edge `edge` (its curve over its interval) lies from the surface of face `face`,
/// over that surface's own domain and not beyond it, in mm: SMLib's own edge/face gap, the
/// one it sizes an edge's tolerance by. Indices are in the body's edge and face order (as
Generated Vendored
+9 -3
View File
@@ -107,10 +107,9 @@ dependencies = [
"serde",
"serde_json",
"solidcore",
"solidcore-record",
"solidcore-ssi",
"ssi",
"step-export",
"step-import",
"toml",
]
@@ -379,7 +378,14 @@ name = "step-export"
version = "0.1.0"
dependencies = [
"solidcore",
"solidcore-record",
"step-import",
]
[[package]]
name = "step-import"
version = "0.1.0"
dependencies = [
"solidcore",
]
[[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/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/step-import", "crates/xtask", "crates/solidcore-ops", "crates/solidcore-step", "crates/diagnostics"]
default-members = ["crates/solidcore"]
[workspace.package]
+2 -4
View File
@@ -13,10 +13,8 @@ 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 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" }
# The clean-room STEP importer of --step-corpus.
step-import = { path = "../step-import" }
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"] }
+5 -4
View File
@@ -431,7 +431,8 @@ 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.** `solidcore-record`'s Part 21 parser (`step::p21`) and `step-export`'s mapper (`map`) parse
* **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
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
@@ -443,15 +444,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
`goldens::script::from_body`; the `construct` stage replaces `faces` and `stitch`.
`step_import::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 goldens --example step_survey -- corpus/steptools` lists what the
importer maps per file; `--example step_dump_body -- <file> <body> [--flat]` prints one body.
* `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.
## Notes
@@ -17,7 +17,7 @@ fn main() {
eprintln!("cannot read {file}");
std::process::exit(2);
};
let Some(body) = step_export::import(&bytes, &step_export::Limits::default())
let Some(body) = step_import::import(&bytes, &step_import::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 = goldens::script::step_face_area(&body, face, 512);
let exact = step_import::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_export::import(&b, &step_export::Limits::default())
step_import::import(&b, &step_import::Limits::default())
.ok()?
.bodies
.into_iter()
@@ -21,13 +21,14 @@ fn main() {
eprintln!("usage: constructor_script_ops <file.stp> <body> <op>...");
std::process::exit(2);
};
let script = goldens::script::from_body(&body, goldens::script::FromBodyOptions::default());
let script =
step_import::script::from_body(&body, step_import::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 goldens::script::Op::AdjustStepUvDomain { surface, .. } = op {
if let step_import::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 ([`goldens::script::step_face_area`] summed over its faces), or which
//! STEP body ([`step_import::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_export::import(&b, &step_export::Limits::default()).ok())
.and_then(|b| step_import::import(&b, &step_import::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 goldens::script::step_face_area(&body, f, 2048) {
match step_import::script::step_face_area(&body, f, 2048) {
Some(a) => total += a,
None => missing += 1,
}
+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_export::import(&b, &step_export::Limits::default()).ok())
.and_then(|b| step_import::import(&b, &step_import::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)) = (
goldens::script::edge_point(e, 0.0),
goldens::script::edge_point(e, 1.0),
step_import::script::edge_point(e, 0.0),
step_import::script::edge_point(e, 1.0),
) else {
continue;
};
+3 -2
View File
@@ -82,7 +82,7 @@ fn main() {
};
let Some(body) = std::fs::read(file)
.ok()
.and_then(|b| step_export::import(&b, &step_export::Limits::default()).ok())
.and_then(|b| step_import::import(&b, &step_import::Limits::default()).ok())
.and_then(|m| m.bodies.into_iter().nth(bi))
else {
eprintln!("no body {bi}");
@@ -93,7 +93,8 @@ fn main() {
report("stitch recipe (alone)", &k, &mut b, f);
}
}
let script = goldens::script::from_body(&body, goldens::script::FromBodyOptions::default());
let script =
step_import::script::from_body(&body, step_import::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)
+3 -3
View File
@@ -1,4 +1,4 @@
//! The Rust side of constructor scripts ([`crate::script`]): runs a script on
//! The Rust side of constructor scripts ([`step_import::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 crate::script::{Node, Op, Orient, RegionRef, Script, ScriptCurve};
use solidcore_record::step::model as sm;
use step_import::model as sm;
use step_import::script::{Node, Op, Orient, RegionRef, Script, ScriptCurve};
use crate::step::{make_curve, make_surface};
-2
View File
@@ -19,7 +19,6 @@ pub mod cases {
pub mod compare;
pub mod constructor;
pub mod fixtures;
pub mod flat;
pub mod golden;
pub mod jobs;
pub mod json;
@@ -29,7 +28,6 @@ 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;
+14 -12
View File
@@ -1,9 +1,9 @@
//! `--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.
//! `--step-corpus`: imports real-world STEP files with the clean-room `step-import`
//! crate 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 ([`crate::flat::encode`]) and decoded again, and built from the
//! body is encoded ([`step_import::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`
//! ([`crate::script::from_body`], run by [`crate::constructor`]): shared edges
//! ([`step_import::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 solidcore_record::step::model::{self as sm, Body, Bound};
use step_import::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 ([`crate::script::step_uv_domain`]) by
/// face's STEP parameter box ([`step_import::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) = crate::script::step_uv_domain(body, face) {
if let Some(d) = step_import::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,7 +479,8 @@ pub fn face_areas(
) -> Vec<(Option<f64>, Option<f64>)> {
use solidcore::mass::integrator::properties::face_area;
let mut out = Vec::new();
let script = crate::script::from_body(body, crate::script::FromBodyOptions::default());
let script =
step_import::script::from_body(body, step_import::script::FromBodyOptions::default());
let (k2, built, _) = crate::constructor::build_brep(&script);
let mut built = built;
let ctor_faces: Vec<FaceId> = built
@@ -700,7 +701,8 @@ fn constructor_record(out: &[f64]) -> Result<Record, String> {
/// Builds `body` through the Rust `BrepConstructor`.
pub fn build_rust_constructor(body: &Body) -> Record {
let script = crate::script::from_body(body, crate::script::FromBodyOptions::default());
let script =
step_import::script::from_body(body, step_import::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 {
@@ -714,7 +716,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_export::import(&bytes, &step_export::Limits::default())
let model = step_import::import(&bytes, &step_import::Limits::default())
.map_err(|e| format!("parse: {e}"))?;
let body = model
.bodies
@@ -722,7 +724,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.
crate::flat::decode(&crate::flat::encode(&body))
step_import::flat::decode(&step_import::flat::encode(&body))
}
/// `--step-case <file> <body> <out.json>`: one body.
@@ -989,7 +991,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_export::import(&bytes, &step_export::Limits::default()) {
match step_import::import(&bytes, &step_import::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 ([`crate::script`]): the calls an importer makes,
//! A case is a constructor script ([`step_import::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-export`'s import tests, each with limits from vertex points or curve
//! tetrahedron and disk of `step-import`, 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 solidcore_record::step::model::{
use step_import::model::{
BSplineCurve, Body, BodyKind, Bound, Curve, Edge, Face, Frame, Loop, OrientedEdge, Shell,
Surface, Vertex, P3,
};
use crate::script::{
use step_import::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(solidcore_record::step::model::BSplineSurface {
Surface::BSpline(step_import::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_export::import(TETRA_STEP.as_bytes(), &step_export::Limits::default())
step_import::import(TETRA_STEP.as_bytes(), &step_import::Limits::default())
.map(|m| m.bodies)
.unwrap_or_default()
}
-34
View File
@@ -1,34 +0,0 @@
//! 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
@@ -1,90 +0,0 @@
//! 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 solidcore_record::step::model::{
use step_import::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.
goldens::flat::decode(&goldens::flat::encode(&body)).unwrap()
step_import::flat::decode(&step_import::flat::encode(&body)).unwrap()
}
fn stage<'a>(r: &'a Record, name: &str) -> &'a [f64] {
+54
View File
@@ -163,3 +163,57 @@ fn a_rod_cut_by_the_union_of_two_counter_wound_channel_sweeps_is_valid() {
.unwrap();
graded("by the union", &r);
}
/// Cancel stops the full check of a long coil cut (solidcore#3247): a 20-turn square coil cut
/// from a cylinder, the case of tangency-labs/solidcraft#3962, whose check takes seconds (most
/// of the cut's time, in the edges' curve analyses). Cancelled from another thread part way,
/// the check returns `Cancelled` within 500 ms (a few ms on a desktop; the bound leaves room
/// for shared CI runners). nextest runs each test in a process of its own, so no other test's
/// call holds the session's kernel meanwhile.
#[test]
fn the_check_of_a_long_coil_cut_stops_on_cancel() {
use solidcore_ops::cancel::{within, CancelToken};
use solidcore_ops::sweep::{screw_sweep, Curve};
use std::time::{Duration, Instant};
let corners = [
[19.0, 0.0, 12.0],
[32.0, 0.0, 12.0],
[32.0, 0.0, 25.0],
[19.0, 0.0, 25.0],
];
let square: Vec<Curve> = (0..4)
.map(|i| Curve::Line {
start: corners[i],
end: corners[(i + 1) % 4],
})
.collect();
let coil = screw_sweep(
&square,
[0.0; 3],
[0.0, 0.0, 1.0],
15.24,
20.0,
None,
15.24e-4,
)
.unwrap();
let stock = solidcore_ops::make_cylinder([0.0; 3], 20.0, 304.5).unwrap();
let cut = boolean(stock, coil, BooleanOp::Subtract).unwrap().unwrap();
let token = CancelToken::new();
let watched = token.clone();
let worker = std::thread::spawn(move || {
let r = within(&watched, || cut.check(CheckLevel::All));
(r.map(|_| ()), Instant::now())
});
std::thread::sleep(Duration::from_millis(300));
let cancelled = Instant::now();
token.cancel();
let (r, returned) = worker.join().unwrap();
let took = returned.saturating_duration_since(cancelled);
eprintln!("the check returned {took:?} after Cancel");
assert_eq!(r, Err(Error::Cancelled));
assert!(
took < Duration::from_millis(500),
"returned {took:?} after Cancel"
);
}
+2 -6
View File
@@ -1,13 +1,9 @@
//! 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, 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").
//! 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").
pub mod geom;
pub mod model;
pub mod p21;
pub mod product;
pub mod read;
pub mod units;
pub use model::Model;
+3 -57
View File
@@ -9,8 +9,9 @@
//! 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). This is
//! solidcore's one Part 21 implementation (`step-import`'s `part21.rs` is gone, #14123).
//! 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.
//!
//! 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
@@ -197,34 +198,9 @@ 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.
@@ -832,39 +808,9 @@ 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)
+2 -5
View File
@@ -198,9 +198,7 @@ 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. A presentation's reference
/// (a style, a layer, an invisibility) does not count: a styled loose shell is still unlisted. As
/// solidcore's
/// face no shell lists (each a one-face sheet), in id order within each. 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> {
@@ -217,10 +215,9 @@ 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.iter().filter(|r| !presentation(&r.name)) {
for r in &e.records {
for p in &r.params {
refer(p, &mut referenced, 0);
}
+5 -1
View File
@@ -121,6 +121,7 @@ use crate::topology::brep_cache::{
};
use crate::topology::{edge, face, vertex, Brep, BrepId, NamingScheme, RegionId};
const OP_TIGHT_BOX: &str = "query::brep::calculate_tight_bounding_box";
const OP_CLASSIFY: &str = "query::brep::BrepQuery::point_3d_classify";
const OP_EVAL_POINT: &str = "query::brep::evaluate_surface_point";
const OP_EVAL_NORMAL: &str = "query::brep::evaluate_surface_normal";
@@ -268,8 +269,10 @@ pub fn calculate_tight_bounding_box<'a>(
brep_bbox = brep_bbox.union(&v_bbox);
}
// Union all of the Tight Edge bounding boxes.
// Union all of the Tight Edge bounding boxes. (not upstream) cooperative cancellation
// once per edge and face (solidcore#3247).
for e in bp1::edges(b, None)? {
k.check_cancelled(OP_TIGHT_BOX)?;
// (CalculateBoundingBox initialises the box first)
let mut edge_bbox = ignored(crate::error::passed_on(
qe::calculate_bounding_box(k, b, e, NORMAL_BOX, false),
@@ -294,6 +297,7 @@ pub fn calculate_tight_bounding_box<'a>(
// face).
let mut face_bbox = Extent3d::new();
for f in bp1::faces(b, None)? {
k.check_cancelled(OP_TIGHT_BOX)?;
if let Some(loose) = ignored(crate::error::passed_on(
qf1::calculate_bounding_box(k, lb.get(), f, false),
k.fixes.brep_edit_statuses_pass_on,
+2
View File
@@ -329,6 +329,8 @@ pub fn find_infinite_region(
break;
}
for edge_id in &edges {
// (not upstream) cooperative cancellation once per edge (solidcore#3247)
k.check_cancelled(OP)?;
let record = b.edge(*edge_id)?;
let curve = required(record.curve.as_ref(), "ray target edge without curve")?;
let big_tolerance = record.tolerance > 1.01 * b.tolerance();
@@ -317,6 +317,10 @@ fn validate_and_update_body(
// for every face->edgeuse
for eu in edgeuses {
// (not upstream) cooperative cancellation once per edgeuse (solidcore#3247)
k.check_cancelled(
"topology::brep::validation::assert_valid::validate_and_update_body",
)?;
let mate = eu1::mate(b, eu)?;
// skip marked edgeuses
@@ -520,6 +524,9 @@ fn collect_regions(
/// `SmBrep::AssertSubTopology(*this)`: `AssertValid(SM_NO_WALK)` of every region,
/// shell, face, face loop, edge and vertex of the B-rep, in that order.
fn sub_topology(k: &mut Kernel, b: &mut Brep, cx: &mut Cx<'_>) -> Result<bool> {
// (not upstream) cooperative cancellation once per entity: a check of a large body's
// geometry runs for seconds, the edges' curve analyses most of it (solidcore#3247)
const OP: &str = "topology::brep::validation::assert_valid::sub_topology";
let regions = p1::regions(b, None)?;
let shells = p1::shells(k, b, None)?;
let faces = p1::faces(b, None)?;
@@ -536,18 +543,23 @@ fn sub_topology(k: &mut Kernel, b: &mut Brep, cx: &mut Cx<'_>) -> Result<bool> {
rtn &= shell::region(b, cx, r)?;
}
for s in shells {
k.check_cancelled(OP)?;
rtn &= shell::shell(k, b, cx, &shell_index, s)?;
}
for f in faces {
k.check_cancelled(OP)?;
rtn &= face::face(k, b, cx, f)?;
}
for l in face_loops {
k.check_cancelled(OP)?;
rtn &= loops::loop_(k, b, cx, l)?;
}
for e in edges {
k.check_cancelled(OP)?;
rtn &= edge::edge(k, b, cx, e)?;
}
for v in vertices {
k.check_cancelled(OP)?;
rtn &= vertex::vertex(k, b, cx, v)?;
}
Ok(rtn)
@@ -68,6 +68,9 @@ pub fn contains_coincident_topology(
level: u8,
keys: &dyn Fn(EntityRef) -> crate::geom::object::ObjectRef,
) -> Result<CoincidenceResult> {
// (not upstream) cooperative cancellation once per vertex, edge and face of each search
// (solidcore#3247)
const OP: &str = "topology::brep::validation::coincidence::contains_coincident_topology";
profile_scope!(k, "validation::coincidence");
if level > 4 {
return Err(invalid("invalid assert test level"));
@@ -94,6 +97,7 @@ pub fn contains_coincident_topology(
{
profile_scope!(k, "validation::coincidence::vertex_vertex");
for (i, &v) in vertices.iter().enumerate() {
k.check_cancelled(OP)?;
let point = b.vertex(v)?.point;
let mut previous = i;
for j in point_index
@@ -157,6 +161,7 @@ pub fn contains_coincident_topology(
{
profile_scope!(k, "validation::coincidence::edge_edge");
for (i, &e) in edges.iter().enumerate() {
k.check_cancelled(OP)?;
let v1 = required(edge::vertex(b, e)?)?;
let v2 = required(edge::other_vertex(b, e, Some(v1))?)?;
let candidates: Vec<_> = endpoint_groups
@@ -214,6 +219,7 @@ pub fn contains_coincident_topology(
let mut caches = FaceCaches::new();
let mut face_boxes = Vec::new();
for &f in &faces {
k.check_cancelled(OP)?;
face_boxes.push(ignored(query_face::calculate_bounding_box(
k, &mut *b, f, false,
))?);
@@ -232,6 +238,7 @@ pub fn contains_coincident_topology(
{
profile_scope!(k, "validation::coincidence::face_face");
for (i, &f) in faces.iter().enumerate() {
k.check_cancelled(OP)?;
let key = outer_keys.get(i).ok_or_else(|| invalid("face candidate"))?;
let mut found = None;
for &j in outer_groups
@@ -312,6 +319,7 @@ pub fn contains_coincident_topology(
{
profile_scope!(k, "validation::coincidence::vertex_edge");
for &e in &edges {
k.check_cancelled(OP)?;
let bbox = ignored(crate::query::edge::calculate_bounding_box(
k,
b,
@@ -418,6 +418,9 @@ pub fn validate_pointers(
}
let faces = p1::faces(b, None)?;
for &f in &faces {
// (not upstream) cooperative cancellation once per face: checking a face's trim
// evaluates its curves and surface (solidcore#3247)
k.check_cancelled("topology::brep::validation::pointers::validate_pointers")?;
let tol = b.face(f)?.tolerance;
let checked =
crate::topology::trimming_tools::check_face(k, b, f, tol, r.array.as_deref_mut(), walk);
+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 goldens' `tests/flat_panic_fuzz.rs` (the flat arrays). Environment:
//! `tests/panic_fuzz.rs` (STEP import) and step-import's (its 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, and the mapper that reads a Part 21 file into that model"
description = "ISO 10303-21 writer for the neutral STEP B-rep model"
edition.workspace = true
license.workspace = true
version.workspace = true
@@ -8,7 +8,7 @@ publish = false
[dependencies]
solidcore = { path = "../solidcore" }
solidcore-record = { path = "../solidcore-record" }
step-import = { path = "../step-import" }
[lints]
workspace = true
+27 -31
View File
@@ -1,4 +1,4 @@
//! Convert a `SolidCore` B-rep into the neutral [`solidcore_record::step::Model`].
//! Convert a `SolidCore` B-rep into the neutral [`step_import::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 solidcore_record::step::model::{
use step_import::model::{
Body, BodyKind, Bound, Curve as ModelCurve, Edge, Face, Frame, Loop, Model, OrientedEdge,
Shell, Surface as ModelSurface, Vertex,
};
@@ -87,15 +87,13 @@ 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(
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(),
},
))
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(),
}))
}
fn approximate_crv_on_surf(
@@ -225,26 +223,24 @@ 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(
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(),
},
))
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(),
}))
}
/// Which of `surface`'s STEP parameters (u, v) are angles (ISO 10303-42): a cylinder's
-94
View File
@@ -1,94 +0,0 @@
//! `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());
}
+12 -22
View File
@@ -1,5 +1,4 @@
//! 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.
//! ISO 10303-21 writer for the neutral [`step_import::Model`].
//!
//! 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
@@ -39,22 +38,12 @@
/// 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 solidcore_record::step::model::{
Body, BodyKind, Bound, Curve, Frame, Model, PcurveKey, Surface,
};
use step_import::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;
@@ -457,7 +446,7 @@ fn write_surface(
fn write_bspline_surface(
entities: &mut Entities,
points: &mut PointIds,
surface: &solidcore_record::step::model::BSplineSurface,
surface: &step_import::model::BSplineSurface,
) -> Result<u64, ExportError> {
let mut ids = Vec::with_capacity(surface.points.len());
for row in &surface.points {
@@ -574,7 +563,7 @@ fn write_curve(
fn write_bspline_curve(
entities: &mut Entities,
points: &mut PointIds,
curve: &solidcore_record::step::model::BSplineCurve,
curve: &step_import::model::BSplineCurve,
) -> Result<u64, ExportError> {
let ids = curve
.points
@@ -1405,8 +1394,8 @@ fn refs(ids: &[u64]) -> String {
#[cfg(test)]
mod tests {
use super::*;
use solidcore_record::step::model::{Edge, Face, Loop, OrientedEdge, Shell, Vertex};
use solidcore_record::step::p21::Header;
use step_import::model::{Edge, Face, Loop, OrientedEdge, Shell, Vertex};
use step_import::part21::Header;
fn line(origin: [f64; 3], dir: [f64; 3], magnitude: f64) -> Curve {
Curve::Line {
@@ -1531,7 +1520,7 @@ mod tests {
diagnostics: Vec::new(),
assembly_instances: 0,
pcurves: BTreeMap::new(),
file_pcurves: solidcore_record::step::model::FilePcurves::default(),
file_pcurves: step_import::model::FilePcurves::default(),
}
}
@@ -1661,7 +1650,7 @@ mod tests {
diagnostics: Vec::new(),
assembly_instances: 0,
pcurves: BTreeMap::new(),
file_pcurves: solidcore_record::step::model::FilePcurves::default(),
file_pcurves: step_import::model::FilePcurves::default(),
}
}
@@ -1699,7 +1688,8 @@ mod tests {
}
fn reimport(text: &str) -> Model {
let model = import(text.as_bytes(), &Limits::default()).expect("re-import written STEP");
let model = step_import::import(text.as_bytes(), &step_import::Limits::default())
.expect("re-import written STEP");
assert!(model.skipped.is_empty(), "{:?}", model.skipped);
model
}
@@ -2051,7 +2041,7 @@ mod tests {
magnitude: 3.0,
},
];
let bspline = solidcore_record::step::model::BSplineSurface {
let bspline = step_import::model::BSplineSurface {
u_degree: 1,
v_degree: 1,
points: vec![
@@ -2081,7 +2071,7 @@ mod tests {
semi1: 2.0,
semi2: 1.0,
},
Curve::BSpline(solidcore_record::step::model::BSplineCurve {
Curve::BSpline(step_import::model::BSplineCurve {
degree: 1,
points: vec![[0.0; 3], [1.0, 0.0, 0.0]],
weights: None,
-101
View File
@@ -1,101 +0,0 @@
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;
+15
View File
@@ -0,0 +1,15 @@
[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" }
@@ -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 goldens --example step_dump_body -- <file.stp> <body index> [--flat]`
//! `cargo run -p step-import --example 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_export::import(&bytes, &step_export::Limits::default()) {
let model = match step_import::import(&bytes, &step_import::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 goldens::flat::encode(body) {
for x in step_import::flat::encode(body) {
println!("{x:?}");
}
} else {
@@ -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 goldens --example step_survey -- corpus/steptools`
//! `cargo run -p step-import --example 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_export::import(&bytes, &step_export::Limits::default()) {
match step_import::import(&bytes, &step_import::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| {
@@ -25,11 +25,11 @@
//!
//! `kind` is 0 manifold solid, 1 solid with voids, 2 faceted, 3 shell model.
use solidcore_record::step::model::{
use crate::map::{check_bspline_curve, check_knots};
use crate::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| {
step_export::geom::expand_knots(k, m, n + d + 1).is_some()
crate::geom::expand_knots(k, m, n + d + 1).is_some()
};
if nu < ud + 1
|| nv < vd + 1
@@ -1,20 +1,17 @@
//! Small vector helpers and the curve evaluation the mapper needs to find the
//! parameters of an edge's vertices on its curve.
use solidcore_record::step::model::{BSplineCurve, Frame, P3};
use crate::model::{BSplineCurve, Frame, P3};
/// `a - b`.
pub fn sub(a: P3, b: P3) -> P3 {
pub(crate) fn sub(a: P3, b: P3) -> P3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
/// `a * s`.
pub fn scale(a: P3, s: f64) -> P3 {
pub(crate) fn scale(a: P3, s: f64) -> P3 {
[a[0] * s, a[1] * s, a[2] * s]
}
/// The dot product.
pub fn dot(a: P3, b: P3) -> f64 {
pub(crate) fn dot(a: P3, b: P3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
@@ -66,7 +63,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 fn expand_knots(knots: &[f64], mults: &[u32], expected: usize) -> Option<Vec<f64>> {
pub(crate) 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)?;
@@ -87,7 +84,7 @@ pub fn expand_knots(knots: &[f64], mults: &[u32], expected: usize) -> Option<Vec
/// 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 fn bspline_point(c: &BSplineCurve, t: f64) -> Option<P3> {
pub(crate) 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()?;
+284
View File
@@ -0,0 +1,284 @@
//! 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());
}
}
@@ -21,69 +21,11 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use crate::geom::{self, build_frame, conic_angle, dist, unit};
use solidcore_record::step::model::{
use crate::model::{
BSplineCurve, BSplineSurface, Body, BodyKind, Bound, Curve, Curve2d, Diagnostic, Edge, Face,
FilePcurves, Frame, Loop, Model, OrientedEdge, Pcurve, Shell, Skipped, Surface, Vertex, P3,
};
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))
}
use crate::part21::{Instance, Limits, Param, Record, StepFile};
/// Why a body could not be mapped.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -155,11 +97,10 @@ struct Ctx {
}
impl<'a> Mapper<'a> {
fn inst(&self, id: u64) -> R<Instance<'a>> {
fn inst(&self, id: u64) -> R<&'a Instance> {
self.file
.entities
.get(&id)
.map(Instance::of)
.map_or_else(|| invalid(format!("dangling reference #{id}")), Ok)
}
@@ -331,7 +272,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().map(Instance::of) {
for inst in self.file.entities.values() {
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") {
@@ -1269,11 +1210,7 @@ fn polygon_plane(pts: &[P3]) -> R<Surface> {
Ok(Surface::Plane { frame })
}
/// Checks a B-spline curve is well formed.
///
/// # Errors
/// [`MapError::Invalid`] naming the fault.
pub fn check_bspline_curve(b: &BSplineCurve) -> R<()> {
pub(crate) fn check_bspline_curve(b: &BSplineCurve) -> R<()> {
let n = b.points.len();
let p = b.degree as usize;
if n < p + 1 {
@@ -1291,11 +1228,7 @@ pub fn check_bspline_curve(b: &BSplineCurve) -> R<()> {
Ok(())
}
/// 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<()> {
pub(crate) 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)))
@@ -1515,7 +1448,7 @@ fn trim_parameter(p: &Param) -> Option<f64> {
p.as_list()?
.iter()
.find_map(|select| match select {
Param::Typed(name, _) if name == "PARAMETER_VALUE" => select.as_f64(),
Param::Typed(name, value) if name == "PARAMETER_VALUE" => value.as_f64(),
Param::Real(_) | Param::Int(_) => select.as_f64(),
_ => None,
})
@@ -1551,7 +1484,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).map(Instance::of)? else {
let Instance::Simple(record) = file.entities.get(&base)? else {
return None;
};
if !matches!(record.name.as_str(), "ADVANCED_FACE" | "FACE_SURFACE") {
@@ -1606,16 +1539,12 @@ fn collect_dangling_references(file: &StepFile, diagnostics: &mut Vec<Diagnostic
visit(item, missing);
}
}
Param::Typed(_, values) => {
for value in values {
visit(value, missing);
}
}
Param::Typed(_, value) => visit(value, missing),
_ => {}
}
}
let mut seen = BTreeSet::new();
for (id, inst) in file.entities.iter().map(|(id, e)| (id, Instance::of(e))) {
for (id, inst) in &file.entities {
let records: &[Record] = match inst {
Instance::Simple(record) => std::slice::from_ref(record),
Instance::Complex(records) => records,
@@ -1641,7 +1570,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).map(Instance::of)? else {
let Instance::Simple(record) = file.entities.get(&current)? else {
return Some(current);
};
if record.name != "ORIENTED_FACE" {
@@ -1658,7 +1587,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().map(Instance::of) {
for instance in file.entities.values() {
let Instance::Simple(record) = instance else {
continue;
};
@@ -1684,7 +1613,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().map(Instance::of) {
for instance in file.entities.values() {
let Instance::Simple(record) = instance else {
continue;
};
@@ -1709,7 +1638,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.iter().map(|(id, e)| (id, Instance::of(e))) {
for (id, instance) in &m.file.entities {
let Instance::Simple(record) = instance else {
continue;
};
@@ -1754,7 +1683,7 @@ fn map_loose_faces(
.file
.entities
.get(&id)
.map_or_else(String::new, |e| Instance::of(e).type_name()),
.map_or_else(String::new, Instance::type_name),
reason: error.to_string(),
}),
}
@@ -1763,7 +1692,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.iter().map(|(id, e)| (id, Instance::of(e))) {
for (id, instance) in &m.file.entities {
let Instance::Simple(record) = instance else {
continue;
};
@@ -1804,7 +1733,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.iter().map(|(id, e)| (id, Instance::of(e))) {
for (id, inst) in &file.entities {
if inst.record("GLOBAL_UNIT_ASSIGNED_CONTEXT").is_some() {
if let Ok(u) = m.context_units(*id) {
if !all_units.contains(&u) {
@@ -1836,7 +1765,7 @@ pub fn map_file(file: &StepFile, limits: &Limits) -> Model {
),
});
}
for (id, inst) in file.entities.iter().map(|(id, e)| (id, Instance::of(e))) {
for (id, inst) in &file.entities {
let rec = match inst {
Instance::Simple(r) => r,
Instance::Complex(_) => {
@@ -1890,13 +1819,11 @@ pub fn map_file(file: &StepFile, limits: &Limits) -> Model {
#[cfg(test)]
mod tests {
use super::*;
use solidcore_record::step::p21::parse;
use crate::part21::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).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.as_bytes(), &Limits::default()).unwrap();
map_file(&f, &Limits::default())
}
@@ -368,7 +368,7 @@ pub struct FilePcurves {
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Model {
/// Part 21 header metadata.
pub header: crate::step::p21::Header,
pub header: crate::part21::Header,
/// `FILE_SCHEMA` names.
pub schemas: Vec<String>,
/// Bodies in entity id order. Includes a one-face open-shell
+918
View File
@@ -0,0 +1,918 @@
//! 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());
}
}
@@ -45,8 +45,8 @@
//! once (the call takes it over).
use crate::flat::{decode_curve, decode_surface, encode_curve, encode_surface};
use solidcore_record::step::model::{Body, BodyKind, Bound, Curve, Surface, P3};
use step_export::geom::{bspline_point, dot, scale, sub};
use crate::geom::{bspline_point, dot, scale, sub};
use crate::model::{Body, BodyKind, Bound, Curve, Surface, P3};
/// 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<&solidcore_record::step::model::Loop> =
let mut loops: Vec<&crate::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: &solidcore_record::step::model::Edge, s: f64) -> Option<P3> {
pub fn edge_point(e: &crate::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: &solidcore_record::step::model::Frame, a: f64, b: f64, deg: f64) -> P3 {
fn conic(f: &crate::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: &solidcore_record::step::model::Face) -> Option<[f64; 4]> {
pub fn step_uv_domain(body: &Body, face: &crate::model::Face) -> Option<[f64; 4]> {
let map: ParamMap = match &face.surface {
Surface::Plane { frame } => {
let f = *frame;
@@ -945,11 +945,7 @@ 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: &solidcore_record::step::model::Face,
samples: usize,
) -> Option<f64> {
pub fn step_face_area(body: &Body, face: &crate::model::Face, samples: usize) -> Option<f64> {
step_uv_domain(body, face)?;
let (f, map): (AreaElement, ParamMap) = match &face.surface {
Surface::Plane { frame } => {
@@ -7,8 +7,8 @@
use std::fmt;
use solidcore_record::step::model::{Body, Model};
use solidcore_record::step::p21::{Exchange as StepFile, Param, Record};
use crate::model::{Body, Model};
use crate::part21::{Instance, Param, Record, StepFile};
/// One validation problem.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -38,11 +38,7 @@ fn visit(param: &Param, missing: &mut Vec<u64>) {
visit(item, missing);
}
}
Param::Typed(_, values) => {
for value in values {
visit(value, missing);
}
}
Param::Typed(_, value) => visit(value, missing),
_ => {}
}
}
@@ -50,7 +46,10 @@ 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] = &instance.records;
let records: &[Record] = match instance {
Instance::Simple(record) => std::slice::from_ref(record),
Instance::Complex(records) => records,
};
for record in records {
for param in &record.params {
let mut missing = Vec::new();
@@ -96,7 +95,7 @@ fn validate_body(body: &Body) -> Vec<ValidationError> {
for face in &body.faces {
for loop_ in &face.loops {
match &loop_.bound {
solidcore_record::step::model::Bound::Edges(edges) => {
crate::model::Bound::Edges(edges) => {
for edge in edges {
if edge.edge >= body.edges.len() {
errors.push(ValidationError {
@@ -109,7 +108,7 @@ fn validate_body(body: &Body) -> Vec<ValidationError> {
}
}
}
&solidcore_record::step::model::Bound::Vertex(vertex) => {
&crate::model::Bound::Vertex(vertex) => {
if vertex >= body.vertices.len() {
errors.push(ValidationError {
step_id: Some(body.step_id),
@@ -146,13 +145,13 @@ pub fn validate(file: &StepFile, model: &Model) -> Vec<ValidationError> {
#[cfg(test)]
mod tests {
use super::*;
use crate::map::{map_file, Limits};
use solidcore_record::step::p21::parse;
use crate::map::map_file;
use crate::part21::{parse, Limits};
#[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).unwrap();
let file = parse(text.as_bytes(), &Limits::default()).unwrap();
let model = map_file(&file, &Limits::default());
assert!(validate(&file, &model)
.iter()
@@ -164,7 +163,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).unwrap();
let file = parse(text.as_bytes(), &Limits::default()).unwrap();
let model = map_file(&file, &Limits::default());
assert_eq!(model.bodies.len(), 1);
assert!(
@@ -177,13 +176,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).unwrap();
let file = parse(text.as_bytes(), &Limits::default()).unwrap();
let mut model = map_file(&file, &Limits::default());
let nv = model.bodies[0].vertices.len();
let vertex_loop = |v| solidcore_record::step::model::Loop {
let vertex_loop = |v| crate::model::Loop {
outer: false,
orientation: true,
bound: solidcore_record::step::model::Bound::Vertex(v),
bound: crate::model::Bound::Vertex(v),
};
// A vertex loop of an existing vertex is consistent.
model.bodies[0].faces[0].loops.push(vertex_loop(nv - 1));
@@ -200,7 +199,7 @@ mod tests {
#[test]
fn accepts_a_consistent_model() {
let file = parse("ISO-10303-21;HEADER;FILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));ENDSEC;DATA;ENDSEC;END-ISO-10303-21;").unwrap();
let file = parse(b"ISO-10303-21;HEADER;FILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));ENDSEC;DATA;ENDSEC;END-ISO-10303-21;", &Limits::default()).unwrap();
let model = map_file(&file, &Limits::default());
assert!(validate(&file, &model).is_empty());
}
+10 -5
View File
@@ -1122,8 +1122,13 @@ 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-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
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
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-export/src/geom.rs crates/step-import/src/flat.rs
1126 crates/step-export/src/map.rs crates/step-import/src/flat.rs
1127 crates/step-export/src/map.rs crates/step-import/src/geom.rs
1128 crates/step-export/src/map.rs crates/step-import/src/map.rs
1129 crates/step-export/src/map.rs crates/step-import/src/map.rs
1130 crates/step-import/src/map.rs
1131 crates/step-import/src/map.rs
1132 crates/step-import/src/script.rs
1133 crates/step-import/src/script.rs
1134 crates/step-import/src/script.rs
+2 -6
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-export"]
CRATES = ["solidcore", "step-import", "step-export"]
COLUMNS = ["file", "fn", "pattern", "n", "status", "code", "reason"]
# clippy `disallowed-methods` path -> pattern name. Only calls that can panic on their
@@ -418,11 +418,7 @@ def run_clippy(root):
for crate in CRATES:
cmd += ["-p", crate]
# a renamed or misspelt lint would otherwise pass with no sites at all
# --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)]
cmd += ["--", "-D", "unknown_lints"] + [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: