1pub mod carry;
13pub mod envelope;
14pub mod frame;
15pub mod job;
16pub mod mesh;
17pub mod preop;
18pub mod remaining;
19pub mod removal;
20
21use crate::index::{ProjectionMode, TriangleIndex};
22use crate::mesh::MeshSnapshot;
23use crate::model::{Bounds3, Point3};
24use crate::stock::ResolvedStock;
25use serde::{Deserialize, Serialize};
26
27#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30#[serde(default)]
31pub struct DexelOptions {
32 pub cell: f64,
35 pub max_columns: u64,
37}
38
39impl Default for DexelOptions {
40 fn default() -> Self {
41 Self { cell: 0.5, max_columns: 4_000_000 }
42 }
43}
44
45#[derive(Clone, Debug, PartialEq)]
47pub enum StockSimError {
48 BudgetExceeded { requested_cell: f64, columns: u64, max_columns: u64, cell_that_fits: f64 },
51 RestStockMissing,
53 OpenStockMesh { column: [u32; 2] },
56 InvalidInput(String),
58}
59
60impl std::fmt::Display for StockSimError {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 StockSimError::BudgetExceeded { requested_cell, columns, max_columns, cell_that_fits } => write!(
64 f,
65 "stock grid at cell {requested_cell} needs {columns} columns, over the budget of {max_columns}; a cell of {cell_that_fits} fits"
66 ),
67 StockSimError::RestStockMissing => write!(f, "rest stock needs the previous setup's simulated stock"),
68 StockSimError::OpenStockMesh { column } => {
69 write!(f, "stock mesh is not closed at column ({}, {})", column[0], column[1])
70 }
71 StockSimError::InvalidInput(m) => write!(f, "stock grid: {m}"),
72 }
73 }
74}
75
76impl std::error::Error for StockSimError {}
77
78#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
80#[serde(transparent)]
81pub struct Column {
82 pub intervals: Vec<[f64; 2]>,
83}
84
85impl Column {
86 pub fn full(low: f64, high: f64) -> Self {
87 Self { intervals: vec![[low, high]] }
88 }
89
90 pub fn is_empty(&self) -> bool {
91 self.intervals.is_empty()
92 }
93
94 pub fn height(&self) -> f64 {
96 self.intervals.iter().map(|[a, b]| b - a).sum()
97 }
98
99 pub fn top(&self) -> Option<f64> {
101 self.intervals.last().map(|[_, b]| *b)
102 }
103
104 pub fn cut(&mut self, low: f64, high: f64) -> f64 {
107 if !(high > low) {
108 return 0.0;
109 }
110 let mut removed = 0.0;
111 let mut out: Vec<[f64; 2]> = Vec::with_capacity(self.intervals.len() + 1);
112 for &[a, b] in &self.intervals {
113 if b <= low || a >= high {
114 out.push([a, b]);
115 continue;
116 }
117 let cut_lo = a.max(low);
119 let cut_hi = b.min(high);
120 removed += cut_hi - cut_lo;
121 if a < cut_lo {
122 out.push([a, cut_lo]);
123 }
124 if cut_hi < b {
125 out.push([cut_hi, b]);
126 }
127 }
128 self.intervals = out;
129 removed
130 }
131
132 pub fn has_material(&self, low: f64, high: f64) -> bool {
134 self.intervals.iter().any(|[a, b]| *b > low && *a < high)
135 }
136}
137
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct DexelGrid {
143 pub origin: Point3,
144 pub cell_x: f64,
145 pub cell_y: f64,
146 pub nx: u32,
147 pub ny: u32,
148 pub columns: Vec<Column>,
150 pub z_range: [f64; 2],
152}
153
154impl DexelGrid {
155 fn layout(bounds: &Bounds3, options: &DexelOptions) -> Result<(Point3, f64, f64, u32, u32), StockSimError> {
158 if !(options.cell > 0.0) || !options.cell.is_finite() {
159 return Err(StockSimError::InvalidInput(format!("cell {} must be finite and positive", options.cell)));
160 }
161 if bounds.is_empty() || !bounds.is_finite() {
162 return Err(StockSimError::InvalidInput("stock bounds are empty or not finite".into()));
163 }
164 let size = bounds.size();
165 let nx = (size.x / options.cell).ceil().max(1.0);
166 let ny = (size.y / options.cell).ceil().max(1.0);
167 let columns = nx * ny;
168 if columns > options.max_columns as f64 {
169 let area = size.x * size.y;
170 let cell_that_fits = (area / options.max_columns as f64).sqrt() * 1.001;
171 return Err(StockSimError::BudgetExceeded {
172 requested_cell: options.cell,
173 columns: columns as u64,
174 max_columns: options.max_columns,
175 cell_that_fits,
176 });
177 }
178 Ok((bounds.min, size.x / nx, size.y / ny, nx as u32, ny as u32))
179 }
180
181 pub fn from_stock(
185 stock: &ResolvedStock,
186 options: &DexelOptions,
187 rest: Option<&DexelGrid>,
188 index: Option<&dyn TriangleIndex>,
189 ) -> Result<DexelGrid, StockSimError> {
190 match stock {
191 ResolvedStock::Box(b) => {
192 let bounds = b.machine_bounds();
193 let (origin, cell_x, cell_y, nx, ny) = Self::layout(&bounds, options)?;
194 let is_aligned = b.to_machine.rotation.iter().flatten().all(|v| v.abs() < 1e-12 || (v.abs() - 1.0).abs() < 1e-12);
195 let mut grid = Self::empty(origin, cell_x, cell_y, nx, ny, [bounds.min.z, bounds.max.z]);
196 for iy in 0..ny {
197 for ix in 0..nx {
198 let (x, y) = grid.center(ix, iy);
199 let inside = is_aligned || b.contains(Point3::new(x, y, 0.5 * (bounds.min.z + bounds.max.z)));
200 if inside {
201 grid.columns[(iy * nx + ix) as usize] = Column::full(bounds.min.z, bounds.max.z);
202 }
203 }
204 }
205 Ok(grid)
206 }
207 ResolvedStock::Cylinder(c) => {
208 let bounds = c.machine_bounds();
209 let (origin, cell_x, cell_y, nx, ny) = Self::layout(&bounds, options)?;
210 let mut grid = Self::empty(origin, cell_x, cell_y, nx, ny, [bounds.min.z, bounds.max.z]);
211 let vertical = (c.axis.z.abs() - 1.0).abs() < 1e-12;
212 for iy in 0..ny {
213 for ix in 0..nx {
214 let (x, y) = grid.center(ix, iy);
215 if vertical {
216 let d = ((x - c.base.x).powi(2) + (y - c.base.y).powi(2)).sqrt();
217 if d <= c.radius {
218 grid.columns[(iy * nx + ix) as usize] = Column::full(bounds.min.z, bounds.max.z);
219 }
220 } else {
221 if let Some(iv) = cylinder_column(c, x, y) {
224 grid.columns[(iy * nx + ix) as usize] = Column::full(iv[0], iv[1]);
225 }
226 }
227 }
228 }
229 Ok(grid)
230 }
231 ResolvedStock::Mesh(mesh) => Self::from_mesh(mesh, options, index),
232 ResolvedStock::Rest(_) => rest.cloned().ok_or(StockSimError::RestStockMissing),
233 }
234 }
235
236 pub fn from_mesh(mesh: &MeshSnapshot, options: &DexelOptions, index: Option<&dyn TriangleIndex>) -> Result<DexelGrid, StockSimError> {
242 Self::seed_from_mesh(mesh, options, index, false).map(|(grid, _)| grid)
243 }
244
245 pub fn protected_from_mesh(
254 mesh: &MeshSnapshot,
255 options: &DexelOptions,
256 index: Option<&dyn TriangleIndex>,
257 ) -> Result<(DexelGrid, Option<crate::model::ApproximationLimit>), StockSimError> {
258 let (grid, open) = Self::seed_from_mesh(mesh, options, index, true)?;
259 let limit = (open > 0).then(|| crate::model::ApproximationLimit {
260 kind: "open-mesh-protection".into(),
261 bound: None,
262 detail: Some(format!(
263 "the protected mesh is open at {open} column(s); each is protected over the mesh's whole height"
264 )),
265 });
266 Ok((grid, limit))
267 }
268
269 fn seed_from_mesh(
270 mesh: &MeshSnapshot,
271 options: &DexelOptions,
272 index: Option<&dyn TriangleIndex>,
273 fill_open: bool,
274 ) -> Result<(DexelGrid, usize), StockSimError> {
275 let bounds = mesh.bounds;
276 let (origin, cell_x, cell_y, nx, ny) = Self::layout(&bounds, options)?;
277 let mut grid = Self::empty(origin, cell_x, cell_y, nx, ny, [bounds.min.z, bounds.max.z]);
278 let brute = crate::index::BruteForceIndex::from_snapshot(mesh);
279 let idx: &dyn TriangleIndex = index.unwrap_or(&brute);
280 let mut candidates = Vec::new();
281 let mut hits: Vec<f64> = Vec::new();
282 const JITTER: [(f64, f64); 4] = [(1.0e-3, 0.37e-3), (-0.61e-3, 1.0e-3), (0.29e-3, -1.0e-3), (-1.0e-3, -0.53e-3)];
285 let side = cell_x.min(cell_y);
286 let mut open = 0usize;
287 for iy in 0..ny {
288 for ix in 0..nx {
289 let (cx, cy) = grid.center(ix, iy);
290 let mut cast = |x: f64, y: f64, hits: &mut Vec<f64>| {
291 candidates.clear();
292 let q = Bounds3::new(Point3::new(x, y, f64::NEG_INFINITY), Point3::new(x, y, f64::INFINITY));
293 idx.query_aabb(&q, ProjectionMode::Xy, &mut candidates);
294 hits.clear();
295 for &id in &candidates {
296 let t = mesh.triangle(id);
297 if let Some(z) = vertical_ray_hit(t.a, t.b, t.c, x, y) {
298 hits.push(z);
299 }
300 }
301 hits.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
302 hits.dedup();
304 };
305 cast(cx, cy, &mut hits);
306 if hits.is_empty() {
307 continue;
308 }
309 let mut k = 0;
310 while hits.len() % 2 == 1 && k < JITTER.len() {
311 let (jx, jy) = JITTER[k];
312 cast(cx + jx * side, cy + jy * side, &mut hits);
313 k += 1;
314 }
315 let column = &mut grid.columns[(iy * nx + ix) as usize];
316 if hits.len() % 2 == 1 {
317 if !fill_open {
318 return Err(StockSimError::OpenStockMesh { column: [ix, iy] });
319 }
320 open += 1;
321 column.intervals.push([bounds.min.z, bounds.max.z]);
322 continue;
323 }
324 for pair in hits.chunks_exact(2) {
325 if pair[1] > pair[0] {
326 column.intervals.push([pair[0], pair[1]]);
327 }
328 }
329 }
330 }
331 Ok((grid, open))
332 }
333
334 fn empty(origin: Point3, cell_x: f64, cell_y: f64, nx: u32, ny: u32, z_range: [f64; 2]) -> Self {
335 Self {
336 origin,
337 cell_x,
338 cell_y,
339 nx,
340 ny,
341 columns: vec![Column::default(); (nx as usize) * (ny as usize)],
342 z_range,
343 }
344 }
345
346 pub fn center(&self, ix: u32, iy: u32) -> (f64, f64) {
348 (self.origin.x + (ix as f64 + 0.5) * self.cell_x, self.origin.y + (iy as f64 + 0.5) * self.cell_y)
349 }
350
351 pub fn column(&self, ix: u32, iy: u32) -> &Column {
352 &self.columns[(iy * self.nx + ix) as usize]
353 }
354
355 pub fn column_mut(&mut self, ix: u32, iy: u32) -> &mut Column {
356 &mut self.columns[(iy * self.nx + ix) as usize]
357 }
358
359 pub fn columns_in(&self, bounds: &Bounds3) -> Option<[u32; 4]> {
362 let fx = |x: f64| (x - self.origin.x) / self.cell_x - 0.5;
363 let fy = |y: f64| (y - self.origin.y) / self.cell_y - 0.5;
364 let ix0 = fx(bounds.min.x).ceil().max(0.0);
365 let ix1 = fx(bounds.max.x).floor().min(self.nx as f64 - 1.0);
366 let iy0 = fy(bounds.min.y).ceil().max(0.0);
367 let iy1 = fy(bounds.max.y).floor().min(self.ny as f64 - 1.0);
368 if ix0 > ix1 || iy0 > iy1 {
369 return None;
370 }
371 Some([ix0 as u32, ix1 as u32, iy0 as u32, iy1 as u32])
372 }
373
374 pub fn cell_area(&self) -> f64 {
375 self.cell_x * self.cell_y
376 }
377
378 pub fn volume(&self) -> f64 {
380 self.columns.iter().map(|c| c.height()).sum::<f64>() * self.cell_area()
381 }
382
383 pub fn work_units(&self) -> u64 {
385 self.columns.len() as u64 + self.columns.iter().map(|c| c.intervals.len() as u64).sum::<u64>()
386 }
387
388 pub fn top_at(&self, x: f64, y: f64) -> Option<f64> {
390 let ix = ((x - self.origin.x) / self.cell_x).floor();
391 let iy = ((y - self.origin.y) / self.cell_y).floor();
392 if ix < 0.0 || iy < 0.0 || ix >= self.nx as f64 || iy >= self.ny as f64 {
393 return None;
394 }
395 self.column(ix as u32, iy as u32).top()
396 }
397
398 pub fn bounds(&self) -> Bounds3 {
400 Bounds3::new(
401 Point3::new(self.origin.x, self.origin.y, self.z_range[0]),
402 Point3::new(self.origin.x + self.nx as f64 * self.cell_x, self.origin.y + self.ny as f64 * self.cell_y, self.z_range[1]),
403 )
404 }
405}
406
407pub(crate) fn vertical_ray_hit(a: Point3, b: Point3, c: Point3, x: f64, y: f64) -> Option<f64> {
411 let side = |p: Point3, q: Point3| (q.x - p.x) * (y - p.y) - (q.y - p.y) * (x - p.x);
412 let (d1, d2, d3) = (side(a, b), side(b, c), side(c, a));
413 let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
414 let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
415 if has_neg && has_pos {
416 return None;
417 }
418 let n = b.sub(a).cross(c.sub(a));
419 if n.z.abs() <= f64::EPSILON * (n.length() + 1.0) {
420 return None;
421 }
422 Some(a.z - (n.x * (x - a.x) + n.y * (y - a.y)) / n.z)
423}
424
425fn cylinder_column(c: &crate::stock::StockCylinder, x: f64, y: f64) -> Option<[f64; 2]> {
427 let a = c.axis;
430 let d0 = Point3::new(x - c.base.x, y - c.base.y, -c.base.z);
431 let dz = Point3::Z;
432 let r0 = d0.sub(a.scale(d0.dot(a)));
434 let r1 = dz.sub(a.scale(dz.dot(a)));
435 let qa = r1.dot(r1);
436 let qb = 2.0 * r0.dot(r1);
437 let qc = r0.dot(r0) - c.radius * c.radius;
438 let (mut lo, mut hi) = if qa.abs() < 1e-15 {
439 if qc > 0.0 {
440 return None;
441 }
442 (f64::NEG_INFINITY, f64::INFINITY)
443 } else {
444 let disc = qb * qb - 4.0 * qa * qc;
445 if disc < 0.0 {
446 return None;
447 }
448 let s = disc.sqrt();
449 ((-qb - s) / (2.0 * qa), (-qb + s) / (2.0 * qa))
450 };
451 let s0 = d0.dot(a);
453 let s1 = dz.dot(a);
454 if s1.abs() < 1e-15 {
455 if s0 < 0.0 || s0 > c.height {
456 return None;
457 }
458 } else {
459 let z_a = -s0 / s1;
460 let z_b = (c.height - s0) / s1;
461 lo = lo.max(z_a.min(z_b));
462 hi = hi.min(z_a.max(z_b));
463 }
464 (hi > lo).then_some([lo, hi])
465}
466
467