1use 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
34struct Names {
37 blocks: HashMap<BlockId, String>,
38 pins: HashMap<PinId, (BlockId, String)>,
39}
40
41pub 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
92pub fn to_kdl<R: RevKind>(doc: &Document<R>) -> String {
94 raise(doc).to_kdl()
95}
96
97fn position_key(rect: &GridRect, id: BlockId) -> (i32, i32, BlockId) {
101 (rect.top_left.y, rect.top_left.x, id)
102}
103
104fn 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 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 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 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
364fn 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#[derive(Default)]
400struct AssetIds {
401 ids: Vec<(blockworx_doc::hash::AssetHash, String)>,
402 assets: Vec<schema::Asset>,
403}
404
405impl AssetIds {
406 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 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 #[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 #[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 #[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 #[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}