Files
jyviko dc38349cbf Layer split stage 1: the B-rep record, native body file and Vec/Plane come from solidcore-record (#11496)
* Kernel: the B-rep record and the native body file come from solidcore-record

SolidCraft's portable B-rep record (`solidcraft_geom::brep`: brep.rs, topology, history,
envelope, part, curve2) and the native body file (`solidcraft_kernel::native`: the tagged binary
codec and the container) move to solidcore's `solidcore-record` crate: geometry as data, with
structural validation and no maths (solidcore's docs/layers.md, stage 1). Every caller uses
`solidcore_record::brep` and `solidcore_record::native`; the old copies are deleted.

What computes from coordinates stays with the kernel, in geom's `brep`:
- `brep::check`: the record's geometric checks (orthonormal frames, non-degenerate planes,
  unit revolution axes, non-singular placements). `check::validate` and
  `check::validate_built` are the record's validation and these together, and every caller of
  `Brep::validate` uses them, so what is accepted is unchanged;
- `brep::label`: a surface's natural ranges (a cone's apex), `curve_interval`,
  `edge_curve_interval` and `form_turns`;
- `brep::pcurve::eval`: a pcurve's point;
- `geometry::check`, `placement::moved` and `split_edges` become functions on the record.

The kernel's native module keeps the conversion between a body and its saved record
(metadata.rs) and checks the decoded record's geometry. `native/analytic.rs`, which no module
declared, is deleted.

solidcore-record is pinned to its rev on solidcore's `layer-record` branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT

* Geometry: Vec2, Vec3, Plane and the profile types come from solidcore-record

The plain data of vectors and points, sketch planes and planar profiles (`Seg2`, `Loop2`,
`Region2`) moves to `solidcore-record`, with its constants and component-wise operators only
(owner decision, solidcore layer split). Their geometry stays in geom as traits:
`Vec2Geom`, `Vec3Geom`, `PlaneGeom`, `Seg2Geom`, `Loop2Geom`, `Region2Geom`, `Seg2ArcFit`,
`Loop2ArcFit` and `Region2ArcFit`, in scope with `use solidcraft_geom::prelude::*`. Every caller
names the types from `solidcore_record`; geom no longer defines or re-exports them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT

* Kernel: pin solidcore-record to solidcore main f5e8e9a29

solidcore-record is merged on solidcore main (tangency-labs/solidcore#2519); its crate there is
identical to the rev pinned before but for its clippy configuration. The lock is regenerated
against the merged rev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:04:26 -05:00

176 lines
8.6 KiB
Rust

//! Command-level diagnostic work, including the report ultimately published to the model.
use solidcraft_geom::prelude::*;
use std::sync::Arc;
use serde_json::json;
use solidcore_record::Vec2;
use solidcraft_engine::Session;
use solidcraft_sketch::{Analysis, ConstraintKind, Sketch, profiling::capture};
fn session(horizontal: bool) -> (Session, u64, String) {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let id = s.active_sketch.unwrap();
let mut sk = Sketch::new();
let q = sk.add_point(Vec2::new(10.0, 0.0), None).unwrap();
let l = sk.add_line_pts(0, q, None).unwrap();
let k = ConstraintKind::Distance { p: 0, q, value: 10.0 };
sk.add_constraint(k.clone(), None).unwrap();
let redundant = sk.add_constraint(k, None).unwrap();
if horizontal {
sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap();
}
*Arc::make_mut(&mut s.doc).sketch_mut(id).unwrap() = sk;
(s, id, redundant)
}
#[test]
fn auto_constrain_computes_redundancy_once_and_publishes_full_diagnostics() {
let (mut s, id, redundant) = session(true);
let before = s.doc.sketch(id).unwrap().clone();
let (response, profile) = capture(|| s.execute("sketch.auto_constrain", &json!({"dimensions": false})));
let response = response.unwrap();
assert_eq!(response["added"], json!([]));
assert_eq!(response["sketch"], json!({"solved": true, "dof": 0}));
assert_eq!(*s.doc.sketch(id).unwrap(), before);
let state = s.model.state();
let shown = state.sketch(id).unwrap();
assert!(shown.report.ok());
assert_eq!(shown.report.dof, 0);
assert!(shown.report.redundant.contains(&redundant));
assert!(shown.report.point_determined.iter().all(|d| *d));
assert_eq!(profile.counters.redundancy_checks, 1, "only the published model report needs redundant IDs");
assert_eq!(profile.counters.solve_calls, 3, "reuse the preparation solve's DOF");
}
#[test]
fn auto_constrain_reuses_real_freedom_and_keeps_accepted_trial_diagnostics() {
let (mut s, id, redundant) = session(false);
let (response, profile) = capture(|| s.execute("sketch.auto_constrain", &json!({"dimensions": false})));
let response = response.unwrap();
assert_eq!(response["added"].as_array().unwrap().len(), 1, "one angular degree of freedom must be removed");
assert_eq!(response["dof"], 0);
assert_eq!(response["sketch"], json!({"solved": true, "dof": 0}));
let state = s.model.state();
let shown = state.sketch(id).unwrap();
assert!(shown.report.redundant.contains(&redundant));
assert!(shown.report.ok());
assert_eq!(profile.counters.redundancy_checks, 1);
}
#[test]
fn a_live_drag_keeps_full_diagnostics_when_it_publishes_without_evaluation() {
for horizontal in [false, true] {
let (mut s, id, redundant) = session(horizontal);
// The fixture edits the document directly. Establish a valid full report before
// testing its reuse; a no-op solve is not required to rebuild an unchanged model.
s.model.evaluate(&s.doc);
s.execute("sketch.solve", &json!({})).unwrap();
assert!(
s.model.state().sketch(id).unwrap().report.redundant.contains(&redundant),
"the pre-drag model must contain the fixture's full diagnostics"
);
let endpoint = s.doc.sketch(id).unwrap().points[1].id.clone();
s.execute("sketch.drag", &json!({"entity": endpoint, "from": [10, 0], "to": [12, 8], "live": true})).unwrap();
let state = s.model.state();
let shown = state.sketch(id).unwrap();
assert!(shown.report.redundant.contains(&redundant));
assert_eq!(shown.report.dof, usize::from(!horizontal));
if !horizontal {
assert!(s.doc.sketch(id).unwrap().points[1].pos.dist(Vec2::new(10.0, 0.0)) > 1.0, "exercise publication of changed geometry");
}
}
}
#[test]
fn a_live_text_edit_keeps_full_diagnostics_when_it_publishes_without_evaluation() {
let (mut s, id, redundant) = session(false);
let text = s.execute("sketch.text", &json!({"text": "T", "at": [30, 20], "height": 5})).unwrap();
assert!(s.model.state().sketch(id).unwrap().report.redundant.contains(&redundant));
s.execute("sketch.edit_text", &json!({"link": text["link"], "height": 8, "live": true})).unwrap();
let state = s.model.state();
let shown = state.sketch(id).unwrap();
assert!(shown.report.ok());
assert!(shown.report.redundant.contains(&redundant), "live publication must retain redundant IDs for unrelated geometry");
}
#[test]
fn a_live_text_batch_keeps_full_diagnostics_when_it_publishes_without_evaluation() {
let (mut s, id, redundant) = session(false);
let a = s.execute("sketch.text", &json!({"text": "T", "at": [30, 20], "height": 5})).unwrap();
let b = s.execute("sketch.text", &json!({"text": "I", "at": [50, 20], "height": 5})).unwrap();
let out =
s.execute("sketch.edit_text", &json!({"edits": [{"link": a["link"], "height": 8}, {"link": b["link"], "height": 9}], "live": true})).unwrap();
assert_eq!(out["edits"][0]["link"], a["link"]);
assert_eq!(out["edits"][1]["link"], b["link"]);
let state = s.model.state();
let shown = state.sketch(id).unwrap();
let expected = solidcraft_sketch::solve(&mut s.doc.sketch(id).unwrap().clone());
assert!(shown.report.ok());
assert!(shown.report.redundant.contains(&redundant));
assert_eq!(serde_json::to_value(&shown.report).unwrap(), serde_json::to_value(expected).unwrap());
}
fn compare_document_levels(doc: &solidcraft_doc::Document, id: u64, expected_ok: bool) {
let input = doc.sketch(id).unwrap();
let stored = input.driving_values();
let values = doc.param_values().0;
let mut full_geometry = input.clone();
let (full, profile) = capture(|| doc.solve_sketch(&values, &mut full_geometry, &stored, Some(input)).unwrap());
assert_eq!(full.ok(), expected_ok);
assert!(profile.counters.redundancy_checks > 0, "the default document API retains full diagnostics");
if expected_ok {
let (_, last_only) = capture(|| solidcraft_sketch::solve(&mut full_geometry.clone()));
assert_eq!(profile.counters.redundancy_checks, last_only.counters.redundancy_checks, "only the last offset solve needs redundant IDs");
}
for analysis in [Analysis::Freedom, Analysis::StatusOnly] {
let mut geometry = input.clone();
let (report, profile) = capture(|| doc.solve_sketch_with_analysis(&values, &mut geometry, &stored, Some(input), analysis).unwrap());
assert_eq!(geometry, full_geometry, "parameter application, offset refresh and rollback must match");
assert_eq!(profile.counters.redundancy_checks, 0, "offset retries must retain the requested level");
let mut expected = full.clone();
expected.redundant.clear();
if analysis == Analysis::StatusOnly {
expected.dof = 0;
expected.point_determined.clear();
expected.curve_determined.clear();
}
assert_eq!(serde_json::to_value(report).unwrap(), serde_json::to_value(expected).unwrap());
}
}
#[test]
fn document_analysis_levels_survive_parameter_application_and_offset_retries() {
for freeform in [false, true] {
let (mut s, id, _) = session(false);
let curve = if freeform {
s.execute("sketch.ellipse", &json!({"center": [30, 30], "major": [40, 30], "minor_radius": 5})).unwrap()["curves"][0]
.as_str()
.unwrap()
.to_owned()
} else {
s.doc.sketch(id).unwrap().curves[0].id.clone()
};
s.execute("sketch.offset", &json!({"curves": [curve], "distance": -1, "chain": false})).unwrap();
let sk = s.doc.sketch(id).unwrap();
assert!(if freeform { !sk.freeform_offsets.is_empty() } else { !sk.offsets.is_empty() });
compare_document_levels(&s.doc, id, true);
// Both original distances must follow the edited parameter before analyzing freedom.
let doc = Arc::make_mut(&mut s.doc);
let param = doc.new_model_param("20 mm", "mm");
for c in &mut doc.sketch_mut(id).unwrap().constraints {
if matches!(c.kind, ConstraintKind::Distance { .. }) {
c.param = Some(param.clone());
}
}
compare_document_levels(doc, id, true);
// One conflicting dimension makes the first solve fail, exercising the offset retry.
let conflict = doc.new_model_param("11 mm", "mm");
doc.sketch_mut(id).unwrap().constraints.iter_mut().find(|c| matches!(c.kind, ConstraintKind::Distance { .. })).unwrap().param =
Some(conflict);
compare_document_levels(doc, id, false);
}
}