Merge branch 'main' into feature/parametric-constraint-system

Brings in 54 commits from origin/main: a selection-performance overhaul,
surface thickening, GPU-native wide/tapered stroke rendering, continued
ocs_doc_api evolution, and assorted fixes/polish (block double-click
editing, DWG hatch/database repair, clipboard, collapsible Properties
sections).

Six real conflicts, all where this branch had independently re-applied
a fix upstream later revised or already carried natively:

- src/app/automation.rs, src/app/command_driver.rs: combined both
  sides — kept this branch's sketch-constraint/named-parameter loading
  on "new"/"open", adopted main's Scene::clear()/material_base_dir and
  its newer rebuild_derived_caches() over the older bump_geometry().
  Scene::clear() (src/scene/entity.rs) now also resets
  sketch_constraints/named_parameters, so every caller gets this for
  free instead of each call site repeating it.
- src/io/mod.rs, src/scene/render_graph.rs: adopted main's design
  outright (commit 973af87d, authored by Hakan directly) — a large
  MINSERT is no longer purged as "corrupt" at load, only its rendering
  is bounded, preserving the source data. This branch's own earlier
  design (reject at load) predates that revision.
- src/scene/pipeline/mesh_gpu.rs: adopted main's valid_rgba_size()
  guard (a real robustness improvement over this branch's version).
- src/snap.rs: replaced wholesale with main's version — this branch's
  copy was an explicit verbatim snapshot from an earlier upstream PR
  (commit 4c264b8c) with zero constraint-system-specific content, and
  main has since evolved it considerably further.

Also removed two now-dead duplicates the merge surfaced without a
conflict marker (both branches had independently fixed the same
things): the OPTIONS/OP command dispatch/autocomplete entries, and
automation.rs's open_finalizes_and_purges_like_the_ui_open_path test
(kept main's version, which covers strictly more: solid_models/
material_base_dir reset on "new").

Full workspace suite passes (812 lib tests + this branch's own 24
constraint/persistence integration tests) except the one pre-existing,
unrelated font-test failure already present on main. Zero clippy
findings in the hand-written merge resolution itself; the ~1,171
warnings clippy reports are pre-existing in the 54 absorbed commits,
not introduced here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-09 20:44:38 +02:00
commit d675244fb8
89 files changed
+5700 -1101

No files matched your search

+220 -1
View File
@@ -353,7 +353,32 @@ impl OpenCADStudio {
self.reset_tracking_after_point();
self.push_ucs_to_cmd(i);
}
if let StepInput::EntityPick(handle, _) = &input {
if let StepInput::EntityPick(handle, point) = &input {
let solid_pick = matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Surface(_)
| acadrust::EntityType::Region(_)
| acadrust::EntityType::Body(_)
)
);
let direction = if solid_pick {
self.tabs[i]
.scene
.solid_planar_face_normal_at(*handle, *point)
} else {
self.tabs[i]
.scene
.document
.get_entity(*handle)
.and_then(crate::entities::curve::entity_curve)
.and_then(|curve| curve.plane.normal())
.map(glam::DVec3::from_array)
};
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.set_entity_pick_direction(direction);
}
if self.tabs[i].active_cmd.as_ref()
.is_some_and(|command| command.inject_before_entity_pick())
{
@@ -4099,6 +4124,102 @@ impl OpenCADStudio {
self.refresh_properties();
}
CmdResult::ThickenEntities { handles, distance } => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
if handles.is_empty() {
self.command_line
.push_output(crate::t!("No surfaces selected.").as_ref());
return Task::none();
}
if !distance.is_finite() {
self.command_line.push_error(
crate::t!("Requires numeric distance or two points.").as_ref(),
);
return Task::none();
}
if distance.abs() <= f64::EPSILON {
return Task::none();
}
use crate::modules::insert::solid3d_cmds::empty_solid3d;
let pending = self.begin_undo(i, "THICKEN", handles.len(), true);
let mut created = 0usize;
let mut failed = 0usize;
let mut self_intersections = 0usize;
for handle in handles {
let Some(acadrust::EntityType::Surface(surface)) =
self.tabs[i].scene.document.get_entity(handle)
else {
failed += 1;
continue;
};
let kernel_distance = if surface.kind
== acadrust::entities::SurfaceKind::Revolved
{
-distance
} else {
distance
};
let body = self.tabs[i]
.scene
.solid_models
.get(&handle)
.cloned()
.or_else(|| {
crate::scene::convert::solid3d_tess::kernel_surface_body(surface)
});
let Some(body) = body else {
failed += 1;
continue;
};
let solid = match cadkernel::brep::thicken(&body, kernel_distance) {
Ok(solid) => solid,
Err(cadkernel::brep::ThickenError::SelfIntersection) => {
failed += 1;
self_intersections += 1;
continue;
}
Err(_) => {
failed += 1;
continue;
}
};
let history = crate::scene::model::solid_history::brep_op(&solid);
if self
.add_solid_model(empty_solid3d(), solid, history)
.is_null()
{
failed += 1;
} else {
created += 1;
}
}
if created > 0 {
self.tabs[i].dirty = true;
}
if self_intersections > 0 {
self.command_line.push_error(&crate::tf!(
"{} surface(s) cannot be thickened because the offset intersects itself.",
self_intersections
));
}
if failed > 0 {
self.command_line.push_output(&crate::tf!(
"THICKEN: created {} solid(s); {} surface(s) could not be thickened exactly.",
created, failed
));
}
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
self.refresh_properties();
}
CmdResult::PresspullPick { handle, point, offset, multiple: _ } => {
self.presspull_pick(handle, point, offset);
}
@@ -6345,3 +6466,101 @@ mod sketch_constraint_undo_tests {
);
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod thicken_tests {
use super::*;
use acadrust::entities::{Surface, SurfaceKind};
use cadkernel::geom2d::{Circle, Curve, NurbsCurve};
use cadkernel::space::{Parameterization, Plane};
#[test]
fn thicken_preserves_sources_and_round_trips_results_with_one_undo() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let body = cadkernel::brep::planar_region(Plane::XY, &[
vec![Curve::Circle(Circle { centre: [0.0; 2], radius: 3.0 })],
]).unwrap();
let source = app.add_surface_model(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), body);
assert!(!source.is_null());
let i = app.active_tab;
let invalid = app.tabs[i].scene.add_entity(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)));
let original = serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap();
let before = app.tabs[i].history.undo_stack.len();
let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![source], distance: 0.0 });
assert_eq!(app.tabs[i].history.undo_stack.len(), before);
let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![invalid, source], distance: 2.0 });
assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1);
let result = app.tabs[i].scene.document.entities().find_map(|entity| {
matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle)
}).unwrap();
assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some());
assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original);
let expected = cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]).unwrap().volume;
let bytes = crate::io::save_to_bytes(&app.tabs[i].scene.document, "dwg", app.tabs[i].scene.document.version).unwrap();
let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap();
let mut restored = crate::scene::Scene::new();
restored.document = document;
restored.restore_solid_models(&[result]);
let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]).unwrap().volume;
assert!((expected - actual).abs() < 1e-8);
app.undo_active_tab();
assert!(app.tabs[i].scene.document.get_entity(result).is_none());
assert!(app.tabs[i].scene.document.solid_history_operation(result).is_none());
assert!(app.tabs[i].scene.document.get_entity(source).is_some());
assert!(app.tabs[i].scene.document.get_entity(invalid).is_some());
app.redo_active_tab();
assert!(app.tabs[i].scene.document.get_entity(result).is_some());
assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some());
assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original);
}
#[test]
fn thicken_creates_a_solid_from_a_free_form_surface() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let profile = NurbsCurve::interpolate(
&[[0.0, 0.0], [0.7, 0.15], [1.3, -0.1], [2.0, 0.0]],
None,
None,
Parameterization::Chord,
)
.unwrap();
let body = cadkernel::brep::extrude_surface(
Plane::XY,
&[Curve::Nurbs(profile)],
[0.0, 0.0, 2.0],
)
.unwrap();
let source = app.add_surface_model(
acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)),
body,
);
let i = app.active_tab;
let _ = app.apply_cmd_result(CmdResult::ThickenEntities {
handles: vec![source],
distance: 0.15,
});
let solids = app.tabs[i]
.scene
.document
.entities()
.filter_map(|entity| {
matches!(entity, acadrust::EntityType::Solid3D(_))
.then_some(entity.common().handle)
})
.collect::<Vec<_>>();
assert_eq!(solids.len(), 1);
let solid = &app.tabs[i].scene.solid_models[&solids[0]];
assert!(solid.validate().is_empty());
assert!(solid.edges.iter().all(|(_, edge)| edge.coedges.len() == 2));
assert!(app.tabs[i]
.scene
.document
.solid_history_operation(solids[0])
.is_some());
assert!(app.tabs[i].scene.document.get_entity(source).is_some());
}
}