all_is_cubes/block/modifier/
composite.rs

1use alloc::vec;
2use core::mem;
3
4use crate::block::{
5    self, AIR, Block, BlockCollision, Evoxel, Evoxels, MinEval, Modifier, Resolution::R1,
6};
7use crate::math::{
8    Cube, GridAab, GridCoordinate, GridPoint, GridRotation, GridSize, GridVector, PositiveSign,
9    Rgb, Vol, ZeroOne,
10};
11use crate::op::Operation;
12use crate::universe;
13
14/// Data for [`Modifier::Composite`], describing how to combine the voxels of another
15/// block with the original one.
16///
17/// TODO: This modifier is not complete. It needs additional rules, particularly about combining
18/// the blocks' attributes (right now it always chooses the destination), and the ability to
19/// systematically combine or break apart the composite when applicable.
20#[derive(Clone, Debug, Eq, Hash, PartialEq)]
21#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
22#[non_exhaustive]
23pub struct Composite {
24    /// The “source” input to the compositing operator.
25    /// (The “destination” input is the block this modifier is attached to.)
26    pub source: Block,
27
28    /// The compositing operator used to combine the source and destination blocks.
29    pub operator: CompositeOperator,
30
31    /// Swap the roles of “source” and “destination” for the [`operator`](Self::operator).
32    pub reverse: bool,
33
34    /// Whether the block should come apart into its components when removed from its place.
35    pub disassemblable: bool,
36    // TODO: allow specifying another block to substitute the alpha, so as to be able to
37    // make things become transparent? (That isn't strictly necessary since the “out” operator
38    // will handle it, but a single unit might be useful)
39}
40
41impl Composite {
42    /// Construct a new [`Composite`] modifier with the given source and operator, and
43    /// `reverse: false`.
44    pub fn new(source: Block, operator: CompositeOperator) -> Self {
45        Self {
46            source,
47            operator,
48            reverse: false,
49            disassemblable: false,
50        }
51    }
52
53    /// Toggle the reversed flag, which swaps the roles of the two blocks in the operator.
54    #[must_use]
55    pub fn reversed(mut self) -> Self {
56        self.reverse = !self.reverse;
57        self
58    }
59
60    /// Set the disassemblable flag to true.
61    ///
62    /// This will allow the composite to be taken apart by player action.
63    /// TODO: explain further
64    #[must_use]
65    pub fn with_disassemblable(mut self) -> Self {
66        self.disassemblable = true;
67        self
68    }
69
70    /// Compose `self` and `destination`, except that:
71    ///
72    /// * If `destination` is [`AIR`], then the `self.source` block will be returned.
73    /// * If `self.source` is [`AIR`], then `destination` will be returned.
74    /// * If `destination` has a rotation modifier, it will be rearranged to be last.
75    ///   (In this way, there won't be any unequal-but-equivalent blocks generated due
76    ///   to rotation.)
77    ///
78    /// This operation is of limited use and is designed for world-generation purposes, not
79    /// player action (since it has no restrictions on what it can compose). Its particular
80    /// use is to build corner joint blocks.
81    ///
82    /// TODO: Generalize this so it has a filter on which things should be composed,
83    /// replaced, or left unchanged (failure).
84    ///
85    /// TODO: Figure out a way to express "sorting order" rules for swapping self and
86    /// destination, because for corner joints we don't care which is on top but we want
87    /// there to be only one kind of corner block, not two depending on operation order.
88    pub fn compose_or_replace(mut self, mut destination: Block) -> Block {
89        // If the destination had a rotation, extract it.
90        let dest_rot = if let Some(&Modifier::Rotate(dest_rot)) = destination.modifiers().last() {
91            destination.modifiers_mut().pop();
92            dest_rot
93        } else {
94            GridRotation::IDENTITY
95        };
96
97        if destination == AIR {
98            // If the destination is AIR, discard it.
99            // Note: Since we removed rotation, this is currently equivalent to
100            // testing against Block::unspecialize(), but it might not be in the future.
101            // We could use a better solution.
102            self.source
103        } else if self.source == AIR {
104            // If the source is AIR, produce the original destination block.
105            destination.rotate(dest_rot)
106        } else {
107            self.source = self.source.rotate(dest_rot.inverse());
108            destination.with_modifier(self).rotate(dest_rot)
109        }
110    }
111
112    /// Use [`Composite::compose_or_replace()`] repeatedly to assemble a block from parts.
113    pub fn stack(destination: Block, parts: impl IntoIterator<Item = Composite>) -> Block {
114        parts
115            .into_iter()
116            .fold(destination, |block, part| part.compose_or_replace(block))
117    }
118
119    /// Called by [`Modifier::evaluate`].
120    pub(super) fn evaluate(
121        &self,
122        block: &Block,
123        this_modifier_index: usize,
124        mut dst_evaluated: MinEval,
125        filter: &block::EvalFilter<'_>,
126    ) -> Result<MinEval, block::InEvalError> {
127        let Composite {
128            ref source,
129            operator,
130            reverse,
131            disassemblable,
132        } = *self;
133
134        // The destination block is already evaluated (it is the input to this
135        // modifier), but we need to evaluate the source block.
136        let mut src_evaluated = {
137            let _recursion_scope = block::Budget::recurse(&filter.budget)?;
138            source.evaluate_impl(filter)?
139        };
140
141        if filter.skip_eval {
142            return Ok(dst_evaluated);
143        }
144
145        // Apply the reverse option by swapping everything.
146        if reverse {
147            mem::swap(&mut src_evaluated, &mut dst_evaluated);
148        }
149
150        evaluate_composition(
151            src_evaluated,
152            dst_evaluated,
153            operator,
154            filter,
155            &CompEvalCtx {
156                block,
157                this_modifier_index: Some(this_modifier_index),
158                was_reversed: reverse,
159                disassemblable,
160            },
161        )
162    }
163
164    /// Called by [`Modifier::unspecialize()`].
165    pub(super) fn unspecialize(&self, entire_block: &Block) -> block::ModifierUnspecialize {
166        if self.disassemblable {
167            let mut destination = entire_block.clone();
168            destination.modifiers_mut().pop().expect("Missing Composite modifier");
169            block::ModifierUnspecialize::Replace(vec![self.source.clone(), destination])
170        } else {
171            block::ModifierUnspecialize::Keep
172        }
173    }
174
175    pub(crate) fn rotationally_symmetric(&self) -> bool {
176        let Self {
177            source,
178            operator,
179            reverse: _,
180            disassemblable: _,
181        } = self;
182        source.rotationally_symmetric() && operator.rotationally_symmetric()
183    }
184
185    #[must_use]
186    pub(crate) fn rotate(self, rotation: GridRotation) -> Self {
187        let Self {
188            source,
189            operator,
190            reverse,
191            disassemblable,
192        } = self;
193        Self {
194            source: source.rotate(rotation),
195            operator,
196            reverse,
197            disassemblable,
198        }
199    }
200}
201
202/// Ingredients with which to properly process parts of the composition process.
203struct CompEvalCtx<'a> {
204    block: &'a Block,
205    /// None if this isn't a regular `Modifier::Composite`
206    this_modifier_index: Option<usize>,
207    was_reversed: bool,
208    disassemblable: bool,
209}
210
211/// Implementation of [`Composite::evaluate()`], without the requirement that the source
212/// be a [`Block`] rather than a [`MinEval`].
213///
214/// If `was_reversed` is true, this does not affect the main composition but swaps which
215/// parts of the block the composed [`Operation`]s are set up to alter.
216fn evaluate_composition(
217    src_evaluated: MinEval,
218    dst_evaluated: MinEval,
219    operator: CompositeOperator,
220    filter: &block::EvalFilter<'_>,
221    ctx: &CompEvalCtx<'_>,
222) -> Result<MinEval, block::InEvalError> {
223    // Short-circuit cases where we can return a block unchanged.
224    // TODO: We currently cannot do *any* cases where we return `src_evaluated`, because
225    // block attributes are not yet merged in a symmetric way such that this would be consistent
226    // with the non-short-circuit case, and the asymmetry is always in the “keep dst” direction.
227    if operator == CompositeOperator::Over && src_evaluated == block::AIR_EVALUATED_MIN {
228        return Ok(dst_evaluated);
229    }
230
231    // Unpack blocks.
232    let (dst_att, mut dst_voxels) = dst_evaluated.into_parts();
233    let (src_att, mut src_voxels) = src_evaluated.into_parts();
234
235    let src_resolution = src_voxels.resolution();
236    let dst_resolution = dst_voxels.resolution();
237    let effective_resolution = src_resolution.max(dst_resolution);
238    let src_scale =
239        GridCoordinate::from(effective_resolution) / GridCoordinate::from(src_resolution);
240    let dst_scale =
241        GridCoordinate::from(effective_resolution) / GridCoordinate::from(dst_resolution);
242
243    let src_bounds_scaled = bounds_excluding_air(&src_voxels, src_scale);
244    let dst_bounds_scaled = bounds_excluding_air(&dst_voxels, dst_scale);
245
246    let output_bounds = operator.bounds(src_bounds_scaled, dst_bounds_scaled);
247
248    // Volume in which cubes from both sources exist and blending actually needs to be executed.
249    let intersection_for_blend = src_voxels
250        .bounds()
251        .intersection_cubes(dst_voxels.bounds())
252        .unwrap_or(GridAab::ORIGIN_EMPTY);
253
254    let attributes = block::BlockAttributes {
255        // TODO: smarter, configurable merge — e.g. the game logic might want to compose a noun and
256        // adjective or otherwise acknowledge two blocks into one.
257        // This may require more from the `CompositeOperator` type or from the type of the
258        // `display_name` attribute (which is currently a string).
259        display_name: if dst_att.display_name.is_empty() {
260            src_att.display_name
261        } else {
262            dst_att.display_name
263        },
264        selectable: src_att.selectable | dst_att.selectable,
265        inventory: src_att.inventory.concatenate(dst_att.inventory),
266        ambient_sound: dst_att.ambient_sound, // TODO merge
267        rotation_rule: dst_att.rotation_rule, // TODO merge
268        placement_action: operator
269            .blend_operations(
270                ctx,
271                src_att.placement_action.as_ref().map(|a| &a.operation),
272                dst_att.placement_action.as_ref().map(|a| &a.operation),
273            )
274            .map(|operation| block::PlacementAction {
275                operation,
276                // TODO: unclear if logical OR is the right merge rule here
277                in_front: src_att.placement_action.is_some_and(|a| a.in_front)
278                    || dst_att.placement_action.is_some_and(|a| a.in_front),
279            }),
280        tick_action: operator
281            .blend_operations(
282                ctx,
283                src_att.tick_action.as_ref().map(|a| &a.operation),
284                dst_att.tick_action.as_ref().map(|a| &a.operation),
285            )
286            .map(|operation| block::TickAction {
287                operation,
288                // TODO: we actually need to be able to schedule whichever period is shorter
289                // and run the specific appropriate action in that case; this only works when
290                // the schedules are equal or there is only one.
291                schedule: src_att
292                    .tick_action
293                    .as_ref()
294                    .map(|a| a.schedule)
295                    .or_else(|| dst_att.tick_action.as_ref().map(|a| a.schedule))
296                    .expect("unreachable: no schedule"),
297            }),
298        activation_action: operator.blend_operations(
299            ctx,
300            src_att.activation_action.as_ref(),
301            dst_att.activation_action.as_ref(),
302        ),
303        animation_hint: src_att.animation_hint | dst_att.animation_hint, // TODO: some operators should ignore some hints (e.g. `In` should ignore destination color changes)
304    };
305
306    // Evaluate the voxel compositions, choosing an evaluation strategy based on the
307    // situation to minimize the number of voxels we need to process individually and
308    // memory we need to allocate.
309    let voxels = if let (true, Some(src_voxel), Some(dst_voxel)) = (
310        output_bounds == GridAab::ORIGIN_CUBE,
311        src_voxels.single_voxel(),
312        dst_voxels.single_voxel(),
313    ) {
314        // The output is nonempty and has resolution 1. No allocation needed.
315        block::Budget::decrement_voxels(&filter.budget, 1)?;
316        // TODO: Do we need the `output_bounds == ORIGIN_CUBE` test?
317        // It skips this branch to keep the bounds empty, but is that good?
318        Evoxels::from_one(operator.blend_evoxel(src_voxel, dst_voxel))
319    } else if operator.air_src_leaves_dst_unchanged()
320        && output_bounds == dst_voxels.bounds()
321        && dst_scale == src_scale
322    {
323        // Perform composition in-place in dst_voxels.
324        // This may skip a memory allocation (depending on the reference count),
325        // and in any case avoids performing blending on voxels that are out of bounds of src.
326        block::Budget::decrement_voxels(&filter.budget, src_voxels.count())?;
327        let mut dst_voxels_mut = dst_voxels.as_vol_mut();
328        intersection_for_blend.interior_iter().for_each(|cube| {
329            let dst_voxel = &mut dst_voxels_mut[cube];
330            *dst_voxel = operator.blend_evoxel(src_voxels[cube], *dst_voxel);
331        });
332        dst_voxels
333    } else if operator.air_dst_leaves_src_unchanged()
334        && output_bounds == src_voxels.bounds()
335        && dst_scale == src_scale
336    {
337        // Perform composition in-place in src_voxels.
338        // This is the reverse of the previous case.
339        // TODO: deduplicate code?
340        block::Budget::decrement_voxels(&filter.budget, dst_voxels.count())?;
341        let mut src_voxels_mut = src_voxels.as_vol_mut();
342        intersection_for_blend.interior_iter().for_each(|cube| {
343            let src_voxel = &mut src_voxels_mut[cube];
344            *src_voxel = operator.blend_evoxel(*src_voxel, dst_voxels[cube]);
345        });
346        src_voxels
347    } else {
348        // Allocate new output array that encloses the full output bounds.
349        block::Budget::decrement_voxels(&filter.budget, output_bounds.volume().unwrap())?;
350        Evoxels::from_many(
351            effective_resolution,
352            Vol::from_fn(output_bounds, |cube| {
353                let p = cube.lower_bounds();
354                operator.blend_evoxel(
355                    src_voxels.get(Cube::from(p / src_scale)).unwrap_or(Evoxel::AIR),
356                    dst_voxels.get(Cube::from(p / dst_scale)).unwrap_or(Evoxel::AIR),
357                )
358            }),
359        )
360    };
361
362    Ok(MinEval::new(attributes, voxels))
363}
364
365/// Rescale the bounds of the input to the resolution of the output, but also, if the voxels are
366/// [`Evoxel::AIR`] and thus equivalent to out-of-bounds, substitute empty bounds.
367/// This way, we produce suitably tight bounds when one of the blocks is AIR.
368fn bounds_excluding_air(voxels: &Evoxels, src_scale: i32) -> GridAab {
369    if voxels.single_voxel() == Some(Evoxel::AIR) {
370        GridAab::ORIGIN_EMPTY
371    } else {
372        voxels.bounds().multiply(src_scale)
373    }
374}
375
376impl From<Composite> for Modifier {
377    fn from(value: Composite) -> Self {
378        Modifier::Composite(value)
379    }
380}
381
382impl universe::VisitHandles for Composite {
383    fn visit_handles(&self, visitor: &mut dyn universe::HandleVisitor) {
384        let Self {
385            source,
386            operator: _,
387            reverse: _,
388            disassemblable: _,
389        } = self;
390        source.visit_handles(visitor);
391    }
392}
393
394/// Compositing operators, mostly as per Porter-Duff.
395///
396/// The “source” block is the [`Composite`]'s stored block, and the “destination” block
397/// is the block the modifier is attached to.
398///
399/// TODO: Document behavior of `collision` and `selectable` properties.
400///
401#[doc = include_str!("../../save/serde-warning.md")]
402#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
403#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
404#[cfg_attr(feature = "save", derive(serde::Serialize, serde::Deserialize))]
405#[non_exhaustive]
406pub enum CompositeOperator {
407    /// Porter-Duff “over”. If both source and destination are opaque, the source is taken;
408    /// otherwise the destination is taken.
409    Over,
410
411    /// Porter-Duff “in”. If both source and destination are opaque, the source is taken;
412    /// otherwise the result is transparent. Thus the destination acts as a mask constraining
413    /// where the source is present; the source is “in” the destination.
414    /// The destination's color is not used.
415    In,
416
417    /// Porter-Duff “out”. If both source and destination are opaque, the result is transparent;
418    /// otherwise the source is taken. Thus the destination acts as a mask removing portions
419    /// of the source.
420    /// The destination's color is not used.
421    Out,
422
423    /// Porter-Duff “atop”. If both source and destination are opaque, the source is taken;
424    /// otherwise the destination is taken. Thus the source is painted onto the destination's
425    /// substance.
426    Atop,
427    //
428    // /// Split the volume in half on the plane perpendicular to `[1, 0, 1]`; all voxels
429    // /// on the side nearer to the origin are taken from the destination, and all voxels
430    // /// on the farther side or exactly on the plane are taken from the source.
431    // Bevel,
432}
433
434impl CompositeOperator {
435    /// Entry point by which [`evaluate_composition()`] uses [`Self`].
436    fn blend_evoxel(self, src_ev: Evoxel, dst_ev: Evoxel) -> Evoxel {
437        use BlockCollision as Coll;
438        Evoxel {
439            color: {
440                // Clamp to avoid silly outcomes of the arithmetic.
441                let source = src_ev.color.clamp();
442                let destination = dst_ev.color.clamp();
443                let (rgb, a) = self.alpha_blend(
444                    source.to_rgb(),
445                    source.alpha(),
446                    destination.to_rgb(),
447                    destination.alpha(),
448                );
449                rgb.with_alpha(a)
450            },
451
452            // TODO: This doesn't work correctly when something is transparent and emissive.
453            // We need to define the semantics of that in terms of volumetric rendering.
454            emission: {
455                let (color_blend, alpha) = self.alpha_blend(
456                    src_ev.emission,
457                    src_ev.color.clamp().alpha(),
458                    dst_ev.emission,
459                    dst_ev.color.clamp().alpha(),
460                );
461                // effectively “premultiplying” in order to apply the intended effect of
462                // alpha on the intensity
463                color_blend * alpha
464            },
465
466            selectable: self.blend_binary(src_ev.selectable, dst_ev.selectable),
467
468            collision: {
469                let src_is_something = !matches!(src_ev.collision, Coll::None);
470                let dst_is_something = !matches!(dst_ev.collision, Coll::None);
471                if self.blend_binary(src_is_something, dst_is_something) {
472                    // TODO: this is probably not a sufficient condition and we will
473                    // eventually need some kind of “ranking” of collision types so that
474                    // depending the operator a "soft" collision (e.g. "high viscosity")
475                    // might entirely override a "hard" one or might not.
476                    if src_is_something {
477                        src_ev.collision
478                    } else {
479                        dst_ev.collision
480                    }
481                } else {
482                    Coll::None
483                }
484            },
485        }
486    }
487
488    /// Called by [`Self::blend_evoxel()`] to handle diffuse and emissive colors.
489    ///
490    /// Note that this does not accept and return `Rgba` because the output is not necessarily
491    /// in the 0-1 range; it might work but that's not an intended use of the type.
492    fn alpha_blend(
493        self,
494        source: Rgb,
495        sa: ZeroOne<f32>,
496        destination: Rgb,
497        da: ZeroOne<f32>,
498    ) -> (Rgb, ZeroOne<f32>) {
499        match self {
500            Self::Over => {
501                // TODO: Surely this is not the only place we have implemented rgba blending?
502                // Note that this math would be simpler if we used premultiplied alpha.
503                let sa_complement = sa.complement();
504                let rgb = source * sa + destination * sa_complement;
505                (
506                    rgb,
507                    // TODO: express this alpha calculation in a correct-by-construction way instead
508                    ZeroOne::<f32>::new_clamped(
509                        sa.into_inner() + (sa_complement * da).into_inner(),
510                    ),
511                )
512            }
513
514            Self::In => (source, sa * da),
515            Self::Out => (source, sa * da.complement()),
516
517            Self::Atop => {
518                let sa_complement = PositiveSign::<f32>::new_clamped(1. - sa.into_inner());
519                let rgb = source * sa + destination * sa_complement;
520
521                let out_alpha = da;
522                if out_alpha.is_zero() {
523                    // we wouldn't have to do this if we used premultiplied alpha :/
524                    (Rgb::ZERO, out_alpha)
525                } else {
526                    (rgb, out_alpha)
527                }
528            }
529        }
530    }
531
532    /// Called by [`Self::blend_evoxel()`] to handle properties that can be described as
533    /// “present or absent” binary flags.
534    #[expect(clippy::needless_bitwise_bool)] // ideally this would be branchless…
535    fn blend_binary(self, source: bool, destination: bool) -> bool {
536        match self {
537            Self::Over => source | destination,
538            Self::In => source & destination,
539            Self::Out => source & !destination,
540            Self::Atop => destination,
541        }
542    }
543
544    fn blend_operations<'op>(
545        self,
546        ctx: &CompEvalCtx<'_>,
547        mut source_op: Option<&'op Operation>,
548        mut destination_op: Option<&'op Operation>,
549    ) -> Option<Operation> {
550        let Some(this_modifier_index) = ctx.this_modifier_index else {
551            // This is not a `Modifier::Composite` and so we cannot compose operations
552            // in the usual fashion. Do nothing.
553            return None;
554        };
555
556        // Unreverse the operations so that they apply to their original parts of the block.
557        if ctx.was_reversed {
558            mem::swap(&mut source_op, &mut destination_op);
559        }
560
561        // For now, `Become` is the only supported operation.
562        // TODO: We should have a warning-reporting path so that this can be debugged when it fails.
563        fn require_become(op: Option<&Operation>) -> Option<&Block> {
564            match op {
565                Some(Operation::Become(block)) => Some(block),
566                _ => None,
567            }
568        }
569        let source_becoming = require_become(source_op);
570        let destination_becoming = require_become(destination_op);
571
572        if source_becoming.is_none() && destination_becoming.is_none() {
573            // No operation to produce
574            return None;
575        }
576
577        // We now know that we need to make a `Become` operation which composes two blocks,
578        // at least one of which will different.
579        let mut new_block = match destination_becoming {
580            // If there is a new whole destination block to become, then start with that.
581            Some(block) => block.clone(),
582            // Else start with the block with modifiers *preceding* this Composite modifier.
583            None => {
584                let mut new_block = Block::from_primitive(ctx.block.primitive().clone());
585                new_block
586                    .modifiers_mut()
587                    .extend(ctx.block.modifiers()[..this_modifier_index].iter().cloned());
588                new_block
589            }
590        };
591        new_block.modifiers_mut().push(Modifier::Composite(Composite {
592            source: match source_becoming {
593                Some(source_becoming) => source_becoming.clone(),
594                None => {
595                    // If the source block had no `Operation::Become` of its own, then treat
596                    // it as becoming itself.
597                    let Modifier::Composite(Composite { source, .. }) =
598                        &ctx.block.modifiers()[this_modifier_index]
599                    else {
600                        panic!("modifier mismatch");
601                    };
602                    source.clone()
603                }
604            },
605            operator: self,
606            reverse: ctx.was_reversed,
607            disassemblable: ctx.disassemblable,
608        }));
609
610        Some(Operation::Become(new_block))
611    }
612
613    /// Compute the bounds of the result given the bounds of the source and destination.
614    fn bounds(self, source: GridAab, destination: GridAab) -> GridAab {
615        match self {
616            Self::Over => source.union_cubes(destination),
617            // We could equally well use intersection_cubes() here, but prefer the one that
618            // more often returns a box related to the input.
619            Self::In => source.intersection_box(destination).unwrap_or(GridAab::ORIGIN_EMPTY),
620            Self::Out => source,
621            Self::Atop => destination,
622        }
623    }
624
625    /// Whether `self.blend_evoxel(Evoxel::AIR, dst) == dst` for all possible `dst`.
626    fn air_src_leaves_dst_unchanged(self) -> bool {
627        match self {
628            CompositeOperator::Over => true,
629            CompositeOperator::In => true,
630            CompositeOperator::Out => false,
631            CompositeOperator::Atop => true,
632        }
633    }
634
635    /// Whether `self.blend_evoxel(src, Evoxel::AIR) == src` for all possible `src`.
636    fn air_dst_leaves_src_unchanged(self) -> bool {
637        match self {
638            CompositeOperator::Over => true,
639            CompositeOperator::In => false,
640            CompositeOperator::Out => true,
641            CompositeOperator::Atop => false,
642        }
643    }
644
645    /// Returns whether this operator’s effects are independent of how the input blocks are
646    /// rotated.
647    #[expect(clippy::unused_self)]
648    fn rotationally_symmetric(self) -> bool {
649        true
650    }
651}
652
653/// Inventories are rendered by compositing their icon blocks in.
654pub(in crate::block) fn render_inventory(
655    mut input: MinEval,
656    inventory: &crate::inv::Inventory,
657    filter: &block::EvalFilter<'_>,
658) -> Result<MinEval, block::InEvalError> {
659    if filter.skip_eval {
660        return Ok(input);
661    }
662
663    let original_attributes = input.attributes().clone();
664
665    // TODO(inventory): clone necessary to avoid a borrow conflict
666    let config = input.attributes().inventory.clone();
667
668    for (slot_index, icon_position) in config.icon_positions(inventory.size()) {
669        let Some(placed_icon_bounds) = GridAab::checked_from_lower_size(
670            icon_position,
671            GridSize::splat((config.icon_resolution / config.icon_scale).unwrap_or(R1).into()),
672        )
673        .ok()
674        .and_then(|b| b.intersection_cubes(GridAab::for_block(config.icon_resolution))) else {
675            // Icon's position doesn't intersect the block's bounds.
676            continue;
677        };
678
679        // TODO(inventory): icon_only_if_intrinsic is a kludge
680        let Some(icon): Option<&Block> =
681            inventory.get(slot_index).and_then(|slot| slot.icon_only_if_intrinsic())
682        else {
683            // No slot to render at this position.
684            continue;
685        };
686
687        let mut icon_evaluated = {
688            let _recursion_scope = block::Budget::recurse(&filter.budget)?;
689            // this is the wrong cost value but it doesn't matter
690            icon.evaluate_impl(filter)?.finish(icon.clone(), filter.budget.get().to_cost())
691        };
692
693        // TODO(inventory): Instead of roughly downsampling the icons here, we should be
694        // asking evaluation to generate a lower resolution as per `config.icon_resolution`).
695        let resample_scale = GridCoordinate::from(icon_evaluated.voxels.resolution())
696            * GridCoordinate::from(config.icon_scale)
697            / GridCoordinate::from(config.icon_resolution);
698        let resample_point_offset = GridVector::splat(resample_scale / 2);
699        icon_evaluated.voxels = Evoxels::from_many(
700            config.icon_resolution,
701            Vol::from_fn(placed_icon_bounds, |render_cube| {
702                // Translate to the coordinate system with the icon's lower corner as origin.
703                let translated: Cube = render_cube - placed_icon_bounds.lower_bounds().to_vector();
704                // Scale to the icon's voxels' resolution.
705                let translated_and_scaled: Cube =
706                    Cube::from(GridPoint::from(translated) * resample_scale);
707                icon_evaluated
708                    .voxels
709                    .get(translated_and_scaled + resample_point_offset)
710                    .unwrap_or(Evoxel::AIR)
711            }),
712        );
713
714        input = evaluate_composition(
715            icon_evaluated.into(),
716            input,
717            CompositeOperator::Over,
718            filter,
719            &CompEvalCtx {
720                block: const { &AIR },     // unused placeholder
721                this_modifier_index: None, // disables operator composition we don't want anyway
722                was_reversed: false,
723                disassemblable: false,
724            },
725        )?;
726    }
727
728    // Reset block attributes to the contained attributes; don't let the icons contribute anything.
729    // TODO: Instead of resetting the attributes, pass an option to `evaluate_composition`
730    // to not modify them in the first place (except where appropriate, like the animation hint).
731    input.set_attributes(block::BlockAttributes {
732        animation_hint: input.attributes().animation_hint,
733        ..original_attributes
734    });
735
736    Ok(input)
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use crate::block::{EvKey, EvaluatedBlock, Resolution::*};
743    use crate::content::{make_slab, make_some_blocks};
744    use crate::math::{Rgba, zo32};
745    use crate::space::Space;
746    use crate::time;
747    use crate::universe::Universe;
748    use BlockCollision::{Hard, None as CNone};
749    use CompositeOperator::{Atop, In, Out, Over};
750    use arcstr::literal;
751    use pretty_assertions::assert_eq;
752
753    // --- Helpers ---
754
755    /// Check the result of applying an operator to a single `Evoxel`
756    #[track_caller]
757    fn assert_blend(src: Evoxel, operator: CompositeOperator, dst: Evoxel, outcome: Evoxel) {
758        // TODO: Replace this direct call with going through the full block evaluation.
759
760        assert_eq!(
761            operator.blend_evoxel(src, dst),
762            outcome,
763            "\nexpecting {operator:?}.blend(\n  {src:?},\n  {dst:?}\n) == {outcome:?}"
764        );
765    }
766
767    fn evcolor(color: Rgba) -> Evoxel {
768        Evoxel {
769            color,
770            emission: Rgb::ZERO,
771            selectable: true,
772            collision: Hard,
773        }
774    }
775
776    /// Construct a voxel with light emission.
777    /// Alpha is taken too, because alpha is used to control blending.
778    fn evemit(emission: Rgb, alpha: f32) -> Evoxel {
779        Evoxel {
780            // color doesn't matter, except that at zero alpha it should be the canonical zero
781            // for convenience of testing. (TODO: maybe `Rgba` should enforce that or be premultiplied.)
782            color: Rgb::ZERO.with_alpha(zo32(alpha)),
783            emission,
784            selectable: true,
785            collision: Hard,
786        }
787    }
788
789    fn evcoll(collision: BlockCollision) -> Evoxel {
790        Evoxel {
791            color: Rgba::WHITE, // no effect
792            emission: Rgb::ZERO,
793            selectable: false, // no effect
794            collision,
795        }
796    }
797
798    #[track_caller]
799    fn eval_compose(
800        universe: &Universe,
801        src: &Block,
802        operator: CompositeOperator,
803        dst: &Block,
804    ) -> EvaluatedBlock {
805        dst.clone()
806            .with_modifier(Composite::new(src.clone(), operator))
807            .evaluate(universe.read_ticket())
808            .expect("failed to evaluate in eval_compose()")
809    }
810
811    // --- Tests ---
812
813    #[test]
814    fn bounding_volume_combination() {
815        let universe = &mut Universe::new();
816        // Two spaces for blocks that overlap in Venn diagram fashion
817        let bounds1 = GridAab::from_lower_size([0, 0, 0], [2, 1, 1]);
818        let bounds2 = GridAab::from_lower_size([1, 0, 0], [2, 1, 1]);
819        let space1 = universe.insert_anonymous(Space::builder(bounds1).build());
820        let space2 = universe.insert_anonymous(Space::builder(bounds2).build());
821        let block1 = Block::builder().voxels_handle(R4, space1).build();
822        let block2 = Block::builder().voxels_handle(R4, space2).build();
823
824        let union = GridAab::from_lower_size([0, 0, 0], [3, 1, 1]);
825        let intersection = GridAab::from_lower_size([1, 0, 0], [1, 1, 1]);
826
827        assert_eq!(
828            eval_compose(universe, &block1, Over, &block2).voxels_bounds(),
829            union,
830            "Over"
831        );
832        assert_eq!(
833            eval_compose(universe, &block1, In, &block2).voxels_bounds(),
834            intersection,
835            "In"
836        );
837        assert_eq!(
838            eval_compose(universe, &block1, Atop, &block2).voxels_bounds(),
839            bounds2,
840            "Atop"
841        );
842    }
843
844    #[test]
845    fn bounding_volume_when_one_is_air() {
846        let universe = &mut Universe::new();
847        let slab = make_slab(universe, 1, R2);
848        let slab_bounds = slab.evaluate(universe.read_ticket()).unwrap().voxels_bounds();
849
850        assert_eq!(
851            eval_compose(universe, &slab, Over, &AIR).voxels_bounds(),
852            slab_bounds,
853            "Over",
854        );
855        assert_eq!(
856            eval_compose(universe, &slab, In, &AIR).voxels_bounds(),
857            GridAab::ORIGIN_EMPTY,
858            "In",
859        );
860        assert_eq!(
861            eval_compose(universe, &slab, Atop, &AIR).voxels_bounds(),
862            GridAab::ORIGIN_EMPTY,
863            "Atop AIR",
864        );
865        assert_eq!(
866            eval_compose(universe, &AIR, Atop, &slab).voxels_bounds(),
867            slab_bounds,
868            "AIR Atop",
869        );
870    }
871
872    /// Test each operator’s treatment of input blocks’ individual voxels (not attributes).
873    mod voxel {
874        use super::*;
875
876        #[test]
877        fn over_silly_floats() {
878            // We just want to see this does not panic on math errors.
879            // TODO: this test should eventually become obsolete by using constrained numeric types.
880            Over.blend_evoxel(
881                evcolor(Rgba::new(2e25, 2e25, 2e25, 1.0)),
882                evcolor(Rgba::new(2e25, 2e25, 2e25, 1.0)),
883            );
884        }
885
886        #[test]
887        fn over_emission() {
888            let red_1 = evemit(Rgb::new(1., 0., 0.), 1.0);
889            let green_0 = evemit(Rgb::new(0., 1., 0.), 0.0);
890            let green_05 = evemit(Rgb::new(0., 1., 0.), 0.5);
891            let none_1 = evemit(Rgb::ZERO, 1.0);
892            let none_0 = evemit(Rgb::ZERO, 0.0);
893
894            // Simple 100% blending cases
895            assert_blend(red_1, Over, none_1, red_1);
896            assert_blend(none_1, Over, red_1, none_1);
897            assert_blend(none_1, Over, none_1, none_1);
898            assert_blend(red_1, Over, red_1, red_1);
899            assert_blend(red_1, Over, none_0, red_1);
900            assert_blend(none_0, Over, red_1, red_1);
901
902            // Partial alpha
903            assert_blend(red_1, Over, green_05, red_1);
904            assert_blend(green_05, Over, red_1, evemit(Rgb::new(0.5, 0.5, 0.0), 1.0));
905            assert_blend(
906                green_05,
907                Over,
908                green_05,
909                evemit(Rgb::new(0.0, 0.75, 0.0), 0.75),
910            );
911            // assert_blend(green_05, Over, none_0, green_05); // TODO: broken, too dim
912
913            // What if emission with zero alpha is blended in?
914            assert_blend(green_0, Over, none_1, none_1);
915            // assert_blend(green_0, Over, none_0, green_0); // TODO
916            assert_blend(none_1, Over, green_0, none_1);
917            // assert_blend(green_0, Over, green_0, green_0); // TODO: goes to zero
918        }
919
920        #[test]
921        fn over_collision() {
922            assert_blend(evcoll(Hard), Over, evcoll(Hard), evcoll(Hard));
923            assert_blend(evcoll(CNone), Over, evcoll(CNone), evcoll(CNone));
924            assert_blend(evcoll(Hard), Over, evcoll(CNone), evcoll(Hard));
925            assert_blend(evcoll(CNone), Over, evcoll(Hard), evcoll(Hard));
926        }
927
928        #[test]
929        fn in_emission() {
930            let red_1 = evemit(Rgb::new(1., 0., 0.), 1.0);
931            let green_1 = evemit(Rgb::new(0., 1., 0.), 1.0);
932            let green_0 = evemit(Rgb::new(0., 1., 0.), 0.0);
933            let green_05 = evemit(Rgb::new(0., 1., 0.), 0.5);
934            let none_1 = evemit(Rgb::ZERO, 1.0);
935            let none_0 = evemit(Rgb::ZERO, 0.0);
936
937            // Simple 100% blending cases
938            assert_blend(red_1, In, none_1, red_1);
939            assert_blend(red_1, In, red_1, red_1);
940            assert_blend(red_1, In, green_1, red_1);
941            assert_blend(red_1, In, none_0, none_0);
942            assert_blend(none_1, In, red_1, none_1);
943            assert_blend(none_0, In, red_1, none_0);
944            assert_blend(none_1, In, none_1, none_1);
945            assert_blend(none_0, In, none_1, none_0);
946
947            // Partial alpha
948            assert_blend(red_1, In, green_05, evemit(Rgb::new(0.5, 0.0, 0.0), 0.5));
949            assert_blend(green_05, In, red_1, evemit(Rgb::new(0.0, 0.5, 0.0), 0.5));
950            assert_blend(
951                green_05,
952                In,
953                green_05,
954                evemit(Rgb::new(0.0, 0.25, 0.0), 0.25),
955            );
956            assert_blend(green_05, In, none_0, none_0); // TODO: broken, too dim
957
958            // What if emission with zero alpha is blended in?
959            assert_blend(green_0, In, none_1, none_0);
960            assert_blend(green_0, In, none_0, none_0);
961            assert_blend(none_1, In, green_0, none_0);
962            assert_blend(green_0, In, green_0, none_0); // TODO: this should plausibly stay
963        }
964
965        #[test]
966        fn in_collision() {
967            assert_blend(evcoll(Hard), In, evcoll(Hard), evcoll(Hard));
968            assert_blend(evcoll(CNone), In, evcoll(CNone), evcoll(CNone));
969            assert_blend(evcoll(Hard), In, evcoll(CNone), evcoll(CNone));
970            assert_blend(evcoll(CNone), In, evcoll(Hard), evcoll(CNone));
971        }
972
973        #[test]
974        fn atop_color() {
975            let opaque1 = evcolor(Rgba::new(1.0, 0.0, 0.0, 1.0));
976            let opaque2 = evcolor(Rgba::new(0.0, 1.0, 0.0, 1.0));
977            let half_red = evcolor(Rgba::new(1.0, 0.0, 0.0, 0.5));
978            let clear = evcolor(Rgba::TRANSPARENT);
979
980            assert_blend(opaque1, Atop, opaque2, opaque1);
981            assert_blend(
982                half_red,
983                Atop,
984                opaque2,
985                evcolor(Rgba::new(0.5, 0.5, 0.0, 1.0)),
986            );
987            assert_blend(opaque1, Atop, clear, clear);
988            assert_blend(clear, Atop, opaque2, opaque2);
989            assert_blend(clear, Atop, clear, clear);
990        }
991
992        #[test]
993        fn atop_emission() {
994            let red_1 = evemit(Rgb::new(1., 0., 0.), 1.0);
995            let green_1 = evemit(Rgb::new(0., 1., 0.), 1.0);
996            let green_0 = evemit(Rgb::new(0., 1., 0.), 0.0);
997            let green_05 = evemit(Rgb::new(0., 1., 0.), 0.5);
998            let none_1 = evemit(Rgb::ZERO, 1.0);
999            let none_0 = evemit(Rgb::ZERO, 0.0);
1000
1001            // Simple 100% blending cases
1002            assert_blend(red_1, Atop, none_1, red_1);
1003            assert_blend(red_1, Atop, red_1, red_1);
1004            assert_blend(red_1, Atop, green_1, red_1);
1005            assert_blend(red_1, Atop, none_0, none_0);
1006            assert_blend(none_1, Atop, red_1, none_1);
1007            assert_blend(none_0, Atop, red_1, red_1);
1008            assert_blend(none_1, Atop, none_1, none_1);
1009            assert_blend(none_0, Atop, none_1, none_1);
1010
1011            // Partial alpha
1012            assert_blend(red_1, Atop, green_05, evemit(Rgb::new(0.5, 0.0, 0.0), 0.5));
1013            assert_blend(green_05, Atop, red_1, evemit(Rgb::new(0.5, 0.5, 0.0), 1.0));
1014            assert_blend(
1015                green_05,
1016                Atop,
1017                green_05,
1018                evemit(Rgb::new(0.0, 0.5, 0.0), 0.5),
1019            );
1020            assert_blend(green_05, Atop, none_0, none_0);
1021
1022            // What if emission with zero alpha is blended in?
1023            assert_blend(green_0, Atop, none_1, none_1);
1024            assert_blend(green_0, Atop, none_0, none_0);
1025            assert_blend(none_1, Atop, green_0, none_0);
1026            assert_blend(green_0, Atop, green_0, none_0); // TODO: this should plausibly stay
1027        }
1028
1029        #[test]
1030        fn blend_atop_collision() {
1031            assert_blend(evcoll(Hard), Atop, evcoll(Hard), evcoll(Hard));
1032            assert_blend(evcoll(CNone), Atop, evcoll(CNone), evcoll(CNone));
1033            assert_blend(evcoll(Hard), Atop, evcoll(CNone), evcoll(CNone));
1034            assert_blend(evcoll(CNone), Atop, evcoll(Hard), evcoll(Hard));
1035        }
1036    }
1037
1038    /// Test each operator’s treatment of input blocks’ attributes (not voxels).
1039    mod attributes {
1040        use super::{assert_eq, *};
1041
1042        #[test]
1043        fn display_name() {
1044            let u = Universe::new();
1045            let no_name = &Block::builder().color(Rgba::WHITE).build();
1046            let has_name_1 =
1047                &Block::builder().color(Rgba::WHITE).display_name(literal!("has_name_1")).build();
1048            let has_name_2 =
1049                &Block::builder().color(Rgba::WHITE).display_name(literal!("has_name_2")).build();
1050
1051            // Nonempty source overrides empty destination.
1052            assert_eq!(
1053                eval_compose(&u, has_name_1, Over, no_name).attributes().display_name,
1054                literal!("has_name_1")
1055            );
1056
1057            // Nonempty destination overrides empty source.
1058            assert_eq!(
1059                eval_compose(&u, no_name, Over, has_name_1).attributes().display_name,
1060                literal!("has_name_1")
1061            );
1062
1063            // If both have names, destination wins.
1064            assert_eq!(
1065                eval_compose(&u, has_name_1, Over, has_name_2).attributes().display_name,
1066                // the original block’s name is preferred over the modifier
1067                literal!("has_name_2")
1068            );
1069
1070            // If both have names and the composition is reversed, the source wins because it is
1071            // playing the destination role.
1072            //
1073            // TODO: This is probably not desired behavior. Think about what the semantics of
1074            // the reversed flag should be. Maybe we should have separate flags for voxels and
1075            // attributes?
1076            assert_eq!(
1077                has_name_2
1078                    .clone()
1079                    .with_modifier(Composite::new(has_name_1.clone(), Over).reversed())
1080                    .evaluate(u.read_ticket())
1081                    .unwrap()
1082                    .attributes()
1083                    .display_name,
1084                literal!("has_name_1")
1085            );
1086        }
1087
1088        #[test]
1089        fn selectable_if_either_is_selectable() {
1090            let u = Universe::new();
1091            // TODO: make this a more thorough test by making the two blocks slabs so that
1092            // all four types of voxels are involved. This currently doesn't matter but it may.
1093            let is_s = &Block::builder().color(Rgba::WHITE).selectable(true).build();
1094            let not_s = &Block::builder().color(Rgba::WHITE).selectable(false).build();
1095
1096            assert!(eval_compose(&u, is_s, Over, is_s).attributes().selectable);
1097            assert!(eval_compose(&u, is_s, Over, not_s).attributes().selectable);
1098            assert!(eval_compose(&u, not_s, Over, is_s).attributes().selectable);
1099            assert!(!eval_compose(&u, not_s, Over, not_s).attributes().selectable);
1100
1101            assert!(eval_compose(&u, is_s, In, is_s).attributes().selectable);
1102            assert!(eval_compose(&u, is_s, In, not_s).attributes().selectable);
1103            assert!(eval_compose(&u, not_s, In, is_s).attributes().selectable);
1104            assert!(!eval_compose(&u, not_s, In, not_s).attributes().selectable);
1105        }
1106
1107        #[test]
1108        fn activation_action_is_composed() {
1109            let universe = Universe::new();
1110            let [result1, result2] = make_some_blocks();
1111            let b1 = &Block::builder()
1112                .color(Rgba::WHITE)
1113                .activation_action(Operation::Become(result1.clone()))
1114                .build();
1115            let b2 = &Block::builder()
1116                .color(Rgba::WHITE)
1117                .activation_action(Operation::Become(result2.clone()))
1118                .build();
1119
1120            assert_eq!(
1121                eval_compose(&universe, b1, Over, b2).attributes().activation_action,
1122                Some(Operation::Become(
1123                    result2.with_modifier(Composite::new(result1, Over))
1124                ))
1125            );
1126
1127            // TODO: add other tests for when there is only one operation
1128        }
1129
1130        #[test]
1131        fn tick_action_is_composed() {
1132            let universe = Universe::new();
1133            let [result1, result2] = make_some_blocks();
1134            let b1 = &Block::builder()
1135                .color(Rgba::WHITE)
1136                .tick_action(block::TickAction {
1137                    schedule: time::Schedule::EVERY_TICK,
1138                    operation: Operation::Become(result1.clone()),
1139                })
1140                .build();
1141            let b2 = &Block::builder()
1142                .color(Rgba::WHITE)
1143                .tick_action(block::TickAction {
1144                    schedule: time::Schedule::EVERY_TICK,
1145                    operation: Operation::Become(result2.clone()),
1146                })
1147                .build();
1148
1149            assert_eq!(
1150                eval_compose(&universe, b1, Over, b2).attributes().tick_action,
1151                Some(block::TickAction {
1152                    schedule: time::Schedule::EVERY_TICK,
1153                    operation: Operation::Become(
1154                        result2.with_modifier(Composite::new(result1, Over))
1155                    )
1156                })
1157            );
1158
1159            // TODO: add other tests for when there is only one operation
1160            // TODO: add test of merging schedules
1161        }
1162    }
1163
1164    /// Operations on the `Composite` modifier or block themselves.
1165    mod ops {
1166        use super::{assert_eq, *};
1167
1168        #[test]
1169        fn compose_or_replace_source_is_air() {
1170            let [block] = make_some_blocks();
1171            assert_eq!(
1172                Composite::new(AIR, Over).compose_or_replace(block.clone()),
1173                block
1174            );
1175        }
1176
1177        #[test]
1178        fn compose_or_replace_destination_is_air() {
1179            let [block] = make_some_blocks();
1180            assert_eq!(
1181                Composite::new(block.clone(), Over).compose_or_replace(AIR),
1182                block
1183            );
1184        }
1185
1186        #[test]
1187        fn unspecialize_no() {
1188            let [b1, b2] = make_some_blocks();
1189            let composed = b1.with_modifier(Composite::new(b2, Over));
1190            assert_eq!(composed.unspecialize(), vec![composed]);
1191        }
1192
1193        #[test]
1194        fn unspecialize_yes() {
1195            let [b1, b2] = make_some_blocks();
1196            let composed =
1197                b1.clone().with_modifier(Composite::new(b2.clone(), Over).with_disassemblable());
1198            assert_eq!(composed.unspecialize(), vec![b2, b1]);
1199        }
1200    }
1201
1202    /// Tests of the self-reported performance of composition
1203    /// under specific cases that are supposed to be optimized.
1204    mod optimization {
1205        use super::{assert_eq, *};
1206
1207        /// Test that when we composite with `AIR`, the result skips unnecessary evaluation.
1208        ///
1209        /// TODO: Expand this test to AIR-like blocks that aren't exactly AIR.
1210        #[test]
1211        fn composite_with_air_is_short_circuit_noop() {
1212            let universe = &mut Universe::new();
1213
1214            // Construct a voxel block with BlockDef.
1215            // By using the BlockDef, we get a cache that we can use to confirm
1216            // by pointer identity that the composition didn't needlessly build a new
1217            // voxel allocation.
1218            let base_block = Block::builder()
1219                .voxels_fn(R4, |_| block::from_color!(1.0, 0.0, 0.0, 1.0))
1220                .unwrap()
1221                .build_into(universe);
1222            let base_block = Block::from(
1223                universe.insert_anonymous(block::BlockDef::new(universe.read_ticket(), base_block)),
1224            );
1225            let base_block_voxel_count = 64;
1226            let base_ev = base_block.evaluate(universe.read_ticket()).unwrap();
1227            let base_key = EvKey::new(&base_ev);
1228            assert_eq!(base_key, EvKey::new(&base_ev), "test assumption failed");
1229            assert_eq!(
1230                base_ev.cost,
1231                block::Cost {
1232                    components: 1,
1233                    voxels: 0, // zero because of BlockDef cache
1234                    recursion: 0
1235                },
1236                "test assumption failed"
1237            );
1238
1239            let air_src_block = base_block.clone().with_modifier(Composite::new(AIR, Over));
1240            let air_src_ev = air_src_block.evaluate(universe.read_ticket()).unwrap();
1241            assert_eq!(base_key, EvKey::new(&air_src_ev), "src");
1242            assert_eq!(
1243                air_src_ev.cost,
1244                block::Cost {
1245                    components: 3,
1246                    voxels: 0,
1247                    recursion: 1
1248                }
1249            );
1250
1251            // TODO: This assertion should be of equality, not inequality.
1252            // It is currently reversed, not because the short circuit logic is broken,
1253            // but because attribute merges aren't symmetric and the evaluation
1254            // result has the display name `"<air>"`, so the short circuit does not apply.
1255            let air_dst_block = base_block.with_modifier(Composite::new(AIR, Over).reversed());
1256            let air_dst_ev = air_dst_block.evaluate(universe.read_ticket()).unwrap();
1257            assert_ne!(base_key, EvKey::new(&air_dst_ev), "dst");
1258            assert_eq!(
1259                air_dst_ev.cost,
1260                block::Cost {
1261                    components: 3,
1262                    voxels: base_block_voxel_count,
1263                    recursion: 1
1264                }
1265            );
1266        }
1267
1268        /// Test that volumes that do not need to be touched, due to block bounds aren’t.
1269        /// This optimization allows compositions of many small parts to be efficient.
1270        ///
1271        /// Note that this test relies on an accurate [`Cost`] result, but there is no good
1272        /// way to do better.
1273        #[test]
1274        fn unaltered_volume_is_untouched() {
1275            let universe = &mut Universe::new();
1276
1277            // Has voxels with a total volume of 64.
1278            let base_block = Block::builder()
1279                .voxels_fn(R4, |_| block::from_color!(1.0, 0.0, 0.0, 1.0))
1280                .unwrap()
1281                .build_into(universe);
1282            // Has voxels with a total volume of 16.
1283            let smaller_block = Block::builder()
1284                .voxels_fn(R4, |cube| {
1285                    if cube.x == 0 {
1286                        block::from_color!(0.0, 1.0, 0.0, 1.0)
1287                    } else {
1288                        AIR
1289                    }
1290                })
1291                .unwrap()
1292                .build_into(universe);
1293            assert_eq!(
1294                smaller_block.evaluate(universe.read_ticket()).unwrap().voxels().bounds(),
1295                GridAab::from_lower_size([0, 0, 0], [1, 4, 4]),
1296                "test assumption failed"
1297            );
1298            let expected_lower_cost = block::Cost {
1299                components: 3,
1300                // 2 original blocks plus the composition, which should touch only the
1301                // 16 and not the 64.
1302                voxels: 64 + 16 + 16,
1303                recursion: 1,
1304            };
1305
1306            // Test with small src Over large dst
1307            {
1308                let composition_evaluation = base_block
1309                    .clone()
1310                    .with_modifier(Composite::new(smaller_block.clone(), Over))
1311                    .evaluate(universe.read_ticket())
1312                    .unwrap();
1313                assert_eq!(
1314                    composition_evaluation.cost, expected_lower_cost,
1315                    "small Over large"
1316                );
1317            }
1318
1319            // Test with large src Out small dst
1320            {
1321                let composition_evaluation = smaller_block
1322                    .clone()
1323                    .with_modifier(Composite::new(base_block, Out))
1324                    .evaluate(universe.read_ticket())
1325                    .unwrap();
1326                assert_eq!(
1327                    composition_evaluation.cost, expected_lower_cost,
1328                    "large Out small"
1329                );
1330            }
1331        }
1332    }
1333}