1use crate::coupled_field::{CoupledField, CoupledFieldError, CoupledScalar};
17use crate::math::Fix128;
18use crate::sim_field::ScalarField3D;
19use crate::sim_modifier::PhysicsModifier;
20use crate::sim_modifier::{observe_max, observe_sum, StateReader, StateWriter};
21use crate::world_participant::{
22 ObservationSink, Participant, ParticipantFault, ParticipantKind, StateError, SubstepCtx,
23};
24
25#[cfg(not(feature = "std"))]
26use alloc::vec::Vec;
27
28#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct ThermalConfig {
35 pub diffusion_rate: f32,
37 pub ambient_temperature: f32,
39 pub cooling_rate: f32,
41 pub melt_temperature: f32,
43 pub melt_rate: f32,
45 pub droop_strength: f32,
47 pub expansion_coefficient: f32,
49 pub freeze_temperature: f32,
51 pub freeze_rate: f32,
53}
54
55impl Default for ThermalConfig {
56 fn default() -> Self {
57 Self {
58 diffusion_rate: 0.5,
59 ambient_temperature: 20.0,
60 cooling_rate: 0.1,
61 melt_temperature: 200.0,
62 melt_rate: 0.02,
63 droop_strength: 0.5,
64 expansion_coefficient: 0.0001,
65 freeze_temperature: -10.0,
66 freeze_rate: 0.01,
67 }
68 }
69}
70
71#[derive(Clone, Copy, Debug, PartialEq)]
73pub enum HeatSource {
74 Point {
76 x: f32,
78 y: f32,
80 z: f32,
82 power: f32,
85 radius: f32,
87 },
88 Volume {
90 min: (f32, f32, f32),
92 max: (f32, f32, f32),
94 power: f32,
96 },
97}
98
99pub struct ThermalModifier {
108 pub config: ThermalConfig,
110 pub temperature: ScalarField3D,
112 pub melt_accumulator: ScalarField3D,
114 pub heat_sources: Vec<HeatSource>,
116 pub enabled: bool,
118}
119
120impl ThermalModifier {
121 #[must_use]
123 pub fn new(
124 config: ThermalConfig,
125 resolution: usize,
126 min: (f32, f32, f32),
127 max: (f32, f32, f32),
128 ) -> Self {
129 Self {
130 config,
131 temperature: ScalarField3D::new_filled(
132 resolution,
133 resolution,
134 resolution,
135 min,
136 max,
137 config.ambient_temperature,
138 ),
139 melt_accumulator: ScalarField3D::new(resolution, resolution, resolution, min, max),
140 heat_sources: Vec::new(),
141 enabled: true,
142 }
143 }
144
145 pub fn add_heat_point(&mut self, x: f32, y: f32, z: f32, power: f32, radius: f32) {
147 self.heat_sources.push(HeatSource::Point {
148 x,
149 y,
150 z,
151 power,
152 radius,
153 });
154 }
155
156 pub fn apply_heat_at(&mut self, x: f32, y: f32, z: f32, amount: f32, radius: f32) {
158 self.temperature.splat(x, y, z, amount, radius);
159 }
160
161 #[must_use]
163 pub fn temperature_at(&self, x: f32, y: f32, z: f32) -> f32 {
164 self.temperature.sample(x, y, z)
165 }
166
167 fn apply_heat_sources(&mut self, dt: f32) {
169 for source in &self.heat_sources {
170 match *source {
171 HeatSource::Point {
172 x,
173 y,
174 z,
175 power,
176 radius,
177 } => {
178 self.temperature.splat(x, y, z, power * dt, radius);
179 }
180 HeatSource::Volume { min, max, power } => {
181 let nx = self.temperature.nx;
183 let ny = self.temperature.ny;
184 let nz = self.temperature.nz;
185 for iz in 0..nz {
186 for iy in 0..ny {
187 for ix in 0..nx {
188 let wx = self.temperature.min.0
189 + ix as f32 * (self.temperature.max.0 - self.temperature.min.0)
190 / (nx - 1).max(1) as f32;
191 let wy = self.temperature.min.1
192 + iy as f32 * (self.temperature.max.1 - self.temperature.min.1)
193 / (ny - 1).max(1) as f32;
194 let wz = self.temperature.min.2
195 + iz as f32 * (self.temperature.max.2 - self.temperature.min.2)
196 / (nz - 1).max(1) as f32;
197
198 if wx >= min.0
199 && wx <= max.0
200 && wy >= min.1
201 && wy <= max.1
202 && wz >= min.2
203 && wz <= max.2
204 {
205 self.temperature.add(ix, iy, iz, power * dt);
206 }
207 }
208 }
209 }
210 }
211 }
212 }
213 }
214
215 fn accumulate_melt(&mut self, dt: f32) {
217 let melt_temp = self.config.melt_temperature;
218 let melt_rate = self.config.melt_rate;
219 let droop = self.config.droop_strength;
220
221 let n = self.temperature.cell_count();
222 for i in 0..n {
223 let temp = self.temperature.data[i];
224 if temp > melt_temp {
225 let excess = temp - melt_temp;
226 self.melt_accumulator.data[i] += excess * melt_rate * dt;
228 }
229 }
230
231 if droop > 0.0 {
233 let nx = self.melt_accumulator.nx;
234 let ny = self.melt_accumulator.ny;
235 let nz = self.melt_accumulator.nz;
236 for iz in 0..nz {
238 for ix in 0..nx {
239 for iy in 1..ny {
240 let above_idx = self.melt_accumulator.index(ix, iy, iz);
241 let below_idx = self.melt_accumulator.index(ix, iy - 1, iz);
242 let transfer = self.melt_accumulator.data[above_idx] * droop * dt;
243 if transfer > 0.0 {
244 self.melt_accumulator.data[below_idx] += transfer;
245 self.melt_accumulator.data[above_idx] -= transfer * 0.5;
246 }
247 }
248 }
249 }
250 }
251 }
252}
253
254impl PhysicsModifier for ThermalModifier {
255 #[inline]
256 fn modify_distance(&self, x: f32, y: f32, z: f32, original_dist: f32) -> f32 {
257 if !self.enabled {
258 return original_dist;
259 }
260
261 let temp = self.temperature.sample(x, y, z);
262 let mut d = original_dist;
263
264 let melt = self.melt_accumulator.sample(x, y, z);
266 if melt > 0.0 {
267 d += melt; }
269
270 let temp_delta = temp - self.config.ambient_temperature;
272 if temp_delta > 0.0 && self.config.expansion_coefficient > 0.0 {
273 d -= temp_delta * self.config.expansion_coefficient;
274 }
275
276 if temp < self.config.freeze_temperature && self.config.freeze_rate > 0.0 {
278 let cold = self.config.freeze_temperature - temp;
279 let freeze_offset = crate::det_math::min(cold * self.config.freeze_rate, 1.0);
280 d -= freeze_offset;
281 }
282
283 d
284 }
285
286 fn update(&mut self, dt: f32) {
287 if !self.enabled {
288 return;
289 }
290
291 self.apply_heat_sources(dt);
293
294 self.temperature.diffuse(dt, self.config.diffusion_rate);
296
297 self.temperature.decay_toward(
299 self.config.ambient_temperature,
300 self.config.cooling_rate,
301 dt,
302 );
303
304 self.accumulate_melt(dt);
306 }
307
308 fn name(&self) -> &'static str {
309 "thermal"
310 }
311
312 fn is_active(&self) -> bool {
313 self.enabled
314 }
315}
316
317impl CoupledScalar for ThermalModifier {
332 fn coupled_name(&self) -> &'static str {
333 "temperature"
334 }
335
336 fn coupled_channel(&self) -> Result<CoupledField, CoupledFieldError> {
337 CoupledField::try_matching(&self.temperature)
338 }
339
340 fn publish(&self, out: &mut CoupledField) -> Result<(), CoupledFieldError> {
341 out.copy_from_f32(&self.temperature)
342 }
343
344 fn adopt(&mut self, src: &CoupledField) -> Result<(), CoupledFieldError> {
345 src.write_to_f32(&mut self.temperature)
346 }
347
348 fn check_adopt(&self, src: &CoupledField) -> Result<(), CoupledFieldError> {
349 src.require_same_grid_f32(&self.temperature)
350 }
351}
352
353impl ThermalModifier {
358 pub const PARTICIPANT_KIND: ParticipantKind =
361 ParticipantKind::new(u32::from_be_bytes(*b"THRM"));
362
363 fn decode_state(bytes: &[u8]) -> Result<Self, StateError> {
364 let mut r = StateReader::new(bytes)?;
365 let config = ThermalConfig {
366 diffusion_rate: r.f32()?,
367 ambient_temperature: r.f32()?,
368 cooling_rate: r.f32()?,
369 melt_temperature: r.f32()?,
370 melt_rate: r.f32()?,
371 droop_strength: r.f32()?,
372 expansion_coefficient: r.f32()?,
373 freeze_temperature: r.f32()?,
374 freeze_rate: r.f32()?,
375 };
376 let enabled = r.bool()?;
377 let temperature = r.field()?;
378 let melt_accumulator = r.field()?;
379 let n = r.count(1 + 5 * 4)?;
380 let mut heat_sources = Vec::with_capacity(n);
381 for _ in 0..n {
382 heat_sources.push(match r.u8()? {
383 0 => HeatSource::Point {
384 x: r.f32()?,
385 y: r.f32()?,
386 z: r.f32()?,
387 power: r.f32()?,
388 radius: r.f32()?,
389 },
390 1 => HeatSource::Volume {
391 min: r.vec3()?,
392 max: r.vec3()?,
393 power: r.f32()?,
394 },
395 _ => return Err(StateError::InvalidValue),
396 });
397 }
398 r.finish()?;
399 Ok(Self {
400 config,
401 temperature,
402 melt_accumulator,
403 heat_sources,
404 enabled,
405 })
406 }
407}
408
409impl Participant for ThermalModifier {
416 fn kind(&self) -> ParticipantKind {
417 Self::PARTICIPANT_KIND
418 }
419
420 fn substep(&mut self, _ctx: &mut SubstepCtx<'_>, h: Fix128) -> Result<(), ParticipantFault> {
421 PhysicsModifier::update(self, h.to_f32());
422 Ok(())
423 }
424
425 fn observe(&self, out: &mut ObservationSink) {
426 observe_max(out, 0, &self.temperature);
427 observe_sum(out, 1, &self.melt_accumulator);
428 }
429
430 fn write_state(&self, out: &mut Vec<u8>) {
431 let mut w = StateWriter::new(out);
432 let c = &self.config;
433 for v in [
434 c.diffusion_rate,
435 c.ambient_temperature,
436 c.cooling_rate,
437 c.melt_temperature,
438 c.melt_rate,
439 c.droop_strength,
440 c.expansion_coefficient,
441 c.freeze_temperature,
442 c.freeze_rate,
443 ] {
444 w.f32(v);
445 }
446 w.bool(self.enabled);
447 w.field(&self.temperature);
448 w.field(&self.melt_accumulator);
449 w.usize(self.heat_sources.len());
450 for s in &self.heat_sources {
451 match *s {
452 HeatSource::Point {
453 x,
454 y,
455 z,
456 power,
457 radius,
458 } => {
459 w.u8(0);
460 for v in [x, y, z, power, radius] {
461 w.f32(v);
462 }
463 }
464 HeatSource::Volume { min, max, power } => {
465 w.u8(1);
466 w.vec3(min);
467 w.vec3(max);
468 w.f32(power);
469 }
470 }
471 }
472 }
473
474 fn check_state(&self, bytes: &[u8]) -> Result<(), StateError> {
475 Self::decode_state(bytes).map(drop)
476 }
477
478 fn read_state(&mut self, bytes: &[u8]) {
479 match Self::decode_state(bytes) {
480 Ok(state) => *self = state,
481 Err(e) => panic!("read_state called with a payload check_state refuses: {e:?}"),
482 }
483 }
484}
485
486#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::sdf_collider::{ClosureSdf, SdfField};
494 use crate::sim_modifier::SingleModifiedSdf;
495
496 fn unit_sphere() -> ClosureSdf {
497 ClosureSdf::new(
498 |x, y, z| z.mul_add(z, x.mul_add(x, y * y)).sqrt() - 1.0,
499 |x, y, z| {
500 let len = z.mul_add(z, x.mul_add(x, y * y)).sqrt();
501 if len < 1e-10 {
502 (0.0, 1.0, 0.0)
503 } else {
504 (x / len, y / len, z / len)
505 }
506 },
507 )
508 }
509
510 #[test]
511 fn test_thermal_no_heat() {
512 let config = ThermalConfig::default();
513 let modifier = ThermalModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
514 let modified = SingleModifiedSdf::new(Box::new(unit_sphere()), modifier);
515
516 let d = modified.distance(2.0, 0.0, 0.0);
518 assert!(
519 (d - 1.0).abs() < 0.1,
520 "No heat should not change SDF, got {d}"
521 );
522 }
523
524 #[test]
525 fn test_thermal_melt() {
526 let config = ThermalConfig {
527 melt_temperature: 100.0,
528 melt_rate: 0.1,
529 ambient_temperature: 20.0,
530 ..Default::default()
531 };
532 let mut modifier = ThermalModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
533
534 modifier.apply_heat_at(0.0, 0.0, 0.0, 2000.0, 1.5);
536
537 for _ in 0..50 {
539 modifier.update(0.016);
540 }
541
542 let melt = modifier.melt_accumulator.sample(0.0, 0.0, 0.0);
544 assert!(melt > 0.0, "Melt should accumulate, got {melt}");
545 }
546
547 #[test]
548 fn test_thermal_expansion() {
549 let config = ThermalConfig {
550 expansion_coefficient: 0.01,
551 ambient_temperature: 20.0,
552 melt_temperature: 10000.0, ..Default::default()
554 };
555 let mut modifier = ThermalModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
556
557 modifier.temperature.data.fill(100.0); let d_before = 1.0f32; let d_after = modifier.modify_distance(2.0, 0.0, 0.0, d_before);
562
563 assert!(
565 d_after < d_before,
566 "Expansion should decrease distance, before={d_before}, after={d_after}"
567 );
568 }
569
570 #[test]
571 fn test_heat_diffusion() {
572 let config = ThermalConfig {
573 diffusion_rate: 1.0,
574 ambient_temperature: 0.0,
575 cooling_rate: 0.0,
576 melt_temperature: 10000.0,
577 ..Default::default()
578 };
579 let mut modifier = ThermalModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
580
581 modifier.temperature.data.fill(0.0);
583
584 modifier.apply_heat_at(0.0, 0.0, 0.0, 500.0, 1.5);
586 let center_before = modifier.temperature_at(0.0, 0.0, 0.0);
587
588 for _ in 0..20 {
590 modifier.update(0.01);
591 }
592
593 let center_after = modifier.temperature_at(0.0, 0.0, 0.0);
594 assert!(
595 center_after < center_before,
596 "Heat should spread, before={center_before}, after={center_after}"
597 );
598 }
599
600 fn still_config() -> ThermalConfig {
601 ThermalConfig {
602 diffusion_rate: 0.0,
603 ambient_temperature: 20.0,
604 cooling_rate: 0.0,
605 melt_temperature: 200.0,
606 melt_rate: 0.0625,
607 droop_strength: 0.0,
608 expansion_coefficient: 0.0,
609 freeze_temperature: -10.0,
610 freeze_rate: 0.0,
611 }
612 }
613
614 #[test]
620 fn volume_and_point_heat_sources() {
621 let mut m = ThermalModifier::new(still_config(), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
622 m.heat_sources.push(HeatSource::Volume {
623 min: (-0.5, -0.5, -0.5),
624 max: (0.5, 1.5, 1.5),
625 power: 8.0,
626 });
627 m.apply_heat_sources(0.25);
628 for iz in 0..2 {
629 for iy in 0..2 {
630 assert_eq!(m.temperature.get(0, iy, iz), 22.0, "(0, {iy}, {iz})");
631 assert_eq!(m.temperature.get(1, iy, iz), 20.0, "(1, {iy}, {iz})");
632 }
633 }
634 let mut p = ThermalModifier::new(still_config(), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
635 p.add_heat_point(1.0, 1.0, 1.0, 4.0, 0.25);
636 p.apply_heat_sources(0.25);
637 assert!(p.temperature.get(1, 1, 1) > 20.0);
638 assert_eq!(p.temperature.get(0, 0, 0), 20.0);
639 }
640
641 #[test]
646 fn melt_and_droop_closed_form() {
647 let mut m = ThermalModifier::new(
648 ThermalConfig {
649 melt_rate: 0.01,
650 droop_strength: 0.5,
651 ..still_config()
652 },
653 2,
654 (0.0, 0.0, 0.0),
655 (1.0, 1.0, 1.0),
656 );
657 let above = m.temperature.index(0, 1, 0);
658 m.temperature.data[above] = 300.0;
659 m.accumulate_melt(1.0);
660 let below = m.melt_accumulator.index(0, 0, 0);
661 assert!((m.melt_accumulator.data[below] - 0.5).abs() < 1e-6);
662 assert!((m.melt_accumulator.data[above] - 0.75).abs() < 1e-6);
663 let other = m.melt_accumulator.index(1, 1, 1);
664 assert_eq!(m.melt_accumulator.data[other], 0.0);
665 }
666
667 #[test]
673 fn modify_distance_melt_expansion_freeze_and_disabled() {
674 let mut m = ThermalModifier::new(
675 ThermalConfig {
676 expansion_coefficient: 0.001,
677 freeze_rate: 0.01,
678 ..still_config()
679 },
680 2,
681 (0.0, 0.0, 0.0),
682 (1.0, 1.0, 1.0),
683 );
684 m.temperature.data.fill(120.0);
685 m.melt_accumulator.data.fill(0.25);
686 assert!((m.modify_distance(0.5, 0.5, 0.5, 1.0) - 1.15).abs() < 1e-5);
687 m.melt_accumulator.data.fill(0.0);
688 m.temperature.data.fill(-60.0);
689 assert!((m.modify_distance(0.5, 0.5, 0.5, 1.0) - 0.5).abs() < 1e-5);
690 m.temperature.data.fill(-210.0);
691 assert!(m.modify_distance(0.5, 0.5, 0.5, 1.0).abs() < 1e-5);
692
693 m.enabled = false;
694 assert!(!m.is_active());
695 assert_eq!(m.modify_distance(0.5, 0.5, 0.5, 1.0), 1.0);
696 m.heat_sources.push(HeatSource::Volume {
697 min: (-1.0, -1.0, -1.0),
698 max: (2.0, 2.0, 2.0),
699 power: 100.0,
700 });
701 m.update(1.0);
702 assert!(m.temperature.data.iter().all(|&t| t == -210.0));
703 }
704
705 fn sample_modifier() -> ThermalModifier {
706 let mut m = ThermalModifier::new(
707 ThermalConfig {
708 diffusion_rate: 0.125,
709 ambient_temperature: 21.5,
710 cooling_rate: 0.25,
711 melt_temperature: 180.0,
712 melt_rate: 0.375,
713 droop_strength: 0.5,
714 expansion_coefficient: 0.0625,
715 freeze_temperature: -4.0,
716 freeze_rate: 0.75,
717 },
718 2,
719 (-1.0, -2.0, -3.0),
720 (1.0, 2.0, 3.0),
721 );
722 for (i, v) in m.temperature.data.iter_mut().enumerate() {
723 *v = 10.0 + i as f32;
724 }
725 m.melt_accumulator.data[2] = 0.5;
726 m.add_heat_point(0.5, 0.25, -0.5, 30.0, 0.75);
727 m.heat_sources.push(HeatSource::Volume {
728 min: (-1.0, -1.0, -1.0),
729 max: (0.0, 0.5, 1.0),
730 power: 12.0,
731 });
732 m.enabled = false;
733 m
734 }
735
736 #[test]
742 fn participant_state_round_trip_and_observations() {
743 assert_eq!(
744 ThermalModifier::PARTICIPANT_KIND,
745 ParticipantKind::new(0x5448_524d)
746 );
747 let src = sample_modifier();
748 assert_eq!(Participant::kind(&src), ThermalModifier::PARTICIPANT_KIND);
749 let mut bytes = Vec::new();
750 src.write_state(&mut bytes);
751 assert_eq!(src.check_state(&bytes), Ok(()));
752 let mut dst = ThermalModifier::new(
753 ThermalConfig::default(),
754 1,
755 (0.0, 0.0, 0.0),
756 (1.0, 1.0, 1.0),
757 );
758 dst.read_state(&bytes);
759 assert_eq!(dst.config, src.config);
760 assert!(!dst.enabled);
761 assert_eq!(dst.temperature.data, src.temperature.data);
762 assert_eq!(dst.melt_accumulator.data, src.melt_accumulator.data);
763 assert_eq!(dst.heat_sources, src.heat_sources);
764 let mut again = Vec::new();
765 dst.write_state(&mut again);
766 assert_eq!(again, bytes);
767
768 let mut sink = ObservationSink::new();
769 dst.observe(&mut sink);
770 assert_eq!(
771 sink.values(),
772 &[(0, Fix128::from_int(17)), (1, Fix128::from_ratio(1, 2))]
773 );
774 }
775
776 #[test]
780 fn participant_state_refusals() {
781 let src = sample_modifier();
782 let mut bytes = Vec::new();
783 src.write_state(&mut bytes);
784 let tag = bytes.len() - 29;
785 assert_eq!(bytes[tag], 1);
786 let mut bad = bytes.clone();
787 bad[tag] = 2;
788 assert_eq!(src.check_state(&bad), Err(StateError::InvalidValue));
789 assert!(matches!(
790 src.check_state(&bytes[..bytes.len() - 1]),
791 Err(StateError::Length { .. })
792 ));
793 let mut long = bytes;
794 long.push(0);
795 assert!(matches!(
796 src.check_state(&long),
797 Err(StateError::Length { .. })
798 ));
799 }
800
801 #[test]
802 #[should_panic(expected = "read_state called with a payload check_state refuses")]
803 fn participant_read_state_panics_on_a_refused_payload() {
804 let mut m = sample_modifier();
805 m.read_state(&[0, 0, 0, 0]);
806 }
807
808 #[test]
813 fn participant_substep_in_a_world() {
814 let mut m = ThermalModifier::new(still_config(), 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
815 m.temperature.data[0] = 300.0;
816 let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig {
817 substeps: 1,
818 ..Default::default()
819 });
820 world.add_participant(Box::new(m)).expect("register");
821 world.step(Fix128::from_ratio(1, 4));
822 let Some(crate::world_participant::Observed::Exact(sink)) = world.observe_participant(0)
823 else {
824 panic!("observation");
825 };
826 assert_eq!(
827 sink.values(),
828 &[(0, Fix128::from_int(300)), (1, Fix128::from_ratio(25, 16))]
829 );
830 }
831}