fix: restore shell and cut wall topology in 0.2.1
This commit is contained in:
1 parent
1aedbf6631
commit
ef8f6ca6f9
31 files changed
+2976
-678
No files matched your search
@@ -5,6 +5,10 @@ use truck_modeling as mt;
|
||||
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[path = "planar_regularize.rs"]
|
||||
mod planar_regularize;
|
||||
#[path = "planar_sources.rs"]
|
||||
mod planar_sources;
|
||||
#[path = "shell_groups.rs"]
|
||||
mod shell_groups;
|
||||
|
||||
@@ -33,11 +37,12 @@ pub struct Body {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FaceSource {
|
||||
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane> },
|
||||
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane>, coalesced: bool },
|
||||
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option<solvecraft_geom::Plane> },
|
||||
ExtrudeCap { high: bool, region: solvecraft_geom::Region2, plane: solvecraft_geom::Plane },
|
||||
CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: solvecraft_geom::Plane },
|
||||
Box { producer: Option<u64>, axis: u8, high: bool, plane: solvecraft_geom::Plane },
|
||||
ShellPlanar { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: solvecraft_geom::Plane },
|
||||
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
|
||||
}
|
||||
|
||||
@@ -48,7 +53,9 @@ impl FaceSource {
|
||||
fn plane_mut(&mut self) -> Option<&mut solvecraft_geom::Plane> {
|
||||
match self {
|
||||
Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(),
|
||||
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } => Some(plane),
|
||||
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } | Self::ShellPlanar { plane, .. } => {
|
||||
Some(plane)
|
||||
}
|
||||
Self::Imported { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -358,6 +365,12 @@ impl Body {
|
||||
self.face_sources.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// A current wall reconstructed from an exact contiguous section run.
|
||||
/// This transient witness grants no durable source/producer identity.
|
||||
pub fn face_was_coalesced(&self, index: usize) -> bool {
|
||||
matches!(self.face_sources().and_then(|s| s.get(index)), Some(Some(FaceSource::ExtrudeSide { coalesced: true, .. })))
|
||||
}
|
||||
|
||||
/// An exact construction/import plane; never inferred from tessellation.
|
||||
pub fn face_plane(&self, index: usize) -> Option<solvecraft_geom::Plane> {
|
||||
if index >= 50_000 {
|
||||
@@ -366,7 +379,10 @@ impl Body {
|
||||
let source = self.face_sources()?.get(index)?.as_ref()?;
|
||||
match source {
|
||||
FaceSource::ExtrudeSide { plane, .. } | FaceSource::CertifiedExtrudeSide { plane, .. } => *plane,
|
||||
FaceSource::ExtrudeCap { plane, .. } | FaceSource::CertifiedExtrudeCap { plane, .. } | FaceSource::Box { plane, .. } => Some(*plane),
|
||||
FaceSource::ExtrudeCap { plane, .. }
|
||||
| FaceSource::CertifiedExtrudeCap { plane, .. }
|
||||
| FaceSource::Box { plane, .. }
|
||||
| FaceSource::ShellPlanar { plane, .. } => Some(*plane),
|
||||
FaceSource::Imported { .. } => self.planar_face(index),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis })
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis, coalesced: false })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -116,18 +116,32 @@ fn construction_planes_follow_reflected_and_sheared_affine_images() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_changes_discard_producer_certificates() {
|
||||
fn material_changes_preserve_only_unchanged_planar_support_certificates() {
|
||||
let base = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(42).unwrap();
|
||||
let tool = crate::box_solid(Vec3::new(5., -1., -1.), Vec3::new(11., 11., 11.)).unwrap();
|
||||
let cut = crate::boolean(&base, &tool, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
assert!((cut.tessellate(1e-4).unwrap().measure().volume - 500.).abs() < 1e-8);
|
||||
assert!(cut.face_sources().into_iter().flatten().flatten().all(|source| matches!(
|
||||
source,
|
||||
FaceSource::ExtrudeSide { .. }
|
||||
| FaceSource::ExtrudeCap { .. }
|
||||
| FaceSource::Box { producer: None, .. }
|
||||
| FaceSource::Imported { producer: None, .. }
|
||||
)));
|
||||
// Unchanged uniquely certified supports retain their role; a newly cut
|
||||
// support must never inherit the removed opposite wall's source identity.
|
||||
let mut retained = 0;
|
||||
let mut new_wall = 0;
|
||||
for index in 0..cut.face_count() {
|
||||
let actual = cut.planar_face(index).unwrap();
|
||||
let source = cut.face_sources().and_then(|s| s.get(index)).and_then(Option::as_ref);
|
||||
if let Some(FaceSource::Box { producer: Some(42), axis, high, .. }) = source {
|
||||
assert!(!(*axis == 0 && *high));
|
||||
let canonical = cut.face_plane(index).unwrap();
|
||||
assert!(actual.normal().dot(canonical.normal()) > 1. - 1e-10);
|
||||
assert!(actual.height(canonical.origin).abs() < 1e-10);
|
||||
retained += 1;
|
||||
}
|
||||
if actual.normal().x > 0.9 {
|
||||
assert!((actual.origin.x - 5.).abs() < 1e-10);
|
||||
assert!(!matches!(source, Some(FaceSource::Box { producer: Some(_), .. })));
|
||||
new_wall += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!((retained, new_wall), (5, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -221,6 +221,9 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
)),
|
||||
};
|
||||
}
|
||||
if matches!(op, BoolOp::Cut | BoolOp::Union) {
|
||||
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Exact topological removal of construction-induced planar subdivisions.
|
||||
//! Shared edge identity and complete construction lineage select the groups.
|
||||
use super::{Body, from_p3};
|
||||
use crate::{Result, guard};
|
||||
use solvecraft_geom::Plane;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use truck_modeling as mt;
|
||||
|
||||
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn merged_face(faces: &[&mt::Face], members: &[usize], plane: Plane, eligible: &HashSet<mt::EdgeID>) -> Option<mt::Face> {
|
||||
let mut counts = HashMap::<mt::EdgeID, (usize, bool)>::new();
|
||||
for i in members {
|
||||
for edge in faces.get(*i)?.boundary_iters().into_iter().flatten() {
|
||||
// Whole linear supports, not a sampled plane fit.
|
||||
let mt::Curve::Line(line) = edge.curve() else { return None };
|
||||
let (a, b) = (from_p3(line.0), from_p3(line.1));
|
||||
if !a.is_finite() || !b.is_finite() || a.len().max(b.len()) > 1e9 {
|
||||
return None;
|
||||
}
|
||||
let front = from_p3(edge.absolute_front().point());
|
||||
let back = from_p3(edge.absolute_back().point());
|
||||
if !front.is_finite() || !back.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let error = 4096. * f64::EPSILON * (1. + a.len() + b.len() + plane.origin.len());
|
||||
if plane.height(a).abs() > error || plane.height(b).abs() > error || a.dist(front) > error || b.dist(back) > error {
|
||||
return None;
|
||||
}
|
||||
let count = counts.entry(edge.id()).or_insert((0, edge.orientation()));
|
||||
if count.0 == 1 && (!eligible.contains(&edge.id()) || count.1 == edge.orientation()) {
|
||||
return None;
|
||||
}
|
||||
count.0 += 1;
|
||||
if count.0 > 2 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut boundary = Vec::new();
|
||||
for i in members {
|
||||
for edge in faces.get(*i)?.boundary_iters().into_iter().flatten() {
|
||||
if counts.get(&edge.id()).is_some_and(|(n, _)| *n == 1) {
|
||||
boundary.push(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut next = HashMap::new();
|
||||
let mut incoming = HashMap::new();
|
||||
for (i, edge) in boundary.iter().enumerate() {
|
||||
if next.insert(edge.front().id(), i).is_some() || incoming.insert(edge.back().id(), i).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if next.len() != incoming.len() || next.keys().any(|id| !incoming.contains_key(id)) {
|
||||
return None;
|
||||
}
|
||||
let mut used = vec![false; boundary.len()];
|
||||
let mut outer = None;
|
||||
let mut holes = Vec::new();
|
||||
for first in 0..boundary.len() {
|
||||
if used[first] {
|
||||
continue;
|
||||
}
|
||||
let start = boundary.get(first)?.front().id();
|
||||
let origin = from_p3(boundary.get(first)?.front().point());
|
||||
let mut index = first;
|
||||
let mut edges = Vec::new();
|
||||
let (mut area, mut magnitude, mut arithmetic_error) = (0., 0., 0.);
|
||||
loop {
|
||||
if *used.get(index)? {
|
||||
return None;
|
||||
}
|
||||
used[index] = true;
|
||||
let edge = boundary.get(index)?.clone();
|
||||
let da = from_p3(edge.front().point()) - origin;
|
||||
let db = from_p3(edge.back().point()) - origin;
|
||||
let a = solvecraft_geom::Vec2::new(da.dot(plane.x), da.dot(plane.y));
|
||||
let b = solvecraft_geom::Vec2::new(db.dot(plane.x), db.dot(plane.y));
|
||||
let term = a.cross(b);
|
||||
area += term;
|
||||
magnitude += term.abs();
|
||||
let coordinate_error =
|
||||
128. * f64::EPSILON * (1. + origin.len() + from_p3(edge.front().point()).len() + from_p3(edge.back().point()).len());
|
||||
arithmetic_error += coordinate_error * (da.len() + db.len() + coordinate_error) * 16.;
|
||||
let back = edge.back().id();
|
||||
edges.push(edge);
|
||||
if back == start {
|
||||
break;
|
||||
}
|
||||
index = *next.get(&back)?;
|
||||
}
|
||||
if edges.len() < 3 || !area.is_finite() || area.abs() <= arithmetic_error + 64. * f64::EPSILON * (edges.len() as f64 + 1.) * magnitude.max(1.)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let wire = mt::Wire::from(edges);
|
||||
if area > 0. {
|
||||
if outer.replace(wire).is_some() {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
holes.push(wire);
|
||||
}
|
||||
}
|
||||
let mut wires = vec![outer?];
|
||||
wires.extend(holes);
|
||||
mt::Face::try_new(wires, faces.get(*members.first()?)?.oriented_surface()).ok()
|
||||
}
|
||||
|
||||
impl Body {
|
||||
pub(crate) fn regularize_certified_planar_faces(self) -> Result<Self> {
|
||||
let Some(sources) = self.face_sources() else { return Ok(self) };
|
||||
if sources.len() > 16_384 || self.paint().is_some_and(|p| !p.faces.is_empty()) {
|
||||
return Ok(self);
|
||||
}
|
||||
let split_points = self.split_keep().iter().try_fold(0usize, |sum, line| sum.checked_add(line.len()));
|
||||
let Some(split_points) = split_points.filter(|n| *n <= 65_536) else { return Ok(self) };
|
||||
let result = guard("certified planar topology regularization", || {
|
||||
let mut faces = Vec::new();
|
||||
let mut shell_owner = Vec::new();
|
||||
let mut use_budget = 65_536usize;
|
||||
for (shell_index, shell) in self.solid.boundaries().iter().enumerate() {
|
||||
for face in shell.face_iter() {
|
||||
if faces.len() >= sources.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
for _ in face.edge_iter() {
|
||||
let Some(left) = use_budget.checked_sub(1) else { return Ok(None) };
|
||||
use_budget = left;
|
||||
}
|
||||
faces.push(face);
|
||||
shell_owner.push(shell_index);
|
||||
}
|
||||
}
|
||||
if faces.len() != sources.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let valid: Vec<_> = faces
|
||||
.iter()
|
||||
.zip(sources)
|
||||
.map(|(f, s)| {
|
||||
s.as_ref()
|
||||
.and_then(super::planar_sources::certified_plane)
|
||||
.filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual)))
|
||||
})
|
||||
.collect();
|
||||
let mut adjacency = HashMap::<mt::EdgeID, Vec<(usize, mt::Edge)>>::new();
|
||||
for (i, face) in faces.iter().enumerate() {
|
||||
for edge in face.boundary_iters().into_iter().flatten() {
|
||||
adjacency.entry(edge.id()).or_default().push((i, edge));
|
||||
}
|
||||
}
|
||||
let mut eligible = HashSet::new();
|
||||
let mut parent: Vec<_> = (0..faces.len()).collect();
|
||||
let mut identity = 16_000_000usize;
|
||||
let mut work = 2_000_000usize;
|
||||
for uses in adjacency.values() {
|
||||
let [(a, ea), (b, eb)] = uses.as_slice() else { continue };
|
||||
if a == b || shell_owner[*a] != shell_owner[*b] || ea.orientation() == eb.orientation() {
|
||||
continue;
|
||||
}
|
||||
let (Some(pa), Some(pb)) = (valid[*a], valid[*b]) else { continue };
|
||||
if pa != pb {
|
||||
continue;
|
||||
}
|
||||
let (Some(sa), Some(sb)) = (sources[*a].as_ref(), sources[*b].as_ref()) else { continue };
|
||||
let Some(cost) = super::planar_sources::descriptor_cost(sa)
|
||||
.and_then(|a| super::planar_sources::descriptor_cost(sb).and_then(|b| a.checked_add(b)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(left) = identity.checked_sub(cost) else { return Ok(None) };
|
||||
identity = left;
|
||||
if sa != sb {
|
||||
continue;
|
||||
}
|
||||
let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) };
|
||||
let Some(left) = work.checked_sub(cost) else { return Ok(None) };
|
||||
work = left;
|
||||
if crate::heal::on_split_line(&crate::heal::edge_points(ea), self.split_keep()) {
|
||||
continue;
|
||||
}
|
||||
eligible.insert(ea.id());
|
||||
let (ra, rb) = (root(&mut parent, *a), root(&mut parent, *b));
|
||||
parent[ra.max(rb)] = ra.min(rb);
|
||||
}
|
||||
let mut groups = BTreeMap::<usize, Vec<usize>>::new();
|
||||
for i in 0..faces.len() {
|
||||
groups.entry(root(&mut parent, i)).or_default().push(i);
|
||||
}
|
||||
let mut replacements = HashMap::new();
|
||||
let mut changed = false;
|
||||
let mut metadata = 2_000_000usize;
|
||||
for (leader, members) in &groups {
|
||||
if members.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let Some(face) = valid[*leader].and_then(|p| merged_face(&faces, members, p, &eligible)) else { continue };
|
||||
replacements.insert(*leader, face);
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut shells = Vec::new();
|
||||
let mut output_sources = Vec::new();
|
||||
let mut output_ids = Vec::new();
|
||||
let mut offset = 0;
|
||||
for shell in self.solid.boundaries() {
|
||||
let mut output = Vec::new();
|
||||
for face in shell.face_iter() {
|
||||
let leader = root(&mut parent, offset);
|
||||
if let Some(merged) = replacements.get(&leader) {
|
||||
if offset == leader {
|
||||
output.push(merged.clone());
|
||||
} else {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
output.push(face.clone());
|
||||
}
|
||||
output_ids.push(output.last().ok_or_else(|| crate::KernelError::Failed("missing regularized face".into()))?.id());
|
||||
let source = sources[offset].as_ref();
|
||||
if let Some(source) = source {
|
||||
let Some(cost) = super::planar_sources::descriptor_cost(source) else { return Ok(None) };
|
||||
let Some(left) = metadata.checked_sub(cost) else { return Ok(None) };
|
||||
metadata = left;
|
||||
}
|
||||
output_sources.push(source.cloned());
|
||||
offset += 1;
|
||||
}
|
||||
shells.push(mt::Shell::from(output));
|
||||
}
|
||||
let solid = mt::Solid::try_new(shells).map_err(|e| crate::KernelError::Failed(format!("planar regularization does not close: {e}")))?;
|
||||
let body = Body::new(solid)?.with_color(self.color()).with_paint(self.paint().cloned()).with_split_lines(self.split_lines().cloned());
|
||||
if !body.solid.face_iter().map(|f| f.id()).eq(output_ids) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(body.with_face_sources(Some(Arc::new(output_sources)))))
|
||||
})?;
|
||||
Ok(result.unwrap_or(self))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn alternate_adjacency_path_cannot_remove_protected_internal_edge() {
|
||||
let vertices: Vec<_> =
|
||||
(0..9).map(|i| mt::builder::vertex(super::super::p3(solvecraft_geom::Vec3::new((i % 3) as f64, (i / 3) as f64, 0.)))).collect();
|
||||
let mut edges = HashMap::<(usize, usize), mt::Edge>::new();
|
||||
let mut faces = Vec::new();
|
||||
for (x, y) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
let ids = [y * 3 + x, y * 3 + x + 1, (y + 1) * 3 + x + 1, (y + 1) * 3 + x];
|
||||
let wire: mt::Wire = (0..4)
|
||||
.map(|i| {
|
||||
let (a, b) = (ids[i], ids[(i + 1) % 4]);
|
||||
let key = (a.min(b), a.max(b));
|
||||
let edge = edges.entry(key).or_insert_with(|| mt::builder::line(&vertices[key.0], &vertices[key.1]));
|
||||
if a == key.0 { edge.clone() } else { edge.inverse() }
|
||||
})
|
||||
.collect();
|
||||
faces.push(mt::builder::try_attach_plane(&[wire]).unwrap());
|
||||
}
|
||||
let refs: Vec<_> = faces.iter().collect();
|
||||
let mut eligible: HashSet<_> = edges.values().map(|e| e.id()).collect();
|
||||
assert!(merged_face(&refs, &[0, 1, 2, 3], Plane::XY, &eligible).is_some());
|
||||
eligible.remove(&edges[&(1, 4)].id());
|
||||
assert!(
|
||||
merged_face(&refs, &[0, 1, 2, 3], Plane::XY, &eligible).is_none(),
|
||||
"remaining three internal joins cannot bypass authored split protection"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
//! Exact oriented planar support lineage through a trim and an inside shell.
|
||||
//! No tessellated face names, centroids, or nearest-plane choices are used.
|
||||
use super::{Body, FaceSource, from_p3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
use solvecraft_geom::{Plane, Vec3};
|
||||
use std::sync::Arc;
|
||||
use truck_modeling as mt;
|
||||
|
||||
pub(super) fn actual_plane(face: &mt::Face) -> Option<Plane> {
|
||||
let mt::Surface::Plane(p) = face.oriented_surface() else { return None };
|
||||
let u = p.u_axis();
|
||||
let v = p.v_axis();
|
||||
let u = Vec3::new(u.x, u.y, u.z);
|
||||
let v = Vec3::new(v.x, v.y, v.z);
|
||||
if !u.is_finite() || !v.is_finite() || u.cross(v).len() <= u.len() * v.len() * 1e-8 {
|
||||
return None;
|
||||
}
|
||||
Plane::new(from_p3(p.origin()), u, v)
|
||||
}
|
||||
|
||||
pub(super) fn same_support(a: &Plane, b: &Plane) -> bool {
|
||||
let error = 4096. * f64::EPSILON * (1. + a.origin.len() + b.origin.len());
|
||||
a.normal().cross(b.normal()).len() <= 4096. * f64::EPSILON
|
||||
&& a.normal().dot(b.normal()) > 0.
|
||||
&& (a.origin - b.origin).dot(a.normal()).abs() <= error
|
||||
}
|
||||
|
||||
fn base_plane(source: &FaceSource, actual: &Plane) -> Option<Plane> {
|
||||
match source {
|
||||
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
|
||||
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
|
||||
| FaceSource::Box { producer: Some(_), plane: p, .. }
|
||||
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
|
||||
FaceSource::Imported { producer: Some(_), payload_sha256: Some(_), .. } => Some(*actual),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn certified_plane(source: &FaceSource) -> Option<Plane> {
|
||||
match source {
|
||||
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
|
||||
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
|
||||
| FaceSource::Box { producer: Some(_), plane: p, .. }
|
||||
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn boolean_source(source: &FaceSource, constructed: bool) -> bool {
|
||||
matches!(source, FaceSource::ShellPlanar { producer: Some(_), .. }) || (constructed && certified_plane(source).is_some())
|
||||
}
|
||||
|
||||
pub(super) fn descriptor_cost(source: &FaceSource) -> Option<usize> {
|
||||
let mut cost = 256usize;
|
||||
match source {
|
||||
FaceSource::CertifiedExtrudeCap { outer, holes, .. } => {
|
||||
if holes.len() > 4096 {
|
||||
return None;
|
||||
}
|
||||
cost = cost.checked_add(holes.len().checked_mul(64)?)?;
|
||||
let mut entries = 4096usize;
|
||||
for id in outer.iter().chain(holes.iter().flatten()) {
|
||||
entries = entries.checked_sub(1)?;
|
||||
if id.len() > 128 {
|
||||
return None;
|
||||
}
|
||||
cost = cost.checked_add(id.len().checked_add(64)?)?;
|
||||
}
|
||||
}
|
||||
FaceSource::CertifiedExtrudeSide { curve, .. } => {
|
||||
if curve.len() > 128 {
|
||||
return None;
|
||||
}
|
||||
cost = cost.checked_add(curve.len())?;
|
||||
}
|
||||
FaceSource::Imported { payload_sha256: Some(digest), .. } => {
|
||||
if digest.len() > 64 {
|
||||
return None;
|
||||
}
|
||||
cost = cost.checked_add(digest.len())?;
|
||||
}
|
||||
FaceSource::ShellPlanar { source, .. } if !matches!(source.as_ref(), FaceSource::ShellPlanar { .. }) => {
|
||||
cost = cost.checked_add(descriptor_cost(source)?)?;
|
||||
}
|
||||
FaceSource::ExtrudeCap { region, .. } => {
|
||||
if region.holes.len() > 8192 {
|
||||
return None;
|
||||
}
|
||||
cost = cost.checked_add(region.holes.len().checked_mul(64)?)?;
|
||||
let count = region.holes.iter().try_fold(region.outer.segs.len(), |sum, h| sum.checked_add(h.segs.len()))?;
|
||||
if count > 8192 {
|
||||
return None;
|
||||
}
|
||||
cost = cost.checked_add(count.checked_mul(128)?)?;
|
||||
}
|
||||
FaceSource::ExtrudeSide { .. } | FaceSource::Imported { .. } | FaceSource::Box { .. } => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some(cost)
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// Preserve uniquely certified unchanged planar construction supports.
|
||||
pub fn with_preserved_planar_sources(self, input: &Body) -> Result<Self> {
|
||||
self.map_planar_sources(input, None, None, &[], false)
|
||||
}
|
||||
|
||||
/// Generate distinct outer/inner roles from the exact original supports of
|
||||
/// an inside shell. Generated rims and curved walls are intentionally absent.
|
||||
pub(crate) fn with_shell_planar_sources(self, input: &Body, thickness: f64, open: &[Vec3]) -> Result<Self> {
|
||||
self.map_planar_sources(input, Some(thickness), None, open, false)
|
||||
}
|
||||
|
||||
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
|
||||
/// supports without a competing coplanar tool. No imported or fitted role is inferred.
|
||||
pub(crate) fn with_preserved_boolean_planar_sources(self, input: &Body, tool: &Body, constructed: bool) -> Result<Self> {
|
||||
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
|
||||
return Ok(self);
|
||||
}
|
||||
self.map_planar_sources(input, None, Some(tool), &[], constructed)
|
||||
}
|
||||
|
||||
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
|
||||
let Some(sources) = input.face_sources() else { return Ok(self) };
|
||||
if input.solid.face_iter().count() > 16_384
|
||||
|| self.solid.face_iter().count() > 16_384
|
||||
|| blocker.is_some_and(|b| b.solid.face_iter().count() > 16_384)
|
||||
{
|
||||
return Err(KernelError::Invalid("planar source face budget exceeded".into()));
|
||||
}
|
||||
if sources.len() != input.solid.face_iter().count() {
|
||||
return Err(KernelError::Invalid("planar source alignment mismatch".into()));
|
||||
}
|
||||
if shell.is_some_and(|t| !t.is_finite() || t <= 0.) {
|
||||
return Err(KernelError::Invalid("invalid shell source thickness".into()));
|
||||
}
|
||||
if open.len() > 256 {
|
||||
return Ok(self);
|
||||
}
|
||||
let opening_tolerance = if shell.is_some() { input.size() * 1e-4 + 1e-6 } else { 0. };
|
||||
let mapped = guard("exact planar support lineage", || {
|
||||
let competing: Vec<_> = blocker.into_iter().flat_map(|b| b.solid.face_iter()).filter_map(actual_plane).collect();
|
||||
let mut candidates = Vec::new();
|
||||
let mut unknown_supports = Vec::new();
|
||||
for (face, source) in input.solid.face_iter().zip(sources) {
|
||||
let Some(actual) = actual_plane(face) else { continue };
|
||||
let Some(source) = source.as_ref() else {
|
||||
unknown_supports.push(actual);
|
||||
continue;
|
||||
};
|
||||
if (blocker.is_some() && !boolean_source(source, constructed))
|
||||
|| (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. }))
|
||||
{
|
||||
unknown_supports.push(actual);
|
||||
continue;
|
||||
}
|
||||
let Some(plane) = base_plane(source, &actual) else {
|
||||
unknown_supports.push(actual);
|
||||
continue;
|
||||
};
|
||||
if !same_support(&plane, &actual) || (shell.is_some() && open.iter().any(|p| actual.height(*p).abs() <= opening_tolerance)) {
|
||||
unknown_supports.push(actual);
|
||||
continue;
|
||||
}
|
||||
let Some(cost) = descriptor_cost(source) else {
|
||||
unknown_supports.push(actual);
|
||||
continue;
|
||||
};
|
||||
candidates.push((plane, source, false, cost));
|
||||
if let Some(t) = shell {
|
||||
let inner = Plane::new(plane.origin - plane.normal() * t, plane.x, -plane.y)
|
||||
.ok_or_else(|| KernelError::Invalid("invalid shell inner source plane".into()))?;
|
||||
candidates.push((inner, source, true, cost));
|
||||
}
|
||||
}
|
||||
let mut work = 2_000_000usize;
|
||||
let mut metadata = 2_000_000usize;
|
||||
let mut identity_bytes = 16_000_000usize;
|
||||
let mut result = Vec::new();
|
||||
for (index, face) in self.solid.face_iter().enumerate() {
|
||||
let mut found = None;
|
||||
if let Some(actual) = actual_plane(face) {
|
||||
let mut blocked = false;
|
||||
for plane in &unknown_supports {
|
||||
work = work.checked_sub(1).ok_or_else(|| KernelError::Invalid("unknown planar source comparison budget exceeded".into()))?;
|
||||
if same_support(plane, &actual) {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for plane in &competing {
|
||||
work = work.checked_sub(1).ok_or_else(|| KernelError::Invalid("planar tool comparison budget exceeded".into()))?;
|
||||
if same_support(plane, &actual)
|
||||
|| same_support(
|
||||
&Plane::new(plane.origin, plane.x, -plane.y).ok_or_else(|| KernelError::Invalid("invalid opposing plane".into()))?,
|
||||
&actual,
|
||||
)
|
||||
{
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if blocked {
|
||||
let existing = self
|
||||
.face_sources()
|
||||
.and_then(|sources| sources.get(index))
|
||||
.and_then(|s| s.as_ref())
|
||||
.filter(|s| (blocker.is_some() || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
|
||||
let keep = existing.and_then(|s| {
|
||||
descriptor_cost(s).and_then(|cost| {
|
||||
metadata.checked_sub(cost).map(|remaining| {
|
||||
metadata = remaining;
|
||||
s.clone()
|
||||
})
|
||||
})
|
||||
});
|
||||
result.push(keep);
|
||||
continue;
|
||||
}
|
||||
let mut chosen: Option<(Plane, &FaceSource, bool, usize)> = None;
|
||||
for (plane, source, inner, cost) in &candidates {
|
||||
work = work.checked_sub(1).ok_or_else(|| KernelError::Invalid("planar source comparison budget exceeded".into()))?;
|
||||
if same_support(plane, &actual) {
|
||||
if let Some((previous_plane, previous_source, previous_inner, _)) = chosen {
|
||||
let Some(remaining) = identity_bytes.checked_sub(*cost) else {
|
||||
chosen = None;
|
||||
break;
|
||||
};
|
||||
identity_bytes = remaining;
|
||||
// Several current pieces of one exact source are one
|
||||
// logical face; distinct source identities remain ambiguous.
|
||||
if previous_plane != *plane || previous_source != *source || previous_inner != *inner {
|
||||
chosen = None;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
chosen = Some((*plane, *source, *inner, *cost));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((plane, source, inner, cost)) = chosen
|
||||
&& let Some(remaining) = metadata.checked_sub(cost)
|
||||
{
|
||||
metadata = remaining;
|
||||
found = Some(if shell.is_some() {
|
||||
FaceSource::ShellPlanar { producer: None, source: Box::new(source.clone()), inner, plane }
|
||||
} else {
|
||||
source.clone()
|
||||
});
|
||||
}
|
||||
}
|
||||
if found.is_none() && (blocker.is_some() || self.face_was_coalesced(index)) {
|
||||
found = self
|
||||
.face_sources()
|
||||
.and_then(|sources| sources.get(index))
|
||||
.and_then(|s| s.as_ref())
|
||||
.filter(|s| !boolean_source(s, constructed))
|
||||
.and_then(|s| {
|
||||
descriptor_cost(s).and_then(|cost| {
|
||||
metadata.checked_sub(cost).map(|remaining| {
|
||||
metadata = remaining;
|
||||
s.clone()
|
||||
})
|
||||
})
|
||||
});
|
||||
}
|
||||
result.push(found);
|
||||
}
|
||||
Ok(result)
|
||||
})?;
|
||||
self.face_sources = Some(Arc::new(mapped));
|
||||
self.regularize_certified_planar_faces()
|
||||
}
|
||||
|
||||
pub fn with_shell_certificate(mut self, producer: u64) -> Result<Self> {
|
||||
if producer == 0 {
|
||||
return Err(KernelError::Invalid("invalid shell source producer".into()));
|
||||
}
|
||||
if let Some(sources) = &self.face_sources {
|
||||
let mut sources = sources.as_ref().clone();
|
||||
for source in sources.iter_mut().flatten() {
|
||||
if let FaceSource::ShellPlanar { producer: id, .. } = source {
|
||||
*id = Some(producer);
|
||||
}
|
||||
}
|
||||
self.face_sources = Some(Arc::new(sources));
|
||||
}
|
||||
self.regularize_certified_planar_faces()
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{box_solid, fillet, shell, transform_matrix};
|
||||
|
||||
#[test]
|
||||
fn rounded_shell_planar_roles_are_exact_distinct_and_transformable() {
|
||||
for (width, thickness) in [(80., 2.), (100., 3.)] {
|
||||
let box_body = box_solid(Vec3::new(-width / 2., -30., 0.), Vec3::new(width / 2., 30., 30.)).unwrap().with_box_certificate(7).unwrap();
|
||||
let rounded = fillet(
|
||||
&box_body,
|
||||
&[
|
||||
Vec3::new(width / 2., 30., 15.),
|
||||
Vec3::new(-width / 2., 30., 15.),
|
||||
Vec3::new(-width / 2., -30., 15.),
|
||||
Vec3::new(width / 2., -30., 15.),
|
||||
],
|
||||
6.,
|
||||
)
|
||||
.unwrap()
|
||||
.with_preserved_planar_sources(&box_body)
|
||||
.unwrap();
|
||||
assert_eq!(rounded.face_sources().unwrap().iter().flatten().count(), 6);
|
||||
let cup = shell(&rounded, &[Vec3::new(0., 0., 30.)], thickness).unwrap().with_shell_certificate(9).unwrap();
|
||||
let sources = cup.face_sources().unwrap();
|
||||
assert!(
|
||||
!sources
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| matches!(s,FaceSource::ShellPlanar {source, ..} if matches!(source.as_ref(),FaceSource::Box {axis:2,high:true,..})))
|
||||
);
|
||||
let right = sources.iter().position(|s| matches!(s, Some(FaceSource::ShellPlanar { inner:false, source, .. }) if matches!(source.as_ref(),FaceSource::Box {axis:0,high:true,..}))).unwrap();
|
||||
let floor = sources.iter().position(|s| matches!(s, Some(FaceSource::ShellPlanar { inner:true, source, .. }) if matches!(source.as_ref(),FaceSource::Box {axis:2,high:false,..}))).unwrap();
|
||||
assert!((cup.face_plane(right).unwrap().origin.x - width / 2.).abs() < 1e-9);
|
||||
let floor_plane = cup.face_plane(floor).unwrap();
|
||||
assert!((floor_plane.origin.z - thickness).abs() < 1e-9);
|
||||
assert!(floor_plane.normal().dot(Vec3::Z) > 0.999999);
|
||||
for (i, s) in sources.iter().enumerate() {
|
||||
if matches!(s, Some(FaceSource::ExtrudeSide { coalesced: true, plane: None, .. })) {
|
||||
assert!(cup.face_plane(i).is_none(), "loss witness is not source authority");
|
||||
} else if s.is_some() {
|
||||
assert!(same_support(&cup.face_plane(i).unwrap(), &cup.planar_face(i).unwrap()));
|
||||
}
|
||||
}
|
||||
let moved = transform_matrix(&cup, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [100., 200., 300., 1.]]).unwrap();
|
||||
assert!((moved.face_plane(floor).unwrap().origin.z - (300. + thickness)).abs() < 1e-9);
|
||||
assert!(matches!(moved.face_sources().unwrap().get(right), Some(Some(FaceSource::ShellPlanar { producer: Some(9), inner: false, .. }))));
|
||||
assert!(moved.face_plane(right).unwrap().normal().dot(Vec3::Y) > 0.999999);
|
||||
let raw = cup.tessellate(0.01).unwrap();
|
||||
let display = cup.display_mesh(0.01).unwrap();
|
||||
let cylindrical: Vec<_> = cup
|
||||
.solid
|
||||
.face_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, f)| {
|
||||
let crate::FaceSurface::Cylinder { radius, .. } = crate::face_surface(&cup, i)? else { return None };
|
||||
let lo = f.vertex_iter().map(|v| v.point().z).fold(f64::INFINITY, f64::min);
|
||||
let hi = f.vertex_iter().map(|v| v.point().z).fold(f64::NEG_INFINITY, f64::max);
|
||||
Some((i, radius, lo, hi))
|
||||
})
|
||||
.collect();
|
||||
eprintln!("roundedShell width{width} thickness{thickness} raw{} cylindrical roles{cylindrical:?}", cup.solid.face_iter().count());
|
||||
let mut corner_joins = 0;
|
||||
for (edge, adjacent) in raw.edge_faces.iter().enumerate() {
|
||||
let [a, b] = adjacent.as_slice() else { continue };
|
||||
if raw.edges[edge].iter().all(|p| (p.z - thickness).abs() < 1e-8)
|
||||
&& cylindrical.iter().any(|(i, r, _, _)| *i == *a as usize && (*r - 6.).abs() < 1e-6)
|
||||
&& cylindrical.iter().any(|(i, r, _, _)| *i == *b as usize && (*r - 6.).abs() < 1e-6)
|
||||
{
|
||||
corner_joins += 1;
|
||||
assert!(display.seams[edge], "existing cylindrical corner joins must not draw a floor-height arc");
|
||||
}
|
||||
}
|
||||
assert_eq!(corner_joins, 0, "no physical lower corner strip remains");
|
||||
assert_eq!(cup.face_count(), 19);
|
||||
let outer: Vec<_> = cylindrical.iter().filter(|(_, r, _, _)| (*r - 6.).abs() < 1e-6).collect();
|
||||
assert_eq!(outer.len(), 4);
|
||||
assert!(outer.iter().all(|(i, _, _, _)| cup.face_was_coalesced(*i) && moved.face_was_coalesced(*i)));
|
||||
assert!(outer.iter().all(|(_, _, lo, hi)| lo.abs() < 1e-9 && (*hi - 30.).abs() < 1e-9));
|
||||
|
||||
for axis in 0..2 {
|
||||
for high in [false, true] {
|
||||
let matching: Vec<_> = sources.iter().enumerate().filter_map(|(i,s)| matches!(s,Some(FaceSource::ShellPlanar {inner:false,source,..}) if matches!(source.as_ref(),FaceSource::Box {axis:a,high:h,..} if *a==axis && *h==high)).then_some(i)).collect();
|
||||
assert_eq!(matching.len(), 1, "each straight exterior wall is one actual face");
|
||||
let face = cup.solid.face_iter().nth(matching[0]).unwrap();
|
||||
let z: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
|
||||
assert!(z.iter().any(|z| z.abs() < 1e-9));
|
||||
assert!(z.iter().any(|z| (*z - 30.).abs() < 1e-9));
|
||||
}
|
||||
}
|
||||
assert!(display.contains(Vec3::new(width / 2. - thickness / 2., 0., thickness / 2.)));
|
||||
assert!(display.contains(Vec3::new(width / 2. - thickness / 2., 0., 15.)));
|
||||
assert!(!display.contains(Vec3::new(0., 0., 15.)));
|
||||
assert_eq!(raw.measure().volume, display.measure().volume);
|
||||
let expected = (width * 60. - (4. - std::f64::consts::PI) * 36.) * 30.
|
||||
- ((width - 2. * thickness) * (60. - 2. * thickness) - (4. - std::f64::consts::PI) * (6. - thickness).powi(2)) * (30. - thickness);
|
||||
assert!((cup.tessellate(1e-4).unwrap().measure().volume - expected).abs() < 0.2);
|
||||
let imported = crate::step_import(&crate::step_export(&[&cup], "regularized rounded shell").unwrap()).unwrap();
|
||||
assert!(imported.warnings.is_empty());
|
||||
let copy = &imported.bodies[0].body;
|
||||
assert_eq!(copy.face_count(), 19);
|
||||
// STEP may additionally coalesce cylindrical corner patches. The
|
||||
// certified planar wall topology itself must survive unchanged.
|
||||
for (axis, coordinate) in [(Vec3::X, width / 2.), (-Vec3::X, width / 2.), (Vec3::Y, 30.), (-Vec3::Y, 30.)] {
|
||||
let walls: Vec<_> = copy
|
||||
.solid
|
||||
.face_iter()
|
||||
.filter(|f| actual_plane(f).is_some_and(|p| p.normal().dot(axis) > 0.999 && (p.origin.dot(axis) - coordinate).abs() < 1e-8))
|
||||
.collect();
|
||||
assert_eq!(walls.len(), 1);
|
||||
let z: Vec<_> = walls[0].vertex_iter().map(|v| v.point().z).collect();
|
||||
assert!(z.iter().any(|z| z.abs() < 1e-8));
|
||||
assert!(z.iter().any(|z| (*z - 30.).abs() < 1e-8));
|
||||
}
|
||||
let copy_mesh = copy.tessellate(1e-4).unwrap();
|
||||
assert!((copy_mesh.measure().volume - expected).abs() < 0.2);
|
||||
assert!(copy_mesh.contains(Vec3::new(width / 2. - thickness / 2., 0., 15.)));
|
||||
assert!(!copy_mesh.contains(Vec3::new(0., 0., 15.)));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn simple_shell_has_one_full_height_face_per_outer_wall_and_exports_that_topology() {
|
||||
let source = box_solid(Vec3::ZERO, Vec3::new(40., 30., 20.)).unwrap().with_box_certificate(1).unwrap();
|
||||
let pending = shell(&source, &[Vec3::new(20., 15., 20.)], 2.).unwrap();
|
||||
let cup = pending.clone().with_shell_certificate(2).unwrap();
|
||||
assert_eq!(cup.solid.face_iter().count(), 11);
|
||||
let mesh = cup.tessellate(0.01).unwrap();
|
||||
assert!((mesh.measure().volume - (40. * 30. * 20. - 36. * 26. * 18.)).abs() < 1e-6);
|
||||
assert!(mesh.contains(Vec3::new(20., 1., 1.)));
|
||||
assert!(mesh.contains(Vec3::new(20., 1., 10.)));
|
||||
assert!(!mesh.contains(Vec3::new(20., 15., 10.)));
|
||||
let imported = crate::step_import(&crate::step_export(&[&cup], "regularized shell").unwrap()).unwrap();
|
||||
assert!(imported.warnings.is_empty());
|
||||
let copy = &imported.bodies[0].body;
|
||||
assert_eq!(copy.solid.face_iter().count(), 11);
|
||||
assert!((copy.tessellate(0.01).unwrap().measure().volume - mesh.measure().volume).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn coincident_competing_source_planes_and_tool_planes_stay_unknown() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(1).unwrap();
|
||||
let b = box_solid(Vec3::new(20., 0., 0.), Vec3::new(30., 10., 10.)).unwrap().with_box_certificate(2).unwrap();
|
||||
let mut shells = a.solid.boundaries().to_vec();
|
||||
shells.extend(b.solid.boundaries().iter().cloned());
|
||||
let mut combined = Body::new(mt::Solid::try_new(shells).unwrap()).unwrap();
|
||||
combined.face_sources = Some(Arc::new(a.face_sources().unwrap().iter().chain(b.face_sources().unwrap()).cloned().collect()));
|
||||
let result = a.clone().with_preserved_planar_sources(&combined).unwrap();
|
||||
for (i, face) in result.solid.face_iter().enumerate() {
|
||||
if actual_plane(face).unwrap().normal().dot(Vec3::Z).abs() > 0.999 {
|
||||
assert!(result.face_sources().unwrap()[i].is_none());
|
||||
}
|
||||
}
|
||||
let mut mixed = combined.clone();
|
||||
mixed.face_sources =
|
||||
Some(Arc::new(a.face_sources().unwrap().iter().cloned().chain(std::iter::repeat_n(None, b.solid.face_iter().count())).collect()));
|
||||
let guarded = a.clone().with_preserved_planar_sources(&mixed).unwrap();
|
||||
for (i, face) in guarded.solid.face_iter().enumerate() {
|
||||
if actual_plane(face).unwrap().normal().dot(Vec3::Z).abs() > 0.999 {
|
||||
assert!(guarded.face_sources().unwrap()[i].is_none(), "uncertified competing support must not gain identity");
|
||||
}
|
||||
}
|
||||
let cup = shell(&a, &[Vec3::new(5., 5., 10.)], 1.).unwrap().with_shell_certificate(3).unwrap();
|
||||
let tool = box_solid(Vec3::new(2., 2., 1.), Vec3::new(4., 4., 3.)).unwrap();
|
||||
let preserved = cup.clone().with_preserved_boolean_planar_sources(&cup, &tool, false).unwrap();
|
||||
for (i, face) in preserved.solid.face_iter().enumerate() {
|
||||
let Some(p) = actual_plane(face) else { continue };
|
||||
if p.normal().dot(Vec3::Z) > 0.999 && (p.origin.z - 1.).abs() < 1e-9 {
|
||||
assert!(preserved.face_sources().unwrap()[i].is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pocket_cut_removes_internal_wall_faces_in_actual_topology() {
|
||||
use solvecraft_geom::{Loop2, Region2, Vec2};
|
||||
let region = Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(-30., -27.), Vec2::new(30., -27.), Vec2::new(30., 27.), Vec2::new(-30., 27.)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let original = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0., 10.).unwrap().pop().unwrap();
|
||||
let curves: Vec<_> = original
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, source)| matches!(source, Some(FaceSource::ExtrudeSide { .. })).then(|| format!("l{i}")))
|
||||
.collect();
|
||||
let original = original.with_extrude_certificates(2, 1, &curves).unwrap();
|
||||
let (x, y) = (24.73317426966596, 21.733174269665962);
|
||||
let cutter = box_solid(Vec3::new(-x, -y, 1.), Vec3::new(x, y, 10.)).unwrap();
|
||||
let pocket = crate::boolean(&original, &cutter, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let raw = pocket.tessellate(0.01).unwrap();
|
||||
let display = pocket.display_mesh(0.01).unwrap();
|
||||
let sources = pocket.face_sources().unwrap();
|
||||
assert_eq!(pocket.solid.face_iter().count(), 11, "four outer walls, four inner walls, rim, floor, bottom");
|
||||
let sides: Vec<_> =
|
||||
sources.iter().enumerate().filter_map(|(i, s)| matches!(s, Some(FaceSource::CertifiedExtrudeSide { .. })).then_some(i)).collect();
|
||||
assert_eq!(sides.len(), 4);
|
||||
for i in sides {
|
||||
let face = pocket.solid.face_iter().nth(i).unwrap();
|
||||
let z: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
|
||||
assert!(z.iter().any(|z| z.abs() < 1e-9));
|
||||
assert!(z.iter().any(|z| (*z - 10.).abs() < 1e-9));
|
||||
assert!(!face.edge_iter().any(|e| (e.front().point().z - 1.).abs() < 1e-9 && (e.back().point().z - 1.).abs() < 1e-9));
|
||||
}
|
||||
assert!(display.contains(Vec3::new(0., 0., 0.5)));
|
||||
assert!(!display.contains(Vec3::new(0., 0., 5.)));
|
||||
assert!(display.contains(Vec3::new(28., 0., 5.)));
|
||||
assert!((display.measure().volume - (60. * 54. * 10. - 4. * x * y * 9.)).abs() < 1e-7);
|
||||
assert_eq!(raw.measure().volume, display.measure().volume);
|
||||
let imported = crate::step_import(&crate::step_export(&[&pocket], "regularized pocket").unwrap()).unwrap();
|
||||
assert!(imported.warnings.is_empty());
|
||||
let copy = &imported.bodies[0].body;
|
||||
assert_eq!(copy.solid.face_iter().count(), 11);
|
||||
let copy_mesh = copy.tessellate(0.01).unwrap();
|
||||
assert!((copy_mesh.measure().volume - raw.measure().volume).abs() < 1e-6);
|
||||
assert!(copy_mesh.contains(Vec3::new(0., 0., 0.5)));
|
||||
assert!(!copy_mesh.contains(Vec3::new(0., 0., 5.)));
|
||||
let competing = box_solid(Vec3::new(30., -1., 0.), Vec3::new(31., 1., 2.)).unwrap();
|
||||
let guarded = pocket.clone().with_preserved_boolean_planar_sources(&pocket, &competing, true).unwrap();
|
||||
for (index, source) in guarded.face_sources().unwrap().iter().enumerate() {
|
||||
if pocket.face_plane(index).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && (p.origin.x - 30.).abs() < 1e-9) {
|
||||
assert!(source.is_none(), "competing tool plane cannot retain source identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,10 @@ pub fn planar_convex(b: &Body) -> Result<Vec<(HalfSpace, Vec3)>> {
|
||||
|
||||
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed.
|
||||
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open)
|
||||
}
|
||||
|
||||
fn shell_impl(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
b.require_brep("shell")?;
|
||||
if !(thickness.is_finite() && thickness > 1e-6) {
|
||||
return Err(KernelError::Invalid("shell thickness must be positive".into()));
|
||||
|
||||
+156
-26
@@ -683,12 +683,17 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
|
||||
};
|
||||
slabs.push(oriented(&s));
|
||||
}
|
||||
sew_sections(plane, d, &levels, &slabs, tol)
|
||||
sew_sections(plane, d, &levels, &slabs, tol, false)
|
||||
}
|
||||
|
||||
/// Sew the exact sections between successive levels, including empty gaps.
|
||||
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
|
||||
if levels.len() != slabs.len() + 1 || slabs.len() > 128 || plane.normal().dot(d) < 1. - 1e-12 {
|
||||
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
|
||||
if levels.len() != slabs.len() + 1
|
||||
|| slabs.len() > 128
|
||||
|| plane.normal().dot(d) < 1. - 1e-12
|
||||
|| levels.iter().any(|z| !z.is_finite())
|
||||
|| levels.windows(2).any(|z| z[0] >= z[1])
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut blocks = Vec::new();
|
||||
@@ -732,7 +737,7 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
|
||||
pieces.push(piece);
|
||||
}
|
||||
} else {
|
||||
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol)
|
||||
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol, coalesce)
|
||||
.ok_or_else(|| KernelError::Failed("stepped prism construction failed".into()))??;
|
||||
remaining_faces =
|
||||
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
|
||||
@@ -765,8 +770,18 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
|
||||
})())
|
||||
}
|
||||
|
||||
/// Stable exact identity for the bounded analytic segment subset.
|
||||
fn wall_key(s: &Seg2) -> Option<[u64; 6]> {
|
||||
let bits = |v: f64| v.is_finite().then(|| if v == 0. { 0 } else { v.to_bits() });
|
||||
match s {
|
||||
Seg2::Line { a, b } => Some([0, bits(a.x)?, bits(a.y)?, bits(b.x)?, bits(b.y)?, 0]),
|
||||
Seg2::Arc { center, radius, start, sweep } => Some([1, bits(center.x)?, bits(center.y)?, bits(*radius)?, bits(*start)?, bits(*sweep)?]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A genuinely stepped connected axial block has no single extrusion footprint.
|
||||
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
|
||||
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
|
||||
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
|
||||
// Faces at each level: up where the slab below sticks out, down where the one above does.
|
||||
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
|
||||
@@ -800,30 +815,61 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
|
||||
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
|
||||
}
|
||||
};
|
||||
let mut faces: Vec<FaceSpec> = Vec::new();
|
||||
for (i, s) in slabs.iter().enumerate() {
|
||||
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
|
||||
for lp in loops_of(s) {
|
||||
// Exact oriented generated segments alone define a run. No support fitting,
|
||||
// proximity matching, or old face lineage is used here.
|
||||
let mut runs: Vec<(usize, usize, Seg2)> = Vec::new();
|
||||
let mut last = std::collections::BTreeMap::new();
|
||||
let mut remaining = MAX_PLANAR_SEGMENTS * 2;
|
||||
for (i, slab) in slabs.iter().enumerate() {
|
||||
for lp in loops_of(slab) {
|
||||
for sg0 in &lp.segs {
|
||||
for sg in split_seg(sg0, &pts, tol)? {
|
||||
let (a, b) = (sg.start(), sg.end());
|
||||
let lp3 = vec![
|
||||
edge(&sg, z0),
|
||||
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
|
||||
edge(&sg.reversed(), z1),
|
||||
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
|
||||
];
|
||||
let tangent = plane.dir_to_world(sg.derivative(0.5));
|
||||
let outward = tangent.cross(d).normalized()?;
|
||||
let surface = match sg {
|
||||
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
|
||||
_ => SurfSpec::Plane,
|
||||
};
|
||||
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
|
||||
remaining = remaining.checked_sub(1)?;
|
||||
let key = wall_key(&sg)?;
|
||||
if coalesce {
|
||||
if let Some(&index) = last.get(&key) {
|
||||
let run: &mut (usize, usize, Seg2) = runs.get_mut(index)?;
|
||||
if run.1 == i {
|
||||
run.1 = i + 1;
|
||||
continue;
|
||||
}
|
||||
// Duplicate oriented boundaries in one slab are not
|
||||
// a valid reason to reconstruct a larger wall.
|
||||
if run.1 == i + 1 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
last.insert(key, runs.len());
|
||||
}
|
||||
runs.push((i, i + 1, sg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut faces: Vec<FaceSpec> = Vec::new();
|
||||
let mut sources = Vec::new();
|
||||
for (first, end, sg) in runs {
|
||||
let (z0, z1) = (levels[first], levels[end]);
|
||||
let (a, b) = (sg.start(), sg.end());
|
||||
let mut lp3 = Vec::with_capacity(2 + 2 * (end - first));
|
||||
lp3.push(edge(&sg, z0));
|
||||
// Keep station vertices at both ends: neighboring stepped walls/caps
|
||||
// must share complete edges rather than create T-junctions.
|
||||
for k in first..end {
|
||||
lp3.push(EdgeSpec::Line { a: at(b, levels[k]), b: at(b, levels[k + 1]) });
|
||||
}
|
||||
lp3.push(edge(&sg.reversed(), z1));
|
||||
for k in (first..end).rev() {
|
||||
lp3.push(EdgeSpec::Line { a: at(a, levels[k + 1]), b: at(a, levels[k]) });
|
||||
}
|
||||
let outward = plane.dir_to_world(sg.derivative(0.5)).cross(d).normalized()?;
|
||||
let surface = match sg {
|
||||
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
|
||||
_ => SurfSpec::Plane,
|
||||
};
|
||||
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
|
||||
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true }));
|
||||
}
|
||||
for (z, up, rs) in &caps {
|
||||
for r in rs {
|
||||
let mut loops = Vec::new();
|
||||
@@ -836,9 +882,10 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
|
||||
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
|
||||
}
|
||||
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
|
||||
sources.push(None);
|
||||
}
|
||||
}
|
||||
Some(crate::sew::sew_touching(&faces))
|
||||
Some(crate::sew::sew_touching_with_sources(&faces, &sources))
|
||||
}
|
||||
|
||||
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
@@ -1055,7 +1102,19 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
|
||||
if slabs.iter().all(Vec::is_empty) {
|
||||
return Some(Ok(None));
|
||||
}
|
||||
sew_sections(&plane, d, &levels, &slabs, tol).map(|r| r.map(Some))
|
||||
sew_sections(
|
||||
&plane,
|
||||
d,
|
||||
&levels,
|
||||
&slabs,
|
||||
tol,
|
||||
op == BoolOp::Cut
|
||||
&& a.split_keep().is_empty()
|
||||
&& b.split_keep().is_empty()
|
||||
&& a.paint().is_none_or(|p| p.faces.is_empty())
|
||||
&& b.paint().is_none_or(|p| p.faces.is_empty()),
|
||||
)
|
||||
.map(|r| r.map(Some))
|
||||
}
|
||||
|
||||
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
@@ -1144,7 +1203,12 @@ mod planar_tests {
|
||||
assert!(mesh.contains(Vec3::new(21., 21., 3.)));
|
||||
assert!(!mesh.contains(Vec3::new(25., 25., 3.)));
|
||||
assert!(mesh.contains(Vec3::new(25., 25., 22.)));
|
||||
assert!(lump.face_sources().is_none_or(|s| s.iter().all(Option::is_none)), "genuinely stepped geometry remains uncertified");
|
||||
assert!(
|
||||
lump.face_sources().is_none_or(|s| s
|
||||
.iter()
|
||||
.all(|s| s.is_none() || matches!(s, Some(crate::FaceSource::ExtrudeSide { coalesced: true, plane: None, .. })))),
|
||||
"genuinely stepped geometry has no durable certificate"
|
||||
);
|
||||
assert!((mesh.measure().volume - (18. * (100. - 4. * std::f64::consts::PI) + 500.)).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1216,72 @@ mod planar_tests {
|
||||
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
|
||||
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
|
||||
}
|
||||
#[test]
|
||||
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
|
||||
for curved in [false, true] {
|
||||
let outer = if curved {
|
||||
Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] }
|
||||
} else {
|
||||
rectangle(Vec2::new(-10., -10.), Vec2::new(10., 10.))
|
||||
};
|
||||
let mut upper = outer.clone();
|
||||
upper.holes.push(if curved {
|
||||
Loop2::circle(Vec2::ZERO, 5.).reversed()
|
||||
} else {
|
||||
rectangle(Vec2::new(-5., -5.), Vec2::new(5., 5.)).outer.reversed()
|
||||
});
|
||||
let slabs = vec![vec![outer], vec![upper]];
|
||||
let old = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, false).unwrap().unwrap();
|
||||
let merged = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, true).unwrap().unwrap();
|
||||
let count = if curved { 2 } else { 4 };
|
||||
assert_eq!(old.face_count() - merged.face_count(), count);
|
||||
let marked: Vec<_> = merged.solid.face_iter().enumerate().filter(|(i, _)| merged.face_was_coalesced(*i)).collect();
|
||||
assert_eq!(marked.len(), count);
|
||||
for (_, face) in marked {
|
||||
assert_eq!(face.boundaries()[0].len(), 6, "all axial station vertices are retained");
|
||||
let heights: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
|
||||
for z in [0., 2., 20.] {
|
||||
assert!(heights.contains(&z));
|
||||
}
|
||||
}
|
||||
assert!(old.face_sources().is_none_or(|s| s.iter().all(Option::is_none)));
|
||||
let expected = if curved { std::f64::consts::PI * (100. * 20. - 25. * 18.) } else { 400. * 20. - 100. * 18. };
|
||||
let mesh = merged.tessellate(1e-5).unwrap();
|
||||
assert!((mesh.measure().volume - expected).abs() < 0.03);
|
||||
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
|
||||
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
|
||||
assert!(merged.validity().is_empty());
|
||||
assert_ne!(wall_key(&slabs[0][0].outer.segs[0]), wall_key(&slabs[0][0].outer.segs[0].reversed()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_run_coalescing_respects_authored_splits_and_face_paint() {
|
||||
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] };
|
||||
let tool_region = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.), holes: vec![] };
|
||||
let stock = crate::extrude(&Plane::XY, &[region], 0., 20.).unwrap().pop().unwrap();
|
||||
let cutter = crate::extrude(&Plane::XY, &[tool_region], 2., 22.).unwrap().pop().unwrap();
|
||||
let merged = stack_boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
|
||||
let protected = [
|
||||
stock.clone().with_split_lines(Some(std::sync::Arc::new(vec![vec![Vec3::new(10., 0., 0.), Vec3::new(10., 0., 20.)]]))),
|
||||
stock.with_paint(Some(crate::Paint { opacity: 1., faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] })),
|
||||
];
|
||||
for input in protected {
|
||||
let output = stack_boolean(&input, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
|
||||
assert_eq!(output.face_count(), merged.face_count() + 2);
|
||||
assert!((0..output.face_count()).all(|i| !output.face_was_coalesced(i)));
|
||||
// Independent circular chord-volume bound: 2*pi*(10*20+5*18)*1e-5 < .019.
|
||||
let expected = std::f64::consts::PI * (100. * 20. - 25. * 18.);
|
||||
for body in [&output, &merged] {
|
||||
let mesh = body.tessellate(1e-5).unwrap();
|
||||
assert!((mesh.measure().volume - expected).abs() < 0.03);
|
||||
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
|
||||
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
|
||||
assert!(mesh.contains(Vec3::new(7., 0., 10.)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() {
|
||||
let plane = Plane::XY;
|
||||
|
||||
@@ -97,13 +97,16 @@ impl Cache {
|
||||
|
||||
/// Sew the faces into a closed solid.
|
||||
pub fn sew(faces: &[FaceSpec]) -> Result<Body> {
|
||||
sew_impl(faces, false)
|
||||
sew_impl(faces, false, None)
|
||||
}
|
||||
|
||||
/// Separate boundary sheets at a shared geometric edge without moving it.
|
||||
/// The face adjacency must still describe one connected solid boundary.
|
||||
pub(crate) fn sew_touching(faces: &[FaceSpec]) -> Result<Body> {
|
||||
sew_impl(faces, true)
|
||||
/// Optional transient sources follow only unchanged produced face identities.
|
||||
pub(crate) fn sew_touching_with_sources(faces: &[FaceSpec], sources: &[Option<crate::FaceSource>]) -> Result<Body> {
|
||||
if sources.len() != faces.len() {
|
||||
return Err(KernelError::Invalid("sew source alignment".into()));
|
||||
}
|
||||
sew_impl(faces, true, Some(sources))
|
||||
}
|
||||
|
||||
type EdgeUse = (usize, usize, usize);
|
||||
@@ -347,7 +350,7 @@ fn split_vertex_fans(faces: Vec<mt::Face>) -> Result<Vec<mt::Face>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
|
||||
fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option<crate::FaceSource>]>) -> Result<Body> {
|
||||
if faces.len() < 4 || faces.len() > 200_000 {
|
||||
return Err(KernelError::Invalid("a solid needs 4…200000 faces".into()));
|
||||
}
|
||||
@@ -396,6 +399,11 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
|
||||
};
|
||||
out.push(face);
|
||||
}
|
||||
let source_ids: HashMap<_, _> = out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, f)| sources.and_then(|s| s.get(i)).and_then(Option::as_ref).map(|s| (f.id(), (f.orientation(), s))))
|
||||
.collect();
|
||||
if !sheets.is_empty() && !faces_connected(&out) {
|
||||
return Err(KernelError::Invalid("sew: bodies touch only along an edge".into()));
|
||||
}
|
||||
@@ -404,7 +412,10 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
|
||||
}
|
||||
let shells = closed_components(out)?;
|
||||
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?;
|
||||
Body::new(solid)
|
||||
let body = Body::new(solid)?;
|
||||
let mapped =
|
||||
body.solid.face_iter().map(|f| source_ids.get(&f.id()).filter(|(o, _)| *o == f.orientation()).map(|(_, s)| (*s).clone())).collect();
|
||||
Ok(body.with_face_sources(sources.map(|_| std::sync::Arc::new(mapped))))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user