arael-sketch-solver 0.6.2

2D constraint-based sketch solver: entities, constraints, and optimization
Documentation
use arael::utils::Float as _;

// ---------------------------------------------------------------------------
// Line/arc visual style
// ---------------------------------------------------------------------------

#[derive(Clone, Copy, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
#[arael::model]
pub enum LineStyle {
    #[default]
    Solid,
    Dashed,
    DashDot,
}

impl LineStyle {
    pub fn next(self) -> Self {
        match self {
            LineStyle::Solid => LineStyle::Dashed,
            LineStyle::Dashed => LineStyle::DashDot,
            LineStyle::DashDot => LineStyle::Solid,
        }
    }
    pub fn from_name(s: &str) -> Option<Self> {
        match s {
            "solid" => Some(Self::Solid),
            "dashed" => Some(Self::Dashed),
            "dashdot" | "dash_dot" | "dash-dot" => Some(Self::DashDot),
            _ => None,
        }
    }
    pub fn name(self) -> &'static str {
        match self {
            Self::Solid => "solid",
            Self::Dashed => "dashed",
            Self::DashDot => "dashdot",
        }
    }
}

// ---------------------------------------------------------------------------
// Constraint data stored on entities (for guarded self-constraints)
// ---------------------------------------------------------------------------

#[derive(serde::Serialize, serde::Deserialize)]
#[arael::model]
pub struct PointConstraints {
    pub has_fix_x: bool,
    pub fix_x: f64,
    pub has_fix_y: bool,
    pub fix_y: f64,
}

#[derive(serde::Serialize, serde::Deserialize)]
#[arael::model]
pub struct LineConstraints {
    pub horizontal: bool,
    pub vertical: bool,
    pub has_length: bool,
    pub length: f64,
    #[serde(default)]
    pub has_angle: bool,
    #[serde(default)]
    pub target_angle: f64,
    /// Captured sign of (p2.x - p1.x) at the moment `horizontal` was
    /// applied. Used by the direction-preserving heaviside barrier to
    /// prevent p1/p2 from swapping along x. NaN means "not yet
    /// initialized -- derive from current geometry on next solve".
    /// Skipped on serialize because NaN doesn't survive JSON round-trips;
    /// derived fresh on load via `update_line_dir_flags`.
    #[serde(skip, default = "default_dir_sign_nan")]
    pub h_dir_sign: f64,
    /// Same as h_dir_sign but for `vertical` and (p2.y - p1.y).
    #[serde(skip, default = "default_dir_sign_nan")]
    pub v_dir_sign: f64,
}

fn default_dir_sign_nan() -> f64 { f64::NAN }

#[derive(serde::Serialize, serde::Deserialize)]
#[arael::model]
pub struct ArcConstraints {
    pub has_target_radius: bool,
    pub target_radius: f64,
    #[serde(default)]
    pub has_target_radius_b: bool,
    #[serde(default)]
    pub target_radius_b: f64,
    #[serde(default)]
    pub has_target_sweep: bool,
    #[serde(default)]
    pub target_sweep: f64,
    #[serde(default = "default_sweep_sign")]
    pub sweep_sign: f64,
    /// Ellipse rotation dimension flag. When true, the arc's
    /// rotation param is constrained to `target_rotation` (radians).
    /// Only meaningful when `is_ellipse`; for circular arcs rotation
    /// is `Param::fixed(0.0)` and the constraint is inert.
    #[serde(default)]
    pub has_target_rotation: bool,
    #[serde(default)]
    pub target_rotation: f64,
}

fn default_sweep_sign() -> f64 { 1.0 }
fn default_ccw() -> bool { true }
fn default_param_zero() -> arael::model::Param<f64> { arael::model::Param::fixed(0.0) }

// ---------------------------------------------------------------------------
// Entities
// ---------------------------------------------------------------------------

#[derive(serde::Serialize, serde::Deserialize)]
#[arael::model]
// Drift: weak regularizer
#[arael(constraint(hb, name = "drift", {
    let d = point.pos - point.pos_value;
    [d.x * sketch.drift_isigma, d.y * sketch.drift_isigma]
}))]
// Drag pull: soft attractor toward pos_value with per-point weight.
// Used by the editor's soft-drag helper to pull the helper (and, via
// a hard coincident constraint, the dragged endpoint) toward the
// cursor. Weight sits between drift_isigma (1e-3) and
// constraint_isigma (1e3) so it dominates other drifts but yields
// to any real constraint -- the sketch stays at cost ~ 0 and the
// dragged point lags if the cursor target is infeasible.
#[arael(constraint(hb, guard = self.drag_pull > 0.0, name = "drag_pull", {
    let d = point.pos - point.pos_value;
    [d.x * point.drag_pull, d.y * point.drag_pull]
}))]
// Fix X coordinate
#[arael(constraint(hb, guard = self.constraints.has_fix_x, name = "fix_x", {
    [(point.pos.x - point.constraints.fix_x) * sketch.constraint_isigma]
}))]
// Fix Y coordinate
#[arael(constraint(hb, guard = self.constraints.has_fix_y, name = "fix_y", {
    [(point.pos.y - point.constraints.fix_y) * sketch.constraint_isigma]
}))]
pub struct Point {
    pub pos: Param<vect2d>,
    pub constraints: PointConstraints,
    pub helper: bool,
    #[serde(default)]
    pub quiet: bool,
    pub name: String,
    /// Weight of the drag-pull attractor toward `pos.value`. Zero (the
    /// default) disables the attractor; set to e.g. 1.0 on an
    /// editor-owned helper point during a drag so the solver softly
    /// tracks the cursor without overriding hard constraints.
    #[serde(skip)]
    pub drag_pull: f64,
    #[arael(constraint_index)]
    #[serde(skip)]
    pub cid: u32,
    #[serde(skip)]
    pub hb: SelfBlock<Point>,
}

#[derive(serde::Serialize, serde::Deserialize)]
#[arael::model]
// Drift: weak regularizer on both endpoints
#[arael(constraint(hb, name = "drift", {
    let d1 = line.p1 - line.p1_value;
    let d2 = line.p2 - line.p2_value;
    [d1.x * sketch.drift_isigma, d1.y * sketch.drift_isigma,
     d2.x * sketch.drift_isigma, d2.y * sketch.drift_isigma]
}))]
// Drift: weak regularizer on length (epsilon avoids sqrt singularity at zero length)
#[arael(constraint(hb, name = "drift_length", {
    let dx = line.p2.x - line.p1.x;
    let dy = line.p2.y - line.p1.y;
    let dx0 = line.p2_value.x - line.p1_value.x;
    let dy0 = line.p2_value.y - line.p1_value.y;
    [(sqrt(dx * dx + dy * dy + 1e-6) - sqrt(dx0 * dx0 + dy0 * dy0 + 1e-6)) * sketch.drift_isigma]
}))]
// Drift: weak regularizer on angle (safe_atan2 avoids NaN at zero length)
#[arael(constraint(hb, name = "drift_angle", {
    let angle = safe_atan2(line.p2.y - line.p1.y, line.p2.x - line.p1.x);
    let angle0 = safe_atan2(line.p2_value.y - line.p1_value.y, line.p2_value.x - line.p1_value.x);
    [rad_diff(angle, angle0) * sketch.drift_isigma]
}))]
// Horizontal: p1.y == p2.y
#[arael(constraint(hb, guard = self.constraints.horizontal, name = "horizontal", {
    [(line.p1.y - line.p2.y) * sketch.constraint_isigma]
}))]
// Horizontal direction preserver. Linear one-sided barrier: when
// (p2.x - p1.x) has flipped sign from the value captured at the time
// `horizontal` was applied, d > 0 and the residual pushes back.
// Linear form (heaviside*d) gives full restoring gradient at d=0+ so
// it resists active flip drivers (drags, competing constraints)
// immediately -- the quadratic form used by min_length would let the
// line slip through the boundary before engaging.
#[arael(constraint(hb, guard = self.constraints.horizontal, name = "horizontal_dir", {
    let d = -line.constraints.h_dir_sign * (line.p2.x - line.p1.x);
    [heaviside(d) * d * sketch.constraint_isigma]
}))]
// Vertical: p1.x == p2.x
#[arael(constraint(hb, guard = self.constraints.vertical, name = "vertical", {
    [(line.p1.x - line.p2.x) * sketch.constraint_isigma]
}))]
// Vertical direction preserver (see horizontal_dir for the reasoning).
#[arael(constraint(hb, guard = self.constraints.vertical, name = "vertical_dir", {
    let d = -line.constraints.v_dir_sign * (line.p2.y - line.p1.y);
    [heaviside(d) * d * sketch.constraint_isigma]
}))]
// Length
#[arael(constraint(hb, guard = self.constraints.has_length, name = "length_target", {
    let dx = line.p2.x - line.p1.x;
    let dy = line.p2.y - line.p1.y;
    [(sqrt(dx * dx + dy * dy) - line.constraints.length) * sketch.constraint_isigma]
}))]
// Angle from x-axis
#[arael(constraint(hb, guard = self.constraints.has_angle, name = "angle_target", {
    [(atan2(line.p2.y - line.p1.y, line.p2.x - line.p1.x) - line.constraints.target_angle) * sketch.constraint_isigma]
}))]
// Soft minimum length via squared heaviside penalty.
// Prevents line from collapsing to zero length (which makes direction undefined
// and breaks tangent/angle constraints). Same pattern as arc minimum radius.
// Uses length^2 directly to avoid sqrt singularity at zero.
#[arael(constraint(hb, name = "min_length", {
    let dx = line.p2.x - line.p1.x;
    let dy = line.p2.y - line.p1.y;
    let d = sketch.min_length * sketch.min_length - (dx * dx + dy * dy);
    [heaviside(d) * d * sketch.constraint_isigma * sketch.constraint_isigma]
}))]
pub struct Line {
    pub p1: Param<vect2d>,
    pub p2: Param<vect2d>,
    pub constraints: LineConstraints,
    pub style: LineStyle,
    #[serde(default)]
    pub construction: bool,
    #[serde(default)]
    pub quiet: bool,
    pub name: String,
    #[arael(constraint_index)]
    #[serde(skip)]
    pub cid: u32,
    #[serde(skip)]
    pub hb: SelfBlock<Line>,
}

#[derive(serde::Serialize, serde::Deserialize)]
#[arael::model]
// Drift: weak regularizer on center, radii, rotation, angles
#[arael(constraint(hb, name = "drift", {
    let dc = arc.center - arc.center_value;
    let dr = arc.radius - arc.radius_value;
    let drb = arc.radius_b - arc.radius_b_value;
    let drot = arc.rotation - arc.rotation_value;
    let dsa = arc.start_angle - arc.start_angle_value;
    let dea = arc.end_angle - arc.end_angle_value;
    [dc.x * sketch.drift_isigma, dc.y * sketch.drift_isigma,
     dr * sketch.drift_isigma, drb * sketch.drift_isigma,
     drot * sketch.drift_isigma,
     dsa * sketch.drift_isigma, dea * sketch.drift_isigma]
}))]
// Target radius (semi-major axis)
#[arael(constraint(hb, guard = self.constraints.has_target_radius, name = "radius_target", {
    [(arc.radius - arc.constraints.target_radius) * sketch.constraint_isigma]
}))]
// Target radius_b (semi-minor axis, for ellipses)
#[arael(constraint(hb, guard = self.constraints.has_target_radius_b, name = "radius_b_target", {
    [(arc.radius_b - arc.constraints.target_radius_b) * sketch.constraint_isigma]
}))]
// For non-ellipse arcs: radius_b must equal radius (rotation is Param::fixed so no constraint needed)
#[arael(constraint(hb, guard = !self.is_ellipse, name = "radius_b_eq_radius", {
    [(arc.radius_b - arc.radius) * sketch.constraint_isigma]
}))]
// EXPERIMENTAL: soft minimum radius via squared heaviside penalty.
// Prevents radius from going below 0.001. The squared penalty is smooth
// at the transition (value and gradient both zero at threshold).
// A proper solution would be bound-constrained optimization in the framework.
#[arael(constraint(hb, name = "min_radius", {
    let d = sketch.min_length - arc.radius;
    [heaviside(d) * d * d * sketch.constraint_isigma * sketch.constraint_isigma]
}))]
// EXPERIMENTAL: same for radius_b on ellipses.
#[arael(constraint(hb, guard = self.is_ellipse, name = "min_radius_b", {
    let d = sketch.min_length - arc.radius_b;
    [heaviside(d) * d * d * sketch.constraint_isigma * sketch.constraint_isigma]
}))]
// Target sweep angle (multiplied by radius for position-equivalent scaling)
#[arael(constraint(hb, guard = self.constraints.has_target_sweep, name = "sweep", {
    [(arc.end_angle - arc.start_angle - arc.constraints.sweep_sign * arc.constraints.target_sweep) * arc.radius * sketch.constraint_isigma]
}))]
// Target ellipse rotation. Only meaningful when is_ellipse (for
// circular arcs `rotation` is Param::fixed(0.0)). target_rotation is
// stored in radians. Residual is the raw angular error scaled by
// `constraint_isigma` only -- unlike `sweep`, we do NOT multiply by
// `arc.radius`, because that would let the solver collapse the
// radius as a cheap way to zero out a hard-to-reach target rotation
// (the min_radius barrier is too weak to fight a large rotation
// residual). Rotation is already dimensionless radians.
#[arael(constraint(hb, guard = self.constraints.has_target_rotation && self.is_ellipse, name = "rotation", {
    [(arc.rotation - arc.constraints.target_rotation) * sketch.constraint_isigma]
}))]
pub struct Arc {
    pub center: Param<vect2d>,
    pub radius: Param<f64>,
    #[serde(default = "default_param_zero")]
    pub radius_b: Param<f64>,
    #[serde(default = "default_param_zero")]
    pub rotation: Param<f64>,
    pub start_angle: Param<f64>,
    pub end_angle: Param<f64>,
    /// Full circle/ellipse (true) vs partial arc (false). When true, start/end
    /// angles are fixed and excluded from optimization.
    pub closed: bool,
    /// True for elliptic arcs/ellipses (radius_b and rotation are free params).
    /// False for circular arcs/circles (radius_b fixed to radius, rotation fixed to 0).
    #[serde(default)]
    pub is_ellipse: bool,
    /// Arc direction: true = counter-clockwise from start to end,
    /// false = clockwise. Determined at creation from the midpoint.
    #[serde(default = "default_ccw")]
    pub ccw: bool,
    pub style: LineStyle,
    #[serde(default)]
    pub construction: bool,
    #[serde(default)]
    pub quiet: bool,
    pub name: String,
    pub constraints: ArcConstraints,
    #[arael(constraint_index)]
    #[serde(skip)]
    pub cid: u32,
    #[serde(skip)]
    pub hb: SelfBlock<Arc>,
}