├── .gitignore ├── .travis.yml ├── LDConfig.ldr ├── LICENSE ├── README.md ├── ldrawpy ├── __init__.py ├── constants.py ├── ldrarrows.py ├── ldrawpy.py ├── ldrcolour.py ├── ldrcolourdict.py ├── ldrhelpers.py ├── ldrmodel.py ├── ldrpprint.py ├── ldrprimitives.py ├── ldrshapes.py ├── ldvrender.py └── scripts │ ├── __init__.py │ └── ldrcat.py ├── setup.py ├── tests ├── rerun.sh ├── test_files │ ├── stylesheet.yml │ ├── stylesheet_dark.yml │ ├── test_model.ldr │ └── testfile2.ldr ├── test_ldrcolour.py ├── test_ldrprimitives.py └── test_misc.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | *.pdf 4 | *.png 5 | # C extensions 6 | *.so 7 | .vscode 8 | 9 | # OS litter 10 | .DS_Store 11 | Desktop.ini 12 | ._* 13 | Thumbs.db 14 | .Trashes 15 | 16 | # Packages 17 | *.egg 18 | *.egg-info 19 | dist 20 | build 21 | eggs 22 | parts 23 | bin 24 | var 25 | sdist 26 | develop-eggs 27 | .installed.cfg 28 | lib 29 | lib64 30 | __pycache__ 31 | 32 | # Installer logs 33 | pip-log.txt 34 | 35 | # Unit test / coverage reports 36 | .coverage 37 | .tox 38 | nosetests.xml 39 | 40 | # Translations 41 | *.mo 42 | 43 | # Mr Developer 44 | .mr.developer.cfg 45 | .project 46 | .pydevproject 47 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.7" 5 | 6 | before_script: 7 | - pip install setuptools==60.8.2 8 | - wget https://github.com/michaelgale/toolbox-py/archive/master.zip -O /tmp/toolbox.zip 9 | - unzip /tmp/toolbox.zip 10 | - cd toolbox-py-master 11 | - python setup.py install 12 | - cd .. 13 | 14 | script: 15 | - python setup.py install 16 | - cd tests 17 | - pytest 18 | -------------------------------------------------------------------------------- /LDConfig.ldr: -------------------------------------------------------------------------------- 1 | 0 LDraw.org Configuration File 2 | 0 Name: LDConfig.ldr 3 | 0 Author: LDraw.org 4 | 0 !LDRAW_ORG Configuration UPDATE 2017-12-15 5 | 6 | 0 // LDraw Solid Colours 7 | 0 // LEGOID 26 - Black 8 | 0 !COLOUR Black CODE 0 VALUE #05131D EDGE #FFFFFF 9 | 0 // LEGOID 23 - Bright Blue 10 | 0 !COLOUR Blue CODE 1 VALUE #0055BF EDGE #05131D 11 | 0 // LEGOID 28 - Dark Green 12 | 0 !COLOUR Green CODE 2 VALUE #257A3E EDGE #05131D 13 | 0 // LEGOID 107 - Bright Bluish Green 14 | 0 !COLOUR Dark_Turquoise CODE 3 VALUE #00838F EDGE #05131D 15 | 0 // LEGOID 21 - Bright Red 16 | 0 !COLOUR Red CODE 4 VALUE #C91A09 EDGE #05131D 17 | 0 // LEGOID 221 - Bright Purple 18 | 0 !COLOUR Dark_Pink CODE 5 VALUE #C870A0 EDGE #05131D 19 | 0 // LEGOID 217 - Brown 20 | 0 !COLOUR Brown CODE 6 VALUE #583927 EDGE #05131D 21 | 0 // LEGOID 2 - Grey 22 | 0 !COLOUR Light_Grey CODE 7 VALUE #9BA19D EDGE #05131D 23 | 0 // LEGOID 27 - Dark Grey 24 | 0 !COLOUR Dark_Grey CODE 8 VALUE #6D6E5C EDGE #05131D 25 | 0 // LEGOID 45 - Light Blue 26 | 0 !COLOUR Light_Blue CODE 9 VALUE #B4D2E3 EDGE #05131D 27 | 0 // LEGOID 37 - Bright Green 28 | 0 !COLOUR Bright_Green CODE 10 VALUE #4B9F4A EDGE #05131D 29 | 0 // LEGOID 116 - Medium Bluish Green 30 | 0 !COLOUR Light_Turquoise CODE 11 VALUE #55A5AF EDGE #05131D 31 | 0 // LEGOID 4 - Brick Red 32 | 0 !COLOUR Salmon CODE 12 VALUE #F2705E EDGE #05131D 33 | 0 // LEGOID 9 - Light Reddish Violet 34 | 0 !COLOUR Pink CODE 13 VALUE #FC97AC EDGE #05131D 35 | 0 // LEGOID 24 - Bright Yellow 36 | 0 !COLOUR Yellow CODE 14 VALUE #F2CD37 EDGE #05131D 37 | 0 // LEGOID 1 - White 38 | 0 !COLOUR White CODE 15 VALUE #FFFFFF EDGE #05131D 39 | 0 // LEGOID 6 - Light Green 40 | 0 !COLOUR Light_Green CODE 17 VALUE #C2DAB8 EDGE #05131D 41 | 0 // LEGOID 3 - Light Yellow 42 | 0 !COLOUR Light_Yellow CODE 18 VALUE #FBE696 EDGE #05131D 43 | 0 // LEGOID 5 - Brick Yellow 44 | 0 !COLOUR Tan CODE 19 VALUE #E4CD9E EDGE #05131D 45 | 0 // LEGOID 39 - Light Bluish Violet 46 | 0 !COLOUR Light_Violet CODE 20 VALUE #C9CAE2 EDGE #05131D 47 | 0 // LEGOID 104 - Bright Violet 48 | 0 !COLOUR Purple CODE 22 VALUE #81007B EDGE #05131D 49 | 0 // LEGOID 196 - Dark Royal Blue 50 | 0 !COLOUR Dark_Blue_Violet CODE 23 VALUE #2032B0 EDGE #05131D 51 | 0 // LEGOID 106 - Bright Orange 52 | 0 !COLOUR Orange CODE 25 VALUE #FE8A18 EDGE #05131D 53 | 0 // LEGOID 124 - Bright Reddish Violet 54 | 0 !COLOUR Magenta CODE 26 VALUE #923978 EDGE #05131D 55 | 0 // LEGOID 119 - Bright Yellowish Green 56 | 0 !COLOUR Lime CODE 27 VALUE #BBE90B EDGE #05131D 57 | 0 // LEGOID 138 - Sand Yellow 58 | 0 !COLOUR Dark_Tan CODE 28 VALUE #958A73 EDGE #05131D 59 | 0 // LEGOID 222 - Light Purple 60 | 0 !COLOUR Bright_Pink CODE 29 VALUE #E4ADC8 EDGE #05131D 61 | 0 // LEGOID 324 - Medium Lavender 62 | 0 !COLOUR Medium_Lavender CODE 30 VALUE #AC78BA EDGE #05131D 63 | 0 // LEGOID 325 - Lavender 64 | 0 !COLOUR Lavender CODE 31 VALUE #E1D5ED EDGE #05131D 65 | 0 // LEGOID 36 - Light Yellowish Orange 66 | 0 !COLOUR Very_Light_Orange CODE 68 VALUE #F3CF9B EDGE #05131D 67 | 0 // LEGOID 198 - Bright Reddish Lilac 68 | 0 !COLOUR Bright_Reddish_Lilac CODE 69 VALUE #CD6298 EDGE #05131D 69 | 0 // LEGOID 192 - Reddish Brown 70 | 0 !COLOUR Reddish_Brown CODE 70 VALUE #582A12 EDGE #05131D 71 | 0 // LEGOID 194 - Medium Stone Grey 72 | 0 !COLOUR Light_Bluish_Grey CODE 71 VALUE #A0A5A9 EDGE #05131D 73 | 0 // LEGOID 199 - Dark Stone Grey 74 | 0 !COLOUR Dark_Bluish_Grey CODE 72 VALUE #6C6E68 EDGE #05131D 75 | 0 // LEGOID 102 - Medium Blue 76 | 0 !COLOUR Medium_Blue CODE 73 VALUE #5C9DD1 EDGE #05131D 77 | 0 // LEGOID 29 - Medium Green 78 | 0 !COLOUR Medium_Green CODE 74 VALUE #73DCA1 EDGE #05131D 79 | 0 // LEGOID 223 - Light Pink 80 | 0 !COLOUR Light_Pink CODE 77 VALUE #FECCCF EDGE #05131D 81 | 0 // LEGOID 283 - Light Nougat 82 | 0 !COLOUR Light_Flesh CODE 78 VALUE #F6D7B3 EDGE #05131D 83 | 0 // LEGOID 38 - Dark Orange 84 | 0 !COLOUR Medium_Dark_Flesh CODE 84 VALUE #CC702A EDGE #05131D 85 | 0 // LEGOID 268 - Medium Lilac 86 | 0 !COLOUR Medium_Lilac CODE 85 VALUE #3F3691 EDGE #05131D 87 | 0 // LEGOID 312 - Medium Nougat 88 | 0 !COLOUR Dark_Flesh CODE 86 VALUE #7C503A EDGE #05131D 89 | 0 // LEGOID 195 - Medium Royal Blue 90 | 0 !COLOUR Blue_Violet CODE 89 VALUE #4C61DB EDGE #05131D 91 | 0 // LEGOID 18 - Nougat 92 | 0 !COLOUR Flesh CODE 92 VALUE #D09168 EDGE #05131D 93 | 0 // LEGOID 100 - Light Red 94 | 0 !COLOUR Light_Salmon CODE 100 VALUE #FEBABD EDGE #05131D 95 | 0 // LEGOID 110 - Bright Bluish Violet 96 | 0 !COLOUR Violet CODE 110 VALUE #4354A3 EDGE #05131D 97 | 0 // LEGOID 112 - Medium Bluish Violet 98 | 0 !COLOUR Medium_Violet CODE 112 VALUE #6874CA EDGE #05131D 99 | 0 // LEGOID 115 - Medium Yellowish Green 100 | 0 !COLOUR Medium_Lime CODE 115 VALUE #C7D23C EDGE #05131D 101 | 0 // LEGOID 118 - Light Bluish Green 102 | 0 !COLOUR Aqua CODE 118 VALUE #B3D7D1 EDGE #05131D 103 | 0 // LEGOID 120 - Light Yellowish Green 104 | 0 !COLOUR Light_Lime CODE 120 VALUE #D9E4A7 EDGE #05131D 105 | 0 // LEGOID 125 - Light Orange 106 | 0 !COLOUR Light_Orange CODE 125 VALUE #F9BA61 EDGE #05131D 107 | 0 // LEGOID 208 - Light Stone Grey 108 | 0 !COLOUR Very_Light_Bluish_Grey CODE 151 VALUE #E6E3E0 EDGE #05131D 109 | 0 // LEGOID 191 - Flame Yellowish Orange 110 | 0 !COLOUR Bright_Light_Orange CODE 191 VALUE #F8BB3D EDGE #05131D 111 | 0 // LEGOID 212 - Light Royal Blue 112 | 0 !COLOUR Bright_Light_Blue CODE 212 VALUE #86C1E1 EDGE #05131D 113 | 0 // LEGOID 216 - Rust 114 | 0 !COLOUR Rust CODE 216 VALUE #B31004 EDGE #05131D 115 | 0 // LEGOID 226 - Cool Yellow 116 | 0 !COLOUR Bright_Light_Yellow CODE 226 VALUE #FFF03A EDGE #05131D 117 | 0 // LEGOID 232 - Dove Blue 118 | 0 !COLOUR Sky_Blue CODE 232 VALUE #56BED6 EDGE #05131D 119 | 0 // LEGOID 140 - Earth Blue 120 | 0 !COLOUR Dark_Blue CODE 272 VALUE #0D325B EDGE #05131D 121 | 0 // LEGOID 141 - Earth Green 122 | 0 !COLOUR Dark_Green CODE 288 VALUE #184632 EDGE #05131D 123 | 0 // LEGOID 308 - Dark Brown 124 | 0 !COLOUR Dark_Brown CODE 308 VALUE #352100 EDGE #05131D 125 | 0 // LEGOID 11 - Pastel Blue 126 | 0 !COLOUR Maersk_Blue CODE 313 VALUE #54A9C8 EDGE #05131D 127 | 0 // LEGOID 154 - New Dark Red 128 | 0 !COLOUR Dark_Red CODE 320 VALUE #720E0F EDGE #05131D 129 | 0 // LEGOID 321 - Dark Azur 130 | 0 !COLOUR Dark_Azure CODE 321 VALUE #1498D7 EDGE #05131D 131 | 0 // LEGOID 322 - Medium Azur 132 | 0 !COLOUR Medium_Azure CODE 322 VALUE #3EC2DD EDGE #05131D 133 | 0 // LEGOID 323 - Aqua 134 | 0 !COLOUR Light_Aqua CODE 323 VALUE #BDDCD8 EDGE #05131D 135 | 0 // LEGOID 326 - Spring Yellowish Green 136 | 0 !COLOUR Yellowish_Green CODE 326 VALUE #DFEEA5 EDGE #05131D 137 | 0 // LEGOID 330 - Olive Green 138 | 0 !COLOUR Olive_Green CODE 330 VALUE #9B9A5A EDGE #05131D 139 | 0 // LEGOID 153 - Sand Red 140 | 0 !COLOUR Sand_Red CODE 335 VALUE #D67572 EDGE #05131D 141 | 0 // LEGOID 22 - Medium Reddish Violet 142 | 0 !COLOUR Medium_Dark_Pink CODE 351 VALUE #F785B1 EDGE #05131D 143 | 0 // LEGOID 25 - Earth Orange 144 | 0 !COLOUR Earth_Orange CODE 366 VALUE #FA9C1C EDGE #05131D 145 | 0 // LEGOID 136 - Sand Violet 146 | 0 !COLOUR Sand_Purple CODE 373 VALUE #845E84 EDGE #05131D 147 | 0 // LEGOID 151 - Sand Green 148 | 0 !COLOUR Sand_Green CODE 378 VALUE #A0BCAC EDGE #05131D 149 | 0 // LEGOID 135 - Sand Blue 150 | 0 !COLOUR Sand_Blue CODE 379 VALUE #597184 EDGE #05131D 151 | 0 // LEGOID 12 - Light Orange Brown 152 | 0 !COLOUR Fabuland_Brown CODE 450 VALUE #B67B50 EDGE #05131D 153 | 0 // LEGOID 105 - Bright Yellowish Orange 154 | 0 !COLOUR Medium_Orange CODE 462 VALUE #FFA70B EDGE #05131D 155 | 0 // LEGOID 38 - Dark Orange 156 | 0 !COLOUR Dark_Orange CODE 484 VALUE #A95500 EDGE #05131D 157 | 0 // LEGOID 103 - Light Grey 158 | 0 !COLOUR Very_Light_Grey CODE 503 VALUE #E6E3DA EDGE #05131D 159 | 0 // LEGOID 218 - Reddish Lilac 160 | 0 !COLOUR Reddish_Lilac CODE 218 VALUE #8E5597 EDGE #05131D 161 | 0 // LEGOID 295 - Flamingo Pink 162 | 0 !COLOUR Flamingo_Pink CODE 295 VALUE #FF94C2 EDGE #05131D 163 | 0 // LEGOID 219 - Lilac 164 | 0 !COLOUR Lilac CODE 219 VALUE #564E9D EDGE #05131D 165 | 0 // LEGOID 128 - Dark Nougat 166 | 0 !COLOUR Dark_Nougat CODE 128 VALUE #AD6140 EDGE #05131D 167 | 168 | 169 | 0 // LDraw Transparent Colours 170 | 0 // LEGOID 40 - Transparent 171 | 0 !COLOUR Trans_Clear CODE 47 VALUE #ECECEC EDGE #222222 ALPHA 128 172 | 0 // LEGOID 111 - Transparent Brown 173 | 0 !COLOUR Trans_Black CODE 40 VALUE #635F52 EDGE #171316 ALPHA 128 174 | 0 // LEGOID 41 - Transparent Red 175 | 0 !COLOUR Trans_Red CODE 36 VALUE #C91A09 EDGE #880000 ALPHA 128 176 | 0 // LEGOID 47 - Transparent Fluorescent Reddish Orange 177 | 0 !COLOUR Trans_Neon_Orange CODE 38 VALUE #FF800D EDGE #BD2400 ALPHA 128 178 | 0 // LEGOID 182 - Trans Bright Orange 179 | 0 !COLOUR Trans_Orange CODE 57 VALUE #F08F1C EDGE #A45C28 ALPHA 128 180 | 0 // LEGOID 157 - Transparent Fluorescent Yellow 181 | 0 !COLOUR Trans_Neon_Yellow CODE 54 VALUE #DAB000 EDGE #C3BA3F ALPHA 128 182 | 0 // LEGOID 44 - Transparent Yellow 183 | 0 !COLOUR Trans_Yellow CODE 46 VALUE #F5CD2F EDGE #8E7400 ALPHA 128 184 | 0 // LEGOID 49 - Transparent Fluorescent Green 185 | 0 !COLOUR Trans_Neon_Green CODE 42 VALUE #C0FF00 EDGE #84C300 ALPHA 128 186 | 0 // LEGOID 311 / 227 - Transparent Bright Green / Transparent Bright Yellowish Green 187 | 0 !COLOUR Trans_Bright_Green CODE 35 VALUE #56E646 EDGE #9DA86B ALPHA 128 188 | 0 // LEGOID 48 - Transparent Green 189 | 0 !COLOUR Trans_Green CODE 34 VALUE #237841 EDGE #1E6239 ALPHA 128 190 | 0 // LEGOID 43 - Transparent Blue 191 | 0 !COLOUR Trans_Dark_Blue CODE 33 VALUE #0020A0 EDGE #000064 ALPHA 128 192 | 0 // LEGOID 143 - Transparent Fluorescent Blue 193 | 0 !COLOUR Trans_Medium_Blue CODE 41 VALUE #559AB7 EDGE #196973 ALPHA 128 194 | 0 // LEGOID 42 - Transparent Light Blue 195 | 0 !COLOUR Trans_Light_Blue CODE 43 VALUE #AEE9EF EDGE #224340 ALPHA 128 196 | 0 // LEGOID 229 - Transparent Light Bluish Green 197 | 0 !COLOUR Trans_Very_Light_Blue CODE 39 VALUE #C1DFF0 EDGE #85A3B4 ALPHA 128 198 | 0 // LEGOID 236 - Transparent Bright Reddish Lilac 199 | 0 !COLOUR Trans_Bright_Reddish_Lilac CODE 44 VALUE #96709F EDGE #5A3463 ALPHA 128 200 | 0 // LEGOID 126 - Transparent Bright Bluish Violet 201 | 0 !COLOUR Trans_Purple CODE 52 VALUE #A5A5CB EDGE #280025 ALPHA 128 202 | 0 // LEGOID 113 - Transparent Medium Reddish Violet 203 | 0 !COLOUR Trans_Dark_Pink CODE 37 VALUE #DF6695 EDGE #A32A59 ALPHA 128 204 | 0 // LEGOID 230 - Transparent Bright Pink 205 | 0 !COLOUR Trans_Pink CODE 45 VALUE #FC97AC EDGE #A8718C ALPHA 128 206 | 0 // LEGOID 285 - Transparent Light Green 207 | 0 !COLOUR Trans_Light_Green CODE 285 VALUE #7DC291 EDGE #52805F ALPHA 128 208 | 0 // LEGOID 234 - Transparent Fire Yellow 209 | 0 !COLOUR Trans_Fire_Yellow CODE 234 VALUE #FBE890 EDGE #BAAB6A ALPHA 128 210 | 0 // LEGOID 293 - Transparent Light Royal Blue 211 | 0 !COLOUR Trans_Light_Blue_Violet CODE 293 VALUE #6BABE4 EDGE #4D7BA3 ALPHA 128 212 | 0 // LEGOID 231 - Transparent Flame Yellowish Orange 213 | 0 !COLOUR Trans_Bright_Light_Orange CODE 231 VALUE #FCB76D EDGE #BD8951 ALPHA 128 214 | 0 // LEGOID 284 - Transparent Reddish Lilac 215 | 0 !COLOUR Trans_Reddish_Lilac CODE 284 VALUE #C281A5 EDGE #82566E ALPHA 128 216 | 217 | 218 | 0 // LDraw Chrome Colours 219 | 0 // LEGOID 299 - Warm Gold Drum Lacq 220 | 0 !COLOUR Chrome_Gold CODE 334 VALUE #BBA53D EDGE #BBB23D CHROME 221 | 0 // LEGOID 298 - Cool Silver Drum Lacq 222 | 0 !COLOUR Chrome_Silver CODE 383 VALUE #E0E0E0 EDGE #A4A4A4 CHROME 223 | 0 // LEGOID 187 - Metallic Earth Orange 224 | 0 !COLOUR Chrome_Antique_Brass CODE 60 VALUE #645A4C EDGE #281E10 CHROME 225 | 0 !COLOUR Chrome_Black CODE 64 VALUE #1B2A34 EDGE #595959 CHROME 226 | 0 // LEGOID 185 - Metallic Bright Blue 227 | 0 !COLOUR Chrome_Blue CODE 61 VALUE #6C96BF EDGE #202A68 CHROME 228 | 0 // LEGOID 147 - Metallic Dark Green 229 | 0 !COLOUR Chrome_Green CODE 62 VALUE #3CB371 EDGE #007735 CHROME 230 | 0 !COLOUR Chrome_Pink CODE 63 VALUE #AA4D8E EDGE #6E1152 CHROME 231 | 232 | 233 | 0 // LDraw Pearl Colours 234 | 0 // LEGOID 183 - Metallic White 235 | 0 !COLOUR Pearl_White CODE 183 VALUE #F2F3F2 EDGE #05131D PEARLESCENT 236 | 0 // LEGOID 150 - Metallic Light Grey 237 | 0 !COLOUR Pearl_Very_Light_Grey CODE 150 VALUE #BBBDBC EDGE #05131D PEARLESCENT 238 | 0 // LEGOID 179 / 296 / 131 / 315 - Silver Flip-flop / Cool Silver / Silver / Silver Metallic 239 | 0 !COLOUR Pearl_Light_Grey CODE 135 VALUE #9CA3A8 EDGE #05131D PEARLESCENT 240 | 0 // LEGOID 131 - Silver 241 | 0 !COLOUR Flat_Silver CODE 179 VALUE #898788 EDGE #05131D PEARLESCENT 242 | 0 // LEGOID 148 - Metallic Dark Grey 243 | 0 !COLOUR Pearl_Dark_Grey CODE 148 VALUE #575857 EDGE #05131D PEARLESCENT 244 | 0 // LEGOID 145 - Sand Blue Metallic 245 | 0 !COLOUR Metal_Blue CODE 137 VALUE #5677BA EDGE #05131D PEARLESCENT 246 | 0 // LEGOID 127 - Gold 247 | 0 !COLOUR Pearl_Light_Gold CODE 142 VALUE #DCBE61 EDGE #05131D PEARLESCENT 248 | 0 // LEGOID 297 - Warm Gold 249 | 0 !COLOUR Pearl_Gold CODE 297 VALUE #CC9C2B EDGE #05131D PEARLESCENT 250 | 0 // LEGOID 147 - Metallic Sand Yellow 251 | 0 !COLOUR Flat_Dark_Gold CODE 178 VALUE #B4883E EDGE #05131D PEARLESCENT 252 | 0 // LEGOID 139 - Copper 253 | 0 !COLOUR Copper CODE 134 VALUE #964A27 EDGE #05131D PEARLESCENT 254 | 0 // LEGOID 189 - Reddish Gold 255 | 0 !COLOUR Reddish_Gold CODE 189 VALUE #AC8247 EDGE #05131D PEARLESCENT 256 | 257 | 258 | 0 // LDraw Metallic Colours 259 | 0 // LEGOID 315 - Silver Metallic 260 | 0 !COLOUR Metallic_Silver CODE 80 VALUE #A5A9B4 EDGE #05131D METAL 261 | 0 // LEGOID 200 - Lemon Metallic 262 | 0 !COLOUR Metallic_Green CODE 81 VALUE #899B5F EDGE #05131D METAL 263 | 0 // LEGOID 310 / 335 Metalized Gold / Gold Ink 264 | 0 !COLOUR Metallic_Gold CODE 82 VALUE #DBAC34 EDGE #05131D METAL 265 | 0 // LEGOID 149 - Metallic Black 266 | 0 !COLOUR Metallic_Black CODE 83 VALUE #1A2831 EDGE #05131D METAL 267 | 0 // LEGOID 309 / 336 - Metalized Silver / Silver Ink 268 | 0 !COLOUR Metallic_Dark_Grey CODE 87 VALUE #6D6E5C EDGE #05131D METAL 269 | 0 // LEGOID 300 / 334 - Copper Drum Lacq / Copper Ink 270 | 0 !COLOUR Metallic_Copper CODE 300 VALUE #C27F53 EDGE #05131D METAL 271 | 0 // LEGOID 184 - Metallic Bright Red 272 | 0 !COLOUR Metallic_Bright_Red CODE 184 VALUE #D60026 EDGE #05131D METAL 273 | 0 // LEGOID 186 - Metallic Dark Green 274 | 0 !COLOUR Metallic_Dark_Green CODE 186 VALUE #008E3C EDGE #05131D METAL 275 | 276 | 277 | 0 // LDraw Milky Colours 278 | 0 // LEGOID 20 - Nature 279 | 0 !COLOUR Milky_White CODE 79 VALUE #FFFFFF EDGE #C3C3C3 ALPHA 240 280 | 0 // LEGOID 294 - Phosphorescent Green 281 | 0 !COLOUR Glow_In_Dark_Opaque CODE 21 VALUE #E0FFB0 EDGE #A4C374 ALPHA 240 LUMINANCE 15 282 | 0 // LEGOID 50 - Phosphorescent White 283 | 0 !COLOUR Glow_In_Dark_Trans CODE 294 VALUE #BDC6AD EDGE #818A71 ALPHA 240 LUMINANCE 15 284 | 0 // LEGOID 329 - White Glow 285 | 0 !COLOUR Glow_In_Dark_White CODE 329 VALUE #F5F3D7 EDGE #B5B49F ALPHA 240 LUMINANCE 15 286 | 287 | 288 | 0 // LDraw Glitter Colours 289 | 0 // LEGOID 114 - Tr. Medium Reddish-Violet w. Glitter 2% 290 | 0 !COLOUR Glitter_Trans_Dark_Pink CODE 114 VALUE #DF6695 EDGE #9A2A66 ALPHA 128 MATERIAL GLITTER VALUE #923978 FRACTION 0.17 VFRACTION 0.2 SIZE 1 291 | 0 // LEGOID 117 - Transparent Glitter 292 | 0 !COLOUR Glitter_Trans_Clear CODE 117 VALUE #FFFFFF EDGE #C3C3C3 ALPHA 128 MATERIAL GLITTER VALUE #FFFFFF FRACTION 0.08 VFRACTION 0.1 SIZE 1 293 | 0 // LEGOID 129 - Tr. Bright Bluish Violet w. Glitter 2% 294 | 0 !COLOUR Glitter_Trans_Purple CODE 129 VALUE #640061 EDGE #280025 ALPHA 128 MATERIAL GLITTER VALUE #8C00FF FRACTION 0.3 VFRACTION 0.4 SIZE 1 295 | 0 // LEGOID 302 Tr. Light Blue with Glitter 2% 296 | 0 !COLOUR Glitter_Trans_Light_Blue CODE 302 VALUE #AEE9EF EDGE #72B3B0 ALPHA 128 MATERIAL GLITTER VALUE #923978 FRACTION 0.17 VFRACTION 0.2 SIZE 1 297 | 0 // LEGOID 339 Tr Fluorescent Green with Glitter 2% 298 | 0 !COLOUR Glitter_Trans_Neon_Green CODE 339 VALUE #C0FF00 EDGE #84C300 ALPHA 128 MATERIAL GLITTER VALUE #923978 FRACTION 0.17 VFRACTION 0.2 SIZE 1 299 | 300 | 301 | 0 // LDraw Speckle Colours 302 | 0 !COLOUR Speckle_Black_Silver CODE 132 VALUE #000000 EDGE #898788 MATERIAL SPECKLE VALUE #898788 FRACTION 0.4 MINSIZE 1 MAXSIZE 3 303 | 0 // LEGOID 132 - Black Glitter 304 | 0 !COLOUR Speckle_Black_Gold CODE 133 VALUE #000000 EDGE #DBAC34 MATERIAL SPECKLE VALUE #DBAC34 FRACTION 0.4 MINSIZE 1 MAXSIZE 3 305 | 0 !COLOUR Speckle_Black_Copper CODE 75 VALUE #000000 EDGE #AB6038 MATERIAL SPECKLE VALUE #AB6038 FRACTION 0.4 MINSIZE 1 MAXSIZE 3 306 | 0 !COLOUR Speckle_Dark_Bluish_Grey_Silver CODE 76 VALUE #635F61 EDGE #898788 MATERIAL SPECKLE VALUE #898788 FRACTION 0.4 MINSIZE 1 MAXSIZE 3 307 | 308 | 309 | 0 // LDraw Rubber Colours 310 | 0 !COLOUR Rubber_Yellow CODE 65 VALUE #F5CD2F EDGE #05131D RUBBER 311 | 0 !COLOUR Rubber_Trans_Yellow CODE 66 VALUE #CAB000 EDGE #8E7400 ALPHA 128 RUBBER 312 | 0 !COLOUR Rubber_Trans_Clear CODE 67 VALUE #FFFFFF EDGE #C3C3C3 ALPHA 128 RUBBER 313 | 0 !COLOUR Rubber_Black CODE 256 VALUE #212121 EDGE #595959 RUBBER 314 | 0 !COLOUR Rubber_Blue CODE 273 VALUE #0033B2 EDGE #05131D RUBBER 315 | 0 !COLOUR Rubber_Red CODE 324 VALUE #C40026 EDGE #05131D RUBBER 316 | 0 !COLOUR Rubber_Orange CODE 350 VALUE #D06610 EDGE #05131D RUBBER 317 | 0 !COLOUR Rubber_Light_Grey CODE 375 VALUE #C1C2C1 EDGE #05131D RUBBER 318 | 0 !COLOUR Rubber_Dark_Blue CODE 406 VALUE #001D68 EDGE #595959 RUBBER 319 | 0 !COLOUR Rubber_Purple CODE 449 VALUE #81007B EDGE #05131D RUBBER 320 | 0 !COLOUR Rubber_Lime CODE 490 VALUE #D7F000 EDGE #05131D RUBBER 321 | 0 !COLOUR Rubber_Light_Bluish_Grey CODE 496 VALUE #A3A2A4 EDGE #05131D RUBBER 322 | 0 !COLOUR Rubber_Flat_Silver CODE 504 VALUE #898788 EDGE #05131D RUBBER 323 | 0 !COLOUR Rubber_White CODE 511 VALUE #FAFAFA EDGE #05131D RUBBER 324 | 325 | 326 | 0 // LDraw Internal Common Material Colours 327 | 0 !COLOUR Main_Colour CODE 16 VALUE #FFFF80 EDGE #05131D 328 | 0 !COLOUR Edge_Colour CODE 24 VALUE #7F7F7F EDGE #05131D 329 | 0 // LEGOID 109 - Black IR 330 | 0 !COLOUR Trans_Black_IR_Lens CODE 32 VALUE #000000 EDGE #05131D ALPHA 210 331 | 0 !COLOUR Magnet CODE 493 VALUE #656761 EDGE #595959 METAL 332 | 0 !COLOUR Electric_Contact_Alloy CODE 494 VALUE #D0D0D0 EDGE #05131D METAL 333 | 0 !COLOUR Electric_Contact_Copper CODE 495 VALUE #AE7A59 EDGE #05131D METAL 334 | 0 335 | 336 | 0 !COLOUR TrueBlack CODE 500 VALUE #05131D EDGE #05131D 337 | 0 !COLOUR TrueWhite CODE 501 VALUE #FFFFFF EDGE #FFFFFF 338 | 0 !COLOUR TrueClear CODE 502 VALUE #80FF80 EDGE #20FF20 ALPHA 1 339 | 340 | 0 !COLOUR ArrowRed CODE 804 VALUE #C91A09 EDGE #05131D LUMINANCE 15 341 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Michael Gale 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ldraw-py 2 | 3 | ![python version](https://img.shields.io/static/v1?label=python&message=3.6%2B&color=blue&style=flat&logo=python) 4 | ![https://github.com/michaelgale/toolbox-py/blob/master/LICENSE](https://img.shields.io/badge/license-MIT-blue.svg) 5 | Code style: black 6 | 7 | ![https://travis-ci.org/michaelgale/ldraw-py](https://travis-ci.com/michaelgale/ldraw-py.svg?branch=master) 8 | 9 | A utility package for creating, modifying, and reading LDraw files and data structures. 10 | 11 | LDraw is an open standard for LEGO® CAD software. It is based on a hierarchy of elements describing primitive shapes up to complex LEGO models and scenes. 12 | 13 | ## Installation 14 | 15 | The **ldraw-py** package can be installed directly from the source code: 16 | 17 | ```shell 18 | $ git clone https://github.com/michaelgale/ldraw-py.git 19 | $ cd ldraw-py 20 | $ python setup.py install 21 | ``` 22 | 23 | ## Usage 24 | 25 | After installation, the package can imported: 26 | 27 | ```shell 28 | $ python 29 | >>> import ldrawpy 30 | >>> ldrawpy.__version__ 31 | ``` 32 | 33 | An example of the package can be seen below 34 | 35 | ```python 36 | from ldrawpy import LDRColour 37 | 38 | # Create a white colour using LDraw colour code 15 for white 39 | mycolour = LDRColour(15) 40 | print(mycolour) 41 | ``` 42 | 43 | ```shell 44 | White 45 | ``` 46 | 47 | ## Requirements 48 | 49 | * Python 3.7+ 50 | * toolbox-py 51 | 52 | ## References 53 | 54 | - [LDraw.org](https://www.ldraw.org) - Official maintainer of the LDraw file format specification and the LDraw official part library. 55 | - [ldraw-vscode](https://github.com/michaelgale/ldraw-vscode) - Visual Studio Code language extension plug-in for LDraw files 56 | 57 | ### Lego CAD Tools 58 | 59 | - [Bricklink stud.io](https://www.bricklink.com/v3/studio/download.page) new and modern design tool designed and maintained by Bricklink 60 | - [LeoCAD](https://www.leocad.org) cross platform tool 61 | - [MLCAD](http://mlcad.lm-software.com) for Windows 62 | - [Bricksmith](http://bricksmith.sourceforge.net) for macOS by Allen Smith (no longer maintained) 63 | - [LDView](http://ldview.sourceforge.net) real-time 3D viewer for LDraw models 64 | 65 | ### LPub Instructions Tools 66 | 67 | - Original [LPub](http://lpub.binarybricks.nl) publishing tool by Kevin Clague 68 | - [LPub3D](https://trevorsandy.github.io/lpub3d/) successor to LPub by Trevor Sandy 69 | - [Manual](https://sites.google.com/site/workingwithlpub/lpub-4) for Legacy LPub 4 tool (last version by Kevin Clague) 70 | 71 | ## Authors 72 | 73 | `ldraw-py` was written by [Michael Gale](https://github.com/michaelgale) 74 | -------------------------------------------------------------------------------- /ldrawpy/__init__.py: -------------------------------------------------------------------------------- 1 | """ldrawpy - A utility package for creating, modifying, and reading LDraw files and data structures.""" 2 | 3 | import os 4 | 5 | # fmt: off 6 | __project__ = 'ldrawpy' 7 | __version__ = '0.6.0' 8 | # fmt: on 9 | 10 | VERSION = __project__ + "-" + __version__ 11 | 12 | script_dir = os.path.dirname(__file__) 13 | 14 | from .constants import * 15 | from .ldrawpy import brick_name_strip, xyz_to_ldr, mesh_to_ldr 16 | from .ldrcolourdict import * 17 | from .ldrhelpers import * 18 | from .ldrcolour import LDRColour 19 | from .ldrprimitives import LDRAttrib, LDRHeader, LDRLine, LDRTriangle, LDRQuad, LDRPart 20 | from .ldrshapes import * 21 | from .ldrmodel import LDRModel, parse_special_tokens, sort_parts, get_sha1_hash 22 | from .ldvrender import LDViewRender 23 | from .ldrarrows import ArrowContext, arrows_for_step, remove_offset_parts 24 | from .ldrpprint import pprint_line, clean_line, clean_file 25 | -------------------------------------------------------------------------------- /ldrawpy/constants.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # Constants 25 | 26 | LDR_OPT_COLOUR = 24 27 | LDR_DEF_COLOUR = 16 28 | 29 | # 30 | # special colour codes for use with labels 31 | # 32 | LDR_ALL_COLOUR = 1000 33 | LDR_ANY_COLOUR = 1001 34 | LDR_OTHER_COLOUR = 1002 35 | LDR_MONO_COLOUR = 1003 36 | LDR_BLKWHT_COLOUR = 1004 37 | LDR_GRAY_COLOUR = 1005 38 | LDR_REDYLW_COLOUR = 1006 39 | LDR_BLUYLW_COLOUR = 1007 40 | LDR_REDBLUYLW_COLOUR = 1008 41 | LDR_GRNBRN_COLOUR = 1009 42 | LDR_BLUBRN_COLOUR = 1010 43 | LDR_BRGREEN_COLOUR = 1011 44 | LDR_LAVENDER_COLOUR = 1012 45 | LDR_PINK_COLOUR = 1013 46 | LDR_LTYLW_COLOUR = 1014 47 | LDR_BLUBLU_COLOUR = 1015 48 | LDR_DKREDBLU_COLOUR = 1016 49 | LDR_ORGYLW_COLOUR = 1017 50 | LDR_ORGBRN_COLOUR = 1018 51 | LDR_BLUES_COLOUR = 1019 52 | LDR_GREENS_COLOUR = 1020 53 | LDR_YELLOWS_COLOUR = 1021 54 | LDR_REDORG_COLOUR = 1022 55 | LDR_TANBRN_COLOUR = 1023 56 | LDR_REDORGYLW_COLOUR = 1024 57 | LDR_BLUGRN_COLOUR = 1025 58 | LDR_TAN_COLOUR = 1026 59 | LDR_PINKPURP_COLOUR = 1027 60 | 61 | LDR_ANY_COLOUR_FILL = ["E6E6E6", "8EDAFF", "FFFF66", "FD908F"] 62 | 63 | LDRAW_TOKENS = [ 64 | "STEP", 65 | "FILE", 66 | "NOFILE", 67 | "WRITE", 68 | "PRINT", 69 | "CLEAR", 70 | "PAUSE", 71 | "SAVE", 72 | "BFC", 73 | ] 74 | META_TOKENS = [ 75 | "ROTSTEP", 76 | "BACKGROUND", 77 | "GHOST", 78 | "GROUP", 79 | "MLCAD", 80 | "ROTATION", 81 | "SYNTH", 82 | "L3P", 83 | "COLOR", 84 | "COLOUR", 85 | "TRANSLATE", 86 | "ROTATE", 87 | "SCALE", 88 | "TRANSFORM", 89 | "COLOURNAME", 90 | "COLORNAME", 91 | "POINT", 92 | "MATRIX", 93 | "CMDLINE", 94 | "BEGIN", 95 | "END", 96 | ] 97 | 98 | SPECIAL_TOKENS = { 99 | "scale": ["!LPUB ASSEM MODEL_SCALE %-1", "!PY SCALE %-1"], 100 | "rotation_abs": ["ROTSTEP %2 %3 %4 ABS"], 101 | "rotation_rel": ["ROTSTEP %2 %3 %4 REL"], 102 | "page_break": ["!LPUB INSERT PAGE", "!PY PAGE_BREAK"], 103 | "columns": ["!PY COLUMNS %-1"], 104 | "column_break": ["!PY COLUMN_BREAK"], 105 | "arrow_begin": ["!PY ARROW BEGIN %4 %5 %6 %7 %8 %9 %10 %11 %12"], 106 | "arrow_colour": ["!PY ARROW COLOUR %-1"], 107 | "arrow_length": ["!PY ARROW LENGTH %-1"], 108 | "arrow_end": ["!PY ARROW END"], 109 | "callout": ["!PY CALLOUT"], 110 | "bom": ["!LPUB INSERT BOM", "!PY BOM"], 111 | "no_callout": ["!PY NO_CALLOUT"], 112 | "no_fullscale": ["!PY NO_FULLSCALE"], 113 | "no_rotate_icon": ["!PY NO_ROTATE_ICON"], 114 | "rotation_pre": ["!PY ROT %3 %4 %5"], 115 | "no_preview": ["!PY NO_PREVIEW"], 116 | "model_scale": ["!PY MODEL_SCALE %-1"], 117 | "preview_aspect": ["!PY PREVIEW_ASPECT %3 %4 %5"], 118 | "preview_scale": ["!PY PREVIEW_SCALE %3"], 119 | "pli_proxy": ["!PY PLI_PROXY %3 %4 %5 %6 %7"], 120 | } 121 | 122 | LDR_DEFAULT_SCALE = 1.0 123 | LDR_DEFAULT_ASPECT = (40, -55, 0) 124 | 125 | ASPECT_DICT = { 126 | "front": (0, 0, 0), 127 | "back": (0, 180, 0), 128 | "right": (0, 90, 0), 129 | "left": (0, -90, 0), 130 | "top": (90, 0, 0), 131 | "bottom": (-90, 0, 0), 132 | "iso35": (35, 35, 0), 133 | "iso45": (35, 45, 0), 134 | "iso55": (35, 55, 0), 135 | "iso90": (35, 90, 0), 136 | "iso125": (35, 125, 0), 137 | "iso135": (35, 135, 0), 138 | "iso145": (35, 145, 0), 139 | "iso-35": (35, -35, 0), 140 | "iso-45": (35, -45, 0), 141 | "iso-55": (35, -55, 0), 142 | "iso-90": (35, -90, 0), 143 | "iso-125": (35, -125, 0), 144 | "iso-135": (35, -135, 0), 145 | "iso-145": (35, -145, 0), 146 | "iso180": (35, 180, 0), 147 | "iso-180": (35, 180, 0), 148 | "n": (35, 0, 0), 149 | "nnw": (35, 35, 0), 150 | "nw": (35, 45, 0), 151 | "wnw": (35, 55, 0), 152 | "w": (35, 90, 0), 153 | "wsw": (35, 125, 0), 154 | "sw": (35, 135, 0), 155 | "ssw": (35, 145, 0), 156 | "s": (35, 180, 0), 157 | "sse": (35, -145, 0), 158 | "se": (35, -135, 0), 159 | "ese": (35, -125, 0), 160 | "e": (35, -90, 0), 161 | "ene": (35, -55, 0), 162 | "ne": (35, -45, 0), 163 | "nne": (35, -35, 0), 164 | } 165 | 166 | FLIP_DICT = { 167 | "flip-x": (180, 0, 0), 168 | "flip-y": (0, 180, 0), 169 | "flip-z": (0, 0, 180), 170 | "flipx": (180, 0, 0), 171 | "flipy": (0, 180, 0), 172 | "flipz": (0, 0, 180), 173 | "rot90": (0, 90, 0), 174 | "rot-90": (0, -90, 0), 175 | } 176 | -------------------------------------------------------------------------------- /ldrawpy/ldrarrows.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # LDraw arrow callout utilties 25 | 26 | import os 27 | import copy 28 | from math import sin, cos, pi 29 | from functools import reduce 30 | import rich 31 | from toolbox import * 32 | from ldrawpy import * 33 | 34 | ARROW_PREFIX = """0 BUFEXCHG A STORE""" 35 | ARROW_PLI = """0 !LPUB PLI BEGIN IGN""" 36 | ARROW_SUFFIX = """0 !LPUB PLI END 37 | 0 STEP 38 | 0 BUFEXCHG A RETRIEVE""" 39 | ARROW_PLI_SUFFIX = """0 !LPUB PLI END""" 40 | 41 | ARROW_PARTS = ["hashl2", "hashl3", "hashl4", "hashl5", "hashl6"] 42 | 43 | ARROW_MZ = Matrix([[0, -1, 0], [1, 0, 0], [0, 0, 1]]) 44 | ARROW_PZ = Matrix([[0, -1, 0], [-1, 0, 0], [0, 0, -1]]) 45 | ARROW_MX = Matrix([[0, 0, 1], [1, 0, 0], [0, 1, 0]]) 46 | ARROW_PX = Matrix([[0, 0, -1], [-1, 0, 0], [0, 1, 0]]) 47 | ARROW_MY = Matrix([[0, -1, 0], [0, 0, -1], [1, 0, 0]]) 48 | ARROW_PY = Matrix([[0, -1, 0], [0, 0, 1], [-1, 0, 0]]) 49 | 50 | 51 | def value_after_token(tokens, value_token, x, xtype=int): 52 | for i, token in enumerate(tokens): 53 | if not token == value_token or not (i + 1) < len(tokens): 54 | continue 55 | try: 56 | v = xtype(tokens[i + 1]) 57 | return v 58 | except ValueError: 59 | return x 60 | return x 61 | 62 | 63 | def norm_angle(angle): 64 | return angle % 45 65 | 66 | 67 | def vectorize(s): 68 | try: 69 | v = Vector(*(float(x) for x in s)) 70 | except ValueError: 71 | return None 72 | return v 73 | 74 | 75 | class ArrowContext: 76 | def __init__(self, colour=804, length=2): 77 | self.colour = colour 78 | self.length = length 79 | self.scale = 25 80 | self.yscale = 20 81 | self.offset = Vector(0, 0, 0) 82 | self.rotstep = Vector(0, 0, 0) 83 | self.ratio = 0.5 84 | self.outline_colour = 804 85 | 86 | def part_for_length(self, length): 87 | if length <= 2: 88 | return "hashl2" 89 | if length <= 3: 90 | return "hashl3" 91 | if length <= 4: 92 | return "hashl4" 93 | if length <= 5: 94 | return "hashl5" 95 | return "hashl5" 96 | 97 | def matrix_for_offset(self, offset, mask="", invert=False, tilt=0): 98 | rotxy = norm_angle(self.rotstep.x + self.rotstep.y) 99 | rotxz = norm_angle(self.rotstep.x + self.rotstep.z) 100 | rotyz = norm_angle(self.rotstep.y + self.rotstep.z) 101 | 102 | if invert: 103 | rotxy = rotxy + 180 104 | rotxz = rotxz + 180 105 | rotyz = rotyz + 180 106 | if (offset.x > 0) and "x" not in mask: 107 | return ARROW_MX.rotate(-rotyz + tilt, ZAxis) 108 | elif (offset.x < 0) and "x" not in mask: 109 | return ARROW_PX.rotate(rotyz + tilt, ZAxis) 110 | 111 | if (offset.y > 0) and "y" not in mask: 112 | return ARROW_PY.rotate(rotxz + tilt, ZAxis) 113 | elif (offset.y < 0) and "y" not in mask: 114 | return ARROW_MY.rotate(-rotxz + tilt, ZAxis) 115 | 116 | if (offset.z > 0) and "z" not in mask: 117 | return ARROW_MZ.rotate(-rotxy + tilt, ZAxis) 118 | elif (offset.z < 0) and "z" not in mask: 119 | return ARROW_PZ.rotate(rotxy + tilt, ZAxis) 120 | return Identity() 121 | 122 | def loc_for_offset(self, offset, length, mask="", ratio=0.5): 123 | loc_offset = Vector(0, 0, 0) 124 | if (offset.x > 0) and "x" not in mask: 125 | loc_offset.x = offset.x / 2 - float(length) * ratio * self.scale 126 | elif (offset.x < 0) and "x" not in mask: 127 | loc_offset.x = offset.x / 2 + float(length) * ratio * self.scale 128 | if (offset.y > 0) and "y" not in mask: 129 | loc_offset.y = offset.y / 2 - float(length) * ratio * self.yscale 130 | elif (offset.y < 0) and "y" not in mask: 131 | loc_offset.y = offset.y / 2 + float(length) * ratio * self.yscale 132 | if (offset.z > 0) and "z" not in mask: 133 | loc_offset.z = offset.z / 2 - float(length) * ratio * self.scale 134 | elif (offset.z < 0) and "z" not in mask: 135 | loc_offset.z = offset.z / 2 + float(length) * ratio * self.scale 136 | if "x" in mask: 137 | loc_offset += Vector(offset.x, 0, 0) 138 | if "y" in mask: 139 | loc_offset += Vector(0, offset.y, 0) 140 | if "z" in mask: 141 | loc_offset += Vector(0, 0, offset.z) 142 | return loc_offset 143 | 144 | def part_loc_for_offset(self, offset, mask=""): 145 | loc_offset = Vector(0, 0, 0) 146 | if abs(offset.x) > 0.1 and "x" not in mask: 147 | loc_offset.x = offset.x 148 | if abs(offset.y) > 0.1 and "y" not in mask: 149 | loc_offset.y = offset.y 150 | if abs(offset.z) > 0.1 and "z" not in mask: 151 | loc_offset.z = offset.z 152 | return loc_offset 153 | 154 | def _mask_axis(self, offsets): 155 | """Determines which axis has a changing value and is not consistent in a list""" 156 | if len(offsets) == 1: 157 | return "" 158 | mx, my, mz = 1e18, 1e18, 1e18 159 | nx, ny, nz = -1e18, -1e18, -1e18 160 | mask = "" 161 | for o in offsets: 162 | mx, my, mz = min(mx, o.x), min(my, o.y), min(mz, o.z) 163 | nx, ny, nz = max(nx, o.x), max(ny, o.y), max(nz, o.z) 164 | if abs(nx - mx) > 0: 165 | mask = mask + "x" 166 | if abs(ny - my) > 0: 167 | mask = mask + "y" 168 | if abs(nz - mz) > 0: 169 | mask = mask + "z" 170 | return mask 171 | 172 | def arrow_from_dict(self, dict): 173 | arrows = [] 174 | mask = self._mask_axis(dict["offset"]) 175 | for i, o in enumerate(dict["offset"]): 176 | ldrpart = LDRPart() 177 | ldrpart.wrapcallout = False 178 | ldrpart.from_str(dict["line"]) 179 | arrpart = LDRPart() 180 | arrpart.name = self.part_for_length(dict["length"]) 181 | arrpart.attrib.loc = copy.copy(ldrpart.attrib.loc) 182 | arrpart.attrib.loc += self.loc_for_offset( 183 | o, dict["length"], mask, ratio=dict["ratio"] 184 | ) 185 | arrpart.attrib.matrix = self.matrix_for_offset( 186 | o, mask, invert=dict["invert"], tilt=dict["tilt"] 187 | ) 188 | arrpart.attrib.colour = dict["colour"] 189 | arrows.append(str(arrpart)) 190 | return "".join(arrows) 191 | 192 | def dict_for_line(self, line, invert, ratio, colour=None, tilt=0): 193 | item = {} 194 | item["line"] = line 195 | c = colour if colour is not None else self.colour 196 | item["colour"] = c 197 | item["length"] = self.length 198 | item["offset"] = copy.copy(self.offset) 199 | item["invert"] = invert 200 | item["ratio"] = ratio 201 | item["tilt"] = tilt 202 | return item 203 | 204 | 205 | def arrows_for_step(arrow_ctx, step, as_lpub=True, only_arrows=False, as_dict=False): 206 | step_lines = [] 207 | arrow_parts = [] 208 | arrow_dict = [] 209 | lines = step.splitlines() 210 | in_arrow = False 211 | offset = Vector(0, 0, 0) 212 | arrow_ratio = 0.5 213 | arrow_tilt = 0 214 | arrow_colour = arrow_ctx.colour 215 | for line in lines: 216 | lineType = int(line.lstrip()[0] if line.lstrip() else -1) 217 | if lineType == 0: 218 | ls = line.upper().split() 219 | if "!PY ARROW" in line: 220 | if in_arrow == False and "BEGIN" in line: 221 | in_arrow = True 222 | arrow_ctx.offset = [] 223 | nv = int((len(ls) - 4) / 3) 224 | for _, i in enumerate(range(nv)): 225 | v = vectorize(ls[4 + (i * 3) : 7 + (i * 3)]) 226 | if v is not None: 227 | arrow_ctx.offset.append(v) 228 | elif in_arrow and "END" in line: 229 | in_arrow = False 230 | continue 231 | arrow_colour = value_after_token(ls, "COLOUR", arrow_colour, int) 232 | if not in_arrow: 233 | arrow_ctx.colour = arrow_colour 234 | arrow_ctx.length = value_after_token( 235 | ls, "LENGTH", arrow_ctx.length, int 236 | ) 237 | arrow_ratio = value_after_token(ls, "RATIO", arrow_ratio, float) 238 | arrow_tilt = value_after_token(ls, "TILT", arrow_tilt, float) 239 | if ( 240 | any(x in line for x in ["COLOUR", "LENGTH", "RATIO", "TILT"]) 241 | and not in_arrow 242 | ): 243 | continue 244 | 245 | if in_arrow and lineType == 1: 246 | item = arrow_ctx.dict_for_line( 247 | line, 248 | invert=False, 249 | ratio=arrow_ratio, 250 | colour=arrow_colour, 251 | tilt=arrow_tilt, 252 | ) 253 | arrow_parts.append(item) 254 | item = arrow_ctx.dict_for_line( 255 | line, 256 | invert=True, 257 | ratio=arrow_ratio, 258 | colour=arrow_colour, 259 | tilt=arrow_tilt, 260 | ) 261 | arrow_parts.append(item) 262 | elif not in_arrow and lineType == 1: 263 | if not only_arrows: 264 | step_lines.append(line) 265 | 266 | if len(arrow_parts) > 0: 267 | if as_lpub: 268 | step_lines.append(ARROW_PREFIX) 269 | for part in arrow_parts: 270 | ldrpart = LDRPart() 271 | ldrpart.wrapcallout = False 272 | ldrpart.from_str(part["line"]) 273 | mask = arrow_ctx._mask_axis(part["offset"]) 274 | ldrpart.attrib.loc += arrow_ctx.part_loc_for_offset( 275 | part["offset"][0], mask 276 | ) 277 | step_lines.append(str(ldrpart).strip("\n")) 278 | step_lines.append(ARROW_PLI) 279 | for part in arrow_parts: 280 | arrow_part = arrow_ctx.arrow_from_dict(part) 281 | step_lines.append(arrow_part.strip("\n")) 282 | step_lines.append(ARROW_SUFFIX) 283 | step_lines.append(ARROW_PLI) 284 | for part in arrow_parts: 285 | step_lines.append(part["line"]) 286 | step_lines.append(ARROW_PLI_SUFFIX) 287 | else: 288 | for i, part in enumerate(arrow_parts): 289 | ad = {} 290 | ldrpart = LDRPart() 291 | ldrpart.wrapcallout = False 292 | ldrpart.from_str(part["line"]) 293 | mask = arrow_ctx._mask_axis(part["offset"]) 294 | offset = arrow_ctx.part_loc_for_offset(part["offset"][0], mask) 295 | ldrpart.attrib.loc += offset 296 | ad["offset"] = offset 297 | if i % 2 == 0: 298 | step_lines.append(str(ldrpart).strip("\n")) 299 | ad["part"] = str(ldrpart) 300 | arrow_part = arrow_ctx.arrow_from_dict(part) 301 | step_lines.append(arrow_part.strip("\n")) 302 | ad["arrow"] = arrow_part 303 | arrow_dict.append(ad) 304 | 305 | else: 306 | if "NOFILE" not in step: 307 | if len(step_lines) > 0: 308 | step_lines.append("0 STEP") 309 | if as_dict: 310 | return arrow_dict 311 | if len(step_lines) > 0: 312 | return "\n".join(step_lines) 313 | return "" 314 | 315 | 316 | def arrows_for_lpub_file(filename, outfile): 317 | arrow_ctx = ArrowContext() 318 | with open(filename, "rt") as fp: 319 | with open(outfile, "w") as fpo: 320 | files = fp.read().split("0 FILE") 321 | for i, file in enumerate(files): 322 | mfile = file if i == 0 else "0 FILE " + file.strip() 323 | steps = mfile.split("0 STEP") 324 | for j, step in enumerate(steps): 325 | new_step = arrows_for_step(arrow_ctx, step) 326 | fpo.write(new_step) 327 | if len(steps) > 1: 328 | fpo.write("\n") 329 | 330 | 331 | def remove_offset_parts(parts, oparts, arrow_dict, as_str=False): 332 | """Removes parts which are offset versions of the same part.""" 333 | pp = ldrlist_from_parts(parts) 334 | op = ldrlist_from_parts(oparts) 335 | offsets = [] 336 | arrows = [] 337 | for ad in arrow_dict: 338 | p = LDRPart().from_str(ad["part"]) 339 | offset = ad["offset"] * p.attrib.matrix 340 | offsets.append(offset) 341 | a = LDRPart().from_str(ad["arrow"]) 342 | arrows.append(a.name) 343 | np = [] 344 | for p in pp: 345 | matched = False 346 | for o in op: 347 | if not o.name == p.name or not o.attrib.colour == p.attrib.colour: 348 | continue 349 | elif p.name in arrows: 350 | continue 351 | else: 352 | v1 = p.attrib.loc * p.attrib.matrix 353 | v2 = o.attrib.loc * o.attrib.matrix 354 | for offset in offsets: 355 | vd = abs(v2.copy() - v1.copy()) 356 | vo = abs(offset) 357 | ld = abs(p.attrib.loc - o.attrib.loc) 358 | if abs(vd - vo) < 0.1 and abs(ld - vo) < 0.1: 359 | matched = True 360 | if matched: 361 | break 362 | 363 | if not matched: 364 | np.append(p) 365 | if as_str: 366 | return ldrstring_from_list(np) 367 | return np 368 | -------------------------------------------------------------------------------- /ldrawpy/ldrawpy.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # LDraw python module 25 | 26 | from toolbox import * 27 | 28 | from .constants import * 29 | from .ldrprimitives import LDRTriangle, LDRLine 30 | 31 | 32 | def xyz_to_ldr(point, as_tuple=False): 33 | """Converts a typical x,y,z 3D coordinate to the somewhat unconventional 34 | LDraw representations of x, -z, y, i.e. the vertical axis extends in 35 | the -y direction as opposed to +z.""" 36 | if isinstance(point, (tuple, list)): 37 | v = Vector(point[0], -point[2], point[1]) 38 | else: 39 | v = Vector(point.x, -point.z, point.y) 40 | if as_tuple: 41 | return v.as_tuple() 42 | return v 43 | 44 | 45 | def mesh_to_ldr( 46 | faces, vertices, mesh_colour=LDR_DEF_COLOUR, edges=None, edge_colour=None 47 | ): 48 | """Converts a triangular mesh into a LDraw formatted string of triangles 49 | and optionally specified edge lines. 50 | faces - list of triangle vertex indices into the vertices list 51 | vertices - list of mesh 3D vertices (x, y, z) 52 | mesh_colour - LDraw colour code for mesh triangles 53 | edges - list of ((x0, y0, z0), (x1, y1, z1)) line tuples 54 | edge_colour - LDraw colour code for edge lines 55 | """ 56 | s = [] 57 | triangles = [] 58 | for face in faces: 59 | tri = LDRTriangle(mesh_colour, "mm") 60 | tri.p1 = xyz_to_ldr(vertices[face[0]]) 61 | tri.p2 = xyz_to_ldr(vertices[face[1]]) 62 | tri.p3 = xyz_to_ldr(vertices[face[2]]) 63 | triangles.append(tri) 64 | for triangle in triangles: 65 | s.append(str(triangle)) 66 | if edges is not None: 67 | lines = [] 68 | ec = edge_colour if edge_colour is not None else LDR_OPT_COLOUR 69 | for edge in edges: 70 | line = LDRLine(ec, "mm") 71 | line.p1 = xyz_to_ldr(edge[0]) 72 | line.p2 = xyz_to_ldr(edge[1]) 73 | lines.append(line) 74 | for line in lines: 75 | s.append(str(line)) 76 | return "".join(s) 77 | 78 | 79 | def brick_name_strip(s, level=0): 80 | """Progressively strips (with increasing levels) a part description 81 | by making substitutions with abreviations, removing spaces, etc. 82 | This can be useful for labelling or BOM part lists where space is limited.""" 83 | sn = s 84 | if level == 0: 85 | sn = sn.replace(" ", " ") 86 | sn = sn.replace( 87 | "Plate 1 x 2 with Groove with 1 Centre Stud, without Understud", 88 | "Plate 1 x 2 Jumper", 89 | ) 90 | sn = sn.replace( 91 | "Plate 1 x 2 without Groove with 1 Centre Stud", "Plate 1 x 2 Jumper" 92 | ) 93 | sn = sn.replace( 94 | "Plate 1 x 2 with Groove with 1 Centre Stud", "Plate 1 x 2 Jumper" 95 | ) 96 | sn = sn.replace("Brick 1 x 1 with Headlight", "Brick 1 x 1 Erling") 97 | sn = sn.replace("with Groove", "") 98 | sn = sn.replace("Bluish ", "Bl ") 99 | sn = sn.replace("Slope Brick", "Slope") 100 | sn = sn.replace("0.667", "2/3") 101 | sn = sn.replace("1.667", "1-2/3") 102 | sn = sn.replace("1.333", "1-1/3") 103 | sn = sn.replace("1 And 1/3", "1-1/3") 104 | sn = sn.replace("1 and 1/3", "1-1/3") 105 | sn = sn.replace("1 & 1/3", "1-1/3") 106 | sn = sn.replace("with Headlight", "Erling") 107 | sn = sn.replace("Angle Connector", "Conn") 108 | sn = sn.replace("~Plate", "Plate") 109 | elif level == 1: 110 | sn = sn.replace("with ", "w/") 111 | sn = sn.replace("With ", "w/") 112 | sn = sn.replace("Ribbed", "ribbed") 113 | sn = sn.replace("without ", "wo/") 114 | sn = sn.replace("Without ", "wo/") 115 | sn = sn.replace("One", "1") 116 | sn = sn.replace("Two", "2") 117 | sn = sn.replace("Three", "3 ") 118 | sn = sn.replace("Four", "4") 119 | sn = sn.replace(" and ", " & ") 120 | sn = sn.replace(" And ", " & ") 121 | sn = sn.replace("Dark", "Dk") 122 | sn = sn.replace("Light", "Lt") 123 | sn = sn.replace("Bright", "Br") 124 | sn = sn.replace("Reddish Brown", "Red Brown") 125 | sn = sn.replace("Reddish", "Red") 126 | sn = sn.replace("Yellowish", "Ylwish") 127 | sn = sn.replace("Medium", "Med") 128 | sn = sn.replace("Offset", "offs") 129 | sn = sn.replace("Adjacent", "adj") 130 | sn = sn.replace(" degree", "°") 131 | elif level == 2: 132 | sn = sn.replace("Trans", "Tr") 133 | sn = sn.replace(" x ", "x") 134 | sn = sn.replace("Bl ", " ") 135 | elif level == 3: 136 | sn = sn.replace("Orange", "Org") 137 | sn = sn.replace("Yellow", "Ylw") 138 | sn = sn.replace("Black", "Blk") 139 | sn = sn.replace("White", "Wht") 140 | sn = sn.replace("Green", "Grn") 141 | sn = sn.replace("Brown", "Brn") 142 | sn = sn.replace("Purple", "Prpl") 143 | sn = sn.replace("Violet", "Vlt") 144 | sn = sn.replace("Gray", "Gry") 145 | sn = sn.replace("Grey", "Gry") 146 | sn = sn.replace("Axlehole", "axle") 147 | sn = sn.replace("Cylinder", "Cyl") 148 | sn = sn.replace("cylinder", "cyl") 149 | sn = sn.replace("Inverted", "Inv") 150 | sn = sn.replace("inverted", "inv") 151 | sn = sn.replace("Centre", "Ctr") 152 | sn = sn.replace("centre", "ctr") 153 | sn = sn.replace("Center", "Ctr") 154 | sn = sn.replace("center", "ctr") 155 | sn = sn.replace("Figure", "Fig") 156 | sn = sn.replace("figure", "fig") 157 | sn = sn.replace("Rounded", "Round") 158 | sn = sn.replace("rounded", "round") 159 | sn = sn.replace("Underside", "under") 160 | sn = sn.replace("Vertical", "vert") 161 | sn = sn.replace("Horizontal", "horz") 162 | sn = sn.replace("vertical", "vert") 163 | sn = sn.replace("horizontal", "horz") 164 | sn = sn.replace("Flex-System", "Flex") 165 | sn = sn.replace("Flanges", "Flange") 166 | sn = sn.replace("Joiner", "joiner") 167 | sn = sn.replace("Joint", "joint") 168 | sn = sn.replace("Type 1", "") 169 | sn = sn.replace("Type 2", "") 170 | elif level == 4: 171 | sn = sn.replace("Technic", "") 172 | sn = sn.replace("Single", "1") 173 | sn = sn.replace("Dual", "2") 174 | sn = sn.replace("Double", "Dbl") 175 | sn = sn.replace("Stud on", "stud") 176 | sn = sn.replace("Studs on Sides", "stud sides") 177 | sn = sn.replace("Studs on Side", "side studs") 178 | sn = sn.replace("Hinge Plate", "Hinge") 179 | elif level == 5: 180 | sn = sn.replace(" on ", " ") 181 | sn = sn.replace(" On ", " ") 182 | sn = sn.replace("Round", "Rnd") 183 | sn = sn.replace("round", "rnd") 184 | sn = sn.replace("Side", "Sd") 185 | sn = sn.replace("Groove", "Grv") 186 | sn = sn.replace("Minifig", "") 187 | sn = sn.replace("Curved", "Curv") 188 | sn = sn.replace("curved", "curv") 189 | sn = sn.replace("Notched", "notch") 190 | sn = sn.replace("Friction", "fric") 191 | sn = sn.replace("(Complete)", "") 192 | sn = sn.replace("Cross", "X") 193 | sn = sn.replace("Embossed", "Emb") 194 | sn = sn.replace("Extension", "Ext") 195 | sn = sn.replace("Bottom", "Bot") 196 | sn = sn.replace("bottom", "bot") 197 | sn = sn.replace("Inside", "Insd") 198 | sn = sn.replace("inside", "insd") 199 | sn = sn.replace("Locking", "click") 200 | sn = sn.replace("Axleholder", "axle") 201 | sn = sn.replace("axleholder", "axle") 202 | sn = sn.replace("End", "end") 203 | sn = sn.replace("Open", "open") 204 | sn = sn.replace("Rod", "rod") 205 | sn = sn.replace("Hole", "hole") 206 | sn = sn.replace("Ball", "ball") 207 | sn = sn.replace("Thin", "thin") 208 | sn = sn.replace("Thick", "thick") 209 | sn = sn.replace(" - ", "-") 210 | elif level == 6: 211 | sn = sn.replace("Up", "up") 212 | sn = sn.replace("Down", "dn") 213 | sn = sn.replace("Bot", "bot") 214 | sn = sn.replace("Rnd", "rnd") 215 | sn = sn.replace("Studs", "St") 216 | sn = sn.replace("studs", "St") 217 | sn = sn.replace("Stud", "St") 218 | sn = sn.replace("stud", "St") 219 | sn = sn.replace("Corners", "edge") 220 | sn = sn.replace("w/Curv Top", "curved") 221 | sn = sn.replace("Domed", "dome") 222 | sn = sn.replace("Clip", "clip") 223 | sn = sn.replace("Clips", "clip") 224 | sn = sn.replace("Convex", "cvx") 225 | sn = sn[0].upper() + sn[1:] 226 | return sn 227 | -------------------------------------------------------------------------------- /ldrawpy/ldrcolour.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # LDraw colour class 25 | 26 | import string 27 | 28 | from ldrawpy import * 29 | 30 | 31 | class LDRColour(object): 32 | """LDraw colour helper class. This class can be used to store a 33 | colour and to perform conversions among: 34 | LDraw colour code, Bricklink colour code, colour name, 35 | RGB floating point, RGB hex""" 36 | 37 | def __init__(self, colour=LDR_DEF_COLOUR): 38 | self.code = LDR_DEF_COLOUR 39 | self.r = 0.8 40 | self.g = 0.8 41 | self.b = 0.8 42 | if isinstance(colour, (tuple, list)): 43 | if (colour[0] > 1.0) or (colour[1] > 1.0) or (colour[2] > 1.0): 44 | self.r = min(colour[0] / 255.0, 1.0) 45 | self.g = min(colour[1] / 255.0, 1.0) 46 | self.b = min(colour[2] / 255.0, 1.0) 47 | else: 48 | self.r = colour[0] 49 | self.g = colour[1] 50 | self.b = colour[2] 51 | rgbstr = self.as_hex().lower() 52 | newCode = LDR_DEF_COLOUR 53 | for code, hexrgb in LDR_COLOUR_RGB.items(): 54 | if rgbstr == hexrgb.lower(): 55 | newCode = code 56 | if newCode != LDR_DEF_COLOUR: 57 | self.code = newCode 58 | 59 | else: 60 | if isinstance(colour, str): 61 | self.code = LDRColour.ColourCodeFromString(colour) 62 | if self.code == LDR_DEF_COLOUR: 63 | self.r, self.g, self.b = LDRColour.RGBFromHex(colour) 64 | elif isinstance(colour, LDRColour): 65 | self.code = colour.code 66 | else: 67 | self.code = colour 68 | self.code_to_rgb() 69 | 70 | def __repr__(self): 71 | return "%s(%s, r: %.2f g: %.2f b: %.2f, #%s)" % ( 72 | self.__class__.__name__, 73 | self.code, 74 | self.r, 75 | self.g, 76 | self.b, 77 | self.as_hex(), 78 | ) 79 | 80 | def __str__(self): 81 | return LDRColour.SafeLDRColourName(self.code) 82 | 83 | def __eq__(self, other): 84 | if isinstance(other, int): 85 | if self.code == other: 86 | return True 87 | if isinstance(other, LDRColour): 88 | if self.code == other.code and self.code != LDR_DEF_COLOUR: 89 | return True 90 | if self.r == other.r and self.g == other.g and self.b == other.b: 91 | return True 92 | return False 93 | 94 | def code_to_rgb(self): 95 | if self.code == LDR_DEF_COLOUR: 96 | self.r = 0.62 97 | self.g = 0.62 98 | self.b = 0.62 99 | return 100 | if self.code in LDR_COLOUR_RGB: 101 | rgb = LDR_COLOUR_RGB[self.code] 102 | [rd, gd, bd] = tuple(int(rgb[i : i + 2], 16) for i in (0, 2, 4)) 103 | self.r = float(rd) / 255.0 104 | self.g = float(gd) / 255.0 105 | self.b = float(bd) / 255.0 106 | 107 | def as_tuple(self): 108 | return (self.r, self.g, self.b) 109 | 110 | def as_bgr(self): 111 | return (int(self.b * 255), int(self.g * 255), int(self.r * 255)) 112 | 113 | def as_hex(self): 114 | return "%02X%02X%02X" % ( 115 | int(self.r * 255.0), 116 | int(self.g * 255.0), 117 | int(self.b * 255.0), 118 | ) 119 | 120 | def ldvcode(self): 121 | pc = self.code 122 | if self.code >= 1000: 123 | pc = LDR_DEF_COLOUR 124 | return pc 125 | 126 | def name(self): 127 | theName = LDRColour.SafeLDRColourName(self.code) 128 | if theName == "": 129 | return self.as_hex() 130 | return theName 131 | 132 | def high_contrast_complement(self): 133 | level = self.r * self.r + self.g * self.g + self.b * self.b 134 | if level < 1.25: 135 | return (1.0, 1.0, 1.0) 136 | return (0.0, 0.0, 0.0) 137 | 138 | def to_bricklink(self): 139 | for blcode, ldrcode in BL_TO_LDR_COLOUR.items(): 140 | if ldrcode == self.code: 141 | return blcode 142 | return 0 143 | 144 | @staticmethod 145 | def SafeLDRColourName(ldrCode): 146 | if ldrCode in LDR_COLOUR_NAME: 147 | return LDR_COLOUR_NAME[ldrCode] 148 | elif ldrCode in LDR_COLOUR_TITLE: 149 | return LDR_COLOUR_TITLE[ldrCode] 150 | return "" 151 | 152 | @staticmethod 153 | def SafeLDRColourRGB(ldrCode): 154 | if ldrCode in LDR_COLOUR_RGB: 155 | return LDR_COLOUR_RGB[ldrCode] 156 | return "FFFFFF" 157 | 158 | @staticmethod 159 | def BLColourCodeFromLDR(colour): 160 | for blcode, ldrcode in BL_TO_LDR_COLOUR.items(): 161 | if ldrcode == colour: 162 | return blcode 163 | return 0 164 | 165 | @staticmethod 166 | def ColourCodeFromString(colourStr): 167 | for code, label in LDR_COLOUR_NAME.items(): 168 | if label.lower() == colourStr.lower(): 169 | return code 170 | if len(colourStr) == 6 or len(colourStr) == 7: 171 | hs = colourStr.lstrip("#").lower() 172 | if not all(c in string.hexdigits for c in hs): 173 | return LDR_DEF_COLOUR 174 | for code, rgbhex in LDR_COLOUR_RGB.items(): 175 | if hs == rgbhex.lower(): 176 | return code 177 | return LDR_DEF_COLOUR 178 | 179 | @staticmethod 180 | def RGBFromHex(hexStr): 181 | if len(hexStr) < 6: 182 | return 0, 0, 0 183 | hs = hexStr.lstrip("#") 184 | if not all(c in string.hexdigits for c in hs): 185 | return 0, 0, 0 186 | [rd, gd, bd] = tuple(int(hs[i : i + 2], 16) for i in (0, 2, 4)) 187 | r = float(rd) / 255.0 188 | g = float(gd) / 255.0 189 | b = float(bd) / 255.0 190 | return r, g, b 191 | 192 | 193 | def FillColoursFromLDRCode(ldrCode): 194 | fillColours = [] 195 | if ldrCode in LDR_COLOUR_RGB: 196 | fillColours.append(LDR_COLOUR_RGB[ldrCode]) 197 | elif ldrCode in LDR_FILL_CODES: 198 | fillColours = LDR_FILL_CODES[ldrCode] 199 | return [LDRColour.RGBFromHex(x) for x in fillColours] 200 | 201 | 202 | def FillTitlesFromLDRCode(ldrCode): 203 | fillTitles = [] 204 | if ldrCode in LDR_COLOUR_RGB: 205 | fillTitles.append(LDR_COLOUR_NAME[ldrCode]) 206 | elif ldrCode in LDR_FILL_TITLES: 207 | fillTitles = LDR_FILL_TITLES[ldrCode] 208 | return fillTitles 209 | -------------------------------------------------------------------------------- /ldrawpy/ldrcolourdict.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # Colour lookup dictionaries 25 | 26 | from .constants import * 27 | 28 | BL_TO_LDR_COLOUR = { 29 | 1: 15, 30 | 49: 503, 31 | 99: 151, 32 | 86: 71, 33 | 9: 7, 34 | 10: 8, 35 | 85: 72, 36 | 11: 0, 37 | 59: 320, 38 | 5: 4, 39 | 27: 216, 40 | 25: 12, 41 | 26: 100, 42 | 58: 335, 43 | 88: 70, 44 | 8: 6, 45 | 120: 308, 46 | 69: 28, 47 | 2: 19, 48 | 90: 78, 49 | 28: 92, 50 | 150: 84, 51 | 91: 86, 52 | 106: 450, 53 | 29: 366, 54 | 68: 484, 55 | 4: 25, 56 | 31: 462, 57 | 110: 191, 58 | 32: 125, 59 | 96: 68, 60 | 3: 14, 61 | 103: 226, 62 | 33: 18, 63 | 35: 120, 64 | 158: 326, 65 | 34: 27, 66 | 155: 330, 67 | 80: 288, 68 | 6: 2, 69 | 36: 10, 70 | 37: 74, 71 | 38: 17, 72 | 48: 378, 73 | 39: 3, 74 | 40: 11, 75 | 41: 118, 76 | 152: 323, 77 | 63: 272, 78 | 7: 1, 79 | 153: 321, 80 | 156: 322, 81 | 42: 73, 82 | 72: 313, 83 | 105: 212, 84 | 62: 9, 85 | 87: 232, 86 | 55: 379, 87 | 97: 89, 88 | 109: 23, 89 | 43: 110, 90 | 73: 112, 91 | 44: 20, 92 | 24: 22, 93 | 157: 30, 94 | 154: 31, 95 | 54: 373, 96 | 71: 26, 97 | 94: 351, 98 | 104: 29, 99 | 23: 13, 100 | 56: 77, 101 | 12: 47, 102 | 13: 40, 103 | 17: 36, 104 | 18: 38, 105 | 98: 57, 106 | 164: 231, 107 | 121: 54, 108 | 19: 46, 109 | 16: 42, 110 | 108: 35, 111 | 20: 34, 112 | 14: 33, 113 | 74: 41, 114 | 15: 43, 115 | 113: 39, 116 | 51: 52, 117 | 50: 37, 118 | 107: 45, 119 | 21: 334, 120 | 22: 383, 121 | 57: 60, 122 | 122: 64, 123 | 52: 61, 124 | 64: 62, 125 | 82: 63, 126 | 83: 183, 127 | 119: 150, 128 | 66: 135, 129 | 95: 179, 130 | 77: 148, 131 | 78: 137, 132 | 61: 142, 133 | 115: 297, 134 | 81: 178, 135 | 84: 134, 136 | 67: 80, 137 | 70: 81, 138 | 65: 82, 139 | 89: 85, 140 | 46: 21, 141 | 353: 220, 142 | 368: 236, 143 | } 144 | 145 | LDR_COLOUR_NAME = { 146 | 0: "Black", 147 | 1: "Blue", 148 | 2: "Green", 149 | 3: "Dark Turquoise", 150 | 4: "Red", 151 | 5: "Dark Pink", 152 | 6: "Brown", 153 | 7: "Old Light Grey", 154 | 8: "Old Dark Grey", 155 | 9: "Light Blue", 156 | 10: "Bright Green", 157 | 11: "Light Turquoise", 158 | 12: "Salmon", 159 | 13: "Pink", 160 | 14: "Yellow", 161 | 15: "White", 162 | 16: "Default", 163 | 17: "Light Green", 164 | 18: "Light Yellow", 165 | 19: "Tan", 166 | 20: "Light Violet", 167 | 21: "Glow in Dark Opaque", 168 | 22: "Purple", 169 | 23: "Dark Blue Violet", 170 | 24: "Outline", 171 | 25: "Orange", 172 | 26: "Magenta", 173 | 27: "Lime", 174 | 28: "Dark Tan", 175 | 29: "Bright Pink", 176 | 30: "Medium Lavender", 177 | 31: "Lavender", 178 | 68: "Very Light Orange", 179 | 69: "Bright Reddish Lilac", 180 | 70: "Reddish Brown", 181 | 71: "Light Bluish Gray", 182 | 72: "Dark Bluish Gray", 183 | 73: "Medium Blue", 184 | 74: "Medium Green", 185 | 77: "Light Pink", 186 | 78: "Light Nougat", 187 | 84: "Medium Nougat", 188 | 85: "Dark Purple", 189 | 86: "Dark Flesh", 190 | 89: "Blue Violet", 191 | 92: "Nougat", 192 | 100: "Light Salmon", 193 | 110: "Violet", 194 | 112: "Medium Violet", 195 | 115: "Medium Lime", 196 | 118: "Aqua", 197 | 120: "Light Lime", 198 | 125: "Light Orange", 199 | 151: "Very Light Bluish Grey", 200 | 191: "Bright Light Orange", 201 | 212: "Bright Light Blue", 202 | 216: "Rust", 203 | 226: "Bright Light Yellow", 204 | 232: "Sky Blue", 205 | 272: "Dark Blue", 206 | 288: "Dark Green", 207 | 308: "Dark Brown", 208 | 313: "Maersk Blue", 209 | 320: "Dark Red", 210 | 321: "Dark Azur", 211 | 322: "Medium Azur", 212 | 323: "Light Aqua", 213 | 326: "Yellowish Green", 214 | 330: "Olive Green", 215 | 335: "Sand Red", 216 | 351: "Medium Dark Pink", 217 | 353: "Coral", 218 | 366: "Earth Orange", 219 | 373: "Sand Purple", 220 | 378: "Sand Green", 221 | 379: "Sand Blue", 222 | 450: "Fabuland Brown", 223 | 462: "Medium Orange", 224 | 484: "Dark Orange", 225 | 503: "Very Light Grey", 226 | 218: "Reddish Lilac", 227 | 295: "Flamingo Pink", 228 | 219: "Lilac", 229 | 128: "Dark Nougat", 230 | 47: "Trans Clear", 231 | 40: "Trans Black", 232 | 36: "Trans Red", 233 | 38: "Trans Neon Orange", 234 | 57: "Trans Orange", 235 | 54: "Trans Neon Yellow", 236 | 46: "Trans Yellow", 237 | 42: "Trans Neon Green", 238 | 35: "Trans Bright Green", 239 | 34: "Trans Green", 240 | 33: "Trans Dark Blue", 241 | 41: "Trans Medium Blue", 242 | 43: "Trans Light Blue", 243 | 39: "Trans Very Light Blue", 244 | 44: "Trans Bright Reddish Lilac", 245 | 52: "Trans Purple", 246 | 37: "Trans Dark Pink", 247 | 45: "Trans Pink", 248 | 285: "Trans Light Green", 249 | 234: "Trans Fire Yellow", 250 | 293: "Trans Light Blue Violet", 251 | 231: "Trans Bright Light Orange", 252 | 284: "Trans Reddish Lilac", 253 | 334: "Chrome Gold", 254 | 383: "Chrome Silver", 255 | 60: "Chrome Antique Brass", 256 | 64: "Chrome Black", 257 | 61: "Chrome Blue", 258 | 62: "Chrome Green", 259 | 63: "Chrome Pink", 260 | 183: "Pearl White", 261 | 150: "Pearl Very Light Grey", 262 | 135: "Pearl Light Grey", 263 | 179: "Flat Silver", 264 | 148: "Pearl Dark Grey", 265 | 137: "Metal Blue", 266 | 142: "Pearl Light Gold", 267 | 297: "Pearl Gold", 268 | 178: "Flat Dark Gold", 269 | 134: "Copper", 270 | 189: "Reddish Gold", 271 | 80: "Metallic Silver", 272 | 81: "Metallic Green", 273 | 82: "Metallic Gold", 274 | 83: "Metallic Black", 275 | 87: "Metallic Dark Grey", 276 | 300: "Metallic Copper", 277 | 184: "Metallic Bright Red", 278 | 186: "Metallic Dark Green", 279 | 368: "Neon Yellow", 280 | 801: "Arrow Blue", 281 | 802: "Arrow Green", 282 | 804: "Arrow Red", 283 | } 284 | 285 | LDR_COLOUR_RGB = { 286 | 0: "05131D", # 'Black', 287 | 1: "0055bf", #'Blue', 288 | 2: "257a3e", #'Green', 289 | 3: "00838f", #'Dark Turquoise', 290 | 4: "c91a09", #'Red', 291 | 5: "c870a0", #'Dark Pink', 292 | 6: "583927", #'Brown', 293 | 7: "9ba19d", #'Light Grey', 294 | 8: "6d6e5c", #'Dark Grey', 295 | 9: "b4d2e3", #'Light Blue', 296 | 10: "4b9f4a", #'Bright Green', 297 | 11: "55a5af", #'Light Turquoise', 298 | 12: "f2705e", #'Salmon', 299 | 13: "fc97ac", #'Pink', 300 | 14: "f2cd37", #'Yellow', 301 | 15: "ffffff", #'White', 302 | 16: "101010", #'Default', 303 | 17: "c2dab8", #'Light Green', 304 | 18: "fbe696", #'Light Yellow', 305 | 19: "e4cd9e", #'Tan', 306 | 20: "c9cae2", #'Light Violet', 307 | 21: "ECE8DE", #'Glow in Dark Opaque' 308 | 22: "81007b", #'Purple', 309 | 23: "2032b0", #'Dark Blue Violet', 310 | 24: "101010", #'Outline', 311 | 25: "fe8a18", #'Orange', 312 | 26: "923978", #'Magenta', 313 | 27: "bbe90b", #'Lime', 314 | 28: "958a73", #'Dark Tan', 315 | 29: "e4adc8", #'Bright Pink', 316 | 30: "ac78ba", #'Medium Lavender', 317 | 31: "e1d5ed", #'Lavender', 318 | 68: "f3cf9b", #'Very Light Orange', 319 | 69: "cd6298", #'Bright Reddish Lilac', 320 | 70: "582a12", #'Reddish Brown', 321 | 71: "a0a5a9", #'Light Bluish Gray', 322 | 72: "6c6e68", #'Dark Bluish Gray', 323 | 73: "5c9dd1", #'Medium Blue', 324 | 74: "73dca1", #'Medium Green', 325 | 77: "fecccf", #'Light Pink', 326 | 78: "f6d7b3", #'Light Flesh', 327 | 84: "cc702a", #'Medium Dark Flesh', 328 | 85: "3f3691", #'Medium Lilac', / Dark Purple 329 | 86: "7c503a", #'Dark Flesh', 330 | 89: "4c61db", #'Blue Violet', 331 | 92: "d09168", #'Flesh', 332 | 100: "febabd", #'Light Salmon', 333 | 110: "4354a3", #'Violet', 334 | 112: "6874ca", #'Medium Violet', 335 | 115: "c7d23c", #'Medium Lime', 336 | 118: "b3d7d1", #'Aqua', 337 | 120: "d9e4a7", #'Light Lime', 338 | 125: "f9ba61", #'Light Orange', 339 | 151: "e6e3e0", #'Very Light Bluish Grey', 340 | 191: "f8bb3d", #'Bright Light Orange', 341 | 212: "86c1e1", #'Bright Light Blue', 342 | 216: "b31004", #'Rust', 343 | 226: "fff03a", #'Bright Light Yellow', 344 | 232: "56bed6", #'Sky Blue', 345 | 272: "0d325b", #'Dark Blue', 346 | 288: "184632", #'Dark Green', 347 | 308: "352100", #'Dark Brown', 348 | 313: "54a9c8", #'Maersk Blue', 349 | 320: "720e0f", #'Dark Red', 350 | 321: "1498d7", #'Dark Azur', 351 | 322: "3ec2dd", #'Medium Azur', 352 | 323: "bddcd8", #'Light Aqua', 353 | 326: "dfeea5", #'Yellowish Green', 354 | 330: "9b9a5a", #'Olive Green', 355 | 335: "d67572", #'Sand Red', 356 | 351: "f785b1", #'Medium Dark Pink', 357 | 353: "FF6D77", # Coral 358 | 366: "fa9c1c", #'Earth Orange', 359 | 373: "845e84", #'Sand Purple', 360 | 378: "a0bcac", #'Sand Green', 361 | 379: "597184", #'Sand Blue', 362 | 450: "b67b50", #'Fabuland Brown', 363 | 462: "ffa70b", #'Medium Orange', 364 | 484: "a95500", #'Dark Orange', 365 | 503: "e6e3da", #'Very Light Grey', 366 | 218: "8e5597", #'Reddish Lilac', 367 | 295: "ff94c2", #'Flamingo Pink', 368 | 219: "564e9d", #'Lilac', 369 | 128: "ad6140", #'Dark Nougat', 370 | 47: "fcfcfc", #'Trans Clear', 371 | 40: "635f52", #'Trans Black', 372 | 36: "c91a09", #'Trans Red', 373 | 38: "ff800d", #'Trans Neon Orange', 374 | 57: "f08f1c", #'Trans Orange', 375 | 54: "dab000", #'Trans Neon Yellow', 376 | 46: "f5cd2f", #'Trans Yellow', 377 | 42: "c0ff00", #'Trans Neon Green', 378 | 35: "56e646", #'Trans Bright Green', 379 | 34: "237841", #'Trans Green', 380 | 33: "0020a0", #'Trans Dark Blue', 381 | 41: "559ab7", #'Trans Medium Blue', 382 | 43: "aee9ef", #'Trans Light Blue', 383 | 39: "c1dff0", #'Trans Very Light Blue', 384 | 44: "96709f", #'Trans Bright Reddish Lilac', 385 | 52: "a5a5cb", #'Trans Purple', 386 | 37: "df6695", #'Trans Dark Pink', 387 | 45: "fc97ac", #'Trans Pink', 388 | 285: "7dc291", #'Trans Light Green', 389 | 234: "fbe890", #'Trans Fire Yellow', 390 | 293: "68abe4", #'Trans Light Blue Violet', 391 | 231: "fcb76d", #'Trans Bright Light Orange', 392 | 284: "c281a5", #'Trans Reddish Lilac', 393 | 334: "bba53d", #'Chrome Gold', 394 | 383: "e0e0e0", #'Chrome Silver', 395 | 60: "645a4c", #'Chrome Antique Brass', 396 | 64: "1b2a34", #'Chrome Black', 397 | 61: "6c96bf", #'Chrome Blue', 398 | 62: "3cb371", #'Chrome Green', 399 | 63: "aa4d8e", #'Chrome Pink', 400 | 183: "f2f3f2", #'Pearl White', 401 | 150: "bbbdbc", #'Pearl Very Light Grey', 402 | 135: "9ca3a8", #'Pearl Light Grey', 403 | 179: "898788", #'Flat Silver', 404 | 148: "575857", #'Pearl Dark Grey', 405 | 137: "5677ba", #'Metal Blue', 406 | 142: "dcbe61", #'Pearl Light Gold', 407 | 297: "cc9c2b", #'Pearl Gold', 408 | 178: "b4883e", #'Flat Dark Gold', 409 | 134: "964a27", #'Copper', 410 | 189: "ac8247", #'Reddish Gold', 411 | 80: "a5a9b4", #'Metallic Silver', 412 | 81: "899b5f", #'Metallic Green', 413 | 82: "dbac34", #'Metallic Gold', 414 | 83: "1a2831", #'Metallic Black', 415 | 87: "6d6e5c", #'Metallic Dark Grey', 416 | 300: "c27f53", #'Metallic Copper', 417 | 184: "d60026", #'Metallic Bright Red', 418 | 186: "008e3c", #'Metallic Dark Green' 419 | 368: "EBD800", # Neon Yellow 420 | 801: "0830FF", # Arrow Blue 421 | 802: "08B010", # Arrow Green 422 | 804: "FF0000", # Arrow Red 423 | } 424 | 425 | LDR_COLOUR_TITLE = { 426 | LDR_ALL_COLOUR: "All Colours", 427 | LDR_ANY_COLOUR: "Any Colour", 428 | LDR_OTHER_COLOUR: "Other Colours", 429 | LDR_MONO_COLOUR: "Monochrome", 430 | LDR_BLUES_COLOUR: "Blues", 431 | LDR_GREENS_COLOUR: "Greens", 432 | LDR_YELLOWS_COLOUR: "Yellows", 433 | LDR_PINKPURP_COLOUR: "Pinks/Purples", 434 | LDR_BLKWHT_COLOUR: "Black/White", 435 | LDR_GRAY_COLOUR: "Light/Dark Gray", 436 | LDR_REDYLW_COLOUR: "Red/Yellow", 437 | LDR_BLUYLW_COLOUR: "Blue/Yellow", 438 | LDR_REDBLUYLW_COLOUR: "Red/Blue/Yellow", 439 | LDR_GRNBRN_COLOUR: "Green/Brown", 440 | LDR_BLUBRN_COLOUR: "Blue/Brown", 441 | LDR_BRGREEN_COLOUR: "Bright Green/Ylwish Green", 442 | LDR_LAVENDER_COLOUR: "Light/Med Lavender", 443 | LDR_PINK_COLOUR: "Light/Med Pink", 444 | LDR_LTYLW_COLOUR: "Br Light Yellow/Light Nougat", 445 | LDR_BLUBLU_COLOUR: "Blue/Med Blue", 446 | LDR_ORGYLW_COLOUR: "Orange/Yellow", 447 | LDR_ORGBRN_COLOUR: "Orange/Brown", 448 | LDR_REDORG_COLOUR: "Red/Orange", 449 | LDR_REDORGYLW_COLOUR: "Red/Org/Ylw", 450 | LDR_BLUGRN_COLOUR: "Blue/Green", 451 | LDR_TAN_COLOUR: "Light/Dark Tan", 452 | } 453 | 454 | LDR_FILL_CODES = { 455 | LDR_ALL_COLOUR: LDR_ANY_COLOUR_FILL, 456 | LDR_ANY_COLOUR: LDR_ANY_COLOUR_FILL, 457 | LDR_OTHER_COLOUR: LDR_ANY_COLOUR_FILL, 458 | LDR_MONO_COLOUR: [ 459 | LDR_COLOUR_RGB[15], 460 | LDR_COLOUR_RGB[151], 461 | LDR_COLOUR_RGB[71], 462 | LDR_COLOUR_RGB[72], 463 | ], 464 | LDR_BLUES_COLOUR: [ 465 | LDR_COLOUR_RGB[212], 466 | LDR_COLOUR_RGB[73], 467 | LDR_COLOUR_RGB[1], 468 | LDR_COLOUR_RGB[272], 469 | ], 470 | LDR_GREENS_COLOUR: [ 471 | LDR_COLOUR_RGB[378], 472 | LDR_COLOUR_RGB[10], 473 | LDR_COLOUR_RGB[2], 474 | LDR_COLOUR_RGB[288], 475 | ], 476 | LDR_YELLOWS_COLOUR: [ 477 | LDR_COLOUR_RGB[19], 478 | LDR_COLOUR_RGB[226], 479 | LDR_COLOUR_RGB[14], 480 | LDR_COLOUR_RGB[191], 481 | ], 482 | LDR_PINKPURP_COLOUR: [ 483 | LDR_COLOUR_RGB[29], 484 | LDR_COLOUR_RGB[13], 485 | LDR_COLOUR_RGB[30], 486 | LDR_COLOUR_RGB[26], 487 | ], 488 | LDR_BLKWHT_COLOUR: [LDR_COLOUR_RGB[0], LDR_COLOUR_RGB[15]], 489 | LDR_GRAY_COLOUR: [LDR_COLOUR_RGB[71], LDR_COLOUR_RGB[72]], 490 | LDR_REDYLW_COLOUR: [LDR_COLOUR_RGB[4], LDR_COLOUR_RGB[14]], 491 | LDR_BLUYLW_COLOUR: [LDR_COLOUR_RGB[1], LDR_COLOUR_RGB[14]], 492 | LDR_REDBLUYLW_COLOUR: [LDR_COLOUR_RGB[4], LDR_COLOUR_RGB[1], LDR_COLOUR_RGB[14]], 493 | LDR_GRNBRN_COLOUR: [LDR_COLOUR_RGB[2], LDR_COLOUR_RGB[70]], 494 | LDR_BLUBRN_COLOUR: [LDR_COLOUR_RGB[1], LDR_COLOUR_RGB[70]], 495 | LDR_BLUBLU_COLOUR: [LDR_COLOUR_RGB[1], LDR_COLOUR_RGB[73]], 496 | LDR_DKREDBLU_COLOUR: [LDR_COLOUR_RGB[272], LDR_COLOUR_RGB[320]], 497 | LDR_BRGREEN_COLOUR: [LDR_COLOUR_RGB[10], LDR_COLOUR_RGB[326]], 498 | LDR_LAVENDER_COLOUR: [LDR_COLOUR_RGB[30], LDR_COLOUR_RGB[31]], 499 | LDR_PINK_COLOUR: [LDR_COLOUR_RGB[13], LDR_COLOUR_RGB[29]], 500 | LDR_LTYLW_COLOUR: [LDR_COLOUR_RGB[226], LDR_COLOUR_RGB[78]], 501 | LDR_ORGYLW_COLOUR: [LDR_COLOUR_RGB[25], LDR_COLOUR_RGB[14]], 502 | LDR_ORGBRN_COLOUR: [LDR_COLOUR_RGB[25], LDR_COLOUR_RGB[6]], 503 | LDR_REDORG_COLOUR: [LDR_COLOUR_RGB[4], LDR_COLOUR_RGB[25]], 504 | LDR_TANBRN_COLOUR: [LDR_COLOUR_RGB[19], LDR_COLOUR_RGB[6]], 505 | LDR_REDORGYLW_COLOUR: [LDR_COLOUR_RGB[4], LDR_COLOUR_RGB[25], LDR_COLOUR_RGB[14]], 506 | LDR_BLUGRN_COLOUR: [LDR_COLOUR_RGB[73], LDR_COLOUR_RGB[10]], 507 | LDR_TAN_COLOUR: [LDR_COLOUR_RGB[19], LDR_COLOUR_RGB[28]], 508 | } 509 | 510 | LDR_FILL_TITLES = { 511 | LDR_ALL_COLOUR: ["All Colours"], 512 | LDR_ANY_COLOUR: ["Any Colour"], 513 | LDR_OTHER_COLOUR: ["Other Colours"], 514 | LDR_MONO_COLOUR: ["Monochrome"], 515 | LDR_BLUES_COLOUR: ["Blues"], 516 | LDR_GREENS_COLOUR: ["Greens"], 517 | LDR_YELLOWS_COLOUR: ["Yellows"], 518 | LDR_PINKPURP_COLOUR: ["Pinks/Purples"], 519 | LDR_BLKWHT_COLOUR: [LDR_COLOUR_NAME[0], LDR_COLOUR_NAME[15]], 520 | LDR_GRAY_COLOUR: [LDR_COLOUR_NAME[71], LDR_COLOUR_NAME[72]], 521 | LDR_REDYLW_COLOUR: [LDR_COLOUR_NAME[4], LDR_COLOUR_NAME[14]], 522 | LDR_BLUYLW_COLOUR: [LDR_COLOUR_NAME[1], LDR_COLOUR_NAME[14]], 523 | LDR_REDBLUYLW_COLOUR: [LDR_COLOUR_NAME[4], LDR_COLOUR_NAME[1], LDR_COLOUR_NAME[14]], 524 | LDR_GRNBRN_COLOUR: [LDR_COLOUR_NAME[2], LDR_COLOUR_NAME[70]], 525 | LDR_BLUBRN_COLOUR: [LDR_COLOUR_NAME[1], LDR_COLOUR_NAME[70]], 526 | LDR_BLUBLU_COLOUR: [LDR_COLOUR_NAME[1], LDR_COLOUR_NAME[73]], 527 | LDR_DKREDBLU_COLOUR: [LDR_COLOUR_NAME[272], LDR_COLOUR_NAME[320]], 528 | LDR_BRGREEN_COLOUR: [LDR_COLOUR_NAME[10], LDR_COLOUR_NAME[326]], 529 | LDR_LAVENDER_COLOUR: [LDR_COLOUR_NAME[30], LDR_COLOUR_NAME[31]], 530 | LDR_PINK_COLOUR: [LDR_COLOUR_NAME[13], LDR_COLOUR_NAME[29]], 531 | LDR_LTYLW_COLOUR: [LDR_COLOUR_NAME[226], LDR_COLOUR_NAME[78]], 532 | LDR_ORGYLW_COLOUR: [LDR_COLOUR_NAME[25], LDR_COLOUR_NAME[14]], 533 | LDR_ORGBRN_COLOUR: [LDR_COLOUR_NAME[25], LDR_COLOUR_NAME[6]], 534 | LDR_REDORG_COLOUR: [LDR_COLOUR_NAME[4], LDR_COLOUR_NAME[25]], 535 | LDR_TANBRN_COLOUR: [LDR_COLOUR_NAME[19], LDR_COLOUR_NAME[6]], 536 | LDR_REDORGYLW_COLOUR: [ 537 | LDR_COLOUR_NAME[4], 538 | LDR_COLOUR_NAME[25], 539 | LDR_COLOUR_NAME[14], 540 | ], 541 | LDR_BLUGRN_COLOUR: [LDR_COLOUR_NAME[1], LDR_COLOUR_NAME[2]], 542 | LDR_TAN_COLOUR: [LDR_COLOUR_NAME[19], LDR_COLOUR_NAME[28]], 543 | } 544 | -------------------------------------------------------------------------------- /ldrawpy/ldrhelpers.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # LDraw related helper functions 25 | 26 | import decimal 27 | from toolbox import * 28 | from ldrawpy import * 29 | 30 | 31 | def quantize(x): 32 | """Quantizes an string LDraw value to 4 decimal places""" 33 | v = decimal.Decimal(x.strip()).quantize(decimal.Decimal(10) ** -4) 34 | return float(v) 35 | 36 | 37 | def MM2LDU(x): 38 | return x * 2.5 39 | 40 | 41 | def LDU2MM(x): 42 | return x * 0.4 43 | 44 | 45 | def val_units(value, units="ldu"): 46 | """ 47 | Writes a floating point value in units of either mm or ldu. 48 | It restricts the number of decimal places to 4 and minimizes 49 | redundant trailing zeros (as recommended by ldraw.org) 50 | """ 51 | x = value * 2.5 if units == "mm" else value 52 | xs = "%.5f" % (x) 53 | ns = str(quantize(xs)).replace("0E-4", "0.") 54 | if "E" not in ns: 55 | ns = ns.rstrip("0") 56 | ns = ns.rstrip(".") 57 | if ns == "-0": 58 | return "0 " 59 | return ns + " " 60 | 61 | 62 | def mat_str(m): 63 | """ 64 | Writes the values of a matrix formatted by PUnits. 65 | """ 66 | return "".join([val_units(v, "ldu") for v in m]) 67 | 68 | 69 | def vector_str(p, attrib): 70 | return ( 71 | val_units(p.x, attrib.units) 72 | + val_units(p.y, attrib.units) 73 | + val_units(p.z, attrib.units) 74 | ) 75 | 76 | 77 | def get_circle_segments(radius, segments, attrib): 78 | from .ldrprimitives import LDRLine 79 | 80 | lines = [] 81 | for seg in range(segments): 82 | p1 = Vector(0, 0, 0) 83 | p2 = Vector(0, 0, 0) 84 | a1 = seg / segments * 2.0 * pi 85 | a2 = (seg + 1) / segments * 2.0 * pi 86 | p1.x = radius * cos(a1) 87 | p1.z = radius * sin(a1) 88 | p2.x = radius * cos(a2) 89 | p2.z = radius * sin(a2) 90 | l = LDRLine(attrib.colour, attrib.units) 91 | l.p1 = p1 92 | l.p2 = p2 93 | lines.append(l) 94 | return lines 95 | 96 | 97 | def ldrlist_from_parts(parts): 98 | """Returns a list of LDRPart objects from either a list of LDRParts, 99 | a list of strings representing parts or a string with line feed 100 | delimited parts.""" 101 | from .ldrprimitives import LDRPart 102 | 103 | p = [] 104 | if isinstance(parts, str): 105 | # assume its a string of LDraw lines of text 106 | parts = parts.splitlines() 107 | if isinstance(parts, list): 108 | if len(parts) < 1: 109 | return p 110 | if isinstance(parts[0], LDRPart): 111 | p.extend(parts) 112 | else: 113 | p = [LDRPart().from_str(e) for e in parts] 114 | p = [e for e in p if e is not None] 115 | return p 116 | 117 | 118 | def ldrstring_from_list(parts): 119 | """Returns a LDraw formatted string from a list of parts. Each part 120 | is represented in a line feed terminated string concatenated together.""" 121 | from .ldrprimitives import LDRPart 122 | 123 | s = [] 124 | for p in parts: 125 | if isinstance(p, LDRPart): 126 | s.append(str(p)) 127 | else: 128 | if not p[-1] == "\n": 129 | s.append(p + "\n") 130 | else: 131 | s.append(p) 132 | return "".join(s) 133 | 134 | 135 | def merge_same_parts(parts, other, ignore_colour=False, as_str=False): 136 | """Merges parts + other where the the parts in other take precedence.""" 137 | from .ldrprimitives import LDRPart 138 | 139 | op = ldrlist_from_parts(other) 140 | p = ldrlist_from_parts(other) 141 | np = ldrlist_from_parts(parts) 142 | for n in np: 143 | if not any( 144 | [ 145 | n.is_same(o, ignore_location=False, ignore_colour=ignore_colour) 146 | for o in op 147 | ] 148 | ): 149 | p.append(n) 150 | if as_str: 151 | return ldrstring_from_list(p) 152 | return p 153 | 154 | 155 | def remove_parts_from_list( 156 | parts, other, ignore_colour=True, ignore_location=True, exact=False, as_str=False 157 | ): 158 | """Returns a list based on removing the parts from other from parts.""" 159 | pp = ldrlist_from_parts(parts) 160 | op = ldrlist_from_parts(other) 161 | np = [] 162 | for p in pp: 163 | if ignore_colour and ignore_location: 164 | if not any([o.name == p.name for o in op]): 165 | np.append(p) 166 | elif not any( 167 | [ 168 | p.is_same( 169 | o, 170 | ignore_location=ignore_colour, 171 | ignore_colour=ignore_colour, 172 | exact=exact, 173 | ) 174 | for o in op 175 | ] 176 | ): 177 | np.append(p) 178 | if as_str: 179 | return ldrstring_from_list(np) 180 | return np 181 | 182 | 183 | def norm_angle(a): 184 | """Normalizes an angle in degrees to -180 ~ +180 deg.""" 185 | a = a % 360 186 | if a >= 0 and a <= 180: 187 | return a 188 | if a > 180: 189 | return -180 + (-180 + a) 190 | if a >= -180 and a < 0: 191 | return a 192 | return 180 + (a + 180) 193 | 194 | 195 | def norm_aspect(a): 196 | """Normalizes the three angle components of aspect angle to -180 ~ +180 deg.""" 197 | return tuple([norm_angle(v) for v in a]) 198 | 199 | 200 | def _flip_x(a): 201 | return (-a[0], a[1], a[2]) 202 | 203 | 204 | def _add_aspect(a, b): 205 | return norm_aspect( 206 | new_aspect=( 207 | a[0] + b[0], 208 | a[1] + b[1], 209 | a[2] + b[2], 210 | ) 211 | ) 212 | 213 | 214 | def preset_aspect(current_aspect, aspect_change): 215 | if isinstance(aspect_change, list): 216 | changes = aspect_change 217 | else: 218 | changes = [aspect_change] 219 | new_aspect = tuple(current_aspect) 220 | for aspect in changes: 221 | a = aspect.lower() 222 | if a in ASPECT_DICT: 223 | new_aspect = _flip_x(ASPECT_DICT[a]) 224 | elif a in FLIP_DICT: 225 | r = FLIP_DICT[a] 226 | new_aspect = _add_aspect(new_aspect, r) 227 | elif a == "down": 228 | if new_aspect[0] < 0: 229 | new_aspect = (145, new_aspect[1], new_aspect[2]) 230 | elif a == "up": 231 | if new_aspect[0] > 0: 232 | new_aspect = (-35, new_aspect[1], new_aspect[2]) 233 | return norm_aspect(new_aspect) 234 | 235 | def clean_line(line): 236 | sl = line.split() 237 | nl = [] 238 | for i, s in enumerate(sl): 239 | xs = s 240 | if i > 0 and "_" not in s: 241 | try: 242 | x = float(s) 243 | xs = val_units(float(x)).rstrip() 244 | except ValueError: 245 | pass 246 | nl.append(xs) 247 | nl.append(" ") 248 | nl = "".join(nl).rstrip() 249 | return nl 250 | 251 | def clean_file(fn, fno=None, verbose=False, as_str=False): 252 | """Cleans an LDraw file by changing all floating point numbers to 253 | an optimum representation within the suggested precision of up to 254 | 4 decimal places. 255 | """ 256 | if fno is None: 257 | fno = fn.replace(".ldr", "_clean.ldr") 258 | ns = [] 259 | bytes_in = 0 260 | bytes_out = 0 261 | with open(fn, "r") as f: 262 | lines = f.readlines() 263 | for line in lines: 264 | bytes_in += len(line) 265 | nl = clean_line(line) 266 | # sl = line.split() 267 | # nl = [] 268 | # for i, s in enumerate(sl): 269 | # xs = s 270 | # if i > 0 and "_" not in s: 271 | # try: 272 | # x = float(s) 273 | # xs = val_units(float(x)).rstrip() 274 | # except ValueError: 275 | # pass 276 | # nl.append(xs) 277 | # nl.append(" ") 278 | # nl = "".join(nl).rstrip() 279 | bytes_out += len(nl) 280 | ns.append(nl) 281 | if verbose: 282 | print( 283 | "%s : %d bytes in / %d bytes out (%.1f%% saved)" 284 | % (fn, bytes_in, bytes_out, ((bytes_in - bytes_out) / bytes_in * 100.0)) 285 | ) 286 | if as_str: 287 | return ns 288 | ns = "\n".join(ns) 289 | with open(fno, "w") as f: 290 | f.write(ns) 291 | -------------------------------------------------------------------------------- /ldrawpy/ldrmodel.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # LDraw model classes and helper functions 25 | 26 | import hashlib 27 | 28 | import crayons 29 | from collections import defaultdict 30 | 31 | from toolbox import * 32 | from ldrawpy import * 33 | 34 | # import brickbom if available, otherwise don't raise since it is not 35 | # necessary for testing. 36 | try: 37 | from brickbom import BOM, BOMPart 38 | except: 39 | pass 40 | try: 41 | from rich import print 42 | 43 | has_rich = True 44 | except: 45 | has_rich = False 46 | 47 | START_TOKENS = ["PLI BEGIN IGN", "BUFEXCHG STORE"] 48 | END_TOKENS = ["PLI END", "BUFEXCHG RETRIEVE"] 49 | # START_TOKENS = ["PLI BEGIN IGN", "BUFEXCHG STORE", "SYNTH BEGIN"] 50 | # END_TOKENS = ["PLI END", "BUFEXCHG RETRIEVE", "SYNTH END"] 51 | EXCEPTION_LIST = ["2429c01.dat"] 52 | IGNORE_LIST = ["LS02"] 53 | 54 | COMMON_SUBSTITUTIONS = [ 55 | ("3070a", "3070b"), # 1 x 1 tile 56 | ("3069a", "3069b"), # 1 x 2 tile 57 | ("3068a", "3068b"), # 2 x 2 tile 58 | ("x224", "41751"), # windscreen 59 | ("4864a", "87552"), # 1 x 2 x 2 panel with side supports 60 | ("4864b", "87552"), # 1 x 2 x 2 panel with side supports 61 | ("2362a", "87544"), # 1 x 2 x 3 panel with side supports 62 | ("2362b", "87544"), # 1 x 2 x 3 panel with side supports 63 | ("60583", "60583b"), # 1 x 1 x 3 brick with clips 64 | ("60583a", "60583b"), 65 | ("3245a", "3245c"), # 1 x 2 x 2 brick 66 | ("3245b", "3245c"), 67 | ("3794", "15573"), # 1 x 2 jumper plate 68 | ("3794a", "15573"), 69 | ("3794b", "15573"), 70 | ("4215a", "60581"), # 1 x 4 x 3 panel with side supports 71 | ("4215b", "60581"), 72 | ("4215", "60581"), 73 | # ["2429c01", "73983"], # 1 x 4 hinge plate complete 74 | ("73983", "2429c01"), # 1 x 4 hinge plate complete 75 | ("3665a", "3665"), 76 | ("3665b", "3665"), # 2 x 1 45 deg inv slope 77 | ("4081a", "4081b"), # 1x1 plate with light ring 78 | ("4085a", "60897"), # 1x1 plate with vert clip 79 | ("4085b", "60897"), 80 | ("4085c", "60897"), 81 | ("6019", "61252"), # 1x1 plate with horz clip 82 | ("59426", "32209"), # technic 5.5 axle 83 | ("48729", "48729b"), # bar with clip 84 | ("48729a", "48729b"), 85 | ("41005", "48729b"), 86 | ("4459", "2780"), # Technic friction pin 87 | ("44302", "44302a"), # 1x2 click hinge plate 88 | ("44302b", "44302a"), # 1x2 click hinge plate 89 | ("2436", "28802"), # 1x2 x 1x4 bracket 90 | ("2436a", "28802"), 91 | ("2436b", "28802"), 92 | ("2454", "2454b"), # 1x2x5 brick 93 | ("64567", "577b"), # minifig lightsabre holder 94 | ("30241b", "60475b"), 95 | ("2861", "2861c01"), 96 | ("2859", "2859c01"), 97 | ("70026a", "70026"), 98 | ("4707pb01", "4707c01"), 99 | ("4707pb02", "4707c02"), 100 | ("4707pb03", "4707c03"), 101 | ("4707pb04", "4707c04"), 102 | ("4707pb05", "4707c05"), 103 | ("3242", "3240a"), 104 | ("2776c28", "766bc03"), 105 | ("2776c28", "766bc03"), 106 | ("766c96", "766bc03"), 107 | ("7864-1", "u9058c02"), 108 | ("bb0012vb","501bc01"), 109 | ("bb0012v2","867"), 110 | ("70026b", "70026"), 111 | ("3242c", "3240a"), 112 | ("4623b", "4623"), 113 | ("4623a", "4623"), 114 | ] 115 | 116 | 117 | def substitute_part(part): 118 | for e in COMMON_SUBSTITUTIONS: 119 | if part.name == e[0]: 120 | part.name = e[1] 121 | return part 122 | 123 | 124 | def line_has_all_tokens(line, tokenlist): 125 | for t in tokenlist: 126 | tokens = t.split() 127 | tcount = 0 128 | tlen = len(tokens) 129 | for te in tokens: 130 | if te in line.split(): 131 | tcount += 1 132 | if tcount == tlen: 133 | return True 134 | return False 135 | 136 | 137 | def parse_special_tokens(line): 138 | ls = line.split() 139 | metas = [] 140 | for k, v in SPECIAL_TOKENS.items(): 141 | for t in v: 142 | tokens = t.split() 143 | tcount = 0 144 | tlen = len([x for x in tokens if x[0] != "%"]) 145 | for token in tokens: 146 | if token in ls: 147 | tcount += 1 148 | if tcount == tlen: 149 | linelen = len(ls) 150 | captures = [int(x[1:]) for x in tokens if x[0] == "%"] 151 | if len(captures) > 0: 152 | values = [ls[x] for x in captures if x < linelen] 153 | meta = {k: {"values": values, "text": line}} 154 | else: 155 | meta = {k: {"text": line}} 156 | metas.append(meta) 157 | return metas 158 | 159 | 160 | def get_meta_commands(ldr_string): 161 | """Parses an LDraw string looking for known meta commands. Identified meta 162 | commands are returned in a dictionary.""" 163 | cmd = [] 164 | lines = ldr_string.splitlines() 165 | for line in lines: 166 | lineType = int(line.lstrip()[0] if line.lstrip() else -1) 167 | if lineType == -1: 168 | continue 169 | if lineType == 0: 170 | meta = parse_special_tokens(line) 171 | if meta is not None: 172 | cmd.extend(meta) 173 | return cmd 174 | 175 | 176 | def get_parts_from_model(ldr_string): 177 | """Extracts a list of parts representing LDraw parts (line type 1) from a 178 | string of LDraw text. The returned list is contains dictionary for each part 179 | with the keys "partname" and "ldrtext".""" 180 | parts = [] 181 | lines = ldr_string.splitlines() 182 | mask_depth = 0 183 | bufex = False 184 | for line in lines: 185 | pd = {} 186 | if line_has_all_tokens(line, ["BUFEXCHG STORE"]): 187 | bufex = True 188 | if line_has_all_tokens(line, ["BUFEXCHG RETRIEVE"]): 189 | bufex = False 190 | if line_has_all_tokens(line, START_TOKENS): 191 | mask_depth += 1 192 | if line_has_all_tokens(line, END_TOKENS): 193 | if mask_depth > 0: 194 | mask_depth -= 1 195 | 196 | lineType = int(line.lstrip()[0] if line.lstrip() else -1) 197 | if lineType == -1: 198 | continue 199 | if lineType == 1: 200 | splitLine = line.lower().split() 201 | pd["ldrtext"] = line 202 | pd["partname"] = " ".join([str(i) for i in splitLine[14:]]) 203 | if mask_depth == 0: 204 | parts.append(pd) 205 | else: 206 | if pd["partname"] in EXCEPTION_LIST: 207 | parts.append(pd) 208 | elif not bufex and pd["partname"].endswith(".ldr"): 209 | parts.append(pd) 210 | return parts 211 | 212 | 213 | def recursive_parse_model( 214 | model, 215 | submodels, 216 | parts, 217 | offset=None, 218 | matrix=None, 219 | reset_parts=False, 220 | only_submodel=None, 221 | ): 222 | """Recursively parses an LDraw model dictionary plus any submodels and 223 | populates a parts list representing that model. To support selective 224 | parsing of only one submodel, only_submodel can be set to the desired 225 | submodel.""" 226 | o = offset if offset is not None else Vector(0, 0, 0) 227 | m = matrix if matrix is not None else Identity() 228 | if reset_parts: 229 | parts.clear() 230 | for e in model: 231 | if only_submodel is not None: 232 | if not e["partname"] == only_submodel: 233 | continue 234 | if e["partname"] in submodels: 235 | submodel = submodels[e["partname"]] 236 | p = LDRPart() 237 | p.from_str(e["ldrtext"]) 238 | new_matrix = m * p.attrib.matrix 239 | new_loc = m * p.attrib.loc 240 | new_loc += o 241 | recursive_parse_model( 242 | submodel, 243 | submodels, 244 | parts, 245 | offset=new_loc, 246 | matrix=new_matrix, 247 | ) 248 | else: 249 | if only_submodel is None: 250 | part = LDRPart() 251 | part.from_str(e["ldrtext"]) 252 | part = substitute_part(part) 253 | part.transform(matrix=m, offset=o) 254 | if ( 255 | not part.name in IGNORE_LIST 256 | and not part.name.upper() in IGNORE_LIST 257 | ): 258 | parts.append(part) 259 | 260 | 261 | def unique_set(items): 262 | udict = {} 263 | if len(items) > 0: 264 | udict = defaultdict(int) 265 | for e in items: 266 | udict[e] += 1 267 | return udict 268 | 269 | 270 | def key_name(elem): 271 | return elem.name 272 | 273 | 274 | def key_colour(elem): 275 | return elem.attrib.colour 276 | 277 | 278 | def key_colour(elem): 279 | return elem.attrib.colour 280 | 281 | 282 | def get_sha1_hash(parts): 283 | """Gets a normalized sha1 hash LDRPart objects""" 284 | sp = [(p, p.sha1hash()) for p in parts] 285 | sp.sort(key=lambda x: x[1]) 286 | shash = hashlib.sha1() 287 | for p in sp: 288 | shash.update(bytes(p[1], encoding="utf8")) 289 | return shash.hexdigest() 290 | 291 | 292 | def sort_parts(parts, key="name", order="ascending"): 293 | """Sorts a list of LDRPart objects by key""" 294 | if key.lower() == "sha1": 295 | sp = [(p, p.sha1hash()) for p in parts] 296 | sp.sort(key=lambda x: x[1]) 297 | return [p[0] for p in sp] 298 | sp = [p for p in parts] 299 | if key.lower() == "name": 300 | sp.sort(key=key_name, reverse=True if order.lower() == "descending" else False) 301 | elif key.lower() == "colour": 302 | sp.sort( 303 | key=key_colour, reverse=True if order.lower() == "descending" else False 304 | ) 305 | return sp 306 | 307 | 308 | class LDRModel: 309 | PARAMS = { 310 | "global_origin": (0, 0, 0), 311 | "global_aspect": (-40, 55, 0), 312 | "global_scale": 1.0, 313 | "pli_aspect": (-25, -40, 0), 314 | "pli_exceptions": { 315 | "32001": (-50, -25, 0), 316 | "3676": (-25, 50, 0), 317 | "3045": (-25, 50, 0), 318 | }, 319 | "callout_step_thr": 6, 320 | "continuous_step_numbers": False, 321 | } 322 | 323 | def __init__(self, filename, **kwargs): 324 | from brickbom import BOM, BOMPart 325 | 326 | self.filename = filename 327 | apply_params(self, kwargs, locals()) 328 | _, self.title = split_path(filename) 329 | self.bom = BOM() 330 | self.steps = {} 331 | self.pli = {} 332 | self.sub_models = {} 333 | self.sub_model_str = {} 334 | self.unwrapped = None 335 | self.callouts = {} 336 | self.continuous_step_count = 0 337 | 338 | def __str__(self): 339 | s = [] 340 | s.append("LDRModel:") 341 | s.append( 342 | " Global origin: %s Global aspect: %s" 343 | % (self.global_origin, self.global_aspect) 344 | ) 345 | s.append(" Number of steps: %d" % (len(self.steps))) 346 | s.append(" Number of sub-models: %d" % (len(self.sub_models))) 347 | return "\n".join(s) 348 | 349 | def __getitem__(self, key): 350 | return self.unwrapped[key] 351 | 352 | def print_step_dict(self, key): 353 | if key in self.steps: 354 | s = self.steps[key] 355 | for k, v in s.items(): 356 | if k == "sub_parts": 357 | for ks, vs in v.items(): 358 | print("%s: " % (ks)) 359 | for e in vs: 360 | print(" %s" % (str(e).rstrip())) 361 | elif isinstance(v, list): 362 | print("%s: " % (k)) 363 | for vx in v: 364 | print(" %s" % (str(vx).rstrip())) 365 | else: 366 | print("%s: %s" % (k, v)) 367 | 368 | def print_unwrapped_dict(self, idx): 369 | s = self.unwrapped[idx] 370 | for k, v in s.items(): 371 | if isinstance(v, list): 372 | print("%s: " % (k)) 373 | for vx in v: 374 | print(" %s" % (str(vx).rstrip())) 375 | else: 376 | print("%s: %s" % (k, v)) 377 | 378 | def print_unwrapped_verbose(self): 379 | for i, v in enumerate(self.unwrapped): 380 | print( 381 | "%3d. idx:%3d [pl:%d l:%d nl:%d] [s:%2d ns:%2d sc:%2d] %-16s q:%d sc:%.2f (%3.0f,%4.0f,%3.0f)" 382 | % ( 383 | i, 384 | v["idx"], 385 | v["prev_level"], 386 | v["level"], 387 | v["next_level"], 388 | v["step"], 389 | v["next_step"], 390 | v["num_steps"], 391 | v["model"], 392 | v["qty"], 393 | v["scale"], 394 | v["aspect"][0], 395 | v["aspect"][1], 396 | v["aspect"][2], 397 | ) 398 | ) 399 | 400 | def print_unwrapped(self): 401 | for v in self.unwrapped: 402 | self.print_step(v) 403 | 404 | def print_step(self, v): 405 | pb = "break" if v["page_break"] else "" 406 | co = str(v["callout"]) 407 | model_name = v["model"].replace(".ldr", "") 408 | model_name = model_name[:16] 409 | qty = "(%2dx)" % (v["qty"]) if v["qty"] > 0 else " " 410 | level = " " * v["level"] + "Level %d" % (v["level"]) 411 | level = "%-11s" % (level) 412 | parts = "(%2dx pcs)" % (len(v["pli_bom"])) 413 | meta = [v.keys() for v in v["meta"]] 414 | meta = [list(x) for x in meta if "columns" not in x] 415 | for e in v["meta"]: 416 | if "columns" in e: 417 | meta.append("[green]COL%s[/]" % (e["columns"]["values"][0])) 418 | meta = ["".join(x) for x in meta] 419 | meta = " ".join(meta) 420 | meta = meta.replace("arrow_begin", ":arrow_down:") 421 | meta = meta.replace(" arrow_end", "") 422 | meta = meta.replace(" arrow_length", "") 423 | meta = meta.replace("rotation_rel", ":arrows_counterclockwise:R") 424 | meta = meta.replace("rotation_abs", ":arrows_counterclockwise:A") 425 | meta = meta.replace("rotation_pre", ":arrows_counterclockwise:P") 426 | meta = meta.replace("preview_aspect", ":arrows_counterclockwise:M") 427 | meta = meta.replace("model_scale", ":triangular_ruler:M") 428 | meta = meta.replace("scale", ":triangular_ruler:") 429 | meta = meta.replace("page_break", ":page_facing_up:") 430 | meta = meta.replace("no_callout", ":prohibited:CA") 431 | meta = meta.replace("no_preview", ":prohibited:PR") 432 | if has_rich: 433 | if not co == "0": 434 | fmt = "%3d. %s Step [yellow]%3d/%3d[/] Model: [red]%-16s[/]" 435 | else: 436 | if model_name == "root": 437 | fmt = "%3d. %s Step [green]%3d/%3d[/] Model: [green]%-16s[/]" 438 | else: 439 | fmt = "%3d. %s Step [green]%3d/%3d[/] Model: [red]%-16s[/]" 440 | else: 441 | fmt = "%3d. %s Step %3d/%3d Model: %-16s" 442 | fmt += " %s %s scale: %.2f (%3.0f,%4.0f,%3.0f)" 443 | if co == "0": 444 | fmt += " [bright_black]%1s[/]" 445 | else: 446 | fmt += " [yellow]%1s[/]" 447 | if pb == "break": 448 | fmt += " [magenta]BR[/] %s" 449 | else: 450 | fmt += " %s" 451 | print( 452 | fmt 453 | % ( 454 | v["idx"], 455 | level, 456 | v["step"], 457 | v["num_steps"], 458 | model_name, 459 | qty, 460 | parts, 461 | v["scale"], 462 | v["aspect"][0], 463 | v["aspect"][1], 464 | v["aspect"][2], 465 | co, 466 | meta, 467 | ) 468 | ) 469 | 470 | def idx_range_from_steps(self, steps): 471 | """Returns a start and stop index into the unwrapped model from 472 | either a list or range.""" 473 | if isinstance(steps, list): 474 | start_idx = self.idx_from_step(steps[0], as_start_idx=True) 475 | stop_idx = self.idx_from_step(steps[len(steps) - 1]) 476 | elif isinstance(steps, range): 477 | start_idx = self.idx_from_step(steps.start, as_start_idx=True) 478 | stop_idx = self.idx_from_step(steps.stop) 479 | return start_idx, stop_idx 480 | 481 | def idx_from_step(self, step, as_start_idx=False): 482 | """Returns the index from the unwrapped model corresponding to a specified 483 | step in the root model.""" 484 | if step == 1: # and as_start_idx: 485 | return 0 486 | if step < 0: 487 | return len(self.unwrapped) + step 488 | if step < 1: 489 | return 0 490 | if as_start_idx: 491 | if self.continuous_step_numbers: 492 | for s in self.unwrapped: 493 | if s["step"] == step - 1 and s["callout"] == 0: 494 | return s["idx"] + 1 495 | else: 496 | for s in self.unwrapped: 497 | if s["step"] == step - 1 and s["level"] == 0: 498 | return s["idx"] + 1 499 | else: 500 | if self.continuous_step_numbers: 501 | for s in self.unwrapped: 502 | if s["step"] == step and s["callout"] == 0: 503 | return s["idx"] 504 | else: 505 | for s in self.unwrapped: 506 | if s["step"] == step and s["level"] == 0: 507 | return s["idx"] 508 | 509 | return len(self.unwrapped) - 1 510 | 511 | def print_callouts(self): 512 | for k, v in self.callouts.items(): 513 | print( 514 | "Callout: level %d: %d -> %d parent: %d" 515 | % (v["level"], k, v["end"], v["parent"]) 516 | ) 517 | 518 | def is_callout_start(self, idx): 519 | """Returns True if the index to the unwrapped model points to a 520 | the beginning of a callout sequence.""" 521 | return idx in self.callouts 522 | 523 | def prev_step_was_callout(self, idx): 524 | """Returns True if the previous step was in a callout.""" 525 | callout_before = self[idx - 1]["callout"] > 0 if idx > 0 else False 526 | return callout_before 527 | 528 | def prev_step_was_callout_end(self, idx): 529 | """Returns True if the previous step ended a callout.""" 530 | did_end = self.is_callout_end(idx - 1) if idx > 0 else False 531 | return did_end 532 | 533 | def is_callout_end(self, idx): 534 | """Returns True if the index to the unwrapped model points to a 535 | step at the end of a callout sequence.""" 536 | for k, v in self.callouts.items(): 537 | if v["end"] == idx: 538 | return True 539 | return False 540 | 541 | def callout_has_meta(self, idx, tag): 542 | """Returns True if the index to the unwrapped model points to a 543 | a callout whose model has a specified meta tag.""" 544 | for k, v in self.callouts.items(): 545 | if idx >= k and idx <= v["end"]: 546 | for i in range(k, v["end"] + 1): 547 | if self.step_has_meta(i, tag): 548 | return True 549 | return False 550 | 551 | def callout_meta_values(self, idx, tag): 552 | """Returns the values of a callout whose model has a specified meta tag.""" 553 | for k, v in self.callouts.items(): 554 | if idx >= k and idx <= v["end"]: 555 | for i in range(k, v["end"] + 1): 556 | if self.step_has_meta(i, tag): 557 | return self.step_meta_values(idx, tag) 558 | return None 559 | 560 | def callout_parent(self, idx): 561 | """Returns the level into the model hierarchy of a callout's 562 | parent level at the specified index into the unwrapped model.""" 563 | level = 0 564 | for k, v in self.callouts.items(): 565 | if idx >= k and idx <= v["end"]: 566 | level = max(level, v["parent"]) 567 | return level 568 | 569 | def is_parent_a_callout(self, idx): 570 | """Returns True if at the index into the unwrapped model 571 | a callout step is contained in another callout.""" 572 | my_parent = self.callout_parent(idx) 573 | if my_parent > 0: 574 | for k, v in self.callouts.items(): 575 | if v["level"] == my_parent and idx >= k and idx <= v["end"]: 576 | return True 577 | return False 578 | 579 | def has_assembly_arrows(self, idx): 580 | meta = self.unwrapped[idx]["meta"] 581 | for m in meta: 582 | if "arrow_begin" in m: 583 | return True 584 | return False 585 | 586 | def has_meta_tag(self, meta, tag): 587 | for m in meta: 588 | if tag in m: 589 | return True 590 | return False 591 | 592 | def step_meta_values(self, idx, tag): 593 | meta = self.unwrapped[idx]["meta"] 594 | for m in meta: 595 | if tag in m: 596 | return m[tag]["values"] 597 | return None 598 | 599 | def count_meta_keys(self, idx, tag): 600 | meta = self.unwrapped[idx]["meta"] 601 | count = 0 602 | for m in meta: 603 | if tag in m: 604 | count += 1 605 | return count 606 | 607 | def step_has_meta(self, idx, tag): 608 | meta = self.unwrapped[idx]["meta"] 609 | for m in meta: 610 | if tag in m: 611 | return True 612 | return False 613 | 614 | def has_no_preview_meta(self, idx): 615 | return self.step_has_meta(idx, "no_preview") 616 | 617 | def has_model_scale_meta(self, idx): 618 | return self.step_has_meta(idx, "model_scale") 619 | 620 | def has_fixed_scale_meta(self, idx): 621 | return self.step_has_meta(idx, "scale") 622 | 623 | def print_parts_at_idx(self, idx): 624 | parts = self.unwrapped[idx]["step_parts"] 625 | for p in parts: 626 | print(str(p).rstrip()) 627 | 628 | def print_model_at_idx(self, idx): 629 | parts = self.unwrapped[idx]["parts"] 630 | for p in parts: 631 | print(str(p).rstrip()) 632 | 633 | def print_pli_at_idx(self, idx): 634 | parts = self.unwrapped[idx]["pli_bom"] 635 | print(parts) 636 | 637 | def print_bom_at_idx(self, idx): 638 | print(self.unwrapped[idx]["pli_bom"]) 639 | 640 | def get_sub_model_assem(self, submodel): 641 | """Returns the index into the unwrapped model of a specified 642 | sub-model assembly.""" 643 | last_idx = 0 644 | submodel = submodel if ".ldr" in submodel else submodel + ".ldr" 645 | for s in self.unwrapped: 646 | if s["model"] == submodel: 647 | last_idx = max(last_idx, s["idx"]) 648 | return last_idx 649 | 650 | def model_has_meta(self, submodel, tag): 651 | submodel = submodel if ".ldr" in submodel else submodel + ".ldr" 652 | for s in self.unwrapped: 653 | if s["model"] == submodel: 654 | if self.step_has_meta(s["idx"], tag): 655 | return True 656 | return False 657 | 658 | def model_meta_values(self, submodel, tag): 659 | submodel = submodel if ".ldr" in submodel else submodel + ".ldr" 660 | for s in self.unwrapped: 661 | if s["model"] == submodel: 662 | if self.step_has_meta(s["idx"], tag): 663 | return self.step_meta_values(s["idx"], tag) 664 | return None 665 | 666 | def unwrap(self): 667 | self.unwrapped = self.unwrap_model() 668 | 669 | def unwrap_model( 670 | self, 671 | root=None, 672 | idx=None, 673 | level=None, 674 | model_name=None, 675 | model_qty=None, 676 | unwrapped=None, 677 | ): 678 | """This recursive function unwraps the entire sequence of building steps 679 | for a LDraw model. This sequence unwraps nested building steps implied in 680 | the hierarchy of a model consisting of sub-models and the sub-model's 681 | children. Unwrapping the model also allows pre-computation of transitions 682 | to nested sub-steps which can either be represented as callouts or inline 683 | build instructions. The unwrapped model is represented as a list of 684 | dictionaries with a rich representation of the model at each step. 685 | The unwrapped model can then be traversed easily in a linear fashion 686 | by an iterator.""" 687 | 688 | if root is None: 689 | idx = 0 690 | level = 0 691 | unwrapped = [] 692 | model = self.steps 693 | model_name = "root" 694 | model_qty = 0 695 | self.continuous_step_count = 0 696 | else: 697 | idx = idx 698 | level = level 699 | model = root 700 | unwrapped = unwrapped 701 | model_name = model_name 702 | model_qty = model_qty 703 | for k, v in model.items(): 704 | if len(v["sub_models"]) > 0: 705 | subs = unique_set(v["sub_models"]) 706 | for name, qty in subs.items(): 707 | pli, steps = self.parse_model(name, is_top_level=False) 708 | _, newidx = self.unwrap_model( 709 | root=steps, 710 | idx=idx, 711 | level=level + 1, 712 | model_name=name, 713 | model_qty=qty, 714 | unwrapped=unwrapped, 715 | ) 716 | idx = newidx 717 | sd = { 718 | "idx": idx, 719 | "level": level, 720 | "step": k, 721 | "next_step": k + 1 if k < len(model.items()) else k, 722 | "num_steps": len(model.items()), 723 | "model": model_name, 724 | "qty": model_qty, 725 | "scale": v["scale"], 726 | "model_scale": v["model_scale"], 727 | "aspect": v["aspect"], 728 | "parts": v["parts"], 729 | "step_parts": v["step_parts"], 730 | "pli_bom": v["pli_bom"], 731 | "meta": v["meta"], 732 | "aspect_change": v["aspect_change"], 733 | "raw_ldraw": v["raw_ldraw"], 734 | "sub_parts": v["sub_parts"], 735 | } 736 | unwrapped.append(sd) 737 | idx += 1 738 | if level == 0: 739 | # when finished unwrapping the model, loop through the model 740 | # to add new keys for next and prev level changes and automatic 741 | # callout detection and page breaks across changes in level which 742 | # do not result in a callout. Also apply step numbering scheme. 743 | umodel = [] 744 | step_num = 1 745 | prev_level = 0 746 | next_level = 0 747 | prev_break = False 748 | callout = [] 749 | callout_start = [] 750 | callout_end = [] 751 | callout_parent = [] 752 | dont_callout_models = [] 753 | for i, e in enumerate(unwrapped): 754 | level = e["level"] 755 | next_level = ( 756 | unwrapped[i + 1]["level"] if i < len(unwrapped) - 1 else level 757 | ) 758 | level_up = True if next_level > level else False 759 | level_down = True if next_level < level else False 760 | levelled_up = True if level > prev_level else False 761 | levelled_down = True if level < prev_level else False 762 | next_steps = ( 763 | unwrapped[i + 1]["num_steps"] 764 | if i < len(unwrapped) - 1 765 | else e["num_steps"] 766 | ) 767 | prev_steps = e["num_steps"] if i == 0 else unwrapped[i - 1]["num_steps"] 768 | dont_callout = ( 769 | self.has_meta_tag(unwrapped[i]["meta"], "no_callout") 770 | if i < len(unwrapped) 771 | else False 772 | ) 773 | dont_callout_next = ( 774 | self.has_meta_tag(unwrapped[i + 1]["meta"], "no_callout") 775 | if i < len(unwrapped) - 1 776 | else False 777 | ) 778 | if dont_callout: 779 | dont_callout_models.append(e["model"]) 780 | page_break = ( 781 | True if level_up and next_steps >= self.callout_step_thr else False 782 | ) 783 | page_break = ( 784 | True 785 | if level_down and e["num_steps"] >= self.callout_step_thr 786 | else page_break 787 | ) 788 | page_break = True if level_up and dont_callout_next else page_break 789 | page_break = ( 790 | True 791 | if level_down and e["model"] in dont_callout_models 792 | else page_break 793 | ) 794 | pb = False 795 | for x in unwrapped[i]["meta"]: 796 | if "page_break" in x: 797 | pb = True 798 | elif "pli_proxy" in x: 799 | for item in x["pli_proxy"]["values"]: 800 | if "_" in item: 801 | sp = item.split("_") 802 | pname = sp[0] 803 | pcolour = int(sp[1]) 804 | else: 805 | pname = item 806 | pcolour = LDR_DEF_COLOUR 807 | proxy_part = BOMPart(1, pname, pcolour) 808 | e["pli_bom"].add_part(proxy_part) 809 | self.bom.add_part(proxy_part) 810 | 811 | page_break = True if pb else page_break 812 | no_pli = True if levelled_down and prev_break else False 813 | if ( 814 | levelled_up 815 | and (e["num_steps"] < self.callout_step_thr) 816 | and not dont_callout 817 | ): 818 | callout.append(level) 819 | callout_start.append(i) 820 | callout_parent.append(prev_level) 821 | 822 | elif levelled_down: 823 | if len(callout) > 0: 824 | callout.pop() 825 | callout_end.append(i - 1) 826 | if len(callout) > 0: 827 | callout_level = callout[len(callout) - 1] 828 | else: 829 | callout_level = 0 830 | if self.continuous_step_numbers: 831 | if callout_level == 0: 832 | e["step"] = step_num 833 | step_num += 1 834 | self.continuous_step_count += 1 835 | x = { 836 | **e, 837 | "prev_level": prev_level, 838 | "next_level": next_level, 839 | "page_break": page_break, 840 | "no_pli": no_pli, 841 | "callout": callout_level, 842 | } 843 | umodel.append(x) 844 | prev_level = level 845 | prev_break = page_break 846 | if self.continuous_step_numbers: 847 | for e in umodel: 848 | if e["callout"] == 0: 849 | e["num_steps"] = self.continuous_step_count 850 | # pair up the callout boundaries in dictionary with the start 851 | # index as the key and the end index and parent level as values 852 | self.callouts = {} 853 | for x0, p in zip(callout_start, callout_parent): 854 | level = umodel[x0]["callout"] 855 | for x1 in callout_end: 856 | endlevel = umodel[x1]["callout"] 857 | if x1 >= x0 and level == endlevel: 858 | self.callouts[x0] = {"level": level, "end": x1, "parent": p} 859 | # set custom scale for the callout if configured 860 | if self.has_meta_tag(umodel[x0]["meta"], "model_scale"): 861 | self.callouts[x0]["scale"] = umodel[x0]["model_scale"] 862 | for ix in range(x0, x1 + 1): 863 | umodel[ix]["scale"] = umodel[ix]["model_scale"] 864 | break 865 | 866 | return umodel 867 | return unwrapped, idx 868 | 869 | def transform_parts_to(self, parts, origin=None, aspect=None, use_exceptions=False): 870 | """Transforms the location and/or aspect angle of all the parts in 871 | a list to a fixed position and/or aspect angle.""" 872 | aspect = aspect if aspect is not None else self.global_aspect 873 | if origin is not None and not isinstance(origin, Vector): 874 | origin = Vector(origin[0], origin[1], origin[2]) 875 | tparts = [] 876 | for p in parts: 877 | np = p.copy() 878 | angle = aspect 879 | # override the aspect angle for any parts which need a special 880 | # orientation for clarity 881 | if use_exceptions: 882 | if p.name in self.pli_exceptions: 883 | angle = self.pli_exceptions[p.name] 884 | np.set_rotation(angle) 885 | if origin is not None: 886 | np.move_to(origin) 887 | tparts.append(np) 888 | return tparts 889 | 890 | def transform_parts(self, parts, offset=None, aspect=None): 891 | """Transforms the geometry (location and or aspect angle) of all 892 | the parts in a list. The transform is applied as an offset to 893 | the existing part geometry.""" 894 | aspect = aspect if aspect is not None else self.global_aspect 895 | if offset is not None and not isinstance(offset, Vector): 896 | offset = Vector(offset[0], offset[1], offset[2]) 897 | tparts = [] 898 | if len(parts) < 1: 899 | return [] 900 | if isinstance(parts[0], LDRPart): 901 | for p in parts: 902 | np = p.copy() 903 | np.rotate_by(aspect) 904 | if offset is not None: 905 | np.move_by(offset) 906 | tparts.append(np) 907 | return tparts 908 | else: 909 | for p in parts: 910 | np = LDRPart() 911 | if np.from_str(p) is not None: 912 | np.rotate_by(aspect) 913 | if offset is not None: 914 | np.move_by(offset) 915 | tparts.append(str(np)) 916 | else: 917 | # print("part is None ", p) 918 | tparts.append(p) 919 | return tparts 920 | 921 | def transform_colour(self, parts, to_colour, from_colour=None, as_str=False): 922 | """Transforms the colour of a provided list of parts.""" 923 | tparts = [] 924 | if len(parts) < 1: 925 | return 926 | if isinstance(parts[0], LDRPart): 927 | for p in parts: 928 | np = p.copy() 929 | np.change_colour(to_colour) 930 | tparts.append(np) 931 | else: 932 | for p in parts: 933 | np = LDRPart() 934 | if np.from_str(p) is not None: 935 | np.change_colour(to_colour) 936 | tparts.append(np) 937 | if as_str: 938 | return [str(p) for p in tparts] 939 | return tparts 940 | 941 | def parse_file(self): 942 | """Parses an LDraw file and determines the root model and any included 943 | submodels.""" 944 | self.sub_models = {} 945 | with open(self.filename, "rt") as fp: 946 | files = fp.read().split("0 FILE") 947 | root = None 948 | if len(files) == 1: 949 | root = files[0] 950 | else: 951 | root = "0 FILE " + files[1] 952 | for sub_file in files[2:]: 953 | sub_name = sub_file.splitlines()[0].lower().strip() 954 | sub_str = "0 FILE" + sub_file 955 | self.sub_model_str[sub_name] = sub_str 956 | self.sub_models[sub_name] = get_parts_from_model(sub_str) 957 | self.pli, self.steps = self.parse_model(root, is_top_level=True) 958 | self.unwrap() 959 | 960 | def ad_hoc_parse(self, ldrstring, only_submodel=None): 961 | """Performs an adhoc parsing operation on a provided LDraw formatted text 962 | string. If any references are made to submodels, then it recursively un packs 963 | the parts for the submodels based on a previous call to parse_model. 964 | Optionally, the parsing can be confined to only one submodel identified 965 | by only_submodel.""" 966 | model_parts = [] 967 | step_parts = get_parts_from_model(ldrstring) 968 | recursive_parse_model( 969 | step_parts, 970 | self.sub_models, 971 | model_parts, 972 | reset_parts=False, 973 | only_submodel=only_submodel, 974 | ) 975 | return model_parts 976 | 977 | def parse_model(self, root, is_top_level=True, mask_submodels=False): 978 | """Generic parser for LDraw text. It parses a model provided either as a string 979 | of the entire LDR file at root level or as a key to a submodel in the LDR file. 980 | In either case, it recursively traverses the LDraw tree including all the 981 | children of the desired model and returns two lists: one for the parts 982 | at each step and one representing the model at each step. 983 | 984 | To parse at the root: 985 | self.pli, self.steps = self.parse_model(root, is_top_level=True) 986 | To parse a submodel: 987 | pli, steps = self.parse_model("submodel.ldr", is_top_level=False) 988 | 989 | The PLI list is a list of LDRPart objects for each step. 990 | The steps list is list of dictionaries with the following data for each step: 991 | parts - the aggregate parts that form the model at the step 992 | step_parts - only the parts that have been added at the step 993 | sub_models - a list of submodels referred to in this step 994 | scale - the current model scale 995 | aspect - the current euler viewing aspect angle 996 | pli_bom - a BOM object containing the parts in this step 997 | meta - a list of dictionaries representing any meta commands 998 | found in this step 999 | raw_ldraw - the raw LDraw text in the step 1000 | aspect_change - a flag indicating the aspect angle has changed 1001 | sub_parts - parts added to this step that come from sub-models 1002 | indexed by submodel name in a dictionary 1003 | """ 1004 | is_masked = False 1005 | if not is_top_level: 1006 | if root in self.sub_model_str: 1007 | root = self.sub_model_str[root] 1008 | else: 1009 | key = root + ".ldr" 1010 | if key in self.sub_model_str: 1011 | root = self.sub_model_str[key] 1012 | is_masked = True if mask_submodels else False 1013 | 1014 | model_pli = {} 1015 | model_steps = {} 1016 | steps = root.split("0 STEP") 1017 | model_parts = [] 1018 | 1019 | current_aspect = self.global_aspect 1020 | current_scale = self.global_scale 1021 | model_scale = self.global_scale 1022 | callout_style = "top" 1023 | 1024 | step_num = 1 1025 | progress_bar(0, len(steps), "Parsing:", length=50) 1026 | for i, step in enumerate(steps): 1027 | aspect_change = False 1028 | proxy_parts = [] 1029 | step_parts = get_parts_from_model(step) 1030 | meta_cmd = get_meta_commands(step) 1031 | for cmd in meta_cmd: 1032 | if "scale" in cmd: 1033 | current_scale = float(cmd["scale"]["values"][0]) 1034 | elif "model_scale" in cmd: 1035 | model_scale = float(cmd["model_scale"]["values"][0]) 1036 | elif "callout" in cmd: 1037 | callout_style = cmd["callout"]["values"][0].lower() 1038 | elif "rotation_abs" in cmd: 1039 | current_aspect = [float(x) for x in cmd["rotation_abs"]["values"]] 1040 | current_aspect[0] = -current_aspect[0] 1041 | current_aspect = tuple(current_aspect) 1042 | aspect_change = True if step_num > 1 else False 1043 | elif "rotation_rel" in cmd: 1044 | aspect_change = True if step_num > 1 else False 1045 | ar = tuple([float(x) for x in cmd["rotation_rel"]["values"]]) 1046 | current_aspect = ( 1047 | (current_aspect[0] + ar[0]), 1048 | (current_aspect[1] + ar[1]), 1049 | (current_aspect[2] + ar[2]), 1050 | ) 1051 | current_aspect = norm_aspect(current_aspect) 1052 | elif "rotation_pre" in cmd: 1053 | current_aspect = preset_aspect( 1054 | current_aspect, cmd["rotation_pre"]["values"] 1055 | ) 1056 | aspect_change = True if step_num > 1 else False 1057 | elif "pli_proxy" in cmd: 1058 | for item in cmd["pli_proxy"]["values"]: 1059 | if "_" in item: 1060 | sp = item.split("_") 1061 | pname = sp[0] 1062 | pcolour = sp[1] 1063 | else: 1064 | pname = item 1065 | pcolour = LDR_DEF_COLOUR 1066 | proxy_part = LDRPart(colour=pcolour, name=pname) 1067 | proxy_parts.append(proxy_part) 1068 | 1069 | # capture submodel references in this step 1070 | subs = [] 1071 | for p in step_parts: 1072 | if p["partname"] in self.sub_models: 1073 | subs.append(p["partname"]) 1074 | # capture the parts that have been added in this step 1075 | # and store a transformed/normalized version for a PLI 1076 | parts_in_step = [] 1077 | recursive_parse_model( 1078 | step_parts, self.sub_models, parts_in_step, reset_parts=True 1079 | ) 1080 | pli = self.transform_parts_to( 1081 | parts_in_step, 1082 | origin=(0, 0, 0), 1083 | aspect=self.pli_aspect, 1084 | use_exceptions=True, 1085 | ) 1086 | # check for proxy parts added to step for PLI 1087 | if len(proxy_parts) > 0: 1088 | proxy_parts = self.transform_parts_to( 1089 | proxy_parts, 1090 | origin=(0, 0, 0), 1091 | aspect=self.pli_aspect, 1092 | use_exceptions=True, 1093 | ) 1094 | pli.extend(proxy_parts) 1095 | # submodel parts stored in separate dictionaries for convenient 1096 | # access if required 1097 | sub_dict = {} 1098 | for sub in subs: 1099 | sub_parts = [] 1100 | recursive_parse_model( 1101 | step_parts, 1102 | self.sub_models, 1103 | sub_parts, 1104 | reset_parts=True, 1105 | only_submodel=sub, 1106 | ) 1107 | pn = self.transform_parts(sub_parts, aspect=current_aspect) 1108 | sub_dict[sub] = pn 1109 | 1110 | step_dict = {} 1111 | if len(pli) > 0: 1112 | model_pli[step_num] = pli 1113 | # Store a BOM object representation of the parts for convenience 1114 | pli_bom = BOM() 1115 | pli_bom.ignore_parts = self.bom.ignore_parts 1116 | for p in pli: 1117 | pli_bom.add_part(BOMPart(1, p.name, p.attrib.colour)) 1118 | if is_top_level: 1119 | self.bom.add_part(BOMPart(1, p.name, p.attrib.colour)) 1120 | # store the model representation 1121 | recursive_parse_model( 1122 | step_parts, self.sub_models, model_parts, reset_parts=False 1123 | ) 1124 | p = self.transform_parts(model_parts, aspect=current_aspect) 1125 | # store only the parts added in this step 1126 | pn = self.transform_parts(parts_in_step, aspect=current_aspect) 1127 | # put all the collection info into a dictionary 1128 | step_dict["parts"] = p 1129 | step_dict["sub_models"] = subs 1130 | step_dict["aspect"] = current_aspect 1131 | step_dict["scale"] = current_scale 1132 | step_dict["model_scale"] = model_scale 1133 | step_dict["raw_ldraw"] = step 1134 | step_dict["step_parts"] = pn 1135 | step_dict["pli_bom"] = pli_bom 1136 | step_dict["meta"] = meta_cmd 1137 | step_dict["aspect_change"] = aspect_change 1138 | step_dict["sub_parts"] = sub_dict 1139 | model_steps[step_num] = step_dict 1140 | step_num += 1 1141 | 1142 | progress_bar(i, len(steps), "Parsing:", length=50) 1143 | return model_pli, model_steps 1144 | -------------------------------------------------------------------------------- /ldrawpy/ldrpprint.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # LDraw pretty printer helper functions 25 | 26 | import string 27 | 28 | from rich import print 29 | from ldrawpy import * 30 | 31 | 32 | def is_hex_colour(text): 33 | text = text.strip('"') 34 | if not len(text) == 7: 35 | return False 36 | if not text[0] == "#": 37 | return False 38 | hs = text.lstrip("#") 39 | if not all(c in string.hexdigits for c in hs): 40 | return False 41 | return True 42 | 43 | 44 | def pprint_ldr_colour(code): 45 | if code == "16" or code == "24": 46 | return "[bold navajo_white1]%s" % (code) 47 | if code == "0": 48 | return "[bold]0" 49 | # return "[bold yellow reverse]%s[not reverse]" % (code) 50 | colour = LDRColour(int(code)) 51 | return "[#%s reverse]%s[not reverse]" % (colour.as_hex(), code) 52 | 53 | 54 | def pprint_coord_str(v, colour="[white]"): 55 | return "[not bold]%s%s %s %s" % (colour, v[0], v[1], v[2]) 56 | 57 | 58 | def pprint_line1(line): 59 | s = [] 60 | line = line.rstrip() 61 | ls = line.split() 62 | s.append("[bold white]%s" % (ls[0])) 63 | s.append("%s" % (pprint_ldr_colour(ls[1]))) 64 | s.append(pprint_coord_str(ls[2:5])) 65 | s.append(pprint_coord_str(ls[5:8], colour="[#91E3FF]")) 66 | s.append(pprint_coord_str(ls[8:11], colour="[#FFF3AF]")) 67 | s.append(pprint_coord_str(ls[11:14], colour="[#91E3FF]")) 68 | if line.lower().endswith(".ldr"): 69 | s.append("[bold #B7E67A]%s" % (" ".join(ls[14:]))) 70 | else: 71 | s.append("[bold #F27759]%s" % (" ".join(ls[14:]))) 72 | return " ".join(s) 73 | 74 | 75 | def pprint_line2345(line): 76 | s = [] 77 | line = line.rstrip() 78 | ls = line.split() 79 | s.append("[bold white]%s" % (ls[0])) 80 | s.append("%s" % (pprint_ldr_colour(ls[1]))) 81 | s.append(pprint_coord_str(ls[2:5])) 82 | s.append(pprint_coord_str(ls[5:8], colour="[#91E3FF]")) 83 | if len(ls) > 8: 84 | s.append(pprint_coord_str(ls[8:11], colour="[#FFF3AF]")) 85 | if len(ls) > 11: 86 | s.append(pprint_coord_str(ls[8:11], colour="[#91E3FF]")) 87 | return " ".join(s) 88 | 89 | 90 | def pprint_line0(line): 91 | s = [] 92 | line = line.rstrip() 93 | ls = line.split() 94 | if ls[1] in LDRAW_TOKENS or ls[1] in META_TOKENS or ls[1].startswith("!"): 95 | s.append("[bold white]%s[not bold]" % (ls[0])) 96 | if "FILE" in ls[1]: 97 | s.append("[bold #B7E67A]%s[not bold]" % (ls[1])) 98 | elif ls[1].startswith("!"): 99 | s.append("[bold #78D4FE]%s[not bold]" % (ls[1])) 100 | elif ls[1] in META_TOKENS: 101 | s.append("[bold #BA7AE4]%s[not bold]" % (ls[1])) 102 | else: 103 | s.append("[bold #7096FF]%s[not bold]" % (ls[1])) 104 | if len(ls) > 2: 105 | if line.lower().endswith(".ldr"): 106 | for e in ls[2:]: 107 | s.append("[bold #B7E67A]%s" % (e)) 108 | elif line.lower().endswith(".dat"): 109 | for e in ls[2:]: 110 | s.append("[bold #F27759]%s" % (e)) 111 | else: 112 | for e in ls[2:]: 113 | if e in META_TOKENS: 114 | s.append("[bold #BA7AE4]%s[not bold]" % (e)) 115 | elif is_hex_colour(e): 116 | s.append("[%s reverse]%s[not reverse]" % (e.strip('"'), e)) 117 | else: 118 | s.append("[white]%s" % (e)) 119 | else: 120 | s.append("[bold black]%s" % (line.rstrip())) 121 | return " ".join(s) 122 | 123 | 124 | def pprint_line(line, lineno=None, nocolour=False): 125 | ls = line.split() 126 | s = [] 127 | if lineno is not None: 128 | s.append("[#404040]%4d | " % (lineno)) 129 | if len(ls) > 1 and not nocolour: 130 | if ls[0] == "1": 131 | s.append(pprint_line1(line)) 132 | elif ls[0] == "0": 133 | s.append(pprint_line0(line)) 134 | elif any([ls[0] in "2345"]): 135 | s.append(pprint_line2345(line)) 136 | else: 137 | s.append("[white]%s" % (line.rstrip())) 138 | else: 139 | s.append("[white][not bold]%s" % (line.rstrip())) 140 | print("".join(s)) 141 | -------------------------------------------------------------------------------- /ldrawpy/ldrprimitives.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # LDraw primitives 25 | 26 | import hashlib 27 | 28 | from functools import reduce 29 | 30 | from toolbox import * 31 | from ldrawpy import * 32 | from .ldrhelpers import vector_str, mat_str, quantize 33 | 34 | 35 | class LDRAttrib: 36 | __slots__ = ["colour", "units", "loc", "matrix"] 37 | 38 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 39 | self.colour = int(colour) 40 | self.units = units 41 | self.loc = Vector(0, 0, 0) 42 | self.matrix = Identity() 43 | 44 | def __eq__(self, other): 45 | if not isinstance(other, self.__class__): 46 | return False 47 | if not self.colour == other.colour: 48 | return False 49 | if not self.loc.almost_same_as(other.loc): 50 | return False 51 | if not self.matrix.is_almost_same_as(other.matrix): 52 | return False 53 | return True 54 | 55 | def copy(self): 56 | a = LDRAttrib() 57 | a.colour = self.colour 58 | a.units = self.units 59 | a.loc = self.loc.copy() 60 | a.matrix = self.matrix.copy() 61 | return a 62 | 63 | 64 | class LDRLine: 65 | __slots__ = ["attrib", "p1", "p2"] 66 | 67 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 68 | self.attrib = LDRAttrib(colour, units) 69 | self.p1 = Vector(0, 0, 0) 70 | self.p2 = Vector(0, 0, 0) 71 | 72 | def __str__(self): 73 | return ( 74 | ("2 %d " % self.attrib.colour) 75 | + vector_str(self.p1, self.attrib) 76 | + vector_str(self.p2, self.attrib) 77 | + "\n" 78 | ) 79 | 80 | def translate(self, offset): 81 | self.p1 += offset 82 | self.p2 += offset 83 | 84 | def transform(self, matrix): 85 | self.p1 = self.p1 * matrix 86 | self.p2 = self.p2 * matrix 87 | 88 | 89 | class LDRTriangle: 90 | __slots__ = ["attrib", "p1", "p2", "p3"] 91 | 92 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 93 | self.attrib = LDRAttrib(colour, units) 94 | self.p1 = Vector(0, 0, 0) 95 | self.p2 = Vector(0, 0, 0) 96 | self.p3 = Vector(0, 0, 0) 97 | 98 | def __str__(self): 99 | return ( 100 | ("3 %d " % self.attrib.colour) 101 | + vector_str(self.p1, self.attrib) 102 | + vector_str(self.p2, self.attrib) 103 | + vector_str(self.p3, self.attrib) 104 | + "\n" 105 | ) 106 | 107 | def translate(self, offset): 108 | self.p1 += offset 109 | self.p2 += offset 110 | self.p3 += offset 111 | 112 | def transform(self, matrix): 113 | self.p1 *= matrix 114 | self.p2 *= matrix 115 | self.p3 *= matrix 116 | 117 | 118 | class LDRQuad: 119 | __slots__ = ["attrib", "p1", "p2", "p3", "p4"] 120 | 121 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 122 | self.attrib = LDRAttrib(colour, units) 123 | self.p1 = Vector(0, 0, 0) 124 | self.p2 = Vector(0, 0, 0) 125 | self.p3 = Vector(0, 0, 0) 126 | self.p4 = Vector(0, 0, 0) 127 | 128 | def __str__(self): 129 | return ( 130 | ("4 %d " % self.attrib.colour) 131 | + vector_str(self.p1, self.attrib) 132 | + vector_str(self.p2, self.attrib) 133 | + vector_str(self.p3, self.attrib) 134 | + vector_str(self.p4, self.attrib) 135 | + "\n" 136 | ) 137 | 138 | def translate(self, offset): 139 | self.p1 += offset 140 | self.p2 += offset 141 | self.p3 += offset 142 | self.p4 += offset 143 | 144 | def transform(self, matrix): 145 | self.p1 *= matrix 146 | self.p2 *= matrix 147 | self.p3 *= matrix 148 | self.p4 *= matrix 149 | 150 | 151 | class LDRPart: 152 | __slots__ = ["attrib", "name", "wrapcallout"] 153 | 154 | def __init__(self, colour=LDR_DEF_COLOUR, name=None, units="ldu"): 155 | self.attrib = LDRAttrib(colour, units) 156 | self.name = name if name is not None else "" 157 | self.wrapcallout = False 158 | 159 | def __str__(self): 160 | tup = tuple(reduce(lambda row1, row2: row1 + row2, self.attrib.matrix.rows)) 161 | name = self.name 162 | ext = name[-4:].lower() 163 | name = self.name 164 | if len(name) > 4: 165 | if not (ext == ".ldr" or ext == ".dat"): 166 | name += ".dat" 167 | else: 168 | name += ".dat" 169 | s = ( 170 | ("1 %i " % self.attrib.colour) 171 | + vector_str(self.attrib.loc, self.attrib) 172 | + mat_str(tup) 173 | + ("%s\n" % name) 174 | ) 175 | if self.wrapcallout and ext == ".ldr": 176 | return "0 !LPUB CALLOUT BEGIN\n" + s + "0 !LPUB CALLOUT END\n" 177 | return s 178 | 179 | def __eq__(self, other): 180 | if not isinstance(other, self.__class__): 181 | return False 182 | if not self.name == other.name: 183 | return False 184 | if not self.attrib.colour == other.attrib.colour: 185 | return False 186 | return True 187 | 188 | def copy(self): 189 | p = LDRPart() 190 | p.name = self.name 191 | p.wrapcallout = self.wrapcallout 192 | p.attrib = self.attrib.copy() 193 | return p 194 | 195 | def sha1hash(self): 196 | shash = hashlib.sha1() 197 | shash.update(bytes(str(self), encoding="utf8")) 198 | return shash.hexdigest() 199 | 200 | def is_identical(self, other): 201 | if not self.name == other.name: 202 | return False 203 | if not self.attrib == other.attrib: 204 | return False 205 | return True 206 | 207 | def is_same(self, other, ignore_location=False, ignore_colour=False, exact=False): 208 | if not self.name == other.name: 209 | return False 210 | if exact: 211 | if not self.sha1hash() == other.sha1hash(): 212 | return False 213 | if not ignore_colour: 214 | if not self.attrib.colour == other.attrib.colour: 215 | return False 216 | if not ignore_location: 217 | if not self.attrib.loc.almost_same_as(other.attrib.loc): 218 | return False 219 | if not ignore_colour and not ignore_location: 220 | if not self.sha1hash() == other.sha1hash(): 221 | return False 222 | return True 223 | 224 | def is_coaligned(self, other): 225 | v1 = self.attrib.loc * self.attrib.matrix 226 | v2 = other.attrib.loc * other.attrib.matrix 227 | naxis = v1.is_colinear_with(v2) 228 | if naxis == 2: 229 | return True 230 | return False 231 | 232 | def change_colour(self, to_colour): 233 | self.attrib.colour = to_colour 234 | 235 | def set_rotation(self, angle): 236 | rm = euler_to_rot_matrix(angle) 237 | self.attrib.matrix = rm 238 | 239 | def move_to(self, pos): 240 | o = safe_vector(pos) 241 | self.attrib.loc = o 242 | 243 | def move_by(self, offset): 244 | o = safe_vector(offset) 245 | self.attrib.loc += o 246 | 247 | def rotate_by(self, angle): 248 | rm = euler_to_rot_matrix(angle) 249 | rt = rm.transpose() 250 | self.attrib.matrix = rm * self.attrib.matrix 251 | self.attrib.loc *= rt 252 | 253 | def transform(self, matrix=Identity(), offset=Vector(0, 0, 0)): 254 | mt = matrix.transpose() 255 | self.attrib.matrix = matrix * self.attrib.matrix 256 | self.attrib.loc *= mt 257 | self.attrib.loc += offset 258 | 259 | def from_str(self, s): 260 | split_line = s.lower().split() 261 | if not len(split_line) >= 15: 262 | return None 263 | line_type = int(split_line[0].lstrip()) 264 | if not line_type == 1: 265 | return None 266 | self.attrib.colour = int(split_line[1]) 267 | self.attrib.loc.x = quantize(split_line[2]) 268 | self.attrib.loc.y = quantize(split_line[3]) 269 | self.attrib.loc.z = quantize(split_line[4]) 270 | self.attrib.matrix = Matrix( 271 | [ 272 | [ 273 | quantize(split_line[5]), 274 | quantize(split_line[6]), 275 | quantize(split_line[7]), 276 | ], 277 | [ 278 | quantize(split_line[8]), 279 | quantize(split_line[9]), 280 | quantize(split_line[10]), 281 | ], 282 | [ 283 | quantize(split_line[11]), 284 | quantize(split_line[12]), 285 | quantize(split_line[13]), 286 | ], 287 | ] 288 | ) 289 | pname = " ".join(split_line[14:]) 290 | self.name = pname.replace(".dat", "") 291 | return self 292 | 293 | @staticmethod 294 | def translate_from_str(s, offset): 295 | offset = safe_vector(offset) 296 | p = LDRPart() 297 | p.from_str(s) 298 | p.attrib.loc += offset 299 | p.wrapcallout = False 300 | return str(p) 301 | 302 | @staticmethod 303 | def transform_from_str(s, matrix=Identity(), offset=Vector(0, 0, 0), colour=None): 304 | offset = safe_vector(offset) 305 | p = LDRPart() 306 | p.from_str(s) 307 | mt = matrix.transpose() 308 | p.attrib.matrix = matrix * p.attrib.matrix 309 | p.attrib.loc *= mt 310 | p.attrib.loc += offset 311 | p.wrapcallout = False 312 | if colour is not None: 313 | p.attrib.colour = colour 314 | return str(p) 315 | 316 | 317 | class LDRHeader: 318 | def __init__(self): 319 | self.title = "" 320 | self.file = "" 321 | self.name = "" 322 | self.author = "" 323 | 324 | def __str__(self): 325 | return ( 326 | ("0 %s\n" % self.title) 327 | + ("0 FILE %s\n" % self.file) 328 | + ("0 Name: %s\n" % self.name) 329 | + ("0 Author: %s\n" % self.author) 330 | ) 331 | -------------------------------------------------------------------------------- /ldrawpy/ldrshapes.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # LDraw helper functions to generate complicated shapes and solids 25 | # from LDraw primitives 26 | 27 | import os 28 | import copy 29 | 30 | from toolbox import * 31 | from ldrawpy import * 32 | 33 | 34 | class LDRPolyWall: 35 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 36 | self.attrib = LDRAttrib(colour, units) 37 | self.height = 1 38 | 39 | def __str__(self): 40 | 41 | s = [] 42 | nPoints = len(self.points) 43 | for i in range(nPoints): 44 | q = LDRQuad(self.attrib.colour, self.attrib.units) 45 | q.p1.x = self.points[i].x 46 | q.p1.y = self.height 47 | q.p1.z = self.points[i].z 48 | thePoint = self.points[0] 49 | if i < nPoints - 1: 50 | thePoint = self.points[i + 1] 51 | q.p2.x = thePoint.x 52 | q.p2.y = self.height 53 | q.p2.z = thePoint.z 54 | q.p3.x = thePoint.x 55 | q.p3.y = 0 56 | q.p3.z = thePoint.z 57 | q.p4.x = self.points[i].x 58 | q.p4.y = 0 59 | q.p4.z = self.points[i].z 60 | q.transform(self.attrib.matrix) 61 | q.translate(self.attrib.loc) 62 | s.append(str(q)) 63 | return "".join(s) 64 | 65 | 66 | class LDRRect: 67 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 68 | self.attrib = LDRAttrib(colour, units) 69 | self.length = 1 70 | self.width = 1 71 | 72 | def __str__(self): 73 | s = [] 74 | q = LDRQuad(self.attrib.colour, self.attrib.units) 75 | q.p1.x 76 | q.p1.x = -self.length / 2 77 | q.p1.y = 0 78 | q.p1.z = self.width / 2 79 | q.p2.x = -self.length / 2 80 | q.p2.y = 0 81 | q.p2.z = -self.width / 2 82 | q.p3.x = self.length / 2 83 | q.p3.y = 0 84 | q.p3.z = -self.width / 2 85 | q.p4.x = self.length / 2 86 | q.p4.y = 0 87 | q.p4.z = self.width / 2 88 | q.transform(self.attrib.matrix) 89 | q.translate(self.attrib.loc) 90 | l = LDRLine(self.attrib.colour, self.attrib.units) 91 | l.p1 = q.p1 92 | l.p2 = q.p2 93 | s.append(str(l)) 94 | l.p1 = q.p2 95 | l.p2 = q.p3 96 | s.append(str(l)) 97 | l.p1 = q.p3 98 | l.p2 = q.p4 99 | s.append(str(l)) 100 | l.p1 = q.p4 101 | l.p2 = q.p1 102 | s.append(str(l)) 103 | s.append(str(q)) 104 | return "".join(s) 105 | 106 | 107 | class LDRCircle: 108 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 109 | self.attrib = LDRAttrib(colour, units) 110 | self.radius = 1 111 | self.segments = 24 112 | self.fill = False 113 | 114 | def __str__(self): 115 | s = [] 116 | lines = GetCircleSegments(self.radius, self.segments, self.attrib) 117 | for line in lines: 118 | l = LDRLine(self.attrib.colour, self.attrib.units) 119 | l.transform(self.attrib.matrix) 120 | l.translate(self.attrib.loc) 121 | s.append(str(l)) 122 | if self.fill == True: 123 | for line in lines: 124 | t = LDRTriangle(self.attrib.colour, self.attrib.units) 125 | t.p2 = line.p1 126 | t.p3 = line.p2 127 | t.transform(self.attrib.matrix) 128 | t.translate(self.attrib.loc) 129 | s.append(str(t)) 130 | return "".join(s) 131 | 132 | 133 | class LDRDisc: 134 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 135 | self.attrib = LDRAttrib(colour, units) 136 | self.radius = 1 137 | self.border = 2 138 | self.segments = 24 139 | 140 | def __str__(self): 141 | s = [] 142 | lines = GetCircleSegments(self.radius, self.segments, self.attrib) 143 | for line in lines: 144 | l = LDRLine(self.attrib.colour, self.attrib.units) 145 | l = copy.deepcopy(line) 146 | l.transform(self.attrib.matrix) 147 | l.translate(self.attrib.loc) 148 | s.append(str(l)) 149 | 150 | olines = GetCircleSegments( 151 | self.radius + self.border, self.segments, self.attrib 152 | ) 153 | 154 | for i, line in enumerate(lines): 155 | q = LDRQuad(self.attrib.colour, self.attrib.units) 156 | q.p1.x = line.p1.x 157 | q.p1.z = line.p1.z 158 | q.p2.x = line.p2.x 159 | q.p2.z = line.p2.z 160 | q.p3.x = olines[i].p2.x 161 | q.p3.z = olines[i].p2.z 162 | q.p4.x = olines[i].p1.x 163 | q.p4.z = olines[i].p1.z 164 | q.transform(self.attrib.matrix) 165 | q.translate(self.attrib.loc) 166 | s.append(str(q)) 167 | return "".join(s) 168 | 169 | 170 | class LDRHole: 171 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 172 | self.attrib = LDRAttrib(colour, units) 173 | self.radius = 1 174 | self.segments = 24 175 | 176 | def __str__(self): 177 | s = [] 178 | for seg in range(self.segments): 179 | t = LDRTriangle(self.attrib.colour, self.attrib.units) 180 | a1 = seg / self.segments * 2.0 * pi 181 | a2 = (seg + 1) / self.segments * 2.0 * pi 182 | t.p1.x = self.radius * cos(a1) 183 | t.p1.z = self.radius * sin(a1) 184 | t.p2.x = self.radius * cos(a2) 185 | t.p2.z = self.radius * sin(a2) 186 | if (a1 >= 0.0) and (a1 < pi / 2.0): 187 | t.p3.x = self.radius 188 | t.p3.z = self.radius 189 | elif (a1 >= pi / 2.0) and (a1 < pi): 190 | t.p3.x = -self.radius 191 | t.p3.z = self.radius 192 | elif (a1 >= pi) and (a1 < 3 * pi / 2): 193 | t.p3.x = -self.radius 194 | t.p3.z = -self.radius 195 | else: 196 | t.p3.x = self.radius 197 | t.p3.z = -self.radius 198 | t.transform(self.attrib.matrix) 199 | t.translate(self.attrib.loc) 200 | l = LDRLine(OPT_COLOUR, self.attrib.units) 201 | l.p1 = t.p1 202 | l.p2 = t.p2 203 | s.append(str(t)) 204 | s.append(str(l)) 205 | return "".join(s) 206 | 207 | 208 | class LDRCylinder: 209 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 210 | self.attrib = LDRAttrib(colour, units) 211 | self.radius = 1 212 | self.height = 1 213 | self.segments = 24 214 | 215 | def __str__(self): 216 | s = [] 217 | lines = GetCircleSegments(self.radius, self.segments, self.attrib) 218 | for line in lines: 219 | l = LDRLine(self.attrib.colour, self.attrib.units) 220 | l = copy.deepcopy(line) 221 | l.transform(self.attrib.matrix) 222 | l.translate(self.attrib.loc) 223 | s.append(str(l)) 224 | for line in lines: 225 | l = LDRLine(self.attrib.colour, self.attrib.units) 226 | l = copy.deepcopy(line) 227 | l.translate(Vector(0, self.height, 0)) 228 | l.transform(self.attrib.matrix) 229 | l.translate(self.attrib.loc) 230 | s.append(str(l)) 231 | for line in lines: 232 | q = LDRQuad(self.attrib.colour, self.attrib.units) 233 | q.p1.x = line.p1.x 234 | q.p1.z = line.p1.z 235 | q.p2.x = line.p2.x 236 | q.p2.z = line.p2.z 237 | q.p3.x = line.p2.x 238 | q.p3.z = line.p2.z 239 | q.p4.x = line.p1.x 240 | q.p4.z = line.p1.z 241 | q.p1.y = self.height 242 | q.p2.y = self.height 243 | q.p3.y = 0 244 | q.p4.y = 0 245 | q.transform(self.attrib.matrix) 246 | q.translate(self.attrib.loc) 247 | s.append(str(q)) 248 | return "".join(s) 249 | 250 | 251 | class LDRBox: 252 | def __init__(self, colour=LDR_DEF_COLOUR, units="ldu"): 253 | self.attrib = LDRAttrib(colour, units) 254 | self.length = 1 255 | self.width = 1 256 | self.height = 1 257 | 258 | def __str__(self): 259 | s = [] 260 | l = LDRLine(OPT_COLOUR, self.attrib.units) 261 | p = [] 262 | p.append(Vector(self.length / 2, self.height / 2, self.width / 2)) 263 | p.append(Vector(-self.length / 2, self.height / 2, self.width / 2)) 264 | p.append(Vector(-self.length / 2, self.height / 2, -self.width / 2)) 265 | p.append(Vector(self.length / 2, self.height / 2, -self.width / 2)) 266 | p.append(Vector(self.length / 2, -self.height / 2, self.width / 2)) 267 | p.append(Vector(-self.length / 2, -self.height / 2, self.width / 2)) 268 | p.append(Vector(-self.length / 2, -self.height / 2, -self.width / 2)) 269 | p.append(Vector(self.length / 2, -self.height / 2, -self.width / 2)) 270 | coords = [ 271 | [0, 1], 272 | [1, 2], 273 | [2, 3], 274 | [3, 0], 275 | [4, 5], 276 | [5, 6], 277 | [6, 7], 278 | [7, 4], 279 | [0, 4], 280 | [1, 5], 281 | [2, 6], 282 | [3, 7], 283 | ] 284 | for coord in coords: 285 | l.p1 = p[coord[0]] 286 | l.p2 = p[coord[1]] 287 | l.transform(self.attrib.matrix) 288 | l.translate(self.attrib.loc) 289 | s.append(str(l)) 290 | q = LDRQuad(self.attrib.colour, self.attrib.units) 291 | coords = [ 292 | [0, 3, 2, 1], 293 | [6, 7, 4, 5], 294 | [5, 4, 0, 1], 295 | [6, 5, 1, 2], 296 | [7, 6, 2, 3], 297 | [4, 7, 3, 0], 298 | ] 299 | for coord in coords: 300 | q.p1 = p[coord[0]] 301 | q.p2 = p[coord[1]] 302 | q.p3 = p[coord[2]] 303 | q.p4 = p[coord[3]] 304 | q.transform(self.attrib.matrix) 305 | q.translate(self.attrib.loc) 306 | s.append(str(q)) 307 | return "".join(s) 308 | -------------------------------------------------------------------------------- /ldrawpy/ldvrender.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Copyright (C) 2020 Michael Gale 4 | # This file is part of the legocad python module. 5 | # Permission is hereby granted, free of charge, to any person 6 | # obtaining a copy of this software and associated documentation 7 | # files (the "Software"), to deal in the Software without restriction, 8 | # including without limitation the rights to use, copy, modify, merge, 9 | # publish, distribute, sublicense, and/or sell copies of the Software, 10 | # and to permit persons to whom the Software is furnished to do so, 11 | # subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | # 24 | # LDView render class and helper functions 25 | 26 | import os, tempfile 27 | import datetime 28 | import subprocess, shlex 29 | import crayons 30 | from datetime import datetime 31 | from collections import defaultdict 32 | from PIL import Image, ImageOps, ImageChops, ImageFilter, ImageEnhance 33 | 34 | from toolbox import * 35 | from ldrawpy import * 36 | 37 | LDVIEW_BIN = "/Applications/LDView.app/Contents/MacOS/LDView" 38 | LDVIEW_DICT = { 39 | "DefaultMatrix": "1,0,0,0,1,0,0,0,1", 40 | "SnapshotSuffix": ".png", 41 | "BlackHighlights": 0, 42 | "ProcessLDConfig": 1, 43 | "EdgeThickness": 3, 44 | "EdgesOnly": 0, 45 | "ShowHighlightLines": 1, 46 | "ConditionalHighlights": 1, 47 | "SaveZoomToFit": 0, 48 | "SubduedLighting": 1, 49 | "UseSpecular": 1, 50 | "UseFlatShading": 0, 51 | "LightVector": "0,1,1", 52 | "AllowPrimitiveSubstitution": 0, 53 | "HiResPrimitives": 1, 54 | "UseQualityLighting": 0, 55 | "ShowAxes": 0, 56 | "UseQualityStuds": 1, 57 | "TextureStuds": 0, 58 | "SaveActualSize": 0, 59 | "SaveAlpha": 1, 60 | "AutoCrop": 0, 61 | "LineSmoothing": 1, 62 | "Texmaps": 1, 63 | "MemoryUsage": 2, 64 | "MultiThreaded": 1, 65 | } 66 | # 10.0 / tan(0.005 deg) 67 | LDU_DISTANCE = 114591 68 | 69 | 70 | def camera_distance(scale=1.0, dpi=300, page_width=8.5): 71 | one = 20 * 1 / 64 * dpi * scale 72 | sz = page_width * dpi / one * LDU_DISTANCE * 0.775 73 | sz *= 1700 / 1000 74 | return sz 75 | 76 | 77 | def _coord_str(x, y=None, sep=", "): 78 | if isinstance(x, (tuple, list)): 79 | a, b = float(x[0]), float(x[1]) 80 | else: 81 | a, b = float(x), float(y) 82 | sa = ("%f" % (a)).rstrip("0").rstrip(".") 83 | sb = ("%f" % (b)).rstrip("0").rstrip(".") 84 | s = [] 85 | s.append(str(crayons.yellow("%s" % (sa)))) 86 | s.append(sep) 87 | s.append(str(crayons.yellow("%s" % (sb)))) 88 | return "".join(s) 89 | 90 | 91 | class LDViewRender: 92 | """LDView render session helper class.""" 93 | 94 | PARAMS = { 95 | "dpi": 300, 96 | "page_width": 8.5, 97 | "page_height": 11.0, 98 | "auto_crop": True, 99 | "image_smooth": False, 100 | "no_lines": False, 101 | "wireframe": False, 102 | "quality_lighting": True, 103 | "flat_shading": False, 104 | "specular": True, 105 | "line_thickness": 3, 106 | "texmaps": True, 107 | "scale": 1.0, 108 | "output_path": None, 109 | "log_output": True, 110 | "log_level": 0, 111 | "overwrite": False, 112 | } 113 | 114 | def __init__(self, **kwargs): 115 | self.ldr_temp_path = tempfile.gettempdir() + os.sep + "temp.ldr" 116 | apply_params(self, kwargs) 117 | self.set_page_size(self.page_width, self.page_height) 118 | self.set_scale(self.scale) 119 | self.settings_snapshot = None 120 | 121 | def __str__(self): 122 | s = [] 123 | s.append("LDViewRender: ") 124 | s.append(" DPI: %d Scale: %.2f" % (self.dpi, self.scale)) 125 | s.append( 126 | " Page size: %s in (%s pixels)" 127 | % ( 128 | _coord_str(self.page_width, self.page_height, " x "), 129 | _coord_str(self.pix_width, self.pix_height, " x "), 130 | ) 131 | ) 132 | s.append( 133 | " Auto crop: %s Image smooth: %s" % (self.auto_crop, self.image_smooth) 134 | ) 135 | s.append(" Camera distance: %d" % (self.cam_dist)) 136 | return "\n".join(s) 137 | 138 | def snapshot_settings(self): 139 | self.settings_snapshot = {} 140 | for key in ["page_width", "page_height", "auto_crop", "image_smooth", "no_lines", "wireframe", "quality_lighting", "scale", 141 | "log_output", "log_level", "overwrite", "texmaps", "flat_shading", "specular"]: 142 | self.settings_snapshot[key] = self.__dict__[key] 143 | 144 | def restore_settings(self): 145 | if self.settings_snapshot is None: 146 | return 147 | for key in ["page_width", "page_height", "auto_crop", "image_smooth", "no_lines", "wireframe", "quality_lighting", "scale", 148 | "log_output", "log_level", "overwrite", "texmaps", "flat_shading", "specular"]: 149 | self.__dict__[key] = self.settings_snapshot[key] 150 | 151 | def set_page_size(self, width, height): 152 | self.page_width = width 153 | self.page_height = height 154 | self.pix_width = self.page_width * self.dpi 155 | self.pix_height = self.page_height * self.dpi 156 | self.args_size = "-SaveWidth=%d -SaveHeight=%d" % ( 157 | self.pix_width, 158 | self.pix_height, 159 | ) 160 | 161 | def set_dpi(self, dpi): 162 | self.dpi = dpi 163 | self.set_page_size(width=self.page_width, height=self.page_height) 164 | self.set_scale(scale=self.scale) 165 | 166 | def set_scale(self, scale): 167 | self.scale = scale 168 | self.cam_dist = int(camera_distance(self.scale, self.dpi, self.page_width)) 169 | self.args_cam = "-ca0.01 -cg0.0,0.0,%d" % (self.cam_dist) 170 | 171 | def _logoutput(self, msg, tstart=None, level=2): 172 | logmsg(msg, level=level, prefix="LDR", log_level=self.log_level) 173 | 174 | def render_from_str(self, ldrstr, outfile): 175 | """Render from a LDraw text string.""" 176 | if self.log_output: 177 | s = ldrstr.splitlines()[0] 178 | self._logoutput( 179 | "rendering string (%s)..." % (crayons.green(s[: min(len(s), 80)])) 180 | ) 181 | with open(self.ldr_temp_path, "w") as f: 182 | f.write(ldrstr) 183 | self.render_from_file(self.ldr_temp_path, outfile) 184 | 185 | def render_from_parts(self, parts, outfile): 186 | """Render using a list of LDRPart objects.""" 187 | if self.log_output: 188 | self._logoutput("rendering parts (%s)..." % (crayons.green(len(parts)))) 189 | ldrstr = [] 190 | for p in parts: 191 | ldrstr.append(str(p)) 192 | ldrstr = "".join(ldrstr) 193 | self.render_from_str(ldrstr, outfile) 194 | 195 | def render_from_file(self, ldrfile, outfile): 196 | """Render from an LDraw file.""" 197 | tstart = datetime.datetime.now() 198 | if self.output_path is not None: 199 | path, name = split_path(outfile) 200 | oppath = full_path(self.output_path) 201 | if not oppath in path: 202 | filename = os.path.normpath(self.output_path + os.sep + outfile) 203 | else: 204 | filename = outfile 205 | else: 206 | filename = full_path(outfile) 207 | _, fno = split_path(filename) 208 | if not self.overwrite and os.path.isfile(full_path(filename)): 209 | if self.log_output: 210 | _, fno = split_path(filename) 211 | fno = colour_path_str(fno) 212 | self._logoutput("rendered file %s already exists, skipping" % fno) 213 | return 214 | ldv = [] 215 | ldv.append(LDVIEW_BIN) 216 | ldv.append("-SaveSnapShot=%s" % filename) 217 | ldv.append(self.args_size) 218 | ldv.append(self.args_cam) 219 | for key, value in LDVIEW_DICT.items(): 220 | if key == "EdgeThickness": 221 | value = self.line_thickness 222 | elif key == "UseQualityLighting": 223 | value = 1 if self.quality_lighting else 0 224 | elif key == "Texmaps": 225 | value = 1 if self.texmaps else 0 226 | elif key == "UseFlatShading": 227 | value = 1 if self.flat_shading else 0 228 | elif key == "UseSpecular": 229 | value = 1 if self.specular else 0 230 | if self.no_lines: 231 | if key == "EdgeThickness": 232 | value = 0 233 | elif key == "ShowHighlightLines": 234 | value = 0 235 | elif key == "ConditionalHighlights": 236 | value = 0 237 | elif key == "UseQualityStuds": 238 | value = 0 239 | if self.wireframe: 240 | if key == "EdgesOnly": 241 | value = 1 242 | ldv.append("-%s=%s" % (key, value)) 243 | ldv.append(ldrfile) 244 | s = " ".join(ldv) 245 | args = shlex.split(s) 246 | subprocess.Popen(args).wait() 247 | if self.log_output: 248 | _, fni = split_path(ldrfile) 249 | _, fno = split_path(filename) 250 | fni = colour_path_str(fni) 251 | fno = colour_path_str(fno) 252 | self._logoutput("rendered file %s to %s..." % (fni, fno), tstart, level=0) 253 | 254 | if self.auto_crop: 255 | self.crop(filename) 256 | if self.image_smooth: 257 | self.smooth(filename) 258 | 259 | def crop(self, filename): 260 | """Crop image file.""" 261 | tstart = datetime.datetime.now() 262 | im = Image.open(filename) 263 | bg = Image.new(im.mode, im.size, im.getpixel((1, 1))) 264 | diff = ImageChops.difference(im, bg) 265 | diff = ImageChops.add(diff, diff, 2.0, 0) 266 | bbox = diff.getbbox() 267 | if bbox: 268 | im2 = im.crop(bbox) 269 | else: 270 | im2 = im 271 | im2.save(filename) 272 | if self.log_output: 273 | _, fn = split_path(filename) 274 | fn = colour_path_str(fn) 275 | self._logoutput( 276 | "> cropped %s from (%s) to (%s)" 277 | % (fn, _coord_str(im.size), _coord_str(im2.size)), 278 | tstart, 279 | ) 280 | 281 | def smooth(self, filename): 282 | """Apply a smoothing filter to image file.""" 283 | tstart = datetime.datetime.now() 284 | im = Image.open(filename) 285 | im = im.filter(ImageFilter.SMOOTH) 286 | im.save(filename) 287 | if self.log_output: 288 | _, fn = split_path(filename) 289 | fn = colour_path_str(fn) 290 | self._logoutput("> smoothed %s (%s)" % (fn, _coord_str(im.size)), tstart) 291 | -------------------------------------------------------------------------------- /ldrawpy/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelgale/ldraw-py/d650ab0a9c2a774e1ba6f206aa2cd3b1925478e3/ldrawpy/scripts/__init__.py -------------------------------------------------------------------------------- /ldrawpy/scripts/ldrcat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import math, os.path 4 | import sys 5 | import argparse 6 | 7 | from ldrawpy import * 8 | 9 | 10 | def main(): 11 | parser = argparse.ArgumentParser( 12 | description="Display the contents of a LDraw file.", 13 | ) 14 | parser.add_argument( 15 | "filename", metavar="filename", type=str, nargs="?", help="LDraw filename" 16 | ) 17 | parser.add_argument( 18 | "-c", 19 | "--clean", 20 | action="store_true", 21 | default=False, 22 | help="Clean up coordinate values", 23 | ) 24 | parser.add_argument( 25 | "-n", 26 | "--nocolour", 27 | action="store_true", 28 | default=False, 29 | help="Do not show file with colour syntax highlighting", 30 | ) 31 | parser.add_argument( 32 | "-l", 33 | "--lineno", 34 | action="store_true", 35 | default=False, 36 | help="Show line numbers", 37 | ) 38 | args = parser.parse_args() 39 | argsd = vars(args) 40 | 41 | if len(argsd) < 1 or "filename" not in argsd or argsd["filename"] is None: 42 | parser.print_help() 43 | exit() 44 | if argsd["clean"]: 45 | lines = clean_file(argsd["filename"], as_str=True) 46 | else: 47 | with open(argsd["filename"], "r") as f: 48 | lines = f.readlines() 49 | for i, line in enumerate(lines): 50 | lineno = (i + 1) if argsd["lineno"] else None 51 | pprint_line(line, lineno, nocolour=argsd["nocolour"]) 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Learn more: https://github.com/michaelgale/ldraw-py 5 | 6 | import os 7 | import sys 8 | import setuptools 9 | 10 | PACKAGE_NAME = "ldrawpy" 11 | MINIMUM_PYTHON_VERSION = "3.7" 12 | 13 | 14 | def check_python_version(): 15 | """Exit when the Python version is too low.""" 16 | if sys.version < MINIMUM_PYTHON_VERSION: 17 | sys.exit("Python {0}+ is required.".format(MINIMUM_PYTHON_VERSION)) 18 | 19 | 20 | def read_package_variable(key, filename="__init__.py"): 21 | """Read the value of a variable from the package without importing.""" 22 | module_path = os.path.join(PACKAGE_NAME, filename) 23 | with open(module_path) as module: 24 | for line in module: 25 | parts = line.strip().split(" ", 2) 26 | if parts[:-1] == [key, "="]: 27 | return parts[-1].strip("'") 28 | sys.exit("'{0}' not found in '{1}'".format(key, module_path)) 29 | 30 | 31 | def build_description(): 32 | """Build a description for the project from documentation files.""" 33 | try: 34 | readme = open("README.rst").read() 35 | changelog = open("CHANGELOG.rst").read() 36 | except IOError: 37 | return "" 38 | else: 39 | return readme + "\n" + changelog 40 | 41 | 42 | check_python_version() 43 | 44 | setuptools.setup( 45 | name=read_package_variable("__project__"), 46 | version=read_package_variable("__version__"), 47 | description="A python utility package for creating, modifying, and reading LDraw files and data structures.", 48 | url="https://github.com/michaelgale/ldraw-py", 49 | author="Michael Gale", 50 | author_email="michael@fxbricks.com", 51 | packages=setuptools.find_packages(), 52 | long_description=build_description(), 53 | license="MIT", 54 | classifiers=[ 55 | "Development Status :: 3 - Alpha", 56 | "Natural Language :: English", 57 | "Operating System :: OS Independent", 58 | "Programming Language :: Python :: 3.6", 59 | "Intended Audience :: Developers", 60 | "License :: OSI Approved :: MIT License", 61 | ], 62 | install_requires=[ 63 | "pillow", 64 | "pytest", 65 | "rich", 66 | ], 67 | entry_points={ 68 | "console_scripts": [ 69 | "ldrcat=ldrawpy.scripts.ldrcat:main", 70 | ] 71 | } 72 | ) 73 | -------------------------------------------------------------------------------- /tests/rerun.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # rm ~/Dropbox/Lego/PartCache/Thumbnails/* 3 | cd .. 4 | python setup.py install 5 | cd tests 6 | pytest -s 7 | -------------------------------------------------------------------------------- /tests/test_files/stylesheet.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Colour definitions 3 | # 4 | L1: "#FFFFBE" 5 | L2: "#FEC6C7" 6 | L3: "#C8C6FF" 7 | BLACK: "#000000" 8 | WHITE: "#FFFFFF" 9 | PLI: "#D4F9FE" 10 | BLUE: "#204080" 11 | 12 | # 13 | # Font definitions 14 | # 15 | NUM_FONT: British-Rail-Light 16 | QTY_FONT: IKEA-Sans-Regular 17 | 18 | # 19 | # Style dictionary containing all the style sheet styles 20 | # 21 | styles: 22 | COLUMN_STYLE: 23 | gutter-width: 0.2 in 24 | top-margin: 0.0 in 25 | bottom-margin: 0.0 in 26 | line-width: 0.025 in 27 | line-colour: ${BLACK} 28 | 29 | PAGE_NUM_STYLE: 30 | font: ${NUM_FONT} 31 | font-size: 18 32 | font-colour: ${BLACK} 33 | horz-align: right 34 | vert-align: top 35 | 36 | STEP_NUM_STYLE: 37 | font: ${NUM_FONT} 38 | font-size: 32 39 | font-colour: ${BLACK} 40 | horz-align: right 41 | vert-align: top 42 | top-padding: 0.05 in 43 | right-padding: 0.15 in 44 | 45 | STEP_L0_STYLE: 46 | bottom-padding: 0.35 in 47 | right-padding: 0.025 in 48 | background-fill: True 49 | background-colour: ${WHITE} 50 | 51 | STEP_L1_STYLE: 52 | bottom-padding: 0.25 in 53 | right-padding: 0.025 in 54 | background-fill: True 55 | background-colour: ${L1} 56 | 57 | STEP_L2_STYLE: 58 | bottom-padding: 0.15 in 59 | right-padding: 0.025 in 60 | background-fill: True 61 | background-colour: ${L2} 62 | 63 | STEP_L3_STYLE: 64 | bottom-padding: 0.15 in 65 | right-padding: 0.025 in 66 | background-fill: True 67 | background-colour: ${L3} 68 | 69 | STEP_ASSEM_SYTLE: 70 | bottom-padding: 0.5 in 71 | 72 | STEP_ASSEM_IMAGE_STYLE: 73 | top-padding: 0 74 | bottom-padding: 0 75 | left-padding: 0 76 | right-padding: 0 77 | 78 | CALLOUT_STYLE_L1: 79 | font: ${NUM_FONT} 80 | font-size: 20 81 | font-colour: ${BLACK} 82 | background-fill: True 83 | background-colour: ${L1} 84 | border-outline: True 85 | border-width: 0.015 inch 86 | border-radius: 0.08 inch 87 | left-padding: 0.05 inch 88 | right-padding: 0.1 inch 89 | top-padding: 0.1 inch 90 | bottom-padding: 0.1 inch 91 | vert-align: top 92 | horz-align: left 93 | 94 | CALLOUT_STYLE_L2: 95 | font: ${NUM_FONT} 96 | font-size: 20 97 | font-colour: ${BLACK} 98 | background-fill: True 99 | background-colour: ${L2} 100 | border-outline: True 101 | border-width: 0.015 inch 102 | border-radius: 0.08 inch 103 | left-padding: 0.05 inch 104 | right-padding: 0.1 inch 105 | top-padding: 0.1 inch 106 | bottom-padding: 0.1 inch 107 | vert-align: top 108 | horz-align: left 109 | 110 | CALLOUT_STYLE_L3: 111 | font: ${NUM_FONT} 112 | font-size: 20 113 | font-colour: ${BLACK} 114 | background-fill: True 115 | background-colour: ${L3} 116 | border-outline: True 117 | border-width: 0.015 inch 118 | border-radius: 0.08 inch 119 | left-padding: 0.05 inch 120 | right-padding: 0.1 inch 121 | top-padding: 0.1 inch 122 | bottom-padding: 0.1 inch 123 | vert-align: top 124 | horz-align: left 125 | 126 | CALLOUT_STEP_NUM_STYLE: 127 | font: ${NUM_FONT} 128 | font-size: 20 129 | font-colour: ${BLACK} 130 | horz-align: centre 131 | vert-align: top 132 | top-padding: 0.05 inch 133 | right-padding: 0.05 inch 134 | left-padding: 0.05 inch 135 | 136 | CALLOUT_ASSEM_IMAGE_STYLE: 137 | top-padding: 0.0 in 138 | bottom-padding: 0.0 in 139 | left-padding: 0.05 in 140 | right-padding: 0.05 in 141 | top-margin: 0.1 inch 142 | 143 | CALLOUT_STEP_ASSEM_STYLE: 144 | horz-align: left 145 | vert-align: top 146 | bottom-padding: 0.0 147 | right-padding: 0.0 148 | left-padding: 0.0 149 | 150 | CALLOUT_QTY_STYLE: 151 | font: ${QTY_FONT} 152 | font-size: 24 153 | font-colour: ${BLACK} 154 | horz-align: left 155 | vert-align: bottom 156 | right-padding: 0.0 inch 157 | left-padding: 0.15 inch 158 | 159 | CALLOUT_ARROW_STYLE: 160 | line-colour: ${BLACK} 161 | border-colour: ${WHITE} 162 | border-outline: True 163 | border-width: 0.015 inch 164 | line-width: 0.015 inch 165 | length: 28 166 | width: 12 167 | arrow-style: taper 168 | 169 | STEP_PLI_STYLE : 170 | font: ${NUM_FONT} 171 | font-size: 10 172 | font-colour: ${BLACK} 173 | background-fill: True 174 | background-colour: ${PLI} 175 | border-outline: True 176 | border-width: 0.015 inch 177 | border-radius: 0.08 inch 178 | left-padding: 0.1 inch 179 | right-padding: 0.1 inch 180 | top-padding: 0.1 inch 181 | vert-align: bottom 182 | 183 | 184 | STEP_PLI_ITEM_STYLE : 185 | top-padding: 0.0 inch 186 | bottom-padding: 0.0 inch 187 | left-padding: 0.08 inch 188 | right-padding: 0.08 inch 189 | 190 | 191 | STEP_PLI_IMAGE_STYLE : 192 | vert-align: bottom 193 | horz-align: center 194 | top-padding: 0.0 inch 195 | bottom-padding: 0.0 196 | left-padding: 0.0 inch 197 | right-padding: 0.0 inch 198 | 199 | 200 | STEP_PLI_TEXT_STYLE : 201 | font: ${NUM_FONT} 202 | font-size: 9.5 203 | vert-align: top 204 | horz-align: left 205 | top-padding: 0.05 inch 206 | bottom-padding: 0.0 inch 207 | left-padding: 0.0 inch 208 | right-padding: 0.0 inch 209 | 210 | 211 | PREVIEW_ASSEM_STYLE : 212 | background-fill: True 213 | background-colour: ${WHITE} 214 | border-outline: True 215 | border-width: 0.015 inch 216 | border-radius: 0.08 inch 217 | left-padding: 0.25 inch 218 | right-padding: 0.25 inch 219 | top-padding: 0.1 inch 220 | bottom-padding: 0.05 inch 221 | bottom-margin: 0.25 inch 222 | vert-align: centre 223 | horz-align: centre 224 | 225 | 226 | PREVIEW_IMAGE_STYLE : 227 | vert-align: centre 228 | horz-align: centre 229 | top-padding: 0.0 inch 230 | bottom-padding: 0.0 inch 231 | left-padding: 0.0 inch 232 | right-padding: 0.0 inch 233 | 234 | 235 | PREVIEW_QTY_STYLE : 236 | font: ${QTY_FONT} 237 | font-size: 24 238 | font-colour: ${BLACK} 239 | horz-align: centre 240 | vert-align: centre 241 | right-padding: 0.0 inch 242 | left-padding: 0.0 inch 243 | top-padding: 0.0 inch 244 | bottom-padding: 0.0 inch 245 | 246 | ROTATE_ICON_STYLE: 247 | background-fill: True 248 | background-colour: ${WHITE} 249 | border-outline: True 250 | border-colour: ${BLACK} 251 | border-width: 0.015 inch 252 | border-radius: 0.08 inch 253 | 254 | ROTATE_ARROW_STYLE: 255 | line-colour: ${BLUE} 256 | border-colour: ${WHITE} 257 | border-outline: False 258 | line-width: 0.02 inch 259 | length: 10 260 | width: 8 261 | arrow-style: taper 262 | -------------------------------------------------------------------------------- /tests/test_files/stylesheet_dark.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Colour definitions 3 | # 4 | L1: "#1A1E24" 5 | L2: "#1A1E24" 6 | L3: "#1A1E24" 7 | DARK: "#1A1E24" 8 | LIGHT: "#F6FDFF" 9 | BLACK: "#000000" 10 | WHITE: "#FFFFFF" 11 | PLI: "#D4F9FE" 12 | BLUE: "#204080" 13 | 14 | # 15 | # Font definitions 16 | # 17 | NUM_FONT: British-Rail-Light 18 | QTY_FONT: IKEA-Sans-Regular 19 | 20 | # 21 | # Style dictionary containing all the style sheet styles 22 | # 23 | styles: 24 | 25 | COLUMN_STYLE: 26 | gutter-width: 0.2 in 27 | top-margin: 0.0 in 28 | bottom-margin: 0.0 in 29 | line-width: 0.025 in 30 | line-colour: ${WHITE} 31 | 32 | PAGE_NUM_STYLE: 33 | font: ${NUM_FONT} 34 | font-size: 18 35 | font-colour: ${WHITE} 36 | horz-align: right 37 | vert-align: top 38 | 39 | STEP_NUM_STYLE: 40 | font: ${NUM_FONT} 41 | font-size: 32 42 | font-colour: ${WHITE} 43 | horz-align: right 44 | vert-align: top 45 | top-padding: 0.05 in 46 | right-padding: 0.15 in 47 | 48 | STEP_L0_STYLE: 49 | bottom-padding: 0.35 in 50 | right-padding: 0.025 in 51 | background-fill: True 52 | background-colour: ${DARK} 53 | 54 | STEP_L1_STYLE: 55 | bottom-padding: 0.25 in 56 | right-padding: 0.025 in 57 | background-fill: True 58 | background-colour: ${L1} 59 | 60 | STEP_L2_STYLE: 61 | bottom-padding: 0.15 in 62 | right-padding: 0.025 in 63 | background-fill: True 64 | background-colour: ${L2} 65 | 66 | STEP_L3_STYLE: 67 | bottom-padding: 0.15 in 68 | right-padding: 0.025 in 69 | background-fill: True 70 | background-colour: ${L3} 71 | 72 | STEP_ASSEM_SYTLE: 73 | bottom-padding: 0.5 in 74 | 75 | STEP_ASSEM_IMAGE_STYLE: 76 | top-padding: 0 77 | bottom-padding: 0 78 | left-padding: 0 79 | right-padding: 0 80 | 81 | CALLOUT_STYLE_L1: 82 | font: ${NUM_FONT} 83 | font-size: 20 84 | font-colour: ${WHITE} 85 | background-fill: True 86 | background-colour: ${DARK} 87 | border-colour: ${WHITE} 88 | border-outline: True 89 | border-width: 0.015 inch 90 | border-radius: 0.00 inch 91 | left-padding: 0.05 inch 92 | right-padding: 0.1 inch 93 | top-padding: 0.1 inch 94 | bottom-padding: 0.1 inch 95 | vert-align: top 96 | horz-align: left 97 | 98 | CALLOUT_STYLE_L2: 99 | font: ${NUM_FONT} 100 | font-size: 20 101 | font-colour: ${WHITE} 102 | background-fill: True 103 | background-colour: ${DARK} 104 | border-colour: ${WHITE} 105 | border-outline: True 106 | border-width: 0.015 inch 107 | border-radius: 0.00 inch 108 | left-padding: 0.05 inch 109 | right-padding: 0.1 inch 110 | top-padding: 0.1 inch 111 | bottom-padding: 0.1 inch 112 | vert-align: top 113 | horz-align: left 114 | 115 | CALLOUT_STYLE_L3: 116 | font: ${NUM_FONT} 117 | font-size: 20 118 | font-colour: ${WHITE} 119 | background-fill: True 120 | background-colour: ${DARK} 121 | border-colour: ${WHITE} 122 | border-outline: True 123 | border-width: 0.015 inch 124 | border-radius: 0.0 inch 125 | left-padding: 0.05 inch 126 | right-padding: 0.1 inch 127 | top-padding: 0.1 inch 128 | bottom-padding: 0.1 inch 129 | vert-align: top 130 | horz-align: left 131 | 132 | CALLOUT_STEP_NUM_STYLE: 133 | font: ${NUM_FONT} 134 | font-size: 20 135 | font-colour: ${WHITE} 136 | horz-align: centre 137 | vert-align: top 138 | top-padding: 0.05 inch 139 | right-padding: 0.05 inch 140 | left-padding: 0.05 inch 141 | 142 | CALLOUT_ASSEM_IMAGE_STYLE: 143 | top-padding: 0 in 144 | bottom-padding: 0 145 | left-padding: 0.05 in 146 | right-padding: 0.05 in 147 | top-margin: 0.1 inch 148 | 149 | CALLOUT_STEP_ASSEM_STYLE: 150 | horz-align: left 151 | vert-align: top 152 | bottom-padding: 0.0 153 | right-padding: 0.0 154 | left-padding: 0.0 155 | 156 | CALLOUT_QTY_STYLE: 157 | font: ${QTY_FONT} 158 | font-size: 24 159 | font-colour: ${WHITE} 160 | horz-align: left 161 | vert-align: bottom 162 | right-padding: 0.0 inch 163 | left-padding: 0.15 inch 164 | 165 | CALLOUT_ARROW_STYLE: 166 | line-colour: ${WHITE} 167 | border-colour: ${DARK} 168 | border-outline: True 169 | border-width: 0.015 inch 170 | line-width: 0.015 inch 171 | length: 28 172 | width: 12 173 | arrow-style: taper 174 | 175 | STEP_PLI_STYLE : 176 | font: ${NUM_FONT} 177 | font-size: 10 178 | font-colour: ${WHITE} 179 | background-fill: True 180 | background-colour: ${DARK} 181 | border-colour: ${WHITE} 182 | border-outline: True 183 | border-width: 0.015 inch 184 | border-radius: 0.0 inch 185 | left-padding: 0.1 inch 186 | right-padding: 0.1 inch 187 | top-padding: 0.1 inch 188 | vert-align: bottom 189 | 190 | STEP_PLI_ITEM_STYLE : 191 | top-padding: 0.0 inch 192 | bottom-padding: 0.0 inch 193 | left-padding: 0.08 inch 194 | right-padding: 0.08 inch 195 | 196 | STEP_PLI_IMAGE_STYLE : 197 | vert-align: bottom 198 | horz-align: center 199 | top-padding: 0.0 inch 200 | bottom-padding: 0.0 201 | left-padding: 0.0 inch 202 | right-padding: 0.0 inch 203 | 204 | STEP_PLI_TEXT_STYLE : 205 | font: ${NUM_FONT} 206 | font-colour: ${WHITE} 207 | font-size: 9.5 208 | vert-align: top 209 | horz-align: left 210 | top-padding: 0.05 inch 211 | bottom-padding: 0.0 inch 212 | left-padding: 0.0 inch 213 | right-padding: 0.0 inch 214 | 215 | 216 | PREVIEW_ASSEM_STYLE : 217 | background-fill: True 218 | background-colour: ${WHITE} 219 | border-outline: True 220 | border-width: 0.015 inch 221 | border-radius: 0.00 inch 222 | left-padding: 0.25 inch 223 | right-padding: 0.25 inch 224 | top-padding: 0.1 inch 225 | bottom-padding: 0.05 inch 226 | bottom-margin: 0.25 inch 227 | vert-align: centre 228 | horz-align: centre 229 | 230 | 231 | PREVIEW_IMAGE_STYLE : 232 | vert-align: centre 233 | horz-align: centre 234 | top-padding: 0.0 inch 235 | bottom-padding: 0.0 inch 236 | left-padding: 0.0 inch 237 | right-padding: 0.0 inch 238 | 239 | 240 | PREVIEW_QTY_STYLE : 241 | font: ${QTY_FONT} 242 | font-size: 24 243 | font-colour: ${WHITE} 244 | horz-align: centre 245 | vert-align: centre 246 | right-padding: 0.0 inch 247 | left-padding: 0.0 inch 248 | top-padding: 0.0 inch 249 | bottom-padding: 0.0 inch 250 | 251 | ROTATE_ICON_STYLE: 252 | background-fill: True 253 | background-colour: ${DARK} 254 | border-outline: True 255 | border-colour: ${WHITE} 256 | border-width: 0.015 inch 257 | border-radius: 0.08 inch 258 | 259 | ROTATE_ARROW_STYLE: 260 | line-colour: ${WHITE} 261 | border-colour: ${DARK} 262 | border-outline: False 263 | line-width: 0.02 inch 264 | length: 10 265 | width: 8 266 | arrow-style: taper 267 | -------------------------------------------------------------------------------- /tests/test_files/test_model.ldr: -------------------------------------------------------------------------------- 1 | 0 FILE rootmodel.ldr 2 | 0 untitled model 3 | 0 Name: rootmodel.ldr 4 | 0 Author: Michael Gale 5 | 1 2 0 0 0 1 0 0 0 1 0 -0 0 1 3010.dat 6 | 1 1 0 24 0 1 0 0 0 1 0 -0 0 1 3008.dat 7 | 0 STEP 8 | 1 4 -60 24 50 0 0 -1 0 1 0 1 0 0 3001.dat 9 | 1 4 60 24 50 0 0 -1 0 1 0 1 0 0 3001.dat 10 | 0 STEP 11 | 0 !PY SCALE 0.9 12 | 0 ROTSTEP 0 60 0 REL 13 | 1 14 50 16 50 -0 0 -1 -0 1 0 1 0 -0 3666.dat 14 | 1 14 -50 16 50 -0 0 -1 -0 1 0 1 0 -0 3666.dat 15 | 0 STEP 16 | 0 !PY PAGE_BREAK 17 | 0 !PY ARROW BEGIN 0 -100 0 18 | 1 27 20 -8 0 0 0 1 0 1 0 -1 0 0 submodel2.ldr 19 | 0 !PY ARROW END 20 | 1 27 88 44 0 0 -1 0 -0 0 1 -1 0 -0 submodel2.ldr 21 | 1 27 40 32 100 -0 0 1 0 -1 0 1 0 0 submodel2.ldr 22 | 0 STEP 23 | 0 !PY SCALE 0.5 24 | 1 27 -20 -8 0 1 0 0 0 1 0 -0 0 1 submodel1.ldr 25 | 0 STEP 26 | 1 70 0 -8 40 -1 -0 0 -0 1 -0 0 -0 -1 92950.dat 27 | 0 STEP 28 | 1 15 70 16 50 1 0 0 0 1 0 -0 0 1 submodel2.ldr 29 | 1 15 -70 16 90 -1 0 0 0 1 0 0 0 -1 submodel2.ldr 30 | 0 STEP 31 | 1 25 0 -8 60 -1 -0 0 -0 1 -0 0 -0 -1 92950.dat 32 | 0 STEP 33 | 1 19 -10 -32 50 0 0 -1 0 1 0 1 0 0 submodel3.ldr 34 | 0 STEP 35 | 1 10 0 48 50 1 0 0 0 1 0 -0 0 1 92438.dat 36 | 0 STEP 37 | 0 NOFILE 38 | 0 FILE submodel1.ldr 39 | 0 untitled model 40 | 0 Name: submodel1.ldr 41 | 0 Author: Michael Gale 42 | 1 72 0 0 0 1 0 0 0 1 0 -0 0 1 3023.dat 43 | 0 !PY ARROW BEGIN 0 -50 0 44 | 1 72 0 -8 0 1 0 0 0 1 0 -0 0 1 3023.dat 45 | 0 !PY ARROW END 46 | 0 STEP 47 | 1 27 0 -16 0 1 0 0 0 1 0 -0 0 1 3069b.dat 48 | 0 STEP 49 | 0 NOFILE 50 | 0 FILE submodel2.ldr 51 | 0 untitled model 52 | 0 Name: submodel2.ldr 53 | 0 Author: Michael Gale 54 | 0 !PY SCALE 0.75 55 | 1 0 0 0 0 0 0 -1 0 1 0 1 0 0 99781.dat 56 | 0 STEP 57 | 1 15 22 10 0 0 -1 0 -0 0 -1 1 0 -0 2431.dat 58 | 0 STEP 59 | 1 10 0 -8 10 -0 0 -1 -0 1 0 1 0 -0 3024.dat 60 | 0 STEP 61 | 0 NOFILE 62 | 0 FILE submodel3.ldr 63 | 0 untitled model 64 | 0 Name: submodel3.ldr 65 | 0 Author: Michael Gale 66 | 0 !CALLOUT BOTTOM 67 | 1 19 0 0 0 1 0 0 0 1 0 -0 0 1 3297.dat 68 | 0 STEP 69 | 1 19 0 -24 0 1 0 0 0 1 0 -0 0 1 subsubmodel3.ldr 70 | 0 STEP 71 | 1 1 0 24 0 1 0 0 0 1 0 -0 0 1 3297.dat 72 | 0 STEP 73 | 0 NOFILE 74 | 0 FILE subsubmodel3.ldr 75 | 0 untitled model 76 | 0 Name: subsubmodel3.ldr 77 | 0 Author: Michael Gale 78 | 1 19 0 0 0 1 0 0 0 1 0 -0 0 1 3010.dat 79 | 0 STEP 80 | 1 19 20 0 0 1 0 0 0 1 0 -0 0 1 85984.dat 81 | 1 19 -20 0 0 1 0 0 0 1 0 -0 0 1 85984.dat 82 | 0 STEP 83 | 0 NOFILE -------------------------------------------------------------------------------- /tests/test_files/testfile2.ldr: -------------------------------------------------------------------------------- 1 | 0 FILE test2.ldr 2 | 0 untitled model 3 | 0 Name: test2.ldr 4 | 0 Author: Michael Gale 5 | 1 71 -49.999969 8 160 -0 0 1 0 1 0 -1 0 -0 30414.dat 6 | 1 71 -49.999969 8 240 -0 0 1 0 1 0 -1 0 -0 30414.dat 7 | 0 STEP 8 | 1 72 50 0 120 -0 0 1 -0 1 0 -1 -0 -0 3710.dat 9 | 1 72 50 0 280 -0 0 1 -0 1 0 -1 -0 -0 3710.dat 10 | 1 72 -50 0 120 -0 0 1 -0 1 0 -1 -0 -0 3710.dat 11 | 1 72 -50 0 280 -0 0 1 -0 1 0 -1 -0 -0 3710.dat 12 | 1 72 -49.999989 -72 280 -0 0 1 -0 1 0 -1 -0 -0 3701.dat 13 | 1 71 -49.999992 -72 360 -0 0 1 0 1 0 -1 0 -0 30414.dat 14 | 1 71 -50 -72 460 -0 0 1 0 1 0 -1 0 -0 30414.dat 15 | 1 71 -49.999985 -72 40 -0 0 1 0 1 0 -1 0 -0 30414.dat 16 | 1 71 -49.999989 -72 200 -0 0 1 0 1 0 -1 0 -0 30414.dat 17 | 1 71 -50.000015 -72 -59.999975 -0 0 1 0 1 0 -1 0 -0 30414.dat 18 | 1 72 -49.999989 -72 120 -0 0 1 -0 1 0 -1 -0 -0 3701.dat 19 | 0 STEP 20 | 1 71 -49.999992 -112 480 -0 0 1 0 1 0 -1 0 -0 30414.dat 21 | 1 71 -49.999996 -112 200 -0 0 1 0 1 0 -1 0 -0 30414.dat 22 | 1 71 -50 -112 380 -0 0 1 0 1 0 -1 0 -0 30414.dat 23 | 1 71 -49.999992 -112 -80 -0 0 1 0 1 0 -1 0 -0 30414.dat 24 | 1 71 -50 -112 20 -0 0 1 0 1 0 -1 0 -0 30414.dat 25 | 1 71 -49.999996 -112 120 -0 0 1 0 1 0 -1 0 -0 30414.dat 26 | 1 71 -49.999996 -112 280 -0 0 1 0 1 0 -1 0 -0 30414.dat 27 | 0 STEP 28 | 1 71 0 -94 -156 -0.999999 0 0 0 -0 -0.999999 0 -1 -0 85984.dat 29 | 1 71 0 -94 -156 1 0 0 0 -0 0.999999 0 -0.999999 -0 99780.dat 30 | 0 NOFILE 31 | -------------------------------------------------------------------------------- /tests/test_ldrcolour.py: -------------------------------------------------------------------------------- 1 | # Sample Test passing with nose and pytest 2 | 3 | import os 4 | import sys 5 | import pytest 6 | 7 | from ldrawpy import * 8 | from ldrawpy.ldrcolour import FillColoursFromLDRCode, FillTitlesFromLDRCode 9 | 10 | 11 | def test_init_colour(): 12 | c1 = LDRColour(15) 13 | c2 = LDRColour("White") 14 | c3 = LDRColour([1.0, 1.0, 1.0]) 15 | c4 = LDRColour([255, 255, 255]) 16 | c5 = LDRColour("#FFFFFF") 17 | assert c1 == c2 18 | assert c1 == c3 19 | assert c1 == c4 20 | assert c1 == c5 21 | 22 | 23 | def test_equality(): 24 | c1 = LDRColour([0.4, 0.2, 0.6]) 25 | c2 = LDRColour("#663399") 26 | assert c1 == c2 27 | c3 = LDRColour([102, 51, 153]) 28 | assert c2 == c3 29 | 30 | 31 | def test_dict_lookup(): 32 | c1 = LDRColour(LDR_ORGYLW_COLOUR) 33 | assert c1.name() == "Orange/Yellow" 34 | c2 = LDRColour.SafeLDRColourName(LDR_ORGYLW_COLOUR) 35 | assert c2 == "Orange/Yellow" 36 | c3 = FillColoursFromLDRCode(LDR_BLKWHT_COLOUR) 37 | assert (1.0, 1.0, 1.0) in c3 38 | c4 = FillTitlesFromLDRCode(LDR_BLKWHT_COLOUR) 39 | assert "Black" in c4 40 | assert "White" in c4 41 | assert len(c4) == 2 42 | -------------------------------------------------------------------------------- /tests/test_ldrprimitives.py: -------------------------------------------------------------------------------- 1 | # Sample Test passing with nose and pytest 2 | 3 | import os 4 | import sys 5 | import pytest 6 | 7 | from toolbox import * 8 | from ldrawpy import * 9 | 10 | 11 | def test_ldrline(): 12 | l1 = LDRLine(0) 13 | l1.p2 = Vector(1, 2, 3) 14 | l1.translate(Vector(5, 10, 20)) 15 | assert l1.p2.x == 6 16 | assert l1.p2.y == 12 17 | assert l1.p2.z == 23 18 | ls = str(l1).rstrip() 19 | assert ls == "2 0 5 10 20 6 12 23" 20 | 21 | 22 | def test_ldrtriangle(): 23 | t1 = LDRTriangle(0) 24 | t1.p2 = Vector(5, 10, 0) 25 | t1.p3 = Vector(10, 0, 0) 26 | t1.translate(Vector(2, 3, -7)) 27 | assert t1.p3.x == 12 28 | assert t1.p3.y == 3 29 | assert t1.p3.z == -7 30 | ts = str(t1).rstrip() 31 | assert ts == "3 0 2 3 -7 7 13 -7 12 3 -7" 32 | 33 | 34 | def test_ldrquad(): 35 | q1 = LDRQuad(0) 36 | q1.p2 = Vector(0, 5, 0) 37 | q1.p3 = Vector(20, 5, 0) 38 | q1.p4 = Vector(20, 0, 0) 39 | q1.translate(Vector(7, 3, 8)) 40 | assert q1.p4.x == 27 41 | assert q1.p4.y == 3 42 | assert q1.p4.z == 8 43 | qs = str(q1).rstrip() 44 | assert qs == "4 0 7 3 8 7 8 8 27 8 8 27 3 8" 45 | 46 | 47 | def test_ldrpart(): 48 | p1 = LDRPart(0) 49 | p1.attrib.loc = Vector(5, 7, 8) 50 | p1.name = "3002" 51 | assert p1.attrib.loc.x == 5 52 | assert p1.attrib.loc.y == 7 53 | assert p1.attrib.loc.z == 8 54 | ps = str(p1).rstrip() 55 | assert ps == "1 0 5 7 8 1 0 0 0 1 0 0 0 1 3002.dat" 56 | 57 | 58 | def test_ldrpart_translate(): 59 | p1 = LDRPart(0) 60 | p1.attrib.loc = Vector(5, 7, 8) 61 | p1.name = "3002" 62 | assert p1.attrib.loc.x == 5 63 | assert p1.attrib.loc.y == 7 64 | assert p1.attrib.loc.z == 8 65 | ps = str(p1).rstrip() 66 | assert ps == "1 0 5 7 8 1 0 0 0 1 0 0 0 1 3002.dat" 67 | p2 = LDRPart.translate_from_str(ps, Vector(0, -2, -4)) 68 | assert str(p2).rstrip() == "1 0 5 5 4 1 0 0 0 1 0 0 0 1 3002.dat" 69 | 70 | 71 | def test_ldrpart_equality(): 72 | p1 = LDRPart(0, name="3001") 73 | p2 = LDRPart(0, name="3001") 74 | assert p1 == p2 75 | p3 = LDRPart(0, name="3002") 76 | assert p1 != p3 77 | assert p1.is_identical(p2) 78 | p2.move_to((0, 8, 20)) 79 | assert p1 == p2 80 | assert not p1.is_identical(p2) 81 | 82 | 83 | def test_ldrpart_sort(): 84 | p1 = LDRPart(0, name="3001") 85 | p2 = LDRPart(1, name="3666") 86 | p3 = LDRPart(14, name="3070b") 87 | sp = sort_parts([p1, p2, p3], key="sha1") 88 | assert sp[0].name == "3070b" 89 | assert sp[1].name == "3001" 90 | assert sp[2].name == "3666" 91 | sp = sort_parts([p1, p2, p3], key="name", order="descending") 92 | assert sp[2].name == "3001" 93 | assert sp[1].name == "3070b" 94 | assert sp[0].name == "3666" 95 | -------------------------------------------------------------------------------- /tests/test_misc.py: -------------------------------------------------------------------------------- 1 | # Sample Test passing with nose and pytest 2 | 3 | import os 4 | import sys 5 | import pytest 6 | 7 | from toolbox import * 8 | from ldrawpy import * 9 | 10 | fin = "./test_files/testfile2.ldr" 11 | 12 | 13 | def test_cleanup(): 14 | fno = fin.replace(".ldr", "_clean.ldr") 15 | clean_file(fin, fno) 16 | with open(fin, "r") as f: 17 | fl = f.read() 18 | assert len(fl) == 1284 19 | assert "-59.999975" in fl 20 | with open(fno, "r") as f: 21 | fl = f.read() 22 | assert len(fl) == 1101 23 | assert "-60" in fl 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py34,py35,py36,py37 3 | 4 | [testenv] 5 | commands = py.test ldraw-py 6 | deps = pytest 7 | --------------------------------------------------------------------------------