all_is_cubes/block/modifier/
composite.rs

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