mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 14:08:20 +00:00
Merge branch 'main' into feat/printer-agent-impl
This commit is contained in:
139 files changed
+25303
-10511
No files matched your search
@@ -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 |
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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]")
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -505,6 +505,28 @@ TEST_CASE("A project whose components reference themselves fails to load", "[3mf
|
||||
REQUIRE_FALSE(loaded);
|
||||
}
|
||||
|
||||
TEST_CASE("A project in the 2.4.x layout keeps auto drop off from its build item", "[3mf][Regression]")
|
||||
{
|
||||
// 2.4.x wrote auto_drop on the build item and left it off the object, so the object is re-laid out here.
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
{
|
||||
Model model;
|
||||
ModelObject* object = model.add_object();
|
||||
object->add_volume(make_cube(10., 10., 10.));
|
||||
object->add_instance();
|
||||
object->auto_drop = false;
|
||||
store_project(model, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence);
|
||||
}
|
||||
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", " auto_drop=\"0\"", ""));
|
||||
REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "printable=\"1\"/>", "printable=\"1\" auto_drop=\"0\"/>"));
|
||||
|
||||
ScopedTemporaryDir backup_dir("orca_auto_drop_dst");
|
||||
Model model;
|
||||
REQUIRE(load_project(temp.string(), model, backup_dir));
|
||||
REQUIRE(model.objects.size() == 1);
|
||||
CHECK_FALSE(model.objects.front()->auto_drop);
|
||||
}
|
||||
|
||||
TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]")
|
||||
{
|
||||
// The importer queues at most 100000 component references per object. Every reference besides the
|
||||
|
||||
+42
-42
@@ -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",
|
||||
[¢er](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));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1219,6 +1219,24 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
|
||||
CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("The selected preset is its own base when its parent is not loaded", "[Preset]")
|
||||
{
|
||||
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
|
||||
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
|
||||
add_inmemory_preset(printers, "Printer");
|
||||
add_inmemory_preset(printers, "User Printer", "Printer");
|
||||
add_inmemory_preset(printers, "Orphan Printer", "Uninstalled Vendor Printer");
|
||||
|
||||
printers.select_preset_by_name("User Printer", true);
|
||||
REQUIRE(printers.get_selected_preset_name() == "User Printer");
|
||||
CHECK(printers.get_selected_preset_base().name == "Printer");
|
||||
|
||||
printers.select_preset_by_name("Orphan Printer", true);
|
||||
REQUIRE(printers.get_selected_preset_name() == "Orphan Printer");
|
||||
REQUIRE(printers.get_preset_base(printers.get_selected_preset()) == nullptr);
|
||||
CHECK(printers.get_selected_preset_base().name == "Orphan Printer");
|
||||
}
|
||||
|
||||
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
|
||||
{
|
||||
ScopedTemporaryDir temp_dir;
|
||||
@@ -8399,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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "texture_import_cgal.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
std::size_t TextureImportTest::backend_search_tree_size()
|
||||
{
|
||||
return sizeof(SearchTree);
|
||||
}
|
||||
@@ -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
|
||||
@@ -45,6 +45,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]"));
|
||||
}
|
||||
@@ -175,6 +175,13 @@ public:
|
||||
return m_first_byte;
|
||||
}
|
||||
|
||||
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
|
||||
bool wait_for_close_frames(int count)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
return m_changed.wait_for(lock, 10s, [&] { return m_close_frames >= count; });
|
||||
}
|
||||
|
||||
// Hangs up every connection that is waiting.
|
||||
void release()
|
||||
{
|
||||
@@ -316,6 +323,7 @@ private:
|
||||
if (ec == websocket::error::closed) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
++m_close_frames;
|
||||
m_changed.notify_all();
|
||||
}
|
||||
if (ec)
|
||||
reading = false;
|
||||
@@ -455,12 +463,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]")
|
||||
@@ -502,6 +511,7 @@ TEST_CASE("Stopping the status stream sends the printer a close frame", "[Moonra
|
||||
REQUIRE(f.printer.wait_for_ready_connections(1));
|
||||
|
||||
CHECK(f.stops_within(3s));
|
||||
CHECK(f.printer.wait_for_close_frames(1));
|
||||
CHECK(f.printer.close_frames() == 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,22 +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 {
|
||||
|
||||
@@ -349,3 +375,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>
|
||||
@@ -615,8 +621,22 @@ TEST_CASE("Python module release removes package submodules and owned sys.path",
|
||||
|
||||
// Ensure the next import executes the new submodule rather than reusing a stale package child.
|
||||
write_package("'new'");
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(package_root / "__pycache__", ec);
|
||||
{
|
||||
// The rewrite can keep the old mtime and size, so drop the bytecode wherever the
|
||||
// interpreter caches it.
|
||||
PythonGILState gil;
|
||||
REQUIRE(gil);
|
||||
PyObjectPtr util(PyImport_ImportModule("importlib.util"));
|
||||
REQUIRE(util);
|
||||
for (const char* file : {"__init__.py", "sub.py"}) {
|
||||
PyObjectPtr source(PyUnicode_DecodeFSDefault((package_root / file).string().c_str()));
|
||||
REQUIRE(source);
|
||||
PyObjectPtr cached(PyObject_CallMethod(util.get(), "cache_from_source", "O", source.get()));
|
||||
REQUIRE(cached);
|
||||
boost::system::error_code ec;
|
||||
fs::remove(fs::path(PyUnicode_AsUTF8(cached.get())), ec);
|
||||
}
|
||||
}
|
||||
|
||||
paths.clear();
|
||||
modules.clear();
|
||||
@@ -1054,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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user