1#![allow(clippy::result_large_err)]
11
12use std::str::FromStr;
13
14use base64::Engine as _;
15
16use blockworx_doc::geometry::{
17 GridPoint, GridRect as DocGridRect, GridSize as DocGridSize, PinSlot,
18};
19
20use crate::document::{
21 Asset, AutoRoute, Block, BlockLabel, Comment, Decorations, Document, GridPos, GridRect,
22 GridSize, Image, ImageData, LabelSide, LineAnchor, LinearDistance, PinPort, PinSide, PinType,
23 TextBox, Waypoint,
24};
25use crate::edit::lower::pin_side;
26use crate::presentation::store::{IdMap, IdMapExt, IdSet};
27use crate::schema::SchemaError;
28use crate::schema::enums;
29use crate::schema::loc::{format_loc, parse_loc};
30use crate::schema::model as schema;
31use crate::shape::port::PORT_HEIGHT;
32use crate::store::{CommentId, ImageId, PinId, RectId, RouteId, TextId, WaypointId, WireLabelId};
33
34fn as_doc(rect: GridRect) -> DocGridRect {
37 DocGridRect {
38 top_left: GridPoint {
39 x: rect.min.x,
40 y: rect.min.y,
41 },
42 size: DocGridSize {
43 w: rect.size.w,
44 h: rect.size.h,
45 },
46 }
47}
48
49fn from_doc(rect: DocGridRect) -> GridRect {
50 GridRect::new(
51 GridPos::new(rect.top_left.x, rect.top_left.y),
52 GridSize::new(rect.size.w, rect.size.h),
53 )
54}
55
56impl Block {
62 fn default_port_rect(&self, name: &str, side: PinSide, offset: u32) -> GridRect {
63 let siblings: Vec<DocGridRect> = self.pins.values().map(|p| as_doc(p.rect)).collect();
66 from_doc(crate::edit::create::default_port_rect(
67 as_doc(self.inner),
68 &siblings,
69 name,
70 PinSlot {
71 side: pin_side(side.into()),
72 offset,
73 },
74 ))
75 }
76
77 fn fill_missing_port_rects(&mut self) {
82 let ids: Vec<PinId> = self.pins.keys().copied().collect();
83 for id in ids {
84 if self.pins[&id].rect.size.w != 0 {
85 continue;
86 }
87 let (name, side, offset) = {
88 let p = &self.pins[&id];
89 (p.name.clone(), p.side, p.offset)
90 };
91 let rect = self.default_port_rect(&name, side, offset);
92 if let Some(p) = self.pins.get_mut(&id) {
93 p.rect = rect;
94 }
95 }
96 }
97
98 fn auto_port_rects(&self) -> Vec<(PinId, GridRect)> {
102 let mut probe = self.clone();
103 for p in probe.pins.values_mut() {
104 p.rect = GridRect::default();
105 }
106 probe.fill_missing_port_rects();
107 probe.pins.iter().map(|(&id, p)| (id, p.rect)).collect()
108 }
109}
110
111impl From<PinSide> for enums::PinSide {
116 fn from(v: PinSide) -> Self {
117 match v {
118 PinSide::East => enums::PinSide::East,
119 PinSide::West => enums::PinSide::West,
120 }
121 }
122}
123impl From<enums::PinSide> for PinSide {
124 fn from(v: enums::PinSide) -> Self {
125 match v {
126 enums::PinSide::East => PinSide::East,
127 enums::PinSide::West => PinSide::West,
128 }
129 }
130}
131
132impl From<PinType> for enums::PinType {
133 fn from(v: PinType) -> Self {
134 match v {
135 PinType::Input => enums::PinType::Input,
136 PinType::Output => enums::PinType::Output,
137 PinType::InOut => enums::PinType::InOut,
138 }
139 }
140}
141impl From<enums::PinType> for PinType {
142 fn from(v: enums::PinType) -> Self {
143 match v {
144 enums::PinType::Input => PinType::Input,
145 enums::PinType::Output => PinType::Output,
146 enums::PinType::InOut => PinType::InOut,
147 }
148 }
149}
150
151impl From<LabelSide> for enums::LabelSide {
152 fn from(v: LabelSide) -> Self {
153 match v {
154 LabelSide::Top => enums::LabelSide::Top,
155 LabelSide::Center => enums::LabelSide::Center,
156 LabelSide::Bottom => enums::LabelSide::Bottom,
157 }
158 }
159}
160impl From<enums::LabelSide> for LabelSide {
161 fn from(v: enums::LabelSide) -> Self {
162 match v {
163 enums::LabelSide::Top => LabelSide::Top,
164 enums::LabelSide::Center => LabelSide::Center,
165 enums::LabelSide::Bottom => LabelSide::Bottom,
166 }
167 }
168}
169
170impl From<&Document> for schema::Document {
173 fn from(doc: &Document) -> Self {
174 let mut assets = AssetIds::default();
175 let blocks = doc
176 .blocks
177 .iter()
178 .map(|(&id, b)| schema::Block::from_block(id, b, &mut assets))
179 .collect();
180 schema::Document {
181 version: schema::CURRENT_VERSION,
184 name: doc.name.clone(),
185 top: doc.top_id.to_string(),
186 blocks,
187 assets: assets.into_assets(),
188 }
189 }
190}
191
192fn asset_bytes(image: &ImageData) -> (&[u8], &'static str) {
196 match image {
197 ImageData::Svg(src) => (src.as_bytes(), "svg"),
198 ImageData::Png(bytes) => (bytes, "png"),
199 }
200}
201
202pub fn asset_id(image: &ImageData) -> String {
209 let (bytes, ext) = asset_bytes(image);
210 let hash = blake3::hash(bytes).to_hex();
211 format!("{}.{ext}", &hash[..16])
212}
213
214pub fn asset_to_bytes(asset: &schema::Asset) -> Result<Vec<u8>, SchemaError> {
217 match &asset.image {
218 schema::ImageData::Svg(src) => Ok(src.clone().into_bytes()),
219 schema::ImageData::Png(b64) => base64::engine::general_purpose::STANDARD
220 .decode(b64.as_bytes())
221 .map_err(|e| SchemaError::AssetPng {
222 asset: asset.id.clone(),
223 reason: e.to_string(),
224 }),
225 }
226}
227
228pub fn asset_from_bytes(id: &str, bytes: Vec<u8>) -> Result<schema::Asset, SchemaError> {
232 let ext = id.rsplit_once('.').map(|(_, ext)| ext).unwrap_or_default();
233 let image = match ext {
234 "svg" => {
235 schema::ImageData::Svg(String::from_utf8(bytes).map_err(|e| SchemaError::AssetPng {
236 asset: id.to_string(),
237 reason: format!("not valid UTF-8: {e}"),
238 })?)
239 }
240 "png" => schema::ImageData::Png(base64::engine::general_purpose::STANDARD.encode(&bytes)),
241 other => {
242 return Err(SchemaError::AssetPng {
243 asset: id.to_string(),
244 reason: format!("{other:?} is not an image format this build stores"),
245 });
246 }
247 };
248 Ok(schema::Asset {
249 id: id.to_string(),
250 image,
251 })
252}
253
254#[derive(Default)]
260pub struct AssetIds {
261 ids: std::collections::HashMap<Asset, String>,
262 assets: Vec<schema::Asset>,
263}
264
265impl AssetIds {
266 fn id_for(&mut self, asset: &Asset) -> String {
268 if let Some(id) = self.ids.get(asset) {
269 return id.clone();
270 }
271 let id = asset_id(asset);
272 self.assets.push(schema::Asset {
273 id: id.clone(),
274 image: match &**asset {
275 ImageData::Svg(src) => schema::ImageData::Svg(src.clone()),
276 ImageData::Png(bytes) => {
277 schema::ImageData::Png(base64::engine::general_purpose::STANDARD.encode(bytes))
278 }
279 },
280 });
281 self.ids.insert(asset.clone(), id.clone());
282 id
283 }
284
285 pub fn into_assets(self) -> Vec<schema::Asset> {
286 self.assets
287 }
288}
289
290pub struct Assets(std::collections::HashMap<String, Asset>);
294
295impl Assets {
296 pub fn decode(assets: Vec<schema::Asset>) -> Result<Self, SchemaError> {
299 let mut map = std::collections::HashMap::new();
300 for a in assets {
301 let image = match a.image {
302 schema::ImageData::Svg(svg) => ImageData::Svg(svg),
303 schema::ImageData::Png(png) => ImageData::Png(
304 base64::engine::general_purpose::STANDARD
305 .decode(png.as_bytes())
306 .map_err(|e| SchemaError::AssetPng {
307 asset: a.id.clone(),
308 reason: e.to_string(),
309 })?,
310 ),
311 };
312 if map.insert(a.id.clone(), Asset::from(image)).is_some() {
313 return Err(SchemaError::DuplicateAsset { asset: a.id });
314 }
315 }
316 Ok(Self(map))
317 }
318
319 fn get(&self, id: &str, block: &str) -> Result<Asset, SchemaError> {
320 self.0
321 .get(id)
322 .cloned()
323 .ok_or_else(|| SchemaError::UnknownAsset {
324 block: block.to_string(),
325 asset: id.to_string(),
326 })
327 }
328}
329
330fn round1(v: f32) -> f32 {
333 (v * 10.0).round() / 10.0
334}
335
336fn from_label(label: &BlockLabel, default: &BlockLabel) -> Option<schema::Label> {
339 if label == default {
340 return None;
341 }
342 Some(schema::Label {
343 name: label.name.clone(),
344 side: (label.side != default.side).then(|| enums::LabelSide::from(label.side)),
345 offset: round1(label.offset),
346 hidden: label.hidden,
347 })
348}
349
350impl schema::Block {
351 pub fn from_block(id: RectId, b: &Block, assets: &mut AssetIds) -> Self {
352 let auto: std::collections::HashMap<PinId, GridRect> =
355 b.auto_port_rects().into_iter().collect();
356 schema::Block {
357 id: id.to_string(),
358 x: b.inner.min.x,
359 y: b.inner.min.y,
360 w: b.inner.size.w,
361 h: b.inner.size.h,
362 role: b.role,
363 locked: b.locked,
364 title: from_label(&b.decorations.title, &BlockLabel::default()),
365 type_label: from_label(&b.decorations.type_label, &BlockLabel::upper_left()),
366 pins: b
367 .pins
368 .iter()
369 .map(|(&id, p)| schema::Pin::from_pin(id, p, auto.get(&id).copied()))
370 .collect(),
371 routes: b.routes.values().map(schema::Route::from_route).collect(),
372 texts: b.texts.values().map(schema::Text::from_text).collect(),
373 comments: b
374 .comments
375 .values()
376 .map(schema::Comment::from_comment)
377 .collect(),
378 images: b
379 .images
380 .values()
381 .map(|s| schema::Image::from_symbol(s, assets))
382 .collect(),
383 icon: b
384 .icon
385 .as_ref()
386 .map(|s| schema::Image::from_symbol(s, assets)),
387 children: b.children.iter().map(|&cid| cid.to_string()).collect(),
388 }
389 }
390}
391
392impl schema::Pin {
393 pub fn from_pin(id: PinId, p: &PinPort, auto: Option<GridRect>) -> Self {
394 let is_auto = auto == Some(p.rect);
397 schema::Pin {
398 id: id.to_string(),
399 name: p.name.clone(),
400 type_label: p.type_label.clone(),
401 tag: p.tag.clone(),
402 tag_hidden: p.tag_hidden,
403 loc: Some(format_loc(p.side.into(), p.offset)),
404 x: (!is_auto).then_some(p.rect.min.x),
405 y: (!is_auto).then_some(p.rect.min.y),
406 w: (!is_auto).then_some(p.rect.size.w),
407 dir: (p.kind != PinType::InOut).then(|| p.kind.into()),
408 pin_accent: None,
412 port_accent: p.port_accent,
413 port_pin_accent: None,
414 fliplr: p.port_orientation == Some(p.side),
417 }
418 }
419}
420
421impl schema::Route {
422 pub fn from_route(r: &AutoRoute) -> Self {
423 schema::Route {
424 name: r.route_name().to_string(),
425 from: LineAnchor::to_string(&r.start()),
426 to: LineAnchor::to_string(&r.finish()),
427 role: r.role(),
428 waypoints: r
429 .iter_waypoints()
430 .map(|(_, w)| schema::Waypoint {
431 x: w.pos.x,
432 y: w.pos.y,
433 locked: w.locked,
434 })
435 .collect(),
436 labels: r
437 .iter_labels()
438 .map(|(_, &d)| round1(f32::from(d)))
439 .collect(),
440 }
441 }
442}
443
444impl schema::Text {
445 pub fn from_text(t: &TextBox) -> Self {
446 schema::Text {
447 text: t.text.clone(),
448 x: t.anchor.x,
449 y: t.anchor.y,
450 role: t.role,
451 }
452 }
453}
454
455impl schema::Comment {
456 pub fn from_comment(c: &Comment) -> Self {
457 schema::Comment {
458 x: c.inner.min.x,
459 y: c.inner.min.y,
460 w: c.inner.size.w,
461 h: c.inner.size.h,
462 role: c.role,
463 title: from_label(&c.title, &BlockLabel::default()),
464 }
465 }
466}
467
468impl schema::Image {
469 pub fn from_symbol(s: &Image, assets: &mut AssetIds) -> Self {
470 schema::Image {
471 asset: assets.id_for(&s.image),
472 x: round1(s.inner.min.x),
473 y: round1(s.inner.min.y),
474 w: round1(s.inner.width()),
475 h: round1(s.inner.height()),
476 }
477 }
478}
479
480fn parse_id<T: FromStr<Err = String>>(kind: &'static str, s: &str) -> Result<T, SchemaError> {
483 s.parse().map_err(|reason| SchemaError::Id {
484 kind,
485 value: s.to_string(),
486 reason,
487 })
488}
489
490fn parse_anchor(block: &str, s: &str) -> Result<LineAnchor, SchemaError> {
491 let anchor_err = |reason: String| SchemaError::Anchor {
492 block: block.to_string(),
493 value: s.to_string(),
494 reason,
495 };
496 if let Some((b, p)) = s.split_once(':') {
497 Ok(LineAnchor::Pin {
498 block: b.parse().map_err(anchor_err)?,
499 pin: p.parse().map_err(anchor_err)?,
500 })
501 } else {
502 Ok(LineAnchor::Port(s.parse().map_err(anchor_err)?))
503 }
504}
505
506impl schema::Label {
507 fn into_label(self, default: &BlockLabel) -> BlockLabel {
508 BlockLabel {
509 name: self.name,
510 hidden: self.hidden,
511 side: self.side.map_or(default.side, Into::into),
512 offset: self.offset,
513 }
514 }
515}
516
517fn label_or(dto: Option<schema::Label>, default: BlockLabel) -> BlockLabel {
518 match dto {
519 Some(l) => l.into_label(&default),
520 None => default,
521 }
522}
523
524impl schema::Document {
525 pub fn into_document(self) -> Result<Document, SchemaError> {
526 let top_id: RectId = self.top.parse().map_err(|reason| SchemaError::BadTop {
527 value: self.top.clone(),
528 reason,
529 })?;
530 let assets = Assets::decode(self.assets)?;
531 let mut blocks: IdMap<RectId, Block> = IdMap::default();
532 for sb in self.blocks {
533 let (id, block) = sb.into_block(&assets)?;
534 blocks.insert(id, block);
535 }
536 if !blocks.contains_key(&top_id) {
537 return Err(SchemaError::BadTop {
538 value: self.top,
539 reason: "no block with that id".into(),
540 });
541 }
542 validate_children(&blocks)?;
543 Ok(Document {
544 name: self.name,
545 top_id,
546 blocks: blocks.into(),
547 })
548 }
549}
550
551fn validate_children(blocks: &IdMap<RectId, Block>) -> Result<(), SchemaError> {
554 for (bid, block) in blocks {
555 for cid in &block.children {
556 if !blocks.contains_key(cid) {
557 return Err(SchemaError::DanglingChild {
558 block: bid.to_string(),
559 child: cid.to_string(),
560 });
561 }
562 }
563 }
564 Ok(())
565}
566
567impl schema::Block {
568 pub fn into_block(self, assets: &Assets) -> Result<(RectId, Block), SchemaError> {
569 let id: RectId = parse_id("block", &self.id)?;
570 let block_ctx = self.id;
571
572 let decorations = Decorations {
573 title: label_or(self.title, BlockLabel::default()),
574 type_label: label_or(self.type_label, BlockLabel::upper_left()),
575 };
576
577 let mut pins: IdMap<PinId, PinPort> = IdMap::default();
578 for p in self.pins {
579 let (pid, pin) = p.into_pin(&block_ctx)?;
580 pins.insert(pid, pin);
581 }
582
583 let mut routes: IdMap<RouteId, AutoRoute> = IdMap::default();
586 for r in self.routes {
587 routes.insert_value(r.into_route(&block_ctx)?);
588 }
589
590 let mut texts: IdMap<TextId, TextBox> = IdMap::default();
591 for t in self.texts {
592 texts.insert_value(t.into_text());
593 }
594
595 let mut comments: IdMap<CommentId, Comment> = IdMap::default();
596 for c in self.comments {
597 comments.insert_value(c.into_comment());
598 }
599
600 let mut images: IdMap<ImageId, Image> = IdMap::default();
601 for s in self.images {
602 images.insert_value(s.into_symbol(&block_ctx, assets)?);
603 }
604 let icon = match self.icon {
605 Some(s) => Some(s.into_symbol(&block_ctx, assets)?),
606 None => None,
607 };
608
609 let mut children: IdSet<RectId> = IdSet::default();
610 for cid in self.children {
611 children.insert(parse_id("block", &cid)?);
612 }
613
614 let block = Block {
615 inner: GridRect::new(GridPos::new(self.x, self.y), GridSize::new(self.w, self.h)),
616 decorations,
617 pins,
618 children,
619 routes,
620 texts,
621 comments,
622 images,
623 icon,
624 role: self.role,
625 locked: self.locked,
626 };
627 let mut block = block;
629 block.fill_missing_port_rects();
630 Ok((id, block))
631 }
632}
633
634impl schema::Pin {
635 pub fn into_pin(self, block: &str) -> Result<(PinId, PinPort), SchemaError> {
636 let id: PinId = parse_id("pin", &self.id)?;
637 let loc = self.loc.ok_or_else(|| SchemaError::MissingPinSide {
638 block: block.to_string(),
639 pin: self.id.clone(),
640 })?;
641 let (side, offset) = parse_loc(&loc).map_err(|reason| SchemaError::BadLoc {
642 block: block.to_string(),
643 pin: self.id.clone(),
644 value: loc.clone(),
645 reason,
646 })?;
647 let side: PinSide = side.into();
648 let pin = PinPort {
649 name: self.name,
650 type_label: self.type_label,
651 tag: self.tag,
652 tag_hidden: self.tag_hidden,
653 side,
654 offset,
655 rect: match (self.x, self.y, self.w) {
658 (Some(x), Some(y), Some(w)) => {
659 GridRect::new(GridPos::new(x, y), GridSize::new(w, PORT_HEIGHT))
660 }
661 _ => GridRect::default(),
662 },
663 kind: self.dir.map(Into::into).unwrap_or_default(),
664 port_accent: self.port_accent,
665 port_orientation: self.fliplr.then_some(side),
667 };
668 Ok((id, pin))
669 }
670}
671
672impl schema::Route {
673 pub fn into_route(self, block: &str) -> Result<AutoRoute, SchemaError> {
674 let from = parse_anchor(block, &self.from)?;
675 let to = parse_anchor(block, &self.to)?;
676
677 let mut waypoints: IdMap<WaypointId, Waypoint> = IdMap::default();
678 for w in self.waypoints {
679 waypoints.insert_value(Waypoint {
680 pos: GridPos::new(w.x, w.y),
681 locked: w.locked,
682 });
683 }
684 let mut labels: IdMap<WireLabelId, LinearDistance> = IdMap::default();
685 for at in self.labels {
686 labels.insert_value(LinearDistance::from(at));
687 }
688
689 let mut route = AutoRoute::new(from, to, waypoints, labels);
690 route.set_route_name(self.name);
691 route.set_role(self.role);
692 Ok(route)
693 }
694}
695
696impl schema::Text {
697 pub fn into_text(self) -> TextBox {
698 TextBox {
699 text: self.text,
700 anchor: GridPos::new(self.x, self.y),
701 role: self.role,
702 }
703 }
704}
705
706impl schema::Comment {
707 pub fn into_comment(self) -> Comment {
708 Comment {
709 inner: GridRect::new(GridPos::new(self.x, self.y), GridSize::new(self.w, self.h)),
710 title: label_or(self.title, BlockLabel::default()),
711 role: self.role,
712 }
713 }
714}
715
716impl schema::Image {
717 pub fn into_symbol(self, block: &str, assets: &Assets) -> Result<Image, SchemaError> {
718 Ok(Image {
719 image: assets.get(&self.asset, block)?,
720 inner: egui::Rect::from_min_size(
721 egui::pos2(self.x, self.y),
722 egui::vec2(self.w, self.h),
723 ),
724 })
725 }
726}
727
728pub fn to_json(doc: &Document) -> String {
732 schema::Document::from(doc).to_json()
733}
734
735pub fn to_kdl(doc: &Document) -> String {
737 schema::Document::from(doc).to_kdl()
738}
739
740pub fn from_kdl(src: &str, src_name: &str) -> miette::Result<Document> {
743 Ok(schema::Document::parse_kdl(src, src_name)?.into_document()?)
744}
745
746pub fn load(src: &str, src_name: &str) -> miette::Result<Document> {
753 let is_kdl = std::path::Path::new(src_name)
754 .extension()
755 .is_some_and(|e| e.eq_ignore_ascii_case("kdl"));
756 if is_kdl {
757 finish_load(schema::Document::parse_kdl(src, src_name)?)
758 } else {
759 finish_load(schema::Document::parse_json(src, src_name)?)
760 }
761}
762
763pub fn finish_load(model: schema::Document) -> miette::Result<Document> {
770 Ok(model.into_document()?)
771}
772
773pub fn save(doc: &Document, dst_name: &str) -> String {
777 let is_kdl = std::path::Path::new(dst_name)
778 .extension()
779 .is_some_and(|e| e.eq_ignore_ascii_case("kdl"));
780 if is_kdl { to_kdl(doc) } else { to_json(doc) }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::{from_kdl, to_json, to_kdl};
786
787 fn from_json(src: &str, src_name: &str) -> miette::Result<Document> {
791 Ok(super::schema::Document::parse_json(src, src_name)?.into_document()?)
792 }
793 use crate::document::{
794 AutoRoute, Block, BlockLabel, Comment, Decorations, Document, GridPos, GridRect, GridSize,
795 Image, ImageData, LabelSide, LineAnchor, LinearDistance, PinPort, PinSide, PinType,
796 TextBox, Waypoint,
797 };
798 use crate::presentation::store::{IdMap, IdMapExt, IdSet};
799 use crate::schema::SchemaError;
800 use crate::schema::model;
801 use crate::store::{
802 CommentId, ImageId, PinId, RectId, RouteId, TextId, WaypointId, WireLabelId,
803 };
804
805 fn pin(name: &str, side: PinSide, offset: u32, kind: PinType, rect: GridRect) -> PinPort {
806 PinPort {
807 name: name.to_string(),
808 type_label: String::new(),
809 tag: String::new(),
810 tag_hidden: false,
811 side,
812 offset,
813 rect,
814 kind,
815 port_accent: None,
816 port_orientation: None,
817 }
818 }
819
820 fn rect(x: i32, y: i32, w: u32, h: u32) -> GridRect {
821 GridRect::new(GridPos::new(x, y), GridSize::new(w, h))
822 }
823
824 fn block(name: &str) -> Block {
825 Block {
826 decorations: Decorations {
827 title: BlockLabel {
828 name: name.to_string(),
829 ..BlockLabel::default()
830 },
831 ..Decorations::default()
832 },
833 ..Block::default()
834 }
835 }
836
837 fn route(
838 from: LineAnchor,
839 to: LineAnchor,
840 name: &str,
841 wps: &[(i32, i32)],
842 labels: &[f32],
843 ) -> AutoRoute {
844 let mut waypoints: IdMap<WaypointId, Waypoint> = IdMap::default();
845 for &(x, y) in wps {
846 waypoints.insert_value(Waypoint {
847 pos: GridPos::new(x, y),
848 locked: false,
849 });
850 }
851 let mut ld: IdMap<WireLabelId, LinearDistance> = IdMap::default();
852 for &l in labels {
853 ld.insert_value(LinearDistance::from(l));
854 }
855 let mut r = AutoRoute::new(from, to, waypoints, ld);
856 r.set_route_name(name.to_string());
857 r
858 }
859
860 fn demo() -> Document {
864 let top = RectId::nth_default(1);
865 let c1 = RectId::nth_default(2);
866 let c2 = RectId::nth_default(3);
867 let buffer = RectId::nth_default(4);
868
869 let mut t = block("block_1");
870 t.decorations.type_label.name = "Counter".to_string();
871 t.pins.insert(
872 PinId::nth_default(1),
873 pin("clk", PinSide::West, 1, PinType::Input, rect(-11, 28, 4, 2)),
874 );
875 t.pins.insert(
876 PinId::nth_default(2),
877 pin("rst", PinSide::West, 2, PinType::InOut, rect(-11, 32, 4, 2)),
878 );
879 t.children = IdSet::from_iter([c1, c2]);
880 t.role = Some(3);
881 t.locked = true;
882 t.texts.insert(
883 TextId::nth_default(1),
884 TextBox {
885 text: "line one\nline two".to_string(),
886 anchor: GridPos::new(5, 8),
887 role: Some(2),
888 },
889 );
890 t.comments.insert(
891 CommentId::nth_default(1),
892 Comment {
893 inner: rect(2, 2, 10, 8),
894 title: BlockLabel {
895 name: "Region".to_string(),
896 side: LabelSide::Top,
897 ..BlockLabel::default()
898 },
899 role: None,
900 },
901 );
902 t.images.insert(
903 ImageId::nth_default(1),
904 Image {
905 image: ImageData::Svg(
906 "<svg viewBox=\"0 0 10 10\">\n <path d=\"M0 0 L10 10\"/>\n</svg>".to_string(),
907 )
908 .into(),
909 inner: egui::Rect::from_min_size(egui::pos2(20.0, 15.0), egui::vec2(8.0, 8.0)),
910 },
911 );
912 t.icon = Some(Image {
913 image: ImageData::Svg(
914 "<svg viewBox=\"0 0 4 4\"><rect width=\"4\" height=\"4\"/></svg>".to_string(),
915 )
916 .into(),
917 inner: egui::Rect::from_min_size(egui::pos2(4.0, 4.0), egui::vec2(3.0, 3.0)),
918 });
919 let mut r1 = route(
920 LineAnchor::Pin {
921 block: c1,
922 pin: PinId::nth_default(1),
923 },
924 LineAnchor::Pin {
925 block: c2,
926 pin: PinId::nth_default(1),
927 },
928 "route_1",
929 &[(12, 21), (-16, 19)],
930 &[76.6],
931 );
932 r1.set_role(Some(5));
933 t.routes.insert(RouteId::nth_default(1), r1);
934 t.routes.insert(
935 RouteId::nth_default(2),
936 route(
937 LineAnchor::Pin {
938 block: c2,
939 pin: PinId::nth_default(1),
940 },
941 LineAnchor::Port(PinId::nth_default(1)),
942 "",
943 &[],
944 &[],
945 ),
946 );
947
948 let mut b1 = block("block_1");
949 b1.pins.insert(
950 PinId::nth_default(1),
951 pin(
952 "i.1.write_logic",
953 PinSide::West,
954 1,
955 PinType::InOut,
956 rect(0, 0, 8, 2),
957 ),
958 );
959 let mut fancy = pin(
961 "typed",
962 PinSide::East,
963 3,
964 PinType::Output,
965 rect(20, 6, 5, 2),
966 );
967 fancy.type_label = "Counter".to_string();
968 fancy.tag = "U3".to_string();
969 fancy.tag_hidden = true;
970 fancy.port_accent = Some(2);
971 fancy.port_orientation = Some(PinSide::East);
974 b1.pins.insert(PinId::nth_default(2), fancy);
975
976 let mut b2 = block("block_2b");
977 b2.pins.insert(
978 PinId::nth_default(1),
979 pin(
980 "o.0.read_logic",
981 PinSide::East,
982 1,
983 PinType::InOut,
984 rect(30, 0, 8, 2),
985 ),
986 );
987 b2.children = IdSet::from_iter([buffer]);
988
989 let mut buf = block("Internal Buffer");
990 buf.pins.insert(
991 PinId::nth_default(1),
992 pin("Port", PinSide::West, 1, PinType::InOut, rect(22, 5, 4, 2)),
993 );
994
995 let mut blocks: IdMap<RectId, Block> = IdMap::default();
996 blocks.insert(top, t);
997 blocks.insert(c1, b1);
998 blocks.insert(c2, b2);
999 blocks.insert(buffer, buf);
1000 Document {
1001 name: None,
1002 top_id: top,
1003 blocks: blocks.into(),
1004 }
1005 }
1006
1007 #[test]
1008 fn json_round_trip_is_idempotent() {
1009 let doc = demo();
1010 let s1 = to_json(&doc);
1011 let back = from_json(&s1, "demo.json").unwrap_or_else(|e| panic!("re-parse:\n{e:?}\n{s1}"));
1012 let s2 = to_json(&back);
1013 assert_eq!(s1, s2, "JSON must be idempotent\n{s1}");
1014 }
1015
1016 #[test]
1017 fn kdl_round_trip_is_idempotent() {
1018 let doc = demo();
1019 let s1 = to_kdl(&doc);
1020 let back = from_kdl(&s1, "demo.kdl").unwrap_or_else(|e| panic!("re-parse:\n{e:?}\n{s1}"));
1021 let s2 = to_kdl(&back);
1022 assert_eq!(s1, s2, "KDL must be idempotent\n{s1}");
1023 }
1024
1025 #[test]
1026 fn json_and_kdl_agree() {
1027 let doc = demo();
1030 let via_kdl = from_kdl(&to_kdl(&doc), "demo.kdl").unwrap();
1031 assert_eq!(to_json(&doc), to_json(&via_kdl));
1032 }
1033
1034 #[test]
1035 fn schema_projection_round_trips_by_value() {
1036 let doc = demo();
1038 let s = model::Document::from(&doc);
1039 let via_json = from_json(&to_json(&doc), "d.json").unwrap();
1040 let via_kdl = from_kdl(&to_kdl(&doc), "d.kdl").unwrap();
1041 assert_eq!(s, model::Document::from(&via_json), "json");
1042 assert_eq!(s, model::Document::from(&via_kdl), "kdl");
1043 }
1044
1045 #[test]
1046 fn image_fractional_size_round_trips() {
1047 let mut doc = Document::default();
1050 let top = doc.top_id;
1051 let inner = egui::Rect::from_min_size(egui::pos2(3.5, 2.5), egui::vec2(37.5, 12.5));
1052 doc.block_mut(top).unwrap().images.insert(
1053 ImageId::nth_default(1),
1054 Image {
1055 image: ImageData::Svg("<svg/>".into()).into(),
1056 inner,
1057 },
1058 );
1059 for back in [
1060 from_json(&to_json(&doc), "d.json").unwrap(),
1061 from_kdl(&to_kdl(&doc), "d.kdl").unwrap(),
1062 ] {
1063 let img = back.block(top).unwrap().images.values().next().unwrap();
1064 assert_eq!(img.inner, inner, "fractional image rect preserved");
1065 }
1066 }
1067
1068 fn thrice_placed(marker: &str) -> (Document, RectId) {
1071 let content = ImageData::Svg(format!("<svg viewBox=\"0 0 2 2\"><!--{marker}--></svg>"));
1072 let mut doc = Document::default();
1073 let top = doc.top_id;
1074 let child = doc.add_child(top, block("child"));
1075 for (n, at) in [(1, 0.0), (2, 10.0)] {
1076 doc.block_mut(top).unwrap().images.insert(
1077 ImageId::nth_default(n),
1078 Image::new(
1079 content.clone(),
1080 egui::Rect::from_min_size(egui::pos2(at, at), egui::vec2(4.0, 4.0)),
1081 ),
1082 );
1083 }
1084 doc.block_mut(child).unwrap().icon = Some(Image::new(
1085 content.clone(),
1086 egui::Rect::from_min_size(egui::pos2(1.0, 1.0), egui::vec2(2.0, 2.0)),
1087 ));
1088 (doc, child)
1089 }
1090
1091 #[test]
1092 fn a_repeated_image_is_written_once_and_shared_on_from_json() {
1093 let (doc, child) = thrice_placed("written-once");
1094 let kdl = to_kdl(&doc);
1095 assert_eq!(kdl.matches("<svg viewBox").count(), 1, "one copy:\n{kdl}");
1096 assert_eq!(kdl.matches("asset \"").count(), 1, "one asset:\n{kdl}");
1097
1098 let original = doc.block(child).unwrap().icon.clone().unwrap().image;
1099 let id = super::asset_id(&original);
1100 assert_eq!(
1101 kdl.matches(&format!("\"{id}\"")).count(),
1102 4,
1103 "one definition, three placements:\n{kdl}"
1104 );
1105 for (name, back) in [
1106 ("kdl", from_kdl(&kdl, "d.kdl").unwrap()),
1107 ("json", from_json(&to_json(&doc), "d.json").unwrap()),
1108 ] {
1109 let top = back.block(back.top_id).unwrap();
1110 let mut placed: Vec<_> = top.images.values().map(|s| s.image.clone()).collect();
1111 placed.push(back.block(child).unwrap().icon.clone().unwrap().image);
1112 assert_eq!(placed.len(), 3, "{name}");
1113 assert!(
1117 placed.iter().all(|a| *a == original),
1118 "{name}: every placement resolves to the one interned copy"
1119 );
1120 }
1121 }
1122
1123 #[test]
1124 fn an_unknown_asset_reference_is_rejected() {
1125 let (doc, child) = thrice_placed("unknown-ref");
1126 let real = super::asset_id(&doc.block(child).unwrap().icon.clone().unwrap().image);
1127 let kdl = to_kdl(&doc).replace(
1130 &format!("image \"{real}\""),
1131 "image \"0000000000000000.svg\"",
1132 );
1133 let err = from_kdl(&kdl, "d.kdl")
1134 .unwrap_err()
1135 .downcast::<SchemaError>()
1136 .unwrap();
1137 assert!(
1138 matches!(err, SchemaError::UnknownAsset { .. }),
1139 "a placement naming an undefined asset must not load: {err}"
1140 );
1141 }
1142
1143 #[test]
1144 fn ids_preserved_and_child_order_kept() {
1145 let back = from_kdl(&to_kdl(&demo()), "d.kdl").unwrap();
1146 assert_eq!(back.top_id, RectId::nth_default(1));
1147 assert!(back.block(RectId::nth_default(4)).is_some());
1148 let top = back.block(back.top_id).unwrap();
1149 assert_eq!(
1150 top.children.iter().copied().collect::<Vec<_>>(),
1151 vec![RectId::nth_default(2), RectId::nth_default(3)]
1152 );
1153 }
1154
1155 #[test]
1156 fn pin_rect_is_captured_not_recomputed() {
1157 let back = from_json(&to_json(&demo()), "d.json").unwrap();
1159 let clk = back
1160 .block(RectId::nth_default(1))
1161 .unwrap()
1162 .pins
1163 .get(&PinId::nth_default(1))
1164 .unwrap();
1165 assert_eq!(clk.rect, rect(-11, 28, 4, 2));
1166 }
1167
1168 #[test]
1169 fn fliplr_round_trips() {
1170 let kdl = to_kdl(&demo());
1171 assert!(kdl.contains("fliplr=true"), "{kdl}");
1172 assert!(
1173 !kdl.contains("facing") && !kdl.contains("port-orientation"),
1174 "{kdl}"
1175 );
1176 let back = from_kdl(&kdl, "d.kdl").unwrap();
1177 let fancy = back
1178 .block(RectId::nth_default(2))
1179 .unwrap()
1180 .pins
1181 .get(&PinId::nth_default(2))
1182 .unwrap();
1183 assert_eq!(fancy.port_orientation, Some(PinSide::East));
1185 let clk = back
1187 .block(RectId::nth_default(1))
1188 .unwrap()
1189 .pins
1190 .get(&PinId::nth_default(1))
1191 .unwrap();
1192 assert_eq!(clk.port_orientation, None);
1193 }
1194
1195 #[test]
1196 fn route_anchors_are_positional_and_id_less() {
1197 let kdl = to_kdl(&demo());
1198 assert!(
1200 kdl.contains(r#"route "b2:p1" "b3:p1" name="route_1""#),
1201 "{kdl}"
1202 );
1203 assert!(!kdl.contains("from=") && !kdl.contains("to="), "{kdl}");
1204 }
1205
1206 #[test]
1207 fn label_position_round_trips_exactly() {
1208 let doc = demo();
1210 let orig = doc
1211 .block(RectId::nth_default(1))
1212 .unwrap()
1213 .routes
1214 .get(&RouteId::nth_default(1))
1215 .unwrap()
1216 .iter_labels()
1217 .next()
1218 .map(|(_, &d)| d)
1219 .unwrap();
1220 let back = from_kdl(&to_kdl(&doc), "d.kdl").unwrap();
1221 let got = back
1222 .block(RectId::nth_default(1))
1223 .unwrap()
1224 .routes
1225 .values()
1226 .find(|r| r.route_name() == "route_1")
1227 .unwrap()
1228 .iter_labels()
1229 .next()
1230 .map(|(_, &d)| d)
1231 .unwrap();
1232 assert_eq!(orig, got, "label LinearDistance preserved exactly");
1233 }
1234
1235 #[test]
1236 fn enums_are_kebab_case_in_json() {
1237 let json = to_json(&demo());
1238 assert!(json.contains(r#""dir": "output""#), "{json}");
1239 assert!(
1240 !json.contains("\"East\"") && !json.contains("\"InOut\""),
1241 "{json}"
1242 );
1243 }
1244
1245 #[test]
1246 fn loc_combines_side_and_offset() {
1247 let kdl = to_kdl(&demo());
1248 let json = to_json(&demo());
1249 assert!(kdl.contains(r#"loc="w1""#), "{kdl}");
1251 assert!(json.contains(r#""loc": "w1""#), "{json}");
1252 assert!(!kdl.contains(r#""clk" side="#), "{kdl}");
1254 let back = from_kdl(&kdl, "d.kdl").unwrap();
1255 let clk = back
1256 .block(RectId::nth_default(1))
1257 .unwrap()
1258 .pins
1259 .get(&PinId::nth_default(1))
1260 .unwrap();
1261 assert_eq!((clk.side, clk.offset), (PinSide::West, 1));
1262 }
1263
1264 #[test]
1265 fn pin_geometry_omitted_when_auto() {
1266 let p = pin("clk", PinSide::West, 1, PinType::InOut, rect(5, 5, 4, 2));
1267 let auto = model::Pin::from_pin(PinId::nth_default(1), &p, Some(rect(5, 5, 4, 2)));
1269 assert_eq!((auto.x, auto.y, auto.w), (None, None, None));
1270 let kept = model::Pin::from_pin(PinId::nth_default(1), &p, Some(rect(9, 9, 4, 2)));
1272 assert_eq!((kept.x, kept.y, kept.w), (Some(5), Some(5), Some(4)));
1273 }
1274
1275 #[test]
1276 fn hand_authored_pin_without_geometry_gets_auto_placed() {
1277 let src =
1279 "top \"b1\"\nblock \"b1\" x=0 y=0 w=20 h=8 {\n pin \"p1\" \"clk\" loc=\"w1\"\n}";
1280 let back = from_kdl(src, "d.kdl").unwrap();
1281 let p = back
1282 .block(RectId::nth_default(1))
1283 .unwrap()
1284 .pins
1285 .get(&PinId::nth_default(1))
1286 .unwrap();
1287 assert!(p.rect.size.w > 0, "port width was auto-filled");
1289 assert_eq!(p.rect.size.h, crate::shape::port::PORT_HEIGHT);
1290 }
1291
1292 #[test]
1293 fn children_are_one_node() {
1294 let kdl = to_kdl(&demo());
1295 assert!(kdl.contains(r#"children "b2" "b3""#), "{kdl}");
1297 assert!(
1298 !kdl.contains("child \"b2\""),
1299 "no per-line child nodes:\n{kdl}"
1300 );
1301 let back = from_kdl(&kdl, "d.kdl").unwrap();
1302 assert_eq!(
1303 back.block(RectId::nth_default(1))
1304 .unwrap()
1305 .children
1306 .iter()
1307 .copied()
1308 .collect::<Vec<_>>(),
1309 vec![RectId::nth_default(2), RectId::nth_default(3)]
1310 );
1311 }
1312
1313 #[test]
1314 fn bad_and_missing_loc_errors() {
1315 let missing = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n pin \"p1\" \"clk\" x=0 y=0 w=4 h=2\n}";
1317 assert!(matches!(kdl_err(missing), SchemaError::Kdl { .. }));
1318 let bad = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n pin \"p1\" \"clk\" loc=\"z9\" x=0 y=0 w=4 h=2\n}";
1319 let err = kdl_err(bad);
1320 assert!(matches!(err, SchemaError::Kdl { .. }));
1321 let (off, len) = kdl_span(&err);
1322 assert_eq!(&bad[off..off + len], "\"z9\"", "span points at the bad loc");
1323 }
1324
1325 #[test]
1326 fn missing_loc_in_json_is_missing_pin_side() {
1327 let json = r#"{"top":"b1","blocks":[{"id":"b1","x":0,"y":0,"w":40,"h":30,
1329 "pins":[{"id":"p1","name":"clk","x":0,"y":0,"w":4,"h":2}]}]}"#;
1330 let err = from_json(json, "e.json")
1331 .unwrap_err()
1332 .downcast::<SchemaError>()
1333 .unwrap();
1334 assert!(matches!(err, SchemaError::MissingPinSide { .. }));
1335 }
1336
1337 #[test]
1338 fn defaults_omitted() {
1339 let kdl = to_kdl(&demo());
1340 assert!(!kdl.contains("in-out"), "default kind omitted:\n{kdl}");
1341 }
1342
1343 fn json_err(src: &str) -> SchemaError {
1346 from_json(src, "e.json")
1347 .unwrap_err()
1348 .downcast::<SchemaError>()
1349 .expect("SchemaError")
1350 }
1351 fn kdl_err(src: &str) -> SchemaError {
1352 from_kdl(src, "e.kdl")
1353 .unwrap_err()
1354 .downcast::<SchemaError>()
1355 .expect("SchemaError")
1356 }
1357
1358 fn kdl_span(err: &SchemaError) -> (usize, usize) {
1360 match err {
1361 SchemaError::Kdl { span, .. } => (span.offset(), span.len()),
1362 other => panic!("expected a Kdl error, got {other:?}"),
1363 }
1364 }
1365
1366 #[test]
1367 fn malformed_is_an_error() {
1368 assert!(matches!(kdl_err("top \"b1\" {"), SchemaError::Kdl { .. }));
1369 assert!(matches!(json_err("not json"), SchemaError::Json { .. }));
1370 }
1371
1372 #[test]
1373 fn bad_id_error_points_at_the_id() {
1374 let src = "top \"b1\"\nblock \"xyz\" x=0 y=0 w=4 h=4";
1375 let err = kdl_err(src);
1376 assert!(matches!(err, SchemaError::Kdl { .. }));
1377 let (off, len) = kdl_span(&err);
1379 assert_eq!(&src[off..off + len], "\"xyz\"");
1380 }
1381
1382 #[test]
1383 fn bad_anchor_error() {
1384 let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n route \"zzz\" \"p1\"\n}";
1386 assert!(matches!(kdl_err(src), SchemaError::Kdl { .. }));
1387 }
1388
1389 #[test]
1390 fn dangling_child_error() {
1391 let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n children \"b9\"\n}";
1392 assert!(matches!(kdl_err(src), SchemaError::DanglingChild { .. }));
1393 }
1394
1395 #[test]
1398 fn top_naming_a_missing_block_is_an_error() {
1399 let src = "top \"b9\"\nblock \"b1\" x=0 y=0 w=40 h=30";
1400 assert!(matches!(kdl_err(src), SchemaError::BadTop { .. }));
1401 }
1402
1403 #[test]
1404 fn image_without_data_error() {
1405 let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n image x=1 y=1 size=10.0\n}";
1406 assert!(matches!(kdl_err(src), SchemaError::Kdl { .. }));
1407 }
1408
1409 #[test]
1410 fn unknown_node_error() {
1411 let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n widget x=1\n}";
1412 assert!(matches!(kdl_err(src), SchemaError::Kdl { .. }));
1413 }
1414
1415 #[test]
1416 fn svg_with_quotes_round_trips() {
1417 let kdl = to_kdl(&demo());
1418 assert!(kdl.contains("svg r#\""), "{kdl}");
1419 let back = from_kdl(&kdl, "d.kdl").unwrap();
1420 let sym = back
1421 .block(RectId::nth_default(1))
1422 .unwrap()
1423 .images
1424 .get(&ImageId::nth_default(1))
1425 .unwrap();
1426 match &*sym.image {
1427 ImageData::Svg(s) => assert!(s.contains("M0 0 L10 10") && s.contains('\n')),
1428 ImageData::Png(_) => panic!("expected svg"),
1429 }
1430 }
1431
1432 fn png(marker: &str) -> ImageData {
1433 ImageData::Png(format!("fake-png-{marker}").into_bytes())
1434 }
1435
1436 fn doc_with_images(images: Vec<ImageData>) -> Document {
1438 let mut doc = Document::default();
1439 let top = doc.top_id;
1440 let block = doc.blocks.get_mut(&top).expect("the top block");
1441 for image in images {
1442 block
1443 .images
1444 .insert_value(Image::new(image, egui::Rect::ZERO));
1445 }
1446 doc
1447 }
1448
1449 fn asset_ids(doc: &Document) -> Vec<String> {
1450 model::Document::from(doc)
1451 .assets
1452 .into_iter()
1453 .map(|a| a.id)
1454 .collect()
1455 }
1456
1457 #[test]
1458 fn an_asset_id_is_its_content_hash_and_extension() {
1459 let ids = asset_ids(&doc_with_images(vec![
1460 png("one"),
1461 ImageData::Svg("<svg/>".to_string()),
1462 ]));
1463 assert_eq!(ids.len(), 2);
1464 let split = |id: &str| {
1465 let (stem, ext) = id.rsplit_once('.').expect("an extension");
1466 (stem.to_string(), ext.to_string())
1467 };
1468 for (id, expected_ext) in ids.iter().zip(["png", "svg"]) {
1469 let (stem, ext) = split(id);
1470 assert_eq!(ext, expected_ext, "{id}");
1471 assert_eq!(stem.len(), 16, "{id}");
1472 assert!(stem.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
1473 }
1474 assert_eq!(
1476 asset_ids(&doc_with_images(vec![png("one")]))[0],
1477 ids[0],
1478 "the same image got different ids in different documents"
1479 );
1480 }
1481
1482 #[test]
1486 fn removing_one_asset_does_not_rename_the_others() {
1487 let before = doc_with_images(vec![png("first"), png("second"), png("third")]);
1488 let ids_before = asset_ids(&before);
1489 assert_eq!(ids_before.len(), 3, "three distinct images");
1490
1491 let mut after = before.clone();
1492 let top = after.top_id;
1493 let first = *after.blocks[&top].images.keys().next().expect("an image");
1494 after
1495 .blocks
1496 .get_mut(&top)
1497 .expect("the top block")
1498 .images
1499 .shift_remove(&first);
1500
1501 assert_eq!(
1502 asset_ids(&after),
1503 ids_before[1..],
1504 "dropping the first image renamed the survivors"
1505 );
1506 }
1507
1508 #[test]
1511 fn legacy_ids_are_recanonicalized_on_save() {
1512 let legacy = "top \"b1\"\n\
1513 block \"b1\" x=0 y=0 w=40 h=30 {\n\
1514 \x20 image \"i1\" x=0 y=0 w=4 h=4\n\
1515 }\n\
1516 asset \"i1\" {\n svg r#\"<svg/>\"#\n}";
1517 let doc = from_kdl(legacy, "legacy.kdl").expect("a version-1 document loads");
1518
1519 let saved = to_kdl(&doc);
1520 assert!(
1521 !saved.contains("\"i1\""),
1522 "the legacy id survived:\n{saved}"
1523 );
1524 assert!(
1525 saved.contains(&format!("version {}", model::CURRENT_VERSION)),
1526 "{saved}"
1527 );
1528 let id = &asset_ids(&doc)[0];
1529 assert!(saved.contains(&format!("asset \"{id}\"")), "{saved}");
1530 assert!(saved.contains(&format!("image \"{id}\"")), "{saved}");
1531 }
1532}