1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
//! Warp Risk Analysis for 3D Printed Parts (Cooling Shrinkage → Curl / Peel)
//!
//! Phase C4 of the ALICE-Physics completeness project. Given a printed part's
//! **footprint** (build-plate contact area and maximum in-plane dimension),
//! its **material** (shrinkage ratio + Young's modulus) and the print/chamber
//! conditions, estimates the risk of warping — the layer 1 detaching or the
//! whole part curling as the top layers cool and shrink more than the bottom.
//!
//! # Physical model
//!
//! The warping mechanism is a "differential contraction" problem: layers
//! printed at high temperature cool and shrink, but they are constrained by
//! previously-solidified material below them. The result is in-plane residual
//! stress that peaks at the corners of the footprint (largest lever arm).
//!
//! Approximate peak warp force per unit width:
//! `F_warp ≈ E · α · ΔT · h`
//! where `E` = Young's modulus, `α` = linear shrinkage, `ΔT` = temperature
//! difference between extrusion and chamber, `h` = height at which the load
//! acts (proportional to layer thickness).
//!
//! The warping curl radius scales inversely with footprint size, so larger
//! parts warp more. The risk score combines:
//!
//! - `Footprint area (mm²)` — larger area → more integrated stress.
//! - `Footprint max dimension (mm)` — larger span → longer curl lever.
//! - `Material shrinkage ratio` — higher → worse.
//! - `ΔT` between print temperature and chamber temperature.
//!
//! Empirical fit to the case
//! "280×250×5 mm PLA plate on an unheated bed":
//! this scenario returns a `Critical` category (score > 0.75).
//!
//! # References
//!
//! - Wang et al., "A model research for prototype warp deformation in the
//! FDM process", Int. J. Adv. Manuf. Technol. 33 (2007).
//! - PrusaSlicer knowledge base, "Warping".
//! - Bambu Lab X1C manual, chapter on chamber heating for ABS/ASA.
use crate::filament_db::MaterialProperties;
use crate::math::Fix128;
// ============================================================================
// Footprint input
// ============================================================================
/// Geometry of the part's first-layer footprint.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Footprint {
/// Contact area on the build plate (mm²).
pub area_mm2: Fix128,
/// Longest dimension in the XY plane (mm). Used as the "lever arm".
pub max_dimension_mm: Fix128,
}
impl Footprint {
/// Convenience constructor for a rectangular footprint.
#[must_use]
pub fn rectangle(width_mm: Fix128, height_mm: Fix128) -> Self {
let area = width_mm * height_mm;
let max = if width_mm > height_mm {
width_mm
} else {
height_mm
};
Self {
area_mm2: area,
max_dimension_mm: max,
}
}
}
// ============================================================================
// Environmental conditions
// ============================================================================
/// Print & chamber temperature conditions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EnvConditions {
/// Print / extrusion temperature (°C).
pub print_temp_c: Fix128,
/// Chamber (enclosure) temperature (°C). Room temp 20 for open printers.
pub chamber_temp_c: Fix128,
/// Build-plate temperature (°C). PLA typical 60, ABS typical 100.
pub bed_temp_c: Fix128,
}
impl EnvConditions {
/// Open-air PLA typical (bed 60, no enclosure).
#[must_use]
pub fn open_air_pla() -> Self {
Self {
print_temp_c: Fix128::from_int(200),
chamber_temp_c: Fix128::from_int(20),
bed_temp_c: Fix128::from_int(60),
}
}
/// Bambu X1C enclosure ABS/ASA typical.
#[must_use]
pub fn enclosed_abs() -> Self {
Self {
print_temp_c: Fix128::from_int(240),
chamber_temp_c: Fix128::from_int(55),
bed_temp_c: Fix128::from_int(100),
}
}
}
// ============================================================================
// Risk categorization & report
// ============================================================================
/// Coarse risk categorisation for at-a-glance display.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WarpRiskCategory {
/// Score < 0.25 — safe under normal conditions.
Low,
/// 0.25 ≤ score < 0.5 — brim / raft recommended.
Medium,
/// 0.5 ≤ score < 0.75 — enclosure or lower shrinkage material recommended.
High,
/// Score ≥ 0.75 — reduce footprint, use different material, or expect failure.
Critical,
}
/// Warp analysis output.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WarpRiskReport {
/// Normalised risk score in `[0, 1]` (1 = maximum risk observed).
pub score: Fix128,
/// Categorised risk level.
pub category: WarpRiskCategory,
/// Effective in-plane residual force per unit width (N/mm).
/// Useful as an engineering figure alongside the abstract score.
pub warp_force_per_mm: Fix128,
/// Recommended remedy string (const &str, ergonomic for UIs).
pub recommendation: &'static str,
}
// ============================================================================
// Analysis
// ============================================================================
/// Compute the warping risk score.
///
// LIMITATION(COV-AM-035): Score formula (empirical, fit to typical FDM outcomes):
/// Score formula (empirical, fit to typical FDM outcomes):
///
/// `score = clamp01(k · α · E_norm · A_norm · L_norm · ΔT_norm)`
///
/// with:
/// - `α` = shrinkage ratio (dimensionless, 0.002-0.020 typical).
/// - `E_norm` = Young's modulus / 5 GPa (soft materials warp less).
/// - `A_norm` = area / 50 000 mm² (reference: 250×200 print bed).
/// - `L_norm` = max_dim / 300 mm.
/// - `ΔT_norm` = (T_print − T_chamber) / 200 °C (typical PLA=180, ABS=185).
/// - `k` = 500 (empirical fit, calibrated so the documented 280×250 mm cases
/// score `Critical`; `k = 40` would score that PLA plate only 0.066).
#[must_use]
pub fn analyze_warp_risk(
footprint: &Footprint,
material: &MaterialProperties,
env: &EnvConditions,
) -> WarpRiskReport {
let alpha = material.shrinkage_ratio;
let e_gpa = material.youngs_modulus_gpa;
let e_norm = e_gpa / Fix128::from_int(5);
let a_norm = footprint.area_mm2 / Fix128::from_int(50_000);
let l_norm = footprint.max_dimension_mm / Fix128::from_int(300);
let dt = env.print_temp_c - env.chamber_temp_c;
let dt_norm = dt / Fix128::from_int(200);
// Empirical fit constant. Calibrated so the ALICE-Bamboo docs
// "280×250 ABS on open bed" and "280×250 PLA on unheated bed" both fall
// into the Critical (score ≥ 0.75) category, matching lab experience.
let k = Fix128::from_int(500);
let raw = k * alpha * e_norm * a_norm * l_norm * dt_norm;
let score = if raw < Fix128::ZERO {
Fix128::ZERO
} else if raw > Fix128::ONE {
Fix128::ONE
} else {
raw
};
let category = if score < Fix128::from_ratio(25, 100) {
WarpRiskCategory::Low
} else if score < Fix128::from_ratio(50, 100) {
WarpRiskCategory::Medium
} else if score < Fix128::from_ratio(75, 100) {
WarpRiskCategory::High
} else {
WarpRiskCategory::Critical
};
let recommendation = match category {
WarpRiskCategory::Low => "No mitigation required.",
WarpRiskCategory::Medium => "Add a brim (5-10mm) or raft for adhesion insurance.",
WarpRiskCategory::High => "Use an enclosure, heated bed >= 80 C, or switch to lower-shrinkage material (PETG / PLA).",
WarpRiskCategory::Critical => "High failure risk: split into smaller parts, switch material to PLA, or use a heated chamber (>= 50 C).",
};
// LIMITATION(COV-AM-036): Engineering figure: F/mm ≈ E · α · ΔT · h (h taken as 1mm reference)
// Engineering figure: F/mm ≈ E · α · ΔT · h (h taken as 1mm reference)
let e_mpa = e_gpa * Fix128::from_int(1000);
let warp_force_per_mm = e_mpa * alpha * dt;
WarpRiskReport {
score,
category,
warp_force_per_mm,
recommendation,
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn footprint_rectangle_stores_max_dim() {
let f = Footprint::rectangle(Fix128::from_int(100), Fix128::from_int(200));
assert_eq!(f.area_mm2, Fix128::from_int(20_000));
assert_eq!(f.max_dimension_mm, Fix128::from_int(200));
}
#[test]
fn footprint_rectangle_picks_longer_side() {
let f = Footprint::rectangle(Fix128::from_int(150), Fix128::from_int(100));
assert_eq!(f.max_dimension_mm, Fix128::from_int(150));
}
#[test]
fn small_pla_part_is_low_risk() {
// 30×30mm PLA cube on open-air printer — should be Low risk
let f = Footprint::rectangle(Fix128::from_int(30), Fix128::from_int(30));
let report = analyze_warp_risk(
&f,
&MaterialProperties::pla(),
&EnvConditions::open_air_pla(),
);
assert_eq!(report.category, WarpRiskCategory::Low);
assert!(report.score < Fix128::from_ratio(25, 100));
}
#[test]
fn large_abs_part_open_air_is_critical() {
// Large ABS part on open printer — Critical case (documented case)
let f = Footprint::rectangle(Fix128::from_int(280), Fix128::from_int(250));
// Use ABS shrinkage 0.8% and Ambient 20°C
let env = EnvConditions {
print_temp_c: Fix128::from_int(240),
chamber_temp_c: Fix128::from_int(20),
bed_temp_c: Fix128::from_int(90),
};
let report = analyze_warp_risk(&f, &MaterialProperties::abs(), &env);
assert_eq!(report.category, WarpRiskCategory::Critical);
assert!(report.score >= Fix128::from_ratio(75, 100));
}
#[test]
fn enclosure_lowers_abs_risk() {
let f = Footprint::rectangle(Fix128::from_int(200), Fix128::from_int(200));
let open = EnvConditions {
print_temp_c: Fix128::from_int(240),
chamber_temp_c: Fix128::from_int(20),
bed_temp_c: Fix128::from_int(90),
};
let enclosed = EnvConditions::enclosed_abs();
let r_open = analyze_warp_risk(&f, &MaterialProperties::abs(), &open);
let r_enc = analyze_warp_risk(&f, &MaterialProperties::abs(), &enclosed);
assert!(r_enc.score < r_open.score);
}
#[test]
fn pla_lower_risk_than_abs_same_footprint() {
let f = Footprint::rectangle(Fix128::from_int(150), Fix128::from_int(150));
let r_pla = analyze_warp_risk(
&f,
&MaterialProperties::pla(),
&EnvConditions::open_air_pla(),
);
let r_abs = analyze_warp_risk(
&f,
&MaterialProperties::abs(),
&EnvConditions {
print_temp_c: Fix128::from_int(240),
chamber_temp_c: Fix128::from_int(20),
bed_temp_c: Fix128::from_int(90),
},
);
assert!(r_pla.score < r_abs.score);
}
#[test]
fn score_clamped_between_zero_and_one() {
let f = Footprint::rectangle(Fix128::from_int(5000), Fix128::from_int(5000));
let env = EnvConditions {
print_temp_c: Fix128::from_int(500),
chamber_temp_c: Fix128::from_int(20),
bed_temp_c: Fix128::from_int(90),
};
// Extreme case — should saturate at 1.0
let report = analyze_warp_risk(&f, &MaterialProperties::abs(), &env);
assert!(report.score <= Fix128::ONE);
assert!(report.score >= Fix128::ZERO);
}
#[test]
fn category_thresholds_correct() {
// Sanity: constructing a report with a known score gives the right
// category. We can't set the score directly, so choose inputs.
let f = Footprint::rectangle(Fix128::from_int(200), Fix128::from_int(150));
let env = EnvConditions {
print_temp_c: Fix128::from_int(240),
chamber_temp_c: Fix128::from_int(30),
bed_temp_c: Fix128::from_int(90),
};
let r = analyze_warp_risk(&f, &MaterialProperties::abs(), &env);
// Score should be significant but not saturated
assert!(r.score > Fix128::ZERO);
assert!(r.score < Fix128::ONE);
}
#[test]
fn recommendation_scales_with_category() {
let low = Footprint::rectangle(Fix128::from_int(20), Fix128::from_int(20));
let critical = Footprint::rectangle(Fix128::from_int(300), Fix128::from_int(280));
let env_bad = EnvConditions {
print_temp_c: Fix128::from_int(240),
chamber_temp_c: Fix128::from_int(20),
bed_temp_c: Fix128::from_int(60),
};
let r_low = analyze_warp_risk(
&low,
&MaterialProperties::pla(),
&EnvConditions::open_air_pla(),
);
let r_crit = analyze_warp_risk(&critical, &MaterialProperties::abs(), &env_bad);
assert!(r_low.recommendation.contains("No mitigation"));
assert!(
r_crit.recommendation.contains("failure risk")
|| r_crit.recommendation.contains("switch")
);
}
#[test]
fn warp_force_per_mm_positive_for_normal_conditions() {
let f = Footprint::rectangle(Fix128::from_int(100), Fix128::from_int(100));
let r = analyze_warp_risk(
&f,
&MaterialProperties::pla(),
&EnvConditions::open_air_pla(),
);
assert!(r.warp_force_per_mm > Fix128::ZERO);
}
#[test]
fn env_presets_reasonable() {
let pla = EnvConditions::open_air_pla();
let abs = EnvConditions::enclosed_abs();
assert_eq!(pla.print_temp_c, Fix128::from_int(200));
assert_eq!(abs.chamber_temp_c, Fix128::from_int(55));
assert!(abs.print_temp_c > pla.print_temp_c);
}
#[test]
fn petg_lower_warp_risk_than_abs() {
let f = Footprint::rectangle(Fix128::from_int(200), Fix128::from_int(150));
let env = EnvConditions {
print_temp_c: Fix128::from_int(240),
chamber_temp_c: Fix128::from_int(20),
bed_temp_c: Fix128::from_int(80),
};
let r_petg = analyze_warp_risk(&f, &MaterialProperties::petg(), &env);
let r_abs = analyze_warp_risk(&f, &MaterialProperties::abs(), &env);
assert!(r_petg.score < r_abs.score);
}
}