Skip to main content

dotloom_engine/
registry.rs

1//! Entity type definitions and the type registry (ADR-0006).
2//!
3//! A plugin entity type is *data*: a JSON-serializable [`EntityTypeDef`] with a
4//! typed property schema, derived values, anchors, drawable primitive recipes,
5//! constraint templates and property migrations, all written in the expression
6//! language of [`crate::lang`]. Definitions are compiled (parsed, dimension-checked)
7//! at registration; invalid definitions are rejected with typed errors.
8
9use std::{collections::BTreeMap, sync::Arc};
10
11use dotloom_document::{Cmp, Color, StrengthSpec, TypeId, builtin::types};
12use dotloom_geometry::{
13    AnchorKind, HAlign, VAlign,
14    units::{Dim, Quantity},
15};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use thiserror::Error;
19
20use crate::lang::{Binding, Compiled, LangError, Scope, Ty, compile, compile_as};
21
22/// Registry errors.
23#[derive(Debug, Clone, PartialEq, Error)]
24#[non_exhaustive]
25pub enum RegistryError {
26    /// The type ID is already registered.
27    #[error("type `{0}` is already registered")]
28    Duplicate(String),
29    /// Built-in types cannot be redefined.
30    #[error("type `{0}` is reserved by Dotloom")]
31    Reserved(String),
32    /// The type is not registered.
33    #[error("type `{0}` is not registered")]
34    Unknown(String),
35    /// The definition is invalid.
36    #[error("invalid definition of `{type_id}` at {location}: {error}")]
37    Invalid {
38        /// Type.
39        type_id: String,
40        /// Where in the definition.
41        location: String,
42        /// What is wrong.
43        error: String,
44    },
45}
46
47/// Physical dimension names used in definitions.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub enum DimName {
51    /// Length (mm).
52    #[default]
53    Length,
54    /// Angle (rad).
55    Angle,
56    /// Time (s).
57    Time,
58    /// Dimensionless.
59    Scalar,
60}
61
62impl DimName {
63    /// As [`Dim`].
64    #[must_use]
65    pub const fn dim(self) -> Dim {
66        match self {
67            Self::Length => Dim::LENGTH,
68            Self::Angle => Dim::ANGLE,
69            Self::Time => Dim::TIME,
70            Self::Scalar => Dim::SCALAR,
71        }
72    }
73}
74
75/// A number literal in a definition: a plain number (canonical units) or a string
76/// with a unit such as `"60cm"`.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(untagged)]
79pub enum NumberLit {
80    /// Canonical value.
81    Plain(f64),
82    /// Value with unit.
83    WithUnit(String),
84}
85
86impl NumberLit {
87    /// Canonical value, checking the dimension.
88    pub fn value(&self, dim: Dim) -> Result<f64, String> {
89        match self {
90            Self::Plain(v) if v.is_finite() => Ok(*v),
91            Self::Plain(_) => Err("not finite".into()),
92            Self::WithUnit(s) => {
93                let q = Quantity::parse(s).map_err(|e| e.to_string())?;
94                if q.dim == dim || q.dim == Dim::SCALAR {
95                    Ok(q.value)
96                } else {
97                    Err(format!("`{s}` has dimension {} but {dim} is required", q.dim))
98                }
99            }
100        }
101    }
102}
103
104/// What happens to a referencing entity when its target is deleted.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub enum OnDelete {
108    /// Delete the referencing entity too (door with its wall).
109    #[default]
110    Cascade,
111    /// Remove the reference property.
112    Clear,
113    /// Refuse the deletion.
114    Reject,
115}
116
117fn yes() -> bool {
118    true
119}
120
121/// How strongly a solver property keeps its previous value when rules force
122/// changes (relative stay weight).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub enum StayLevel {
126    /// Changes first (positions such as a door offset).
127    Low,
128    /// Default.
129    #[default]
130    Normal,
131    /// Changes last (sizes such as a door width).
132    High,
133}
134
135impl StayLevel {
136    /// Stay multiplier.
137    #[must_use]
138    pub const fn factor(self) -> f64 {
139        match self {
140            Self::Low => 0.2,
141            Self::Normal => 1.0,
142            Self::High => 5.0,
143        }
144    }
145}
146
147/// Property schema.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
150pub enum PropDef {
151    /// Number with a dimension.
152    Number {
153        /// Dimension.
154        #[serde(default)]
155        dim: DimName,
156        /// Default value.
157        #[serde(default)]
158        default: Option<NumberLit>,
159        /// Minimum (enforced as a hard rule).
160        #[serde(default)]
161        min: Option<NumberLit>,
162        /// Maximum (enforced as a hard rule).
163        #[serde(default)]
164        max: Option<NumberLit>,
165        /// Whether the solver may change this property.
166        #[serde(default = "yes")]
167        solve: bool,
168        /// Relative stay weight.
169        #[serde(default)]
170        stay: StayLevel,
171        /// Label.
172        #[serde(default)]
173        label: Option<String>,
174    },
175    /// Point (two length components, solver variables).
176    Point {
177        /// Default.
178        #[serde(default)]
179        default: Option<[f64; 2]>,
180        /// Relative stay weight.
181        #[serde(default)]
182        stay: StayLevel,
183        /// Label.
184        #[serde(default)]
185        label: Option<String>,
186    },
187    /// Boolean.
188    Bool {
189        /// Default.
190        #[serde(default)]
191        default: Option<bool>,
192        /// Label.
193        #[serde(default)]
194        label: Option<String>,
195    },
196    /// Free text.
197    Text {
198        /// Default.
199        #[serde(default)]
200        default: Option<String>,
201        /// Maximum length in characters.
202        #[serde(default)]
203        max_len: Option<usize>,
204        /// Label.
205        #[serde(default)]
206        label: Option<String>,
207    },
208    /// One of a fixed set of strings.
209    Enum {
210        /// Allowed values.
211        values: Vec<String>,
212        /// Default.
213        #[serde(default)]
214        default: Option<String>,
215        /// Label.
216        #[serde(default)]
217        label: Option<String>,
218    },
219    /// Reference to another entity.
220    Ref {
221        /// Required target type (enables typed member access in expressions).
222        #[serde(default)]
223        target: Option<TypeId>,
224        /// Deletion policy.
225        #[serde(default)]
226        on_delete: OnDelete,
227        /// Whether the reference must be set.
228        #[serde(default)]
229        required: bool,
230        /// Label.
231        #[serde(default)]
232        label: Option<String>,
233    },
234}
235
236/// A named expression.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct NamedExpr {
239    /// Name.
240    pub name: String,
241    /// Expression.
242    pub expr: String,
243}
244
245/// An anchor definition.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub struct AnchorDef {
248    /// Anchor name.
249    pub name: String,
250    /// Point expression (local coordinates).
251    pub expr: String,
252    /// Snap kind.
253    #[serde(default = "default_anchor_kind")]
254    pub kind: AnchorKind,
255}
256
257fn default_anchor_kind() -> AnchorKind {
258    AnchorKind::Vertex
259}
260
261/// Style of a primitive recipe.
262#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct PrimStyle {
265    /// Stroke color (default: entity/layer/theme).
266    #[serde(default)]
267    pub stroke: Option<Color>,
268    /// Fill color (regions only).
269    #[serde(default)]
270    pub fill: Option<Color>,
271    /// Stroke width (CSS px).
272    #[serde(default)]
273    pub width: Option<f64>,
274    /// Dash (CSS px).
275    #[serde(default)]
276    pub dash: Option<Vec<f64>>,
277    /// No stroke at all.
278    #[serde(default)]
279    pub no_stroke: bool,
280}
281
282/// Drawable primitive recipe.
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
285pub enum PrimitiveDef {
286    /// Segment.
287    Line {
288        /// Start.
289        from: String,
290        /// End.
291        to: String,
292        /// Style.
293        #[serde(default)]
294        style: PrimStyle,
295    },
296    /// Polyline.
297    Polyline {
298        /// Points.
299        points: Vec<String>,
300        /// Closed.
301        #[serde(default)]
302        closed: bool,
303        /// Style.
304        #[serde(default)]
305        style: PrimStyle,
306    },
307    /// Closed polygon (fillable).
308    Polygon {
309        /// Points.
310        points: Vec<String>,
311        /// Style.
312        #[serde(default)]
313        style: PrimStyle,
314    },
315    /// Circle.
316    Circle {
317        /// Center.
318        center: String,
319        /// Radius.
320        radius: String,
321        /// Style.
322        #[serde(default)]
323        style: PrimStyle,
324    },
325    /// Arc.
326    Arc {
327        /// Center.
328        center: String,
329        /// Radius.
330        radius: String,
331        /// Start angle.
332        start: String,
333        /// Sweep angle.
334        sweep: String,
335        /// Style.
336        #[serde(default)]
337        style: PrimStyle,
338    },
339    /// Text label; `content` may contain `{prop}` placeholders.
340    Text {
341        /// Anchor point.
342        position: String,
343        /// Content template.
344        content: String,
345        /// Height.
346        height: String,
347        /// Rotation.
348        #[serde(default)]
349        rotation: Option<String>,
350        /// Horizontal alignment.
351        #[serde(default)]
352        halign: HAlign,
353        /// Vertical alignment.
354        #[serde(default)]
355        valign: VAlign,
356        /// Style (stroke color is the text color).
357        #[serde(default)]
358        style: PrimStyle,
359    },
360}
361
362/// Constraint template instantiated for every entity of the type.
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
364pub struct TemplateDef {
365    /// Left expression.
366    pub lhs: String,
367    /// Operator.
368    pub op: Cmp,
369    /// Right expression.
370    pub rhs: String,
371    /// Label for diagnostics.
372    #[serde(default)]
373    pub label: Option<String>,
374    /// Strength.
375    #[serde(default)]
376    pub strength: StrengthSpec,
377}
378
379/// Circle-like interpretation for circle rules (concentric, tangency, radius).
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct CircleDef {
382    /// Center expression.
383    pub center: String,
384    /// Radius expression.
385    pub radius: String,
386}
387
388/// Property migration from `from` to `from + 1`.
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
390pub struct MigrationDef {
391    /// Source version.
392    pub from: u32,
393    /// Renamed properties `old → new`.
394    #[serde(default)]
395    pub rename: BTreeMap<String, String>,
396    /// Properties set when missing (literal JSON values).
397    #[serde(default)]
398    pub set: BTreeMap<String, Value>,
399    /// Removed properties.
400    #[serde(default)]
401    pub remove: Vec<String>,
402}
403
404fn one() -> u32 {
405    1
406}
407
408/// A plugin entity type definition.
409#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct EntityTypeDef {
412    /// Namespaced type ID (not `dotloom.*`).
413    pub type_id: TypeId,
414    /// Schema version of the properties.
415    #[serde(default = "one")]
416    pub version: u32,
417    /// Display label.
418    #[serde(default)]
419    pub label: String,
420    /// Property schema.
421    #[serde(default)]
422    pub props: BTreeMap<String, PropDef>,
423    /// Named helper values, in dependency order.
424    #[serde(default)]
425    pub derived: Vec<NamedExpr>,
426    /// Anchors, in dependency order.
427    #[serde(default)]
428    pub anchors: Vec<AnchorDef>,
429    /// Drawable primitives.
430    #[serde(default)]
431    pub primitives: Vec<PrimitiveDef>,
432    /// Constraint templates.
433    #[serde(default)]
434    pub constraints: Vec<TemplateDef>,
435    /// Circle interpretation.
436    #[serde(default)]
437    pub circle: Option<CircleDef>,
438    /// Property migrations.
439    #[serde(default)]
440    pub migrations: Vec<MigrationDef>,
441}
442
443/// Compiled primitive recipe.
444#[derive(Debug, Clone, PartialEq)]
445pub enum CompiledPrim {
446    /// Segment.
447    Line(Compiled, Compiled, PrimStyle),
448    /// Polyline.
449    Polyline(Vec<Compiled>, bool, PrimStyle),
450    /// Polygon.
451    Polygon(Vec<Compiled>, PrimStyle),
452    /// Circle.
453    Circle(Compiled, Compiled, PrimStyle),
454    /// Arc.
455    Arc(Compiled, Compiled, Compiled, Compiled, PrimStyle),
456    /// Text.
457    Text {
458        /// Position.
459        position: Compiled,
460        /// Template.
461        content: String,
462        /// Height.
463        height: Compiled,
464        /// Rotation.
465        rotation: Option<Compiled>,
466        /// Alignment.
467        halign: HAlign,
468        /// Alignment.
469        valign: VAlign,
470        /// Style.
471        style: PrimStyle,
472    },
473}
474
475/// A compiled constraint template.
476#[derive(Debug, Clone, PartialEq)]
477pub struct CompiledTemplate {
478    /// Source definition.
479    pub def: TemplateDef,
480    /// `lhs − rhs` (scalar) and its dimension.
481    pub diff: Compiled,
482    /// Dimension of both sides.
483    pub dim: Dim,
484}
485
486/// Solver parameter of a plugin entity.
487#[derive(Debug, Clone, PartialEq)]
488pub struct ParamInfo {
489    /// Name (`width`, `start.x`).
490    pub name: String,
491    /// Dimension.
492    pub dim: Dim,
493    /// Stay multiplier.
494    pub stay: f64,
495}
496
497/// A registered, compiled plugin type.
498#[derive(Debug, Clone, PartialEq)]
499pub struct CompiledType {
500    /// Definition.
501    pub def: EntityTypeDef,
502    /// Anchors.
503    pub anchors: Vec<(String, AnchorKind, Compiled)>,
504    /// Primitives.
505    pub primitives: Vec<CompiledPrim>,
506    /// Templates.
507    pub templates: Vec<CompiledTemplate>,
508    /// Circle interpretation.
509    pub circle: Option<(Compiled, Compiled)>,
510    /// Solver parameters in stable order.
511    pub params: Vec<ParamInfo>,
512    /// Members visible to referencing types.
513    pub members: BTreeMap<String, Ty>,
514    /// Expression scope of the type (for expression constraints on its entities).
515    pub scope: Scope,
516}
517
518/// Registry entry.
519#[derive(Debug, Clone)]
520pub struct TypeEntry {
521    /// Compiled type.
522    pub compiled: Arc<CompiledType>,
523    /// Enabled flag (disabled types make their entities read-only).
524    pub enabled: bool,
525    /// Plugin that registered the type.
526    pub plugin: String,
527}
528
529/// Members of built-in types usable through typed references.
530#[must_use]
531pub fn builtin_members(t: &TypeId) -> Option<BTreeMap<String, Ty>> {
532    let (anchors, lengths): (&[&str], &[&str]) = match t.as_str() {
533        types::POINT => (&["point"], &[]),
534        types::LINE => (&["start", "end", "mid"], &[]),
535        types::POLYLINE | types::PATH => (&["start", "end"], &[]),
536        types::RECT => (&["c0", "c1", "c2", "c3", "e0", "e1", "e2", "e3", "center"], &["width", "height"]),
537        types::CIRCLE => (&["center", "q0", "q1", "q2", "q3"], &["r"]),
538        types::ARC => (&["start", "end", "mid", "center"], &["r"]),
539        types::TEXT => (&["insert"], &[]),
540        _ => return None,
541    };
542    let mut m: BTreeMap<String, Ty> = anchors.iter().map(|n| ((*n).to_owned(), Ty::Vector(Dim::LENGTH))).collect();
543    m.extend(lengths.iter().map(|n| ((*n).to_owned(), Ty::Scalar(Dim::LENGTH))));
544    Some(m)
545}
546
547/// The type registry.
548#[derive(Debug, Clone, Default)]
549pub struct Registry {
550    types: BTreeMap<TypeId, TypeEntry>,
551}
552
553fn invalid(t: &TypeId, location: impl Into<String>, e: impl ToString) -> RegistryError {
554    RegistryError::Invalid { type_id: t.to_string(), location: location.into(), error: e.to_string() }
555}
556
557impl Registry {
558    /// Empty registry (built-in types need no registration).
559    #[must_use]
560    pub fn new() -> Self {
561        Self::default()
562    }
563
564    /// Registered plugin type.
565    #[must_use]
566    pub fn get(&self, t: &TypeId) -> Option<&TypeEntry> {
567        self.types.get(t)
568    }
569
570    /// Enabled plugin type.
571    #[must_use]
572    pub fn enabled(&self, t: &TypeId) -> Option<&Arc<CompiledType>> {
573        self.types.get(t).filter(|e| e.enabled).map(|e| &e.compiled)
574    }
575
576    /// All registered types.
577    pub fn types(&self) -> impl Iterator<Item = (&TypeId, &TypeEntry)> {
578        self.types.iter()
579    }
580
581    /// Members of any type (built-in or registered).
582    #[must_use]
583    pub fn members_of(&self, t: &TypeId) -> Option<BTreeMap<String, Ty>> {
584        builtin_members(t).or_else(|| self.types.get(t).map(|e| e.compiled.members.clone()))
585    }
586
587    /// Register a definition. Fails on duplicates, reserved namespaces and invalid
588    /// definitions (nothing is registered in that case).
589    pub fn register(&mut self, def: EntityTypeDef, plugin: impl Into<String>) -> Result<(), RegistryError> {
590        let t = def.type_id.clone();
591        if t.namespace() == "dotloom" {
592            return Err(RegistryError::Reserved(t.to_string()));
593        }
594        if self.types.contains_key(&t) {
595            return Err(RegistryError::Duplicate(t.to_string()));
596        }
597        let compiled = self.compile(def)?;
598        self.types.insert(t, TypeEntry { compiled: Arc::new(compiled), enabled: true, plugin: plugin.into() });
599        Ok(())
600    }
601
602    /// Remove a type definition.
603    pub fn unregister(&mut self, t: &TypeId) -> Result<TypeEntry, RegistryError> {
604        self.types.remove(t).ok_or_else(|| RegistryError::Unknown(t.to_string()))
605    }
606
607    /// Enable or disable a type.
608    pub fn set_enabled(&mut self, t: &TypeId, enabled: bool) -> Result<(), RegistryError> {
609        let e = self.types.get_mut(t).ok_or_else(|| RegistryError::Unknown(t.to_string()))?;
610        e.enabled = enabled;
611        Ok(())
612    }
613
614    fn compile(&self, def: EntityTypeDef) -> Result<CompiledType, RegistryError> {
615        let t = def.type_id.clone();
616        if def.version == 0 {
617            return Err(invalid(&t, "version", "must be ≥ 1"));
618        }
619        let mut scope = Scope { names: BTreeMap::new(), has_axis: true };
620        let mut params = Vec::new();
621        let reserved = ["pi", "axis", "self"];
622        for (name, p) in &def.props {
623            if reserved.contains(&name.as_str()) || !valid_name(name) {
624                return Err(invalid(&t, format!("props.{name}"), "invalid property name"));
625            }
626            match p {
627                PropDef::Number { dim, default, min, max, solve, stay, .. } => {
628                    for (what, lit) in [("default", default), ("min", min), ("max", max)] {
629                        if let Some(l) = lit {
630                            l.value(dim.dim()).map_err(|e| invalid(&t, format!("props.{name}.{what}"), e))?;
631                        }
632                    }
633                    scope.names.insert(name.clone(), Binding::NumberProp(dim.dim()));
634                    if *solve {
635                        params.push(ParamInfo { name: name.clone(), dim: dim.dim(), stay: stay.factor() });
636                    }
637                }
638                PropDef::Point { stay, .. } => {
639                    scope.names.insert(name.clone(), Binding::PointProp);
640                    params.push(ParamInfo { name: format!("{name}.x"), dim: Dim::LENGTH, stay: stay.factor() });
641                    params.push(ParamInfo { name: format!("{name}.y"), dim: Dim::LENGTH, stay: stay.factor() });
642                }
643                PropDef::Ref { target, .. } => {
644                    let members = match target {
645                        Some(tt) if *tt == t => Some(BTreeMap::new()),
646                        Some(tt) => Some(self.members_of(tt).ok_or_else(|| {
647                            invalid(&t, format!("props.{name}.target"), format!("type `{tt}` must be registered first"))
648                        })?),
649                        None => None,
650                    };
651                    scope.names.insert(name.clone(), Binding::RefProp(members));
652                }
653                PropDef::Enum { values, default, .. } => {
654                    if values.is_empty() || default.as_ref().is_some_and(|d| !values.contains(d)) {
655                        return Err(invalid(&t, format!("props.{name}"), "enum needs values and a valid default"));
656                    }
657                }
658                PropDef::Bool { .. } | PropDef::Text { .. } => {}
659            }
660        }
661        let err = |loc: String| {
662            let t = t.clone();
663            move |e: LangError| invalid(&t, loc, e)
664        };
665        for d in &def.derived {
666            if scope.names.contains_key(&d.name) || !valid_name(&d.name) {
667                return Err(invalid(&t, format!("derived.{}", d.name), "name is already defined or invalid"));
668            }
669            let c = compile(&d.expr, &scope).map_err(err(format!("derived.{}", d.name)))?;
670            scope.names.insert(d.name.clone(), Binding::Value(c));
671        }
672        let mut anchors = Vec::new();
673        let mut members: BTreeMap<String, Ty> = BTreeMap::new();
674        for a in &def.anchors {
675            if !valid_name(&a.name) {
676                return Err(invalid(&t, format!("anchors.{}", a.name), "invalid anchor name"));
677            }
678            let c = compile_as(&a.expr, &scope, Ty::Vector(Dim::LENGTH)).map_err(err(format!("anchors.{}", a.name)))?;
679            members.insert(a.name.clone(), Ty::Vector(Dim::LENGTH));
680            if !scope.names.contains_key(&a.name) {
681                scope.names.insert(a.name.clone(), Binding::Value(c.clone()));
682            }
683            anchors.push((a.name.clone(), a.kind, c));
684        }
685        for p in &params {
686            if !p.name.contains('.') {
687                members.entry(p.name.clone()).or_insert(Ty::Scalar(p.dim));
688            }
689        }
690        let pt = |src: &str, loc: String| compile_as(src, &scope, Ty::Vector(Dim::LENGTH)).map_err(err(loc));
691        let len = |src: &str, loc: String| compile_as(src, &scope, Ty::Scalar(Dim::LENGTH)).map_err(err(loc));
692        let ang = |src: &str, loc: String| compile_as(src, &scope, Ty::Scalar(Dim::ANGLE)).map_err(err(loc));
693        let mut primitives = Vec::new();
694        for (i, p) in def.primitives.iter().enumerate() {
695            let loc = |f: &str| format!("primitives[{i}].{f}");
696            primitives.push(match p {
697                PrimitiveDef::Line { from, to, style } => {
698                    CompiledPrim::Line(pt(from, loc("from"))?, pt(to, loc("to"))?, style.clone())
699                }
700                PrimitiveDef::Polyline { points, closed, style } => CompiledPrim::Polyline(
701                    points
702                        .iter()
703                        .enumerate()
704                        .map(|(k, s)| pt(s, loc(&format!("points[{k}]"))))
705                        .collect::<Result<_, _>>()?,
706                    *closed,
707                    style.clone(),
708                ),
709                PrimitiveDef::Polygon { points, style } => {
710                    if points.len() < 3 {
711                        return Err(invalid(&t, loc("points"), "polygon needs at least 3 points"));
712                    }
713                    CompiledPrim::Polygon(
714                        points
715                            .iter()
716                            .enumerate()
717                            .map(|(k, s)| pt(s, loc(&format!("points[{k}]"))))
718                            .collect::<Result<_, _>>()?,
719                        style.clone(),
720                    )
721                }
722                PrimitiveDef::Circle { center, radius, style } => {
723                    CompiledPrim::Circle(pt(center, loc("center"))?, len(radius, loc("radius"))?, style.clone())
724                }
725                PrimitiveDef::Arc { center, radius, start, sweep, style } => CompiledPrim::Arc(
726                    pt(center, loc("center"))?,
727                    len(radius, loc("radius"))?,
728                    ang(start, loc("start"))?,
729                    ang(sweep, loc("sweep"))?,
730                    style.clone(),
731                ),
732                PrimitiveDef::Text { position, content, height, rotation, halign, valign, style } => {
733                    for name in placeholders(content) {
734                        if !def.props.contains_key(&name) {
735                            return Err(invalid(&t, loc("content"), format!("unknown placeholder {{{name}}}")));
736                        }
737                    }
738                    CompiledPrim::Text {
739                        position: pt(position, loc("position"))?,
740                        content: content.clone(),
741                        height: len(height, loc("height"))?,
742                        rotation: rotation.as_ref().map(|r| ang(r, loc("rotation"))).transpose()?,
743                        halign: *halign,
744                        valign: *valign,
745                        style: style.clone(),
746                    }
747                }
748            });
749        }
750        let mut templates = Vec::new();
751        for (i, tpl) in def.constraints.iter().enumerate() {
752            let loc = format!("constraints[{i}]");
753            let l = compile(&tpl.lhs, &scope).map_err(err(loc.clone()))?;
754            let r = compile(&tpl.rhs, &scope).map_err(err(loc.clone()))?;
755            let (Ty::Scalar(dl), Ty::Scalar(dr)) = (l.ty, r.ty) else {
756                return Err(invalid(&t, loc, "constraint sides must be scalars"));
757            };
758            if dl != dr {
759                return Err(invalid(&t, loc, format!("cannot compare {dl} with {dr}")));
760            }
761            let diff = compile(&format!("({}) - ({})", tpl.lhs, tpl.rhs), &scope).map_err(err(loc))?;
762            templates.push(CompiledTemplate { def: tpl.clone(), diff, dim: dl });
763        }
764        // Property bounds become templates too.
765        for (name, p) in &def.props {
766            if let PropDef::Number { dim, min, max, .. } = p {
767                for (lit, op) in [(min, Cmp::Ge), (max, Cmp::Le)] {
768                    if let Some(l) = lit {
769                        let v = l.value(dim.dim()).map_err(|e| invalid(&t, format!("props.{name}"), e))?;
770                        let tpl = TemplateDef {
771                            lhs: name.clone(),
772                            op,
773                            rhs: format!("{v}"),
774                            label: Some(format!("{name} {} {v}", if op == Cmp::Ge { "≥" } else { "≤" })),
775                            strength: StrengthSpec::Required,
776                        };
777                        let mut diff = compile(name, &scope).map_err(err(format!("props.{name}")))?;
778                        diff.parts = diff
779                            .parts
780                            .into_iter()
781                            .map(|e| dotloom_constraints::Expr::sub(e, dotloom_constraints::Expr::c(v)))
782                            .collect();
783                        templates.push(CompiledTemplate { def: tpl, diff, dim: dim.dim() });
784                    }
785                }
786            }
787        }
788        let circle = match &def.circle {
789            Some(c) => Some((pt(&c.center, "circle.center".into())?, len(&c.radius, "circle.radius".into())?)),
790            None => None,
791        };
792        let mut seen = std::collections::BTreeSet::new();
793        for m in &def.migrations {
794            if m.from == 0 || m.from >= def.version || !seen.insert(m.from) {
795                return Err(invalid(
796                    &t,
797                    format!("migrations.from={}", m.from),
798                    "migration steps must be unique and below the current version",
799                ));
800            }
801        }
802        Ok(CompiledType { def, anchors, primitives, templates, circle, params, members, scope })
803    }
804}
805
806fn valid_name(n: &str) -> bool {
807    let mut c = n.chars();
808    c.next().is_some_and(|f| f.is_ascii_alphabetic() || f == '_')
809        && c.all(|x| x.is_ascii_alphanumeric() || x == '_')
810        && n.len() <= 64
811}
812
813/// `{name}` placeholders of a text template.
814#[must_use]
815pub fn placeholders(s: &str) -> Vec<String> {
816    let mut out = Vec::new();
817    let mut rest = s;
818    while let Some(i) = rest.find('{') {
819        let after = &rest[i + 1..];
820        match after.find('}') {
821            Some(j) => {
822                out.push(after[..j].to_owned());
823                rest = &after[j + 1..];
824            }
825            None => break,
826        }
827    }
828    out
829}