1use crate::coupled_field::{CoupledField, CoupledFieldError, CoupledScalar};
27use crate::math::Fix128;
28use crate::sim_field::ScalarField3D;
29use crate::sim_modifier::PhysicsModifier;
30use crate::sim_modifier::{observe_max, observe_sum, StateReader, StateWriter};
31use crate::world_participant::{
32 ObservationSink, Participant, ParticipantFault, ParticipantKind, StateError, SubstepCtx,
33};
34
35#[cfg(not(feature = "std"))]
36use alloc::vec::Vec;
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44#[repr(u8)]
45pub enum Phase {
46 Solid = 0,
48 Liquid = 1,
50 Gas = 2,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq)]
60pub struct PhaseChangeConfig {
61 pub melt_temperature: f32,
63 pub boil_temperature: f32,
65 pub latent_heat_fusion: f32,
69 pub latent_heat_vaporization: f32,
71 pub diffusion_rate: f32,
73 pub ambient_temperature: f32,
75 pub cooling_rate: f32,
77 pub liquid_flow_speed: f32,
81 pub gas_expansion_rate: f32,
83 pub gas_dissipation_rate: f32,
85 pub max_offset: f32,
87}
88
89impl Default for PhaseChangeConfig {
90 fn default() -> Self {
91 Self {
92 melt_temperature: 200.0,
93 boil_temperature: 500.0,
94 latent_heat_fusion: 50.0,
95 latent_heat_vaporization: 100.0,
96 diffusion_rate: 0.5,
97 ambient_temperature: 20.0,
98 cooling_rate: 0.1,
99 liquid_flow_speed: 1.0,
100 gas_expansion_rate: 0.5,
101 gas_dissipation_rate: 0.3,
102 max_offset: 3.0,
103 }
104 }
105}
106
107pub struct PhaseChangeModifier {
113 pub config: PhaseChangeConfig,
115 pub temperature: ScalarField3D,
117 pub phase: ScalarField3D,
119 pub latent_heat: ScalarField3D,
121 pub sdf_offset: ScalarField3D,
123 pub enabled: bool,
125}
126
127impl PhaseChangeModifier {
128 #[must_use]
130 pub fn new(
131 config: PhaseChangeConfig,
132 resolution: usize,
133 min: (f32, f32, f32),
134 max: (f32, f32, f32),
135 ) -> Self {
136 Self {
137 config,
138 temperature: ScalarField3D::new_filled(
139 resolution,
140 resolution,
141 resolution,
142 min,
143 max,
144 config.ambient_temperature,
145 ),
146 phase: ScalarField3D::new(resolution, resolution, resolution, min, max),
147 latent_heat: ScalarField3D::new(resolution, resolution, resolution, min, max),
148 sdf_offset: ScalarField3D::new(resolution, resolution, resolution, min, max),
149 enabled: true,
150 }
151 }
152
153 pub fn apply_heat_at(&mut self, x: f32, y: f32, z: f32, amount: f32, radius: f32) {
155 self.temperature.splat(x, y, z, amount, radius);
156 }
157
158 #[must_use]
160 pub fn temperature_at(&self, x: f32, y: f32, z: f32) -> f32 {
161 self.temperature.sample(x, y, z)
162 }
163
164 #[must_use]
166 pub fn phase_at(&self, x: f32, y: f32, z: f32) -> Phase {
167 let v = self.phase.sample(x, y, z);
168 if v < 0.5 {
169 Phase::Solid
170 } else if v < 1.5 {
171 Phase::Liquid
172 } else {
173 Phase::Gas
174 }
175 }
176
177 fn process_transitions(&mut self) {
189 let melt_t = self.config.melt_temperature;
190 let boil_t = self.config.boil_temperature;
191 let lh_fusion = self.config.latent_heat_fusion.max(0.0);
192 let lh_vaporization = self.config.latent_heat_vaporization.max(0.0);
193 let lh_total = lh_fusion + lh_vaporization;
194
195 let n = self.temperature.cell_count();
196 for i in 0..n {
197 let mut temp = self.temperature.data[i];
198 let mut phase_val = self.phase.data[i];
199 let mut lh = self.latent_heat.data[i];
200
201 if phase_val < 0.5 && temp > melt_t {
203 let absorbed = (temp - melt_t).min(lh_fusion - lh).max(0.0);
204 temp -= absorbed;
205 lh += absorbed;
206 if lh >= lh_fusion {
207 phase_val = 1.0;
208 lh = lh_fusion;
209 }
210 }
211
212 if (0.5..1.5).contains(&phase_val) && temp > boil_t {
214 let absorbed = (temp - boil_t).min(lh_total - lh).max(0.0);
215 temp -= absorbed;
216 lh += absorbed;
217 if lh >= lh_total {
218 phase_val = 2.0;
219 lh = lh_total;
220 }
221 }
222
223 if phase_val >= 1.5 && temp < boil_t && lh > lh_fusion {
225 let released = (boil_t - temp).min(lh - lh_fusion);
226 temp += released;
227 lh -= released;
228 if lh <= lh_fusion {
229 phase_val = 1.0;
230 lh = lh_fusion;
231 }
232 }
233
234 if (0.5..1.5).contains(&phase_val) {
238 if temp < boil_t && lh > lh_fusion {
239 let released = (boil_t - temp).min(lh - lh_fusion);
240 temp += released;
241 lh -= released;
242 }
243 if temp < melt_t && lh > 0.0 {
244 let released = (melt_t - temp).min(lh);
245 temp += released;
246 lh -= released;
247 }
248 if temp < melt_t && lh <= 0.0 {
249 phase_val = 0.0;
250 lh = 0.0;
251 }
252 }
253
254 if phase_val < 0.5 && temp < melt_t && lh > 0.0 {
256 let released = (melt_t - temp).min(lh);
257 temp += released;
258 lh -= released;
259 }
260
261 self.temperature.data[i] = temp;
262 self.phase.data[i] = phase_val;
263 self.latent_heat.data[i] = lh;
264 }
265 }
266
267 fn accumulate_offsets(&mut self, dt: f32) {
269 let liquid_flow = self.config.liquid_flow_speed;
270 let gas_expand = self.config.gas_expansion_rate;
271 let gas_dissipate = self.config.gas_dissipation_rate;
272 let max_offset = self.config.max_offset;
273
274 let n = self.sdf_offset.cell_count();
275 for i in 0..n {
276 let phase_val = self.phase.data[i];
277
278 if phase_val >= 1.5 {
279 self.sdf_offset.data[i] = (gas_expand + gas_dissipate)
281 .mul_add(dt, self.sdf_offset.data[i])
282 .min(max_offset);
283 } else if phase_val >= 0.5 {
284 self.sdf_offset.data[i] = (liquid_flow * 0.1)
286 .mul_add(dt, self.sdf_offset.data[i])
287 .min(max_offset);
288 }
289 }
291
292 if liquid_flow > 0.0 {
297 let fraction = (liquid_flow * dt).min(1.0);
298 let nx = self.sdf_offset.nx;
299 let ny = self.sdf_offset.ny;
300 let nz = self.sdf_offset.nz;
301 for iz in 0..nz {
302 for ix in 0..nx {
303 for iy in 1..ny {
304 let idx = self.phase.index(ix, iy, iz);
305 let below_idx = self.phase.index(ix, iy - 1, iz);
306 if !(0.5..1.5).contains(&self.phase.data[idx]) {
307 continue;
308 }
309 let source = self.sdf_offset.data[idx];
310 if source <= 0.0 {
311 continue;
312 }
313 let room = (max_offset - self.sdf_offset.data[below_idx]).max(0.0);
314 let transfer = (source * fraction).min(room);
315 if transfer > 0.0 {
316 self.sdf_offset.data[below_idx] += transfer;
317 self.sdf_offset.data[idx] = source - transfer;
318 }
319 }
320 }
321 }
322 }
323 }
324}
325
326impl PhysicsModifier for PhaseChangeModifier {
327 #[inline]
328 fn modify_distance(&self, x: f32, y: f32, z: f32, original_dist: f32) -> f32 {
329 if !self.enabled {
330 return original_dist;
331 }
332
333 let offset = self.sdf_offset.sample(x, y, z);
334 original_dist + offset
335 }
336
337 fn update(&mut self, dt: f32) {
338 if !self.enabled {
339 return;
340 }
341
342 self.temperature.diffuse(dt, self.config.diffusion_rate);
344
345 self.temperature.decay_toward(
347 self.config.ambient_temperature,
348 self.config.cooling_rate,
349 dt,
350 );
351
352 self.process_transitions();
354
355 self.accumulate_offsets(dt);
357 }
358
359 fn name(&self) -> &'static str {
360 "phase_change"
361 }
362
363 fn is_active(&self) -> bool {
364 self.enabled
365 }
366}
367
368impl CoupledScalar for PhaseChangeModifier {
379 fn coupled_name(&self) -> &'static str {
380 "temperature"
381 }
382
383 fn coupled_channel(&self) -> Result<CoupledField, CoupledFieldError> {
384 CoupledField::try_matching(&self.temperature)
385 }
386
387 fn publish(&self, out: &mut CoupledField) -> Result<(), CoupledFieldError> {
388 out.copy_from_f32(&self.temperature)
389 }
390
391 fn adopt(&mut self, src: &CoupledField) -> Result<(), CoupledFieldError> {
392 src.write_to_f32(&mut self.temperature)
393 }
394
395 fn check_adopt(&self, src: &CoupledField) -> Result<(), CoupledFieldError> {
396 src.require_same_grid_f32(&self.temperature)
397 }
398}
399
400impl PhaseChangeModifier {
405 pub const PARTICIPANT_KIND: ParticipantKind =
408 ParticipantKind::new(u32::from_be_bytes(*b"PHAS"));
409
410 fn decode_state(bytes: &[u8]) -> Result<Self, StateError> {
411 let mut r = StateReader::new(bytes)?;
412 let config = PhaseChangeConfig {
413 melt_temperature: r.f32()?,
414 boil_temperature: r.f32()?,
415 latent_heat_fusion: r.f32()?,
416 latent_heat_vaporization: r.f32()?,
417 diffusion_rate: r.f32()?,
418 ambient_temperature: r.f32()?,
419 cooling_rate: r.f32()?,
420 liquid_flow_speed: r.f32()?,
421 gas_expansion_rate: r.f32()?,
422 gas_dissipation_rate: r.f32()?,
423 max_offset: r.f32()?,
424 };
425 let enabled = r.bool()?;
426 let temperature = r.field()?;
427 let phase = r.field()?;
428 let latent_heat = r.field()?;
429 let sdf_offset = r.field()?;
430 r.finish()?;
431 Ok(Self {
432 config,
433 temperature,
434 phase,
435 latent_heat,
436 sdf_offset,
437 enabled,
438 })
439 }
440}
441
442impl Participant for PhaseChangeModifier {
450 fn kind(&self) -> ParticipantKind {
451 Self::PARTICIPANT_KIND
452 }
453
454 fn substep(&mut self, _ctx: &mut SubstepCtx<'_>, h: Fix128) -> Result<(), ParticipantFault> {
455 PhysicsModifier::update(self, h.to_f32());
456 Ok(())
457 }
458
459 fn observe(&self, out: &mut ObservationSink) {
460 observe_max(out, 0, &self.temperature);
461 let fluid = self.phase.data.iter().filter(|&&p| p >= 0.5).count();
462 out.push(1, Fix128::from_int(fluid as i64));
463 observe_sum(out, 2, &self.sdf_offset);
464 }
465
466 fn write_state(&self, out: &mut Vec<u8>) {
467 let mut w = StateWriter::new(out);
468 let c = &self.config;
469 for v in [
470 c.melt_temperature,
471 c.boil_temperature,
472 c.latent_heat_fusion,
473 c.latent_heat_vaporization,
474 c.diffusion_rate,
475 c.ambient_temperature,
476 c.cooling_rate,
477 c.liquid_flow_speed,
478 c.gas_expansion_rate,
479 c.gas_dissipation_rate,
480 c.max_offset,
481 ] {
482 w.f32(v);
483 }
484 w.bool(self.enabled);
485 w.field(&self.temperature);
486 w.field(&self.phase);
487 w.field(&self.latent_heat);
488 w.field(&self.sdf_offset);
489 }
490
491 fn check_state(&self, bytes: &[u8]) -> Result<(), StateError> {
492 Self::decode_state(bytes).map(drop)
493 }
494
495 fn read_state(&mut self, bytes: &[u8]) {
496 match Self::decode_state(bytes) {
497 Ok(state) => *self = state,
498 Err(e) => panic!("read_state called with a payload check_state refuses: {e:?}"),
499 }
500 }
501}
502
503#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_phase_change_no_heat() {
513 let config = PhaseChangeConfig::default();
514 let modifier = PhaseChangeModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
515
516 let d = modifier.modify_distance(0.0, 0.0, 0.0, -1.0);
518 assert!(
519 (d - (-1.0)).abs() < 0.01,
520 "No heat should not change SDF, got {d}"
521 );
522 assert_eq!(modifier.phase_at(0.0, 0.0, 0.0), Phase::Solid);
523 }
524
525 #[test]
526 fn test_melting_transition() {
527 let config = PhaseChangeConfig {
528 melt_temperature: 100.0,
529 latent_heat_fusion: 10.0,
530 ambient_temperature: 20.0,
531 cooling_rate: 0.0, diffusion_rate: 0.0, ..Default::default()
534 };
535 let mut modifier = PhaseChangeModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
536
537 modifier.apply_heat_at(0.0, 0.0, 0.0, 2000.0, 1.5);
539
540 for _ in 0..100 {
542 modifier.update(0.016);
543 }
544
545 let phase = modifier.phase_at(0.0, 0.0, 0.0);
546 assert!(
547 phase == Phase::Liquid || phase == Phase::Gas,
548 "High heat should melt material, got {phase:?}"
549 );
550 }
551
552 #[test]
553 fn test_vaporization_transition() {
554 let config = PhaseChangeConfig {
555 melt_temperature: 50.0,
556 boil_temperature: 100.0,
557 latent_heat_fusion: 1.0,
558 latent_heat_vaporization: 1.0,
559 ambient_temperature: 20.0,
560 cooling_rate: 0.0,
561 diffusion_rate: 0.0,
562 ..Default::default()
563 };
564 let mut modifier = PhaseChangeModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
565
566 modifier.apply_heat_at(0.0, 0.0, 0.0, 5000.0, 1.5);
568
569 for _ in 0..200 {
570 modifier.update(0.016);
571 }
572
573 let phase = modifier.phase_at(0.0, 0.0, 0.0);
574 assert_eq!(
575 phase,
576 Phase::Gas,
577 "Extreme heat should vaporize, got {phase:?}"
578 );
579
580 let offset = modifier.sdf_offset.sample(0.0, 0.0, 0.0);
582 assert!(offset > 0.0, "Gas should add SDF offset, got {offset}");
583 }
584
585 #[test]
586 fn test_solidification() {
587 let config = PhaseChangeConfig {
588 melt_temperature: 50.0,
589 latent_heat_fusion: 1.0,
590 ambient_temperature: 20.0,
591 cooling_rate: 0.0,
592 diffusion_rate: 0.0,
593 ..Default::default()
594 };
595 let mut modifier = PhaseChangeModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
596
597 modifier.apply_heat_at(0.0, 0.0, 0.0, 2000.0, 1.5);
599 for _ in 0..50 {
600 modifier.update(0.016);
601 }
602
603 let phase_hot = modifier.phase_at(0.0, 0.0, 0.0);
605 assert!(
606 phase_hot == Phase::Liquid || phase_hot == Phase::Gas,
607 "Should be liquid/gas after heating, got {phase_hot:?}"
608 );
609
610 modifier.config.cooling_rate = 5.0;
612 for _ in 0..500 {
613 modifier.update(0.016);
614 }
615
616 let phase_cold = modifier.phase_at(0.0, 0.0, 0.0);
617 assert_eq!(
618 phase_cold,
619 Phase::Solid,
620 "Should solidify after cooling, got {phase_cold:?}"
621 );
622 }
623
624 fn still_config() -> PhaseChangeConfig {
625 PhaseChangeConfig {
626 melt_temperature: 200.0,
627 boil_temperature: 500.0,
628 latent_heat_fusion: 50.0,
629 latent_heat_vaporization: 100.0,
630 diffusion_rate: 0.0,
631 ambient_temperature: 20.0,
632 cooling_rate: 0.0,
633 liquid_flow_speed: 0.0,
634 gas_expansion_rate: 0.5,
635 gas_dissipation_rate: 0.25,
636 max_offset: 3.0,
637 }
638 }
639
640 #[test]
644 fn gas_condenses_to_liquid_closed_form() {
645 let mut m = PhaseChangeModifier::new(still_config(), 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
646 m.phase.data[0] = 2.0;
647 m.latent_heat.data[0] = 150.0;
648 m.temperature.data[0] = 400.0;
649 m.process_transitions();
650 assert_eq!(m.phase.data[0], 1.0);
651 assert_eq!(m.temperature.data[0], 500.0);
652 assert_eq!(m.latent_heat.data[0], 50.0);
653 }
654
655 #[test]
659 fn gas_offset_grows_and_is_capped() {
660 let mut m = PhaseChangeModifier::new(still_config(), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
661 m.phase.data[0] = 2.0;
662 m.accumulate_offsets(0.5);
663 assert_eq!(m.sdf_offset.data[0], 0.375);
664 assert_eq!(m.sdf_offset.data[1], 0.0);
665 m.accumulate_offsets(100.0);
666 assert_eq!(m.sdf_offset.data[0], 3.0);
667 }
668
669 #[test]
675 fn liquid_flows_down_with_room_and_source() {
676 let mut m = PhaseChangeModifier::new(
677 PhaseChangeConfig {
678 liquid_flow_speed: 1.0,
679 ..still_config()
680 },
681 2,
682 (0.0, 0.0, 0.0),
683 (1.0, 1.0, 1.0),
684 );
685 m.phase.data.fill(1.0);
686 let full_below = m.sdf_offset.index(1, 0, 0);
687 let full_above = m.sdf_offset.index(1, 1, 0);
688 m.sdf_offset.data[full_below] = 3.0;
689 let neg_below = m.sdf_offset.index(0, 0, 1);
690 let neg_above = m.sdf_offset.index(0, 1, 1);
691 m.sdf_offset.data[neg_above] = -1.0;
692 m.accumulate_offsets(0.5);
693 let below = m.sdf_offset.index(0, 0, 0);
694 let above = m.sdf_offset.index(0, 1, 0);
695 assert!((m.sdf_offset.data[below] - 0.075).abs() < 1e-6);
696 assert!((m.sdf_offset.data[above] - 0.025).abs() < 1e-6);
697 assert_eq!(m.sdf_offset.data[full_below], 3.0);
698 assert!((m.sdf_offset.data[full_above] - 0.05).abs() < 1e-6);
699 assert!((m.sdf_offset.data[neg_above] + 0.95).abs() < 1e-6);
700 assert!((m.sdf_offset.data[neg_below] - 0.05).abs() < 1e-6);
701 }
702
703 #[test]
706 fn disabled_phase_change_is_inert() {
707 let mut m = PhaseChangeModifier::new(still_config(), 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
708 m.sdf_offset.data[0] = 0.5;
709 m.temperature.data[0] = 900.0;
710 m.enabled = false;
711 assert!(!m.is_active());
712 assert_eq!(m.modify_distance(0.5, 0.5, 0.5, 1.0), 1.0);
713 m.update(1.0);
714 assert_eq!(m.temperature.data[0], 900.0);
715 assert_eq!(m.phase.data[0], 0.0);
716 }
717
718 fn sample_modifier() -> PhaseChangeModifier {
719 let mut m = PhaseChangeModifier::new(
720 PhaseChangeConfig {
721 melt_temperature: 150.0,
722 boil_temperature: 450.0,
723 latent_heat_fusion: 40.0,
724 latent_heat_vaporization: 80.0,
725 diffusion_rate: 0.125,
726 ambient_temperature: 22.5,
727 cooling_rate: 0.25,
728 liquid_flow_speed: 0.5,
729 gas_expansion_rate: 0.375,
730 gas_dissipation_rate: 0.625,
731 max_offset: 2.5,
732 },
733 2,
734 (-1.0, -2.0, -3.0),
735 (1.0, 2.0, 3.0),
736 );
737 for (i, v) in m.temperature.data.iter_mut().enumerate() {
738 *v = 100.0 + i as f32;
739 }
740 m.phase.data[1] = 1.0;
741 m.phase.data[2] = 2.0;
742 m.latent_heat.data[1] = 40.0;
743 m.sdf_offset.data[2] = 0.75;
744 m.enabled = false;
745 m
746 }
747
748 #[test]
755 fn participant_state_round_trip_and_observations() {
756 assert_eq!(
757 PhaseChangeModifier::PARTICIPANT_KIND,
758 ParticipantKind::new(0x5048_4153)
759 );
760 let src = sample_modifier();
761 assert_eq!(
762 Participant::kind(&src),
763 PhaseChangeModifier::PARTICIPANT_KIND
764 );
765 let mut bytes = Vec::new();
766 src.write_state(&mut bytes);
767 assert_eq!(src.check_state(&bytes), Ok(()));
768 let mut dst = PhaseChangeModifier::new(
769 PhaseChangeConfig::default(),
770 1,
771 (0.0, 0.0, 0.0),
772 (1.0, 1.0, 1.0),
773 );
774 dst.read_state(&bytes);
775 assert_eq!(dst.config, src.config);
776 assert!(!dst.enabled);
777 assert_eq!(dst.temperature.data, src.temperature.data);
778 assert_eq!(dst.phase.data, src.phase.data);
779 assert_eq!(dst.latent_heat.data, src.latent_heat.data);
780 assert_eq!(dst.sdf_offset.data, src.sdf_offset.data);
781 let mut again = Vec::new();
782 dst.write_state(&mut again);
783 assert_eq!(again, bytes);
784
785 let mut sink = ObservationSink::new();
786 dst.observe(&mut sink);
787 assert_eq!(
788 sink.values(),
789 &[
790 (0, Fix128::from_int(107)),
791 (1, Fix128::from_int(2)),
792 (2, Fix128::from_ratio(3, 4))
793 ]
794 );
795 }
796
797 #[test]
801 fn participant_state_refusals() {
802 let src = sample_modifier();
803 let mut bytes = Vec::new();
804 src.write_state(&mut bytes);
805 assert_eq!(bytes[48], 0);
806 let mut bad = bytes.clone();
807 bad[48] = 5;
808 assert_eq!(src.check_state(&bad), Err(StateError::InvalidValue));
809 assert!(matches!(
810 src.check_state(&bytes[..bytes.len() - 1]),
811 Err(StateError::Length { .. })
812 ));
813 let mut long = bytes;
814 long.push(0);
815 assert!(matches!(
816 src.check_state(&long),
817 Err(StateError::Length { .. })
818 ));
819 }
820
821 #[test]
822 #[should_panic(expected = "read_state called with a payload check_state refuses")]
823 fn participant_read_state_panics_on_a_refused_payload() {
824 let mut m = sample_modifier();
825 m.read_state(&[1, 0, 0]);
826 }
827
828 #[test]
833 fn participant_substep_in_a_world() {
834 let mut m = PhaseChangeModifier::new(still_config(), 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
835 m.temperature.data[0] = 220.0;
836 let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig {
837 substeps: 1,
838 ..Default::default()
839 });
840 world.add_participant(Box::new(m)).expect("register");
841 world.step(Fix128::from_ratio(1, 4));
842 let Some(crate::world_participant::Observed::Exact(sink)) = world.observe_participant(0)
843 else {
844 panic!("observation");
845 };
846 assert_eq!(
847 sink.values(),
848 &[
849 (0, Fix128::from_int(200)),
850 (1, Fix128::ZERO),
851 (2, Fix128::ZERO)
852 ]
853 );
854 }
855}