app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688
//! The `swx_run_script` modeling DSL — parsed here, executed on the com host.
//!
//! Fusion's escape hatch (`fusion_run_modeling_script`) is a Python string
//! evaluated inside Fusion, because Fusion HAS a Python interpreter in-process.
//! SOLIDWORKS has VBA, which is a macro *file* format driven through a UI, not
//! an in-process eval — so a faithful copy of Fusion's verb is not available.
//!
//! What IS available, and what the verb actually has to do, is build real
//! geometry deterministically: the analysis verbs (mass properties, bodies,
//! BOM, export) are worthless without a body to point them at, and the live
//! test suite has to be able to MAKE one. So the escape hatch here is a small
//! typed DSL over the SOLIDWORKS calls proven to work on a live install:
//!
//! ```json
//! {"script": [
//! {"op": "new_part"},
//! {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 50, "heightMm": 30},
//! {"op": "extrude", "depthMm": 20},
//! {"op": "set_material", "name": "6061 Alloy"},
//! {"op": "set_prop", "name": "PartNo", "value": "BRK-100"},
//! {"op": "save_as", "path": "C:\\parts\\bracket.sldprt"}
//! ]}
//! ```
//!
//! Every dimension the caller writes is in MILLIMETRES (what a mechanical
//! engineer says out loud, and what `swx_inspect_bodies` reports back);
//! SOLIDWORKS' API is metres, and the conversion happens exactly once, here.
//!
//! Parsing lives on the server side, outside `cfg(windows)`, so a malformed
//! script is refused with a precise message BEFORE anything touches the live
//! CAD session — and so the whole grammar is unit-tested on this Linux host.
use serde_json::Value;
/// Millimetres to metres — the single place the unit boundary is crossed.
pub const MM: f64 = 0.001;
/// WHERE A SKETCH LIVES (0.10.0, issue #1).
///
/// COORDINATE CONVENTIONS — stated, because Michael (issue #1) had to work them
/// out by testing:
/// * a sketch's (x, y) are the PLANE's own 2D axes. On **Top Plane**, sketch
/// +X is model +X and sketch +Y is model **−Z** (measured by Michael on
/// SOLIDWORKS 2021). On Front Plane sketch (x, y) is model (X, Y).
/// * an OFFSET plane keeps its base plane's axes; it sits `offsetMm` along the
/// base plane's normal (Top +Y, Front +Z, Right +X).
/// * a FACE is resolved to the offset plane that coincides with it, so its
/// sketch axes are the matching default plane's axes too (only faces parallel
/// to a default plane are accepted).
#[derive(Debug, Clone, PartialEq)]
pub enum PlaneRef {
/// A plane by name: "Front Plane", "Top Plane", "Right Plane", or any
/// reference plane in the part.
Named(String),
/// A reference plane `offset` metres along `from`'s normal (negative = the
/// other side). Created by the script and owned by it.
Offset { from: String, offset: f64 },
/// The planar face of the part that passes through `point` (metres).
Face { point: [f64; 3] },
/// A plane `offset` metres from the planar face through `point`, along the
/// face's OUTWARD normal (negative = into the part) — 0.11.0, CSWA Tool Block
/// step 3's "plane 12 from the back face". Built as an offset of the
/// parallel default plane; a sketch on it keeps that plane's axes, and the
/// face's outward normal stays the reference (a cut goes inward).
FaceOffset { point: [f64; 3], offset: f64 },
}
impl PlaneRef {
pub fn describe(&self) -> Value {
match self {
PlaneRef::Named(n) => Value::String(n.clone()),
PlaneRef::Offset { from, offset } => serde_json::json!({"offsetFrom": from, "offsetMm": offset / MM}),
PlaneRef::Face { point } => serde_json::json!({"face": {"atPointMm": [point[0] / MM, point[1] / MM, point[2] / MM]}}),
PlaneRef::FaceOffset { point, offset } => serde_json::json!({"offsetFrom": {"face": {"atPointMm": [point[0] / MM, point[1] / MM, point[2] / MM]}}, "offsetMm": offset / MM}),
}
}
}
/// One sketch entity, in the plane's 2D coordinates (metres).
/// A rectangle is stored by its two CORNERS — the `anchor` was resolved at
/// parse time, so both backends draw exactly the same thing.
///
/// LINES AND ARCS (0.11.0) are the segments of closed CONTOURS: every
/// composite the caller writes — `polyline`, `slot`, `polygon`, a rectangle
/// with `cornerRadiusMm`, `arc3`, `tangentArc` — is EXPANDED at parse time into
/// these two, so both backends draw (and the simulator measures) exactly the
/// same segments, and the contours are checked closed before anything is drawn
/// (`contours`). A `construction` segment is drawn as construction geometry: it
/// is never part of a profile (a revolve axis, a reference line).
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Entity {
Rect { x0: f64, y0: f64, x1: f64, y1: f64 },
Circle { cx: f64, cy: f64, r: f64 },
/// A straight segment from (x0, y0) to (x1, y1).
Line { x0: f64, y0: f64, x1: f64, y1: f64, construction: bool },
/// A circular arc about (cx, cy) from (x0, y0) to (x1, y1), counter-clockwise
/// when `ccw` (in the plane's own 2D frame). Its radius is |start − centre|.
Arc { cx: f64, cy: f64, x0: f64, y0: f64, x1: f64, y1: f64, ccw: bool, construction: bool },
}
impl Entity {
pub fn describe(&self) -> Value {
match self {
Entity::Rect { x0, y0, x1, y1 } => serde_json::json!({"rect": {"cornersMm": [[x0 / MM, y0 / MM], [x1 / MM, y1 / MM]]}}),
Entity::Circle { cx, cy, r } => serde_json::json!({"circle": {"centerMm": [cx / MM, cy / MM], "radiusMm": r / MM}}),
Entity::Line { x0, y0, x1, y1, construction } => {
let mut v = serde_json::json!({"line": {"fromMm": [x0 / MM, y0 / MM], "toMm": [x1 / MM, y1 / MM]}});
if *construction {
v["line"]["construction"] = Value::Bool(true);
}
v
}
Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, construction } => {
let mut v = serde_json::json!({"arc": {"centerMm": [cx / MM, cy / MM], "startMm": [x0 / MM, y0 / MM],
"endMm": [x1 / MM, y1 / MM], "radiusMm": ((x0 - cx).hypot(y0 - cy)) / MM, "ccw": ccw}});
if *construction {
v["arc"]["construction"] = Value::Bool(true);
}
v
}
}
}
/// A segment of a contour: a line or an arc that is not construction.
pub fn is_segment(&self) -> bool {
matches!(self, Entity::Line { construction: false, .. } | Entity::Arc { construction: false, .. })
}
pub fn is_construction(&self) -> bool {
matches!(self, Entity::Line { construction: true, .. } | Entity::Arc { construction: true, .. })
}
/// A line's or an arc's two ends (start, end).
pub fn ends(&self) -> Option<((f64, f64), (f64, f64))> {
match *self {
Entity::Line { x0, y0, x1, y1, .. } | Entity::Arc { x0, y0, x1, y1, .. } => Some(((x0, y0), (x1, y1))),
_ => None,
}
}
}
/// How close two segment ends must be to JOIN (1e-6 mm, in metres).
pub const JOIN_TOL: f64 = 1e-9;
/// THE CLOSED CONTOURS of a sketch's lines and arcs (0.11.0): each contour is
/// its segments in order, as (entity index, reversed). A segment end that
/// joins nothing is an OPEN profile and is refused, naming the gap; a point
/// where three or more segments meet is a branch and is refused too.
/// Rectangles and circles are closed by themselves and are not listed;
/// construction geometry is never part of a contour.
pub fn contours(entities: &[Entity]) -> Result<Vec<Vec<(usize, bool)>>, (usize, String)> {
let segs: Vec<usize> = (0..entities.len()).filter(|i| entities[*i].is_segment()).collect();
let ends = |i: usize| entities[i].ends().unwrap_or(((0.0, 0.0), (0.0, 0.0)));
let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).hypot(a.1 - b.1) <= JOIN_TOL;
let fmt = |p: (f64, f64)| format!("[{}, {}] mm", trim_mm(p.0), trim_mm(p.1));
for &i in &segs {
for p in [ends(i).0, ends(i).1] {
let n: usize = segs.iter().map(|&j| [ends(j).0, ends(j).1].iter().filter(|q| close(p, **q)).count()).sum();
if n > 2 {
return Err((i, format!("{n} segment ends meet at {} — a profile's corner joins exactly two (a branch cannot be extruded)", fmt(p))));
}
}
}
let mut used = vec![false; entities.len()];
let mut out = Vec::new();
for &first in &segs {
if used[first] {
continue;
}
used[first] = true;
let start = ends(first).0;
let mut cur = ends(first).1;
let mut contour = vec![(first, false)];
while !close(cur, start) {
let next = segs.iter().copied().find(|&j| !used[j] && (close(ends(j).0, cur) || close(ends(j).1, cur)));
match next {
Some(j) => {
used[j] = true;
let rev = !close(ends(j).0, cur);
contour.push((j, rev));
cur = if rev { ends(j).0 } else { ends(j).1 };
}
None => {
// The gap: from the dangling end to the nearest end it could
// have joined (the contour's own start included).
let mut best: Option<((f64, f64), f64)> = Some((start, (cur.0 - start.0).hypot(cur.1 - start.1)));
for &j in segs.iter().filter(|j| !used[**j]) {
for q in [ends(j).0, ends(j).1] {
let d = (cur.0 - q.0).hypot(cur.1 - q.1);
if best.map(|b| d < b.1).unwrap_or(true) {
best = Some((q, d));
}
}
}
let (q, d) = best.unwrap_or((start, 0.0));
return Err((
contour.last().map(|c| c.0).unwrap_or(first),
format!(
"the contour is OPEN: it ends at {} and no segment continues from there — the nearest free end is {}, a gap of {} mm. A profile must close (its last point equal to its first within 1e-6 mm); mark a reference line construction:true",
fmt(cur),
fmt(q),
trim_mm(d)
),
));
}
}
}
out.push(contour);
}
Ok(out)
}
/// Metres as millimetres for a message, without float noise.
fn trim_mm(m: f64) -> String {
let v = (m / MM * 1.0e6).round() / 1.0e6;
if v == v.trunc() { format!("{v:.1}") } else { format!("{v}") }
}
/// The one-entity shorthands' result fields (`widthMm`/`heightMm`,
/// `radiusMm`), kept so a sketch_rect / sketch_circle step reports what it
/// always reported.
pub fn single_entity_fields(entities: &[Entity], out: &mut Value) {
if let (Some(e), true, Value::Object(m)) = (entities.first(), entities.len() == 1, out) {
match *e {
Entity::Rect { x0, y0, x1, y1 } => {
m.insert("widthMm".into(), serde_json::json!((x1 - x0).abs() / MM));
m.insert("heightMm".into(), serde_json::json!((y1 - y0).abs() / MM));
}
Entity::Circle { r, .. } => {
m.insert("radiusMm".into(), serde_json::json!(r / MM));
}
_ => {}
}
}
}
/// Which way(s) an extrude or cut goes from its sketch plane.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Direction {
/// One direction: along the plane's normal for an extrude, along its
/// −normal for a cut (measured, issue #1); `reverse` flips it.
One,
/// Two directions: `depth` one way and `depth2` the other.
Both,
/// Symmetric about the sketch plane, `depth` in total.
MidPlane,
}
/// The extent of an extrude or a cut.
#[derive(Debug, Clone, PartialEq)]
pub struct Extent {
pub depth: f64,
pub depth2: f64,
pub through_all: bool,
pub reverse: bool,
pub direction: Direction,
/// An end condition other than a depth (0.11.0): up to the next face, up
/// to a face, or a distance short of a face.
pub up_to: Option<UpTo>,
/// A taper (0.11.0): `angle` radians from the extrude direction; inward
/// (the profile shrinks as it goes) unless `outward`.
pub draft: Option<Draft>,
/// SKETCH REGIONS (0.11.4): extrude only the regions of the sketch that
/// hold these points ((u, v) metres, the sketch's own coordinates) —
/// SOLIDWORKS' "Selected Contours", for a sketch whose contours CROSS
/// (an offset outline and a circle). Empty: the whole sketch.
pub regions: Vec<[f64; 2]>,
}
impl Extent {
/// A plain blind extent `depth` one way.
pub fn blind(depth: f64) -> Extent {
Extent { depth, depth2: depth, through_all: false, reverse: false, direction: Direction::One, up_to: None, draft: None, regions: Vec::new() }
}
}
/// OFFSET ENTITIES (0.11.4): a model face's edges offset into the sketch by
/// SOLIDWORKS itself (IModelDoc2::SketchOffsetEntities2) — an outline the DSL
/// cannot draw, because it is the MODEL's (the CSWA Tool Block's Step 4
/// pocket: the front face's outline 1 mm in).
#[derive(Debug, Clone, PartialEq)]
pub struct OffsetFace {
/// A point ON the face (metres), picked geometrically.
pub point: [f64; 3],
/// Metres, never 0: NEGATIVE = INWARD (the new outline lies inside the
/// face), positive = outward. SOLIDWORKS' own sign does not decide the
/// side (it varies by face): the bridge checks where the new entities
/// landed and offsets the other way if they landed outside.
pub offset: f64,
/// Which of the face's edges.
pub loops: OffsetLoops,
/// SketchOffsetEntities2's `Chain`: carry the offset along every edge
/// contiguous with the selected ones.
pub chain: bool,
}
/// The edges of a face that Offset Entities takes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffsetLoops {
/// The face's OUTER loop only (its edges selected): holes in the face are
/// left out of the sketch.
Outer,
/// The whole FACE selected: every loop, a hole's edge offset away from
/// the face's material too.
All,
}
impl OffsetFace {
pub fn describe(&self) -> serde_json::Value {
serde_json::json!({"offsetEntities": {
"fromFace": {"atPointMm": [self.point[0] / MM, self.point[1] / MM, self.point[2] / MM]},
"offsetMm": self.offset / MM,
"side": if self.offset < 0.0 { "inward" } else { "outward" },
"loops": match self.loops { OffsetLoops::Outer => "outer", OffsetLoops::All => "all" },
"chain": self.chain,
}})
}
}
/// END CONDITIONS beyond blind / through all (swEndConditions_e, verified on
/// help.solidworks.com: UpToNext 11, UpToSelection 10 — UpToSurface 4 is the
/// deprecated id — and OffsetFromSurface 5).
#[derive(Debug, Clone, PartialEq)]
pub enum UpTo {
/// Up to the next face the profile meets.
Next,
/// Up to the face through `point` (metres), picked geometrically.
Surface { point: [f64; 3] },
/// `offset` metres SHORT of the face through `point` (`reverse_offset`:
/// beyond it).
OffsetFrom { point: [f64; 3], offset: f64, reverse_offset: bool },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Draft {
pub angle: f64,
pub outward: bool,
}
/// A revolve's axis as the caller wrote it (0.11.0): a line entity of the
/// sketch, a line given by two points, or the sketch's own x / y axis.
#[derive(Debug, Clone, PartialEq)]
pub enum AxisSpec {
/// The caller's entity `i` (0-based, as written) of the sketch — a `line`.
Entity(usize),
Line([f64; 2], [f64; 2]),
SketchX,
SketchY,
}
/// A revolve or a revolve-cut of the sketch just closed.
#[derive(Debug, Clone, PartialEq)]
pub struct Revolution {
/// The axis, resolved at parse time to a LINE entity of the sketch (an
/// axis given by points or as x / y is added to the sketch as a
/// construction line).
pub axis: usize,
pub axis_spec: AxisSpec,
/// Radians, (0, 2π].
pub angle: f64,
pub mid_plane: bool,
pub reverse: bool,
}
/// A model axis (patterns, directions).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Axis {
X,
Y,
Z,
}
impl Axis {
pub fn name(&self) -> &'static str {
match self {
Axis::X => "x",
Axis::Y => "y",
Axis::Z => "z",
}
}
/// The two default planes whose intersection IS this axis.
pub fn planes(&self) -> (&'static str, &'static str) {
match self {
Axis::X => ("Top Plane", "Front Plane"),
Axis::Y => ("Front Plane", "Right Plane"),
Axis::Z => ("Top Plane", "Right Plane"),
}
}
}
/// Which edges a fillet or chamfer takes.
#[derive(Debug, Clone, PartialEq)]
pub enum EdgeSel {
/// The edge passing through each point (metres).
AtPoints(Vec<[f64; 3]>),
/// Every edge of a feature's faces ("last" = the previous feature).
AllOfFeature(String),
}
/// What an appearance is applied to.
#[derive(Debug, Clone, PartialEq)]
pub enum AppearanceTarget {
Part,
Body(String),
FaceAt([f64; 3]),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Op {
/// Create a new part document from the resolved part template. `visible`
/// false creates it HIDDEN (ISldWorks::DocumentVisible bracketed around
/// NewDocument, 0.10.6): no window, no graphics context.
NewPart { visible: bool },
/// Create a new assembly document from the resolved assembly template.
NewAssembly { visible: bool },
/// DELETE EVERY FEATURE after the origin of a part the BRIDGE created
/// (0.10.6): N geometry checks cost one document instead of N. Refused on
/// any document the bridge did not create.
ClearPart,
/// ONE sketch holding every entity listed (0.10.0) — several closed
/// profiles in one sketch extrude into rings, walls or hole patterns in ONE
/// feature. `shorthand` is the op the caller wrote: "sketch", "sketch_rect"
/// or "sketch_circle" (the last two are one-entity shorthands).
Sketch { shorthand: &'static str, plane: PlaneRef, entities: Vec<Entity>, offsets: Vec<OffsetFace> },
/// Extrude the sketch just closed.
Extrude(Extent),
/// Cut-extrude through the sketch just closed.
Cut(Extent),
/// A material from a library, and/or the part's density (0.11.0): with
/// `density` alone (kg/m³) the document's own material density is set
/// (no library material may be applied); with both, the material is
/// applied and its density must be the one asked for. `name` is empty
/// when only a density is given.
SetMaterial { name: String, database: String, density: Option<f64> },
/// Colour, as 0..1 RGB (the caller writes 0..255).
SetAppearance { rgb: [f64; 3], target: AppearanceTarget },
Fillet { radius: f64, edges: EdgeSel },
/// An angle-distance chamfer: `distance` metres, `angle` radians.
Chamfer { distance: f64, angle: f64, edges: EdgeSel },
/// Hollow the part, removing the faces through `faces`.
Shell { thickness: f64, faces: Vec<[f64; 3]>, outward: bool },
/// Revolve the sketch just closed about a line of it (0.11.0).
Revolve(Revolution),
/// Revolve-cut the sketch just closed (FeatureRevolve2 with IsCut).
RevolveCut(Revolution),
/// Mirror features (or every body) about a plane or a planar face (0.11.0).
Mirror { features: Vec<String>, bodies: bool, plane: PlaneRef },
/// A HOLE at a point on a planar face (0.11.0): cuts of circles sketched on
/// that face's plane — a counterbore or a countersink first, then the
/// hole from where it ends. `depth` None = through all.
Hole { at: [f64; 3], diameter: f64, depth: Option<f64>, counterbore: Option<(f64, f64)>, countersink: Option<(f64, f64)> },
/// Pattern a feature along one or two model axes.
LinearPattern { feature: String, dirs: Vec<(Axis, u32, f64)> },
/// Pattern a feature about a model axis; `angle` is the TOTAL angle (radians).
CircularPattern { feature: String, axis: Axis, count: u32, angle: f64 },
SetProp { name: String, value: String },
SaveAs { path: String },
Rebuild,
ZoomFit,
}
impl Op {
/// The slug used in the DSL and echoed in the per-step result.
pub fn name(&self) -> &'static str {
match self {
Op::NewPart { .. } => "new_part",
Op::NewAssembly { .. } => "new_assembly",
Op::ClearPart => "clear_part",
Op::Sketch { shorthand, .. } => shorthand,
Op::Extrude(_) => "extrude",
Op::Cut(_) => "cut",
Op::SetMaterial { .. } => "set_material",
Op::SetAppearance { .. } => "set_appearance",
Op::Fillet { .. } => "fillet",
Op::Chamfer { .. } => "chamfer",
Op::Shell { .. } => "shell",
Op::Revolve(_) => "revolve",
Op::RevolveCut(_) => "revolve_cut",
Op::Mirror { .. } => "mirror",
Op::Hole { .. } => "hole",
Op::LinearPattern { .. } => "linear_pattern",
Op::CircularPattern { .. } => "circular_pattern",
Op::SetProp { .. } => "set_prop",
Op::SaveAs { .. } => "save_as",
Op::Rebuild => "rebuild",
Op::ZoomFit => "zoom_fit",
}
}
/// Does this op SELECT something in the document? SOLIDWORKS selects only in
/// its ACTIVE document — a plane, a sketch, a face, an edge, a feature — and
/// a document created invisible cannot be activated (MEASURED 2026-10-14:
/// `new_part {visible:false}` then `sketch_rect` on Top Plane failed "could
/// not activate 'Part1'"). So these ops cannot run in a hidden document.
pub fn needs_selection(&self) -> bool {
matches!(
self,
Op::Sketch { .. }
| Op::Extrude(_)
| Op::Cut(_)
| Op::Fillet { .. }
| Op::Chamfer { .. }
| Op::Shell { .. }
| Op::Revolve(_)
| Op::RevolveCut(_)
| Op::Mirror { .. }
| Op::Hole { .. }
| Op::LinearPattern { .. }
| Op::CircularPattern { .. }
| Op::ClearPart
| Op::SetAppearance { target: AppearanceTarget::FaceAt(_), .. }
)
}
/// Does this op make a FEATURE in the tree (the one "last" refers to)?
pub fn makes_feature(&self) -> bool {
matches!(
self,
Op::Extrude(_) | Op::Cut(_) | Op::Fillet { .. } | Op::Chamfer { .. } | Op::Shell { .. }
| Op::LinearPattern { .. } | Op::CircularPattern { .. }
| Op::Revolve(_) | Op::RevolveCut(_) | Op::Mirror { .. } | Op::Hole { .. }
)
}
}
/// Every op slug, for the unknown-op hint and for `swx_describe`.
pub const OPS: &[&str] = &[
"new_part",
"new_assembly",
"clear_part",
"sketch",
"sketch_rect",
"sketch_circle",
"extrude",
"cut",
"set_material",
"set_appearance",
"fillet",
"chamfer",
"shell",
"revolve",
"revolve_cut",
"mirror",
"hole",
"linear_pattern",
"circular_pattern",
"set_prop",
"save_as",
"rebuild",
"zoom_fit",
];
fn mm(step: &Value, key: &str, default: Option<f64>) -> Result<f64, String> {
match step.get(key).and_then(|v| v.as_f64()) {
Some(v) if v.is_finite() => Ok(v * MM),
Some(_) => Err(format!("{key} must be a finite number of millimetres")),
None => default.map(|d| d * MM).ok_or_else(|| format!("{key} (millimetres) is required")),
}
}
fn text(step: &Value, key: &str) -> Result<String, String> {
step.get(key)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.ok_or_else(|| format!("{key} is required"))
}
fn flag(step: &Value, key: &str, default: bool) -> bool {
step.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
}
/// Destination-path rules for the DSL's `save_as`, applied AT PARSE TIME.
///
/// The standalone `swx_save_as` verb has always run its destination through
/// `safe_out`; the DSL's `save_as` reached `SaveAs3` by a different road and
/// skipped it entirely, so a relative path resolved against the bridge's own
/// working directory and a `.wrl` destination walked straight past the block on
/// the format that hangs SOLIDWORKS. Checking here means both roads meet the
/// same rules, and a bad path is refused before the script starts rather than
/// halfway through it.
fn safe_dest(path: &str) -> Result<String, String> {
let p = std::path::Path::new(path);
let b = path.as_bytes();
let drive_rooted =
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/');
if !(p.is_absolute() || path.starts_with('\\') || drive_rooted) {
return Err(format!(
"save_as path must be ABSOLUTE, got '{path}' — a relative path would resolve against the bridge's own working directory"
));
}
if p.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(format!("save_as path must not contain a '..' component: {path}"));
}
// The SAME normalisation the verb layer uses: `a.wrl.`, `a.wrl ` and
// `a.wrl::$DATA` all resolve to `a.wrl` on Windows.
let ext = crate::verbs3::normalized_extension(path).unwrap_or_default();
if BLOCKED_DEST_EXTENSIONS.contains(&ext.as_str()) {
return Err(format!(
"save_as would write a .{ext} file, which this bridge refuses: VRML export raises a modal option sheet no unattended caller can answer, and on a live install it hung SOLIDWORKS until the watchdog killed the host"
));
}
Ok(path.to_string())
}
/// Kept in step with `verbs3::BLOCKED_FORMATS`; duplicated here rather than
/// imported so the grammar module stays free of the verb layer (and so this
/// rule is unit-testable on its own). The pairing is asserted by a test.
pub const BLOCKED_DEST_EXTENSIONS: &[&str] = &["wrl", "vrml"];
/// One component insertion for `swx_add_component`, position in METRES.
///
/// Lives here, beside the modelling DSL, for the same reason the DSL does: this
/// is where millimetres become metres, exactly once, on the SERVER, so a bad
/// request is refused before anything touches the live CAD session — and so the
/// grammar is unit-tested on a Linux host. Both backends parse with this
/// function, so the mock cannot accept a request SOLIDWORKS would reject.
#[derive(Debug, Clone, PartialEq)]
pub struct Component {
pub file: String,
pub x: f64,
pub y: f64,
pub z: f64,
/// Rotation about the ASSEMBLY's axes, in RADIANS, applied X then Y then Z.
/// Degrees in (`rotXDeg`), radians here: a component that has to lie on its
/// side is the ordinary case in a crate, and without this every part used in
/// two orientations would need two files — which would split its BOM line in
/// two and make the quantities lie.
pub rx: f64,
pub ry: f64,
pub rz: f64,
pub config: String,
}
impl Component {
/// True when this component is not axis-aligned with the assembly and
/// therefore needs its transform written after insertion.
pub fn rotated(&self) -> bool {
self.rx != 0.0 || self.ry != 0.0 || self.rz != 0.0
}
/// `IMathUtility::CreateTransform`'s sixteen doubles: a 3x3 rotation
/// (indices 0-8), the translation in metres (9-11), the scale (12), and
/// three SOLIDWORKS leaves at zero (13-15).
///
/// The composed rotation is Rz·Ry·Rx — X first, then Y, then Z — which is
/// what "roll it onto its side, then swing it round" means to the person
/// writing the numbers.
///
/// THE 3x3 IS WRITTEN COLUMN-MAJOR, and that is a live finding, not a
/// reading of the docs: filling indices 0-8 row-major put a component
/// rotated 90° about Z at -90° instead (measured off the assembly's centre
/// of mass on SOLIDWORKS Connected R2026x). For the axis-aligned quarter
/// turns a crate is made of, a transposed rotation is a rotation the other
/// way — silently wrong rather than obviously broken, which is exactly the
/// kind of error a bounding-box check catches and eyeballing does not.
pub fn transform(&self) -> [f64; 16] {
let (sx, cx) = self.rx.sin_cos();
let (sy, cy) = self.ry.sin_cos();
let (sz, cz) = self.rz.sin_cos();
// Rz * Ry * Rx as r[row][col], v' = R·v.
let r = [
[cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx],
[sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx],
[-sy, cy * sx, cy * cx],
];
let mut t = [0.0f64; 16];
for col in 0..3 {
for row in 0..3 {
t[col * 3 + row] = r[row][col];
}
}
t[9] = self.x;
t[10] = self.y;
t[11] = self.z;
t[12] = 1.0;
t
}
}
/// Parse `components:[{filePath,xMm,yMm,zMm,configuration}]`, or the
/// single-component shorthand where those keys sit on the args themselves.
///
/// `filePath` is deliberately the ONLY accepted spelling of the component file:
/// `path` already means THE TARGET ASSEMBLY on every doc verb, and letting it
/// mean the component here would make the single-component shorthand ambiguous
/// in the one direction that matters — which document gets modified.
pub fn parse_components(args: &Value) -> Result<Vec<Component>, String> {
let items: Vec<Value> = match args.get("components") {
Some(Value::Array(a)) => a.clone(),
Some(other) if !other.is_null() => {
return Err(
"components must be an ARRAY of {filePath, xMm, yMm, zMm} objects".to_string()
)
}
_ => vec![args.clone()],
};
if items.is_empty() {
return Err("components is empty — there is nothing to insert".to_string());
}
let mut out = Vec::with_capacity(items.len());
for (i, it) in items.iter().enumerate() {
let at = i + 1;
let file = it
.get("filePath")
.or_else(|| it.get("file"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
format!(
"component {at}: filePath is required — an absolute .sldprt/.sldasm path on \
the DESKTOP filesystem"
)
})?;
let p = std::path::Path::new(file);
let b = file.as_bytes();
let drive_rooted = b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b[2] == b'\\' || b[2] == b'/');
if !(p.is_absolute() || file.starts_with('\\') || drive_rooted) {
return Err(format!(
"component {at}: filePath must be ABSOLUTE, got '{file}' — a relative path would \
resolve against the bridge's own working directory"
));
}
if p.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(format!("component {at}: filePath must not contain a '..' component: {file}"));
}
let coord = |k: &str| -> Result<f64, String> {
match it.get(k) {
None | Some(Value::Null) => Ok(0.0),
Some(v) => v
.as_f64()
.filter(|f| f.is_finite())
.map(|f| f * MM)
.ok_or_else(|| format!("component {at}: {k} must be a finite number of millimetres")),
}
};
let degrees = |k: &str| -> Result<f64, String> {
match it.get(k) {
None | Some(Value::Null) => Ok(0.0),
Some(v) => v
.as_f64()
.filter(|f| f.is_finite())
.map(|f| f.to_radians())
.ok_or_else(|| format!("component {at}: {k} must be a finite number of degrees")),
}
};
out.push(Component {
file: file.to_string(),
x: coord("xMm")?,
y: coord("yMm")?,
z: coord("zMm")?,
rx: degrees("rotXDeg")?,
ry: degrees("rotYDeg")?,
rz: degrees("rotZDeg")?,
config: it.get("configuration").and_then(|v| v.as_str()).unwrap_or("").to_string(),
});
}
Ok(out)
}
/// Where in `paths` (an assembly's components, in SOLIDWORKS' order) the run of
/// `reqs` file paths sits, and which way round: `Some((offset, forward))`.
///
/// The components one `add_component` call inserted occupy a CONTIGUOUS run of
/// the assembly's component list — but SOLIDWORKS does not promise which end
/// new components land at, and the insert call returns no handle to them. So
/// the run is FOUND by matching the sequence of file paths rather than assumed
/// from an ordering, which means a wrong guess about that ordering can never
/// silently move the wrong component: no match, nothing written, and the
/// response says so.
pub fn locate_run(paths: &[String], reqs: &[Component]) -> Option<(usize, bool)> {
if reqs.is_empty() || reqs.len() > paths.len() {
return None;
}
let matches = |offset: usize, forward: bool| {
reqs.iter().enumerate().all(|(i, r)| {
let idx = if forward { offset + i } else { offset + reqs.len() - 1 - i };
crate::verbs::same_path(&r.file, &paths[idx])
})
};
// Newest components are usually at one end, so try both ends first; the
// full scan is the fallback.
let last = paths.len() - reqs.len();
for offset in std::iter::once(last).chain(std::iter::once(0)).chain(0..=last) {
for forward in [true, false] {
if matches(offset, forward) {
return Some((offset, forward));
}
}
}
None
}
/// Parse a `script` argument into ops. Accepts the array directly or an object
/// with a `steps`/`script` array, because both are things a caller writes.
pub fn parse(script: &Value) -> Result<Vec<Op>, String> {
let steps = match script {
Value::Array(a) => a.clone(),
Value::Object(_) => script
.get("steps")
.or_else(|| script.get("script"))
.and_then(|v| v.as_array())
.cloned()
.ok_or_else(|| "script must be an array of steps, or an object with `steps`".to_string())?,
_ => {
return Err(
"script must be a JSON array of steps (this bridge has no scripting host to \
evaluate a string of code — see the op list in the hint)"
.to_string(),
)
}
};
if steps.is_empty() {
return Err("script has no steps".to_string());
}
let mut ops = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
ops.push(parse_step(step).map_err(|e| format!("step {}: {e}", i + 1))?);
}
// A REVOLVE'S AXIS, resolved into the sketch it consumes (0.11.0).
let mut last_sketch: Option<usize> = None;
for i in 0..ops.len() {
match &ops[i] {
Op::Sketch { .. } => last_sketch = Some(i),
Op::Revolve(_) | Op::RevolveCut(_) => {
if let Some(j) = last_sketch.take() {
resolve_axis(&mut ops, j, i, &steps[j]).map_err(|e| format!("step {}: {e}", i + 1))?;
}
}
Op::Extrude(_) | Op::Cut(_) | Op::NewPart { .. } | Op::NewAssembly { .. } | Op::ClearPart | Op::Hole { .. } => last_sketch = None,
_ => {}
}
}
Ok(ops)
}
/// Put revolve `r`'s axis into sketch `j`: the line entity it names, or a
/// construction line added for an axis given by points or as x / y. Then
/// check the profile lies on ONE side of it (SOLIDWORKS revolves nothing else).
fn resolve_axis(ops: &mut [Op], j: usize, r: usize, sketch_step: &Value) -> Result<(), String> {
let spec = match &ops[r] {
Op::Revolve(rv) | Op::RevolveCut(rv) => rv.axis_spec.clone(),
_ => return Ok(()),
};
if let Op::Sketch { offsets, .. } = &ops[j] {
if !offsets.is_empty() {
return Err("a sketch with offsetEntities cannot be revolved: its profile is SOLIDWORKS' own, so no side of an axis can be checked — extrude or cut it".into());
}
}
let Op::Sketch { entities, .. } = &mut ops[j] else { return Ok(()) };
let line_from = |a: (f64, f64), b: (f64, f64)| Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction: true };
let span = |ents: &[Entity], a: f64, b: f64| {
let (lo, hi) = ents.iter().map(|e| extent_along(e, a, b)).fold((f64::MAX, f64::MIN), |m, x| (m.0.min(x.0), m.1.max(x.1)));
if lo > hi { (-0.01, 0.01) } else { (lo - 0.001, hi + 0.001) }
};
let axis = match spec {
AxisSpec::Entity(k) => {
let ranges = match sketch_step.get("entities").and_then(|e| e.as_array()) {
Some(list) => sketch_entities(list)?.1,
None => vec![0..entities.len()],
};
let range = ranges.get(k).cloned().ok_or_else(|| format!("axis.entity {k} — the sketch has {} entities (0-based)", ranges.len()))?;
if range.len() != 1 || !matches!(entities[range.start], Entity::Line { .. }) {
return Err(format!("axis.entity {k} is not a line — a revolve axis is a `line` or `centerline` entity"));
}
range.start
}
AxisSpec::Line(a, b) => {
entities.push(line_from((a[0], a[1]), (b[0], b[1])));
entities.len() - 1
}
AxisSpec::SketchX => {
let (lo, hi) = span(entities, 1.0, 0.0);
entities.push(line_from((lo, 0.0), (hi, 0.0)));
entities.len() - 1
}
AxisSpec::SketchY => {
let (lo, hi) = span(entities, 0.0, 1.0);
entities.push(line_from((0.0, lo), (0.0, hi)));
entities.len() - 1
}
};
profile_one_side(entities, axis)?;
if let Op::Revolve(rv) | Op::RevolveCut(rv) = &mut ops[r] {
rv.axis = axis;
}
Ok(())
}
/// The profile (every entity but construction and the axis itself) on ONE
/// side of the axis line; touching it is allowed.
pub fn profile_one_side(entities: &[Entity], axis: usize) -> Result<(), String> {
let Some(((ax, ay), (bx, by))) = entities.get(axis).and_then(|e| e.ends()) else {
return Err("the revolve axis is not a line".into());
};
let l = (bx - ax).hypot(by - ay);
// Signed distance from the axis: n·p − n·a with n the axis's left normal.
let (nx, ny) = (-(by - ay) / l, (bx - ax) / l);
let off = nx * ax + ny * ay;
let (mut lo, mut hi) = (f64::MAX, f64::MIN);
for (i, e) in entities.iter().enumerate() {
if i == axis || e.is_construction() {
continue;
}
let (a, b) = extent_along(e, nx, ny);
lo = lo.min(a - off);
hi = hi.max(b - off);
}
if lo < -JOIN_TOL && hi > JOIN_TOL {
return Err(format!(
"the profile crosses the revolve axis (it reaches {} mm on one side and {} mm on the other) — SOLIDWORKS revolves only a profile on ONE side of its axis",
trim_mm(-lo), trim_mm(hi)
));
}
Ok(())
}
fn point3(v: &Value, what: &str) -> Result<[f64; 3], String> {
let a = v.as_array().filter(|a| a.len() == 3).ok_or_else(|| format!("{what} must be [x, y, z] in millimetres"))?;
let mut out = [0.0; 3];
for (i, x) in a.iter().enumerate() {
out[i] = x.as_f64().filter(|f| f.is_finite()).ok_or_else(|| format!("{what} must be three finite numbers (mm)"))? * MM;
}
Ok(out)
}
fn points3(v: Option<&Value>, what: &str) -> Result<Vec<[f64; 3]>, String> {
let a = v.and_then(|v| v.as_array()).filter(|a| !a.is_empty()).ok_or_else(|| format!("{what} must be a non-empty list of [x, y, z] points in millimetres"))?;
a.iter().map(|p| point3(p, what)).collect()
}
fn positive_mm(v: &Value, key: &str) -> Result<f64, String> {
let x = mm(v, key, None)?;
if x <= 0.0 {
return Err(format!("{key} must be greater than zero"));
}
Ok(x)
}
/// `"plane": "Top Plane"` | `"plane": {"offsetFrom": "Top Plane", "offsetMm": 9.6}`
/// | `"face": {"atPointMm": [x, y, z]}`.
fn plane_ref(step: &Value) -> Result<PlaneRef, String> {
if let Some(face) = step.get("face").filter(|f| !f.is_null()) {
if step.get("plane").map(|p| !p.is_null()).unwrap_or(false) {
return Err("give `plane` OR `face`, not both".into());
}
let p = face.get("atPointMm").ok_or_else(|| "face needs atPointMm: [x, y, z] (mm), a point ON the planar face".to_string())?;
return Ok(PlaneRef::Face { point: point3(p, "face.atPointMm")? });
}
match step.get("plane") {
None | Some(Value::Null) => Ok(PlaneRef::Named("Front Plane".into())),
Some(Value::String(s)) if !s.trim().is_empty() => Ok(PlaneRef::Named(s.clone())),
Some(o @ Value::Object(_)) if o.get("offsetFrom").map(|f| f.is_object()).unwrap_or(false) => {
let f = &o["offsetFrom"];
let point = point3(
f.get("face").and_then(|x| x.get("atPointMm")).or_else(|| f.get("atPointMm"))
.ok_or_else(|| "plane.offsetFrom as an object is a face: {\"face\": {\"atPointMm\": [x, y, z]}}".to_string())?,
"plane.offsetFrom.face.atPointMm",
)?;
let off = mm(o, "offsetMm", None).map_err(|e| format!("plane.{e} (along the face's OUTWARD normal; negative = into the part)"))?;
if off == 0.0 {
return Ok(PlaneRef::Face { point });
}
Ok(PlaneRef::FaceOffset { point, offset: off })
}
Some(o @ Value::Object(_)) => {
let from = o.get("offsetFrom").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty())
.ok_or_else(|| "plane.offsetFrom (a plane name, e.g. \"Top Plane\") is required for an offset plane".to_string())?;
let off = mm(o, "offsetMm", None).map_err(|e| format!("plane.{e}"))?;
if off == 0.0 {
return Ok(PlaneRef::Named(from.to_string()));
}
Ok(PlaneRef::Offset { from: from.to_string(), offset: off })
}
_ => Err("plane must be a plane name or {offsetFrom, offsetMm}".into()),
}
}
/// A centre given as `centerMm: [x, y]`, if present.
fn center2(v: &Value) -> Result<Option<(f64, f64)>, String> {
match v.get("centerMm").or_else(|| v.get("center")) {
None | Some(Value::Null) => Ok(None),
Some(c) => {
let a = c.as_array().filter(|a| a.len() == 2).ok_or_else(|| "centerMm must be [x, y] in millimetres".to_string())?;
let f = |i: usize| a[i].as_f64().filter(|f| f.is_finite()).map(|f| f * MM).ok_or_else(|| "centerMm must be two finite numbers".to_string());
Ok(Some((f(0)?, f(1)?)))
}
}
}
/// A rectangle: `widthMm`, `heightMm`, and its position — `xMm`/`yMm` are the
/// CORNER by default (`anchor:"corner"`), the CENTRE with `anchor:"center"`, or
/// `centerMm:[x, y]`.
fn rect(v: &Value) -> Result<Entity, String> {
let w = positive_mm(v, "widthMm")?;
let h = positive_mm(v, "heightMm")?;
let anchor = v.get("anchor").and_then(|a| a.as_str()).unwrap_or("corner");
let (x0, y0) = match (center2(v)?, anchor) {
(Some((cx, cy)), _) => (cx - w / 2.0, cy - h / 2.0),
(None, "center" | "centre") => (mm(v, "xMm", Some(0.0))? - w / 2.0, mm(v, "yMm", Some(0.0))? - h / 2.0),
(None, "corner") => (mm(v, "xMm", Some(0.0))?, mm(v, "yMm", Some(0.0))?),
(None, other) => return Err(format!("anchor must be \"corner\" or \"center\", got {other:?}")),
};
Ok(Entity::Rect { x0, y0, x1: x0 + w, y1: y0 + h })
}
/// A rectangle as the entities it is drawn with: one `Rect`, or — with
/// `cornerRadiusMm` (0.11.0) — four lines and four quarter arcs, or four
/// construction lines with `construction:true`.
fn rect_entities(v: &Value) -> Result<Vec<Entity>, String> {
let r = rect(v)?;
let Entity::Rect { x0, y0, x1, y1 } = r else { return Ok(vec![r]) };
let construction = flag(v, "construction", false);
let rc = match v.get("cornerRadiusMm").filter(|c| !c.is_null()) {
None => 0.0,
Some(_) => {
let rc = positive_mm(v, "cornerRadiusMm")?;
let half = ((x1 - x0).min(y1 - y0)) / 2.0;
if rc > half + JOIN_TOL {
return Err(format!("cornerRadiusMm {} is more than half the rectangle's shorter side ({} mm)", trim_mm(rc), trim_mm(half)));
}
rc.min(half)
}
};
if rc == 0.0 && !construction {
return Ok(vec![r]);
}
let mut out = Vec::new();
let line = |a: (f64, f64), b: (f64, f64), out: &mut Vec<Entity>| {
if (a.0 - b.0).hypot(a.1 - b.1) > JOIN_TOL {
out.push(Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction });
}
};
let arc = |c: (f64, f64), a: (f64, f64), b: (f64, f64), out: &mut Vec<Entity>| {
if rc > 0.0 {
out.push(Entity::Arc { cx: c.0, cy: c.1, x0: a.0, y0: a.1, x1: b.0, y1: b.1, ccw: true, construction });
}
};
line((x0 + rc, y0), (x1 - rc, y0), &mut out);
arc((x1 - rc, y0 + rc), (x1 - rc, y0), (x1, y0 + rc), &mut out);
line((x1, y0 + rc), (x1, y1 - rc), &mut out);
arc((x1 - rc, y1 - rc), (x1, y1 - rc), (x1 - rc, y1), &mut out);
line((x1 - rc, y1), (x0 + rc, y1), &mut out);
arc((x0 + rc, y1 - rc), (x0 + rc, y1), (x0, y1 - rc), &mut out);
line((x0, y1 - rc), (x0, y0 + rc), &mut out);
arc((x0 + rc, y0 + rc), (x0, y0 + rc), (x0 + rc, y0), &mut out);
Ok(out)
}
/// A circle as drawn: one `Circle`, or two construction half arcs.
fn circle_entities(v: &Value) -> Result<Vec<Entity>, String> {
let c = circle(v)?;
match (c.clone(), flag(v, "construction", false)) {
(Entity::Circle { cx, cy, r }, true) => Ok(vec![
Entity::Arc { cx, cy, x0: cx + r, y0: cy, x1: cx - r, y1: cy, ccw: true, construction: true },
Entity::Arc { cx, cy, x0: cx - r, y0: cy, x1: cx + r, y1: cy, ccw: true, construction: true },
]),
_ => Ok(vec![c]),
}
}
/// A 2D point under the first of `keys` present, as [x, y] millimetres.
fn pt2(v: &Value, keys: &[&str]) -> Result<Option<(f64, f64)>, String> {
for k in keys {
if let Some(c) = v.get(*k).filter(|c| !c.is_null()) {
let a = c.as_array().filter(|a| a.len() == 2).ok_or_else(|| format!("{k} must be [x, y] in millimetres"))?;
let f = |i: usize| a[i].as_f64().filter(|f| f.is_finite()).map(|f| f * MM).ok_or_else(|| format!("{k} must be two finite numbers (mm)"));
return Ok(Some((f(0)?, f(1)?)));
}
}
Ok(None)
}
fn need_pt2(v: &Value, keys: &[&str]) -> Result<(f64, f64), String> {
pt2(v, keys)?.ok_or_else(|| format!("{} ([x, y] in millimetres) is required", keys[0]))
}
/// Degrees under `key` (default `default`), as radians.
fn degrees(v: &Value, key: &str, default: f64) -> Result<f64, String> {
match v.get(key).filter(|d| !d.is_null()) {
None => Ok(default.to_radians()),
Some(d) => d.as_f64().filter(|f| f.is_finite()).map(|f| f.to_radians()).ok_or_else(|| format!("{key} must be a finite number of degrees")),
}
}
fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
(a.0 - b.0).hypot(a.1 - b.1)
}
/// `line {from, to}` (`centerline` = a construction line).
fn line_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
let a = need_pt2(v, &["from", "fromMm"])?;
let b = need_pt2(v, &["to", "toMm"])?;
if dist(a, b) <= JOIN_TOL {
return Err("a line's from and to are the same point".into());
}
Ok(vec![Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction }])
}
/// `polyline {points, close}`: a line between each pair of points.
fn polyline_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
let pts = v.get("points").or_else(|| v.get("pointsMm")).and_then(|p| p.as_array()).filter(|a| a.len() >= 2)
.ok_or_else(|| "polyline needs points: at least two [x, y] (mm)".to_string())?;
let mut ps = Vec::new();
for (i, p) in pts.iter().enumerate() {
ps.push(need_pt2(&serde_json::json!({"point": p}), &["point"]).map_err(|e| format!("point {}: {e}", i + 1))?);
}
if flag(v, "close", false) && dist(ps[0], ps[ps.len() - 1]) > JOIN_TOL {
ps.push(ps[0]);
}
let mut out = Vec::new();
for (i, w) in ps.windows(2).enumerate() {
if dist(w[0], w[1]) <= JOIN_TOL {
return Err(format!("points {} and {} are the same point", i + 1, i + 2));
}
out.push(Entity::Line { x0: w[0].0, y0: w[0].1, x1: w[1].0, y1: w[1].1, construction });
}
Ok(out)
}
/// `arc {center, radiusMm, startDeg, endDeg, ccw}` (CreateArc's direction:
/// counter-clockwise unless `ccw:false`).
fn arc_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
let c = need_pt2(v, &["center", "centerMm"])?;
let r = positive_mm(v, "radiusMm")?;
let a0 = degrees(v, "startDeg", f64::NAN)?;
let a1 = degrees(v, "endDeg", f64::NAN)?;
if a0.is_nan() || a1.is_nan() {
return Err("arc needs startDeg and endDeg".into());
}
let sweep = (a1 - a0).rem_euclid(std::f64::consts::TAU);
if sweep < 1e-9 || sweep > std::f64::consts::TAU - 1e-9 {
return Err("an arc's startDeg and endDeg are the same angle — for a full circle use circle".into());
}
let (s0, c0) = a0.sin_cos();
let (s1, c1) = a1.sin_cos();
Ok(vec![Entity::Arc { cx: c.0, cy: c.1, x0: c.0 + r * c0, y0: c.1 + r * s0, x1: c.0 + r * c1, y1: c.1 + r * s1, ccw: flag(v, "ccw", true), construction }])
}
/// `arc3 {start, mid, end}`: the arc through three points.
fn arc3_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
let a = need_pt2(v, &["start", "startMm"])?;
let b = need_pt2(v, &["mid", "midMm"])?;
let c = need_pt2(v, &["end", "endMm"])?;
let cross = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);
if cross.abs() <= 1e-9 * dist(a, b) * dist(a, c) || dist(a, c) <= JOIN_TOL {
return Err("arc3's three points are in a line (or start equals end) — no arc passes through them".into());
}
let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));
let (a2, b2, c2) = (a.0 * a.0 + a.1 * a.1, b.0 * b.0 + b.1 * b.1, c.0 * c.0 + c.1 * c.1);
let ux = (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d;
let uy = (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d;
Ok(vec![Entity::Arc { cx: ux, cy: uy, x0: a.0, y0: a.1, x1: c.0, y1: c.1, ccw: cross > 0.0, construction }])
}
/// The direction a line or an arc LEAVES its end in.
fn end_tangent(e: &Entity) -> Option<((f64, f64), (f64, f64))> {
match *e {
Entity::Line { x0, y0, x1, y1, .. } => {
let l = (x1 - x0).hypot(y1 - y0);
Some(((x1, y1), ((x1 - x0) / l, (y1 - y0) / l)))
}
Entity::Arc { cx, cy, x1, y1, ccw, .. } => {
let (rx, ry) = (x1 - cx, y1 - cy);
let l = rx.hypot(ry);
Some(((x1, y1), if ccw { (-ry / l, rx / l) } else { (ry / l, -rx / l) }))
}
_ => None,
}
}
/// `tangentArc {to, radiusMm?}`: the arc that leaves the PREVIOUS entity's end
/// along its direction and ends at `to` — there is exactly one; `radiusMm`, if
/// given, must be its radius.
fn tangent_arc_entities(v: &Value, construction: bool, prev: Option<&Entity>) -> Result<Vec<Entity>, String> {
let (p, t) = prev.and_then(end_tangent).ok_or_else(|| "tangentArc continues from the entity before it, which must be a line or an arc".to_string())?;
let q = need_pt2(v, &["to", "toMm"])?;
let n = (-t.1, t.0);
let d = (q.0 - p.0, q.1 - p.1);
let s = d.0 * n.0 + d.1 * n.1;
let len2 = d.0 * d.0 + d.1 * d.1;
if len2.sqrt() <= JOIN_TOL || s.abs() <= 1e-9 * len2.sqrt() {
return Err("tangentArc's `to` lies on the tangent line (or is the start point) — that is a straight line, not an arc".into());
}
let r = len2 / (2.0 * s);
if let Some(want) = v.get("radiusMm").filter(|r| !r.is_null()) {
let want = want.as_f64().filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| "radiusMm must be greater than zero".to_string())? * MM;
if (want - r.abs()).abs() > JOIN_TOL {
return Err(format!(
"a tangent arc from [{}, {}] to [{}, {}] mm has radius {} mm, not the radiusMm given — omit radiusMm or move `to`",
trim_mm(p.0), trim_mm(p.1), trim_mm(q.0), trim_mm(q.1), trim_mm(r.abs())
));
}
}
Ok(vec![Entity::Arc { cx: p.0 + n.0 * r, cy: p.1 + n.1 * r, x0: p.0, y0: p.1, x1: q.0, y1: q.1, ccw: s > 0.0, construction }])
}
/// `slot {center, lengthMm (centre to centre), widthMm, angleDeg}` — the
/// straight slot ISketchManager::CreateSketchSlot draws, drawn here as its two
/// lines and two half arcs so both backends and the closure check see the
/// same segments.
fn slot_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
let c = need_pt2(v, &["center", "centerMm"])?;
let len = positive_mm(v, "lengthMm")?;
let r = positive_mm(v, "widthMm")? / 2.0;
let (s, co) = degrees(v, "angleDeg", 0.0)?.sin_cos();
let (u, n) = ((co, s), (-s, co));
let c1 = (c.0 - u.0 * len / 2.0, c.1 - u.1 * len / 2.0);
let c2 = (c.0 + u.0 * len / 2.0, c.1 + u.1 * len / 2.0);
let at = |o: (f64, f64), k: f64| (o.0 + n.0 * k, o.1 + n.1 * k);
let (a, b, cc, d) = (at(c1, -r), at(c2, -r), at(c2, r), at(c1, r));
Ok(vec![
Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction },
Entity::Arc { cx: c2.0, cy: c2.1, x0: b.0, y0: b.1, x1: cc.0, y1: cc.1, ccw: true, construction },
Entity::Line { x0: cc.0, y0: cc.1, x1: d.0, y1: d.1, construction },
Entity::Arc { cx: c1.0, cy: c1.1, x0: d.0, y0: d.1, x1: a.0, y1: a.1, ccw: true, construction },
])
}
/// `polygon {center, sides, circumradiusMm | inscribedRadiusMm, angleDeg}` —
/// a regular polygon; `angleDeg` is the direction of its first VERTEX.
fn polygon_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
let c = need_pt2(v, &["center", "centerMm"])?;
let n = v.get("sides").and_then(|s| s.as_u64()).filter(|n| (3..=200).contains(n)).ok_or_else(|| "sides must be a whole number from 3 to 200".to_string())?;
let r = match (v.get("circumradiusMm").filter(|x| !x.is_null()), v.get("inscribedRadiusMm").filter(|x| !x.is_null())) {
(Some(_), None) => positive_mm(v, "circumradiusMm")?,
(None, Some(_)) => positive_mm(v, "inscribedRadiusMm")? / (std::f64::consts::PI / n as f64).cos(),
_ => return Err("polygon needs ONE of circumradiusMm (centre to vertex) or inscribedRadiusMm (centre to the middle of a side)".into()),
};
let a0 = degrees(v, "angleDeg", 0.0)?;
let vtx = |k: u64| {
let (s, co) = (a0 + std::f64::consts::TAU * k as f64 / n as f64).sin_cos();
(c.0 + r * co, c.1 + r * s)
};
Ok((0..n).map(|k| {
let (a, b) = (vtx(k), vtx(k + 1));
Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction }
}).collect())
}
/// A SKETCH FILLET or CHAMFER (0.11.0) on the corner where two LINES drawn
/// before it meet at `at`: both lines are trimmed, and the corner becomes a
/// tangent arc of `radius` (fillet) or a line `distance` along each (chamfer).
/// Computed here — not ISketchManager::CreateFillet / CreateChamfer — so both
/// backends draw, and the closure check sees, the same segments.
fn corner_entities(v: &Value, out: &mut [Entity], fillet: bool, construction: bool) -> Result<Vec<Entity>, String> {
let at = need_pt2(v, &["at", "atMm"])?;
let size = if fillet { positive_mm(v, "radiusMm")? } else { positive_mm(v, "distanceMm")? };
let mut hits: Vec<(usize, bool)> = Vec::new();
for (i, e) in out.iter().enumerate() {
if let Entity::Line { x0, y0, x1, y1, construction: false } = *e {
if dist((x0, y0), at) <= JOIN_TOL {
hits.push((i, false));
} else if dist((x1, y1), at) <= JOIN_TOL {
hits.push((i, true));
}
}
}
if hits.len() != 2 {
return Err(format!(
"{} lines end at [{}, {}] mm — a sketch {} takes the corner where exactly TWO lines drawn before it meet",
hits.len(), trim_mm(at.0), trim_mm(at.1), if fillet { "fillet" } else { "chamfer" }
));
}
// Each line's direction AWAY from the corner, and its length.
let away = |e: &Entity, at_end: bool| -> ((f64, f64), f64) {
let ((x0, y0), (x1, y1)) = e.ends().unwrap_or(((0.0, 0.0), (0.0, 0.0)));
let (fx, fy, tx, ty) = if at_end { (x1, y1, x0, y0) } else { (x0, y0, x1, y1) };
let l = (tx - fx).hypot(ty - fy);
(((tx - fx) / l, (ty - fy) / l), l)
};
let (a, la) = away(&out[hits[0].0], hits[0].1);
let (b, lb) = away(&out[hits[1].0], hits[1].1);
let cos = (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0);
let theta = cos.acos();
if theta < 1e-6 || theta > std::f64::consts::PI - 1e-6 {
return Err("the two lines at that corner are in line — there is no corner to round or cut".into());
}
let t = if fillet { size / (theta / 2.0).tan() } else { size };
if t >= la - JOIN_TOL || t >= lb - JOIN_TOL {
return Err(format!("the {} needs {} mm along each line, and a line at that corner is shorter", if fillet { "fillet" } else { "chamfer" }, trim_mm(t)));
}
let pa = (at.0 + a.0 * t, at.1 + a.1 * t);
let pb = (at.0 + b.0 * t, at.1 + b.1 * t);
for (k, p) in [(hits[0], pa), (hits[1], pb)] {
if let Entity::Line { x0, y0, x1, y1, .. } = &mut out[k.0] {
if k.1 {
*x1 = p.0;
*y1 = p.1;
} else {
*x0 = p.0;
*y0 = p.1;
}
}
}
if !fillet {
return Ok(vec![Entity::Line { x0: pa.0, y0: pa.1, x1: pb.0, y1: pb.1, construction }]);
}
let w = {
let (x, y) = (a.0 + b.0, a.1 + b.1);
let l = x.hypot(y);
(x / l, y / l)
};
let c = (at.0 + w.0 * size / (theta / 2.0).sin(), at.1 + w.1 * size / (theta / 2.0).sin());
let mid = (c.0 - w.0 * size, c.1 - w.1 * size);
let cross = (mid.0 - pa.0) * (pb.1 - pa.1) - (mid.1 - pa.1) * (pb.0 - pa.0);
Ok(vec![Entity::Arc { cx: c.0, cy: c.1, x0: pa.0, y0: pa.1, x1: pb.0, y1: pb.1, ccw: cross > 0.0, construction }])
}
/// The entity kinds a sketch takes.
pub const ENTITY_KINDS: &[&str] = &[
"rect", "circle", "line", "centerline", "polyline", "arc", "arc3", "tangentArc", "slot", "polygon", "sketchFillet", "sketchChamfer",
];
/// EVERY ENTITY OF A SKETCH, expanded (0.11.0): the entities drawn, and for
/// each entity the caller wrote, the range of drawn entities it became. Lines
/// and arcs must form closed contours (`contours`), checked here so an open
/// profile is refused before anything is drawn.
pub fn sketch_entities(list: &[Value]) -> Result<(Vec<Entity>, Vec<std::ops::Range<usize>>), String> {
let mut out: Vec<Entity> = Vec::new();
let mut ranges = Vec::with_capacity(list.len());
for (i, e) in list.iter().enumerate() {
let start = out.len();
let found = ENTITY_KINDS.iter().find_map(|k| e.get(*k).filter(|b| b.is_object()).map(|b| (*k, b)));
let (kind, body) = match found {
Some(f) => f,
None => match e.get("type").and_then(|t| t.as_str()).and_then(|t| ENTITY_KINDS.iter().find(|k| **k == t)) {
Some(k) => (*k, e),
None => return Err(format!("entity {}: each entity is one of {}, e.g. {{\"line\": {{\"from\": [0, 0], \"to\": [10, 0]}}}}", i + 1, ENTITY_KINDS.join(", "))),
},
};
let construction = flag(body, "construction", false) || kind == "centerline";
let made = match kind {
"rect" => rect_entities(body),
"circle" => circle_entities(body),
"line" | "centerline" => line_entities(body, construction),
"polyline" => polyline_entities(body, construction),
"arc" => arc_entities(body, construction),
"arc3" => arc3_entities(body, construction),
"tangentArc" => tangent_arc_entities(body, construction, out.last()),
"slot" => slot_entities(body, construction),
"sketchFillet" => corner_entities(body, &mut out, true, construction),
"sketchChamfer" => corner_entities(body, &mut out, false, construction),
_ => polygon_entities(body, construction),
}
.map_err(|m| format!("entity {} ({kind}): {m}", i + 1))?;
out.extend(made);
ranges.push(start..out.len());
}
if out.len() > 5000 {
return Err(format!("a sketch may draw at most 5000 entities; this one expands to {}", out.len()));
}
if let Err((at, msg)) = contours(&out) {
let caller = ranges.iter().position(|r| r.contains(&at)).unwrap_or(0);
return Err(format!("entity {}: {msg}", caller + 1));
}
Ok((out, ranges))
}
/// An arc's centre, radius, start angle and SIGNED sweep (radians; positive
/// counter-clockwise).
pub fn arc_geom(e: &Entity) -> Option<(f64, f64, f64, f64, f64)> {
let Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, .. } = *e else { return None };
let r = (x0 - cx).hypot(y0 - cy);
let t0 = (y0 - cy).atan2(x0 - cx);
let t1 = (y1 - cy).atan2(x1 - cx);
let tau = std::f64::consts::TAU;
let mut sweep = if ccw { (t1 - t0).rem_euclid(tau) } else { -((t0 - t1).rem_euclid(tau)) };
if sweep.abs() < 1e-12 {
sweep = if ccw { tau } else { -tau };
}
Some((cx, cy, r, t0, sweep))
}
/// Is the angle `phi` on an arc that starts at `t0` and sweeps `sweep`?
pub fn on_sweep(phi: f64, t0: f64, sweep: f64) -> bool {
let tau = std::f64::consts::TAU;
if sweep >= 0.0 { (phi - t0).rem_euclid(tau) <= sweep + 1e-12 } else { (t0 - phi).rem_euclid(tau) <= -sweep + 1e-12 }
}
/// The least and greatest of `f(x, y) = a·x + b·y` over an entity (its
/// extent across a direction): a revolve's side check, a bounding box.
pub fn extent_along(e: &Entity, a: f64, b: f64) -> (f64, f64) {
let f = |x: f64, y: f64| a * x + b * y;
match *e {
Entity::Rect { x0, y0, x1, y1 } => {
let v = [f(x0, y0), f(x0, y1), f(x1, y0), f(x1, y1)];
(v.iter().cloned().fold(f64::MAX, f64::min), v.iter().cloned().fold(f64::MIN, f64::max))
}
Entity::Circle { cx, cy, r } => {
let k = r * a.hypot(b);
(f(cx, cy) - k, f(cx, cy) + k)
}
Entity::Line { x0, y0, x1, y1, .. } => (f(x0, y0).min(f(x1, y1)), f(x0, y0).max(f(x1, y1))),
Entity::Arc { x0, y0, x1, y1, .. } => {
let (cx, cy, r, t0, sweep) = arc_geom(e).unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0));
let mut lo = f(x0, y0).min(f(x1, y1));
let mut hi = f(x0, y0).max(f(x1, y1));
let phi = b.atan2(a);
if on_sweep(phi, t0, sweep) {
hi = hi.max(f(cx, cy) + r * a.hypot(b));
}
if on_sweep(phi + std::f64::consts::PI, t0, sweep) {
lo = lo.min(f(cx, cy) - r * a.hypot(b));
}
(lo, hi)
}
}
}
fn circle(v: &Value) -> Result<Entity, String> {
let r = positive_mm(v, "radiusMm").or_else(|e| {
v.get("diameterMm").map(|_| positive_mm(v, "diameterMm").map(|d| d / 2.0)).unwrap_or(Err(e))
})?;
let (cx, cy) = match center2(v)? {
Some(c) => c,
None => (mm(v, "xMm", Some(0.0))?, mm(v, "yMm", Some(0.0))?),
};
Ok(Entity::Circle { cx, cy, r })
}
/// A face given as `{"face": {"atPointMm": [...]}}` or `{"atPointMm": [...]}`.
fn face_point(v: &Value, what: &str) -> Result<[f64; 3], String> {
let p = v.get("face").and_then(|f| f.get("atPointMm")).or_else(|| v.get("atPointMm"))
.ok_or_else(|| format!("{what} needs the face: {{\"face\": {{\"atPointMm\": [x, y, z]}}}} (mm, a point ON the face)"))?;
point3(p, &format!("{what}.atPointMm"))
}
/// `{"offsetEntities": {"fromFace": {"atPointMm": [x, y, z]}, "offsetMm": -1,
/// "loops": "outer" | "all", "chain": true}}` (0.11.4).
fn offset_entities(v: &Value) -> Result<OffsetFace, String> {
if !v.is_object() {
return Err("offsetEntities is {\"fromFace\": {\"atPointMm\": [x, y, z]}, \"offsetMm\": -1 (negative = inward)}".into());
}
let from = v.get("fromFace").filter(|f| !f.is_null()).ok_or_else(|| "offsetEntities needs fromFace: {\"atPointMm\": [x, y, z]} (mm, a point ON the face)".to_string())?;
let point = face_point(from, "fromFace")?;
let offset = mm(v, "offsetMm", None)?;
if offset == 0.0 {
return Err("offsetMm must not be 0 (negative offsets INTO the face, positive away from it)".into());
}
if offset.abs() > 1.0 {
return Err("offsetMm must be within ±1000 mm".into());
}
let loops = match v.get("loops").and_then(|l| l.as_str()) {
None | Some("outer") => OffsetLoops::Outer,
Some("all") => OffsetLoops::All,
Some(other) => return Err(format!("loops is \"outer\" (the face's outer loop, the default) or \"all\" (every loop: the face selected), got {other:?}")),
};
Ok(OffsetFace { point, offset, loops, chain: flag(v, "chain", true) })
}
/// "entity N …" from `sketch_entities` over the DRAWN entities, renumbered
/// as the caller wrote the list (the offsets taken out of it).
fn renumber_entity(m: &str, written_at: &[usize]) -> String {
let Some(rest) = m.strip_prefix("entity ") else { return m.to_string() };
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
match digits.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).and_then(|k| written_at.get(k)) {
Some(at) => format!("entity {}{}", at + 1, &rest[digits.len()..]),
None => m.to_string(),
}
}
/// `regionsAtMm: [[u, v], ...]` (or one `regionAtMm: [u, v]`): the sketch
/// regions to extrude, by a point inside each (0.11.4).
fn regions(step: &Value) -> Result<Vec<[f64; 2]>, String> {
let list: Vec<Value> = match (step.get("regionsAtMm").filter(|r| !r.is_null()), step.get("regionAtMm").filter(|r| !r.is_null())) {
(None, None) => return Ok(Vec::new()),
(Some(_), Some(_)) => return Err("give regionsAtMm OR regionAtMm, not both".into()),
(Some(r), None) => r.as_array().filter(|a| !a.is_empty()).cloned().ok_or_else(|| "regionsAtMm must be a non-empty list of [u, v] points (mm, the sketch's coordinates)".to_string())?,
(None, Some(r)) => vec![r.clone()],
};
if list.len() > 32 {
return Err("at most 32 regions".into());
}
list.iter()
.map(|p| need_pt2(&serde_json::json!({"p": p}), &["p"]).map(|(u, v)| [u, v]).map_err(|_| "each region is a point [u, v] (mm) inside it, in the sketch's coordinates".to_string()))
.collect()
}
/// The end condition other than a depth, if one is given.
fn up_to(step: &Value) -> Result<Option<UpTo>, String> {
let mut found: Vec<UpTo> = Vec::new();
match step.get("endCondition").and_then(|e| e.as_str()) {
None | Some("blind") | Some("throughAll") => {}
Some("upToNext") => found.push(UpTo::Next),
Some(other) => {
return Err(format!(
"endCondition must be \"blind\", \"throughAll\" or \"upToNext\" (got {other:?}); up to a face is upToSurface: {{\"face\": {{\"atPointMm\": [x,y,z]}}}}"
))
}
}
if flag(step, "upToNext", false) {
found.push(UpTo::Next);
}
if let Some(u) = step.get("upToSurface").filter(|u| !u.is_null()) {
found.push(UpTo::Surface { point: face_point(u, "upToSurface")? });
}
if let Some(o) = step.get("offsetFromSurface").filter(|u| !u.is_null()) {
let offset = mm(o, "distanceMm", None).map_err(|e| format!("offsetFromSurface.{e}"))?;
if offset < 0.0 {
return Err("offsetFromSurface.distanceMm must not be negative (reverseOffset:true puts it beyond the face)".into());
}
found.push(UpTo::OffsetFrom { point: face_point(o, "offsetFromSurface")?, offset, reverse_offset: flag(o, "reverseOffset", false) });
}
found.dedup();
match found.len() {
0 => Ok(None),
1 => Ok(found.pop()),
_ => Err("give ONE end condition: upToNext, upToSurface or offsetFromSurface".into()),
}
}
fn draft(step: &Value) -> Result<Option<Draft>, String> {
match step.get("draftDeg").filter(|d| !d.is_null()) {
None => Ok(None),
Some(d) => {
let a = d.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a < 90.0).ok_or_else(|| "draftDeg must be between 0 and 90 (exclusive)".to_string())?;
Ok(Some(Draft { angle: a.to_radians(), outward: flag(step, "draftOutward", false) }))
}
}
}
fn extent(step: &Value, is_cut: bool) -> Result<Extent, String> {
let up = up_to(step)?;
let draft = draft(step)?;
if let Some(u) = up {
if flag(step, "throughAll", false) {
return Err("throughAll and an up-to end condition exclude each other".into());
}
if step.get("depthMm").map(|d| !d.is_null()).unwrap_or(false) {
return Err("depthMm has no meaning with an up-to end condition — the face decides where it ends".into());
}
if step.get("direction").and_then(|d| d.as_str()).map(|d| d != "one").unwrap_or(false) || flag(step, "midPlane", false) {
return Err("an up-to end condition goes ONE way (the bridge does not build two-direction up-to extents)".into());
}
let depth = if let UpTo::OffsetFrom { offset, .. } = u { offset } else { 0.0 };
return Ok(Extent { depth, depth2: depth, through_all: false, reverse: flag(step, "reverse", false), direction: Direction::One, up_to: Some(u), draft, regions: regions(step)? });
}
let through_all = flag(step, "throughAll", false);
let mid = flag(step, "midPlane", false);
let mut direction = match step.get("direction").and_then(|d| d.as_str()) {
None => Direction::One,
Some("one") => Direction::One,
Some("both") => Direction::Both,
Some("midPlane") | Some("mid_plane") | Some("midplane") => Direction::MidPlane,
Some(other) => return Err(format!("direction must be \"one\", \"both\" or \"midPlane\", got {other:?}")),
};
if mid {
direction = Direction::MidPlane;
}
if through_all && direction == Direction::MidPlane {
// "Through All - Both" is SOLIDWORKS' own name for this.
direction = Direction::Both;
}
let depth = if through_all {
mm(step, "depthMm", Some(0.0))?
} else if is_cut && step.get("depthMm").is_none() {
return Err("cut needs depthMm (mm), or throughAll:true".into());
} else {
positive_mm(step, "depthMm")?
};
let depth2 = match step.get("depth2Mm") {
Some(_) => positive_mm(step, "depth2Mm")?,
None => depth,
};
Ok(Extent { depth, depth2, through_all, reverse: flag(step, "reverse", false), direction, up_to: None, draft, regions: regions(step)? })
}
fn edges(step: &Value) -> Result<EdgeSel, String> {
let e = step.get("edges").ok_or_else(|| "edges is required: {\"atPointsMm\": [[x,y,z], ...]} or {\"allOfFeature\": \"Boss-Extrude1\" | \"last\"}".to_string())?;
if let Some(f) = e.get("allOfFeature").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()) {
return Ok(EdgeSel::AllOfFeature(f.to_string()));
}
Ok(EdgeSel::AtPoints(points3(e.get("atPointsMm"), "edges.atPointsMm")?))
}
fn axis(v: Option<&Value>, what: &str) -> Result<Axis, String> {
match v.and_then(|a| a.as_str()).map(|a| a.to_ascii_lowercase()) {
Some(a) if a == "x" => Ok(Axis::X),
Some(a) if a == "y" => Ok(Axis::Y),
Some(a) if a == "z" => Ok(Axis::Z),
_ => Err(format!("{what} must be \"x\", \"y\" or \"z\" (a model axis)")),
}
}
fn count(v: &Value, key: &str) -> Result<u32, String> {
let n = v.get(key).and_then(|c| c.as_u64()).ok_or_else(|| format!("{key} (a whole number of instances, including the original) is required"))?;
if !(2..=1000).contains(&n) {
return Err(format!("{key} must be between 2 and 1000"));
}
Ok(n as u32)
}
/// `visible` on new_part / new_assembly: a boolean, default true (a user's
/// script shows its part).
fn visible_arg(step: &Value) -> Result<bool, String> {
match step.get("visible") {
None | Some(Value::Null) => Ok(true),
Some(Value::Bool(b)) => Ok(*b),
Some(other) => Err(format!("visible must be true or false, got {other}")),
}
}
fn feature_ref(step: &Value) -> String {
step.get("feature").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()).unwrap_or("last").to_string()
}
/// `revolve` / `revolve_cut`: `axis` is "x" | "y" (the sketch's own axes),
/// {"entity": i} (the sketch's i-th entity as written, 0-based — a line) or
/// {"line": [[x, y], [x, y]]}; `angleDeg` (default 360), `midPlane`, `reverse`.
fn revolution(step: &Value) -> Result<Revolution, String> {
let angle = match step.get("angleDeg").filter(|a| !a.is_null()) {
None => 360.0,
Some(a) => a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a <= 360.0).ok_or_else(|| "angleDeg must be in (0, 360]".to_string())?,
};
let axis_spec = match step.get("axis") {
Some(Value::String(a)) if a.eq_ignore_ascii_case("x") => AxisSpec::SketchX,
Some(Value::String(a)) if a.eq_ignore_ascii_case("y") => AxisSpec::SketchY,
Some(o @ Value::Object(_)) if o.get("entity").is_some() => AxisSpec::Entity(
o.get("entity").and_then(|e| e.as_u64()).ok_or_else(|| "axis.entity is the 0-based index of a line in the sketch".to_string())? as usize,
),
Some(o @ Value::Object(_)) if o.get("line").is_some() => {
let l = o.get("line").and_then(|l| l.as_array()).filter(|l| l.len() == 2).ok_or_else(|| "axis.line must be [[x, y], [x, y]] (mm)".to_string())?;
let a = need_pt2(&serde_json::json!({"p": l[0]}), &["p"]).map_err(|e| format!("axis.line: {e}"))?;
let b = need_pt2(&serde_json::json!({"p": l[1]}), &["p"]).map_err(|e| format!("axis.line: {e}"))?;
if dist(a, b) <= JOIN_TOL {
return Err("axis.line's two points are the same point".into());
}
AxisSpec::Line([a.0, a.1], [b.0, b.1])
}
_ => return Err("axis is required: \"x\" or \"y\" (the sketch's own axes), {\"entity\": i} (a line of the sketch, 0-based) or {\"line\": [[x, y], [x, y]]} (mm)".into()),
};
let mid_plane = flag(step, "midPlane", false) || step.get("direction").and_then(|d| d.as_str()) == Some("midPlane");
Ok(Revolution { axis: usize::MAX, axis_spec, angle: angle.to_radians(), mid_plane, reverse: flag(step, "reverse", false) })
}
fn mirror(step: &Value) -> Result<Op, String> {
if step.get("plane").map(|p| p.is_null()).unwrap_or(true) && step.get("face").map(|p| p.is_null()).unwrap_or(true) {
return Err("mirror needs the plane: \"plane\": \"Right Plane\" | {offsetFrom, offsetMm}, or \"face\": {\"atPointMm\": [x,y,z]} (a planar face)".into());
}
let plane = plane_ref(step)?;
let what = step.get("featureOrBody").or_else(|| step.get("features")).or_else(|| step.get("feature"));
let mut features: Vec<String> = match what {
None | Some(Value::Null) => vec!["last".into()],
Some(Value::String(f)) if !f.trim().is_empty() => vec![f.clone()],
Some(Value::Array(a)) if !a.is_empty() => a
.iter()
.map(|f| f.as_str().filter(|s| !s.trim().is_empty()).map(String::from).ok_or_else(|| "features must be feature names".to_string()))
.collect::<Result<_, _>>()?,
_ => return Err("features is \"last\", a feature name, a list of names, or \"all\" (every body)".into()),
};
let mut bodies = flag(step, "mirrorBody", false);
if features.iter().any(|f| f == "all") {
bodies = true;
}
if bodies {
features.clear();
}
Ok(Op::Mirror { features, bodies, plane })
}
fn hole(step: &Value) -> Result<Op, String> {
let at = point3(step.get("atMm").or_else(|| step.get("at")).ok_or_else(|| "hole needs atMm: [x, y, z] (mm), a point ON a planar face — the hole's centre".to_string())?, "atMm")?;
let diameter = positive_mm(step, "diameterMm")?;
let through = flag(step, "throughAll", false);
let depth = match (through, step.get("depthMm").filter(|d| !d.is_null())) {
(true, None) => None,
(false, Some(_)) => Some(positive_mm(step, "depthMm")?),
(true, Some(_)) => return Err("give depthMm OR throughAll:true, not both".into()),
(false, None) => return Err("hole needs depthMm (mm), or throughAll:true".into()),
};
let counterbore = match step.get("counterbore").filter(|c| !c.is_null()) {
None => None,
Some(c) => {
let d = positive_mm(c, "diameterMm").map_err(|e| format!("counterbore.{e}"))?;
let h = positive_mm(c, "depthMm").map_err(|e| format!("counterbore.{e}"))?;
if d <= diameter {
return Err("counterbore.diameterMm must be larger than the hole's diameterMm".into());
}
if depth.map(|t| h >= t).unwrap_or(false) {
return Err("counterbore.depthMm must be less than the hole's depthMm".into());
}
Some((d, h))
}
};
let countersink = match step.get("countersink").filter(|c| !c.is_null()) {
None => None,
Some(c) => {
let d = positive_mm(c, "diameterMm").map_err(|e| format!("countersink.{e}"))?;
if d <= diameter {
return Err("countersink.diameterMm must be larger than the hole's diameterMm".into());
}
let a = c.get("angleDeg").filter(|a| !a.is_null()).map(|a| a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a < 180.0)
.ok_or_else(|| "countersink.angleDeg (the included angle, e.g. 82 or 90) must be in (0, 180)".to_string())).transpose()?.unwrap_or(90.0).to_radians();
if let Some(t) = depth {
if countersink_depth(diameter, d, a) >= t {
return Err("the countersink is deeper than the hole".into());
}
}
Some((d, a))
}
};
if counterbore.is_some() && countersink.is_some() {
return Err("a hole has a counterbore OR a countersink, not both".into());
}
Ok(Op::Hole { at, diameter, depth, counterbore, countersink })
}
/// THE HOLE AS FEATURES (0.11.0), shared by both backends: the face's plane
/// is `base` (a default plane) at height `h` along its normal, the face looks
/// along +normal when `outward_is_normal`. Circles at the hole's centre are
/// sketched on that plane (an OFFSET plane where h ≠ 0 — it outlives the cuts,
/// unlike the face) and cut into the material: a counterbore (a blind cut) or
/// a countersink (a cut drafted INWARD at half its included angle, so it
/// narrows to the hole's diameter) first, then the hole from where that ends.
#[allow(clippy::too_many_arguments)]
pub fn hole_plan(
frame: &crate::modeling::PlaneFrame,
base: &str,
h: f64,
outward_is_normal: bool,
at: [f64; 3],
diameter: f64,
depth: Option<f64>,
counterbore: Option<(f64, f64)>,
countersink: Option<(f64, f64)>,
) -> Vec<Op> {
use crate::modeling::{dot, sub};
let rel = sub(at, frame.origin);
let (u, v) = (dot(rel, frame.x), dot(rel, frame.y));
// A cut from a plane goes against the plane's normal: reversed when the
// face looks the other way.
let reverse = !outward_is_normal;
let inward = if outward_is_normal { -1.0 } else { 1.0 };
let plane_at = |hh: f64| if hh.abs() < 1e-12 { PlaneRef::Named(base.to_string()) } else { PlaneRef::Offset { from: base.to_string(), offset: hh } };
let sketch = |hh: f64, r: f64| Op::Sketch { shorthand: "sketch", plane: plane_at(hh), entities: vec![Entity::Circle { cx: u, cy: v, r }], offsets: Vec::new() };
let blind = |t: f64| Extent { reverse, ..Extent::blind(t) };
let mut ops = Vec::new();
let mut start = h;
let mut left = depth;
if let Some((dcb, hcb)) = counterbore {
ops.push(sketch(h, dcb / 2.0));
ops.push(Op::Cut(blind(hcb)));
start = h + inward * hcb;
left = left.map(|t| t - hcb);
}
if let Some((dcs, angle)) = countersink {
let hcs = countersink_depth(diameter, dcs, angle);
ops.push(sketch(h, dcs / 2.0));
ops.push(Op::Cut(Extent { draft: Some(Draft { angle: angle / 2.0, outward: false }), ..blind(hcs) }));
start = h + inward * hcs;
left = left.map(|t| t - hcs);
}
ops.push(sketch(start, diameter / 2.0));
ops.push(Op::Cut(match left {
Some(t) => blind(t),
None => Extent { through_all: true, ..blind(0.0) },
}));
ops
}
/// How deep a countersink of diameter `dcs` and included angle `angle`
/// (radians) goes on a hole of diameter `d`: (dcs − d) / (2·tan(angle/2)).
pub fn countersink_depth(d: f64, dcs: f64, angle: f64) -> f64 {
(dcs - d) / (2.0 * (angle / 2.0).tan())
}
fn parse_step(step: &Value) -> Result<Op, String> {
let op = step
.get("op")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("missing `op` (one of: {})", OPS.join(", ")))?;
Ok(match op {
"new_part" => Op::NewPart { visible: visible_arg(step)? },
"new_assembly" => Op::NewAssembly { visible: visible_arg(step)? },
"clear_part" => Op::ClearPart,
"sketch_rect" => Op::Sketch { shorthand: "sketch_rect", plane: plane_ref(step)?, entities: rect_entities(step)?, offsets: Vec::new() },
"sketch_circle" => Op::Sketch { shorthand: "sketch_circle", plane: plane_ref(step)?, entities: vec![circle(step)?], offsets: Vec::new() },
"sketch" => {
let list = step.get("entities").and_then(|e| e.as_array()).filter(|a| !a.is_empty())
.ok_or_else(|| format!("sketch needs entities: a non-empty list, each one of {}, or offsetEntities", ENTITY_KINDS.join(", ")))?;
if list.len() > 500 {
return Err("a sketch may hold at most 500 entities".into());
}
// OFFSET ENTITIES (0.11.4) are the MODEL's edges, drawn by
// SOLIDWORKS: kept apart from the entities the bridge draws.
let mut offsets = Vec::new();
let mut drawn: Vec<Value> = Vec::new();
// Where each drawn entity stands in the caller's list, so an error
// names the entity the caller wrote (offsets are taken out first).
let mut written_at: Vec<usize> = Vec::new();
for (i, e) in list.iter().enumerate() {
match e.get("offsetEntities") {
Some(o) => offsets.push(offset_entities(o).map_err(|m| format!("entity {} (offsetEntities): {m}", i + 1))?),
None => {
drawn.push(e.clone());
written_at.push(i);
}
}
}
if offsets.len() > 8 {
return Err("a sketch may hold at most 8 offsetEntities".into());
}
let entities = if drawn.is_empty() {
Vec::new()
} else {
sketch_entities(&drawn).map_err(|m| renumber_entity(&m, &written_at))?.0
};
if offsets.is_empty() && !entities.iter().any(|e| !e.is_construction()) {
return Err("the sketch has only construction geometry — there is no profile".into());
}
Op::Sketch { shorthand: "sketch", plane: plane_ref(step)?, entities, offsets }
}
"extrude" => Op::Extrude(extent(step, false)?),
"cut" => Op::Cut(extent(step, true)?),
"revolve" => Op::Revolve(revolution(step)?),
"revolve_cut" => Op::RevolveCut(revolution(step)?),
"mirror" => mirror(step)?,
"hole" => hole(step)?,
"set_material" => {
let density = match step.get("densityKgM3").filter(|d| !d.is_null()) {
None => None,
Some(d) => Some(d.as_f64().filter(|d| d.is_finite() && *d > 0.0 && *d < 100_000.0)
.ok_or_else(|| "densityKgM3 must be a positive number of kg/m³ (e.g. 2700 for aluminium)".to_string())?),
};
let name = match (text(step, "name"), density) {
(Ok(n), _) => n,
(Err(_), Some(_)) => String::new(),
(Err(e), None) => return Err(format!("{e} (or densityKgM3 alone, to set the part's density)")),
};
Op::SetMaterial {
name,
database: step
.get("database")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("SOLIDWORKS Materials.sldmat")
.to_string(),
density,
}
}
"set_appearance" => {
let rgb = step.get("rgb").and_then(|v| v.as_array()).filter(|a| a.len() == 3)
.ok_or_else(|| "rgb must be [r, g, b], each 0..255".to_string())?;
let mut c = [0.0; 3];
for (i, v) in rgb.iter().enumerate() {
let x = v.as_f64().filter(|x| x.is_finite() && (0.0..=255.0).contains(x)).ok_or_else(|| "rgb values must be 0..255".to_string())?;
c[i] = x / 255.0;
}
let target = match step.get("target") {
None | Some(Value::Null) => AppearanceTarget::Part,
Some(t) => {
if let Some(b) = t.get("bodyName").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {
AppearanceTarget::Body(b.to_string())
} else if let Some(p) = t.get("faceAtPointMm") {
AppearanceTarget::FaceAt(point3(p, "target.faceAtPointMm")?)
} else if t.as_str() == Some("part") {
AppearanceTarget::Part
} else {
return Err("target is \"part\", {\"bodyName\": ...} or {\"faceAtPointMm\": [x,y,z]}".into());
}
}
};
Op::SetAppearance { rgb: c, target }
}
"fillet" => Op::Fillet { radius: positive_mm(step, "radiusMm")?, edges: edges(step)? },
"chamfer" => {
let angle = step.get("angleDeg").map(|a| a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a < 90.0)
.ok_or_else(|| "angleDeg must be between 0 and 90".to_string())).transpose()?.unwrap_or(45.0);
Op::Chamfer { distance: positive_mm(step, "distanceMm")?, angle: angle.to_radians(), edges: edges(step)? }
}
"shell" => Op::Shell {
thickness: positive_mm(step, "thicknessMm")?,
faces: points3(step.get("removeFacesAtPointsMm"), "removeFacesAtPointsMm")?,
outward: flag(step, "outward", false),
},
"linear_pattern" => {
let dirs = step.get("directions").and_then(|d| d.as_array()).filter(|a| !a.is_empty() && a.len() <= 2)
.ok_or_else(|| "directions: one or two of {\"axis\": \"x\"|\"y\"|\"z\", \"count\": n, \"spacingMm\": s}".to_string())?;
let mut out = Vec::new();
for (i, d) in dirs.iter().enumerate() {
let one = (|| -> Result<(Axis, u32, f64), String> {
Ok((axis(d.get("axis"), "axis")?, count(d, "count")?, mm(d, "spacingMm", None)?))
})()
.map_err(|m| format!("direction {}: {m}", i + 1))?;
if one.2 == 0.0 {
return Err(format!("direction {}: spacingMm must not be zero", i + 1));
}
out.push(one);
}
if out.len() == 2 && out[0].0 == out[1].0 {
return Err("the two directions must be different axes".into());
}
Op::LinearPattern { feature: feature_ref(step), dirs: out }
}
"circular_pattern" => {
let angle = step.get("angleDeg").map(|a| a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a <= 360.0)
.ok_or_else(|| "angleDeg must be in (0, 360]".to_string())).transpose()?.unwrap_or(360.0);
Op::CircularPattern { feature: feature_ref(step), axis: axis(step.get("axis"), "axis")?, count: count(step, "count")?, angle: angle.to_radians() }
}
"set_prop" => Op::SetProp {
name: text(step, "name")?,
value: match step.get("value") {
Some(Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
None => return Err("value is required".into()),
},
},
"save_as" => Op::SaveAs { path: safe_dest(&text(step, "path")?)? },
"rebuild" => Op::Rebuild,
"zoom_fit" => Op::ZoomFit,
other => {
return Err(format!("unknown op '{other}' (valid ops: {})", OPS.join(", ")))
}
})
}
/// The message for a modelling op aimed at a HIDDEN document.
pub const HIDDEN_DOCUMENT: &str = "SOLIDWORKS can select only in the active document, and a document \
created invisible cannot be activated (measured 2026-10-14) — so it cannot be modelled";
/// A script that makes a HIDDEN part and then models it: the step that would
/// fail mid-script, named up front (0.10.7).
pub fn hidden_conflict(ops: &[Op]) -> Option<String> {
let mut hidden = false;
for (i, op) in ops.iter().enumerate() {
match op {
Op::NewPart { visible } | Op::NewAssembly { visible } => hidden = !*visible,
_ if hidden && op.needs_selection() => {
return Some(format!(
"step {} ({}) would model a part created with visible:false: {HIDDEN_DOCUMENT}. Leave \
visible at its default (true) — to keep a battery's memory flat, reuse one document \
(reuseDocument) instead",
i + 1,
op.name()
))
}
_ => {}
}
}
None
}
/// A sketch op must come before the feature that consumes it, a feature needs
/// a document, and "last" needs a feature before it. Checked BEFORE the first
/// COM call so a script that cannot work never half-runs on the user's live
/// session — the difference between a clean refusal and a stray unsaved
/// document on their screen.
pub fn validate(ops: &[Op], has_open_doc: bool) -> Result<(), String> {
let mut doc = has_open_doc;
let mut sketch_pending = false;
// A feature to call "last": one made EARLIER IN THIS SCRIPT. (On an
// existing document, name the feature — swx_design_info lists them.)
let mut have_feature = false;
for (i, op) in ops.iter().enumerate() {
let at = i + 1;
match op {
Op::NewPart { .. } | Op::NewAssembly { .. } => {
doc = true;
have_feature = false;
continue;
}
Op::ClearPart if doc => {
// Every feature is gone: nothing to call "last", no sketch pending.
have_feature = false;
sketch_pending = false;
continue;
}
_ if !doc => {
return Err(format!(
"step {at} ({}) needs a document: start the script with new_part, or open one \
first with swx_open / swx_new_document",
op.name()
))
}
Op::Sketch { .. } => sketch_pending = true,
Op::Hole { .. } if sketch_pending => {
return Err(format!(
"step {at} (hole) comes between a sketch and the feature that consumes it — a hole draws its own sketches; \
extrude, cut or revolve the pending sketch first"
));
}
Op::Revolve(rv) | Op::RevolveCut(rv) if sketch_pending && rv.axis == usize::MAX => {
return Err(format!("step {at} ({}): its axis could not be placed in the sketch", op.name()));
}
Op::Extrude(_) | Op::Cut(_) | Op::Revolve(_) | Op::RevolveCut(_) => {
if !sketch_pending {
return Err(format!(
"step {at} ({}) has no sketch to consume — put a sketch, sketch_rect or \
sketch_circle before it (other ops may come between: the bridge re-selects \
the sketch by name)",
op.name()
));
}
sketch_pending = false;
}
Op::Fillet { edges: EdgeSel::AllOfFeature(f), .. } | Op::Chamfer { edges: EdgeSel::AllOfFeature(f), .. }
| Op::LinearPattern { feature: f, .. } | Op::CircularPattern { feature: f, .. }
if f == "last" && !have_feature =>
{
return Err(format!("step {at} ({}) refers to the \"last\" feature, and no feature comes before it", op.name()));
}
Op::Mirror { features, .. } if features.iter().any(|f| f == "last") && !have_feature => {
return Err(format!("step {at} (mirror) refers to the \"last\" feature, and no feature comes before it"));
}
_ => {}
}
if op.makes_feature() {
have_feature = true;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_the_documented_example_and_converts_mm_to_metres() {
let ops = parse(&json!([
{"op": "new_part"},
{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 50, "heightMm": 30},
{"op": "extrude", "depthMm": 20},
{"op": "set_material", "name": "6061 Alloy"},
{"op": "set_prop", "name": "PartNo", "value": "BRK-100"},
{"op": "save_as", "path": "C:\\p\\b.sldprt"}
]))
.unwrap();
assert_eq!(ops.len(), 6);
assert_eq!(
ops[1],
Op::Sketch {
shorthand: "sketch_rect",
plane: PlaneRef::Named("Top Plane".into()),
entities: vec![Entity::Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.03 }],
offsets: vec![]
}
);
assert_eq!(
ops[2],
Op::Extrude(Extent { depth: 0.02, depth2: 0.02, through_all: false, reverse: false, direction: Direction::One , up_to: None, draft: None, regions: Vec::new() })
);
assert_eq!(
ops[3],
Op::SetMaterial { name: "6061 Alloy".into(), database: "SOLIDWORKS Materials.sldmat".into(), density: None }
);
// a non-string prop value is stringified rather than refused
assert_eq!(parse(&json!([{"op":"set_prop","name":"Qty","value":4}])).unwrap()[0],
Op::SetProp { name: "Qty".into(), value: "4".into() });
}
#[test]
fn defaults_the_plane_and_the_sketch_origin() {
let ops = parse(&json!([{"op": "sketch_circle", "radiusMm": 5}])).unwrap();
assert_eq!(
ops[0],
Op::Sketch {
shorthand: "sketch_circle",
plane: PlaneRef::Named("Front Plane".into()),
entities: vec![Entity::Circle { cx: 0.0, cy: 0.0, r: 0.005 }],
offsets: vec![]
}
);
}
#[test]
fn refuses_what_it_cannot_honestly_run() {
// a code string: there is no scripting host, and saying so beats a COM error
assert!(parse(&json!("import adsk")).unwrap_err().contains("array of steps"));
assert!(parse(&json!([])).unwrap_err().contains("no steps"));
assert!(parse(&json!([{"op": "fly"}])).unwrap_err().contains("unknown op 'fly'"));
assert!(parse(&json!([{}])).unwrap_err().contains("missing `op`"));
// the step INDEX is in the message — a 20-step script needs to say which one
let e = parse(&json!([{"op": "new_part"}, {"op": "extrude"}])).unwrap_err();
assert!(e.starts_with("step 2:"), "{e}");
assert!(e.contains("depthMm"));
// NaN / infinity cannot become a dimension
assert!(parse(&json!([{"op": "extrude", "depthMm": "20"}])).unwrap_err().contains("depthMm"));
}
#[test]
fn validation_catches_the_two_orderings_that_cannot_work() {
let ops = parse(&json!([{"op": "extrude", "depthMm": 5}])).unwrap();
let e = validate(&ops, false).unwrap_err();
assert!(e.contains("needs a document"), "{e}");
// with a document already open, the same script fails for the OTHER reason
assert!(validate(&ops, true).unwrap_err().contains("no sketch to consume"));
let good = parse(&json!([
{"op": "new_part"},
{"op": "sketch_rect", "widthMm": 10, "heightMm": 10},
{"op": "extrude", "depthMm": 2}
]))
.unwrap();
assert!(validate(&good, false).is_ok());
// one sketch feeds exactly one feature
let two = parse(&json!([
{"op": "new_part"},
{"op": "sketch_rect", "widthMm": 10, "heightMm": 10},
{"op": "extrude", "depthMm": 2},
{"op": "extrude", "depthMm": 2}
]))
.unwrap();
assert!(validate(&two, false).is_err());
}
#[test]
fn save_as_paths_obey_the_same_rules_as_the_standalone_verb() {
// absolute is fine, on either platform's spelling
assert!(parse(&json!([{"op": "save_as", "path": "C:\\parts\\a.sldprt"}])).is_ok());
assert!(parse(&json!([{"op": "save_as", "path": "/tmp/a.sldprt"}])).is_ok());
// relative would resolve against the bridge's cwd — the exact bypass
// that let a script write outside the destination the caller believed in
let e = parse(&json!([{"op": "save_as", "path": "RELATIVE-BYPASS.sldprt"}])).unwrap_err();
assert!(e.contains("ABSOLUTE"), "{e}");
assert!(parse(&json!([{"op": "save_as", "path": "/tmp/../etc/x"}])).unwrap_err().contains(".."));
// ...and the format that HANGS SOLIDWORKS cannot sneak in by filename
let e = parse(&json!([{"op": "save_as", "path": "C:\\x\\a.WRL"}])).unwrap_err();
assert!(e.contains("VRML"), "{e}");
}
#[test]
fn a_hostile_destination_cannot_smuggle_the_blocked_extension_past() {
// Windows strips trailing dots and spaces and ignores an ADS suffix, so
// every one of these opens `a.wrl`. A raw string compare admitted all
// three.
for bad in [r"C:\x\a.wrl.", "C:\\x\\a.wrl ", r"C:\x\a.wrl::$DATA", r"C:\x\a.WRL",
r"C:\x\a.Wrl.. "] {
let e = parse(&json!([{"op": "save_as", "path": bad}])).unwrap_err();
assert!(e.contains("VRML"), "{bad} should have been refused, got: {e}");
}
// ...and a legitimate destination still works.
assert!(parse(&json!([{"op": "save_as", "path": r"C:\x\a.sldprt"}])).is_ok());
}
#[test]
fn the_blocked_extension_list_matches_the_verb_layers() {
// Two lists, one rule: if these ever drift, the DSL becomes a bypass for
// the guard the verb layer enforces.
let mut a: Vec<&str> = BLOCKED_DEST_EXTENSIONS.to_vec();
let mut b: Vec<&str> = crate::verbs3::BLOCKED_FORMATS.to_vec();
a.sort();
b.sort();
assert_eq!(a, b, "script::BLOCKED_DEST_EXTENSIONS must match verbs3::BLOCKED_FORMATS");
}
#[test]
fn components_parse_in_both_shapes_and_convert_mm_to_metres() {
// the array shape
let c = parse_components(&json!({"components": [
{"filePath": "C:\\p\\a.sldprt", "xMm": 100, "yMm": -25.4, "zMm": 0},
{"filePath": "C:\\p\\b.sldprt", "configuration": "Long"}
]}))
.unwrap();
assert_eq!(c.len(), 2);
assert_eq!(
c[0],
Component {
file: "C:\\p\\a.sldprt".into(),
x: 0.1,
y: -0.0254,
z: 0.0,
rx: 0.0,
ry: 0.0,
rz: 0.0,
config: String::new()
}
);
assert!(!c[0].rotated());
// omitted coordinates are the origin, not an error
assert_eq!(c[1].x, 0.0);
assert_eq!(c[1].config, "Long");
// the single-component shorthand, with the assembly named by `path`
let one = parse_components(&json!({"path": "C:\\p\\crate.sldasm", "filePath": "C:\\p\\a.sldprt", "zMm": 12.7})).unwrap();
assert_eq!(one.len(), 1);
assert_eq!(one[0].z, 0.0127);
// ...and `path` is NOT read as the component: it names the assembly.
assert_eq!(one[0].file, "C:\\p\\a.sldprt");
}
#[test]
fn components_refuse_what_would_land_somewhere_unintended() {
assert!(parse_components(&json!({"components": []})).unwrap_err().contains("empty"));
assert!(parse_components(&json!({"components": {"filePath": "C:\\a.sldprt"}}))
.unwrap_err()
.contains("ARRAY"));
assert!(parse_components(&json!({})).unwrap_err().contains("filePath is required"));
// the same two path rules the DSL's save_as enforces
assert!(parse_components(&json!({"filePath": "RELATIVE.sldprt"})).unwrap_err().contains("ABSOLUTE"));
assert!(parse_components(&json!({"filePath": "/tmp/../etc/a.sldprt"})).unwrap_err().contains(".."));
// a NaN coordinate cannot become a position
assert!(parse_components(&json!({"filePath": "C:\\a.sldprt", "xMm": "12"}))
.unwrap_err()
.contains("xMm"));
// and the step index is in the message
let e = parse_components(&json!({"components": [{"filePath": "C:\\a.sldprt"}, {}]})).unwrap_err();
assert!(e.starts_with("component 2:"), "{e}");
}
#[test]
fn a_component_transform_is_the_sixteen_doubles_solidworks_wants() {
let at = |x: f64, y: f64, z: f64, rz: f64| Component {
file: "C:\\a.sldprt".into(),
x,
y,
z,
rx: 0.0,
ry: 0.0,
rz: rz.to_radians(),
config: String::new(),
};
// No rotation: identity, the translation, and the scale.
let t = at(0.1, 0.2, 0.3, 0.0).transform();
assert_eq!(&t[..9], &[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!((t[9], t[10], t[11], t[12]), (0.1, 0.2, 0.3, 1.0));
assert_eq!(&t[13..], &[0.0, 0.0, 0.0]);
// A quarter turn about Z sends local +X to assembly +Y. Written
// COLUMN-major, so t[0..3] is where local +X ends up.
let t = at(0.0, 0.0, 0.0, 90.0).transform();
let round = |v: f64| (v * 1e9).round() / 1e9;
let col = |i: usize| [round(t[i * 3]), round(t[i * 3 + 1]), round(t[i * 3 + 2])];
assert_eq!(col(0), [0.0, 1.0, 0.0], "local +X becomes assembly +Y");
assert_eq!(col(1), [-1.0, 0.0, 0.0], "local +Y becomes assembly -X");
assert_eq!(col(2), [0.0, 0.0, 1.0], "Z is untouched");
// ...and a quarter turn about Y sends local +Z to assembly +X.
let mut lay = at(0.0, 0.0, 0.0, 0.0);
lay.ry = std::f64::consts::FRAC_PI_2;
let t = lay.transform();
let col = |i: usize| [round(t[i * 3]), round(t[i * 3 + 1]), round(t[i * 3 + 2])];
assert_eq!(col(2), [1.0, 0.0, 0.0], "local +Z becomes assembly +X");
// Degrees in, radians kept.
let c = parse_components(&json!({"filePath": "C:\\a.sldprt", "rotYDeg": 90})).unwrap();
assert_eq!(round(c[0].ry), round(std::f64::consts::FRAC_PI_2));
assert!(c[0].rotated());
assert!(parse_components(&json!({"filePath": "C:\\a.sldprt", "rotZDeg": "90"}))
.unwrap_err()
.contains("rotZDeg"));
}
#[test]
fn an_inserted_run_is_found_by_matching_the_path_sequence() {
let c = |f: &str| Component {
file: f.into(),
x: 0.0,
y: 0.0,
z: 0.0,
rx: 0.0,
ry: 0.0,
rz: 0.0,
config: String::new(),
};
let p = |v: &[&str]| -> Vec<String> { v.iter().map(|s| s.to_string()).collect() };
let reqs = vec![c("C:\\p\\a.sldprt"), c("C:\\p\\b.sldprt"), c("C:\\p\\a.sldprt")];
// appended at the end, in order
let paths = p(&["C:\\p\\z.sldprt", "C:\\p\\a.sldprt", "C:\\p\\b.sldprt", "C:\\p\\a.sldprt"]);
assert_eq!(locate_run(&paths, &reqs), Some((1, true)));
// prepended, newest first — the reversed run
let paths = p(&["C:\\p\\a.sldprt", "C:\\p\\b.sldprt", "C:\\p\\a.sldprt", "C:\\p\\z.sldprt"]);
assert_eq!(locate_run(&paths, &reqs), Some((0, true)));
let reqs2 = vec![c("C:\\p\\a.sldprt"), c("C:\\p\\b.sldprt")];
let paths = p(&["C:\\p\\b.sldprt", "C:\\p\\a.sldprt"]);
assert_eq!(locate_run(&paths, &reqs2), Some((0, false)));
// SOLIDWORKS uppercases the extension it reports back
let paths = p(&["C:\\p\\A.SLDPRT", "C:\\p\\B.SLDPRT"]);
assert_eq!(locate_run(&paths, &reqs2), Some((0, true)));
// NO MATCH is an answer, not a guess
assert_eq!(locate_run(&p(&["C:\\p\\z.sldprt", "C:\\p\\y.sldprt"]), &reqs2), None);
assert_eq!(locate_run(&p(&["C:\\p\\a.sldprt"]), &reqs2), None);
assert_eq!(locate_run(&p(&["C:\\p\\a.sldprt"]), &[]), None);
}
#[test]
fn an_object_wrapper_is_accepted_because_callers_write_both() {
let ops = parse(&json!({"steps": [{"op": "rebuild"}]})).unwrap();
assert_eq!(ops, vec![Op::Rebuild]);
assert_eq!(ops[0].name(), "rebuild");
assert!(OPS.contains(&"rebuild"));
}
// ── 0.10.0: issue #1 (Michael) ──────────────────────────────────────────
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-12
}
#[test]
fn offset_planes_faces_and_anchors_parse_into_exact_geometry() {
let ops = parse(&json!([
{"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 9.6}, "widthMm": 31.8, "heightMm": 15.8, "anchor": "center"},
{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 4, "centerMm": [5, 2]},
{"op": "sketch_circle", "face": {"atPointMm": [0, 9.6, 0]}, "diameterMm": 4.8},
{"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 0}, "widthMm": 1, "heightMm": 1}
]))
.unwrap();
match &ops[0] {
Op::Sketch { plane: PlaneRef::Offset { from, offset }, entities, .. } => {
assert_eq!(from, "Top Plane");
assert!(near(*offset, 0.0096));
match entities[0] {
Entity::Rect { x0, y0, x1, y1 } => assert!(near(x0, -0.0159) && near(y0, -0.0079) && near(x1, 0.0159) && near(y1, 0.0079)),
_ => panic!(),
}
}
other => panic!("{other:?}"),
}
match &ops[1] {
Op::Sketch { entities, .. } => match entities[0] {
Entity::Rect { x0, y0, x1, y1 } => assert!(near(x0, 0.0) && near(y0, 0.0) && near(x1, 0.01) && near(y1, 0.004)),
_ => panic!(),
},
_ => panic!(),
}
match &ops[2] {
Op::Sketch { plane: PlaneRef::Face { point }, entities, .. } => {
assert!(near(point[1], 0.0096));
assert_eq!(entities[0], Entity::Circle { cx: 0.0, cy: 0.0, r: 0.0024 });
}
other => panic!("{other:?}"),
}
// an offset of zero IS the base plane
assert!(matches!(&ops[3], Op::Sketch { plane: PlaneRef::Named(p), .. } if p == "Top Plane"));
// refusals
assert!(parse(&json!([{"op": "sketch_rect", "widthMm": 1, "heightMm": 1, "anchor": "middle"}])).unwrap_err().contains("anchor"));
assert!(parse(&json!([{"op": "sketch_rect", "widthMm": 0, "heightMm": 1}])).unwrap_err().contains("greater than zero"));
assert!(parse(&json!([{"op": "sketch_rect", "plane": "Top Plane", "face": {"atPointMm": [0, 0, 0]}, "widthMm": 1, "heightMm": 1}])).unwrap_err().contains("not both"));
assert!(parse(&json!([{"op": "sketch_circle", "face": {"atPointMm": [0, 0]}, "radiusMm": 1}])).unwrap_err().contains("[x, y, z]"));
}
#[test]
fn one_sketch_holds_many_profiles_and_extents_read_every_direction() {
let ops = parse(&json!([
{"op": "sketch", "plane": "Top Plane", "entities": [
{"rect": {"widthMm": 10, "heightMm": 10, "anchor": "center"}},
{"circle": {"radiusMm": 3.255}},
{"type": "circle", "radiusMm": 2.4}
]},
{"op": "extrude", "depthMm": 8.6, "direction": "both", "depth2Mm": 2},
{"op": "cut", "throughAll": true, "reverse": true},
{"op": "cut", "depthMm": 4, "midPlane": true},
{"op": "extrude", "throughAll": true, "midPlane": true}
]))
.unwrap();
assert!(matches!(&ops[0], Op::Sketch { shorthand: "sketch", entities, .. } if entities.len() == 3));
assert_eq!(ops[1], Op::Extrude(Extent { depth: 0.0086, depth2: 0.002, through_all: false, reverse: false, direction: Direction::Both , up_to: None, draft: None, regions: Vec::new() }));
assert_eq!(ops[2], Op::Cut(Extent { depth: 0.0, depth2: 0.0, through_all: true, reverse: true, direction: Direction::One , up_to: None, draft: None, regions: Vec::new() }));
assert!(matches!(&ops[3], Op::Cut(e) if e.direction == Direction::MidPlane && near(e.depth, 0.004)));
// through all + mid plane is SOLIDWORKS' "Through All - Both"
assert!(matches!(&ops[4], Op::Extrude(e) if e.direction == Direction::Both && e.through_all));
assert!(parse(&json!([{"op": "cut"}])).unwrap_err().contains("throughAll"));
assert!(parse(&json!([{"op": "sketch", "entities": []}])).unwrap_err().contains("non-empty"));
assert!(parse(&json!([{"op": "sketch", "entities": [{"triangle": {}}]}])).unwrap_err().starts_with("step 1: entity 1:"));
assert!(parse(&json!([{"op": "extrude", "depthMm": 1, "direction": "sideways"}])).unwrap_err().contains("direction"));
}
#[test]
fn appearance_fillet_chamfer_shell_and_patterns_parse() {
let ops = parse(&json!([
{"op": "set_appearance", "rgb": [200, 0, 0]},
{"op": "set_appearance", "rgb": [0, 0, 255], "target": {"faceAtPointMm": [0, 1, 0]}},
{"op": "fillet", "radiusMm": 0.5, "edges": {"allOfFeature": "last"}},
{"op": "chamfer", "distanceMm": 0.3, "edges": {"atPointsMm": [[1, 2, 3]]}},
{"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[0, 0, 0]]},
{"op": "linear_pattern", "directions": [{"axis": "x", "count": 4, "spacingMm": 8}, {"axis": "z", "count": 2, "spacingMm": -8}]},
{"op": "circular_pattern", "feature": "Boss-Extrude2", "axis": "y", "count": 6}
]))
.unwrap();
assert!(matches!(&ops[0], Op::SetAppearance { rgb, target: AppearanceTarget::Part } if near(rgb[0], 200.0 / 255.0) && rgb[1] == 0.0));
assert!(matches!(&ops[1], Op::SetAppearance { target: AppearanceTarget::FaceAt(p), .. } if near(p[1], 0.001)));
assert!(matches!(&ops[2], Op::Fillet { radius, edges: EdgeSel::AllOfFeature(f) } if near(*radius, 0.0005) && f == "last"));
assert!(matches!(&ops[3], Op::Chamfer { angle, .. } if near(*angle, std::f64::consts::FRAC_PI_4)));
assert!(matches!(&ops[4], Op::Shell { outward: false, .. }));
assert!(matches!(&ops[5], Op::LinearPattern { feature, dirs } if feature == "last" && dirs.len() == 2 && dirs[1].0 == Axis::Z && near(dirs[1].2, -0.008)));
assert!(matches!(&ops[6], Op::CircularPattern { axis: Axis::Y, count: 6, angle, .. } if near(*angle, std::f64::consts::TAU)));
assert!(ops.iter().filter(|o| o.makes_feature()).count() == 5);
assert!(parse(&json!([{"op": "set_appearance", "rgb": [300, 0, 0]}])).unwrap_err().contains("0..255"));
assert!(parse(&json!([{"op": "linear_pattern", "directions": [{"axis": "x", "count": 1, "spacingMm": 8}]}])).unwrap_err().contains("count"));
assert!(parse(&json!([{"op": "linear_pattern", "directions": [{"axis": "x", "count": 2, "spacingMm": 8}, {"axis": "x", "count": 2, "spacingMm": 8}]}])).unwrap_err().contains("different axes"));
assert!(parse(&json!([{"op": "fillet", "radiusMm": 1}])).unwrap_err().contains("edges"));
// "last" needs a feature before it
let bad = parse(&json!([{"op": "new_part"}, {"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "last"}}])).unwrap();
assert!(validate(&bad, false).unwrap_err().contains("\"last\""));
let good = parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 1, "heightMm": 1}, {"op": "extrude", "depthMm": 1},
{"op": "fillet", "radiusMm": 0.1, "edges": {"allOfFeature": "last"}}])).unwrap();
assert!(validate(&good, false).is_ok());
}
// ── 0.11.0: sketch geometry, end conditions, revolve, mirror, hole ─────
fn sketch_of(entities: Value) -> Result<Vec<Entity>, String> {
match parse(&json!([{"op": "sketch", "plane": "Front Plane", "entities": entities}]))?.remove(0) {
Op::Sketch { entities, .. } => Ok(entities),
_ => unreachable!(),
}
}
/// OFFSET ENTITIES AND SKETCH REGIONS (0.11.4): parsed, defaulted, and
/// refused for what would make them meaningless.
#[test]
fn offset_entities_and_regions_parse_and_refuse_what_they_must() {
let ops = parse(&json!([
{"op": "sketch", "face": {"atPointMm": [70, 10, 44]}, "entities": [
{"offsetEntities": {"fromFace": {"atPointMm": [70, 10, 44]}, "offsetMm": -1}},
{"circle": {"centerMm": [72, 28.7], "radiusMm": 42}}]},
{"op": "cut", "regionsAtMm": [[25, 0], [30, 1]], "offsetFromSurface": {"face": {"atPointMm": [30, 0, 0]}, "distanceMm": 12}},
{"op": "sketch", "plane": "Top Plane", "entities": [
{"offsetEntities": {"fromFace": {"atPointMm": [0, 10, 0]}, "offsetMm": 2, "loops": "all", "chain": false}}]},
{"op": "extrude", "depthMm": 3, "regionAtMm": [1, 2]}
]))
.unwrap();
let Op::Sketch { entities, offsets, .. } = &ops[0] else { panic!() };
assert_eq!(entities.len(), 1, "the circle is drawn by the bridge; the offset is not an entity");
assert_eq!(offsets, &vec![OffsetFace { point: [0.07, 0.01, 0.044], offset: -0.001, loops: OffsetLoops::Outer, chain: true }], "defaults: the outer loop, chained");
assert_eq!(offsets[0].describe()["offsetEntities"]["side"], "inward");
let Op::Cut(e) = &ops[1] else { panic!() };
assert_eq!(e.regions, vec![[0.025, 0.0], [0.03, 0.001]]);
assert!(matches!(e.up_to, Some(UpTo::OffsetFrom { .. })), "regions go with any end condition");
let Op::Sketch { entities, offsets, .. } = &ops[2] else { panic!() };
assert!(entities.is_empty(), "a sketch may hold nothing but an offset");
assert_eq!((offsets[0].loops, offsets[0].chain, offsets[0].offset), (OffsetLoops::All, false, 0.002));
assert_eq!(offsets[0].describe()["offsetEntities"]["side"], "outward");
let Op::Extrude(e) = &ops[3] else { panic!() };
assert_eq!(e.regions, vec![[0.001, 0.002]], "regionAtMm is one region");
// Refusals.
let one = |ent: Value| parse(&json!([{"op": "sketch", "entities": [ent]}])).unwrap_err();
assert!(one(json!({"offsetEntities": {"offsetMm": -1}})).contains("needs fromFace"));
assert!(one(json!({"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": 0}})).contains("must not be 0"));
assert!(one(json!({"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}}})).contains("offsetMm"));
assert!(one(json!({"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": -1, "loops": "inner"}})).contains("loops is"));
assert!(one(json!({"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": 2000}})).contains("1000"));
assert!(one(json!({"offsetEntities": 5})).starts_with("step 1: entity 1 (offsetEntities):"));
// An entity after an offset is numbered as the caller wrote it.
let e = parse(&json!([{"op": "sketch", "entities": [
{"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": -1}},
{"circle": {"radiusMm": 2}},
{"polyline": {"points": [[0, 0], [10, 0], [10, 10]]}}]}]))
.unwrap_err();
assert!(e.starts_with("step 1: entity 3"), "{e}");
assert_eq!(renumber_entity("entity 2 (polyline): a gap", &[1, 2]), "entity 3 (polyline): a gap");
assert_eq!(renumber_entity("entity 9: x", &[1]), "entity 9: x", "out of range: as it was");
assert_eq!(renumber_entity("no entity", &[1]), "no entity");
let cut = |extra: Value| {
let mut c = json!({"op": "cut", "depthMm": 1});
for (k, v) in extra.as_object().unwrap() {
c[k] = v.clone();
}
parse(&json!([{"op": "sketch_rect", "widthMm": 1, "heightMm": 1}, c])).unwrap_err()
};
assert!(cut(json!({"regionsAtMm": []})).contains("non-empty"));
assert!(cut(json!({"regionsAtMm": [[1, 2, 3]]})).contains("[u, v]"));
assert!(cut(json!({"regionsAtMm": [[1, 2]], "regionAtMm": [1, 2]})).contains("not both"));
// A sketch whose profile is SOLIDWORKS' own cannot be revolved: no side
// of an axis can be checked.
let e = parse(&json!([
{"op": "sketch", "entities": [{"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": -1}}]},
{"op": "revolve", "axis": "x"}
]))
.unwrap_err();
assert!(e.contains("cannot be revolved"), "{e}");
}
#[test]
fn an_open_contour_is_refused_naming_the_gap() {
let e = sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 10]]}}])).unwrap_err();
assert!(e.contains("entity 1") && e.contains("OPEN") && e.contains("[0.0, 10.0] mm") && e.contains("gap of 10.0 mm"), "{e}");
// Closed by its own `close`, or by a line back to the start: accepted.
assert_eq!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 10]], "close": true}}])).unwrap().len(), 4);
let joined = sketch_of(json!([
{"line": {"from": [0, 0], "to": [10, 0]}}, {"line": {"from": [10, 10], "to": [10, 0]}},
{"line": {"from": [10, 10], "to": [0, 0]}}
]))
.unwrap();
assert_eq!(contours(&joined).unwrap(), vec![vec![(0, false), (1, true), (2, false)]], "a reversed segment joins too");
// A gap of 1e-5 mm is a gap; 1e-7 mm is not.
assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 0.00001]]}}])).unwrap_err().contains("gap of 0.00001 mm"));
assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 0.0000001]]}}])).is_ok());
// A branch: three ends at one point.
let e = sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10]], "close": true}}, {"line": {"from": [0, 0], "to": [-5, 0]}}])).unwrap_err();
assert!(e.contains("3 segment ends meet"), "{e}");
// Construction geometry is never a contour, and a sketch of only it is refused.
assert!(sketch_of(json!([{"rect": {"widthMm": 10, "heightMm": 10}}, {"line": {"from": [0, 0], "to": [5, 5], "construction": true}}])).is_ok());
assert!(sketch_of(json!([{"centerline": {"from": [0, 0], "to": [5, 5]}}])).unwrap_err().contains("only construction"));
}
#[test]
fn arcs_three_ways_land_where_they_should() {
// arc: centre, radius, angles — counter-clockwise by default.
let a = sketch_of(json!([{"arc": {"center": [0, 0], "radiusMm": 10, "startDeg": 0, "endDeg": 180}}, {"line": {"from": [-10, 0], "to": [10, 0]}}])).unwrap();
let Entity::Arc { x0, y0, x1, y1, ccw, .. } = a[0] else { panic!() };
assert!((x0 - 0.01).abs() < 1e-15 && y0.abs() < 1e-15 && (x1 + 0.01).abs() < 1e-15 && y1.abs() < 1e-15 && ccw);
// arc3: the circumcentre, and the direction from the three points.
let b = sketch_of(json!([{"arc3": {"start": [10, 0], "mid": [0, 10], "end": [-10, 0]}}, {"line": {"from": [-10, 0], "to": [10, 0]}}])).unwrap();
let Entity::Arc { cx, cy, ccw, .. } = b[0] else { panic!() };
assert!(cx.abs() < 1e-15 && cy.abs() < 1e-15 && ccw);
let c = sketch_of(json!([{"arc3": {"start": [10, 0], "mid": [0, -10], "end": [-10, 0]}}, {"line": {"from": [-10, 0], "to": [10, 0]}}])).unwrap();
assert!(matches!(c[0], Entity::Arc { ccw: false, .. }), "below the chord: clockwise");
assert!(sketch_of(json!([{"arc3": {"start": [0, 0], "mid": [5, 0], "end": [10, 0]}}])).unwrap_err().contains("in a line"));
// tangentArc: from a line along +x ending at (20, 10): the quarter circle about (10, 10).
let t = sketch_of(json!([
{"line": {"from": [0, 0], "to": [10, 0]}}, {"tangentArc": {"to": [20, 10]}},
{"line": {"from": [20, 10], "to": [0, 10]}}, {"line": {"from": [0, 10], "to": [0, 0]}}
]))
.unwrap();
let Entity::Arc { cx, cy, ccw, .. } = t[1] else { panic!() };
assert!((cx - 0.01).abs() < 1e-15 && (cy - 0.01).abs() < 1e-15 && ccw, "{:?}", t[1]);
let e = sketch_of(json!([{"line": {"from": [0, 0], "to": [10, 0]}}, {"tangentArc": {"to": [20, 10], "radiusMm": 12}}])).unwrap_err();
assert!(e.contains("radius 10.0 mm"), "{e}");
assert!(sketch_of(json!([{"tangentArc": {"to": [20, 10]}}])).unwrap_err().contains("continues from the entity before it"));
assert!(sketch_of(json!([{"arc": {"center": [0, 0], "radiusMm": 5, "startDeg": 10, "endDeg": 370}}])).unwrap_err().contains("same angle"));
}
#[test]
fn composites_expand_to_lines_and_arcs() {
let s = sketch_of(json!([{"slot": {"center": [0, 0], "lengthMm": 20, "widthMm": 6}}])).unwrap();
assert_eq!(s.iter().filter(|e| matches!(e, Entity::Line { .. })).count(), 2);
assert_eq!(s.iter().filter(|e| matches!(e, Entity::Arc { .. })).count(), 2);
let p = sketch_of(json!([{"polygon": {"center": [0, 0], "sides": 6, "circumradiusMm": 10, "angleDeg": 90}}])).unwrap();
assert_eq!(p.len(), 6);
let Entity::Line { x0, y0, .. } = p[0] else { panic!() };
assert!(x0.abs() < 1e-15 && (y0 - 0.01).abs() < 1e-15, "the first vertex at 90°");
assert!(sketch_of(json!([{"polygon": {"center": [0, 0], "sides": 6}}])).unwrap_err().contains("ONE of circumradiusMm"));
// A construction rect: four construction lines; a construction circle: two arcs.
let c = sketch_of(json!([{"circle": {"radiusMm": 9}}, {"rect": {"widthMm": 4, "heightMm": 4, "construction": true}}, {"circle": {"radiusMm": 2, "construction": true}}])).unwrap();
assert_eq!(c.iter().filter(|e| e.is_construction()).count(), 6);
assert!(parse(&json!([{"op": "sketch_rect", "widthMm": 10, "heightMm": 4, "cornerRadiusMm": 2.5}])).unwrap_err().contains("more than half"));
// sketch_rect with cornerRadiusMm keeps its shorthand name.
assert_eq!(parse(&json!([{"op": "sketch_rect", "widthMm": 10, "heightMm": 4, "cornerRadiusMm": 1}])).unwrap()[0].name(), "sketch_rect");
}
#[test]
fn a_revolve_axis_is_resolved_into_its_sketch() {
let ops = parse(&json!([
{"op": "new_part"},
{"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 5, "yMm": 0, "widthMm": 5, "heightMm": 10}}]},
{"op": "revolve", "axis": "y"}
]))
.unwrap();
let Op::Sketch { entities, .. } = &ops[1] else { panic!() };
let Op::Revolve(rv) = &ops[2] else { panic!() };
assert_eq!(rv.axis, 1);
assert!(matches!(entities[1], Entity::Line { x0, x1, construction: true, .. } if x0 == 0.0 && x1 == 0.0), "{:?}", entities[1]);
assert_eq!(rv.angle, std::f64::consts::TAU);
// {entity: i} is the caller's i-th entity, after any expansion before it.
let ops = parse(&json!([
{"op": "new_part"},
{"op": "sketch", "entities": [{"polyline": {"points": [[0, 0], [5, 0], [5, 5], [0, 5]], "close": true}}, {"centerline": {"from": [-1, -2], "to": [-1, 9]}}]},
{"op": "revolve_cut", "axis": {"entity": 1}, "angleDeg": 90}
]))
.unwrap();
let Op::RevolveCut(rv) = &ops[2] else { panic!() };
assert_eq!(rv.axis, 4);
assert!(parse(&json!([{"op": "new_part"}, {"op": "sketch", "entities": [{"rect": {"widthMm": 5, "heightMm": 5}}]}, {"op": "revolve", "axis": {"entity": 0}}]))
.unwrap_err()
.contains("not a line"));
// A profile across the axis is refused before anything runs.
let e = parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 10, "heightMm": 5, "anchor": "center"}, {"op": "revolve", "axis": "y"}])).unwrap_err();
assert!(e.contains("step 3") && e.contains("crosses the revolve axis") && e.contains("5.0 mm"), "{e}");
// ...an ARC bulging across it too (its ends are both on one side).
let e = parse(&json!([{"op": "new_part"}, {"op": "sketch", "entities": [
{"line": {"from": [2, -5], "to": [2, 5]}}, {"arc": {"center": [4, 0], "radiusMm": 5.385164807134504, "startDeg": 111.80140948635182, "endDeg": 248.19859051364818}}]},
{"op": "revolve", "axis": "y"}])).unwrap_err();
assert!(e.contains("crosses"), "{e}");
assert!(parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 5, "heightMm": 5}, {"op": "revolve"}])).unwrap_err().contains("axis is required"));
assert!(validate(&parse(&json!([{"op": "new_part"}, {"op": "revolve", "axis": "x"}])).unwrap(), false).unwrap_err().contains("no sketch to consume"));
}
#[test]
fn end_conditions_and_draft_parse_and_refuse_their_conflicts() {
let e = |v: Value| parse(&json!([v])).map(|mut o| o.remove(0));
assert_eq!(e(json!({"op": "extrude", "upToNext": true})).unwrap(), Op::Extrude(Extent { up_to: Some(UpTo::Next), depth: 0.0, depth2: 0.0, ..Extent::blind(0.0) }));
assert_eq!(e(json!({"op": "cut", "endCondition": "upToNext"})).unwrap(), Op::Cut(Extent { up_to: Some(UpTo::Next), ..Extent::blind(0.0) }));
assert_eq!(
e(json!({"op": "extrude", "upToSurface": {"face": {"atPointMm": [1, 2, 3]}}})).unwrap(),
Op::Extrude(Extent { up_to: Some(UpTo::Surface { point: [0.001, 0.002, 0.003] }), ..Extent::blind(0.0) })
);
assert_eq!(
e(json!({"op": "cut", "offsetFromSurface": {"atPointMm": [0, 0, 0], "distanceMm": 2, "reverseOffset": true}, "reverse": true})).unwrap(),
Op::Cut(Extent { up_to: Some(UpTo::OffsetFrom { point: [0.0; 3], offset: 0.002, reverse_offset: true }), reverse: true, ..Extent::blind(0.002) })
);
assert!(e(json!({"op": "extrude", "upToNext": true, "throughAll": true})).unwrap_err().contains("exclude"));
assert!(e(json!({"op": "extrude", "upToNext": true, "depthMm": 5})).unwrap_err().contains("no meaning"));
assert!(e(json!({"op": "extrude", "upToNext": true, "direction": "both"})).unwrap_err().contains("ONE way"));
assert!(e(json!({"op": "extrude", "upToNext": true, "upToSurface": {"atPointMm": [0, 0, 0]}})).unwrap_err().contains("ONE end condition"));
assert!(e(json!({"op": "extrude", "endCondition": "upToVertex"})).unwrap_err().contains("endCondition"));
assert_eq!(e(json!({"op": "extrude", "depthMm": 5, "draftDeg": 3})).unwrap(), Op::Extrude(Extent { draft: Some(Draft { angle: 3f64.to_radians(), outward: false }), ..Extent::blind(0.005) }));
assert!(e(json!({"op": "extrude", "depthMm": 5, "draftDeg": 90})).unwrap_err().contains("draftDeg"));
assert_eq!(crate::modeling::end_condition_id(&UpTo::Next), 11);
assert_eq!(crate::modeling::end_condition_id(&UpTo::Surface { point: [0.0; 3] }), 4, "swEndCondUpToSurface (measured 2026-10-16; 10 stops at the next face)");
assert_eq!(crate::modeling::end_condition_id(&UpTo::OffsetFrom { point: [0.0; 3], offset: 0.0, reverse_offset: false }), 5);
}
#[test]
fn mirror_hole_and_density_parse() {
let one = |v: Value| parse(&json!([v])).map(|mut o| o.remove(0));
assert_eq!(one(json!({"op": "mirror", "plane": "Right Plane"})).unwrap(), Op::Mirror { features: vec!["last".into()], bodies: false, plane: PlaneRef::Named("Right Plane".into()) });
assert_eq!(one(json!({"op": "mirror", "plane": "Right Plane", "featureOrBody": "all"})).unwrap(), Op::Mirror { features: vec![], bodies: true, plane: PlaneRef::Named("Right Plane".into()) });
assert_eq!(
one(json!({"op": "mirror", "face": {"atPointMm": [0, 5, 0]}, "features": ["Boss-Extrude1", "Cut-Extrude1"]})).unwrap(),
Op::Mirror { features: vec!["Boss-Extrude1".into(), "Cut-Extrude1".into()], bodies: false, plane: PlaneRef::Face { point: [0.0, 0.005, 0.0] } }
);
assert!(one(json!({"op": "mirror"})).unwrap_err().contains("needs the plane"));
assert!(validate(&parse(&json!([{"op": "new_part"}, {"op": "mirror", "plane": "Top Plane"}])).unwrap(), false).unwrap_err().contains("\"last\""));
assert_eq!(
one(json!({"op": "hole", "atMm": [1, 2, 3], "diameterMm": 5, "depthMm": 10, "counterbore": {"diameterMm": 9, "depthMm": 4}})).unwrap(),
Op::Hole { at: [0.001, 0.002, 0.003], diameter: 0.005, depth: Some(0.01), counterbore: Some((9.0 * MM, 0.004)), countersink: None }
);
assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5})).unwrap_err().contains("depthMm"));
assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "throughAll": true, "counterbore": {"diameterMm": 4, "depthMm": 1}})).unwrap_err().contains("larger"));
assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "depthMm": 3, "counterbore": {"diameterMm": 9, "depthMm": 3}})).unwrap_err().contains("less than"));
assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "depthMm": 2, "countersink": {"diameterMm": 10, "angleDeg": 90}})).unwrap_err().contains("deeper"));
assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "throughAll": true, "countersink": {"diameterMm": 10}, "counterbore": {"diameterMm": 9, "depthMm": 1}})).unwrap_err().contains("not both"));
assert!((countersink_depth(0.006, 0.012, 90f64.to_radians()) - 0.003).abs() < 1e-15);
assert_eq!(one(json!({"op": "set_material", "densityKgM3": 1250})).unwrap(), Op::SetMaterial { name: String::new(), database: "SOLIDWORKS Materials.sldmat".into(), density: Some(1250.0) });
assert!(one(json!({"op": "set_material"})).unwrap_err().contains("densityKgM3 alone"));
assert!(one(json!({"op": "set_material", "densityKgM3": -3})).unwrap_err().contains("positive"));
// A hole between a sketch and its extrude would take the sketch's place.
assert!(validate(&parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 5, "heightMm": 5},
{"op": "hole", "atMm": [0, 0, 0], "diameterMm": 1, "throughAll": true}, {"op": "extrude", "depthMm": 1}])).unwrap(), false)
.unwrap_err().contains("between a sketch"));
}
#[test]
fn a_hole_plan_cuts_into_the_face_from_where_the_counterbore_ends() {
let top = crate::modeling::PlaneFrame::defaults()[1];
let plan = hole_plan(&top, "Top Plane", 0.02, true, [0.005, 0.02, -0.003], 0.006, None, Some((0.011, 0.004)), None);
assert_eq!(plan.iter().map(|o| o.name()).collect::<Vec<_>>(), ["sketch", "cut", "sketch", "cut"]);
// Top Plane: sketch +y = model −Z, so model z −3 is sketch v +3.
assert_eq!(plan[0], Op::Sketch { shorthand: "sketch", plane: PlaneRef::Offset { from: "Top Plane".into(), offset: 0.02 }, entities: vec![Entity::Circle { cx: 0.005, cy: 0.003, r: 0.0055 }], offsets: vec![] });
assert_eq!(plan[1], Op::Cut(Extent::blind(0.004)));
assert!(matches!(&plan[2], Op::Sketch { plane: PlaneRef::Offset { offset, .. }, .. } if (offset - 0.016).abs() < 1e-15));
assert_eq!(plan[3], Op::Cut(Extent { through_all: true, ..Extent::blind(0.0) }));
// From a face looking down (−normal) at height 0: Top Plane itself, reversed.
let plan = hole_plan(&top, "Top Plane", 0.0, false, [0.0; 3], 0.006, Some(0.005), None, Some((0.012, std::f64::consts::FRAC_PI_2)));
assert_eq!(plan[0], Op::Sketch { shorthand: "sketch", plane: PlaneRef::Named("Top Plane".into()), entities: vec![Entity::Circle { cx: 0.0, cy: 0.0, r: 0.006 }], offsets: vec![] });
let Op::Cut(cs) = &plan[1] else { panic!() };
assert!(cs.reverse && (cs.depth - 0.003).abs() < 1e-15 && cs.draft == Some(Draft { angle: std::f64::consts::FRAC_PI_4, outward: false }));
let Op::Cut(main) = &plan[3] else { panic!() };
assert!(main.reverse && (main.depth - 0.002).abs() < 1e-15);
assert!(matches!(&plan[2], Op::Sketch { plane: PlaneRef::Offset { offset, .. }, .. } if (offset - 0.003).abs() < 1e-15));
}
#[test]
fn sketch_fillets_and_chamfers_round_and_cut_a_corner_of_two_lines() {
// A 20 x 10 rectangle as a polyline; the corner at (20, 10) filleted R3,
// the corner at (0, 0) chamfered 2.
let e = sketch_of(json!([
{"polyline": {"points": [[0, 0], [20, 0], [20, 10], [0, 10]], "close": true}},
{"sketchFillet": {"at": [20, 10], "radiusMm": 3}},
{"sketchChamfer": {"at": [0, 0], "distanceMm": 2}}
]))
.unwrap();
assert_eq!(e.len(), 6, "four lines, an arc, a chamfer line");
let Entity::Arc { cx, cy, ccw, .. } = e[4] else { panic!("{:?}", e[4]) };
assert!((cx - 0.017).abs() < 1e-15 && (cy - 0.007).abs() < 1e-15 && ccw, "the fillet's centre 3 in from both edges");
assert_eq!(e[5], Entity::Line { x0: 0.002, y0: 0.0, x1: 0.0, y1: 0.002, construction: false });
assert!(contours(&e).is_ok(), "still one closed contour");
let a = contours(&e).unwrap();
let (area, _, _) = crate::modeling::contour_moments(&e, &a[0]);
let want = (200.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) - 2.0) * 1e-6;
assert!((area.abs() - want).abs() < 1e-15, "{} vs {want}", area.abs());
// A 60° corner: the tangent length is r / tan(30°).
let t = sketch_of(json!([
{"polyline": {"points": [[0, 0], [40, 0], [20, 34.64101615137755]], "close": true}},
{"sketchFillet": {"at": [40, 0], "radiusMm": 2}}
]))
.unwrap();
let Entity::Line { x1, .. } = t[0] else { panic!() };
assert!((x1 - (0.040 - 0.002 / (30f64).to_radians().tan())).abs() < 1e-12);
assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10]], "close": true}}, {"sketchFillet": {"at": [5, 5], "radiusMm": 1}}]))
.unwrap_err().contains("0 lines end at"));
assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [4, 0], [4, 4], [0, 4]], "close": true}}, {"sketchChamfer": {"at": [4, 4], "distanceMm": 5}}]))
.unwrap_err().contains("shorter"));
}
#[test]
fn a_plane_offset_from_a_face_parses() {
let o = parse(&json!([{"op": "sketch_circle", "radiusMm": 2, "plane": {"offsetFrom": {"face": {"atPointMm": [1, 2, 3]}}, "offsetMm": -12}}])).unwrap();
let Op::Sketch { plane, .. } = &o[0] else { panic!() };
assert_eq!(*plane, PlaneRef::FaceOffset { point: [0.001, 0.002, 0.003], offset: -0.012 });
let z = parse(&json!([{"op": "sketch_circle", "radiusMm": 2, "plane": {"offsetFrom": {"atPointMm": [1, 2, 3]}, "offsetMm": 0}}])).unwrap();
assert!(matches!(&z[0], Op::Sketch { plane: PlaneRef::Face { .. }, .. }), "offset 0 is the face itself");
assert!(parse(&json!([{"op": "sketch_circle", "radiusMm": 2, "plane": {"offsetFrom": {"nope": 1}, "offsetMm": 3}}])).is_err());
}
}