Files
OneCAD/crates/doc/src/document.rs
T

3042 lines
128 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde::{Deserialize, Serialize};
use solidcraft_geom::{Plane, Vec2, Vec3};
use solidcraft_kernel::Continuity;
use solidcraft_sketch::Sketch;
use crate::construct::{AxisDef, GeoRef, PointDef};
use crate::expr::{self, Expr, Kind};
use crate::names::{BodyName, FeatureName, SketchId};
use crate::points::{Dir, FacePick, Position};
use crate::{DocError, Result};
pub const MAX_FEATURES: usize = 10_000;
pub const MAX_PARAMS: usize = 10_000;
/// Largest feature id a replayed feature may keep (`Document::insert_feature`): exact in JSON
/// numbers, and far from overflowing the next id.
pub const MAX_FEATURE_ID: u64 = 1 << 52;
/// A named parameter. User parameters are created by the user; model parameters (`d1`, `d2`…)
/// are created by dimensions and features.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Param {
pub name: String,
pub expr: String,
/// `mm`, `deg` or empty (unit-less).
pub unit: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub comment: String,
/// Created by a dimension or feature (not by the user).
#[serde(default)]
pub model: bool,
}
impl Param {
pub fn kind(&self) -> Kind {
Kind::from_unit(&self.unit)
}
}
/// Where a sketch lies.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlaneRef {
/// `XY`, `XZ` or `YZ`.
Origin { name: String },
/// An explicit plane (e.g. picked from a planar face).
Custom { plane: Plane },
/// An explicit plane whose origin is given as length expressions
/// (`{origin: ["half", 0, 0], normal: [1, 0, 0]}`), so it follows parameter changes.
Placed {
#[serde(with = "point_expr")]
origin: PointExpr,
x_dir: Vec3,
y_dir: Vec3,
},
/// Another plane offset along its normal by an expression.
Offset { base: Box<PlaneRef>, distance: Expr },
/// Another plane rotated about a line (in world coordinates) by an angle expression.
AtAngle { base: Box<PlaneRef>, axis_origin: Vec3, axis_dir: Vec3, angle: Expr },
/// A construction plane feature (by name).
Construction { name: FeatureName },
/// `base` seen from its other side: the normal reversed by a half turn about the base's
/// own x axis (y reversed), or about its y axis (x reversed) when `about_y`. A sketch
/// started from behind a plane faces the viewer this way.
Flipped {
base: Box<PlaneRef>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
about_y: bool,
},
/// Halfway between two planes (or planar faces); bisecting them when they meet at an angle.
Midplane { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through two straight edges (lines) that lie in one plane.
TwoEdges { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through three points.
ThreePoints { a: Box<GeoRef>, b: Box<GeoRef>, c: Box<GeoRef> },
/// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it.
Tangent { face: Box<GeoRef>, at: Vec3 },
/// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it.
AlongPath { path: Box<GeoRef>, t: Expr },
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
Perpendicular { plane: Box<GeoRef>, line: Box<GeoRef> },
/// A planar body face. Certified attachments resolve only their exact source and report
/// an error when it is lost; `plane` then supplies a repair guide. Legacy references without
/// an anchor try the persistent `name`, then the nearest parallel face at `at`, and retain
/// the picked `plane` as their historical fallback.
Face {
plane: Plane,
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
/// Exact attachment identity for newly certified faces; absent on legacy designs.
#[serde(default, skip_serializing_if = "Option::is_none")]
anchor: Option<Box<crate::FaceAnchor>>,
},
}
/// Complete source curves of an outer boundary and each of its holes. Unlike a
/// span multiset, this identity is independent of adaptive spline subdivision.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WholeProfileBoundary {
pub outer: Vec<String>,
pub holes: Vec<Vec<String>>,
}
/// Which closed profiles of a sketch a feature uses.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProfileSel {
/// Every profile.
All,
/// Profiles by index in the solved sketch's profile list.
Indices { indices: Vec<usize> },
/// Profiles whose outer loop is made of exactly these curves, or pieces of them where other
/// curves cut them (order and repeats don't matter; see `sketch::profile_by_curves`). Found
/// again on every evaluation, so stable across parameter changes; no match or several is
/// an error, never another profile.
Curves { loops: Vec<Vec<String>> },
/// Complete current boundaries of topology-aware analytic offset associations.
/// The local association identity survives retirement and resurrection of arc roles.
OffsetBoundaries { associations: Vec<String> },
/// Exact whole source coverage, including holes; partial source curves do not match.
WholeBoundaries { profiles: Vec<WholeProfileBoundary> },
/// The profile containing each sketch point.
Points { points: Vec<Vec2> },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Operation {
#[default]
NewBody,
Join,
Cut,
Intersect,
}
impl Operation {
pub fn parse(s: &str) -> Option<Operation> {
Some(match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"new" | "newbody" | "newcomponent" => Operation::NewBody,
"join" | "union" | "add" => Operation::Join,
"cut" | "subtract" | "difference" => Operation::Cut,
"intersect" | "intersection" => Operation::Intersect,
_ => return None,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
/// Along the sketch normal.
#[default]
Positive,
Negative,
/// Both sides; the extent convention determines whether `distance` is total or per side.
Symmetric,
}
/// The direction a ruled surface leaves its edges (`surface.ruled`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuledMode {
/// Along the normal of the face beside each edge.
#[default]
Normal,
/// Continuing the face beside each edge.
Tangent,
/// At an angle from a reference direction (the face's normal unless one is given).
Angle,
/// Along a fixed direction.
Direction,
}
impl RuledMode {
fn is_normal(&self) -> bool {
*self == RuledMode::Normal
}
}
/// How a ruled surface's strips meet at sharp corners of its edge chain.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuledCorners {
/// Extended or cut back until they meet.
#[default]
Mitre,
/// Joined by a sector (inside corners are cut back).
Round,
}
impl RuledCorners {
fn is_mitre(&self) -> bool {
*self == RuledCorners::Mitre
}
}
/// Angular starts in older designs used the raw axis sign. New revolves measure starts
/// along the same sketch-normal sweep direction as the kernel.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RevolveConvention {
#[default]
LegacyAxisStart,
SketchNormal,
}
/// Symmetric distance semantics. Missing markers preserve existing designs' thickness.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtrudeConvention {
#[default]
LegacyPerSide,
TotalDistance,
}
impl ExtrudeConvention {
/// Distance from the mid-plane to either cap for a distance-limited extent.
pub fn symmetric_half_distance(self, distance: f64) -> f64 {
match self {
Self::LegacyPerSide => distance,
Self::TotalDistance => distance * 0.5,
}
}
}
/// Extent of an extrude.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct Extent {
#[walk(length = "Distance")]
pub distance: Expr,
#[serde(default)]
pub convention: ExtrudeConvention,
#[serde(default)]
pub direction: Direction,
/// Optional second side distance (two-sided extrude), along the negative normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Distance 2")]
pub distance2: Option<Expr>,
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
/// direction.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Start offset")]
pub start_offset: Option<Expr>,
/// Through all bodies in the direction(s) instead of `distance`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub through_all: bool,
/// Taper angle (expression; positive grows the profile).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Taper angle")]
pub taper: Option<Expr>,
/// To Object: end at the face (or vertex/point) at this point, instead of `distance`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<FacePick>,
/// Offset past the To Object face (expression; negative stops short).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "To offset")]
pub to_offset: Option<Expr>,
/// From Object: start at the face (or point) here instead of the sketch plane (plus
/// `start_offset`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<FacePick>,
}
/// Thin Extrude: a wall of constant thickness along sketch curves instead of the profiles.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct ThinWall {
/// The sketch curves the wall follows: open chains and closed loops, joined where they
/// meet end to end.
pub curves: Vec<String>,
/// The wall thickness (expression); with `side: two_sides`, the thickness inside.
#[walk(length = "Thickness")]
pub thickness: Expr,
#[serde(default, skip_serializing_if = "ThinSide::is_center")]
pub side: ThinSide,
/// With `side: two_sides`, the thickness outside (expression).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Thickness 2")]
pub thickness2: Option<Expr>,
}
/// How a swept profile turns as it follows its path.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(rename_all = "snake_case")]
pub enum SweepOrientation {
/// Square to the path, turning with it (a rotation-minimising frame).
#[default]
Perpendicular,
/// As drawn: the profile moves along the path without turning.
Parallel,
/// Square to the path, its turn about the path fixed by this direction (in the feature's
/// frame): the direction's projection onto the profile stays where it was at the start.
Direction(Dir),
}
impl SweepOrientation {
pub fn is_perpendicular(&self) -> bool {
*self == SweepOrientation::Perpendicular
}
}
/// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses;
/// an open chain's is the side it bends toward (a straight one's, the left of its first
/// curve as drawn).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThinSide {
/// Half the thickness either side of the curves.
#[default]
Center,
Inside,
Outside,
/// `thickness` inside and `thickness2` outside.
TwoSides,
}
impl ThinSide {
pub fn is_center(&self) -> bool {
*self == ThinSide::Center
}
pub fn parse(s: &str) -> Option<ThinSide> {
match s.trim().to_ascii_lowercase().replace([' ', '-'], "_").as_str() {
"center" | "centre" | "centered" | "centred" | "symmetric" => Some(ThinSide::Center),
"inside" | "side1" | "side_1" => Some(ThinSide::Inside),
"outside" | "side2" | "side_2" => Some(ThinSide::Outside),
"two_sides" | "both" => Some(ThinSide::TwoSides),
_ => None,
}
}
}
/// Revolve axis.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AxisRef {
/// A line of the profile's sketch.
SketchLine { curve: String },
/// Sketch x or y axis through the sketch origin (`x` / `y`).
SketchAxis { axis: String },
/// World X / Y / Z axis through the origin.
World { axis: String },
/// Any line in the sketch plane, in world coordinates.
Line { origin: Position, dir: Dir },
}
/// Persistent cylindrical Emboss selection, authored in the feature component frame.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct EmbossFace {
pub body: BodyName,
#[walk(pinned)]
pub at: FacePick,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub path: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub author_path: Vec<u64>,
}
impl EmbossFace {
/// The selected sketch placement, shared by evaluation and interactive guides.
pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body: &crate::ModelBody) -> Result<crate::Mat> {
let owner = doc.model_body_component(body);
if sketch_owner == owner {
return Self::component_frame(doc, owner, &self.path);
}
if sketch_owner == author {
return Self::component_frame(doc, author, &self.author_path);
}
let path = doc.first_path(sketch_owner);
let mut component = sketch_owner;
for _ in 0..64 {
if component == 0 {
break;
}
let mut occurrences = doc.occurrences.iter().filter(|o| o.component == component);
let occurrence = occurrences.next().ok_or_else(|| DocError::Invalid("sketch placement no longer exists".into()))?;
if occurrences.next().is_some() {
return Err(DocError::Invalid("sketch placement is ambiguous".into()));
}
component = occurrence.parent;
}
if component != 0 {
return Err(DocError::Invalid("sketch placement exceeds depth limit".into()));
}
Self::component_frame(doc, sketch_owner, &path)
}
/// Resolve this exact placement without expanding every assembly instance.
pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result<crate::Mat> {
let owner = doc.model_body_component(body);
let mut frame = Self::component_frame(doc, owner, &self.path)?;
if let Some(offset) = doc.model_body_offset(body) {
frame = crate::mat_mul(&frame, offset);
}
Ok(frame)
}
/// Resolve one saved component path without expanding other occurrences.
pub fn component_frame(doc: &Document, owner: u64, path: &[u64]) -> Result<crate::Mat> {
if path.len() > 64 || doc.occurrences.len() > 100_000 {
return Err(DocError::Invalid("selected cylinder path exceeds work limits".into()));
}
let mut frame = crate::IDENTITY;
let mut component = 0;
let mut seen = std::collections::BTreeSet::new();
for id in path {
if *id == 0 || !seen.insert(*id) {
return Err(DocError::Invalid("invalid selected cylinder path".into()));
}
let occurrence = doc
.occurrences
.iter()
.find(|o| o.id == *id && o.parent == component)
.ok_or_else(|| DocError::Invalid("selected cylinder placement no longer exists".into()))?;
frame = crate::mat_mul(&frame, &occurrence.transform);
component = occurrence.component;
}
if path.is_empty() {
frame = doc.component_transform(owner);
} else if component != owner {
return Err(DocError::Invalid("selected cylinder path belongs to another component".into()));
}
Ok(frame)
}
}
/// One edge of an edge flange feature: a point on the sheet's edge and, where given, this
/// edge's own angle and height (expressions) in place of the feature's. In a design file an
/// edge with neither is the point `[x, y, z]`, as every edge was before; one with its own
/// values is `{"point": [x, y, z], "angle": "…", "height": "…"}`.
#[derive(Clone, Debug, Default, PartialEq, solidcraft_derive::Walk)]
pub struct FlangeEdge {
pub point: Position,
#[walk(angle = "Edge angle")]
pub angle: Option<Expr>,
#[walk(length = "Edge height")]
pub height: Option<Expr>,
}
impl FlangeEdge {
/// An edge that takes the feature's angle and height.
pub fn at(point: Vec3) -> Self {
Self { point: Position(point), angle: None, height: None }
}
}
impl Serialize for FlangeEdge {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
if self.angle.is_none() && self.height.is_none() {
return self.point.serialize(s);
}
let mut m = s.serialize_map(None)?;
m.serialize_entry("point", &self.point)?;
if let Some(a) = &self.angle {
m.serialize_entry("angle", a)?;
}
if let Some(h) = &self.height {
m.serialize_entry("height", h)?;
}
m.end()
}
}
impl<'de> Deserialize<'de> for FlangeEdge {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer};
/// The object form; a key this build doesn't know is refused, not dropped.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Own {
point: Vec3,
#[serde(default)]
angle: Option<Expr>,
#[serde(default)]
height: Option<Expr>,
}
struct Edge;
impl<'de> serde::de::Visitor<'de> for Edge {
type Value = FlangeEdge;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a point [x, y, z] or an edge {point, angle?, height?}")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, seq: A) -> std::result::Result<FlangeEdge, A::Error> {
Vec3::deserialize(SeqAccessDeserializer::new(seq)).map(FlangeEdge::at)
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> std::result::Result<FlangeEdge, A::Error> {
let o = Own::deserialize(MapAccessDeserializer::new(map))?;
Ok(FlangeEdge { point: Position(o.point), angle: o.angle, height: o.height })
}
}
d.deserialize_any(Edge)
}
}
/// Feature definitions.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FeatureKind {
/// A recorded timeline ownership boundary. Geometry is converted from its
/// current local frame into the destination component frame exactly once.
BodyTransfer {
bodies: Vec<BodyName>,
from: u64,
to: u64,
transform: crate::Mat,
},
Sketch {
plane: PlaneRef,
sketch: Sketch,
},
Extrude {
/// Feature id of the sketch.
sketch: SketchId,
// A thin extrude follows curves; its profiles are not used.
#[walk(profiles_of = sketch, when = thin.is_none())]
profiles: ProfileSel,
extent: Extent,
#[serde(default)]
operation: Operation,
/// Target body names for join/cut/intersect (empty = all bodies).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<BodyName>,
/// Thin: a wall along sketch curves instead of the profiles (which are then unused).
#[serde(default, skip_serializing_if = "Option::is_none")]
thin: Option<ThinWall>,
},
Revolve {
sketch: SketchId,
#[walk(profiles_of = sketch)]
profiles: ProfileSel,
axis: AxisRef,
#[walk(angle = "Angle")]
angle: Expr,
#[serde(default)]
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<BodyName>,
/// Two Sides: the angle on the other side.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Angle 2")]
angle2: Option<Expr>,
/// Symmetric: `angle` split evenly either side of the profile.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
symmetric: bool,
/// Missing fields retain the angular placement of existing designs.
#[serde(default)]
convention: RevolveConvention,
/// To Object: turn until the face, vertex or point here.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<FacePick>,
},
Fillet {
/// Reference points on the edges to round (each re-finds the nearest edge).
edges: Vec<Position>,
#[walk(length = style.radius_label())]
radius: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
/// Constant radius (the default), chord length (`radius` is the width across), or a
/// radius varying along each edge.
#[serde(default, skip_serializing_if = "FilletStyle::is_constant")]
style: FilletStyle,
/// What to do when the radius is too large for the geometry around the edges.
#[serde(default, skip_serializing_if = "FilletFit::is_exact")]
fit: FilletFit,
},
Chamfer {
edges: Vec<Position>,
#[walk(length = "Distance")]
distance: Expr,
/// Unequal chamfers: the distance along the second face…
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Distance 2")]
distance2: Option<Expr>,
/// …or the angle the chamfer makes with the first face.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Angle")]
angle: Option<Expr>,
/// Swap which face is the first (by default the one facing up most).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
Box {
#[serde(with = "point_expr")]
#[walk(placement)]
corner: PointExpr,
#[walk(length = "Length")]
length: Expr,
#[walk(length = "Width")]
width: Expr,
#[walk(length = "Height")]
height: Expr,
#[serde(default)]
operation: Operation,
},
Cylinder {
#[serde(with = "point_expr")]
#[walk(placement)]
base: PointExpr,
#[serde(default = "z_axis")]
axis: Dir,
#[walk(length = "Radius")]
radius: Expr,
#[walk(length = "Height")]
height: Expr,
#[serde(default)]
operation: Operation,
},
Sphere {
#[serde(with = "point_expr")]
#[walk(placement)]
center: PointExpr,
#[walk(length = "Radius")]
radius: Expr,
#[serde(default)]
operation: Operation,
},
Torus {
#[serde(with = "point_expr")]
#[walk(placement)]
center: PointExpr,
#[walk(length = "Major radius")]
major: Expr,
#[walk(length = "Minor radius")]
minor: Expr,
#[serde(default)]
operation: Operation,
},
Combine {
target: BodyName,
tools: Vec<BodyName>,
operation: Operation,
#[serde(default)]
keep_tools: bool,
},
/// Copies of other features' results, or of bodies (rectangular, circular or along a path).
Pattern {
features: Vec<FeatureName>,
pattern: PatternKind,
/// Copy these bodies (as new bodies) instead of replaying features.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
bodies: Vec<BodyName>,
},
/// Mirror copies of other features' results in a plane.
Mirror {
features: Vec<FeatureName>,
plane: PlaneRef,
/// Mirror these bodies instead of replaying features.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
bodies: Vec<BodyName>,
/// Join each mirrored body with its original.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
combine: bool,
},
/// A drilled hole (simple, counterbore or countersink) at a point, into the material.
Hole {
#[walk(pick_if = points.is_none())]
position: FacePick,
/// Drilling direction (into the material).
direction: Dir,
#[walk(length = "Diameter")]
diameter: Expr,
/// Depth to the shoulder; `None` = through all.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Depth")]
depth: Option<Expr>,
#[serde(default)]
hole: HoleKind,
/// Drill at these sketch points instead (one hole each, perpendicular to the sketch, into the material: against the sketch normal, or along it when only that side has material);
/// the holes follow the points when the sketch changes.
#[serde(default, skip_serializing_if = "Option::is_none")]
points: Option<SketchPoints>,
/// A cosmetic thread in the holes (e.g. "M6x1").
#[serde(default, skip_serializing_if = "Option::is_none")]
thread: Option<String>,
/// To Object: drill down to the face (or point) here instead of `depth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<FacePick>,
},
/// A thread on a cylindrical face. Cosmetic by default (no geometry change; drawn and
/// exported as an annotation); `modeled` cuts the real helical ISO profile into the face.
/// `designation` is an ISO metric size such as "M8" or "M8x1".
Thread {
face: FacePick,
#[serde(default, skip_serializing_if = "Option::is_none")]
designation: Option<String>,
/// Thread length from the face's end nearest `face`; `None` = full length.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Length")]
length: Option<Expr>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
},
/// Ruled loft through profiles of several sketches, in order.
Loft {
sections: Vec<LoftSection>,
#[serde(default)]
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<BodyName>,
},
/// Sweep profiles along a chain of sketch curves.
Sweep {
sketch: SketchId,
#[walk(profiles_of = sketch)]
profiles: ProfileSel,
path_sketch: SketchId,
path: Vec<String>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
/// Angle the profile turns about the path over its length.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Twist")]
twist: Option<Expr>,
/// The profile's scale at the end of the path (linear along its length).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<Expr>,
#[serde(default)]
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<BodyName>,
},
/// Hollow a body leaving walls of `thickness`, removing the faces at the given points.
Shell {
faces: Vec<FacePick>,
#[walk(length = "Thickness")]
thickness: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
/// inside (default) | outside | both (the wall straddles the faces).
#[serde(default, skip_serializing_if = "String::is_empty")]
direction: String,
/// Open the faces smoothly joined to the picked ones too.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
tangent_chain: bool,
},
/// Tilt faces (at the given points) about a neutral plane, leaning toward `pull`.
Draft {
faces: Vec<FacePick>,
#[walk(angle = "Angle")]
angle: Expr,
neutral: PlaneRef,
pull: Dir,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// A construction plane (sketch placement, split tool).
ConstructionPlane {
plane: PlaneRef,
},
/// Split a body into the parts on either side of a plane, or of a face of a body (the face
/// through `point`, its surface extended) when `tool` is set.
Split {
body: BodyName,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
/// A straight line of this sketch as the tool (#1552): the plane through it along the
/// sketch's normal, which follows the sketch.
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<SketchId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
line: Option<String>,
},
/// A construction axis (Fusion's CONSTRUCT axes).
ConstructionAxis {
def: AxisDef,
},
/// A construction point (Fusion's CONSTRUCT points).
ConstructionPoint {
def: PointDef,
},
/// Split faces (at the given points) where a tool meets them, the solid unchanged: a plane,
/// a face of a body (`tool`, its surface extended) or a sketch's curves (`sketch`, swept
/// along its normal). The pieces are named after the face (`#k`).
SplitFace {
faces: Vec<FacePick>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<SketchId>,
},
/// Scale bodies about a point (uniform, or per axis).
Scale {
bodies: Vec<BodyName>,
#[serde(default)]
origin: Position,
#[walk(unitless = "Scale factor")]
factor: Expr,
/// Per-axis factors instead of `factor`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = ["X scale", "Y scale", "Z scale"])]
factors: Option<[Expr; 3]>,
},
/// Move planar faces (at the given points) along their normals; the body keeps its shape.
OffsetFace {
faces: Vec<FacePick>,
#[walk(length = "Distance")]
distance: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// Remove faces (at the given points) and heal the gap from the neighbouring planes, as when
/// a fillet, chamfer, hole or boss is taken off. A picked face brings the rest of its surface
/// (both halves of a hole's wall).
DeleteFace {
faces: Vec<FacePick>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
/// Close the gap by extending the neighbouring faces (the default); `false` leaves it
/// open, the faces left a surface body.
#[serde(default = "yes", skip_serializing_if = "is_yes")]
heal: bool,
},
/// A box around bodies, grown by a margin.
BoundingSolid {
bodies: Vec<BodyName>,
#[serde(default = "zero_expr")]
#[walk(length = "Margin")]
margin: Expr,
},
/// A tube along a chain of sketch curves (solid, or hollow with a wall thickness).
Pipe {
path_sketch: SketchId,
path: Vec<String>,
#[walk(length = "Diameter")]
diameter: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Wall")]
wall: Option<Expr>,
#[serde(default)]
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<BodyName>,
},
/// Raise or sink profiles on a planar face, or wrap them on a selected cylindrical wall.
Emboss {
sketch: SketchId,
#[walk(profiles_of = sketch)]
profiles: ProfileSel,
#[walk(length = "Depth")]
depth: Expr,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
deboss: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
face: Option<EmbossFace>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<BodyName>,
},
/// A thin wall from open sketch curves: `thickness` across the sketch plane, filling from
/// the curves toward the body (to the next face, or `depth`). A web makes one per curve.
Rib {
sketch: SketchId,
curves: Vec<String>,
#[walk(length = "Thickness")]
thickness: Expr,
/// Depth from the curves; `None` = to the next face of the body.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Depth")]
depth: Option<Expr>,
/// Fill toward the other side of the curves.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
/// Web: each curve is its own wall.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
web: bool,
},
/// Move planar faces (at the given points) onto a target plane parallel to them.
ReplaceFace {
faces: Vec<FacePick>,
target: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// Move bodies so that `from` lands on `to` (and, with normals, the faces meet face to face).
Align {
bodies: Vec<BodyName>,
from: Position,
to: Position,
#[serde(default, skip_serializing_if = "Option::is_none")]
from_normal: Option<Dir>,
#[serde(default, skip_serializing_if = "Option::is_none")]
to_normal: Option<Dir>,
/// Normals the same way instead of facing each other.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
},
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
Coil {
#[serde(with = "point_expr")]
#[walk(placement)]
base: PointExpr,
#[serde(default = "z_axis")]
axis: Dir,
#[walk(length = "Diameter")]
diameter: Expr,
#[walk(length = "Pitch")]
pitch: Expr,
#[walk(unitless = "Revolutions")]
turns: Expr,
#[walk(length = "Section size")]
section_size: Expr,
#[serde(default)]
section: CoilSection,
/// -1 inside, 0 on the centre, 1 outside of the diameter.
#[serde(default, skip_serializing_if = "is_zero_i8")]
position: i8,
/// Start angle about the axis (expression; 0 = from the axis toward +x, or a
/// perpendicular direction when the axis is x).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Start angle")]
start_angle: Option<Expr>,
/// Turn the other way (left-handed).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
clockwise: bool,
#[serde(default)]
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<BodyName>,
},
/// Sheet metal base flange: a sheet from a closed profile (thickness from the rule).
SheetBase {
sketch: SketchId,
#[walk(profiles_of = sketch)]
profiles: ProfileSel,
#[serde(default, skip_serializing_if = "Option::is_none")]
rule: Option<String>,
/// Thickness grows against the sketch normal.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
},
/// Sheet metal contour flange: a sheet bent along an open chain of sketch lines.
SheetContour {
sketch: SketchId,
curves: Vec<String>,
#[walk(length = "Distance")]
distance: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
rule: Option<String>,
/// Material on the other side of the chain.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
/// Width the other way along the sketch normal.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// Edge flanges on sheet edges (points on the top or bottom edges). `height` and `angle`
/// hold for every edge that doesn't carry its own (see [`FlangeEdge`]).
SheetFlange {
#[walk(after = radius)]
edges: Vec<FlangeEdge>,
#[walk(length = "Height")]
height: Expr,
#[walk(angle = "Angle")]
angle: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Bend radius")]
radius: Option<Expr>,
/// inside | outside | middle.
#[serde(default = "inside_str")]
position: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
/// The relief cut where two of this feature's bends meet at a corner of the base, in place
/// of the rule's: `corner_relief_shape` (notch | round | rectangular | tear) and
/// `corner_relief`, the size across. Either one left out follows the rule.
#[serde(default, skip_serializing_if = "Option::is_none")]
corner_relief_shape: Option<String>,
/// Walked last, after the edges' values, so the inputs before it keep their names.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Corner relief")]
corner_relief: Option<Expr>,
},
/// Flat hems on sheet edges.
SheetHem {
edges: Vec<Position>,
#[walk(length = "Length")]
length: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Gap")]
gap: Option<Expr>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// A perforation field (#732): round holes of `diameter` on a staggered (60°) or square grid of `pitch` over the
/// whole flat sheet, kept `margin` (default: the diameter) clear of its edges, cut-outs and bend zones. Stored like
/// a cosmetic thread: the holes go to the flat pattern, the DXF and the measured volume, not into the B-rep, so a
/// panel can carry tens of thousands of them.
SheetPerforate {
#[walk(length = "Diameter")]
diameter: Expr,
#[walk(length = "Pitch")]
pitch: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Margin")]
margin: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// Fold a sheet along a line on its base face: a sketch line (followed when the sketch
/// changes), else the points `a`, `b`.
SheetFold {
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<SketchId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
curve: Option<String>,
a: Position,
b: Position,
#[walk(angle = "Angle")]
angle: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Bend radius")]
radius: Option<Expr>,
/// centerline | start | end | mould
position: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
/// A point on the side that stays put (default: the larger side).
#[serde(default, skip_serializing_if = "Option::is_none")]
fixed: Option<Position>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// Unfold a sheet body flat (all bends), or refold it.
SheetUnfold {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
refold: bool,
},
/// Convert a body to sheet metal from its fixed face at `face`: a plate of constant
/// thickness or, with `bends`, a solid or shelled body whose faces reached from the fixed
/// face across those edges become the sheet.
SheetConvert {
body: BodyName,
#[walk(pick_if = !bends.is_empty())]
face: FacePick,
#[serde(default, skip_serializing_if = "Option::is_none")]
rule: Option<String>,
/// Points on the sharp edges that become bends.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
bends: Vec<Position>,
},
/// Plastic boss: a round post on a face (draft, root fillet, hole, ribs).
Boss {
position: FacePick,
/// Out of the face (the boss grows along it).
#[serde(default = "z_axis")]
direction: Dir,
#[walk(length = "Diameter")]
diameter: Expr,
#[walk(length = "Height")]
height: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Hole diameter")]
hole_diameter: Option<Expr>,
/// Hole depth from the top (default: down to the face).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Hole depth")]
hole_depth: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Draft angle")]
draft: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Root fillet")]
fillet: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Ribs")]
ribs: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Rib thickness")]
rib_thickness: Option<Expr>,
/// Rib reach out from the boss wall.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Rib length")]
rib_length: Option<Expr>,
/// Rib top below the boss top.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Rib offset")]
rib_offset: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// Plastic lip (raised band) or groove (recess) along the rim face picked at `face`.
Lip {
face: Position,
#[walk(length = "Width")]
width: Expr,
#[walk(length = "Height")]
height: Expr,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
groove: bool,
/// Clearance added to a groove's width (and depth).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Gap")]
gap: Option<Expr>,
/// Along the rim's outer edge instead of its inner one.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
outside: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// Plastic snap fit: a cantilever arm on a face with a hook (catch) at its tip.
SnapFit {
position: FacePick,
/// The arm grows along this (out of the face).
#[serde(default = "z_axis")]
direction: Dir,
/// The catch sticks out this way.
hook: Dir,
#[walk(length = "Length")]
length: Expr,
#[walk(length = "Thickness")]
thickness: Expr,
#[walk(length = "Width")]
width: Expr,
#[walk(length = "Catch depth")]
catch_depth: Expr,
/// The catch's height along the arm.
#[walk(length = "Catch length")]
catch_length: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// Plastic rest: a raised pad on a face (round, or a rectangle), solid or a hollow wall,
/// with draft.
Rest {
position: FacePick,
/// Out of the face (the pad grows along it).
#[serde(default = "z_axis")]
direction: Dir,
/// The rectangle's length runs along this (default: any direction in the face).
#[serde(default, skip_serializing_if = "Option::is_none")]
along: Option<Dir>,
/// Diameter of a round rest, or the rectangle's width.
#[walk(length = "Width")]
width: Expr,
/// The rectangle's length (none: round).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Length", after = height)]
length: Option<Expr>,
#[walk(length = "Height")]
height: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Draft angle")]
draft: Option<Expr>,
/// Wall thickness of a hollow rest (none: solid).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Wall thickness")]
thickness: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
/// Boundary Fill: solids from the cells the tool bodies enclose (a cell: inside some
/// tools and outside the rest), each picked by a point inside it.
BoundaryFill {
tools: Vec<BodyName>,
cells: Vec<Position>,
#[serde(default)]
operation: Operation,
/// Remove the tool bodies afterwards.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
remove_tools: bool,
},
/// Remove bodies from the model (from here on in the timeline).
Remove {
bodies: Vec<BodyName>,
},
/// Surface bodies: planar patches filling sketch regions (`profiles` of `sketch`), or a
/// closed loop of a body's edges (`edges`: points on them, on `body`).
Patch {
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<SketchId>,
#[serde(default = "all_profiles")]
#[walk(profiles_of = sketch)]
profiles: ProfileSel,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
edges: Vec<Position>,
/// How full a fitted patch's middle is (`crown`, -0.9…3, default 0; a planar loop is an
/// exact plane whatever this says).
#[serde(default, skip_serializing_if = "is_zero")]
crown: f64,
/// How a patch on edges meets the faces beside its loop (G0 everywhere by default, and
/// then not saved).
#[serde(default, skip_serializing_if = "PatchContinuity::is_g0")]
continuity: PatchContinuity,
/// Points a patch on edges passes through.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
points: Vec<Position>,
},
/// Surface bodies sewn along shared edges into the first one (a solid when they close).
Stitch {
bodies: Vec<BodyName>,
#[serde(default = "zero_expr")]
#[walk(length = "Tolerance")]
tolerance: Expr,
},
/// Surface bodies thickened into solids along their normals (by half each way when
/// `symmetric`).
Thicken {
bodies: Vec<BodyName>,
#[walk(length = "Thickness")]
thickness: Expr,
#[serde(default)]
symmetric: bool,
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<BodyName>,
},
/// A surface body swept straight along the sketch normal from open curve chains and the
/// boundaries of profiles: walls only, no caps.
SurfaceExtrude {
sketch: SketchId,
/// Profiles whose boundary loops are swept (none: only `curves`).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(profiles_of = sketch)]
profiles: Option<ProfileSel>,
/// Sketch curve ids, grouped into chains of curves that meet end to end.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
curves: Vec<String>,
extent: Extent,
},
/// A body taken apart: each face at `faces` (every face when empty) its own surface body,
/// the faces left one surface body under the body's name. A solid comes apart into surfaces.
Unstitch {
body: BodyName,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
faces: Vec<Position>,
},
/// A surface body with the faces at `faces` (every face when empty) turned over.
ReverseNormal {
body: BodyName,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
faces: Vec<Position>,
},
/// The face at `face` restored towards its surface: the inner loops at the `holes` points
/// taken away (every one when `None`; none when empty), and with `outer` its outer boundary
/// back at the surface's natural bounds (an analytic surface's: the face's own span). A new
/// surface body; with `in_place`, a surface body's face is untrimmed where it is.
SurfaceUntrim {
// The face is picked (and named); the holes are picked by points on their edges.
#[walk(picks_faces)]
face: FaceAt,
#[serde(default, skip_serializing_if = "Option::is_none")]
holes: Option<Vec<Position>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
outer: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
in_place: bool,
},
/// A surface body turned about an axis from open curve chains and the boundaries of
/// profiles: walls only, no caps (a solid where a full turn closes them).
SurfaceRevolve {
sketch: SketchId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(profiles_of = sketch)]
profiles: Option<ProfileSel>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
curves: Vec<String>,
axis: AxisRef,
#[walk(angle = "Angle")]
angle: Expr,
/// Two Sides: the angle on the other side.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Angle 2")]
angle2: Option<Expr>,
/// Symmetric: `angle` split evenly either side of the sketch.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
symmetric: bool,
},
/// A surface body swept along a chain of sketch curves from open curve chains and the
/// boundaries of profiles: walls only, no caps.
SurfaceSweep {
sketch: SketchId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(profiles_of = sketch)]
profiles: Option<ProfileSel>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
curves: Vec<String>,
path_sketch: SketchId,
path: Vec<String>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
/// Angle the profile turns about the path over its length.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Twist")]
twist: Option<Expr>,
/// The profile's scale at the end of the path (linear along its length).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<Expr>,
},
/// A surface body lofted through sections in order, each one open or closed chain of
/// sketch curves: walls only, no caps.
SurfaceLoft {
sections: Vec<SheetSection>,
},
/// The faces at `faces` copied out at a signed `distance` along their normals as a new
/// surface body (0 copies them); `chain` takes each pick's whole smooth chain of faces;
/// fitted faces within `tolerance`.
SurfaceOffset {
faces: Vec<FacePick>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
#[walk(length = "Distance")]
distance: Expr,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
chain: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Tolerance")]
tolerance: Option<Expr>,
},
/// A strip of ruled surface `distance` wide hanging off the chain of `body`'s edges through
/// `edges`, as a new surface body: along `mode` (`angle` from `direction`, or from the
/// face's normal; along `direction`), from the face at `face` where it borders the chain;
/// `flip` puts it on the other side; sharp corners as `corners` says.
SurfaceRuled {
body: BodyName,
edges: Vec<Position>,
#[walk(length = "Distance")]
distance: Expr,
#[serde(default, skip_serializing_if = "RuledMode::is_normal")]
mode: RuledMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Angle")]
angle: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
direction: Option<Dir>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
#[serde(default, skip_serializing_if = "RuledCorners::is_mitre")]
corners: RuledCorners,
#[serde(default, skip_serializing_if = "Option::is_none")]
face: Option<FacePick>,
},
/// A surface body's boundary edges through `edges` extended outward: by `distance`, or up
/// to `up_to`; the surface continued (`mode` natural) or a run-off face along each edge
/// (tangent, curvature). `neighbours`: the edges next to an extended edge are lengthened to
/// meet it (else the extension's sides run square to the edge).
SurfaceExtend {
body: BodyName,
edges: Vec<Position>,
#[walk(length = "Distance")]
distance: Expr,
#[serde(default, skip_serializing_if = "ExtendMode::is_natural")]
mode: ExtendMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
up_to: Option<ExtendTo>,
#[serde(default = "yes", skip_serializing_if = "is_yes")]
neighbours: bool,
},
/// A blend surface bridging two picked edge chains of two bodies (`surface.blend`): each end
/// repeats the surface it leaves up to its requested continuity, with the fullness `bulge`.
SurfaceBlend {
from: BlendSide,
to: BlendSide,
/// How much of each support surface the blend repeats: [from, to].
#[serde(default = "blend_orders")]
continuity: [Continuity; 2],
/// How full each end leaves: [from, to]; 0 is the plain bridge.
#[serde(default)]
bulge: [f64; 2],
/// Points the blend leaves towards, when the faces at the picks do not say: [from, to].
#[serde(default, skip_serializing_if = "Option::is_none")]
align: Option<[Position; 2]>,
/// Sew the blend onto its two bodies where the boundaries meet.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
stitch: bool,
},
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
/// `keep`.
SurfaceTrim {
body: BodyName,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
keep: Position,
},
Move {
bodies: Vec<BodyName>,
#[walk(length = ["X distance", "Y distance", "Z distance"])]
translate: [Expr; 3],
#[serde(default, skip_serializing_if = "Option::is_none")]
rotate_axis: Option<Dir>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Angle")]
angle: Option<Expr>,
/// When set, the original bodies are kept and the transform is applied to a new copy
/// of each instead of moving them in place.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
copy: bool,
},
/// A non-parametric base feature: bodies imported from a STEP file. The STEP text is kept
/// in the design (it is the B-rep), so the design reopens without the original file.
Import {
/// Source file name (display only).
file: String,
/// The STEP (ISO 10303-21) text, shared by every copy of the design (undo states,
/// previews, scratch sessions): a large file is held once, not once per edit.
step: std::sync::Arc<str>,
/// The file's product/assembly tree, kept for components.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
components: Vec<solidcraft_kernel::StepNode>,
/// The file's bodies (by index) this feature does not build: later instances of a part
/// the file places more than once, shown by the other occurrences of that part's
/// component (#1386). Empty in designs from before format 22: every body is built.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
skip: Vec<u64>,
},
/// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces.
MeshConvert {
body: BodyName,
#[walk(length = "Tolerance")]
tolerance: Expr,
},
/// Mesh bodies imported from a 3MF or STL file, embedded in the design.
MeshImport {
/// Source file name (display only).
file: String,
meshes: Vec<MeshData>,
},
}
/// How Extend continues a surface past its edges.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtendMode {
/// The surface itself, continued.
#[default]
Natural,
/// A ruled run-off in the surface's tangent plane (G1).
Tangent,
/// A run-off that keeps the surface's curvature across the edge (G2).
Curvature,
}
impl ExtendMode {
pub fn is_natural(&self) -> bool {
*self == ExtendMode::Natural
}
}
/// What Extend extends up to instead of a distance.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(rename_all = "snake_case")]
pub enum ExtendTo {
/// A plane.
Plane(PlaneRef),
/// The surface of a body's face (extended).
Face(FaceAt),
/// The surface's own bounds (natural only): a trim undone.
Bounds,
}
/// How a fillet's size is given.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FilletStyle {
#[default]
Constant,
/// `radius` is the chord: the width across the blend.
Chord,
/// G2 quintic blend of planar faces along a straight edge. `radius` sets the
/// equivalent circular setback, not a constant curvature radius.
Curvature,
/// `radius` at the end of each edge nearest `start`, `radius2` at the other; `mid` radii at
/// positions between (0 to 1 from the start end); straight between them, or `smooth`.
Variable {
#[walk(length = "End radius")]
radius2: Expr,
start: Position,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[walk(length = "Mid radius")]
mid: Vec<(f64, Expr)>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
smooth: bool,
},
/// The radius along each edge as a clamped B-spline of the position from the end nearest
/// `start` (0) to the other (1): `radius` is its first control radius and `radii` the rest,
/// its degree what `knots` leave (knots = control radii + degree + 1).
Spline {
start: Position,
knots: Vec<f64>,
#[walk(length = |i: usize, n: usize| if i + 1 == n { "End radius" } else { "Control radius" })]
radii: Vec<Expr>,
},
}
/// A fillet radius too large for the geometry around its edges: refused (`Exact`, the default),
/// reduced to the largest that works for all of them (`Reduce`), or tapered along each edge it
/// doesn't fit to the room there, down to zero where a face closes to a point (`Taper`); a
/// warning says what was done.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FilletFit {
#[default]
Exact,
Reduce,
Taper,
}
impl FilletFit {
pub fn is_exact(&self) -> bool {
*self == FilletFit::Exact
}
}
impl FilletStyle {
pub fn is_constant(&self) -> bool {
*self == FilletStyle::Constant
}
/// What the feature's `radius` is in this style (the label of its parameter).
pub fn radius_label(&self) -> &'static str {
match self {
FilletStyle::Constant => "Radius",
FilletStyle::Curvature => "Equivalent radius",
FilletStyle::Chord => "Chord length",
FilletStyle::Variable { .. } | FilletStyle::Spline { .. } => "Start radius",
}
}
/// The spline of a [`FilletStyle::Spline`] with its control radii `values` (the first is
/// the feature's `radius`).
pub fn spline(knots: &[f64], values: Vec<f64>) -> solidcraft_kernel::RadiusSpline {
let degree = knots.len().saturating_sub(values.len() + 1);
solidcraft_kernel::RadiusSpline { degree, knots: knots.to_vec(), radii: values }
}
}
/// A face picked by a point on it, on a named body.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct FaceAt {
pub body: BodyName,
pub point: Position,
}
/// One end of a blend: a body and the points that pick the edges of its boundary chain. With two
/// picks on the same edge the chain is that one edge; more pick a chain that runs on from pick to
/// pick (the order of the picks does not matter).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct BlendSide {
pub body: BodyName,
pub edges: Vec<Position>,
}
/// How a patch on a loop of edges meets the faces beside the loop: every edge with `all`, but the
/// edges picked in `edges` with their own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct PatchContinuity {
#[serde(default = "patch_g0", skip_serializing_if = "is_g0")]
pub all: Continuity,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<EdgeContinuity>,
}
/// One edge of a patch's loop (a point on it) and how the patch meets the face beside it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct EdgeContinuity {
pub edge: Position,
pub g: Continuity,
}
impl Default for PatchContinuity {
fn default() -> PatchContinuity {
PatchContinuity { all: Continuity::G0, edges: Vec::new() }
}
}
impl PatchContinuity {
/// G0 everywhere: the patch only meets its edges.
pub fn is_g0(&self) -> bool {
self.all == Continuity::G0 && self.edges.iter().all(|e| e.g == Continuity::G0)
}
}
fn patch_g0() -> Continuity {
Continuity::G0
}
fn is_g0(g: &Continuity) -> bool {
*g == Continuity::G0
}
/// The continuity a blend repeats by default: the tangent plane on both sides.
fn blend_orders() -> [Continuity; 2] {
[Continuity::G1, Continuity::G1]
}
/// Triangles of one imported mesh body (millimetres).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
pub name: String,
/// x, y, z per vertex.
pub positions: Vec<f64>,
/// Three vertex indices per triangle.
pub triangles: Vec<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<[f32; 3]>,
}
/// Points of a sketch, by id.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct SketchPoints {
pub sketch: SketchId,
pub ids: Vec<String>,
/// Drill the other way: away from the side the holes would go into by themselves.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub flip: bool,
}
/// A component: a node of the design tree that owns sketches, features and bodies. The root
/// (id 0) is the design itself and is not stored.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Component {
pub id: u64,
pub name: String,
/// Parent component (0 = the root).
#[serde(default)]
pub parent: u64,
}
/// One loft section: profiles of a sketch.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct LoftSection {
pub sketch: SketchId,
#[walk(profiles_of = sketch, when = point.is_none())]
pub profiles: ProfileSel,
/// A sketch point instead of a profile (the loft closes to it, as a pyramid's apex).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point: Option<String>,
}
/// A section of a surface loft: one chain from a sketch (one profile's boundary, or curves
/// meeting end to end).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct SheetSection {
pub sketch: SketchId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(profiles_of = sketch)]
pub profiles: Option<ProfileSel>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub curves: Vec<String>,
}
/// Coil cross-sections.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoilSection {
#[default]
Circular,
Square,
}
fn is_zero_i8(v: &i8) -> bool {
*v == 0
}
/// A parameter that is left out when it is 0 (an optional shape knob).
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
/// Hole shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HoleKind {
/// Plain hole; blind holes get a drill point when `tip_angle` is set.
#[default]
Simple,
Drilled {
#[walk(angle = "Tip angle")]
tip_angle: Expr,
},
Counterbore {
#[walk(length = "Counterbore diameter")]
cb_diameter: Expr,
#[walk(length = "Counterbore depth")]
cb_depth: Expr,
},
Countersink {
#[walk(length = "Countersink diameter")]
cs_diameter: Expr,
#[walk(angle = "Countersink angle")]
cs_angle: Expr,
},
}
/// Pattern layouts.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PatternKind {
Rectangular {
dir1: Dir,
#[walk(unitless = "Quantity")]
count1: Expr,
#[walk(length = "Spacing")]
spacing1: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
dir2: Option<Dir>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Quantity 2")]
count2: Option<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Spacing 2")]
spacing2: Option<Expr>,
},
Circular {
origin: Position,
axis: Dir,
#[walk(unitless = "Quantity")]
count: Expr,
/// Total angle (360 deg = evenly around).
#[walk(angle = "Total angle")]
angle: Expr,
},
/// Copies along a chain of sketch curves: instance k moves by path(k·spacing) − path(0),
/// measured along the path from its start.
Path {
path_sketch: SketchId,
path: Vec<String>,
#[walk(unitless = "Quantity")]
count: Expr,
/// Distance along the path between instances, or the whole extent when `extent`.
#[walk(length = "Distance")]
spacing: Expr,
/// `spacing` is the distance from the first to the last instance.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
extent: bool,
/// Run from the path's other end.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
/// Turn copies with the path's direction (else they keep their orientation).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
orient: bool,
},
}
fn z_axis() -> Dir {
Dir(Vec3::Z)
}
/// A point whose coordinates are length expressions (`["basin_width / 2", "10 mm", "0"]`), so
/// it follows parameter changes. Primitives are placed by one.
pub type PointExpr = [Expr; 3];
/// A point as expressions (plain numbers, mm).
pub fn point_expr(p: Vec3) -> PointExpr {
[Expr::new(format!("{}", p.x)), Expr::new(format!("{}", p.y)), Expr::new(format!("{}", p.z))]
}
/// Stored as `[x, y, z]`: plain numbers as numbers (as points were before they took
/// expressions, so such designs read and write the same), anything else as a string.
mod point_expr {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Coord {
Num(f64),
Expr(String),
}
pub fn serialize<S: Serializer>(p: &super::PointExpr, s: S) -> Result<S::Ok, S::Error> {
let c = |e: &crate::Expr| match e.parse::<f64>() {
Ok(v) if v.is_finite() && format!("{v}") == e.as_str() => Coord::Num(v),
_ => Coord::Expr(e.to_string()),
};
[c(&p[0]), c(&p[1]), c(&p[2])].serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<super::PointExpr, D::Error> {
let c = |x: Coord| match x {
Coord::Num(v) => crate::Expr::new(format!("{v}")),
Coord::Expr(e) => crate::Expr::new(e),
};
let [x, y, z] = <[Coord; 3]>::deserialize(d)?;
Ok([c(x), c(y), c(z)])
}
}
impl FeatureKind {
/// A sheet metal feature (its result depends on the sheet metal rules).
pub fn is_plastic(&self) -> bool {
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
}
pub fn is_sheet(&self) -> bool {
matches!(
self,
FeatureKind::SheetBase { .. }
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetFlange { .. }
| FeatureKind::SheetHem { .. }
| FeatureKind::SheetPerforate { .. }
| FeatureKind::SheetFold { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. }
)
}
/// Fusion-like type name (timeline tooltip, `inspect`).
pub fn type_name(&self) -> &'static str {
match self {
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "ExtrudeFeature",
FeatureKind::Revolve { .. } => "RevolveFeature",
FeatureKind::Fillet { .. } => "FilletFeature",
FeatureKind::Chamfer { .. } => "ChamferFeature",
FeatureKind::Box { .. } => "BoxFeature",
FeatureKind::Cylinder { .. } => "CylinderFeature",
FeatureKind::Sphere { .. } => "SphereFeature",
FeatureKind::Torus { .. } => "TorusFeature",
FeatureKind::Combine { .. } => "CombineFeature",
FeatureKind::Pattern { pattern: PatternKind::Rectangular { .. }, .. } => "RectangularPatternFeature",
FeatureKind::Pattern { pattern: PatternKind::Path { .. }, .. } => "PathPatternFeature",
FeatureKind::Pattern { .. } => "CircularPatternFeature",
FeatureKind::Mirror { .. } => "MirrorFeature",
FeatureKind::ConstructionPlane { .. } => "ConstructionPlane",
FeatureKind::Hole { .. } => "HoleFeature",
FeatureKind::Thread { .. } => "ThreadFeature",
FeatureKind::Loft { .. } => "LoftFeature",
FeatureKind::Sweep { .. } => "SweepFeature",
FeatureKind::Shell { .. } => "ShellFeature",
FeatureKind::Draft { .. } => "DraftFeature",
FeatureKind::Split { .. } => "SplitBodyFeature",
FeatureKind::SplitFace { .. } => "SplitFaceFeature",
FeatureKind::ConstructionAxis { .. } => "ConstructionAxis",
FeatureKind::ConstructionPoint { .. } => "ConstructionPoint",
FeatureKind::Move { .. } => "MoveFeature",
FeatureKind::Scale { .. } => "ScaleFeature",
FeatureKind::OffsetFace { .. } => "OffsetFacesFeature",
FeatureKind::DeleteFace { .. } => "DeleteFaceFeature",
FeatureKind::SurfaceOffset { .. } => "OffsetSurfaceFeature",
FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature",
FeatureKind::Pipe { .. } => "PipeFeature",
FeatureKind::Emboss { .. } => "EmbossFeature",
FeatureKind::Boss { .. } => "BossFeature",
FeatureKind::Lip { groove: false, .. } => "LipFeature",
FeatureKind::Lip { .. } => "GrooveFeature",
FeatureKind::SnapFit { .. } => "SnapFitFeature",
FeatureKind::Rest { .. } => "RestFeature",
FeatureKind::BoundaryFill { .. } => "BoundaryFillFeature",
FeatureKind::SheetBase { .. } => "BaseFlangeFeature",
FeatureKind::SheetContour { .. } => "ContourFlangeFeature",
FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature",
FeatureKind::SheetHem { .. } => "HemFeature",
FeatureKind::SheetPerforate { .. } => "PerforateFeature",
FeatureKind::SheetFold { .. } => "FoldFeature",
FeatureKind::SheetUnfold { refold: false, .. } => "UnfoldFeature",
FeatureKind::SheetUnfold { .. } => "RefoldFeature",
FeatureKind::SheetConvert { .. } => "ConvertToSheetMetalFeature",
FeatureKind::Coil { .. } => "CoilFeature",
FeatureKind::Rib { web: false, .. } => "RibFeature",
FeatureKind::Rib { .. } => "WebFeature",
FeatureKind::ReplaceFace { .. } => "ReplaceFaceFeature",
FeatureKind::Align { .. } => "AlignFeature",
FeatureKind::Remove { .. } => "RemoveFeature",
FeatureKind::Patch { .. } => "PatchFeature",
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature",
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolveFeature",
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
FeatureKind::Thicken { .. } => "ThickenFeature",
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
FeatureKind::SurfaceRuled { .. } => "RuledSurfaceFeature",
FeatureKind::Unstitch { .. } => "UnstitchFeature",
FeatureKind::ReverseNormal { .. } => "ReverseNormalFeature",
FeatureKind::SurfaceUntrim { .. } => "UntrimFeature",
FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature",
FeatureKind::MeshConvert { .. } => "MeshConvertFeature",
}
}
/// Default name prefix (`Extrude` → `Extrude1`).
pub fn base_name(&self) -> &'static str {
match self {
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "Extrude",
FeatureKind::Revolve { .. } => "Revolve",
FeatureKind::Fillet { .. } => "Fillet",
FeatureKind::Chamfer { .. } => "Chamfer",
FeatureKind::Box { .. } => "Box",
FeatureKind::Cylinder { .. } => "Cylinder",
FeatureKind::Sphere { .. } => "Sphere",
FeatureKind::Torus { .. } => "Torus",
FeatureKind::Combine { .. } => "Combine",
FeatureKind::Pattern { pattern: PatternKind::Rectangular { .. }, .. } => "RectangularPattern",
FeatureKind::Pattern { pattern: PatternKind::Path { .. }, .. } => "PathPattern",
FeatureKind::Pattern { .. } => "CircularPattern",
FeatureKind::Mirror { .. } => "Mirror",
FeatureKind::ConstructionPlane { .. } => "Plane",
FeatureKind::Hole { .. } => "Hole",
FeatureKind::Thread { .. } => "Thread",
FeatureKind::Loft { .. } => "Loft",
FeatureKind::Sweep { .. } => "Sweep",
FeatureKind::Shell { .. } => "Shell",
FeatureKind::Draft { .. } => "Draft",
FeatureKind::Split { .. } => "Split",
FeatureKind::SplitFace { .. } => "SplitFace",
FeatureKind::ConstructionAxis { .. } => "Axis",
FeatureKind::ConstructionPoint { .. } => "Point",
FeatureKind::Move { .. } => "Move",
FeatureKind::Scale { .. } => "Scale",
FeatureKind::OffsetFace { .. } => "OffsetFace",
FeatureKind::DeleteFace { .. } => "DeleteFace",
FeatureKind::SurfaceOffset { .. } => "OffsetSurface",
FeatureKind::BoundingSolid { .. } => "BoundingSolid",
FeatureKind::Pipe { .. } => "Pipe",
FeatureKind::Emboss { .. } => "Emboss",
FeatureKind::Boss { .. } => "Boss",
FeatureKind::Lip { groove: false, .. } => "Lip",
FeatureKind::Lip { .. } => "Groove",
FeatureKind::SnapFit { .. } => "SnapFit",
FeatureKind::Rest { .. } => "Rest",
FeatureKind::BoundaryFill { .. } => "BoundaryFill",
FeatureKind::SheetBase { .. } => "BaseFlange",
FeatureKind::SheetContour { .. } => "ContourFlange",
FeatureKind::SheetFlange { .. } => "EdgeFlange",
FeatureKind::SheetHem { .. } => "Hem",
FeatureKind::SheetPerforate { .. } => "Perforate",
FeatureKind::SheetFold { .. } => "Fold",
FeatureKind::SheetUnfold { refold: false, .. } => "Unfold",
FeatureKind::SheetUnfold { .. } => "Refold",
FeatureKind::SheetConvert { .. } => "ConvertToSheetMetal",
FeatureKind::Coil { .. } => "Coil",
FeatureKind::Rib { web: false, .. } => "Rib",
FeatureKind::Rib { .. } => "Web",
FeatureKind::ReplaceFace { .. } => "ReplaceFace",
FeatureKind::Align { .. } => "Align",
FeatureKind::Remove { .. } => "Remove",
FeatureKind::Patch { .. } => "Patch",
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude",
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolve",
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
FeatureKind::SurfaceBlend { .. } => "Blend",
FeatureKind::Stitch { .. } => "Stitch",
FeatureKind::Thicken { .. } => "Thicken",
FeatureKind::SurfaceTrim { .. } => "Trim",
FeatureKind::SurfaceExtend { .. } => "Extend",
FeatureKind::SurfaceRuled { .. } => "RuledSurface",
FeatureKind::Unstitch { .. } => "Unstitch",
FeatureKind::ReverseNormal { .. } => "ReverseNormal",
FeatureKind::SurfaceUntrim { .. } => "Untrim",
FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh",
FeatureKind::MeshConvert { .. } => "ConvertMesh",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Feature {
pub id: u64,
pub name: String,
#[serde(default)]
pub suppressed: bool,
/// Names for the bodies this feature creates (in order); missing ones are numbered after the
/// first (`Ribs (4)`), or get `BodyN` when there is none (or see `shell_piece_order`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub body_names: Vec<String>,
/// Parameter names of the feature's inputs (see `FeatureKind::inputs`), e.g. `d3`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_names: Vec<String>,
/// The component the feature (and what it makes) belongs to; 0 = the root.
#[serde(default, skip_serializing_if = "is_root")]
pub component: u64,
/// For each picked edge (fillets, chamfers): where it sat in its body when picked, so it
/// is found again when earlier edits move or resize the body.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edge_refs: Vec<crate::EdgeRef>,
/// Persistent names of the picked edges (resolved first; see `naming`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edge_names: Vec<String>,
/// Persistent names of the faces at the feature's face points (`FeatureKind::face_points`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub face_names: Vec<String>,
/// Bodies a join/cut/intersect may change (any component); empty = the feature's own
/// component's bodies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub participants: Vec<String>,
/// The pieces this feature splits a body into take its body names in the kernel's shell order,
/// as builds before format 14 named them, instead of after their faces (`lineage::order_lumps`).
/// Set on the features of designs saved before then, whose stored body names mean that order.
/// Such features also name the bodies past their `body_names` `BodyN`, as those builds did,
/// where newer ones number them after the first name (`Ribs (4)`, #373).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub shell_piece_order: bool,
#[serde(flatten)]
pub kind: FeatureKind,
}
fn inside_str() -> String {
"inside".into()
}
fn all_profiles() -> ProfileSel {
ProfileSel::All
}
fn yes() -> bool {
true
}
fn is_yes(b: &bool) -> bool {
*b
}
fn zero_expr() -> Expr {
"0".into()
}
fn is_root(c: &u64) -> bool {
*c == 0
}
/// A standard part (fastener, pin, bearing) inserted as a component.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StandardPart {
pub family: String,
pub size: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub length: Option<f64>,
}
/// A component whose contents come from another design file (Insert Design): where that file
/// is, and the Import feature in the component that holds its bodies (Update Links replaces
/// them; Break Link keeps them and forgets the file).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DesignLink {
/// The design file: relative to this design's folder once this design is saved, else as
/// given (`/` separates folders).
pub path: String,
/// The Import feature holding the linked design's bodies.
pub feature: u64,
}
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
/// Named, renderer-independent setups. Device/job state is never saved here.
#[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")]
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
#[serde(default = "default_format")]
pub format: String,
/// The changes of `format` this design has while they are pending (no release has fixed
/// that number yet; `crate::format::pending_names`). Empty, and not saved, otherwise.
#[serde(default = "crate::format::pending_names", skip_serializing_if = "Vec::is_empty")]
pub format_pending: Vec<String>,
pub name: String,
#[serde(default = "default_units")]
pub units: String,
/// Design properties (project, author…) and other tools' data, kept as they are (#1015).
#[serde(default, skip_serializing_if = "crate::Metadata::is_empty")]
pub metadata: crate::Metadata,
pub params: Vec<Param>,
pub features: Vec<Feature>,
/// Timeline marker: features at or after this index are rolled back (not evaluated).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub marker: Option<usize>,
#[serde(default)]
pub next_id: u64,
/// Components below the root, as a tree (by parent).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<Component>,
/// Placements of components in their parents (each non-root component has at least one).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub occurrences: Vec<crate::Occurrence>,
/// Joints between occurrences, joint origins and motion links.
#[serde(default, skip_serializing_if = "crate::joints::Assembly::is_empty")]
pub assembly: crate::joints::Assembly,
/// Sheet metal rules.
#[serde(default, skip_serializing_if = "crate::sheet::SheetSettings::is_empty")]
pub sheet: crate::sheet::SheetSettings,
/// Plastic rules.
#[serde(default, skip_serializing_if = "crate::plastic::PlasticSettings::is_empty")]
pub plastic: crate::plastic::PlasticSettings,
/// Components that are standard parts (by component id): what to rebuild them from.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub parts: std::collections::BTreeMap<u64, StandardPart>,
/// Components linked to another design file (by component id).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub links: std::collections::BTreeMap<u64, DesignLink>,
/// Configurations (rows of parameter values and suppressions).
#[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")]
pub configs: crate::config::ConfigTable,
/// Favourite parameters (by name).
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub favorites: std::collections::BTreeSet<String>,
/// Reference images on planes (Insert Canvas).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub canvases: Vec<crate::canvas::Canvas>,
/// Comments of feature-input parameters (by name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub param_comments: std::collections::BTreeMap<String, String>,
/// Physical material per body (by body name); others use the default.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub materials: std::collections::BTreeMap<String, String>,
/// Physical materials defined in this design (`material.create`), next to the built-in ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub user_materials: Vec<UserMaterial>,
/// Appearance colour per body (by body name, sRGB); it overrides the material's look.
#[serde(default, skip_serializing_if = "crate::appearance::Appearances::is_empty")]
pub appearances: crate::appearance::Appearances,
/// Bodies moved into another component than their feature's (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_components: std::collections::BTreeMap<String, u64>,
/// Bodies moved into another component keep their place in the world: the transform from
/// the new component's frame to the frame the body was made in (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_offsets: std::collections::BTreeMap<String, crate::Mat>,
/// Browser groups (folders of bodies, sketches or construction planes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub browser_groups: Vec<BrowserGroup>,
/// Named folders used to organise features in the timeline. They do not affect evaluation
/// order; feature ids remain the source of truth when features are renamed or reordered.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub timeline_groups: Vec<TimelineGroup>,
/// Browser item order per component folder (`"<component>/<folder>"` → item keys).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub browser_order: std::collections::BTreeMap<String, Vec<String>>,
/// Named views: saved cameras.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub named_views: Vec<NamedView>,
/// What was hidden when the design was saved. A session takes it from here when the design
/// opens and keeps the live state itself (`browser.visibility`: saved, not an undo step), so
/// a document in a session leaves this empty; the session puts it back when it saves.
#[serde(default, skip_serializing_if = "Visibility::is_empty")]
pub visibility: Visibility,
}
/// Items hidden or shown one by one. Saved with the design, but not an undo step.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Visibility {
/// Bodies by name.
pub hidden_bodies: Vec<String>,
/// Exact occurrence placements hidden with all descendants.
pub hidden_occurrences: Vec<Vec<u64>>,
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
pub hidden_origin: Vec<String>,
/// Sketches by feature id.
pub hidden_sketches: Vec<u64>,
/// Finished sketches shown although a feature uses them.
pub shown_sketches: Vec<u64>,
/// Assembly joints by id: their glyphs are not drawn (they still constrain and move).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hidden_joints: Vec<u64>,
}
impl Visibility {
/// Nothing hidden or shown one by one (the default view).
pub fn is_empty(&self) -> bool {
self.hidden_bodies.is_empty()
&& self.hidden_occurrences.is_empty()
&& self.hidden_origin.is_empty()
&& self.hidden_sketches.is_empty()
&& self.shown_sketches.is_empty()
&& self.hidden_joints.is_empty()
}
}
/// A saved camera (orbit camera: target, yaw, pitch, distance; `fov` 0 for orthographic).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct NamedView {
#[serde(default)]
pub name: String,
pub target: solidcraft_geom::Vec3,
pub yaw: f64,
pub pitch: f64,
pub distance: f64,
#[serde(default)]
pub fov: f64,
}
/// A browser group: items of one folder (`bodies`, `sketches` or `construction`) of a
/// component, by key (body name, or the feature id of a sketch or construction plane).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BrowserGroup {
pub id: u64,
pub name: String,
#[serde(default)]
pub component: u64,
pub folder: String,
#[serde(default)]
pub items: Vec<String>,
}
/// A saved feature-history folder. Folders may nest, and a feature can belong to at most one folder.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TimelineGroup {
pub id: u64,
pub name: String,
/// The containing feature folder. `None` places the folder at the timeline root.
#[serde(default)]
pub parent: Option<u64>,
#[serde(default)]
pub features: Vec<u64>,
}
/// The first feature-history folder whose own and nested features do not form one
/// uninterrupted run in the timeline.
pub fn non_contiguous_timeline_group(features: &[Feature], groups: &[TimelineGroup]) -> Option<u64> {
let positions: std::collections::BTreeMap<u64, usize> = features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
let mut stats = std::collections::BTreeMap::new();
let mut children = std::collections::BTreeMap::new();
for group in groups {
let mut count = 0usize;
let mut first = usize::MAX;
let mut last = 0usize;
for index in group.features.iter().filter_map(|feature| positions.get(feature)) {
count = count.saturating_add(1);
first = first.min(*index);
last = last.max(*index);
}
stats.insert(group.id, (count, first, last));
children.entry(group.id).or_insert(0usize);
if let Some(parent) = group.parent {
let child_count = children.entry(parent).or_insert(0);
*child_count = child_count.saturating_add(1);
}
}
let parents: std::collections::BTreeMap<u64, Option<u64>> = groups.iter().map(|group| (group.id, group.parent)).collect();
let mut pending: Vec<u64> = groups.iter().filter(|group| children.get(&group.id) == Some(&0)).map(|group| group.id).collect();
while let Some(id) = pending.pop() {
let (count, first, last) = stats.get(&id).copied().unwrap_or_default();
if count > 0 && last.saturating_sub(first).saturating_add(1) != count {
return Some(id);
}
if let Some(parent) = parents.get(&id).copied().flatten() {
if let Some((parent_count, parent_first, parent_last)) = stats.get_mut(&parent) {
*parent_count = parent_count.saturating_add(count);
if count > 0 {
*parent_first = (*parent_first).min(first);
*parent_last = (*parent_last).max(last);
}
}
if let Some(remaining) = children.get_mut(&parent) {
*remaining = remaining.saturating_sub(1);
if *remaining == 0 {
pending.push(parent);
}
}
}
}
None
}
fn default_format() -> String {
crate::format::format_string(crate::format::FORMAT)
}
fn default_units() -> String {
"mm".into()
}
impl Default for Document {
fn default() -> Self {
Document::new("Untitled")
}
}
impl Document {
pub fn new(name: &str) -> Self {
Document {
render: Default::default(),
format: default_format(),
format_pending: crate::format::pending_names(),
name: name.into(),
units: default_units(),
metadata: Default::default(),
params: Vec::new(),
features: Vec::new(),
marker: None,
next_id: 1,
components: Vec::new(),
occurrences: Vec::new(),
assembly: Default::default(),
sheet: Default::default(),
plastic: Default::default(),
configs: Default::default(),
parts: Default::default(),
links: Default::default(),
body_components: Default::default(),
body_offsets: Default::default(),
materials: Default::default(),
user_materials: Vec::new(),
appearances: Default::default(),
favorites: Default::default(),
canvases: Vec::new(),
param_comments: Default::default(),
browser_groups: Vec::new(),
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
visibility: Visibility::default(),
}
}
/// A complete structural proof: no non-render field is allowed to differ. Keeping this
/// comparison based on the whole Document also includes fields added in future versions.
/// The clone/comparison is only needed when render intent actually differs.
pub fn differs_only_in_render(&self, previous: &Document) -> bool {
if std::sync::Arc::ptr_eq(&self.render, &previous.render) || self.render == previous.render || self.render.validate().is_err() {
return false;
}
let mut normalized = previous.clone();
normalized.render = self.render.clone();
*self == normalized
}
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its appearance,
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
self.model_body_look(b).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
}
/// The model with each body carrying the colour, opacity and face colours it is shown with
/// (for export).
pub fn painted(&self, state: &crate::ModelState) -> crate::ModelState {
let mut st = state.clone();
for b in &mut st.bodies {
let c = self.body_color(b);
let faces: Vec<solidcraft_kernel::FacePaint> = self
.face_colors(b)
.into_iter()
.map(|(face, l)| solidcraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 })
.collect();
let opacity = self.model_body_opacity(b) as f32;
let paint = solidcraft_kernel::Paint { opacity, faces };
if c != b.body.color() || b.body.paint() != Some(&paint) {
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
}
}
st
}
/// Density of a body's material (g/cm³).
pub fn density(&self, body: &str) -> f64 {
self.materials.get(body).and_then(|m| self.material_density(m)).unwrap_or(DEFAULT_DENSITY)
}
/// A material defined in this design, by name (case-insensitive).
pub fn user_material(&self, name: &str) -> Option<&UserMaterial> {
let name = name.trim();
self.user_materials.iter().find(|m| m.name.eq_ignore_ascii_case(name))
}
/// A physical material's name as the library or this design spells it (case-insensitive
/// lookup): built-in materials first, then the design's own.
pub fn material_name(&self, name: &str) -> Option<String> {
MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name.trim()))
.map(|(n, _)| n.to_string())
.or_else(|| self.user_material(name).map(|m| m.name.clone()))
}
/// Density of a physical material (g/cm³): built-in or defined in this design. A design
/// material with an unusable density (a damaged file) has none.
pub fn material_density(&self, name: &str) -> Option<f64> {
material_density(name).or_else(|| self.user_material(name).map(|m| m.density).filter(|d| valid_density(*d)))
}
/// The look a physical material gives a body without an appearance of its own.
pub fn material_look(&self, name: &str) -> Option<crate::appearance::Look> {
crate::appearance::material_look(name).or_else(|| {
let mut look = self.user_material(name)?.appearance.clone()?;
// A damaged file's opacity is shown opaque rather than trusted.
if !(look.opacity.is_finite() && (0.0..=1.0).contains(&look.opacity)) {
look.opacity = 1.0;
}
Some(look)
})
}
/// Every physical material's name: the built-in library, then the design's own.
pub fn material_names(&self) -> Vec<String> {
MATERIALS.iter().map(|(n, _)| n.to_string()).chain(self.user_materials.iter().map(|m| m.name.clone())).collect()
}
/// The component a body belongs to: where it was moved, else its feature's.
pub fn body_component(&self, body: &str, feature: u64) -> u64 {
self.features
.iter()
.take(self.marker.unwrap_or(usize::MAX))
.rev()
.find_map(|f| {
if f.suppressed {
return None;
}
match &f.kind {
FeatureKind::BodyTransfer { bodies, to, .. } if bodies.iter().any(|b| b == body) => Some(*to),
_ => None,
}
})
.unwrap_or_else(|| self.legacy_body_component(body, feature))
}
pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 {
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
}
/// Ownership in this evaluated timeline state, rather than a later transfer.
pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 {
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
}
/// Recorded transfers already converted geometry into the new local frame.
pub fn model_body_offset(&self, body: &crate::ModelBody) -> Option<&crate::Mat> {
if body.transferred { None } else { self.body_offsets.get(&body.name) }
}
/// Add a component under `parent` and return its id.
pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result<u64> {
if parent != 0 && !self.components.iter().any(|c| c.id == parent) {
return Err(DocError::Unknown(format!("component {parent}")));
}
if self.components.len() >= 10_000 {
return Err(DocError::Invalid("too many components".into()));
}
let id = self.next_id.max(1);
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
let name = match name.map(str::trim) {
Some(n) if !n.is_empty() => n.to_string(),
_ => {
let mut k = self.components.len() + 1;
while self.components.iter().any(|c| c.name == format!("Component{k}")) {
k += 1;
}
format!("Component{k}")
}
};
self.components.push(Component { id, name, parent });
self.add_occurrence(id, parent, crate::IDENTITY)?;
Ok(id)
}
/// A component by id or name (0 / "root" is the design itself).
pub fn find_component(&self, key: &str) -> Option<u64> {
if key == "0" || key.eq_ignore_ascii_case("root") || key == self.name {
return Some(0);
}
self.components.iter().find(|c| c.id.to_string() == key || c.name == key).map(|c| c.id)
}
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
pub fn from_json(s: &str) -> Result<Document> {
let d = crate::format::read(s)?;
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
return Err(DocError::Invalid("document too large".into()));
}
Ok(d)
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
pub fn feature(&self, id: u64) -> Option<&Feature> {
self.features.iter().find(|f| f.id == id)
}
pub fn feature_mut(&mut self, id: u64) -> Option<&mut Feature> {
self.features.iter_mut().find(|f| f.id == id)
}
pub fn feature_index(&self, id: u64) -> Option<usize> {
self.features.iter().position(|f| f.id == id)
}
/// Find a feature by id (number) or name.
pub fn find_feature(&self, r: &str) -> Option<&Feature> {
if let Ok(id) = r.parse::<u64>()
&& let Some(f) = self.feature(id)
{
return Some(f);
}
self.features.iter().find(|f| f.name.eq_ignore_ascii_case(r))
}
fn unique_name(&self, base: &str) -> String {
let mut n = 1;
loop {
let name = format!("{base}{n}");
if !self.features.iter().any(|f| f.name == name) {
return name;
}
n += 1;
}
}
/// The deepest timeline folder spanning the insertion slot between `marker - 1` and
/// `marker`. A slot at a folder's outer boundary is not inside that folder.
fn timeline_group_at_insertion(&self, marker: usize) -> Option<u64> {
let before = self.features.get(marker.checked_sub(1)?)?.id;
let after = self.features.get(marker)?.id;
let direct = |feature| self.timeline_groups.iter().find(|group| group.features.contains(&feature)).map(|group| group.id);
let parents: std::collections::BTreeMap<u64, Option<u64>> = self.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
let mut before_ancestors = std::collections::BTreeSet::new();
let mut current = direct(before);
let mut steps = 0usize;
while let Some(id) = current {
if !before_ancestors.insert(id) || steps >= self.timeline_groups.len() {
return None;
}
current = parents.get(&id).copied().flatten();
steps = steps.saturating_add(1);
}
current = direct(after);
steps = 0;
while let Some(id) = current {
if before_ancestors.contains(&id) {
return Some(id);
}
if steps >= self.timeline_groups.len() {
return None;
}
current = parents.get(&id).copied().flatten();
steps = steps.saturating_add(1);
}
None
}
/// Insert a feature at the marker (or the end) and return its id.
pub fn add_feature(&mut self, kind: FeatureKind, name: Option<&str>) -> Result<u64> {
if self.features.len() >= MAX_FEATURES {
return Err(DocError::Invalid("too many features".into()));
}
let component = if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &kind {
crate::validate_body_transfer(self, &crate::plain(bodies), *from, *to, transform)?;
*to
} else {
0
};
let id = self.next_id.max(1);
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
let name = match name {
Some(n) if !n.trim().is_empty() => n.trim().to_string(),
_ => self.unique_name(kind.base_name()),
};
let f = Feature {
id,
name,
suppressed: false,
body_names: Vec::new(),
param_names: Vec::new(),
component,
edge_refs: Vec::new(),
edge_names: Vec::new(),
face_names: Vec::new(),
participants: Vec::new(),
shell_piece_order: false,
kind,
};
self.place_feature(f);
Ok(id)
}
/// Insert a feature as given, keeping its id, name, parameter names and stored edge and
/// face names, at the marker (or the end), with the model parameters its sketch dimensions
/// use: a feature replayed from a script (`timeline.add`) is the feature the design had.
/// An id of 0 or an empty name gets a new one. Refused, changing nothing, when the id, the
/// name or a parameter name is already used, or the feature belongs to a component this
/// design does not have.
pub fn insert_feature(&mut self, mut f: Feature, params: Vec<Param>) -> Result<u64> {
if self.features.len() >= MAX_FEATURES {
return Err(DocError::Invalid("too many features".into()));
}
if f.id == 0 {
f.id = self.next_id.max(1);
} else if f.id > MAX_FEATURE_ID {
return Err(DocError::Invalid(format!("feature id {} is out of range", f.id)));
} else if self.feature(f.id).is_some() {
return Err(DocError::Invalid(format!("feature id {} is already used", f.id)));
}
f.name = f.name.trim().to_string();
if f.name.is_empty() {
f.name = self.unique_name(f.kind.base_name());
} else if self.features.iter().any(|x| x.name == f.name) {
return Err(DocError::Invalid(format!("a feature named `{}` already exists", f.name)));
}
if f.component != 0 && !self.components.iter().any(|c| c.id == f.component) {
return Err(DocError::Invalid(format!("feature `{}` belongs to an unknown component", f.name)));
}
if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &f.kind {
crate::validate_body_transfer(self, &crate::plain(bodies), *from, *to, transform)?;
if f.component != *to {
return Err(DocError::Invalid("a body transfer belongs to its destination component".into()));
}
}
let mut own = std::collections::BTreeSet::new();
for n in f.param_names.iter().filter(|n| !n.is_empty()) {
if let Some(why) = Self::param_name_problem(n) {
return Err(DocError::Invalid(why));
}
if self.name_taken(n) || !own.insert(n) {
return Err(DocError::Invalid(format!("parameter name `{n}` is already used")));
}
}
if self.params.len().saturating_add(params.len()) > MAX_PARAMS {
return Err(DocError::Invalid("too many parameters".into()));
}
for p in &params {
if let Some(why) = Self::param_name_problem(&p.name) {
return Err(DocError::Invalid(why));
}
if self.name_taken(&p.name) || !own.insert(&p.name) {
return Err(DocError::Invalid(format!("parameter name `{}` is already used", p.name)));
}
if p.expr.len() > 4096 || p.comment.len() > 4096 {
return Err(DocError::Expr(format!("parameter `{}`: expression or comment too long", p.name)));
}
if !p.unit.is_empty() && expr::unit_info(&p.unit).is_none() {
return Err(DocError::Invalid(format!("parameter `{}`: unknown unit `{}`", p.name, p.unit)));
}
}
let id = f.id;
self.next_id = self.next_id.max(id.saturating_add(1));
self.params.extend(params.into_iter().map(|p| Param { model: true, ..p }));
self.place_feature(f);
Ok(id)
}
/// Put a feature at the marker (or the end), in the timeline folder that spans that slot,
/// and name its inputs.
fn place_feature(&mut self, f: Feature) {
let id = f.id;
let insertion_group = self.marker.and_then(|marker| self.timeline_group_at_insertion(marker));
match self.marker {
Some(m) if m < self.features.len() => {
self.features.insert(m, f);
self.marker = Some(m + 1);
}
_ => self.features.push(f),
}
if let Some(group_id) = insertion_group {
let positions: std::collections::BTreeMap<u64, usize> =
self.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
if let Some(group) = self.timeline_groups.iter_mut().find(|group| group.id == group_id) {
group.features.push(id);
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
}
}
self.name_feature_inputs(id);
}
/// Delete a feature (and, for a sketch, the features that use it).
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
let mut gone = vec![id];
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
let gone_params: Vec<String> = self.features.iter().filter(|f| gone.contains(&f.id)).flat_map(|f| f.param_names.clone()).collect();
self.configs
.rules
.retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
self.features.retain(|f| !gone.contains(&f.id));
for group in &mut self.timeline_groups {
group.features.retain(|feature| !gone.contains(feature));
}
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self
.configs
.columns
.iter()
.map(|c| match c {
crate::config::Column::Suppress(id) => !gone.contains(id),
crate::config::Column::Param(name) => !gone_params.contains(name),
crate::config::Column::FaceAppearance(target) => !gone.contains(&target.origin_feature),
_ => true,
})
.collect();
self.appearances
.faces
.retain(|face| !face.strict_name || !face.name.as_ref().is_some_and(|name| gone.iter().any(|id| name.starts_with(&format!("F{id}:")))));
if keep.contains(&false) {
let mut k = keep.iter();
self.configs.columns.retain(|_| k.next().copied().unwrap_or(true));
for r in &mut self.configs.rows {
let mut k = keep.iter();
r.cells.retain(|_| k.next().copied().unwrap_or(true));
}
}
if let Some(m) = self.marker
&& m > idx
{
self.marker = Some(m.saturating_sub(gone.len()).max(idx));
}
// Drop model parameters nobody references any more.
self.prune_model_params();
Ok(gone)
}
/// Remove model parameters not referenced by any dimension, feature or other parameter.
pub fn prune_model_params(&mut self) {
let used = self.referenced_names();
self.params.retain(|p| !p.model || used.contains(&p.name));
}
fn referenced_names(&self) -> Vec<String> {
let mut used: Vec<String> = Vec::new();
for rule in &self.configs.rules {
used.extend(rule.parameter_references());
}
for (index, column) in self.configs.columns.iter().enumerate() {
if let crate::config::Column::Param(name) = column {
used.push(name.clone());
for row in &self.configs.rows {
if let Some(value) = row.cells.get(index) {
used.extend(expr::references(value));
}
}
}
}
for f in &self.features {
for e in f.kind.expressions() {
used.extend(expr::references(e));
}
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
used.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
used.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
}
}
for p in &self.params {
used.extend(expr::references(&p.expr));
}
used
}
pub fn param(&self, name: &str) -> Option<&Param> {
self.params.iter().find(|p| p.name == name)
}
/// Why a name cannot be a parameter name (None = fine).
pub fn param_name_problem(name: &str) -> Option<String> {
let mut cs = name.chars();
if !matches!(cs.next(), Some(c) if c.is_alphabetic() || c == '_') || !cs.all(|c| c.is_alphanumeric() || c == '_') {
return Some(format!("`{name}` is not a valid parameter name (letters, digits and _, starting with a letter)"));
}
if name.len() > 64 {
return Some("parameter names are at most 64 characters".into());
}
if expr::is_reserved(name) {
return Some(format!("`{name}` is a unit, constant or function name"));
}
None
}
/// Add or change a parameter. Fails if the result would not evaluate. A new parameter
/// without a unit takes the unit of its value (mm, deg or none).
pub fn set_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
if let Some(p) = Self::param_name_problem(name) {
return Err(DocError::Invalid(p));
}
if expr_s.len() > 4096 {
return Err(DocError::Expr("expression too long".into()));
}
if expr_s.trim().is_empty() {
return Err(DocError::Expr("empty expression".into()));
}
if let Some(u) = unit
&& expr::unit_info(u).is_none()
{
return Err(DocError::Expr(format!("unknown unit `{u}` (mm, cm, m, in, ft, deg, rad or none)")));
}
if comment.is_some_and(|c| c.len() > 4096) {
return Err(DocError::Invalid("comment too long".into()));
}
if self.features.iter().any(|f| f.param_names.iter().any(|n| n == name)) {
return Err(DocError::Invalid(format!("`{name}` is a feature's parameter")));
}
let mut next = self.clone();
let inferred = match unit {
Some(u) => u.trim().to_string(),
None => next.infer_unit(expr_s),
};
match next.params.iter_mut().find(|p| p.name == name) {
Some(p) => {
p.expr = expr_s.trim().to_string();
if let Some(u) = unit {
p.unit = u.trim().to_string();
}
if let Some(c) = comment {
p.comment = c.to_string();
}
}
None => {
if next.params.len() >= MAX_PARAMS {
return Err(DocError::Invalid("too many parameters".into()));
}
next.params.push(Param {
name: name.into(),
expr: expr_s.trim().into(),
unit: inferred,
comment: comment.unwrap_or_default().into(),
model: false,
})
}
}
let (_, errors) = next.param_values();
if let Some(e) = errors.get(name) {
return Err(DocError::Expr(e.clone()));
}
*self = next;
Ok(())
}
/// Create the next free model parameter `dN` with the given expression and unit.
pub fn new_model_param(&mut self, expr_s: &str, unit: &str) -> String {
let name = self.next_d_name();
self.params.push(Param { name: name.clone(), expr: expr_s.into(), unit: unit.into(), comment: String::new(), model: true });
name
}
/// Evaluate an expression against the document's parameters.
pub fn eval(&self, e: &str, kind: Kind) -> Result<f64> {
// The parameters are evaluated only when the expression names one: most expressions an
// agent sends are plain values, and evaluating every parameter of a large design for
// each of them made every new feature cost more than the last.
let vals: std::cell::OnceCell<std::collections::BTreeMap<String, expr::Value>> = std::cell::OnceCell::new();
expr::eval_with(e, &|n| {
vals.get_or_init(|| self.param_values().0).get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")))
})?
.to_kind(kind)
}
pub fn eval_in(vals: &std::collections::BTreeMap<String, expr::Value>, e: &str, kind: Kind) -> Result<f64> {
expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?.to_kind(kind)
}
/// Resolve a plane reference.
pub fn resolve_plane(&self, vals: &std::collections::BTreeMap<String, expr::Value>, p: &PlaneRef, depth: usize) -> Result<Plane> {
self.resolve_plane_in(vals, p, depth, None)
}
/// Resolve a plane; planes built from model geometry (edges, faces, construction axes and
/// points) need the model state `st`, and construction planes are read from it when given.
pub fn resolve_plane_in(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
p: &PlaneRef,
depth: usize,
st: Option<&crate::ModelState>,
) -> Result<Plane> {
if depth > 32 {
return Err(DocError::Invalid("plane offsets nested too deeply".into()));
}
match p {
PlaneRef::Origin { name } => Plane::named(name).ok_or_else(|| DocError::Unknown(format!("plane `{name}`"))),
PlaneRef::Custom { plane } | PlaneRef::Face { plane, .. } => {
Plane::new(plane.origin, plane.x, plane.y).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Placed { origin, x_dir, y_dir } => {
let [x, y, z] = origin;
let o =
Vec3::new(Self::eval_in(vals, x, Kind::Length)?, Self::eval_in(vals, y, Kind::Length)?, Self::eval_in(vals, z, Kind::Length)?);
if ![o.x, o.y, o.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) {
return Err(DocError::Invalid("the plane's origin is out of range".into()));
}
Plane::new(o, *x_dir, *y_dir).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Offset { base, distance } => {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?))
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
let a = Self::eval_in(vals, angle, Kind::Angle)?;
let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?;
let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos()));
let o = *axis_origin + rot(b.origin - *axis_origin);
Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Flipped { base, about_y } => Ok(self.resolve_plane_in(vals, base, depth + 1, st)?.flipped(*about_y)),
PlaneRef::Construction { name } => {
// Evaluated already: as it came out.
if let Some(c) = st.and_then(|st| st.construct.iter().find(|c| &c.name == name))
&& let crate::construct::ConstructGeom::Plane(pl) = &c.geom
{
return Ok(*pl);
}
match self.find_feature(name).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane_in(vals, plane, depth + 1, st),
_ => Err(DocError::Unknown(format!("construction plane `{name}`"))),
}
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::Tangent { .. }
| PlaneRef::AlongPath { .. }
| PlaneRef::Perpendicular { .. } => {
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st, depth }, p)
}
}
}
/// Mutable access to a sketch feature's sketch.
pub fn sketch_mut(&mut self, id: u64) -> Result<&mut Sketch> {
match self.feature_mut(id).map(|f| &mut f.kind) {
Some(FeatureKind::Sketch { sketch, .. }) => Ok(sketch),
Some(_) => Err(DocError::Invalid(format!("feature {id} is not a sketch"))),
None => Err(DocError::Unknown(format!("sketch {id}"))),
}
}
pub fn sketch(&self, id: u64) -> Result<&Sketch> {
match self.feature(id).map(|f| &f.kind) {
Some(FeatureKind::Sketch { sketch, .. }) => Ok(sketch),
Some(_) => Err(DocError::Invalid(format!("feature {id} is not a sketch"))),
None => Err(DocError::Unknown(format!("sketch {id}"))),
}
}
/// Apply parameter values to every dimension in a sketch (in place).
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
for c in &mut sk.constraints {
if let Some(p) = &c.param {
let kind = if c.kind.is_angle() { Kind::Angle } else { Kind::Length };
let v = vals.get(p).copied().ok_or_else(|| DocError::Unknown(format!("parameter `{p}`")))?.to_kind(kind)?;
c.kind.set_value(v);
}
}
for c in &sk.constraints {
sk.validate(&c.kind)?;
}
Ok(())
}
/// Give the texts of a sketch the numbers their expressions come to (in place): a text's
/// height, place and angle that were typed as expressions. A text's outlines are rebuilt from
/// its numbers when the sketch's links are refreshed, so this comes before that. A number
/// typed as a number is left as it is.
pub fn apply_text_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
use crate::eval::val;
for link in &mut sk.links {
let solidcraft_sketch::LinkSource::Text { at, height, angle, exprs, .. } = &mut link.source else { continue };
let number = |e: &Option<String>, kind: Kind, what: &str| -> Result<Option<f64>> {
match e {
None => Ok(None),
Some(e) => val(vals, e, kind).map(Some).map_err(|error| DocError::Expr(format!("{what}: {error}"))),
}
};
if let Some(v) = number(&exprs.x, Kind::Length, "text X")? {
at.x = v;
}
if let Some(v) = number(&exprs.y, Kind::Length, "text Y")? {
at.y = v;
}
if let Some(v) = number(&exprs.height, Kind::Length, "text height")? {
*height = v;
}
if let Some(v) = number(&exprs.angle, Kind::Angle, "text angle")? {
*angle = v;
}
if exprs.height.is_some() && !(*height > 1e-6 && *height < 1e5) {
return Err(DocError::Invalid("a text's height must be positive".into()));
}
}
Ok(())
}
/// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values
/// its geometry is solved for (the stored sketch's): what the solve cannot reach in one
/// jump it reaches by stepping the changed values from there, and an edit that would turn
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
/// from the stored state again after a refused or failed first solve (the geometry is still
/// the stored one), from the solved one otherwise.
///
/// `moved_from`: the stored sketch, when `sk` is that sketch with its links refreshed. A
/// link's points are where their source is now; the sides and turns the solve keeps are
/// those of the stored state ([`Sketch::positions_in`]), not the ones read beside a line
/// that has moved past a point.
pub fn solve_sketch(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
sk: &mut Sketch,
stored: &solidcraft_sketch::DrivingValues,
moved_from: Option<&Sketch>,
) -> Result<solidcraft_sketch::SolveReport> {
self.apply_dimension_values(vals, sk)?;
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
let mut report = solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref());
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref()) };
}
Ok(report)
}
}
/// Density of the default material (g/cm³), as Fusion's default.
#[path = "../../../assets/materials/library.rs"]
mod materials;
pub use materials::{DEFAULT_DENSITY, MATERIALS, material_color};
/// A physical material defined in a design (`material.create`): its name, its density and,
/// optionally, the look it gives bodies without an appearance of their own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UserMaterial {
pub name: String,
/// Density (g/cm³).
pub density: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub appearance: Option<crate::appearance::Look>,
}
/// Largest density a material may have (g/cm³); the densest elements are about 22.6.
pub const MAX_DENSITY: f64 = 1000.0;
/// A density a material can have: finite, above zero and at most [`MAX_DENSITY`] g/cm³.
pub fn valid_density(d: f64) -> bool {
d.is_finite() && d > 0.0 && d <= MAX_DENSITY
}
/// Density of a built-in physical material (g/cm³).
pub fn material_density(name: &str) -> Option<f64> {
MATERIALS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name.trim())).map(|(_, d)| *d)
}