diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 305aa3fd1b..14286ab56b 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -160,6 +161,273 @@ static TopoDS_Wire make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir, return poly.Wire(); // closed triangle, swept by MakePipeShell with a fixed binormal } +// ---- expression evaluator (file-local) ------------------------------------- +// ponytail: self-contained shunting-yard arithmetic + function set for +// parametric dimension expressions. Extend the function table if needed. + +// Evaluate an arithmetic expression to a double. Supports + - * /, parentheses, +// unary minus, decimal literals, and identifiers resolved via `vars`. Functions: +// sqrt, abs, sin, cos, tan (degrees), min, max, and the constant `pi`. +// Throws std::runtime_error on any parse or lookup error, div-by-zero, bad arity. +static double eval_expr(const std::string& src, const std::map& vars) +{ + struct Token { + enum Type { Num, Id, Op, LParen, RParen, Comma }; + Type type; + std::string val; + double num{0}; + }; + // precedence: 0=paren/comma, 1=+-, 2=*/, 3=unary-minus, 4=function + auto prec = [](char op, bool unary) -> int { + if (unary && op == 'u') return 3; + if (op == '+' || op == '-') return 1; + if (op == '*' || op == '/') return 2; + if (op == '(' || op == ')' || op == ',') return 0; + return 0; + }; + auto is_op = [](char c) { return c == '+' || c == '-' || c == '*' || c == '/'; }; + + // ---- tokenise ---- + std::vector tokens; + size_t i = 0; + bool prev_was_val = false; // tracked for unary-minus detection + while (i < src.size()) { + char c = src[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { ++i; continue; } + if (c == '(') { tokens.push_back({Token::LParen, "("}); ++i; prev_was_val = false; continue; } + if (c == ')') { tokens.push_back({Token::RParen, ")"}); ++i; prev_was_val = true; continue; } + if (c == ',') { tokens.push_back({Token::Comma, ","}); ++i; prev_was_val = false; continue; } + if (isdigit(c) || c == '.') { + size_t start = i; + while (i < src.size() && (isdigit(src[i]) || src[i] == '.')) ++i; + Token t{Token::Num, src.substr(start, i - start)}; + t.num = std::stod(t.val); + tokens.push_back(t); + prev_was_val = true; + continue; + } + if (isalpha(c) || c == '_') { + size_t start = i; + while (i < src.size() && (isalnum(src[i]) || src[i] == '_')) ++i; + std::string id = src.substr(start, i - start); + tokens.push_back({Token::Id, id}); + prev_was_val = true; + continue; + } + if (is_op(c)) { + // unary minus detection + if (c == '-' && !prev_was_val) { + tokens.push_back({Token::Op, "u"}); + } else { + tokens.push_back({Token::Op, std::string(1, c)}); + } + ++i; + prev_was_val = false; + continue; + } + throw std::runtime_error("bad character in expression"); + } + + // ---- shunting-yard: infix -> RPN ---- + std::vector rpn; + std::vector stack; + auto flush_paren = [&]() { + while (!stack.empty() && stack.back().type != Token::LParen) { + rpn.push_back(stack.back()); stack.pop_back(); + } + if (stack.empty()) throw std::runtime_error("mismatched parentheses"); + stack.pop_back(); // discard '(' +#if 0 + // If the '(' belonged to a function call, push the function name + // (ponytail: we track this via the Id token on top of the op stack + // BEFORE the '(' was pushed; after flush we check if the previous + // token was a function-call Id). +#endif + if (!stack.empty() && stack.back().type == Token::Id) { + rpn.push_back(stack.back()); stack.pop_back(); + } + }; + + for (size_t j = 0; j < tokens.size(); ++j) { + const Token& t = tokens[j]; + if (t.type == Token::Num) { + rpn.push_back(t); + } else if (t.type == Token::Id) { + // function call if the next token is '(' + if (j + 1 < tokens.size() && tokens[j + 1].type == Token::LParen) { + stack.push_back(t); + } else { + // identifier — resolve now + double v; + if (t.val == "pi") v = M_PI; + else { + auto it = vars.find(t.val); + if (it == vars.end()) + throw std::runtime_error("unknown identifier: " + t.val); + v = it->second; + } + rpn.push_back({Token::Num, "", v}); + } + } else if (t.type == Token::Op) { + int p = prec(t.val[0], t.val == "u"); + while (!stack.empty()) { + const Token& top = stack.back(); + if (top.type != Token::Op && top.type != Token::Id) break; + int tp = prec(top.val[0], top.val == "u"); + if (tp < 4 && p <= tp) { + rpn.push_back(top); stack.pop_back(); + } else break; + } + stack.push_back(t); + } else if (t.type == Token::LParen) { + stack.push_back(t); + } else if (t.type == Token::RParen) { + flush_paren(); + } else if (t.type == Token::Comma) { + while (!stack.empty() && stack.back().type != Token::LParen) { + rpn.push_back(stack.back()); stack.pop_back(); + } + // , is a no-op separator — just stay inside the paren + } + } + while (!stack.empty()) { + if (stack.back().type == Token::LParen || stack.back().type == Token::RParen) + throw std::runtime_error("mismatched parentheses"); + rpn.push_back(stack.back()); stack.pop_back(); + } + + // ---- evaluate RPN ---- + std::vector vs; + for (const Token& t : rpn) { + if (t.type == Token::Num) { + vs.push_back(t.num); + } else if (t.type == Token::Op) { + if (t.val == "u") { + if (vs.empty()) throw std::runtime_error("missing operand for unary minus"); + vs.back() = -vs.back(); + } else { + if (vs.size() < 2) throw std::runtime_error("not enough operands for '" + t.val + "'"); + double b = vs.back(); vs.pop_back(); + double a = vs.back(); vs.pop_back(); + if (t.val == "+") vs.push_back(a + b); + else if (t.val == "-") vs.push_back(a - b); + else if (t.val == "*") vs.push_back(a * b); + else if (t.val == "/") { if (b == 0) throw std::runtime_error("division by zero"); vs.push_back(a / b); } + } + } else if (t.type == Token::Id) { + // function call (already on RPN via shunting-yard) + auto call_fn = [&](const std::string& name, int arity) { + if ((int)vs.size() < arity) + throw std::runtime_error("not enough arguments for " + name + "()"); + if (name == "sqrt") { double a = vs.back(); vs.pop_back(); vs.push_back(std::sqrt(a)); } + else if (name == "abs") { double a = vs.back(); vs.pop_back(); vs.push_back(std::abs(a)); } + else if (name == "sin") { double a = vs.back(); vs.pop_back(); vs.push_back(std::sin(a * M_PI / 180.0)); } + else if (name == "cos") { double a = vs.back(); vs.pop_back(); vs.push_back(std::cos(a * M_PI / 180.0)); } + else if (name == "tan") { double a = vs.back(); vs.pop_back(); vs.push_back(std::tan(a * M_PI / 180.0)); } + else if (name == "min") { double b = vs.back(); vs.pop_back(); double a = vs.back(); vs.pop_back(); vs.push_back(std::min(a, b)); } + else if (name == "max") { double b = vs.back(); vs.pop_back(); double a = vs.back(); vs.pop_back(); vs.push_back(std::max(a, b)); } + else throw std::runtime_error("unknown function: " + name); + }; + call_fn(t.val, (t.val == "min" || t.val == "max") ? 2 : 1); + } + } + if (vs.size() != 1) throw std::runtime_error("invalid expression"); + return vs[0]; +} + +// Topologically evaluate `variables` (name -> expression) into name -> value. +// An expression may reference other variables; resolution is recursive with a +// visiting set. Throws std::runtime_error("variable cycle: ...") on a dependency +// cycle, or propagates eval_expr errors. +static std::map +evaluate_variables(const std::map& variables) +{ + std::map done; + std::set visiting; + + std::function resolve = [&](const std::string& name) -> double { + auto itd = done.find(name); + if (itd != done.end()) return itd->second; + if (visiting.count(name)) throw std::runtime_error("variable cycle: " + name); + auto itv = variables.find(name); + if (itv == variables.end()) throw std::runtime_error("unknown identifier: " + name); + visiting.insert(name); + // pre-resolve every identifier `name` references: scan for identifiers in + // its expression, resolve them first, then eval with the populated map. + // ponytail: simplest correct approach — depth-first with visited tracking. + std::map scope = done; // start with already-resolved + // Add any variable name found in the expression to scope (resolve recursively) + const std::string& expr = itv->second; + for (size_t i = 0; i < expr.size(); ) { + char c = expr[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { ++i; continue; } + if (isalpha(c) || c == '_') { + size_t start = i; + while (i < expr.size() && (isalnum(expr[i]) || expr[i] == '_')) ++i; + std::string id = expr.substr(start, i - start); + // skip "pi" and function names — they're built-ins, not variables + if (id == "pi" || id == "sqrt" || id == "abs" || id == "sin" || id == "cos" || + id == "tan" || id == "min" || id == "max") continue; + if (!variables.count(id)) throw std::runtime_error("unknown identifier: " + id); + scope[id] = resolve(id); + continue; + } + ++i; + } + double val = eval_expr(expr, scope); + visiting.erase(name); + done[name] = val; + return val; + }; + + for (const auto& [k, _] : variables) resolve(k); + return done; +} + +// Write `value` into the CadFeature numeric field named `field`. Allow-list of +// the geometric dimension fields a variable realistically drives. Integer-valued +// fields are rounded. Throws std::runtime_error("unknown parameter: " + field) +// for anything not in the list. +static void assign_field(CadFeature& f, const std::string& field, double value) +{ + // double fields (alphabetical) + if (field == "distance") { f.distance = value; return; } + if (field == "distance2") { f.distance2 = value; return; } + if (field == "draft_angle") { f.draft_angle = value; return; } + if (field == "dressup_size") { f.dressup_size = value; return; } + if (field == "height") { f.height = value; return; } + if (field == "hole_cbore_diameter") { f.hole_cbore_diameter = value; return; } + if (field == "hole_cbore_depth") { f.hole_cbore_depth = value; return; } + if (field == "hole_csink_angle") { f.hole_csink_angle = value; return; } + if (field == "hole_csink_diameter") { f.hole_csink_diameter = value; return; } + if (field == "hole_depth") { f.hole_depth = value; return; } + if (field == "hole_diameter") { f.hole_diameter = value; return; } + if (field == "hole_x") { f.hole_x = value; return; } + if (field == "hole_y") { f.hole_y = value; return; } + if (field == "import_scale_x") { f.import_scale_x = value; return; } + if (field == "import_scale_y") { f.import_scale_y = value; return; } + if (field == "pattern_angle") { f.pattern_angle = value; return; } + if (field == "pattern_spacing") { f.pattern_spacing = value; return; } + if (field == "plane_angle_tilt") { f.plane_angle_tilt = value; return; } + if (field == "plane_offset") { f.plane_offset = value; return; } + if (field == "radius") { f.radius = value; return; } + if (field == "revolve_angle") { f.revolve_angle = value; return; } + if (field == "rib_depth") { f.rib_depth = value; return; } + if (field == "rib_thickness") { f.rib_thickness = value; return; } + if (field == "shell_thickness") { f.shell_thickness = value; return; } + if (field == "taper_deg") { f.taper_deg = value; return; } + if (field == "thread_depth") { f.thread_depth = value; return; } + if (field == "thread_height") { f.thread_height = value; return; } + if (field == "thread_pitch") { f.thread_pitch = value; return; } + if (field == "thread_radius") { f.thread_radius = value; return; } + if (field == "thread_x") { f.thread_x = value; return; } + if (field == "thread_y") { f.thread_y = value; return; } + if (field == "width") { f.width = value; return; } + // int fields (rounded) + if (field == "pattern_count") { f.pattern_count = (int)std::lround(value); return; } + throw std::runtime_error("unknown parameter: " + field); +} + // --------------------------------------------------------------------------- // Sample a sketch entity at parameter t in [0,1]. Handles Line, Arc, BSpline (cubic, 4 poles). @@ -2497,6 +2765,12 @@ bool CadDocument::recompute() error.clear(); std::vector built; try { + // Parametric pass: evaluate document variables, then each feature's expression bindings, + // writing the results into the feature's numeric fields before geometry runs. + std::map varvals = evaluate_variables(variables); + for (CadFeature& f : features) + for (const auto& [field, e] : f.expr) + assign_field(f, field, eval_expr(e, varvals)); for (CadFeature& f : features) { if (!f.enabled) continue; if (f.type == CadFeatureType::Sketch) continue; // consumed by an extrude @@ -2608,6 +2882,7 @@ std::string CadDocument::serialize_recipe() const uint32_t v = SNAPORCA_CAD_RECIPE_VERSION; ar(v); ar(features); + ar(variables); } return oss.str(); } @@ -2632,6 +2907,7 @@ bool CadDocument::deserialize_recipe(const std::string& blob) return false; } ar(features); + ar(variables); return recompute(); } catch (const Standard_Failure& e) { const char* what = e.GetMessageString(); diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index b53a896c83..404ef24111 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include #include #include @@ -179,6 +181,11 @@ struct CadFeature { 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 + // Parametric bindings: field-member-name -> expression string. On recompute() each entry + // is evaluated against the document variables and written into the named numeric field + // BEFORE geometry runs. Empty (the common case) means the feature uses its literal fields. + std::map expr; + // 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; @@ -321,8 +328,9 @@ 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, - pattern_curve_sketch, pattern_curve_entity); + rib_sketch_ref, rib_entity, rib_thickness, rib_depth, + pattern_curve_sketch, pattern_curve_entity, + expr); } template void load(Archive& ar) { @@ -358,7 +366,8 @@ struct CadFeature { 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, - pattern_curve_sketch, pattern_curve_entity); + pattern_curve_sketch, pattern_curve_entity, + expr); imported_solid = brep_from_string(brep); } }; @@ -381,6 +390,9 @@ struct CadBody { class CadDocument { public: std::vector features; + // Named document variables: name -> expression. Evaluated topologically each recompute(); + // an expression may reference other variables. Feature `expr` bindings resolve against these. + std::map variables; // Multi-body result of the last replay. A "New" extrude appends a body; other ops // mutate a target body. Empty after a failed/empty recompute. std::vector bodies; diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index cbcded3822..77608d7a1d 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -1210,6 +1210,42 @@ json action_helix(DesignPanel* panel, const json& params) return json{{"ok", true}, {"helix_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } +json action_set_variable(DesignPanel* panel, const json& params) +{ + if (!params.contains("name")) throw std::runtime_error("set_variable needs 'name'"); + if (!params.contains("expr")) throw std::runtime_error("set_variable needs 'expr'"); + CadDocument& doc = panel->mcp_doc(); + std::string name = params["name"].get(); + std::string expr = params["expr"].get(); + std::string old = doc.variables.count(name) ? doc.variables[name] : ""; + doc.checkpoint(); + doc.variables[name] = expr; + bool ok = doc.recompute(); + if (!ok) { doc.undo(); } + panel->mcp_after_change(); + return json{{"ok", ok}, {"name", name}, {"error", doc.error}}; +} + +json action_set_feature_expr(DesignPanel* panel, const json& params) +{ + if (!params.contains("feature")) throw std::runtime_error("set_feature_expr needs 'feature' index"); + if (!params.contains("field")) throw std::runtime_error("set_feature_expr needs 'field' name"); + if (!params.contains("expr")) throw std::runtime_error("set_feature_expr needs 'expr' string"); + CadDocument& doc = panel->mcp_doc(); + int fi = params["feature"].get(); + if (fi < 0 || fi >= (int)doc.features.size()) + return json{{"ok", false}, {"error", "feature index out of range"}}; + std::string field = params["field"].get(); + std::string expr = params["expr"].get(); + std::string old = doc.features[fi].expr.count(field) ? doc.features[fi].expr[field] : ""; + doc.checkpoint(); + doc.features[fi].expr[field] = expr; + bool ok = doc.recompute(); + if (!ok) { doc.undo(); } + panel->mcp_after_change(); + return json{{"ok", ok}, {"feature", fi}, {"field", field}, {"error", doc.error}}; +} + // Dispatch one parsed request ON THE MAIN THREAD. Returns a JSON-RPC reply string. std::string handle_on_main(const std::string& method, const json& params, const json& id) { @@ -1250,6 +1286,8 @@ std::string handle_on_main(const std::string& method, const json& params, const if (method == "axis") return rpc_result(id, action_axis(panel, params)); if (method == "coordsys") return rpc_result(id, action_coordsys(panel, params)); if (method == "helix") return rpc_result(id, action_helix(panel, params)); + if (method == "set_variable") return rpc_result(id, action_set_variable(panel, params)); + if (method == "set_feature_expr") return rpc_result(id, action_set_feature_expr(panel, params)); return rpc_error(id, -32601, "Unknown method: " + method); } catch (const Standard_Failure& ex) { // OCCT errors are NOT std::exception return rpc_error(id, -32000, std::string("OCCT: ") + (ex.GetMessageString() ? ex.GetMessageString() : "failure")); diff --git a/tests/data/cad_recipe_v2.bin b/tests/data/cad_recipe_v2.bin index 4c7a965422..58f5b5fcc8 100644 Binary files a/tests/data/cad_recipe_v2.bin and b/tests/data/cad_recipe_v2.bin differ diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index f4daeebae9..f3c190206b 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -4376,3 +4376,159 @@ TEST_CASE("hole: round-trip preserves styled counterbore hole", "[CadDocument][h } REQUIRE(found); } + +TEST_CASE("variables drive a box (parametric sketch + extrude)", "[CadDocument][variables]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.features.size() >= 2); + int ex = sk + 1; + + doc.variables = {{"w", "10"}, {"h", "w*2"}}; + doc.features[sk].expr = {{"width", "w"}, {"height", "w"}}; + doc.features[ex].expr = {{"distance", "h"}}; + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_FALSE(doc.body.IsNull()); + + Bnd_Box bb; BRepBndLib::Add(doc.body, bb); + double xlo, ylo, zlo, xhi, yhi, zhi; + bb.Get(xlo, ylo, zlo, xhi, yhi, zhi); + REQUIRE_THAT(xhi - xlo, WithinAbs(10.0, 1.0)); + REQUIRE_THAT(yhi - ylo, WithinAbs(10.0, 1.0)); + REQUIRE_THAT(zhi - zlo, WithinAbs(20.0, 2.0)); +} + +TEST_CASE("function + pi in expression binding", "[CadDocument][variables]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.features.size() >= 2); + + doc.variables = {{"r", "sqrt(16)+abs(-2)"}}; + doc.features[sk].expr = {{"radius", "r"}}; + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(doc.features[sk].radius, WithinAbs(6.0, 1e-6)); + + doc.variables = {{"r", "max(3, pi)"}}; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(doc.features[sk].radius, WithinAbs(M_PI, 1e-6)); + + doc.variables = {{"r", "5"}, {"d", "r*2"}}; + doc.features[sk].expr = {{"radius", "d"}}; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(doc.features[sk].radius, WithinAbs(10.0, 1e-6)); +} + +TEST_CASE("cycle detected fails recompute with error", "[CadDocument][variables]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + doc.features[sk].expr = {{"width", "a"}}; + + doc.variables = {{"a", "b"}, {"b", "a"}}; + REQUIRE_FALSE(doc.recompute()); + REQUIRE_THAT(doc.error, Catch::Matchers::Contains("cycle")); +} + +TEST_CASE("unknown parameter fails recompute", "[CadDocument][variables]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + doc.features[sk].expr = {{"nope", "1"}}; + + REQUIRE_FALSE(doc.recompute()); + REQUIRE_THAT(doc.error, Catch::Matchers::Contains("unknown parameter")); +} + +TEST_CASE("unknown identifier fails recompute", "[CadDocument][variables]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + doc.features[sk].expr = {{"width", "x"}}; + + doc.variables = {{"x", "y+1"}}; + REQUIRE_FALSE(doc.recompute()); + REQUIRE_THAT(doc.error, Catch::Matchers::Contains("unknown identifier")); +} + +TEST_CASE("parametric recipe round-trips through serialize/deserialize", "[CadDocument][variables]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.features.size() >= 2); + int ex = sk + 1; + + doc.variables = {{"w", "10"}, {"h", "w*2"}}; + doc.features[sk].expr = {{"width", "w"}, {"height", "w"}}; + doc.features[ex].expr = {{"distance", "h"}}; + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + Bnd_Box bb_orig; BRepBndLib::Add(doc.body, bb_orig); + double ox0, oy0, oz0, ox1, oy1, oz1; + bb_orig.Get(ox0, oy0, oz0, ox1, oy1, oz1); + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + + REQUIRE(doc2.variables.size() == 2); + REQUIRE(doc2.variables["w"] == "10"); + REQUIRE(doc2.variables["h"] == "w*2"); + + bool found_sk = false, found_ex = false; + for (const auto& f : doc2.features) { + if (f.name == "Sketch") { + REQUIRE(f.expr.size() == 2); + REQUIRE(f.expr.at("width") == "w"); + REQUIRE(f.expr.at("height") == "w"); + found_sk = true; + } + if (f.name == "Extrude") { + REQUIRE(f.expr.size() == 1); + REQUIRE(f.expr.at("distance") == "h"); + found_ex = true; + } + } + REQUIRE(found_sk); + REQUIRE(found_ex); + + REQUIRE(doc2.bodies.size() == doc.bodies.size()); + + Bnd_Box bb2; BRepBndLib::Add(doc2.body, bb2); + double nx0, ny0, nz0, nx1, ny1, nz1; + bb2.Get(nx0, ny0, nz0, nx1, ny1, nz1); + REQUIRE_THAT(nx0, WithinAbs(ox0, 1e-6)); + REQUIRE_THAT(ny0, WithinAbs(oy0, 1e-6)); + REQUIRE_THAT(nz0, WithinAbs(oz0, 1e-6)); + REQUIRE_THAT(nx1, WithinAbs(ox1, 1e-6)); + REQUIRE_THAT(ny1, WithinAbs(oy1, 1e-6)); + REQUIRE_THAT(nz1, WithinAbs(oz1, 1e-6)); +}