diff --git a/ROADMAP.md b/ROADMAP.md index 1fbc2048..f3806a18 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # SolidCraft roadmap -Status as of **2026-10-11**. SolidCraft supports parametric modelling (sketch → +Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch → features → parametric timeline) as an open, pure-Rust, agent-drivable application. ## At a glance @@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat |---|---| | Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. | | Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth | -| Reference tests | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR. | +| Reference tests | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) or FreeCAD-computed (21) expected values, run by CI on every PR. | | Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green | | Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) | @@ -50,7 +50,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 | | M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | -| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | +| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | | M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | | M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 | diff --git a/apps/solidcraft-cli/src/recipe.rs b/apps/solidcraft-cli/src/recipe.rs index 3180262e..c3b9afc0 100644 --- a/apps/solidcraft-cli/src/recipe.rs +++ b/apps/solidcraft-cli/src/recipe.rs @@ -296,6 +296,17 @@ pub fn to_script(recipe: &Value) -> Result { Some("sketch") => sketch_cmds(f, &mut out)?, Some("extrude") => { let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies}); + // Thin: {sketch, curves, thickness, side?, thickness2?} instead of profiles. + if let (Some(thin), Some(params)) = (f.get("thin"), p.as_object_mut()) { + params.remove("profiles"); + params.insert("thin".into(), json!(true)); + params.insert("sketch".into(), thin.get("sketch").cloned().unwrap_or(Value::Null)); + for key in ["curves", "thickness", "side", "thickness2"] { + if let Some(v) = thin.get(key) { + params.insert(key.into(), v.clone()); + } + } + } extent(f, &mut p); out.push(json!({"command": "solid.extrude", "params": p})); } diff --git a/assets/locales/de.json b/assets/locales/de.json index 02a02e0d..990c85cf 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -702,6 +702,14 @@ "sketch_text.path_hint": "Eine Skizzenkurve, der der Text folgt: hier klicken, dann die Kurve", "sketch_text.path_clear": "Text von der Kurve lösen", "sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage", + "Thin": "Dünnwandig", + "Thin Extrude": "Dünnwandig extrudieren", + "Wall Location": "Wandlage", + "Wall Thickness": "Wandstärke", + "Thickness Outside": "Dicke außen", + "Inside": "Innen", + "Outside": "Außen", + "Profiles/Curves": "Profile/Kurven", "update.button": "Update", "update.button_tip": "SolidCraft {version} ist verfügbar", "update.title": "Update verfügbar", diff --git a/assets/locales/en.json b/assets/locales/en.json index 75332873..d8c3fc3e 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -959,6 +959,14 @@ "Line Spacing": "Line Spacing", "Path": "Path", "sketch_text.on_path": "The text follows its path: the curve places it", + "Thin": "Thin", + "Thin Extrude": "Thin Extrude", + "Wall Location": "Wall Location", + "Wall Thickness": "Wall Thickness", + "Thickness Outside": "Thickness Outside", + "Inside": "Inside", + "Outside": "Outside", + "Profiles/Curves": "Profiles/Curves", "cmd.material_properties_validate": "Validate Physical Material Data", "cmd.material_properties_directional": "Directional Elastic Modulus", "update.button": "Update", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 668509b1..393609d9 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -959,6 +959,14 @@ "Line Spacing": "行間", "Path": "パス", "sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります", + "Thin": "薄肉", + "Thin Extrude": "薄肉押し出し", + "Wall Location": "壁の位置", + "Wall Thickness": "壁の厚さ", + "Thickness Outside": "外側の厚さ", + "Inside": "内側", + "Outside": "外側", + "Profiles/Curves": "プロファイル/曲線", "cmd.material_properties_validate": "物理材料データを検証", "cmd.material_properties_directional": "方向別弾性率", "update.button": "アップデート", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index a64401bf..71bb47cf 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -702,6 +702,14 @@ "sketch_text.path_hint": "텍스트가 따라갈 스케치 곡선: 여기를 클릭한 다음 곡선을 클릭", "sketch_text.path_clear": "곡선에서 텍스트 떼기", "sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다", + "Thin": "얇은 벽", + "Thin Extrude": "얇은 벽 돌출", + "Wall Location": "벽 위치", + "Wall Thickness": "벽 두께", + "Thickness Outside": "바깥쪽 두께", + "Inside": "안쪽", + "Outside": "바깥쪽", + "Profiles/Curves": "프로파일/곡선", "update.button": "업데이트", "update.button_tip": "SolidCraft {version} 사용 가능", "update.title": "업데이트 사용 가능", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 625f0426..eab47bf9 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -959,6 +959,14 @@ "sketch_text.on_path": "文字沿路径排列:由曲线放置", "ui.analysis": "分析", "ui.edit_section_analysis": "编辑截面分析", + "Thin": "薄壁", + "Thin Extrude": "薄壁拉伸", + "Wall Location": "壁位置", + "Wall Thickness": "壁厚度", + "Thickness Outside": "外侧厚度", + "Inside": "内侧", + "Outside": "外侧", + "Profiles/Curves": "轮廓/曲线", "cmd.material_properties_validate": "验证物理材料数据", "cmd.material_properties_directional": "方向弹性模量", "update.button": "更新", diff --git a/crates/doc/src/config_shell_tests.rs b/crates/doc/src/config_shell_tests.rs index 8990fd3e..3aa5dce1 100644 --- a/crates/doc/src/config_shell_tests.rs +++ b/crates/doc/src/config_shell_tests.rs @@ -118,6 +118,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() { let boss = doc .add_feature( FeatureKind::Extrude { + thin: None, sketch: sk, profiles: crate::ProfileSel::All, extent: serde_json::from_value(json!({"distance":"2"})).unwrap(), @@ -280,6 +281,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material let boss = doc .add_feature( FeatureKind::Extrude { + thin: None, sketch: sk, profiles: crate::ProfileSel::All, extent: serde_json::from_value(json!({"distance":"1"})).unwrap(), @@ -429,6 +431,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() { let boss = doc .add_feature( FeatureKind::Extrude { + thin: None, sketch: sk, profiles: crate::ProfileSel::All, extent: serde_json::from_value(json!({"distance":"1"})).unwrap(), @@ -553,6 +556,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() { let material = doc .add_feature( FeatureKind::Extrude { + thin: None, sketch: sk, profiles: crate::ProfileSel::All, extent: serde_json::from_value(json!({"distance":"1"})).unwrap(), diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index dc50665d..37f71456 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -179,6 +179,51 @@ pub struct Extent { pub from: Option, } +/// Thin Extrude: a wall of constant thickness along sketch curves instead of the profiles. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ThinWall { + /// The sketch curves the wall follows: open chains and closed loops, joined where they + /// meet end to end. + pub curves: Vec, + /// The wall thickness (expression); with `side: two_sides`, the thickness inside. + pub thickness: String, + #[serde(default, skip_serializing_if = "ThinSide::is_center")] + pub side: ThinSide, + /// With `side: two_sides`, the thickness outside (expression). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thickness2: Option, +} + +/// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses; +/// an open chain's is the side it bends toward (a straight one's, the left of its first +/// curve as drawn). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThinSide { + /// Half the thickness either side of the curves. + #[default] + Center, + Inside, + Outside, + /// `thickness` inside and `thickness2` outside. + TwoSides, +} + +impl ThinSide { + pub fn is_center(&self) -> bool { + *self == ThinSide::Center + } + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().replace([' ', '-'], "_").as_str() { + "center" | "centre" | "centered" | "centred" | "symmetric" => Some(ThinSide::Center), + "inside" | "side1" | "side_1" => Some(ThinSide::Inside), + "outside" | "side2" | "side_2" => Some(ThinSide::Outside), + "two_sides" | "both" => Some(ThinSide::TwoSides), + _ => None, + } + } +} + /// Revolve axis. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -365,6 +410,9 @@ pub enum FeatureKind { /// Target body names for join/cut/intersect (empty = all bodies). #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, + /// Thin: a wall along sketch curves instead of the profiles (which are then unused). + #[serde(default, skip_serializing_if = "Option::is_none")] + thin: Option, }, Revolve { sketch: u64, @@ -1464,6 +1512,8 @@ impl FeatureKind { /// The feature's profile selections, each with its sketch. pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> { match self { + // A thin extrude follows curves; its profiles are not used. + FeatureKind::Extrude { thin: Some(_), .. } => Vec::new(), FeatureKind::Extrude { sketch, profiles, .. } | FeatureKind::Revolve { sketch, profiles, .. } | FeatureKind::Sweep { sketch, profiles, .. } @@ -1484,6 +1534,8 @@ impl FeatureKind { /// Profile selections and their source sketches without cloning feature data. pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> { match self { + // A thin extrude follows curves; its profiles are not used. + FeatureKind::Extrude { thin: Some(_), .. } => Vec::new(), FeatureKind::Extrude { sketch, profiles, .. } | FeatureKind::Revolve { sketch, profiles, .. } | FeatureKind::Sweep { sketch, profiles, .. } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 53d2e195..c578b3e5 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2551,6 +2551,35 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc .collect() } +/// The regions a thin extrude's wall covers in its sketch. +pub(crate) fn thin_regions(vals: &BTreeMap, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result> { + if thin.curves.is_empty() || thin.curves.len() > 1000 { + return Err(DocError::Invalid("a thin extrude needs 1…1000 sketch curves".into())); + } + let t = val(vals, &thin.thickness, Kind::Length)?; + let (inside, outside) = match thin.side { + crate::ThinSide::Center => (t / 2.0, t / 2.0), + crate::ThinSide::Inside => (t, 0.0), + crate::ThinSide::Outside => (0.0, t), + crate::ThinSide::TwoSides => { + let e = thin.thickness2.as_deref().ok_or_else(|| DocError::Invalid("a two-sided thin wall needs `thickness2`".into()))?; + (t, val(vals, e, Kind::Length)?) + } + }; + if !(inside >= 0.0 && outside >= 0.0 && inside + outside > 0.0) { + return Err(DocError::Invalid("the wall thickness must be positive".into())); + } + let curves = thin + .curves + .iter() + .map(|id| { + let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}` of {}", ss.name)))?; + Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci))) + }) + .collect::>>()?; + solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| DocError::Invalid(format!("thin wall: {e}"))) +} + /// How far apart (mm) profiles must stay for their extruded tools to be cut as one. const APART_GAP: f64 = 1e-3; @@ -2564,13 +2593,16 @@ fn feature_tools_with_constructor( ) -> Result> { let unnamed = |bodies: Vec| -> Vec { bodies.into_iter().map(Traced::unnamed).collect() }; match &f.kind { - FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { + FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => { let ss = sketch_in_feature_frame( doc, st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?, f.component, )?; - let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); + let regions = match thin { + Some(thin) => thin_regions(vals, &ss, thin)?, + None => solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?), + }; // From / To Object: where the face or point lies along the normal, measured over the // middle of the profiles. The points are in this feature's component frame, so the // model is seen from there: a target in another, moved component where it is shown. diff --git a/crates/doc/src/face_anchor.rs b/crates/doc/src/face_anchor.rs index 6aee9da9..8609ae36 100644 --- a/crates/doc/src/face_anchor.rs +++ b/crates/doc/src/face_anchor.rs @@ -446,6 +446,7 @@ mod tests { let producer = doc .add_feature( FeatureKind::Extrude { + thin: None, sketch: source, profiles: crate::ProfileSel::Indices { indices: vec![0, 1] }, extent, @@ -555,7 +556,14 @@ mod tests { let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap(); let boss = doc .add_feature( - FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] }, + FeatureKind::Extrude { + thin: None, + sketch: sk, + profiles: crate::ProfileSel::All, + extent, + operation: crate::Operation::NewBody, + targets: vec![], + }, Some("Boss"), ) .unwrap(); diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index d7975d00..b94e5fb5 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -36,6 +36,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { ("sketch", json!({"type": "sketch", "plane": plane("sketch"), "sketch": sk.clone()})), ("sketch_along_path", json!({"type": "sketch", "plane": along("sketch"), "sketch": sk})), ("extrude", json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("extrude")})), + ( + "extrude_thin", + json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("thin"), + "thin": {"curves": ["c1"], "thickness": "thin.thickness", "side": "two_sides", "thickness2": "thin.thickness2"}}), + ), ( "revolve", json!({"type": "revolve", "sketch": 1, "profiles": {"type": "all"}, "axis": {"type": "world", "axis": "X"}, "angle": "revolve.angle", "angle2": "revolve.angle2"}), @@ -158,7 +163,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value { let o = [0, 0, 0]; let tool = json!({"body": "B", "point": o}); let extra = match name { - "extrude" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}), + "extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}), "fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft" | "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_unfold" | "boss" | "lip" | "snap_fit" | "rest" | "surface_offset" => { diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index 9a781302..5525396f 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -16,6 +16,16 @@ extrude Taper angle = extrude.taper (Angle) To offset = extrude.to_offset (Length) expressions: extrude.distance, extrude.distance2, extrude.start_offset, extrude.taper, extrude.to_offset +extrude_thin + inputs: + Distance = thin.distance (Length) + Distance 2 = thin.distance2 (Length) + Start offset = thin.start_offset (Length) + Taper angle = thin.taper (Angle) + To offset = thin.to_offset (Length) + Thickness = thin.thickness (Length) + Thickness 2 = thin.thickness2 (Length) + expressions: thin.distance, thin.distance2, thin.start_offset, thin.taper, thin.to_offset, thin.thickness, thin.thickness2 revolve inputs: Angle = revolve.angle (Angle) diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index 421ffeb0..549d4e3f 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -7,6 +7,10 @@ extrude sketches: [1] remap_refs: sketch remap_bodies: targets[0] +extrude_thin + sketches: [1] + remap_refs: sketch + remap_bodies: targets[0] revolve sketches: [1] remap_refs: sketch diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 7c0f3301..3da8eb59 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -138,13 +138,18 @@ //! Under the same version, a fillet's style can be `curvature` (a G2 quintic blend; `radius` //! is the equivalent circular setback). Fillets of the other styles save as before; 0.9.0 //! would refuse the unknown style. +//! - 20: an Extrude can be thin (`thin`: `curves`, `thickness`, optional `side` and +//! `thickness2`): a wall along sketch curves instead of its profiles. Version 19 designs need +//! no transformation, and an extrude without `thin` saves as before; the version stamp makes +//! 0.10.0 (which reads up to 19) ask for an update instead of ignoring the wall and filling +//! the sketch's profiles. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 19; +pub const FORMAT: u32 = 20; pub fn format_string(n: u32) -> String { format!("solidcraft/{n}") @@ -161,7 +166,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 18] = [ +const JSON_STEPS: [JsonStep; 19] = [ |_| Ok(()), v2_to_v3, v3_to_v4, @@ -180,6 +185,7 @@ const JSON_STEPS: [JsonStep; 18] = [ |_| Ok(()), |_| Ok(()), |_| Ok(()), + |_| Ok(()), ]; /// Stored face and edge names were worked out from geometry before format 14; they get the @@ -1048,6 +1054,7 @@ mod tests { document .add_feature( FeatureKind::Extrude { + thin: None, sketch, profiles: ProfileSel::All, extent: Extent { diff --git a/crates/doc/src/format_offsets.rs b/crates/doc/src/format_offsets.rs index c6087d19..8834a08e 100644 --- a/crates/doc/src/format_offsets.rs +++ b/crates/doc/src/format_offsets.rs @@ -423,6 +423,7 @@ mod tests { document .add_feature( FeatureKind::Extrude { + thin: None, sketch: if missing_sketch { 999_999 } else { id }, profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] }, extent: crate::Extent { diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 7aea893b..43c8bfaf 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -81,7 +81,28 @@ macro_rules! feature_walk { } } }, - FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => { + FeatureKind::Extrude { extent, thin, .. } => { + f("Distance", & $($m)? extent.distance, L, I); + if let Some(d) = & $($m)? extent.distance2 { + f("Distance 2", d, L, I); + } + if let Some(d) = & $($m)? extent.start_offset { + f("Start offset", d, L, I); + } + if let Some(d) = & $($m)? extent.taper { + f("Taper angle", d, A, I); + } + if let Some(d) = & $($m)? extent.to_offset { + f("To offset", d, L, I); + } + if let Some(thin) = thin { + f("Thickness", & $($m)? thin.thickness, L, I); + if let Some(t) = & $($m)? thin.thickness2 { + f("Thickness 2", t, L, I); + } + } + } + FeatureKind::SurfaceExtrude { extent, .. } => { f("Distance", & $($m)? extent.distance, L, I); if let Some(d) = & $($m)? extent.distance2 { f("Distance 2", d, L, I); diff --git a/crates/doc/src/profile_refs.rs b/crates/doc/src/profile_refs.rs index 5ca45488..177a3dc5 100644 --- a/crates/doc/src/profile_refs.rs +++ b/crates/doc/src/profile_refs.rs @@ -308,6 +308,7 @@ mod tests { document .add_feature( crate::FeatureKind::Extrude { + thin: None, sketch: feature, profiles: selection.clone(), extent: crate::Extent { @@ -433,6 +434,7 @@ mod tests { document .add_feature( crate::FeatureKind::Extrude { + thin: None, sketch: feature, profiles: selection.clone(), extent: crate::Extent { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index cb0aa91e..faeaae49 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -29,6 +29,7 @@ fn plate_doc() -> (Document, u64) { let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap(); doc.add_feature( FeatureKind::Extrude { + thin: None, sketch: s, profiles: ProfileSel::All, extent: Extent { @@ -120,6 +121,7 @@ fn box_fillet_cut_timeline() { let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap(); doc.add_feature( FeatureKind::Extrude { + thin: None, sketch: s2, profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] }, extent: Extent { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 1b52c2a5..c7ee869f 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -1,7 +1,7 @@ //! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move. use serde_json::{Value, json}; -use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr}; +use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, ThinSide, ThinWall, expr::Kind, point_expr}; use solidcraft_geom::Vec3; use super::{CommandSpec, not_in_sketch}; @@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("extrude") .key("E") - .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"), + .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"), CommandSpec::new("solid.revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve") @@ -119,6 +119,11 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> { let sid = feature_sketch(s, p, "Extrude").ok()?; let st = s.model.state(); let ss = st.sketch(sid)?; + if bool_(p, "thin").unwrap_or(false) { + let thin = thin_param(s, p, sid, "solid.extrude").ok()?; + let q = thin_regions(s, ss, &thin).ok()?.first()?.interior_point(); + return Some((ss.plane.to_world(q), ss.plane.normal())); + } let ps = &ss.profiles; let q = match profiles(p, "Extrude").ok()? { ProfileSel::All => ps.first()?.region.interior_point(), @@ -505,10 +510,21 @@ fn extrude(s: &mut Session, p: &Value) -> Result { None => (feature_sketch(s, p, cmd)?, None), }; let extent = extent_param(s, p, cmd)?; + let thin = match bool_(p, "thin") { + Some(true) => { + if face_profile.is_some() || p.get("profiles").is_some() { + return Err(bad(cmd, "a thin extrude follows `curves`; it takes no `profiles` or `face`")); + } + Some(thin_param(s, p, sketch, cmd)?) + } + _ if p.get("thin").is_some_and(|v| !v.is_boolean()) => return Err(bad(cmd, "`thin` must be true or false")), + _ => None, + }; let kind = FeatureKind::Extrude { sketch, profiles: match face_profile { Some(f) => f, + None if thin.is_some() => ProfileSel::All, None => profiles(p, cmd)?, }, extent, @@ -517,10 +533,64 @@ fn extrude(s: &mut Session, p: &Value) -> Result { _ => operation(p, cmd)?, }, targets: string_list(p, "targets"), + thin, }; add_feature(s, p, kind) } +/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn +/// curve when none are given), its thickness and which side of them it lies on. +fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result { + let st = s.model.state(); + let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?; + let mut curves = string_list(p, "curves"); + if curves.is_empty() { + curves = ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && c.link.is_none()).map(|c| c.id.clone()).collect(); + } + if curves.is_empty() || curves.len() > 1000 { + return Err(bad(cmd, "a thin extrude needs 1…1000 sketch curves (`curves`)")); + } + if let Some(c) = curves.iter().find(|c| ss.sketch.curve_index(c).is_none()) { + return Err(bad(cmd, format!("no curve `{c}` in the sketch"))); + } + let thickness = req_expr(cmd, p, "thickness")?; + check_expr(s, &thickness, Kind::Length, cmd, "thickness")?; + let side = match str_(p, "side") { + None => ThinSide::Center, + Some(o) => ThinSide::parse(o).ok_or_else(|| bad(cmd, format!("unknown side `{o}` (center, inside, outside or two_sides)")))?, + }; + let thickness2 = expr(p, "thickness2"); + match (&thickness2, side) { + (Some(t), ThinSide::TwoSides) => check_expr(s, t, Kind::Length, cmd, "thickness2")?, + (None, ThinSide::TwoSides) => return Err(bad(cmd, "`side: two_sides` needs `thickness2` (the thickness outside)")), + (Some(_), _) => return Err(bad(cmd, "`thickness2` goes with `side: two_sides`")), + (None, _) => {} + } + let thin = ThinWall { curves, thickness, side, thickness2 }; + // Refuse a wall that can't be built now, with the reason, rather than a failed feature. + thin_regions(s, ss, &thin).map_err(|e| bad(cmd, e))?; + Ok(thin) +} + +/// The regions of a thin wall in its (solved) sketch, with the current parameter values. +fn thin_regions(s: &Session, ss: &solidcraft_doc::SolvedSketch, thin: &ThinWall) -> std::result::Result, String> { + let length = |e: &str| s.doc.eval(&s.doc.with_design_unit(e, Kind::Length), Kind::Length).map_err(|e| e.to_string()); + let t = length(&thin.thickness)?; + let (inside, outside) = match thin.side { + ThinSide::Center => (t / 2.0, t / 2.0), + ThinSide::Inside => (t, 0.0), + ThinSide::Outside => (0.0, t), + ThinSide::TwoSides => (t, length(thin.thickness2.as_deref().unwrap_or("0"))?), + }; + let curves: Vec> = thin + .curves + .iter() + .filter_map(|id| ss.sketch.curve_index(id)) + .map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci))) + .collect(); + solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| format!("thin wall: {e}")) +} + /// An extrude's extent from its parameters: distance (or through all, or to an object), the /// side(s), a second distance, a start offset, a taper, and from an object. pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result { @@ -1696,3 +1766,7 @@ mod solid_move_copy_tests; #[cfg(test)] #[path = "move_in_sketch_tests.rs"] mod move_in_sketch_tests; + +#[cfg(test)] +#[path = "thin_extrude_tests.rs"] +mod thin_extrude_tests; diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index f1674e3d..60ea4f0c 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -1475,7 +1475,7 @@ fn offset_driver_format18_offsets_keep_fixed_distances_on_upgrade() { let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap(); json["format"] = json!("solidcraft/18"); let mut reopened = Session::new(solidcraft_doc::Document::from_json(&json.to_string()).unwrap()); - assert_eq!(reopened.doc.format, "solidcraft/19"); + assert_eq!(reopened.doc.format, "solidcraft/20"); run(&mut reopened, "sketch.edit", json!({"sketch":1})); let sk = sketch(&reopened); assert!((sk.offsets[0].distance - 2.0).abs() < 1e-8); diff --git a/crates/engine/src/cmd/thin_extrude_tests.rs b/crates/engine/src/cmd/thin_extrude_tests.rs new file mode 100644 index 00000000..f2086497 --- /dev/null +++ b/crates/engine/src/cmd/thin_extrude_tests.rs @@ -0,0 +1,174 @@ +//! Thin Extrude (#721): walls along sketch curves, their volumes worked out by hand (a wall's +//! area times its height). + +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn volume(s: &mut Session) -> f64 { + run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) +} + +fn body_count(s: &mut Session) -> u64 { + run(s, "inspect.measure", json!({}))["body_count"].as_u64().unwrap_or(0) +} + +fn close(a: f64, b: f64) { + assert!((a - b).abs() <= 1e-6 * b.abs().max(1.0), "{a} != {b}"); +} + +/// A sketch on XY holding a 40 × 20 rectangle from (5, 5); returns its side ids. +fn rectangle(s: &mut Session) -> Vec { + run(s, "sketch.create", json!({"plane": "XY", "name": "Outline"})); + let r = run(s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [45, 25]})); + run(s, "sketch.finish", json!({})); + ["bottom", "right", "top", "left"].iter().map(|k| r["sides"][k].as_str().unwrap_or_default().to_string()).collect() +} + +#[test] +fn a_rectangle_makes_a_ring_wall_on_either_side() { + for (side, extra, area) in [ + ("center", json!({}), 42.0 * 22.0 - 38.0 * 18.0), + ("inside", json!({}), 40.0 * 20.0 - 36.0 * 16.0), + ("outside", json!({}), 44.0 * 24.0 - 40.0 * 20.0), + ("two_sides", json!({"thickness2": 1.5}), 43.0 * 23.0 - 39.0 * 19.0), + ] { + let mut s = Session::default(); + rectangle(&mut s); + let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": side, "distance": 10}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + if side == "two_sides" { + p["thickness"] = json!(0.5); + } + run(&mut s, "solid.extrude", p); + close(volume(&mut s), area * 10.0); + assert_eq!(body_count(&mut s), 1, "{side}"); + } +} + +#[test] +fn the_thickness_is_a_parameter_and_the_wall_survives_reopening() { + let mut s = Session::default(); + rectangle(&mut s); + let r = run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 10, "name": "Wall"})); + assert!(r["created_bodies"].as_array().is_some_and(|b| b.len() == 1)); + close(volume(&mut s), 240.0 * 10.0); + let f = s.doc.features.iter().find(|f| f.name == "Wall").cloned().unwrap(); + let t = f.param_names.iter().zip(f.kind.inputs()).find(|(_, (label, _, _))| *label == "Thickness").map(|(n, _)| n.clone()).unwrap(); + run(&mut s, "parameters.change", json!({"name": t, "expression": "4"})); + close(volume(&mut s), (44.0 * 24.0 - 36.0 * 16.0) * 10.0); + let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + close(volume(&mut reopened), (44.0 * 24.0 - 36.0 * 16.0) * 10.0); + // A plain extrude of the same sketch fills the rectangle. + let mut s = Session::default(); + rectangle(&mut s); + run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10})); + close(volume(&mut s), 800.0 * 10.0); +} + +#[test] +fn chosen_curves_make_an_open_wall_that_joins_and_cuts() { + // Three sides of the rectangle: a U, inside toward the open side's middle. + let mut s = Session::default(); + let sides = rectangle(&mut s); + let u = [&sides[0], &sides[1], &sides[2]]; + run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "curves": u, "thickness": 2, "side": "inside", "distance": 5})); + // 40 × 2 twice and 20 × 2, less the two 2 × 2 corners they share. + close(volume(&mut s), (80.0 + 40.0 + 80.0 - 8.0) * 5.0); + // Cut a groove 3 deep along the whole outline into a block under it. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"})); + rectangle(&mut s); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 3, "direction": "negative", "operation": "cut"}), + ); + close(volume(&mut s), 15000.0 - 240.0 * 3.0); + assert_eq!(body_count(&mut s), 1); + // And join a rim on top of it. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"})); + rectangle(&mut s); + run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 4, "operation": "auto"})); + close(volume(&mut s), 15000.0 + 240.0 * 4.0); + assert_eq!(body_count(&mut s), 1); +} + +#[test] +fn a_ring_and_a_cross_of_walls_are_one_body() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Target"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 20})); + run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]})); + run(&mut s, "sketch.line", json!({"points": [[0, -20], [0, 20]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Target", "thin": true, "thickness": 1, "distance": 5})); + assert_eq!(body_count(&mut s), 1); + // The ring, plus the bars inside it less where they cross and where they run into it. + let ring = std::f64::consts::PI * (20.5f64.powi(2) - 19.5f64.powi(2)); + let v = volume(&mut s); + assert!(v > (ring + 2.0 * 38.0 - 1.0) * 5.0 && v < (ring + 2.0 * 40.0) * 5.0, "{v}"); +} + +#[test] +fn bad_thin_walls_are_refused_and_change_nothing() { + let mut s = Session::default(); + rectangle(&mut s); + let before = s.doc.to_json(); + for (p, says) in [ + (json!({"thin": true, "thickness": 2, "distance": 5, "curves": ["nope"]}), "no curve"), + (json!({"thin": true, "distance": 5}), "thickness"), + (json!({"thin": true, "thickness": 2, "distance": 5, "side": "two_sides"}), "thickness2"), + (json!({"thin": true, "thickness": 2, "thickness2": 1, "distance": 5}), "two_sides"), + (json!({"thin": true, "thickness": 2, "distance": 5, "side": "sideways"}), "unknown side"), + (json!({"thin": true, "thickness": 2, "distance": 5, "profiles": [0]}), "profiles"), + (json!({"thin": true, "thickness": 0, "distance": 5}), "thickness"), + (json!({"thin": true, "thickness": -1, "distance": 5}), "thickness"), + (json!({"thin": "yes", "thickness": 1, "distance": 5}), "`thin`"), + ] { + let mut p = p; + p["sketch"] = json!("Outline"); + let e = s.execute("solid.extrude", &p).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains(says), "{p}: {e}"); + assert_eq!(s.doc.to_json(), before); + } + // A wall thicker than an arc it follows bends. + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 1})); + run(&mut s, "sketch.finish", json!({})); + let e = s.execute("solid.extrude", &json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "inside", "distance": 5})); + assert!(e.is_err_and(|e| e.to_string().contains("thicker"))); + run(&mut s, "solid.extrude", json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "outside", "distance": 5})); + // Measured from the tessellation, within its 1e-4. + let (v, exact) = (volume(&mut s), std::f64::consts::PI * (16.0 - 1.0) * 5.0); + assert!((v - exact).abs() < 1e-4 * exact, "{v} vs {exact}"); +} + +#[test] +fn hostile_thin_params_never_panic() { + let keys = ["curves", "thickness", "thickness2", "side", "distance", "taper", "direction", "to", "operation"]; + for v in crate::tests::hostile_values().into_iter().chain([json!(1e9), json!(1e-9), json!("1e6 m"), json!(["l1", "l1", "l1"])]) { + for k in keys { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + rectangle(&mut s); + let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": "two_sides", "thickness2": 1, "distance": 5}); + p[k] = v.clone(); + if let Err(crate::EngineError::Internal(id, msg)) = s.execute("solid.extrude", &p) { + panic!("{id} {p}: {msg}"); + } + } + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b06eb3aa..02a33843 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -602,7 +602,7 @@ fn script_runs_commands() { assert!(s.run_script(&json!([{"command": "Nope"}])).is_err()); } -fn hostile_values() -> Vec { +pub(crate) fn hostile_values() -> Vec { vec![ Value::Null, json!(true), diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 563141ce..1f6e0aa0 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -33,6 +33,7 @@ mod profiles; mod shape; mod solver; mod text; +mod thin; mod union_find; mod wire; @@ -61,6 +62,7 @@ pub use solver::{ solve_after_edit, solve_after_value_edit, solve_holding, solve_with, }; pub use text::{MAX_TEXT_CHARS, TextAlign, TextStyle, text_geometry, text_geometry_in, text_geometry_styled}; +pub use thin::{MAX_THIN_SEGMENTS, thin_wall_regions}; pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline}; #[cfg(test)] @@ -73,6 +75,8 @@ mod continuation_tests; mod font_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod thin_tests; #[cfg(test)] mod virtual_corner_tests; diff --git a/crates/sketch/src/thin.rs b/crates/sketch/src/thin.rs new file mode 100644 index 00000000..1d9fb512 --- /dev/null +++ b/crates/sketch/src/thin.rs @@ -0,0 +1,387 @@ +//! Thin walls: the regions a wall of constant thickness covers along sketch curves (Thin +//! Extrude). Curves that meet end to end are walked as one chain, open or closed; the wall runs +//! `inside` to the left of each chain and `outside` to its right. A closed chain runs +//! counter-clockwise, so its inside is the area it encloses; an open chain runs so that it +//! bends to the left (its inside is the side it curls toward, as a U's inside), and a straight +//! one runs the way its first listed curve was drawn. +//! +//! The wall is the union of a band along every segment (squared off at its ends) and, where +//! segments meet at a corner, a mitred piece that closes the outer side of the corner. The +//! bands' outlines (offsets and end lines) and the mitres are laid out as sketch curves, the +//! sketch's profile detection splits them where they cross, and the faces inside the wall are +//! merged into regions. Walls that cross or meet (a honeycomb, a ring and a cross) come out as +//! one region where they touch. + +use std::f64::consts::{PI, TAU}; + +use solidcraft_geom::{Loop2, Region2, Seg2, Vec2}; + +use crate::model::{CurveKind, Result, Sketch, SketchError}; +use crate::profiles::{find_profiles, merge_regions}; + +/// Most curve segments one wall is built along. +pub const MAX_THIN_SEGMENTS: usize = 2_000; +/// End points closer than this are one vertex of the curves (mm). +const MERGE_TOL: f64 = 1e-6; +/// A mitre longer than this many wall thicknesses is cut off square (a bevel). +const MITRE_LIMIT: f64 = 4.0; +/// Free-form offsets are approximated by cubics within this distance (mm). +const FIT_TOL: f64 = 1e-4; +/// Most cubic pieces the offset of one free-form segment is approximated with. +const MAX_FIT_PIECES: usize = 256; + +fn bad(message: impl Into) -> SketchError { + SketchError::Invalid(message.into()) +} + +/// The regions of a wall `inside` thick to the left of the curves and `outside` to their +/// right (see the module docs for which way the curves run). `curves` holds each selected +/// curve's exact segments in its drawn direction, in the order the curves were listed. +pub fn thin_wall_regions(curves: &[Vec], inside: f64, outside: f64) -> Result> { + if !(inside.is_finite() && outside.is_finite()) || inside < 0.0 || outside < 0.0 { + return Err(bad("the wall thickness must be a positive length")); + } + if inside + outside <= 1e-6 { + return Err(bad("the wall thickness must be positive")); + } + if inside.max(outside) > crate::MAX_COORD { + return Err(bad("the wall thickness is too large")); + } + // Every segment with the index of the curve it belongs to. + let mut segs: Vec<(usize, Seg2)> = Vec::new(); + for (k, curve) in curves.iter().enumerate() { + for s in curve { + if !seg_is_finite(s) { + return Err(bad("a wall curve is not finite")); + } + if s.length() > 1e-9 { + segs.push((k, *s)); + } + if segs.len() > MAX_THIN_SEGMENTS { + return Err(SketchError::TooLarge); + } + } + } + if segs.is_empty() { + return Err(bad("a thin wall needs at least one curve")); + } + let chains = chains(&segs)?; + let (mut bands, mut boundary) = (Vec::new(), Vec::new()); + // Each vertex's segment ends: (vertex, end point, direction away from it, thickness to the + // left of that direction, to its right). + let mut ends: Vec<(usize, Vec2, Vec2, f64, f64)> = Vec::new(); + for chain in &chains { + for (k, s) in chain.segs.iter().enumerate() { + let (start_v, end_v) = chain.vertices.get(k).copied().ok_or_else(|| bad("wall chain"))?; + for side in [inside, -outside] { + boundary.extend(offset_seg(s, side)?); + } + for (p, n) in [(s.start(), s.start_tangent().perp()), (s.end(), s.end_tangent().perp())] { + boundary.push(Seg2::Line { a: p + n * inside, b: p - n * outside }); + } + ends.push((start_v, s.start(), s.start_tangent(), inside, outside)); + ends.push((end_v, s.end(), -s.end_tangent(), outside, inside)); + bands.push((*s, inside, outside)); + } + } + // Close the outer side of every corner with a mitre (or a bevel where the mitre would run + // far out): between two neighbouring segment ends around a vertex whose walls leave a gap + // of more than half a turn. + let mut fills: Vec = Vec::new(); + ends.sort_by(|a, b| a.0.cmp(&b.0).then(a.2.angle().total_cmp(&b.2.angle()))); + for group in ends.chunk_by(|a, b| a.0 == b.0) { + if group.len() < 2 { + continue; + } + for (i, a) in group.iter().enumerate() { + let Some(b) = group.get((i + 1) % group.len()) else { continue }; + let gap = (b.2.angle() - a.2.angle()).rem_euclid(TAU); + if gap <= PI + 1e-7 { + continue; + } + let (vertex, ui, uj) = (a.1, a.2, b.2); + let (ti, tj) = (a.3, b.4); + if ti.max(tj) <= 0.0 { + continue; + } + let pi = vertex + ui.perp() * ti; + let pj = b.1 - uj.perp() * tj; + let den = ui.cross(uj); + let mitre = (den.abs() > 1e-9) + .then(|| { + let s = (pj - pi).cross(uj) / den; + let r = (pj - pi).cross(ui) / den; + let m = pi + ui * s; + (s <= 1e-12 && r <= 1e-12 && m.dist(vertex) <= MITRE_LIMIT * ti.max(tj)).then_some(m) + }) + .flatten(); + let pts = match mitre { + Some(m) => vec![vertex, pi, m, pj], + None => vec![vertex, pi, pj], + }; + let fill = Loop2::polygon(&pts); + if fill.signed_area().abs() < 1e-10 { + continue; + } + for w in pts.windows(2).skip(1) { + boundary.push(Seg2::Line { a: w[0], b: w[1] }); + } + fills.push(fill); + } + } + let inside_wall = |q: Vec2| bands.iter().any(|(s, l, r)| band_contains(s, *l, *r, q)) || fills.iter().any(|f| f.contains(q)); + // Lay the outlines out as a sketch, read its regions, and keep the ones in the wall. + let sketch = outline_sketch(&boundary)?; + let kept: Vec = find_profiles(&sketch).into_iter().map(|p| p.region).filter(|r| inside_wall(r.interior_point())).collect(); + if kept.is_empty() { + return Err(bad("the wall has no area")); + } + Ok(merge_regions(&kept)) +} + +fn seg_is_finite(s: &Seg2) -> bool { + let radius_ok = match *s { + Seg2::Arc { radius, start, sweep, .. } => radius.is_finite() && start.is_finite() && sweep.is_finite(), + _ => true, + }; + radius_ok && s.start().is_finite() && s.end().is_finite() && s.mid().is_finite() +} + +/// A run of segments end to end, oriented as the wall's sides are measured. +struct Chain { + segs: Vec, + /// The vertex at each segment's start and end. + vertices: Vec<(usize, usize)>, +} + +/// The segments walked into chains: maximal runs through vertices where exactly two segments +/// meet, each oriented as the module docs say. +fn chains(segs: &[(usize, Seg2)]) -> Result> { + let mut verts: Vec = Vec::new(); + let mut vertex = |p: Vec2| match verts.iter().position(|v| v.dist(p) <= MERGE_TOL) { + Some(i) => i, + None => { + verts.push(p); + verts.len() - 1 + } + }; + let ends: Vec<(usize, usize)> = segs.iter().map(|(_, s)| (vertex(s.start()), vertex(s.end()))).collect(); + // Each vertex's segment ends: (segment, whether it's the segment's start). + let mut at: Vec> = vec![Vec::new(); verts.len()]; + for (e, (a, b)) in ends.iter().enumerate() { + if let Some(v) = at.get_mut(*a) { + v.push((e, true)); + } + if let Some(v) = at.get_mut(*b) { + v.push((e, false)); + } + } + let degree = |v: usize| at.get(v).map_or(0, Vec::len); + let mut used = vec![false; segs.len()]; + // Walk from segment `e` entered at its start (or its end), through vertices of degree 2. + let walk = |mut e: usize, mut from_start: bool, used: &mut Vec| -> Vec<(usize, bool)> { + let mut run = Vec::new(); + for _ in 0..segs.len() { + if used.get(e).copied().unwrap_or(true) { + break; + } + if let Some(u) = used.get_mut(e) { + *u = true; + } + run.push((e, !from_start)); + let Some(&(a, b)) = ends.get(e) else { break }; + let far = if from_start { b } else { a }; + if degree(far) != 2 { + break; + } + let next = at.get(far).and_then(|v| v.iter().find(|(e2, s2)| !(*e2 == e && *s2 != from_start)).copied()); + match next { + Some((e2, s2)) => { + e = e2; + from_start = s2; + } + None => break, + } + } + run + }; + let mut runs: Vec<(Vec<(usize, bool)>, bool)> = Vec::new(); + for v in 0..verts.len() { + if degree(v) == 2 { + continue; + } + for &(e, s) in at.get(v).map(Vec::as_slice).unwrap_or_default() { + if !used.get(e).copied().unwrap_or(true) { + runs.push((walk(e, s, &mut used), false)); + } + } + } + for e in 0..segs.len() { + if !used.get(e).copied().unwrap_or(true) { + runs.push((walk(e, true, &mut used), true)); + } + } + let mut out = Vec::with_capacity(runs.len()); + for (mut run, closed) in runs { + let oriented = |run: &[(usize, bool)]| -> Vec { + run.iter().filter_map(|(e, rev)| segs.get(*e).map(|(_, s)| if *rev { s.reversed() } else { *s })).collect() + }; + let path = oriented(&run); + let (Some(first), Some(last)) = (path.first(), path.last()) else { continue }; + let length: f64 = path.iter().map(Seg2::length).sum(); + let mut area: f64 = path.iter().map(Seg2::area_term).sum(); + if !closed { + area += 0.5 * last.end().cross(first.start()); + } + let backwards = if area.abs() > 1e-9 * length * length { + area < 0.0 + } else { + // Straight: the way the first listed curve of the chain was drawn. + run.iter().min_by_key(|(e, _)| segs.get(*e).map_or(usize::MAX, |(k, _)| *k)).is_some_and(|(_, rev)| *rev) + }; + if backwards { + run.reverse(); + for item in &mut run { + item.1 = !item.1; + } + } + let vertices = run + .iter() + .map(|(e, rev)| ends.get(*e).map(|&(a, b)| if *rev { (b, a) } else { (a, b) })) + .collect::>>() + .ok_or_else(|| bad("wall chain"))?; + out.push(Chain { segs: oriented(&run), vertices }); + } + Ok(out) +} + +/// Whether `q` lies in the band `left` thick to the left of `s` and `right` thick to its right, +/// between the lines square to `s` at its ends. +fn band_contains(s: &Seg2, left: f64, right: f64, q: Vec2) -> bool { + let within = |d: f64| d >= -right - 1e-9 && d <= left + 1e-9; + match *s { + Seg2::Line { a, b } => { + let d = b - a; + let (t, n) = ((q - a).dot(d) / d.len2(), d.len()); + t > 1e-12 && t < 1.0 - 1e-12 && within((q - a).dot(d.perp()) / n) + } + Seg2::Arc { center, radius, start, sweep } => { + let angle = (q - center).angle(); + let along = if sweep > 0.0 { (angle - start).rem_euclid(TAU) } else { (start - angle).rem_euclid(TAU) }; + let rho = q.dist(center); + along > 1e-12 && along < sweep.abs() - 1e-12 && within(if sweep > 0.0 { radius - rho } else { rho - radius }) + } + _ => { + let t = s.closest_param(q); + if t <= 1e-9 || t >= 1.0 - 1e-9 { + return false; + } + s.derivative(t).normalized().is_some_and(|tangent| within((q - s.point_at(t)).dot(tangent.perp()))) + } + } +} + +/// `s` moved `d` to its left (to its right when `d` is negative): exact for lines and arcs, +/// cubics within [`FIT_TOL`] for free-form segments. +fn offset_seg(s: &Seg2, d: f64) -> Result> { + match *s { + Seg2::Line { a, b } => { + let n = (b - a).normalized().ok_or_else(|| bad("a wall curve has no length"))?.perp(); + Ok(vec![Seg2::Line { a: a + n * d, b: b + n * d }]) + } + Seg2::Arc { center, radius, start, sweep } => { + // The centre is on the left of a counter-clockwise arc. + let r = if sweep > 0.0 { radius - d } else { radius + d }; + if r <= 1e-6 { + return Err(bad(format!("the wall is thicker than the radius of an arc it follows ({radius:.3} mm)"))); + } + Ok(vec![Seg2::Arc { center, radius: r, start, sweep }]) + } + _ => offset_freeform(s, d), + } +} + +/// A free-form segment's offset as Hermite cubics through exact offset points and tangents, +/// halved until each is within [`FIT_TOL`] of the exact offset. +fn offset_freeform(s: &Seg2, d: f64) -> Result> { + if d == 0.0 && matches!(s, Seg2::Cubic { .. }) { + return Ok(vec![*s]); + } + // The exact offset and its derivative at `t`. Along a curve of signed curvature κ the + // offset's derivative is the curve's scaled by 1 − dκ; it turns back where that reaches 0. + let at = |t: f64| -> Result<(Vec2, Vec2)> { + let d1 = s.derivative(t); + let speed = d1.len(); + let tangent = d1.normalized().ok_or_else(|| bad("a wall curve has a point without a direction"))?; + let h = 1e-6; + let d2 = (s.derivative((t + h).min(1.0)) - s.derivative((t - h).max(0.0))) / ((t + h).min(1.0) - (t - h).max(0.0)); + let kappa = d1.cross(d2) / (speed * speed * speed); + let scale = 1.0 - d * kappa; + if !(scale > 0.05) || !kappa.is_finite() { + return Err(bad("the wall is thicker than the radius of a curve it follows")); + } + let p = s.point_at(t) + tangent.perp() * d; + if !p.is_finite() { + return Err(bad("a wall offset is not finite")); + } + Ok((p, d1 * scale)) + }; + for k in 0..=64 { + at(k as f64 / 64.0)?; + } + let mut out = Vec::new(); + let mut stack = vec![(0.0f64, 1.0f64)]; + while let Some((t0, t1)) = stack.pop() { + let ((q0, v0), (q1, v1)) = (at(t0)?, at(t1)?); + let h = (t1 - t0) / 3.0; + let cubic = Seg2::Cubic { p0: q0, p1: q0 + v0 * h, p2: q1 - v1 * h, p3: q1 }; + let mut error = 0.0f64; + for k in 1..8 { + let u = k as f64 / 8.0; + error = error.max(cubic.point_at(u).dist(at(t0 + (t1 - t0) * u)?.0)); + } + if error <= FIT_TOL || out.len() + stack.len() >= MAX_FIT_PIECES { + if error > FIT_TOL * 10.0 { + return Err(bad("could not follow a free-form wall curve closely enough")); + } + out.push((t0, cubic)); + } else { + let mid = 0.5 * (t0 + t1); + stack.push((mid, t1)); + stack.push((t0, mid)); + } + } + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + Ok(out.into_iter().map(|(_, c)| c).collect()) +} + +/// The outline segments as the curves of a sketch (duplicate lines once). +fn outline_sketch(boundary: &[Seg2]) -> Result { + let mut sk = Sketch::new(); + let mut lines: Vec<(Vec2, Vec2)> = Vec::new(); + for s in boundary { + match *s { + Seg2::Line { a, b } => { + if a.dist(b) <= 1e-9 { + continue; + } + let same = |(p, q): &(Vec2, Vec2)| (p.dist(a) <= 1e-9 && q.dist(b) <= 1e-9) || (p.dist(b) <= 1e-9 && q.dist(a) <= 1e-9); + if lines.iter().any(same) { + continue; + } + lines.push((a, b)); + sk.add_line(a, b, None, None, None)?; + } + Seg2::Arc { center, sweep, .. } => { + let (p0, p1) = if sweep > 0.0 { (s.start(), s.end()) } else { (s.end(), s.start()) }; + sk.add_arc(center, p0, p1, [None; 3], None)?; + } + Seg2::Cubic { p0, p1, p2, p3 } => { + let pts = [p0, p1, p2, p3].iter().map(|p| sk.add_point(*p, None)).collect::>>()?; + sk.add_curve(CurveKind::Spline { pts, control: true, degree: 3, periodic: false }, None)?; + } + _ => return Err(bad("not supported yet: this kind of wall curve")), + } + } + Ok(sk) +} diff --git a/crates/sketch/src/thin_tests.rs b/crates/sketch/src/thin_tests.rs new file mode 100644 index 00000000..236a6254 --- /dev/null +++ b/crates/sketch/src/thin_tests.rs @@ -0,0 +1,158 @@ +//! Thin walls against areas worked out by hand: a wall along a chain covers its length times +//! the thickness, less what corners overlap and plus what mitres add. + +use std::f64::consts::PI; + +use solidcraft_geom::{Seg2, Vec2}; + +use crate::thin_wall_regions; + +fn line(a: [f64; 2], b: [f64; 2]) -> Vec { + vec![Seg2::Line { a: Vec2::new(a[0], a[1]), b: Vec2::new(b[0], b[1]) }] +} + +fn area(curves: &[Vec], inside: f64, outside: f64) -> f64 { + let regions = thin_wall_regions(curves, inside, outside).unwrap_or_else(|e| panic!("wall: {e}")); + regions.iter().map(|r| r.area()).sum() +} + +fn close(a: f64, b: f64) { + assert!((a - b).abs() < 1e-6 * (1.0 + b.abs()), "{a} != {b}"); +} + +/// A 40 × 20 rectangle, its sides listed in any order and drawn either way. +fn rectangle() -> Vec> { + vec![line([0.0, 0.0], [40.0, 0.0]), line([40.0, 20.0], [0.0, 20.0]), line([40.0, 0.0], [40.0, 20.0]), line([0.0, 0.0], [0.0, 20.0])] +} + +#[test] +fn a_rectangle_makes_a_ring_on_either_side_or_centred() { + let regions = thin_wall_regions(&rectangle(), 1.0, 1.0).unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].holes.len(), 1); + // The mitred corners keep straight sides: four lines outside, four inside. + assert_eq!(regions[0].outer.segs.len(), 4); + assert_eq!(regions[0].holes[0].segs.len(), 4); + close(area(&rectangle(), 1.0, 1.0), 42.0 * 22.0 - 38.0 * 18.0); + close(area(&rectangle(), 2.0, 0.0), 40.0 * 20.0 - 36.0 * 16.0); + close(area(&rectangle(), 0.0, 2.0), 44.0 * 24.0 - 40.0 * 20.0); + close(area(&rectangle(), 0.5, 1.5), 43.0 * 23.0 - 39.0 * 19.0); +} + +#[test] +fn an_open_corner_has_its_inside_where_it_bends() { + // (0,0) → (10,0) → (10,10) turns left: the inside is toward the triangle it closes. + let l = vec![line([0.0, 0.0], [10.0, 0.0]), line([10.0, 10.0], [10.0, 0.0])]; + close(area(&l, 1.0, 1.0), 40.0); + close(area(&l, 2.0, 0.0), 20.0 + 20.0 - 4.0); + close(area(&l, 0.0, 2.0), 20.0 + 20.0 + 4.0); + let inside = thin_wall_regions(&l, 2.0, 0.0).unwrap(); + assert!(inside[0].contains(Vec2::new(5.0, 1.0)) && !inside[0].contains(Vec2::new(5.0, -1.0))); +} + +#[test] +fn a_straight_wall_takes_its_side_from_the_drawn_direction() { + let up = thin_wall_regions(&[line([0.0, 0.0], [10.0, 0.0])], 2.0, 0.0).unwrap(); + assert!(up[0].contains(Vec2::new(5.0, 1.0))); + let down = thin_wall_regions(&[line([10.0, 0.0], [0.0, 0.0])], 2.0, 0.0).unwrap(); + assert!(down[0].contains(Vec2::new(5.0, -1.0))); + close(area(&[line([0.0, 0.0], [10.0, 0.0])], 1.5, 0.5), 20.0); +} + +#[test] +fn a_circle_makes_an_exact_ring() { + let circle = solidcraft_geom::Loop2::circle(Vec2::new(3.0, 4.0), 10.0).segs; + let regions = thin_wall_regions(std::slice::from_ref(&circle), 1.0, 1.0).unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].holes.len(), 1); + assert!(regions[0].outer.segs.iter().chain(®ions[0].holes[0].segs).all(|s| matches!(s, Seg2::Arc { .. }))); + close(area(std::slice::from_ref(&circle), 1.0, 1.0), PI * (11.0f64.powi(2) - 9.0f64.powi(2))); + close(area(std::slice::from_ref(&circle), 3.0, 0.0), PI * (100.0 - 49.0)); + // A clockwise circle still has its inside inside. + let cw: Vec = circle.iter().rev().map(Seg2::reversed).collect(); + close(area(&[cw], 3.0, 0.0), PI * (100.0 - 49.0)); +} + +#[test] +fn walls_that_cross_or_meet_are_one_region() { + // Two lines crossing, and four meeting at a point: a plus of four 10 mm arms either way. + let crossing = vec![line([-10.0, 0.0], [10.0, 0.0]), line([0.0, -10.0], [0.0, 10.0])]; + let meeting: Vec> = [[10.0, 0.0], [0.0, 10.0], [-10.0, 0.0], [0.0, -10.0]].iter().map(|p| line([0.0, 0.0], *p)).collect(); + for curves in [crossing, meeting] { + let regions = thin_wall_regions(&curves, 1.0, 1.0).unwrap(); + assert_eq!(regions.len(), 1); + close(regions[0].area(), 4.0 * 10.0 * 2.0 - 4.0); + } + // A ring with a bar across it. + let mut ring = vec![solidcraft_geom::Loop2::circle(Vec2::ZERO, 10.0).segs]; + ring.push(line([-10.0, 0.0], [10.0, 0.0])); + let regions = thin_wall_regions(&ring, 1.0, 1.0).unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].holes.len(), 2); +} + +#[test] +fn separate_walls_stay_separate() { + let regions = thin_wall_regions(&[line([0.0, 0.0], [10.0, 0.0]), line([0.0, 5.0], [10.0, 5.0])], 1.0, 1.0).unwrap(); + assert_eq!(regions.len(), 2); +} + +#[test] +fn a_spline_wall_is_its_length_times_its_thickness() { + // A gentle cubic; its wall's area is its length times the thickness (a centred wall's + // sides are as much longer on one side as shorter on the other). + let s = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(10.0, 8.0), p2: Vec2::new(20.0, -8.0), p3: Vec2::new(30.0, 0.0) }; + // Within the fit's 1e-4 mm along both sides. + let a = area(&[vec![s]], 0.5, 0.5); + assert!((a - s.length()).abs() < 2.0 * s.length() * 1e-4, "{a} vs {}", s.length()); + let regions = thin_wall_regions(&[vec![s]], 0.5, 0.5).unwrap(); + for q in [0.25, 0.5, 0.75] { + let p = s.point_at(q); + let n = s.derivative(q).normalized().unwrap().perp(); + assert!(regions[0].contains(p + n * 0.4) && regions[0].contains(p - n * 0.4)); + assert!(!regions[0].contains(p + n * 0.6) && !regions[0].contains(p - n * 0.6)); + } +} + +#[test] +fn walls_thicker_than_a_curve_bends_or_without_thickness_are_refused() { + let arc = vec![Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: PI }]; + assert!(thin_wall_regions(std::slice::from_ref(&arc), 3.0, 0.0).is_err()); + assert!(thin_wall_regions(&[arc], 0.0, 3.0).is_ok()); + let l = [line([0.0, 0.0], [10.0, 0.0])]; + for (a, b) in [(0.0, 0.0), (-1.0, 2.0), (f64::NAN, 1.0), (1.0, f64::INFINITY)] { + assert!(thin_wall_regions(&l, a, b).is_err()); + } + assert!(thin_wall_regions(&[], 1.0, 1.0).is_err()); + assert!(thin_wall_regions(&[line([f64::NAN, 0.0], [1.0, 0.0])], 1.0, 1.0).is_err()); +} + +/// The edges of an `n` × `n` grid of hexagons of side `a` (pointy top), each edge once. +fn honeycomb(n: usize, a: f64) -> Vec> { + let key = |p: Vec2| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64); + let mut seen = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + let w = a * 3f64.sqrt(); + for i in 0..n { + for j in 0..n { + let c = Vec2::new(i as f64 * w + if j % 2 == 1 { w / 2.0 } else { 0.0 }, j as f64 * 1.5 * a); + let corners: Vec = (0..6).map(|k| c + Vec2::from_angle(PI / 6.0 + k as f64 * PI / 3.0) * a).collect(); + for k in 0..6 { + let (p, q) = (corners[k], corners[(k + 1) % 6]); + let edge = if key(p) < key(q) { (key(p), key(q)) } else { (key(q), key(p)) }; + if seen.insert(edge) { + out.push(vec![Seg2::Line { a: p, b: q }]); + } + } + } + } + out +} + +#[test] +fn a_honeycomb_is_one_region_with_a_hole_per_cell() { + let cells = honeycomb(4, 5.0); + let regions = thin_wall_regions(&cells, 0.4, 0.4).unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].holes.len(), 16); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 51477342..8bf25ed4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -59,6 +59,8 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { let n = if *flip && !crate::sketch_modify_ui::offset_symmetric(*mode) { -tangent.perp() } else { tangent.perp() }; Some(ss.plane.to_world(mid + n) - ss.plane.to_world(mid)) } + // A thin extrude's curves extrude along their sketch's normal. + Kind::Extrude { .. } => Some(ss.plane.normal()), _ => None, }; (ss.plane.to_world(mid), dir) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 688f9837..b4d7a6ba 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -9,7 +9,7 @@ use serde_json::{Value, json}; use solidcraft_engine::Sel; use solidcraft_engine::Session; use solidcraft_engine::doc::expr::Kind as ValueKind; -use solidcraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel}; +use solidcraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel, ThinSide}; use solidcraft_engine::geom::{Aabb3, Vec3}; use crate::SolveApp; @@ -22,6 +22,12 @@ pub(crate) const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"] const DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"]; /// The "Two sides" direction (a second distance the other way). pub const TWO_SIDES: usize = 3; +/// Thin Extrude wall locations, as `solid.extrude` names them, and their labels. +const WALL_SIDES: [&str; 4] = ["center", "inside", "outside", "two_sides"]; +const WALL_LABELS: [&str; 4] = ["Center", "Inside", "Outside", "Two Sides"]; +/// What an extrude's first input picks: profiles or planar faces, or curves too when thin. +const EXTRUDE_ACCEPT: Accept = PROFILES | PLANAR_FACES; +const THIN_ACCEPT: Accept = PROFILES | CURVES; const HOLE_TYPES: [&str; 3] = ["simple", "counterbore", "countersink"]; const HOLE_LABELS: [&str; 3] = ["Simple", "Counterbore", "Countersink"]; @@ -48,6 +54,14 @@ pub enum Kind { start: String, /// Extent type All (through everything) instead of a distance. all: bool, + /// Thin Extrude: a wall along the picked curves (or profiles' outlines). + thin: bool, + /// The wall thickness (inside, with Two Sides). + wall: String, + /// Wall location: an index into [`WALL_SIDES`]. + wall_side: usize, + /// The thickness outside, with Two Sides. + wall2: String, }, Revolve { angle: String, @@ -376,8 +390,12 @@ impl Dialog { to_offset: String::new(), start: String::new(), all: false, + thin: false, + wall: "2 mm".into(), + wall_side: 0, + wall2: "1 mm".into(), }, - vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)], + vec![SelInput::new("Profiles", EXTRUDE_ACCEPT, true)], ), "solid.revolve" => Dialog::new( Kind::Revolve { angle: "360 deg".into(), operation: 0, axis: None }, @@ -1913,7 +1931,40 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); } } - Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => { + Kind::Extrude { + distance, + direction, + operation, + auto_op, + distance2, + taper, + taper_present, + to_offset, + start, + all, + thin, + wall, + wall_side, + wall2, + } => { + row_label(ui, "Thin"); + if ui.checkbox(thin, tr("Thin Extrude")).changed() { + set_thin(&mut d.inputs, *thin); + } + ui.end_row(); + if *thin { + row_label(ui, "Wall Location"); + combo(ui, "ex_wall", &WALL_LABELS, wall_side); + ui.end_row(); + row_label(ui, "Wall Thickness"); + enter |= field(ui, wall); + ui.end_row(); + if *wall_side == 3 { + row_label(ui, "Thickness Outside"); + enter |= field(ui, wall2); + ui.end_row(); + } + } row_label(ui, "Start"); let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) }; let mut st = old; @@ -3016,7 +3067,7 @@ pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result let (cmd, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?; if let Some(feature) = app.session.doc.feature(id) { let original_all = match &feature.kind { - FeatureKind::Extrude { sketch, profiles: ProfileSel::All, .. } + FeatureKind::Extrude { sketch, profiles: ProfileSel::All, thin: None, .. } | FeatureKind::Revolve { sketch, profiles: ProfileSel::All, .. } | FeatureKind::Emboss { sketch, profiles: ProfileSel::All, .. } => Some(*sketch), _ => None, @@ -3093,8 +3144,23 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect() }; let (cmd, params): (&str, Value) = match &d.kind { - Kind::Extrude { distance, direction, operation, distance2, taper, taper_present, to_offset, start, all, .. } => { - need(0, "profiles or a planar face")?; + Kind::Extrude { + distance, + direction, + operation, + distance2, + taper, + taper_present, + to_offset, + start, + all, + thin, + wall, + wall_side, + wall2, + .. + } => { + need(0, if *thin { "curves or profiles" } else { "profiles or a planar face" })?; let mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}); let tapered = s.doc.eval(taper, ValueKind::Angle).map(|v| v.abs() > 1e-12).unwrap_or(!taper.trim().is_empty()); if *direction == TWO_SIDES { @@ -3127,6 +3193,37 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } p }; + if *thin { + // The picked curves, and the outlines (outer and holes) of picked profiles. + let st = s.world_state(); + let mut curves = curve_ids(d, 0); + let mut sketch = None; + for x in sels(d, 0) { + if let Sel::Profile { sketch: id, index } = x + && let Some(p) = st.sketch(*id).and_then(|ss| ss.profiles.get(*index)) + { + sketch = Some(*id); + curves.extend(p.curve_ids()); + } + } + let mut seen = std::collections::BTreeSet::new(); + curves.retain(|c| seen.insert(c.clone())); + // A picked profile's sketch, or the edited extrude's, when it has every curve. + let edited = d.editing.and_then(|(id, _)| s.doc.feature(id)).and_then(|f| match f.kind { + FeatureKind::Extrude { sketch, .. } => Some(sketch), + _ => None, + }); + let has_all = |id: u64| curves.iter().all(|c| st.sketch(id).is_some_and(|ss| ss.sketch.curve_index(c).is_some())); + let sketch = match sketch.or(edited) { + Some(id) if has_all(id) => id, + _ => curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?, + }; + let mut wall_params = json!({"sketch": sketch, "thin": true, "curves": curves, "thickness": wall, "side": WALL_SIDES.get(*wall_side).copied().unwrap_or("center")}); + if *wall_side == 3 { + wall_params["thickness2"] = json!(wall2); + } + return Ok(vec![("solid.extrude".to_string(), with(wall_params))]); + } let mut out = Vec::new(); let (sketch, idx) = profiles(); if !sketch.is_null() { @@ -3660,6 +3757,21 @@ fn box_corner(s: &Session, fields: &[(&'static str, String)], p: Vec3, normal: O json!([coord(p.x, size("length"), Vec3::X), coord(p.y, size("width"), Vec3::Y), coord(p.z, size("height"), Vec3::Z)]) } +/// Switch an extrude's first input between profiles and faces, and (thin) profiles and curves; +/// picks the other kind doesn't take are dropped. +fn set_thin(inputs: &mut [SelInput], thin: bool) { + if let Some(input) = inputs.first_mut() { + input.accept = if thin { THIN_ACCEPT } else { EXTRUDE_ACCEPT }; + input.label = if thin { "Profiles/Curves" } else { "Profiles" }; + input.items.retain(|x| match x { + Sel::Profile { .. } => true, + Sel::SketchCurve { .. } => thin, + Sel::Face { .. } => !thin, + _ => false, + }); + } +} + /// Sketch curve ids picked in an input. fn curve_ids(d: &Dialog, i: usize) -> Vec { sels(d, i).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect() @@ -3912,7 +4024,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => { let mut d = start("solid.extrude")?; d.kind = Kind::Extrude { distance: extent.distance.clone(), @@ -3930,6 +4042,10 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option) -> Option u64 { + h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,20]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.session.doc.find_feature("Base").unwrap().id +} + +fn volume(h: &Harness) -> f64 { + h.app.session.world_state().bodies.iter().map(|b| solidcraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum() +} + +#[test] +fn a_thin_extrude_of_a_picked_profile_follows_its_outline() { + let mut h = Harness::new(); + let sketch = rectangle(&mut h); + h.step(&json!({"call":"ui.start","params":{"command":"solid.extrude"}})).unwrap(); + h.frames(3); + h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::Profile { sketch, index: 0 }]; + h.step(&json!({"widget":"Thin Extrude"})).unwrap(); + h.frames(3); + assert!(h.widget("Wall Thickness").is_some(), "the wall rows show once Thin is on"); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log); + let f = h.app.session.doc.features.last().unwrap(); + let FeatureKind::Extrude { thin: Some(thin), .. } = &f.kind else { panic!("a thin extrude: {:?}", f.kind) }; + assert_eq!(thin.curves.len(), 4); + assert_eq!(thin.side, ThinSide::Center); + // A 2 mm wall centred on the 40 × 20 outline, 10 mm high. + assert!((volume(&h) - (42.0 * 22.0 - 38.0 * 18.0) * 10.0).abs() < 1e-6, "{}", volume(&h)); +} + +#[test] +fn editing_a_thin_extrude_keeps_its_wall() { + let mut h = Harness::new(); + rectangle(&mut h); + h.app + .run( + "solid.extrude", + json!({"sketch":"Base","thin":true,"thickness":"3 mm","side":"two_sides","thickness2":"1 mm","distance":"5 mm","name":"Wall"}), + ) + .unwrap(); + h.frames(4); + let before = h.app.session.doc.find_feature("Wall").unwrap().clone(); + let v = volume(&h); + h.step(&json!({"call":"ui.editFeature","params":{"feature":"Wall"}})).unwrap(); + h.frames(8); + assert!(h.widget("Thickness Outside").is_some()); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log); + let after = h.app.session.doc.find_feature("Wall").unwrap(); + assert_eq!(after.kind, before.kind); + assert_eq!(after.param_names, before.param_names); + assert!((volume(&h) - v).abs() < 1e-9); +} + +#[test] +fn the_thin_rows_are_translated() { + for (lang, thin, location, outside) in + [("ja", "薄肉押し出し", "壁の位置", "外側の厚さ"), ("de", "Dünnwandig extrudieren", "Wandlage", "Dicke außen")] + { + let mut h = Harness::new(); + h.app.preferences.language = lang.into(); + rectangle(&mut h); + h.app + .run( + "solid.extrude", + json!({"sketch":"Base","thin":true,"thickness":"3 mm","side":"two_sides","thickness2":"1 mm","distance":"5 mm","name":"Wall"}), + ) + .unwrap(); + h.step(&json!({"call":"ui.editFeature","params":{"feature":"Wall"}})).unwrap(); + h.frames(8); + for word in [thin, location, outside] { + assert!(h.widget(word).is_some(), "{lang}: {word:?} must be on screen"); + } + assert!(h.widget("Wall Location").is_none(), "{lang}: must not fall back to English"); + } +} diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index e7b671a4..7a82dcb2 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -7,7 +7,7 @@ it. | Panel | Features | |---|---| -| Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces or at sketch points), thread, rectangular/circular/path patterns, mirror | +| Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect; thin walls along curves), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces or at sketch points), thread, rectangular/circular/path patterns, mirror | | Modify | press pull, fillet, chamfer, shell, draft, scale, combine, split body, offset face, replace face, move/copy, align, remove | | Construct | offset plane, plane at angle, plane along path | | Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass | @@ -23,6 +23,19 @@ curves move in or out together, and a flat tip shorter than the taper closes to taper steeper than the profile's details (a stroke, a curve or a gap narrower than it) is refused with that reason; a smaller taper or distance builds. +**Thin Extrude** (`thin: true` on `solid.extrude`, the Thin Extrude box in its dialog) builds a +wall of constant thickness along sketch curves instead of filling profiles: open chains (a single +wall) and closed loops (a ring), lines, arcs, circles, ellipses and splines. In the dialog, a +picked profile stands for the curves around it (its outline and its holes). The wall lies on the curves' centre, inside, +outside, or on both sides with two thicknesses (`side`: `center`, `inside`, `outside`, +`two_sides` with `thickness2`). A closed loop's inside is the area it encloses; an open chain's +is the side it bends toward (the inside of a U), and a straight line's is the left of its first +curve as drawn. Curves that meet end to end are one chain whose corners are mitred; walls that +cross or meet (a ring and a cross, a honeycomb) become one piece. Every extent, operation and target of an extrude applies, and a taper +for walls of straight lines or of whole circles. A wall thicker than the +radius of a curve it follows on the inside is refused. Spline walls follow the exact offset +within 0.0001 mm. + Editing an extrusion retains its signed distances, From/To Object inputs, taper expressions and explicit body targets. Revolve edits retain world, sketch and explicit-line axes, both angle expressions and To Object targets. An unchanged **All profiles** selection continues @@ -124,7 +137,7 @@ still be added through `set`. The fields of the common features: | Feature | Fields | |---|---| -| Extrude | `extent`: {`distance`, `direction`: positive\|negative\|symmetric, `distance2`, `start_offset`, `through_all`, `taper`, `to`, `to_offset`, `from`}; `operation`: new_body\|join\|cut\|intersect; `targets`; `profiles`; `sketch` | +| Extrude | `extent`: {`distance`, `direction`: positive\|negative\|symmetric, `distance2`, `start_offset`, `through_all`, `taper`, `to`, `to_offset`, `from`}; `operation`: new_body\|join\|cut\|intersect; `targets`; `profiles`; `sketch`; `thin`: {`curves`, `thickness`, `side`: center\|inside\|outside\|two_sides, `thickness2`} | | Revolve | `angle`, `angle2`, `symmetric`, `axis`, `to`, `operation`, `targets`, `profiles`, `sketch` | | Fillet | `radius`, `edges` (a point on each edge), `body`, `style` | | Chamfer | `distance`, `distance2` or `angle`, `flip`, `edges`, `body` | diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 71d3647c..0bdf6c6e 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -73,7 +73,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio The default `cache: false` omits the portable thumbnail too. The repository's `docs/design-container.md` describes limits and encoding details. -Current designs use format 19, including editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis +Current designs use format 20, including thin extrudes, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis links, persistent names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and association profile references, durable sketch face anchors, periodic fit splines and versioned diff --git a/docs/design-container.md b/docs/design-container.md index d3d96d5a..28d621b3 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract; `io::write_design_container` is the fallible container writer. Packages have `container.json` with `{"format":"solidcraft-container/1"}` and -`document.json` with the versioned parametric document (currently `solidcraft/19`). +`document.json` with the versioned parametric document (currently `solidcraft/20`). The independent versions let document migrations evolve without changing blob encodings. Existing serialized document fields are retained automatically, without a second copy of the document schema or a deep clone of embedded payloads. diff --git a/docs/oracle.md b/docs/oracle.md index 93d9a74a..88241c35 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -9,7 +9,7 @@ Where the expected values come from (the **Source** column): No expected value comes from a commercial CAD product. -**81 / 81 cases pass**; sources: 60 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet. +**82 / 82 cases pass**; sources: 61 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet. | Case | Source | Result | Surface distance mm (max / mean) | Method | Notes | |---|---|---|---|---|---| @@ -54,7 +54,7 @@ No expected value comes from a commercial CAD product. | 39-tee-pipe | freecad 1.1.4 | pass | 0.008 / 0.0014 (worst at -9.7, 2.4, 40.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | | | 40-box-sphere-torus | analytic | pass | | box 60x60x10 (z -10..0) + upper hemisphere r12 + spherical cap h2 below z=-10 (V = pi h^2(3R-h)/3, A = 2 pi R h) - lower half torus R22 r4 (Pappus: V = pi r^2/2 * 2 pi R, centroid -4r/3pi, A = pi r * 2 pi R); top loses disc r12 and annulus 18..26, bottom loses disc r sqrt(44) | faces: bottom, 4 sides, top split by the groove into an annulus (r 12..18) and the outer part, upper sphere, lower cap, torus | | 41-tube-radial-holes | freecad 1.1.4 | pass | 0.010 / 0.0012 (worst at 1.9, 19.9, 24.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied) | | -| 42-overlapping-patterns | freecad 1.1.4 | pass | 0.030 / 0.0010 (worst at -20.0, -0.0, 5.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied), Part extrude cut, pattern (tools transformed and re-applied) | | +| 42-overlapping-patterns | freecad 1.1.4 | pass | 0.009 / 0.0010 (worst at -1.0, -10.1, 2.2) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied), Part extrude cut, pattern (tools transformed and re-applied) | | | 43-cut-splits-body | analytic | pass | | two boxes 35x20x20 (x 0..35 and 45..80): V = 2abc, A = 2*2(ab+bc+ca) | | | 44-shell-l-bracket | analytic | pass | | L prism (area 736, perimeter 200) x 20 minus cavity = L shrunk by 2 (SolidCraft's intersection-join shell convention: inner faces offset by 2 and extended to meet, sharp inner corner at (6,6); area 352) from z=2 to 20. A rounded (arc) join would give V 8368.5487, A 8768.5487, 16 faces | | | 45-shell-boss-plate | analytic | pass | | plate 60x60x10 + boss r12 h20 minus cavity (box 56x56x8 + cylinder r10 z8..28) under SolidCraft's intersection-join shell convention (offset faces extended to meet: sharp circular inner edge at r10, z8). A rounded (arc) join would give V 13620.2567, A 14104.4455, 16 faces (torus round r2) | | @@ -71,13 +71,13 @@ No expected value comes from a commercial CAD product. | 56-holes-on-curved-face | freecad 1.1.4 | pass | 0.009 / 0.0018 (worst at -16.9, 18.5, 0.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, hole tool (cylinder/cone) cut | | | 57-hole-pattern-sketch-points | analytic | pass | | plate 80x50x10 minus 6 counterbored through holes (cb d10 x 5.5, hole d5.5) at x 15/40/65, y 15/35 | | | 58-phone-stand | freecad 1.1.4 | pass | 0.007 / 0.0010 (worst at 82.5, 11.7, 6.1) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet, Part extrude cut | | -| 59-enclosure-base | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at 90.9, 51.0, 6.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet, makeThickness shell, Part extrude join, Part extrude cut | | +| 59-enclosure-base | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at 90.9, 53.0, 6.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet, makeThickness shell, Part extrude join, Part extrude cut | | | 60-pipe-flange | freecad 1.1.4 | pass | | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part revolve, Part extrude cut, pattern (tools transformed and re-applied), Part makeChamfer, Part makeFillet | No reference.stl: over 409600 bytes even at 0.02 mm deflection (11380 facets). | -| 61-shaft-keyway | freecad 1.1.4 | pass | 0.008 / 0.0019 (worst at 11.9, 3.9, 45.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part revolve, Part makeChamfer, Part extrude cut | | +| 61-shaft-keyway | freecad 1.1.4 | pass | 0.008 / 0.0019 (worst at 12.2, 2.8, 72.1) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part revolve, Part makeChamfer, Part extrude cut | | | 62-heat-sink | analytic | pass | | base 60x50x5 + 15 fins 1.5x50x25 at x = 2 + 4k: sums of boxes; fin ends coplanar with the base ends | faces: bottom, x=0, x=60, y=0 and y=50 (each merged with the fin ends), 16 base-top strips, 15x3 fin faces | | 63-hinge-knuckle | freecad 1.1.4 | pass | 0.008 / 0.0016 (worst at 28.0, 36.9, 4.7) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | | | 64-sketch-tangent-arc-chain | analytic | pass | | solved sketch: 80x20 rectangle whose top is 4 tangent R12 arcs on 20 mm chords alternating up/down (net area 1600); prism h3: V = A h, area = 2A + perimeter*h, Green's theorem for centroid | | -| 65-sketch-gear-tooth | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at 6.5, 39.5, 8.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Sketcher-solved sketch, Part extrude | | +| 65-sketch-gear-tooth | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at -6.5, 39.5, 0.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Sketcher-solved sketch, Part extrude | | | 66-sketch-linkage | analytic | pass | | solved sketch: hull of circles R12 at (0,0) and R8 at (60,0) (outer tangent lines, cos a = 4/60) minus holes d12, d8 (concentric) and d4 at (30,+-4) (symmetry keeps radii equal); prism h6 | | | 67-sm-base-flange | analytic | pass | | plate 100 x 60 x t, t = 2.5 (Steel (mm) rule), thickness along +Z: V = 100*60*t, A = 2(ab+bc+ca) | | | 68-sm-edge-flange | analytic | pass | | extrusion along x (100) of the YZ section: base y 5..60 x t; 90 deg bend annulus R 2.5..5 about (y 5, z 5); flange y 0..2.5, z 5..20. Setback (R+t)tan45 = 5 (inside bend position, outer-face height datum: outer face of flange reaches z = 20); V = L*A_sec, A = 2 A_sec + L P_sec | trim/setback and height datum are SolidCraft's documented edge-flange conventions (sheet.rs add_flanges) | @@ -94,6 +94,7 @@ No expected value comes from a commercial CAD product. | 79-fillet-chord-length | analytic | pass | | box 40x30x20, top front edge rounded 4 wide across (chord c): r = c / (2 cos 45 deg) = 2 sqrt 2; V = abc - (1 - pi/4) r^2 L, A = 2(ab + bc + ca) - 2 r L + (pi/2) r L - 2 (1 - pi/4) r^2 | | | 80-fillet-taper-to-fit | analytic | pass | | plate (top 20 -> 2 wide over x 0..40, 15 high) with its top front edge filleted at 3, fit: taper. The radius is the taper law: a clamped cubic B-spline in u = x/40 on 8 equal spans, control radii min(3, 0.9 room) at the Greville abscissae, room = min(20 - 18u, 15) tan 45 deg (top face width, front face height): 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 2.4750, 1.8000. Sections are quarter rounds; V, moments and areas integrated along the edge | | | 81-fillet-spline-radius | analytic | pass | | box 40x30x20 with its top front edge (y=0, z=20) rounded at a radius that is a clamped cubic B-spline in u = x/40, knots 0,0,0,0,0.25,0.6,1,1,1,1, control radii 2, 5, 1.5, 4, 1, 3 (r(0) = 2, r(40) = 3). Sections are quarter rounds; V, moments and areas integrated along the edge, exactly per knot span | | +| 82-thin-extrude-frame | analytic | pass | | square frame: the lines' 40 mm square walled 3 mm inside and 1 mm outside (mitred corners) is 42 x 42 less 34 x 34, 10 mm tall: V = (42^2 - 34^2) * 10, A = 2 (42^2 - 34^2) + 4 * 42 * 10 + 4 * 34 * 10 | | ## Not covered by a reference case diff --git a/tests/oracle/82-thin-extrude-frame/expected.json b/tests/oracle/82-thin-extrude-frame/expected.json new file mode 100644 index 00000000..b7b2e3a6 --- /dev/null +++ b/tests/oracle/82-thin-extrude-frame/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "square frame: the lines' 40 mm square walled 3 mm inside and 1 mm outside (mitred corners) is 42 x 42 less 34 x 34, 10 mm tall: V = (42^2 - 34^2) * 10, A = 2 (42^2 - 34^2) + 4 * 42 * 10 + 4 * 34 * 10", + "body_count": 1, + "volume_mm3": 6080.0, + "area_mm2": 4256.0, + "center_of_mass": [ + 0.0, + 0.0, + 5.0 + ], + "bbox": { + "min": [ + -21.0, + -21.0, + 0.0 + ], + "max": [ + 21.0, + 21.0, + 10.0 + ] + }, + "faces": 10 +} diff --git a/tests/oracle/82-thin-extrude-frame/recipe.json b/tests/oracle/82-thin-extrude-frame/recipe.json new file mode 100644 index 00000000..f6215ad3 --- /dev/null +++ b/tests/oracle/82-thin-extrude-frame/recipe.json @@ -0,0 +1,149 @@ +{ + "id": "82-thin-extrude-frame", + "name": "Thin extrude frame", + "description": "40x40 square of lines centred on origin; a thin wall 3 mm inside and 1 mm outside the lines, extruded 10 mm.", + "exercises": [ + "extrude", + "thin" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Sketch1", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -20, + -20 + ] + }, + "end": { + "at": [ + 20, + -20 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 20.0, + -20.0 + ] + }, + "end": { + "at": [ + 20, + 20 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 20.0, + 20.0 + ] + }, + "end": { + "at": [ + -20, + 20 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -20.0, + 20.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -20.0, + -20.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Frame", + "operation": "new", + "extent": { + "type": "distance", + "distance": 10, + "direction": "positive" + }, + "result_bodies": [ + "Frame" + ], + "thin": { + "sketch": "Sketch1", + "curves": [ + "l1", + "l2", + "l3", + "l4" + ], + "thickness": 3, + "side": "two_sides", + "thickness2": 1 + } + } + ] +} diff --git a/tools/oracle-analytic/README.md b/tools/oracle-analytic/README.md index e984bb50..b4d1526a 100644 --- a/tools/oracle-analytic/README.md +++ b/tools/oracle-analytic/README.md @@ -8,7 +8,7 @@ plain Python 3 (standard library only) with one function per case. Running a scr `expected.json` of its cases with `"source": "analytic"` and a `method` string giving the formulas: ```sh -python3 tools/oracle-analytic/cases_basic.py # 01-29: boxes, holes, revolves, sketches +python3 tools/oracle-analytic/cases_basic.py # 01-29, 82: boxes, holes, revolves, sketches, thin walls python3 tools/oracle-analytic/cases_features.py # 32-66: fillets, shells, sweeps, patterns python3 tools/oracle-analytic/cases_sheet_metal.py # 67-76: sheet metal python3 tools/oracle-analytic/cases_fillets.py # 77-81: variable, chord-length, tapered and spline fillets diff --git a/tools/oracle-analytic/cases_basic.py b/tools/oracle-analytic/cases_basic.py index aefb6a5f..23d34326 100644 --- a/tools/oracle-analytic/cases_basic.py +++ b/tools/oracle-analytic/cases_basic.py @@ -515,6 +515,16 @@ def c29_two_sided_partial_revolve(): 1, mp.v, A, mp.com(), [-20, -25, 0], [20, 10, 20], 12) +def c82_thin_extrude_frame(): + a, t_in, t_out, h = 40.0, 3.0, 1.0, 10.0 + outer, inner = a + 2 * t_out, a - 2 * t_in + ring = outer * outer - inner * inner + return result("square frame: the lines' 40 mm square walled 3 mm inside and 1 mm outside (mitred corners) is " + "42 x 42 less 34 x 34, 10 mm tall: V = (42^2 - 34^2) * 10, A = 2 (42^2 - 34^2) + 4 * 42 * 10 + 4 * 34 * 10", + 1, ring * h, 2 * ring + 4 * outer * h + 4 * inner * h, [0, 0, h / 2], [-outer / 2, -outer / 2, 0], + [outer / 2, outer / 2, h], 10) + + CASES = { "01-box": c01_box, "02-cylinder": c02_cylinder, @@ -542,6 +552,7 @@ CASES = { "26-sketch-belt": c26_sketch_belt, "27-sketch-trapezoid": c27_sketch_trapezoid, "29-two-sided-and-partial-revolve": c29_two_sided_partial_revolve, + "82-thin-extrude-frame": c82_thin_extrude_frame, }