1use 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
20const DEFAULT_IMAGE_CELLS: f32 = 8.0;
22
23const ICON_SIDE: f32 = 0.6;
25
26#[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
49pub(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#[derive(Clone, Copy, Debug)]
62pub struct NewImage {
63 pub id: ImageId,
64 pub owner: crate::path::Scope,
65 pub placement: Placement,
66}
67
68pub 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
83pub 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
102pub 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 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 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 #[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 #[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 #[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 #[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 #[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}