1use std::{collections::HashMap, time::Duration};
22
23use serde::Serialize;
24use sqlx::PgPool;
25use uuid::Uuid;
26
27use crate::{
28 auth::Actor,
29 domain::Role,
30 error::{AppError, AppResult},
31 models::{FileRecord, Revision},
32 preview::PreviewFormat,
33 services::{access, files},
34 shape::{self, Signature},
35 AppState,
36};
37
38pub const MIN_SCORE: f64 = 0.5;
40pub const MAX_LIMIT: i64 = 50;
42pub const PREFILTER: i64 = 2000;
44pub const SIZE_WINDOW: f64 = 3.0;
48pub const RATIO_WINDOW: f64 = 2.0;
51const THIN: f64 = 1e-3;
54
55#[derive(Clone, Copy, Debug)]
57pub struct Options {
58 pub limit: i64,
59 pub ignore_scale: bool,
61}
62
63impl Default for Options {
64 fn default() -> Self {
65 Options {
66 limit: 10,
67 ignore_scale: false,
68 }
69 }
70}
71
72#[derive(Clone, Debug, Serialize)]
74pub struct Candidate {
75 pub item_id: Uuid,
76 pub item_number: String,
77 pub item_type: String,
78 pub project_code: Option<String>,
79 pub revision_id: Uuid,
80 pub revision_code: String,
81 pub state: String,
82 pub name: String,
83 pub file_id: Uuid,
85 pub filename: String,
86 pub identical: bool,
88 pub score: u8,
90 pub shape: u8,
92 pub size: u8,
95 pub extents: [f64; 3],
96 pub explanation: String,
97 pub thumbnail_file_id: Option<Uuid>,
99}
100
101#[derive(Clone, Debug, Serialize)]
103pub struct SourceFile {
104 pub file_id: Uuid,
105 pub filename: String,
106 pub sha256: String,
107 pub format: &'static str,
108}
109
110#[derive(Clone, Debug, Serialize)]
111#[serde(rename_all = "snake_case")]
112pub enum Status {
113 Ready,
115 Pending,
117 NoShapeFile,
119 Failed,
121}
122
123#[derive(Clone, Debug, Serialize)]
125pub struct Report {
126 pub status: Status,
127 pub message: Option<String>,
128 pub source: Option<SourceFile>,
129 pub signature: Option<SignatureSummary>,
130 pub ignore_scale: bool,
131 pub candidates: Vec<Candidate>,
132}
133
134#[derive(Clone, Debug, Serialize)]
136pub struct SignatureSummary {
137 pub extents: [f64; 3],
138 pub area: f64,
139 pub volume: Option<f64>,
141 pub closed: bool,
142 pub triangles: u64,
143}
144
145impl From<&Signature> for SignatureSummary {
146 fn from(s: &Signature) -> Self {
147 SignatureSummary {
148 extents: s.extents,
149 area: s.area,
150 volume: s.closed.then_some(s.volume),
151 closed: s.closed,
152 triangles: s.triangles,
153 }
154 }
155}
156
157pub fn shape_format(file: &FileRecord) -> Option<PreviewFormat> {
159 PreviewFormat::of_format(&file.format)
160 .or_else(|| PreviewFormat::of(&file.filename))
161 .filter(|f| !f.is_point_cloud())
162}
163
164pub fn pick_file<'a>(
167 files: &'a [FileRecord],
168 filename: Option<&str>,
169) -> AppResult<Option<(&'a FileRecord, PreviewFormat)>> {
170 if let Some(name) = filename.map(str::trim).filter(|n| !n.is_empty()) {
171 let file = files
172 .iter()
173 .find(|f| f.filename.eq_ignore_ascii_case(name))
174 .ok_or_else(|| AppError::not_found(format!("The revision has no file '{name}'.")))?;
175 let format = shape_format(file).ok_or_else(|| {
176 AppError::BadRequest(format!(
177 "'{}' is no 3D model with a preview (meshes such as STL, OBJ, 3MF, glTF or STEP).",
178 file.filename
179 ))
180 })?;
181 return Ok(Some((file, format)));
182 }
183 let mut usable: Vec<(&FileRecord, PreviewFormat)> = files
184 .iter()
185 .filter_map(|f| shape_format(f).map(|p| (f, p)))
186 .collect();
187 usable.sort_by_key(|(_, p)| !p.is_brep());
188 Ok(usable.into_iter().next())
189}
190
191#[derive(sqlx::FromRow)]
192struct SignatureRow {
193 version: i16,
194 extent_1: f64,
195 extent_2: f64,
196 extent_3: f64,
197 area: f64,
198 volume: f64,
199 closed: bool,
200 triangles: i64,
201 d2: Vec<f32>,
202}
203
204impl SignatureRow {
205 fn signature(&self) -> Option<Signature> {
206 let s = Signature {
207 version: self.version,
208 extents: [self.extent_1, self.extent_2, self.extent_3],
209 area: self.area,
210 volume: self.volume,
211 closed: self.closed,
212 triangles: u64::try_from(self.triangles).unwrap_or(0),
213 d2: self.d2.clone(),
214 };
215 s.is_valid().then_some(s)
216 }
217}
218
219const SIGNATURE_COLUMNS: &str =
220 "s.version, s.extent_1, s.extent_2, s.extent_3, s.area, s.volume, s.closed, s.triangles, s.d2";
221
222pub async fn stored(pool: &PgPool, sha256: &str) -> AppResult<Option<Signature>> {
224 let row = sqlx::query_as::<_, SignatureRow>(&format!(
225 "SELECT {SIGNATURE_COLUMNS} FROM shape_signatures s WHERE s.sha256 = $1 AND s.version = $2"
226 ))
227 .bind(sha256)
228 .bind(shape::VERSION)
229 .fetch_optional(pool)
230 .await?;
231 Ok(row.and_then(|r| r.signature()))
232}
233
234pub async fn store(pool: &PgPool, sha256: &str, s: &Signature) -> AppResult<()> {
237 if !s.is_valid() {
238 return Err(AppError::BadRequest("Invalid shape signature.".into()));
239 }
240 let [r2, r3] = s.proportions();
241 sqlx::query(
242 "INSERT INTO shape_signatures
243 (sha256, version, extent_1, extent_2, extent_3, ratio_2, ratio_3, area, volume, closed, triangles, d2)
244 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
245 ON CONFLICT (sha256) DO UPDATE SET
246 version = EXCLUDED.version, extent_1 = EXCLUDED.extent_1, extent_2 = EXCLUDED.extent_2,
247 extent_3 = EXCLUDED.extent_3, ratio_2 = EXCLUDED.ratio_2, ratio_3 = EXCLUDED.ratio_3,
248 area = EXCLUDED.area, volume = EXCLUDED.volume, closed = EXCLUDED.closed,
249 triangles = EXCLUDED.triangles, d2 = EXCLUDED.d2, computed_at = now()",
250 )
251 .bind(sha256)
252 .bind(s.version)
253 .bind(s.extents[0])
254 .bind(s.extents[1])
255 .bind(s.extents[2])
256 .bind(r2 as f32)
257 .bind(r3 as f32)
258 .bind(s.area)
259 .bind(s.volume)
260 .bind(s.closed)
261 .bind(i64::try_from(s.triangles).unwrap_or(i64::MAX))
262 .bind(&s.d2)
263 .execute(pool)
264 .await?;
265 Ok(())
266}
267
268pub async fn ensure(st: &AppState, sha256: &str, format: PreviewFormat) -> AppResult<Signature> {
271 if let Some(s) = stored(&st.db, sha256).await? {
272 return Ok(s);
273 }
274 let s = st.previews.signature(&st.vault, sha256, format).await?;
275 store(&st.db, sha256, &s).await?;
276 Ok(s)
277}
278
279pub fn spawn_ensure(
282 st: &AppState,
283 sha256: &str,
284 format: PreviewFormat,
285) -> tokio::task::JoinHandle<AppResult<Signature>> {
286 let st = st.clone();
287 let sha256 = sha256.to_string();
288 tokio::spawn(async move {
289 let result = ensure(&st, &sha256, format).await;
290 if let Err(e) = &result {
291 tracing::debug!(sha256, error = %e.public_message(), "no shape signature");
292 }
293 result
294 })
295}
296
297#[derive(sqlx::FromRow)]
298struct CandidateRow {
299 sha256: String,
300 version: i16,
301 extent_1: f64,
302 extent_2: f64,
303 extent_3: f64,
304 area: f64,
305 volume: f64,
306 closed: bool,
307 triangles: i64,
308 d2: Vec<f32>,
309 file_id: Uuid,
310 filename: String,
311 revision_id: Uuid,
312 revision_code: String,
313 state: String,
314 name: String,
315 item_id: Uuid,
316 item_number: String,
317 item_type: String,
318 project_code: Option<String>,
319}
320
321fn floor_extents(e: [f64; 3]) -> [f64; 3] {
324 let min = (e[0] * THIN).max(1e-12);
325 e.map(|x| x.max(min))
326}
327
328fn prefilter(source: &Signature, ignore_scale: bool) -> ([f64; 4], [f64; 3]) {
333 if ignore_scale {
334 let [r2, r3] = source.proportions().map(|r| r.max(THIN));
335 let w = RATIO_WINDOW;
336 let lo = |r: f64| if r / w <= THIN { -1.0 } else { r / w };
337 ([lo(r2), r2 * w, lo(r3), r3 * w], [r2, r3, 0.0])
338 } else {
339 let e = floor_extents(source.extents);
340 let w = SIZE_WINDOW;
341 ([e[0] / w, e[0] * w, e[1] / w, e[1] * w], e)
342 }
343}
344
345pub async fn find(
348 pool: &PgPool,
349 actor: &Actor,
350 source_item_id: Uuid,
351 source_sha256: &str,
352 source: &Signature,
353 options: Options,
354) -> AppResult<Vec<Candidate>> {
355 actor.require(Role::Viewer)?;
357 let limit = options.limit.clamp(1, MAX_LIMIT) as usize;
358 let (window, order) = if options.ignore_scale {
362 (
363 "s.ratio_2 BETWEEN $3 AND $4 AND s.ratio_3 BETWEEN $5 AND $6",
364 "abs(ln(GREATEST(s.ratio_2::float8, 1e-3) / $7)) \
365 + abs(ln(GREATEST(s.ratio_3::float8, 1e-3) / $8)) + 0 * $9",
366 )
367 } else {
368 (
369 "s.extent_1 BETWEEN $3 AND $4 \
370 AND GREATEST(s.extent_2, s.extent_1 * 1e-3) BETWEEN $5 AND $6",
371 "abs(ln(s.extent_1 / $7)) + abs(ln(GREATEST(s.extent_2, s.extent_1 * 1e-3) / $8)) \
372 + abs(ln(GREATEST(s.extent_3, s.extent_1 * 1e-3) / $9))",
373 )
374 };
375 let ([lo1, hi1, lo2, hi2], [t1, t2, t3]) = prefilter(source, options.ignore_scale);
376 let rows = sqlx::query_as::<_, CandidateRow>(&format!(
377 "SELECT s.sha256, {SIGNATURE_COLUMNS},
378 f.id AS file_id, f.filename, r.id AS revision_id, r.code AS revision_code, r.state, r.name,
379 i.id AS item_id, i.number AS item_number, i.item_type, p.code AS project_code
380 FROM shape_signatures s
381 JOIN files f ON f.sha256 = s.sha256
382 JOIN revisions r ON r.id = f.revision_id
383 JOIN items i ON i.id = r.item_id
384 LEFT JOIN projects p ON p.id = i.project_id
385 WHERE s.version = $1 AND i.id <> $2 AND {window} AND {visible}
386 AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
387 ORDER BY {order}, i.number, f.filename
388 LIMIT $10",
389 visible = access::item_visible("i", 11),
390 ))
391 .bind(shape::VERSION)
392 .bind(source_item_id)
393 .bind(lo1)
394 .bind(hi1)
395 .bind(lo2)
396 .bind(hi2)
397 .bind(t1)
398 .bind(t2)
399 .bind(t3)
400 .bind(PREFILTER)
401 .bind(&actor.hidden_projects)
402 .fetch_all(pool)
403 .await?;
404
405 let mut best: HashMap<Uuid, (shape::Similarity, CandidateRow, Signature)> = HashMap::new();
407 for row in rows {
408 let sig = SignatureRow {
409 version: row.version,
410 extent_1: row.extent_1,
411 extent_2: row.extent_2,
412 extent_3: row.extent_3,
413 area: row.area,
414 volume: row.volume,
415 closed: row.closed,
416 triangles: row.triangles,
417 d2: row.d2.clone(),
418 }
419 .signature();
420 let Some(sig) = sig else { continue };
421 let sim = shape::compare(source, &sig, options.ignore_scale);
422 if sim.score < MIN_SCORE {
423 continue;
424 }
425 match best.get(&row.item_id) {
426 Some((b, _, _)) if b.score >= sim.score => {}
427 _ => {
428 best.insert(row.item_id, (sim, row, sig));
429 }
430 }
431 }
432 let mut ranked: Vec<_> = best.into_values().collect();
433 ranked.sort_by(|a, b| {
434 b.0.score
435 .total_cmp(&a.0.score)
436 .then_with(|| a.1.item_number.cmp(&b.1.item_number))
437 });
438 ranked.truncate(limit);
439
440 let revision_ids: Vec<Uuid> = ranked.iter().map(|r| r.1.revision_id).collect();
442 let thumbs = thumbnails(pool, &revision_ids).await?;
443 let percent = |x: f64| (x * 100.0).floor().clamp(0.0, 100.0) as u8;
444 Ok(ranked
445 .into_iter()
446 .map(|(sim, row, sig)| Candidate {
447 explanation: if row.sha256 == source_sha256 {
448 "identical file content".to_string()
449 } else {
450 shape::explain(source, &sig, options.ignore_scale)
451 },
452 identical: row.sha256 == source_sha256,
453 thumbnail_file_id: thumbs.get(&row.revision_id).copied(),
454 item_id: row.item_id,
455 item_number: row.item_number,
456 item_type: row.item_type,
457 project_code: row.project_code,
458 revision_id: row.revision_id,
459 revision_code: row.revision_code,
460 state: row.state,
461 name: row.name,
462 file_id: row.file_id,
463 filename: row.filename,
464 score: percent(sim.score),
465 shape: percent(sim.shape),
466 size: percent(sim.size),
467 extents: sig.extents,
468 })
469 .collect())
470}
471
472async fn thumbnails(pool: &PgPool, revision_ids: &[Uuid]) -> AppResult<HashMap<Uuid, Uuid>> {
474 if revision_ids.is_empty() {
475 return Ok(HashMap::new());
476 }
477 let rows = sqlx::query_as::<_, (Uuid, Uuid, Option<String>)>(
478 "SELECT revision_id, id, format FROM files
479 WHERE revision_id = ANY($1) ORDER BY lower(filename)",
480 )
481 .bind(revision_ids)
482 .fetch_all(pool)
483 .await?;
484 let mut out = HashMap::new();
485 for (revision_id, file_id, format) in rows {
486 if format.as_deref().is_some_and(crate::thumbnail::supports) {
487 out.entry(revision_id).or_insert(file_id);
488 }
489 }
490 Ok(out)
491}
492
493pub async fn for_revision(
497 st: &AppState,
498 actor: &Actor,
499 rev: &Revision,
500 filename: Option<&str>,
501 options: Options,
502 wait: Duration,
503) -> AppResult<Report> {
504 let files = files::list(&st.db, actor, rev.id).await?;
505 for_files(st, actor, rev, &files, filename, options, wait).await
506}
507
508pub async fn for_files(
510 st: &AppState,
511 actor: &Actor,
512 rev: &Revision,
513 files: &[FileRecord],
514 filename: Option<&str>,
515 options: Options,
516 wait: Duration,
517) -> AppResult<Report> {
518 let mut report = Report {
519 status: Status::NoShapeFile,
520 message: None,
521 source: None,
522 signature: None,
523 ignore_scale: options.ignore_scale,
524 candidates: Vec::new(),
525 };
526 let Some((file, format)) = pick_file(files, filename)? else {
527 report.message = Some(format!(
528 "{}/{} has no 3D model to compare (meshes such as STL, OBJ, 3MF, glTF or STEP); \
529 attach a STEP export of native CAD files.",
530 rev.item_number, rev.code
531 ));
532 return Ok(report);
533 };
534 report.source = Some(SourceFile {
535 file_id: file.id,
536 filename: file.filename.clone(),
537 sha256: file.sha256.clone(),
538 format: format.as_str(),
539 });
540 let signature = match stored(&st.db, &file.sha256).await? {
541 Some(s) => s,
542 None => {
543 let task = spawn_ensure(st, &file.sha256, format);
544 match tokio::time::timeout(wait, task).await {
545 Err(_) => {
546 report.status = Status::Pending;
547 report.message = Some(
548 "The shape signature is being computed – try again in a moment.".into(),
549 );
550 return Ok(report);
551 }
552 Ok(Err(e)) => return Err(AppError::Other(anyhow::anyhow!("signature task: {e}"))),
553 Ok(Ok(Err(e @ AppError::BadRequest(_))))
554 | Ok(Ok(Err(e @ AppError::NotFound(_)))) => {
555 report.status = Status::Failed;
556 report.message = Some(e.public_message());
557 return Ok(report);
558 }
559 Ok(Ok(Err(e))) => return Err(e),
560 Ok(Ok(Ok(s))) => s,
561 }
562 }
563 };
564 report.candidates = find(
565 &st.db,
566 actor,
567 rev.item_id,
568 &file.sha256,
569 &signature,
570 options,
571 )
572 .await?;
573 report.signature = Some(SignatureSummary::from(&signature));
574 report.status = Status::Ready;
575 Ok(report)
576}
577
578pub async fn missing(pool: &PgPool, after: &str, limit: i64) -> AppResult<Vec<(String, String)>> {
582 let formats: Vec<&str> = PreviewFormat::ALL
583 .iter()
584 .filter(|f| !f.is_point_cloud())
585 .map(|f| f.as_str())
586 .collect();
587 Ok(sqlx::query_as::<_, (String, String)>(
588 "SELECT DISTINCT ON (f.sha256) f.sha256, f.format FROM files f
589 WHERE f.format = ANY($1) AND f.sha256 > $4
590 AND NOT EXISTS (SELECT 1 FROM shape_signatures s
591 WHERE s.sha256 = f.sha256 AND s.version = $2)
592 ORDER BY f.sha256
593 LIMIT $3",
594 )
595 .bind(&formats)
596 .bind(shape::VERSION)
597 .bind(limit)
598 .bind(after)
599 .fetch_all(pool)
600 .await?)
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 fn signature(extents: [f64; 3]) -> Signature {
608 Signature {
609 version: shape::VERSION,
610 extents,
611 area: 1.0,
612 volume: 0.0,
613 closed: false,
614 triangles: 12,
615 d2: vec![1.0 / shape::D2_BINS as f32; shape::D2_BINS],
616 }
617 }
618
619 fn passes(window: [f64; 4], c: &Signature, ignore_scale: bool) -> bool {
621 let inside = |v: f64, lo: f64, hi: f64| lo <= v && v <= hi;
622 if ignore_scale {
623 let [r2, r3] = c.proportions().map(|r| r as f32 as f64);
624 inside(r2, window[0], window[1]) && inside(r3, window[2], window[3])
625 } else {
626 let e = c.extents;
627 inside(e[0], window[0], window[1])
628 && inside(e[1].max(e[0] * 1e-3), window[2], window[3])
629 }
630 }
631
632 #[test]
636 fn prefilter_keeps_every_possible_hit() {
637 let lengths = [0.4, 1.0, 2.6, 2.9, 7.0, 25.0];
638 let shares = [
639 0.0, 0.0004, 0.001, 0.0015, 0.003, 0.1, 0.3, 0.45, 0.5, 0.7, 0.95, 1.0,
640 ];
641 let mut all = Vec::new();
642 for &l in &lengths {
643 for &f in &shares {
644 for &g in &shares {
645 all.push(signature([l, l * f, l * f * g]));
646 }
647 }
648 }
649 let mut checked = 0;
650 for source in all.iter().step_by(7) {
651 for ignore_scale in [false, true] {
652 let (window, _) = prefilter(source, ignore_scale);
653 for c in &all {
654 let sim = shape::compare(source, c, ignore_scale);
655 if sim.size >= MIN_SCORE + 1e-9 {
656 checked += 1;
657 assert!(
658 passes(window, c, ignore_scale),
659 "ignore_scale={ignore_scale}: {:?} vs {:?} (size {}) outside {window:?}",
660 source.extents,
661 c.extents,
662 sim.size
663 );
664 }
665 }
666 }
667 }
668 assert!(checked > 1000, "{checked}");
669 let plate = signature([120.0, 40.0, 8.0]);
671 let (window, _) = prefilter(&plate, false);
672 assert!(!passes(window, &signature([400.0, 40.0, 8.0]), false));
673 let (window, _) = prefilter(&plate, true);
674 assert!(passes(window, &signature([240.0, 80.0, 16.0]), true));
675 assert!(!passes(window, &signature([50.0, 50.0, 50.0]), true));
676 }
677}