Skip to main content

teamcraft/services/
similar.rs

1//! Similar part search: "do we already have a part like this?".
2//!
3//! Every 3D file (meshes and STEP, the formats of the 3D preview) gets a
4//! shape signature (see `shape`) when its preview is generated – after an
5//! upload, when a viewer opens it, or by `teamcraft vault signatures` for
6//! older files. Signatures are stored once per blob (`shape_signatures`).
7//!
8//! [`find`] ranks the latest revisions of other items by
9//! [`shape::compare`]: a SQL prefilter on indexed columns (the largest
10//! extents within a factor of [`SIZE_WINDOW`], or the proportions within a
11//! factor of [`RATIO_WINDOW`] when scale is ignored – wide enough never to
12//! drop a part that could reach [`MIN_SCORE`]) keeps at most [`PREFILTER`]
13//! files, which are then scored in Rust – the work stays bounded however
14//! large the vault is. One hit per item (its best file), scores below
15//! [`MIN_SCORE`] are dropped.
16//!
17//! Visibility is the one of the item search: candidates are all items the
18//! actor may read (no items of restricted groups it is not a member of –
19//! their signatures are neither matched nor counted) except the source.
20
21use 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
38/// Candidates below this score (0…1) are not shown.
39pub const MIN_SCORE: f64 = 0.5;
40/// Most hits in one result.
41pub const MAX_LIMIT: i64 = 50;
42/// Files scored in Rust after the SQL prefilter.
43pub const PREFILTER: i64 = 2000;
44/// Largest and middle extent within this factor (with scale). A hit needs
45/// `size ≥ MIN_SCORE`, i.e. Σ|ln ratio| over three extents ≤ 1.5·ln 2, so
46/// no single extent of a hit is off by more than 2^1.5 ≈ 2.83.
47pub const SIZE_WINDOW: f64 = 3.0;
48/// Both proportions within this factor (ignoring scale): the two |ln ratio|
49/// of a hit sum to at most ln 2.
50pub const RATIO_WINDOW: f64 = 2.0;
51/// Lengths below this share of the largest count as this share (as in
52/// `shape::compare`).
53const THIN: f64 = 1e-3;
54
55/// How to search.
56#[derive(Clone, Copy, Debug)]
57pub struct Options {
58    pub limit: i64,
59    /// Compare proportions and shape only (a scaled copy scores 100 %).
60    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/// One similar part.
73#[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    /// The 3D file that matched.
84    pub file_id: Uuid,
85    pub filename: String,
86    /// Same content as the source file (an exact copy).
87    pub identical: bool,
88    /// Similarity in percent (0…100), the ranking key.
89    pub score: u8,
90    /// Shape distribution similarity in percent.
91    pub shape: u8,
92    /// Size agreement in percent (100 = same extents, or same proportions
93    /// when scale is ignored).
94    pub size: u8,
95    pub extents: [f64; 3],
96    pub explanation: String,
97    /// A file of the revision with a stored preview image (native CAD).
98    pub thumbnail_file_id: Option<Uuid>,
99}
100
101/// The source of a search and what is known about its shape.
102#[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    /// Searched; `candidates` holds the result (possibly empty).
114    Ready,
115    /// The shape signature is still being computed.
116    Pending,
117    /// The revision has no mesh or STEP file.
118    NoShapeFile,
119    /// No signature can be computed (the reason is in `message`).
120    Failed,
121}
122
123/// The answer for a revision (web page, REST API, MCP).
124#[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/// The signature in the words of a result.
135#[derive(Clone, Debug, Serialize)]
136pub struct SignatureSummary {
137    pub extents: [f64; 3],
138    pub area: f64,
139    /// `None` when the mesh is not closed (the volume would be unreliable).
140    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
157/// The preview format of a file usable for shapes (not point clouds).
158pub 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
164/// The file a revision is compared by: `filename` if given, else its STEP
165/// model, else its first mesh.
166pub 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
222/// The stored signature of a blob (of the current [`shape::VERSION`]).
223pub 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
234/// Stores (or replaces) the signature of a blob. Derived data – no audit
235/// record, like the text index.
236pub 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
268/// The signature of a blob: stored, or computed in the preview process
269/// (converting the file first if needed) and stored.
270pub 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
279/// [`ensure`] in the background (after uploads); the handle may be awaited
280/// with a time limit – dropping it does not stop the work.
281pub 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
321/// Lengths below this share of the largest count as this share (as in
322/// `shape::compare`), so flat parts have a usable window.
323fn 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
328/// The SQL prefilter of a search: `[lo₁, hi₁, lo₂, hi₂]` of the window and
329/// the three targets of its order (see [`find`]). With scale: the largest
330/// and the (floored) middle extent; ignoring scale: the two (floored)
331/// proportions, a lower bound below [`THIN`] reaching down to 0.
332fn 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
345/// Ranks the latest revisions of all other items by their similarity to
346/// `source` (see the module documentation).
347pub 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    // the item search's visibility: signed-in users, no hidden groups
356    actor.require(Role::Viewer)?;
357    let limit = options.limit.clamp(1, MAX_LIMIT) as usize;
358    // $1 version, $2 source item, $3…$6 window, $7…$9 targets, $10 limit,
359    // $11 the actor's hidden groups; the order is the size distance of
360    // `shape::compare` (floored lengths)
361    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    // best file per item
406    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    // preview images of native CAD files of the hits
441    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
472/// Per revision the first file with an embedded preview image.
473async 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
493/// Similar parts of a revision: picks its 3D file (or `filename`), gets
494/// the signature – waiting at most `wait` for a computation, which goes on
495/// in the background – and searches.
496pub 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
508/// [`for_revision`] with the revision's files already at hand.
509pub 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
578/// Stored files (current versions) with a 3D format and no signature yet,
579/// for `teamcraft vault signatures`: distinct blobs after `after` (keyset
580/// paging by hash) with their format.
581pub 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    /// The SQL window of [`find`], evaluated in Rust on stored values.
620    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    /// The prefilter never drops a part that `shape::compare` would show:
633    /// every candidate whose size agreement reaches MIN_SCORE (the score
634    /// can be no higher) lies inside the window – flat parts included.
635    #[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        // and the window does filter: much larger, or other proportions
670        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}