Add persistent sketch-constraint system (ocs_gcs port) and named-parameters handoff docs

Ports FreeCAD's planegcs 2D geometric constraint solver to Rust as a new
crates/ocs_gcs workspace member, and wires it into the document/scene model
as a persistent SketchConstraintSet: Coincident, Horizontal/Vertical,
Parallel/Perpendicular, Equal, Tangent, Distance, Angle and Radius
constraints that re-solve live (including mid-drag, not just on release),
render glyphs and a DOF badge, flag conflicts with a one-click resolver
pill, and round-trip through save/load via an XRecord blob.

Also includes docs/command_reference.md (a statically generated reference
of every registered command and its prompt sequence) and
docs/named_parameters_design.md + docs/backlog.md, which hand off the next
queued feature (a named-parameter/expression table) and consolidate every
deliberate scope-down and open gap found along the way, so a fresh session
has a complete starting point.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-08 22:19:23 +02:00
1 parent a39c7b8f71
commit 63a74b19a9
63 files changed
+12193 -49

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
//! CCONSTRAINT — the manual Coincident UI (design doc §6.1's open item):
//! unlike the rest of this module's plain select-and-click constraints,
//! Coincident addresses a *point* on an entity (an endpoint, a circle/arc
//! center), not the whole entity, so it needs two point picks instead of a
//! selection.
//!
//! A `CadCommand` has no document access (see `value.rs`'s module doc
//! comment for the same constraint on `DCONSTRAINT`/`ACONSTRAINT`), so this
//! command only accumulates the two raw points `on_point` receives and hands
//! them back as `CmdResult::AddCoincidentConstraint` — the host resolves
//! each point to a real `SketchRef` (`sketch_constraints::nearest_sketch_point`)
//! and adds the constraint, the same split `ReassociateCenterMark` already
//! uses for a similar reason.
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub mod coincident_tool {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "CCONSTRAINT",
label: "Coincident",
icon: IconKind::Glyph("≡"),
event: ModuleEvent::Command("CCONSTRAINT".to_string()),
}
}
}
#[derive(Default)]
pub struct CoincidentConstraintCommand {
first: Option<DVec3>,
}
impl CoincidentConstraintCommand {
pub fn new() -> Self {
Self::default()
}
}
impl CadCommand for CoincidentConstraintCommand {
fn name(&self) -> &'static str {
"CCONSTRAINT"
}
fn prompt(&self) -> String {
if self.first.is_none() {
"COINCIDENT Specify first point:".to_string()
} else {
"COINCIDENT Specify second point:".to_string()
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.first {
None => {
self.first = Some(pt);
CmdResult::NeedPoint
}
Some(first) => CmdResult::AddCoincidentConstraint { point_a: first, point_b: pt, label: "Coincident constraint" },
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
+21
View File
@@ -0,0 +1,21 @@
//! Ribbon tools and typed-value commands for the persistent "Constraints"
//! group (design doc `docs/parametric_system_design.md` §6.1).
//!
//! All eight — Horizontal, Vertical, Parallel, Perpendicular, Equal,
//! Tangent, Distance, Angle — add a [`crate::scene::sketch_constraints::SketchConstraint`]
//! to the current scope and let `Scene::bump_entities` solve it, the same
//! path any later edit to the constrained entities re-solves through
//! (`src/scene/sketch_solve.rs`). Horizontal/Vertical/Parallel/Perpendicular/
//! Equal/Tangent dispatch straight to `CmdResult::AddSketchConstraint` from
//! `src/app/commands/draw.rs` (no typed value needed, so no `CadCommand` of
//! their own); Distance and Angle need one (`value.rs`), since the target
//! value comes from the command line after the tool is clicked; Coincident
//! needs one too (`coincident.rs`), since it addresses a *point* on an
//! entity rather than the whole thing, so it can't reuse plain selection.
mod coincident;
mod tools;
mod value;
pub use coincident::{coincident_tool, CoincidentConstraintCommand};
pub use tools::{equal, horizontal, parallel, perpendicular, tangent, vertical};
pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand};
+75
View File
@@ -0,0 +1,75 @@
//! Ribbon button definitions for the Constraints group.
use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub mod horizontal {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "HCONSTRAINT",
label: "Horizontal",
icon: IconKind::Glyph("—"),
event: ModuleEvent::Command("HCONSTRAINT".to_string()),
}
}
}
pub mod vertical {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "VCONSTRAINT",
label: "Vertical",
icon: IconKind::Glyph("│"),
event: ModuleEvent::Command("VCONSTRAINT".to_string()),
}
}
}
pub mod parallel {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "PCONSTRAINT",
label: "Parallel",
icon: IconKind::Glyph("∥"),
event: ModuleEvent::Command("PCONSTRAINT".to_string()),
}
}
}
pub mod perpendicular {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "QCONSTRAINT",
label: "Perpendicular",
icon: IconKind::Glyph("⊥"),
event: ModuleEvent::Command("QCONSTRAINT".to_string()),
}
}
}
pub mod equal {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "ECONSTRAINT",
label: "Equal",
icon: IconKind::Glyph("="),
event: ModuleEvent::Command("ECONSTRAINT".to_string()),
}
}
}
pub mod tangent {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "TCONSTRAINT",
label: "Tangent",
icon: IconKind::Glyph("⌒"),
event: ModuleEvent::Command("TCONSTRAINT".to_string()),
}
}
}
+184
View File
@@ -0,0 +1,184 @@
//! DCONSTRAINT / ACONSTRAINT — constraints that need a typed target value
//! (a distance or an angle), unlike the plain select-and-click constraints
//! in `mod.rs`.
//!
//! Persistent (design doc `docs/parametric_system_design.md` §6.1): these
//! don't solve anything themselves. A `CadCommand`'s `on_text_input` only
//! gets `&mut self` — no document access — so it can't add a
//! `SketchConstraint` to the scene directly; instead these commands hand the
//! typed value back as `CmdResult::AddSketchConstraint`, and the host (which
//! does have `&mut Scene`) adds the record and solves it via the same
//! `Scene::bump_entities` path any later edit to these entities will use too.
//! `default_value` (what the prompt shows in `<...>`, and what a bare Enter
//! submits) is still computed from a one-time read of current geometry —
//! that part doesn't change.
use acadrust::entities::EntityType;
use acadrust::types::Handle;
use glam::DVec3;
use crate::command::{CadCommand, CmdResult, InputKind};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
use crate::scene::Scene;
pub mod distance_tool {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "DCONSTRAINT",
label: "Distance",
icon: IconKind::Glyph("↔"),
event: ModuleEvent::Command("DCONSTRAINT".to_string()),
}
}
}
pub mod angle_tool {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
id: "ACONSTRAINT",
label: "Angle",
icon: IconKind::Glyph("∠"),
event: ModuleEvent::Command("ACONSTRAINT".to_string()),
}
}
}
/// Constrains a single line's length (between its two endpoints) or a
/// circle's radius, to a typed target value.
pub struct DistanceConstraintCommand {
handle: Handle,
/// Which kind this becomes once a value comes in — decided once at
/// construction from the entity's type, matching `ConstraintKind`'s own
/// split between line-length (`Distance`) and circle-radius (`Radius`).
is_circle: bool,
default_value: f64,
}
impl DistanceConstraintCommand {
/// `None` if `handle` isn't a Line or Circle.
pub fn new(scene: &Scene, handle: Handle) -> Option<Self> {
let entity = scene.document.get_entity(handle)?;
let (is_circle, default_value) = match entity {
EntityType::Line(l) => {
let dx = l.end.x - l.start.x;
let dy = l.end.y - l.start.y;
(false, (dx * dx + dy * dy).sqrt())
}
EntityType::Circle(c) => (true, c.radius),
_ => return None,
};
Some(Self { handle, is_circle, default_value })
}
fn build(&self, target: f64) -> Option<CmdResult> {
if target <= 0.0 {
return None;
}
let (kind, refs, label) = if self.is_circle {
(ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint")
} else {
(
ConstraintKind::Distance,
vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)],
"Distance constraint",
)
};
Some(CmdResult::AddSketchConstraint { kind, refs, driving_param: Some(target), label })
}
}
impl CadCommand for DistanceConstraintCommand {
fn name(&self) -> &'static str {
"DCONSTRAINT"
}
fn prompt(&self) -> String {
format!("Specify distance <{:.4}>: ", self.default_value)
}
fn input_kind(&self) -> InputKind {
InputKind::SingleToken
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
self.build(self.default_value).unwrap_or(CmdResult::Cancel)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let value: f64 = text.trim().parse().ok()?;
self.build(value)
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
/// Constrains the angle (in degrees) from `fixed`'s direction to `moving`'s.
pub struct AngleConstraintCommand {
fixed_handle: Handle,
moving_handle: Handle,
default_value: f64,
}
impl AngleConstraintCommand {
/// `None` unless both `fixed` and `moving` are lines.
pub fn new(scene: &Scene, fixed: Handle, moving: Handle) -> Option<Self> {
let fixed_entity = scene.document.get_entity(fixed)?;
let moving_entity = scene.document.get_entity(moving)?;
let (EntityType::Line(f), EntityType::Line(m)) = (fixed_entity, moving_entity) else {
return None;
};
let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x);
let a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x);
let default_value = (a2 - a1).to_degrees();
Some(Self { fixed_handle: fixed, moving_handle: moving, default_value })
}
fn build(&self, target_degrees: f64) -> Option<CmdResult> {
Some(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Angle,
refs: vec![SketchRef::whole(self.fixed_handle), SketchRef::whole(self.moving_handle)],
driving_param: Some(target_degrees),
label: "Angle constraint",
})
}
}
impl CadCommand for AngleConstraintCommand {
fn name(&self) -> &'static str {
"ACONSTRAINT"
}
fn prompt(&self) -> String {
format!("Specify angle in degrees <{:.4}>: ", self.default_value)
}
fn input_kind(&self) -> InputKind {
InputKind::SingleToken
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
self.build(self.default_value).unwrap_or(CmdResult::Cancel)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let value: f64 = text.trim().parse().ok()?;
self.build(value)
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
+16
View File
@@ -2,6 +2,7 @@
mod changelog;
pub mod clipboard;
pub mod constrain;
pub mod defaults;
mod donate;
pub mod draw;
@@ -33,6 +34,7 @@ impl CadModule for DrawModule {
};
use crate::modules::insert::{create_block, insert_block};
use clipboard::{copy_clip, cut, paste};
use constrain::{angle_tool, coincident_tool, distance_tool, equal, horizontal, parallel, perpendicular, tangent, vertical};
use draw::{arc, circle, ellipse, hatch, line, polyline, shapes};
use groups::{group, ungroup};
use layers::{
@@ -120,6 +122,20 @@ impl CadModule for DrawModule {
offset::tool().into(),
],
},
RibbonGroup {
title: "Constraints",
tools: vec![
horizontal::tool().into(),
vertical::tool().into(),
parallel::tool().into(),
perpendicular::tool().into(),
equal::tool().into(),
tangent::tool().into(),
coincident_tool::tool().into(),
distance_tool::tool().into(),
angle_tool::tool().into(),
],
},
RibbonGroup {
title: "Annotation",
tools: vec![