app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988
//! THE MODELLING DSL'S GEOMETRIC DECISIONS (0.10.2) — pure functions BOTH
//! backends use: the COM host (with numbers read from SOLIDWORKS) and the
//! simulator (with its own model). Pure, so every rule is unit-tested here.
//!
//! An independent review of 0.10.x (2026-10-13) found that the API calls were
//! right and the BEHAVIOUR was the risk: picks made through the view, a face's
//! normal sign dropped, duplicate edges toggling themselves off, a negative
//! offset plane's orientation unverified. The answers live here:
//! * a face or edge is picked GEOMETRICALLY — the entity whose closest point
//! to the caller's point is within a tolerance — never through the view;
//! * a sketch's coordinates are the BASE PLANE's own axes (its IRefPlane
//! transform), mapped into whatever sketch SOLIDWORKS opened through that
//! sketch's ModelToSketchTransform — so a flipped offset plane or a face's
//! own sketch axes cannot mirror the geometry;
//! * an extrude goes along a REFERENCE direction (the face's outward normal,
//! the base plane's normal) and a cut against it, whatever way the sketch's
//! own normal points: `Dir` is flipped when they disagree;
//! * entities are de-duplicated before they are selected, because selecting an
//! already-selected entity DESELECTS it.
use crate::script::{Direction, Entity, Extent};
pub type V3 = [f64; 3];
pub fn dot(a: V3, b: V3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
pub fn add(a: V3, b: V3) -> V3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
pub fn sub(a: V3, b: V3) -> V3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
pub fn scale(a: V3, k: f64) -> V3 {
[a[0] * k, a[1] * k, a[2] * k]
}
pub fn norm(a: V3) -> f64 {
dot(a, a).sqrt()
}
/// An IMathTransform's `ArrayData`: the rotation's ROWS are the images of the
/// local X, Y and Z axes (indices 0-2, 3-5, 6-8), then the translation (9-11)
/// and the scale (12). The same layout `swx_add_component` writes — measured
/// live (component placement, 2026-09).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Xform {
pub rows: [V3; 3],
pub t: V3,
pub s: f64,
}
impl Xform {
pub fn from_array_data(a: &[f64]) -> Option<Xform> {
if a.len() < 12 || a.iter().take(12).any(|v| !v.is_finite()) {
return None;
}
let s = a.get(12).copied().filter(|s| s.is_finite() && *s != 0.0).unwrap_or(1.0);
Some(Xform { rows: [[a[0], a[1], a[2]], [a[3], a[4], a[5]], [a[6], a[7], a[8]]], t: [a[9], a[10], a[11]], s })
}
pub fn identity() -> Xform {
Xform { rows: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], t: [0.0; 3], s: 1.0 }
}
/// p' = (p.x·row0 + p.y·row1 + p.z·row2)·scale + t.
pub fn apply(&self, p: V3) -> V3 {
let r = add(add(scale(self.rows[0], p[0]), scale(self.rows[1], p[1])), scale(self.rows[2], p[2]));
add(scale(r, self.s), self.t)
}
/// The inverse of a rigid transform (orthonormal rows, uniform scale):
/// p = ((p' − t) / s)·Rᵀ.
pub fn inverse(&self) -> Xform {
let r = self.rows;
// Rows of Rᵀ are the columns of R.
let rows = [[r[0][0], r[1][0], r[2][0]], [r[0][1], r[1][1], r[2][1]], [r[0][2], r[1][2], r[2][2]]];
let inv_s = if self.s != 0.0 { 1.0 / self.s } else { 1.0 };
let neg_t = scale(self.t, -inv_s);
let t = add(add(scale(rows[0], neg_t[0]), scale(rows[1], neg_t[1])), scale(rows[2], neg_t[2]));
Xform { rows, t, s: inv_s }
}
/// The direction (in the SOURCE space) that this transform maps onto its
/// target's +Z: for a ModelToSketch transform, the sketch's normal in model
/// space. With orthonormal rows that is the third COLUMN.
pub fn source_z(&self) -> V3 {
[self.rows[0][2], self.rows[1][2], self.rows[2][2]]
}
}
/// A plane's frame in model space: origin, its own X and Y axes (a sketch on
/// it draws in these), and its normal.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PlaneFrame {
pub origin: V3,
pub x: V3,
pub y: V3,
pub n: V3,
}
impl PlaneFrame {
/// From the plane's IRefPlane::Transform (plane-local -> model).
pub fn from_plane_xform(x: &Xform) -> PlaneFrame {
PlaneFrame { origin: x.t, x: x.rows[0], y: x.rows[1], n: x.rows[2] }
}
/// The default planes as the documented conventions state them — Front
/// (x→X, y→Y, normal +Z), Top (x→X, y→−Z, normal +Y: MEASURED, issue #1),
/// Right (x→−Z, y→Y, normal +X). The simulator's frames; the COM host reads
/// the real ones from each plane's transform.
pub fn defaults() -> [PlaneFrame; 3] {
[
PlaneFrame { origin: [0.0; 3], x: [1.0, 0.0, 0.0], y: [0.0, 1.0, 0.0], n: [0.0, 0.0, 1.0] },
PlaneFrame { origin: [0.0; 3], x: [1.0, 0.0, 0.0], y: [0.0, 0.0, -1.0], n: [0.0, 1.0, 0.0] },
PlaneFrame { origin: [0.0; 3], x: [0.0, 0.0, -1.0], y: [0.0, 1.0, 0.0], n: [1.0, 0.0, 0.0] },
]
}
/// The model point of sketch (u, v) on the plane parallel to this one at
/// height `h` along its normal.
pub fn point(&self, u: f64, v: f64, h: f64) -> V3 {
add(add(add(self.origin, scale(self.n, h)), scale(self.x, u)), scale(self.y, v))
}
/// How far `p` is along this plane's normal.
pub fn height_of(&self, p: V3) -> f64 {
dot(sub(p, self.origin), self.n)
}
}
impl PlaneFrame {
/// Do two frames agree (same axes and normal, origins within 1 µm)?
pub fn same_as(&self, o: &PlaneFrame) -> bool {
let close = |a: V3, b: V3, tol: f64| norm(sub(a, b)) < tol;
close(self.x, o.x, 1e-6) && close(self.y, o.y, 1e-6) && close(self.n, o.n, 1e-6) && close(self.origin, o.origin, 1e-6)
}
}
/// THE SKETCH MAPPING, with its own check: entities through `m2s`; if they do
/// not land on the sketch plane (|z| > 1e-7 m), through its inverse — an
/// API whose transform runs the other way is caught, not obeyed. Returns the
/// mapped entities and whether the inverse was used; Err when neither lands.
pub fn map_entities(frame: &PlaneFrame, h: f64, entities: &[Entity], m2s: &Xform) -> Result<(Vec<Entity>, bool), f64> {
let attempt = |t: &Xform| -> (Vec<Entity>, f64) {
let mut out = Vec::new();
let mut worst: f64 = 0.0;
for e in entities {
let (m, z) = entity_to_sketch(frame, h, e, t);
worst = worst.max(z);
out.push(m);
}
(out, worst)
};
let (fwd, zf) = attempt(m2s);
if zf <= 1e-7 {
return Ok((fwd, false));
}
let (inv, zi) = attempt(&m2s.inverse());
if zi <= 1e-7 {
return Ok((inv, true));
}
Err(zf.min(zi))
}
/// Which default plane (0 Front, 1 Top, 2 Right) a caller's name means —
/// English names and their short forms; a localized install's own names are
/// matched by the caller against the first three RefPlane features.
pub fn default_index(name: &str) -> Option<usize> {
match name.trim().to_ascii_lowercase().as_str() {
"front plane" | "front" => Some(0),
"top plane" | "top" => Some(1),
"right plane" | "right" => Some(2),
_ => None,
}
}
/// The default plane parallel to a face normal.
pub fn parallel_default(frames: &[PlaneFrame; 3], n: V3) -> Option<usize> {
let len = norm(n);
if len < 1e-9 {
return None;
}
(0..3).find(|i| (dot(frames[*i].n, n) / len).abs() > 0.999)
}
/// An entity drawn on `frame` at height `h`, mapped into a sketch through its
/// model-to-sketch transform. Returns the sketch-space entity and how far off
/// the sketch plane (|z|) the mapping put it — anything but ~0 means the
/// transform is not what it should be, and nothing is drawn.
pub fn entity_to_sketch(frame: &PlaneFrame, h: f64, e: &Entity, m2s: &Xform) -> (Entity, f64) {
match *e {
Entity::Rect { x0, y0, x1, y1 } => {
let a = m2s.apply(frame.point(x0, y0, h));
let b = m2s.apply(frame.point(x1, y1, h));
(Entity::Rect { x0: a[0], y0: a[1], x1: b[0], y1: b[1] }, a[2].abs().max(b[2].abs()))
}
Entity::Circle { cx, cy, r } => {
let c = m2s.apply(frame.point(cx, cy, h));
(Entity::Circle { cx: c[0], cy: c[1], r: r * m2s.s.abs() }, c[2].abs())
}
Entity::Line { x0, y0, x1, y1, construction } => {
let a = m2s.apply(frame.point(x0, y0, h));
let b = m2s.apply(frame.point(x1, y1, h));
(Entity::Line { x0: a[0], y0: a[1], x1: b[0], y1: b[1], construction }, a[2].abs().max(b[2].abs()))
}
Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, construction } => {
let c = m2s.apply(frame.point(cx, cy, h));
let a = m2s.apply(frame.point(x0, y0, h));
let b = m2s.apply(frame.point(x1, y1, h));
// A MIRRORING map turns counter-clockwise into clockwise: the arc
// keeps its ends and centre, so its direction must flip with it.
let ccw = if mapping_reflects(frame, h, m2s) { !ccw } else { ccw };
(Entity::Arc { cx: c[0], cy: c[1], x0: a[0], y0: a[1], x1: b[0], y1: b[1], ccw, construction }, c[2].abs().max(a[2].abs()).max(b[2].abs()))
}
}
}
/// Does the map from the plane's (u, v) into the sketch's (x, y) MIRROR (its
/// 2D determinant negative)? Then an arc's turning direction flips.
pub fn mapping_reflects(frame: &PlaneFrame, h: f64, m2s: &Xform) -> bool {
let o = m2s.apply(frame.point(0.0, 0.0, h));
let ex = sub(m2s.apply(frame.point(1.0, 0.0, h)), o);
let ey = sub(m2s.apply(frame.point(0.0, 1.0, h)), o);
ex[0] * ey[1] - ex[1] * ey[0] < 0.0
}
/// THE MOMENTS OF ONE CLOSED CONTOUR (0.11.0), exactly, by Green's theorem:
/// (A, Sx, Sy) = (∬dA, ∬x dA, ∬y dA), SIGNED — positive when the contour runs
/// counter-clockwise. Per segment:
/// * a line P→Q: ∮x dy − y dx = px·qy − qx·py; ∮x² dy = (qy−py)(px²+px·qx+qx²)/3;
/// ∮y² dx = (qx−px)(py²+py·qy+qy²)/3;
/// * an arc about (cx, cy) of radius r from θ0 sweeping Δ: the same three
/// integrals in closed form over θ (sin/cos, sin², cos², sin³, cos³).
/// A = ½∮(x dy − y dx), Sx = ½∮x² dy, Sy = −½∮y² dx.
pub fn contour_moments(entities: &[Entity], contour: &[(usize, bool)]) -> (f64, f64, f64) {
let (mut cross, mut x2dy, mut y2dx) = (0.0, 0.0, 0.0);
for &(i, rev) in contour {
let e = &entities[i];
match *e {
Entity::Line { x0, y0, x1, y1, .. } => {
let (px, py, qx, qy) = if rev { (x1, y1, x0, y0) } else { (x0, y0, x1, y1) };
cross += px * qy - qx * py;
x2dy += (qy - py) * (px * px + px * qx + qx * qx) / 3.0;
y2dx += (qx - px) * (py * py + py * qy + qy * qy) / 3.0;
}
Entity::Arc { .. } => {
let Some((cx, cy, r, t0, sweep)) = crate::script::arc_geom(e) else { continue };
let (a, d) = if rev { (t0 + sweep, -sweep) } else { (t0, sweep) };
let b = a + d;
let (s0, c0) = a.sin_cos();
let (s1, c1) = b.sin_cos();
let int_c = s1 - s0;
let int_s = -(c1 - c0);
let int_c2 = d / 2.0 + ((2.0 * b).sin() - (2.0 * a).sin()) / 4.0;
let int_s2 = d / 2.0 - ((2.0 * b).sin() - (2.0 * a).sin()) / 4.0;
let int_c3 = (s1 - s1.powi(3) / 3.0) - (s0 - s0.powi(3) / 3.0);
let int_s3 = (-c1 + c1.powi(3) / 3.0) - (-c0 + c0.powi(3) / 3.0);
// x = cx + r cosθ, y = cy + r sinθ, dx = −r sinθ dθ, dy = r cosθ dθ.
// x dy − y dx = (r² + cx·r·cosθ + cy·r·sinθ) dθ.
cross += r * r * d + cx * r * int_c + cy * r * int_s;
x2dy += cx * cx * r * int_c + 2.0 * cx * r * r * int_c2 + r * r * r * int_c3;
y2dx += -(cy * cy * r * int_s + 2.0 * cy * r * r * int_s2 + r * r * r * int_s3);
}
_ => {}
}
}
(cross / 2.0, x2dy / 2.0, -y2dx / 2.0)
}
/// THE `Dir` FLAG for FeatureExtrusion3 / FeatureCut4. SOLIDWORKS extrudes a
/// boss ALONG the sketch normal and cuts AGAINST it ("The default sketch normal
/// is the same as the face or plane normal… boss… along the sketch normal; cut…
/// opposite"). The DSL's rule is about a REFERENCE direction — a face's outward
/// normal, an offset plane's base normal: a boss goes along it, a cut against
/// it. When the sketch's own normal points the other way (a plane SOLIDWORKS
/// flipped), `Dir` is flipped to keep the rule; the caller's `reverse` flips it
/// again. Returns (dir, flipped).
pub fn dir_flag(sketch_normal: Option<V3>, reference: Option<V3>, reverse: bool) -> (bool, bool) {
let flipped = match (sketch_normal, reference) {
(Some(s), Some(r)) => dot(s, r) < 0.0,
_ => false,
};
(flipped ^ reverse, flipped)
}
/// The swEndConditions_e id of an up-to end condition (verified on
/// help.solidworks.com, swconst): UpToNext 11, UpToSelection 10 (the face
/// selected with mark 1 — UpToSurface 4 is the obsolete id), OffsetFromSurface 5.
pub fn end_condition_id(u: &crate::script::UpTo) -> i32 {
match u {
crate::script::UpTo::Next => 11,
// swEndCondUpToSurface 4, the id the macro recorder writes, NOT
// swEndCondUpToSelection 10 (the help's current name for it).
// MEASURED 2026-10-16 (SOLIDWORKS 2026, canary 0.11.4-rc1, cswa-12):
// with 10 and the face at mark 1, a boss and a cut both built with an
// EMPTY direction box and stopped at the NEXT face (the boss 3392.9 mm3
// short, the cut 1005.3 mm3 short, both exactly one face early);
// OffsetFromSurface 5 with the same selection went to the face (the
// pillar at x 68 was right). A forum report says the same of 10
// (3dswym, "FeatureCut4 Method": the recorder writes 4, 10 leaves the
// direction box empty).
crate::script::UpTo::Surface { .. } => 4,
crate::script::UpTo::OffsetFrom { .. } => 5,
}
}
/// The `Ddir1` / `Ddir2` flag `FeatureExtrusion3` and `FeatureCut4` take for a
/// draft. MEASURED 2026-10-16 (SOLIDWORKS 2026, canary 0.11.4-rc1): the flag
/// TRUE made the profile GROW along the extrude - a 5 deg boss widened (cswa-12)
/// and a 45 deg countersink cut widened into exactly the outward frustum, 804.25
/// mm3 more than the inward one (cswa-11, 1273.4 vs 469.1 mm3). The 2024 help
/// for FeatureExtrusion3 says "True for first draft angle to be inward"; its
/// sibling FeatureExtruRefSurface3 says the opposite, and SOLIDWORKS itself
/// did the opposite. So: TRUE = OUTWARD, and the bridge passes `outward` as is.
pub fn draft_outward_flag(d: &crate::script::Draft) -> bool {
d.outward
}
/// Why a geometric pick found no single entity.
#[derive(Clone, Debug, PartialEq)]
pub enum PickError {
/// Nothing within the tolerance; the nearest was this far (metres).
None(Option<f64>),
/// The point lies on several entities (an edge between faces, a vertex
/// between edges) — which one is meant cannot be decided.
Ambiguous(usize),
}
/// THE GEOMETRIC PICK: `cands` are (index, distance from the caller's point to
/// the entity's closest point, outward offset — the point's height above the
/// face along its normal, 0 for edges). Within `tol`: one is the pick; several
/// prefer those the point is not BEHIND (outward >= -tol); still several is
/// ambiguous. Never "the first face along the view ray".
pub fn pick(cands: &[(usize, f64, f64)], tol: f64) -> Result<usize, PickError> {
let near: Vec<&(usize, f64, f64)> = cands.iter().filter(|c| c.1 <= tol).collect();
match near.len() {
0 => Err(PickError::None(cands.iter().map(|c| c.1).fold(None, |m: Option<f64>, d| Some(m.map_or(d, |m| m.min(d)))))),
1 => Ok(near[0].0),
_ => {
let front: Vec<&&(usize, f64, f64)> = near.iter().filter(|c| c.2 >= -tol).collect();
if front.len() == 1 {
Ok(front[0].0)
} else {
Err(PickError::Ambiguous(near.len()))
}
}
}
}
/// Indices of the FIRST occurrence of each key — the de-duplication every
/// multi-entity selection goes through (a box's 6 faces list its 12 edges 24
/// times; an already-selected entity selected again is DESELECTED).
pub fn dedupe<K: PartialEq>(keys: &[K]) -> Vec<usize> {
let mut out: Vec<usize> = Vec::new();
for (i, k) in keys.iter().enumerate() {
if !out.iter().any(|j| keys[*j] == *k) {
out.push(i);
}
}
out
}
/// What SOLIDWORKS ends up with selected after selecting `keys` one by one with
/// Append: an entity selected twice is deselected (the documented toggle). The
/// simulator MODELS the hazard with this.
pub fn toggled_selection<K: PartialEq + Clone>(keys: &[K]) -> Vec<K> {
let mut sel: Vec<K> = Vec::new();
for k in keys {
if let Some(i) = sel.iter().position(|s| s == k) {
sel.remove(i);
} else {
sel.push(k.clone());
}
}
sel
}
/// The span along a plane's normal an extrude or cut covers, given the
/// direction its DEPTH goes (`own`: +1 or −1 along the normal — the op's own
/// direction after the sketch normal, the boss/cut rule and `reverse`).
/// `direction:"both"`: `depthMm` along the op's own direction, `depth2Mm` the
/// other way (SOLIDWORKS' D1 / D2). Through all spans `through` (the part's
/// extent along the normal) in the op's direction.
pub fn extent_span(o: f64, own: f64, e: &Extent, through: (f64, f64)) -> (f64, f64) {
let sorted = |a: f64, b: f64| if a <= b { (a, b) } else { (b, a) };
if e.through_all {
return match e.direction {
Direction::One => {
if own > 0.0 { (o, through.1.max(o)) } else { (through.0.min(o), o) }
}
_ => (through.0.min(o), through.1.max(o)),
};
}
match e.direction {
Direction::One => sorted(o, o + own * e.depth),
Direction::MidPlane => (o - e.depth / 2.0, o + e.depth / 2.0),
Direction::Both => sorted(o + own * e.depth, o - own * e.depth2),
}
}
// ---------------------------------------------------------------------------
// THE SELECTION LADDER (0.10.3)
//
// The first live run on SOLIDWORKS 2026 showed every selection after a
// geometric pick selecting NOTHING: IModelDocExtension::MultiSelect2 given a
// SAFEARRAY of VARIANTs (each wrapping an IDispatch) returned 0 and listed
// nothing. So no single selection call is trusted: each RUNG is tried in turn
// and is believed only when ISelectionMgr::GetSelectedObjectCount2 GROWS by
// exactly what was asked. The rung that worked is reported (`selectedVia`).
// ---------------------------------------------------------------------------
/// One way of selecting a picked entity.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Rung {
/// MultiSelect2 with a SAFEARRAY of VARIANT(VT_DISPATCH) — the 0.10.2 call,
/// MEASURED on SOLIDWORKS 2026 to select nothing. Not on the ladder; named
/// so the simulator can model what was measured.
MultiSelectVariantArray,
/// MultiSelect2 with a TYPED SAFEARRAY of VT_DISPATCH (what .NET's
/// `object[] Objects` marshals as). The whole batch at once.
MultiSelectTyped,
/// IEntity::Select4(append, ISelectData), per entity, through the entity's
/// IEntity interface.
EntitySelect4,
/// IModelDocExtension::SelectByRay (2017+) with the bridge's OWN ray: from
/// just outside the picked point back onto it. View-independent.
SelectByRay,
/// IModelDocExtension::SelectByID2 at the picked point — goes through the
/// VIEW, so a hidden face can be missed or the wrong one taken. Last resort.
SelectByIdAtPoint,
}
impl Rung {
pub fn label(self) -> &'static str {
match self {
Rung::MultiSelectVariantArray => "MultiSelect2(VARIANT array)",
Rung::MultiSelectTyped => "MultiSelect2(typed array)",
Rung::EntitySelect4 => "IEntity.Select4",
Rung::SelectByRay => "SelectByRay",
Rung::SelectByIdAtPoint => "SelectByID2 at the point (view-dependent)",
}
}
pub fn view_dependent(self) -> bool {
matches!(self, Rung::SelectByIdAtPoint)
}
}
/// The batch rung, tried once for all entities.
pub const BATCH_RUNG: Rung = Rung::MultiSelectTyped;
/// The per-entity rungs, in order, for whatever the batch did not select.
pub const PER_ENTITY_RUNGS: [Rung; 3] = [Rung::EntitySelect4, Rung::SelectByRay, Rung::SelectByIdAtPoint];
/// What the ladder needs from a selection backend (the COM host, or the
/// simulator). `attempt` APPENDS; its own return value is never believed —
/// only `count` is.
pub trait Selector {
fn clear(&mut self);
/// The number of selected objects (GetSelectedObjectCount2(-1)).
fn count(&mut self) -> Option<usize>;
/// Try to select `items` (indices into the caller's list) by `rung`.
/// `Err` = that rung is unavailable for them (no API, no ray, ...).
fn attempt(&mut self, rung: Rung, items: &[usize]) -> Result<(), String>;
}
/// The outcome: how many ended up selected (by the count), and per entity the
/// rung that selected it (`None` = nothing did).
#[derive(Clone, Debug, PartialEq)]
pub struct Selected {
pub count: usize,
pub via: Vec<Option<Rung>>,
pub notes: Vec<String>,
}
impl Selected {
pub fn all(&self, n: usize) -> bool {
self.count == n && self.via.iter().all(|v| v.is_some())
}
/// The `selectedVia` report: one label when one rung did it all, else the
/// list per entity.
pub fn report(&self) -> serde_json::Value {
let labels: Vec<serde_json::Value> =
self.via.iter().map(|v| v.map(|r| serde_json::json!(r.label())).unwrap_or(serde_json::Value::Null)).collect();
match labels.first() {
Some(first) if labels.iter().all(|l| l == first) => first.clone(),
_ => serde_json::Value::Array(labels),
}
}
pub fn view_dependent(&self) -> bool {
self.via.iter().any(|v| v.map(|r| r.view_dependent()).unwrap_or(false))
}
}
/// Select `n` entities, starting from an EMPTY selection, rung by rung, every
/// rung VERIFIED by the selection count.
pub fn select_ladder<S: Selector>(s: &mut S, n: usize) -> Selected {
let mut via = vec![None; n];
let mut notes = Vec::new();
if n == 0 {
return Selected { count: 0, via, notes };
}
s.clear();
let Some(base) = s.count() else {
notes.push("the selection count could not be read, so no selection could be verified".into());
return Selected { count: 0, via, notes };
};
let all: Vec<usize> = (0..n).collect();
match s.attempt(BATCH_RUNG, &all) {
Ok(()) if s.count() == Some(base + n) => {
return Selected { count: n, via: vec![Some(BATCH_RUNG); n], notes };
}
Ok(()) => {
notes.push(format!("{} selected {} of {n}", BATCH_RUNG.label(), s.count().unwrap_or(base).saturating_sub(base)));
s.clear();
}
Err(e) => notes.push(format!("{}: {e}", BATCH_RUNG.label())),
}
let mut have = s.count().unwrap_or(base);
for (i, slot) in via.iter_mut().enumerate() {
let mut why: Vec<String> = Vec::new();
for rung in PER_ENTITY_RUNGS {
match s.attempt(rung, &[i]) {
Ok(()) => {
let now = s.count().unwrap_or(have);
if now == have + 1 {
*slot = Some(rung);
have = now;
break;
}
why.push(format!("{} changed the count by {}", rung.label(), now as i64 - have as i64));
have = now;
}
Err(e) => why.push(format!("{}: {e}", rung.label())),
}
}
if slot.is_none() {
notes.push(format!("entity {i}: nothing selected it ({})", why.join("; ")));
}
}
let count = s.count().unwrap_or(have).saturating_sub(base);
Selected { count, via, notes }
}
/// The ray SelectByRay is given for a FACE: from 1 mm outside the picked point
/// along the outward normal, pointing back in. Its own ray — not the view's.
pub fn face_ray(point: V3, outward: V3) -> Option<(V3, V3)> {
let l = norm(outward);
if l < 1e-9 {
return None;
}
let n = scale(outward, 1.0 / l);
Some((add(point, scale(n, 1.0e-3)), scale(n, -1.0)))
}
/// The ray for an EDGE: from 1 mm out along the bisector of its two faces'
/// outward normals, pointing at the edge point. One normal will do.
pub fn edge_ray(point: V3, normals: &[V3]) -> Option<(V3, V3)> {
let mut b = [0.0; 3];
for n in normals {
let l = norm(*n);
if l > 1e-9 {
b = add(b, scale(*n, 1.0 / l));
}
}
face_ray(point, b)
}
/// A simulated install: which rungs really select. `measured_2026` is the
/// first live run — the VARIANT-array MultiSelect2 selected nothing.
#[derive(Clone, Debug)]
pub struct SelProfile {
pub dead: Vec<Rung>,
}
impl Default for SelProfile {
fn default() -> Self {
SelProfile::measured_2026()
}
}
impl SelProfile {
pub fn measured_2026() -> Self {
SelProfile { dead: vec![Rung::MultiSelectVariantArray] }
}
}
/// The simulator's selection list: a rung on the dead list "succeeds" but
/// selects nothing (exactly what was measured live); a live one appends — and,
/// like SOLIDWORKS, re-selecting a selected entity DESELECTS it.
pub struct ProfileSelector {
pub profile: SelProfile,
pub selected: Vec<usize>,
pub calls: Vec<Rung>,
}
impl ProfileSelector {
pub fn new(profile: SelProfile) -> Self {
ProfileSelector { profile, selected: Vec::new(), calls: Vec::new() }
}
}
impl Selector for ProfileSelector {
fn clear(&mut self) {
self.selected.clear();
}
fn count(&mut self) -> Option<usize> {
Some(self.selected.len())
}
fn attempt(&mut self, rung: Rung, items: &[usize]) -> Result<(), String> {
self.calls.push(rung);
if self.profile.dead.contains(&rung) {
return Ok(());
}
for i in items {
if let Some(k) = self.selected.iter().position(|s| s == i) {
self.selected.remove(k);
} else {
self.selected.push(*i);
}
}
Ok(())
}
}
/// What clear_part did (0.10.8).
#[derive(Clone, Debug, PartialEq)]
pub struct Cleared {
pub passes: usize,
pub deleted: Vec<String>,
}
/// clear_part could not empty the part.
#[derive(Clone, Debug, PartialEq)]
pub enum ClearError<E> {
/// Something is still there after `passes` passes.
Stuck { passes: usize, left: Vec<String>, deleted: Vec<String> },
Host(E),
}
/// CLEAR_PART IN PASSES (0.10.8), shared by the COM host and the simulator.
///
/// MEASURED 2026-10-14 (0.10.7's self-test on SOLIDWORKS 2026): three checks
/// failed because one DeleteSelection2 over every feature after the origin
/// returned TRUE and left a top-level SKETCH behind (Sketch13, Sketch4,
/// Sketch9) — a sketch the previous check left unconsumed. So: walk, delete,
/// walk again, and repeat on whatever is left, up to `max_passes`. It succeeds
/// ONLY when the walk comes back empty. `deleted` is what each pass actually
/// removed (present before it, gone after it).
pub fn clear_in_passes<E>(
mut remaining: impl FnMut() -> Result<Vec<String>, E>,
mut delete: impl FnMut(&[String], usize) -> Result<(), E>,
max_passes: usize,
) -> Result<Cleared, ClearError<E>> {
let mut left = remaining().map_err(ClearError::Host)?;
let mut deleted: Vec<String> = Vec::new();
let mut passes = 0;
while !left.is_empty() {
if passes >= max_passes {
return Err(ClearError::Stuck { passes, left, deleted });
}
passes += 1;
delete(&left, passes).map_err(ClearError::Host)?;
let after = remaining().map_err(ClearError::Host)?;
deleted.extend(left.iter().filter(|n| !after.contains(n)).cloned());
left = after;
}
Ok(Cleared { passes, deleted })
}
/// Read the interop probe's output (`guid=…`, `itype=…`, `tlflags=…`) into
/// the IID and whether the interface can be called through IDispatch:
/// InterfaceType Dual (0) or IDispatch (2), or TypeLibType FDual (0x40) /
/// FDispatchable (0x1000); an IUnknown-only interface (1) never.
pub fn parse_iid_probe(text: &str) -> Option<(u128, bool)> {
let mut guid = None;
let mut itype: Option<i64> = None;
let mut flags: Option<i64> = None;
for l in text.lines() {
match l.trim().split_once('=') {
Some(("guid", v)) => {
let hex: String = v.trim().chars().filter(|c| *c != '-' && *c != '{' && *c != '}').collect();
if hex.len() == 32 {
guid = u128::from_str_radix(&hex, 16).ok();
}
}
Some(("itype", v)) => itype = v.trim().parse().ok(),
Some(("tlflags", v)) => flags = v.trim().parse().ok(),
_ => {}
}
}
let g = guid.filter(|g| *g != 0)?;
let dual = match itype {
Some(1) => false,
Some(0) | Some(2) => true,
_ => flags.map(|f| f & (0x40 | 0x1000) != 0).unwrap_or(false),
};
Some((g, dual))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transforms_planes_and_the_sketch_mapping() {
let id = Xform::identity();
assert_eq!(id.apply([1.0, 2.0, 3.0]), [1.0, 2.0, 3.0]);
let [front, top, right] = PlaneFrame::defaults();
assert_eq!(top.point(1.0, 2.0, 0.5), [1.0, 0.5, -2.0], "Top: v -> -Z (measured)");
assert_eq!(front.point(1.0, 2.0, 0.5), [1.0, 2.0, 0.5]);
assert_eq!(right.point(1.0, 2.0, 0.5), [0.5, 2.0, -1.0]);
// A sketch whose own axes are the plane's: model -> sketch is the
// inverse of the plane's transform. For Top at height 9.6 (a translated
// plane), a point drawn at (u, v) comes back as (u, v, 0).
let h = 9.6;
let m2s = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, -1.0, 0.0]], t: [0.0, 0.0, 0.0], s: 1.0 };
// rows: model X -> sketch x; model Y -> sketch -z... build it honestly:
// sketch = (X, -Z, Y - h). Rows are images of model axes in sketch space.
let m2s = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]], t: [0.0, 0.0, -h], ..m2s };
let (e, z) = entity_to_sketch(&top, h, &Entity::Circle { cx: 3.0, cy: 4.0, r: 1.0 }, &m2s);
assert_eq!(e, Entity::Circle { cx: 3.0, cy: 4.0, r: 1.0 });
assert!(z < 1e-12);
assert_eq!(m2s.source_z(), [0.0, 1.0, 0.0], "that sketch's normal is +Y");
// A FLIPPED plane (normal −Y, x kept, y mirrored): the same model point
// lands at a mirrored sketch y — and is still drawn where it belongs.
let flipped = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]], t: [0.0, 0.0, h], s: 1.0 };
let (e, z) = entity_to_sketch(&top, h, &Entity::Rect { x0: 0.0, y0: 0.0, x1: 4.0, y1: 6.0 }, &flipped);
assert_eq!(e, Entity::Rect { x0: 0.0, y0: 0.0, x1: 4.0, y1: -6.0 });
assert!(z < 1e-12);
assert_eq!(flipped.source_z(), [0.0, -1.0, 0.0]);
assert_eq!(default_index("Top Plane"), Some(1));
assert_eq!(default_index("Ebene oben"), None);
assert_eq!(parallel_default(&PlaneFrame::defaults(), [0.0, -1.0, 0.0]), Some(1));
assert_eq!(parallel_default(&PlaneFrame::defaults(), [0.7, 0.7, 0.0]), None);
}
#[test]
fn a_transform_that_runs_the_other_way_is_caught_and_inverted() {
let top = PlaneFrame::defaults()[1];
let h = 0.01;
// A sketch-to-model transform handed over by mistake: model = (sx, h, -sy).
let s2m = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]], t: [0.0, h, 0.0], s: 1.0 };
let es = [Entity::Circle { cx: 0.003, cy: 0.004, r: 0.001 }];
let (out, inverted) = map_entities(&top, h, &es, &s2m).unwrap();
assert!(inverted);
assert_eq!(out[0], Entity::Circle { cx: 0.003, cy: 0.004, r: 0.001 });
// ...and the forward one is used when it is right.
let (out, inverted) = map_entities(&top, h, &es, &s2m.inverse()).unwrap();
assert!(!inverted);
assert!(matches!(out[0], Entity::Circle { cx, cy, .. } if (cx - 0.003).abs() < 1e-12 && (cy - 0.004).abs() < 1e-12));
assert!(PlaneFrame::defaults()[0].same_as(&PlaneFrame::defaults()[0]));
assert!(!PlaneFrame::defaults()[0].same_as(&PlaneFrame::defaults()[1]));
}
#[test]
fn dir_follows_the_reference_whatever_the_sketch_normal() {
// Sketch normal agrees with the reference: no flip.
assert_eq!(dir_flag(Some([0.0, 1.0, 0.0]), Some([0.0, 1.0, 0.0]), false), (false, false));
// A bottom face's sketch on a plane SOLIDWORKS oriented +Y while the face
// looks −Y: flipped, so the boss goes OUTWARD.
assert_eq!(dir_flag(Some([0.0, 1.0, 0.0]), Some([0.0, -1.0, 0.0]), false), (true, true));
// ...and the caller's reverse flips it back.
assert_eq!(dir_flag(Some([0.0, 1.0, 0.0]), Some([0.0, -1.0, 0.0]), true), (false, true));
assert_eq!(dir_flag(None, None, true), (true, false));
}
#[test]
fn picks_are_geometric_and_never_guess() {
// One face within tolerance: it.
assert_eq!(pick(&[(0, 5.0, 0.0), (1, 1e-9, 0.0), (2, 3.0, 0.0)], 1e-6), Ok(1));
// A point on an edge between two faces, slightly in front of one.
assert_eq!(pick(&[(0, 1e-9, 1e-9), (1, 1e-9, -1e-3)], 1e-6), Ok(0));
// Truly on both: ambiguous, never a guess.
assert_eq!(pick(&[(0, 0.0, 0.0), (1, 0.0, 0.0)], 1e-6), Err(PickError::Ambiguous(2)));
assert_eq!(pick(&[(0, 0.004, 0.0)], 1e-6), Err(PickError::None(Some(0.004))));
}
#[test]
fn duplicates_are_removed_and_their_toggle_is_modelled() {
let keys = ["e1", "e2", "e1", "e3", "e2"];
assert_eq!(dedupe(&keys), vec![0, 1, 3]);
assert_eq!(toggled_selection(&keys), vec!["e3"], "selected twice = deselected");
let deduped: Vec<&str> = dedupe(&keys).into_iter().map(|i| keys[i]).collect();
assert_eq!(toggled_selection(&deduped), vec!["e1", "e2", "e3"]);
}
#[test]
fn spans_follow_the_ops_own_direction() {
let e = |depth: f64, depth2: f64, direction: Direction, through_all: bool| Extent { depth, depth2, through_all, reverse: false, direction , up_to: None, draft: None, regions: Vec::new() };
assert_eq!(extent_span(0.0, 1.0, &e(5.0, 5.0, Direction::One, false), (0.0, 10.0)), (0.0, 5.0));
assert_eq!(extent_span(0.0, -1.0, &e(5.0, 5.0, Direction::One, false), (0.0, 10.0)), (-5.0, 0.0));
// The audit's T27: a cut on Front Plane (own direction −Z), both 6 / 2.
assert_eq!(extent_span(0.0, -1.0, &e(6.0, 2.0, Direction::Both, false), (0.0, 10.0)), (-6.0, 2.0));
assert_eq!(extent_span(0.0, 1.0, &e(5.0, 3.0, Direction::Both, false), (0.0, 10.0)), (-3.0, 5.0));
assert_eq!(extent_span(0.0, 1.0, &e(10.0, 10.0, Direction::MidPlane, false), (0.0, 10.0)), (-5.0, 5.0));
assert_eq!(extent_span(0.0, 1.0, &e(0.0, 0.0, Direction::One, true), (0.0, 10.0)), (0.0, 10.0));
}
/// A backend whose rungs CLAIM success; only some really select.
#[test]
fn the_ladder_believes_the_count_not_the_call() {
// As measured on SOLIDWORKS 2026 with the typed array ALSO dead: every
// entity falls through to IEntity::Select4.
let mut s = ProfileSelector::new(SelProfile { dead: vec![Rung::MultiSelectVariantArray, Rung::MultiSelectTyped] });
let r = select_ladder(&mut s, 3);
assert!(r.all(3), "{r:?}");
assert_eq!(r.via, vec![Some(Rung::EntitySelect4); 3]);
assert_eq!(r.report(), serde_json::json!("IEntity.Select4"));
assert!(!r.view_dependent());
assert!(r.notes[0].contains("selected 0 of 3"), "{:?}", r.notes);
// The typed batch works: one call, nothing else tried.
let mut s = ProfileSelector::new(SelProfile::measured_2026());
let r = select_ladder(&mut s, 12);
assert!(r.all(12));
assert_eq!(s.calls, vec![Rung::MultiSelectTyped]);
// Everything but the view-dependent last resort dead: it is used, and SAID.
let mut s = ProfileSelector::new(SelProfile { dead: vec![Rung::MultiSelectTyped, Rung::EntitySelect4, Rung::SelectByRay] });
let r = select_ladder(&mut s, 1);
assert!(r.all(1));
assert!(r.view_dependent());
assert_eq!(r.report(), serde_json::json!("SelectByID2 at the point (view-dependent)"));
// Nothing works: nothing is claimed.
let mut s = ProfileSelector::new(SelProfile { dead: vec![Rung::MultiSelectTyped, Rung::EntitySelect4, Rung::SelectByRay, Rung::SelectByIdAtPoint] });
let r = select_ladder(&mut s, 2);
assert_eq!(r.count, 0);
assert!(!r.all(2));
assert_eq!(r.report(), serde_json::json!(null));
assert_eq!(r.notes.len(), 3, "the batch, then one line per entity: {:?}", r.notes);
}
/// A batch that half-worked is CLEARED before the per-entity rungs, or
/// re-selecting its survivors would toggle them OFF.
#[test]
fn a_partial_batch_is_cleared_first() {
struct Half(ProfileSelector);
impl Selector for Half {
fn clear(&mut self) {
self.0.clear()
}
fn count(&mut self) -> Option<usize> {
self.0.count()
}
fn attempt(&mut self, rung: Rung, items: &[usize]) -> Result<(), String> {
if rung == Rung::MultiSelectTyped {
return self.0.attempt(rung, &items[..1]);
}
self.0.attempt(rung, items)
}
}
let mut s = Half(ProfileSelector::new(SelProfile::measured_2026()));
let r = select_ladder(&mut s, 2);
assert!(r.all(2), "{r:?}");
assert_eq!(r.via, vec![Some(Rung::EntitySelect4); 2]);
assert_eq!(s.0.selected, vec![0, 1]);
// The simulator toggles like SOLIDWORKS: selected twice = deselected.
let mut t = ProfileSelector::new(SelProfile::measured_2026());
t.attempt(Rung::EntitySelect4, &[0]).unwrap();
t.attempt(Rung::SelectByRay, &[0]).unwrap();
assert_eq!(t.count(), Some(0));
}
#[test]
fn rays_start_outside_and_point_back_in() {
let (o, d) = face_ray([0.0, 0.01, 0.0], [0.0, 2.0, 0.0]).unwrap();
assert!((o[1] - 0.011).abs() < 1e-12 && d == [0.0, -1.0, 0.0]);
assert!(face_ray([0.0; 3], [0.0; 3]).is_none(), "no normal, no ray");
// A box's top-front edge: the bisector of +Y and +Z.
let (o, d) = edge_ray([0.0, 0.01, 0.01], &[[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).unwrap();
let k = 1.0e-3 / 2f64.sqrt();
assert!((o[1] - (0.01 + k)).abs() < 1e-12 && (o[2] - (0.01 + k)).abs() < 1e-12, "{o:?}");
assert!((d[1] + 0.5f64.sqrt()).abs() < 1e-12 && (d[2] + 0.5f64.sqrt()).abs() < 1e-12);
// Unnormalised normals still give the BISECTOR (each counts once).
let (_, d2) = edge_ray([0.0; 3], &[[0.0, 3.0, 0.0], [0.0, 0.0, 1.0]]).unwrap();
assert!((d2[1] - d2[2]).abs() < 1e-12, "{d2:?}");
// One known face is enough.
assert!(edge_ray([0.0; 3], &[[0.0, 0.0, 3.0], [0.0; 3]]).is_some());
}
#[test]
fn clear_part_repeats_until_the_tree_is_empty() {
use std::cell::RefCell;
// THE MEASURED CASE: the first delete takes the features and leaves a sketch.
let tree = RefCell::new(vec!["Boss-Extrude1".to_string(), "Cut-Extrude1".to_string(), "Sketch13".to_string()]);
let r = clear_in_passes(
|| Ok::<_, String>(tree.borrow().clone()),
|names: &[String], pass| {
let mut t = tree.borrow_mut();
if pass == 1 {
t.retain(|n| n.starts_with("Sketch") && names.contains(n));
} else {
t.clear();
}
Ok(())
},
5,
);
assert_eq!(r, Ok(Cleared { passes: 2, deleted: vec!["Boss-Extrude1".into(), "Cut-Extrude1".into(), "Sketch13".into()] }));
// Nothing there: no pass at all.
assert_eq!(clear_in_passes(|| Ok::<_, String>(vec![]), |_: &[String], _| Ok(()), 5), Ok(Cleared { passes: 0, deleted: vec![] }));
// Something that never goes: stuck after exactly max passes, and SAID.
let calls = RefCell::new(0);
let r = clear_in_passes(
|| Ok::<_, String>(vec!["Sketch9".to_string()]),
|_: &[String], _| {
*calls.borrow_mut() += 1;
Ok(())
},
5,
);
assert_eq!(r, Err(ClearError::Stuck { passes: 5, left: vec!["Sketch9".into()], deleted: vec![] }));
assert_eq!(*calls.borrow(), 5);
// A host failure stops it, as a host failure.
let r = clear_in_passes(|| Ok::<_, String>(vec!["A".into()]), |_: &[String], _| Err("COM said no".to_string()), 5);
assert_eq!(r, Err(ClearError::Host("COM said no".to_string())));
}
#[test]
fn the_iid_probe_is_read_strictly() {
let g = "guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7\nitype=0\n";
assert_eq!(parse_iid_probe(g), Some((0x4c1e9cc6_3d6d_11d5_9a8c_00c04f80e3e7, true)));
assert_eq!(parse_iid_probe("guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7\ntlflags=4160").map(|x| x.1), Some(true));
assert_eq!(parse_iid_probe("guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7\nitype=1\ntlflags=4160").map(|x| x.1), Some(false), "IUnknown-only is never called by name");
assert_eq!(parse_iid_probe("guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7").map(|x| x.1), Some(false), "no evidence, no call");
assert_eq!(parse_iid_probe("guid=00000000-0000-0000-0000-000000000000\nitype=0"), None);
assert_eq!(parse_iid_probe("nothing"), None);
}
#[test]
fn contour_moments_are_greens_theorem_exactly() {
use crate::script::Entity;
let l = |x0: f64, y0: f64, x1: f64, y1: f64| Entity::Line { x0, y0, x1, y1, construction: false };
// The unit square counter-clockwise: A 1, centroid (½, ½).
let sq = [l(0.0, 0.0, 1.0, 0.0), l(1.0, 0.0, 1.0, 1.0), l(1.0, 1.0, 0.0, 1.0), l(0.0, 1.0, 0.0, 0.0)];
let c: Vec<(usize, bool)> = (0..4).map(|i| (i, false)).collect();
let (a, sx, sy) = contour_moments(&sq, &c);
assert!((a - 1.0).abs() < 1e-15 && (sx - 0.5).abs() < 1e-15 && (sy - 0.5).abs() < 1e-15, "{a} {sx} {sy}");
// Walked backwards: the same moments, negated.
let back: Vec<(usize, bool)> = (0..4).rev().map(|i| (i, true)).collect();
let (b, bx, by) = contour_moments(&sq, &back);
assert!((b + 1.0).abs() < 1e-15 && (bx + 0.5).abs() < 1e-15 && (by + 0.5).abs() < 1e-15);
// A half disc r 2 about (3, 1), the arc on top: A = 2π, ȳ = 1 + 8/(3π).
let hd = [
Entity::Arc { cx: 3.0, cy: 1.0, x0: 5.0, y0: 1.0, x1: 1.0, y1: 1.0, ccw: true, construction: false },
l(1.0, 1.0, 5.0, 1.0),
];
let (a, sx, sy) = contour_moments(&hd, &[(0, false), (1, false)]);
assert!((a - 2.0 * std::f64::consts::PI).abs() < 1e-12, "{a}");
assert!((sx / a - 3.0).abs() < 1e-12 && (sy / a - (1.0 + 8.0 / (3.0 * std::f64::consts::PI))).abs() < 1e-12, "{} {}", sx / a, sy / a);
// The same arc clockwise is the lower half: ȳ = 1 − 8/(3π), and the
// contour (arc then line back) runs clockwise, so A is negative.
let lo = [Entity::Arc { cx: 3.0, cy: 1.0, x0: 5.0, y0: 1.0, x1: 1.0, y1: 1.0, ccw: false, construction: false }, l(1.0, 1.0, 5.0, 1.0)];
let (a, _, sy) = contour_moments(&lo, &[(0, false), (1, false)]);
assert!((a + 2.0 * std::f64::consts::PI).abs() < 1e-12 && (sy / a - (1.0 - 8.0 / (3.0 * std::f64::consts::PI))).abs() < 1e-12);
}
#[test]
fn a_mirroring_sketch_map_flips_an_arc() {
use crate::script::Entity;
let front = PlaneFrame::defaults()[0];
let arc = Entity::Arc { cx: 0.0, cy: 0.0, x0: 1.0, y0: 0.0, x1: 0.0, y1: 1.0, ccw: true, construction: false };
let (same, _) = entity_to_sketch(&front, 0.0, &arc, &Xform::identity());
assert!(matches!(same, Entity::Arc { ccw: true, .. }));
// A sketch whose x runs the other way (a plane SOLIDWORKS flipped).
let flip = Xform { rows: [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], t: [0.0; 3], s: 1.0 };
assert!(mapping_reflects(&front, 0.0, &flip));
let (m, z) = entity_to_sketch(&front, 0.0, &arc, &flip);
assert!(matches!(m, Entity::Arc { ccw: false, x0, .. } if x0 == -1.0) && z == 0.0, "{m:?}");
let line = Entity::Line { x0: 1.0, y0: 2.0, x1: 3.0, y1: 4.0, construction: true };
assert_eq!(entity_to_sketch(&front, 0.0, &line, &flip).0, Entity::Line { x0: -1.0, y0: 2.0, x1: -3.0, y1: 4.0, construction: true });
}
/// MEASURED 2026-10-16 on SOLIDWORKS 2026 (canary 0.11.4-rc1): "up to surface" is
/// swEndCondUpToSurface 4 (10, the help's UpToSelection, left the direction box
/// empty and stopped at the next face), and Ddir1 TRUE drafts OUTWARD (the help
/// says inward; a 5 deg boss and a 45 deg countersink both widened with TRUE).
#[test]
fn end_condition_ids_and_the_draft_flag_as_solidworks_measured_them() {
use crate::script::{Draft, UpTo};
assert_eq!(end_condition_id(&UpTo::Next), 11);
assert_eq!(end_condition_id(&UpTo::Surface { point: [0.0, 0.0, 0.0] }), 4, "swEndCondUpToSurface, the macro recorder's id");
assert_eq!(end_condition_id(&UpTo::OffsetFrom { point: [0.0, 0.0, 0.0], offset: 0.008, reverse_offset: false }), 5);
assert!(!draft_outward_flag(&Draft { angle: 0.1, outward: false }), "inward (the default, a countersink) = Ddir FALSE");
assert!(draft_outward_flag(&Draft { angle: 0.1, outward: true }), "outward = Ddir TRUE");
}
}