app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089
//! THE SIMULATOR'S SOLID MODEL (0.10.0, rebuilt 0.10.2) — enough geometry to
//! simulate the modelling DSL HONESTLY, including the hazards an independent
//! review of the COM code named (2026-10-13). It is ARITHMETIC, not a kernel:
//! * bodies are boxes, cylinders or "other" solids with an exact volume and a
//! bounding box; a profile's area counts nested entities as holes; a boss
//! merges into every body it touches, or starts a new one; a cut removes
//! only what lies inside a body — a cut into empty space comes back NULL,
//! exactly as SOLIDWORKS' does;
//! * faces and edges are picked GEOMETRICALLY (`modeling::pick`); the
//! view-dependent pick the review warned about is modelled too
//! (`view_pick`), so the difference is testable;
//! * selecting an already-selected entity DESELECTS it
//! (`modeling::toggled_selection`): an un-deduplicated edge list selects
//! nothing;
//! * a face's outward normal decides which way a boss from it goes;
//! * applying a material WIPES the part's appearance (documented SOLIDWORKS
//! behaviour), so the bridge must re-apply it;
//! * fillets, chamfers and shells change the volume by closed-form formulas
//! for the shapes they are applied to (box, cylinder) — anything else is
//! reported as "not modelled", never guessed.
//!
//! Nothing here is ever presented as a measurement of SOLIDWORKS.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::f64::consts::PI;
use crate::modeling::{self, PlaneFrame, V3};
use crate::script::{AppearanceTarget, Axis, EdgeSel, Entity, Extent, Op, PlaneRef, UpTo};
const TOL: f64 = 1e-6;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Model {
pub bodies: Vec<Body>,
pub features: Vec<Feature>,
pub counters: std::collections::BTreeMap<String, u32>,
/// Part-level appearance (0..255 RGB).
pub appearance: Option<[i64; 3]>,
/// Face-level appearances: (a point on the face, rgb).
pub face_appearances: Vec<(V3, [i64; 3])>,
pub material: Option<String>,
/// The part's density, kg/m³, when set by set_material (0.11.0): the
/// library material's, or the document's own (`densityKgM3` alone).
#[serde(default)]
pub density: Option<f64>,
/// THE SOLID AS PRIMITIVES (0.11.0, mock_csg): every feature's prism or
/// revolve, in order — integrated by columns for the mass of a part the
/// box model can only approximate.
#[serde(default)]
pub csg: Vec<crate::comhost::mock_csg::Prim>,
/// Why `csg` no longer describes the part (a fillet, a shell…).
#[serde(default)]
pub csg_unsupported: Option<String>,
/// SKETCHES LEFT IN THE TREE UNCONSUMED (0.10.8): a feature that failed
/// (T07's cut that removed nothing) or a script that ended on a sketch
/// leaves its sketch behind, unabsorbed. MEASURED: the first
/// DeleteSelection2 of a clear_part left exactly such a sketch.
#[serde(default)]
pub loose_sketches: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum Shape {
/// The body IS its bounding box.
Box,
/// A cylinder along `axis` (0 X, 1 Y, 2 Z) of radius `r`; its extent along
/// the axis is the box's.
Cylinder { axis: usize, r: f64 },
Other,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Body {
pub name: String,
pub lo: V3,
pub hi: V3,
pub volume: f64,
pub faces: u32,
pub shape: Shape,
pub appearance: Option<[i64; 3]>,
/// ∭ p dV (m⁴): the body's first moment, for its centre of mass (0.11.0).
#[serde(default)]
pub moment: V3,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Feature {
pub name: String,
pub kind: String,
/// The volume this feature added (negative: removed).
pub volume: f64,
/// The box of the material it added (a boss), for patterns.
pub lo: Option<V3>,
pub hi: Option<V3>,
pub body: Option<usize>,
/// The first moment of the material it added (m⁴), for mirrors and patterns.
#[serde(default)]
pub moment: V3,
}
/// A sketch closed and not yet consumed.
#[derive(Clone, Debug)]
pub struct Pending {
pub name: String,
pub frame: PlaneFrame,
pub h: f64,
/// The REFERENCE direction along the frame's normal: +1, or −1 for a face
/// whose outward normal is the frame's −normal.
pub sign: f64,
pub entities: Vec<Entity>,
}
/// What a running script's simulation remembers (mirrors com.rs ScriptState).
#[derive(Default)]
pub struct Run {
pub pending: Option<Pending>,
/// Is the pending sketch still SELECTED? Ops between a sketch and its
/// extrude clear the selection; the bridge re-selects the sketch by name.
pub sketch_selected: bool,
pub last_feature: Option<String>,
pub appearances: Vec<(AppearanceTarget, [f64; 3])>,
/// Which selection calls really select on this simulated install (the
/// default is what the first live run on SOLIDWORKS 2026 measured).
pub sel_profile: modeling::SelProfile,
}
/// Select `n` picked entities through THE SAME LADDER as the COM host
/// (modeling::select_ladder), failing with the COM host's words when not all
/// of them end up selected.
fn select_n(run: &Run, n: usize, what: &str, nothing: &str) -> Result<Value, String> {
let mut s = modeling::ProfileSelector::new(run.sel_profile.clone());
let r = modeling::select_ladder(&mut s, n);
if !r.all(n) {
return Err(format!("only {} of {n} {what} ended up selected — {nothing} ({})", r.count, r.notes.join("; ")));
}
Ok(json!({"selectedVia": r.report(), "viewDependent": r.view_dependent()}))
}
fn mm(v: f64) -> f64 {
(v * 1.0e6).round() / 1000.0
}
fn rgb255(rgb: &[f64; 3]) -> [i64; 3] {
[(rgb[0] * 255.0).round() as i64, (rgb[1] * 255.0).round() as i64, (rgb[2] * 255.0).round() as i64]
}
fn axis_index(a: Axis) -> usize {
match a {
Axis::X => 0,
Axis::Y => 1,
Axis::Z => 2,
}
}
/// The index of the model axis a default plane's normal runs along.
fn normal_axis(f: &PlaneFrame) -> usize {
(0..3).max_by(|a, b| f.n[*a].abs().partial_cmp(&f.n[*b].abs()).unwrap()).unwrap_or(1)
}
fn touches(alo: V3, ahi: V3, blo: V3, bhi: V3) -> bool {
(0..3).all(|i| alo[i] <= bhi[i] + 1e-9 && blo[i] <= ahi[i] + 1e-9)
}
fn overlap_len(a0: f64, a1: f64, b0: f64, b1: f64) -> f64 {
(a1.min(b1) - a0.max(b0)).max(0.0)
}
// ── profiles ────────────────────────────────────────────────────────────────
/// What kind of closed profile: the simulator models drafts and fillets on
/// rectangles and circles only.
#[derive(Clone, Copy, Debug, PartialEq)]
enum ProfKind {
Rect { w: f64, h: f64 },
Circle { r: f64 },
Contour,
}
/// One closed profile of a sketch (0.11.0): a rectangle, a circle, or a
/// contour of lines and arcs — its EXACT area and centroid (Green's theorem
/// for contours, `modeling::contour_moments`), its bounds and side count.
#[derive(Clone, Debug)]
struct Prof {
id: usize,
area: f64,
cx: f64,
cy: f64,
bounds: (f64, f64, f64, f64),
sides: u32,
kind: ProfKind,
}
fn profiles(entities: &[Entity]) -> Vec<Prof> {
let mut out = Vec::new();
for e in entities {
match *e {
Entity::Rect { x0, y0, x1, y1 } => {
let b = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
out.push(Prof { id: out.len(), area: ((x1 - x0) * (y1 - y0)).abs(), cx: (x0 + x1) / 2.0, cy: (y0 + y1) / 2.0, bounds: b, sides: 4,
kind: ProfKind::Rect { w: b.2 - b.0, h: b.3 - b.1 } });
}
Entity::Circle { cx, cy, r } => {
out.push(Prof { id: out.len(), area: PI * r * r, cx, cy, bounds: (cx - r, cy - r, cx + r, cy + r), sides: 1, kind: ProfKind::Circle { r } });
}
_ => {}
}
}
for c in crate::script::contours(entities).unwrap_or_default() {
let (a, sx, sy) = modeling::contour_moments(entities, &c);
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for (i, _) in &c {
let (lo, hi) = crate::script::extent_along(&entities[*i], 1.0, 0.0);
let (vlo, vhi) = crate::script::extent_along(&entities[*i], 0.0, 1.0);
x0 = x0.min(lo);
x1 = x1.max(hi);
y0 = y0.min(vlo);
y1 = y1.max(vhi);
}
if a.abs() > 1e-30 {
out.push(Prof { id: out.len(), area: a.abs(), cx: sx / a, cy: sy / a, bounds: (x0, y0, x1, y1), sides: c.len() as u32, kind: ProfKind::Contour });
}
}
out
}
/// Every profile with the entities that make it (0.11.4): a rectangle or a
/// circle is its own entity; a contour, its lines and arcs.
fn profile_members(entities: &[Entity]) -> Vec<(Prof, Vec<usize>)> {
let ps = profiles(entities);
let mut members: Vec<Vec<usize>> = Vec::new();
for (i, e) in entities.iter().enumerate() {
if matches!(e, Entity::Rect { .. } | Entity::Circle { .. }) {
members.push(vec![i]);
}
}
for c in crate::script::contours(entities).unwrap_or_default() {
let (a, _, _) = modeling::contour_moments(entities, &c);
if a.abs() > 1e-30 {
members.push(c.iter().map(|(i, _)| *i).collect());
}
}
ps.into_iter().zip(members).collect()
}
fn members_of(members: &[(Prof, Vec<usize>)], id: usize) -> Vec<usize> {
members.iter().find(|(p, _)| p.id == id).map(|(_, m)| m.clone()).unwrap_or_default()
}
fn inside(a: &Prof, b: &Prof) -> bool {
let (ax0, ay0, ax1, ay1) = a.bounds;
let (bx0, by0, bx1, by1) = b.bounds;
a.id != b.id && ax0 >= bx0 - 1e-12 && ay0 >= by0 - 1e-12 && ax1 <= bx1 + 1e-12 && ay1 <= by1 + 1e-12 && a.area < b.area
}
/// The profile's area: profiles at an even nesting depth add, odd subtract —
/// two concentric circles are a RING (issue #1 measured a solid r 2.4 cylinder
/// when each circle had its own sketch).
pub fn profile_area(entities: &[Entity]) -> f64 {
let ps = profiles(entities);
ps.iter()
.map(|e| {
let depth = ps.iter().filter(|o| inside(e, o)).count();
if depth % 2 == 0 { e.area } else { -e.area }
})
.sum()
}
/// A closed region of a sketch: an outer profile and the holes directly in it.
struct Region {
outer: Prof,
holes: Vec<Prof>,
}
fn regions(entities: &[Entity]) -> Vec<Region> {
let ps = profiles(entities);
ps.iter()
.filter(|e| ps.iter().filter(|o| inside(e, o)).count() % 2 == 0)
.map(|outer| Region {
outer: outer.clone(),
holes: ps
.iter()
.filter(|h| inside(h, outer) && ps.iter().filter(|o| inside(h, o)).count() == ps.iter().filter(|o| inside(outer, o)).count() + 1)
.cloned()
.collect(),
})
.collect()
}
impl Region {
fn area(&self) -> f64 {
self.outer.area - self.holes.iter().map(|h| h.area).sum::<f64>()
}
/// The region's centroid (u, v): the outer's first moment less the holes'.
fn centroid(&self) -> (f64, f64) {
let a = self.area();
if a <= 0.0 {
return (self.outer.cx, self.outer.cy);
}
let sx = self.outer.area * self.outer.cx - self.holes.iter().map(|h| h.area * h.cx).sum::<f64>();
let sy = self.outer.area * self.outer.cy - self.holes.iter().map(|h| h.area * h.cy).sum::<f64>();
(sx / a, sy / a)
}
fn faces(&self) -> u32 {
2 + self.outer.sides + self.holes.iter().map(|h| h.sides).sum::<u32>()
}
fn shape(&self, axis: usize) -> Shape {
match (self.outer.kind, self.holes.is_empty()) {
(ProfKind::Rect { .. }, true) => Shape::Box,
(ProfKind::Circle { r }, true) => Shape::Cylinder { axis, r },
_ => Shape::Other,
}
}
/// THE PRISM'S VOLUME over `len`, with a draft of `k` = tan(angle)
/// (positive inward: the profile shrinks as it goes). Exact for a
/// rectangle ((w−2kt)(h−2kt)) or a circle (π(r−kt)²) with no holes —
/// both integrands are quadratic, so Simpson's rule is exact. Returns the
/// volume and the distance of its centroid from the sketch plane; None for
/// a drafted profile the simulator does not model.
fn prism(&self, len: f64, k: f64) -> Option<(f64, f64)> {
if k == 0.0 {
return Some((self.area() * len, len / 2.0));
}
if !self.holes.is_empty() {
return None;
}
let a = |t: f64| match self.outer.kind {
ProfKind::Rect { w, h } => ((w - 2.0 * k * t).max(0.0)) * ((h - 2.0 * k * t).max(0.0)),
ProfKind::Circle { r } => PI * (r - k * t).max(0.0).powi(2),
ProfKind::Contour => f64::NAN,
};
if a(0.0).is_nan() {
return None;
}
let vol = len / 6.0 * (a(0.0) + 4.0 * a(len / 2.0) + a(len));
let first = len / 6.0 * (0.0 + 4.0 * (len / 2.0) * a(len / 2.0) + len * a(len));
Some((vol, if vol > 0.0 { first / vol } else { len / 2.0 }))
}
}
fn prism_box(f: &PlaneFrame, e: &Prof, span: (f64, f64)) -> (V3, V3) {
let (u0, v0, u1, v1) = e.bounds;
let mut lo = [f64::MAX; 3];
let mut hi = [f64::MIN; 3];
for (u, v) in [(u0, v0), (u0, v1), (u1, v0), (u1, v1)] {
for h in [span.0, span.1] {
let p = f.point(u, v, h);
for i in 0..3 {
lo[i] = lo[i].min(p[i]);
hi[i] = hi[i].max(p[i]);
}
}
}
(lo, hi)
}
// ── faces and edges, picked ─────────────────────────────────────────────────
/// A face of a body's box: (body, axis, side: −1 lo / +1 hi).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FaceRef {
pub body: usize,
pub axis: usize,
pub side: i32,
}
/// An edge of a body's box: (body, the axis it runs along, the two fixed sides).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EdgeRef {
pub body: usize,
pub along: usize,
pub sides: [i32; 2],
}
impl Model {
fn face_coord(&self, f: FaceRef) -> f64 {
let b = &self.bodies[f.body];
if f.side > 0 { b.hi[f.axis] } else { b.lo[f.axis] }
}
fn all_faces(&self) -> Vec<FaceRef> {
let mut out = Vec::new();
for body in 0..self.bodies.len() {
for axis in 0..3 {
for side in [-1, 1] {
out.push(FaceRef { body, axis, side });
}
}
}
out
}
fn all_edges(&self) -> Vec<EdgeRef> {
let mut out = Vec::new();
for body in 0..self.bodies.len() {
for along in 0..3 {
for s0 in [-1, 1] {
for s1 in [-1, 1] {
out.push(EdgeRef { body, along, sides: [s0, s1] });
}
}
}
}
out
}
/// The closest point of a box face to `p`, and `p`'s height above it.
fn face_distance(&self, f: FaceRef, p: V3) -> (f64, f64) {
let b = &self.bodies[f.body];
let mut q = p;
for i in 0..3 {
if i == f.axis {
q[i] = self.face_coord(f);
} else {
q[i] = p[i].clamp(b.lo[i], b.hi[i]);
}
}
(modeling::norm(modeling::sub(p, q)), (p[f.axis] - q[f.axis]) * f.side as f64)
}
fn edge_axes(e: EdgeRef) -> [usize; 2] {
match e.along {
0 => [1, 2],
1 => [0, 2],
_ => [0, 1],
}
}
fn edge_distance(&self, e: EdgeRef, p: V3) -> f64 {
let b = &self.bodies[e.body];
let mut q = p;
q[e.along] = p[e.along].clamp(b.lo[e.along], b.hi[e.along]);
for (k, ax) in Self::edge_axes(e).iter().enumerate() {
q[*ax] = if e.sides[k] > 0 { b.hi[*ax] } else { b.lo[*ax] };
}
modeling::norm(modeling::sub(p, q))
}
fn edge_len(&self, e: EdgeRef) -> f64 {
let b = &self.bodies[e.body];
b.hi[e.along] - b.lo[e.along]
}
/// THE GEOMETRIC FACE PICK.
pub fn pick_face(&self, p: V3) -> Result<FaceRef, String> {
let faces = self.all_faces();
let cands: Vec<(usize, f64, f64)> = faces
.iter()
.enumerate()
.map(|(i, f)| {
let (d, out) = self.face_distance(*f, p);
(i, d, out)
})
.collect();
modeling::pick(&cands, TOL).map(|i| faces[i]).map_err(|e| match e {
modeling::PickError::None(near) => format!(
"no face of the part passes through [{:.3}, {:.3}, {:.3}] mm (nearest {} mm away)",
p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0,
near.map(|d| format!("{:.3}", d * 1000.0)).unwrap_or_else(|| "—".into())
),
modeling::PickError::Ambiguous(n) => format!(
"the point [{:.3}, {:.3}, {:.3}] mm lies on {n} faces (an edge or a corner) — move it inside ONE face",
p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0
),
})
}
/// THE VIEW-DEPENDENT PICK the review warned about (SelectByID2 on a FACE
/// "uses the specified point as input to the normal user-interface
/// selection routines"): the first face the eye's ray meets, looking from
/// the default isometric direction. Modelled so a test can show it picks a
/// VISIBLE face for a point on a hidden one; the bridge never uses it.
pub fn view_pick(&self, p: V3) -> Option<FaceRef> {
// Coming from +infinity along (1, 1, 1): the first slab boundary met is
// the smallest (hi_k - p_k) over the body that contains the ray.
let b = self.bodies.iter().position(|b| (0..3).all(|i| p[i] >= b.lo[i] - TOL && p[i] <= b.hi[i] + TOL))?;
let body = &self.bodies[b];
let axis = (0..3).min_by(|x, y| (body.hi[*x] - p[*x]).partial_cmp(&(body.hi[*y] - p[*y])).unwrap()).unwrap_or(1);
Some(FaceRef { body: b, axis, side: 1 })
}
pub fn pick_edge(&self, p: V3) -> Result<EdgeRef, String> {
let edges = self.all_edges();
let cands: Vec<(usize, f64, f64)> = edges.iter().enumerate().map(|(i, e)| (i, self.edge_distance(*e, p), 0.0)).collect();
modeling::pick(&cands, TOL).map(|i| edges[i]).map_err(|e| match e {
modeling::PickError::None(_) => format!("no edge at [{:.3}, {:.3}, {:.3}] mm", p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0),
modeling::PickError::Ambiguous(n) => format!(
"the point [{:.3}, {:.3}, {:.3}] mm lies on {n} edges (a corner) — move it along ONE edge",
p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0
),
})
}
// ── features ───────────────────────────────────────────────────────────
fn next_name(&mut self, kind: &str) -> String {
let n = self.counters.entry(kind.to_string()).or_insert(0);
*n += 1;
format!("{kind}{n}")
}
fn feature(&self, name: &str) -> Option<&Feature> {
self.features.iter().find(|f| f.name == name)
}
fn through(&self, axis: usize) -> (f64, f64) {
let lo = self.bodies.iter().map(|b| b.lo[axis]).fold(f64::MAX, f64::min);
let hi = self.bodies.iter().map(|b| b.hi[axis]).fold(f64::MIN, f64::max);
if lo > hi { (0.0, 0.0) } else { (lo, hi) }
}
/// A sketch: where it is, resolved. Returns the pending sketch and the
/// step's report.
pub fn sketch(&mut self, plane: &PlaneRef, entities: &[Entity], offsets: &[crate::script::OffsetFace]) -> Result<(Pending, Value), String> {
let defaults = PlaneFrame::defaults();
let (frame, h, sign, resolved, made) = match plane {
PlaneRef::Named(n) => {
let i = modeling::default_index(n).ok_or_else(|| format!("no plane named '{n}' in this document"))?;
(defaults[i], 0.0, 1.0, Value::Null, None)
}
PlaneRef::Offset { from, offset } => {
let i = modeling::default_index(from).ok_or_else(|| format!("no plane named '{from}' to offset from"))?;
let made = self.next_name("Plane");
(defaults[i], *offset, 1.0, json!({"offsetFrom": from, "offsetMm": offset * 1000.0}), Some(made))
}
PlaneRef::FaceOffset { point, offset } => {
let f = self.pick_face(*point)?;
let mut n = [0.0; 3];
n[f.axis] = f.side as f64;
let i = modeling::parallel_default(&defaults, n).ok_or("the face is not parallel to a default plane")?;
let h = self.face_coord(f) + offset * f.side as f64;
let names = ["Front Plane", "Top Plane", "Right Plane"];
let made = self.next_name("Plane");
(
defaults[i],
h,
f.side as f64,
json!({"offsetFrom": names[i], "offsetMm": h * 1000.0, "fromFace": true, "outward": f.side, "pickedBy": "geometry"}),
Some(made),
)
}
PlaneRef::Face { point } => {
let f = self.pick_face(*point)?;
let i = modeling::parallel_default(&defaults, {
let mut n = [0.0; 3];
n[f.axis] = f.side as f64;
n
})
.ok_or("the face is not parallel to a default plane")?;
let h = self.face_coord(f);
let names = ["Front Plane", "Top Plane", "Right Plane"];
(
defaults[i],
h,
f.side as f64,
json!({"offsetFrom": names[i], "offsetMm": h * 1000.0, "outward": f.side, "pickedBy": "geometry"}),
None,
)
}
};
// OFFSET ENTITIES (0.11.4): the simulator knows a face's outline only
// where the body IS its box — a plain block, whose faces are
// rectangles. Anything else is SOLIDWORKS' to draw: refused, saying so.
let mut all = entities.to_vec();
let mut offset_report = Vec::new();
for o in offsets {
let f = self.pick_face(o.point)?;
if self.bodies[f.body].shape != Shape::Box {
return Err("the simulator can offset only the face of a plain block (a body that is its own bounding box); \
this face's outline is SOLIDWORKS' own — run it live (offsetEntities)".into());
}
if f.axis != normal_axis(&frame) {
return Err("the simulator offsets a face PARALLEL to the sketch plane only (SOLIDWORKS projects any face)".into());
}
let b = &self.bodies[f.body];
let (mut u0, mut v0, mut u1, mut v1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let others: Vec<usize> = (0..3).filter(|i| *i != f.axis).collect();
for ca in [b.lo[others[0]], b.hi[others[0]]] {
for cb in [b.lo[others[1]], b.hi[others[1]]] {
let mut p = [0.0; 3];
p[others[0]] = ca;
p[others[1]] = cb;
let rel = modeling::sub(p, frame.origin);
let (u, v) = (modeling::dot(rel, frame.x), modeling::dot(rel, frame.y));
u0 = u0.min(u);
u1 = u1.max(u);
v0 = v0.min(v);
v1 = v1.max(v);
}
}
// offset < 0: INTO the face.
let d = o.offset;
let (x0, y0, x1, y1) = (u0 - d, v0 - d, u1 + d, v1 + d);
if x1 - x0 <= 1e-9 || y1 - y0 <= 1e-9 {
return Err(format!("an offset of {} mm inward leaves nothing of a {:.3} x {:.3} mm face", -d * 1000.0, (u1 - u0) * 1000.0, (v1 - v0) * 1000.0));
}
all.push(Entity::Rect { x0, y0, x1, y1 });
offset_report.push(json!({"rectMm": [[x0 * 1000.0, y0 * 1000.0], [x1 * 1000.0, y1 * 1000.0]], "side": if d < 0.0 { "inward" } else { "outward" },
"simulated": "the face of a plain block: its rectangle, offset"}));
}
let entities = &all[..];
let name = self.next_name("Sketch");
let mut report = json!({
"plane": plane.describe(),
"resolvedPlane": resolved,
"createdPlane": made,
"sketch": name,
"entities": entities.iter().map(|e| e.describe()).collect::<Vec<_>>(),
"profileAreaMm2": profile_area(entities) * 1.0e6,
});
if !offsets.is_empty() {
report["offsets"] = json!(offsets.iter().zip(offset_report).map(|(o, r)| {
let mut d = o.describe();
d["drawn"] = r;
d
}).collect::<Vec<_>>());
}
crate::script::single_entity_fields(entities, &mut report);
Ok((Pending { name, frame, h, sign, entities: entities.to_vec() }, report))
}
/// THE SKETCH REGIONS a pick selects (0.11.4), as the entities that bound
/// them: for each point, the innermost profile holding it and the
/// profiles directly inside that one (its holes). The simulator models
/// regions of profiles that NEST or stand apart; crossing contours (an
/// offset outline and a circle) are SOLIDWORKS' to resolve — refused.
pub fn region_entities(entities: &[Entity], picks: &[[f64; 2]]) -> Result<Vec<Entity>, String> {
let members = profile_members(entities);
for (i, (a, _)) in members.iter().enumerate() {
for (b, _) in members.iter().skip(i + 1) {
let (ax0, ay0, ax1, ay1) = a.bounds;
let (bx0, by0, bx1, by1) = b.bounds;
let apart = ax1 < bx0 - 1e-12 || bx1 < ax0 - 1e-12 || ay1 < by0 - 1e-12 || by1 < ay0 - 1e-12;
let crossing = crate::comhost::mock_csg::profiles_cross(entities, &members_of(&members, a.id), &members_of(&members, b.id));
if !apart && crossing {
return Err("the simulator models regions of profiles that nest or stand apart; these contours CROSS — \
only SOLIDWORKS resolves their regions (run it live)".into());
}
}
}
let mut keep: Vec<usize> = Vec::new();
for (k, p) in picks.iter().enumerate() {
let holding: Vec<&(Prof, Vec<usize>)> = members.iter().filter(|(_, m)| crate::comhost::mock_csg::point_in(entities, m, (p[0], p[1]))).collect();
let inner = holding.iter().min_by(|a, b| a.0.area.partial_cmp(&b.0.area).unwrap_or(std::cmp::Ordering::Equal))
.ok_or_else(|| format!("region {}: [{}, {}] mm is in no closed profile of the sketch", k + 1, p[0] * 1000.0, p[1] * 1000.0))?;
keep.extend(inner.1.iter().copied());
// Its holes: the profiles inside it with nothing between.
for (q, m) in &members {
if q.id != inner.0.id && inside(q, &inner.0) && !members.iter().any(|(r, _)| r.id != q.id && r.id != inner.0.id && inside(q, r) && inside(r, &inner.0)) {
keep.extend(m.iter().copied());
}
}
}
keep.sort_unstable();
keep.dedup();
Ok(keep.into_iter().map(|i| entities[i].clone()).collect())
}
/// Where an UP-TO end condition stops, as a coordinate along the sketch's
/// normal axis (0.11.0). The simulator models faces parallel to the sketch
/// plane only.
fn up_to_target(&self, p: &Pending, regs: &[Region], axis: usize, own: f64, u: &UpTo) -> Result<f64, String> {
let null = |why: &str| format!("SOLIDWORKS made no feature (it came back null): {why}");
let target = match u {
UpTo::Next => {
let others: Vec<usize> = (0..3).filter(|i| *i != axis).collect();
let mut best: Option<f64> = None;
for r in regs {
let (lo, hi) = prism_box(&p.frame, &r.outer, (p.h, p.h));
for b in &self.bodies {
let overlaps = others.iter().all(|i| overlap_len(lo[*i], hi[*i], b.lo[*i], b.hi[*i]) > 1e-12);
if !overlaps {
continue;
}
for c in [b.lo[axis], b.hi[axis]] {
let ahead = (c - p.h) * own;
if ahead > 1e-9 && best.map(|x| ahead < (x - p.h) * own).unwrap_or(true) {
best = Some(c);
}
}
}
}
best.ok_or_else(|| null("there is no next face in that direction (up to next)"))?
}
UpTo::Surface { point } | UpTo::OffsetFrom { point, .. } => {
let f = self.pick_face(*point)?;
if f.axis != axis {
return Err("the simulator models an up-to face PARALLEL to the sketch plane only (SOLIDWORKS takes any face)".into());
}
let c = self.face_coord(f);
match u {
UpTo::OffsetFrom { offset, reverse_offset, .. } => c - own * offset * if *reverse_offset { -1.0 } else { 1.0 },
_ => c,
}
}
};
if (target - p.h) * own <= 1e-9 {
return Err(null("the face is BEHIND the sketch for this direction — a boss goes along the plane's normal (outward from a face), a cut against it; pass reverse:true"));
}
Ok(target)
}
/// An extrude or a cut of the pending sketch.
pub fn extrude(&mut self, p: &Pending, ext: &Extent, cut: bool) -> Result<Value, String> {
let axis = normal_axis(&p.frame);
// The op's own direction along the frame's normal: a boss along the
// reference, a cut against it; reverse flips either.
let own = p.sign * if cut { -1.0 } else { 1.0 } * if ext.reverse { -1.0 } else { 1.0 };
let kind = if cut { "Cut-Extrude" } else { "Boss-Extrude" };
let regs = regions(&p.entities);
// An UP-TO end condition is a blind depth once its face is found.
let mut eff = ext.clone();
let mut ends_at: Option<f64> = None;
if let Some(u) = &ext.up_to {
let t = self.up_to_target(p, ®s, axis, own, u)?;
eff = Extent { depth: (t - p.h) * own, up_to: None, ..Extent::blind(0.0) };
eff.draft = ext.draft;
ends_at = Some(t);
}
let span = modeling::extent_span(p.h, own, &eff, self.through(axis));
let len = span.1 - span.0;
// A DRAFT tapers from the sketch plane outward on each side it goes.
let k = ext.draft.map(|d| d.angle.tan() * if d.outward { -1.0 } else { 1.0 }).unwrap_or(0.0);
let sides = [(p.h - span.0, -1.0), (span.1 - p.h, 1.0)];
let csg_draft = (k != 0.0).then_some((k, p.h));
let mut draft_modelled = true;
let solid = |r: &Region, draft_modelled: &mut bool| -> (f64, f64) {
let mut vol = 0.0;
let mut first = 0.0;
for (l, sgn) in sides {
if l <= 1e-15 {
continue;
}
let (v, c) = r.prism(l, k).unwrap_or_else(|| {
*draft_modelled = false;
(r.area() * l, l / 2.0)
});
vol += v;
first += v * (p.h + sgn * c);
}
(vol, if vol > 0.0 { first / vol } else { (span.0 + span.1) / 2.0 })
};
let mut delta = 0.0;
let mut flo = [f64::MAX; 3];
let mut fhi = [f64::MIN; 3];
let mut body_of: Option<usize> = None;
let mut fmoment = [0.0; 3];
let report = |name: &str, change: f64, draft_modelled: bool| {
json!({"feature": name, "spanMm": [span.0 * 1000.0, span.1 * 1000.0], "volumeChangeMm3": change * 1.0e9,
"depthMm": eff.depth * 1000.0, "throughAll": ext.through_all, "reverse": ext.reverse,
"endsAtMm": ends_at.map(|t| t * 1000.0),
"draftDeg": ext.draft.map(|d| d.angle.to_degrees()),
"simulated": if draft_modelled { "closed-form" } else { "the draft is not modelled for this profile: volume without it" }})
};
if !cut {
let name = self.next_name(kind);
for (k_reg, r) in regs.iter().enumerate() {
let (lo, hi) = prism_box(&p.frame, &r.outer, span);
let (vol, hc) = solid(r, &mut draft_modelled);
let (cu, cv) = r.centroid();
let moment = modeling::scale(p.frame.point(cu, cv, hc), vol);
fmoment = modeling::add(fmoment, moment);
delta += vol;
for i in 0..3 {
flo[i] = flo[i].min(lo[i]);
fhi[i] = fhi[i].max(hi[i]);
}
let hits: Vec<usize> = (0..self.bodies.len()).filter(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi)).collect();
if hits.is_empty() {
self.bodies.push(Body {
name: if k_reg == 0 { name.clone() } else { format!("{name}[{}]", k_reg + 1) },
lo,
hi,
volume: vol,
faces: r.faces(),
shape: if k == 0.0 { r.shape(axis) } else { Shape::Other },
appearance: None,
moment,
});
body_of.get_or_insert(self.bodies.len() - 1);
} else {
let keep = hits[0];
for other in hits.iter().skip(1).rev() {
let o = self.bodies.remove(*other);
let b = &mut self.bodies[keep];
for i in 0..3 {
b.lo[i] = b.lo[i].min(o.lo[i]);
b.hi[i] = b.hi[i].max(o.hi[i]);
}
b.volume += o.volume;
b.faces += o.faces;
b.moment = modeling::add(b.moment, o.moment);
}
let b = &mut self.bodies[keep];
for i in 0..3 {
b.lo[i] = b.lo[i].min(lo[i]);
b.hi[i] = b.hi[i].max(hi[i]);
}
b.volume += vol;
b.moment = modeling::add(b.moment, moment);
b.faces += r.faces() - 1;
b.shape = Shape::Other;
body_of.get_or_insert(keep);
}
}
self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: delta, lo: Some(flo), hi: Some(fhi), body: body_of, moment: fmoment });
self.csg_push(true, &name, crate::comhost::mock_csg::Kind::Prism { frame: p.frame, lo: span.0, hi: span.1, entities: p.entities.clone(), draft: csg_draft });
return Ok(report(&name, delta, draft_modelled));
}
// A CUT removes only what lies inside a body (exact when the cut lies
// wholly inside the material; a partial overlap is scaled by the
// overlap of the boxes).
let mut removed = 0.0;
let mut cut_moment = [0.0; 3];
let mut touched: Vec<usize> = Vec::new();
for r in ®s {
let (lo, hi) = prism_box(&p.frame, &r.outer, span);
let (u0, v0, u1, v1) = r.outer.bounds;
let reg_bounds_area = ((u1 - u0) * (v1 - v0)).max(1e-30);
let (vol, hc) = solid(r, &mut draft_modelled);
let (cu, cv) = r.centroid();
for (bi, b) in self.bodies.iter_mut().enumerate() {
let along = overlap_len(lo[axis], hi[axis], b.lo[axis], b.hi[axis]);
let others: Vec<usize> = (0..3).filter(|i| *i != axis).collect();
let inplane = overlap_len(lo[others[0]], hi[others[0]], b.lo[others[0]], b.hi[others[0]])
* overlap_len(lo[others[1]], hi[others[1]], b.lo[others[1]], b.hi[others[1]]);
let take = vol * (inplane / reg_bounds_area).min(1.0) * if len > 0.0 { (along / len).min(1.0) } else { 0.0 };
if take > 1e-18 {
let whole = along >= len - 1e-12;
let h_mid = if whole { hc } else { (lo[axis].max(b.lo[axis]) + hi[axis].min(b.hi[axis])) / 2.0 };
let mut c = p.frame.point(cu, cv, 0.0);
c[axis] = h_mid;
b.volume = (b.volume - take).max(0.0);
b.moment = modeling::sub(b.moment, modeling::scale(c, take));
b.faces += r.faces() - 1;
b.shape = Shape::Other;
removed += take;
cut_moment = modeling::sub(cut_moment, modeling::scale(c, take));
touched.push(bi);
}
}
}
if removed <= 1e-18 {
return Err(
"SOLIDWORKS made no cut (the feature came back null) — nothing was removed. A cut goes along its plane's \
−NORMAL (measured: a cut from Top Plane removes −Y); if the material is on the other side, pass \
reverse:true, or direction:\"both\" / midPlane:true"
.into(),
);
}
let name = self.next_name(kind);
self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: -removed, lo: None, hi: None, body: touched.first().copied(), moment: cut_moment });
self.csg_push(false, &name, crate::comhost::mock_csg::Kind::Prism { frame: p.frame, lo: span.0, hi: span.1, entities: p.entities.clone(), draft: csg_draft });
Ok(report(&name, -removed, draft_modelled))
}
/// The edges a fillet / chamfer selects — de-duplicated, then selected with
/// SOLIDWORKS' toggle semantics (modelled). `dedupe:false` is the bug the
/// review found; it is a parameter so a test can show what it does.
pub fn select_edges(&self, sel: &EdgeSel, last: &Option<String>, dedupe: bool) -> Result<(Vec<EdgeRef>, Value), String> {
let (listed, how): (Vec<EdgeRef>, Value) = match sel {
EdgeSel::AtPoints(ps) => (ps.iter().map(|p| self.pick_edge(*p)).collect::<Result<_, _>>()?, json!({"atPoints": ps.len()})),
EdgeSel::AllOfFeature(f) => {
let name = if f == "last" { last.clone().ok_or("\"last\" names no feature")? } else { f.clone() };
let feat = self.feature(&name).ok_or_else(|| format!("no feature named '{name}'"))?;
let body = feat.body.ok_or_else(|| format!("feature '{name}' made no body"))?;
// Every face lists its edges, so each edge comes up TWICE.
let mut refs = Vec::new();
match self.bodies[body].shape {
Shape::Cylinder { axis, .. } => {
for _face in 0..2 {
for s in [-1, 1] {
let other = if axis == 0 { [1usize, 2] } else if axis == 1 { [0, 2] } else { [0, 1] };
let _ = other;
refs.push(EdgeRef { body, along: axis, sides: [s, s] });
}
}
}
_ => {
for axis in 0..3 {
for side in [-1, 1] {
// the 4 edges around face (axis, side)
for e in self.all_edges().into_iter().filter(|e| e.body == body && e.along != axis) {
let ax = Self::edge_axes(e);
if let Some(k) = ax.iter().position(|a| *a == axis) {
if e.sides[k] == side {
refs.push(e);
}
}
}
}
}
}
}
(refs, json!({"allOfFeature": name}))
}
};
let listed_n = listed.len();
let keep: Vec<EdgeRef> = if dedupe { modeling::dedupe(&listed).into_iter().map(|i| listed[i]).collect() } else { listed };
let selected = modeling::toggled_selection(&keep);
if selected.is_empty() {
return Err(format!(
"nothing ended up selected ({listed_n} edge references; an edge selected twice is DESELECTED) — SOLIDWORKS returned no feature"
));
}
let mut how = how;
how["listed"] = json!(listed_n);
how["selected"] = json!(selected.len());
Ok((selected, how))
}
/// Fillet or chamfer the selected edges. The volume is modelled for a box
/// (all 12 edges, or single straight edges) and a cylinder's two circular
/// edges; anything else is reported as not modelled.
pub fn edge_feature(&mut self, fillet: bool, size: f64, angle: f64, edges: &[EdgeRef]) -> Result<Value, String> {
let body = edges.first().map(|e| e.body).ok_or("no edges")?;
let b = self.bodies[body].clone();
let dims = modeling::sub(b.hi, b.lo);
let mut delta: Option<f64> = None;
match (&b.shape, fillet) {
(Shape::Box, true) if edges.len() == 12 => {
let r = size;
let (a, bb, c) = (dims[0] - 2.0 * r, dims[1] - 2.0 * r, dims[2] - 2.0 * r);
let rounded = a * bb * c + 2.0 * r * (a * bb + bb * c + a * c) + PI * r * r * (a + bb + c) + 4.0 / 3.0 * PI * r.powi(3);
delta = Some(rounded - b.volume);
}
(Shape::Box, _) => {
let per = if fillet { (1.0 - PI / 4.0) * size * size } else { 0.5 * size * size * angle.tan() };
delta = Some(-edges.iter().map(|e| per * self.edge_len(*e)).sum::<f64>());
}
(Shape::Cylinder { axis, r: rc }, true) if edges.len() == 2 => {
let h = dims[*axis];
let r = size;
let spandrel = (1.0 - PI / 4.0) * r * r;
let xbar = r * (5.0 / 6.0 - PI / 4.0) / (1.0 - PI / 4.0);
delta = Some(-2.0 * spandrel * 2.0 * PI * (rc - xbar) + (PI * rc * rc * h - b.volume));
}
_ => {}
}
let name = self.next_name(if fillet { "Fillet" } else { "Chamfer" });
if let Some(d) = delta {
let bm = &mut self.bodies[body];
bm.volume += d;
bm.faces += edges.len() as u32;
bm.shape = Shape::Other;
}
self.features.push(Feature { name: name.clone(), kind: if fillet { "Fillet".into() } else { "Chamfer".into() }, volume: delta.unwrap_or(0.0), lo: None, hi: None, body: Some(body), moment: [0.0; 3] });
self.csg_decline(if fillet { "a fillet" } else { "a chamfer" });
Ok(json!({"feature": name, "edgesSelected": edges.len(), "volumeChangeMm3": delta.map(|d| d * 1.0e9),
"simulated": if delta.is_some() { "closed-form" } else { "not modelled for this shape: volume unchanged" }}))
}
pub fn shell(&mut self, thickness: f64, points: &[V3], outward: bool) -> Result<Value, String> {
let picked: Vec<FaceRef> = points.iter().map(|p| self.pick_face(*p)).collect::<Result<_, _>>()?;
let faces: Vec<FaceRef> = modeling::dedupe(&picked).into_iter().map(|i| picked[i]).collect();
let body = faces.first().map(|f| f.body).ok_or("no faces to remove")?;
let b = self.bodies[body].clone();
let mut delta = None;
if b.shape == Shape::Box && !outward {
let mut inner = 1.0;
for axis in 0..3 {
let removed = faces.iter().filter(|f| f.axis == axis).count() as f64;
inner *= (b.hi[axis] - b.lo[axis]) - thickness * (2.0 - removed);
}
delta = Some(-inner.max(0.0));
}
let name = self.next_name("Shell");
if let Some(d) = delta {
let bm = &mut self.bodies[body];
bm.volume += d;
bm.shape = Shape::Other;
}
self.csg_decline("a shell");
self.features.push(Feature { name: name.clone(), kind: "Shell".into(), volume: delta.unwrap_or(0.0), lo: None, hi: None, body: Some(body), moment: [0.0; 3] });
Ok(json!({"feature": name, "thicknessMm": thickness * 1000.0, "facesRemoved": faces.len(), "outward": outward,
"simulated": if delta.is_some() { "closed-form" } else { "not modelled for this shape" }}))
}
fn add_copies(&mut self, seed: &Feature, boxes: Vec<(V3, V3)>) {
let body = seed.body.unwrap_or(0);
// Each copy's centroid: the seed's, moved as its box moved.
let mid = |lo: V3, hi: V3| modeling::scale(modeling::add(lo, hi), 0.5);
let seed_mid = match (seed.lo, seed.hi) {
(Some(l), Some(h)) => mid(l, h),
_ => [0.0; 3],
};
let seed_c = if seed.volume.abs() > 0.0 && seed.moment != [0.0; 3] { modeling::scale(seed.moment, 1.0 / seed.volume) } else { seed_mid };
for (lo, hi) in boxes {
let c = modeling::add(seed_c, modeling::sub(mid(lo, hi), seed_mid));
let target = (0..self.bodies.len()).find(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi)).unwrap_or(body);
if let Some(b) = self.bodies.get_mut(target) {
for i in 0..3 {
b.lo[i] = b.lo[i].min(lo[i]);
b.hi[i] = b.hi[i].max(hi[i]);
}
b.volume += seed.volume;
b.moment = modeling::add(b.moment, modeling::scale(c, seed.volume));
b.shape = Shape::Other;
}
}
}
pub fn linear_pattern(&mut self, seed_name: &str, dirs: &[(Axis, u32, f64)]) -> Result<Value, String> {
let seed = self.feature(seed_name).cloned().ok_or_else(|| format!("no feature named '{seed_name}' to pattern"))?;
let (lo, hi) = match (seed.lo, seed.hi) {
(Some(l), Some(h)) => (l, h),
_ => return Err(format!("'{seed_name}' added no material to pattern")),
};
let (a1, n1, s1) = dirs[0];
let (a2, n2, s2) = dirs.get(1).copied().unwrap_or((a1, 1, 0.0));
let mut boxes = Vec::new();
for i in 0..n1 {
for j in 0..n2 {
if i == 0 && j == 0 {
continue;
}
let mut d = [0.0; 3];
d[axis_index(a1)] += i as f64 * s1;
d[axis_index(a2)] += j as f64 * s2;
boxes.push((modeling::add(lo, d), modeling::add(hi, d)));
}
}
let n = boxes.len() as u32 + 1;
self.add_copies(&seed, boxes);
let name = self.next_name("LPattern");
// The seed's primitives, translated to every instance.
let seeds: Vec<crate::comhost::mock_csg::Prim> = self.csg.iter().filter(|p| p.feature == seed_name).cloned().collect();
for i in 0..n1 {
for j in 0..n2 {
if i == 0 && j == 0 {
continue;
}
for sp in &seeds {
let k1 = crate::comhost::mock_csg::translate(&sp.kind, axis_index(a1), i as f64 * s1);
let k2 = crate::comhost::mock_csg::translate(&k1, axis_index(a2), j as f64 * s2);
self.csg.push(crate::comhost::mock_csg::Prim { add: sp.add, feature: seed_name.to_string(), kind: k2 });
}
}
}
self.features.push(Feature { name: name.clone(), kind: "LPattern".into(), volume: seed.volume * (n as f64 - 1.0), lo: None, hi: None, body: seed.body, moment: [0.0; 3] });
Ok(json!({"feature": name, "seed": seed_name, "instances": n}))
}
pub fn circular_pattern(&mut self, seed_name: &str, axis: Axis, count: u32, angle: f64) -> Result<Value, String> {
let seed = self.feature(seed_name).cloned().ok_or_else(|| format!("no feature named '{seed_name}' to pattern"))?;
let (lo, hi) = match (seed.lo, seed.hi) {
(Some(l), Some(h)) => (l, h),
_ => return Err(format!("'{seed_name}' added no material to pattern")),
};
let ax = axis_index(axis);
let step = if angle >= 2.0 * PI - 1e-9 { angle / count as f64 } else { angle / (count.saturating_sub(1).max(1)) as f64 };
let rot = |p: V3, t: f64| -> V3 {
let (s, c) = t.sin_cos();
match ax {
0 => [p[0], c * p[1] - s * p[2], s * p[1] + c * p[2]],
1 => [c * p[0] + s * p[2], p[1], -s * p[0] + c * p[2]],
_ => [c * p[0] - s * p[1], s * p[0] + c * p[1], p[2]],
}
};
let mut boxes = Vec::new();
for k in 1..count {
let t = step * k as f64;
let mut blo = [f64::MAX; 3];
let mut bhi = [f64::MIN; 3];
for x in [lo[0], hi[0]] {
for y in [lo[1], hi[1]] {
for z in [lo[2], hi[2]] {
let q = rot([x, y, z], t);
for i in 0..3 {
blo[i] = blo[i].min(q[i]);
bhi[i] = bhi[i].max(q[i]);
}
}
}
}
boxes.push((blo, bhi));
}
self.add_copies(&seed, boxes);
let name = self.next_name("CirPattern");
self.csg_decline("a circular pattern");
self.features.push(Feature { name: name.clone(), kind: "CirPattern".into(), volume: seed.volume * (count as f64 - 1.0), lo: None, hi: None, body: seed.body, moment: [0.0; 3] });
Ok(json!({"feature": name, "seed": seed_name, "instances": count, "angleDeg": angle.to_degrees()}))
}
/// Appearance. A body target on a SINGLE-body part is written at the part
/// level (IBody2::MaterialPropertyValues2 is documented for non-base bodies).
pub fn set_appearance(&mut self, target: &AppearanceTarget, rgb: &[f64; 3]) -> Result<Value, String> {
let c = rgb255(rgb);
let route = match target {
AppearanceTarget::Part => {
self.appearance = Some(c);
"part"
}
AppearanceTarget::Body(name) => {
if self.bodies.len() <= 1 {
self.appearance = Some(c);
"part (single-body part)"
} else {
let b = self.bodies.iter_mut().find(|b| &b.name == name).ok_or_else(|| format!("no solid body named '{name}'"))?;
b.appearance = Some(c);
"body"
}
}
AppearanceTarget::FaceAt(p) => {
let f = self.pick_face(*p)?;
let _ = f;
self.face_appearances.retain(|(q, _)| q != p);
self.face_appearances.push((*p, c));
"face"
}
};
Ok(json!({"rgb": c, "route": route, "readBack": c}))
}
fn csg_push(&mut self, add: bool, feature: &str, kind: crate::comhost::mock_csg::Kind) {
if let Err(why) = crate::comhost::mock_csg::supported(&kind) {
self.csg_unsupported.get_or_insert(why);
}
self.csg.push(crate::comhost::mock_csg::Prim { add, feature: feature.to_string(), kind });
}
/// A body mirror whose history has cuts: appended in order, the copy's cuts
/// would cut the original too — the column model declines rather than lie.
fn csg_decline_if_cuts_mirrored_as_body(&mut self) {
if self.csg.iter().any(|p| !p.add) {
self.csg_decline("a body mirror of a body with cuts");
}
}
fn csg_decline(&mut self, why: &str) {
self.csg_unsupported.get_or_insert_with(|| why.to_string());
}
/// The part's volume (m³) and centre of mass by COLUMN INTEGRATION of its
/// primitives, or why that cannot be done for this part.
pub fn csg_mass(&self) -> Result<(f64, V3), String> {
if let Some(why) = &self.csg_unsupported {
return Err(why.clone());
}
let mut lo = [f64::MAX; 3];
let mut hi = [f64::MIN; 3];
for b in &self.bodies {
for i in 0..3 {
lo[i] = lo[i].min(b.lo[i]);
hi[i] = hi[i].max(b.hi[i]);
}
}
if self.bodies.is_empty() {
return Err("no bodies".into());
}
crate::comhost::mock_csg::integrate(&self.csg, (lo, hi), 700)
}
/// Material DEPOSITED by a boss-like feature: merged into every body its
/// box touches, or a new body. Returns the body's index.
#[allow(clippy::too_many_arguments)]
fn deposit(&mut self, name: &str, lo: V3, hi: V3, vol: f64, moment: V3, faces: u32, shape: Shape) -> usize {
let hits: Vec<usize> = (0..self.bodies.len()).filter(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi)).collect();
if hits.is_empty() {
self.bodies.push(Body { name: name.to_string(), lo, hi, volume: vol, faces, shape, appearance: None, moment });
return self.bodies.len() - 1;
}
let keep = hits[0];
for other in hits.iter().skip(1).rev() {
let o = self.bodies.remove(*other);
let b = &mut self.bodies[keep];
for i in 0..3 {
b.lo[i] = b.lo[i].min(o.lo[i]);
b.hi[i] = b.hi[i].max(o.hi[i]);
}
b.volume += o.volume;
b.faces += o.faces;
b.moment = modeling::add(b.moment, o.moment);
}
let b = &mut self.bodies[keep];
for i in 0..3 {
b.lo[i] = b.lo[i].min(lo[i]);
b.hi[i] = b.hi[i].max(hi[i]);
}
b.volume += vol;
b.moment = modeling::add(b.moment, moment);
b.faces += faces;
b.shape = Shape::Other;
keep
}
/// A REVOLVE (0.11.0) of the pending sketch about its axis line, by
/// PAPPUS: volume = Σ (region area × its centroid's distance from the
/// axis) × angle — EXACT for any profile on one side of the axis (the
/// parser refused any other). The centre of mass is exact for a full
/// revolution of a profile symmetric along the axis; the bounding box is
/// the full revolution's.
pub fn revolve(&mut self, p: &Pending, rv: &crate::script::Revolution, cut: bool) -> Result<Value, String> {
let ((ax, ay), (bx, by)) = p.entities.get(rv.axis).and_then(|e| e.ends()).ok_or("the revolve axis is not a line of the sketch")?;
let l = (bx - ax).hypot(by - ay);
let (dx, dy) = ((bx - ax) / l, (by - ay) / l);
let (nx, ny) = (-dy, dx);
let regs = regions(&p.entities);
let a3 = p.frame.point(ax, ay, p.h);
let d3 = modeling::sub(p.frame.point(bx, by, p.h), a3);
let d3 = modeling::scale(d3, 1.0 / modeling::norm(d3));
let mut vol = 0.0;
let mut moment = [0.0; 3];
let (mut t0, mut t1, mut rmax) = (f64::MAX, f64::MIN, 0.0f64);
for r in ®s {
let (cu, cv) = r.centroid();
let d = ((cu - ax) * nx + (cv - ay) * ny).abs();
let v = r.area() * d * rv.angle;
vol += v;
let t = (cu - ax) * dx + (cv - ay) * dy;
moment = modeling::add(moment, modeling::scale(modeling::add(a3, modeling::scale(d3, t)), v));
let (u0, v0, u1, v1) = r.outer.bounds;
for (u, w) in [(u0, v0), (u0, v1), (u1, v0), (u1, v1)] {
let t = (u - ax) * dx + (w - ay) * dy;
t0 = t0.min(t);
t1 = t1.max(t);
rmax = rmax.max(((u - ax) * nx + (w - ay) * ny).abs());
}
}
if vol <= 1e-18 {
return Err("SOLIDWORKS made no revolve (the feature came back null) — the profile has no area away from the axis".into());
}
let mut lo = [0.0; 3];
let mut hi = [0.0; 3];
for i in 0..3 {
let (e0, e1) = (a3[i] + d3[i] * t0, a3[i] + d3[i] * t1);
let rad = rmax * (1.0 - d3[i] * d3[i]).max(0.0).sqrt();
lo[i] = e0.min(e1) - rad;
hi[i] = e0.max(e1) + rad;
}
let full = rv.angle >= 2.0 * PI - 1e-9;
let faces: u32 = regs.iter().map(|r| r.faces() - 2 + if full { 0 } else { 2 }).sum();
let kind = if cut { "Cut-Revolve" } else { "Revolve" };
let name = self.next_name(kind);
let note = if full { "Pappus (exact volume)" } else { "Pappus (exact volume; the box is the full revolution's)" };
if !cut {
let body = self.deposit(&name, lo, hi, vol, moment, faces, Shape::Other);
if full {
self.csg_push(true, &name, crate::comhost::mock_csg::Kind::Revolve { frame: p.frame, h: p.h, entities: p.entities.clone(), axis: rv.axis });
} else {
self.csg_decline("a revolve through less than 360°");
}
self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: vol, lo: Some(lo), hi: Some(hi), body: Some(body), moment });
return Ok(json!({"feature": name, "volumeChangeMm3": vol * 1.0e9, "angleDeg": rv.angle.to_degrees(), "simulated": note}));
}
let target = (0..self.bodies.len()).find(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi))
.ok_or("SOLIDWORKS made no cut (the feature came back null) — the revolved profile touches no material")?;
if full {
self.csg_push(false, &name, crate::comhost::mock_csg::Kind::Revolve { frame: p.frame, h: p.h, entities: p.entities.clone(), axis: rv.axis });
} else {
self.csg_decline("a revolve-cut through less than 360°");
}
let b = &mut self.bodies[target];
b.volume = (b.volume - vol).max(0.0);
b.moment = modeling::sub(b.moment, moment);
b.faces += faces;
b.shape = Shape::Other;
self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: -vol, lo: None, hi: None, body: Some(target), moment: modeling::scale(moment, -1.0) });
Ok(json!({"feature": name, "volumeChangeMm3": -vol * 1.0e9, "angleDeg": rv.angle.to_degrees(),
"simulated": format!("{note}; exact when the cut lies wholly inside the material")}))
}
/// The mirror plane, as (model axis, coordinate) — default planes, offsets
/// of them, and planar faces parallel to them.
fn mirror_plane(&mut self, plane: &PlaneRef) -> Result<(usize, f64, Value), String> {
let defaults = PlaneFrame::defaults();
match plane {
PlaneRef::Named(n) => {
let i = modeling::default_index(n).ok_or_else(|| format!("no plane named '{n}' in this document"))?;
Ok((normal_axis(&defaults[i]), 0.0, json!(n)))
}
PlaneRef::Offset { from, offset } => {
let i = modeling::default_index(from).ok_or_else(|| format!("no plane named '{from}' to offset from"))?;
let made = self.next_name("Plane");
Ok((normal_axis(&defaults[i]), *offset, json!({"offsetFrom": from, "offsetMm": offset * 1000.0, "createdPlane": made})))
}
PlaneRef::Face { point } => {
let f = self.pick_face(*point)?;
Ok((f.axis, self.face_coord(f), json!({"face": {"atPointMm": [point[0] * 1000.0, point[1] * 1000.0, point[2] * 1000.0]}, "pickedBy": "geometry"})))
}
PlaneRef::FaceOffset { point, offset } => {
let f = self.pick_face(*point)?;
let made = self.next_name("Plane");
Ok((f.axis, self.face_coord(f) + offset * f.side as f64, json!({"fromFace": true, "offsetMm": offset * 1000.0, "createdPlane": made})))
}
}
}
/// A MIRROR (0.11.0): the features' material (or every body) reflected
/// across a plane and merged. Exact when the copy does not overlap the
/// original; an overlap is estimated from the boxes (and said so).
pub fn mirror(&mut self, features: &[String], bodies: bool, plane: &PlaneRef, last: &Option<String>) -> Result<Value, String> {
let (ax, c, how) = self.mirror_plane(plane)?;
let refl = |p: V3| {
let mut q = p;
q[ax] = 2.0 * c - p[ax];
q
};
let refl_moment = |m: V3, v: f64| {
let mut q = m;
q[ax] = 2.0 * c * v - m[ax];
q
};
let box_vol = |lo: V3, hi: V3| (0..3).map(|i| (hi[i] - lo[i]).max(0.0)).product::<f64>();
let overlap_vol = |alo: V3, ahi: V3, blo: V3, bhi: V3| (0..3).map(|i| overlap_len(alo[i], ahi[i], blo[i], bhi[i])).product::<f64>();
let mut added = 0.0;
let mut exact = true;
let name = self.next_name(if bodies { "Mirror-Body" } else { "Mirror" });
if bodies {
let originals = self.bodies.clone();
for (bi, b) in originals.iter().enumerate() {
let (p0, p1) = (refl(b.lo), refl(b.hi));
let (lo, hi) = ([p0[0].min(p1[0]), p0[1].min(p1[1]), p0[2].min(p1[2])], [p0[0].max(p1[0]), p0[1].max(p1[1]), p0[2].max(p1[2])]);
let ov = overlap_vol(lo, hi, b.lo, b.hi);
let share = if ov > 1e-18 { exact = false; b.volume * ov / box_vol(b.lo, b.hi).max(1e-30) } else { 0.0 };
let v = b.volume - share;
added += v;
let m = modeling::scale(refl_moment(b.moment, b.volume), v / b.volume.max(1e-30));
let t = &mut self.bodies[bi];
for i in 0..3 {
t.lo[i] = t.lo[i].min(lo[i]);
t.hi[i] = t.hi[i].max(hi[i]);
}
t.volume += v;
t.moment = modeling::add(t.moment, m);
t.faces = t.faces * 2;
t.shape = Shape::Other;
}
} else {
for f in features {
let fname = if f == "last" { last.clone().ok_or("\"last\" names no feature")? } else { f.clone() };
let seed = self.feature(&fname).cloned().ok_or_else(|| format!("no feature named '{fname}' to mirror"))?;
let body = seed.body.ok_or_else(|| format!("feature '{fname}' changed no body"))?;
match (seed.lo, seed.hi) {
(Some(slo), Some(shi)) if seed.volume > 0.0 => {
let (p0, p1) = (refl(slo), refl(shi));
let lo = [p0[0].min(p1[0]), p0[1].min(p1[1]), p0[2].min(p1[2])];
let hi = [p0[0].max(p1[0]), p0[1].max(p1[1]), p0[2].max(p1[2])];
let ov = overlap_vol(lo, hi, slo, shi);
let share = if ov > 1e-18 { exact = false; seed.volume * ov / box_vol(slo, shi).max(1e-30) } else { 0.0 };
let v = seed.volume - share;
let m = modeling::scale(refl_moment(seed.moment, seed.volume), v / seed.volume);
self.deposit(&name, lo, hi, v, m, 0, Shape::Other);
added += v;
}
_ => {
// A CUT mirrored: the same volume removed from the body
// (assumed wholly inside the material, not overlapping).
let v = seed.volume;
let b = self.bodies.get_mut(body).ok_or("the mirrored feature's body is gone")?;
b.volume = (b.volume + v).max(0.0);
b.moment = modeling::add(b.moment, refl_moment(seed.moment, -v));
b.shape = Shape::Other;
added += v;
}
}
}
}
// The primitives, reflected: every one (bodies) or the features' own.
let copies: Vec<crate::comhost::mock_csg::Prim> = self
.csg
.iter()
.filter(|p| bodies || features.iter().any(|f| {
let f = if f == "last" { last.clone().unwrap_or_default() } else { f.clone() };
p.feature == f
}))
.map(|p| crate::comhost::mock_csg::Prim { add: p.add, feature: name.clone(), kind: crate::comhost::mock_csg::reflect(&p.kind, ax, c) })
.collect();
if bodies {
// A mirrored BODY is the body's whole history reflected and UNIONED:
// its cuts must not remove from the original.
self.csg_decline_if_cuts_mirrored_as_body();
self.csg.extend(copies);
} else {
self.csg.extend(copies);
}
let body = self.bodies.iter().position(|_| true);
self.features.push(Feature { name: name.clone(), kind: "Mirror".into(), volume: added, lo: None, hi: None, body, moment: [0.0; 3] });
Ok(json!({"feature": name, "plane": how, "volumeChangeMm3": added * 1.0e9, "mirrorBody": bodies,
"simulated": if exact { "exact (the copy does not overlap the original)" } else { "the copy overlaps the original: overlap estimated from boxes" }}))
}
/// The CENTRE OF MASS (uniform density): Σ body moments / Σ volumes.
pub fn center_of_mass(&self) -> Option<V3> {
let v = self.volume();
if v <= 0.0 {
return None;
}
let m = self.bodies.iter().fold([0.0; 3], |a, b| modeling::add(a, b.moment));
Some(modeling::scale(m, 1.0 / v))
}
/// A material: and, as SOLIDWORKS does, the part's (and bodies') own
/// appearance is GONE afterwards. The caller re-applies it.
pub fn set_material(&mut self, name: &str) {
self.density = library_density(name);
self.material = Some(name.to_string());
self.appearance = None;
for b in &mut self.bodies {
b.appearance = None;
}
}
/// The bodies, in swx_inspect_bodies' shape.
/// clear_part (0.10.6): every feature and body goes; the part-level colour
/// and material stay (they are not features), and so do the name counters
/// (whether SOLIDWORKS restarts its feature numbering is UNMEASURED, so the
/// simulator assumes it does not).
pub fn clear(&mut self) {
self.bodies.clear();
self.features.clear();
self.face_appearances.clear();
self.csg.clear();
self.csg_unsupported = None;
}
/// The top-level tree after the origin, as clear_part walks it.
pub fn tree_after_origin(&self) -> Vec<String> {
let mut t: Vec<String> = self.features.iter().map(|f| f.name.clone()).collect();
t.extend(self.loose_sketches.iter().cloned());
t
}
pub fn bodies_json(&self) -> Vec<Value> {
self.bodies
.iter()
.map(|b| {
let size = modeling::sub(b.hi, b.lo);
json!({
"name": b.name,
"bbox": {"minMm": [mm(b.lo[0]), mm(b.lo[1]), mm(b.lo[2])], "maxMm": [mm(b.hi[0]), mm(b.hi[1]), mm(b.hi[2])]},
"sizeMm": {"x": mm(size[0]), "y": mm(size[1]), "z": mm(size[2])},
"volumeMm3": (b.volume * 1.0e9 * 1000.0).round() / 1000.0,
"areaMm2": (2.0 * (size[0] * size[1] + size[1] * size[2] + size[0] * size[2]) * 1.0e6 * 1000.0).round() / 1000.0,
"massKg": Value::Null,
"centerOfMassMm": if b.volume > 0.0 && b.moment != [0.0; 3] {
let c = modeling::scale(b.moment, 1.0 / b.volume);
[mm(c[0]), mm(c[1]), mm(c[2])]
} else {
[mm((b.lo[0] + b.hi[0]) / 2.0), mm((b.lo[1] + b.hi[1]) / 2.0), mm((b.lo[2] + b.hi[2]) / 2.0)]
},
"faceCount": b.faces,
"visible": true,
"isSolid": true,
"appearanceRgb": b.appearance.or(self.appearance),
"simulated": "mock arithmetic model (mock_model.rs), not a kernel",
})
})
.collect()
}
pub fn volume(&self) -> f64 {
self.bodies.iter().map(|b| b.volume).sum()
}
}
/// The simulated material library (kg/m³) — SOLIDWORKS' own values for the
/// materials the tests and the CSWA specs name.
pub fn library_density(name: &str) -> Option<f64> {
let m = name.to_ascii_lowercase();
let table: &[(&str, f64)] = &[
("abs pc", 1070.0),
("abs", 1020.0),
("6061", 2700.0),
("1060", 2700.0),
("aisi 1020", 7900.0),
("aisi 304", 8000.0),
("alloy steel", 7700.0),
("cast alloy steel", 7300.0),
("plain carbon steel", 7800.0),
("cast carbon steel", 7800.0),
("gray cast iron", 7200.0),
("copper", 8900.0),
("brass", 8500.0),
("nylon 6/10", 1400.0),
("pvc", 1300.0),
("steel", 7800.0),
];
// The longest name that matches wins ("cast alloy steel" over "alloy steel").
table.iter().filter(|(k, _)| m.contains(k)).max_by_key(|(k, _)| k.len()).map(|(_, d)| *d)
}
/// Run one geometry op of a script on the model. `active` is whether the
/// target is SOLIDWORKS' active document (selection works only there);
/// `dedupe` and `reselect` exist so tests can show what the bridge's own
/// safeguards prevent.
pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result<Value, String> {
let needs_selection = matches!(
op,
Op::Sketch { plane: PlaneRef::Face { .. } | PlaneRef::FaceOffset { .. }, .. }
| Op::Fillet { .. }
| Op::Chamfer { .. }
| Op::Shell { .. }
| Op::LinearPattern { .. }
| Op::CircularPattern { .. }
| Op::Mirror { .. }
| Op::Hole { .. }
| Op::SetAppearance { target: AppearanceTarget::FaceAt(_), .. }
);
if needs_selection && !active {
return Err("selection works only in the ACTIVE document, and this one is not active — nothing could be selected".into());
}
let out = match op {
Op::Sketch { plane, entities, offsets, .. } => {
let via = if let PlaneRef::Face { point } = plane {
model.pick_face(*point)?;
Some(select_n(run, 1, "faces", "nothing to sketch on").map_err(|e| format!("the picked face could not be selected ({e})"))?)
} else {
None
};
let (p, mut report) = model.sketch(plane, entities, offsets)?;
if let Some(v) = via {
report["resolvedPlane"]["selectedVia"] = v["selectedVia"].clone();
report["resolvedPlane"]["viewDependent"] = v["viewDependent"].clone();
}
run.pending = Some(p);
run.sketch_selected = true;
report
}
Op::Extrude(ext) | Op::Cut(ext) => {
let p = run.pending.take().ok_or("no sketch to consume")?;
// A selection-clearing op since the sketch would have left NOTHING
// selected; the bridge re-selects the sketch by name first.
if !run.sketch_selected {
return Err(format!("the sketch '{}' was no longer selected — SOLIDWORKS returned no feature", p.name));
}
run.sketch_selected = false;
// SELECTED REGIONS (0.11.4): only those profiles are extruded.
let p = if ext.regions.is_empty() {
p
} else {
let kept = Model::region_entities(&p.entities, &ext.regions).inspect_err(|_| model.loose_sketches.push(p.name.clone()))?;
Pending { entities: kept, ..p }
};
let made = model.extrude(&p, ext, matches!(op, Op::Cut(_)));
if made.is_err() {
// A feature that came back null leaves its sketch in the tree.
model.loose_sketches.push(p.name.clone());
}
let mut r = made?;
r["sketch"] = json!(p.name);
r
}
Op::Revolve(rv) | Op::RevolveCut(rv) => {
let p = run.pending.take().ok_or("no sketch to consume")?;
if !run.sketch_selected {
return Err(format!("the sketch '{}' was no longer selected — SOLIDWORKS returned no feature", p.name));
}
run.sketch_selected = false;
let made = model.revolve(&p, rv, matches!(op, Op::RevolveCut(_)));
if made.is_err() {
model.loose_sketches.push(p.name.clone());
}
let mut r = made?;
r["sketch"] = json!(p.name);
r["route"] = json!("FeatureRevolve2");
r
}
Op::Mirror { features, bodies, plane } => {
run.sketch_selected = false;
let mut r = model.mirror(features, *bodies, plane, &run.last_feature)?;
r["route"] = json!("InsertMirrorFeature2");
r
}
Op::Hole { at, diameter, depth, counterbore, countersink } => hole(model, run, *at, *diameter, *depth, *counterbore, *countersink, active)?,
Op::Fillet { radius, edges } => {
run.sketch_selected = false;
let (sel, mut how) = model.select_edges(edges, &run.last_feature, true)?;
let via = select_n(run, sel.len(), "edges", "nothing was made")?;
how["selectedVia"] = via["selectedVia"].clone();
how["viewDependent"] = via["viewDependent"].clone();
let mut r = model.edge_feature(true, *radius, 0.0, &sel)?;
r["edges"] = how;
r["route"] = json!("FeatureFillet3");
r
}
Op::Chamfer { distance, angle, edges } => {
run.sketch_selected = false;
let (sel, mut how) = model.select_edges(edges, &run.last_feature, true)?;
let via = select_n(run, sel.len(), "edges", "nothing was made")?;
how["selectedVia"] = via["selectedVia"].clone();
how["viewDependent"] = via["viewDependent"].clone();
let mut r = model.edge_feature(false, *distance, *angle, &sel)?;
r["edges"] = how;
r["route"] = json!("InsertFeatureChamfer");
r
}
Op::Shell { thickness, faces, outward } => {
run.sketch_selected = false;
let picked: Vec<FaceRef> = faces.iter().map(|p| model.pick_face(*p)).collect::<Result<_, _>>()?;
let via = select_n(run, modeling::dedupe(&picked).len(), "faces", "no shell was made")?;
let mut r = model.shell(*thickness, faces, *outward)?;
r["route"] = json!("InsertFeatureShell");
r["selectedVia"] = via["selectedVia"].clone();
r["viewDependent"] = via["viewDependent"].clone();
r
}
Op::LinearPattern { feature, dirs } => {
run.sketch_selected = false;
let seed = if feature == "last" { run.last_feature.clone().ok_or("\"last\" names no feature")? } else { feature.clone() };
model.linear_pattern(&seed, dirs)?
}
Op::CircularPattern { feature, axis, count, angle } => {
run.sketch_selected = false;
let seed = if feature == "last" { run.last_feature.clone().ok_or("\"last\" names no feature")? } else { feature.clone() };
model.circular_pattern(&seed, *axis, *count, *angle)?
}
Op::SetAppearance { rgb, target } => {
let mut via = None;
if let AppearanceTarget::FaceAt(p) = target {
run.sketch_selected = false;
model.pick_face(*p)?;
via = Some(select_n(run, 1, "faces", "the face could not be selected to write its colour")?);
}
let mut r = model.set_appearance(target, rgb)?;
if let Some(v) = via {
r["selectedVia"] = v["selectedVia"].clone();
r["viewDependent"] = v["viewDependent"].clone();
}
run.appearances.retain(|(t, _)| t != target);
run.appearances.push((target.clone(), *rgb));
r
}
Op::SetMaterial { name, density, .. } => {
// THE SAME RULES as the COM host: a library material's density is
// the material's; a density alone is the document's own, and only
// where no library material is applied.
if !name.is_empty() {
let lib = library_density(name);
if let (Some(want), Some(have)) = (density, lib) {
if (want - have).abs() > 0.005 * have {
return Err(format!(
"the material '{name}' has a density of {have} kg/m³, not the {want} asked for — apply the material whose density \
matches, or give densityKgM3 alone (no name) to set the part's own density"
));
}
}
model.set_material(name);
} else if let Some(m) = model.material.clone().filter(|m| !m.is_empty()) {
return Err(format!("the library material '{m}' is applied, and its density wins over the document's — densityKgM3 alone cannot change it"));
}
if let (true, Some(d)) = (name.is_empty(), density) {
model.density = Some(*d);
}
// RE-APPLY every appearance this script set: a material wipes it.
let mut again = Vec::new();
for (t, rgb) in run.appearances.clone() {
model.set_appearance(&t, &rgb)?;
again.push(json!({"target": format!("{t:?}"), "rgb": rgb255(&rgb)}));
}
json!({"material": if name.is_empty() { Value::Null } else { json!(name) }, "appearanceReapplied": again,
"densityKgM3": model.density, "densityRequested": density})
}
_ => return Ok(Value::Null),
};
if op.makes_feature() {
if let Some(f) = out.get("feature").and_then(|f| f.as_str()) {
run.last_feature = Some(f.to_string());
}
}
Ok(out)
}
/// A HOLE (0.11.0) as the COM host builds it: the face at `at` is picked
/// geometrically, its plane resolved to a default plane at a height, and
/// circles are sketched on that plane (an offset plane, which survives the
/// cuts) and cut INTO the face — a counterbore or a countersink (a cut drafted
/// at half its included angle) first, then the hole from where it ends.
#[allow(clippy::too_many_arguments)]
pub fn hole(model: &mut Model, run: &mut Run, at: V3, diameter: f64, depth: Option<f64>, counterbore: Option<(f64, f64)>,
countersink: Option<(f64, f64)>, active: bool) -> Result<Value, String> {
let defaults = PlaneFrame::defaults();
let f = model.pick_face(at)?;
let mut n = [0.0; 3];
n[f.axis] = f.side as f64;
let i = modeling::parallel_default(&defaults, n).ok_or("the face is not parallel to a default plane")?;
let frame = defaults[i];
let h = model.face_coord(f);
let names = ["Front Plane", "Top Plane", "Right Plane"];
let plan = crate::script::hole_plan(&frame, names[i], h, modeling::dot(n, frame.n) > 0.0, at, diameter, depth, counterbore, countersink);
let mut steps = Vec::new();
let mut features = Vec::new();
for op in &plan {
let r = apply(model, run, op, active)?;
if let Some(fe) = r.get("feature").and_then(|f| f.as_str()) {
features.push(fe.to_string());
}
steps.push(json!({"op": op.name(), "result": r}));
}
Ok(json!({"feature": features.last(), "features": features, "face": {"atPointMm": [at[0] * 1000.0, at[1] * 1000.0, at[2] * 1000.0], "pickedBy": "geometry"},
"diameterMm": diameter * 1000.0, "depthMm": depth.map(|d| d * 1000.0), "throughAll": depth.is_none(),
"counterbore": counterbore.map(|(d, t)| json!({"diameterMm": d * 1000.0, "depthMm": t * 1000.0})),
"countersink": countersink.map(|(d, a)| json!({"diameterMm": d * 1000.0, "angleDeg": a.to_degrees(),
"depthMm": crate::script::countersink_depth(diameter, d, a) * 1000.0})),
"steps": steps}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::{Direction, Extent};
fn ext(depth: f64) -> Extent {
Extent { depth, depth2: depth, through_all: false, reverse: false, direction: Direction::One , up_to: None, draft: None, regions: Vec::new() }
}
fn block() -> (Model, Run) {
let mut m = Model::default();
let mut run = Run::default();
apply(&mut m, &mut run, &Op::Sketch { shorthand: "sketch_rect", plane: PlaneRef::Named("Top Plane".into()),
entities: vec![Entity::Rect { x0: -0.01, y0: -0.01, x1: 0.01, y1: 0.01 }], offsets: vec![] }, true).unwrap();
apply(&mut m, &mut run, &Op::Extrude(ext(0.01)), true).unwrap();
(m, run)
}
#[test]
fn a_hidden_face_is_picked_by_geometry_and_the_view_would_pick_another() {
let (m, _) = block();
let bottom = [0.0, 0.0, 0.0];
let g = m.pick_face(bottom).unwrap();
assert_eq!((g.axis, g.side), (1, -1), "geometry: the BOTTOM face");
let v = m.view_pick(bottom).unwrap();
assert_ne!((v.axis, v.side), (1, -1), "the view would pick a visible face instead");
assert!(m.pick_face([0.0, 0.01, 0.01]).unwrap_err().contains("2 faces"), "an edge point is ambiguous, never guessed");
}
#[test]
fn concentric_circles_are_a_ring_and_cuts_remove_only_material() {
assert!((profile_area(&[Entity::Circle { cx: 0.0, cy: 0.0, r: 3.255 }, Entity::Circle { cx: 0.0, cy: 0.0, r: 2.4 }])
- PI * (3.255f64.powi(2) - 2.4f64.powi(2)))
.abs()
< 1e-9);
let (mut m, mut run) = block();
// Michael's case: a default cut from Top Plane goes −Y, where there is no material.
apply(&mut m, &mut run, &Op::Sketch { shorthand: "sketch_rect", plane: PlaneRef::Named("Top Plane".into()),
entities: vec![Entity::Rect { x0: -0.005, y0: -0.005, x1: 0.005, y1: 0.005 }], offsets: vec![] }, true).unwrap();
assert!(apply(&mut m, &mut run, &Op::Cut(ext(0.005)), true).unwrap_err().contains("null"));
}
#[test]
fn duplicate_edges_toggle_off_unless_deduplicated() {
let (m, run) = block();
let sel = EdgeSel::AllOfFeature("Boss-Extrude1".into());
assert_eq!(m.select_edges(&sel, &run.last_feature, true).unwrap().0.len(), 12);
assert!(m.select_edges(&sel, &run.last_feature, false).is_err(), "24 references, each edge twice: nothing stays selected");
}
#[test]
fn a_material_wipes_the_appearance_and_the_run_reapplies_it() {
let (mut m, mut run) = block();
apply(&mut m, &mut run, &Op::SetAppearance { rgb: [200.0 / 255.0, 0.0, 0.0], target: AppearanceTarget::Part }, true).unwrap();
let r = apply(&mut m, &mut run, &Op::SetMaterial { name: "ABS".into(), database: "x".into(), density: None }, true).unwrap();
assert_eq!(m.appearance, Some([200, 0, 0]));
assert_eq!(r["appearanceReapplied"].as_array().map(|a| a.len()), Some(1));
let mut bare = m.clone();
bare.set_material("ABS");
assert_eq!(bare.appearance, None, "the material alone wipes it");
}
#[test]
fn a_boss_from_the_bottom_face_goes_outward() {
let (mut m, mut run) = block();
apply(&mut m, &mut run, &Op::Sketch { shorthand: "sketch_circle", plane: PlaneRef::Face { point: [0.0, 0.0, 0.0] },
entities: vec![Entity::Circle { cx: 0.0, cy: 0.0, r: 0.003 }], offsets: vec![] }, true).unwrap();
apply(&mut m, &mut run, &Op::Extrude(ext(0.003)), true).unwrap();
assert!((m.bodies[0].lo[1] + 0.003).abs() < 1e-12, "y down to -3 mm: {:?}", m.bodies[0].lo);
}
// ── 0.11.0: CSWA phase 1, against CLOSED FORMS ─────────────────────────
fn script(steps: Value) -> Vec<Op> {
crate::script::parse(&steps).unwrap()
}
/// Run ops on a fresh model; every step must succeed.
fn build(steps: Value) -> (Model, Run, Vec<Value>) {
let mut m = Model::default();
let mut run = Run::default();
let mut out = Vec::new();
for op in script(steps) {
if matches!(op, Op::NewPart { .. }) {
continue;
}
if matches!(op, Op::Extrude(_) | Op::Cut(_) | Op::Revolve(_) | Op::RevolveCut(_)) {
run.sketch_selected = true;
}
out.push(apply(&mut m, &mut run, &op, true).unwrap_or_else(|e| panic!("{}: {e}", op.name())));
}
(m, run, out)
}
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * b.abs().max(1e-30)
}
#[test]
fn a_closed_polyline_and_a_rounded_rect_have_their_exact_areas() {
let square = script(json!([{"op": "sketch", "entities": [{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 10]], "close": true}}]}]));
let Op::Sketch { entities, .. } = &square[0] else { panic!() };
assert!(close(profile_area(entities), 1.0e-4, 1e-12), "{}", profile_area(entities));
// A rounded rect: w·h − (4 − π)·rc².
let rr = script(json!([{"op": "sketch_rect", "widthMm": 40, "heightMm": 20, "cornerRadiusMm": 5}]));
let Op::Sketch { entities, .. } = &rr[0] else { panic!() };
assert_eq!(entities.len(), 8, "four lines, four arcs");
assert!(close(profile_area(entities), (40.0 * 20.0 - (4.0 - PI) * 25.0) * 1e-6, 1e-12), "{}", profile_area(entities));
// A slot: L·W + π(W/2)²; a hexagon by its inscribed radius: 2√3·r².
let sl = script(json!([{"op": "sketch", "entities": [{"slot": {"center": [5, 5], "lengthMm": 30, "widthMm": 10, "angleDeg": 30}}]}]));
let Op::Sketch { entities, .. } = &sl[0] else { panic!() };
assert!(close(profile_area(entities), (300.0 + PI * 25.0) * 1e-6, 1e-12));
let hx = script(json!([{"op": "sketch", "entities": [{"polygon": {"center": [0, 0], "sides": 6, "inscribedRadiusMm": 10}}]}]));
let Op::Sketch { entities, .. } = &hx[0] else { panic!() };
assert!(close(profile_area(entities), 2.0 * 3f64.sqrt() * 100.0 * 1e-6, 1e-12));
}
#[test]
fn a_contour_with_a_hole_is_a_region_and_extrudes_to_its_exact_volume() {
// A 30 x 20 polyline plate with a 4 mm-radius circle inside, 5 mm thick.
let (m, _, _) = build(json!([
{"op": "sketch", "plane": "Front Plane", "entities": [
{"polyline": {"points": [[0, 0], [30, 0], [30, 20], [0, 20]], "close": true}},
{"circle": {"centerMm": [15, 10], "radiusMm": 4}}]},
{"op": "extrude", "depthMm": 5}
]));
assert!(close(m.volume(), (600.0 - PI * 16.0) * 5.0 * 1e-9, 1e-12), "{}", m.volume() * 1e9);
let c = m.center_of_mass().unwrap();
assert!((c[0] - 0.015).abs() < 1e-12 && (c[1] - 0.010).abs() < 1e-12 && (c[2] - 0.0025).abs() < 1e-12, "{c:?}");
}
#[test]
fn a_revolved_rectangle_is_a_ring_exactly() {
// Front Plane, the rectangle x 10..15, y 0..20, about the sketch's y axis:
// π(R² − r²)·h, and its centre of mass on the axis at mid-height.
let (m, _, out) = build(json!([
{"op": "sketch", "plane": "Front Plane", "entities": [{"polyline": {"points": [[10, 0], [15, 0], [15, 20], [10, 20]], "close": true}}]},
{"op": "revolve", "axis": "y"}
]));
let ring = PI * (15.0f64.powi(2) - 10.0f64.powi(2)) * 20.0;
assert!(close(m.volume() * 1e9, ring, 1e-12), "{} vs {ring}", m.volume() * 1e9);
let c = m.center_of_mass().unwrap();
assert!(c[0].abs() < 1e-12 && (c[1] - 0.010).abs() < 1e-12 && c[2].abs() < 1e-12, "{c:?}");
assert_eq!(out[1]["route"], "FeatureRevolve2");
assert!((m.bodies[0].hi[0] - 0.015).abs() < 1e-12 && (m.bodies[0].lo[2] + 0.015).abs() < 1e-12, "the box is the ring's");
// A quarter turn is a quarter of it.
let (q, _, _) = build(json!([
{"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 10, "yMm": 0, "widthMm": 5, "heightMm": 20}}]},
{"op": "revolve", "axis": "y", "angleDeg": 90}
]));
assert!(close(q.volume() * 1e9, ring / 4.0, 1e-12));
}
#[test]
fn pappus_a_half_disc_about_its_diameter_is_a_sphere() {
// A semicircle (an arc and the diameter line) revolved about that line:
// area πr²/2, centroid 4r/3π from it → 4/3·πr³ (Pappus, exact).
let (m, _, _) = build(json!([
{"op": "sketch", "plane": "Top Plane", "entities": [
{"line": {"from": [-10, 0], "to": [10, 0]}},
{"arc": {"center": [0, 0], "radiusMm": 10, "startDeg": 0, "endDeg": 180}}]},
{"op": "revolve", "axis": {"entity": 0}}
]));
assert!(close(m.volume() * 1e9, 4.0 / 3.0 * PI * 1000.0, 1e-12), "{}", m.volume() * 1e9);
let c = m.center_of_mass().unwrap();
assert!(modeling::norm(c) < 1e-12, "the sphere's centre: {c:?}");
// A triangle (1,0) (2,0) (1,1) about y: A = ½, x̄ = 4/3 → V = ½·4/3·2π.
let (t, _, _) = build(json!([
{"op": "sketch", "plane": "Front Plane", "entities": [{"polyline": {"points": [[1, 0], [2, 0], [1, 1]], "close": true}}]},
{"op": "revolve", "axis": {"line": [[0, -5], [0, 5]]}}
]));
assert!(close(t.volume() * 1e9, 0.5 * 4.0 / 3.0 * 2.0 * PI, 1e-12), "{}", t.volume() * 1e9);
}
#[test]
fn a_revolve_cut_grooves_a_shaft_exactly() {
// A Ø20 x 40 shaft along Y, a 2 x 3 rectangle at radius 8..10 revolved out.
let (m, _, _) = build(json!([
{"op": "sketch_circle", "plane": "Top Plane", "radiusMm": 10},
{"op": "extrude", "depthMm": 40},
{"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 8, "yMm": 20, "widthMm": 2, "heightMm": 3}}]},
{"op": "revolve_cut", "axis": "y"}
]));
let v = PI * 100.0 * 40.0 - PI * (100.0 - 64.0) * 3.0;
assert!(close(m.volume() * 1e9, v, 1e-12), "{} vs {v}", m.volume() * 1e9);
}
#[test]
fn a_mirrored_boss_doubles_and_a_mirrored_body_is_whole() {
// A 10 mm cube at x 20..30 mirrored about Right Plane: 2000 mm³, centred.
let (m, _, out) = build(json!([
{"op": "sketch_rect", "plane": "Front Plane", "xMm": 20, "yMm": 0, "widthMm": 10, "heightMm": 10},
{"op": "extrude", "depthMm": 10},
{"op": "mirror", "plane": "Right Plane"}
]));
assert!(close(m.volume() * 1e9, 2000.0, 1e-12));
assert_eq!(m.bodies.len(), 2, "apart, two bodies");
assert!(m.center_of_mass().unwrap()[0].abs() < 1e-12);
assert_eq!(out[2]["route"], "InsertMirrorFeature2");
// A body touching the plane mirrors into ONE body twice the size.
let (b, _, _) = build(json!([
{"op": "sketch_rect", "plane": "Front Plane", "xMm": 0, "yMm": 0, "widthMm": 10, "heightMm": 10},
{"op": "extrude", "depthMm": 10},
{"op": "mirror", "plane": "Right Plane", "featureOrBody": "all"}
]));
assert!(close(b.volume() * 1e9, 2000.0, 1e-12) && b.bodies.len() == 1, "{} {}", b.volume() * 1e9, b.bodies.len());
assert!((b.bodies[0].lo[0] + 0.010).abs() < 1e-12);
// A mirrored HOLE removes the same again.
let (h, _, _) = build(json!([
{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 60, "heightMm": 20, "anchor": "center"},
{"op": "extrude", "depthMm": 10},
{"op": "hole", "atMm": [20, 10, 0], "diameterMm": 5, "throughAll": true},
{"op": "mirror", "plane": "Right Plane"}
]));
assert!(close(h.volume() * 1e9, 60.0 * 20.0 * 10.0 - 2.0 * PI * 6.25 * 10.0, 1e-12), "{}", h.volume() * 1e9);
}
#[test]
fn holes_plain_counterbored_and_countersunk_remove_their_exact_volumes() {
let block = json!([{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 40, "heightMm": 40, "anchor": "center"}, {"op": "extrude", "depthMm": 20}]);
let with = |hole: Value| {
let mut steps = block.as_array().unwrap().clone();
steps.push(hole);
build(Value::Array(steps))
};
let full = 40.0 * 40.0 * 20.0;
let (m, _, out) = with(json!({"op": "hole", "atMm": [5, 20, 5], "diameterMm": 6, "depthMm": 12}));
assert!(close(m.volume() * 1e9, full - PI * 9.0 * 12.0, 1e-12), "{}", m.volume() * 1e9);
assert_eq!(out[2]["features"].as_array().map(|a| a.len()), Some(1));
let (m, _, out) = with(json!({"op": "hole", "atMm": [0, 20, 0], "diameterMm": 6, "throughAll": true, "counterbore": {"diameterMm": 11, "depthMm": 6}}));
assert!(close(m.volume() * 1e9, full - PI * (30.25 * 6.0 + 9.0 * 14.0), 1e-12), "{}", m.volume() * 1e9);
assert_eq!(out[2]["features"].as_array().map(|a| a.len()), Some(2));
// A 90° countersink Ø12 on a Ø6 hole: a 3 mm deep frustum, then the hole.
let (m, _, _) = with(json!({"op": "hole", "atMm": [0, 20, 0], "diameterMm": 6, "throughAll": true, "countersink": {"diameterMm": 12, "angleDeg": 90}}));
let frustum = PI * 3.0 / 3.0 * (36.0 + 18.0 + 9.0);
assert!(close(m.volume() * 1e9, full - frustum - PI * 9.0 * 17.0, 1e-9), "{} vs {}", m.volume() * 1e9, full - frustum - PI * 9.0 * 17.0);
// From the BOTTOM face the cuts go up, into the material.
let (m, _, _) = with(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 6, "depthMm": 5}));
assert!(close(m.volume() * 1e9, full - PI * 9.0 * 5.0, 1e-12));
}
#[test]
fn up_to_next_up_to_a_face_and_offset_from_it() {
// Two blocks on Top Plane: y 0..10, and a slab y 30..35 above. A boss
// from the first's top face up to the next face stops at y 30.
let base = json!([
{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},
{"op": "extrude", "depthMm": 10},
{"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 30}, "widthMm": 20, "heightMm": 20, "anchor": "center"},
{"op": "extrude", "depthMm": 5}
]);
let with = |more: Value| {
let mut steps = base.as_array().unwrap().clone();
steps.extend(more.as_array().unwrap().iter().cloned());
build(Value::Array(steps))
};
let (m, _, out) = with(json!([{"op": "sketch_circle", "face": {"atPointMm": [0, 10, 0]}, "radiusMm": 2}, {"op": "extrude", "upToNext": true}]));
assert!(close(m.volume() * 1e9, 4000.0 + 2000.0 + PI * 4.0 * 20.0, 1e-12), "{}", m.volume() * 1e9);
assert!((out[5]["endsAtMm"].as_f64().unwrap() - 30.0).abs() < 1e-9);
let (m, _, _) = with(json!([{"op": "sketch_circle", "face": {"atPointMm": [0, 10, 0]}, "radiusMm": 2}, {"op": "extrude", "upToSurface": {"face": {"atPointMm": [0, 35, 0]}}}]));
assert!(close(m.volume() * 1e9, 6000.0 + PI * 4.0 * 25.0, 1e-12));
let (m, _, _) = with(json!([{"op": "sketch_circle", "face": {"atPointMm": [0, 10, 0]}, "radiusMm": 2}, {"op": "extrude", "offsetFromSurface": {"face": {"atPointMm": [0, 30, 0]}, "distanceMm": 4}}]));
assert!(close(m.volume() * 1e9, 6000.0 + PI * 4.0 * 16.0, 1e-12));
// Nothing ahead: up to next has no face, and says so (a null feature).
let mut m2 = Model::default();
let mut run = Run::default();
for op in script(json!([{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20}, {"op": "extrude", "depthMm": 10},
{"op": "sketch_circle", "face": {"atPointMm": [10, 10, -10]}, "radiusMm": 2}])) {
run.sketch_selected = true;
apply(&mut m2, &mut run, &op, true).unwrap();
}
run.sketch_selected = true;
let e = apply(&mut m2, &mut run, &script(json!([{"op": "extrude", "upToNext": true}]))[0], true).unwrap_err();
assert!(e.contains("no next face"), "{e}");
}
#[test]
fn a_draft_tapers_a_box_and_a_cylinder_by_their_closed_forms() {
// A 20 x 10 rect drafted 5° inward over 8 mm: ∫(w−2kt)(h−2kt)dt.
let k = 5f64.to_radians().tan();
let (w, h, l) = (20.0, 10.0, 8.0);
let want = w * h * l - (w + h) * k * l * l + 4.0 * k * k * l * l * l / 3.0;
let (m, _, _) = build(json!([{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 20, "heightMm": 10}, {"op": "extrude", "depthMm": 8, "draftDeg": 5}]));
assert!(close(m.volume() * 1e9, want, 1e-12), "{} vs {want}", m.volume() * 1e9);
// Outward: the same with k → −k.
let want_out = w * h * l + (w + h) * k * l * l + 4.0 * k * k * l * l * l / 3.0;
let (m, _, _) = build(json!([{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 20, "heightMm": 10}, {"op": "extrude", "depthMm": 8, "draftDeg": 5, "draftOutward": true}]));
assert!(close(m.volume() * 1e9, want_out, 1e-12));
// A circle r 5 drafted 10° over 6 mm is a frustum.
let k = 10f64.to_radians().tan();
let r2 = 5.0 - 6.0 * k;
let frustum = PI * 6.0 / 3.0 * (25.0 + 5.0 * r2 + r2 * r2);
let (m, _, _) = build(json!([{"op": "sketch_circle", "plane": "Front Plane", "radiusMm": 5}, {"op": "extrude", "depthMm": 6, "draftDeg": 10}]));
assert!(close(m.volume() * 1e9, frustum, 1e-12), "{} vs {frustum}", m.volume() * 1e9);
}
#[test]
fn a_density_alone_sets_the_part_and_a_mismatched_one_is_refused() {
let (m, _, out) = build(json!([{"op": "sketch_rect", "widthMm": 10, "heightMm": 10}, {"op": "extrude", "depthMm": 10}, {"op": "set_material", "densityKgM3": 1250}]));
assert_eq!(m.density, Some(1250.0));
assert_eq!(out[2]["densityKgM3"], 1250.0);
let (m, _, _) = build(json!([{"op": "set_material", "name": "AISI 1020", "densityKgM3": 7900}]));
assert_eq!(m.density, Some(7900.0));
let mut m = Model::default();
let mut run = Run::default();
let e = apply(&mut m, &mut run, &script(json!([{"op": "set_material", "name": "6061 Alloy", "densityKgM3": 7900}]))[0], true).unwrap_err();
assert!(e.contains("2700") && e.contains("7900"), "{e}");
apply(&mut m, &mut run, &script(json!([{"op": "set_material", "name": "6061 Alloy"}]))[0], true).unwrap();
let e = apply(&mut m, &mut run, &script(json!([{"op": "set_material", "densityKgM3": 1000}]))[0], true).unwrap_err();
assert!(e.contains("density wins"), "{e}");
assert_eq!(library_density("Cast Alloy Steel"), Some(7300.0), "the longest name wins");
}
#[test]
fn a_cut_from_a_plane_offset_inside_a_face_goes_on_inward() {
// A 40 x 20 x 44 block on Front Plane (z 0..44); a plane 12 inside the
// BACK face (z = 0, outward −Z) sits at z = 12; a cut from it goes +Z.
let (m, _, out) = build(json!([
{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 40, "heightMm": 20},
{"op": "extrude", "depthMm": 44},
{"op": "sketch_rect", "plane": {"offsetFrom": {"face": {"atPointMm": [5, 5, 0]}}, "offsetMm": -12}, "xMm": 10, "yMm": 5, "widthMm": 10, "heightMm": 10},
{"op": "cut", "depthMm": 24}
]));
assert!((out[2]["resolvedPlane"]["offsetMm"].as_f64().unwrap() - 12.0).abs() < 1e-9, "{}", out[2]);
assert!((out[3]["spanMm"][0].as_f64().unwrap() - 12.0).abs() < 1e-9 && (out[3]["spanMm"][1].as_f64().unwrap() - 36.0).abs() < 1e-9, "{}", out[3]);
assert!(close(m.volume() * 1e9, 40.0 * 20.0 * 44.0 - 100.0 * 24.0, 1e-12));
// The column model agrees, and says where the material is.
let (v, c) = m.csg_mass().unwrap();
assert!(close(v * 1e9, 40.0 * 20.0 * 44.0 - 100.0 * 24.0, 1e-9), "{}", v * 1e9);
assert!(c[2] > 0.0 && c[2] < 0.044);
}
#[test]
fn the_column_model_declines_what_it_cannot_represent() {
let (m, _, _) = build(json!([
{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},
{"op": "extrude", "depthMm": 10},
{"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "last"}}
]));
assert_eq!(m.csg_mass().unwrap_err(), "a fillet");
let (m, _, _) = build(json!([
{"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 10, "yMm": 0, "widthMm": 5, "heightMm": 20}}]},
{"op": "revolve", "axis": "y", "angleDeg": 90}
]));
assert!(m.csg_mass().unwrap_err().contains("less than 360"));
// A full revolve IS represented: the ring, by columns along its axis.
let (m, _, _) = build(json!([
{"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 10, "yMm": 0, "widthMm": 5, "heightMm": 20}}]},
{"op": "revolve", "axis": "y"},
{"op": "linear_pattern", "feature": "last", "directions": [{"axis": "x", "count": 2, "spacingMm": 50}]}
]));
let (v, c) = m.csg_mass().unwrap();
let ring = PI * (225.0 - 100.0) * 20.0;
assert!(close(v * 1e9, 2.0 * ring, 1e-7), "{} vs {}", v * 1e9, 2.0 * ring);
assert!((c[0] - 0.025).abs() < 1e-6 && (c[1] - 0.010).abs() < 1e-9, "{c:?}");
}
#[test]
fn a_mirror_about_a_face_or_an_offset_plane_lands_beyond_it() {
// A 10 mm cube at x 0..10 mirrored about its own RIGHT face (x = 10):
// the copy at x 10..20, one body, its centre of mass at x = 10.
let (m, _, _) = build(json!([
{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10},
{"op": "extrude", "depthMm": 10},
{"op": "mirror", "face": {"atPointMm": [10, 5, 5]}}
]));
assert!(close(m.volume() * 1e9, 2000.0, 1e-12) && (m.bodies[0].hi[0] - 0.020).abs() < 1e-12, "{:?}", m.bodies);
let (v, c) = m.csg_mass().unwrap();
assert!(close(v * 1e9, 2000.0, 1e-9) && (c[0] - 0.010).abs() < 1e-9, "{c:?}");
// About a plane offset 30 from Right Plane: the copy at x 50..60.
let (m, _, _) = build(json!([
{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10},
{"op": "extrude", "depthMm": 10},
{"op": "mirror", "plane": {"offsetFrom": "Right Plane", "offsetMm": 30}}
]));
let (_, c) = m.csg_mass().unwrap();
assert!((c[0] - 0.030).abs() < 1e-9, "{c:?}");
}
#[test]
fn offset_from_surface_can_go_beyond_the_face() {
// From Top Plane (y 0) up to 4 mm BEYOND a face at y 30 (reverseOffset).
let (m, _, out) = build(json!([
{"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 30}, "widthMm": 20, "heightMm": 20, "anchor": "center"},
{"op": "extrude", "depthMm": 5},
{"op": "sketch_circle", "plane": "Top Plane", "radiusMm": 2},
{"op": "extrude", "offsetFromSurface": {"face": {"atPointMm": [0, 30, 0]}, "distanceMm": 4, "reverseOffset": true}}
]));
assert!((out[3]["endsAtMm"].as_f64().unwrap() - 34.0).abs() < 1e-9, "{}", out[3]);
let _ = m;
}
}