indicatrix-cut 0.7.2

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Tests for the angle-side classification, row-count filter and plane-fallback
//! logic shared across the detail module's local/remote load paths.

use super::{
    planes::planes_gear_and_reference_angle,
    shared::{count_angles_for_mode, parse_catalogue_angle_deg, sides_from_rows, standard_codes},
};
use crate::AngleItem;
use indicatrix::geometry::plane::GpuFacetPlane;
use slint::{ModelRc, VecModel};

// --- parse_catalogue_angle_deg / sides_from_rows ---

/// The real catalogue's own shape: 50,809 of 50,817 stored `angle_settings.angle`
/// values end in `\u{b0}`, which a bare `str::parse` chokes on.
#[test]
fn parse_catalogue_angle_deg_strips_the_degree_sign() {
    assert_eq!(parse_catalogue_angle_deg("42.70\u{b0}"), Some(42.70));
}

#[test]
fn parse_catalogue_angle_deg_also_accepts_plain_text_with_no_degree_sign() {
    assert_eq!(parse_catalogue_angle_deg("42.70"), Some(42.70));
}

/// 6 of the 50,817 real rows (detail 3282's `P`/`G`/`C` rows) store a mangled
/// degree sign as `\u{fffd}` (the Unicode replacement character) instead of
/// `\u{b0}` -- this must still parse rather than silently reporting `0`/neither
/// side for those rows.
#[test]
fn parse_catalogue_angle_deg_strips_a_mangled_degree_sign_too() {
    assert_eq!(parse_catalogue_angle_deg("43.00\u{fffd}"), Some(43.00));
}

#[test]
fn parse_catalogue_angle_deg_is_none_for_unparsable_text() {
    assert_eq!(parse_catalogue_angle_deg("not a number"), None);
}

/// A tiny helper so each test can write `row("A", "50.00\u{b0}", "...")` instead of
/// a 3-tuple literal.
fn row<'a>(facet: &'a str, angle: &'a str, index_val: &'a str) -> (&'a str, &'a str, &'a str) {
    (facet, angle, index_val)
}

/// Real catalogue detail 16 (`G(90) 1(47) 2(43) A(50) B(35) C(15) T(0,'Table')`):
/// digit facets are pavilion, letter facets are crown, `G` is the girdle, and the
/// `T`/`'Table'` row is crown even though it's listed last.
#[test]
fn classifies_real_detail_16() {
    let rows = [
        row("G", "90.00\u{b0}", "04-12-20"),
        row("1", "47.00\u{b0}", "04-12-20"),
        row("2", "43.00\u{b0}", "08-24-40"),
        row("A", "50.00\u{b0}", "04-12-20"),
        row("B", "35.00\u{b0}", "96-16-32"),
        row("C", "15.00\u{b0}", "08-24-40"),
        row("T", "0.00\u{b0}", "Table"),
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![0, -1, -1, 1, 1, 1, 1]);
}

/// Real catalogue detail 47 (`''(90) 1(43) 2(41) A(36) B(34.16) T(0)`): an EMPTY
/// facet label at the girdle angle is still caught by the angle-threshold rule
/// (`label_side`'s rule (a) doesn't need a facet label at all).
#[test]
fn classifies_real_detail_47_with_an_empty_girdle_label() {
    let rows = [
        row("", "90.00\u{b0}", "96-08-16"),
        row("1", "43.00\u{b0}", "96-08-16"),
        row("2", "41.00\u{b0}", "02-06-10"),
        row("A", "36.00\u{b0}", "96-08-16"),
        row("B", "34.16\u{b0}", "02-06-10"),
        row("T", "0.00\u{b0}", "Table"),
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![0, -1, -1, 1, 1, 1]);
}

/// Real catalogue detail 1001 (`a..f`(crown letters) `g`(Table) `1..5`(pavilion
/// digits) `G1 G2`(girdle)): lowercase letters are crown same as uppercase, and
/// TWO girdle-labelled rows (`G1`/`G2`) both report `0`.
#[test]
fn classifies_real_detail_1001() {
    let rows = [
        row("a", "44.00\u{b0}", "02-22-26"),
        row("b", "44.00\u{b0}", "08-16-32"),
        row("c", "34.00\u{b0}", "02-22-26"),
        row("d", "34.00\u{b0}", "08-16-32"),
        row("e", "24.00\u{b0}", "02-22-26"),
        row("f", "24.00\u{b0}", "08-16-32"),
        row("g", "0.00\u{b0}", "Table"),
        row("1", "41.00\u{b0}", "96-24-48"),
        row("2", "42.74\u{b0}", "03-21-27"),
        row("3", "43.33\u{b0}", "06-18-30"),
        row("4", "43.00\u{b0}", "08-16-32"),
        row("5", "60.00\u{b0}", "02-22-26"),
        row("G1", "90.00\u{b0}", "02-22-26"),
        row("G2", "90.00\u{b0}", "08-16-32"),
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1, 0, 0]);
}

/// Real catalogue detail 2001 (`a..e`(crown) `f`(Table) `1 2`(pavilion) `G1`
/// (girdle)) -- a shorter facet count than 1001, same conventions.
#[test]
fn classifies_real_detail_2001() {
    let rows = [
        row("a", "37.00\u{b0}", "64-09-18"),
        row("b", "45.00\u{b0}", "02-07-11"),
        row("c", "27.00\u{b0}", "02-07-11"),
        row("d", "24.48\u{b0}", "29-35"),
        row("e", "27.00\u{b0}", "39-44-48"),
        row("f", "0.00\u{b0}", "Table"),
        row("1", "43.00\u{b0}", "64-09-18"),
        row("2", "44.00\u{b0}", "02-07-11"),
        row("G1", "90.00\u{b0}", "02-07-11"),
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![1, 1, 1, 1, 1, 1, -1, -1, 0]);
}

/// The `P<n>`/`C<n>` facet convention (1,059 + 790 rows in the real catalogue):
/// `P`/`PF` prefixed labels are pavilion, `C` prefixed labels are crown, regardless
/// of angle or row order -- including the odd real labels `P2(G)`, `C1A`, `pf3`.
#[test]
fn classifies_the_p_n_and_c_n_facet_convention() {
    let rows = [
        row("P1", "43.00\u{b0}", ""),
        row("P2(G)", "44.00\u{b0}", ""),
        row("pf3", "45.00\u{b0}", ""),
        row("PF1", "46.00\u{b0}", ""),
        row("C1", "15.00\u{b0}", ""),
        row("C1A", "16.00\u{b0}", ""),
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![-1, -1, -1, -1, 1, 1]);
}

/// `1G` is the catalogue's own way of tagging a facet as girdle-adjacent by label
/// rather than by reaching the angle threshold -- it must report girdle (`0`), not
/// fall into the more general "digits with an optional trailing letter" pavilion
/// rule that would otherwise also match its shape.
#[test]
fn classifies_digit_plus_trailing_g_as_girdle_not_pavilion() {
    let rows = [row("1G", "80.00\u{b0}", "")];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![0]);
}

/// A design whose facet labels are all EMPTY (no digit/letter/prefix convention to
/// read at all), with the girdle row FIRST and the table row LAST -- exactly the
/// shape 1,403 of the 3,021 real designs use for the girdle position, which is what
/// defeats a purely positional before/after-girdle rule. This is the case that
/// exercises [`positional_fallback`]'s table-anchored branch (a digits-only design
/// wouldn't reach it: `label_side`'s digit rule already classifies every digit
/// facet as pavilion regardless of position, see
/// `classifies_the_p_n_and_c_n_facet_convention` above).
///
/// DECISION: the rows between the girdle and the table are reported crown (`1`),
/// not pavilion -- since the table sits on the far side of the girdle from row 0,
/// [`positional_fallback`] treats that whole post-girdle block as the table's
/// (crown) side. This is the fix for the bug: the OLD before/after-girdle rule
/// would have reported every one of these rows pavilion.
#[test]
fn positional_fallback_treats_the_tables_side_of_the_girdle_as_crown() {
    let rows = [
        row("", "90.00\u{b0}", ""), // girdle by angle threshold
        row("", "47.00\u{b0}", ""), // unlabelled, between girdle and table
        row("", "43.00\u{b0}", ""), // unlabelled, between girdle and table
        row("", "50.00\u{b0}", ""), // unlabelled, between girdle and table
        row("", "0.00\u{b0}", "Table"),
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![0, 1, 1, 1, 1]);
}

/// With no table row to anchor on, an empty-labelled design falls back to the OLD
/// before/after-girdle rule -- but only when the girdle sits in the MIDDLE of the
/// row list (there's at least one row on each side to trust).
#[test]
fn positional_fallback_without_a_table_row_uses_before_after_girdle_when_girdle_is_in_the_middle() {
    let rows = [
        row("", "40.00\u{b0}", ""),
        row("", "90.00\u{b0}", ""), // girdle, in the middle
        row("", "41.00\u{b0}", ""),
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![1, 0, -1]);
}

/// Same as above but the girdle is FIRST -- there is no "before" side at all, so
/// the old before/after rule cannot be trusted; every unlabelled row reports `0`
/// (unclassified) rather than guessing.
#[test]
fn positional_fallback_without_a_table_row_reports_zero_when_girdle_is_first() {
    let rows = [
        row("", "90.00\u{b0}", ""), // girdle, first
        row("", "41.00\u{b0}", ""),
        row("", "42.00\u{b0}", ""),
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![0, 0, 0]);
}

/// Same again but the girdle is LAST -- no "after" side to trust either.
#[test]
fn positional_fallback_without_a_table_row_reports_zero_when_girdle_is_last() {
    let rows = [
        row("", "41.00\u{b0}", ""),
        row("", "42.00\u{b0}", ""),
        row("", "90.00\u{b0}", ""), // girdle, last
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![0, 0, 0]);
}

/// More than one row can sit at exactly the girdle angle (e.g. two girdle facets
/// listed back to back, as real detail 1001's `G1`/`G2` do) -- every one of them
/// reports `0`, not just the first. Uses labelled crown/pavilion rows either side
/// (rather than empty labels) so this test only exercises the girdle-angle check
/// itself, not the positional fallback.
#[test]
fn sides_from_rows_reports_zero_for_every_girdle_row_not_only_the_first() {
    let rows = [
        row("A", "40.00\u{b0}", ""),
        row("", "90.00\u{b0}", ""),
        row("", "90.00\u{b0}", ""),
        row("1", "41.00\u{b0}", ""),
    ];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![1, 0, 0, -1]);
}

/// A row at 89.6\u{b0} is a real, if steep, facet -- NOT the girdle -- since the
/// real catalogue's own girdle angles cluster tightly at exactly `90.00\u{b0}` with
/// nothing between `88.73\u{b0}` and `90.0\u{b0}`. A "steep enough" threshold would
/// have wrongly swallowed it; the equality-with-tolerance rule must not.
#[test]
fn a_steep_but_not_exactly_90_degree_facet_is_not_the_girdle() {
    let rows = [row("A", "89.60\u{b0}", "")];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![1]);
}

#[test]
fn sides_from_rows_reports_zero_for_unparsable_angle_text_with_no_usable_label() {
    let rows = [row("", "not a number", ""), row("", "41.00\u{b0}", "")];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides[0], 0);
}

/// With NEITHER a girdle row nor a table row at all, there is nothing to anchor
/// the positional fallback on -- every unlabelled row reports `0` rather than
/// defaulting to crown (a change from the old order-only rule, which had no way to
/// tell "no girdle marker" apart from "every row before the girdle").
#[test]
fn sides_from_rows_reports_zero_with_no_girdle_marker_and_no_usable_label() {
    let rows = [row("", "34.50\u{b0}", ""), row("", "42.70\u{b0}", "")];
    let sides = sides_from_rows(rows.into_iter());
    assert_eq!(sides, vec![0, 0]);
}

/// With no `real_design` resolved (the remote route, or a
/// local resolution failure), the catalogue view must still fall back to the
/// existing placeholder `reconstruct_planes` path -- gear read from the text
/// column, reference angle `0.0` (library records carry none).
#[test]
fn falls_back_to_the_placeholder_reconstruction_with_no_real_design() {
    let angle_items = [AngleItem {
        order_idx: 0,
        side: -1,
        facet: "P1".into(),
        code: "P1".into(),
        angle: "-41.0".into(),
        index_val: "0, 24, 48, 72".into(),
        notes: "".into(),
        second_line: "".into(),
    }];
    let (planes, gear_teeth, reference_angle) =
        planes_gear_and_reference_angle(None, Some("Round"), Some("96"), &angle_items);
    assert_ne!(planes.len(), 0);
    assert_eq!(gear_teeth, 96);
    assert_eq!(reference_angle, 0.0);
}

/// With a resolved `real_design` tuple, the
/// catalogue view must use exactly that (already-converted) planes/gear/reference-
/// angle, not the placeholder guess or a hardcoded `0.0` -- regardless of what the
/// (deliberately wrong, "999") shape/gear text arguments would otherwise have
/// produced.
#[test]
fn prefers_the_real_designs_own_planes_and_reference_angle_when_resolved() {
    let resolved_planes = vec![GpuFacetPlane::new(glam::Vec3::Y, -1.0)];
    let (planes, gear_teeth, reference_angle) = planes_gear_and_reference_angle(
        Some((resolved_planes.clone(), 12, 2.5)),
        Some("Round"),
        Some("999"),
        &[],
    );

    assert_eq!(planes.len(), resolved_planes.len());
    assert_eq!(gear_teeth, 12);
    assert_eq!(reference_angle, 2.5);
}

// --- standard_codes ---

/// The codes the Cutting Instructions tab shows for `rows`, classified the way the load
/// paths classify them (sides from the labels, then the codes from the sides).
fn codes_of(rows: &[(&str, &str, &str)]) -> Vec<String> {
    let sides = sides_from_rows(rows.iter().copied());
    let labels: Vec<(&str, &str)> = rows
        .iter()
        .map(|&(facet, _, index)| (facet, index))
        .collect();
    standard_codes(&labels, &sides)
}

/// The old 123/ABC labels read as the standard codes: digits on the pavilion side, a girdle
/// row `G`, letters on the crown side, `T` for the table, each numbered in the listed order.
#[test]
fn old_style_labels_become_standard_codes_numbered_per_block() {
    let rows = [
        row("1", "50.00\u{b0}", "12"),
        row("2", "90.00\u{b0}", "8"),
        row("3", "45.00\u{b0}", "16"),
        row("A", "35.00\u{b0}", "12"),
        row("B", "30.00\u{b0}", "8"),
        row("T", "0.00\u{b0}", "Table"),
    ];
    assert_eq!(codes_of(&rows), ["P1", "G1", "P2", "C1", "C2", "T"]);
}

/// Real catalogue detail 16 (`G 1 2 A B C T`): the girdle is listed first and the table last.
#[test]
fn real_detail_16_reads_as_standard_codes() {
    let rows = [
        row("G", "90.00\u{b0}", "04-12-20"),
        row("1", "47.00\u{b0}", "04-12-20"),
        row("2", "43.00\u{b0}", "08-24-40"),
        row("A", "50.00\u{b0}", "04-12-20"),
        row("B", "35.00\u{b0}", "96-16-32"),
        row("C", "15.00\u{b0}", "08-24-40"),
        row("T", "0.00\u{b0}", "Table"),
    ];
    assert_eq!(
        codes_of(&rows),
        ["G1", "P1", "P2", "C1", "C2", "C3", "T"],
        "the original labels stay on the rows themselves; only the shown code changes"
    );
}

/// A label that already follows the standard (`P1`, `C1`, `G1`, `PF1`, `Table`) is kept
/// exactly as the catalogue wrote it.
#[test]
fn standard_labels_stay_as_they_are() {
    let rows = [
        row("P1", "40.00\u{b0}", ""),
        row("P2", "41.00\u{b0}", ""),
        row("G1", "90.00\u{b0}", ""),
        row("C1", "34.00\u{b0}", ""),
        row("Table", "0.00\u{b0}", "Table"),
    ];
    assert_eq!(codes_of(&rows), ["P1", "P2", "G1", "C1", "Table"]);
}

/// A standard row takes its place in the numbering: the old-style row after `P1` is `P2`,
/// as `compute_tier_labels` would number the tiers. A preform (`PF`) facet has its own
/// numbering and does not take a `P` number.
#[test]
fn an_old_style_row_is_numbered_after_the_standard_rows_of_its_block() {
    let mixed = [row("P1", "40.00\u{b0}", ""), row("2", "42.00\u{b0}", "")];
    assert_eq!(codes_of(&mixed), ["P1", "P2"]);
    let preform = [row("PF1", "60.00\u{b0}", ""), row("1", "41.00\u{b0}", "")];
    assert_eq!(codes_of(&preform), ["PF1", "P1"]);
}

/// A girdle-adjacent `1G` is a girdle row, a lower-case `t` is the table, and a digit row
/// at exactly 90 degrees is the girdle too.
#[test]
fn girdle_adjacent_labels_and_a_lower_case_table_read_correctly() {
    let rows = [
        row("1G", "47.00\u{b0}", ""),
        row("5", "90.00\u{b0}", ""),
        row("t", "0.00\u{b0}", ""),
    ];
    assert_eq!(codes_of(&rows), ["G1", "G2", "T"]);
}

/// A row with no label gets the code of its block when the side is known, and nothing when
/// it is not (a row no block can be told for is never guessed at).
#[test]
fn an_unlabelled_row_gets_a_code_only_when_its_side_is_known() {
    let rows: [(&str, &str); 3] = [("", ""), ("", ""), ("", "")];
    assert_eq!(standard_codes(&rows, &[-1, 1, 0]), ["P1", "C1", ""]);
}

// --- count_angles_for_mode ---

fn angle_item(side: i32) -> AngleItem {
    AngleItem {
        order_idx: 0,
        side,
        facet: "P1".into(),
        code: "P1".into(),
        angle: "0.0".into(),
        index_val: "".into(),
        notes: "".into(),
        second_line: "".into(),
    }
}

/// Mode 0 (All) counts every row regardless of side, including girdle/unparsed
/// rows (`side == 0`).
#[test]
fn mode_all_counts_every_row() {
    let angles = ModelRc::new(VecModel::from(vec![
        angle_item(-1),
        angle_item(1),
        angle_item(0),
    ]));
    assert_eq!(count_angles_for_mode(&angles, 0), 3);
}

/// Mode 1 (Pavilion) counts only `side < 0` rows -- exactly
/// `cutting_table.slint`'s own `row_shown` predicate.
#[test]
fn mode_pavilion_counts_only_negative_side_rows() {
    let angles = ModelRc::new(VecModel::from(vec![
        angle_item(-1),
        angle_item(-1),
        angle_item(1),
        angle_item(0),
    ]));
    assert_eq!(count_angles_for_mode(&angles, 1), 2);
}

/// Mode 2 (Crown) counts only `side > 0` rows.
#[test]
fn mode_crown_counts_only_positive_side_rows() {
    let angles = ModelRc::new(VecModel::from(vec![
        angle_item(-1),
        angle_item(1),
        angle_item(1),
        angle_item(0),
    ]));
    assert_eq!(count_angles_for_mode(&angles, 2), 2);
}

/// An empty angle list counts to zero under every mode, rather than panicking.
#[test]
fn empty_angle_list_counts_to_zero_under_every_mode() {
    let angles: ModelRc<AngleItem> = ModelRc::new(VecModel::from(Vec::<AngleItem>::new()));
    for mode in [0, 1, 2] {
        assert_eq!(count_angles_for_mode(&angles, mode), 0);
    }
}

/// End-to-end: [`sides_from_rows`]'s output, loaded into real `AngleItem`s the way
/// `local_load.rs`/`remote_load.rs` do, must agree with [`count_angles_for_mode`] --
/// which is in turn exactly `cutting_table.slint`'s own `row_shown` predicate
/// (`side < 0` pavilion, `side > 0` crown). Uses real detail 16's own schedule
/// (`G 1 2 A B C T`): 1 girdle row, 2 pavilion digit facets, 4 crown rows (3
/// lettered facets plus the table row).
#[test]
fn filtered_row_count_agrees_with_sides_from_rows_on_a_real_schedule() {
    let rows = [
        row("G", "90.00\u{b0}", ""),
        row("1", "47.00\u{b0}", ""),
        row("2", "43.00\u{b0}", ""),
        row("A", "50.00\u{b0}", ""),
        row("B", "35.00\u{b0}", ""),
        row("C", "15.00\u{b0}", ""),
        row("T", "0.00\u{b0}", "Table"),
    ];
    let sides = sides_from_rows(rows.into_iter());
    let angles = ModelRc::new(VecModel::from(
        sides.into_iter().map(angle_item).collect::<Vec<_>>(),
    ));
    assert_eq!(count_angles_for_mode(&angles, 0), 7); // All
    assert_eq!(count_angles_for_mode(&angles, 1), 2); // Pavilion: 1, 2
    assert_eq!(count_angles_for_mode(&angles, 2), 4); // Crown: A, B, C, T
}