1use crate::{GroundModel, SoilLayer, SoilType};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct StripLogEntry {
6 pub top_level: f64,
7 pub bottom_level: f64,
8 pub thickness: f64,
9 pub reference: String,
10 pub typical_description: Option<String>,
11 pub behavior: SoilType,
12 pub unit_weight: Option<f64>,
13 pub phi_prime_deg: Option<f64>,
14 pub c_prime: Option<f64>,
15 pub cu: Option<f64>,
16 pub gw_within_layer: bool,
17 pub gw_above_top: bool,
18 pub sigma_v_total_mid: Option<f64>,
19 pub u_mid: Option<f64>,
20 pub sigma_v_prime_mid: Option<f64>,
21}
22
23#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
24pub struct BuildStripLogOptions {
25 pub include_stresses: bool,
26 pub stress_dz: f64,
27}
28
29impl Default for BuildStripLogOptions {
30 fn default() -> Self {
31 BuildStripLogOptions {
32 include_stresses: false,
33 stress_dz: 0.05,
34 }
35 }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct StripLogRenderOptions {
40 pub column_width_px: u32,
41 pub px_per_meter: Option<f64>,
42 pub left_margin_px: u32,
43 pub right_margin_px: u32,
44 pub top_margin_px: u32,
45 pub bottom_margin_px: u32,
46 pub tick_every_meters: f64,
47 pub show_grid: bool,
48 pub show_labels: bool,
49 pub axis_unit_label: String,
50 pub colors: Option<StripLogColors>,
51 pub font_family: String,
52 pub title: Option<String>,
53}
54
55impl Default for StripLogRenderOptions {
56 fn default() -> Self {
57 StripLogRenderOptions {
58 column_width_px: 220,
59 px_per_meter: None,
60 left_margin_px: 64,
61 right_margin_px: 64,
62 top_margin_px: 28,
63 bottom_margin_px: 28,
64 tick_every_meters: 1.0,
65 show_grid: true,
66 show_labels: true,
67 axis_unit_label: "m".to_string(),
68 colors: None,
69 font_family: "Segoe UI, Arial, sans-serif".to_string(),
70 title: None,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct StripLogColors {
77 pub cohesive: Option<String>,
78 pub granular: Option<String>,
79 pub rock: Option<String>,
80}
81
82impl Default for StripLogColors {
83 fn default() -> Self {
84 StripLogColors {
85 cohesive: Some("#B8906B".to_string()),
86 granular: Some("#F6D04D".to_string()),
87 rock: Some("#9BA3AD".to_string()),
88 }
89 }
90}
91
92impl GroundModel {
93 pub fn to_strip_log(&self, opts: BuildStripLogOptions) -> Vec<StripLogEntry> {
94 let mut layers = self.soil_layers.clone();
95 layers.sort_by(|a, b| b.top_level.partial_cmp(&a.top_level).unwrap());
96
97 let mut entries = Vec::new();
98 for layer in layers {
99 let reference = layer_reference(&layer);
100 let params = self.get_soil_params(&reference).cloned();
101 let mid = match layer.base_level {
102 Some(base) => (layer.top_level + base) / 2.0,
103 None => layer.top_level,
104 };
105 let gw = self.groundwater;
106 let base = layer.base_level.unwrap_or(layer.top_level);
107 let gw_within =
108 (gw >= base && gw <= layer.top_level) || (gw <= layer.top_level && gw >= base);
109 let gw_above_top = gw > layer.top_level;
110
111 let mut entry = StripLogEntry {
112 top_level: layer.top_level,
113 bottom_level: base,
114 thickness: (layer.top_level - base).abs(),
115 reference: reference.clone(),
116 typical_description: if layer.typical_description.is_empty() {
117 None
118 } else {
119 Some(layer.typical_description.clone())
120 },
121 behavior: params
122 .as_ref()
123 .map(|p| p.behaviour)
124 .unwrap_or(SoilType::Granular),
125 unit_weight: params.as_ref().map(|p| p.unit_weight),
126 phi_prime_deg: params
127 .as_ref()
128 .and_then(|p| p.phi_prime)
129 .map(|phi| phi.to_degrees()),
130 c_prime: params.as_ref().and_then(|p| p.c_prime),
131 cu: params.as_ref().and_then(|p| p.cu),
132 gw_within_layer: gw_within,
133 gw_above_top,
134 sigma_v_total_mid: None,
135 u_mid: None,
136 sigma_v_prime_mid: None,
137 };
138
139 if opts.include_stresses {
140 let sigma_v = integrate_sigma_v_total_at(self, mid, opts.stress_dz);
141 let u = self.get_pwp_at_level(mid);
142 entry.sigma_v_total_mid = Some(sigma_v);
143 entry.u_mid = Some(u);
144 entry.sigma_v_prime_mid = Some(sigma_v - u);
145 }
146
147 entries.push(entry);
148 }
149
150 entries
151 }
152
153 pub fn to_strip_log_csv(&self, rows: Option<Vec<StripLogEntry>>) -> String {
154 let rows = rows.unwrap_or_else(|| self.to_strip_log(BuildStripLogOptions::default()));
155 let headers = [
156 "TOP_LEVEL(m)",
157 "BOTTOM_LEVEL(m)",
158 "THICKNESS(m)",
159 "REFERENCE",
160 "DESCRIPTION",
161 "BEHAVIOR",
162 "GAMMA(kN/m3)",
163 "PHI_PRIME(deg)",
164 "C_PRIME(kPa)",
165 "CU(kPa)",
166 "GW_WITHIN_LAYER",
167 "GW_ABOVE_TOP",
168 "SIGMA_V_TOTAL_MID(kPa)",
169 "U_MID(kPa)",
170 "SIGMA_V_PRIME_MID(kPa)",
171 ];
172
173 let mut lines = vec![headers.join(",")];
174 for r in rows {
175 let desc = r.typical_description.unwrap_or_default();
176 lines.push(
177 [
178 r.top_level.to_string(),
179 r.bottom_level.to_string(),
180 r.thickness.to_string(),
181 csv_quote(&r.reference),
182 csv_quote(&desc),
183 format!("{:?}", r.behavior),
184 r.unit_weight.map_or("".to_string(), |v| v.to_string()),
185 r.phi_prime_deg.map_or("".to_string(), |v| v.to_string()),
186 r.c_prime.map_or("".to_string(), |v| v.to_string()),
187 r.cu.map_or("".to_string(), |v| v.to_string()),
188 r.gw_within_layer.to_string(),
189 r.gw_above_top.to_string(),
190 r.sigma_v_total_mid
191 .map_or("".to_string(), |v| v.to_string()),
192 r.u_mid.map_or("".to_string(), |v| v.to_string()),
193 r.sigma_v_prime_mid
194 .map_or("".to_string(), |v| v.to_string()),
195 ]
196 .join(","),
197 );
198 }
199
200 lines.join("\n")
201 }
202
203 pub fn to_ags_geol_csv(&self, hole_id: &str, rows: Option<Vec<StripLogEntry>>) -> String {
204 let rows = rows.unwrap_or_else(|| self.to_strip_log(BuildStripLogOptions::default()));
205 let headers = ["HOLE_ID", "GEOL_TOP(m)", "GEOL_BASE(m)", "GEOL_DESC"];
206 let mut lines = vec![headers.join(",")];
207 for r in rows {
208 let desc = r
209 .typical_description
210 .unwrap_or_else(|| format!("{:?}", r.behavior));
211 lines.push(
212 [
213 csv_quote(hole_id),
214 r.top_level.to_string(),
215 r.bottom_level.to_string(),
216 csv_quote(&desc),
217 ]
218 .join(","),
219 );
220 }
221 lines.join("\n")
222 }
223
224 pub fn render_strip_log_svg(&self, opts: StripLogRenderOptions) -> String {
225 let colors = opts.colors.unwrap_or_default();
226 let top = self.get_top_level();
227 let bottom = self.get_base_level();
228 let model_height_m = (top - bottom).abs().max(0.0001);
229 let col_height = 600.0;
230 let scale = opts.px_per_meter.unwrap_or(col_height / model_height_m);
231
232 let width = opts.left_margin_px + opts.column_width_px + opts.right_margin_px;
233 let height = opts.top_margin_px + (model_height_m * scale) as u32 + opts.bottom_margin_px;
234 let col_x = opts.left_margin_px as f64;
235 let col_y = opts.top_margin_px as f64;
236 let col_h = (model_height_m * scale).round();
237
238 let y_for_level = |level: f64| col_y + (top - level) * scale;
239 let depth_at = |level: f64| top - level;
240
241 let rows = self.to_strip_log(BuildStripLogOptions::default());
242
243 let mut svg = Vec::new();
244 svg.push(format!(
245 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">",
246 width, height, width, height
247 ));
248 svg.push(format!(
249 "<style>.axis{{font:12px {};fill:#333;}}.title{{font:14px {};font-weight:600;fill:#111;}}.tickLabelDepth{{dominant-baseline:middle;text-anchor:end;}}.tickLabelLevel{{dominant-baseline:middle;text-anchor:start;}}.layerDesc{{font:12px {};fill:#222;}}.layerNotes{{font:10px {};fill:#444;}}.legend{{font:12px {};}}</style>",
250 opts.font_family, opts.font_family, opts.font_family, opts.font_family, opts.font_family
251 ));
252
253 if let Some(title) = &opts.title {
254 svg.push(format!(
255 "<text class=\"title\" x=\"{}\" y=\"{}\">{}</text>",
256 opts.left_margin_px,
257 (opts.top_margin_px as f64 * 0.7).max(16.0),
258 escape_xml(title)
259 ));
260 }
261
262 if opts.show_grid {
263 let mut m = bottom.ceil();
264 while m <= top.floor() {
265 let yy = y_for_level(m).round() + 0.5;
266 svg.push(format!(
267 "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#eee\" />",
268 col_x,
269 yy,
270 col_x + opts.column_width_px as f64,
271 yy
272 ));
273 m += opts.tick_every_meters;
274 }
275 }
276
277 svg.push(format!(
278 "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"#fff\" stroke=\"#222\" />",
279 col_x, col_y, opts.column_width_px, col_h
280 ));
281
282 let mut m = bottom.ceil();
283 while m <= top.floor() {
284 let yy = y_for_level(m).round() + 0.5;
285 let depth = depth_at(m);
286 svg.push(format!(
287 "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#222\" />",
288 col_x - 6.0,
289 yy,
290 col_x,
291 yy
292 ));
293 svg.push(format!(
294 "<text class=\"axis tickLabelDepth\" x=\"{}\" y=\"{}\">{}</text>",
295 col_x - 8.0,
296 yy,
297 format_number(depth)
298 ));
299 m += opts.tick_every_meters;
300 }
301 svg.push(format!(
302 "<text class=\"axis\" x=\"{}\" y=\"{}\" text-anchor=\"end\">m bGL</text>",
303 col_x - 8.0,
304 col_y - 6.0
305 ));
306
307 let mut m = bottom.ceil();
308 while m <= top.floor() {
309 let yy = y_for_level(m).round() + 0.5;
310 svg.push(format!(
311 "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#222\" />",
312 col_x + opts.column_width_px as f64,
313 yy,
314 col_x + opts.column_width_px as f64 + 6.0,
315 yy
316 ));
317 svg.push(format!(
318 "<text class=\"axis tickLabelLevel\" x=\"{}\" y=\"{}\">{}</text>",
319 col_x + opts.column_width_px as f64 + 8.0,
320 yy,
321 format_number(m)
322 ));
323 m += opts.tick_every_meters;
324 }
325 svg.push(format!(
326 "<text class=\"axis\" x=\"{}\" y=\"{}\" text-anchor=\"start\">{}</text>",
327 col_x + opts.column_width_px as f64 + 8.0,
328 col_y - 6.0,
329 escape_xml(&opts.axis_unit_label)
330 ));
331
332 for row in rows {
333 let y_top = y_for_level(row.top_level);
334 let y_bot = y_for_level(row.bottom_level);
335 let h = (y_bot - y_top).max(0.0);
336 let fill = match row.behavior {
337 SoilType::Cohesive => colors
338 .cohesive
339 .clone()
340 .unwrap_or_else(|| "#B8906B".to_string()),
341 SoilType::Granular => colors
342 .granular
343 .clone()
344 .unwrap_or_else(|| "#F6D04D".to_string()),
345 SoilType::Rock => colors.rock.clone().unwrap_or_else(|| "#9BA3AD".to_string()),
346 };
347
348 svg.push(format!(
349 "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\" stroke=\"#555\" stroke-width=\"0.5\"/>",
350 col_x,
351 y_top,
352 opts.column_width_px,
353 h,
354 fill
355 ));
356
357 if opts.show_labels && h >= 16.0 {
358 let inner_x = col_x + 6.0;
359 let max_text_width = opts.column_width_px as f64 - 12.0;
360 let desc = row
361 .typical_description
362 .clone()
363 .unwrap_or_else(|| format!("{:?}", row.behavior));
364 let mut cursor_y = y_top + 6.0 + 12.0;
365 for line in wrap_text(&desc, max_text_width, 12.0) {
366 if cursor_y < y_bot - 2.0 {
367 svg.push(format!(
368 "<text class=\"layerDesc\" x=\"{}\" y=\"{}\" dominant-baseline=\"alphabetic\">{}</text>",
369 inner_x,
370 cursor_y,
371 escape_xml(&line)
372 ));
373 cursor_y += 14.0;
374 }
375 }
376 }
377 }
378
379 if self.groundwater <= top && self.groundwater >= bottom {
380 let y_gw = y_for_level(self.groundwater).round() + 0.5;
381 svg.push(format!(
382 "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#1E90FF\" stroke-width=\"2\" stroke-dasharray=\"6,4\"/>",
383 col_x,
384 y_gw,
385 col_x + opts.column_width_px as f64,
386 y_gw
387 ));
388 svg.push(format!(
389 "<text class=\"axis\" x=\"{}\" y=\"{}\" text-anchor=\"end\" fill=\"#1E90FF\">Groundwater</text>",
390 col_x - 8.0,
391 y_gw - 2.0
392 ));
393 svg.push(format!(
394 "<text class=\"axis\" x=\"{}\" y=\"{}\" text-anchor=\"start\" fill=\"#1E90FF\">{} {}</text>",
395 col_x + opts.column_width_px as f64 + 8.0,
396 y_gw - 2.0,
397 format_number(self.groundwater),
398 escape_xml(&opts.axis_unit_label)
399 ));
400 }
401
402 svg.push("</svg>".to_string());
403 svg.join("")
404 }
405}
406
407fn layer_reference(layer: &SoilLayer) -> String {
408 if !layer.unit_reference.is_empty() {
409 layer.unit_reference.clone()
410 } else {
411 layer.reference.clone()
412 }
413}
414
415fn integrate_sigma_v_total_at(model: &GroundModel, level: f64, dz: f64) -> f64 {
416 let top = model.get_top_level();
417 if level >= top {
418 return 0.0;
419 }
420 let mut s = 0.0;
421 let mut z = top - dz / 2.0;
422 while z > level {
423 let sp = model.get_params_at_level(z).ok();
424 if let Some(params) = sp {
425 s += params.unit_weight * dz;
426 }
427 z -= dz;
428 }
429 s
430}
431
432fn csv_quote(s: &str) -> String {
433 format!("\"{}\"", s.replace('"', "\"\""))
434}
435
436fn escape_xml(s: &str) -> String {
437 s.replace('&', "&")
438 .replace('<', "<")
439 .replace('>', ">")
440 .replace('"', """)
441 .replace('\'', "'")
442}
443
444fn wrap_text(text: &str, max_width_px: f64, font_size_px: f64) -> Vec<String> {
445 let approx_char_w = 0.6 * font_size_px;
446 let max_chars = ((max_width_px / approx_char_w).floor() as usize).max(4);
447 let words: Vec<&str> = text.split_whitespace().collect();
448 let mut lines = Vec::new();
449 let mut line = String::new();
450
451 for w in words {
452 if line.is_empty() {
453 line = w.to_string();
454 } else if line.len() + 1 + w.len() <= max_chars {
455 line.push(' ');
456 line.push_str(w);
457 } else {
458 lines.push(line);
459 line = w.to_string();
460 }
461 }
462 if !line.is_empty() {
463 lines.push(line);
464 }
465 lines
466}
467
468fn format_number(n: f64) -> String {
469 if n.abs() < 1e-6 {
470 "0".to_string()
471 } else {
472 format!("{:.3}", n)
473 .trim_end_matches('0')
474 .trim_end_matches('.')
475 .to_string()
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use crate::SoilParams;
483
484 fn sample_model() -> GroundModel {
485 let mut params1 = SoilParams::default();
486 params1.reference = "CL".to_string();
487 params1.unit_weight = 19.0;
488 params1.behaviour = SoilType::Cohesive;
489 params1.phi_prime = Some(22.0_f64.to_radians());
490
491 let mut params2 = SoilParams::default();
492 params2.reference = "SA".to_string();
493 params2.unit_weight = 18.0;
494 params2.behaviour = SoilType::Granular;
495 params2.phi_prime = Some(30.0_f64.to_radians());
496
497 let layer1 = SoilLayer::with_all_fields(
498 "CL".to_string(),
499 1.0,
500 Some(-1.0),
501 None,
502 "Firm clay".to_string(),
503 "CL".to_string(),
504 );
505 let layer2 = SoilLayer::with_all_fields(
506 "SA".to_string(),
507 -1.0,
508 Some(-3.0),
509 None,
510 "Medium dense sand".to_string(),
511 "SA".to_string(),
512 );
513
514 let mut model = GroundModel::new(vec![layer1, layer2], vec![params1, params2]);
515 model.groundwater = -0.5;
516 model
517 }
518
519 #[test]
520 fn strip_log_csv_has_headers() {
521 let model = sample_model();
522 let csv = model.to_strip_log_csv(None);
523 assert!(csv.starts_with("TOP_LEVEL(m),BOTTOM_LEVEL(m),THICKNESS(m),REFERENCE"));
524 }
525
526 #[test]
527 fn strip_log_svg_contains_elements() {
528 let model = sample_model();
529 let svg = model.render_strip_log_svg(StripLogRenderOptions::default());
530 assert!(svg.starts_with("<svg"));
531 assert!(svg.contains("stroke=\"#1E90FF\""));
532 }
533
534 #[test]
535 fn ags_geol_csv_has_hole_id() {
536 let model = sample_model();
537 let csv = model.to_ags_geol_csv("BH101", None);
538 assert!(csv.contains("\"BH101\""));
539 }
540}