Skip to main content

blockworx_editor/edit/
assets.rs

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