Merge branch 'main' into fix/plugin-network-permissions

This commit is contained in:
Ian Chua committed 2026-10-20 18:23:02 +08:00
commit 4a0d5e5edd
133 files changed
+25203 -10673

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
`two_color_jpeg.glb` is a generated, closed tetrahedron with four faces, UVs, and
an embedded 8×8 red/blue JPEG base-color texture. Its coordinates span 20 mm.
The core tests load all three fixtures through `Model::read_from_file`, convert
textures or MTL colors to painting with boundary smoothing, and match red and blue
to filaments. They run in CI without a display. A separate layout check compares
the smoothing search-tree type seen by a core consumer with the same type compiled
inside `libslic3r_cgal`. The mismatch is checked directly because undefined behavior
can be masked by which inline template implementations the linker selects.
`two_color_sphere/test_two_color_sphere.obj` and its matching MTL are the original
red/blue material-color sphere supplied by RF47 in
[the PR discussion](https://github.com/OrcaSlicer/OrcaSlicer/pull/16607#issuecomment-6244475095).
They contain solid `Kd` colors and no texture images.
`test_two_color_sphere_textured.obj` is a derived version of that sphere. Its MTL
uses white `Kd` values and references `textures/two_color.jpg` by a relative path;
the two face groups sample different halves of that image through their UVs.
The JPEG is extracted unchanged from `two_color_jpeg.glb`. This case must retain
both colors from the external texture, rather than fall back to MTL colors.
Binary file not shown.
@@ -0,0 +1,11 @@
# test model for OrcaSlicer tex2color
newmtl Red
Ka 0.0000 0.0000 0.0000
Kd 0.7500 0.2500 0.2500
Ks 0.0000 0.0000 0.0000
d 1.0000
newmtl Blue
Ka 0.0000 0.0000 0.0000
Kd 0.0700 0.2100 0.7000
Ks 0.0000 0.0000 0.0000
d 1.0000
@@ -0,0 +1,457 @@
# test model for OrcaSlicer tex2color
mtllib test_two_color_sphere.mtl
v 0.000000 0.000000 20.000000
v 7.653669 0.000000 18.477591
v 7.071068 2.928932 18.477591
v 5.411961 5.411961 18.477591
v 2.928932 7.071068 18.477591
v 0.000000 7.653669 18.477591
v -2.928932 7.071068 18.477591
v -5.411961 5.411961 18.477591
v -7.071068 2.928932 18.477591
v -7.653669 0.000000 18.477591
v -7.071068 -2.928932 18.477591
v -5.411961 -5.411961 18.477591
v -2.928932 -7.071068 18.477591
v -0.000000 -7.653669 18.477591
v 2.928932 -7.071068 18.477591
v 5.411961 -5.411961 18.477591
v 7.071068 -2.928932 18.477591
v 14.142136 0.000000 14.142136
v 13.065630 5.411961 14.142136
v 10.000000 10.000000 14.142136
v 5.411961 13.065630 14.142136
v 0.000000 14.142136 14.142136
v -5.411961 13.065630 14.142136
v -10.000000 10.000000 14.142136
v -13.065630 5.411961 14.142136
v -14.142136 0.000000 14.142136
v -13.065630 -5.411961 14.142136
v -10.000000 -10.000000 14.142136
v -5.411961 -13.065630 14.142136
v -0.000000 -14.142136 14.142136
v 5.411961 -13.065630 14.142136
v 10.000000 -10.000000 14.142136
v 13.065630 -5.411961 14.142136
v 18.477591 0.000000 7.653669
v 17.071068 7.071068 7.653669
v 13.065630 13.065630 7.653669
v 7.071068 17.071068 7.653669
v 0.000000 18.477591 7.653669
v -7.071068 17.071068 7.653669
v -13.065630 13.065630 7.653669
v -17.071068 7.071068 7.653669
v -18.477591 0.000000 7.653669
v -17.071068 -7.071068 7.653669
v -13.065630 -13.065630 7.653669
v -7.071068 -17.071068 7.653669
v -0.000000 -18.477591 7.653669
v 7.071068 -17.071068 7.653669
v 13.065630 -13.065630 7.653669
v 17.071068 -7.071068 7.653669
v 20.000000 0.000000 0.000000
v 18.477591 7.653669 0.000000
v 14.142136 14.142136 0.000000
v 7.653669 18.477591 0.000000
v 0.000000 20.000000 0.000000
v -7.653669 18.477591 0.000000
v -14.142136 14.142136 0.000000
v -18.477591 7.653669 0.000000
v -20.000000 0.000000 0.000000
v -18.477591 -7.653669 0.000000
v -14.142136 -14.142136 0.000000
v -7.653669 -18.477591 0.000000
v -0.000000 -20.000000 0.000000
v 7.653669 -18.477591 0.000000
v 14.142136 -14.142136 0.000000
v 18.477591 -7.653669 0.000000
v 18.477591 0.000000 -7.653669
v 17.071068 7.071068 -7.653669
v 13.065630 13.065630 -7.653669
v 7.071068 17.071068 -7.653669
v 0.000000 18.477591 -7.653669
v -7.071068 17.071068 -7.653669
v -13.065630 13.065630 -7.653669
v -17.071068 7.071068 -7.653669
v -18.477591 0.000000 -7.653669
v -17.071068 -7.071068 -7.653669
v -13.065630 -13.065630 -7.653669
v -7.071068 -17.071068 -7.653669
v -0.000000 -18.477591 -7.653669
v 7.071068 -17.071068 -7.653669
v 13.065630 -13.065630 -7.653669
v 17.071068 -7.071068 -7.653669
v 14.142136 0.000000 -14.142136
v 13.065630 5.411961 -14.142136
v 10.000000 10.000000 -14.142136
v 5.411961 13.065630 -14.142136
v 0.000000 14.142136 -14.142136
v -5.411961 13.065630 -14.142136
v -10.000000 10.000000 -14.142136
v -13.065630 5.411961 -14.142136
v -14.142136 0.000000 -14.142136
v -13.065630 -5.411961 -14.142136
v -10.000000 -10.000000 -14.142136
v -5.411961 -13.065630 -14.142136
v -0.000000 -14.142136 -14.142136
v 5.411961 -13.065630 -14.142136
v 10.000000 -10.000000 -14.142136
v 13.065630 -5.411961 -14.142136
v 7.653669 0.000000 -18.477591
v 7.071068 2.928932 -18.477591
v 5.411961 5.411961 -18.477591
v 2.928932 7.071068 -18.477591
v 0.000000 7.653669 -18.477591
v -2.928932 7.071068 -18.477591
v -5.411961 5.411961 -18.477591
v -7.071068 2.928932 -18.477591
v -7.653669 0.000000 -18.477591
v -7.071068 -2.928932 -18.477591
v -5.411961 -5.411961 -18.477591
v -2.928932 -7.071068 -18.477591
v -0.000000 -7.653669 -18.477591
v 2.928932 -7.071068 -18.477591
v 5.411961 -5.411961 -18.477591
v 7.071068 -2.928932 -18.477591
v 0.000000 0.000000 -20.000000
vt 0 0
vn 0.000000 -0.000000 1.000000
vn 0.439605 0.035510 0.897489
vn 0.392553 0.201037 0.897489
vn 0.285739 0.335958 0.897489
vn 0.135423 0.419732 0.897489
vn -0.035510 0.439605 0.897489
vn -0.201037 0.392553 0.897489
vn -0.335958 0.285739 0.897489
vn -0.419732 0.135423 0.897489
vn -0.439605 -0.035510 0.897489
vn -0.392553 -0.201037 0.897489
vn -0.285739 -0.335958 0.897489
vn -0.135423 -0.419732 0.897489
vn 0.035510 -0.439605 0.897489
vn 0.201037 -0.392553 0.897489
vn 0.335958 -0.285739 0.897489
vn 0.419732 -0.135423 0.897489
vn 0.725001 0.027230 0.688209
vn 0.659393 0.302603 0.688209
vn 0.493399 0.531907 0.688209
vn 0.252289 0.680234 0.688209
vn -0.027230 0.725001 0.688209
vn -0.302603 0.659393 0.688209
vn -0.531907 0.493399 0.688209
vn -0.680234 0.252289 0.688209
vn -0.725001 -0.027230 0.688209
vn -0.659393 -0.302603 0.688209
vn -0.493399 -0.531907 0.688209
vn -0.252289 -0.680234 0.688209
vn 0.027230 -0.725001 0.688209
vn 0.302603 -0.659393 0.688209
vn 0.531907 -0.493399 0.688209
vn 0.680234 -0.252289 0.688209
vn 0.927851 0.014745 0.372658
vn 0.851580 0.368696 0.372658
vn 0.645664 0.666516 0.372658
vn 0.341451 0.862865 0.372658
vn -0.014745 0.927851 0.372658
vn -0.368696 0.851580 0.372658
vn -0.666516 0.645664 0.372658
vn -0.862865 0.341451 0.372658
vn -0.927851 -0.014745 0.372658
vn -0.851580 -0.368696 0.372658
vn -0.645664 -0.666516 0.372658
vn -0.341451 -0.862865 0.372658
vn 0.014745 -0.927851 0.372658
vn 0.368696 -0.851580 0.372658
vn 0.666516 -0.645664 0.372658
vn 0.862865 -0.341451 0.372658
vn 1.000000 -0.000000 -0.000000
vn 0.923880 0.382683 0.000000
vn 0.707107 0.707107 0.000000
vn 0.382683 0.923880 0.000000
vn 0.000000 1.000000 0.000000
vn -0.382683 0.923880 0.000000
vn -0.707107 0.707107 0.000000
vn -0.923880 0.382683 0.000000
vn -1.000000 0.000000 0.000000
vn -0.923880 -0.382683 -0.000000
vn -0.707107 -0.707107 0.000000
vn -0.382683 -0.923880 -0.000000
vn -0.000000 -1.000000 -0.000000
vn 0.382683 -0.923880 0.000000
vn 0.707107 -0.707107 0.000000
vn 0.923880 -0.382683 -0.000000
vn 0.927851 -0.014745 -0.372658
vn 0.862865 0.341451 -0.372658
vn 0.666516 0.645664 -0.372658
vn 0.368696 0.851580 -0.372658
vn 0.014745 0.927851 -0.372658
vn -0.341451 0.862865 -0.372658
vn -0.645664 0.666516 -0.372658
vn -0.851580 0.368696 -0.372658
vn -0.927851 0.014745 -0.372658
vn -0.862865 -0.341451 -0.372658
vn -0.666516 -0.645664 -0.372658
vn -0.368696 -0.851580 -0.372658
vn -0.014745 -0.927851 -0.372658
vn 0.341451 -0.862865 -0.372658
vn 0.645664 -0.666516 -0.372658
vn 0.851580 -0.368696 -0.372658
vn 0.725001 -0.027230 -0.688209
vn 0.680234 0.252289 -0.688209
vn 0.531907 0.493399 -0.688209
vn 0.302603 0.659393 -0.688209
vn 0.027230 0.725001 -0.688209
vn -0.252289 0.680234 -0.688209
vn -0.493399 0.531907 -0.688209
vn -0.659393 0.302603 -0.688209
vn -0.725001 0.027230 -0.688209
vn -0.680234 -0.252289 -0.688209
vn -0.531907 -0.493399 -0.688209
vn -0.302603 -0.659393 -0.688209
vn -0.027230 -0.725001 -0.688209
vn 0.252289 -0.680234 -0.688209
vn 0.493399 -0.531907 -0.688209
vn 0.659393 -0.302603 -0.688209
vn 0.439605 -0.035510 -0.897489
vn 0.419732 0.135423 -0.897489
vn 0.335958 0.285739 -0.897489
vn 0.201037 0.392553 -0.897489
vn 0.035510 0.439605 -0.897489
vn -0.135423 0.419732 -0.897489
vn -0.285739 0.335958 -0.897489
vn -0.392553 0.201037 -0.897489
vn -0.439605 0.035510 -0.897489
vn -0.419732 -0.135423 -0.897489
vn -0.335958 -0.285739 -0.897489
vn -0.201037 -0.392553 -0.897489
vn -0.035510 -0.439605 -0.897489
vn 0.135423 -0.419732 -0.897489
vn 0.285739 -0.335958 -0.897489
vn 0.392553 -0.201037 -0.897489
vn 0.000000 -0.000000 -1.000000
usemtl Red
f 1/1/1 2/1/2 3/1/3
f 1/1/1 3/1/3 4/1/4
f 1/1/1 4/1/4 5/1/5
f 1/1/1 5/1/5 6/1/6
f 1/1/1 6/1/6 7/1/7
f 1/1/1 7/1/7 8/1/8
f 1/1/1 8/1/8 9/1/9
f 1/1/1 9/1/9 10/1/10
f 1/1/1 10/1/10 11/1/11
f 1/1/1 11/1/11 12/1/12
f 1/1/1 12/1/12 13/1/13
f 1/1/1 13/1/13 14/1/14
f 1/1/1 14/1/14 15/1/15
f 1/1/1 15/1/15 16/1/16
f 1/1/1 16/1/16 17/1/17
f 1/1/1 17/1/17 2/1/2
f 2/1/2 18/1/18 19/1/19
f 2/1/2 19/1/19 3/1/3
f 3/1/3 19/1/19 20/1/20
f 3/1/3 20/1/20 4/1/4
f 4/1/4 20/1/20 21/1/21
f 4/1/4 21/1/21 5/1/5
f 5/1/5 21/1/21 22/1/22
f 5/1/5 22/1/22 6/1/6
f 6/1/6 22/1/22 23/1/23
f 6/1/6 23/1/23 7/1/7
f 7/1/7 23/1/23 24/1/24
f 7/1/7 24/1/24 8/1/8
f 8/1/8 24/1/24 25/1/25
f 8/1/8 25/1/25 9/1/9
f 9/1/9 25/1/25 26/1/26
f 9/1/9 26/1/26 10/1/10
f 10/1/10 26/1/26 27/1/27
f 10/1/10 27/1/27 11/1/11
f 11/1/11 27/1/27 28/1/28
f 11/1/11 28/1/28 12/1/12
f 12/1/12 28/1/28 29/1/29
f 12/1/12 29/1/29 13/1/13
f 13/1/13 29/1/29 30/1/30
f 13/1/13 30/1/30 14/1/14
f 14/1/14 30/1/30 31/1/31
f 14/1/14 31/1/31 15/1/15
f 15/1/15 31/1/31 32/1/32
f 15/1/15 32/1/32 16/1/16
f 16/1/16 32/1/32 33/1/33
f 16/1/16 33/1/33 17/1/17
f 17/1/17 33/1/33 18/1/18
f 17/1/17 18/1/18 2/1/2
f 18/1/18 34/1/34 35/1/35
f 18/1/18 35/1/35 19/1/19
f 19/1/19 35/1/35 36/1/36
f 19/1/19 36/1/36 20/1/20
f 20/1/20 36/1/36 37/1/37
f 20/1/20 37/1/37 21/1/21
f 21/1/21 37/1/37 38/1/38
f 21/1/21 38/1/38 22/1/22
f 22/1/22 38/1/38 39/1/39
f 22/1/22 39/1/39 23/1/23
f 23/1/23 39/1/39 40/1/40
f 23/1/23 40/1/40 24/1/24
f 24/1/24 40/1/40 41/1/41
f 24/1/24 41/1/41 25/1/25
f 25/1/25 41/1/41 42/1/42
f 25/1/25 42/1/42 26/1/26
f 26/1/26 42/1/42 43/1/43
f 26/1/26 43/1/43 27/1/27
f 27/1/27 43/1/43 44/1/44
f 27/1/27 44/1/44 28/1/28
f 28/1/28 44/1/44 45/1/45
f 28/1/28 45/1/45 29/1/29
f 29/1/29 45/1/45 46/1/46
f 29/1/29 46/1/46 30/1/30
f 30/1/30 46/1/46 47/1/47
f 30/1/30 47/1/47 31/1/31
f 31/1/31 47/1/47 48/1/48
f 31/1/31 48/1/48 32/1/32
f 32/1/32 48/1/48 49/1/49
f 32/1/32 49/1/49 33/1/33
f 33/1/33 49/1/49 34/1/34
f 33/1/33 34/1/34 18/1/18
f 34/1/34 50/1/50 51/1/51
f 34/1/34 51/1/51 35/1/35
f 35/1/35 51/1/51 52/1/52
f 35/1/35 52/1/52 36/1/36
f 36/1/36 52/1/52 53/1/53
f 36/1/36 53/1/53 37/1/37
f 37/1/37 53/1/53 54/1/54
f 37/1/37 54/1/54 38/1/38
f 38/1/38 54/1/54 55/1/55
f 38/1/38 55/1/55 39/1/39
f 39/1/39 55/1/55 56/1/56
f 39/1/39 56/1/56 40/1/40
f 40/1/40 56/1/56 57/1/57
f 40/1/40 57/1/57 41/1/41
f 41/1/41 57/1/57 58/1/58
f 41/1/41 58/1/58 42/1/42
f 42/1/42 58/1/58 59/1/59
f 42/1/42 59/1/59 43/1/43
f 43/1/43 59/1/59 60/1/60
f 43/1/43 60/1/60 44/1/44
f 44/1/44 60/1/60 61/1/61
f 44/1/44 61/1/61 45/1/45
f 45/1/45 61/1/61 62/1/62
f 45/1/45 62/1/62 46/1/46
f 46/1/46 62/1/62 63/1/63
f 46/1/46 63/1/63 47/1/47
f 47/1/47 63/1/63 64/1/64
f 47/1/47 64/1/64 48/1/48
f 48/1/48 64/1/64 65/1/65
f 48/1/48 65/1/65 49/1/49
f 49/1/49 65/1/65 50/1/50
f 49/1/49 50/1/50 34/1/34
usemtl Blue
f 50/1/50 66/1/66 67/1/67
f 50/1/50 67/1/67 51/1/51
f 51/1/51 67/1/67 68/1/68
f 51/1/51 68/1/68 52/1/52
f 52/1/52 68/1/68 69/1/69
f 52/1/52 69/1/69 53/1/53
f 53/1/53 69/1/69 70/1/70
f 53/1/53 70/1/70 54/1/54
f 54/1/54 70/1/70 71/1/71
f 54/1/54 71/1/71 55/1/55
f 55/1/55 71/1/71 72/1/72
f 55/1/55 72/1/72 56/1/56
f 56/1/56 72/1/72 73/1/73
f 56/1/56 73/1/73 57/1/57
f 57/1/57 73/1/73 74/1/74
f 57/1/57 74/1/74 58/1/58
f 58/1/58 74/1/74 75/1/75
f 58/1/58 75/1/75 59/1/59
f 59/1/59 75/1/75 76/1/76
f 59/1/59 76/1/76 60/1/60
f 60/1/60 76/1/76 77/1/77
f 60/1/60 77/1/77 61/1/61
f 61/1/61 77/1/77 78/1/78
f 61/1/61 78/1/78 62/1/62
f 62/1/62 78/1/78 79/1/79
f 62/1/62 79/1/79 63/1/63
f 63/1/63 79/1/79 80/1/80
f 63/1/63 80/1/80 64/1/64
f 64/1/64 80/1/80 81/1/81
f 64/1/64 81/1/81 65/1/65
f 65/1/65 81/1/81 66/1/66
f 65/1/65 66/1/66 50/1/50
f 66/1/66 82/1/82 83/1/83
f 66/1/66 83/1/83 67/1/67
f 67/1/67 83/1/83 84/1/84
f 67/1/67 84/1/84 68/1/68
f 68/1/68 84/1/84 85/1/85
f 68/1/68 85/1/85 69/1/69
f 69/1/69 85/1/85 86/1/86
f 69/1/69 86/1/86 70/1/70
f 70/1/70 86/1/86 87/1/87
f 70/1/70 87/1/87 71/1/71
f 71/1/71 87/1/87 88/1/88
f 71/1/71 88/1/88 72/1/72
f 72/1/72 88/1/88 89/1/89
f 72/1/72 89/1/89 73/1/73
f 73/1/73 89/1/89 90/1/90
f 73/1/73 90/1/90 74/1/74
f 74/1/74 90/1/90 91/1/91
f 74/1/74 91/1/91 75/1/75
f 75/1/75 91/1/91 92/1/92
f 75/1/75 92/1/92 76/1/76
f 76/1/76 92/1/92 93/1/93
f 76/1/76 93/1/93 77/1/77
f 77/1/77 93/1/93 94/1/94
f 77/1/77 94/1/94 78/1/78
f 78/1/78 94/1/94 95/1/95
f 78/1/78 95/1/95 79/1/79
f 79/1/79 95/1/95 96/1/96
f 79/1/79 96/1/96 80/1/80
f 80/1/80 96/1/96 97/1/97
f 80/1/80 97/1/97 81/1/81
f 81/1/81 97/1/97 82/1/82
f 81/1/81 82/1/82 66/1/66
f 82/1/82 98/1/98 99/1/99
f 82/1/82 99/1/99 83/1/83
f 83/1/83 99/1/99 100/1/100
f 83/1/83 100/1/100 84/1/84
f 84/1/84 100/1/100 101/1/101
f 84/1/84 101/1/101 85/1/85
f 85/1/85 101/1/101 102/1/102
f 85/1/85 102/1/102 86/1/86
f 86/1/86 102/1/102 103/1/103
f 86/1/86 103/1/103 87/1/87
f 87/1/87 103/1/103 104/1/104
f 87/1/87 104/1/104 88/1/88
f 88/1/88 104/1/104 105/1/105
f 88/1/88 105/1/105 89/1/89
f 89/1/89 105/1/105 106/1/106
f 89/1/89 106/1/106 90/1/90
f 90/1/90 106/1/106 107/1/107
f 90/1/90 107/1/107 91/1/91
f 91/1/91 107/1/107 108/1/108
f 91/1/91 108/1/108 92/1/92
f 92/1/92 108/1/108 109/1/109
f 92/1/92 109/1/109 93/1/93
f 93/1/93 109/1/109 110/1/110
f 93/1/93 110/1/110 94/1/94
f 94/1/94 110/1/110 111/1/111
f 94/1/94 111/1/111 95/1/95
f 95/1/95 111/1/111 112/1/112
f 95/1/95 112/1/112 96/1/96
f 96/1/96 112/1/112 113/1/113
f 96/1/96 113/1/113 97/1/97
f 97/1/97 113/1/113 98/1/98
f 97/1/97 98/1/98 82/1/82
f 114/1/114 99/1/99 98/1/98
f 114/1/114 100/1/100 99/1/99
f 114/1/114 101/1/101 100/1/100
f 114/1/114 102/1/102 101/1/101
f 114/1/114 103/1/103 102/1/102
f 114/1/114 104/1/104 103/1/103
f 114/1/114 105/1/105 104/1/104
f 114/1/114 106/1/106 105/1/105
f 114/1/114 107/1/107 106/1/106
f 114/1/114 108/1/108 107/1/107
f 114/1/114 109/1/109 108/1/108
f 114/1/114 110/1/110 109/1/109
f 114/1/114 111/1/111 110/1/110
f 114/1/114 112/1/112 111/1/111
f 114/1/114 113/1/113 112/1/112
f 114/1/114 98/1/98 113/1/113
@@ -0,0 +1,8 @@
# Derived texture-file regression: color comes from map_Kd, not Kd.
newmtl Red
Kd 1 1 1
map_Kd textures/two_color.jpg
newmtl Blue
Kd 1 1 1
map_Kd textures/two_color.jpg
@@ -0,0 +1,458 @@
# test model for OrcaSlicer tex2color
mtllib test_two_color_sphere_textured.mtl
v 0.000000 0.000000 20.000000
v 7.653669 0.000000 18.477591
v 7.071068 2.928932 18.477591
v 5.411961 5.411961 18.477591
v 2.928932 7.071068 18.477591
v 0.000000 7.653669 18.477591
v -2.928932 7.071068 18.477591
v -5.411961 5.411961 18.477591
v -7.071068 2.928932 18.477591
v -7.653669 0.000000 18.477591
v -7.071068 -2.928932 18.477591
v -5.411961 -5.411961 18.477591
v -2.928932 -7.071068 18.477591
v -0.000000 -7.653669 18.477591
v 2.928932 -7.071068 18.477591
v 5.411961 -5.411961 18.477591
v 7.071068 -2.928932 18.477591
v 14.142136 0.000000 14.142136
v 13.065630 5.411961 14.142136
v 10.000000 10.000000 14.142136
v 5.411961 13.065630 14.142136
v 0.000000 14.142136 14.142136
v -5.411961 13.065630 14.142136
v -10.000000 10.000000 14.142136
v -13.065630 5.411961 14.142136
v -14.142136 0.000000 14.142136
v -13.065630 -5.411961 14.142136
v -10.000000 -10.000000 14.142136
v -5.411961 -13.065630 14.142136
v -0.000000 -14.142136 14.142136
v 5.411961 -13.065630 14.142136
v 10.000000 -10.000000 14.142136
v 13.065630 -5.411961 14.142136
v 18.477591 0.000000 7.653669
v 17.071068 7.071068 7.653669
v 13.065630 13.065630 7.653669
v 7.071068 17.071068 7.653669
v 0.000000 18.477591 7.653669
v -7.071068 17.071068 7.653669
v -13.065630 13.065630 7.653669
v -17.071068 7.071068 7.653669
v -18.477591 0.000000 7.653669
v -17.071068 -7.071068 7.653669
v -13.065630 -13.065630 7.653669
v -7.071068 -17.071068 7.653669
v -0.000000 -18.477591 7.653669
v 7.071068 -17.071068 7.653669
v 13.065630 -13.065630 7.653669
v 17.071068 -7.071068 7.653669
v 20.000000 0.000000 0.000000
v 18.477591 7.653669 0.000000
v 14.142136 14.142136 0.000000
v 7.653669 18.477591 0.000000
v 0.000000 20.000000 0.000000
v -7.653669 18.477591 0.000000
v -14.142136 14.142136 0.000000
v -18.477591 7.653669 0.000000
v -20.000000 0.000000 0.000000
v -18.477591 -7.653669 0.000000
v -14.142136 -14.142136 0.000000
v -7.653669 -18.477591 0.000000
v -0.000000 -20.000000 0.000000
v 7.653669 -18.477591 0.000000
v 14.142136 -14.142136 0.000000
v 18.477591 -7.653669 0.000000
v 18.477591 0.000000 -7.653669
v 17.071068 7.071068 -7.653669
v 13.065630 13.065630 -7.653669
v 7.071068 17.071068 -7.653669
v 0.000000 18.477591 -7.653669
v -7.071068 17.071068 -7.653669
v -13.065630 13.065630 -7.653669
v -17.071068 7.071068 -7.653669
v -18.477591 0.000000 -7.653669
v -17.071068 -7.071068 -7.653669
v -13.065630 -13.065630 -7.653669
v -7.071068 -17.071068 -7.653669
v -0.000000 -18.477591 -7.653669
v 7.071068 -17.071068 -7.653669
v 13.065630 -13.065630 -7.653669
v 17.071068 -7.071068 -7.653669
v 14.142136 0.000000 -14.142136
v 13.065630 5.411961 -14.142136
v 10.000000 10.000000 -14.142136
v 5.411961 13.065630 -14.142136
v 0.000000 14.142136 -14.142136
v -5.411961 13.065630 -14.142136
v -10.000000 10.000000 -14.142136
v -13.065630 5.411961 -14.142136
v -14.142136 0.000000 -14.142136
v -13.065630 -5.411961 -14.142136
v -10.000000 -10.000000 -14.142136
v -5.411961 -13.065630 -14.142136
v -0.000000 -14.142136 -14.142136
v 5.411961 -13.065630 -14.142136
v 10.000000 -10.000000 -14.142136
v 13.065630 -5.411961 -14.142136
v 7.653669 0.000000 -18.477591
v 7.071068 2.928932 -18.477591
v 5.411961 5.411961 -18.477591
v 2.928932 7.071068 -18.477591
v 0.000000 7.653669 -18.477591
v -2.928932 7.071068 -18.477591
v -5.411961 5.411961 -18.477591
v -7.071068 2.928932 -18.477591
v -7.653669 0.000000 -18.477591
v -7.071068 -2.928932 -18.477591
v -5.411961 -5.411961 -18.477591
v -2.928932 -7.071068 -18.477591
v -0.000000 -7.653669 -18.477591
v 2.928932 -7.071068 -18.477591
v 5.411961 -5.411961 -18.477591
v 7.071068 -2.928932 -18.477591
v 0.000000 0.000000 -20.000000
vt 0.25 0.5
vt 0.75 0.5
vn 0.000000 -0.000000 1.000000
vn 0.439605 0.035510 0.897489
vn 0.392553 0.201037 0.897489
vn 0.285739 0.335958 0.897489
vn 0.135423 0.419732 0.897489
vn -0.035510 0.439605 0.897489
vn -0.201037 0.392553 0.897489
vn -0.335958 0.285739 0.897489
vn -0.419732 0.135423 0.897489
vn -0.439605 -0.035510 0.897489
vn -0.392553 -0.201037 0.897489
vn -0.285739 -0.335958 0.897489
vn -0.135423 -0.419732 0.897489
vn 0.035510 -0.439605 0.897489
vn 0.201037 -0.392553 0.897489
vn 0.335958 -0.285739 0.897489
vn 0.419732 -0.135423 0.897489
vn 0.725001 0.027230 0.688209
vn 0.659393 0.302603 0.688209
vn 0.493399 0.531907 0.688209
vn 0.252289 0.680234 0.688209
vn -0.027230 0.725001 0.688209
vn -0.302603 0.659393 0.688209
vn -0.531907 0.493399 0.688209
vn -0.680234 0.252289 0.688209
vn -0.725001 -0.027230 0.688209
vn -0.659393 -0.302603 0.688209
vn -0.493399 -0.531907 0.688209
vn -0.252289 -0.680234 0.688209
vn 0.027230 -0.725001 0.688209
vn 0.302603 -0.659393 0.688209
vn 0.531907 -0.493399 0.688209
vn 0.680234 -0.252289 0.688209
vn 0.927851 0.014745 0.372658
vn 0.851580 0.368696 0.372658
vn 0.645664 0.666516 0.372658
vn 0.341451 0.862865 0.372658
vn -0.014745 0.927851 0.372658
vn -0.368696 0.851580 0.372658
vn -0.666516 0.645664 0.372658
vn -0.862865 0.341451 0.372658
vn -0.927851 -0.014745 0.372658
vn -0.851580 -0.368696 0.372658
vn -0.645664 -0.666516 0.372658
vn -0.341451 -0.862865 0.372658
vn 0.014745 -0.927851 0.372658
vn 0.368696 -0.851580 0.372658
vn 0.666516 -0.645664 0.372658
vn 0.862865 -0.341451 0.372658
vn 1.000000 -0.000000 -0.000000
vn 0.923880 0.382683 0.000000
vn 0.707107 0.707107 0.000000
vn 0.382683 0.923880 0.000000
vn 0.000000 1.000000 0.000000
vn -0.382683 0.923880 0.000000
vn -0.707107 0.707107 0.000000
vn -0.923880 0.382683 0.000000
vn -1.000000 0.000000 0.000000
vn -0.923880 -0.382683 -0.000000
vn -0.707107 -0.707107 0.000000
vn -0.382683 -0.923880 -0.000000
vn -0.000000 -1.000000 -0.000000
vn 0.382683 -0.923880 0.000000
vn 0.707107 -0.707107 0.000000
vn 0.923880 -0.382683 -0.000000
vn 0.927851 -0.014745 -0.372658
vn 0.862865 0.341451 -0.372658
vn 0.666516 0.645664 -0.372658
vn 0.368696 0.851580 -0.372658
vn 0.014745 0.927851 -0.372658
vn -0.341451 0.862865 -0.372658
vn -0.645664 0.666516 -0.372658
vn -0.851580 0.368696 -0.372658
vn -0.927851 0.014745 -0.372658
vn -0.862865 -0.341451 -0.372658
vn -0.666516 -0.645664 -0.372658
vn -0.368696 -0.851580 -0.372658
vn -0.014745 -0.927851 -0.372658
vn 0.341451 -0.862865 -0.372658
vn 0.645664 -0.666516 -0.372658
vn 0.851580 -0.368696 -0.372658
vn 0.725001 -0.027230 -0.688209
vn 0.680234 0.252289 -0.688209
vn 0.531907 0.493399 -0.688209
vn 0.302603 0.659393 -0.688209
vn 0.027230 0.725001 -0.688209
vn -0.252289 0.680234 -0.688209
vn -0.493399 0.531907 -0.688209
vn -0.659393 0.302603 -0.688209
vn -0.725001 0.027230 -0.688209
vn -0.680234 -0.252289 -0.688209
vn -0.531907 -0.493399 -0.688209
vn -0.302603 -0.659393 -0.688209
vn -0.027230 -0.725001 -0.688209
vn 0.252289 -0.680234 -0.688209
vn 0.493399 -0.531907 -0.688209
vn 0.659393 -0.302603 -0.688209
vn 0.439605 -0.035510 -0.897489
vn 0.419732 0.135423 -0.897489
vn 0.335958 0.285739 -0.897489
vn 0.201037 0.392553 -0.897489
vn 0.035510 0.439605 -0.897489
vn -0.135423 0.419732 -0.897489
vn -0.285739 0.335958 -0.897489
vn -0.392553 0.201037 -0.897489
vn -0.439605 0.035510 -0.897489
vn -0.419732 -0.135423 -0.897489
vn -0.335958 -0.285739 -0.897489
vn -0.201037 -0.392553 -0.897489
vn -0.035510 -0.439605 -0.897489
vn 0.135423 -0.419732 -0.897489
vn 0.285739 -0.335958 -0.897489
vn 0.392553 -0.201037 -0.897489
vn 0.000000 -0.000000 -1.000000
usemtl Red
f 1/1/1 2/1/2 3/1/3
f 1/1/1 3/1/3 4/1/4
f 1/1/1 4/1/4 5/1/5
f 1/1/1 5/1/5 6/1/6
f 1/1/1 6/1/6 7/1/7
f 1/1/1 7/1/7 8/1/8
f 1/1/1 8/1/8 9/1/9
f 1/1/1 9/1/9 10/1/10
f 1/1/1 10/1/10 11/1/11
f 1/1/1 11/1/11 12/1/12
f 1/1/1 12/1/12 13/1/13
f 1/1/1 13/1/13 14/1/14
f 1/1/1 14/1/14 15/1/15
f 1/1/1 15/1/15 16/1/16
f 1/1/1 16/1/16 17/1/17
f 1/1/1 17/1/17 2/1/2
f 2/1/2 18/1/18 19/1/19
f 2/1/2 19/1/19 3/1/3
f 3/1/3 19/1/19 20/1/20
f 3/1/3 20/1/20 4/1/4
f 4/1/4 20/1/20 21/1/21
f 4/1/4 21/1/21 5/1/5
f 5/1/5 21/1/21 22/1/22
f 5/1/5 22/1/22 6/1/6
f 6/1/6 22/1/22 23/1/23
f 6/1/6 23/1/23 7/1/7
f 7/1/7 23/1/23 24/1/24
f 7/1/7 24/1/24 8/1/8
f 8/1/8 24/1/24 25/1/25
f 8/1/8 25/1/25 9/1/9
f 9/1/9 25/1/25 26/1/26
f 9/1/9 26/1/26 10/1/10
f 10/1/10 26/1/26 27/1/27
f 10/1/10 27/1/27 11/1/11
f 11/1/11 27/1/27 28/1/28
f 11/1/11 28/1/28 12/1/12
f 12/1/12 28/1/28 29/1/29
f 12/1/12 29/1/29 13/1/13
f 13/1/13 29/1/29 30/1/30
f 13/1/13 30/1/30 14/1/14
f 14/1/14 30/1/30 31/1/31
f 14/1/14 31/1/31 15/1/15
f 15/1/15 31/1/31 32/1/32
f 15/1/15 32/1/32 16/1/16
f 16/1/16 32/1/32 33/1/33
f 16/1/16 33/1/33 17/1/17
f 17/1/17 33/1/33 18/1/18
f 17/1/17 18/1/18 2/1/2
f 18/1/18 34/1/34 35/1/35
f 18/1/18 35/1/35 19/1/19
f 19/1/19 35/1/35 36/1/36
f 19/1/19 36/1/36 20/1/20
f 20/1/20 36/1/36 37/1/37
f 20/1/20 37/1/37 21/1/21
f 21/1/21 37/1/37 38/1/38
f 21/1/21 38/1/38 22/1/22
f 22/1/22 38/1/38 39/1/39
f 22/1/22 39/1/39 23/1/23
f 23/1/23 39/1/39 40/1/40
f 23/1/23 40/1/40 24/1/24
f 24/1/24 40/1/40 41/1/41
f 24/1/24 41/1/41 25/1/25
f 25/1/25 41/1/41 42/1/42
f 25/1/25 42/1/42 26/1/26
f 26/1/26 42/1/42 43/1/43
f 26/1/26 43/1/43 27/1/27
f 27/1/27 43/1/43 44/1/44
f 27/1/27 44/1/44 28/1/28
f 28/1/28 44/1/44 45/1/45
f 28/1/28 45/1/45 29/1/29
f 29/1/29 45/1/45 46/1/46
f 29/1/29 46/1/46 30/1/30
f 30/1/30 46/1/46 47/1/47
f 30/1/30 47/1/47 31/1/31
f 31/1/31 47/1/47 48/1/48
f 31/1/31 48/1/48 32/1/32
f 32/1/32 48/1/48 49/1/49
f 32/1/32 49/1/49 33/1/33
f 33/1/33 49/1/49 34/1/34
f 33/1/33 34/1/34 18/1/18
f 34/1/34 50/1/50 51/1/51
f 34/1/34 51/1/51 35/1/35
f 35/1/35 51/1/51 52/1/52
f 35/1/35 52/1/52 36/1/36
f 36/1/36 52/1/52 53/1/53
f 36/1/36 53/1/53 37/1/37
f 37/1/37 53/1/53 54/1/54
f 37/1/37 54/1/54 38/1/38
f 38/1/38 54/1/54 55/1/55
f 38/1/38 55/1/55 39/1/39
f 39/1/39 55/1/55 56/1/56
f 39/1/39 56/1/56 40/1/40
f 40/1/40 56/1/56 57/1/57
f 40/1/40 57/1/57 41/1/41
f 41/1/41 57/1/57 58/1/58
f 41/1/41 58/1/58 42/1/42
f 42/1/42 58/1/58 59/1/59
f 42/1/42 59/1/59 43/1/43
f 43/1/43 59/1/59 60/1/60
f 43/1/43 60/1/60 44/1/44
f 44/1/44 60/1/60 61/1/61
f 44/1/44 61/1/61 45/1/45
f 45/1/45 61/1/61 62/1/62
f 45/1/45 62/1/62 46/1/46
f 46/1/46 62/1/62 63/1/63
f 46/1/46 63/1/63 47/1/47
f 47/1/47 63/1/63 64/1/64
f 47/1/47 64/1/64 48/1/48
f 48/1/48 64/1/64 65/1/65
f 48/1/48 65/1/65 49/1/49
f 49/1/49 65/1/65 50/1/50
f 49/1/49 50/1/50 34/1/34
usemtl Blue
f 50/2/50 66/2/66 67/2/67
f 50/2/50 67/2/67 51/2/51
f 51/2/51 67/2/67 68/2/68
f 51/2/51 68/2/68 52/2/52
f 52/2/52 68/2/68 69/2/69
f 52/2/52 69/2/69 53/2/53
f 53/2/53 69/2/69 70/2/70
f 53/2/53 70/2/70 54/2/54
f 54/2/54 70/2/70 71/2/71
f 54/2/54 71/2/71 55/2/55
f 55/2/55 71/2/71 72/2/72
f 55/2/55 72/2/72 56/2/56
f 56/2/56 72/2/72 73/2/73
f 56/2/56 73/2/73 57/2/57
f 57/2/57 73/2/73 74/2/74
f 57/2/57 74/2/74 58/2/58
f 58/2/58 74/2/74 75/2/75
f 58/2/58 75/2/75 59/2/59
f 59/2/59 75/2/75 76/2/76
f 59/2/59 76/2/76 60/2/60
f 60/2/60 76/2/76 77/2/77
f 60/2/60 77/2/77 61/2/61
f 61/2/61 77/2/77 78/2/78
f 61/2/61 78/2/78 62/2/62
f 62/2/62 78/2/78 79/2/79
f 62/2/62 79/2/79 63/2/63
f 63/2/63 79/2/79 80/2/80
f 63/2/63 80/2/80 64/2/64
f 64/2/64 80/2/80 81/2/81
f 64/2/64 81/2/81 65/2/65
f 65/2/65 81/2/81 66/2/66
f 65/2/65 66/2/66 50/2/50
f 66/2/66 82/2/82 83/2/83
f 66/2/66 83/2/83 67/2/67
f 67/2/67 83/2/83 84/2/84
f 67/2/67 84/2/84 68/2/68
f 68/2/68 84/2/84 85/2/85
f 68/2/68 85/2/85 69/2/69
f 69/2/69 85/2/85 86/2/86
f 69/2/69 86/2/86 70/2/70
f 70/2/70 86/2/86 87/2/87
f 70/2/70 87/2/87 71/2/71
f 71/2/71 87/2/87 88/2/88
f 71/2/71 88/2/88 72/2/72
f 72/2/72 88/2/88 89/2/89
f 72/2/72 89/2/89 73/2/73
f 73/2/73 89/2/89 90/2/90
f 73/2/73 90/2/90 74/2/74
f 74/2/74 90/2/90 91/2/91
f 74/2/74 91/2/91 75/2/75
f 75/2/75 91/2/91 92/2/92
f 75/2/75 92/2/92 76/2/76
f 76/2/76 92/2/92 93/2/93
f 76/2/76 93/2/93 77/2/77
f 77/2/77 93/2/93 94/2/94
f 77/2/77 94/2/94 78/2/78
f 78/2/78 94/2/94 95/2/95
f 78/2/78 95/2/95 79/2/79
f 79/2/79 95/2/95 96/2/96
f 79/2/79 96/2/96 80/2/80
f 80/2/80 96/2/96 97/2/97
f 80/2/80 97/2/97 81/2/81
f 81/2/81 97/2/97 82/2/82
f 81/2/81 82/2/82 66/2/66
f 82/2/82 98/2/98 99/2/99
f 82/2/82 99/2/99 83/2/83
f 83/2/83 99/2/99 100/2/100
f 83/2/83 100/2/100 84/2/84
f 84/2/84 100/2/100 101/2/101
f 84/2/84 101/2/101 85/2/85
f 85/2/85 101/2/101 102/2/102
f 85/2/85 102/2/102 86/2/86
f 86/2/86 102/2/102 103/2/103
f 86/2/86 103/2/103 87/2/87
f 87/2/87 103/2/103 104/2/104
f 87/2/87 104/2/104 88/2/88
f 88/2/88 104/2/104 105/2/105
f 88/2/88 105/2/105 89/2/89
f 89/2/89 105/2/105 106/2/106
f 89/2/89 106/2/106 90/2/90
f 90/2/90 106/2/106 107/2/107
f 90/2/90 107/2/107 91/2/91
f 91/2/91 107/2/107 108/2/108
f 91/2/91 108/2/108 92/2/92
f 92/2/92 108/2/108 109/2/109
f 92/2/92 109/2/109 93/2/93
f 93/2/93 109/2/109 110/2/110
f 93/2/93 110/2/110 94/2/94
f 94/2/94 110/2/110 111/2/111
f 94/2/94 111/2/111 95/2/95
f 95/2/95 111/2/111 112/2/112
f 95/2/95 112/2/112 96/2/96
f 96/2/96 112/2/112 113/2/113
f 96/2/96 113/2/113 97/2/97
f 97/2/97 113/2/113 98/2/98
f 97/2/97 98/2/98 82/2/82
f 114/2/114 99/2/99 98/2/98
f 114/2/114 100/2/100 99/2/99
f 114/2/114 101/2/101 100/2/100
f 114/2/114 102/2/102 101/2/101
f 114/2/114 103/2/103 102/2/102
f 114/2/114 104/2/104 103/2/103
f 114/2/114 105/2/105 104/2/104
f 114/2/114 106/2/106 105/2/105
f 114/2/114 107/2/107 106/2/106
f 114/2/114 108/2/108 107/2/107
f 114/2/114 109/2/109 108/2/108
f 114/2/114 110/2/110 109/2/109
f 114/2/114 111/2/111 110/2/110
f 114/2/114 112/2/112 111/2/111
f 114/2/114 113/2/113 112/2/112
f 114/2/114 98/2/98 113/2/113
Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

+98 -34
View File
@@ -37,7 +37,7 @@
#include "libslic3r/Fill/Fill.hpp"
#include "libslic3r/Fill/FillAdaptive.hpp"
#include "libslic3r/Fill/FillGyroid.hpp"
#include "libslic3r/Fill/FillTpmsAdaptive.hpp"
#include "libslic3r/Fill/AdaptiveInfillField.hpp"
#include "libslic3r/Flow.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/IntersectionPoints.hpp"
@@ -1719,9 +1719,10 @@ TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", "
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.path_count > 0);
// Smoothing should leave the loop count about where it was; uncapping the smoothing reach, the
// regression this guards against, adds about 10%.
const size_t allowed_extra = sharp.path_count / 50; // 2%
// Smoothing should leave the path count about where it was; uncapping the smoothing reach, the
// regression this guards against, adds a quarter to a third. Outline ends next to each other are
// joined into one path, so a few loops more or less are a larger share of the count.
const size_t allowed_extra = sharp.path_count / 10; // 10%
REQUIRE(smooth.path_count <= sharp.path_count + allowed_extra);
// The outlines are still rounded.
REQUIRE(smooth.point_count > sharp.point_count);
@@ -2060,9 +2061,9 @@ TEST_CASE("Adaptive TPMS infill thins out from the surface to the interior densi
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(60)}, print,
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
@@ -2089,9 +2090,9 @@ TEST_CASE("Adaptive TPMS infill of a tall object is sparsest at its middle heigh
Slic3r::Test::init_and_process_print({make_cube(30., 30., 90.)}, print,
{{"sparse_infill_pattern", "tpmsd"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", "lobes"},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"adaptive_infill_mode", "lobes"},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
const Polygons core = centered_square(5.);
@@ -2111,9 +2112,9 @@ TEST_CASE("2D adaptive TPMS infill does not change along its axis", "[Fill]")
Slic3r::Test::init_and_process_print({along_x ? make_cube(90., 30., 30.) : make_cube(30., 30., 90.)}, print,
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
@@ -2147,9 +2148,9 @@ TEST_CASE("Adaptive TPMS shells and blend keep the whole deep core of a tall obj
Slic3r::Test::init_and_process_print({make_cube(30., 30., 90.)}, print,
{{"sparse_infill_pattern", "tpmsd"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
@@ -2179,9 +2180,9 @@ TEST_CASE("Adaptive TPMS infill is sparse at the center of both of two united sp
Slic3r::Test::init_and_process_print({TriangleMesh(spheres)}, print,
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
@@ -2209,9 +2210,9 @@ TEST_CASE("Adaptive TPMS gradients keep the surface density deeper in the order
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(40)}, print,
{{"sparse_infill_pattern", "tpmsd"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", "lobes"},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", gradient},
{"adaptive_infill_mode", "lobes"},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", gradient},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
return sparse_infill_length(print, 0., 40., centered_square(20.));
@@ -2236,9 +2237,9 @@ TEST_CASE("Adaptive TPMS settings leave the infill unchanged when they do not ap
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "20%"},
{"layer_height", 0.2},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "40%"},
{"tpms_adaptive_gradient", "exponential"}});
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "40%"},
{"adaptive_infill_gradient", "exponential"}});
const SparseInfillShape expected = sparse_infill_shape(reference);
REQUIRE(expected.path_count > 0);
CHECK(sparse_infill_shape(tuned).sequence == expected.sequence);
@@ -2252,7 +2253,7 @@ TEST_CASE("Adaptive gyroid infill ignores the Z-buckling optimization", "[Fill]"
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "10%"},
{"gyroid_optimized", gyroid_optimized},
{"tpms_adaptive", "lobes"},
{"adaptive_infill_mode", "lobes"},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
@@ -2268,10 +2269,10 @@ TEST_CASE("Adaptive TPMS infill of a region matches the infill of a larger regio
// An 80 x 80 x 40 mm box around both regions, which share its radial field.
const ExPolygons box{ExPolygon(Points{Point::new_scale(60., 20.), Point::new_scale(140., 20.), Point::new_scale(140., 100.),
Point::new_scale(60., 100.)})};
std::vector<TpmsRadialField::Slice> slices;
std::vector<AdaptiveInfillField::Slice> slices;
for (int i = 0; i < 200; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &box});
const TpmsRadialField field(slices, get_extents(box), TpmsAdaptiveMode::Lobes, [] {});
const AdaptiveInfillField field(slices, get_extents(box), AdaptiveInfillMode::Lobes, [] {});
auto circle = [](double radius) {
Polygon contour = make_circle_num_segments(scale_(radius), 120);
@@ -2285,13 +2286,13 @@ TEST_CASE("Adaptive TPMS infill of a region matches the infill of a larger regio
filler->spacing = 0.45;
filler->angle = float(M_PI / 7.);
filler->z = z;
filler->tpms_radial_field = &field;
filler->adaptive_infill_field = &field;
FillParams params;
params.density = 0.2f;
params.tpms_adaptive = TpmsAdaptiveMode::Lobes;
params.tpms_interior_density = 0.05f;
params.tpms_adaptive_gradient = TpmsAdaptiveGradient::Linear;
params.adaptive_infill_mode = AdaptiveInfillMode::Lobes;
params.adaptive_infill_interior_density = 0.05f;
params.adaptive_infill_gradient = AdaptiveInfillGradient::Linear;
params.layer_height = 0.2;
params.dont_adjust = true;
Surface surface(stInternal, expolygon);
@@ -2328,8 +2329,8 @@ TEST_CASE("Adaptive TPMS anchors match the printed infill", "[Fill][InternalBrid
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(30)}, print,
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", "lobes"},
{"tpms_interior_density", "5%"},
{"adaptive_infill_mode", "lobes"},
{"adaptive_infill_interior_density", "5%"},
{"layer_height", 0.2},
{"initial_layer_print_height", 0.2},
{"resolution", 0.012}});
@@ -2353,3 +2354,66 @@ TEST_CASE("Adaptive TPMS anchors match the printed infill", "[Fill][InternalBrid
max_distance = std::max(max_distance, printed_tree.distance_from_lines<false>(point));
CHECK(unscale<double>(max_distance) <= 0.012);
}
TEST_CASE("Infill lines whose ends are next to each other join into one path", "[Fill]")
{
// An out-and-back pair 1 mm apart, as multiline infill leaves, and a line 19 mm away.
Polylines lines { Polyline(Point::new_scale(0., 0.), Point::new_scale(10., 0.)), Polyline(Point::new_scale(0., 1.), Point::new_scale(10., 1.)),
Polyline(Point::new_scale(0., 20.), Point::new_scale(10., 20.)) };
join_close_ends(lines, scale_(1.5), true);
REQUIRE(lines.size() == 2);
const auto joined = std::find_if(lines.begin(), lines.end(), [](const Polyline &pl) { return pl.size() == 4; });
REQUIRE(joined != lines.end());
// Both lines plus the 1 mm between them, joined at one end only, as joining the other end too would close a loop.
CHECK_THAT(unscale<double>(joined->length()), Catch::Matchers::WithinAbs(21., 1e-6));
CHECK(joined->first_point() != joined->last_point());
}
TEST_CASE("Joined infill lines print each line once", "[Fill]")
{
// The middle line comes first and both of its ends get joined, so the group has to be walked from an outer line.
Polylines lines { Polyline(Point::new_scale(0., 0.), Point::new_scale(10., 0.)), Polyline(Point::new_scale(0., 1.), Point::new_scale(10., 1.)),
Polyline(Point::new_scale(10., -1.), Point::new_scale(0., -1.)) };
join_close_ends(lines, scale_(1.5), true);
REQUIRE(lines.size() == 1);
CHECK(lines.front().size() == 6);
// Three 10 mm lines and two 1 mm joins.
CHECK_THAT(unscale<double>(lines.front().length()), Catch::Matchers::WithinAbs(32., 1e-6));
}
TEST_CASE("Infill line ends are not joined across another line", "[Fill]")
{
// A pair 1 mm apart whose left ends have a third line running between them, which a join there would cross.
Polylines lines { Polyline(Point::new_scale(0., 0.), Point::new_scale(10., 0.)), Polyline(Point::new_scale(0., 1.), Point::new_scale(10., 1.)),
Polyline(Point::new_scale(-5., 0.5), Point::new_scale(5., 0.5)) };
join_close_ends(lines, scale_(1.5), true);
REQUIRE(lines.size() == 2);
const auto joined = std::find_if(lines.begin(), lines.end(), [](const Polyline &pl) { return pl.size() == 4; });
REQUIRE(joined != lines.end());
// Joined on the right, where nothing is in between, so the left ends stay open.
CHECK(joined->first_point().x() == 0);
CHECK(joined->last_point().x() == 0);
}
TEST_CASE("Infill lines that cannot reverse join only end to start", "[Fill]")
{
const Point a(Point::new_scale(0., 0.)), b(Point::new_scale(10., 0.)), c(Point::new_scale(10., 1.)), d(Point::new_scale(0., 1.));
SECTION("Ends that would need a reversal stay apart") {
Polylines lines { Polyline(a, b), Polyline(d, c) };
join_close_ends(lines, scale_(1.5), false);
CHECK(lines.size() == 2);
}
SECTION("The end of one line joins the start of the next, both kept in their direction") {
Polylines lines { Polyline(c, d), Polyline(a, b) };
join_close_ends(lines, scale_(1.5), false);
REQUIRE(lines.size() == 1);
const Points &points = lines.front().points;
REQUIRE(points.size() == 4);
auto position = [&points](const Point &p) { return std::find(points.begin(), points.end(), p) - points.begin(); };
CHECK(position(a) < position(b));
CHECK(position(c) < position(d));
}
}
+38
View File
@@ -51,6 +51,44 @@ TEST_CASE("Klipper object labels name each copy without the characters Klipper c
}
}
// The outline written to EXCLUDE_OBJECT_DEFINE is simplified with exclude_object_tolerance. A larger value must
// leave fewer points, which keeps the exclude-object array small on printers that stall while parsing it.
TEST_CASE("A larger exclude object tolerance writes fewer outline points", "[GCode]")
{
const auto slice_with_tolerance = [](const std::string &tolerance) {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"gcode_flavor", "klipper"}, {"exclude_object", "1"},
{"exclude_object_tolerance", tolerance}});
// Default facet angle (2 degrees): 180 hull points, well inside either tolerance.
return Test::slice({make_cylinder(20., 5.)}, config);
};
// Points of the POLYGON=[[x,y],...] on the first EXCLUDE_OBJECT_DEFINE line.
const auto polygon_points = [](const std::string &gcode) {
const size_t define = gcode.find("EXCLUDE_OBJECT_DEFINE");
REQUIRE(define != std::string::npos);
const size_t begin = gcode.find("POLYGON=", define);
REQUIRE(begin != std::string::npos);
const std::string polygon = gcode.substr(begin, gcode.find('\n', begin) - begin);
size_t points = 1;
for (size_t pos = polygon.find("],["); pos != std::string::npos; pos = polygon.find("],[", pos + 1))
++points;
return points;
};
const size_t fine = polygon_points(slice_with_tolerance("0.1"));
const size_t coarse = polygon_points(slice_with_tolerance("0.4"));
INFO("points at 0.1 mm: " << fine << ", at 0.4 mm: " << coarse);
CHECK(coarse >= 4);
CHECK(coarse < fine);
}
// Existing profiles must keep writing the outlines they wrote when the tolerance was a constant 0.1 mm.
TEST_CASE("The exclude object tolerance defaults to the former fixed 0.1 mm", "[GCode]")
{
CHECK_THAT(DynamicPrintConfig::full_print_config().opt_float("exclude_object_tolerance"),
Catch::Matchers::WithinAbs(0.1, 1e-9));
}
// Firmwares read the compatibility header lines (Anycubic's GoKlipper heats the bed with
// first_layer_bed_temperature), so they must describe the filament the plate prints, not filament 1.
TEST_CASE("The G-code header names the first layer temperatures of the filament the plate prints", "[GCode]")
+200
View File
@@ -2,15 +2,18 @@
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/catch_message.hpp>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/MultiNozzleUtils.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/libslic3r.h"
#include "libslic3r/Utils.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
@@ -30,6 +33,7 @@ namespace Slic3r { enum ExtrusionRole : uint8_t; }
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
// Regression coverage for filament/tool-change time being folded into the first
// pending motion block (an extrusion move) instead of the tool-change move, and
@@ -570,6 +574,33 @@ double planned_corner_speed(GCodeFlavor flavor, double corner_velocity, double j
} // namespace
TEST_CASE("Klipper extrusion ratio changes use its own corner velocity, not E jerk", "[GCodeTiming][JunctionDeviation]")
{
const double jerk_e = GENERATE(0.0, 100.0);
auto config = make_junction_config(gcfKlipper, 5.0, 0.0);
config.machine_max_jerk_e.values = {jerk_e, jerk_e};
GCodeProcessor processor;
run_processor(processor, config,
"G90\nM83\nSET_VELOCITY_LIMIT ACCEL=1000 MINIMUM_CRUISE_RATIO=0\n"
"G1 X20 Y60 F6000\nM400\n"
"G1 X60 Y60 E0.8 F6000\nG1 X100 Y60 E1.6 F6000\nM400\n");
// A 0.02 -> 0.04 extrusion ratio change is capped at 1 / 0.02 = 50 mm/s.
REQUIRE_THAT(corner_speed(processor.get_result()), Catch::Matchers::WithinAbs(50.0, 0.01));
}
TEST_CASE("Klipper legacy smoothing stays absolute across acceleration changes", "[GCodeTiming][Klipper]")
{
const bool modern = GENERATE(false, true);
auto config = make_junction_config(gcfKlipper, 5.0, 0.0);
const std::string gcode = std::string("G90\nSET_VELOCITY_LIMIT ACCEL=1000 ") +
(modern ? "MINIMUM_CRUISE_RATIO=0.75\n" : "ACCEL_TO_DECEL=250\n") +
"G1 X40 F30000\nM400\nM204 S2000\nG1 X0\nM400\n";
GCodeProcessor processor;
run_processor(processor, config, gcode.c_str());
const double expected = modern ? 0.5 + 0.25 * std::sqrt(2.0) : 0.95;
REQUIRE_THAT(processor.get_time(PrintEstimatedStatistics::ETimeMode::Normal), WithinAbs(expected, 1e-5));
}
TEST_CASE("Klipper corners are planned with junction deviation derived from the square corner velocity",
"[GCodeTiming][JunctionDeviation]")
{
@@ -669,3 +700,172 @@ TEST_CASE("How fast a corner is taken does not depend on how much is extruded th
Catch::Matchers::WithinRel(marlin, 0.02));
}
}
// Expected times are Klipper's own planner math (klippy/toolhead.py). For a move from rest to rest,
// LookAheadQueue.flush() reduces to cruise_v2 = min(vmax^2, d * accel, d * pseudo_accel) with
// pseudo_accel = accel * (1 - minimum_cruise_ratio), and Move.set_junction() to a trapezoid at accel;
// a move too short to reach its speed still spends that ratio of its length cruising.
namespace {
constexpr double klipper_accel = 1000.0; // make_junction_config's acceleration
constexpr double klipper_cruise_ratio = 0.5;
constexpr double klipper_pseudo_accel = klipper_accel * (1.0 - klipper_cruise_ratio);
// Time of a move that starts and ends at rest.
double rest_to_rest_time(double distance, double max_speed, double accel, double pseudo_accel)
{
const double cruise_v2 = std::min(sqr(max_speed), distance * pseudo_accel);
const double cruise = std::sqrt(cruise_v2);
return 2.0 * cruise / accel + (distance - cruise_v2 / accel) / cruise;
}
// Every run states the cruise ratio rather than relying on the estimator's built-in default.
void run_klipper(GCodeProcessor& proc, const std::string& gcode,
const FullPrintConfig& config = make_junction_config(gcfKlipper, 5.0, 0.0))
{
const std::string limits = "SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=" + std::to_string(klipper_cruise_ratio) + "\n";
run_processor(proc, config, (limits + gcode).c_str());
}
double klipper_time(const std::string& gcode, const FullPrintConfig& config = make_junction_config(gcfKlipper, 5.0, 0.0))
{
GCodeProcessor proc;
run_klipper(proc, gcode, config);
return proc.get_time(PrintEstimatedStatistics::ETimeMode::Normal);
}
double time_after_retract(const GCodeProcessorResult& r)
{
double t = 0.0;
bool after = false;
for (const auto& mv : r.moves) {
if (mv.type == EMoveType::Retract)
after = true;
else if (after)
t += mv.time[NORMAL];
}
return t;
}
} // namespace
TEST_CASE("A straight line split into short segments is planned as one move", "[GCodeTiming][Klipper]")
{
// 100 mm at 50 mm/s queues 2 s of motion, so the look-ahead flushes lazily part-way along the
// line; at 10 mm/s^2 the ramps span many segments and the cruise ratio caps the speed.
const int segments = GENERATE(1, 100, 1000);
std::ostringstream gcode;
gcode << "SET_VELOCITY_LIMIT ACCEL=10\n" << std::fixed << std::setprecision(3);
for (int i = 1; i <= segments; ++i)
gcode << "G1 X" << 100.0 * i / segments << " F3000\n";
REQUIRE_THAT(klipper_time(gcode.str()),
WithinRel(rest_to_rest_time(100.0, 50.0, 10.0, 10.0 * (1.0 - klipper_cruise_ratio)), 1e-6));
}
TEST_CASE("Where the look-ahead flushes does not change the estimate", "[GCodeTiming][Klipper]")
{
// A retraction stops the toolhead, so the zig-zag after it is planned on its own; the straight run
// before it only shifts where the queue flushes lazily (1 s of motion after a stop, then every 0.15 s).
auto zigzag_time = [](int run_moves) {
std::string gcode = "M83\nG91\n";
for (int i = 0; i < run_moves; ++i)
gcode += "G1 X1 F6000\n";
gcode += "G1 E-1 F2400\n";
const char* moves[] = {"G1 X2 F3000\n", "G1 Y2 F9000\n", "G1 X2 F1200\n", "G1 Y-2 F9000\n"};
for (int i = 0; i < 24; ++i)
gcode += moves[i % 4];
GCodeProcessor proc;
run_klipper(proc, gcode);
return time_after_retract(proc.get_result());
};
const double reference = zigzag_time(0);
REQUIRE(reference > 0.0);
for (int run_moves : {30, 80, 105, 130}) {
INFO("run moves = " << run_moves);
REQUIRE_THAT(zigzag_time(run_moves), WithinAbs(reference, 1e-9));
}
}
TEST_CASE("A short move into a corner is limited by the centripetal term, not the corner velocity",
"[GCodeTiming][Klipper]")
{
// A 50 mm/s square corner velocity allows 50 mm/s through the right angle; the 1 mm move into it
// allows sqrt(0.5 * 1 * 1000 * tan(45 deg)) = 22.4 mm/s (Move.calc_junction's move_centripetal_v2).
GCodeProcessor proc;
run_klipper(proc, "G90\nG1 X59 Y60 F6000\nM400\nG1 X60 Y60 F9000\nG1 X60 Y100 F9000\n",
make_junction_config(gcfKlipper, 50.0, 0.0));
REQUIRE_THAT(corner_speed(proc.get_result()), WithinRel(std::sqrt(500.0), 1e-6));
}
TEST_CASE("A corner keeps the square corner velocity when the acceleration changes between its moves",
"[GCodeTiming][Klipper]")
{
GCodeProcessor proc;
run_klipper(proc, "G90\nG1 X20 Y60 F6000\nM400\nG1 X60 Y60 F9000\nM204 S4000\nG1 X60 Y100 F9000\n");
REQUIRE_THAT(corner_speed(proc.get_result()), WithinRel(5.0, 1e-6));
}
TEST_CASE("A retraction stops the toolhead and runs at the extruder's own limits", "[GCodeTiming][Klipper]")
{
// An E acceleration of 0 is unset, and the retraction then uses the retract acceleration.
const auto [e_accel, retract_accel] = GENERATE(table<double, double>({{20000.0, 20000.0}, {0.0, 3000.0}}));
auto config = make_junction_config(gcfKlipper, 5.0, 0.0);
config.machine_max_acceleration_e.values = {e_accel, e_accel};
config.machine_max_acceleration_retracting.values = {3000.0, 3000.0};
GCodeProcessor proc;
run_klipper(proc, "G90\nM83\nG1 X60 Y60 F9000\nG1 E-1 F2400\nG1 X100 Y60 F9000\n", config);
REQUIRE_THAT(corner_speed(proc.get_result()), WithinAbs(0.0, 1e-6));
const double expected = rest_to_rest_time(std::sqrt(2.0) * 60.0, 150.0, klipper_accel, klipper_pseudo_accel) +
rest_to_rest_time(1.0, 40.0, retract_accel, std::min(retract_accel, klipper_pseudo_accel)) +
rest_to_rest_time(40.0, 150.0, klipper_accel, klipper_pseudo_accel);
REQUIRE_THAT(proc.get_time(PrintEstimatedStatistics::ETimeMode::Normal), WithinRel(expected, 1e-6));
}
TEST_CASE("Extruder limits apply to retractions, not to extrusion along a path", "[GCodeTiming][Klipper]")
{
auto config = make_junction_config(gcfKlipper, 5.0, 0.0);
config.machine_max_speed_e.values = {10.0, 10.0};
config.machine_max_acceleration_e.values = {10.0, 10.0};
REQUIRE_THAT(klipper_time("M83\nG1 X40 E20 F30000\n", config),
WithinRel(rest_to_rest_time(40.0, 500.0, klipper_accel, klipper_pseudo_accel), 1e-6));
REQUIRE_THAT(klipper_time("M83\nG1 E-1 F2400\n", config),
WithinRel(rest_to_rest_time(1.0, 10.0, 10.0, 10.0), 1e-6));
}
TEST_CASE("A Z move is limited by the Z axis speed and acceleration", "[GCodeTiming][Klipper]")
{
auto config = make_junction_config(gcfKlipper, 5.0, 0.0);
config.machine_max_speed_z.values = {20.0, 20.0};
config.machine_max_acceleration_z.values = {100.0, 100.0};
REQUIRE_THAT(klipper_time("G1 Z10 F1200\n", config),
WithinRel(rest_to_rest_time(10.0, 20.0, 100.0, 100.0), 1e-6));
}
TEST_CASE("M204 sets the acceleration from S, or the smaller of P and T", "[GCodeTiming][Klipper]")
{
const char* m204 = GENERATE("M204 S500", "M204 P500 T2000", "M204 P2000 T500");
REQUIRE_THAT(klipper_time(std::string(m204) + "\nG1 X100 F30000\n"),
WithinRel(rest_to_rest_time(100.0, 500.0, 500.0, 500.0 * (1.0 - klipper_cruise_ratio)), 1e-6));
}
TEST_CASE("SET_VELOCITY_LIMIT applies every limit on the line and ignores the comment", "[GCodeTiming][Klipper]")
{
REQUIRE_THAT(klipper_time("SET_VELOCITY_LIMIT VELOCITY=80 ACCEL=500 MINIMUM_CRUISE_RATIO=0.2 ; ACCEL=9999\n"
"G1 X20 F30000\n"),
WithinRel(rest_to_rest_time(20.0, 80.0, 500.0, 500.0 * (1.0 - 0.2)), 1e-6));
}
TEST_CASE("ACCEL_TO_DECEL is ignored on a line that also sets the minimum cruise ratio", "[GCodeTiming][Klipper]")
{
// Klipper 0.13 prefers the cruise ratio and later releases no longer read ACCEL_TO_DECEL at all.
// ACCEL_TO_DECEL comes last, so applying the parameters in line order would let it win.
REQUIRE_THAT(klipper_time("SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=" + std::to_string(klipper_cruise_ratio) +
" ACCEL_TO_DECEL=100\nG1 X40 F30000\n"),
WithinRel(rest_to_rest_time(40.0, 500.0, klipper_accel, klipper_pseudo_accel), 1e-6));
}
TEST_CASE("M400 without a delay waits for the queue to drain", "[GCodeTiming][Klipper]")
{
REQUIRE_THAT(klipper_time("G90\nG1 X40 F30000\nM400\nG1 X80\n"),
WithinRel(2.0 * rest_to_rest_time(40.0, 500.0, klipper_accel, klipper_pseudo_accel), 1e-6));
}
+36 -1
View File
@@ -514,7 +514,7 @@ TEST_CASE("Acceleration and velocity limit commands print their values in genera
const Case c = GENERATE(values<Case>({
{gcfKlipper, Command::KlipperLimits, 2000000, 25. / 3., false,
{"SET_VELOCITY_LIMIT ACCEL=2000000 ", "ACCEL_TO_DECEL=1e+06 ", "SQUARE_CORNER_VELOCITY=8.33333\n"}, {}},
{gcfKlipper, Command::KlipperLimits, 12345, 0., false, {"ACCEL=12345 ", "ACCEL_TO_DECEL=6172.5\n"}, {"SQUARE_CORNER_VELOCITY"}},
{gcfKlipper, Command::KlipperLimits, 12345, 0., false, {"ACCEL=12345 ", "ACCEL_TO_DECEL=6172.5 ", "MINIMUM_CRUISE_RATIO=0.5\n"}, {"SQUARE_CORNER_VELOCITY"}},
{gcfKlipper, Command::KlipperLimits, 0, 0.25, true, {"SQUARE_CORNER_VELOCITY=0.25 ", "; adjust VELOCITY_LIMIT"}, {"ACCEL"}},
{gcfKlipper, Command::Print, 3001, 0., true, {"ACCEL=3001 ", "ACCEL_TO_DECEL=1500.5 ", "; adjust ACCEL_TO_DECEL", "; adjust acceleration"}, {}},
{gcfMarlinFirmware, Command::Print, 2500, 0., false, {"M204 P2500\n"}, {}},
@@ -1272,6 +1272,41 @@ TEST_CASE("Custom G-code motion limits are restored before generated moves", "[G
REQUIRE(gcode.find("M205 X8 Y8 ; adjust jerk", custom_gcode_pos) != std::string::npos);
}
TEST_CASE("Klipper acceleration commands send accel_to_decel both as ACCEL_TO_DECEL and as the cruise ratio", "[GCodeWriter]")
{
// Klipper 0.13 defines MINIMUM_CRUISE_RATIO = 1 - ACCEL_TO_DECEL / ACCEL; older releases read only the
// first parameter and current ones only the second, so both have to describe the same limit.
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfKlipper;
writer.config.accel_to_decel_enable.value = true;
// The writer appends "; adjust ..." comments by default; keep them out so the line ends at the ratio.
struct CommentGuard
{
bool saved = GCodeWriter::full_gcode_comment;
~CommentGuard() { GCodeWriter::full_gcode_comment = saved; }
} comment_guard;
GCodeWriter::full_gcode_comment = false;
SECTION("a 25% factor") {
writer.config.accel_to_decel_factor.value = 25;
REQUIRE_THAT(writer.set_print_acceleration(2000),
Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=2000 ACCEL_TO_DECEL=500 MINIMUM_CRUISE_RATIO=0.75"));
REQUIRE_THAT(writer.set_accel_and_jerk(3000, 0.0),
Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=3000 ACCEL_TO_DECEL=750 MINIMUM_CRUISE_RATIO=0.75"));
}
SECTION("a 100% factor turns the cruise ratio off") {
writer.config.accel_to_decel_factor.value = 100;
REQUIRE_THAT(writer.set_print_acceleration(2000),
Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=2000 ACCEL_TO_DECEL=2000 MINIMUM_CRUISE_RATIO=0\n"));
}
SECTION("neither is sent while the option is off") {
writer.config.accel_to_decel_enable.value = false;
const std::string line = writer.set_print_acceleration(2000);
REQUIRE_THAT(line, Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=2000"));
REQUIRE_THAT(line, !Catch::Matchers::ContainsSubstring("ACCEL_TO_DECEL") && !Catch::Matchers::ContainsSubstring("MINIMUM_CRUISE_RATIO"));
}
}
// Regression test for the belt-printer "illegal gantry move at print start" bug.
//
// On a belt printer the layer-change z-hop is deferred (lazy_lift) and consumed
+17
View File
@@ -202,6 +202,23 @@ TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor",
}
}
TEST_CASE("The wipe tower sets Klipper's acceleration and accel_to_decel on one line", "[WipeTower]")
{
// On separate lines, current Klipper finds nothing it reads on the ACCEL_TO_DECEL one and 0.13
// derives the cruise ratio from the acceleration before the change.
DynamicPrintConfig config = wipe_tower_toolchange_config("klipper");
// Type 2 towers leave the acceleration to the main G-code writer.
config.set_deserialize_strict({ { "wipe_tower_type", "type1" },
{ "accel_to_decel_enable", "1" }, { "accel_to_decel_factor", "50%" },
{ "default_acceleration", "1000" }, { "travel_acceleration", "2000" },
{ "machine_max_acceleration_extruding", "5000" } });
const std::string tower = wipe_tower_regions(slice_with_prime_tower(config));
REQUIRE_THAT(tower, Catch::Matchers::ContainsSubstring("SET_VELOCITY_LIMIT ACCEL="));
CHECK_THAT(tower, Catch::Matchers::ContainsSubstring(" MINIMUM_CRUISE_RATIO=0.5\n"));
CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring("SET_VELOCITY_LIMIT ACCEL_TO_DECEL") &&
!Catch::Matchers::ContainsSubstring("M204"));
}
// What Print feeds the shared estimate. The libslic3r WipeTowerEstimate cases cannot see this:
// they call the estimator directly. The estimate counts the filaments the print really uses,
// so the two-filament shape gives the outer wall the second one.
+8 -1
View File
@@ -1,5 +1,9 @@
get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
# Observe the geometry backend's actual CGAL layout without duplicating its flags.
# This source is only added when the test suite is enabled.
target_sources(libslic3r_cgal PRIVATE texture_import_cgal.cpp)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests.cpp
test_3mf.cpp
@@ -31,11 +35,12 @@ add_executable(${_TEST_NAME}_tests
test_preset_diff.cpp
test_vendor_cache.cpp
test_preset_options.cpp
test_edge_grid.cpp
test_elephant_foot_compensation.cpp
test_fill_corner_smoothing.cpp
test_filament_mixer.cpp
test_fill_plane_path.cpp
test_fill_tpms_adaptive.cpp
test_adaptive_infill_field.cpp
test_geometry.cpp
test_imex_arrange.cpp
test_imex_helpers.cpp
@@ -67,10 +72,12 @@ add_executable(${_TEST_NAME}_tests
test_wipe_path.cpp
test_optimizers.cpp
test_ordering_strategies.cpp
test_shortest_path.cpp
# test_png_io.cpp
test_indexed_triangle_set.cpp
test_connected_bodies.cpp
test_texture_displacement.cpp
test_texture_import.cpp
test_instance_lock.cpp
../libnest2d/printer_parts.cpp
)
@@ -13,7 +13,7 @@
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Fill/FillTpmsAdaptive.hpp"
#include "libslic3r/Fill/AdaptiveInfillField.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/PrintConfig.hpp"
@@ -30,24 +30,24 @@ ExPolygon rectangle(double x0, double y0, double x1, double y1)
}
// The expolygons stacked in 0.2 mm layers from z = 0 to height.
TpmsRadialField radial_field(const ExPolygons &expolygons, double height, TpmsAdaptiveMode mode = TpmsAdaptiveMode::Lobes)
AdaptiveInfillField radial_field(const ExPolygons &expolygons, double height, AdaptiveInfillMode mode = AdaptiveInfillMode::Lobes)
{
std::vector<TpmsRadialField::Slice> slices;
std::vector<AdaptiveInfillField::Slice> slices;
for (int i = 0; 0.2 * (i + 1) < height + EPSILON; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &expolygons});
return TpmsRadialField(slices, get_extents(expolygons), mode, [] {});
return AdaptiveInfillField(slices, get_extents(expolygons), mode, [] {});
}
double radial(const TpmsRadialField &field, const Vec3d &pt)
double radial(const AdaptiveInfillField &field, const Vec3d &pt)
{
TpmsRadialField::Radials radials;
AdaptiveInfillField::Radials radials;
field.radial(pt, radials);
return radials[0].t;
}
Vec3d center(const TpmsRadialField &field, const Vec3d &pt)
Vec3d center(const AdaptiveInfillField &field, const Vec3d &pt)
{
TpmsRadialField::Radials radials;
AdaptiveInfillField::Radials radials;
field.radial(pt, radials);
return radials[0].center;
}
@@ -57,11 +57,11 @@ constexpr double Tolerance = 0.075;
} // namespace
TEST_CASE("TPMS radial field is zero at the center of a cube and one at its faces", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field is zero at the center of a cube and one at its faces", "[AdaptiveInfillField]")
{
// A 20 mm cube, 10 mm from its center to every face.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 20.);
const AdaptiveInfillField field = radial_field(square, 20.);
const Vec3d c = center(field, {10., 10., 10.});
CHECK_THAT(c.x(), WithinAbs(10., 0.5));
@@ -74,10 +74,10 @@ TEST_CASE("TPMS radial field is zero at the center of a cube and one at its face
}
}
TEST_CASE("TPMS radial field grows linearly from the center of a cube to its faces", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field grows linearly from the center of a cube to its faces", "[AdaptiveInfillField]")
{
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 20.);
const AdaptiveInfillField field = radial_field(square, 20.);
for (double d = 1.; d < 10.; d += 1.) {
CAPTURE(d);
CHECK_THAT(radial(field, {10. - d, 10., 10.}), WithinAbs(d / 10., Tolerance));
@@ -85,11 +85,11 @@ TEST_CASE("TPMS radial field grows linearly from the center of a cube to its fac
}
}
TEST_CASE("TPMS radial field of a tall box is centered at its middle height", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field of a tall box is centered at its middle height", "[AdaptiveInfillField]")
{
// 20 x 20 x 60 mm: 10 mm from the center to the sides, 30 mm to the top and the bottom.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 60.);
const AdaptiveInfillField field = radial_field(square, 60.);
CHECK_THAT(center(field, {10., 10., 45.}).z(), WithinAbs(30., 0.5));
CHECK_THAT(radial(field, {10., 10., 15.}), WithinAbs(0.5, Tolerance));
@@ -97,10 +97,10 @@ TEST_CASE("TPMS radial field of a tall box is centered at its middle height", "[
CHECK_THAT(radial(field, {15., 10., 30.}), WithinAbs(0.5, Tolerance));
}
TEST_CASE("TPMS radial field grades every body towards its own center", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field grades every body towards its own center", "[AdaptiveInfillField]")
{
const ExPolygons squares{rectangle(0., 0., 20., 20.), rectangle(30., 0., 50., 20.)};
const TpmsRadialField field = radial_field(squares, 20.);
const AdaptiveInfillField field = radial_field(squares, 20.);
CHECK_THAT(center(field, {5., 10., 10.}).x(), WithinAbs(10., 0.5));
CHECK_THAT(center(field, {45., 10., 10.}).x(), WithinAbs(40., 0.5));
@@ -108,20 +108,20 @@ TEST_CASE("TPMS radial field grades every body towards its own center", "[FillTp
CHECK_THAT(radial(field, {30., 10., 10.}), WithinAbs(1., Tolerance));
}
TEST_CASE("TPMS radial field is beyond one outside of the object", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field is beyond one outside of the object", "[AdaptiveInfillField]")
{
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 20.);
const AdaptiveInfillField field = radial_field(square, 20.);
CHECK(radial(field, {-5., 10., 10.}) > 1.);
CHECK(radial(field, {10., 10., 30.}) > 1.);
}
TEST_CASE("TPMS radial field grades every lobe of a body towards its own center", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field grades every lobe of a body towards its own center", "[AdaptiveInfillField]")
{
// Two spheres of 10 mm united, their centers 16 mm apart: the neck between them is 6 mm deep.
const Vec3d c1(10., 10., 10.), c2(26., 10., 10.);
std::vector<ExPolygons> layers;
std::vector<TpmsRadialField::Slice> slices;
std::vector<AdaptiveInfillField::Slice> slices;
for (int i = 0; i < 100; ++i) {
const double z = 0.2 * i + 0.1, r = std::sqrt(std::max(0., 100. - sqr(z - 10.)));
Polygons circles;
@@ -134,7 +134,7 @@ TEST_CASE("TPMS radial field grades every lobe of a body towards its own center"
}
for (int i = 0; i < 100; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &layers[i]});
const TpmsRadialField field(slices, get_extents(layers[50]), TpmsAdaptiveMode::Lobes, [] {});
const AdaptiveInfillField field(slices, get_extents(layers[50]), AdaptiveInfillMode::Lobes, [] {});
for (const Vec3d &c : {c1, c2}) {
CAPTURE(c.x());
@@ -143,7 +143,7 @@ TEST_CASE("TPMS radial field grades every lobe of a body towards its own center"
CHECK_THAT(radial(field, c + Vec3d(0., 0., 9.5)), WithinAbs(1., 2. * Tolerance));
}
// The side between the lobes is half way to the surface, where both patterns morph into each other.
TpmsRadialField::Radials radials;
AdaptiveInfillField::Radials radials;
REQUIRE(field.radial(0.5 * (c1 + c2), radials) == 2);
for (size_t i = 0; i < 2; ++i) {
CHECK_THAT(radials[i].t, WithinAbs(0.5, 2. * Tolerance));
@@ -151,12 +151,12 @@ TEST_CASE("TPMS radial field grades every lobe of a body towards its own center"
}
}
TEST_CASE("TPMS radial field blends the lobes meeting at a junction continuously", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field blends the lobes meeting at a junction continuously", "[AdaptiveInfillField]")
{
// Three spheres of 10 mm united, their centers on a triangle of 16 mm sides: the necks meet at its middle.
const std::array<Vec3d, 3> centers{Vec3d(10., 10., 10.), Vec3d(26., 10., 10.), Vec3d(18., 10. + 8. * std::sqrt(3.), 10.)};
std::vector<ExPolygons> layers;
std::vector<TpmsRadialField::Slice> slices;
std::vector<AdaptiveInfillField::Slice> slices;
for (int i = 0; i < 100; ++i) {
const double z = 0.2 * i + 0.1, r = std::sqrt(std::max(0., 100. - sqr(z - 10.)));
Polygons circles;
@@ -169,7 +169,7 @@ TEST_CASE("TPMS radial field blends the lobes meeting at a junction continuously
}
for (int i = 0; i < 100; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &layers[i]});
const TpmsRadialField field(slices, get_extents(layers[50]), TpmsAdaptiveMode::Lobes, [] {});
const AdaptiveInfillField field(slices, get_extents(layers[50]), AdaptiveInfillMode::Lobes, [] {});
// Around the junction the nearest lobes swap, but the weight of every lobe changes smoothly.
const Vec3d junction = (centers[0] + centers[1] + centers[2]) / 3.;
@@ -179,7 +179,7 @@ TEST_CASE("TPMS radial field blends the lobes meeting at a junction continuously
for (int row = 0; row <= 100; ++row) {
std::array<float, 3> previous{};
for (int step = 0; step <= 200; ++step) {
TpmsRadialField::Radials radials;
AdaptiveInfillField::Radials radials;
const size_t count = field.radial(junction + Vec3d(0.01 * step - 1., 0.02 * row - 1., 0.), radials);
max_count = std::max(max_count, count);
std::array<float, 3> weights{};
@@ -201,23 +201,23 @@ TEST_CASE("TPMS radial field blends the lobes meeting at a junction continuously
CHECK(max_jump < 0.05);
}
TEST_CASE("TPMS radial field is empty when the object is thinner than the grid cells", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field is empty when the object is thinner than the grid cells", "[AdaptiveInfillField]")
{
// A 0.3 mm square bar between the nodes of a grid sized by a 200 mm bounding box, with cells of 0.5 mm or more.
const ExPolygons bar{rectangle(0.1, 0.1, 0.4, 0.4)};
std::vector<TpmsRadialField::Slice> slices;
std::vector<AdaptiveInfillField::Slice> slices;
for (int i = 0; i < 1000; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &bar});
const TpmsRadialField field(slices, BoundingBox(Point::new_scale(0., 0.), Point::new_scale(200., 200.)),
TpmsAdaptiveMode::Lobes, [] {});
const AdaptiveInfillField field(slices, BoundingBox(Point::new_scale(0., 0.), Point::new_scale(200., 200.)),
AdaptiveInfillMode::Lobes, [] {});
CHECK(field.empty());
}
TEST_CASE("TPMS depth follows the distance to the surface relative to the deepest point", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill depth follows the distance to the surface relative to the deepest point", "[AdaptiveInfillField]")
{
// 20 x 20 x 60 mm: from 10 to 50 mm high the axis is 10 mm deep, as deep as the center.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 60., TpmsAdaptiveMode::SmoothBlend);
const AdaptiveInfillField field = radial_field(square, 60., AdaptiveInfillMode::SmoothBlend);
for (double z : {15., 30., 45.}) {
CAPTURE(z);
CHECK_THAT(field.depth({10., 10., z}), WithinAbs(1., Tolerance));
@@ -227,18 +227,18 @@ TEST_CASE("TPMS depth follows the distance to the surface relative to the deepes
CHECK_THAT(field.depth({0., 10., 30.}), WithinAbs(0., Tolerance));
}
TEST_CASE("TPMS radial field in Distance warp mode follows the distance to the surface", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field in Distance warp mode follows the distance to the surface", "[AdaptiveInfillField]")
{
// In a cube the depth falls linearly along every ray from the center, so Distance warp matches Lobes.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField cube = radial_field(square, 20., TpmsAdaptiveMode::DistanceWarp);
const AdaptiveInfillField cube = radial_field(square, 20., AdaptiveInfillMode::DistanceWarp);
CHECK_THAT(radial(cube, {10., 10., 10.}), WithinAbs(0., Tolerance));
CHECK_THAT(radial(cube, {15., 10., 10.}), WithinAbs(0.5, Tolerance));
CHECK_THAT(radial(cube, {10., 10., 20.}), WithinAbs(1., Tolerance));
// 20 x 20 x 60 mm: Lobes grades the axis towards the top and the bottom, Distance warp keeps it deep.
const TpmsRadialField lobes = radial_field(square, 60.);
const TpmsRadialField warp = radial_field(square, 60., TpmsAdaptiveMode::DistanceWarp);
const AdaptiveInfillField lobes = radial_field(square, 60.);
const AdaptiveInfillField warp = radial_field(square, 60., AdaptiveInfillMode::DistanceWarp);
for (double z : {15., 45.}) {
CAPTURE(z);
CHECK_THAT(radial(lobes, {10., 10., z}), WithinAbs(0.5, Tolerance));
@@ -247,12 +247,12 @@ TEST_CASE("TPMS radial field in Distance warp mode follows the distance to the s
CHECK_THAT(radial(warp, {0., 10., 30.}), WithinAbs(1., Tolerance));
}
TEST_CASE("TPMS stepped shells split a layer by depth from the surface inwards", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill stepped shells split a layer by depth from the surface inwards", "[AdaptiveInfillField]")
{
// The middle layer of a 40 mm cube, 20 mm from its center to every face, 20% at the surface to 5% inside.
const ExPolygons square{rectangle(0., 0., 40., 40.)};
const TpmsRadialField field = radial_field(square, 40., TpmsAdaptiveMode::SteppedShells);
const std::vector<TpmsShell> shells = make_tpms_shells(field, square.front(), 20., 0.2f, 0.05f, TpmsAdaptiveGradient::Linear);
const AdaptiveInfillField field = radial_field(square, 40., AdaptiveInfillMode::SteppedShells);
const std::vector<AdaptiveInfillShell> shells = make_adaptive_infill_shells(field, square.front(), 20., 0.2f, 0.05f, AdaptiveInfillGradient::Linear);
REQUIRE(shells.size() == 5);
CHECK_THAT(shells.front().density, WithinAbs(0.2, 1e-6));
CHECK_THAT(shells.back().density, WithinAbs(0.05, 1e-6));
@@ -270,11 +270,11 @@ TEST_CASE("TPMS stepped shells split a layer by depth from the surface inwards",
[&center](const ExPolygon &expolygon) { return expolygon.contains(center); }));
}
TEST_CASE("TPMS radial field in 2D grades every section normal to the axis on its own", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field in 2D grades every section normal to the axis on its own", "[AdaptiveInfillField]")
{
// 20 x 20 x 60 mm: every section normal to Z is 10 mm from its center to the sides, whatever its height.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField normal_z = radial_field(square, 60., TpmsAdaptiveMode::NormalZ);
const AdaptiveInfillField normal_z = radial_field(square, 60., AdaptiveInfillMode::NormalZ);
for (double z : {5., 30., 55.}) {
CAPTURE(z);
CHECK_THAT(radial(normal_z, {10., 10., z}), WithinAbs(0., Tolerance));
@@ -283,7 +283,7 @@ TEST_CASE("TPMS radial field in 2D grades every section normal to the axis on it
}
// Normal to X, the sections are 20 x 60 mm: 10 mm from the center to the sides, 30 mm to the top and the bottom.
const TpmsRadialField normal_x = radial_field(square, 60., TpmsAdaptiveMode::NormalX);
const AdaptiveInfillField normal_x = radial_field(square, 60., AdaptiveInfillMode::NormalX);
for (double x : {3., 10., 17.}) {
CAPTURE(x);
CHECK_THAT(radial(normal_x, {x, 15., 30.}), WithinAbs(0.5, Tolerance));
+134
View File
@@ -1263,6 +1263,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
}
TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") {
using Catch::Matchers::WithinAbs;
SECTION("float") {
ConfigOptionFloat opt(7.5);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9));
}
SECTION("int") {
ConfigOptionInt opt(3);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK(opt.value == 3);
}
SECTION("percent") {
ConfigOptionPercent opt(40.);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(40., 1e-9));
}
SECTION("float or percent") {
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9));
CHECK_FALSE(opt.percent);
}
SECTION("point") {
ConfigOptionPoint opt(Vec2d(1., 2.));
REQUIRE_FALSE(opt.deserialize("5,abc"));
CHECK(opt.value == Vec2d(1., 2.));
}
SECTION("point3") {
ConfigOptionPoint3 opt(Vec3d(1., 2., 3.));
REQUIRE_FALSE(opt.deserialize("5,6,abc"));
CHECK(opt.value == Vec3d(1., 2., 3.));
}
}
TEST_CASE("A scalar option still takes a valid value", "[Config]") {
using Catch::Matchers::WithinAbs;
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE(opt.deserialize("50%"));
CHECK_THAT(opt.value, WithinAbs(50., 1e-9));
CHECK(opt.percent);
ConfigOptionPoint point;
REQUIRE(point.deserialize("3x4"));
CHECK(point.value == Vec2d(3., 4.));
}
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
Slic3r::DynamicPrintConfig config;
t_config_option_keys extra, keys;
@@ -1457,6 +1506,19 @@ TEST_CASE("min_object_distance yields no floor when an FFF config lacks the opti
}
}
TEST_CASE("handle_legacy migrates the adaptive TPMS keys", "[Config]") {
ConfigSubstitutionContext ctxt(ForwardCompatibilitySubstitutionRule::Enable);
DynamicPrintConfig config;
config.set_deserialize("tpms_adaptive", "stepped_shells", ctxt);
config.set_deserialize("tpms_interior_density", "12%", ctxt);
config.set_deserialize("tpms_adaptive_gradient", "quadratic", ctxt);
CHECK(config.opt_enum<AdaptiveInfillMode>("adaptive_infill_mode") == AdaptiveInfillMode::SteppedShells);
CHECK(config.option<ConfigOptionPercent>("adaptive_infill_interior_density")->value == Catch::Approx(12.));
CHECK(config.opt_enum<AdaptiveInfillGradient>("adaptive_infill_gradient") == AdaptiveInfillGradient::Quadratic);
CHECK(ctxt.substitutions.empty());
}
TEST_CASE("handle_legacy migrates every IDEX/IQEX key testers already have", "[Config]") {
// Three eras of saved profiles: the feature shipped as iXex (is_ixex + ixex_*), the clearance
// pair was renamed to say what it measures, then the whole prefix became imex_. Driven from the
@@ -1665,3 +1727,75 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con
}
CHECK(enum_lists > 0);
}
TEST_CASE("A disabled filament override survives the merge with a parent that sets it", "[Config][Variant]")
{
std::set<std::string> no_keys;
Slic3r::DynamicPrintConfig parent;
parent.set_deserialize_strict("filament_retract_before_wipe", "100%");
parent.set_deserialize_strict("filament_ironing_flow", "15%");
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
child.set_deserialize_strict("filament_ironing_flow", "nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil");
CHECK(parent.opt_serialize("filament_ironing_flow") == "nil");
}
SCENARIO("A two-variant child's disabled override column is matched to the parent's variant by name", "[Config][Variant]")
{
std::set<std::string> no_keys;
GIVEN("A two-variant filament parent") {
Slic3r::DynamicPrintConfig parent;
parent.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
parent.set_deserialize_strict("filament_retract_before_wipe", "100%,100%");
parent.set_deserialize_strict("filament_flow_ratio", "1,1");
WHEN("the child disables the override on one variant and leaves a plain option nil on it") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
child.set_deserialize_strict("filament_retract_before_wipe", "nil,100%");
child.set_deserialize_strict("filament_flow_ratio", "nil,1.1");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("the override column is disabled while the plain option's nil column inherits") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
}
}
WHEN("the child lists the same variants in the other order") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive High Flow;Direct Drive Standard");
child.set_deserialize_strict("filament_retract_before_wipe", "70%,nil");
child.set_deserialize_strict("filament_flow_ratio", "1.1,nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("columns are matched by variant name, the disabled one included") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,70%");
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
}
}
WHEN("the child was saved with a single variant") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard");
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("its one value applies to every variant of that extruder, as slicing does") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,nil");
}
}
}
}
TEST_CASE("Every listed filament override is nullable", "[Config]")
{
std::set<std::string> overrides(Slic3r::filament_extruder_override_keys.begin(), Slic3r::filament_extruder_override_keys.end());
overrides.insert({ "filament_retract_length_nc", "filament_ironing_flow", "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed" });
for (const auto &[key, def] : Slic3r::print_config_def.options) {
INFO(key);
CHECK(Slic3r::is_filament_extruder_override_key(key) == (overrides.count(key) == 1));
if (overrides.count(key) == 1)
CHECK(def.nullable);
}
}
+33
View File
@@ -0,0 +1,33 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "catch2/matchers/catch_matchers.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/EdgeGrid.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/libslic3r.h"
using namespace Slic3r;
TEST_CASE("EdgeGrid propagates signed distances across the interior and exterior", "[EdgeGrid][Regression]")
{
const Polygon rectangle {Point::new_scale(-10., -12.), Point::new_scale(10., -12.),
Point::new_scale(10., 12.), Point::new_scale(-10., 12.)};
const Polygons outlines {rectangle};
const BoundingBox bounds(Point::new_scale(-30., -32.), Point::new_scale(30., 32.));
EdgeGrid::Grid grid(bounds);
grid.create(outlines, scale_(1.));
// Distances beyond the initialized edge band require backward propagation.
// With -fsanitize=shift, this also checks negative neighbour offsets.
grid.calculate_sdf();
const auto distance = [&](double x, double y) {
return grid.signed_distance_bilinear(Point::new_scale(x, y)) * SCALING_FACTOR;
};
REQUIRE_THAT(distance(0., 0.), Catch::Matchers::WithinAbs(-10., 0.01));
for (double sign : {-1., 1.}) {
REQUIRE_THAT(distance(sign * 5., 0.), Catch::Matchers::WithinAbs(-5., 0.01));
REQUIRE_THAT(distance(0., sign * 7.), Catch::Matchers::WithinAbs(-5., 0.01));
REQUIRE_THAT(distance(sign * 20., 0.), Catch::Matchers::WithinAbs(10., 0.01));
REQUIRE_THAT(distance(0., sign * 22.), Catch::Matchers::WithinAbs(10., 0.01));
}
}
@@ -1,4 +1,5 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/catch_all.hpp>
#include <vector>
#include <cstddef>
@@ -12,10 +13,13 @@
#include "libslic3r/Point.hpp"
#include "libslic3r/GCode/OrderingStrategies.hpp"
#include "libslic3r/ShortestPath.hpp"
#include "libslic3r/Geometry.hpp"
#include <algorithm>
#include <unordered_set>
#include <numeric>
#include <random>
#include <catch2/catch_approx.hpp>
using namespace Slic3r;
@@ -304,3 +308,60 @@ TEST_CASE("snake_core handles three points", "[Snake]") {
REQUIRE(is_permutation(p2, 3));
}
// The shortest cycle through the points, trying every order.
static double shortest_cycle_length(const Points& centers)
{
std::vector<size_t> path(centers.size());
std::iota(path.begin(), path.end(), 0);
double best = std::numeric_limits<double>::max();
do {
best = std::min(best, tsp_cycle_path_length(path, centers));
} while (std::next_permutation(path.begin() + 1, path.end()));
return best;
}
static Points random_centers(unsigned seed, size_t count)
{
std::mt19937 rng(seed);
std::uniform_real_distribution<> coordinate(0., 200.);
Points centers;
for (size_t i = 0; i < count; ++i) {
// One draw per statement, as the evaluation order of function arguments is unspecified.
const double x = coordinate(rng);
const double y = coordinate(rng);
centers.emplace_back(Point::new_scale(x, y));
}
return centers;
}
TEST_CASE("tsp_exact_cycle finds the shortest cycle", "[TSPPostProcessing]") {
const size_t count = GENERATE(4, 5, 6, 8);
for (unsigned seed = 1; seed <= 10; ++seed) {
const Points centers = random_centers(seed, count);
const std::vector<size_t> path = tsp_exact_cycle(centers);
REQUIRE(is_permutation(path, count));
CHECK(euclidean_path_length(path, centers) <= shortest_cycle_length(centers) + SCALED_EPSILON);
}
}
TEST_CASE("Default ordering tours up to ten points along the shortest cycle", "[TSPPostProcessing]") {
const bool from_start = GENERATE(false, true);
const Point start = Point::new_scale(-20., -20.);
for (unsigned seed = 1; seed <= 5; ++seed) {
const Points centers = random_centers(seed, 9);
const std::vector<size_t> path = chain_points_with_postprocessing(centers, from_start ? &start : nullptr);
REQUIRE(is_permutation(path, centers.size()));
CHECK(euclidean_path_length(path, centers) <= shortest_cycle_length(centers) + SCALED_EPSILON);
}
}
TEST_CASE("Default ordering of many points is never longer than the greedy cycle", "[TSPPostProcessing]") {
for (unsigned seed = 1; seed <= 10; ++seed) {
const Points centers = random_centers(seed, 150);
const std::vector<size_t> path = chain_points_with_postprocessing(centers, nullptr);
REQUIRE(is_permutation(path, centers.size()));
CHECK(euclidean_path_length(path, centers) <= euclidean_path_length(chain_points(centers, nullptr), centers) + SCALED_EPSILON);
CHECK_FALSE(has_crossings(path, centers));
}
}
@@ -8417,3 +8417,58 @@ TEST_CASE("An empty choice list in a G-code's settings keeps the value it had be
CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow");
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty());
}
TEST_CASE("A disabled filament override is saved and reloaded column by column", "[Preset][Bundle]")
{
ScopedPresetDataDir data_dir;
PresetBundle bundle;
Preset &parent = add_inmemory_preset(bundle.filaments, "Parent PLA @System");
parent.config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = { "Direct Drive Standard", "Direct Drive High Flow" };
parent.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values = { 100., 100. };
parent.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1., 1. };
// The child turns the override off on the first variant and changes a plain option on the second.
const fs::path file = data_dir.tmp.path() / PRESET_FILAMENT_NAME / "Child PLA.json";
Preset child(Preset::TYPE_FILAMENT, "Child PLA");
child.file = file.string();
child.version = *Semver::parse("1.0.0");
child.config = parent.config;
child.config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = parent.name;
child.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe")->set_at_to_nil(0);
child.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio")->values = { 1., 1.1 };
REQUIRE(child.save(&parent.config));
// On disk the override keeps every column, so its nil can only mean "off"; a plain
// option still stores nil for the columns that match the parent.
DynamicPrintConfig saved;
std::map<std::string, std::string> key_values;
std::string reason;
saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason);
REQUIRE(reason.empty());
CHECK(saved.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(saved.opt_serialize("filament_flow_ratio") == "nil,1.1");
PresetsConfigSubstitutions substitutions;
bundle.filaments.load_presets(data_dir.tmp.path().string(), PRESET_FILAMENT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::EnableSilent);
const Preset *loaded = bundle.filaments.find_preset("Child PLA");
REQUIRE(loaded != nullptr);
CHECK(loaded->config.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(loaded->config.opt_serialize("filament_flow_ratio") == "1,1.1");
}
TEST_CASE("The cloud diff force-emits a disabled override only where the cloud would fill a value in", "[Preset][Bundle]")
{
PresetBundle bundle;
add_inmemory_preset(bundle.filaments, "Parent PLA @System");
Preset &child = add_inmemory_preset(bundle.filaments, "Child PLA", "Parent PLA @System");
// Every override is nil in both parent and child, so none is in the plain diff.
std::map<std::string, std::string> key_values;
REQUIRE(bundle.filaments.get_differed_values_to_update(child, key_values) >= 0);
// Retract Before Wipe defaults to 100%: an absent key would come back as 100%, so nil is sent.
CHECK(key_values.count("filament_retract_before_wipe") == 1);
CHECK(key_values["filament_retract_before_wipe"] == "nil");
// An ironing override defaults to nil: an absent key stays nil, so it is not sent.
CHECK(key_values.count("filament_ironing_flow") == 0);
}
+202
View File
@@ -0,0 +1,202 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Polyline.hpp"
#include "libslic3r/ShortestPath.hpp"
#include "libslic3r/libslic3r.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <limits>
#include <map>
#include <numeric>
#include <math.h>
#include <random>
#include <utility>
#include <vector>
using namespace Slic3r;
namespace {
// Infill-like paths: lines of 1 to 30 mm scattered over a 100 mm square. The collection owns them, and entities hands
// them out as the raw pointers chain_and_reorder_extrusion_entities() reorders.
struct Paths
{
ExtrusionEntityCollection collection;
std::vector<ExtrusionEntity*> entities;
Paths(unsigned seed, size_t count, bool can_reverse)
{
std::mt19937 rng(seed);
std::uniform_real_distribution<> coordinate(0., 100.), length(1., 30.), angle(0., 2. * M_PI);
Polylines lines;
for (size_t i = 0; i < count; ++ i) {
// One draw per statement, as the evaluation order of function arguments is unspecified.
const double x = coordinate(rng);
const double y = coordinate(rng);
const double l = length(rng);
const double t = angle(rng);
const Vec2d a(x, y);
const Vec2d b = a + l * Vec2d(std::cos(t), std::sin(t));
lines.emplace_back(Point::new_scale(a), Point::new_scale(b));
}
extrusion_entities_append_paths(collection.entities, std::move(lines), erInternalInfill, 0.1, 0.45f, 0.2f, can_reverse);
entities = collection.entities;
}
};
double travel(const std::vector<ExtrusionEntity*> &entities, const Point &start)
{
double length = 0.;
Point last = start;
for (const ExtrusionEntity *ee : entities) {
length += (ee->first_point() - last).cast<double>().norm();
last = ee->last_point();
}
return length;
}
// Travel of the chain that always goes on to the nearest free path end.
double nearest_next_travel(const std::vector<ExtrusionEntity*> &entities, bool can_reverse, const Point &from)
{
std::vector<char> taken(entities.size(), 0);
double length = 0.;
Point last = from;
for (size_t step = 0; step < entities.size(); ++ step) {
double best = std::numeric_limits<double>::max();
size_t best_idx = 0;
bool best_reversed = false;
for (size_t i = 0; i < entities.size(); ++ i)
for (const bool reversed : { false, true })
if (! taken[i] && (can_reverse || ! reversed)) {
const double d = ((reversed ? entities[i]->last_point() : entities[i]->first_point()) - last).cast<double>().norm();
if (d < best) {
best = d;
best_idx = i;
best_reversed = reversed;
}
}
taken[best_idx] = 1;
length += best;
last = best_reversed ? entities[best_idx]->first_point() : entities[best_idx]->last_point();
}
return length;
}
// The shortest travel over every order and allowed reversal of the paths, from start.
double shortest_travel(const std::vector<ExtrusionEntity*> &entities, bool can_reverse, const Point &from)
{
std::vector<size_t> order(entities.size());
std::iota(order.begin(), order.end(), 0);
double best = std::numeric_limits<double>::max();
do {
for (size_t mask = 0; mask < (size_t(1) << order.size()) && (can_reverse || mask == 0); ++ mask) {
double length = 0.;
Point last = from;
for (size_t k = 0; k < order.size(); ++ k) {
const ExtrusionEntity *ee = entities[order[k]];
const bool reversed = (mask >> k & 1) != 0;
length += ((reversed ? ee->last_point() : ee->first_point()) - last).cast<double>().norm();
last = reversed ? ee->first_point() : ee->last_point();
}
best = std::min(best, length);
}
} while (std::next_permutation(order.begin(), order.end()));
return best;
}
const Point start = Point::new_scale(-10., -10.);
} // namespace
TEST_CASE("An improved infill chain is never longer than the greedy or nearest-next one and keeps every path", "[ShortestPath]")
{
const size_t count = GENERATE(2, 3, 10, 60, 300);
const bool can_reverse = GENERATE(true, false);
double greedy_total = 0., improved_total = 0.;
for (unsigned seed = 1; seed <= 20; ++ seed) {
Paths greedy(seed, count, can_reverse), improved(seed, count, can_reverse);
const double nearest_travel = nearest_next_travel(improved.entities, can_reverse, start);
chain_and_reorder_extrusion_entities(greedy.entities, &start);
chain_and_reorder_extrusion_entities(improved.entities, start);
std::vector<ExtrusionEntity*> sorted = improved.entities;
std::sort(sorted.begin(), sorted.end());
REQUIRE(std::unique(sorted.begin(), sorted.end()) == sorted.end());
REQUIRE(sorted.size() == count);
const double greedy_travel = travel(greedy.entities, start), improved_travel = travel(improved.entities, start);
CHECK(improved_travel <= greedy_travel + SCALED_EPSILON);
CHECK(improved_travel <= nearest_travel + SCALED_EPSILON);
greedy_total += greedy_travel;
improved_total += improved_travel;
}
// Twenty random sets of ten or more paths always leave the greedy chain something to shorten.
if (count >= 10)
CHECK(improved_total < 0.95 * greedy_total);
}
TEST_CASE("Improving an infill chain keeps the direction of paths that cannot reverse", "[ShortestPath]")
{
Paths paths(7, 100, false);
std::map<const ExtrusionEntity*, Point> first_points;
for (const ExtrusionEntity *ee : paths.entities)
first_points.emplace(ee, ee->first_point());
chain_and_reorder_extrusion_entities(paths.entities, start);
for (const ExtrusionEntity *ee : paths.entities)
CHECK(ee->first_point() == first_points.at(ee));
}
TEST_CASE("Chains of a few paths are the shortest possible", "[ShortestPath]")
{
const size_t count = GENERATE(2, 3, 4, 5, 6);
const bool can_reverse = GENERATE(true, false);
for (unsigned seed = 1; seed <= 10; ++ seed) {
Paths paths(seed, count, can_reverse);
const double shortest = shortest_travel(paths.entities, can_reverse, start);
chain_and_reorder_extrusion_entities(paths.entities, start);
CHECK(travel(paths.entities, start) <= shortest + SCALED_EPSILON);
}
}
TEST_CASE("The greedy chain of two to four paths is the shortest possible", "[ShortestPath]")
{
const size_t count = GENERATE(2, 3, 4);
const bool can_reverse = GENERATE(true, false);
for (unsigned seed = 1; seed <= 10; ++ seed) {
Paths paths(seed, count, can_reverse);
const double shortest = shortest_travel(paths.entities, can_reverse, start);
chain_and_reorder_extrusion_entities(paths.entities, &start);
CHECK(travel(paths.entities, start) <= shortest + SCALED_EPSILON);
}
}
TEST_CASE("A short chain need not start with the path nearest to its start", "[ShortestPath]")
{
// The nearest end leads 10 mm away from the two other paths, so the shortest chain leaves that path for last.
const Point from = Point::new_scale(0., 0.);
for (const bool greedy : { false, true }) {
ExtrusionEntityCollection collection;
extrusion_entities_append_paths(collection.entities,
Polylines{ Polyline(Point::new_scale(-0.9, 0.), Point::new_scale(-10., 0.)), Polyline(Point::new_scale(1., 0.), Point::new_scale(2., 0.)),
Polyline(Point::new_scale(3., 1.), Point::new_scale(4., 1.)) },
erInternalInfill, 0.1, 0.45f, 0.2f);
std::vector<ExtrusionEntity*> entities = collection.entities;
const double shortest = shortest_travel(entities, true, from);
if (greedy)
chain_and_reorder_extrusion_entities(entities, &from);
else
chain_and_reorder_extrusion_entities(entities, from);
CHECK(entities.back() == collection.entities.front());
CHECK_THAT(travel(entities, from), Catch::Matchers::WithinAbs(shortest, SCALED_EPSILON));
}
}
+76
View File
@@ -0,0 +1,76 @@
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include "libslic3r/Model.hpp"
#include "libslic3r/TexturePainting.hpp"
#include "texture_import_cgal.hpp"
#include <algorithm>
#include <array>
#include <string>
#include <vector>
namespace {
void check_texture_import(const std::string& fixture)
{
const std::string filename = std::string(TEST_DATA_DIR) + "/texture_import/" + fixture;
INFO(filename);
const Slic3r::Model model = Slic3r::Model::read_from_file(filename);
REQUIRE_FALSE(model.objects.empty());
REQUIRE(model.texture_mesh);
const Slic3r::TexturedMesh& mesh = *model.texture_mesh;
Slic3r::TexturePaintingSettings settings;
settings.target_colors_num = 0; // Match the dialog's initial automatic color count.
settings.smooth_weight = 0.5;
settings.mesh_repair_decision = Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair;
Slic3r::PaintedMesh painted;
// Follow the dialog worker's format-dependent path, including the boundary
// smoothing that needs a consistent CGAL Kd_tree layout across libraries.
if (!mesh.precomputed_face_colors.empty())
REQUIRE(Slic3r::face_colors_to_painting(mesh, painted, settings));
else
REQUIRE(Slic3r::texture_to_painting(mesh, painted, settings));
REQUIRE_FALSE(painted.indices.empty());
REQUIRE(painted.face_colors.size() == painted.indices.size());
REQUIRE(std::any_of(painted.face_colors.begin(), painted.face_colors.end(),
[](const auto& color) { return color[0] > color[2]; }));
REQUIRE(std::any_of(painted.face_colors.begin(), painted.face_colors.end(),
[](const auto& color) { return color[2] > color[0]; }));
const std::vector<std::array<float, 4>> filament_colors{
{1.f, 0.f, 0.f, 1.f}, {0.f, 0.f, 1.f, 1.f}};
const auto matches = Slic3r::match_clusters_to_filaments(
painted.cluster_colors, filament_colors, {"Red PLA", "Blue PLA"});
REQUIRE(matches.size() == painted.cluster_colors.size());
REQUIRE(std::any_of(matches.begin(), matches.end(),
[](const auto& match) { return match.filament_index == 0; }));
REQUIRE(std::any_of(matches.begin(), matches.end(),
[](const auto& match) { return match.filament_index == 1; }));
}
} // namespace
TEST_CASE("Color-boundary smoothing shares its CGAL tree layout with the geometry backend", "[TextureImport][CGAL][Regression]")
{
// An ABI mismatch need not crash every link of the importer. Compare the
// layouts directly so a lucky choice of inline symbols cannot mask it.
REQUIRE(sizeof(TextureImportTest::SearchTree) == TextureImportTest::backend_search_tree_size());
}
TEST_CASE("A JPEG textured GLB retains both colors through filament matching", "[TextureImport][Regression]")
{
check_texture_import("two_color_jpeg.glb");
}
TEST_CASE("An OBJ preserves both MTL colors through filament matching", "[TextureImport][OBJ][Regression]")
{
check_texture_import("two_color_sphere/test_two_color_sphere.obj");
}
TEST_CASE("An OBJ resolves its MTL and external JPEG through filament matching", "[TextureImport][OBJ][Regression]")
{
check_texture_import("two_color_sphere/test_two_color_sphere_textured.obj");
}
+8
View File
@@ -0,0 +1,8 @@
#include "texture_import_cgal.hpp"
#include <cstddef>
std::size_t TextureImportTest::backend_search_tree_size()
{
return sizeof(SearchTree);
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <CGAL/AABB_face_graph_triangle_primitive.h>
#include <CGAL/AABB_traits_3.h>
#include <CGAL/Exact_predicates_inexact_constructions_kernel.h>
#include <CGAL/Kd_tree.h>
#include <CGAL/Search_traits_adapter.h>
#include <CGAL/Surface_mesh/Surface_mesh.h>
#include <CGAL/property_map.h>
#include <cstddef>
namespace TextureImportTest {
// The distance-query tree used by ColorUtils.cpp's boundary smoothing. Its
// type name stays the same when CGAL changes its storage for TBB support.
using Kernel = CGAL::Exact_predicates_inexact_constructions_kernel;
using Mesh = CGAL::Surface_mesh<Kernel::Point_3>;
using Primitive = CGAL::AABB_face_graph_triangle_primitive<Mesh>;
using Traits = CGAL::AABB_traits_3<Kernel, Primitive>;
using PointId = Traits::Point_and_primitive_id;
using SearchTraits = CGAL::Search_traits_adapter<PointId, CGAL::First_of_pair_property_map<PointId>, Traits>;
using SearchTree = CGAL::Kd_tree<SearchTraits>;
// Implemented inside the actual geometry target, using its private definitions.
std::size_t backend_search_tree_size();
} // namespace TextureImportTest
+1
View File
@@ -44,6 +44,7 @@ add_executable(${_TEST_NAME}_tests
test_shortcuts.cpp
test_texture_color_palette.cpp
test_texture_island_view.cpp
test_timelapse_codecs.cpp
test_file_url.cpp
test_imgui_wrapper.cpp
test_user_manager.cpp
@@ -81,3 +81,24 @@ TEST_CASE("Payload rewrite covers nested trays, calibration lists and mapping in
CHECK(BBLPrinterAgent::from_orca_payload("not json") == "not json");
CHECK(BBLPrinterAgent::from_orca_payload(R"({"print":{"command":"pushall"}})") == R"({"print":{"command":"pushall"}})");
}
TEST_CASE("Payload rewrite covers the nozzle rack status and the auto nozzle mapping request", "[BambuFilamentIds]")
{
const ScopedRepoResourcesDir repo_resources;
const std::string petg_hf = orca_id_of("GFG96");
const std::string rack_status =
R"({"print":{"device":{"nozzle":{"info":[{"id":16,"fila_id":"GFG96","color_m":"FFFFFFFF"},{"id":17,"fila_id":"P22f54c9"}]}}}})";
json inbound = json::parse(BBLPrinterAgent::to_orca_payload(rack_status));
CHECK(inbound["print"]["device"]["nozzle"]["info"][0]["fila_id"] == petg_hf);
CHECK(inbound["print"]["device"]["nozzle"]["info"][1]["fila_id"] == "P22f54c9"); // user id: untouched
const std::string request = R"({"print":{"command":"get_auto_nozzle_mapping","ams_mapping":[65535,65024],)"
R"("fila_info":[{"id":2,"cate":")" + petg_hf + R"(","color":"FFFFFFFF"}],)"
R"("nozzle_info":[{"pos":16,"cate":")" + petg_hf + R"(","color":"FFFFFFFF"},{"pos":1,"cate":""}]}})";
json outbound = json::parse(BBLPrinterAgent::from_orca_payload(request));
CHECK(outbound["print"]["fila_info"][0]["cate"] == "GFG96");
CHECK(outbound["print"]["nozzle_info"][0]["cate"] == "GFG96");
CHECK(outbound["print"]["nozzle_info"][1]["cate"] == ""); // empty nozzle
CHECK(outbound["print"]["ams_mapping"] == json::parse("[65535,65024]"));
}
@@ -420,12 +420,13 @@ TEST_CASE("Moonraker discovery waits for a complete device identity", "[Moonrake
agent.device_info.dev_id = "127.0.0.1:7125";
agent.device_info.dev_ip = agent.device_info.dev_id;
agent.device_info.base_url = "http://127.0.0.1:7125";
agent.device_info.model_name = "Test model";
agent.start_discovery(true, false);
REQUIRE(announcements == 1);
REQUIRE(agent.fetch_count == 1);
REQUIRE(agent.fetch_count == 0);
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
REQUIRE(announcement.at("dev_name") == "Test printer");
REQUIRE(announcement.at("dev_name") == "Test model");
}
TEST_CASE("Stopping the status stream gives a silent printer no more than its timeouts", "[MoonrakerPrinterAgent][StatusStream]")
+58 -32
View File
@@ -8,11 +8,11 @@
#include <boost/asio/ip/address.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/read_until.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/asio/streambuf.hpp>
#include <boost/asio/write.hpp>
#include <boost/filesystem.hpp>
#include <boost/filesystem/fstream.hpp>
#include <boost/nowide/cstdlib.hpp>
#include <boost/system/error_code.hpp>
#include <atomic>
@@ -23,7 +23,6 @@
#include <istream>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <thread>
@@ -93,7 +92,9 @@ using boost::asio::ip::tcp;
class LoopbackServer
{
public:
enum class Mode { Ok, Unavailable, Silent };
// Session answers every request with the body given to set_session_body(), as the token endpoint
// does, after 200 ms so the client is still waiting when the test makes its next call.
enum class Mode { Ok, Unavailable, Silent, Session };
struct Request
{
@@ -115,6 +116,11 @@ public:
std::string url() const { return "http://127.0.0.1:" + std::to_string(m_acceptor.local_endpoint().port()); }
void set_mode(Mode mode) { m_mode = mode; }
void set_session_body(std::string body)
{
std::lock_guard<std::mutex> lock(m_mutex);
m_session_body = std::move(body);
}
bool wait_for_accepts(size_t count, std::chrono::milliseconds timeout)
{
@@ -174,13 +180,31 @@ private:
const Mode mode = m_mode;
if (mode == Mode::Silent)
return;
const std::string_view response = mode == Mode::Ok ?
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}" :
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
boost::asio::async_write(connection->socket, boost::asio::buffer(response),
[connection](boost::system::error_code, size_t) {
boost::system::error_code ignored;
connection->socket.shutdown(tcp::socket::shutdown_both, ignored);
auto response = std::make_shared<std::string>();
if (mode == Mode::Session) {
std::lock_guard<std::mutex> lock(m_mutex);
*response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " +
std::to_string(m_session_body.size()) + "\r\nConnection: close\r\n\r\n" + m_session_body;
} else {
*response = mode == Mode::Ok ?
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}" :
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
}
auto write = [connection, response] {
boost::asio::async_write(connection->socket, boost::asio::buffer(*response),
[connection, response](boost::system::error_code, size_t) {
boost::system::error_code ignored;
connection->socket.shutdown(tcp::socket::shutdown_both, ignored);
});
};
if (mode != Mode::Session) {
write();
return;
}
auto timer = std::make_shared<boost::asio::steady_timer>(m_io, 200ms);
timer->async_wait([timer, write](boost::system::error_code ec) {
if (!ec)
write();
});
});
}
@@ -194,30 +218,11 @@ private:
std::condition_variable m_cv;
std::vector<Request> m_requests;
std::vector<std::shared_ptr<Connection>> m_connections;
std::string m_session_body;
};
// Sets an environment variable for the lifetime of the guard and restores the previous value.
class ScopedEnvVar
{
public:
ScopedEnvVar(std::string name, const std::string& value) : m_name(std::move(name))
{
if (const char* old = boost::nowide::getenv(m_name.c_str()))
m_previous = old;
boost::nowide::setenv(m_name.c_str(), value.c_str(), 1);
}
~ScopedEnvVar()
{
if (m_previous)
boost::nowide::setenv(m_name.c_str(), m_previous->c_str(), 1);
else
boost::nowide::unsetenv(m_name.c_str());
}
private:
std::string m_name;
std::optional<std::string> m_previous;
};
// A JWT whose only claim is {"exp":4102444800} (2100-01-01); the agent reads nothing else from it.
constexpr const char* FAR_FUTURE_JWT = "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9.sig";
// What the agent reported through its callbacks, recorded on whichever thread delivers it so the
// assertions can run on the test thread.
@@ -540,3 +545,24 @@ TEST_CASE_METHOD(HealthCheckFixture, "The health check does no token work", "[Or
CHECK(agent->is_user_login());
CHECK(agent->get_access_token() == "test-token");
}
TEST_CASE_METHOD(HealthCheckFixture, "A token request made while start() refreshes waits for it instead of refreshing again", "[OrcaCloudServiceAgent]")
{
// A session saved by a previous run: start() refreshes it on its own thread.
agent->persist_user_secret(nlohmann::json{{"refresh_token", "stored-refresh-token"}, {"user_id", "test-user-id"}}.dump());
const std::string access_token = FAR_FUTURE_JWT;
server.set_session_body(nlohmann::json{{"access_token", access_token},
{"refresh_token", "rotated-refresh-token"},
{"user_id", "test-user-id"}}.dump());
server.set_mode(LoopbackServer::Mode::Session);
REQUIRE(agent->start() == BAMBU_NETWORK_SUCCESS);
CHECK(agent->ensure_token_fresh("test"));
CHECK(agent->get_access_token() == access_token);
size_t token_posts = 0;
for (const auto& request : server.requests())
if (boost::starts_with(request.request_line, "POST /auth/v1/token"))
++token_posts;
CHECK(token_posts == 1);
}
+779 -34
View File
@@ -1,23 +1,48 @@
#include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
#include <slic3r/plugin/PluginAuditManager.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "libslic3r_version.h"
#include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <pybind11/cast.h>
#include <pybind11/embed.h>
#include <pybind11/eval.h>
#include <pybind11/gil.h>
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <pybind11/stl.h>
#include <fstream>
#include <functional>
#include <ios>
#include <memory>
#include <string>
#include <utility>
#include <vector>
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace py = pybind11;
namespace {
@@ -350,6 +375,16 @@ TEST_CASE("Plugin audit network permissions match by scheme and host", "[audit]"
CHECK(PluginAuditManager::network_permission_matches(granted, "https://api.example.com:8443/upload"));
}
SECTION("canonical host grants are broad but persisted full-URL grants stay exact")
{
const std::vector<std::string> declared_host{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(declared_host, "https://api.example.com/v1"));
const std::vector<std::string> saved_url{"https://api.example.com/v1"};
CHECK(PluginAuditManager::network_permission_matches(saved_url, "https://api.example.com/v1"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(saved_url, "https://api.example.com/v2"));
}
SECTION("a URL target does not match a different host or a different scheme")
{
const std::vector<std::string> granted{"https://api.example.com"};
@@ -403,40 +438,6 @@ TEST_CASE("Plugin audit network permissions match by scheme and host", "[audit]"
}
}
TEST_CASE("Plugin audit limits network ancestor approvals to resolved addresses", "[audit]")
{
SECTION("IP literals are distinguished from hostnames and URLs")
{
CHECK(PluginAuditManager::is_ip_address_target("127.0.0.1"));
CHECK(PluginAuditManager::is_ip_address_target("93.184.216.34"));
CHECK(PluginAuditManager::is_ip_address_target("::1"));
CHECK(PluginAuditManager::is_ip_address_target("2001:db8::1"));
CHECK(PluginAuditManager::is_ip_address_target("[2001:db8::1]"));
CHECK_FALSE(PluginAuditManager::is_ip_address_target("api.example.com"));
CHECK_FALSE(PluginAuditManager::is_ip_address_target("localhost"));
CHECK_FALSE(PluginAuditManager::is_ip_address_target("https://api.example.com"));
CHECK_FALSE(PluginAuditManager::is_ip_address_target(""));
}
SECTION("a network approval is kept apart from the general call-site cascade")
{
PluginAuditManager& mgr = PluginAuditManager::instance();
const std::string plugin_key = "plugin-audit-network-ancestor";
const std::string other_key = "plugin-audit-network-ancestor-other";
mgr.record_approved_network_call_sites(plugin_key, {"frame:network"});
mgr.record_approved_call_sites(plugin_key, {"frame:general"});
CHECK(mgr.has_approved_network_ancestor(plugin_key, {"frame:network"}));
CHECK_FALSE(mgr.has_approved_network_ancestor(plugin_key, {"frame:general"}));
CHECK_FALSE(mgr.has_approved_network_ancestor(other_key, {"frame:network"}));
// The network approval must not leak into the general cascade used by other categories.
CHECK_FALSE(mgr.has_approved_ancestor(plugin_key, {"frame:network"}));
}
}
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
{
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");
@@ -461,3 +462,747 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
}
}
// ---------------------------------------------------------------------------
// The audit hook, run in the embedded interpreter
// ---------------------------------------------------------------------------
//
// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system
// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their
// cases instead of using SECTIONs, which would restart the interpreter for every section.
namespace {
// Discovered, never loaded: the hook only needs the plugin's root and its sidecar.
const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Audit Plugin"
# description = "Plugin audit hook fixture"
# author = "OrcaSlicer"
# version = "1.0"
# type = "script"
# ///
)PY";
// A third-party library, installed outside the stdlib and outside the plugin.
const char* const LIBRARY_SOURCE = R"PY(
import socket
import urllib.request
def read_all(paths):
for path in paths:
with open(path) as f:
f.read()
def connect_from_host(port):
socket.gethostname()
socket.create_connection(("127.0.0.1", port)).close()
def fetch(url):
with urllib.request.urlopen(url) as response:
return response.read()
def fetch_each(urls):
for url in urls:
yield fetch(url)
class ReadingHandler(urllib.request.BaseHandler):
"""Reads a file while each response is processed."""
def __init__(self, path):
self.path = path
self.outcome = None
def http_response(self, request, response):
try:
open(self.path).close()
self.outcome = "ok"
except PermissionError:
self.outcome = "denied"
return response
)PY";
// Test-side Python, run outside any plugin context: files outside every allowed root, a local
// HTTP server, the library, and every module the tests use imported up front, so that no import
// happens inside a plugin context. Code from exec() runs as "<string>", which is not stdlib, so
// attempt() and fetch() stand in for plugin code.
const char* const AUDIT_SETUP = R"PY(
import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading
import urllib.request, zipfile
if os.name != "nt":
import pty
APPROVED = os.path.join(OUTSIDE, "approved.txt")
PRIVATE = os.path.join(OUTSIDE, "private.txt")
MISSING = os.path.join(OUTSIDE, "missing")
TREE = os.path.join(OUTSIDE, "tree")
TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"]
for path in (APPROVED, PRIVATE):
with open(path, "w") as f:
f.write("x")
for sub in ("one", "two"):
os.makedirs(os.path.join(TREE, sub))
with open(os.path.join(TREE, sub, "f.txt"), "w") as f:
f.write("x")
LIBRARY = os.path.join(OUTSIDE, "library")
os.makedirs(LIBRARY)
with open(os.path.join(LIBRARY, "netlib.py"), "w") as f:
f.write(LIBRARY_SOURCE)
sys.path.insert(0, LIBRARY)
import netlib
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", URL_B)
body = b""
else:
self.send_response(200)
body = b"ok"
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
PORT = server.server_address[1]
BASE = "http://127.0.0.1:%d" % PORT
URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect"
urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({})))
def fetch(url):
with urllib.request.urlopen(url) as response:
return response.read()
fetch(URL_A) # urllib imports some modules on its first request
def attempt(action):
try:
action()
return "ok"
except PermissionError:
return "denied"
def spawn_checked(argv):
parent = os.getpid()
try:
return pty.spawn(argv)
finally:
if os.getpid() != parent:
os._exit(1) # a child whose exec was refused must not go on running the tests
finalizer_outcomes = []
class TrackedRequest(urllib.request.Request):
def __del__(self):
finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close()))
)PY";
struct Prompt
{
std::string plugin;
std::string event;
std::vector<std::string> targets;
};
// One line per prompt, "event: target ...", so a failed check shows every prompt.
std::string describe(const std::vector<Prompt>& prompts)
{
std::string text;
for (const Prompt& prompt : prompts) {
text += prompt.event + ":";
for (const std::string& target : prompt.targets)
text += " " + target;
text += "\n";
}
return text;
}
// The plugin system with the audit hook installed, and the permission dialog replaced by a
// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last.
struct AuditHarness
{
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
// data_dir() is a global allowed root from then on, so the test files live in `outside`.
ScopedDataDir data{"plugin-audit-hook"};
bool initialized = PluginManager::instance().initialize();
ScopedTemporaryDir outside{"orca-plugin-audit-outside"};
std::vector<Prompt> prompts;
std::function<bool(const Prompt&)> approve = [](const Prompt&) { return false; };
AuditHarness()
{
PluginAuditManager::set_permission_prompt_for_testing(
[this](const std::string& event, const std::vector<std::string>& targets) {
prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets});
return approve(prompts.back());
});
}
~AuditHarness()
{
PluginAuditManager::set_permission_prompt_for_testing(nullptr);
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
// Call before taking the GIL: discovery is not Python work.
void add_plugin(const std::string& key)
{
const fs::path root = data.plugins_dir() / key;
fs::create_directories(root);
std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE;
save_permissions(key, {});
PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true);
}
// Grants as if saved in an earlier session.
void save_permissions(const std::string& key, const PluginPermissions& permissions)
{
PluginInstallState state;
state.installed_from = "local";
state.installed_version = "1.0";
state.plugin_name = key;
state.permissions = permissions;
REQUIRE(write_install_state(data.plugins_dir() / key, state));
}
PluginPermissions saved_permissions(const std::string& key) const
{
PluginInstallState state;
read_install_state(data.plugins_dir() / key, state);
return state.permissions;
}
};
// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP
// server before the interpreter shuts down.
struct PythonSide
{
py::dict globals;
explicit PythonSide(const AuditHarness& harness)
{
globals["OUTSIDE"] = harness.outside.path().string();
globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE;
exec(AUDIT_SETUP);
}
// A Python error fails the test here, as text: an error_already_set left to propagate would
// outlive the interpreter the harness shuts down.
void exec(const char* code)
{
std::string error;
try {
py::exec(code, globals);
} catch (py::error_already_set& raised) {
error = raised.what();
}
if (!error.empty())
FAIL(error);
}
~PythonSide()
{
try {
globals["server"].attr("shutdown")();
globals["server"].attr("server_close")();
} catch (...) {
}
}
std::string str(const char* name) const { return py::str(globals[name]).cast<std::string>(); }
std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast<std::string>(); }
std::vector<std::string> strings(const char* name) const { return globals[name].cast<std::vector<std::string>>(); }
// Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an
// audit scope of its own.
void run_as(const std::string& plugin_key, const char* code)
{
ScopedPluginAuditContext audit(plugin_key);
exec(code);
}
};
PluginPermissions http_grants(std::vector<std::string> urls)
{
PluginPermissions permissions;
permissions.network_http = std::move(urls);
return permissions;
}
const char* const PLUGIN = "AuditPlugin";
} // namespace
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")}));
python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))");
CHECK(python.str("outcome") == "ok");
CHECK(describe(harness.prompts).empty());
}
TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")}));
python.run_as(PLUGIN, R"PY(
outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)]
outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)]
outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B]))))
)PY");
// The redirect target is asked for inside the approved redirecting request, and a library's
// wrapper or generator asks for the second URL as plain code does.
CHECK(python.strings("outcomes") == std::vector<std::string>{"ok", "denied", "denied", "ok", "denied", "denied"});
const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n";
CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b);
}
TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.exec(R"PY(
CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy")
handler = netlib.ReadingHandler(PRIVATE)
handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler)
walk_outcomes = []
copy_outcomes = []
def on_walk_error(error):
walk_outcomes.append(attempt(lambda: open(PRIVATE).close()))
def copy_file(source, destination):
copy_outcomes.append(attempt(lambda: open(PRIVATE).close()))
)PY");
PluginPermissions permissions = http_grants({python.str("URL_A")});
permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")};
permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")};
harness.save_permissions(PLUGIN, permissions);
// A library handler that urllib calls while the approved request runs, the plugin's own
// os.walk error callback and copytree copy function (called once per file), and a library
// loop over a saved file and an unsaved one.
python.run_as(PLUGIN, R"PY(
request_outcome = attempt(lambda: handler_opener.open(URL_A).read())
for _ in os.walk(MISSING, onerror=on_walk_error):
pass
shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file)
loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE]))
)PY");
CHECK(python.str("request_outcome") == "ok");
CHECK(python.eval("handler.outcome") == "denied");
CHECK(python.strings("walk_outcomes") == std::vector<std::string>{"denied"});
CHECK(python.strings("copy_outcomes") == std::vector<std::string>{"denied", "denied"});
CHECK(python.str("loop_outcome") == "denied");
const std::string private_file = "open: " + python.str("PRIVATE") + "\n";
CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file);
}
TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions;
permissions.fs_read = {python.str("TREE"), python.str("APPROVED")};
harness.save_permissions(PLUGIN, permissions);
// The walk lists the tree's two subdirectories on later resumes, after the plugin's own
// audited reads in between. Abandoned walks leave frames whose generator is gone, which the
// next audited event lets go.
python.run_as(PLUGIN, R"PY(
walked = []
for root, dirs, files in os.walk(TREE):
walked.append(root)
open(APPROVED).close()
globbed = list(pathlib.Path(TREE).glob("**/*"))
abandoned = os.walk(TREE)
next(abandoned)
del abandoned
open(APPROVED).close()
for _ in pathlib.Path(TREE).glob("*"):
break
open(APPROVED).close()
)PY");
CHECK(py::len(python.globals["walked"]) == 3);
CHECK(py::len(python.globals["globbed"]) == 4);
CHECK(describe(harness.prompts).empty());
}
TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
struct Case
{
const char* prompts;
const char* code;
};
// Each operation names targets no earlier one saved, so each is asked once. FsCreate and
// FsDelete grants are never saved, so those operations are asked every time. Only the
// expected prompts are approved, so a nested event left uncovered fails the operation.
std::vector<Case> cases = {
{"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"},
{"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"},
{"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"},
{"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"},
{"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"},
{"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"},
{"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"},
{"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"},
{"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"},
{"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"},
{"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"},
// send() runs after connect() has returned, so its own grant is asked once.
{"http.client.connect\nhttp.client.send",
"connection = http.client.HTTPConnection('127.0.0.1', PORT)\n"
"connection.request('GET', '/a')\n"
"connection.getresponse().read()\n"
"connection.close()"},
};
#ifndef _WIN32
cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"});
// The forked child's exec is covered by the spawn too: had it prompted, the child's own
// denial would end it with status 1.
cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"});
#endif
for (const Case& operation : cases) {
INFO(operation.code);
const std::string expected = std::string(operation.prompts) + "\n";
harness.approve = [&expected](const Prompt& prompt) {
return expected.find(prompt.event + "\n") != std::string::npos;
};
harness.prompts.clear();
python.run_as(PLUGIN, operation.code);
std::string asked;
for (const Prompt& prompt : harness.prompts)
asked += (asked.empty() ? "" : "\n") + prompt.event;
CHECK(asked == operation.prompts);
}
#ifndef _WIN32
CHECK(python.eval("spawn_status") == "0");
#endif
}
TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.run_as(PLUGIN, R"PY(
socket.socket().close()
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9)))
udp.close()
)PY");
// The socket is asked for once, without a target to show; the datagram's address is a target.
CHECK(python.str("sendto_outcome") == "denied");
CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n");
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"Socket:socket.__new__"});
CHECK(harness.saved_permissions(PLUGIN).network_socket.empty());
harness.prompts.clear();
python.run_as(PLUGIN, "socket.socket().close()");
CHECK(describe(harness.prompts).empty());
// A grant for an event with no target covers nothing else: a library's approved
// gethostname() does not carry the connection it makes next.
harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; };
python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))");
CHECK(python.str("connect_outcome") == "denied");
CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n");
harness.prompts.clear();
harness.approve = [](const Prompt&) { return true; };
#ifndef _WIN32
// sendmsg() without an address names no target either.
python.run_as(PLUGIN, R"PY(
left, right = socket.socketpair()
left.sendmsg([b"x"])
left.close()
right.close()
)PY");
CHECK(describe(harness.prompts) == "socket.sendmsg:\n");
CHECK(harness.saved_permissions(PLUGIN).events ==
std::vector<std::string>{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"});
#endif
}
TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.exec(R"PY(
READ_FD = os.open(APPROVED, os.O_RDONLY)
WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT)
)PY");
// A file descriptor has no path to show or save: a granted read of one is not a write.
python.run_as(PLUGIN, R"PY(
fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()),
attempt(lambda: open(WRITE_FD, "w", closefd=False).close())]
)PY");
CHECK(python.strings("fd_outcomes") == std::vector<std::string>{"ok", "denied"});
CHECK(describe(harness.prompts) == "open:\nopen:\n");
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"FsRead:open"});
// A saved os.fork event grant is not an os.system command named "os.fork".
PluginPermissions permissions;
permissions.events = {"FsRead:open", "ProcessCreate:os.fork"};
harness.save_permissions(PLUGIN, permissions);
harness.prompts.clear();
python.run_as(PLUGIN, R"PY(
reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close())
system_outcome = attempt(lambda: os.system("os.fork"))
os.close(READ_FD)
os.close(WRITE_FD)
)PY");
CHECK(python.str("reread_outcome") == "ok");
CHECK(python.str("system_outcome") == "denied");
REQUIRE(harness.prompts.size() == 1);
CHECK(harness.prompts.front().event == "os.system");
CHECK(harness.saved_permissions(PLUGIN).process.empty());
}
TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions = http_grants({python.str("URL_A")});
permissions.fs_read = {python.str("APPROVED")};
harness.save_permissions(PLUGIN, permissions);
PluginAuditManager& mgr = PluginAuditManager::instance();
// Once the request returns, only its held frame keeps the request object alive, and the
// request's finalizer reads a file the plugin was never granted.
python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))");
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"}); // at scope end
CHECK(describe(harness.prompts).empty());
CHECK_FALSE(mgr.audit_denial_pending());
python.exec("finalizer_outcomes.clear()");
python.run_as(PLUGIN, R"PY(
fetch(TrackedRequest(URL_A))
open(APPROVED).close() # the next event lets the returned request's frame go
outcomes_before_scope_end = list(finalizer_outcomes)
)PY");
CHECK(python.strings("outcomes_before_scope_end") == std::vector<std::string>{"denied"});
CHECK(describe(harness.prompts).empty());
// The finalizer's denial during another event's decision leaves that event's own denial intact.
python.exec("finalizer_outcomes.clear()");
python.run_as(PLUGIN, R"PY(
fetch(TrackedRequest(URL_A))
try:
os.listdir(OUTSIDE)
outer_error = ""
except PermissionError as error:
outer_error = str(error)
)PY");
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"});
CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir"));
CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n");
AuditViolation violation;
REQUIRE(mgr.last_violation(violation));
CHECK(violation.event_name == "os.listdir");
CHECK(mgr.audit_denial_pending());
}
TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin("PluginA");
harness.add_plugin("PluginB");
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions;
permissions.fs_read = {python.str("TREE")};
harness.save_permissions("PluginA", permissions);
{
// Two walks plugin A started and suspended after listing the tree itself.
ScopedPluginAuditContext plugin_a("PluginA");
python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)");
{
// Plugin B resuming one of them is asked for each subdirectory, as plugin B.
ScopedPluginAuditContext plugin_b("PluginB");
python.exec("listed_by_b = len(list(first))");
}
// Back in plugin A's scope, its walks are covered again.
python.exec("listed_by_a = len(list(second))");
}
CHECK(python.globals["listed_by_b"].cast<int>() == 0);
CHECK(python.globals["listed_by_a"].cast<int>() == 2);
REQUIRE(harness.prompts.size() == 2);
for (const Prompt& prompt : harness.prompts) {
CHECK(prompt.plugin == "PluginB");
CHECK(prompt.event == "os.scandir");
}
}
TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
python.globals["orca"] = py::module_::import("orca");
// walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker
// in its locals; the frame is let go when the capability call ends, which runs the marker's
// finalizer.
python.exec(R"PY(
class Marker:
def __init__(self, action=None):
self.action = action
def __del__(self):
finalized.append(self.action() if self.action else "ran")
finalized = []
def walk_and_drop(marker):
walker = os.walk(TREE, onerror=marker)
next(walker)
del walker
class ConfigCapability(orca.PythonPluginBase):
def get_name(self):
return "config_cap"
def get_default_config(self):
walk_and_drop(Marker())
return {"finalized_during_call": len(finalized)}
def has_config_ui(self):
walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close())))
next(walker)
attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key
del walker # the walk's frame is now held by the audit scope alone
raise RuntimeError("the capability fails after a denial")
class TicketAgent(orca.printer_agent.PrinterAgentBase):
def get_name(self):
return "ticket_agent"
def request_bind_ticket(self):
walk_and_drop(Marker())
return (0, "ticket")
)PY");
py::object config_instance = python.globals["ConfigCapability"]();
auto config_cap = config_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
config_cap->set_audit_plugin_key("GilPlugin");
config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script);
py::object agent_instance = python.globals["TicketAgent"]();
auto agent_cap = agent_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
agent_cap->set_audit_plugin_key("GilPlugin");
agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection);
auto agent = std::dynamic_pointer_cast<IPrinterAgent>(agent_cap);
REQUIRE(agent);
// A host call arrives without the GIL; both calls take it before their audit scope.
nlohmann::json config;
int ticket_result = -1;
std::string ticket;
{
py::gil_scoped_release released;
config = config_cap->get_default_config();
ticket_result = agent->request_bind_ticket(&ticket);
}
CHECK(config == nlohmann::json{{"finalized_during_call", 0}});
CHECK(ticket_result == 0);
CHECK(ticket == "ticket");
CHECK(python.strings("finalized") == std::vector<std::string>{"ran", "ran"});
// On the trampoline's denial path the frame is let go as the plugin, though the ambient key
// was cleared: the finalizer's read is audited and denied without a prompt.
python.exec("finalized.clear()");
harness.prompts.clear();
CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set);
CHECK(python.strings("finalized") == std::vector<std::string>{"denied"});
CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n");
}
TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
const fs::path link = harness.outside.path() / "linked-stdlib";
boost::system::error_code ec;
fs::create_directory_symlink(stdlib, link, ec);
if (ec)
SKIP("Cannot create a directory symlink here: " + ec.message());
CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string()));
CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string()));
CHECK(PluginAuditManager::is_stdlib_file("<frozen os>"));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
}
@@ -5,6 +5,7 @@
#include <ios>
#include <libslic3r/LifecycleEvents.hpp>
#include <memory>
#include <slic3r/Utils/Http.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginManager.hpp>
@@ -14,8 +15,13 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <pybind11/cast.h>
#include <pybind11/gil.h>
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <algorithm>
#include <chrono>
@@ -1068,3 +1074,43 @@ TEST_CASE("Unloading a plugin that is not loaded is a no-op", "[PluginLifecycle]
CHECK(manager.unload_plugin("No_Such_Plugin"));
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
}
namespace {
// CA certificates in the default client context, as a plugin's urllib or http.client request would
// verify against.
int default_context_ca_count()
{
pybind11::gil_scoped_acquire gil;
const pybind11::dict stats = pybind11::module_::import("ssl").attr("create_default_context")().attr("cert_store_stats")();
return stats["x509_ca"].cast<int>();
}
} // namespace
TEST_CASE("The bundled Python trusts the system CA certificates", "[PluginLifecycle][Python]")
{
// The app runs this before plugins load; on Linux it is what points OpenSSL at the distro bundle.
Http::tls_global_init();
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
CHECK(default_context_ca_count() > 0);
}
#ifndef _WIN32 // Windows adds its system store whatever SSL_CERT_FILE names.
TEST_CASE("SSL_CERT_FILE still overrides the bundled Python's default CA certificates", "[PluginLifecycle][Python]")
{
// Existing but empty: Linux's CurlGlobalInit replaces an SSL_CERT_FILE that names a missing file.
ScopedTemporaryFile empty_bundle(".pem");
std::ofstream(empty_bundle.string()).close();
ScopedEnvVar ssl_cert_file{"SSL_CERT_FILE", empty_bundle.string()};
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
CHECK(default_context_ca_count() == 0);
}
#endif
+100
View File
@@ -0,0 +1,100 @@
#include <catch2/catch_test_macros.hpp>
#include "libslic3r/GCode/ThumbnailData.hpp"
#include "libslic3r/GCode/Thumbnails.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/TexturePainting.hpp"
#include "slic3r/GUI/AVVideoDecoder.hpp"
#include "slic3r/GUI/Printer/BambuTunnel.h"
#include <atomic>
#include <cstddef>
#include <cstdlib>
#include <thread>
#include <vector>
#include <wx/bitmap.h>
#include <wx/gdicmn.h>
#include <wx/image.h>
#include <wx/init.h>
#include <wx/mstream.h>
TEST_CASE("JPEG texture decoding coexists with thumbnails and video frames", "[TimelapseCodecs][Regression]")
{
#if defined(__linux__)
if (!std::getenv("DISPLAY") && !std::getenv("WAYLAND_DISPLAY"))
SKIP("This integration test needs a display (for example, xvfb-run).");
#endif
wxInitializer init;
REQUIRE(init.IsOk());
wxInitAllImageHandlers();
// Generate a JPEG through the same encoder used for G-code thumbnails.
constexpr int width = 160, height = 90, iterations = 1000;
Slic3r::ThumbnailData thumbnail;
thumbnail.set(width, height);
for (std::size_t i = 0; i < thumbnail.pixels.size(); i += 4) {
thumbnail.pixels[i] = 180;
thumbnail.pixels[i + 1] = 70;
thumbnail.pixels[i + 2] = 30;
thumbnail.pixels[i + 3] = 255;
}
const auto jpeg = Slic3r::GCodeThumbnails::compress_thumbnail(thumbnail, Slic3r::GCodeThumbnailsFormat::JPG);
REQUIRE(jpeg);
REQUIRE(jpeg->size > 0);
const auto* bytes = static_cast<const unsigned char*>(jpeg->data);
Slic3r::TextureImage texture;
texture.width = texture.height = -1;
texture.data.assign(bytes, bytes + jpeg->size);
std::atomic<bool> texture_ok{true};
std::thread texture_worker([&] {
for (int i = 0; i < iterations; ++i) {
std::vector<unsigned char> pixels;
int decoded_width = 0, decoded_height = 0;
if (!Slic3r::decode_texture_to_pixels(texture, pixels, decoded_width, decoded_height) ||
decoded_width != width || decoded_height != height || pixels.size() != width * height * 3)
texture_ok = false;
}
});
// PrinterFileSystem decodes timelapse thumbnails with wxImage. Do not
// assert until the worker is joined: Catch assertions can unwind the scope.
bool thumbnails_ok = true;
for (int i = 0; i < iterations; ++i) {
wxMemoryInputStream stream(jpeg->data, jpeg->size);
wxImage image(stream, "image/jpeg");
if (!image.IsOk() || image.GetWidth() != width || image.GetHeight() != height ||
image.GetRed(width / 2, height / 2) < 170 || image.GetGreen(width / 2, height / 2) > 80)
thumbnails_ok = false;
}
texture_worker.join();
REQUIRE(texture_ok.load());
REQUIRE(thumbnails_ok);
// Exercise Orca's real FFmpeg decoder and its platform display conversion.
AVVideoDecoder decoder;
Bambu_StreamInfo info{};
info.type = VIDE;
info.sub_type = MJPG;
info.format_type = video_jpeg;
REQUIRE(decoder.open(info) == 0);
Bambu_Sample sample{};
sample.size = static_cast<int>(jpeg->size);
sample.buffer = bytes;
for (int i = 0; i < iterations; ++i) {
REQUIRE(decoder.decode(sample) == 0);
wxImage image;
REQUIRE(decoder.toWxImage(image, wxSize(width, height)));
REQUIRE(image.IsOk());
REQUIRE(image.GetWidth() == width);
REQUIRE(image.GetHeight() == height);
#ifdef _WIN32
// Playback uses this raw 32-bit bitmap conversion only on Windows.
wxBitmap bitmap;
REQUIRE(decoder.toWxBitmap(bitmap, wxSize(width, height)));
REQUIRE(bitmap.IsOk());
REQUIRE(bitmap.GetWidth() == width);
REQUIRE(bitmap.GetHeight() == height);
#endif
}
}
+26
View File
@@ -10,10 +10,13 @@
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <boost/nowide/cstdlib.hpp>
#include <cstdio>
#include <fstream>
#include <optional>
#include <string>
#include <utility>
#if defined(WIN32) || defined(_WIN32)
#define PATH_SEPARATOR R"(\)"
@@ -197,4 +200,27 @@ private:
boost::filesystem::path m_previous;
};
// Sets an environment variable for the lifetime of the guard and restores the previous value.
class ScopedEnvVar
{
public:
ScopedEnvVar(std::string name, const std::string& value) : m_name(std::move(name))
{
if (const char* old = boost::nowide::getenv(m_name.c_str()))
m_previous = old;
boost::nowide::setenv(m_name.c_str(), value.c_str(), 1);
}
~ScopedEnvVar()
{
if (m_previous)
boost::nowide::setenv(m_name.c_str(), m_previous->c_str(), 1);
else
boost::nowide::unsetenv(m_name.c_str());
}
private:
std::string m_name;
std::optional<std::string> m_previous;
};
#endif // SLIC3R_TEST_UTILS