Connector face drift: warn without crying wolf — and bump the recipe version

kqih option (c). A FaceAndDirection connector stores a global face index, and an
upstream edit can renumber faces so the index silently names a different one. The
DANGLING case already threw; this is the in-range-but-wrong case, which nothing
detected.

Fingerprint the face on first resolve, compare afterwards, and report a mismatch
into mate_conflicts — the channel that already marks the tree row — never as an
error. A drift warning must not abort the recompute, because the alternative
makes a legitimate Draft on a mated face fatal.

WHAT THE FINGERPRINT IS, AND WHAT IT IS NOT. Surface type plus edge count. Not
centroid or area: legitimate parametric edits move and resize faces, which is the
entire point of the model, so either would fire on every dimension change. Not
the normal, which is the tempting one — Draft deliberately tilts a face and
Transform reorients a body, both legitimate. Type and edge count survive rigid
motion, tilting and resizing, and catch the case that actually happens: a planar
index sliding onto a fillet's cylindrical face after a dress-up inserts faces.
The accepted cost is that a slide between two planar 4-edge faces is invisible. A
partial detector that never cries wolf beats a total one that does, because a
false alarm on a valid connector teaches people to ignore the warning.

Connectors with no fingerprint record one on first recompute, so old recipes
self-heal and both writers (DesignPanel, McpControl) get it without changing.

THE VERSION BUMP IS THE IMPORTANT HALF. The task was specified with "do not
change the recipe version" — that was wrong, and the rule is written in the
header three lines above the constant: bump whenever save/load gains a field.
deserialize_recipe() gates on v == VERSION and then reads a FLAT symmetric field
list. A v3 blob under a v3 build that has grown two fields passes the gate and
reads two ints past the end of every connector, into the next feature's bytes.
That is silent corruption of a saved project, which is worse than any load error.
Now v4, and v3 gets the existing clean refusal.

cad_recipe_v3.bin is KEPT, unregenerated, with a test asserting it is refused and
that nothing half-read is left behind. It is the only artefact that can prove the
gate works, because it was written by an older build — regenerating it with
today's code would destroy the evidence, which the test says in as many words.

Suite 163 -> 167 cases, 2248 -> 2277 assertions, green. Fixture v4 34928 bytes.
snaporca-kqih.
This commit is contained in:
Tommaso Bianchi
2026-08-12 23:04:49 +02:00
parent 13c702bed3
commit 9125e0b4f8
4 changed files with 166 additions and 5 deletions
+31
View File
@@ -3404,6 +3404,37 @@ bool CadDocument::recompute()
}
}
bodies = std::move(built);
// Face-drift fingerprint for FaceAndDirection CoordSys connectors. This runs AFTER the
// bodies are final and APPENDS to mate_conflicts (detect_mate_conflicts() cleared it at
// the top of recompute() and must not run again here). A mismatch is a WARNING, not an
// error: a legitimate Draft on a mated face renumbers nothing but a real renumber after a
// dress-up silently points the connector at a different face, and that must not abort.
for (int fi = 0; fi < int(features.size()); ++fi) {
CadFeature& f = features[fi];
if (!f.enabled) continue;
if (f.type != CadFeatureType::CoordSys) continue;
if (f.coordsys_type != CoordSysType::FaceAndDirection) continue;
if (f.coordsys_body < 0 || f.coordsys_body >= int(bodies.size())) continue;
TopoDS_Face face = GeometryEngine::face_by_index(bodies[f.coordsys_body].shape, f.coordsys_face);
if (face.IsNull()) continue; // "face not found" is already reported by datum_frame()
const int kind = int(BRepAdaptor_Surface(face).GetType());
const int edges = int(GeometryEngine::edges_of_face(face).size());
if (f.coordsys_face_kind < 0) {
// No fingerprint yet (old recipe, or a connector never resolved): adopt the state
// the user last saw. Self-heals old recipes on first load, and gives both existing
// writers the fingerprint for free.
f.coordsys_face_kind = kind;
f.coordsys_face_edges = edges;
continue;
}
if (f.coordsys_face_kind != kind || f.coordsys_face_edges != edges) {
mate_conflicts.emplace_back(int(fi),
"connector \"" + f.name + "\" may have moved to a different face "
"(an upstream edit renumbered this body's faces)");
}
}
body = compound_of(bodies);
display_mesh = tessellate_bodies(bodies, display_tri_face, display_tri_body,
display_body_meshes,
+21 -3
View File
@@ -251,6 +251,17 @@ struct CadFeature {
int coordsys_edge{-1};
Vec3d coordsys_x_hint{1, 0, 0};
// Fingerprint of the face this connector was bound to, for drift detection. -1 = not yet
// recorded (an old recipe, or a connector that has never resolved).
//
// Surface TYPE and EDGE COUNT specifically, because they survive every legitimate edit:
// Transform moves the body, Draft tilts the face, a dimension change resizes it, and none
// of those change either value. Centroid, area and normal all fail that test — see the
// issue. The cost is that a slide from one planar 4-edge face to another planar 4-edge face
// is invisible; a detector that never cries wolf is worth more here than a total one.
int coordsys_face_kind{-1}; // GeomAbs_SurfaceType as int
int coordsys_face_edges{-1}; // number of edges bounding the face
// Helix curve params (consumed as a sweep path to build springs/coils/augers).
// Axis = plane normal through plane origin. pitch = axial rise per full turn.
// left_handed flips the winding direction. taper_deg != 0 gives a conical helix.
@@ -345,7 +356,8 @@ struct CadFeature {
rib_sketch_ref, rib_entity, rib_thickness, rib_depth,
pattern_curve_sketch, pattern_curve_entity,
expr,
mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip);
mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip,
coordsys_face_kind, coordsys_face_edges);
}
template<class Archive>
void load(Archive& ar) {
@@ -383,7 +395,8 @@ struct CadFeature {
rib_sketch_ref, rib_entity, rib_thickness, rib_depth,
pattern_curve_sketch, pattern_curve_entity,
expr,
mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip);
mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip,
coordsys_face_kind, coordsys_face_edges);
imported_solid = brep_from_string(brep);
}
};
@@ -594,7 +607,12 @@ public:
// - bump this whenever CadFeature::save/load gains or loses a field
// - v1 blobs are deliberately not loadable; there is no migration path by design
// - append fields ONLY at the end of save/load, never reorder (golden fixture enforces this)
static constexpr uint32_t SNAPORCA_CAD_RECIPE_VERSION = 3;
// v4: coordsys_face_kind + coordsys_face_edges appended (connector face-drift fingerprint).
// The bump is not optional. deserialize_recipe() gates on v == VERSION and then reads a FLAT
// symmetric field list, so a v3 blob under a v3 build that has grown two fields passes the
// gate and then reads two ints past the end of every connector — straight into the next
// feature's bytes. That is silent corruption of a saved project, not a load error.
static constexpr uint32_t SNAPORCA_CAD_RECIPE_VERSION = 4;
std::string serialize_recipe() const;
bool deserialize_recipe(const std::string& blob);