///|
/// Defaults for coordination checks: report volumetric intersections but not
/// merely touching faces.
pub fn default_clash_options() -> ClashOptions {
{
tolerance: 0.0001,
minimum_overlap_volume: 0.000001,
include_touching: false,
include_same_container: true,
ignored_types: [],
maximum_pairs: 0,
}
}
///|
pub fn ClashOptions::with_tolerance(
self : ClashOptions,
tolerance : Double,
) -> ClashOptions {
{ ..self, tolerance: if tolerance < 0.0 { 0.0 } else { tolerance } }
}
///|
pub fn ClashOptions::with_minimum_overlap(
self : ClashOptions,
volume : Double,
) -> ClashOptions {
{ ..self, minimum_overlap_volume: if volume < 0.0 { 0.0 } else { volume } }
}
///|
pub fn ClashOptions::including_touching(
self : ClashOptions,
value : Bool,
) -> ClashOptions {
{ ..self, include_touching: value }
}
///|
pub fn ClashOptions::including_same_container(
self : ClashOptions,
value : Bool,
) -> ClashOptions {
{ ..self, include_same_container: value }
}
///|
pub fn ClashOptions::ignoring_type(
self : ClashOptions,
name : String,
) -> ClashOptions {
let result = self.ignored_types.copy()
let normalized = name.to_upper()
if normalized.length() > 0 && !result.contains(normalized) {
result.push(normalized)
}
{ ..self, ignored_types: result }
}
///|
pub fn ClashOptions::with_maximum_pairs(
self : ClashOptions,
count : Int,
) -> ClashOptions {
{ ..self, maximum_pairs: if count < 0 { 0 } else { count } }
}
///|
fn clash_type_ignored(options : ClashOptions, entry : SpatialEntry) -> Bool {
options.ignored_types.contains(entry.ifc_type.to_upper())
}
///|
fn clash_same_container(left : SpatialEntry, right : SpatialEntry) -> Bool {
match (left.container, right.container) {
(Some(left_id), Some(right_id)) => left_id == right_id
_ => false
}
}
///|
fn clash_pair_key(left : SpatialEntry, right : SpatialEntry) -> String {
let first = if left.product_id < right.product_id {
left.product_id
} else {
right.product_id
}
let second = if left.product_id < right.product_id {
right.product_id
} else {
left.product_id
}
first.to_string() + ":" + second.to_string()
}
///|
fn clash_expanded_bounds(bounds : BoundingBox, amount : Double) -> BoundingBox {
let margin = if amount < 0.0 { 0.0 } else { amount }
if bounds.empty {
bounds
} else {
{
min: {
x: bounds.min.x - margin,
y: bounds.min.y - margin,
z: bounds.min.z - margin,
},
max: {
x: bounds.max.x + margin,
y: bounds.max.y + margin,
z: bounds.max.z + margin,
},
empty: false,
}
}
}
///|
fn clash_overlap_box(
left : SpatialEntry,
right : SpatialEntry,
tolerance : Double,
) -> BoundingBox {
box_intersection(
clash_expanded_bounds(left.bounds, tolerance),
clash_expanded_bounds(right.bounds, tolerance),
)
}
///|
fn clash_clearance(left : SpatialEntry, right : SpatialEntry) -> Double {
box_distance(left.bounds, right.bounds)
}
///|
fn clash_pair_for(
left : SpatialEntry,
right : SpatialEntry,
options : ClashOptions,
) -> ClashPair? {
let relation = box_relation(left.bounds, right.bounds, options.tolerance)
let touching = relation == Touching
let overlap = clash_overlap_box(left, right, options.tolerance)
let volume = overlap.volume()
if relation == Disjoint ||
(touching && !options.include_touching) ||
volume < options.minimum_overlap_volume {
None
} else {
Some({
left,
right,
relation,
overlap,
overlap_volume: volume,
clearance: clash_clearance(left, right),
})
}
}
///|
fn clash_pair_precedes(left : ClashPair, right : ClashPair) -> Bool {
if left.overlap_volume == right.overlap_volume {
clash_pair_key(left.left, left.right) <
clash_pair_key(right.left, right.right)
} else {
left.overlap_volume > right.overlap_volume
}
}
///|
fn clash_sort_pairs(pairs : Array[ClashPair]) -> Array[ClashPair] {
let result = pairs.copy()
for i in 0.. String {
let left = pair.left.ifc_type.to_upper()
let right = pair.right.ifc_type.to_upper()
if left < right {
left + " / " + right
} else {
right + " / " + left
}
}
///|
fn clash_by_type(pairs : Array[ClashPair]) -> Map[String, Int] {
pairs.fold(init=Map([]), (result, pair) => {
result.update_or_default(clash_type_key(pair), 1, count => count + 1)
result
})
}
///|
/// Detect product-level clashes using the spatial grid as the broad phase.
pub fn detect_clashes(
spatial : SpatialIndex,
options? : ClashOptions = default_clash_options(),
) -> ClashReport {
let pairs : Array[ClashPair] = []
let seen : Map[String, Bool] = Map([])
let mut candidates = 0
let mut ignored = 0
for left in spatial.entries() {
if clash_type_ignored(options, left) {
ignored += 1
continue
}
let query = clash_expanded_bounds(left.bounds, options.tolerance)
for hit in spatial.query_box(query, tolerance=options.tolerance) {
if hit.product_id <= left.product_id {
continue
}
match spatial.entry(hit.product_id) {
Some(right) => {
let key = clash_pair_key(left, right)
if seen.contains(key) {
continue
}
seen[key] = true
candidates += 1
if clash_type_ignored(options, right) {
ignored += 1
continue
}
if !options.include_same_container &&
clash_same_container(left, right) {
ignored += 1
continue
}
match clash_pair_for(left, right, options) {
Some(pair) => pairs.push(pair)
None => ()
}
}
None => ()
}
}
}
let sorted = clash_sort_pairs(pairs)
let limited = if options.maximum_pairs > 0 &&
sorted.length() > options.maximum_pairs {
sorted[:options.maximum_pairs].to_owned()
} else {
sorted
}
{
pairs: limited,
candidate_count: candidates,
ignored_count: ignored,
overlap_volume: limited.fold(init=0.0, (result, pair) => {
result + pair.overlap_volume
}),
by_type: clash_by_type(limited),
}
}
///|
/// Construct a spatial index and run a clash report in one call.
pub fn clash_report(
model : Model,
options? : ClashOptions = default_clash_options(),
) -> ClashReport raise ParseError {
detect_clashes(SpatialIndex::new(model), options~)
}
///|
pub fn ClashReport::is_empty(self : ClashReport) -> Bool {
self.pairs.length() == 0
}
///|
pub fn ClashReport::count(self : ClashReport) -> Int {
self.pairs.length()
}
///|
pub fn ClashReport::highest_overlap(self : ClashReport) -> ClashPair? {
self.pairs.get(0)
}
///|
pub fn ClashReport::pairs_for_type(
self : ClashReport,
left_type : String,
right_type : String,
) -> Array[ClashPair] {
let left = left_type.to_upper()
let right = right_type.to_upper()
self.pairs.filter(pair => {
(
pair.left.ifc_type.to_upper() == left &&
pair.right.ifc_type.to_upper() == right
) ||
(
pair.left.ifc_type.to_upper() == right &&
pair.right.ifc_type.to_upper() == left
)
})
}
///|
pub fn ClashReport::pairs_for_product(
self : ClashReport,
product_id : Int,
) -> Array[ClashPair] {
self.pairs.filter(pair => {
pair.left.product_id == product_id || pair.right.product_id == product_id
})
}
///|
pub fn ClashReport::product_ids(self : ClashReport) -> Array[Int] {
let result : Array[Int] = []
for pair in self.pairs {
if !result.contains(pair.left.product_id) {
result.push(pair.left.product_id)
}
if !result.contains(pair.right.product_id) {
result.push(pair.right.product_id)
}
}
result
}
///|
pub fn ClashReport::severity(self : ClashReport) -> String {
if self.pairs.length() == 0 {
"clear"
} else if self.overlap_volume > 100.0 {
"high"
} else if self.overlap_volume > 1.0 {
"medium"
} else {
"low"
}
}
///|
pub fn ClashReport::headline(self : ClashReport) -> String {
"clashes=" +
self.pairs.length().to_string() +
" candidates=" +
self.candidate_count.to_string() +
" overlap_volume=" +
self.overlap_volume.to_string() +
" severity=" +
self.severity()
}
///|
pub fn ClashPair::headline(self : ClashPair) -> String {
"#" +
self.left.product_id.to_string() +
" <-> #" +
self.right.product_id.to_string() +
" " +
box_relation_text(self.relation) +
" volume=" +
self.overlap_volume.to_string()
}
///|
pub fn ClashPair::type_key(self : ClashPair) -> String {
clash_type_key(self)
}
///|
pub fn ClashPair::is_touching(self : ClashPair) -> Bool {
self.relation == Touching
}
///|
pub fn ClashPair::has_volume(self : ClashPair) -> Bool {
self.overlap_volume > 0.0
}
///|
pub fn ClashPair::intersection_center(self : ClashPair) -> Point3 {
self.overlap.center()
}
///|
pub fn ClashPair::minimum_separation(self : ClashPair) -> Double {
self.clearance
}
///|
fn clash_pair_json(pair : ClashPair) -> String {
"{\"left\":" +
pair.left.product_id.to_string() +
",\"right\":" +
pair.right.product_id.to_string() +
",\"left_type\":\"" +
pair.left.ifc_type +
"\"" +
",\"right_type\":\"" +
pair.right.ifc_type +
"\"" +
",\"relation\":\"" +
box_relation_text(pair.relation) +
"\"" +
",\"overlap_volume\":" +
pair.overlap_volume.to_string() +
",\"clearance\":" +
pair.clearance.to_string() +
"}"
}
///|
pub fn ClashReport::to_json(self : ClashReport) -> String {
let values = self.pairs.map(pair => clash_pair_json(pair))
"{\"candidate_count\":" +
self.candidate_count.to_string() +
",\"ignored_count\":" +
self.ignored_count.to_string() +
",\"overlap_volume\":" +
self.overlap_volume.to_string() +
",\"severity\":\"" +
self.severity() +
"\"" +
",\"pairs\":[" +
values.join(",") +
"]}"
}
///|
pub fn ClashReport::to_markdown(self : ClashReport) -> String {
let builder = StringBuilder()
builder <+ "# Clash report\n\n"
builder <+ "- Pairs: \{self.pairs.length()}\n"
builder <+ "- Candidates: \{self.candidate_count}\n"
builder <+ "- Ignored: \{self.ignored_count}\n"
builder <+ "- Overlap volume: \{self.overlap_volume}\n"
builder <+ "- Severity: \{self.severity()}\n\n"
if self.pairs.length() > 0 {
builder <+ "| Left | Right | Relation | Volume | Clearance |\n"
builder <+ "| ---: | ---: | --- | ---: | ---: |\n"
for pair in self.pairs {
builder <+
"| \{pair.left.product_id} | \{pair.right.product_id} | \{box_relation_text(pair.relation)} | \{pair.overlap_volume} | \{pair.clearance} |\n"
}
}
builder.to_string()
}
///|
pub fn clash_pairs_between(
spatial : SpatialIndex,
left_ids : Array[Int],
right_ids : Array[Int],
options? : ClashOptions = default_clash_options(),
) -> Array[ClashPair] {
let result : Array[ClashPair] = []
for left_id in left_ids {
match spatial.entry(left_id) {
Some(left) =>
for right_id in right_ids {
if left_id == right_id {
continue
}
match spatial.entry(right_id) {
Some(right) =>
match clash_pair_for(left, right, options) {
Some(pair) if !result.any(item => {
clash_pair_key(item.left, item.right) ==
clash_pair_key(left, right)
}) => result.push(pair)
_ => ()
}
None => ()
}
}
None => ()
}
}
clash_sort_pairs(result)
}
///|
pub fn clearances_below(
spatial : SpatialIndex,
clearance : Double,
options? : ClashOptions = default_clash_options(),
) -> Array[ClashPair] {
let limit = if clearance < 0.0 { 0.0 } else { clearance }
let relaxed = {
..options,
include_touching: true,
minimum_overlap_volume: 0.0,
tolerance: limit,
}
let report = detect_clashes(spatial, options=relaxed)
report.pairs.filter(pair => pair.clearance <= limit)
}
///|
pub fn clash_report_summary(report : ClashReport) -> Array[String] {
[
report.headline(),
"pair_count=" + report.pairs.length().to_string(),
"product_count=" + report.product_ids().length().to_string(),
"type_groups=" + report.by_type.length().to_string(),
]
}
///|
pub fn clash_type_histogram(report : ClashReport) -> Map[String, Int] {
report.by_type
}