Skip to main content

blockworx/edit/
assets.rs

1//! The artwork family (`docs/op-emitter-playbook.md`, 10e): the emitters
2//! that place a picked image file, and the payload op that carries its
3//! bytes into the log beside the reference.
4//!
5//! Assets ride the log (editor-swap decision D7): the payload is
6//! content-addressed and create-only, so the only question an emitter has
7//! to answer is whether the document already holds the hash — if it does,
8//! the bytes are already there and identical, and the reference alone is
9//! the whole edit.
10
11use blockworx_doc::{
12    block_model::{Asset, BlockUpdate, Icon, Image},
13    commit::CommitBuilder,
14    document::Document,
15    id::{BlockId, ImageId},
16    opcode::{Crud, OpCodes},
17};
18use blockworx_geom::{Pos2, Rect, Vec2, vec2};
19
20use crate::grid::{GRID_SIZE, px_rect, screen_rect};
21
22/// A click-placed image spans this many cells on its longer side (legacy
23/// `new_image::default_image_box`).
24const DEFAULT_IMAGE_CELLS: f32 = 8.0;
25
26/// A fresh icon's square side, as a fraction of its block's shorter side
27/// (legacy `default_icon_box`, `src/widget/drawing.rs:91`).
28const ICON_SIDE: f32 = 0.6;
29
30/// How the picked image was sized (legacy `new_image::Placement`). A drag
31/// gives its own box; a click has none, so the box comes from the image's
32/// intrinsic size — the painter's measurement, which is why it arrives as
33/// a parameter rather than being read here.
34#[derive(Clone, Copy, Debug)]
35pub enum Placement {
36    Box(Rect),
37    Centered { center: Pos2, intrinsic: Vec2 },
38}
39
40impl Placement {
41    fn rect(self) -> Rect {
42        match self {
43            Placement::Box(rect) => rect,
44            Placement::Centered { center, intrinsic } => {
45                let longest = intrinsic.x.max(intrinsic.y).max(1.0);
46                let scale = GRID_SIZE * DEFAULT_IMAGE_CELLS / longest;
47                Rect::from_center_size(center, intrinsic * scale)
48            }
49        }
50    }
51}
52
53/// The payload rider: the create-only asset op, pushed only where the
54/// document does not already hold the hash. Content addressing makes the
55/// non-edit filter exact — the same hash *is* the same bytes, so a second
56/// copy could only ever say what the first already said.
57pub(crate) fn push_payload(doc: &Document, asset: &Asset, builder: &mut CommitBuilder) {
58    let hash = asset.hash();
59    if doc.asset(&hash).is_none() {
60        builder.push(OpCodes::Asset(hash, asset.clone()));
61    }
62}
63
64/// One image the gesture placed: where it goes, and in whose scope.
65#[derive(Clone, Copy, Debug)]
66pub struct NewImage {
67    pub id: ImageId,
68    pub owner: crate::path::Scope,
69    pub placement: Placement,
70}
71
72/// Inventory row "New Image": place a picked file as free-floating
73/// artwork. Its box is the one float geometry the document stores —
74/// unsnapped, so the image keeps the aspect the drag or the file gave it.
75pub fn image(doc: &Document, new: NewImage, asset: &Asset, builder: &mut CommitBuilder) {
76    push_payload(doc, asset, builder);
77    builder.push(OpCodes::Image(
78        new.id,
79        Crud::Create(Image {
80            owner: new.owner.wire_id(),
81            asset: asset.hash(),
82            rect: screen_rect(new.placement.rect()),
83        }),
84    ));
85}
86
87/// Inventory row "Set Block Icon": attach — or replace — a block's
88/// foreground artwork. The icon is one atomic value, so the picture and
89/// the box it sits in are written whole; the default box is a square
90/// [`ICON_SIDE`] of the block's shorter side, centered.
91pub fn set_icon(doc: &Document, block: BlockId, asset: &Asset, builder: &mut CommitBuilder) {
92    let Some(live) = doc.block(&block) else {
93        return;
94    };
95    let icon = Icon {
96        asset: asset.hash(),
97        rect: screen_rect(default_icon_box(px_rect(live.rect))),
98    };
99    if icon == live.icon {
100        return;
101    }
102    push_payload(doc, asset, builder);
103    builder.push(OpCodes::Block(block, Crud::Update(BlockUpdate::Icon(icon))));
104}
105
106/// Inventory row "Delete Icon": the block survives and its icon value goes
107/// back to the model's zero (null hash, empty box). An icon has no id of
108/// its own, so there is no lifecycle op to write here.
109pub fn delete_icon(doc: &Document, block: BlockId, builder: &mut CommitBuilder) {
110    let Some(live) = doc.block(&block) else {
111        return;
112    };
113    if live.icon == Icon::default() {
114        return;
115    }
116    builder.push(OpCodes::Block(
117        block,
118        Crud::Update(BlockUpdate::Icon(Icon::default())),
119    ));
120}
121
122fn default_icon_box(block: Rect) -> Rect {
123    let side = block.width().min(block.height()) * ICON_SIDE;
124    Rect::from_center_size(block.center(), vec2(side, side))
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::edit::harness::{fold, seals_to_nothing, wired};
131    use crate::grid::artwork_rect;
132    use crate::path::Scope;
133    use blockworx_doc::{
134        block_model::{Block, Image},
135        fixtures::{block_id, image_id},
136        geometry::{GridPoint, GridRect, GridSize},
137    };
138    use blockworx_geom::pos2;
139
140    fn svg(source: &str) -> Asset {
141        Asset::Svg(source.as_bytes().into())
142    }
143
144    fn block_of(doc: &Document, id: u32) -> &Block {
145        doc.block(&block_id(id)).expect("the scene's block exists")
146    }
147
148    fn image_of(doc: &Document, id: u32) -> &Image {
149        doc.image(&image_id(id)).expect("the placed image exists")
150    }
151
152    fn icon_of(doc: &Document, id: u32) -> Icon {
153        block_of(doc, id).icon.clone()
154    }
155
156    fn bytes_held(doc: &Document, asset: &Asset) -> Option<Vec<u8>> {
157        doc.asset(&asset.hash()).map(|held| held.bytes().to_vec())
158    }
159
160    /// A block 10×6 cells: wider than tall, so the default icon box has a
161    /// shorter side to pick.
162    fn sized() -> Document {
163        let doc = wired();
164        let mut builder = CommitBuilder::new("Sized the block");
165        builder.push(OpCodes::Block(
166            block_id(1),
167            Crud::Update(BlockUpdate::Rect(GridRect {
168                top_left: GridPoint { x: 0, y: 0 },
169                size: GridSize { w: 10, h: 6 },
170            })),
171        ));
172        let doc = fold(builder, &doc);
173        let rect = px_rect(block_of(&doc, 1).rect);
174        assert!(
175            rect.height() < rect.width(),
176            "precondition: the height is the shorter side, so the 60% rule has a choice to make"
177        );
178        doc
179    }
180
181    /// Artwork geometry is float and a 60% scale is not exact on the grid,
182    /// so boxes derived from one compare within a hundredth of a pixel.
183    fn assert_close(actual: Rect, expected: Rect) {
184        let drift = (actual.min - expected.min)
185            .abs()
186            .max_elem()
187            .max((actual.max - expected.max).abs().max_elem());
188        assert!(
189            drift < 0.01,
190            "{actual:?} is not within a hundredth of a pixel of {expected:?}",
191        );
192    }
193
194    /// The round trip the whole sub-step exists for: the placement and its
195    /// bytes travel in one commit, and the bytes come back out of the
196    /// folded document under the hash the image references.
197    #[test]
198    fn image_places_unsnapped_artwork_and_carries_its_payload() {
199        let doc = sized();
200        let asset = svg("<svg>one</svg>");
201        let box_drawn = Rect::from_min_max(pos2(12.5, 30.0), pos2(112.5, 80.0));
202
203        let mut builder = CommitBuilder::new("Placed an image");
204        image(
205            &doc,
206            NewImage {
207                id: image_id(20),
208                owner: Scope::Block(block_id(1)),
209                placement: Placement::Box(box_drawn),
210            },
211            &asset,
212            &mut builder,
213        );
214        let doc = fold(builder, &doc);
215
216        let placed = image_of(&doc, 20);
217        assert_eq!(placed.owner, block_id(1));
218        assert_eq!(placed.asset, asset.hash());
219        assert_eq!(
220            artwork_rect(placed.rect),
221            box_drawn,
222            "the box is stored as drawn — artwork is the one unsnapped geometry"
223        );
224        assert_eq!(
225            bytes_held(&doc, &asset).as_deref(),
226            Some(b"<svg>one</svg>".as_slice()),
227            "the payload rode the same commit and resolves by hash"
228        );
229    }
230
231    /// A click has no box, so the image's own proportions give it one:
232    /// eight cells on the longer side, centered on the click.
233    #[test]
234    fn a_clicked_image_takes_its_intrinsic_aspect_at_eight_cells() {
235        let doc = sized();
236        let asset = svg("<svg>wide</svg>");
237        let intrinsic = vec2(40.0, 20.0);
238        assert!(
239            intrinsic.x > intrinsic.y,
240            "precondition: the sides differ, or the aspect is not tested"
241        );
242
243        let mut builder = CommitBuilder::new("Placed an image");
244        image(
245            &doc,
246            NewImage {
247                id: image_id(20),
248                owner: Scope::Block(block_id(1)),
249                placement: Placement::Centered {
250                    center: pos2(10.0 * GRID_SIZE, 5.0 * GRID_SIZE),
251                    intrinsic,
252                },
253            },
254            &asset,
255            &mut builder,
256        );
257        let doc = fold(builder, &doc);
258
259        let placed = artwork_rect(image_of(&doc, 20).rect);
260        assert_close(
261            placed,
262            Rect::from_center_size(
263                pos2(10.0 * GRID_SIZE, 5.0 * GRID_SIZE),
264                vec2(8.0 * GRID_SIZE, 4.0 * GRID_SIZE),
265            ),
266        );
267    }
268
269    /// The dedupe: the second placement of one file is a reference and
270    /// nothing else, because the hash the document already holds can only
271    /// name the bytes it already holds.
272    #[test]
273    fn a_second_placement_of_held_bytes_carries_no_payload() {
274        let doc = sized();
275        let asset = svg("<svg>one</svg>");
276        let place = |doc: &Document, id, builder: &mut CommitBuilder| {
277            image(
278                doc,
279                NewImage {
280                    id: image_id(id),
281                    owner: Scope::Block(block_id(1)),
282                    placement: Placement::Box(Rect::from_min_max(
283                        pos2(12.5, 30.0),
284                        pos2(112.5, 80.0),
285                    )),
286                },
287                &asset,
288                builder,
289            );
290        };
291
292        let mut builder = CommitBuilder::new("Placed an image");
293        place(&doc, 20, &mut builder);
294        assert_eq!(
295            builder
296                .seal()
297                .expect("the placement produced ops")
298                .ops()
299                .len(),
300            2,
301            "precondition: the first placement carries the payload as well as the reference"
302        );
303
304        let mut builder = CommitBuilder::new("Placed an image");
305        place(&doc, 20, &mut builder);
306        let doc = fold(builder, &doc);
307
308        let mut builder = CommitBuilder::new("Placed it again");
309        place(&doc, 21, &mut builder);
310        let ops = builder.seal().expect("the placement produced ops");
311        assert_eq!(ops.ops().len(), 1, "no second copy of held bytes");
312        assert!(matches!(ops.ops().first(), Some(OpCodes::Image(..))));
313
314        let doc = doc.try_apply(&ops).expect("the second placement folds");
315        assert_eq!(
316            image_of(&doc, 21).asset,
317            image_of(&doc, 20).asset,
318            "both placements resolve through the one payload",
319        );
320        assert!(bytes_held(&doc, &asset).is_some());
321    }
322
323    #[test]
324    fn set_icon_centers_a_square_box_on_its_block() {
325        let doc = sized();
326        let asset = svg("<svg>icon</svg>");
327
328        let mut builder = CommitBuilder::new("Gave the block an icon");
329        set_icon(&doc, block_id(1), &asset, &mut builder);
330        let doc = fold(builder, &doc);
331
332        let block = px_rect(block_of(&doc, 1).rect);
333        let icon = icon_of(&doc, 1);
334        assert_eq!(icon.asset, asset.hash());
335        let side = block.height() * 0.6;
336        assert_close(
337            artwork_rect(icon.rect),
338            Rect::from_center_size(block.center(), vec2(side, side)),
339        );
340        assert_eq!(
341            bytes_held(&doc, &asset).as_deref(),
342            Some(b"<svg>icon</svg>".as_slice()),
343        );
344    }
345
346    /// Replacing is the same write: one atomic `Icon` value, so the old
347    /// picture cannot survive under the new box. The displaced bytes stay
348    /// in the table — payloads are create-only, and nothing here knows
349    /// whether someone else still points at them.
350    #[test]
351    fn set_icon_replaces_an_existing_icon_in_place() {
352        let doc = sized();
353        let first = svg("<svg>first</svg>");
354        let second = svg("<svg>second</svg>");
355
356        let mut builder = CommitBuilder::new("Gave the block an icon");
357        set_icon(&doc, block_id(1), &first, &mut builder);
358        let doc = fold(builder, &doc);
359        assert_eq!(
360            icon_of(&doc, 1).asset,
361            first.hash(),
362            "precondition: there is an icon to replace"
363        );
364
365        let mut builder = CommitBuilder::new("Replaced the icon");
366        set_icon(&doc, block_id(1), &second, &mut builder);
367        let doc = fold(builder, &doc);
368
369        assert_eq!(icon_of(&doc, 1).asset, second.hash());
370        assert!(
371            doc.block(&block_id(1)).is_some(),
372            "the block itself is untouched by the swap"
373        );
374        assert!(
375            bytes_held(&doc, &first).is_some(),
376            "the displaced payload stays: create-only, reclaimed by compaction",
377        );
378    }
379
380    #[test]
381    fn re_setting_the_same_icon_is_a_non_edit() {
382        let doc = sized();
383        let asset = svg("<svg>icon</svg>");
384
385        let mut builder = CommitBuilder::new("Gave the block an icon");
386        set_icon(&doc, block_id(1), &asset, &mut builder);
387        let doc = fold(builder, &doc);
388        assert_ne!(
389            icon_of(&doc, 1),
390            Icon::default(),
391            "precondition: the block carries the icon the gesture repeats"
392        );
393
394        let mut builder = CommitBuilder::new("Picked the same file");
395        set_icon(&doc, block_id(1), &asset, &mut builder);
396        seals_to_nothing(builder);
397    }
398
399    #[test]
400    fn delete_icon_writes_the_zero_value_and_keeps_the_block() {
401        let doc = sized();
402        let asset = svg("<svg>icon</svg>");
403        let mut builder = CommitBuilder::new("Gave the block an icon");
404        set_icon(&doc, block_id(1), &asset, &mut builder);
405        let doc = fold(builder, &doc);
406        assert_ne!(
407            icon_of(&doc, 1),
408            Icon::default(),
409            "precondition: there is an icon to delete"
410        );
411
412        let mut builder = CommitBuilder::new("Removed the icon");
413        delete_icon(&doc, block_id(1), &mut builder);
414        let doc = fold(builder, &doc);
415
416        assert_eq!(icon_of(&doc, 1), Icon::default());
417        assert!(
418            doc.block(&block_id(1)).is_some(),
419            "the icon is a value on the block, not an entity of its own",
420        );
421        assert!(
422            bytes_held(&doc, &asset).is_some(),
423            "the unreferenced payload stays held",
424        );
425    }
426
427    #[test]
428    fn deleting_an_absent_icon_is_a_non_edit() {
429        let doc = sized();
430        assert_eq!(
431            icon_of(&doc, 1),
432            Icon::default(),
433            "precondition: the block carries no icon"
434        );
435
436        let mut builder = CommitBuilder::new("Removed the icon");
437        delete_icon(&doc, block_id(1), &mut builder);
438        seals_to_nothing(builder);
439    }
440
441    /// E4: a target the document does not hold takes nothing — including
442    /// the payload, which would otherwise land with nothing referencing it.
443    #[test]
444    fn an_absent_block_takes_neither_icon_nor_payload() {
445        let doc = sized();
446        let asset = svg("<svg>icon</svg>");
447        let stranger = block_id(99);
448        assert!(
449            doc.block(&stranger).is_none(),
450            "precondition: the target is absent"
451        );
452
453        let mut builder = CommitBuilder::new("Gave a stranger an icon");
454        set_icon(&doc, stranger, &asset, &mut builder);
455        delete_icon(&doc, stranger, &mut builder);
456        seals_to_nothing(builder);
457    }
458}