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