1use 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
22const DEFAULT_IMAGE_CELLS: f32 = 8.0;
25
26const ICON_SIDE: f32 = 0.6;
29
30#[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
53pub(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#[derive(Clone, Copy, Debug)]
66pub struct NewImage {
67 pub id: ImageId,
68 pub owner: crate::path::Scope,
69 pub placement: Placement,
70}
71
72pub 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
87pub 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
106pub 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 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 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 #[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 #[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 #[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 #[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 #[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}