M6: Variables & equations — parametric expressions driving feature dimensions

Add named document variables (CadDocument::variables) and per-feature
expression bindings (CadFeature::expr, field-name -> expression). On
recompute(), variables are evaluated topologically (cycle detection), then
each feature's expr entries are evaluated and written into its numeric fields
before geometry runs. Self-contained shunting-yard evaluator (+ - * /, parens,
unary minus, sqrt/abs/sin/cos/tan(deg)/min/max, pi). assign_field allow-lists
the 33 dimension fields + pattern_count; unknown names error loudly.

Additive: recipe stays v2 (fields appended to both cereal lists, golden
fixture regenerated). MCP set_variable / set_feature_expr are pure additions.
Suite 95 cases / 1440 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
This commit is contained in:
Tommaso Bianchi
2026-07-25 08:11:06 +02:00
co-authored by Claude Opus 4.8
parent aa30575369
commit 15ea0a813f
5 changed files with 485 additions and 3 deletions
+276
View File
@@ -54,6 +54,7 @@
#include <BRepGProp.hxx>
#include <GProp_GProps.hxx>
#include <cmath>
#include <cctype>
#include <stdexcept>
#include <algorithm>
#include <sstream>
@@ -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<std::string, double>& 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<Token> 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<Token> rpn;
std::vector<Token> 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<double> 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<std::string, double>
evaluate_variables(const std::map<std::string, std::string>& variables)
{
std::map<std::string, double> done;
std::set<std::string> visiting;
std::function<double(const std::string&)> 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<std::string, double> 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<CadBody> 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<std::string, double> 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();
+15 -3
View File
@@ -11,6 +11,8 @@
#include <cereal/cereal.hpp>
#include <cereal/types/vector.hpp>
#include <cereal/types/string.hpp>
#include <map>
#include <cereal/types/map.hpp>
#include <string>
#include <vector>
#include <utility>
@@ -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<std::string, std::string> 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<class Archive>
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<CadFeature> features;
// Named document variables: name -> expression. Evaluated topologically each recompute();
// an expression may reference other variables. Feature `expr` bindings resolve against these.
std::map<std::string, std::string> 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<CadBody> bodies;
+38
View File
@@ -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>();
std::string expr = params["expr"].get<std::string>();
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<int>();
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>();
std::string expr = params["expr"].get<std::string>();
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"));
Binary file not shown.
+156
View File
@@ -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));
}