Skip to main content

blockworx_doc/
block_model.rs

1//! The block model: entities and namespaces of the document.
2//! Rationale: `docs/doc-ng-design-notes.md`.
3
4use crate::{
5    entity::entity,
6    geometry::{FracVal, GridPoint, GridRect, PinSlot, ScreenRect, Waypoint},
7    hash::AssetHash,
8    id::{AreaId, BlockId, ImageId, PinId, RouteId, RouteLabelId, TextId},
9    values::{LabelSide, PinDir, Role},
10};
11use serde::{Deserialize, Serialize};
12use std::num::NonZeroU32;
13use std::sync::Arc;
14
15/// One atomic value on its block; the zero icon (null hash, empty rect)
16/// means "no image".
17#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
18pub struct Icon {
19    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
20    pub asset: AssetHash,
21    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
22    pub rect: ScreenRect,
23}
24
25entity! {
26    /// A namespace of four independent registers — no id, no lifecycle.
27    pub struct Label(update LabelUpdate, id ()) {
28        registers {
29            Name => name: String,
30            Side => side: LabelSide,
31            Offset => offset: FracVal,
32            Hidden => hidden: bool,
33        }
34        namespaces {}
35        constants {}
36    }
37}
38
39entity! {
40    pub struct Block(update BlockUpdate, id BlockId) {
41        registers {
42            /// `Id::NULL` = the document itself.
43            Parent => parent: BlockId,
44            Rect => rect: GridRect,
45            Locked => locked: bool,
46            Role => role: Role,
47            Icon => icon: Icon,
48        }
49        namespaces {
50            Title => title: Label,
51            TypeLabel => type_label: Label,
52        }
53        constants {}
54    }
55}
56
57entity! {
58    pub struct Pin(update PinUpdate, id PinId) {
59        registers {
60            Owner => owner: BlockId,
61            Name => name: String,
62            TypeName => type_name: String,
63            Tag => tag: String,
64            TagHidden => tag_hidden: bool,
65            /// The port body's placement inside the block's own interior
66            /// view — a different scope from `slot`, which places the pin
67            /// on the block-as-child.
68            Rect => rect: GridRect,
69            Slot => slot: PinSlot,
70            Dir => dir: PinDir,
71            /// The one authored accent. Stub and port-pin accents are
72            /// propagated from route roles — derived state, computed
73            /// client-side, never in the log.
74            PortAccent => port_accent: Role,
75            /// The port body's facing: `false` = the default, opposite the
76            /// pin's edge (`slot.side.flip()`); `true` = frozen to face
77            /// `slot.side` itself.
78            FlipLR => flip_lr: bool,
79        }
80        namespaces {}
81        constants {}
82    }
83}
84
85entity! {
86    pub struct Route(update RouteUpdate, id RouteId) {
87        registers {
88            Owner => owner: BlockId,
89            Name => name: String,
90            Role => role: Role,
91            Waypoints => waypoints: Vec<Waypoint>,
92        }
93        namespaces {}
94        constants {
95            /// Endpoints are creation-time constants; re-pointing one is
96            /// delete-and-recreate.
97            from: PinId,
98            to: PinId,
99        }
100    }
101}
102
103entity! {
104    pub struct RouteLabel(update RouteLabelUpdate, id RouteLabelId) {
105        registers {
106            Owner => owner: RouteId,
107            /// Offset along the route.
108            Pos => pos: FracVal,
109        }
110        namespaces {}
111        constants {}
112    }
113}
114
115entity! {
116    pub struct Text(update TextUpdate, id TextId) {
117        registers {
118            Owner => owner: BlockId,
119            Text => text: String,
120            Pos => pos: GridPoint,
121            Role => role: Role,
122            /// The box's width in grid cells, set by resizing it; `None` is
123            /// a box as wide as its text.
124            Width => width: Option<NonZeroU32>,
125        }
126        namespaces {}
127        constants {}
128    }
129}
130
131entity! {
132    pub struct Area(update AreaUpdate, id AreaId) {
133        registers {
134            Owner => owner: BlockId,
135            Rect => rect: GridRect,
136            Role => role: Role,
137        }
138        namespaces {
139            Title => title: Label,
140        }
141        constants {}
142    }
143}
144
145entity! {
146    /// A free-floating placed image (block icons are the atomic [`Icon`] value).
147    pub struct Image(update ImageUpdate, id ImageId) {
148        registers {
149            Owner => owner: BlockId,
150            Asset => asset: AssetHash,
151            Rect => rect: ScreenRect,
152        }
153        namespaces {}
154        constants {}
155    }
156}
157
158/// `Arc`: crosses the sync thread boundary.
159///
160/// The variant names the payload's format; the [`AssetHash`] keying it
161/// covers the bytes alone — an asset is addressed by its content, and the
162/// format is how to read that content, not part of its identity.
163#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
164#[serde(rename_all = "lowercase")]
165pub enum Asset {
166    Svg(#[serde(with = "svg_payload")] Arc<[u8]>),
167    Png(#[serde(with = "png_payload")] Arc<[u8]>),
168}
169
170/// The largest artwork payload a commit may carry. A payload is create-only
171/// and the document holds every one it has ever been given, so an unbounded
172/// one is unbounded forever: it is folded again on every open. Four
173/// megabytes is generous for a diagram symbol and small enough that a stray
174/// photograph is refused rather than archived.
175pub const ASSET_LIMIT: usize = 4 * 1024 * 1024;
176
177impl Asset {
178    pub fn bytes(&self) -> &[u8] {
179        match self {
180            Asset::Svg(bytes) | Asset::Png(bytes) => bytes,
181        }
182    }
183
184    /// Whether this payload is small enough to be committed.
185    pub fn within_limit(&self) -> bool {
186        self.bytes().len() <= ASSET_LIMIT
187    }
188
189    /// The key this payload travels under.
190    pub fn hash(&self) -> AssetHash {
191        AssetHash::of(self.bytes())
192    }
193}
194
195/// An SVG payload in a format a human reads: its own source text,
196/// verbatim, so the document stays greppable and an artwork diff reads as
197/// the drawing it is. Every construction site builds one from a `String`
198/// ([`crate::block_model::Asset`]'s callers read SVG as text), so the
199/// bytes are UTF-8 and the encode cannot fail in practice.
200mod svg_payload {
201    use serde::{Deserializer, Serializer, ser};
202    use std::sync::Arc;
203
204    pub fn serialize<S: Serializer>(bytes: &Arc<[u8]>, serializer: S) -> Result<S::Ok, S::Error> {
205        if serializer.is_human_readable() {
206            let text = std::str::from_utf8(bytes).map_err(ser::Error::custom)?;
207            serializer.serialize_str(text)
208        } else {
209            serializer.serialize_bytes(bytes)
210        }
211    }
212
213    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Arc<[u8]>, D::Error> {
214        if deserializer.is_human_readable() {
215            deserializer.deserialize_any(super::payload::Verbatim)
216        } else {
217            deserializer.deserialize_byte_buf(super::payload::Verbatim)
218        }
219    }
220}
221
222/// A PNG payload in a format a human reads: **standard-alphabet base64
223/// with padding** (RFC 4648 §4, `A–Z a–z 0–9 + /`, `=`-padded), since the
224/// bytes are not text.
225mod png_payload {
226    use base64::{Engine as _, engine::general_purpose::STANDARD};
227    use serde::{Deserializer, Serializer};
228    use std::sync::Arc;
229
230    pub fn serialize<S: Serializer>(bytes: &Arc<[u8]>, serializer: S) -> Result<S::Ok, S::Error> {
231        if serializer.is_human_readable() {
232            serializer.serialize_str(&STANDARD.encode(bytes))
233        } else {
234            serializer.serialize_bytes(bytes)
235        }
236    }
237
238    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Arc<[u8]>, D::Error> {
239        if deserializer.is_human_readable() {
240            deserializer.deserialize_any(super::payload::Base64)
241        } else {
242            deserializer.deserialize_byte_buf(super::payload::Base64)
243        }
244    }
245}
246
247/// Serde has no `Arc<[u8]>` without its `rc` feature, and reading is
248/// deliberately wider than writing: a binary format hands over a byte
249/// string, a text format hands over the spelling its variant writes, and
250/// either may also arrive as the array of numbers written by builds before
251/// this was format-aware.
252mod payload {
253    use base64::{Engine as _, engine::general_purpose::STANDARD};
254    use serde::de;
255    use std::sync::Arc;
256
257    /// The text *is* the bytes.
258    pub struct Verbatim;
259
260    /// The text is base64 over the bytes.
261    pub struct Base64;
262
263    macro_rules! payload_visitor {
264        ($visitor:ident, $expecting:literal, |$text:ident| $decode:expr) => {
265            impl<'de> de::Visitor<'de> for $visitor {
266                type Value = Arc<[u8]>;
267
268                fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269                    f.write_str($expecting)
270                }
271
272                fn visit_str<E: de::Error>(self, $text: &str) -> Result<Self::Value, E> {
273                    $decode
274                }
275
276                fn visit_bytes<E: de::Error>(self, bytes: &[u8]) -> Result<Self::Value, E> {
277                    Ok(Arc::from(bytes))
278                }
279
280                fn visit_byte_buf<E: de::Error>(self, bytes: Vec<u8>) -> Result<Self::Value, E> {
281                    Ok(Arc::from(bytes))
282                }
283
284                fn visit_seq<A: de::SeqAccess<'de>>(
285                    self,
286                    mut seq: A,
287                ) -> Result<Self::Value, A::Error> {
288                    let mut bytes = Vec::with_capacity(seq.size_hint().unwrap_or_default());
289                    while let Some(byte) = seq.next_element::<u8>()? {
290                        bytes.push(byte);
291                    }
292                    Ok(Arc::from(bytes))
293                }
294            }
295        };
296    }
297
298    payload_visitor!(
299        Verbatim,
300        "a byte string, source text, or a sequence of bytes",
301        |text| Ok(Arc::from(text.as_bytes()))
302    );
303    payload_visitor!(
304        Base64,
305        "a byte string, base64 text, or a sequence of bytes",
306        |text| STANDARD
307            .decode(text)
308            .map(Arc::from)
309            .map_err(de::Error::custom)
310    );
311}
312
313#[cfg(test)]
314mod tests {
315    use super::{ASSET_LIMIT, Asset};
316
317    fn svg() -> Asset {
318        Asset::Svg(b"<svg/>".as_slice().into())
319    }
320
321    /// The greppable spelling the projection rests on: an SVG travels as
322    /// its own source, a PNG as base64, and the tags are the format's
323    /// lower-case ones.
324    #[test]
325    fn a_payload_round_trips_through_json_in_its_own_spelling() {
326        let text = serde_json::to_string(&svg()).expect("it serializes");
327        assert_eq!(text, r#"{"svg":"<svg/>"}"#, "verbatim source text");
328        assert_eq!(
329            serde_json::from_str::<Asset>(&text).expect("and parses back"),
330            svg(),
331        );
332
333        let png = Asset::Png(b"\x89PNG".as_slice().into());
334        let text = serde_json::to_string(&png).expect("it serializes");
335        assert_eq!(text, r#"{"png":"iVBORw=="}"#, "standard-alphabet base64");
336        assert_eq!(
337            serde_json::from_str::<Asset>(&text).expect("and parses back"),
338            png,
339        );
340    }
341
342    /// A payload written as a JSON array of numbers still reads as those
343    /// bytes, which is what keeps an older container replayable.
344    #[test]
345    fn a_payload_written_as_a_number_array_still_reads() {
346        assert_eq!(
347            serde_json::from_str::<Asset>(r#"{"svg":[60,115,118,103,47,62]}"#)
348                .expect("the number-array spelling parses"),
349            svg(),
350        );
351        assert_eq!(
352            serde_json::from_str::<Asset>(r#"{"png":[]}"#).expect("an empty one too"),
353            Asset::Png(b"".as_slice().into()),
354        );
355    }
356
357    #[test]
358    fn png_text_that_is_not_base64_is_refused_rather_than_taken_as_its_own_bytes() {
359        assert!(serde_json::from_str::<Asset>(r#"{"png":"not base64!!"}"#).is_err());
360    }
361
362    /// A binary format still gets a byte string. This is what
363    /// `Document::content_hash` canonicalizes through, so the text
364    /// spelling above must not have reached it — CBOR major type 2, not
365    /// an array.
366    #[test]
367    fn a_binary_format_still_writes_a_byte_string() {
368        let mut bytes = Vec::new();
369        ciborium::into_writer(&Asset::Png(b"hi".as_slice().into()), &mut bytes)
370            .expect("it serializes");
371        assert_eq!(bytes, b"\xa1cpngBhi", "a map of one, then a 2-byte string");
372        assert_eq!(
373            ciborium::from_reader::<Asset, _>(bytes.as_slice()).expect("and parses back"),
374            Asset::Png(b"hi".as_slice().into()),
375        );
376    }
377
378    /// Base64 is 4 characters per 3 bytes, so the text form of a PNG at
379    /// the limit is the size a record has to carry if nobody extracts it —
380    /// the number that makes the store's `assets/` directory worth its
381    /// complexity.
382    #[test]
383    fn the_text_form_of_a_png_is_a_third_larger_than_the_payload() {
384        let big = Asset::Png(vec![0u8; 3 * 1024].into());
385        let text = serde_json::to_string(&big).expect("it serializes");
386        assert!(
387            text.len() > 4 * 1024 && text.len() < 5 * 1024,
388            "{}",
389            text.len()
390        );
391        assert_eq!(ASSET_LIMIT, 4 * 1024 * 1024);
392    }
393}