From aa30575369aa7c8fb7081112179858321e12da85 Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Sat, 25 Jul 2026 04:15:31 +0200 Subject: [PATCH] =?UTF-8?q?M5c:=20pattern-on-curve=20=E2=80=94=20replicate?= =?UTF-8?q?=20a=20body=20along=20a=20sketch=20curve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend CadFeatureType::Pattern (no new enum) with a curve mode: when pattern_curve_sketch >= 0 it takes precedence over linear/circular. The guide entity is sampled at equal-parameter points via a file-local sample_entity_2d() (Line lerp, Arc angle-lerp, cubic-BSpline Bernstein, p0->p1 fallback), and each seed copy is translated by (P_i - P_0) and fused. Two serialized fields (pattern_curve_sketch/pattern_curve_entity) appended to both symmetric cereal lists (version stays 2, golden fixture regenerated 30269->30517). MCP: pattern_on_curve. 3 new [CadDocument][pattern] tests; suite 89/1395. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q --- src/libslic3r/CadDocument.cpp | 73 +++++++++++++++++++++ src/libslic3r/CadDocument.hpp | 15 ++++- src/slic3r/GUI/McpControl.cpp | 27 ++++++++ tests/data/cad_recipe_v2.bin | Bin 30269 -> 30517 bytes tests/libslic3r/test_caddocument.cpp | 92 +++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 0eefc5f765..305aa3fd1b 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -162,6 +162,35 @@ static TopoDS_Wire make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir, // --------------------------------------------------------------------------- +// Sample a sketch entity at parameter t in [0,1]. Handles Line, Arc, BSpline (cubic, 4 poles). +// Falls back to a p0->p1 lerp for any other type. // ponytail: covers the entities a guide +// curve is realistically drawn with; extend if needed. +static Vec2d sample_entity_2d(const SketchEntity& e, double t) +{ + t = std::max(0.0, std::min(1.0, t)); + switch (e.type) { + case SketchEntity::Type::Line: + return e.p0 + (e.p1 - e.p0) * t; + case SketchEntity::Type::Arc: { + double theta = e.start_angle + (e.end_angle - e.start_angle) * t; + return e.center + e.radius * Vec2d(std::cos(theta), std::sin(theta)); + } + case SketchEntity::Type::BSpline: + if (e.ctrl.size() == 4) { + const double u = 1.0 - t; + const double b0 = u * u * u; + const double b1 = 3.0 * u * u * t; + const double b2 = 3.0 * u * t * t; + const double b3 = t * t * t; + return e.ctrl[0] * b0 + e.ctrl[1] * b1 + e.ctrl[2] * b2 + e.ctrl[3] * b3; + } + return e.ctrl.empty() ? e.p0 + (e.p1 - e.p0) * t + : e.ctrl.front() + (e.ctrl.back() - e.ctrl.front()) * t; + default: + return e.p0 + (e.p1 - e.p0) * t; + } +} + int CadDocument::add_sketch(SketchShape shape, const SketchPlane& plane, double width, double height, double radius, const std::string& name) @@ -736,6 +765,21 @@ int CadDocument::add_pattern(bool circular, int count, double spacing, int dir, return int(features.size()) - 1; } +int CadDocument::add_pattern_on_curve(int count, int curve_sketch, int curve_entity, + int target, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Pattern; + f.name = name; + f.pattern_count = count; + f.pattern_curve_sketch = curve_sketch; + f.pattern_curve_entity = curve_entity; + f.target_body = target; + f.pattern_circular = false; + features.push_back(f); + return int(features.size()) - 1; +} + int CadDocument::add_shell(double thickness, int face, int target_body, const std::string& name) { CadFeature f; @@ -1771,6 +1815,35 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, break; } case CadFeatureType::Pattern: { + // Pattern along a curve: when pattern_curve_sketch >= 0 this mode takes + // precedence over linear/circular. Copies are placed at equal-parameter points + // along the referenced sketch entity, translated by (P_i - P_0). + if (f.pattern_curve_sketch >= 0) { + if (f.pattern_curve_sketch >= (int)features.size()) + throw std::runtime_error("pattern-on-curve: bad sketch ref"); + const CadFeature& gs = features[f.pattern_curve_sketch]; + if (gs.type != CadFeatureType::Sketch) + throw std::runtime_error("pattern-on-curve: ref is not a sketch"); + if (f.pattern_curve_entity < 0 || f.pattern_curve_entity >= (int)gs.entities.size()) + throw std::runtime_error("pattern-on-curve: bad entity"); + if (!have_body) throw std::runtime_error("pattern needs a body"); + const SketchEntity& gc = gs.entities[f.pattern_curve_entity]; + const int n = std::max(1, f.pattern_count); + const TopoDS_Shape seed = result; + Vec3d p0 = gs.plane.to_world(sample_entity_2d(gc, 0.0)); + for (int i = 1; i < n; ++i) { + double t = double(i) / double(n - 1 > 0 ? n - 1 : 1); + Vec3d pi = gs.plane.to_world(sample_entity_2d(gc, t)); + Vec3d d = pi - p0; + gp_Trsf trsf; + trsf.SetTranslation(gp_Vec(d.x(), d.y(), d.z())); + TopoDS_Shape copy = BRepBuilderAPI_Transform(seed, trsf, true).Shape(); + BRepAlgoAPI_Fuse fuse(result, copy); + if (!fuse.IsDone()) throw std::runtime_error("pattern fuse failed"); + result = fuse.Shape(); + } + break; + } // Replicate the target body. Each copy is a rigid gp_Trsf of the seed, all // fused into one body. Linear: i*spacing along plane axis pattern_dir // (0=X,1=Y). Circular: i*(angle/count) about the plane normal through the diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index 009592a82b..b53a896c83 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -173,6 +173,12 @@ struct CadFeature { int pattern_dir{0}; // linear direction: 0 = plane X, 1 = plane Y double pattern_angle{360}; // circular total angle (degrees) + // Pattern along a curve: when pattern_curve_sketch >= 0 this mode takes precedence over + // linear/circular. Copies are placed at equal-parameter points along entity + // pattern_curve_entity of sketch pattern_curve_sketch, translated by (P_i - P_0). + int pattern_curve_sketch{-1}; // feature index of the Sketch holding the guide curve + int pattern_curve_entity{-1}; // entity index of the guide curve within that sketch + // Datum/reference plane: a derived SketchPlane the document offers as a selectable // sketch plane (no solid). plane_base selects the reference (0=XY,1=XZ,2=YZ, or 3+N // = the Nth earlier datum plane); plane_offset shifts along the base normal; @@ -315,7 +321,8 @@ struct CadFeature { delete_faces, hole_style, hole_cbore_diameter, hole_cbore_depth, hole_csink_diameter, hole_csink_angle, hole_standard, - rib_sketch_ref, rib_entity, rib_thickness, rib_depth); + rib_sketch_ref, rib_entity, rib_thickness, rib_depth, + pattern_curve_sketch, pattern_curve_entity); } template void load(Archive& ar) { @@ -350,7 +357,8 @@ struct CadFeature { delete_faces, hole_style, hole_cbore_diameter, hole_cbore_depth, hole_csink_diameter, hole_csink_angle, hole_standard, - rib_sketch_ref, rib_entity, rib_thickness, rib_depth); + rib_sketch_ref, rib_entity, rib_thickness, rib_depth, + pattern_curve_sketch, pattern_curve_entity); imported_solid = brep_from_string(brep); } }; @@ -451,6 +459,9 @@ public: // Sweep the profile Sketch (profile_sketch_ref) along the path Sketch (path_sketch_ref). int add_pattern(bool circular, int count, double spacing, int dir, double angle_deg, int target_body, const std::string& name); + // Pattern `count` copies of `target` along entity `curve_entity` of sketch `curve_sketch`. + int add_pattern_on_curve(int count, int curve_sketch, int curve_entity, int target, + const std::string& name); int add_sweep(int profile_sketch_ref, int path_sketch_ref, BooleanMode mode, const std::string& name); // Loft through the ordered profile Sketches (each a closed wire on its own plane). diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index 1391be1a0a..cbcded3822 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -198,6 +198,13 @@ json describe_tools() json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, })}}, + json{{"name", "pattern_on_curve"}, {"summary", "Replicate a body along a sketch curve: `count` copies placed at equal-parameter points on the entity, each translated by (P_i - P_0)."}, + {"params", json::array({ + json{{"name", "count"}, {"type", "integer"}, {"default", 3}, {"min", 1}}, + json{{"name", "sketch"}, {"type", "integer"}, {"description", "feature index of the sketch holding the guide curve"}}, + json{{"name", "entity"}, {"type", "integer"}, {"description", "entity index of the guide curve within that sketch"}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + })}}, json{{"name", "shell"}, {"summary", "Hollow a body to a wall thickness (inward); optionally leave one face open."}, {"params", json::array({ json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}}, @@ -938,6 +945,25 @@ json action_pattern(DesignPanel* panel, const json& params) return json{{"ok", ok}, {"pattern_index", p}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } +json action_pattern_on_curve(DesignPanel* panel, const json& params) +{ + const int count = params.value("count", 3); + if (count < 1) throw std::runtime_error("count must be >= 1"); + if (!params.contains("sketch")) throw std::runtime_error("pattern_on_curve needs 'sketch' (feature index)"); + if (!params.contains("entity")) throw std::runtime_error("pattern_on_curve needs 'entity' (entity index)"); + const int sketch = params["sketch"].get(); + const int entity = params["entity"].get(); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to pattern"); + int bi = target_body_arg(params, doc); + doc.checkpoint(); + int p = doc.add_pattern_on_curve(count, sketch, entity, bi, "PatternOnCurve"); + bool ok = doc.recompute(); + if (!ok) doc.undo(); + panel->mcp_after_change(); + return json{{"ok", ok}, {"pattern_index", p}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + json action_shell(DesignPanel* panel, const json& params) { const double thickness = params.value("thickness", 1.0); @@ -1210,6 +1236,7 @@ std::string handle_on_main(const std::string& method, const json& params, const if (method == "hole_standard") return rpc_result(id, action_hole_standard(panel, params)); if (method == "boolean") return rpc_result(id, action_boolean(panel, params)); if (method == "pattern") return rpc_result(id, action_pattern(panel, params)); + if (method == "pattern_on_curve") return rpc_result(id, action_pattern_on_curve(panel, params)); if (method == "shell") return rpc_result(id, action_shell(panel, params)); if (method == "rib") return rpc_result(id, action_rib(panel, params)); if (method == "draft") return rpc_result(id, action_draft(panel, params)); diff --git a/tests/data/cad_recipe_v2.bin b/tests/data/cad_recipe_v2.bin index 472bfaeecaf6ced634f8fcff970fe8f89c85bff0..4c7a9654226ac36b30645dec6ec73666bd989e42 100644 GIT binary patch delta 438 zcmdn{hH>jV#tlc9IsQYz<|E8Ik$AQ2$UHM{WZrwe?MNz@32jBDa*-Q)7T}b*qX~?i3T*jx1tn#>lJd)fq6J#E%4ORY@a~X zIXQl3-t6eQpLsKf=MIR_86TiQZvuc;tqIx2yg4gk3-e~R7@(z3E~? S-mIAoRPr(pYW@16Iu-y-o=W8a diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 907783749a..f4daeebae9 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -1125,6 +1125,98 @@ TEST_CASE("pattern replicates a body linearly and circularly", "[CadDocument]") } } +TEST_CASE("pattern-on-curve: copies land on a line and bbox spans the curve length", "[CadDocument][pattern]") +{ + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + using namespace Slic3r; + + CadDocument doc; + + // Seed body: 4x4x4 box at the origin via sketch+extrude. + int s0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 4, 4, 0, "Seed"); + doc.add_extrude(s0, 4.0, false, BooleanMode::New, "E"); + + // Guide sketch: one Line entity from (0,0) to (30,0) on XY. + std::vector guide = { + {SketchEntity::Type::Line, Vec2d(0, 0), Vec2d(30, 0)}, + }; + int gs = doc.add_sketch_entities(guide, SketchPlane::XY(), "Guide"); + + doc.add_pattern_on_curve(4, gs, 0, 0, "OnCurve"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + auto bb = doc.display_mesh.bounding_box(); + double x_extent = bb.max.x() - bb.min.x(); + // 4 copies: at x=0, x=10, x=20, x=30. The seed is a 4x4 box centred at origin, + // so the overall X span is from -2 to 32 = 34 mm. + REQUIRE_THAT(x_extent, WithinAbs(34.0, 2.0)); +} + +TEST_CASE("pattern-on-curve: bad refs are safe", "[CadDocument][pattern]") +{ + using namespace Slic3r; + + CadDocument doc; + int s0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 4, 4, 0, "Seed"); + doc.add_extrude(s0, 4.0, false, BooleanMode::New, "E"); + + // Bad sketch ref -> error. + doc.add_pattern_on_curve(3, 999, 0, 0, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_FALSE(doc.error.empty()); + REQUIRE(doc.error.find("pattern") != std::string::npos); +} + +TEST_CASE("pattern-on-curve: round-trip through serialize/deserialize", "[CadDocument][pattern]") +{ + using Catch::Matchers::WithinRel; + using namespace Slic3r; + + CadDocument doc; + int s0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 4, 4, 0, "Seed"); + doc.add_extrude(s0, 4.0, false, BooleanMode::New, "E"); + + std::vector guide = { + {SketchEntity::Type::Line, Vec2d(0, 0), Vec2d(30, 0)}, + }; + int gs = doc.add_sketch_entities(guide, SketchPlane::XY(), "Guide"); + + doc.add_pattern_on_curve(4, gs, 0, 0, "OnCurve"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto orig_bb = doc.display_mesh.bounding_box(); + size_t orig_n = doc.bodies.size(); + + std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.recompute()); + REQUIRE(fresh.error.empty()); + REQUIRE(fresh.bodies.size() == orig_n); + + auto fresh_bb = fresh.display_mesh.bounding_box(); + REQUIRE_THAT(orig_bb.min.x(), WithinRel(fresh_bb.min.x(), 1e-6)); + REQUIRE_THAT(orig_bb.max.x(), WithinRel(fresh_bb.max.x(), 1e-6)); + + // Verify the deserialized field values. + bool found = false; + for (const auto& f : fresh.features) { + if (f.name == "OnCurve") { + REQUIRE(f.pattern_curve_sketch == gs); + REQUIRE(f.pattern_curve_entity == 0); + found = true; + break; + } + } + REQUIRE(found); +} + TEST_CASE("thread standards table carries correct ISO/UTS measures", "[CadDocument]") { using namespace Slic3r;