Skip to main content

dotloom_engine/
eval.rs

1//! Numeric evaluation of entities: world anchors, drawable shapes and texts.
2//!
3//! Built-in entities use their canonical geometry; dimensions are computed from
4//! the anchors they reference; plugin entities are evaluated from their compiled
5//! definition; entities whose plugin is missing, disabled or too old fall back to
6//! their stored standard representation and are read-only.
7
8use std::collections::BTreeMap;
9
10use dotloom_document::{
11    Entity, EntityId, PropValue,
12    builtin::{get_geometry_param, is_builtin, types},
13};
14use dotloom_geometry::{
15    Aabb, Anchor, AnchorKind, Arc, Circle, HAlign, Point, Polygon, Polyline, Segment, Shape, Text, TransformPolicy,
16    VAlign, Vector,
17    dimension::{self, DimensionStyle, LinearKind},
18    units::LengthUnit,
19};
20use serde::{Deserialize, Serialize};
21
22use crate::{
23    DocView,
24    lang::{Axis, Compiled, Leaf},
25    registry::{CompiledPrim, CompiledType, PrimStyle, Registry},
26};
27
28/// Maximum reference depth while evaluating anchors of referenced entities.
29pub const MAX_REF_DEPTH: usize = 16;
30
31/// Why an entity cannot be edited.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(tag = "reason", rename_all = "camelCase")]
34pub enum ReadOnly {
35    /// Plugin type not registered.
36    MissingPlugin {
37        /// Type.
38        type_id: String,
39    },
40    /// Plugin type registered but disabled.
41    DisabledPlugin {
42        /// Type.
43        type_id: String,
44    },
45    /// Entity was written by a newer version of the type.
46    NewerVersion {
47        /// Type.
48        type_id: String,
49        /// Entity version.
50        found: u32,
51        /// Registered version.
52        supported: u32,
53    },
54}
55
56/// A drawable piece.
57#[derive(Debug, Clone, PartialEq)]
58pub enum Drawable {
59    /// Shape with recipe style.
60    Shape(Shape, PrimStyle),
61    /// Text with recipe style.
62    Text(Text, PrimStyle),
63    /// Arrow head (dimensions).
64    Arrow {
65        /// Tip.
66        tip: Point,
67        /// Direction.
68        direction: Vector,
69        /// Size (model units).
70        size: f64,
71    },
72}
73
74/// Result of evaluating one entity.
75#[derive(Debug, Clone, PartialEq)]
76pub struct Evaluated {
77    /// World-space anchors.
78    pub anchors: Vec<Anchor>,
79    /// World-space drawables.
80    pub drawables: Vec<Drawable>,
81    /// Bounding box of drawables.
82    pub bbox: Aabb,
83    /// Set when the entity cannot be edited.
84    pub read_only: Option<ReadOnly>,
85    /// Evaluation problem (bad expression input, broken reference, ...).
86    pub error: Option<String>,
87    /// Measured value for dimensions (mm or rad).
88    pub measured: Option<f64>,
89}
90
91impl Evaluated {
92    fn empty() -> Self {
93        Self {
94            anchors: Vec::new(),
95            drawables: Vec::new(),
96            bbox: Aabb::EMPTY,
97            read_only: None,
98            error: None,
99            measured: None,
100        }
101    }
102
103    /// Anchor by name.
104    #[must_use]
105    pub fn anchor(&self, name: &str) -> Option<Point> {
106        self.anchors.iter().find(|a| a.name == name).map(|a| a.point)
107    }
108
109    /// World-space shapes usable for hit testing (texts as their estimated box).
110    pub fn shapes(&self) -> impl Iterator<Item = Shape> + '_ {
111        self.drawables.iter().filter_map(|d| match d {
112            Drawable::Shape(s, _) => Some(s.clone()),
113            Drawable::Text(t, _) => Some(Shape::Text(t.clone())),
114            Drawable::Arrow { .. } => None,
115        })
116    }
117}
118
119fn bbox_of(ds: &[Drawable]) -> Aabb {
120    ds.iter().fold(Aabb::EMPTY, |b, d| match d {
121        Drawable::Shape(s, _) => b.union(s.bbox()),
122        Drawable::Text(t, _) => b.union(Shape::Text(t.clone()).bbox()),
123        Drawable::Arrow { tip, direction, size } => b.include(*tip).include(*tip - *direction * *size),
124    })
125}
126
127/// Evaluation context.
128#[derive(Debug, Clone, Copy)]
129pub struct Ctx<'a> {
130    /// Document view.
131    pub view: &'a dyn DocView,
132    /// Registry.
133    pub registry: &'a Registry,
134}
135
136impl core::fmt::Debug for dyn DocView + '_ {
137    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
138        f.write_str("DocView")
139    }
140}
141
142/// Compatibility of an entity with the registered plugin type.
143pub fn plugin_for<'r>(registry: &'r Registry, e: &Entity) -> Result<&'r CompiledType, ReadOnly> {
144    let t = e.type_id.to_string();
145    let Some(entry) = registry.get(&e.type_id) else {
146        return Err(ReadOnly::MissingPlugin { type_id: t });
147    };
148    if !entry.enabled {
149        return Err(ReadOnly::DisabledPlugin { type_id: t });
150    }
151    if e.type_version > entry.compiled.def.version {
152        return Err(ReadOnly::NewerVersion {
153            type_id: t,
154            found: e.type_version,
155            supported: entry.compiled.def.version,
156        });
157    }
158    Ok(&entry.compiled)
159}
160
161/// Evaluate an entity.
162#[must_use]
163pub fn evaluate(ctx: Ctx<'_>, id: EntityId) -> Evaluated {
164    evaluate_depth(ctx, id, 0)
165}
166
167fn evaluate_depth(ctx: Ctx<'_>, id: EntityId, depth: usize) -> Evaluated {
168    let Some(e) = ctx.view.entity(id) else {
169        let mut ev = Evaluated::empty();
170        ev.error = Some(format!("{id} does not exist"));
171        return ev;
172    };
173    if depth > MAX_REF_DEPTH {
174        let mut ev = Evaluated::empty();
175        ev.error = Some("reference chain too deep or cyclic".into());
176        return ev;
177    }
178    if e.type_id.as_str() == types::DIMENSION {
179        return evaluate_dimension(ctx, e, depth);
180    }
181    if is_builtin(&e.type_id) {
182        return evaluate_builtin(e);
183    }
184    match plugin_for(ctx.registry, e) {
185        Ok(def) => evaluate_plugin(ctx, e, def, depth),
186        Err(ro) => {
187            let mut ev = Evaluated::empty();
188            ev.drawables =
189                e.fallback.iter().flatten().map(|s| Drawable::Shape(s.clone(), PrimStyle::default())).collect();
190            ev.bbox = bbox_of(&ev.drawables);
191            ev.read_only = Some(ro);
192            ev
193        }
194    }
195}
196
197fn evaluate_builtin(e: &Entity) -> Evaluated {
198    let mut ev = Evaluated::empty();
199    let Some(g) = &e.geometry else {
200        ev.error = Some("missing geometry".into());
201        return ev;
202    };
203    let t = e.transform;
204    match g.transform(t, TransformPolicy::Convert) {
205        Ok(Shape::Text(tx)) => ev.drawables.push(Drawable::Text(tx, PrimStyle::default())),
206        Ok(s) => ev.drawables.push(Drawable::Shape(s, PrimStyle::default())),
207        Err(err) => ev.error = Some(err.to_string()),
208    }
209    ev.anchors = g.anchors().into_iter().map(|a| Anchor { point: t.apply(a.point), ..a }).collect();
210    ev.bbox = bbox_of(&ev.drawables);
211    ev
212}
213
214/// World anchor of another entity.
215fn world_anchor(ctx: Ctx<'_>, id: EntityId, name: &str, depth: usize) -> Option<Point> {
216    evaluate_depth(ctx, id, depth + 1).anchor(name)
217}
218
219fn num_prop(e: &Entity, def: Option<&CompiledType>, name: &str) -> Option<f64> {
220    match e.props.get(name) {
221        Some(PropValue::Number(v)) => Some(*v),
222        Some(_) => None,
223        None => def.and_then(|d| match d.def.props.get(name) {
224            Some(crate::registry::PropDef::Number { dim, default: Some(l), .. }) => l.value(dim.dim()).ok(),
225            _ => None,
226        }),
227    }
228}
229
230fn point_prop(e: &Entity, def: Option<&CompiledType>, name: &str) -> Option<Point> {
231    match e.props.get(name) {
232        Some(PropValue::Point(p)) => Some(*p),
233        Some(_) => None,
234        None => def.and_then(|d| match d.def.props.get(name) {
235            Some(crate::registry::PropDef::Point { default: Some([x, y]), .. }) => Some(Point::new(*x, *y)),
236            _ => None,
237        }),
238    }
239}
240
241/// Numeric parameter of any entity (`width`, `start.x`, `r`, ...), in its local units.
242#[must_use]
243pub fn param_value(ctx: Ctx<'_>, e: &Entity, name: &str) -> Option<f64> {
244    if is_builtin(&e.type_id) {
245        return e.geometry.as_ref().and_then(|g| get_geometry_param(g, name));
246    }
247    let def = ctx.registry.get(&e.type_id).map(|t| t.compiled.as_ref());
248    if let Some((base, axis)) = name.rsplit_once('.') {
249        let p = point_prop(e, def, base)?;
250        return match axis {
251            "x" => Some(p.x),
252            "y" => Some(p.y),
253            _ => None,
254        };
255    }
256    num_prop(e, def, name)
257}
258
259/// Leaf values for a plugin entity.
260pub(crate) fn leaf_value(ctx: Ctx<'_>, e: &Entity, def: &CompiledType, leaf: &Leaf, depth: usize) -> Option<f64> {
261    match leaf {
262        Leaf::Prop(n) => num_prop(e, Some(def), n),
263        Leaf::PropPoint(n, a) => point_prop(e, Some(def), n).map(|p| if *a == Axis::X { p.x } else { p.y }),
264        Leaf::RefAnchor(prop, anchor, axis) => {
265            let target = match e.props.get(prop) {
266                Some(PropValue::Ref(r)) => r.entity,
267                _ => return None,
268            };
269            let w = world_anchor(ctx, target, anchor, depth)?;
270            let local = e.transform.inverse().ok()?.apply(w);
271            Some(if *axis == Axis::X { local.x } else { local.y })
272        }
273        Leaf::RefParam(prop, name) => {
274            let target = match e.props.get(prop) {
275                Some(PropValue::Ref(r)) => r.entity,
276                _ => return None,
277            };
278            let t = ctx.view.entity(target)?;
279            param_value(ctx, t, name)
280        }
281        Leaf::AxisScale => ctx.view.settings().time_axis.map(|a| a.mm_per_second),
282        Leaf::AxisOrigin => ctx.view.settings().time_axis.map(|a| a.origin_s),
283    }
284}
285
286fn eval_point(ctx: Ctx<'_>, e: &Entity, def: &CompiledType, c: &Compiled, depth: usize) -> Option<Point> {
287    let v = c.eval(&mut |l| leaf_value(ctx, e, def, l, depth))?;
288    Some(Point::new(*v.first()?, *v.get(1)?))
289}
290
291fn eval_scalar(ctx: Ctx<'_>, e: &Entity, def: &CompiledType, c: &Compiled, depth: usize) -> Option<f64> {
292    c.eval(&mut |l| leaf_value(ctx, e, def, l, depth))?.first().copied()
293}
294
295/// Fill `{prop}` placeholders.
296#[must_use]
297pub fn fill_template(template: &str, e: &Entity) -> String {
298    let mut out = String::with_capacity(template.len());
299    let mut rest = template;
300    while let Some(i) = rest.find('{') {
301        out.push_str(&rest[..i]);
302        let after = &rest[i + 1..];
303        match after.find('}') {
304            Some(j) => {
305                let key = &after[..j];
306                match e.props.get(key) {
307                    Some(PropValue::Text(t)) => out.push_str(t),
308                    Some(PropValue::Number(n)) => out.push_str(&format_number(*n, 1)),
309                    Some(PropValue::Bool(b)) => out.push_str(if *b { "yes" } else { "no" }),
310                    _ => {}
311                }
312                rest = &after[j + 1..];
313            }
314            None => {
315                out.push_str(&rest[i..]);
316                rest = "";
317            }
318        }
319    }
320    out.push_str(rest);
321    out
322}
323
324/// Format a number with at most `decimals` fraction digits, trimming zeros.
325#[must_use]
326pub fn format_number(v: f64, decimals: usize) -> String {
327    let s = format!("{v:.decimals$}");
328    if s.contains('.') {
329        let t = s.trim_end_matches('0').trim_end_matches('.');
330        if t == "-0" { "0".into() } else { t.to_owned() }
331    } else {
332        s
333    }
334}
335
336fn evaluate_plugin(ctx: Ctx<'_>, e: &Entity, def: &CompiledType, depth: usize) -> Evaluated {
337    let mut ev = Evaluated::empty();
338    let t = e.transform;
339    for (name, kind, c) in &def.anchors {
340        match eval_point(ctx, e, def, c, depth) {
341            Some(p) => ev.anchors.push(Anchor { name: name.clone().into(), kind: *kind, point: t.apply(p) }),
342            None => ev.error = Some(format!("anchor `{name}` cannot be evaluated")),
343        }
344    }
345    for prim in &def.primitives {
346        let local: Option<Drawable> = match prim {
347            CompiledPrim::Line(a, b, st) => (|| {
348                Some(Drawable::Shape(
349                    Shape::Line(Segment::new(eval_point(ctx, e, def, a, depth)?, eval_point(ctx, e, def, b, depth)?)),
350                    st.clone(),
351                ))
352            })(),
353            CompiledPrim::Polyline(pts, closed, st) => {
354                pts.iter().map(|c| eval_point(ctx, e, def, c, depth)).collect::<Option<Vec<_>>>().map(|p| {
355                    Drawable::Shape(
356                        Shape::Polyline(Polyline { points: p, bulges: Vec::new(), closed: *closed }),
357                        st.clone(),
358                    )
359                })
360            }
361            CompiledPrim::Polygon(pts, st) => pts
362                .iter()
363                .map(|c| eval_point(ctx, e, def, c, depth))
364                .collect::<Option<Vec<_>>>()
365                .map(|p| Drawable::Shape(Shape::Polygon(Polygon { outer: p, holes: Vec::new() }), st.clone())),
366            CompiledPrim::Circle(c, r, st) => (|| {
367                let center = eval_point(ctx, e, def, c, depth)?;
368                let radius = eval_scalar(ctx, e, def, r, depth)?;
369                Circle::new(center, radius).ok().map(|c| Drawable::Shape(Shape::Circle(c), st.clone()))
370            })(),
371            CompiledPrim::Arc(c, r, s0, sw, st) => (|| {
372                let a = Arc::new(
373                    eval_point(ctx, e, def, c, depth)?,
374                    eval_scalar(ctx, e, def, r, depth)?,
375                    eval_scalar(ctx, e, def, s0, depth)?,
376                    eval_scalar(ctx, e, def, sw, depth)?,
377                )
378                .ok()?;
379                Some(Drawable::Shape(Shape::Arc(a), st.clone()))
380            })(),
381            CompiledPrim::Text { position, content, height, rotation, halign, valign, style } => (|| {
382                let pos = eval_point(ctx, e, def, position, depth)?;
383                let h = eval_scalar(ctx, e, def, height, depth)?;
384                let rot = match rotation {
385                    Some(r) => eval_scalar(ctx, e, def, r, depth)?,
386                    None => 0.0,
387                };
388                (h > 0.0).then(|| {
389                    Drawable::Text(
390                        Text {
391                            position: pos,
392                            content: fill_template(content, e),
393                            height: h,
394                            rotation: rot,
395                            halign: *halign,
396                            valign: *valign,
397                        },
398                        style.clone(),
399                    )
400                })
401            })(),
402        };
403        let Some(d) = local else {
404            ev.error = Some("a primitive cannot be evaluated".into());
405            continue;
406        };
407        let world = match d {
408            Drawable::Shape(s, st) => match s.transform(t, TransformPolicy::Convert) {
409                Ok(w) => Some(Drawable::Shape(w, st)),
410                Err(_) => None,
411            },
412            Drawable::Text(tx, st) => match Shape::Text(tx).transform(t, TransformPolicy::Strict) {
413                Ok(Shape::Text(w)) => Some(Drawable::Text(w, st)),
414                _ => None,
415            },
416            other => Some(other),
417        };
418        if let Some(w) = world {
419            ev.drawables.push(w);
420        }
421    }
422    ev.bbox = bbox_of(&ev.drawables);
423    if ev.bbox.is_empty() {
424        ev.bbox = Aabb::from_points(ev.anchors.iter().map(|a| a.point));
425    }
426    ev
427}
428
429fn anchor_prop(ctx: Ctx<'_>, e: &Entity, key: &str, depth: usize) -> Option<Point> {
430    match e.props.get(key) {
431        Some(PropValue::Anchor(a)) => world_anchor(ctx, a.entity, &a.anchor, depth),
432        Some(PropValue::Point(p)) => Some(e.transform.apply(*p)),
433        _ => None,
434    }
435}
436
437fn text_prop<'e>(e: &'e Entity, key: &str) -> Option<&'e str> {
438    match e.props.get(key) {
439        Some(PropValue::Text(t)) => Some(t),
440        _ => None,
441    }
442}
443
444/// Format a measured value in the document's display unit.
445#[must_use]
446pub fn format_measure(value: f64, angular: bool, unit: LengthUnit, decimals: usize) -> String {
447    if angular {
448        format!("{}°", format_number(value.to_degrees(), decimals))
449    } else {
450        format!("{} {}", format_number(unit.from_mm(value), decimals), unit.symbol())
451    }
452}
453
454/// Associative dimension evaluation.
455///
456/// Properties: `kind` (`linear` | `radial` | `angular`), `a`/`b` (anchors or points),
457/// `through` (point the dimension line passes through), `orientation`
458/// (`aligned` | `horizontal` | `vertical`), `circle` (ref), `angle`, `line1`/`line2`
459/// (refs), `radius`, `textHeight`, `precision`, `prefix`.
460fn evaluate_dimension(ctx: Ctx<'_>, e: &Entity, depth: usize) -> Evaluated {
461    let mut ev = Evaluated::empty();
462    let num = |k: &str| e.props.get(k).and_then(PropValue::as_number);
463    let text_h = num("textHeight").filter(|v| *v > 0.0).unwrap_or(100.0);
464    let style = DimensionStyle {
465        extension_gap: text_h * 0.3,
466        extension_overshoot: text_h * 0.5,
467        arrow_size: text_h * 0.8,
468        text_height: text_h,
469    };
470    let decimals = num("precision").map_or(0, |p| p.clamp(0.0, 6.0) as usize);
471    let unit = ctx.view.settings().display_unit;
472    let kind = text_prop(e, "kind").unwrap_or("linear");
473    let geom = match kind {
474        "linear" => (|| {
475            let a = anchor_prop(ctx, e, "a", depth)?;
476            let b = anchor_prop(ctx, e, "b", depth)?;
477            let through = match e.props.get("through") {
478                Some(PropValue::Point(p)) => e.transform.apply(*p),
479                _ => a.midpoint(b),
480            };
481            let orient = match text_prop(e, "orientation") {
482                Some("horizontal") => LinearKind::Horizontal,
483                Some("vertical") => LinearKind::Vertical,
484                _ => LinearKind::Aligned,
485            };
486            dimension::linear(a, b, through, orient, style).ok()
487        })(),
488        "radial" => (|| {
489            let target = match e.props.get("circle") {
490                Some(PropValue::Ref(r)) => r.entity,
491                _ => return None,
492            };
493            let te = evaluate_depth(ctx, target, depth + 1);
494            let (center, radius) = te.drawables.iter().find_map(|d| match d {
495                Drawable::Shape(Shape::Circle(c), _) => Some((c.center, c.radius)),
496                Drawable::Shape(Shape::Arc(a), _) => Some((a.center, a.radius)),
497                _ => None,
498            })?;
499            dimension::radial(center, radius, num("angle").unwrap_or(core::f64::consts::FRAC_PI_4), style).ok()
500        })(),
501        "angular" => (|| {
502            let line_pts = |k: &str| -> Option<(Point, Point)> {
503                let id = match e.props.get(k) {
504                    Some(PropValue::Ref(r)) => r.entity,
505                    _ => return None,
506                };
507                let te = evaluate_depth(ctx, id, depth + 1);
508                Some((te.anchor("start")?, te.anchor("end")?))
509            };
510            let (a0, a1) = line_pts("line1")?;
511            let (b0, b1) = line_pts("line2")?;
512            let hit = dotloom_geometry::intersect::intersect(
513                &dotloom_geometry::Curve::Line(Segment::new(a0 - (a1 - a0) * 1e6, a1 + (a1 - a0) * 1e6)),
514                &dotloom_geometry::Curve::Line(Segment::new(b0 - (b1 - b0) * 1e6, b1 + (b1 - b0) * 1e6)),
515                dotloom_geometry::ModelTolerance::DEFAULT,
516            );
517            let vertex = hit.points.first()?.point;
518            let far = |p: Point, q: Point| if vertex.distance(p) > vertex.distance(q) { p } else { q };
519            dimension::angular(vertex, far(a0, a1), far(b0, b1), num("radius").unwrap_or(text_h * 10.0), style).ok()
520        })(),
521        _ => None,
522    };
523    let Some(g) = geom else {
524        ev.error = Some("dimension references cannot be resolved".into());
525        return ev;
526    };
527    let style = PrimStyle::default();
528    for l in &g.lines {
529        ev.drawables.push(Drawable::Shape(Shape::Line(*l), style.clone()));
530    }
531    if let Some(a) = g.arc {
532        ev.drawables.push(Drawable::Shape(Shape::Arc(a), style.clone()));
533    }
534    for a in &g.arrows {
535        ev.drawables.push(Drawable::Arrow { tip: a.tip, direction: a.direction, size: text_h * 0.8 });
536    }
537    let label = format_measure(g.value, kind == "angular", unit, decimals);
538    let prefix = text_prop(e, "prefix").unwrap_or(if kind == "radial" { "R " } else { "" });
539    ev.drawables.push(Drawable::Text(
540        Text {
541            position: g.text_position,
542            content: format!("{prefix}{label}"),
543            height: text_h,
544            rotation: g.text_rotation,
545            halign: HAlign::Center,
546            valign: VAlign::Middle,
547        },
548        style,
549    ));
550    ev.anchors.push(Anchor { name: "text".into(), kind: AnchorKind::Insert, point: g.text_position });
551    ev.measured = Some(g.value);
552    ev.bbox = bbox_of(&ev.drawables);
553    ev
554}
555
556/// Cache of evaluations keyed by entity.
557#[derive(Debug, Clone, Default)]
558pub struct EvalCache {
559    map: BTreeMap<EntityId, Evaluated>,
560}
561
562impl EvalCache {
563    /// Cached or fresh evaluation.
564    pub fn get(&mut self, ctx: Ctx<'_>, id: EntityId) -> &Evaluated {
565        self.map.entry(id).or_insert_with(|| {
566            // Cached for the document's lifetime: drop the spare capacity that
567            // `Vec` growth leaves behind (a single drawable would otherwise keep room
568            // for four, ~400 bytes per simple entity).
569            let mut ev = evaluate(ctx, id);
570            ev.anchors.shrink_to_fit();
571            ev.drawables.shrink_to_fit();
572            ev
573        })
574    }
575
576    /// Cached evaluation without computing.
577    #[must_use]
578    pub fn peek(&self, id: EntityId) -> Option<&Evaluated> {
579        self.map.get(&id)
580    }
581
582    /// Drop entries.
583    pub fn invalidate(&mut self, ids: impl IntoIterator<Item = EntityId>) {
584        for id in ids {
585            self.map.remove(&id);
586        }
587    }
588
589    /// Drop everything.
590    pub fn clear(&mut self) {
591        self.map.clear();
592    }
593}