1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet, HashMap};
3use std::io::ErrorKind;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, OnceLock, RwLock};
7#[cfg(test)]
8use std::sync::{Mutex, MutexGuard};
9
10use self::capture::DEFAULT_SVG_CAPTURE_ADAPTER;
11use chrono::Utc;
12use runmat_geometry_core::{
13 AssemblyNode, DiagnosticSeverity, EntityIdRange, EntityKind, EntityRef, GeometryAsset,
14 GeometrySource, MeshKind, Region, SourceGeometry, SourceGeometryKind, TessellationProfile,
15 UnitSystem,
16};
17use runmat_geometry_io::{
18 import::GeometryImportError, import_geometry_with_context, GeometryFormat,
19 GeometryImportContext, GeometryImportOptions,
20};
21use runmat_geometry_ops::{compute_stats, find_region, GeometryStats, QueryError};
22use runmat_meshing_core::{
23 prepare_geometry_for_analysis, MeshingOptions, MeshingPrepResult, MeshingProfile,
24};
25use serde::{Deserialize, Serialize};
26
27use crate::operations::{
28 operation_error, OperationContext, OperationEnvelope, OperationErrorEnvelope,
29 OperationErrorSeverity, OperationErrorSpec, OperationErrorType,
30};
31use crate::{build_runtime_error, BuiltinResult};
32
33const GEOMETRY_INSPECT_OPERATION: &str = "geometry.inspect";
34const GEOMETRY_INSPECT_OP_VERSION: &str = "geometry.inspect/v1";
35const GEOMETRY_LOAD_OPERATION: &str = "geometry.load";
36const GEOMETRY_LOAD_OP_VERSION: &str = "geometry.load/v1";
37const GEOMETRY_COMPUTE_STATS_OPERATION: &str = "geometry.compute_stats";
38const GEOMETRY_COMPUTE_STATS_OP_VERSION: &str = "geometry.compute_stats/v1";
39const GEOMETRY_LIST_REGIONS_OPERATION: &str = "geometry.list_regions";
40const GEOMETRY_LIST_REGIONS_OP_VERSION: &str = "geometry.list_regions/v1";
41const GEOMETRY_QUERY_ENTITIES_OPERATION: &str = "geometry.query_entities";
42const GEOMETRY_QUERY_ENTITIES_OP_VERSION: &str = "geometry.query_entities/v1";
43const GEOMETRY_CAPTURE_VIEW_OPERATION: &str = "geometry.capture_view";
44const GEOMETRY_CAPTURE_VIEW_OP_VERSION: &str = "geometry.capture_view/v1";
45const GEOMETRY_PREP_FOR_ANALYSIS_OPERATION: &str = "geometry.prep_for_analysis";
46const GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION: &str = "geometry.prep_for_analysis/v1";
47const GEOMETRY_PREP_ARTIFACT_HEALTH_OPERATION: &str = "geometry.prep_artifact_health";
48const GEOMETRY_PREP_ARTIFACT_HEALTH_OP_VERSION: &str = "geometry.prep_artifact_health/v1";
49const DEFAULT_QUERY_LIMIT: usize = 2048;
50const DEFAULT_MAPPING_RANGE_PREVIEW_LIMIT: usize = 8;
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct GeometryInspectResult {
54 pub format: String,
55 pub byte_count: usize,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct GeometryRegionsResult {
60 pub regions: Vec<Region>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct GeometryEntityQuery {
65 pub region_id: Option<String>,
66 pub mesh_id: Option<String>,
67 pub entity_kind: EntityKind,
68 pub limit: Option<usize>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct GeometryEntityQueryResult {
73 pub entities: Vec<EntityRef>,
74 pub truncated: bool,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct GeometryBoundsSummary {
80 pub min: [f64; 3],
81 pub max: [f64; 3],
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct GeometryMeshSummary {
87 pub mesh_id: String,
88 pub kind: MeshKind,
89 pub vertex_count: u64,
90 pub element_count: u64,
91 pub surface_vertex_count: Option<u64>,
92 pub surface_triangle_count: Option<u64>,
93 pub bounds: Option<GeometryBoundsSummary>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct GeometryRegionMappingSummaryEntry {
99 pub region_id: String,
100 pub mesh_id: String,
101 pub entity_kind: EntityKind,
102 pub range_count: usize,
103 pub entity_count: u64,
104 pub range_preview: Vec<EntityIdRange>,
105 pub truncated: bool,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct GeometryRegionMappingSummary {
111 pub mapping_count: usize,
112 pub mapped_region_count: usize,
113 pub total_entity_count: u64,
114 pub range_preview_limit: usize,
115 pub entries: Vec<GeometryRegionMappingSummaryEntry>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum GeometryCadRegionStatus {
121 NotCad,
122 MetadataOnly,
123 GenericFaceTopology,
124 SemanticRegions,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct GeometryCadSummary {
130 pub backend: Option<String>,
131 pub source_format: Option<String>,
132 pub face_region_count: usize,
133 pub mapped_face_region_count: usize,
134 pub semantic_region_count: usize,
135 pub mapped_semantic_region_count: usize,
136 pub region_status: GeometryCadRegionStatus,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct GeometryAssetSummary {
142 pub geometry_id: String,
143 pub revision: u32,
144 pub source: GeometrySource,
145 pub source_geometry: SourceGeometry,
146 pub tessellation_profile: TessellationProfile,
147 pub units: UnitSystem,
148 pub meshes: Vec<GeometryMeshSummary>,
149 pub mapping_summary: GeometryRegionMappingSummary,
150 pub cad: GeometryCadSummary,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct GeometryCaptureViewSpec {
155 pub format: String,
156 pub width: u32,
157 pub height: u32,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct GeometryCaptureViewResult {
162 pub format: String,
163 pub width: u32,
164 pub height: u32,
165 pub payload: Vec<u8>,
166}
167
168#[cfg(feature = "plot-core")]
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum GeometryPreviewPresentation {
172 Analysis,
173 Cad,
174}
175
176#[cfg(feature = "plot-core")]
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178pub struct GeometryPreviewFigureOptions {
179 pub edge_overlay_triangle_limit: usize,
180 pub presentation: GeometryPreviewPresentation,
181 pub xray: bool,
182 pub allow_create_fea_study: bool,
183}
184
185#[cfg(feature = "plot-core")]
186impl Default for GeometryPreviewFigureOptions {
187 fn default() -> Self {
188 Self {
189 edge_overlay_triangle_limit: 250_000,
190 presentation: GeometryPreviewPresentation::Analysis,
191 xray: false,
192 allow_create_fea_study: false,
193 }
194 }
195}
196
197#[cfg(feature = "plot-core")]
198impl GeometryPreviewFigureOptions {
199 pub fn cad_preview() -> Self {
200 Self {
201 edge_overlay_triangle_limit: 250_000,
202 presentation: GeometryPreviewPresentation::Cad,
203 xray: false,
204 allow_create_fea_study: false,
205 }
206 }
207}
208
209#[cfg(feature = "plot-core")]
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211pub struct GeometryPreviewSceneOptions {
212 pub triangles_per_chunk: usize,
213 pub presentation: GeometryPreviewPresentation,
214 pub xray: bool,
215 pub allow_create_fea_study: bool,
216}
217
218#[cfg(feature = "plot-core")]
219impl Default for GeometryPreviewSceneOptions {
220 fn default() -> Self {
221 Self {
222 triangles_per_chunk: 128_000,
223 presentation: GeometryPreviewPresentation::Cad,
224 xray: false,
225 allow_create_fea_study: false,
226 }
227 }
228}
229
230#[cfg(feature = "plot-core")]
231const CAD_DEFAULT_FACE_COLOR: glam::Vec4 = glam::Vec4::new(0.66, 0.72, 0.80, 1.0);
232#[cfg(feature = "plot-core")]
233const CAD_FEATURE_EDGE_COLOR: glam::Vec4 = glam::Vec4::new(0.08, 0.10, 0.13, 1.0);
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum GeometryPrepProfile {
238 SurfaceOnly,
239 AnalysisReady,
240 AdaptiveRefine,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct GeometryPrepForAnalysisSpec {
245 pub profile: GeometryPrepProfile,
246 pub target_element_budget: usize,
247}
248
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250pub struct GeometryPrepForAnalysisResult {
251 pub prep_artifact_id: String,
252 pub prep: MeshingPrepResult,
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct StoredGeometryPrepArtifact {
257 pub prep_artifact_id: String,
258 pub schema_version: String,
259 pub created_at: String,
260 pub source_geometry_id: String,
261 pub source_geometry_revision: u32,
262 pub prep: MeshingPrepResult,
263}
264
265#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266pub struct PrepArtifactMetrics {
267 pub created_count: u64,
268 pub loaded_count: u64,
269 pub pruned_count: u64,
270 pub stale_reject_count: u64,
271 pub mismatch_reject_count: u64,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct GeometryPrepArtifactHealthQuery {
276 pub include_per_geometry: bool,
277}
278
279impl Default for GeometryPrepArtifactHealthQuery {
280 fn default() -> Self {
281 Self {
282 include_per_geometry: true,
283 }
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct GeometryPrepArtifactHealthEntry {
289 pub geometry_id: String,
290 pub latest_revision: u32,
291 pub artifact_count: usize,
292}
293
294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295pub struct GeometryPrepArtifactHealthResult {
296 pub schema_version: String,
297 pub current_artifact_count: usize,
298 pub age_p50_seconds: Option<f64>,
299 pub age_p95_seconds: Option<f64>,
300 pub metrics: PrepArtifactMetrics,
301 pub per_geometry: Vec<GeometryPrepArtifactHealthEntry>,
302}
303
304#[derive(Debug, Clone, Default, PartialEq, Eq)]
305pub struct GeometryPrepArtifactConfig {
306 pub artifact_root: Option<PathBuf>,
307 pub max_artifacts: Option<usize>,
308 pub max_artifacts_per_geometry: Option<usize>,
309 pub max_age_seconds: Option<u64>,
310 pub require_latest_revision: Option<bool>,
311}
312
313type PrepStore = Arc<RwLock<HashMap<String, StoredGeometryPrepArtifact>>>;
314
315fn prep_store() -> &'static PrepStore {
316 static STORE: OnceLock<PrepStore> = OnceLock::new();
317 STORE.get_or_init(|| Arc::new(RwLock::new(HashMap::new())))
318}
319
320fn prep_artifact_counter() -> &'static AtomicU64 {
321 static COUNTER: OnceLock<AtomicU64> = OnceLock::new();
322 COUNTER.get_or_init(|| AtomicU64::new(1))
323}
324
325fn prep_metrics() -> &'static Arc<RwLock<PrepArtifactMetrics>> {
326 static METRICS: OnceLock<Arc<RwLock<PrepArtifactMetrics>>> = OnceLock::new();
327 METRICS.get_or_init(|| Arc::new(RwLock::new(PrepArtifactMetrics::default())))
328}
329
330fn prep_config() -> &'static RwLock<GeometryPrepArtifactConfig> {
331 static CONFIG: OnceLock<RwLock<GeometryPrepArtifactConfig>> = OnceLock::new();
332 CONFIG.get_or_init(|| RwLock::new(GeometryPrepArtifactConfig::default()))
333}
334
335fn current_prep_config() -> GeometryPrepArtifactConfig {
336 prep_config()
337 .read()
338 .map(|guard| guard.clone())
339 .unwrap_or_default()
340}
341
342pub fn configure_prep_artifacts(config: GeometryPrepArtifactConfig) -> Result<(), String> {
343 let mut guard = prep_config()
344 .write()
345 .map_err(|_| "geometry prep artifact config lock poisoned".to_string())?;
346 *guard = config;
347 Ok(())
348}
349
350fn increment_metric(f: impl FnOnce(&mut PrepArtifactMetrics)) {
351 if let Ok(mut metrics) = prep_metrics().write() {
352 f(&mut metrics);
353 }
354}
355
356fn prep_artifact_root() -> Option<PathBuf> {
357 current_prep_config().artifact_root.or_else(|| {
358 std::env::var("RUNMAT_GEOMETRY_PREP_ARTIFACT_ROOT")
359 .ok()
360 .map(PathBuf::from)
361 })
362}
363
364pub(crate) fn require_latest_prep_revision() -> bool {
365 current_prep_config()
366 .require_latest_revision
367 .unwrap_or_else(|| {
368 std::env::var("RUNMAT_GEOMETRY_PREP_REQUIRE_LATEST_REVISION")
369 .ok()
370 .map(|value| {
371 matches!(
372 value.to_ascii_lowercase().as_str(),
373 "1" | "true" | "yes" | "on"
374 )
375 })
376 .unwrap_or(true)
377 })
378}
379
380fn prep_artifact_path(root: &Path, prep_artifact_id: &str) -> PathBuf {
381 root.join("prep").join(format!("{prep_artifact_id}.json"))
382}
383
384fn prep_artifact_id_fragment(value: &str) -> String {
385 let mut fragment = String::with_capacity(value.len());
386 for byte in value.bytes() {
387 match byte {
388 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'_' | b'-' => {
389 fragment.push(byte as char);
390 }
391 _ => {
392 if !fragment.ends_with('_') {
393 fragment.push('_');
394 }
395 }
396 }
397 }
398 let fragment = fragment.trim_matches('_').to_string();
399 if fragment.is_empty() {
400 "geometry".to_string()
401 } else {
402 fragment
403 }
404}
405
406fn fs_create_dir_all(path: impl Into<PathBuf>) -> std::io::Result<()> {
407 runmat_filesystem::create_dir_all(path.into())
408}
409
410fn fs_read(path: impl Into<PathBuf>) -> std::io::Result<Vec<u8>> {
411 runmat_filesystem::read(path.into())
412}
413
414fn fs_write(path: impl Into<PathBuf>, bytes: &[u8]) -> std::io::Result<()> {
415 runmat_filesystem::write(path.into(), bytes)
416}
417
418fn fs_remove_file(path: impl Into<PathBuf>) -> std::io::Result<()> {
419 match runmat_filesystem::remove_file(path.into()) {
420 Ok(()) => Ok(()),
421 Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
422 Err(err) => Err(err),
423 }
424}
425
426fn fs_read_dir(path: impl Into<PathBuf>) -> std::io::Result<Vec<runmat_filesystem::DirEntry>> {
427 runmat_filesystem::read_dir(path.into())
428}
429
430fn fs_exists(path: impl Into<PathBuf>) -> std::io::Result<bool> {
431 match runmat_filesystem::metadata(path.into()) {
432 Ok(_) => Ok(true),
433 Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
434 Err(err) => Err(err),
435 }
436}
437
438#[derive(Debug, Clone, Copy)]
439struct PrepArtifactRetentionPolicy {
440 max_artifacts: usize,
441 max_artifacts_per_geometry: usize,
442 max_age_seconds: u64,
443}
444
445impl PrepArtifactRetentionPolicy {
446 fn current() -> Self {
447 let config = current_prep_config();
448 Self {
449 max_artifacts: config.max_artifacts.unwrap_or_else(|| {
450 std::env::var("RUNMAT_GEOMETRY_PREP_MAX_ARTIFACTS")
451 .ok()
452 .and_then(|value| value.parse::<usize>().ok())
453 .unwrap_or(0)
454 }),
455 max_artifacts_per_geometry: config.max_artifacts_per_geometry.unwrap_or_else(|| {
456 std::env::var("RUNMAT_GEOMETRY_PREP_MAX_ARTIFACTS_PER_GEOMETRY")
457 .ok()
458 .and_then(|value| value.parse::<usize>().ok())
459 .unwrap_or(0)
460 }),
461 max_age_seconds: config.max_age_seconds.unwrap_or_else(|| {
462 std::env::var("RUNMAT_GEOMETRY_PREP_MAX_AGE_SECONDS")
463 .ok()
464 .and_then(|value| value.parse::<u64>().ok())
465 .unwrap_or(0)
466 }),
467 }
468 }
469}
470
471fn persist_prep_artifact(
472 geometry: &GeometryAsset,
473 prep: MeshingPrepResult,
474) -> Result<StoredGeometryPrepArtifact, String> {
475 let prep_artifact_id = format!(
476 "prep_{}_{}_{}",
477 prep_artifact_id_fragment(&geometry.geometry_id),
478 geometry.revision,
479 prep_artifact_counter().fetch_add(1, Ordering::Relaxed)
480 );
481 let artifact = StoredGeometryPrepArtifact {
482 prep_artifact_id: prep_artifact_id.clone(),
483 schema_version: "geometry_prep_artifact/v1".to_string(),
484 created_at: Utc::now().to_rfc3339(),
485 source_geometry_id: geometry.geometry_id.clone(),
486 source_geometry_revision: geometry.revision,
487 prep,
488 };
489
490 prep_store()
491 .write()
492 .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?
493 .insert(prep_artifact_id.clone(), artifact.clone());
494 increment_metric(|metrics| metrics.created_count = metrics.created_count.saturating_add(1));
495 tracing::info!(
496 target: "runmat_geometry",
497 "prep_artifact_created id={} geometry_id={} revision={}",
498 prep_artifact_id,
499 geometry.geometry_id,
500 geometry.revision
501 );
502
503 if let Some(root) = prep_artifact_root() {
504 let path = prep_artifact_path(&root, &prep_artifact_id);
505 if let Some(parent) = path.parent() {
506 fs_create_dir_all(parent)
507 .map_err(|err| format!("failed to create prep artifact directory: {err}"))?;
508 }
509 let bytes = serde_json::to_vec_pretty(&artifact)
510 .map_err(|err| format!("failed to encode prep artifact: {err}"))?;
511 fs_write(&path, &bytes).map_err(|err| format!("failed to write prep artifact: {err}"))?;
512 }
513
514 prune_prep_artifacts(PrepArtifactRetentionPolicy::current())?;
515
516 Ok(artifact)
517}
518
519pub(crate) fn load_prep_artifact(
520 prep_artifact_id: &str,
521) -> Result<Option<StoredGeometryPrepArtifact>, String> {
522 if let Some(artifact) = prep_store()
523 .read()
524 .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?
525 .get(prep_artifact_id)
526 .cloned()
527 {
528 return Ok(Some(artifact));
529 }
530
531 let Some(root) = prep_artifact_root() else {
532 return Ok(None);
533 };
534 let path = prep_artifact_path(&root, prep_artifact_id);
535 if !fs_exists(&path).map_err(|err| format!("failed to inspect prep artifact: {err}"))? {
536 return Ok(None);
537 }
538 let bytes = fs_read(&path).map_err(|err| format!("failed to read prep artifact: {err}"))?;
539 let artifact = serde_json::from_slice::<StoredGeometryPrepArtifact>(&bytes)
540 .map_err(|err| format!("failed to decode prep artifact: {err}"))?;
541 prep_store()
542 .write()
543 .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?
544 .insert(prep_artifact_id.to_string(), artifact.clone());
545 increment_metric(|metrics| metrics.loaded_count = metrics.loaded_count.saturating_add(1));
546 tracing::info!(
547 target: "runmat_geometry",
548 "prep_artifact_loaded id={} geometry_id={} revision={}",
549 prep_artifact_id,
550 artifact.source_geometry_id,
551 artifact.source_geometry_revision
552 );
553 prune_prep_artifacts(PrepArtifactRetentionPolicy::current())?;
554 Ok(Some(artifact))
555}
556
557pub(crate) fn record_prep_stale_reject() {
558 increment_metric(|metrics| {
559 metrics.stale_reject_count = metrics.stale_reject_count.saturating_add(1)
560 });
561 tracing::warn!(target: "runmat_geometry", "prep_artifact_rejected reason=stale");
562}
563
564pub(crate) fn record_prep_mismatch_reject() {
565 increment_metric(|metrics| {
566 metrics.mismatch_reject_count = metrics.mismatch_reject_count.saturating_add(1)
567 });
568 tracing::warn!(
569 target: "runmat_geometry",
570 "prep_artifact_rejected reason=mismatch"
571 );
572}
573
574pub fn geometry_prep_artifact_health_op(
575 query: GeometryPrepArtifactHealthQuery,
576 context: OperationContext,
577) -> Result<OperationEnvelope<GeometryPrepArtifactHealthResult>, OperationErrorEnvelope> {
578 let artifacts = list_prep_artifacts().map_err(|err| {
579 operation_error(
580 GEOMETRY_PREP_ARTIFACT_HEALTH_OPERATION,
581 GEOMETRY_PREP_ARTIFACT_HEALTH_OP_VERSION,
582 &context,
583 OperationErrorSpec {
584 error_code: "RM.GEOMETRY.PREP_ARTIFACT_HEALTH.STORE_FAILED",
585 error_type: OperationErrorType::Internal,
586 retryable: true,
587 severity: OperationErrorSeverity::Error,
588 },
589 format!("failed to list prep artifacts: {err}"),
590 BTreeMap::new(),
591 )
592 })?;
593
594 let now = Utc::now();
595 let mut age_seconds = Vec::new();
596 let mut per_geometry_map: HashMap<String, (u32, usize)> = HashMap::new();
597 for artifact in &artifacts {
598 if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&artifact.created_at) {
599 let age = now.signed_duration_since(created.with_timezone(&Utc));
600 age_seconds.push(age.num_seconds().max(0) as f64);
601 }
602 let entry = per_geometry_map
603 .entry(artifact.source_geometry_id.clone())
604 .or_insert((artifact.source_geometry_revision, 0));
605 if artifact.source_geometry_revision > entry.0 {
606 entry.0 = artifact.source_geometry_revision;
607 }
608 entry.1 = entry.1.saturating_add(1);
609 }
610 age_seconds.sort_by(|a, b| a.total_cmp(b));
611
612 let per_geometry = if query.include_per_geometry {
613 let mut values = per_geometry_map
614 .into_iter()
615 .map(|(geometry_id, (latest_revision, artifact_count))| {
616 GeometryPrepArtifactHealthEntry {
617 geometry_id,
618 latest_revision,
619 artifact_count,
620 }
621 })
622 .collect::<Vec<_>>();
623 values.sort_by(|a, b| a.geometry_id.cmp(&b.geometry_id));
624 values
625 } else {
626 Vec::new()
627 };
628
629 let metrics = prep_metrics()
630 .read()
631 .map_err(|_| {
632 operation_error(
633 GEOMETRY_PREP_ARTIFACT_HEALTH_OPERATION,
634 GEOMETRY_PREP_ARTIFACT_HEALTH_OP_VERSION,
635 &context,
636 OperationErrorSpec {
637 error_code: "RM.GEOMETRY.PREP_ARTIFACT_HEALTH.STORE_FAILED",
638 error_type: OperationErrorType::Internal,
639 retryable: true,
640 severity: OperationErrorSeverity::Error,
641 },
642 "geometry prep metrics store lock poisoned",
643 BTreeMap::new(),
644 )
645 })?
646 .clone();
647
648 Ok(OperationEnvelope::new(
649 GEOMETRY_PREP_ARTIFACT_HEALTH_OPERATION,
650 GEOMETRY_PREP_ARTIFACT_HEALTH_OP_VERSION,
651 &context,
652 GeometryPrepArtifactHealthResult {
653 schema_version: "geometry-prep-artifact-health/v1".to_string(),
654 current_artifact_count: artifacts.len(),
655 age_p50_seconds: percentile(&age_seconds, 0.5),
656 age_p95_seconds: percentile(&age_seconds, 0.95),
657 metrics,
658 per_geometry,
659 },
660 ))
661}
662
663fn percentile(sorted: &[f64], ratio: f64) -> Option<f64> {
664 if sorted.is_empty() {
665 return None;
666 }
667 let index = ((sorted.len() - 1) as f64 * ratio.clamp(0.0, 1.0)).round() as usize;
668 sorted.get(index).copied()
669}
670
671pub(crate) fn latest_prep_revision_for_geometry(geometry_id: &str) -> Result<Option<u32>, String> {
672 let mut revisions = list_prep_artifacts()?
673 .into_iter()
674 .filter(|artifact| artifact.source_geometry_id == geometry_id)
675 .map(|artifact| artifact.source_geometry_revision)
676 .collect::<Vec<_>>();
677 revisions.sort_unstable();
678 Ok(revisions.pop())
679}
680
681fn list_prep_artifacts() -> Result<Vec<StoredGeometryPrepArtifact>, String> {
682 let mut artifacts = prep_store()
683 .read()
684 .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?
685 .values()
686 .cloned()
687 .collect::<Vec<_>>();
688 if artifacts.is_empty() {
689 if let Some(root) = prep_artifact_root() {
690 let prep_dir = root.join("prep");
691 if fs_exists(&prep_dir)
692 .map_err(|err| format!("failed to inspect prep artifacts: {err}"))?
693 {
694 for entry in fs_read_dir(&prep_dir)
695 .map_err(|err| format!("failed to scan prep artifacts: {err}"))?
696 {
697 let path = entry.path().to_path_buf();
698 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
699 continue;
700 }
701 let bytes = fs_read(&path)
702 .map_err(|err| format!("failed to read prep artifact: {err}"))?;
703 if let Ok(artifact) =
704 serde_json::from_slice::<StoredGeometryPrepArtifact>(&bytes)
705 {
706 artifacts.push(artifact);
707 }
708 }
709 }
710 }
711 }
712 Ok(artifacts)
713}
714
715fn prune_prep_artifacts(policy: PrepArtifactRetentionPolicy) -> Result<(), String> {
716 if policy.max_artifacts == 0
717 && policy.max_artifacts_per_geometry == 0
718 && policy.max_age_seconds == 0
719 {
720 return Ok(());
721 }
722
723 let now = Utc::now();
724 let mut artifacts = list_prep_artifacts()?;
725 artifacts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
726
727 let mut remove_ids = Vec::new();
728 if policy.max_age_seconds > 0 {
729 for artifact in &artifacts {
730 if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&artifact.created_at) {
731 let age = now.signed_duration_since(created.with_timezone(&Utc));
732 if age.num_seconds().max(0) as u64 > policy.max_age_seconds {
733 remove_ids.push(artifact.prep_artifact_id.clone());
734 }
735 }
736 }
737 }
738
739 if policy.max_artifacts_per_geometry > 0 {
740 let mut per_geometry_counts: HashMap<String, usize> = HashMap::new();
741 for artifact in &artifacts {
742 let count = per_geometry_counts
743 .entry(artifact.source_geometry_id.clone())
744 .or_default();
745 *count += 1;
746 if *count > policy.max_artifacts_per_geometry {
747 remove_ids.push(artifact.prep_artifact_id.clone());
748 }
749 }
750 }
751
752 if policy.max_artifacts > 0 {
753 for (index, artifact) in artifacts.iter().enumerate() {
754 if index >= policy.max_artifacts {
755 remove_ids.push(artifact.prep_artifact_id.clone());
756 }
757 }
758 }
759
760 remove_ids.sort();
761 remove_ids.dedup();
762 if remove_ids.is_empty() {
763 return Ok(());
764 }
765
766 {
767 let mut store = prep_store()
768 .write()
769 .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?;
770 for id in &remove_ids {
771 store.remove(id);
772 }
773 }
774
775 if let Some(root) = prep_artifact_root() {
776 for id in &remove_ids {
777 let path = prep_artifact_path(&root, id);
778 let _ = fs_remove_file(path);
779 }
780 }
781
782 increment_metric(|metrics| {
783 metrics.pruned_count = metrics.pruned_count.saturating_add(remove_ids.len() as u64)
784 });
785 tracing::info!(
786 target: "runmat_geometry",
787 "prep_artifact_pruned count={}",
788 remove_ids.len()
789 );
790
791 Ok(())
792}
793
794#[doc(hidden)]
795pub fn reset_prep_artifact_store_for_tests() {
796 if let Ok(mut store) = prep_store().write() {
797 store.clear();
798 }
799 prep_artifact_counter().store(1, Ordering::Relaxed);
800 if let Ok(mut metrics) = prep_metrics().write() {
801 *metrics = PrepArtifactMetrics::default();
802 }
803 if let Ok(mut config) = prep_config().write() {
804 *config = GeometryPrepArtifactConfig::default();
805 }
806}
807
808#[cfg(test)]
809pub(crate) fn prep_artifact_test_guard() -> MutexGuard<'static, ()> {
810 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
811 LOCK.get_or_init(|| Mutex::new(()))
812 .lock()
813 .unwrap_or_else(|poisoned| poisoned.into_inner())
814}
815
816impl Default for GeometryPrepForAnalysisSpec {
817 fn default() -> Self {
818 Self {
819 profile: GeometryPrepProfile::AnalysisReady,
820 target_element_budget: 250_000,
821 }
822 }
823}
824
825pub trait GeometryViewCaptureAdapter {
826 fn adapter_name(&self) -> &'static str;
827 fn capture(
828 &self,
829 asset: &GeometryAsset,
830 view_spec: &GeometryCaptureViewSpec,
831 ) -> Result<GeometryCaptureViewResult, String>;
832}
833
834thread_local! {
835 static GEOMETRY_CAPTURE_ADAPTER: RefCell<Option<&'static dyn GeometryViewCaptureAdapter>> =
836 RefCell::new(None);
837}
838
839mod capture;
840
841pub struct ThreadGeometryCaptureAdapterGuard {
842 previous: Option<&'static dyn GeometryViewCaptureAdapter>,
843}
844
845impl ThreadGeometryCaptureAdapterGuard {
846 pub fn set(adapter: Option<&'static dyn GeometryViewCaptureAdapter>) -> Self {
847 let previous = GEOMETRY_CAPTURE_ADAPTER.with(|slot| slot.replace(adapter));
848 Self { previous }
849 }
850}
851
852impl Drop for ThreadGeometryCaptureAdapterGuard {
853 fn drop(&mut self) {
854 GEOMETRY_CAPTURE_ADAPTER.with(|slot| {
855 slot.replace(self.previous.take());
856 });
857 }
858}
859
860pub fn geometry_inspect_op(
861 path: &str,
862 bytes: &[u8],
863 context: OperationContext,
864) -> Result<OperationEnvelope<GeometryInspectResult>, OperationErrorEnvelope> {
865 let format = runmat_geometry_io::detect_geometry_format(path, bytes);
866 let data = GeometryInspectResult {
867 format: format_name(format).to_string(),
868 byte_count: bytes.len(),
869 };
870 Ok(OperationEnvelope::new(
871 GEOMETRY_INSPECT_OPERATION,
872 GEOMETRY_INSPECT_OP_VERSION,
873 &context,
874 data,
875 ))
876}
877
878pub fn geometry_inspect(path: &str, bytes: &[u8]) -> BuiltinResult<GeometryInspectResult> {
879 let envelope =
880 geometry_inspect_op(path, bytes, OperationContext::new(None, None)).map_err(|error| {
881 build_runtime_error(error.message)
882 .with_builtin(GEOMETRY_INSPECT_OPERATION)
883 .with_identifier("RunMat:GeometryInspectFailed")
884 .build()
885 })?;
886 Ok(envelope.data)
887}
888
889pub fn geometry_load_op(
890 path: &str,
891 bytes: &[u8],
892 context: OperationContext,
893) -> Result<OperationEnvelope<GeometryAsset>, OperationErrorEnvelope> {
894 geometry_load_with_options_op(path, bytes, GeometryImportOptions::default(), context)
895}
896
897pub fn geometry_load_with_options_op(
898 path: &str,
899 bytes: &[u8],
900 options: GeometryImportOptions,
901 context: OperationContext,
902) -> Result<OperationEnvelope<GeometryAsset>, OperationErrorEnvelope> {
903 let import_context = current_geometry_import_context();
904 let imported = import_geometry_with_context(path, bytes, options, &import_context)
905 .map_err(|error| map_geometry_load_error(path, error, &context))?;
906 Ok(OperationEnvelope::new(
907 GEOMETRY_LOAD_OPERATION,
908 GEOMETRY_LOAD_OP_VERSION,
909 &context,
910 imported.asset,
911 ))
912}
913
914pub fn geometry_load(path: &str, bytes: &[u8]) -> BuiltinResult<GeometryAsset> {
915 let envelope =
916 geometry_load_op(path, bytes, OperationContext::new(None, None)).map_err(|error| {
917 build_runtime_error(error.message)
918 .with_builtin(GEOMETRY_LOAD_OPERATION)
919 .with_identifier("RunMat:GeometryLoadFailed")
920 .build()
921 })?;
922 Ok(envelope.data)
923}
924
925fn current_geometry_import_context() -> GeometryImportContext {
926 crate::interrupt::current_interrupt()
927 .map(GeometryImportContext::with_cancellation)
928 .unwrap_or_default()
929}
930
931pub fn geometry_compute_stats_op(
932 asset: &GeometryAsset,
933 context: OperationContext,
934) -> Result<OperationEnvelope<GeometryStats>, OperationErrorEnvelope> {
935 Ok(OperationEnvelope::new(
936 GEOMETRY_COMPUTE_STATS_OPERATION,
937 GEOMETRY_COMPUTE_STATS_OP_VERSION,
938 &context,
939 compute_stats(asset),
940 ))
941}
942
943pub fn geometry_compute_stats(asset: &GeometryAsset) -> BuiltinResult<GeometryStats> {
944 let envelope =
945 geometry_compute_stats_op(asset, OperationContext::new(None, None)).map_err(|error| {
946 build_runtime_error(error.message)
947 .with_builtin(GEOMETRY_COMPUTE_STATS_OPERATION)
948 .with_identifier("RunMat:GeometryStatsFailed")
949 .build()
950 })?;
951 Ok(envelope.data)
952}
953
954pub fn geometry_asset_summary(asset: &GeometryAsset) -> GeometryAssetSummary {
955 geometry_asset_summary_with_options(asset, DEFAULT_MAPPING_RANGE_PREVIEW_LIMIT)
956}
957
958pub fn geometry_asset_summary_with_options(
959 asset: &GeometryAsset,
960 range_preview_limit: usize,
961) -> GeometryAssetSummary {
962 GeometryAssetSummary {
963 geometry_id: asset.geometry_id.clone(),
964 revision: asset.revision,
965 source: asset.source.clone(),
966 source_geometry: asset.source_geometry.clone(),
967 tessellation_profile: asset.tessellation_profile.clone(),
968 units: asset.units,
969 meshes: mesh_summaries(asset),
970 mapping_summary: region_mapping_summary(asset, range_preview_limit),
971 cad: cad_summary(asset),
972 }
973}
974
975fn mesh_summaries(asset: &GeometryAsset) -> Vec<GeometryMeshSummary> {
976 asset
977 .meshes
978 .iter()
979 .map(|mesh| {
980 let surface_mesh = asset
981 .surface_meshes
982 .iter()
983 .find(|surface| surface.mesh_id == mesh.mesh_id);
984 GeometryMeshSummary {
985 mesh_id: mesh.mesh_id.clone(),
986 kind: mesh.kind,
987 vertex_count: mesh.vertex_count,
988 element_count: mesh.element_count,
989 surface_vertex_count: surface_mesh.map(|surface| surface.vertices.len() as u64),
990 surface_triangle_count: surface_mesh.map(|surface| surface.triangles.len() as u64),
991 bounds: surface_mesh.and_then(|surface| bounds_for_vertices(&surface.vertices)),
992 }
993 })
994 .collect()
995}
996
997fn bounds_for_vertices(vertices: &[[f64; 3]]) -> Option<GeometryBoundsSummary> {
998 let first = vertices.first().copied()?;
999 let mut min = first;
1000 let mut max = first;
1001 for vertex in vertices.iter().skip(1) {
1002 for axis in 0..3 {
1003 min[axis] = min[axis].min(vertex[axis]);
1004 max[axis] = max[axis].max(vertex[axis]);
1005 }
1006 }
1007 Some(GeometryBoundsSummary { min, max })
1008}
1009
1010fn region_mapping_summary(
1011 asset: &GeometryAsset,
1012 range_preview_limit: usize,
1013) -> GeometryRegionMappingSummary {
1014 let mut mapped_regions = BTreeSet::new();
1015 let mut total_entity_count = 0_u64;
1016 let entries = asset
1017 .region_entity_mappings
1018 .iter()
1019 .map(|mapping| {
1020 mapped_regions.insert(mapping.region_id.clone());
1021 let entity_count = mapping.entity_count();
1022 total_entity_count = total_entity_count.saturating_add(entity_count);
1023 let range_count = mapping.ranges.len();
1024 GeometryRegionMappingSummaryEntry {
1025 region_id: mapping.region_id.clone(),
1026 mesh_id: mapping.mesh_id.clone(),
1027 entity_kind: mapping.entity_kind,
1028 range_count,
1029 entity_count,
1030 range_preview: mapping
1031 .ranges
1032 .iter()
1033 .take(range_preview_limit)
1034 .copied()
1035 .collect(),
1036 truncated: range_count > range_preview_limit,
1037 }
1038 })
1039 .collect();
1040
1041 GeometryRegionMappingSummary {
1042 mapping_count: asset.region_entity_mappings.len(),
1043 mapped_region_count: mapped_regions.len(),
1044 total_entity_count,
1045 range_preview_limit,
1046 entries,
1047 }
1048}
1049
1050fn cad_summary(asset: &GeometryAsset) -> GeometryCadSummary {
1051 let importer_parts = asset.source.importer_version.split('/').collect::<Vec<_>>();
1052 let backend = match importer_parts.as_slice() {
1053 ["cad", backend, ..] => Some((*backend).to_string()),
1054 ["step", ..] if asset.source_geometry.kind == SourceGeometryKind::Cad => {
1055 Some("metadata".to_string())
1056 }
1057 _ => None,
1058 };
1059 let source_format = match importer_parts.as_slice() {
1060 ["cad", _, format, ..] => Some((*format).to_string()),
1061 [format, ..] if asset.source_geometry.kind == SourceGeometryKind::Cad => {
1062 Some((*format).to_string())
1063 }
1064 _ => None,
1065 };
1066 let face_region_ids = asset
1067 .regions
1068 .iter()
1069 .filter(|region| region.tag.as_deref() == Some("occt_face"))
1070 .map(|region| region.region_id.as_str())
1071 .collect::<BTreeSet<_>>();
1072 let mapped_face_region_ids = asset
1073 .region_entity_mappings
1074 .iter()
1075 .filter_map(|mapping| {
1076 (mapping.entity_kind == EntityKind::Face
1077 && face_region_ids.contains(mapping.region_id.as_str()))
1078 .then_some(mapping.region_id.as_str())
1079 })
1080 .collect::<BTreeSet<_>>();
1081 let semantic_region_ids = asset
1082 .regions
1083 .iter()
1084 .filter(|region| {
1085 region.cad_ownership.is_some()
1086 && region
1087 .tag
1088 .as_deref()
1089 .is_some_and(|tag| tag.starts_with("cad_"))
1090 })
1091 .map(|region| region.region_id.as_str())
1092 .collect::<BTreeSet<_>>();
1093 let mapped_semantic_region_ids = asset
1094 .region_entity_mappings
1095 .iter()
1096 .filter_map(|mapping| {
1097 (mapping.entity_kind == EntityKind::Face
1098 && semantic_region_ids.contains(mapping.region_id.as_str()))
1099 .then_some(mapping.region_id.as_str())
1100 })
1101 .collect::<BTreeSet<_>>();
1102 let region_status = if asset.source_geometry.kind != SourceGeometryKind::Cad {
1103 GeometryCadRegionStatus::NotCad
1104 } else if mapped_face_region_ids.is_empty() {
1105 GeometryCadRegionStatus::MetadataOnly
1106 } else if !mapped_semantic_region_ids.is_empty() {
1107 GeometryCadRegionStatus::SemanticRegions
1108 } else {
1109 GeometryCadRegionStatus::GenericFaceTopology
1110 };
1111
1112 GeometryCadSummary {
1113 backend,
1114 source_format,
1115 face_region_count: face_region_ids.len(),
1116 mapped_face_region_count: mapped_face_region_ids.len(),
1117 semantic_region_count: semantic_region_ids.len(),
1118 mapped_semantic_region_count: mapped_semantic_region_ids.len(),
1119 region_status,
1120 }
1121}
1122
1123pub fn geometry_list_regions_op(
1124 asset: &GeometryAsset,
1125 context: OperationContext,
1126) -> Result<OperationEnvelope<GeometryRegionsResult>, OperationErrorEnvelope> {
1127 Ok(OperationEnvelope::new(
1128 GEOMETRY_LIST_REGIONS_OPERATION,
1129 GEOMETRY_LIST_REGIONS_OP_VERSION,
1130 &context,
1131 GeometryRegionsResult {
1132 regions: asset.regions.clone(),
1133 },
1134 ))
1135}
1136
1137pub fn geometry_list_regions(asset: &GeometryAsset) -> BuiltinResult<GeometryRegionsResult> {
1138 let envelope =
1139 geometry_list_regions_op(asset, OperationContext::new(None, None)).map_err(|error| {
1140 build_runtime_error(error.message)
1141 .with_builtin(GEOMETRY_LIST_REGIONS_OPERATION)
1142 .with_identifier("RunMat:GeometryListRegionsFailed")
1143 .build()
1144 })?;
1145 Ok(envelope.data)
1146}
1147
1148pub fn geometry_query_entities_op(
1149 asset: &GeometryAsset,
1150 query: GeometryEntityQuery,
1151 context: OperationContext,
1152) -> Result<OperationEnvelope<GeometryEntityQueryResult>, OperationErrorEnvelope> {
1153 let requested_limit = query.limit.unwrap_or(DEFAULT_QUERY_LIMIT);
1154 if requested_limit == 0 {
1155 return Err(operation_error(
1156 GEOMETRY_QUERY_ENTITIES_OPERATION,
1157 GEOMETRY_QUERY_ENTITIES_OP_VERSION,
1158 &context,
1159 OperationErrorSpec {
1160 error_code: "RM.GEOMETRY.QUERY_ENTITIES.INVALID_LIMIT",
1161 error_type: OperationErrorType::Input,
1162 retryable: false,
1163 severity: OperationErrorSeverity::Error,
1164 },
1165 "entity query limit must be greater than zero",
1166 BTreeMap::new(),
1167 ));
1168 }
1169
1170 if let Some(region_id) = query.region_id.as_ref() {
1171 find_region(asset, region_id)
1172 .map_err(|error| map_geometry_query_error(region_id, error, &context))?;
1173 return Ok(OperationEnvelope::new(
1174 GEOMETRY_QUERY_ENTITIES_OPERATION,
1175 GEOMETRY_QUERY_ENTITIES_OP_VERSION,
1176 &context,
1177 query_region_entities(asset, &query, region_id, requested_limit),
1178 ));
1179 }
1180
1181 let mut entities = Vec::new();
1182 let mut produced_total = 0usize;
1183
1184 for mesh in &asset.meshes {
1185 if query
1186 .mesh_id
1187 .as_ref()
1188 .is_some_and(|mesh_id| mesh_id != &mesh.mesh_id)
1189 {
1190 continue;
1191 }
1192
1193 let count = match query.entity_kind {
1194 EntityKind::Node => mesh.vertex_count as usize,
1195 EntityKind::Element | EntityKind::Face => mesh.element_count as usize,
1196 EntityKind::Edge => 0,
1197 };
1198
1199 produced_total += count;
1200
1201 if entities.len() >= requested_limit {
1202 continue;
1203 }
1204
1205 let remaining = requested_limit - entities.len();
1206 let emit = count.min(remaining);
1207 for entity_id in 0..emit {
1208 entities.push(EntityRef {
1209 geometry_id: asset.geometry_id.clone(),
1210 geometry_revision: asset.revision,
1211 mesh_id: mesh.mesh_id.clone(),
1212 entity_kind: query.entity_kind,
1213 entity_id: entity_id as u64,
1214 });
1215 }
1216 }
1217
1218 Ok(OperationEnvelope::new(
1219 GEOMETRY_QUERY_ENTITIES_OPERATION,
1220 GEOMETRY_QUERY_ENTITIES_OP_VERSION,
1221 &context,
1222 GeometryEntityQueryResult {
1223 entities,
1224 truncated: produced_total > requested_limit,
1225 },
1226 ))
1227}
1228
1229fn query_region_entities(
1230 asset: &GeometryAsset,
1231 query: &GeometryEntityQuery,
1232 region_id: &str,
1233 requested_limit: usize,
1234) -> GeometryEntityQueryResult {
1235 if query.entity_kind == EntityKind::Node {
1236 return query_region_nodes(asset, query, region_id, requested_limit);
1237 }
1238
1239 let mut entities = Vec::new();
1240 let mut produced_total = 0usize;
1241 for mapping in asset.region_entity_mappings.iter().filter(|mapping| {
1242 mapping.region_id == region_id
1243 && query
1244 .mesh_id
1245 .as_ref()
1246 .is_none_or(|mesh_id| mesh_id == &mapping.mesh_id)
1247 && mapping_matches_query_kind(mapping.entity_kind, query.entity_kind)
1248 }) {
1249 let mapped_total = mapping.entity_count() as usize;
1250 produced_total = produced_total.saturating_add(mapped_total);
1251 if entities.len() >= requested_limit {
1252 continue;
1253 }
1254 for range in &mapping.ranges {
1255 let Some(end) = range.end_exclusive() else {
1256 continue;
1257 };
1258 for entity_id in range.start..end {
1259 if entities.len() >= requested_limit {
1260 break;
1261 }
1262 entities.push(EntityRef {
1263 geometry_id: asset.geometry_id.clone(),
1264 geometry_revision: asset.revision,
1265 mesh_id: mapping.mesh_id.clone(),
1266 entity_kind: query.entity_kind,
1267 entity_id,
1268 });
1269 }
1270 }
1271 }
1272
1273 GeometryEntityQueryResult {
1274 entities,
1275 truncated: produced_total > requested_limit,
1276 }
1277}
1278
1279fn query_region_nodes(
1280 asset: &GeometryAsset,
1281 query: &GeometryEntityQuery,
1282 region_id: &str,
1283 requested_limit: usize,
1284) -> GeometryEntityQueryResult {
1285 let mut node_refs = BTreeSet::<(String, u64)>::new();
1286 let mut truncated = false;
1287
1288 for mapping in asset.region_entity_mappings.iter().filter(|mapping| {
1289 mapping.region_id == region_id
1290 && query
1291 .mesh_id
1292 .as_ref()
1293 .is_none_or(|mesh_id| mesh_id == &mapping.mesh_id)
1294 && mapping_matches_query_kind(mapping.entity_kind, EntityKind::Face)
1295 }) {
1296 let Some(surface_mesh) = asset
1297 .surface_meshes
1298 .iter()
1299 .find(|mesh| mesh.mesh_id == mapping.mesh_id)
1300 else {
1301 continue;
1302 };
1303 for range in &mapping.ranges {
1304 let Some(end) = range.end_exclusive() else {
1305 continue;
1306 };
1307 for face_id in range.start..end {
1308 let Some(triangle) = surface_mesh.triangles.get(face_id as usize) else {
1309 continue;
1310 };
1311 for vertex_id in triangle {
1312 node_refs.insert((mapping.mesh_id.clone(), *vertex_id as u64));
1313 if node_refs.len() > requested_limit {
1314 truncated = true;
1315 break;
1316 }
1317 }
1318 if truncated {
1319 break;
1320 }
1321 }
1322 if truncated {
1323 break;
1324 }
1325 }
1326 if truncated {
1327 break;
1328 }
1329 }
1330
1331 let entities = node_refs
1332 .into_iter()
1333 .take(requested_limit)
1334 .map(|(mesh_id, entity_id)| EntityRef {
1335 geometry_id: asset.geometry_id.clone(),
1336 geometry_revision: asset.revision,
1337 mesh_id,
1338 entity_kind: EntityKind::Node,
1339 entity_id,
1340 })
1341 .collect();
1342
1343 GeometryEntityQueryResult {
1344 entities,
1345 truncated,
1346 }
1347}
1348
1349fn mapping_matches_query_kind(mapping_kind: EntityKind, query_kind: EntityKind) -> bool {
1350 mapping_kind == query_kind
1351 || matches!(
1352 (mapping_kind, query_kind),
1353 (EntityKind::Face, EntityKind::Element) | (EntityKind::Element, EntityKind::Face)
1354 )
1355}
1356
1357pub fn geometry_query_entities(
1358 asset: &GeometryAsset,
1359 query: GeometryEntityQuery,
1360) -> BuiltinResult<GeometryEntityQueryResult> {
1361 let envelope = geometry_query_entities_op(asset, query, OperationContext::new(None, None))
1362 .map_err(|error| {
1363 build_runtime_error(error.message)
1364 .with_builtin(GEOMETRY_QUERY_ENTITIES_OPERATION)
1365 .with_identifier("RunMat:GeometryQueryEntitiesFailed")
1366 .build()
1367 })?;
1368 Ok(envelope.data)
1369}
1370
1371pub fn geometry_capture_view_op(
1372 asset: &GeometryAsset,
1373 view_spec: GeometryCaptureViewSpec,
1374 context: OperationContext,
1375) -> Result<OperationEnvelope<GeometryCaptureViewResult>, OperationErrorEnvelope> {
1376 if view_spec.width == 0 || view_spec.height == 0 {
1377 return Err(operation_error(
1378 GEOMETRY_CAPTURE_VIEW_OPERATION,
1379 GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1380 &context,
1381 OperationErrorSpec {
1382 error_code: "RM.GEOMETRY.CAPTURE_VIEW.INVALID_SPEC",
1383 error_type: OperationErrorType::Input,
1384 retryable: false,
1385 severity: OperationErrorSeverity::Error,
1386 },
1387 "capture view dimensions must be greater than zero",
1388 BTreeMap::from([
1389 ("width".to_string(), view_spec.width.to_string()),
1390 ("height".to_string(), view_spec.height.to_string()),
1391 ]),
1392 ));
1393 }
1394
1395 let adapter = GEOMETRY_CAPTURE_ADAPTER.with(|slot| *slot.borrow());
1396 if let Some(adapter) = adapter {
1397 let capture = adapter.capture(asset, &view_spec).map_err(|message| {
1398 operation_error(
1399 GEOMETRY_CAPTURE_VIEW_OPERATION,
1400 GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1401 &context,
1402 OperationErrorSpec {
1403 error_code: "RM.GEOMETRY.CAPTURE_VIEW.BACKEND_FAILED",
1404 error_type: OperationErrorType::Backend,
1405 retryable: true,
1406 severity: OperationErrorSeverity::Error,
1407 },
1408 message,
1409 BTreeMap::from([
1410 ("geometry_id".to_string(), asset.geometry_id.clone()),
1411 ("adapter".to_string(), adapter.adapter_name().to_string()),
1412 ]),
1413 )
1414 })?;
1415 return Ok(OperationEnvelope::new(
1416 GEOMETRY_CAPTURE_VIEW_OPERATION,
1417 GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1418 &context,
1419 capture,
1420 ));
1421 }
1422
1423 if view_spec.format.eq_ignore_ascii_case("svg") {
1424 let capture = DEFAULT_SVG_CAPTURE_ADAPTER
1425 .capture(asset, &view_spec)
1426 .map_err(|message| {
1427 operation_error(
1428 GEOMETRY_CAPTURE_VIEW_OPERATION,
1429 GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1430 &context,
1431 OperationErrorSpec {
1432 error_code: "RM.GEOMETRY.CAPTURE_VIEW.BACKEND_FAILED",
1433 error_type: OperationErrorType::Backend,
1434 retryable: true,
1435 severity: OperationErrorSeverity::Error,
1436 },
1437 message,
1438 BTreeMap::from([
1439 ("geometry_id".to_string(), asset.geometry_id.clone()),
1440 (
1441 "adapter".to_string(),
1442 DEFAULT_SVG_CAPTURE_ADAPTER.adapter_name().to_string(),
1443 ),
1444 ]),
1445 )
1446 })?;
1447 return Ok(OperationEnvelope::new(
1448 GEOMETRY_CAPTURE_VIEW_OPERATION,
1449 GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1450 &context,
1451 capture,
1452 ));
1453 }
1454
1455 Err(operation_error(
1456 GEOMETRY_CAPTURE_VIEW_OPERATION,
1457 GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1458 &context,
1459 OperationErrorSpec {
1460 error_code: "RM.GEOMETRY.CAPTURE_VIEW.UNSUPPORTED",
1461 error_type: OperationErrorType::Backend,
1462 retryable: false,
1463 severity: OperationErrorSeverity::Error,
1464 },
1465 "geometry view capture is not wired in runtime yet",
1466 BTreeMap::from([("geometry_id".to_string(), asset.geometry_id.clone())]),
1467 ))
1468}
1469
1470pub fn geometry_capture_view(
1471 asset: &GeometryAsset,
1472 view_spec: GeometryCaptureViewSpec,
1473) -> BuiltinResult<GeometryCaptureViewResult> {
1474 let envelope = geometry_capture_view_op(asset, view_spec, OperationContext::new(None, None))
1475 .map_err(|error| {
1476 build_runtime_error(error.message)
1477 .with_builtin(GEOMETRY_CAPTURE_VIEW_OPERATION)
1478 .with_identifier("RunMat:GeometryCaptureViewFailed")
1479 .build()
1480 })?;
1481 Ok(envelope.data)
1482}
1483
1484#[cfg(feature = "plot-core")]
1485pub fn geometry_preview_scene(
1486 asset: &GeometryAsset,
1487 title: impl Into<String>,
1488 options: GeometryPreviewSceneOptions,
1489) -> Result<runmat_plot::GeometryScene, String> {
1490 if asset.surface_meshes.is_empty() {
1491 return Err("geometry asset does not contain renderable surface mesh data".to_string());
1492 }
1493
1494 let triangles_per_chunk = options.triangles_per_chunk.max(1);
1495 let cad_presentation = options.presentation == GeometryPreviewPresentation::Cad;
1496 let mut chunks = Vec::new();
1497
1498 for (mesh_index, surface_mesh) in asset.surface_meshes.iter().enumerate() {
1499 if surface_mesh.vertices.is_empty() || surface_mesh.triangles.is_empty() {
1500 continue;
1501 }
1502 let positions = surface_mesh
1503 .vertices
1504 .iter()
1505 .map(|position| {
1506 Ok([
1507 f64_to_f32_coordinate(position[0])?,
1508 f64_to_f32_coordinate(position[1])?,
1509 f64_to_f32_coordinate(position[2])?,
1510 ])
1511 })
1512 .collect::<Result<Vec<_>, String>>()?;
1513
1514 let presentation = cad_presentation.then(|| {
1515 cad_mesh_presentation(
1516 asset,
1517 &surface_mesh.mesh_id,
1518 surface_mesh.triangles.len(),
1519 surface_mesh.vertices.len(),
1520 )
1521 });
1522 let base_color = if cad_presentation {
1523 CAD_DEFAULT_FACE_COLOR
1524 } else {
1525 preview_mesh_color(mesh_index)
1526 };
1527 let alpha = if options.xray { 0.34 } else { base_color.w };
1528 let mut material = runmat_plot::cad_default_material();
1529 material.albedo = glam::Vec4::new(base_color.x, base_color.y, base_color.z, alpha);
1530 if options.xray {
1531 material.alpha_mode = runmat_plot::core::AlphaMode::Blend;
1532 }
1533
1534 let mut chunk_index = 0usize;
1535 let mut chunk_start_triangle = 0usize;
1536 while chunk_start_triangle < surface_mesh.triangles.len() {
1537 let owner_node_ids = presentation
1538 .as_ref()
1539 .map(|item| {
1540 item.owner_node_ids_for_triangle(chunk_start_triangle)
1541 .to_vec()
1542 })
1543 .unwrap_or_default();
1544 let mut chunk_end_triangle = chunk_start_triangle + 1;
1545 while chunk_end_triangle < surface_mesh.triangles.len()
1546 && chunk_end_triangle.saturating_sub(chunk_start_triangle) < triangles_per_chunk
1547 {
1548 let next_owner_node_ids = presentation
1549 .as_ref()
1550 .map(|item| item.owner_node_ids_for_triangle(chunk_end_triangle))
1551 .unwrap_or(&[]);
1552 if next_owner_node_ids != owner_node_ids.as_slice() {
1553 break;
1554 }
1555 chunk_end_triangle += 1;
1556 }
1557 let triangles = &surface_mesh.triangles[chunk_start_triangle..chunk_end_triangle];
1558 let mut remap = HashMap::<u32, u32>::with_capacity(triangles.len() * 3);
1559 let mut local_positions = Vec::<[f32; 3]>::new();
1560 let mut local_colors = Vec::<[f32; 4]>::new();
1561 let mut indices = Vec::<u32>::with_capacity(triangles.len() * 3);
1562
1563 for triangle in triangles {
1564 for source_index in triangle {
1565 let local_index = if let Some(local_index) = remap.get(source_index) {
1566 *local_index
1567 } else {
1568 let source_index_usize = usize::try_from(*source_index).map_err(|_| {
1569 format!(
1570 "surface mesh '{}' has an invalid vertex index",
1571 surface_mesh.mesh_id
1572 )
1573 })?;
1574 let position = positions.get(source_index_usize).ok_or_else(|| {
1575 format!(
1576 "surface mesh '{}' references vertex {} outside {} vertices",
1577 surface_mesh.mesh_id,
1578 source_index,
1579 positions.len()
1580 )
1581 })?;
1582 let local_index = u32::try_from(local_positions.len()).map_err(|_| {
1583 format!(
1584 "surface mesh '{}' preview chunk exceeded u32 vertex indices",
1585 surface_mesh.mesh_id
1586 )
1587 })?;
1588 local_positions.push(*position);
1589 let vertex_color = presentation
1590 .as_ref()
1591 .and_then(|item| item.vertex_colors.as_ref())
1592 .and_then(|colors| colors.get(source_index_usize))
1593 .copied()
1594 .unwrap_or(base_color);
1595 local_colors.push([vertex_color.x, vertex_color.y, vertex_color.z, alpha]);
1596 remap.insert(*source_index, local_index);
1597 local_index
1598 };
1599 indices.push(local_index);
1600 }
1601 }
1602
1603 let normals = local_vertex_normals(&local_positions, &indices);
1604 let vertices = local_positions
1605 .into_iter()
1606 .zip(local_colors)
1607 .zip(normals)
1608 .map(|((position, color), normal)| {
1609 runmat_plot::geometry_scene_vertex(position, color, normal)
1610 })
1611 .collect::<Vec<_>>();
1612 let regions = geometry_scene_regions_for_surface_chunk(
1613 asset,
1614 &surface_mesh.mesh_id,
1615 chunk_start_triangle,
1616 triangles.len(),
1617 );
1618 let chunk = runmat_plot::GeometrySceneChunk::indexed_triangles(
1619 format!("{}:chunk_{chunk_index}", surface_mesh.mesh_id),
1620 vertices,
1621 indices,
1622 material.clone(),
1623 )
1624 .with_mesh_id(surface_mesh.mesh_id.clone())
1625 .with_label(format!(
1626 "{} chunk {}",
1627 surface_mesh.mesh_id,
1628 chunk_index + 1
1629 ))
1630 .with_regions(regions)
1631 .with_owner_node_ids(owner_node_ids);
1632 chunks.push(chunk);
1633 chunk_start_triangle = chunk_end_triangle;
1634 chunk_index += 1;
1635 }
1636 }
1637
1638 if chunks.is_empty() {
1639 return Err("geometry asset did not contain renderable surface mesh triangles".to_string());
1640 }
1641
1642 Ok(
1643 runmat_plot::GeometryScene::new(geometry_scene_id(asset), asset.revision as u64, chunks)
1644 .with_title(title),
1645 )
1646}
1647
1648#[cfg(feature = "plot-core")]
1649pub fn geometry_preview_scene_overlay(
1650 asset: &GeometryAsset,
1651 source_name: Option<String>,
1652 status: runmat_plot::GeometrySceneCompleteness,
1653 quality_label: impl Into<String>,
1654 format: Option<String>,
1655 byte_count: Option<u64>,
1656 allow_create_fea_study: bool,
1657) -> runmat_plot::GeometrySceneOverlay {
1658 let mapping_summary = region_mapping_summary(asset, DEFAULT_MAPPING_RANGE_PREVIEW_LIMIT);
1659 let source_label = Some(format!(
1660 "{} / {}",
1661 source_geometry_kind_label(asset.source_geometry.kind),
1662 asset.source.importer_version
1663 ));
1664 let warnings = asset
1665 .diagnostics
1666 .iter()
1667 .filter(|diagnostic| {
1668 matches!(
1669 diagnostic.severity,
1670 DiagnosticSeverity::Warning | DiagnosticSeverity::Error
1671 )
1672 })
1673 .take(4)
1674 .map(|diagnostic| diagnostic.message.clone())
1675 .collect();
1676
1677 runmat_plot::GeometrySceneOverlay {
1678 source_name,
1679 status,
1680 quality_label: Some(quality_label.into()),
1681 format,
1682 source_label,
1683 allow_create_fea_study,
1684 byte_count,
1685 mesh_count: asset.meshes.len(),
1686 vertex_count: asset
1687 .surface_meshes
1688 .iter()
1689 .map(|mesh| mesh.vertices.len())
1690 .sum(),
1691 triangle_count: asset
1692 .surface_meshes
1693 .iter()
1694 .map(|mesh| mesh.triangles.len())
1695 .sum(),
1696 progress_percent: None,
1697 region_count: asset.regions.len(),
1698 mapped_region_count: mapping_summary.mapped_region_count,
1699 assembly_nodes: asset
1700 .source_geometry
1701 .assembly
1702 .as_ref()
1703 .map(|node| vec![geometry_scene_assembly_node(node)])
1704 .unwrap_or_default(),
1705 regions: geometry_scene_region_summaries(asset),
1706 warnings,
1707 }
1708}
1709
1710#[cfg(feature = "plot-core")]
1711fn geometry_scene_assembly_node(node: &AssemblyNode) -> runmat_plot::GeometrySceneAssemblyNode {
1712 runmat_plot::GeometrySceneAssemblyNode {
1713 node_id: node.node_id.clone(),
1714 label: node.label.clone(),
1715 children: node
1716 .children
1717 .iter()
1718 .map(geometry_scene_assembly_node)
1719 .collect(),
1720 }
1721}
1722
1723#[cfg(feature = "plot-core")]
1724fn geometry_scene_region_summaries(
1725 asset: &GeometryAsset,
1726) -> Vec<runmat_plot::GeometrySceneRegionSummary> {
1727 let mut triangle_counts: BTreeMap<String, usize> = BTreeMap::new();
1728 for mapping in &asset.region_entity_mappings {
1729 if !matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element) {
1730 continue;
1731 }
1732 let count = mapping
1733 .ranges
1734 .iter()
1735 .filter_map(|range| {
1736 range
1737 .end_exclusive()
1738 .map(|end| end.saturating_sub(range.start))
1739 })
1740 .map(|count| usize::try_from(count).unwrap_or(usize::MAX))
1741 .fold(0usize, |total, count| total.saturating_add(count));
1742 triangle_counts
1743 .entry(mapping.region_id.clone())
1744 .and_modify(|total| *total = total.saturating_add(count))
1745 .or_insert(count);
1746 }
1747
1748 asset
1749 .regions
1750 .iter()
1751 .map(|region| runmat_plot::GeometrySceneRegionSummary {
1752 region_id: region.region_id.clone(),
1753 label: region.name.clone(),
1754 tag: region.tag.clone(),
1755 kind: region
1756 .cad_ownership
1757 .as_ref()
1758 .and_then(|ownership| ownership.label.as_ref())
1759 .map(|label| format!("{:?}", label.kind).to_ascii_lowercase()),
1760 triangle_count: triangle_counts
1761 .get(®ion.region_id)
1762 .copied()
1763 .unwrap_or_default(),
1764 })
1765 .collect()
1766}
1767
1768#[cfg(feature = "plot-core")]
1769fn source_geometry_kind_label(kind: SourceGeometryKind) -> &'static str {
1770 match kind {
1771 SourceGeometryKind::Mesh => "mesh",
1772 SourceGeometryKind::Cad => "cad",
1773 }
1774}
1775
1776#[cfg(feature = "plot-core")]
1777pub fn geometry_preview_figure(
1778 asset: &GeometryAsset,
1779 title: impl Into<String>,
1780 options: GeometryPreviewFigureOptions,
1781) -> Result<runmat_plot::plots::Figure, String> {
1782 if asset.surface_meshes.is_empty() {
1783 return Err("geometry asset does not contain renderable surface mesh data".to_string());
1784 }
1785
1786 let cad_presentation = options.presentation == GeometryPreviewPresentation::Cad;
1787 let mut figure = if cad_presentation {
1788 runmat_plot::plots::Figure::new()
1789 .with_grid(false)
1790 .with_legend(false)
1791 .with_axis_equal(true)
1792 } else {
1793 let mut figure = runmat_plot::plots::Figure::new()
1794 .with_title(title)
1795 .with_labels("X", "Y")
1796 .with_grid(true)
1797 .with_axis_equal(true);
1798 figure.z_label = Some("Z".to_string());
1799 figure
1800 };
1801 if cad_presentation {
1802 figure.set_axes_view(0, -38.0, 24.0);
1803 }
1804
1805 for (index, surface_mesh) in asset.surface_meshes.iter().enumerate() {
1806 let vertices = surface_mesh
1807 .vertices
1808 .iter()
1809 .map(|vertex| {
1810 Ok(glam::Vec3::new(
1811 f64_to_f32_coordinate(vertex[0])?,
1812 f64_to_f32_coordinate(vertex[1])?,
1813 f64_to_f32_coordinate(vertex[2])?,
1814 ))
1815 })
1816 .collect::<Result<Vec<_>, String>>()?;
1817 let mut mesh = runmat_plot::plots::MeshPlot::new(vertices, surface_mesh.triangles.clone())?;
1818 mesh.set_mesh_id(Some(surface_mesh.mesh_id.clone()));
1819 mesh.set_regions(mesh_regions_for_surface(asset, &surface_mesh.mesh_id));
1820 if !cad_presentation {
1821 mesh.set_label(Some(format!(
1822 "{}: {} triangles",
1823 surface_mesh.mesh_id,
1824 surface_mesh.triangles.len()
1825 )));
1826 }
1827
1828 if cad_presentation {
1829 let presentation = cad_mesh_presentation(
1830 asset,
1831 &surface_mesh.mesh_id,
1832 surface_mesh.triangles.len(),
1833 surface_mesh.vertices.len(),
1834 );
1835 mesh.set_face_color(CAD_DEFAULT_FACE_COLOR);
1836 mesh.set_edge_color(CAD_FEATURE_EDGE_COLOR);
1837 mesh.set_face_alpha(if options.xray { 0.34 } else { 1.0 });
1838 mesh.set_edge_alpha(if options.xray { 0.9 } else { 0.72 });
1839 if let Some(colors) = presentation.vertex_colors {
1840 mesh.set_vertex_colors(Some(colors))?;
1841 }
1842 if let Some(groups) = presentation.feature_edge_groups {
1843 mesh.set_feature_edge_groups(Some(groups))?;
1844 mesh.set_edge_mode(runmat_plot::plots::MeshEdgeMode::Feature);
1845 mesh.set_edge_width(0.85);
1846 } else if surface_mesh.triangles.len() > options.edge_overlay_triangle_limit {
1847 mesh.set_edge_mode(runmat_plot::plots::MeshEdgeMode::None);
1848 mesh.set_edge_width(0.0);
1849 } else {
1850 mesh.set_edge_mode(runmat_plot::plots::MeshEdgeMode::All);
1851 mesh.set_edge_width(0.28);
1852 }
1853 } else {
1854 let color = preview_mesh_color(index);
1855 mesh.set_face_color(color);
1856 mesh.set_edge_color(glam::Vec4::new(0.86, 0.91, 1.0, 0.82));
1857 mesh.set_face_alpha(0.92);
1858 if surface_mesh.triangles.len() > options.edge_overlay_triangle_limit {
1859 mesh.set_edge_width(0.0);
1860 } else {
1861 mesh.set_edge_width(0.35);
1862 }
1863 }
1864 figure.add_mesh_plot(mesh);
1865 }
1866
1867 Ok(figure)
1868}
1869
1870#[cfg(feature = "plot-core")]
1871#[derive(Debug, Default)]
1872struct CadMeshPresentation {
1873 feature_edge_groups: Option<Vec<u64>>,
1874 vertex_colors: Option<Vec<glam::Vec4>>,
1875 owner_paths: Vec<Vec<String>>,
1876 triangle_owner_path_indices: Option<Vec<Option<usize>>>,
1877}
1878
1879#[cfg(feature = "plot-core")]
1880impl CadMeshPresentation {
1881 fn owner_node_ids_for_triangle(&self, triangle_index: usize) -> &[String] {
1882 self.triangle_owner_path_indices
1883 .as_ref()
1884 .and_then(|indices| indices.get(triangle_index))
1885 .and_then(|index| index.and_then(|index| self.owner_paths.get(index)))
1886 .map(Vec::as_slice)
1887 .unwrap_or(&[])
1888 }
1889}
1890
1891#[cfg(feature = "plot-core")]
1892fn cad_mesh_presentation(
1893 asset: &GeometryAsset,
1894 mesh_id: &str,
1895 triangle_count: usize,
1896 vertex_count: usize,
1897) -> CadMeshPresentation {
1898 if triangle_count == 0 {
1899 return CadMeshPresentation::default();
1900 }
1901
1902 let prefer_face_mappings = asset.source_geometry.kind == SourceGeometryKind::Cad;
1903 let mut feature_edge_groups = vec![0_u64; triangle_count];
1904 let mut vertex_colors = vec![CAD_DEFAULT_FACE_COLOR; vertex_count];
1905 let mut triangle_owner_path_indices = vec![None; triangle_count];
1906 let mut owner_paths = Vec::<Vec<String>>::new();
1907 let mut owner_path_indices = BTreeMap::<Vec<String>, usize>::new();
1908 let mut group_ids_by_region = BTreeMap::<String, u64>::new();
1909 let mut assigned_groups = false;
1910 let mut assigned_colors = false;
1911 let mut assigned_owner_paths = false;
1912 let surface_triangles = asset
1913 .surface_meshes
1914 .iter()
1915 .find(|surface_mesh| surface_mesh.mesh_id == mesh_id)
1916 .map(|surface_mesh| surface_mesh.triangles.as_slice());
1917
1918 for mapping in asset.region_entity_mappings.iter().filter(|mapping| {
1919 mapping.mesh_id == mesh_id
1920 && matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element)
1921 }) {
1922 let Some(region) = asset
1923 .regions
1924 .iter()
1925 .find(|region| region.region_id == mapping.region_id)
1926 else {
1927 continue;
1928 };
1929 let face_id = region
1930 .cad_ownership
1931 .as_ref()
1932 .and_then(|ownership| ownership.face_id);
1933 if prefer_face_mappings && face_id.is_none() {
1934 continue;
1935 }
1936 let group_id = face_id
1937 .map(|face_id| face_id.saturating_add(1))
1938 .unwrap_or_else(|| {
1939 if let Some(group_id) = group_ids_by_region.get(&mapping.region_id) {
1940 *group_id
1941 } else {
1942 let group_id = group_ids_by_region.len() as u64 + 1;
1943 group_ids_by_region.insert(mapping.region_id.clone(), group_id);
1944 group_id
1945 }
1946 });
1947 let color = cad_region_color(region);
1948 let owner_node_ids = cad_region_owner_node_ids(region);
1949 let owner_path_index = if owner_node_ids.is_empty() {
1950 None
1951 } else if let Some(index) = owner_path_indices.get(&owner_node_ids) {
1952 Some(*index)
1953 } else {
1954 let index = owner_paths.len();
1955 owner_path_indices.insert(owner_node_ids.clone(), index);
1956 owner_paths.push(owner_node_ids);
1957 Some(index)
1958 };
1959 for range in &mapping.ranges {
1960 for triangle_index in bounded_range(range, triangle_count) {
1961 feature_edge_groups[triangle_index] = group_id;
1962 assigned_groups = true;
1963 if let Some(owner_path_index) = owner_path_index {
1964 triangle_owner_path_indices[triangle_index] = Some(owner_path_index);
1965 assigned_owner_paths = true;
1966 }
1967 if let Some(color) = color {
1968 assigned_colors |= color_vertices_for_triangle(
1969 surface_triangles,
1970 triangle_index,
1971 color,
1972 &mut vertex_colors,
1973 );
1974 }
1975 }
1976 }
1977 }
1978
1979 CadMeshPresentation {
1980 feature_edge_groups: assigned_groups.then_some(feature_edge_groups),
1981 vertex_colors: assigned_colors.then_some(vertex_colors),
1982 owner_paths,
1983 triangle_owner_path_indices: assigned_owner_paths.then_some(triangle_owner_path_indices),
1984 }
1985}
1986
1987#[cfg(feature = "plot-core")]
1988fn cad_region_owner_node_ids(region: &Region) -> Vec<String> {
1989 let Some(ownership) = region.cad_ownership.as_ref() else {
1990 return Vec::new();
1991 };
1992 let mut ids = Vec::new();
1993 for owner in &ownership.owner_path {
1997 push_unique_owner_node_id(&mut ids, &owner.label_entry);
1998 }
1999 ids
2000}
2001
2002#[cfg(feature = "plot-core")]
2003fn push_unique_owner_node_id(ids: &mut Vec<String>, candidate: &str) {
2004 let candidate = candidate.trim();
2005 if candidate.is_empty() || ids.iter().any(|existing| existing == candidate) {
2006 return;
2007 }
2008 ids.push(candidate.to_string());
2009}
2010
2011#[cfg(feature = "plot-core")]
2012fn bounded_range(range: &EntityIdRange, upper_bound: usize) -> std::ops::Range<usize> {
2013 let start = usize::try_from(range.start).unwrap_or(usize::MAX);
2014 let count = usize::try_from(range.count).unwrap_or(usize::MAX);
2015 let start = start.min(upper_bound);
2016 let end = start.saturating_add(count).min(upper_bound);
2017 start..end
2018}
2019
2020#[cfg(feature = "plot-core")]
2021fn color_vertices_for_triangle(
2022 triangles: Option<&[[u32; 3]]>,
2023 triangle_index: usize,
2024 color: glam::Vec4,
2025 vertex_colors: &mut [glam::Vec4],
2026) -> bool {
2027 let Some(triangle) = triangles.and_then(|triangles| triangles.get(triangle_index)) else {
2028 return false;
2029 };
2030 let mut colored = false;
2031 for vertex_id in triangle {
2032 if let Some(slot) = vertex_colors.get_mut(*vertex_id as usize) {
2033 *slot = color;
2034 colored = true;
2035 }
2036 }
2037 colored
2038}
2039
2040#[cfg(feature = "plot-core")]
2041fn cad_region_color(region: &Region) -> Option<glam::Vec4> {
2042 region
2043 .cad_ownership
2044 .as_ref()
2045 .and_then(|ownership| ownership.color.as_ref())
2046 .and_then(|color| parse_cad_hex_rgba(&color.hex_rgba))
2047 .map(cad_display_color)
2048}
2049
2050#[cfg(feature = "plot-core")]
2051fn parse_cad_hex_rgba(value: &str) -> Option<glam::Vec4> {
2052 let value = value.trim().trim_start_matches('#');
2053 if value.len() != 6 && value.len() != 8 {
2054 return None;
2055 }
2056 let r = u8::from_str_radix(&value[0..2], 16).ok()? as f32 / 255.0;
2057 let g = u8::from_str_radix(&value[2..4], 16).ok()? as f32 / 255.0;
2058 let b = u8::from_str_radix(&value[4..6], 16).ok()? as f32 / 255.0;
2059 let a = if value.len() == 8 {
2060 u8::from_str_radix(&value[6..8], 16).ok()? as f32 / 255.0
2061 } else {
2062 1.0
2063 };
2064 Some(glam::Vec4::new(r, g, b, a))
2065}
2066
2067#[cfg(feature = "plot-core")]
2068fn cad_display_color(color: glam::Vec4) -> glam::Vec4 {
2069 let rgb = glam::Vec3::new(color.x, color.y, color.z);
2070 let gray = glam::Vec3::splat((rgb.x + rgb.y + rgb.z) / 3.0);
2071 let softened = rgb
2072 .lerp(gray, 0.18)
2073 .lerp(CAD_DEFAULT_FACE_COLOR.truncate(), 0.16);
2074 glam::Vec4::new(softened.x, softened.y, softened.z, color.w.max(0.2))
2075}
2076
2077#[cfg(feature = "plot-core")]
2078fn mesh_regions_for_surface(
2079 asset: &GeometryAsset,
2080 mesh_id: &str,
2081) -> Vec<runmat_plot::plots::MeshRegion> {
2082 asset
2083 .region_entity_mappings
2084 .iter()
2085 .filter(|mapping| {
2086 mapping.mesh_id == mesh_id
2087 && matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element)
2088 })
2089 .filter_map(|mapping| {
2090 let triangle_ranges = mapping
2091 .ranges
2092 .iter()
2093 .filter_map(|range| {
2094 let start = u32::try_from(range.start).ok()?;
2095 let count = u32::try_from(range.count).ok()?;
2096 if count == 0 {
2097 None
2098 } else {
2099 Some(runmat_plot::plots::MeshTriangleRange::new(start, count))
2100 }
2101 })
2102 .collect::<Vec<_>>();
2103 if triangle_ranges.is_empty() {
2104 return None;
2105 }
2106 let region = asset
2107 .regions
2108 .iter()
2109 .find(|region| region.region_id == mapping.region_id);
2110 Some(runmat_plot::plots::MeshRegion::new(
2111 mapping.region_id.clone(),
2112 region.map(|region| region.name.clone()),
2113 region.and_then(|region| region.tag.clone()),
2114 triangle_ranges,
2115 ))
2116 })
2117 .collect()
2118}
2119
2120#[cfg(feature = "plot-core")]
2121fn geometry_scene_id(asset: &GeometryAsset) -> String {
2122 format!(
2123 "{}:{}:{}",
2124 asset.geometry_id, asset.source.sha256, asset.tessellation_profile.profile_id
2125 )
2126}
2127
2128#[cfg(feature = "plot-core")]
2129fn geometry_scene_regions_for_surface_chunk(
2130 asset: &GeometryAsset,
2131 mesh_id: &str,
2132 chunk_start_triangle: usize,
2133 chunk_triangle_count: usize,
2134) -> Vec<runmat_plot::GeometrySceneRegion> {
2135 if chunk_triangle_count == 0 {
2136 return Vec::new();
2137 }
2138 let chunk_start = chunk_start_triangle as u64;
2139 let chunk_end = chunk_start.saturating_add(chunk_triangle_count as u64);
2140 asset
2141 .region_entity_mappings
2142 .iter()
2143 .filter(|mapping| {
2144 mapping.mesh_id == mesh_id
2145 && matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element)
2146 })
2147 .filter_map(|mapping| {
2148 let triangle_ranges = mapping
2149 .ranges
2150 .iter()
2151 .filter_map(|range| {
2152 let range_end = range.end_exclusive()?;
2153 let start = range.start.max(chunk_start);
2154 let end = range_end.min(chunk_end);
2155 if end <= start {
2156 return None;
2157 }
2158 let local_start = u32::try_from(start - chunk_start).ok()?;
2159 let count = u32::try_from(end - start).ok()?;
2160 Some(runmat_plot::GeometrySceneTriangleRange::new(
2161 local_start,
2162 count,
2163 ))
2164 })
2165 .collect::<Vec<_>>();
2166 if triangle_ranges.is_empty() {
2167 return None;
2168 }
2169 let region = asset
2170 .regions
2171 .iter()
2172 .find(|region| region.region_id == mapping.region_id);
2173 Some(runmat_plot::GeometrySceneRegion::new(
2174 mapping.region_id.clone(),
2175 region.map(|region| region.name.clone()),
2176 region.and_then(|region| region.tag.clone()),
2177 triangle_ranges,
2178 ))
2179 })
2180 .collect()
2181}
2182
2183#[cfg(feature = "plot-core")]
2184fn local_vertex_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
2185 let mut normals = vec![[0.0, 0.0, 0.0]; positions.len()];
2186 for triangle in indices.chunks_exact(3) {
2187 let a = triangle[0] as usize;
2188 let b = triangle[1] as usize;
2189 let c = triangle[2] as usize;
2190 if a >= positions.len() || b >= positions.len() || c >= positions.len() {
2191 continue;
2192 }
2193 let normal = face_normal(positions[a], positions[b], positions[c]);
2194 accumulate_normal(&mut normals[a], normal);
2195 accumulate_normal(&mut normals[b], normal);
2196 accumulate_normal(&mut normals[c], normal);
2197 }
2198 normals.into_iter().map(normalize_or_default).collect()
2199}
2200
2201#[cfg(feature = "plot-core")]
2202fn face_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
2203 let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
2204 let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
2205 normalize_or_default([
2206 ab[1] * ac[2] - ab[2] * ac[1],
2207 ab[2] * ac[0] - ab[0] * ac[2],
2208 ab[0] * ac[1] - ab[1] * ac[0],
2209 ])
2210}
2211
2212#[cfg(feature = "plot-core")]
2213fn accumulate_normal(target: &mut [f32; 3], normal: [f32; 3]) {
2214 target[0] += normal[0];
2215 target[1] += normal[1];
2216 target[2] += normal[2];
2217}
2218
2219#[cfg(feature = "plot-core")]
2220fn normalize_or_default(value: [f32; 3]) -> [f32; 3] {
2221 let length_squared = value[0] * value[0] + value[1] * value[1] + value[2] * value[2];
2222 if length_squared <= f32::EPSILON || !length_squared.is_finite() {
2223 return [0.0, 0.0, 1.0];
2224 }
2225 let inv_length = length_squared.sqrt().recip();
2226 [
2227 value[0] * inv_length,
2228 value[1] * inv_length,
2229 value[2] * inv_length,
2230 ]
2231}
2232
2233#[cfg(feature = "plot-core")]
2234fn f64_to_f32_coordinate(value: f64) -> Result<f32, String> {
2235 if !value.is_finite() {
2236 return Err("geometry preview mesh contains a non-finite coordinate".to_string());
2237 }
2238 if value < f32::MIN as f64 || value > f32::MAX as f64 {
2239 return Err("geometry preview mesh coordinate exceeds f32 render range".to_string());
2240 }
2241 Ok(value as f32)
2242}
2243
2244#[cfg(feature = "plot-core")]
2245fn preview_mesh_color(index: usize) -> glam::Vec4 {
2246 const PALETTE: [[f32; 4]; 6] = [
2247 [0.18, 0.48, 0.86, 1.0],
2248 [0.13, 0.62, 0.44, 1.0],
2249 [0.84, 0.43, 0.18, 1.0],
2250 [0.57, 0.38, 0.77, 1.0],
2251 [0.73, 0.62, 0.18, 1.0],
2252 [0.20, 0.62, 0.75, 1.0],
2253 ];
2254 glam::Vec4::from_array(PALETTE[index % PALETTE.len()])
2255}
2256
2257pub fn geometry_prep_for_analysis_op(
2258 asset: &GeometryAsset,
2259 spec: GeometryPrepForAnalysisSpec,
2260 context: OperationContext,
2261) -> Result<OperationEnvelope<GeometryPrepForAnalysisResult>, OperationErrorEnvelope> {
2262 if spec.target_element_budget == 0 {
2263 return Err(operation_error(
2264 GEOMETRY_PREP_FOR_ANALYSIS_OPERATION,
2265 GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION,
2266 &context,
2267 OperationErrorSpec {
2268 error_code: "RM.GEOMETRY.PREP_FOR_ANALYSIS.INVALID_SPEC",
2269 error_type: OperationErrorType::Input,
2270 retryable: false,
2271 severity: OperationErrorSeverity::Error,
2272 },
2273 "prep-for-analysis target_element_budget must be greater than zero",
2274 BTreeMap::from([(
2275 "target_element_budget".to_string(),
2276 spec.target_element_budget.to_string(),
2277 )]),
2278 ));
2279 }
2280
2281 let profile = match spec.profile {
2282 GeometryPrepProfile::SurfaceOnly => MeshingProfile::SurfaceOnly,
2283 GeometryPrepProfile::AnalysisReady => MeshingProfile::AnalysisReady,
2284 GeometryPrepProfile::AdaptiveRefine => MeshingProfile::AdaptiveRefine,
2285 };
2286 let prepared = prepare_geometry_for_analysis(
2287 asset,
2288 MeshingOptions {
2289 profile,
2290 target_element_budget: spec.target_element_budget,
2291 },
2292 )
2293 .map_err(|error| {
2294 operation_error(
2295 GEOMETRY_PREP_FOR_ANALYSIS_OPERATION,
2296 GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION,
2297 &context,
2298 OperationErrorSpec {
2299 error_code: "RM.GEOMETRY.PREP_FOR_ANALYSIS.FAILED",
2300 error_type: OperationErrorType::Validation,
2301 retryable: false,
2302 severity: OperationErrorSeverity::Error,
2303 },
2304 format!("failed to prepare geometry for analysis: {error}"),
2305 BTreeMap::from([("geometry_id".to_string(), asset.geometry_id.clone())]),
2306 )
2307 })?;
2308
2309 let artifact = persist_prep_artifact(asset, prepared).map_err(|error| {
2310 operation_error(
2311 GEOMETRY_PREP_FOR_ANALYSIS_OPERATION,
2312 GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION,
2313 &context,
2314 OperationErrorSpec {
2315 error_code: "RM.GEOMETRY.PREP_FOR_ANALYSIS.ARTIFACT_STORE_FAILED",
2316 error_type: OperationErrorType::Internal,
2317 retryable: true,
2318 severity: OperationErrorSeverity::Error,
2319 },
2320 format!("failed to persist prep artifact: {error}"),
2321 BTreeMap::from([("geometry_id".to_string(), asset.geometry_id.clone())]),
2322 )
2323 })?;
2324
2325 Ok(OperationEnvelope::new(
2326 GEOMETRY_PREP_FOR_ANALYSIS_OPERATION,
2327 GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION,
2328 &context,
2329 GeometryPrepForAnalysisResult {
2330 prep_artifact_id: artifact.prep_artifact_id,
2331 prep: artifact.prep,
2332 },
2333 ))
2334}
2335
2336pub fn geometry_prep_for_analysis(
2337 asset: &GeometryAsset,
2338 spec: GeometryPrepForAnalysisSpec,
2339) -> BuiltinResult<GeometryPrepForAnalysisResult> {
2340 let envelope = geometry_prep_for_analysis_op(asset, spec, OperationContext::new(None, None))
2341 .map_err(|error| {
2342 build_runtime_error(error.message)
2343 .with_builtin(GEOMETRY_PREP_FOR_ANALYSIS_OPERATION)
2344 .with_identifier("RunMat:GeometryPrepForAnalysisFailed")
2345 .build()
2346 })?;
2347 Ok(envelope.data)
2348}
2349
2350fn format_name(format: GeometryFormat) -> &'static str {
2351 match format {
2352 runmat_geometry_io::GeometryFormat::Stl => "stl",
2353 runmat_geometry_io::GeometryFormat::Step => "step",
2354 runmat_geometry_io::GeometryFormat::Iges => "iges",
2355 runmat_geometry_io::GeometryFormat::Brep => "brep",
2356 runmat_geometry_io::GeometryFormat::Obj => "obj",
2357 runmat_geometry_io::GeometryFormat::Ply => "ply",
2358 runmat_geometry_io::GeometryFormat::Gltf => "gltf",
2359 runmat_geometry_io::GeometryFormat::Unknown => "unknown",
2360 }
2361}
2362
2363fn map_geometry_load_error(
2364 path: &str,
2365 error: GeometryImportError,
2366 context: &OperationContext,
2367) -> OperationErrorEnvelope {
2368 let (error_code, error_type, retryable) = match &error {
2369 GeometryImportError::UnsupportedFormat => (
2370 "RM.GEOMETRY.LOAD.FORMAT_UNSUPPORTED",
2371 OperationErrorType::Input,
2372 false,
2373 ),
2374 GeometryImportError::ParseFailed(_) => (
2375 "RM.GEOMETRY.LOAD.PARSE_FAILED",
2376 OperationErrorType::Validation,
2377 false,
2378 ),
2379 GeometryImportError::CapacityExceeded { .. } => (
2380 "RM.GEOMETRY.LOAD.CAPACITY_LIMIT_EXCEEDED",
2381 OperationErrorType::Capacity,
2382 false,
2383 ),
2384 GeometryImportError::BackendUnavailable(_) => (
2385 "RM.GEOMETRY.LOAD.BACKEND_UNAVAILABLE",
2386 OperationErrorType::Backend,
2387 false,
2388 ),
2389 GeometryImportError::Cancelled => (
2390 "RM.GEOMETRY.LOAD.CANCELLED",
2391 OperationErrorType::Cancelled,
2392 false,
2393 ),
2394 };
2395 operation_error(
2396 GEOMETRY_LOAD_OPERATION,
2397 GEOMETRY_LOAD_OP_VERSION,
2398 context,
2399 OperationErrorSpec {
2400 error_code,
2401 error_type,
2402 retryable,
2403 severity: OperationErrorSeverity::Error,
2404 },
2405 error.to_string(),
2406 BTreeMap::from([("path".to_string(), path.to_string())]),
2407 )
2408}
2409
2410fn map_geometry_query_error(
2411 region_id: &str,
2412 error: QueryError,
2413 context: &OperationContext,
2414) -> OperationErrorEnvelope {
2415 match error {
2416 QueryError::RegionNotFound => operation_error(
2417 GEOMETRY_QUERY_ENTITIES_OPERATION,
2418 GEOMETRY_QUERY_ENTITIES_OP_VERSION,
2419 context,
2420 OperationErrorSpec {
2421 error_code: "RM.GEOMETRY.QUERY_ENTITIES.REGION_NOT_FOUND",
2422 error_type: OperationErrorType::Validation,
2423 retryable: false,
2424 severity: OperationErrorSeverity::Error,
2425 },
2426 format!("region '{region_id}' does not exist"),
2427 BTreeMap::from([("region_id".to_string(), region_id.to_string())]),
2428 ),
2429 }
2430}
2431
2432#[cfg(test)]
2433mod tests;