Skip to main content

brep_kernel/solvers/
sketch3d_solver.rs

1//! 3D sketch constraint solver — the free-POINT sibling of `assembly_solver`.
2//!
3//! A 3D sketch is a set of world-space points, the geometry that references
4//! them (points, lines, arcs, bezier chains, reference planes) and constraints
5//! between those points. Unlike the assembly solver, whose unknowns are the
6//! rigid-body POSES of parts, the unknowns here are the free points' `(x, y, z)`
7//! coordinates: a sketch line must stretch, so it is not a rigid body, and a
8//! point carries no rotation. What this solver shares with `assembly_solver`
9//! is everything else — the residual vocabulary (coincident, distance,
10//! point-on-plane, point-on-line, parallel, perpendicular, angle, tangent),
11//! the Levenberg-Marquardt schedule (`assembly_solver::lm_core`: the same
12//! central-difference Jacobian, the same Cholesky solve, the same λ up/down
13//! schedule and non-worsening best-max guard — copied rather than shared so
14//! the pinned assembly numerics never move under a sketch change), and the
15//! diagnostics contract (`dof` / `rank` / `redundant` / `status` /
16//! `conflicting`, plus per-point mobility from the Jacobian null space, the
17//! shape `sketch_solver::compute_diagnostics` publishes for 2D sketches).
18//!
19//! # Document
20//!
21//! `{points, geometries, constraints}`, typed as [`Sketch3dDoc`]:
22//!
23//! - a point is `{id, x, y, z, fixed?, construction?, externalReference?}`;
24//!   a `fixed` point is excluded from the unknowns (grounded);
25//! - a geometry is `{id, type, points: [ids], construction?}` with
26//!   `point = [p]`, `line = [a, b]`, `arc = [center, start, end]` (the minor
27//!   arc from start to end about the center, in the plane of the three points),
28//!   `bezier = [p0, c1, c2, p1, …]` (every 3 ids a new cubic span) and
29//!   `plane = [origin, normalTip]` (a reference plane: its normal is
30//!   `normalTip − origin`; always construction);
31//! - a constraint is `{id, type, points: [ids], value?}`, see
32//!   [`SKETCH3D_CONSTRAINT_TYPES`] for the vocabulary and the point layouts.
33//!
34//! An `arc` carries an IMPLIED equal-radius row (`‖start − center‖ =
35//! ‖end − center‖`), and a multi-span `bezier` an implied G1 row at every
36//! interior anchor (the two handles and the anchor collinear), exactly as the
37//! 2D solver's temporary `⇌` / `⏛` constraints do. Implied rows count towards
38//! rank and redundancy but are never named as conflicting.
39//!
40//! # Solve
41//!
42//! Levenberg-Marquardt on the damped normal equations over the free
43//! coordinates; the step is in the row space of the Jacobian, so an
44//! under-constrained sketch keeps its free degrees of freedom where the user
45//! left them. The solved coordinates are written back into the document and
46//! the diagnostics are returned alongside (see [`Sketch3dDiagnostics`]). A
47//! constraint set with no solution near the current configuration comes back
48//! as the least-squares best with `conflicting = true` and the violated
49//! constraints named — the editor paints them, it does not lose them.
50
51use crate::solver_linear_algebra::{row_reduce, solve_spd};
52use crate::Vec3;
53use serde::{Deserialize, Serialize};
54use serde_json::{Map, Value};
55use std::collections::HashMap;
56
57// ---------------------------------------------------------------------------
58// Tuning constants (mirroring assembly_solver's)
59// ---------------------------------------------------------------------------
60
61/// Maximum damping increases per outer iteration before abandoning the step.
62const MAX_DAMPING: usize = 16;
63/// Damping growth on a rejected step / Cholesky failure.
64const LAMBDA_UP: f64 = 8.0;
65/// Damping shrink after an accepted step.
66const LAMBDA_DOWN: f64 = 0.25;
67/// An accepted step below this (relative to scale) counts as converged.
68const STEP_REL_TOL: f64 = 1e-13;
69/// Central-difference step for the coordinate columns, times the model scale.
70const FD_STEP: f64 = 1e-6;
71/// Relative pivot tolerance for the numerical Jacobian rank.
72const RANK_REL_TOL: f64 = 1e-7;
73/// A null-space component below this (relative to the vector's max) does not
74/// count as mobility.
75const MOBILITY_TOL: f64 = 1e-6;
76/// A residual row still this far (in length units, relative to scale) from
77/// zero once the solve has come to rest is a conflict.
78const CONFLICT_REL_TOL: f64 = 1e-6;
79/// Directions shorter than this (relative to scale) carry no orientation.
80const DIR_EPS_REL: f64 = 1e-12;
81
82fn default_tolerance() -> f64 {
83    1e-10
84}
85
86fn default_max_iterations() -> usize {
87    200
88}
89
90// ---------------------------------------------------------------------------
91// Document types
92// ---------------------------------------------------------------------------
93
94/// A 3D sketch point: world coordinates plus the role flags the solver tracks.
95#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
96pub struct Sketch3dPoint {
97    /// Stable point id (a number in practice; strings are legal — keyed by
98    /// [`crate::sketch_id_key`]).
99    pub id: Value,
100    pub x: f64,
101    pub y: f64,
102    pub z: f64,
103    /// Grounded: excluded from the unknowns, never moves.
104    #[serde(default)]
105    pub fixed: bool,
106    /// Construction point: constrains but never models an edge or a vertex.
107    #[serde(default)]
108    pub construction: bool,
109    /// Adopted from a model reference (a picked vertex, an edge end, a plane
110    /// origin); re-resolved on every feature run.
111    #[serde(default, rename = "externalReference")]
112    pub external_reference: bool,
113}
114
115impl Sketch3dPoint {
116    pub fn position(&self) -> Vec3 {
117        Vec3::new(self.x, self.y, self.z)
118    }
119}
120
121/// A 3D sketch geometry (see the module doc for the point layouts).
122#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
123pub struct Sketch3dGeometry {
124    pub id: Value,
125    #[serde(rename = "type")]
126    pub geom_type: String,
127    #[serde(default)]
128    pub points: Vec<Value>,
129    /// Every field not modelled explicitly (`construction`, authoring
130    /// metadata) rides along so a load/save is lossless.
131    #[serde(flatten)]
132    pub extra: Map<String, Value>,
133}
134
135impl Sketch3dGeometry {
136    /// Whether this geometry is construction-only (never a model edge). A
137    /// reference `plane` is construction by nature.
138    pub fn construction(&self) -> bool {
139        self.geom_type == "plane"
140            || self
141                .extra
142                .get("construction")
143                .and_then(Value::as_bool)
144                .unwrap_or(false)
145    }
146}
147
148/// A 3D sketch constraint, kept as a transparent map so authoring metadata
149/// (expression text, label offsets) persists exactly.
150#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
151#[serde(transparent)]
152pub struct Sketch3dConstraint {
153    pub raw: Map<String, Value>,
154}
155
156impl Sketch3dConstraint {
157    pub fn ctype(&self) -> Option<&str> {
158        self.raw.get("type").and_then(Value::as_str)
159    }
160
161    pub fn id(&self) -> Option<&Value> {
162        self.raw.get("id")
163    }
164
165    pub fn points(&self) -> &[Value] {
166        self.raw
167            .get("points")
168            .and_then(Value::as_array)
169            .map(Vec::as_slice)
170            .unwrap_or(&[])
171    }
172
173    /// The dimensional value (a number; expression strings are evaluated by
174    /// the feature before the solver sees the doc).
175    pub fn value(&self) -> Option<f64> {
176        self.raw.get("value").and_then(Value::as_f64)
177    }
178}
179
180/// The editable 3D sketch document.
181#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
182pub struct Sketch3dDoc {
183    #[serde(default)]
184    pub points: Vec<Sketch3dPoint>,
185    #[serde(default)]
186    pub geometries: Vec<Sketch3dGeometry>,
187    #[serde(default)]
188    pub constraints: Vec<Sketch3dConstraint>,
189}
190
191impl Sketch3dDoc {
192    pub fn point(&self, id: &Value) -> Option<&Sketch3dPoint> {
193        let key = id_key(id);
194        self.points.iter().find(|p| id_key(&p.id) == key)
195    }
196
197    pub fn point_mut(&mut self, id: &Value) -> Option<&mut Sketch3dPoint> {
198        let key = id_key(id);
199        self.points.iter_mut().find(|p| id_key(&p.id) == key)
200    }
201
202    pub fn geometry(&self, id: &Value) -> Option<&Sketch3dGeometry> {
203        let key = id_key(id);
204        self.geometries.iter().find(|g| id_key(&g.id) == key)
205    }
206
207    pub fn geometry_mut(&mut self, id: &Value) -> Option<&mut Sketch3dGeometry> {
208        let key = id_key(id);
209        self.geometries.iter_mut().find(|g| id_key(&g.id) == key)
210    }
211
212    pub fn constraint(&self, id: &Value) -> Option<&Sketch3dConstraint> {
213        let key = id_key(id);
214        self.constraints
215            .iter()
216            .find(|c| c.id().map(id_key) == Some(key.clone()))
217    }
218
219    /// Mint the next unused POINT id (`max numeric id + 1`).
220    pub fn next_point_id(&self) -> Value {
221        mint_next_id(self.points.iter().map(|p| &p.id))
222    }
223
224    /// Mint the next unused GEOMETRY id.
225    pub fn next_geometry_id(&self) -> Value {
226        mint_next_id(self.geometries.iter().map(|g| &g.id))
227    }
228
229    /// Mint the next unused CONSTRAINT id.
230    pub fn next_constraint_id(&self) -> Value {
231        mint_next_id(self.constraints.iter().filter_map(|c| c.id()))
232    }
233
234    /// Add a free point at `p` and return its id.
235    pub fn add_point(&mut self, p: Vec3) -> Value {
236        let id = self.next_point_id();
237        self.points.push(Sketch3dPoint {
238            id: id.clone(),
239            x: p.x,
240            y: p.y,
241            z: p.z,
242            fixed: false,
243            construction: false,
244            external_reference: false,
245        });
246        id
247    }
248
249    /// Return the id of the existing point within `radius` of `p` (the nearest
250    /// one), else mint a new free point there.
251    pub fn snap_or_add_point(&mut self, p: Vec3, radius: f64) -> Value {
252        let mut best: Option<(f64, Value)> = None;
253        for point in &self.points {
254            let d = point.position().sub(p).length();
255            if d <= radius && best.as_ref().map_or(true, |(bd, _)| d < *bd) {
256                best = Some((d, point.id.clone()));
257            }
258        }
259        match best {
260            Some((_, id)) => id,
261            None => self.add_point(p),
262        }
263    }
264
265    /// Add a geometry of `geom_type` over `points` and return its id.
266    pub fn add_geometry(&mut self, geom_type: &str, points: Vec<Value>, construction: bool) -> Value {
267        let id = self.next_geometry_id();
268        let mut extra = Map::new();
269        if construction {
270            extra.insert("construction".into(), Value::Bool(true));
271        }
272        self.geometries.push(Sketch3dGeometry {
273            id: id.clone(),
274            geom_type: geom_type.to_string(),
275            points,
276            extra,
277        });
278        id
279    }
280
281    /// Add a constraint and return its id. `value` is the dimension for the
282    /// dimensional types (`None` for the geometric ones).
283    pub fn add_constraint(&mut self, ctype: &str, points: Vec<Value>, value: Option<f64>) -> Value {
284        let id = self.next_constraint_id();
285        let mut raw = Map::new();
286        raw.insert("id".into(), id.clone());
287        raw.insert("type".into(), Value::String(ctype.to_string()));
288        raw.insert("points".into(), Value::Array(points));
289        if let Some(value) = value {
290            raw.insert("value".into(), Value::from(value));
291        }
292        self.constraints.push(Sketch3dConstraint { raw });
293        id
294    }
295
296    /// The point ids a geometry carries, resolved to positions (`None` when any
297    /// id is missing).
298    pub fn geometry_positions(&self, geometry: &Sketch3dGeometry) -> Option<Vec<Vec3>> {
299        geometry
300            .points
301            .iter()
302            .map(|id| self.point(id).map(Sketch3dPoint::position))
303            .collect()
304    }
305}
306
307/// Shared solver key for sketch entity and mobility-map lookups.
308pub use crate::sketch_solver::sketch_id_key as id_key;
309
310fn id_num(value: &Value) -> Option<f64> {
311    match value {
312        Value::Number(number) => number.as_f64(),
313        Value::String(text) => text.trim().parse::<f64>().ok(),
314        _ => None,
315    }
316}
317
318fn mint_next_id<'a>(ids: impl Iterator<Item = &'a Value>) -> Value {
319    let ids: Vec<&Value> = ids.collect();
320    let mut max: Option<i64> = None;
321    for &id in &ids {
322        if let Some(n) = id_num(id) {
323            if n.is_finite() {
324                let i = n.floor() as i64;
325                max = Some(max.map_or(i, |m| m.max(i)));
326            }
327        }
328    }
329    let mut candidate = max.map(|m| m + 1).unwrap_or(ids.len() as i64);
330    let existing: std::collections::HashSet<String> = ids.iter().map(|&id| id_key(id)).collect();
331    while existing.contains(&id_key(&Value::from(candidate))) {
332        candidate += 1;
333    }
334    Value::from(candidate)
335}
336
337// ---------------------------------------------------------------------------
338// Constraint vocabulary
339// ---------------------------------------------------------------------------
340
341/// One constraint type: its `type` string, how many point ids it takes, whether
342/// it carries a `value`, and a one-line meaning (the editor's palette reads
343/// these; the solver dispatches on the string).
344#[derive(Clone, Copy, Debug)]
345pub struct Sketch3dConstraintType {
346    pub name: &'static str,
347    /// The point-id layout: how many ids `points` carries, in which roles.
348    pub arity: usize,
349    /// Whether the constraint carries a dimensional `value`.
350    pub dimensional: bool,
351    pub meaning: &'static str,
352}
353
354/// The constraint vocabulary. Point layouts: a LINE is its two endpoints
355/// `[a0, a1]` (direction `a1 − a0`); a PLANE is `[origin, normalTip]`
356/// (normal `normalTip − origin`); an ARC is `[center, start, end]`.
357pub const SKETCH3D_CONSTRAINT_TYPES: &[Sketch3dConstraintType] = &[
358    Sketch3dConstraintType { name: "fixed", arity: 1, dimensional: false, meaning: "ground a point where it is" },
359    Sketch3dConstraintType { name: "coincident", arity: 2, dimensional: false, meaning: "two points touch" },
360    Sketch3dConstraintType { name: "distance", arity: 2, dimensional: true, meaning: "distance between two points (a line's length)" },
361    Sketch3dConstraintType { name: "onLine", arity: 3, dimensional: false, meaning: "[p, a0, a1]: point on the infinite line a0→a1" },
362    Sketch3dConstraintType { name: "lineDistance", arity: 3, dimensional: true, meaning: "[p, a0, a1]: distance from the point to the infinite line" },
363    Sketch3dConstraintType { name: "onPlane", arity: 3, dimensional: false, meaning: "[p, origin, normalTip]: point on the plane" },
364    Sketch3dConstraintType { name: "planeDistance", arity: 3, dimensional: true, meaning: "[p, origin, normalTip]: signed height of the point above the plane" },
365    Sketch3dConstraintType { name: "parallel", arity: 4, dimensional: false, meaning: "[a0, a1, b0, b1]: the two directions are parallel (either sense)" },
366    Sketch3dConstraintType { name: "perpendicular", arity: 4, dimensional: false, meaning: "[a0, a1, b0, b1]: the two directions are perpendicular" },
367    Sketch3dConstraintType { name: "angle", arity: 4, dimensional: true, meaning: "[a0, a1, b0, b1]: angle in degrees between the two directions" },
368    Sketch3dConstraintType { name: "alongX", arity: 2, dimensional: false, meaning: "the line runs along world X" },
369    Sketch3dConstraintType { name: "alongY", arity: 2, dimensional: false, meaning: "the line runs along world Y" },
370    Sketch3dConstraintType { name: "alongZ", arity: 2, dimensional: false, meaning: "the line runs along world Z" },
371    Sketch3dConstraintType { name: "equal", arity: 4, dimensional: false, meaning: "[a0, a1, b0, b1]: equal lengths (an arc's radius is center→start)" },
372    Sketch3dConstraintType { name: "tangent", arity: 5, dimensional: false, meaning: "[a0, a1, center, start, end]: the line is tangent to the arc at the arc end it shares" },
373];
374
375/// Look up a constraint type by its `type` string.
376pub fn sketch3d_constraint_type(name: &str) -> Option<&'static Sketch3dConstraintType> {
377    SKETCH3D_CONSTRAINT_TYPES.iter().find(|t| t.name == name)
378}
379
380// ---------------------------------------------------------------------------
381// Request / options / diagnostics
382// ---------------------------------------------------------------------------
383
384#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
385pub struct Sketch3dSolveOptions {
386    /// Convergence target for the maximum residual, relative to the sketch's
387    /// length scale.
388    #[serde(default = "default_tolerance")]
389    pub tolerance: f64,
390    /// Maximum accepted Levenberg-Marquardt steps.
391    #[serde(default = "default_max_iterations")]
392    pub max_iterations: usize,
393}
394
395impl Default for Sketch3dSolveOptions {
396    fn default() -> Self {
397        Self {
398            tolerance: default_tolerance(),
399            max_iterations: default_max_iterations(),
400        }
401    }
402}
403
404/// The JSON request: the document plus optional solver options.
405#[derive(Debug, Deserialize)]
406pub struct SolveSketch3dRequest {
407    pub sketch: Value,
408    #[serde(default)]
409    pub tolerance: Option<f64>,
410    #[serde(default)]
411    pub max_iterations: Option<usize>,
412}
413
414/// The solver's read-only diagnostics: the same shape the 2D sketch solver
415/// publishes (`dof`, `rank`, `unknowns`, `equations`, `redundant`, `status`,
416/// `conflicting`, `conflictingConstraints`, `pointMobility`,
417/// `geometryMobility`), so the editor's readouts are shared.
418#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
419pub struct Sketch3dDiagnostics {
420    #[serde(default)]
421    pub dof: i64,
422    #[serde(default)]
423    pub rank: i64,
424    #[serde(default)]
425    pub unknowns: i64,
426    #[serde(default)]
427    pub equations: i64,
428    #[serde(default)]
429    pub redundant: i64,
430    /// `"well"`, `"under"` or `"over"`.
431    #[serde(default)]
432    pub status: String,
433    #[serde(default)]
434    pub conflicting: bool,
435    /// The constraints (as [`id_key`] strings) still violated at the
436    /// least-squares rest point. Empty exactly when `conflicting` is clear.
437    #[serde(default, rename = "conflictingConstraints")]
438    pub conflicting_constraints: Vec<String>,
439    /// `id_key -> "movable" | "locked"` per point.
440    #[serde(default, rename = "pointMobility")]
441    pub point_mobility: std::collections::BTreeMap<String, String>,
442    #[serde(default, rename = "geometryMobility")]
443    pub geometry_mobility: std::collections::BTreeMap<String, String>,
444    /// Accepted Levenberg-Marquardt steps.
445    #[serde(default)]
446    pub iterations: i64,
447    /// The worst residual row at the solution, in length units.
448    #[serde(default, rename = "maxResidual")]
449    pub max_residual: f64,
450}
451
452impl Sketch3dDiagnostics {
453    pub fn point_movable(&self, id: &Value) -> Option<bool> {
454        self.point_mobility.get(&id_key(id)).map(|v| v == "movable")
455    }
456
457    pub fn geometry_movable(&self, id: &Value) -> Option<bool> {
458        self.geometry_mobility.get(&id_key(id)).map(|v| v == "movable")
459    }
460
461    pub fn constraint_conflicting(&self, id: &Value) -> bool {
462        if self.conflicting_constraints.is_empty() {
463            return false;
464        }
465        let key = id_key(id);
466        self.conflicting_constraints.iter().any(|c| *c == key)
467    }
468}
469
470// ---------------------------------------------------------------------------
471// Residual atoms over world points
472// ---------------------------------------------------------------------------
473
474/// One residual atom over point INDICES into the coordinate array. `rows()`
475/// is the number of scalar rows; `eff_rows()` the atom's rank at a solution
476/// (a 3-row cross-product block only ever removes 2 DOF).
477#[derive(Clone, Copy, Debug)]
478enum Atom {
479    /// `a − b` (3 rows, eff 3).
480    Coincide(usize, usize),
481    /// `‖a − b‖ − target` (1 row).
482    Distance(usize, usize, f64),
483    /// Component of `p − o` perpendicular to `q − o` (3 rows, eff 2): point on
484    /// the infinite line `o → q`.
485    Lateral(usize, usize, usize),
486    /// `‖(p − o) ⊥ (q − o)‖ − target` (1 row).
487    LineDistance(usize, usize, usize, f64),
488    /// `n̂·(p − o) − target` with `n̂ = unit(t − o)` (1 row).
489    PlaneOffset(usize, usize, usize, f64),
490    /// `unit(a1 − a0) × unit(b1 − b0)` (3 rows, eff 2).
491    DirCross(usize, usize, usize, usize),
492    /// `unit(a1 − a0) · unit(b1 − b0) − target` (1 row).
493    DirDot(usize, usize, usize, usize, f64),
494    /// `unit(a1 − a0) × axis` (3 rows, eff 2).
495    AxisCross(usize, usize, Vec3),
496    /// `‖a1 − a0‖ − ‖b1 − b0‖` (1 row).
497    EqualLength(usize, usize, usize, usize),
498    /// `unit(t − c) · unit(a1 − a0)` (1 row): the line is perpendicular to
499    /// the arc radius at the touch point `t`.
500    TangentTouch(usize, usize, usize, usize),
501    /// `unit(a1 − a0) · unit((s − c) × (e − c))` (1 row): the line lies in
502    /// the arc's plane.
503    InArcPlane(usize, usize, usize, usize, usize),
504}
505
506impl Atom {
507    fn rows(&self) -> usize {
508        match self {
509            Atom::Coincide(..) | Atom::Lateral(..) | Atom::DirCross(..) | Atom::AxisCross(..) => 3,
510            Atom::Distance(..)
511            | Atom::LineDistance(..)
512            | Atom::PlaneOffset(..)
513            | Atom::DirDot(..)
514            | Atom::EqualLength(..)
515            | Atom::TangentTouch(..)
516            | Atom::InArcPlane(..) => 1,
517        }
518    }
519
520    fn eff_rows(&self) -> usize {
521        match self {
522            Atom::Coincide(..) => 3,
523            Atom::Lateral(..) | Atom::DirCross(..) | Atom::AxisCross(..) => 2,
524            _ => 1,
525        }
526    }
527
528    fn eval(&self, p: &[Vec3], dir_eps: f64, out: &mut Vec<f64>) {
529        let unit = |v: Vec3| {
530            let len = v.length();
531            if len > dir_eps {
532                v.scale(1.0 / len)
533            } else {
534                v.scale(1.0 / dir_eps)
535            }
536        };
537        match *self {
538            Atom::Coincide(a, b) => {
539                let d = p[a].sub(p[b]);
540                out.extend([d.x, d.y, d.z]);
541            }
542            Atom::Distance(a, b, target) => out.push(p[a].sub(p[b]).length() - target),
543            Atom::Lateral(pt, o, q) => {
544                let d = unit(p[q].sub(p[o]));
545                let v = p[pt].sub(p[o]);
546                let lateral = v.sub(d.scale(d.dot(v)));
547                out.extend([lateral.x, lateral.y, lateral.z]);
548            }
549            Atom::LineDistance(pt, o, q, target) => {
550                let d = unit(p[q].sub(p[o]));
551                let v = p[pt].sub(p[o]);
552                let lateral = v.sub(d.scale(d.dot(v)));
553                out.push(lateral.length() - target);
554            }
555            Atom::PlaneOffset(pt, o, t, target) => {
556                let n = unit(p[t].sub(p[o]));
557                out.push(n.dot(p[pt].sub(p[o])) - target);
558            }
559            Atom::DirCross(a0, a1, b0, b1) => {
560                let c = unit(p[a1].sub(p[a0])).cross(unit(p[b1].sub(p[b0])));
561                out.extend([c.x, c.y, c.z]);
562            }
563            Atom::DirDot(a0, a1, b0, b1, target) => {
564                out.push(unit(p[a1].sub(p[a0])).dot(unit(p[b1].sub(p[b0]))) - target);
565            }
566            Atom::AxisCross(a0, a1, axis) => {
567                let c = unit(p[a1].sub(p[a0])).cross(axis);
568                out.extend([c.x, c.y, c.z]);
569            }
570            Atom::EqualLength(a0, a1, b0, b1) => {
571                out.push(p[a1].sub(p[a0]).length() - p[b1].sub(p[b0]).length());
572            }
573            Atom::TangentTouch(a0, a1, c, t) => {
574                out.push(unit(p[t].sub(p[c])).dot(unit(p[a1].sub(p[a0]))));
575            }
576            Atom::InArcPlane(a0, a1, c, s, e) => {
577                let n = unit(p[s].sub(p[c]).cross(p[e].sub(p[c])));
578                out.push(unit(p[a1].sub(p[a0])).dot(n));
579            }
580        }
581    }
582}
583
584// ---------------------------------------------------------------------------
585// Problem preparation
586// ---------------------------------------------------------------------------
587
588struct Prepared {
589    /// Every point's coordinates (fixed ones never change).
590    coords: Vec<Vec3>,
591    /// Indices of the free points, ascending.
592    free: Vec<usize>,
593    atoms: Vec<Atom>,
594    /// Atom → owning constraint index, or `None` for an implied row.
595    atom_owner: Vec<Option<usize>>,
596    m: usize,
597    effective_rows: usize,
598    scale: f64,
599    tight: f64,
600}
601
602fn prepare(doc: &Sketch3dDoc, options: &Sketch3dSolveOptions) -> Result<Prepared, String> {
603    let mut index: HashMap<String, usize> = HashMap::with_capacity(doc.points.len());
604    let mut coords: Vec<Vec3> = Vec::with_capacity(doc.points.len());
605    let mut fixed: Vec<bool> = Vec::with_capacity(doc.points.len());
606    for (i, point) in doc.points.iter().enumerate() {
607        if !(point.x.is_finite() && point.y.is_finite() && point.z.is_finite()) {
608            return Err(format!("sketch3d: point {} has a non-finite coordinate", id_key(&point.id)));
609        }
610        // A duplicate id: the later one wins (matches the 2D solver's map).
611        index.insert(id_key(&point.id), i);
612        coords.push(point.position());
613        fixed.push(point.fixed);
614    }
615    let lookup = |what: &str, id: &Value| -> Result<usize, String> {
616        index
617            .get(&id_key(id))
618            .copied()
619            .ok_or_else(|| format!("sketch3d: {what} references unknown point {}", id_key(id)))
620    };
621
622    let mut atoms: Vec<Atom> = Vec::new();
623    let mut atom_owner: Vec<Option<usize>> = Vec::new();
624
625    // User constraints.
626    for (ci, constraint) in doc.constraints.iter().enumerate() {
627        let Some(ctype) = constraint.ctype() else {
628            return Err(format!("sketch3d: constraint {ci} has no type"));
629        };
630        let spec = sketch3d_constraint_type(ctype)
631            .ok_or_else(|| format!("sketch3d: unknown constraint type '{ctype}'"))?;
632        let ids = constraint.points();
633        if ids.len() < spec.arity {
634            return Err(format!(
635                "sketch3d: constraint '{ctype}' needs {} points, got {}",
636                spec.arity,
637                ids.len()
638            ));
639        }
640        let what = format!("constraint '{ctype}'");
641        let mut idx = Vec::with_capacity(spec.arity);
642        for id in &ids[..spec.arity] {
643            idx.push(lookup(&what, id)?);
644        }
645        let value = || -> Result<f64, String> {
646            constraint
647                .value()
648                .filter(|v| v.is_finite())
649                .ok_or_else(|| format!("sketch3d: constraint '{ctype}' needs a finite value"))
650        };
651        let mut push = |atom: Atom| {
652            atoms.push(atom);
653            atom_owner.push(Some(ci));
654        };
655        match ctype {
656            "fixed" => fixed[idx[0]] = true,
657            "coincident" => push(Atom::Coincide(idx[0], idx[1])),
658            "distance" => {
659                let target = value()?;
660                if target.abs() <= 0.0 {
661                    push(Atom::Coincide(idx[0], idx[1]));
662                } else {
663                    push(Atom::Distance(idx[0], idx[1], target.abs()));
664                }
665            }
666            "onLine" => push(Atom::Lateral(idx[0], idx[1], idx[2])),
667            "lineDistance" => {
668                let target = value()?;
669                if target.abs() <= 0.0 {
670                    push(Atom::Lateral(idx[0], idx[1], idx[2]));
671                } else {
672                    push(Atom::LineDistance(idx[0], idx[1], idx[2], target.abs()));
673                }
674            }
675            "onPlane" => push(Atom::PlaneOffset(idx[0], idx[1], idx[2], 0.0)),
676            "planeDistance" => push(Atom::PlaneOffset(idx[0], idx[1], idx[2], value()?)),
677            "parallel" => push(Atom::DirCross(idx[0], idx[1], idx[2], idx[3])),
678            "perpendicular" => push(Atom::DirDot(idx[0], idx[1], idx[2], idx[3], 0.0)),
679            "angle" => {
680                let target = value()?.to_radians().cos();
681                push(Atom::DirDot(idx[0], idx[1], idx[2], idx[3], target));
682            }
683            "alongX" => push(Atom::AxisCross(idx[0], idx[1], Vec3::new(1.0, 0.0, 0.0))),
684            "alongY" => push(Atom::AxisCross(idx[0], idx[1], Vec3::new(0.0, 1.0, 0.0))),
685            "alongZ" => push(Atom::AxisCross(idx[0], idx[1], Vec3::new(0.0, 0.0, 1.0))),
686            "equal" => push(Atom::EqualLength(idx[0], idx[1], idx[2], idx[3])),
687            "tangent" => {
688                // The touch point is the arc end the line shares (by id, else
689                // the nearer one): [a0, a1, center, start, end].
690                let (a0, a1, c, s, e) = (idx[0], idx[1], idx[2], idx[3], idx[4]);
691                let touch = if s == a0 || s == a1 {
692                    s
693                } else if e == a0 || e == a1 {
694                    e
695                } else {
696                    let near = |t: usize| {
697                        coords[t]
698                            .sub(coords[a0])
699                            .length()
700                            .min(coords[t].sub(coords[a1]).length())
701                    };
702                    if near(s) <= near(e) {
703                        s
704                    } else {
705                        e
706                    }
707                };
708                push(Atom::TangentTouch(a0, a1, c, touch));
709                push(Atom::InArcPlane(a0, a1, c, s, e));
710            }
711            other => return Err(format!("sketch3d: unknown constraint type '{other}'")),
712        }
713    }
714
715    // Implied rows: arc radius equality, bezier G1 at interior anchors.
716    for geometry in &doc.geometries {
717        match geometry.geom_type.as_str() {
718            "arc" if geometry.points.len() >= 3 => {
719                let what = "arc geometry";
720                let c = lookup(what, &geometry.points[0])?;
721                let s = lookup(what, &geometry.points[1])?;
722                let e = lookup(what, &geometry.points[2])?;
723                atoms.push(Atom::EqualLength(c, s, c, e));
724                atom_owner.push(None);
725            }
726            "bezier" if geometry.points.len() >= 7 => {
727                let what = "bezier geometry";
728                let ids = &geometry.points;
729                let span_count = (ids.len() - 1) / 3;
730                let last_anchor = span_count * 3;
731                let mut i = 3usize;
732                while i < last_anchor {
733                    let prev = lookup(what, &ids[i - 1])?;
734                    let anchor = lookup(what, &ids[i])?;
735                    let next = lookup(what, &ids[i + 1])?;
736                    if prev != anchor && next != anchor && prev != next {
737                        atoms.push(Atom::Lateral(anchor, prev, next));
738                        atom_owner.push(None);
739                    }
740                    i += 3;
741                }
742            }
743            _ => {}
744        }
745    }
746
747    let free: Vec<usize> = (0..coords.len()).filter(|&i| !fixed[i]).collect();
748    let m: usize = atoms.iter().map(Atom::rows).sum();
749    let effective_rows: usize = atoms.iter().map(Atom::eff_rows).sum();
750
751    let mut scale = 1.0f64;
752    for c in &coords {
753        scale = scale.max(c.x.abs()).max(c.y.abs()).max(c.z.abs());
754    }
755    let tight = options.tolerance * scale;
756    Ok(Prepared {
757        coords,
758        free,
759        atoms,
760        atom_owner,
761        m,
762        effective_rows,
763        scale,
764        tight,
765    })
766}
767
768// ---------------------------------------------------------------------------
769// Numerics (mirroring assembly_solver::numerics)
770// ---------------------------------------------------------------------------
771
772fn apply_delta(base: &[Vec3], free: &[usize], delta: &[f64], out: &mut Vec<Vec3>) {
773    out.clear();
774    out.extend_from_slice(base);
775    for (k, &i) in free.iter().enumerate() {
776        out[i] = base[i].add(Vec3::new(delta[3 * k], delta[3 * k + 1], delta[3 * k + 2]));
777    }
778}
779
780fn eval_residuals(
781    atoms: &[Atom],
782    base: &[Vec3],
783    free: &[usize],
784    delta: &[f64],
785    dir_eps: f64,
786    scratch: &mut Vec<Vec3>,
787    out: &mut Vec<f64>,
788) {
789    apply_delta(base, free, delta, scratch);
790    out.clear();
791    for atom in atoms {
792        atom.eval(scratch, dir_eps, out);
793    }
794}
795
796fn finite_diff_jacobian(
797    atoms: &[Atom],
798    base: &[Vec3],
799    free: &[usize],
800    h: f64,
801    dir_eps: f64,
802    m: usize,
803) -> Vec<Vec<f64>> {
804    let n = 3 * free.len();
805    let mut jac = vec![vec![0.0f64; n]; m];
806    if n == 0 || m == 0 {
807        return jac;
808    }
809    let mut delta = vec![0.0f64; n];
810    let mut scratch: Vec<Vec3> = Vec::with_capacity(base.len());
811    let mut rp: Vec<f64> = Vec::with_capacity(m);
812    let mut rm: Vec<f64> = Vec::with_capacity(m);
813    let inv = 1.0 / (2.0 * h);
814    for col in 0..n {
815        delta[col] = h;
816        eval_residuals(atoms, base, free, &delta, dir_eps, &mut scratch, &mut rp);
817        delta[col] = -h;
818        eval_residuals(atoms, base, free, &delta, dir_eps, &mut scratch, &mut rm);
819        delta[col] = 0.0;
820        for i in 0..m {
821            jac[i][col] = (rp[i] - rm[i]) * inv;
822        }
823    }
824    jac
825}
826
827/// Levenberg-Marquardt over the free coordinates (`assembly_solver::lm_core`'s
828/// loop with point increments in place of pose increments). On return `coords`
829/// holds the best (max-residual) iterate found. Returns the accepted steps.
830fn lm_core(prep: &Prepared, coords: &mut Vec<Vec3>, max_iterations: usize) -> Result<usize, String> {
831    let atoms = &prep.atoms;
832    let free = &prep.free;
833    let n = 3 * free.len();
834    let m = prep.m;
835    let scale = prep.scale;
836    let dir_eps = DIR_EPS_REL * scale;
837    let h = FD_STEP * scale;
838    let step_tol = STEP_REL_TOL * scale;
839    let tight = prep.tight;
840
841    let max_abs = |v: &[f64]| v.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
842    let sum_sq = |v: &[f64]| v.iter().fold(0.0f64, |acc, &x| acc + x * x);
843
844    let zeros = vec![0.0f64; n];
845    let mut scratch: Vec<Vec3> = Vec::with_capacity(coords.len());
846    let mut r: Vec<f64> = Vec::with_capacity(m);
847    eval_residuals(atoms, coords, free, &zeros, dir_eps, &mut scratch, &mut r);
848    if !max_abs(&r).is_finite() {
849        return Err("sketch3d: non-finite residual at the initial coordinates".into());
850    }
851
852    let mut iterations = 0usize;
853    let mut s = sum_sq(&r);
854    let mut best_coords = coords.clone();
855    let mut best_max = max_abs(&r);
856    let mut lambda = -1.0f64;
857
858    if n > 0 && m > 0 {
859        'outer: for _iter in 0..max_iterations {
860            if best_max <= tight {
861                break;
862            }
863            let jac = finite_diff_jacobian(atoms, coords, free, h, dir_eps, m);
864            let mut hm = vec![vec![0.0f64; n]; n];
865            let mut g = vec![0.0f64; n];
866            for i in 0..m {
867                let row = &jac[i];
868                let ri = r[i];
869                for a in 0..n {
870                    let ja = row[a];
871                    if ja == 0.0 {
872                        continue;
873                    }
874                    g[a] += ja * ri;
875                    for b in a..n {
876                        hm[a][b] += ja * row[b];
877                    }
878                }
879            }
880            for a in 0..n {
881                for b in (a + 1)..n {
882                    hm[b][a] = hm[a][b];
883                }
884            }
885            let mut max_diag = 0.0f64;
886            for a in 0..n {
887                max_diag = max_diag.max(hm[a][a]);
888            }
889            if !(max_diag > 0.0) {
890                break;
891            }
892            if lambda < 0.0 {
893                lambda = 1e-9 * max_diag;
894            }
895            let lambda_ceiling = 1e12 * max_diag;
896
897            let mut stepped = false;
898            let mut step_small = false;
899            for _try in 0..MAX_DAMPING {
900                let mut a_mat = hm.clone();
901                for d in 0..n {
902                    a_mat[d][d] += lambda;
903                }
904                let mut delta: Vec<f64> = g.iter().map(|v| -v).collect();
905                if !solve_spd(&mut a_mat, &mut delta) {
906                    lambda *= LAMBDA_UP;
907                    if lambda > lambda_ceiling {
908                        break;
909                    }
910                    continue;
911                }
912                let mut r_trial: Vec<f64> = Vec::with_capacity(m);
913                eval_residuals(atoms, coords, free, &delta, dir_eps, &mut scratch, &mut r_trial);
914                let s_trial = sum_sq(&r_trial);
915                if s_trial.is_finite() && s_trial < s {
916                    let step_norm = sum_sq(&delta).sqrt();
917                    apply_delta(coords, free, &delta, &mut scratch);
918                    coords.copy_from_slice(&scratch);
919                    eval_residuals(atoms, coords, free, &zeros, dir_eps, &mut scratch, &mut r);
920                    s = sum_sq(&r);
921                    let m_now = max_abs(&r);
922                    if m_now < best_max {
923                        best_max = m_now;
924                        best_coords.copy_from_slice(coords);
925                    }
926                    lambda = (lambda * LAMBDA_DOWN).max(1e-12 * max_diag);
927                    iterations += 1;
928                    stepped = true;
929                    step_small = step_norm <= step_tol;
930                    break;
931                }
932                lambda *= LAMBDA_UP;
933                if lambda > lambda_ceiling {
934                    break;
935                }
936            }
937            if !stepped || step_small {
938                break 'outer;
939            }
940        }
941    }
942    coords.copy_from_slice(&best_coords);
943    Ok(iterations)
944}
945
946/// Numerical rank + a null-space basis of a dense `m × n` matrix (the 2D
947/// solver's `rank_and_nullspace`).
948fn rank_and_nullspace(mut a: Vec<Vec<f64>>, m: usize, n: usize) -> (usize, Vec<Vec<f64>>) {
949    if n == 0 {
950        return (0, Vec::new());
951    }
952    let mut maxabs = 0.0f64;
953    for row in &a {
954        for &v in row {
955            maxabs = maxabs.max(v.abs());
956        }
957    }
958    let tol = if maxabs > 0.0 {
959        RANK_REL_TOL * maxabs
960    } else {
961        RANK_REL_TOL
962    };
963    let mut pivot_cols: Vec<usize> = Vec::new();
964    row_reduce(&mut a, m, n, tol, |col| pivot_cols.push(col));
965    let rank = pivot_cols.len();
966    let mut is_pivot = vec![false; n];
967    for &pc in &pivot_cols {
968        is_pivot[pc] = true;
969    }
970    let mut basis: Vec<Vec<f64>> = Vec::new();
971    for free_col in 0..n {
972        if is_pivot[free_col] {
973            continue;
974        }
975        let mut v = vec![0.0f64; n];
976        v[free_col] = 1.0;
977        for (i, &pc) in pivot_cols.iter().enumerate() {
978            v[pc] = -a[i][free_col];
979        }
980        basis.push(v);
981    }
982    (rank, basis)
983}
984
985// ---------------------------------------------------------------------------
986// Public entry points
987// ---------------------------------------------------------------------------
988
989/// Solve `doc` in place: the free points take their solved coordinates and the
990/// diagnostics describe the constraint system at that configuration.
991pub fn solve_sketch3d_doc(
992    doc: &mut Sketch3dDoc,
993    options: &Sketch3dSolveOptions,
994) -> Result<Sketch3dDiagnostics, String> {
995    let prep = prepare(doc, options)?;
996    let mut coords = prep.coords.clone();
997    let iterations = lm_core(&prep, &mut coords, options.max_iterations)?;
998
999    let n = 3 * prep.free.len();
1000    let m = prep.m;
1001    let dir_eps = DIR_EPS_REL * prep.scale;
1002    let zeros = vec![0.0f64; n];
1003    let mut scratch: Vec<Vec3> = Vec::with_capacity(coords.len());
1004    let mut r: Vec<f64> = Vec::with_capacity(m);
1005    eval_residuals(&prep.atoms, &coords, &prep.free, &zeros, dir_eps, &mut scratch, &mut r);
1006
1007    let jac = finite_diff_jacobian(&prep.atoms, &coords, &prep.free, FD_STEP * prep.scale, dir_eps, m);
1008    // Per-row residual in LENGTH units: divide by the row's Jacobian norm so
1009    // a direction row (dimensionless) reads as the coordinate distance that
1010    // would zero it, like the 2D solver's `scaled_res`.
1011    let row_norms: Vec<f64> = jac
1012        .iter()
1013        .map(|row| row.iter().fold(0.0f64, |acc, &v| acc + v * v).sqrt())
1014        .collect();
1015    let max_row_norm = row_norms.iter().fold(0.0f64, |a, &v| a.max(v));
1016    let norm_floor = if max_row_norm > 0.0 { 1e-9 * max_row_norm } else { 1.0 };
1017    let scaled: Vec<f64> = r
1018        .iter()
1019        .zip(row_norms.iter())
1020        .map(|(&v, &norm)| v / norm.max(norm_floor))
1021        .collect();
1022    let max_residual = scaled.iter().fold(0.0f64, |acc, &v| acc.max(v.abs()));
1023
1024    let (rank, basis) = rank_and_nullspace(jac, m, n);
1025    let dof = n - rank;
1026    let redundant = prep.effective_rows.saturating_sub(rank);
1027
1028    // Conflict: a row still violated at rest names its constraint. Implied
1029    // rows (arc radius, bezier G1) are analysed but never named.
1030    let conflict_tol = CONFLICT_REL_TOL * prep.scale;
1031    let mut flagged = vec![false; doc.constraints.len()];
1032    let mut row = 0usize;
1033    for (ai, atom) in prep.atoms.iter().enumerate() {
1034        let rows = atom.rows();
1035        let violated = scaled[row..row + rows].iter().any(|v| v.abs() > conflict_tol);
1036        if violated {
1037            if let Some(owner) = prep.atom_owner[ai] {
1038                flagged[owner] = true;
1039            }
1040        }
1041        row += rows;
1042    }
1043    let mut conflicting_constraints: Vec<String> = Vec::new();
1044    for (ci, &hit) in flagged.iter().enumerate() {
1045        if hit {
1046            if let Some(id) = doc.constraints[ci].id() {
1047                conflicting_constraints.push(id_key(id));
1048            }
1049        }
1050    }
1051    // An implied row alone can be violated (an arc that cannot keep equal
1052    // radii under the user's dimensions): then the flag stands with the
1053    // geometry's constraints unnamed, so name every constraint touching a
1054    // violated implied row's points instead.
1055    if max_residual > conflict_tol && conflicting_constraints.is_empty() {
1056        let mut row = 0usize;
1057        for (ai, atom) in prep.atoms.iter().enumerate() {
1058            let rows = atom.rows();
1059            let violated = scaled[row..row + rows].iter().any(|v| v.abs() > conflict_tol);
1060            row += rows;
1061            if !violated || prep.atom_owner[ai].is_some() {
1062                continue;
1063            }
1064            for (ci, constraint) in doc.constraints.iter().enumerate() {
1065                if flagged[ci] {
1066                    continue;
1067                }
1068                if let Some(id) = constraint.id() {
1069                    flagged[ci] = true;
1070                    conflicting_constraints.push(id_key(id));
1071                }
1072            }
1073        }
1074    }
1075    let conflicting = !conflicting_constraints.is_empty();
1076
1077    let status = if redundant > 0 {
1078        "over"
1079    } else if dof > 0 {
1080        "under"
1081    } else {
1082        "well"
1083    };
1084
1085    // Mobility: a free point moves iff some null-space vector has a
1086    // component at one of its three columns.
1087    let basis_max: Vec<f64> = basis
1088        .iter()
1089        .map(|v| v.iter().fold(0.0f64, |a, &x| a.max(x.abs())).max(1e-300))
1090        .collect();
1091    let mut col_of: HashMap<usize, usize> = HashMap::with_capacity(prep.free.len());
1092    for (k, &i) in prep.free.iter().enumerate() {
1093        col_of.insert(i, k);
1094    }
1095    let mut point_mobility = std::collections::BTreeMap::new();
1096    for (i, point) in doc.points.iter().enumerate() {
1097        let movable = match col_of.get(&i) {
1098            None => false,
1099            Some(&k) => basis.iter().enumerate().any(|(b, v)| {
1100                let c = 3 * k;
1101                let comp = ((v[c] / basis_max[b]).powi(2)
1102                    + (v[c + 1] / basis_max[b]).powi(2)
1103                    + (v[c + 2] / basis_max[b]).powi(2))
1104                .sqrt();
1105                comp > MOBILITY_TOL
1106            }),
1107        };
1108        point_mobility.insert(
1109            id_key(&point.id),
1110            (if movable { "movable" } else { "locked" }).to_string(),
1111        );
1112    }
1113    let mut geometry_mobility = std::collections::BTreeMap::new();
1114    for geometry in &doc.geometries {
1115        let movable = geometry
1116            .points
1117            .iter()
1118            .any(|pid| point_mobility.get(&id_key(pid)).map(String::as_str) == Some("movable"));
1119        geometry_mobility.insert(
1120            id_key(&geometry.id),
1121            (if movable { "movable" } else { "locked" }).to_string(),
1122        );
1123    }
1124
1125    // Write the solved coordinates back.
1126    for (i, point) in doc.points.iter_mut().enumerate() {
1127        point.x = coords[i].x;
1128        point.y = coords[i].y;
1129        point.z = coords[i].z;
1130    }
1131
1132    Ok(Sketch3dDiagnostics {
1133        dof: dof as i64,
1134        rank: rank as i64,
1135        unknowns: n as i64,
1136        equations: m as i64,
1137        redundant: redundant as i64,
1138        status: status.into(),
1139        conflicting,
1140        conflicting_constraints,
1141        point_mobility,
1142        geometry_mobility,
1143        iterations: iterations as i64,
1144        max_residual,
1145    })
1146}
1147
1148/// Solve a JSON request: returns `{"sketch": {points, geometries, constraints,
1149/// diagnostics}}`, the 2D solver's response shape.
1150pub fn solve_sketch3d(request: &SolveSketch3dRequest) -> Result<Value, String> {
1151    let mut doc: Sketch3dDoc = serde_json::from_value(request.sketch.clone())
1152        .map_err(|e| format!("sketch3d: invalid sketch document: {e}"))?;
1153    let options = Sketch3dSolveOptions {
1154        tolerance: request.tolerance.unwrap_or_else(default_tolerance),
1155        max_iterations: request.max_iterations.unwrap_or_else(default_max_iterations),
1156    };
1157    let diagnostics = solve_sketch3d_doc(&mut doc, &options)?;
1158    let mut sketch = serde_json::to_value(&doc).map_err(|e| format!("sketch3d: {e}"))?;
1159    if let Some(map) = sketch.as_object_mut() {
1160        map.insert(
1161            "diagnostics".into(),
1162            serde_json::to_value(&diagnostics).map_err(|e| format!("sketch3d: {e}"))?,
1163        );
1164    }
1165    Ok(serde_json::json!({ "sketch": sketch }))
1166}
1167
1168/// Solve a JSON request given as text; the response is JSON text.
1169pub fn solve_sketch3d_from_json(request: &str) -> Result<String, String> {
1170    let request: SolveSketch3dRequest =
1171        serde_json::from_str(request).map_err(|e| format!("sketch3d: invalid request: {e}"))?;
1172    let response = solve_sketch3d(&request)?;
1173    serde_json::to_string(&response).map_err(|e| format!("sketch3d: {e}"))
1174}
1175