Skip to main content

blockworx/schema/
raise.rs

1//! The write direction of the round trip (phase 7, D9): the folded document
2//! back as the on-disk schema structs, feeding the emitter ([`super::encode`])
3//! that already agrees with the parser — one format authority.
4//!
5//! `raise` is also the document's *canonical form*. Minted uuids collapse to
6//! generated string ids over a deterministic order (blocks depth-first from
7//! `top`, siblings by position; pins by boundary slot), so two documents that
8//! raise identically are the same diagram — which is what the round-trip
9//! tests assert, and why the output is stable under refactors.
10//!
11//! Deliberately lossy, in both directions' favor: asset ids are re-derived
12//! from content (a hand-written `"art.svg"` comes back as `"<hash>.svg"`),
13//! and port bodies are always written explicitly rather than re-omitted when
14//! they match the auto-placement — omission is a read-side reconstruction,
15//! and re-running it on export would move geometry the user never touched.
16
17use ahash::HashMap;
18use base64::Engine as _;
19use blockworx_doc::{
20    block_model::{Asset, Block, Icon, Label, Pin},
21    document::Document,
22    geometry::{GridPoint, GridRect},
23    id::{BlockId, PinId, RouteId},
24    rev::RevKind,
25    values::{LabelSide, PinSide},
26};
27
28use crate::edit::lower::{
29    accent_from_role, artwork_rect, schema_label_side, schema_pin_dir, schema_pin_side,
30};
31use crate::schema::loc::format_loc;
32use crate::schema::model as schema;
33
34/// The generated spellings, both namespaces: `"b<N>"` per block in canonical
35/// order, `"p<N>"` per pin within its owner.
36struct Names {
37    blocks: HashMap<BlockId, String>,
38    pins: HashMap<PinId, (BlockId, String)>,
39}
40
41/// The whole document, canonically ordered and named. The inverse of
42/// [`super::lower::lower`] up to minted identity.
43pub fn raise<R: RevKind>(doc: &Document<R>) -> schema::Document {
44    let order = block_order(doc);
45    let pins = pins_by_owner(doc);
46    let names = Names {
47        blocks: order
48            .iter()
49            .enumerate()
50            .map(|(i, &(id, _))| (id, format!("b{i}")))
51            .collect(),
52        pins: pins
53            .iter()
54            .flat_map(|(&owner, pins)| {
55                pins.iter()
56                    .enumerate()
57                    .map(move |(i, &(id, _))| (id, (owner, format!("p{}", i + 1))))
58            })
59            .collect(),
60    };
61
62    let mut assets = AssetIds::default();
63    let blocks: Vec<schema::Block> = order
64        .iter()
65        .map(|&entry| {
66            raise_block(
67                doc,
68                entry,
69                &names,
70                pins.get(&entry.0).map_or(&[][..], Vec::as_slice),
71                &mut assets,
72            )
73        })
74        .collect();
75
76    let name = doc.title_block().name.as_ref();
77    let top = names
78        .blocks
79        .get(doc.title_block().top.as_ref())
80        .or_else(|| order.first().and_then(|(id, _)| names.blocks.get(id)))
81        .cloned()
82        .unwrap_or_default();
83    schema::Document {
84        version: schema::CURRENT_VERSION,
85        name: (!name.is_empty()).then(|| name.clone()),
86        top,
87        blocks,
88        assets: assets.assets,
89    }
90}
91
92/// Serialize `doc` as KDL text — the whole export path in one call.
93pub fn to_kdl<R: RevKind>(doc: &Document<R>) -> String {
94    raise(doc).to_kdl()
95}
96
97/// Siblings sort by position, then id as the total-order tiebreak for
98/// coincident blocks — file order is not document state (draw order is
99/// derived), so the exporter owns it and keeps it deterministic.
100fn position_key(rect: &GridRect, id: BlockId) -> (i32, i32, BlockId) {
101    (rect.top_left.y, rect.top_left.x, id)
102}
103
104/// Depth-first from the top block, then any unreachable stragglers — so the
105/// top block is `b0` and a child always follows its parent, matching how the
106/// files are written by hand.
107fn block_order<R: RevKind>(doc: &Document<R>) -> Vec<(BlockId, &Block)> {
108    let live: Vec<(BlockId, &Block)> = doc
109        .blocks()
110        .filter(|(_, live)| live.is_alive())
111        .map(|(id, live)| (id, live.as_ref()))
112        .collect();
113    let mut children: HashMap<BlockId, Vec<(BlockId, &Block)>> = HashMap::default();
114    for &(id, block) in &live {
115        children
116            .entry(*block.parent.as_ref())
117            .or_default()
118            .push((id, block));
119    }
120    for siblings in children.values_mut() {
121        siblings.sort_by_key(|(id, block)| position_key(block.rect.as_ref(), *id));
122    }
123
124    let mut order: Vec<(BlockId, &Block)> = Vec::with_capacity(live.len());
125    let mut stack: Vec<(BlockId, &Block)> = Vec::new();
126    let top = *doc.title_block().top.as_ref();
127    stack.extend(live.iter().find(|&&(id, _)| id == top));
128    while let Some(entry) = stack.pop() {
129        order.push(entry);
130        if let Some(kids) = children.get(&entry.0) {
131            stack.extend(kids.iter().rev());
132        }
133    }
134    let mut stragglers: Vec<(BlockId, &Block)> = live
135        .iter()
136        .filter(|(id, _)| !order.iter().any(|(seen, _)| seen == id))
137        .copied()
138        .collect();
139    stragglers.sort_by_key(|(id, block)| position_key(block.rect.as_ref(), *id));
140    // A straggler root brings its own subtree in the same depth-first order
141    // the top's got.
142    for root in stragglers {
143        stack.push(root);
144        while let Some(entry) = stack.pop() {
145            if order.iter().any(|(seen, _)| *seen == entry.0) {
146                continue;
147            }
148            order.push(entry);
149            if let Some(kids) = children.get(&entry.0) {
150                stack.extend(kids.iter().rev());
151            }
152        }
153    }
154    order
155}
156
157fn pins_by_owner<R: RevKind>(doc: &Document<R>) -> HashMap<BlockId, Vec<(PinId, &Pin)>> {
158    let mut pins: HashMap<BlockId, Vec<(PinId, &Pin)>> = HashMap::default();
159    for (id, live) in doc.pins().filter(|(_, live)| live.is_alive()) {
160        let pin = live.as_ref();
161        pins.entry(*pin.owner.as_ref()).or_default().push((id, pin));
162    }
163    for owned in pins.values_mut() {
164        owned.sort_by_key(|(id, pin)| {
165            let slot = *pin.slot.as_ref();
166            (
167                match slot.side {
168                    PinSide::West => 0_u8,
169                    PinSide::East => 1,
170                },
171                slot.offset,
172                *id,
173            )
174        });
175    }
176    pins
177}
178
179fn raise_block<R: RevKind>(
180    doc: &Document<R>,
181    (id, block): (BlockId, &Block),
182    names: &Names,
183    pins: &[(PinId, &Pin)],
184    assets: &mut AssetIds,
185) -> schema::Block {
186    let rect = *block.rect.as_ref();
187
188    let mut children: Vec<(GridRect, BlockId)> = doc
189        .blocks()
190        .filter(|(_, live)| live.is_alive())
191        .filter(|(_, live)| *live.as_ref().parent.as_ref() == id)
192        .map(|(kid, live)| (*live.as_ref().rect.as_ref(), kid))
193        .collect();
194    children.sort_by_key(|&(rect, kid)| position_key(&rect, kid));
195
196    let mut texts: Vec<(GridPoint, schema::Text)> = doc
197        .texts()
198        .filter(|(_, live)| live.is_alive())
199        .map(|(_, live)| live.as_ref())
200        .filter(|text| *text.owner.as_ref() == id)
201        .map(|text| {
202            let pos = *text.pos.as_ref();
203            (
204                pos,
205                schema::Text {
206                    text: text.text.as_ref().clone(),
207                    x: pos.x,
208                    y: pos.y,
209                    role: accent_from_role(*text.role.as_ref()),
210                },
211            )
212        })
213        .collect();
214    texts.sort_by(|(a, ta), (b, tb)| (a.y, a.x, &ta.text).cmp(&(b.y, b.x, &tb.text)));
215
216    let mut comments: Vec<schema::Comment> = doc
217        .comments()
218        .filter(|(_, live)| live.is_alive())
219        .map(|(_, live)| live.as_ref())
220        .filter(|comment| *comment.owner.as_ref() == id)
221        .map(|comment| {
222            let rect = *comment.rect.as_ref();
223            schema::Comment {
224                x: rect.top_left.x,
225                y: rect.top_left.y,
226                w: rect.size.w,
227                h: rect.size.h,
228                role: accent_from_role(*comment.role.as_ref()),
229                title: raise_label(&comment.title, LabelSide::Bottom),
230            }
231        })
232        .collect();
233    comments.sort_by_key(|c| (c.y, c.x, c.w, c.h));
234
235    let mut images: Vec<schema::Image> = doc
236        .images()
237        .filter(|(_, live)| live.is_alive())
238        .map(|(_, live)| live.as_ref())
239        .filter(|image| *image.owner.as_ref() == id)
240        .filter_map(|image| {
241            let asset = assets.id_for(doc, *image.asset.as_ref())?;
242            Some(placement(asset, *image.rect.as_ref()))
243        })
244        .collect();
245    images.sort_by(|a, b| {
246        a.y.total_cmp(&b.y)
247            .then(a.x.total_cmp(&b.x))
248            .then(a.asset.cmp(&b.asset))
249    });
250
251    let icon = block.icon.as_ref();
252    let icon = (*icon != Icon::default())
253        .then(|| Some(placement(assets.id_for(doc, icon.asset)?, icon.rect)))
254        .flatten();
255
256    schema::Block {
257        id: names.blocks[&id].clone(),
258        x: rect.top_left.x,
259        y: rect.top_left.y,
260        w: rect.size.w,
261        h: rect.size.h,
262        role: accent_from_role(*block.role.as_ref()),
263        locked: *block.locked.as_ref(),
264        title: raise_label(&block.title, LabelSide::Bottom),
265        type_label: raise_label(&block.type_label, LabelSide::Top),
266        pins: pins
267            .iter()
268            .map(|&(pin_id, pin)| raise_pin(pin_id, pin, names))
269            .collect(),
270        routes: raise_routes(doc, id, names),
271        texts: texts.into_iter().map(|(_, text)| text).collect(),
272        comments,
273        images,
274        icon,
275        children: children
276            .into_iter()
277            .map(|(_, kid)| names.blocks[&kid].clone())
278            .collect(),
279    }
280}
281
282fn raise_pin(id: PinId, pin: &Pin, names: &Names) -> schema::Pin {
283    let slot = *pin.slot.as_ref();
284    let rect = *pin.rect.as_ref();
285    schema::Pin {
286        id: names.pins[&id].1.clone(),
287        name: pin.name.as_ref().clone(),
288        type_label: pin.type_name.as_ref().clone(),
289        tag: pin.tag.as_ref().clone(),
290        tag_hidden: *pin.tag_hidden.as_ref(),
291        loc: Some(format_loc(schema_pin_side(slot.side), slot.offset)),
292        x: Some(rect.top_left.x),
293        y: Some(rect.top_left.y),
294        w: Some(rect.size.w),
295        dir: schema_pin_dir(*pin.dir.as_ref()),
296        // Stub and port-pin accents are derived from route roles (swap D2)
297        // and are never document state, so they are never exported.
298        pin_accent: None,
299        port_accent: accent_from_role(*pin.port_accent.as_ref()),
300        port_pin_accent: None,
301        fliplr: *pin.flip_lr.as_ref(),
302    }
303}
304
305fn raise_routes<R: RevKind>(
306    doc: &Document<R>,
307    owner: BlockId,
308    names: &Names,
309) -> Vec<schema::Route> {
310    let spell = |pin: PinId| -> Option<String> {
311        let (pin_owner, name) = names.pins.get(&pin)?;
312        Some(if *pin_owner == owner {
313            name.clone()
314        } else {
315            format!("{}:{name}", names.blocks.get(pin_owner)?)
316        })
317    };
318    let mut labels: HashMap<RouteId, Vec<f32>> = HashMap::default();
319    for (_, live) in doc.route_labels().filter(|(_, live)| live.is_alive()) {
320        let label = live.as_ref();
321        labels
322            .entry(*label.owner.as_ref())
323            .or_default()
324            .push((*label.pos.as_ref()).into());
325    }
326
327    let mut routes: Vec<schema::Route> = doc
328        .routes()
329        .filter(|(_, live)| live.is_alive())
330        .filter(|(_, live)| *live.as_ref().owner.as_ref() == owner)
331        .filter_map(|(id, live)| {
332            let route = live.as_ref();
333            let (Some(from), Some(to)) = (spell(route.from), spell(route.to)) else {
334                // Unreachable through the fold (it refuses a route whose
335                // endpoint is absent), so worth a loud note if it happens.
336                tracing::warn!("not exporting a route with an unresolvable endpoint");
337                return None;
338            };
339            let mut positions = labels.remove(&id).unwrap_or_default();
340            positions.sort_by(|a, b| a.total_cmp(b));
341            Some(schema::Route {
342                name: route.name.as_ref().clone(),
343                from,
344                to,
345                role: accent_from_role(*route.role.as_ref()),
346                waypoints: route
347                    .waypoints
348                    .as_ref()
349                    .iter()
350                    .map(|w| schema::Waypoint {
351                        x: w.pos.x,
352                        y: w.pos.y,
353                        locked: w.locked,
354                    })
355                    .collect(),
356                labels: positions,
357            })
358        })
359        .collect();
360    routes.sort_by(|a, b| (&a.from, &a.to, &a.name).cmp(&(&b.from, &b.to, &b.name)));
361    routes
362}
363
364/// A label namespace back as the schema's optional label. Collapses in two
365/// steps mirroring the loader's fallbacks: a side equal to the per-kind
366/// fallback is omitted, and a label all of whose fields are at their
367/// omitted-state values is not written at all.
368fn raise_label(label: &Label, fallback: LabelSide) -> Option<schema::Label> {
369    let name = label.name.as_ref();
370    let side = *label.side.as_ref();
371    let offset: f32 = (*label.offset.as_ref()).into();
372    let hidden = *label.hidden.as_ref();
373    if name.is_empty() && side == fallback && offset == 0.0 && !hidden {
374        return None;
375    }
376    Some(schema::Label {
377        name: name.clone(),
378        side: (side != fallback).then(|| schema_label_side(side)),
379        offset,
380        hidden,
381    })
382}
383
384fn placement(asset: String, rect: blockworx_doc::geometry::ScreenRect) -> schema::Image {
385    let rect = artwork_rect(rect);
386    schema::Image {
387        asset,
388        x: rect.min.x,
389        y: rect.min.y,
390        w: rect.width(),
391        h: rect.height(),
392    }
393}
394
395/// Payloads keyed by their content hash, named content-derived
396/// (`"<hash16>.<ext>"`) exactly as the legacy writer named them — equal
397/// images get equal ids in every document, and an unrelated edit never
398/// renumbers anything.
399#[derive(Default)]
400struct AssetIds {
401    ids: Vec<(blockworx_doc::hash::AssetHash, String)>,
402    assets: Vec<schema::Asset>,
403}
404
405impl AssetIds {
406    /// The id naming `hash`, minting (and recording the payload) on first
407    /// sight. `None` where the document holds a placement but not the bytes
408    /// it names — the fold ought to bar that, so it is worth a loud note.
409    fn id_for<R: RevKind>(
410        &mut self,
411        doc: &Document<R>,
412        hash: blockworx_doc::hash::AssetHash,
413    ) -> Option<String> {
414        if let Some((_, id)) = self.ids.iter().find(|(seen, _)| *seen == hash) {
415            return Some(id.clone());
416        }
417        let Some(asset) = doc.asset(&hash) else {
418            tracing::warn!("not exporting a placement of {hash}: the document holds no payload");
419            return None;
420        };
421        let (bytes, ext, image) = match asset {
422            Asset::Svg(bytes) => (
423                &**bytes,
424                "svg",
425                schema::ImageData::Svg(String::from_utf8_lossy(bytes).into_owned()),
426            ),
427            Asset::Png(bytes) => (
428                &**bytes,
429                "png",
430                schema::ImageData::Png(base64::engine::general_purpose::STANDARD.encode(bytes)),
431            ),
432        };
433        let id = format!("{}.{ext}", &blake3::hash(bytes).to_hex()[..16]);
434        self.assets.push(schema::Asset {
435            id: id.clone(),
436            image,
437        });
438        self.ids.push((hash, id.clone()));
439        Some(id)
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::schema::lower::lower;
447    use blockworx_doc::rev::Confirmed;
448
449    /// `lower`'s own everything-in-one-file fixture, round-tripped here.
450    const RICH: &str = crate::schema::lower::tests::RICH;
451
452    fn folded(src: &str) -> Document<Confirmed> {
453        let parsed = schema::Document::parse_kdl(src, "raise").expect("the fixture parses");
454        let lowered = lower(&parsed, "raise");
455        let mut doc = Document::<Confirmed>::default();
456        for commit in &lowered.commits {
457            doc = doc.try_apply(commit).expect("the lowered commit folds");
458        }
459        doc
460    }
461
462    fn block_named<'a>(doc: &'a schema::Document, title: &str) -> &'a schema::Block {
463        doc.blocks
464            .iter()
465            .find(|b| b.title.as_ref().is_some_and(|t| t.name == title))
466            .unwrap_or_else(|| panic!("no raised block titled {title:?}"))
467    }
468
469    /// The projection carries every stored field back out — spot-checked
470    /// against the fixture's own values so a field `raise` dropped cannot
471    /// hide behind the round trip's symmetry.
472    #[test]
473    fn the_rich_fixture_raises_with_its_fields_intact() {
474        let raised = raise(&folded(RICH));
475
476        assert_eq!(raised.version, schema::CURRENT_VERSION);
477        assert_eq!(raised.name.as_deref(), Some("bridge"));
478
479        let sheet = block_named(&raised, "sheet");
480        assert_eq!(raised.top, sheet.id, "the top pointer survives");
481        assert_eq!((sheet.x, sheet.y, sheet.w, sheet.h), (0, 0, 30, 20));
482        let title = sheet.title.as_ref().expect("the sheet keeps its title");
483        assert_eq!(
484            (title.side, title.offset, title.hidden),
485            (Some(crate::schema::enums::LabelSide::Center), 1.5, true),
486        );
487
488        let core = block_named(&raised, "core");
489        assert_eq!(sheet.children, vec![core.id.clone()], "nesting survives");
490        assert_eq!(core.role, Some(3));
491        assert!(core.locked);
492        assert_eq!(
493            core.type_label.as_ref().map(|t| (t.name.as_str(), t.side)),
494            Some(("Add", None)),
495            "a type label at its per-kind default side omits the side",
496        );
497
498        let pin = |block: &schema::Block, name: &str| -> schema::Pin {
499            block
500                .pins
501                .iter()
502                .find(|p| p.name == name)
503                .unwrap_or_else(|| panic!("no raised pin named {name:?}"))
504                .clone()
505        };
506        let input = pin(sheet, "in");
507        assert_eq!(input.loc.as_deref(), Some("w0"));
508        assert_eq!(input.dir, Some(crate::schema::enums::PinType::Input));
509        assert_eq!((input.tag.as_str(), input.tag_hidden), ("A", true));
510        assert_eq!(input.port_accent, Some(2));
511        assert!(input.fliplr);
512        assert!(
513            input.x.is_some() && input.w.is_some(),
514            "an omitted port body exports explicitly once reconstructed",
515        );
516        let output = pin(sheet, "out");
517        assert_eq!((output.x, output.y, output.w), (Some(4), Some(6), Some(5)));
518        assert_eq!(output.type_label, "bit");
519        assert_eq!(
520            pin(core, "a").dir,
521            None,
522            "an in-out pin omits `dir`, the spelling that reads back as in-out",
523        );
524
525        let route = {
526            assert_eq!(sheet.routes.len(), 1, "the dangling route stayed dropped");
527            &sheet.routes[0]
528        };
529        assert_eq!(route.name, "net");
530        assert_eq!(route.role, Some(1));
531        assert_eq!(
532            route.from,
533            pin(sheet, "in").id,
534            "an own-block anchor is bare"
535        );
536        assert_eq!(
537            route.to,
538            format!("{}:{}", core.id, pin(core, "a").id),
539            "a child anchor is qualified",
540        );
541        assert_eq!(
542            route.waypoints,
543            vec![
544                schema::Waypoint {
545                    x: 12,
546                    y: 35,
547                    locked: false
548                },
549                schema::Waypoint {
550                    x: 2,
551                    y: 41,
552                    locked: true
553                },
554            ],
555        );
556        assert_eq!(route.labels, vec![7.5]);
557
558        assert_eq!(sheet.texts.len(), 1);
559        assert_eq!(
560            (sheet.texts[0].x, sheet.texts[0].y, sheet.texts[0].role),
561            (10, 5, Some(1)),
562        );
563        assert_eq!(sheet.comments.len(), 1);
564        assert_eq!(
565            sheet.comments[0].title.as_ref().map(|t| t.name.as_str()),
566            Some("group"),
567        );
568
569        assert_eq!(raised.assets.len(), 1, "one payload for two placements");
570        let asset = &raised.assets[0];
571        assert_eq!(
572            asset.id,
573            format!("{}.svg", &blake3::hash(b"<svg/>").to_hex()[..16]),
574            "the id is content-derived, replacing the file's own spelling",
575        );
576        assert_eq!(sheet.images.len(), 1);
577        assert_eq!(sheet.images[0].asset, asset.id);
578        assert_eq!(
579            sheet.icon.as_ref().map(|icon| icon.asset.as_str()),
580            Some(asset.id.as_str()),
581        );
582        assert_ne!(
583            (sheet.images[0].x, sheet.images[0].y),
584            (
585                sheet.icon.as_ref().unwrap().x,
586                sheet.icon.as_ref().unwrap().y
587            ),
588            "the two placements keep their own boxes",
589        );
590    }
591
592    /// The round trip: raised text, read back through the real pipeline,
593    /// raises identically. `raise` is canonical (deterministic ids and
594    /// order), so equality of raised forms is equality of diagrams.
595    #[test]
596    fn parse_lower_fold_raise_is_identity_on_the_raised_form() {
597        let raised = raise(&folded(RICH));
598        let round_tripped = raise(&folded(&raised.to_kdl()));
599        assert_eq!(round_tripped, raised);
600    }
601
602    /// The canonical form really is canonical: the same diagram spelled
603    /// with different ids, in a different order, raises to the same text.
604    #[test]
605    fn raising_normalizes_ids_and_order() {
606        const REORDERED: &str = r#"
607            name "bridge"
608            top "b7"
609
610            block "b3" x=6 y=6 w=8 h=5 {
611                title "core"
612                pin "p9" "a" loc="w1"
613            }
614
615            block "b7" x=0 y=0 w=30 h=20 {
616                title "sheet"
617                pin "p4" "out" loc="e3" x=4 y=6 w=5
618                pin "p2" "in" loc="w0"
619                route "p2" "b3:p9" name="net"
620                children "b3"
621            }
622        "#;
623        const CANONICAL: &str = r#"
624            name "bridge"
625            top "b0"
626
627            block "b0" x=0 y=0 w=30 h=20 {
628                title "sheet"
629                pin "p1" "in" loc="w0"
630                pin "p2" "out" loc="e3" x=4 y=6 w=5
631                route "p1" "b1:p1" name="net"
632                children "b1"
633            }
634
635            block "b1" x=6 y=6 w=8 h=5 {
636                title "core"
637                pin "p1" "a" loc="w1"
638            }
639        "#;
640        assert_eq!(
641            raise(&folded(REORDERED)).to_kdl(),
642            raise(&folded(CANONICAL)).to_kdl(),
643        );
644    }
645
646    /// The degenerate export: nothing to say, but still a well-formed
647    /// document shape (an empty block list and an empty top pointer).
648    #[test]
649    fn an_empty_document_raises_empty() {
650        let raised = raise(&Document::<Confirmed>::default());
651        assert_eq!(raised.blocks, vec![]);
652        assert_eq!(raised.top, "");
653        assert_eq!(raised.name, None);
654    }
655}