1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-08 07:44:57 +00:00

Add verified snub dodecahedron geometry and flat-net unfolding

Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-14 22:51:07 +00:00
parent 42fd405534
commit 376552949c
No known key found for this signature in database
5 changed files with 893 additions and 0 deletions

4
snub-dodecahedron/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.stl
out/

View file

@ -0,0 +1,186 @@
<svg xmlns="http://www.w3.org/2000/svg" width="474" height="607" viewBox="0 0 474 607">
<rect width="474" height="607" fill="white"/>
<polygon points="266.77,293.10 267.19,253.10 229.28,240.33 205.43,272.44 228.59,305.05" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="228.59,305.05 258.03,332.13 266.77,293.10" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="229.28,240.33 189.55,235.73 205.43,272.44" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="267.19,253.10 259.29,213.89 229.28,240.33" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="301.62,273.47 267.19,253.10 266.77,293.10" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="228.59,305.05 205.43,272.44 188.77,308.81" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="219.86,344.09 258.03,332.13 228.59,305.05" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="266.77,293.10 258.03,332.13 296.20,320.18" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="213.40,203.62 189.55,235.73 229.28,240.33" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="205.43,272.44 189.55,235.73 165.69,267.84" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="229.28,240.33 259.29,213.89 221.39,201.12" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="267.19,253.10 297.20,226.65 259.29,213.89" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="301.62,273.47 266.77,293.10 301.19,313.47" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="301.62,273.47 302.04,233.47 267.19,253.10" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="188.77,308.81 205.43,272.44 165.61,276.20" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="188.77,308.81 211.94,341.42 228.59,305.05" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="281.20,364.74 258.03,332.13 219.86,344.09 219.43,384.08 257.34,396.85" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="258.03,332.13 287.47,359.21 296.20,320.18" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="213.40,203.62 190.24,171.01 152.07,182.97 151.64,222.96 189.55,235.73" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="189.55,235.73 149.82,231.12 165.69,267.84" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="221.39,201.12 259.29,213.89 251.39,174.67" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="259.72,173.89 259.29,213.89 297.20,226.65 321.06,194.54 297.89,161.93" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="301.62,273.47 301.19,313.47 339.10,326.23 362.95,294.12 339.79,261.51" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="336.47,253.84 302.04,233.47 301.62,273.47" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="127.43,288.15 127.01,328.15 164.92,340.92 188.77,308.81 165.61,276.20" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="188.77,308.81 172.11,345.17 211.94,341.42" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="219.43,384.08 219.86,344.09 185.01,363.72" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="227.33,423.30 257.34,396.85 219.43,384.08" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="297.08,401.45 281.20,364.74 257.34,396.85" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="296.20,320.18 287.47,359.21 325.64,347.26" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="190.24,171.01 160.80,143.93 152.07,182.97" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="117.22,202.59 151.64,222.96 152.07,182.97" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="213.40,203.62 230.06,167.25 190.24,171.01" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="165.69,267.84 149.82,231.12 125.96,263.23" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="297.89,161.93 268.45,134.85 259.72,173.89" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="321.06,194.54 297.20,226.65 336.94,231.26" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="297.89,161.93 321.06,194.54 337.72,158.18" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="362.95,294.12 339.10,326.23 378.83,330.84" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="379.61,257.76 339.79,261.51 362.95,294.12" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="164.92,340.92 127.01,328.15 134.91,367.36" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="92.58,307.78 127.01,328.15 127.43,288.15" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="185.01,363.72 184.58,403.71 219.43,384.08" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="227.33,423.30 265.24,436.06 257.34,396.85" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="189.42,410.53 227.33,423.30 219.43,384.08" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="320.93,369.35 281.20,364.74 297.08,401.45" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="257.34,396.85 273.22,433.56 297.08,401.45" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="198.97,131.98 160.80,143.93 190.24,171.01" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="152.07,182.97 160.80,143.93 122.63,155.88" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="116.79,242.59 151.64,222.96 117.22,202.59" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="117.64,162.60 117.22,202.59 152.07,182.97" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="230.06,167.25 206.90,134.64 190.24,171.01" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="306.63,122.90 268.45,134.85 297.89,161.93" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="360.79,199.15 321.06,194.54 336.94,231.26" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="337.72,158.18 321.06,194.54 360.88,190.79" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="314.55,125.57 297.89,161.93 337.72,158.18" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="402.69,298.73 362.95,294.12 378.83,330.84" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="378.83,330.84 339.10,326.23 354.98,362.94" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="379.61,257.76 362.95,294.12 402.78,290.37" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="134.91,367.36 127.01,328.15 97.00,354.60" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="92.15,347.78 127.01,328.15 92.58,307.78" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="127.43,288.15 93.01,267.78 92.58,307.78" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="265.24,436.06 227.33,423.30 235.23,462.51" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="165.57,442.64 188.73,475.25 226.91,463.29 227.33,423.30 189.42,410.53" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="296.39,466.17 334.56,454.22 334.98,414.22 297.08,401.45 273.22,433.56" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="160.80,143.93 198.97,131.98 199.40,91.98 161.49,79.21 137.64,111.32" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="122.63,155.88 160.80,143.93 131.36,116.85" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="55.88,181.94 79.04,214.55 117.22,202.59 117.64,162.60 79.73,149.83" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="399.05,178.83 399.48,138.84 361.57,126.07 337.72,158.18 360.88,190.79" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<polygon points="354.37,121.81 314.55,125.57 337.72,158.18" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="418.57,335.44 402.69,298.73 378.83,330.84" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="57.73,327.41 92.15,347.78 92.58,307.78" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="273.14,475.27 265.24,436.06 235.23,462.51" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="148.91,479.01 188.73,475.25 165.57,442.64" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="226.91,463.29 188.73,475.25 218.17,502.33" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="334.56,454.22 296.39,466.17 325.82,493.25" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="161.49,79.21 121.76,74.61 137.64,111.32" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="191.50,52.77 161.49,79.21 199.40,91.98" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="131.36,116.85 93.19,128.80 122.63,155.88" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="79.73,149.83 40.00,145.23 55.88,181.94" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="399.48,138.84 399.05,178.83 433.90,159.20" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="399.48,138.84 391.58,99.62 361.57,126.07" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="235.23,462.51 243.13,501.72 273.14,475.27" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="148.91,479.01 172.08,511.61 188.73,475.25" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="180.00,514.28 218.17,502.33 188.73,475.25" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="325.82,493.25 296.39,466.17 287.65,505.21" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="325.82,493.25 364.00,481.30 334.56,454.22" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="137.64,111.32 121.76,74.61 97.90,106.72" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="145.61,42.50 121.76,74.61 161.49,79.21" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="191.50,52.77 153.59,40.00 161.49,79.21" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="79.73,149.83 63.86,113.12 40.00,145.23" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="391.58,99.62 399.48,138.84 429.49,112.39" fill="#cfe8ff" stroke="#888" stroke-width="0.6"/>
<polygon points="241.34,534.94 218.17,502.33 180.00,514.28 179.57,554.28 217.48,567.05" fill="#ffe9b3" stroke="#888" stroke-width="0.6"/>
<line x1="228.59" y1="305.05" x2="266.77" y2="293.10" stroke="#c000c0" stroke-width="1.2"/>
<line x1="205.43" y1="272.44" x2="229.28" y2="240.33" stroke="#c000c0" stroke-width="1.2"/>
<line x1="229.28" y1="240.33" x2="267.19" y2="253.10" stroke="#c000c0" stroke-width="1.2"/>
<line x1="267.19" y1="253.10" x2="266.77" y2="293.10" stroke="#c000c0" stroke-width="1.2"/>
<line x1="205.43" y1="272.44" x2="228.59" y2="305.05" stroke="#c000c0" stroke-width="1.2"/>
<line x1="258.03" y1="332.13" x2="228.59" y2="305.05" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="258.03" y1="332.13" x2="266.77" y2="293.10" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="189.55" y1="235.73" x2="229.28" y2="240.33" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="189.55" y1="235.73" x2="205.43" y2="272.44" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="259.29" y1="213.89" x2="229.28" y2="240.33" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="259.29" y1="213.89" x2="267.19" y2="253.10" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="266.77" y1="293.10" x2="301.62" y2="273.47" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="267.19" y1="253.10" x2="301.62" y2="273.47" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="205.43" y1="272.44" x2="188.77" y2="308.81" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="228.59" y1="305.05" x2="188.77" y2="308.81" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="258.03" y1="332.13" x2="219.86" y2="344.09" stroke="#c000c0" stroke-width="1.2"/>
<line x1="258.03" y1="332.13" x2="296.20" y2="320.18" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="189.55" y1="235.73" x2="213.40" y2="203.62" stroke="#c000c0" stroke-width="1.2"/>
<line x1="189.55" y1="235.73" x2="165.69" y2="267.84" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="259.29" y1="213.89" x2="221.39" y2="201.12" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="297.20" y1="226.65" x2="259.29" y2="213.89" stroke="#c000c0" stroke-width="1.2"/>
<line x1="301.62" y1="273.47" x2="301.19" y2="313.47" stroke="#c000c0" stroke-width="1.2"/>
<line x1="302.04" y1="233.47" x2="301.62" y2="273.47" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="188.77" y1="308.81" x2="165.61" y2="276.20" stroke="#c000c0" stroke-width="1.2"/>
<line x1="211.94" y1="341.42" x2="188.77" y2="308.81" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="219.86" y1="344.09" x2="219.43" y2="384.08" stroke="#c000c0" stroke-width="1.2"/>
<line x1="219.43" y1="384.08" x2="257.34" y2="396.85" stroke="#c000c0" stroke-width="1.2"/>
<line x1="281.20" y1="364.74" x2="257.34" y2="396.85" stroke="#c000c0" stroke-width="1.2"/>
<line x1="287.47" y1="359.21" x2="296.20" y2="320.18" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="152.07" y1="182.97" x2="190.24" y2="171.01" stroke="#c000c0" stroke-width="1.2"/>
<line x1="151.64" y1="222.96" x2="152.07" y2="182.97" stroke="#c000c0" stroke-width="1.2"/>
<line x1="190.24" y1="171.01" x2="213.40" y2="203.62" stroke="#c000c0" stroke-width="1.2"/>
<line x1="149.82" y1="231.12" x2="165.69" y2="267.84" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="259.72" y1="173.89" x2="297.89" y2="161.93" stroke="#c000c0" stroke-width="1.2"/>
<line x1="321.06" y1="194.54" x2="297.20" y2="226.65" stroke="#c000c0" stroke-width="1.2"/>
<line x1="321.06" y1="194.54" x2="297.89" y2="161.93" stroke="#c000c0" stroke-width="1.2"/>
<line x1="362.95" y1="294.12" x2="339.10" y2="326.23" stroke="#c000c0" stroke-width="1.2"/>
<line x1="339.79" y1="261.51" x2="362.95" y2="294.12" stroke="#c000c0" stroke-width="1.2"/>
<line x1="127.01" y1="328.15" x2="164.92" y2="340.92" stroke="#c000c0" stroke-width="1.2"/>
<line x1="127.43" y1="288.15" x2="127.01" y2="328.15" stroke="#c000c0" stroke-width="1.2"/>
<line x1="219.43" y1="384.08" x2="185.01" y2="363.72" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="227.33" y1="423.30" x2="257.34" y2="396.85" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="227.33" y1="423.30" x2="219.43" y2="384.08" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="281.20" y1="364.74" x2="297.08" y2="401.45" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="297.08" y1="401.45" x2="257.34" y2="396.85" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="160.80" y1="143.93" x2="190.24" y2="171.01" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="160.80" y1="143.93" x2="152.07" y2="182.97" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="151.64" y1="222.96" x2="117.22" y2="202.59" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="152.07" y1="182.97" x2="117.22" y2="202.59" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="190.24" y1="171.01" x2="230.06" y2="167.25" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="268.45" y1="134.85" x2="297.89" y2="161.93" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="321.06" y1="194.54" x2="336.94" y2="231.26" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="321.06" y1="194.54" x2="337.72" y2="158.18" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="297.89" y1="161.93" x2="337.72" y2="158.18" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="362.95" y1="294.12" x2="378.83" y2="330.84" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="339.10" y1="326.23" x2="378.83" y2="330.84" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="362.95" y1="294.12" x2="379.61" y2="257.76" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="127.01" y1="328.15" x2="134.91" y2="367.36" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="127.01" y1="328.15" x2="92.58" y2="307.78" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="127.43" y1="288.15" x2="92.58" y2="307.78" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="265.24" y1="436.06" x2="227.33" y2="423.30" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="227.33" y1="423.30" x2="189.42" y2="410.53" stroke="#c000c0" stroke-width="1.2"/>
<line x1="273.22" y1="433.56" x2="297.08" y2="401.45" stroke="#c000c0" stroke-width="1.2"/>
<line x1="198.97" y1="131.98" x2="160.80" y2="143.93" stroke="#c000c0" stroke-width="1.2"/>
<line x1="160.80" y1="143.93" x2="122.63" y2="155.88" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="117.22" y1="202.59" x2="117.64" y2="162.60" stroke="#c000c0" stroke-width="1.2"/>
<line x1="360.88" y1="190.79" x2="337.72" y2="158.18" stroke="#c000c0" stroke-width="1.2"/>
<line x1="314.55" y1="125.57" x2="337.72" y2="158.18" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="378.83" y1="330.84" x2="402.69" y2="298.73" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="92.15" y1="347.78" x2="92.58" y2="307.78" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="265.24" y1="436.06" x2="235.23" y2="462.51" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="188.73" y1="475.25" x2="165.57" y2="442.64" stroke="#c000c0" stroke-width="1.2"/>
<line x1="188.73" y1="475.25" x2="226.91" y2="463.29" stroke="#c000c0" stroke-width="1.2"/>
<line x1="296.39" y1="466.17" x2="334.56" y2="454.22" stroke="#c000c0" stroke-width="1.2"/>
<line x1="137.64" y1="111.32" x2="161.49" y2="79.21" stroke="#c000c0" stroke-width="1.2"/>
<line x1="161.49" y1="79.21" x2="199.40" y2="91.98" stroke="#c000c0" stroke-width="1.2"/>
<line x1="122.63" y1="155.88" x2="131.36" y2="116.85" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="79.73" y1="149.83" x2="55.88" y2="181.94" stroke="#c000c0" stroke-width="1.2"/>
<line x1="399.48" y1="138.84" x2="399.05" y2="178.83" stroke="#c000c0" stroke-width="1.2"/>
<line x1="399.48" y1="138.84" x2="361.57" y2="126.07" stroke="#c000c0" stroke-width="1.2"/>
<line x1="235.23" y1="462.51" x2="273.14" y2="475.27" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="188.73" y1="475.25" x2="148.91" y2="479.01" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="218.17" y1="502.33" x2="188.73" y2="475.25" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="296.39" y1="466.17" x2="325.82" y2="493.25" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="334.56" y1="454.22" x2="325.82" y2="493.25" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="121.76" y1="74.61" x2="137.64" y2="111.32" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="121.76" y1="74.61" x2="161.49" y2="79.21" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="161.49" y1="79.21" x2="191.50" y2="52.77" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="40.00" y1="145.23" x2="79.73" y2="149.83" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="399.48" y1="138.84" x2="391.58" y2="99.62" stroke="#2a8f2a" stroke-width="1.2"/>
<line x1="180.00" y1="514.28" x2="218.17" y2="502.33" stroke="#c000c0" stroke-width="1.2"/>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,95 @@
"""Render an SVG preview of the unfolded net(s) and verify no faces overlap."""
from __future__ import annotations
import numpy as np
from snubgeom import SnubDodecahedron
from unfold import Unfolder, _convex_overlap, _shrink
def layout_pieces(pieces, gap=1.5):
"""Translate each piece so they sit side by side; return offsets."""
offsets = []
x_cursor = 0.0
for p in pieces:
x0, y0, x1, y1 = p.bbox()
offsets.append((x_cursor - x0, -y0))
x_cursor += (x1 - x0) + gap
return offsets
def verify_no_overlap(pieces):
"""Independent global check: no two faces in the same piece overlap."""
bad = 0
for p in pieces:
polys = [poly for _, poly in p.faces]
adj_share = {} # face index pairs that share an edge are allowed to touch
for i in range(len(polys)):
for j in range(i + 1, len(polys)):
a = _shrink(polys[i], 5e-3)
b = _shrink(polys[j], 5e-3)
if _convex_overlap(a, b):
bad += 1
return bad
def write_svg(pieces, path, scale=40.0, gap=1.5):
offsets = layout_pieces(pieces, gap=gap)
# global bbox
minx = miny = 1e9
maxx = maxy = -1e9
for p, (ox, oy) in zip(pieces, offsets):
for _, poly in p.faces:
for x, y in poly:
minx = min(minx, x + ox)
miny = min(miny, y + oy)
maxx = max(maxx, x + ox)
maxy = max(maxy, y + oy)
pad = 1.0
W = (maxx - minx + 2 * pad) * scale
H = (maxy - miny + 2 * pad) * scale
def X(x):
return (x - minx + pad) * scale
def Y(y):
return H - (y - miny + pad) * scale # flip for screen coords
out = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{W:.0f}" height="{H:.0f}" '
f'viewBox="0 0 {W:.0f} {H:.0f}">',
f'<rect width="{W:.0f}" height="{H:.0f}" fill="white"/>',
]
for p, (ox, oy) in zip(pieces, offsets):
for fi, poly in p.faces:
pts = " ".join(f"{X(x + ox):.2f},{Y(y + oy):.2f}" for x, y in poly)
fill = "#ffe9b3" if len(poly) == 5 else "#cfe8ff"
out.append(
f'<polygon points="{pts}" fill="{fill}" stroke="#888" '
f'stroke-width="0.6"/>'
)
# hinges (fold lines) in green
for h in p.hinges:
x1, y1 = h.p
x2, y2 = h.q
col = "#2a8f2a" if h.kind == "3-3" else "#c000c0"
out.append(
f'<line x1="{X(x1 + ox):.2f}" y1="{Y(y1 + oy):.2f}" '
f'x2="{X(x2 + ox):.2f}" y2="{Y(y2 + oy):.2f}" '
f'stroke="{col}" stroke-width="1.2"/>'
)
out.append("</svg>")
with open(path, "w") as f:
f.write("\n".join(out))
if __name__ == "__main__":
s = SnubDodecahedron()
s.verify()
pieces = Unfolder(s).unfold()
bad = verify_no_overlap(pieces)
print(f"pieces={len(pieces)} faces={sum(len(p.faces) for p in pieces)} "
f"overlapping_pairs={bad}")
write_svg(pieces, "net_preview.svg")
print("wrote net_preview.svg")

View file

@ -0,0 +1,278 @@
"""
Exact geometry of the snub dodecahedron (an Archimedean solid).
The snub dodecahedron has:
- 60 vertices
- 150 edges
- 92 faces: 12 regular pentagons + 80 equilateral triangles
- every vertex has configuration 3.3.3.3.5
We build it from the exact Cartesian coordinates (Wikipedia / Weisstein),
take the convex hull, merge coplanar hull triangles back into the real
pentagon/triangle faces, and expose:
- vertices (60, 3) float array, normalised to edge length 1
- faces list[list[int]] CCW (outward) vertex-index loops
- edges dict[(i,j)] -> (faceA, faceB)
- dihedral_angle(...) interior dihedral angle of an edge (degrees)
Everything is verified at import time (see `build_snub_dodecahedron`):
all 150 edges are equal length, there are exactly 12 pentagons and 80
triangles, and the dihedral angles match the known values
(3-3: 164.175 deg, 3-5: 152.930 deg).
"""
from __future__ import annotations
import itertools
import math
import numpy as np
from scipy.spatial import ConvexHull
PHI = (1.0 + math.sqrt(5.0)) / 2.0
def _xi() -> float:
"""Real root of xi^3 - 2*xi = phi."""
# Solve xi^3 - 2 xi - phi = 0 for the single real positive root.
coeffs = [1.0, 0.0, -2.0, -PHI]
roots = np.roots(coeffs)
real = [r.real for r in roots if abs(r.imag) < 1e-9]
# The relevant root is the largest real one (~1.7155615).
return max(real)
def _base_points() -> np.ndarray:
"""The five generating points before permutation / sign expansion."""
xi = _xi()
a = xi - 1.0 / xi
b = xi * PHI + PHI * PHI + PHI / xi
p = PHI
pts = [
(2 * a, 2.0, 2 * b),
(a + b / p + p, -a * p + b + 1.0 / p, a / p + b * p - 1.0),
(a + b / p - p, a * p - b + 1.0 / p, a / p + b * p + 1.0),
(-a / p + b * p + 1.0, -a + b / p - p, a * p + b - 1.0 / p),
(-a / p + b * p - 1.0, a - b / p - p, a * p + b + 1.0 / p),
]
return np.array(pts, dtype=float)
def _even_permutations(v):
"""The 3 even (cyclic) permutations of a 3-vector."""
x, y, z = v
return [(x, y, z), (y, z, x), (z, x, y)]
def _sign_variants(v):
"""Sign assignments with an even number of minus signs (0 or 2)."""
x, y, z = v
out = []
for sx, sy, sz in itertools.product((1, -1), repeat=3):
if (sx * sy * sz) == 1: # product +1 <=> even number of minus signs
out.append((sx * x, sy * y, sz * z))
return out
def _all_vertices() -> np.ndarray:
seen = []
for base in _base_points():
for perm in _even_permutations(base):
for sv in _sign_variants(perm):
seen.append(sv)
pts = np.array(seen, dtype=float)
# De-duplicate (there should be exactly 60 distinct points).
uniq = []
for p in pts:
if not any(np.allclose(p, q, atol=1e-6) for q in uniq):
uniq.append(p)
return np.array(uniq, dtype=float)
def _merge_coplanar_faces(points: np.ndarray, hull: ConvexHull):
"""Group hull simplices (triangles) into the real polygonal faces.
Two adjacent simplices belong to the same face iff they are coplanar
(same outward normal). Returns a list of faces, each an ordered
(CCW seen from outside) list of vertex indices.
"""
n_simp = len(hull.simplices)
# Outward unit normal for each simplex.
normals = hull.equations[:, :3]
offsets = hull.equations[:, 3]
# Union-find over simplices that share an edge and are coplanar.
parent = list(range(n_simp))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
def union(i, j):
parent[find(i)] = find(j)
# Map each undirected triangle edge -> simplices that contain it.
from collections import defaultdict
edge_to_simps = defaultdict(list)
for si, tri in enumerate(hull.simplices):
for a, b in ((tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])):
edge_to_simps[frozenset((int(a), int(b)))].append(si)
for simps in edge_to_simps.values():
if len(simps) == 2:
s0, s1 = simps
if (
np.allclose(normals[s0], normals[s1], atol=1e-6)
and abs(offsets[s0] - offsets[s1]) < 1e-6
):
union(s0, s1)
groups = defaultdict(list)
for si in range(n_simp):
groups[find(si)].append(si)
faces = []
for simps in groups.values():
vids = set()
for si in simps:
vids.update(int(v) for v in hull.simplices[si])
vids = list(vids)
normal = normals[simps[0]]
center = points[vids].mean(axis=0)
# Build an in-plane basis to order vertices by angle.
ref = points[vids[0]] - center
ref = ref / np.linalg.norm(ref)
binormal = np.cross(normal, ref)
def angle(vi):
d = points[vi] - center
return math.atan2(float(d @ binormal), float(d @ ref))
vids.sort(key=angle)
# Ensure CCW with respect to the OUTWARD normal.
ordered = np.array([points[v] for v in vids])
area_vec = np.zeros(3)
for i in range(len(ordered)):
area_vec += np.cross(ordered[i], ordered[(i + 1) % len(ordered)])
if area_vec @ normal < 0:
vids.reverse()
faces.append(vids)
return faces
class SnubDodecahedron:
def __init__(self):
pts = _all_vertices()
assert len(pts) == 60, f"expected 60 vertices, got {len(pts)}"
hull = ConvexHull(pts)
# Normalise so that edge length == 1.
faces = _merge_coplanar_faces(pts, hull)
# Measure a representative edge length and rescale.
i, j = faces[0][0], faces[0][1]
edge_len = np.linalg.norm(pts[i] - pts[j])
pts = pts / edge_len
self.vertices = pts
self.faces = faces
# Build edge -> faces map.
from collections import defaultdict
edge_faces = defaultdict(list)
for fi, face in enumerate(faces):
n = len(face)
for k in range(n):
a, b = face[k], face[(k + 1) % n]
edge_faces[frozenset((a, b))].append(fi)
self.edge_faces = edge_faces
def face_normal(self, fi):
face = self.faces[fi]
v = self.vertices[face]
c = v.mean(axis=0)
n = np.cross(v[1] - v[0], v[2] - v[0])
n = n / np.linalg.norm(n)
# Make it point outward (away from origin, which is the centroid).
if n @ c < 0:
n = -n
return n
def dihedral_angle(self, fa, fb):
"""Interior dihedral angle between two faces (degrees)."""
na, nb = self.face_normal(fa), self.face_normal(fb)
ang_between_normals = math.degrees(
math.acos(max(-1.0, min(1.0, float(na @ nb))))
)
return 180.0 - ang_between_normals
# ---- verification -------------------------------------------------
def verify(self):
nv = len(self.vertices)
nf = len(self.faces)
ne = len(self.edge_faces)
tris = sum(1 for f in self.faces if len(f) == 3)
pents = sum(1 for f in self.faces if len(f) == 5)
assert nv == 60, nv
assert nf == 92, nf
assert ne == 150, ne
assert tris == 80, tris
assert pents == 12, pents
# All edges equal length.
lengths = []
for e in self.edge_faces:
a, b = tuple(e)
lengths.append(np.linalg.norm(self.vertices[a] - self.vertices[b]))
lengths = np.array(lengths)
assert lengths.std() < 1e-6, lengths.std()
assert abs(lengths.mean() - 1.0) < 1e-6, lengths.mean()
# Every edge shared by exactly 2 faces.
assert all(len(v) == 2 for v in self.edge_faces.values())
# Dihedral angles.
d33 = []
d35 = []
for e, (fa, fb) in self.edge_faces.items():
la, lb = len(self.faces[fa]), len(self.faces[fb])
d = self.dihedral_angle(fa, fb)
if la == 3 and lb == 3:
d33.append(d)
else:
d35.append(d)
# There are no pentagon-pentagon edges.
assert len(d35) == 60, len(d35) # 12 pentagons * 5 edges
assert len(d33) == 90, len(d33) # remaining
m33, m35 = float(np.mean(d33)), float(np.mean(d35))
assert abs(m33 - 164.1750) < 0.01, m33
assert abs(m35 - 152.9299) < 0.01, m35
return {
"vertices": nv,
"faces": nf,
"edges": ne,
"triangles": tris,
"pentagons": pents,
"edge_len": float(lengths.mean()),
"dihedral_3_3": m33,
"dihedral_3_5": m35,
}
def build_snub_dodecahedron() -> SnubDodecahedron:
s = SnubDodecahedron()
s.verify()
return s
if __name__ == "__main__":
s = build_snub_dodecahedron()
import json
print(json.dumps(s.verify(), indent=2))

330
snub-dodecahedron/unfold.py Normal file
View file

@ -0,0 +1,330 @@
"""
Unfold the snub dodecahedron into one or more flat, non-overlapping nets.
We grow each net greedily over the face-adjacency graph. A face is attached
to a growing net by rotating it (in 3D) about the shared edge until it is
coplanar with the rest of the net, then projecting to 2D. If attaching a
face would make it overlap a face already in the net, we leave it for a
later net. The result is a "spanning forest": a small number of connected
flat pieces that together tile the whole surface and can be glued up.
Each piece records:
- faces: [(face_index, [(x, y), ...]), ...] 2D polygons
- hinges: [Hinge(p, q, dihedral, kind), ...] fold lines (grooved)
- boundary: [Boundary(p, q, dihedral, inward, kind)] cut/glue edges (chamfered)
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
from snubgeom import SnubDodecahedron
@dataclass
class Hinge:
p: tuple # 2D endpoint
q: tuple
dihedral: float
kind: str # "3-3" or "3-5"
@dataclass
class Boundary:
p: tuple
q: tuple
dihedral: float
inward: tuple # unit 2D vector pointing into the panel (where to chamfer)
kind: str
@dataclass
class Piece:
faces: list = field(default_factory=list)
hinges: list = field(default_factory=list)
boundary: list = field(default_factory=list)
def bbox(self):
xs, ys = [], []
for _, poly in self.faces:
for x, y in poly:
xs.append(x)
ys.append(y)
return min(xs), min(ys), max(xs), max(ys)
# ---------------------------------------------------------------------------
# small linear-algebra helpers (homogeneous 4x4)
# ---------------------------------------------------------------------------
def _skew(v):
x, y, z = v
return np.array([[0, -z, y], [z, 0, -x], [-y, x, 0]], dtype=float)
def _rot_align(a, b):
"""3x3 rotation taking unit vector a onto unit vector b."""
a = a / np.linalg.norm(a)
b = b / np.linalg.norm(b)
v = np.cross(a, b)
c = float(a @ b)
s = np.linalg.norm(v)
if s < 1e-12:
if c > 0:
return np.eye(3)
# antiparallel: 180 deg about any axis perpendicular to a
perp = np.array([1.0, 0.0, 0.0])
if abs(a[0]) > 0.9:
perp = np.array([0.0, 1.0, 0.0])
axis = np.cross(a, perp)
axis = axis / np.linalg.norm(axis)
K = _skew(axis)
return np.eye(3) + 2 * (K @ K)
K = _skew(v)
return np.eye(3) + K + K @ K * ((1 - c) / (s * s))
def _mat4(R3=None, t=None):
M = np.eye(4)
if R3 is not None:
M[:3, :3] = R3
if t is not None:
M[:3, 3] = t
return M
def _rot_about_line(P, d, angle):
"""4x4: rotate by `angle` about the line through point P with direction d."""
d = d / np.linalg.norm(d)
K = _skew(d)
R3 = np.eye(3) + math.sin(angle) * K + (1 - math.cos(angle)) * (K @ K)
T1 = _mat4(t=-np.asarray(P, float))
T2 = _mat4(t=np.asarray(P, float))
return T2 @ _mat4(R3=R3) @ T1
def _apply(M, v):
h = np.array([v[0], v[1], v[2], 1.0])
return (M @ h)[:3]
# ---------------------------------------------------------------------------
# 2D geometry helpers
# ---------------------------------------------------------------------------
def _side(a, b, p):
"""Signed area sign of point p relative to directed line a->b."""
return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
def _poly_centroid(poly):
arr = np.array(poly)
return arr.mean(axis=0)
def _shrink(poly, factor):
c = _poly_centroid(poly)
return [tuple(c + (np.array(p) - c) * (1 - factor)) for p in poly]
def _convex_overlap(poly_a, poly_b):
"""SAT overlap test for two convex polygons (True if they overlap)."""
for poly in (poly_a, poly_b):
n = len(poly)
for i in range(n):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % n]
ax, ay = -(y2 - y1), (x2 - x1) # axis = edge normal
amin = min((ax * px + ay * py) for px, py in poly_a)
amax = max((ax * px + ay * py) for px, py in poly_a)
bmin = min((ax * px + ay * py) for px, py in poly_b)
bmax = max((ax * px + ay * py) for px, py in poly_b)
if amax <= bmin or bmax <= amin:
return False # separating axis found
return True
# ---------------------------------------------------------------------------
# unfolding
# ---------------------------------------------------------------------------
class Unfolder:
def __init__(self, solid: SnubDodecahedron, overlap_margin=2e-3):
self.s = solid
self.margin = overlap_margin
# face adjacency: fi -> list of (neighbor_fi, (va, vb))
self.adj = {fi: [] for fi in range(len(solid.faces))}
for e, fs in solid.edge_faces.items():
a, b = tuple(e)
fa, fb = fs
self.adj[fa].append((fb, (a, b)))
self.adj[fb].append((fa, (a, b)))
def _flatten_root(self, fi):
"""4x4 mapping world -> plane(z=0) for the root face fi."""
n = self.s.face_normal(fi)
R = _rot_align(n, np.array([0.0, 0.0, 1.0]))
M = _mat4(R3=R)
# translate so face sits on z=0
v0 = _apply(M, self.s.vertices[self.s.faces[fi][0]])
return _mat4(t=np.array([0.0, 0.0, -v0[2]])) @ M
def _poly2d(self, M, fi):
return [tuple(_apply(M, self.s.vertices[v])[:2]) for v in self.s.faces[fi]]
def _child_matrix(self, parent_M, fa, fb, edge):
"""Matrix that places face fb coplanar with the net built around fa."""
va, vb = edge
P = self.s.vertices[va]
Q = self.s.vertices[vb]
dih = self.s.dihedral_angle(fa, fb)
fold = math.radians(180.0 - dih)
edge2 = (
tuple(_apply(parent_M, P)[:2]),
tuple(_apply(parent_M, Q)[:2]),
)
parent_c = _poly_centroid(self._poly2d(parent_M, fa))
ps = _side(edge2[0], edge2[1], parent_c)
best = None
for ang in (fold, -fold):
U = _rot_about_line(P, Q - P, ang)
M = parent_M @ U
poly = self._poly2d(M, fb)
cc = _poly_centroid(poly)
cs = _side(edge2[0], edge2[1], cc)
# planarity check: all z near 0
zmax = max(abs(_apply(M, self.s.vertices[v])[2]) for v in self.s.faces[fb])
if zmax < 1e-6 and ps * cs < 0:
best = M
break
if best is None:
# fall back to the better of the two even if marginal
best = M
return best, edge2, dih
def unfold(self, root_face=None):
n_faces = len(self.s.faces)
# prefer to start pieces on pentagons (nice clusters of 5 triangles)
order = sorted(range(n_faces), key=lambda f: (len(self.s.faces[f]) != 5, f))
if root_face is not None:
order = [root_face] + [f for f in order if f != root_face]
unplaced = set(range(n_faces))
placed_M = {} # fi -> matrix (global, but each piece has own frame)
placed_poly = {} # fi -> 2D polygon
piece_of = {} # fi -> piece index
hinge_records = [] # (piece, edge2, dihedral)
pieces = []
for seed in order:
if seed not in unplaced:
continue
pidx = len(pieces)
piece_faces = {}
M = self._flatten_root(seed)
placed_M[seed] = M
poly = self._poly2d(M, seed)
placed_poly[seed] = poly
piece_faces[seed] = poly
piece_of[seed] = pidx
unplaced.discard(seed)
# grow piece maximally with repeated passes
changed = True
while changed:
changed = False
for f in list(piece_faces.keys()):
for nb, edge in self.adj[f]:
if nb not in unplaced:
continue
M_child, edge2, dih = self._child_matrix(
placed_M[f], f, nb, edge
)
cand = self._poly2d(M_child, nb)
cand_s = _shrink(cand, self.margin)
overlap = False
for of, opoly in piece_faces.items():
if of == f:
continue
if _convex_overlap(cand_s, _shrink(opoly, self.margin)):
overlap = True
break
if overlap:
continue
placed_M[nb] = M_child
placed_poly[nb] = cand
piece_faces[nb] = cand
piece_of[nb] = pidx
unplaced.discard(nb)
hinge_records.append((pidx, f, nb, edge2, dih))
changed = True
pieces.append(piece_faces)
# Assemble Piece objects with hinges + boundary edges.
result = []
hinge_by_piece = {}
hinge_edge_set = {} # piece -> set of frozenset(face pair) that are hinges
for pidx, fa, fb, edge2, dih in hinge_records:
hinge_by_piece.setdefault(pidx, []).append((edge2, dih, fa, fb))
hinge_edge_set.setdefault(pidx, set()).add(frozenset((fa, fb)))
for pidx, piece_faces in enumerate(pieces):
piece = Piece()
for fi, poly in piece_faces.items():
piece.faces.append((fi, poly))
for edge2, dih, fa, fb in hinge_by_piece.get(pidx, []):
kind = "3-3" if (len(self.s.faces[fa]) == 3 and len(self.s.faces[fb]) == 3) else "3-5"
piece.hinges.append(Hinge(edge2[0], edge2[1], dih, kind))
# boundary edges: every face edge whose partner is NOT a hinge in this piece
hset = hinge_edge_set.get(pidx, set())
seen_boundary = set()
for fi, poly in piece_faces.items():
face = self.s.faces[fi]
n = len(face)
for k in range(n):
va, vb = face[k], face[(k + 1) % n]
nb = None
for cand_nb, e in self.adj[fi]:
if frozenset(e) == frozenset((va, vb)):
nb = cand_nb
break
if nb is not None and frozenset((fi, nb)) in hset:
continue # this edge is an internal hinge
key = (fi, frozenset((va, vb)))
if key in seen_boundary:
continue
seen_boundary.add(key)
p2 = poly[k]
q2 = poly[(k + 1) % n]
dih = self.s.dihedral_angle(fi, nb) if nb is not None else 180.0
c = _poly_centroid(poly)
mid = ((p2[0] + q2[0]) / 2, (p2[1] + q2[1]) / 2)
inward = np.array([c[0] - mid[0], c[1] - mid[1]])
inward = inward / (np.linalg.norm(inward) + 1e-12)
kind = "3-3" if (nb is not None and len(self.s.faces[fi]) == 3 and len(self.s.faces[nb]) == 3) else "3-5"
piece.boundary.append(
Boundary(p2, q2, dih, tuple(inward), kind)
)
result.append(piece)
return result
if __name__ == "__main__":
s = SnubDodecahedron()
s.verify()
pieces = Unfolder(s).unfold()
total_faces = sum(len(p.faces) for p in pieces)
print(f"pieces: {len(pieces)}, total faces: {total_faces}")
for i, p in enumerate(pieces):
x0, y0, x1, y1 = p.bbox()
print(
f" piece {i}: {len(p.faces):2d} faces, "
f"{len(p.hinges):2d} hinges, {len(p.boundary):2d} boundary, "
f"size {x1 - x0:.2f} x {y1 - y0:.2f}"
)