Skip to main content

arael_sketch_solver/
entities.rs

1use arael::utils::Float as _;
2
3// ---------------------------------------------------------------------------
4// Line/arc visual style
5// ---------------------------------------------------------------------------
6
7#[derive(Clone, Copy, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
8#[arael::model]
9pub enum LineStyle {
10    #[default]
11    Solid,
12    Dashed,
13    DashDot,
14}
15
16impl LineStyle {
17    pub fn next(self) -> Self {
18        match self {
19            LineStyle::Solid => LineStyle::Dashed,
20            LineStyle::Dashed => LineStyle::DashDot,
21            LineStyle::DashDot => LineStyle::Solid,
22        }
23    }
24    pub fn from_name(s: &str) -> Option<Self> {
25        match s {
26            "solid" => Some(Self::Solid),
27            "dashed" => Some(Self::Dashed),
28            "dashdot" | "dash_dot" | "dash-dot" => Some(Self::DashDot),
29            _ => None,
30        }
31    }
32    pub fn name(self) -> &'static str {
33        match self {
34            Self::Solid => "solid",
35            Self::Dashed => "dashed",
36            Self::DashDot => "dashdot",
37        }
38    }
39}
40
41// ---------------------------------------------------------------------------
42// Constraint data stored on entities (for guarded self-constraints)
43// ---------------------------------------------------------------------------
44
45#[derive(serde::Serialize, serde::Deserialize)]
46#[arael::model]
47pub struct PointConstraints {
48    pub has_fix_x: bool,
49    pub fix_x: f64,
50    pub has_fix_y: bool,
51    pub fix_y: f64,
52}
53
54#[derive(serde::Serialize, serde::Deserialize)]
55#[arael::model]
56pub struct LineConstraints {
57    pub horizontal: bool,
58    pub vertical: bool,
59    pub has_length: bool,
60    pub length: f64,
61    #[serde(default)]
62    pub has_angle: bool,
63    #[serde(default)]
64    pub target_angle: f64,
65    /// Captured sign of (p2.x - p1.x) at the moment `horizontal` was
66    /// applied. Used by the direction-preserving heaviside barrier to
67    /// prevent p1/p2 from swapping along x. NaN means "not yet
68    /// initialized -- derive from current geometry on next solve".
69    /// Skipped on serialize because NaN doesn't survive JSON round-trips;
70    /// derived fresh on load via `update_line_dir_flags`.
71    #[serde(skip, default = "default_dir_sign_nan")]
72    pub h_dir_sign: f64,
73    /// Same as h_dir_sign but for `vertical` and (p2.y - p1.y).
74    #[serde(skip, default = "default_dir_sign_nan")]
75    pub v_dir_sign: f64,
76}
77
78fn default_dir_sign_nan() -> f64 { f64::NAN }
79
80#[derive(serde::Serialize, serde::Deserialize)]
81#[arael::model]
82pub struct ArcConstraints {
83    pub has_target_radius: bool,
84    pub target_radius: f64,
85    #[serde(default)]
86    pub has_target_radius_b: bool,
87    #[serde(default)]
88    pub target_radius_b: f64,
89    #[serde(default)]
90    pub has_target_sweep: bool,
91    #[serde(default)]
92    pub target_sweep: f64,
93    #[serde(default = "default_sweep_sign")]
94    pub sweep_sign: f64,
95    /// Ellipse rotation dimension flag. When true, the arc's
96    /// rotation param is constrained to `target_rotation` (radians).
97    /// Only meaningful when `is_ellipse`; for circular arcs rotation
98    /// is `Param::fixed(0.0)` and the constraint is inert.
99    #[serde(default)]
100    pub has_target_rotation: bool,
101    #[serde(default)]
102    pub target_rotation: f64,
103}
104
105fn default_sweep_sign() -> f64 { 1.0 }
106fn default_ccw() -> bool { true }
107fn default_param_zero() -> arael::model::Param<f64> { arael::model::Param::fixed(0.0) }
108
109// ---------------------------------------------------------------------------
110// Entities
111// ---------------------------------------------------------------------------
112
113#[derive(serde::Serialize, serde::Deserialize)]
114#[arael::model]
115// Drift: weak regularizer
116#[arael(constraint(hb, name = "drift", {
117    let d = point.pos - point.pos_value;
118    [d.x * sketch.drift_isigma, d.y * sketch.drift_isigma]
119}))]
120// Drag pull: soft attractor toward pos_value with per-point weight.
121// Used by the editor's soft-drag helper to pull the helper (and, via
122// a hard coincident constraint, the dragged endpoint) toward the
123// cursor. Weight sits between drift_isigma (1e-3) and
124// constraint_isigma (1e3) so it dominates other drifts but yields
125// to any real constraint -- the sketch stays at cost ~ 0 and the
126// dragged point lags if the cursor target is infeasible.
127#[arael(constraint(hb, guard = self.drag_pull > 0.0, name = "drag_pull", {
128    let d = point.pos - point.pos_value;
129    [d.x * point.drag_pull, d.y * point.drag_pull]
130}))]
131// Fix X coordinate
132#[arael(constraint(hb, guard = self.constraints.has_fix_x, name = "fix_x", {
133    [(point.pos.x - point.constraints.fix_x) * sketch.constraint_isigma]
134}))]
135// Fix Y coordinate
136#[arael(constraint(hb, guard = self.constraints.has_fix_y, name = "fix_y", {
137    [(point.pos.y - point.constraints.fix_y) * sketch.constraint_isigma]
138}))]
139pub struct Point {
140    pub pos: Param<vect2d>,
141    pub constraints: PointConstraints,
142    pub helper: bool,
143    #[serde(default)]
144    pub quiet: bool,
145    pub name: String,
146    /// Weight of the drag-pull attractor toward `pos.value`. Zero (the
147    /// default) disables the attractor; set to e.g. 1.0 on an
148    /// editor-owned helper point during a drag so the solver softly
149    /// tracks the cursor without overriding hard constraints.
150    #[serde(skip)]
151    pub drag_pull: f64,
152    #[arael(constraint_index)]
153    #[serde(skip)]
154    pub cid: u32,
155    #[serde(skip)]
156    pub hb: SelfBlock<Point>,
157}
158
159#[derive(serde::Serialize, serde::Deserialize)]
160#[arael::model]
161// Drift: weak regularizer on both endpoints
162#[arael(constraint(hb, name = "drift", {
163    let d1 = line.p1 - line.p1_value;
164    let d2 = line.p2 - line.p2_value;
165    [d1.x * sketch.drift_isigma, d1.y * sketch.drift_isigma,
166     d2.x * sketch.drift_isigma, d2.y * sketch.drift_isigma]
167}))]
168// Drift: weak regularizer on length (epsilon avoids sqrt singularity at zero length)
169#[arael(constraint(hb, name = "drift_length", {
170    let dx = line.p2.x - line.p1.x;
171    let dy = line.p2.y - line.p1.y;
172    let dx0 = line.p2_value.x - line.p1_value.x;
173    let dy0 = line.p2_value.y - line.p1_value.y;
174    [(sqrt(dx * dx + dy * dy + 1e-6) - sqrt(dx0 * dx0 + dy0 * dy0 + 1e-6)) * sketch.drift_isigma]
175}))]
176// Drift: weak regularizer on angle (safe_atan2 avoids NaN at zero length)
177#[arael(constraint(hb, name = "drift_angle", {
178    let angle = safe_atan2(line.p2.y - line.p1.y, line.p2.x - line.p1.x);
179    let angle0 = safe_atan2(line.p2_value.y - line.p1_value.y, line.p2_value.x - line.p1_value.x);
180    [rad_diff(angle, angle0) * sketch.drift_isigma]
181}))]
182// Horizontal: p1.y == p2.y
183#[arael(constraint(hb, guard = self.constraints.horizontal, name = "horizontal", {
184    [(line.p1.y - line.p2.y) * sketch.constraint_isigma]
185}))]
186// Horizontal direction preserver. Linear one-sided barrier: when
187// (p2.x - p1.x) has flipped sign from the value captured at the time
188// `horizontal` was applied, d > 0 and the residual pushes back.
189// Linear form (heaviside*d) gives full restoring gradient at d=0+ so
190// it resists active flip drivers (drags, competing constraints)
191// immediately -- the quadratic form used by min_length would let the
192// line slip through the boundary before engaging.
193#[arael(constraint(hb, guard = self.constraints.horizontal, name = "horizontal_dir", {
194    let d = -line.constraints.h_dir_sign * (line.p2.x - line.p1.x);
195    [heaviside(d) * d * sketch.constraint_isigma]
196}))]
197// Vertical: p1.x == p2.x
198#[arael(constraint(hb, guard = self.constraints.vertical, name = "vertical", {
199    [(line.p1.x - line.p2.x) * sketch.constraint_isigma]
200}))]
201// Vertical direction preserver (see horizontal_dir for the reasoning).
202#[arael(constraint(hb, guard = self.constraints.vertical, name = "vertical_dir", {
203    let d = -line.constraints.v_dir_sign * (line.p2.y - line.p1.y);
204    [heaviside(d) * d * sketch.constraint_isigma]
205}))]
206// Length
207#[arael(constraint(hb, guard = self.constraints.has_length, name = "length_target", {
208    let dx = line.p2.x - line.p1.x;
209    let dy = line.p2.y - line.p1.y;
210    [(sqrt(dx * dx + dy * dy) - line.constraints.length) * sketch.constraint_isigma]
211}))]
212// Angle from x-axis
213#[arael(constraint(hb, guard = self.constraints.has_angle, name = "angle_target", {
214    [(atan2(line.p2.y - line.p1.y, line.p2.x - line.p1.x) - line.constraints.target_angle) * sketch.constraint_isigma]
215}))]
216// Soft minimum length via squared heaviside penalty.
217// Prevents line from collapsing to zero length (which makes direction undefined
218// and breaks tangent/angle constraints). Same pattern as arc minimum radius.
219// Uses length^2 directly to avoid sqrt singularity at zero.
220#[arael(constraint(hb, name = "min_length", {
221    let dx = line.p2.x - line.p1.x;
222    let dy = line.p2.y - line.p1.y;
223    let d = sketch.min_length * sketch.min_length - (dx * dx + dy * dy);
224    [heaviside(d) * d * sketch.constraint_isigma * sketch.constraint_isigma]
225}))]
226pub struct Line {
227    pub p1: Param<vect2d>,
228    pub p2: Param<vect2d>,
229    pub constraints: LineConstraints,
230    pub style: LineStyle,
231    #[serde(default)]
232    pub construction: bool,
233    #[serde(default)]
234    pub quiet: bool,
235    pub name: String,
236    #[arael(constraint_index)]
237    #[serde(skip)]
238    pub cid: u32,
239    #[serde(skip)]
240    pub hb: SelfBlock<Line>,
241}
242
243#[derive(serde::Serialize, serde::Deserialize)]
244#[arael::model]
245// Drift: weak regularizer on center, radii, rotation, angles
246#[arael(constraint(hb, name = "drift", {
247    let dc = arc.center - arc.center_value;
248    let dr = arc.radius - arc.radius_value;
249    let drb = arc.radius_b - arc.radius_b_value;
250    let drot = arc.rotation - arc.rotation_value;
251    let dsa = arc.start_angle - arc.start_angle_value;
252    let dea = arc.end_angle - arc.end_angle_value;
253    [dc.x * sketch.drift_isigma, dc.y * sketch.drift_isigma,
254     dr * sketch.drift_isigma, drb * sketch.drift_isigma,
255     drot * sketch.drift_isigma,
256     dsa * sketch.drift_isigma, dea * sketch.drift_isigma]
257}))]
258// Target radius (semi-major axis)
259#[arael(constraint(hb, guard = self.constraints.has_target_radius, name = "radius_target", {
260    [(arc.radius - arc.constraints.target_radius) * sketch.constraint_isigma]
261}))]
262// Target radius_b (semi-minor axis, for ellipses)
263#[arael(constraint(hb, guard = self.constraints.has_target_radius_b, name = "radius_b_target", {
264    [(arc.radius_b - arc.constraints.target_radius_b) * sketch.constraint_isigma]
265}))]
266// For non-ellipse arcs: radius_b must equal radius (rotation is Param::fixed so no constraint needed)
267#[arael(constraint(hb, guard = !self.is_ellipse, name = "radius_b_eq_radius", {
268    [(arc.radius_b - arc.radius) * sketch.constraint_isigma]
269}))]
270// EXPERIMENTAL: soft minimum radius via squared heaviside penalty.
271// Prevents radius from going below 0.001. The squared penalty is smooth
272// at the transition (value and gradient both zero at threshold).
273// A proper solution would be bound-constrained optimization in the framework.
274#[arael(constraint(hb, name = "min_radius", {
275    let d = sketch.min_length - arc.radius;
276    [heaviside(d) * d * d * sketch.constraint_isigma * sketch.constraint_isigma]
277}))]
278// EXPERIMENTAL: same for radius_b on ellipses.
279#[arael(constraint(hb, guard = self.is_ellipse, name = "min_radius_b", {
280    let d = sketch.min_length - arc.radius_b;
281    [heaviside(d) * d * d * sketch.constraint_isigma * sketch.constraint_isigma]
282}))]
283// Target sweep angle (multiplied by radius for position-equivalent scaling)
284#[arael(constraint(hb, guard = self.constraints.has_target_sweep, name = "sweep", {
285    [(arc.end_angle - arc.start_angle - arc.constraints.sweep_sign * arc.constraints.target_sweep) * arc.radius * sketch.constraint_isigma]
286}))]
287// Target ellipse rotation. Only meaningful when is_ellipse (for
288// circular arcs `rotation` is Param::fixed(0.0)). target_rotation is
289// stored in radians. Residual is the raw angular error scaled by
290// `constraint_isigma` only -- unlike `sweep`, we do NOT multiply by
291// `arc.radius`, because that would let the solver collapse the
292// radius as a cheap way to zero out a hard-to-reach target rotation
293// (the min_radius barrier is too weak to fight a large rotation
294// residual). Rotation is already dimensionless radians.
295#[arael(constraint(hb, guard = self.constraints.has_target_rotation && self.is_ellipse, name = "rotation", {
296    [(arc.rotation - arc.constraints.target_rotation) * sketch.constraint_isigma]
297}))]
298pub struct Arc {
299    pub center: Param<vect2d>,
300    pub radius: Param<f64>,
301    #[serde(default = "default_param_zero")]
302    pub radius_b: Param<f64>,
303    #[serde(default = "default_param_zero")]
304    pub rotation: Param<f64>,
305    pub start_angle: Param<f64>,
306    pub end_angle: Param<f64>,
307    /// Full circle/ellipse (true) vs partial arc (false). When true, start/end
308    /// angles are fixed and excluded from optimization.
309    pub closed: bool,
310    /// True for elliptic arcs/ellipses (radius_b and rotation are free params).
311    /// False for circular arcs/circles (radius_b fixed to radius, rotation fixed to 0).
312    #[serde(default)]
313    pub is_ellipse: bool,
314    /// Arc direction: true = counter-clockwise from start to end,
315    /// false = clockwise. Determined at creation from the midpoint.
316    #[serde(default = "default_ccw")]
317    pub ccw: bool,
318    pub style: LineStyle,
319    #[serde(default)]
320    pub construction: bool,
321    #[serde(default)]
322    pub quiet: bool,
323    pub name: String,
324    pub constraints: ArcConstraints,
325    #[arael(constraint_index)]
326    #[serde(skip)]
327    pub cid: u32,
328    #[serde(skip)]
329    pub hb: SelfBlock<Arc>,
330}