├── LICENSE.md ├── README.md ├── arbitrary_stack.scad ├── assets └── multiboard_base.png ├── generate-stacks.py ├── multiboard_base.scad └── single_cell.scad /LICENSE.md: -------------------------------------------------------------------------------- 1 | Last updated on 12/June/2024 2 | Keep Making Limited 3 | Company no. 15578423 4 | 5 | # **MULTIBOARD LICENCE** 6 | 7 | Please read the following important terms and conditions before using, downloading, copying or modifying any Multiboard 3D design files or any other material (including but not limited to designs, drawings, documentation and manuals or downloadable files) which are owned or licensed by Keep Making (**Designs**) and check that they contain everything you want and nothing that you are not willing to agree to. 8 | 9 | **HOW THESE TERMS WORK** 10 | 11 | These terms and conditions (**Terms**) apply if you are using, downloading, copying, modifying (**Using, Use**) the Designs. These Terms apply in addition to, and should be read together with, the terms of Thangs.com, available here: [https://thangs.com/terms-and-conditions](https://thangs.com/terms-and-conditions) (**Thangs Terms**). 12 | 13 | To the extent of any inconsistency between the Thangs Terms and these Terms, these Terms shall prevail. 14 | 15 | In these Terms, capitalised words and phrases have the meanings given to them where they are followed by bolded brackets, or as set out in the Definitions table at the end of these Terms. 16 | 17 | By Using a Design, you agree to be bound by these Terms which form a binding contractual agreement between you the person acquiring a Subscription or Design, or the company you represent and are acquiring the Subscription or Design on behalf of (‘**you**’ or ‘**your**’) and us, Keep Making Limited (**Keep Making, we, us, our**). 18 | 19 | 1. # **ELIGIBILITY** 20 | 21 | 1) By accepting these Terms, you represent and warrant that you have the legal capacity and authority to enter into a binding contract with us. 22 | 23 | 2) Please do not Use the Designs if you are under the age of 18 years old and do not have your parent or guardian’s consent, if you are under 16 or if you have previously been suspended or prohibited from using the Software. 24 | 25 | 3) If you are Using the Designs not as an individual but on behalf of your company, your employer, an organisation, government or other legal entity (**Represented Entity**), then “you” or “your” means the Represented Entity and you are binding the Represented Entity to this agreement. If you are accepting this agreement and using our Designs on behalf of a Represented Entity, you represent and warrant that you are authorised to do so. 26 | 27 | 2. # **SUBSCRIPTIONS** 28 | 29 | 1. ## SUBSCRIPTIONS AND ACCOUNTS 30 | 31 | 1) To Use our Downloads, you may be required to sign up for an account with Thangs.com (**Account**) and pay for a subscription (**Subscription**). 32 | 33 | 2) We offer different tiers of Subscriptions (**Subscription Tiers**) as set out on the Thangs platform here [https://thangs.com/designer/Keep%20Making](https://thangs.com/designer/Keep%20Making) (**Platform**). 34 | 35 | 3) We reserve the right to offer Designs on an exclusive basis to certain Subscription Tiers, or allow certain Subscription Tiers early access to our Designs, as set out on the Platform. 36 | 37 | 4) You may only Use the Designs as a consumer for personal use unless otherwise agreed by us in writing. 38 | 39 | 5) If you are Using the Designs in the course of business or to derive income of any kind including without limitation to sell any Designed Works, you must have a Multipartner subscription. 40 | 41 | 6) Your Use of the Designs is subject to the licence granted to your Subscription Tier, as set out in clause 3\. 42 | 43 | 2. ## DISCLAIMER 44 | 45 | 1. You acknowledge and agree that: 46 | 47 | 1) any information provided to you as part of or in connection with the Designs is general in nature, may not be suitable for your circumstances and does not constitute financial, legal or any other kind of professional advice; 48 | 49 | 2) we make no warranties as to the suitability of the Designs or any Designed Works for your use, and you are solely responsible for the material and production of Designed Works. Any weight limits or suggestions for materials and production are general and not professional advice, and you should use care when installing and using any Designed Works; and 50 | 51 | 3) it is your responsibility to comply with applicable laws relevant to your Use of the Designs (including but not limited to your business, including industrial relations Laws and privacy laws). 52 | 53 | 3. ## DESIGNS 54 | 55 | 1) We may from time to time, in our absolute discretion, release enhancements to the Designs, meaning an upgraded, improved, modified or new versions of the Designs (**Enhancements**). Any Enhancements to the Designs will not limit or otherwise affect these Terms. 56 | 57 | 2) We may change any features of the Designs at any time. 58 | 59 | 4. ## SERVICE LIMITATIONS 60 | 61 | 1) The Designs are made available to you strictly on an ‘as is’ basis. Without limitation we do not guarantee that: 62 | 63 | 1) the Designs will be free from errors or defects; 64 | 65 | 2) the Designs will be accessible at all times; or 66 | 67 | 3) any information provided with the Designs is accurate or true. 68 | 69 | 2) To the maximum extent permitted by applicable law, all express or implied representations and warranties (whether relating to fitness for purpose or performance, or otherwise) not expressly stated in this agreement are excluded. 70 | 71 | 3. # **INTELLECTUAL PROPERTY LICENSE** 72 | 73 | 1. ## DEFINITIONS 74 | 75 | 1. In these Terms: 76 | 77 | 1) **“Commercial Subscription”** means the Multi Partner membership plan on the Platform; 78 | 79 | 2) **“Designed Works”** means any physical product produced or derived from a Design; 80 | 81 | 3) **“Intellectual Property Rights”** means any and all present and future intellectual and industrial property rights throughout the world (whether registered or unregistered), including copyright, trade marks, designs, patents, moral rights, semiconductor and circuit layout rights, trade, business, company and domain names, and other proprietary rights, trade secrets, know-how, technical data, confidential information and the right to have information kept confidential, or any rights to registration of such rights (including renewal), whether created before or after the date of this agreement; and 82 | 83 | 4) **“Remixed Design”** means a derivative, modified, adapted or enhanced version of the Designs. 84 | 85 | 2. ## DESIGN LICENSE 86 | 87 | 1) We grant you a revocable, worldwide, royalty-free, non-transferrable (and non-sublicensable, except as contemplated by clause 3.3) license to use the Intellectual Property in the Designs for: 88 | 89 | 1) your personal Use only, including making Designed Works for your personal use; and 90 | 91 | 2) If you hold a Commercial Subscription, commercial purposes subject to clause 3.2(b). 92 | 93 | 2) Commercial Subscription holders are permitted under this license to use the Intellectual Property in the Designs, including without limitation the Designs themselves: 94 | 95 | 1) Solely for the purpose of making Designed Works which may be sold on a commercial basis subject to clause 3.2(b)(ii), and not to license or sublicense any part of the Designs; and 96 | 97 | 2) the Designed Works may be sold up to a maximum of $50,000 USD per annum in total sales for the Designed Works (**Sales Cap**). 98 | 99 | 3) You must not exceed the Sales Cap under your Commercial Subscription without our prior consent. You will exceed the Sales Cap if you earn or generate revenue from the sale of Designed Works in a 12 month period (cumulative) which exceeds the Sales Cap. If you exceed the Sales Cap you must obtain an additional commercial license, subject to our agreement. Please contact us at Hello@keep-making.com to discuss commercial license options. If you do not obtain an additional commercial license, you must cease production and sales of the Designed Works. 100 | 101 | 4) If you cease to hold a Commercial Subscription, you must cease all sales of any Designed Works. 102 | 103 | 5) If you breach any terms or conditions of your license set out in this clause 3, you acknowledge and agree: 104 | 105 | 1) That Keep Making may terminate your subscription and the license granted to you under these Terms; and 106 | 107 | 2) You indemnify us for any loss, damage, claim, expense or cost we suffer or incur as a result of your breach. 108 | 109 | 6) For the avoidance of doubt, nothing in these terms gives you any rights in respect of Remixed Designs. Remixed Designs are the product of both Keep Making and third party Intellectual Property Rights, and therefore you must obtain the consent of both before you can use the Remixed Designs. 110 | 111 | 3. ## REMIXED DESIGNS 112 | 113 | 1) Under this licence you may adapt and modify the Designs to create Remixed Designs provided: 114 | 115 | 1) The Remixed Designs must be a substantial change from the original Designs; 116 | 117 | 2) You must not share, publish, distribute, sell, loan, or make available to the public or any individual or entity the original Designs; 118 | 119 | 3) To the extent that the Designs form part of the Remixed Designs, they may only be licensed or sublicensed as part of the Remixed Designs subject to the same conditions under which the original Designs are licensed under these Terms, and we encourage you to adopt the same license terms in respect of the Remixed Designs as a whole; 120 | 121 | 4) The Remixed Designs must not infringe any Intellectual Property Rights, including copyright, trademarks, business names, patents, Confidential Information or any other similar proprietary rights, whether registered or unregistered, anywhere in the world; 122 | 123 | 5) The Remixed Designs must not breach or infringe any laws or regulations including being created for the purpose of any illegal purpose; 124 | 125 | 6) The Remixed Designs must not contain any viruses or other harmful code, or otherwise compromise the security or integrity of any network or system; and 126 | 127 | 7) You and the Remixed Designs comply at all times with clause 4\. 128 | 129 | 2) You grant to Keep Making a non-exclusive, royalty free, non-transferable, sub-licensable, worldwide and irrevocable licence to use, including without limitation adapt, modify and enhance the Remixed Designs. 130 | 131 | 3) You: 132 | 133 | 1) Warrant that Keep Making’s use of the Remixed Designs as contemplated by this agreement will not infringe any third-party Intellectual Property Rights; and 134 | 135 | 2) indemnify Keep Making from and against all losses, claims, expenses, damages and liabilities (including any taxes, fees or costs) which arise out of such infringement. 136 | 137 | 4) We reserve the right to terminate the right to create Remixed Designs under this license in our sole discretion, including without limitation if we believe you to be misusing the license. 138 | 139 | 4. ## CANCELLATION OF YOUR LICENCE 140 | 141 | 1. We reserve the right to terminate your license granted under these Terms without notice to you if you have breached any part of these Terms. 142 | 143 | 4. # **YOUR OBLIGATIONS** 144 | 145 | 1) **You must comply with these Terms at all times.** You acknowledge and agree that we will have no liability in respect of any damage, loss or expense which arises in connection with your breach of these Terms, and you indemnify us in respect of any such damage, loss or expense. 146 | 147 | 2) You must not, and must not encourage or permit any employee, agent, officer or contractor (**Personnel**) or any third party to, without our prior written approval: 148 | 149 | 1) use the Designs for any purpose other than for the purpose for which it was designed, including you must not use the Designs in a manner that is illegal or fraudulent or facilitates illegal or fraudulent activity; 150 | 151 | 2) make copies of the Designs; 152 | 153 | 3) adapt, modify or tamper in any way with the Designs, other than as permitted under these Terms; 154 | 155 | 4) remove or alter any copyright, trade mark or other notice on or forming part of the Designs; 156 | 157 | 5) act in any way that may harm our reputation or that of associated or interested parties or do anything at all contrary to the interests of us or the Designs; 158 | 159 | 6) use the Designs in a way which infringes the Intellectual Property Rights of any third party; 160 | 161 | 7) create derivative works from or translate the Designs, other than as permitted under these Terms; 162 | 163 | 8) publish or otherwise communicate the Designs to the public, including by making it available online or sharing it with third parties; 164 | 165 | 9) intimidate, harass, impersonate, stalk, threaten, bully or endanger any other User or distribute unsolicited commercial content, junk mail, spam, bulk content or harassment in connection with the Designs; or 166 | 167 | 10) sell, loan, transfer, sub-licence, hire or otherwise dispose of the Designs (or any Designed Works or Remixed Designs) to any third party, except as permitted under these Terms. 168 | 169 | 3) If you become aware of misuse of the Designs by any person, any errors in the material of the Designs or any difficulty in accessing or using your Subscription or the Designs, please contact us immediately by email to Hello@Keep-Making.com 170 | 171 | 4) all displays or publications of any Designed Works or Remixed Designs must bear an accreditation and/or a copyright notice of the source of the Designs, including Keep Making’s name and link to the Platform; 172 | 173 | 5) You agree: 174 | 175 | 1) to comply with each of your obligations in these Terms; 176 | 177 | 2) that information given to you through the Subscription or Designs, by us or another User, is general in nature and we take no responsibility for anything caused by any actions you take in reliance on that information; and 178 | 179 | 3) that we may cancel your, or any User’s, Account at any time if we consider, in our absolute discretion, that you or they are in breach of, or are likely to breach, this clause. 180 | 181 | 5. # **LIABILITY AND INDEMNITY** 182 | 183 | 1) (**Liability**) To the maximum extent permitted by applicable law, Keep Making limits all liability in aggregate of all claims to you (and any third parties who encounter the Designs or Designed Works through your business) for loss or damage of any kind, however arising whether in contract, tort, statute, equity, indemnity or otherwise, arising from or relating in any way to this agreement or any goods or services provided by Keep Making to £100. 184 | 185 | 2) (**Indemnity**) You indemnify Keep Making and its employees, contractors and agents in respect of all liability for any claim(s) by any person (including any third party who encounter the Designs or Designed Works through your business) arising from your, or your employee’s, client’s, contractor’s or agent’s: 186 | 187 | 1) breach of any third party intellectual property rights;  188 | 189 | 2) breach of these Terms;  190 | 191 | 3) negligent, wilful, fraudulent or criminal act or omission; or  192 | 193 | 4) use of the Designs. 194 | 195 | 3) (**Consequential loss**) To the maximum extent permitted by law, under no circumstances will Keep Making be liable for any incidental, special or consequential loss or damages, or damages for loss of data, business or business opportunity, goodwill, anticipated savings, profits or revenue arising under or in connection with this agreement or any goods or services provided by Keep Making. 196 | 197 | 4) (**Unfair Contract Terms**) To the extent that the provisions of any applicable law shall impose restrictions on the extent to which liability can be excluded under these including, for the avoidance of doubt, the provisions of sections 3, 6 and 11 of the *Unfair Contract Terms Act 1977* in the UK (and its equivalent in any other jurisdiction) relating to the requirement of reasonableness, the exclusions set out in this clause shall be limited in accordance with such restrictions. However, any exclusions of liability that are not affected by such restrictions shall remain in full force and effect. 198 | 199 | 5) Nothing in this agreement shall exclude or limit a party’s liability for fraud or intentional unlawful conduct by a party, or death or personal injury resulting from a party’s negligence. 200 | 201 | 6. # **GENERAL** 202 | 203 | 1. ## GOVERNING LAW AND JURISDICTION 204 | 205 | 1. These Terms are governed by the law applying in England and Wales. Each party irrevocably submits to the exclusive jurisdiction of the courts of England and Wales and courts of appeal from them in respect of any proceedings arising out of or in connection with this agreement. Each party irrevocably waives any objection to the venue of any legal process on the basis that the process has been brought in an inconvenient forum. 206 | 207 | 2. ## THIRD PARTY RIGHTS 208 | 209 | 1. These Terms do not give rise to any rights under the Contracts (Rights of Third Parties) Act 1999 to enforce any term of these Terms. 210 | 211 | 3. ## WAIVER 212 | 213 | 1. No party to this agreement may rely on the words or conduct of any other party as a waiver of any right unless the waiver is in writing and signed by the party granting the waiver. 214 | 215 | 4. ## FURTHER ACTS AND DOCUMENTS 216 | 217 | 1. Each party must promptly do all further acts and execute and deliver all further documents required by law or reasonably requested by another party to give effect to this agreement. 218 | 219 | 5. ## ASSIGNMENT 220 | 221 | 1. You can’t assign, novate or otherwise transfer your rights or obligations under this agreement without the Keep Making’s prior consent. 222 | 223 | 224 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stacked Parametric Multiboard Tiles 2 | 3 | This is an [OpenSCAD][] file for generating arbitrary stacked 4 | [Multiboard][] tiles. 5 | 6 | [OpenSCAD]: https://openscad.org/ 7 | [Multiboard]: https://www.multiboard.io/ 8 | 9 | ![Rendering of a stack of Multiboard tiles](/assets/multiboard_base.png) 10 | 11 | 12 | ## Usage 13 | 14 | Use the `multiboard_base.scad` file and set the following parameters: 15 | 16 | * `x_cells` – The number of cells (large holes) in the X direction 17 | * `y_cells` – The number of cells in the Y direction 18 | * `core_tiles` – The number of core tiles to generate; these tiles have 19 | teeth in both the X and Y direction 20 | * `side_tiles` – The number of side tiles to generate; these tiles have 21 | teeth only in the X direction 22 | * `corner_tiles` – The number of corner tiles to generate; these tiles 23 | have no teeth 24 | * `layer_thickness` – The layer thickness you will be using to print the 25 | tiles 26 | 27 | If you want the side and corner tiles to have different dimensions from 28 | the corner tiles, you can also change the advanced settings, which should 29 | be fairly self-explanatory: 30 | 31 | * `side_x_cells` 32 | * `side_y_cells` 33 | * `corner_x_cells` 34 | * `corner_y_cells` 35 | 36 | The default value for those advanced settings is "0", which means to use 37 | the `x_cells` and `y_cells` values for the tile. 38 | 39 | Render the file and save to an STL. 40 | 41 | In your slicer, make sure you've enabled ironing of top surfaces. In 42 | addition to that, make sure you're using the recommended Multiboard 43 | printing parameters (3-line thick walls, 15% infill). 44 | 45 | After printing, it may take a little work to separate the tiles. The 46 | author has had the best luck by pulling apart the corner closest to the 47 | origin (i.e. furthest from the tile teeth) and then working along the tile 48 | from there. 49 | 50 | Multiboard has a video about how to print stacks: 51 | [Multiboard: What Is Stack 3D Printing](https://youtu.be/xs2urfM0MRM). 52 | 53 | 54 | ## Stack Printing Drawbacks 55 | 56 | Stack-printed tiles will have one side that's smooth and the other side 57 | (the one on the bottom during printing) will be a bit rough. This 58 | shouldn't be a problem if you're only planning on using one side of the 59 | files (e.g. if you're mounting them on a wall). 60 | 61 | You can print an entire set of tiles at once _as long as the tiles are 62 | square_. If you're tiling non-square tiles, you'll need at least two 63 | separate stacks: one stack with the core tiles, the top (or bottom) side 64 | tiles, and the corner tile; the other stack with the right (or left) side 65 | tiles. That's because this generator always puts the side tile's teeth on 66 | the right side, and the right (or left) side tiles need the teeth on top. 67 | For the right (or left) side tiles, swap the tile X and Y dimensions in 68 | the file parameters and just print side tiles. 69 | 70 | Stack-printed tiles can be difficult to separate. Ironing the tile 71 | surfaces is required, and you should have your printer calibrated as 72 | precisely as possible. If the stacks aren't working for you and you have 73 | a multi-extruder printer, you might consider the [Multiboard Parametric 74 | Extended][] model from Uno. 75 | 76 | [Multiboard Parametric Extended]: https://www.printables.com/model/882280-multiboard-parametric-extended-openscad 77 | 78 | The author has only really tested this model with a 0.2 mm layer height. 79 | Multiboard components in general are designed to be printed with 0.2 mm 80 | layers. Although other layer heights _should_ work with this model, 81 | they're less-guaranteed. Feedback on others' experiences of printing with 82 | different thicknesses would be appreciated. Note also that the model 83 | assumes all layers have equal heights (as opposed to using a 84 | different thickness for the first layer). 85 | 86 | 87 | ## Tile Stack Sizing 88 | 89 | To get the width of a tile, multiply the number of cells by 25 mm and then 90 | add 8 mm for the teeth. Each layer is 6.6 mm high, when printed with a 91 | 0.2 mm layer thickness. 92 | 93 | A printer with a 220×220×250 mm print area can print up to 37 stacked 8×8 94 | tiles. 95 | 96 | 97 | ## Stacks of Arbitrary Tiles 98 | 99 | The `arbitrary_stack.scad` file can be used to create a stack of tiles 100 | with arbitrary shapes and dimensions. You need to put the tile 101 | definitions in the `tiles` array. Tile definitions can include lists of 102 | cells to omit from the generated model. 103 | 104 | See the comment at the top of the file for more information. 105 | 106 | 107 | ## Tile Generating Program 108 | 109 | There's a small program in the repository to generate STLs for the tiles 110 | needed to cover a given area. Run `generate-stacks.py --help` for usage 111 | information. 112 | 113 | You need to have `openscad` in your path for the STL generation to work. 114 | You can also pass the `--no-stl` parameter to just display the tiles that 115 | could be used to cover the area. 116 | 117 | For example, if you had an area 431 mm by 717 mm, you could run: 118 | 119 | generate-stacks.py -w 431 -h 717 120 | 121 | Which would print the following before generating the tile stacks described: 122 | 123 | The parameters for the board are: 124 | 125 | Area dimensions: 431.00×717.00 mm 126 | Board dimensions: 17×28 (425×700 mm) 127 | Base tile size: 6×7 128 | Board tile dimensions: 3×4 129 | 130 | 2 stacks will be printed: 131 | 132 | Stack 1 [Stack-6x6x7_core-2x6x7_top-5x7_corner.stl]: 133 | 6 Core 6×7 tiles 134 | 2 Top Side 6×7 tiles 135 | 1 Corner 5×7 tile 136 | 137 | Stack 2 [Stack-3x5x7_right.stl]: 138 | 3 Right Side 5×7 tiles 139 | 140 | Also of note is the `--dxf` parameter you can pass to generate a DXF file 141 | of the board layout. The author uses these files as bases for detailed 142 | project planning. You will need to have the [ezdxf][] Python module 143 | installed. 144 | 145 | [ezdxf]: https://ezdxf.mozman.at/ 146 | 147 | 148 | ## Credits 149 | 150 | [Multiboard][] was designed by Jonathan of [Keep Making][]. 151 | 152 | [Keep Making]: https://www.youtube.com/@Keep-Making 153 | 154 | These files are based on Victor Zag's [multiboard-parametric][] project. 155 | Victor did all the really important initial work. 156 | 157 | [multiboard-parametric]: https://github.com/shaggyone/multiboard-parametric 158 | 159 | 160 | ## License 161 | 162 | This model is derived from Multiboard files and is covered by the 163 | [Multiboard License][]. A copy is available in the `LICENSE.md` file. 164 | 165 | [Multiboard License]: https://www.multiboard.io/license 166 | 167 | Note in particular that the license restricts commercial use of derived 168 | works. 169 | -------------------------------------------------------------------------------- /arbitrary_stack.scad: -------------------------------------------------------------------------------- 1 | /* [Tile Definitions] */ 2 | 3 | // Array of tile shapes. 4 | // 5 | // Each array element should be: 6 | // [count, x_cells, y_cells, shape, exceptions] 7 | // 8 | // Valid shapes are: 9 | // * "core" 10 | // * "side" (for a left or right side with the teeth on the top) 11 | // * "rotated side" (for a top or bottom side with the teeth on the right) 12 | // * "corner" 13 | // 14 | // The exceptions element is optional. If present, it should be a list of 15 | // x, y pairs. Each pair indicates a cell that should *not* be generated 16 | // in the model. Cells are zero-indexed. [0, 0] is the cell in the lower 17 | // left corner of the tile. Note that you can add exceptions for the 18 | // cells in the row and column just past the dimensions of a tile; this 19 | // will cause some of the teeth on the edge of the tile to be suppressed 20 | // in the same way they would be if cells internal to the tile were being 21 | // omitted. 22 | // 23 | // Warning: This model will create whatever you tell it to. It's up to 24 | // you to make sure your tiles are all supported. 25 | 26 | tiles = [ 27 | [4, 4, 4, "core"], 28 | [3, 4, 4, "side"], 29 | [2, 3, 4, "rotated side"], 30 | [1, 3, 3, "corner", [[1, 1], [2, 1]]], 31 | ]; 32 | 33 | /* [Print Settings] */ 34 | 35 | // Your slicer's layer thickness in millimeters; 0.2 mm is strongly recommended 36 | layer_thickness = 0.2; 37 | 38 | 39 | // No user-servicable parts below this line. 40 | 41 | use 42 | 43 | // `use` doesn't pull in variables, so we need to redefine the ones we 44 | // need. 45 | cell_size = 25+0; 46 | height = 6.4+0; 47 | stack_height = height + abs(-height % layer_thickness) + layer_thickness; 48 | 49 | 50 | module tile_group(offset, tile_params) { 51 | count = tile_params[0]; 52 | x_cells = tile_params[1]; 53 | y_cells = tile_params[2]; 54 | shape = tile_params[3]; 55 | exceptions = tile_params[4]; 56 | assert(x_cells >= 1, "X dimension must be at least 1"); 57 | assert(y_cells >= 1, "Y dimension must be at least 1"); 58 | translate([0, 0, offset * stack_height]) 59 | for (level = [0:1:count-1]) 60 | translate([0, 0, level * stack_height]) 61 | generic_tile(x_cells, y_cells, shape, exceptions); 62 | } 63 | 64 | 65 | module generic_tile(x_cells, y_cells, shape, exceptions) { 66 | if (shape == "core") {multiboard_tile(x_cells, y_cells, right_peg_holes=true, top_peg_holes=true, exceptions=exceptions);} 67 | else if (shape == "side") {multiboard_tile(x_cells, y_cells, right_peg_holes=false, top_peg_holes=true, exceptions=exceptions);} 68 | else if (shape == "rotated side") {multiboard_tile(x_cells, y_cells, right_peg_holes=true, top_peg_holes=false, exceptions=exceptions);} 69 | else if (shape == "corner") {multiboard_tile(x_cells, y_cells, right_peg_holes=false, top_peg_holes=false, exceptions=exceptions);} 70 | else { 71 | assert(false, "Unknown tile shape"); 72 | } 73 | } 74 | 75 | 76 | offsets = [ for (o = 0, i = 0; i < len(tiles); o = o + tiles[i][0], i = i + 1) o ]; 77 | 78 | for ( i = [0:1:len(tiles)-1] ) 79 | tile_group(offsets[i], tiles[i]); 80 | -------------------------------------------------------------------------------- /assets/multiboard_base.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asciipip/multiboard-parametric-stacked/4db5f07abb4653193014eb1bba761011bc29eb87/assets/multiboard_base.png -------------------------------------------------------------------------------- /generate-stacks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import collections 5 | import json 6 | import math 7 | import pathlib 8 | import shutil 9 | import subprocess 10 | import sys 11 | 12 | 13 | CELL_SIZE_MM = 25 14 | MULTIHOLE_OUTER_SIZE_MM = 23.4 15 | MULTIHOLE_INNER_SIZE_MM = 21.4 16 | PEG_HOLE_OUTER_SIZE_MM = 7.5 17 | PEG_HOLE_INNER_SIZE_MM = 6.0 18 | TOOTH_EXTRA_MM = 8 19 | MAX_DEFAULT_TILE_SIZE = 8 20 | STACK_SCAD_FILE = pathlib.Path(__file__)\ 21 | .resolve()\ 22 | .parent\ 23 | .joinpath('arbitrary_stack.scad') 24 | MULTIHOLE_BLOCK_NAME = 'multihole' 25 | PEG_HOLE_BLOCK_NAME = 'peg-hole' 26 | TILE_LAYER_NAME = 'tiles' 27 | HOLE_LAYER_NAME = 'holes' 28 | AREA_LAYER_NAME = 'area' 29 | 30 | 31 | TileGroup = collections.namedtuple('TileGroup', ['count', 'width', 'height', 'shape']) 32 | 33 | 34 | def main(): 35 | args = parse_args() 36 | show_dimensions(args) 37 | stacks = determine_stacks(args) 38 | confirm_stacks(stacks, args) 39 | if args.dxf: 40 | generate_dxf(stacks, args) 41 | if args.stl: 42 | generate_stacks(stacks, args) 43 | 44 | 45 | def parse_args(): 46 | parser = argparse.ArgumentParser( 47 | description="Generates models for a reasonable set of Multiboard tiles to cover a given 2D space.", 48 | epilog=""" 49 | You must give the dimensions of the space to fill, either in millimeters 50 | (-w/--width-mm, -h/--height-mm) or in Multiboard cell counts 51 | (--width-cells, --height-cells). 52 | 53 | If you don't give your preferred tile size (--tile-width, --tile-height), 54 | the program will try to pick a reasonable size that minimizes the number 55 | of tiles needed and makes the side tiles as close as possible in size to 56 | the core tiles. The preferred tile size will be applied to the core 57 | tiles; side and corner tiles might be truncated to fit in the space.""", 58 | formatter_class=argparse.RawTextHelpFormatter, 59 | add_help=False) 60 | parser.add_argument('--help', action='store_true', help='Show this help message and exit') 61 | parser.add_argument('-w', '--width-mm', metavar='MM', type=float, 62 | help='Width of the board area, in mm') 63 | parser.add_argument('-h', '--height-mm', metavar='MM', type=float, 64 | help='Height of the board area, in mm') 65 | parser.add_argument('--width-cells', metavar='CELLS', dest='width', type=int, 66 | help='Width of the board area, in cells') 67 | parser.add_argument('--height-cells', metavar='CELLS', dest='height', type=int, 68 | help='Height of the board area, in cells') 69 | parser.add_argument('--tile-width', metavar='CELLS',type=int, 70 | help='Width of each tile, in cells') 71 | parser.add_argument('--tile-height', metavar='CELLS', 72 | type=int, help='Height of each tile, in cells') 73 | parser.add_argument('--max-tile-size-mm', type=float, metavar='MM', 74 | help='Maximum size of a tile side in mm (default: {})'.format(CELL_SIZE_MM * MAX_DEFAULT_TILE_SIZE)) 75 | parser.add_argument('--max-tile-size', type=int, metavar='CELLS', 76 | help='Maximum size of a tile side in cells (default: {})'.format(MAX_DEFAULT_TILE_SIZE)) 77 | parser.add_argument('-y', '--yes', action='store_true', 78 | help='Don\'t prompt before generating output files') 79 | parser.add_argument('--dxf', action=argparse.BooleanOptionalAction, default=False, 80 | help='Generate a DXF file of the tile layout (default: no DXF file)') 81 | parser.add_argument('--stl', action=argparse.BooleanOptionalAction, default=True, 82 | help='Generate one or more STLs containing stacked tiles (default: generate STLs)') 83 | parser.add_argument('-p', '--filename-prefix', default='Stack', 84 | help='Text used to determine the name of generated files (default: Stack)') 85 | args = parser.parse_args() 86 | 87 | if args.help: 88 | parser.print_help() 89 | exit(0) 90 | 91 | if (args.width_mm is not None and args.width is not None) \ 92 | or (args.height_mm is not None and args.height is not None): 93 | print('Each dimension should be given in mm or cells, but not both.', 94 | file=sys.stderr) 95 | exit(1) 96 | 97 | if args.max_tile_size_mm is not None and args.max_tile_size is not None: 98 | print('Max tile size should be given in mm or cells, but not both.', 99 | file=sys.stderr) 100 | exit(1) 101 | 102 | if args.width_mm is not None: 103 | args.width = math.floor(args.width_mm / CELL_SIZE_MM) 104 | 105 | if args.height_mm is not None: 106 | args.height = math.floor(args.height_mm / CELL_SIZE_MM) 107 | 108 | if args.width is None or args.height is None: 109 | print('You must give a width and height.', file=sys.stderr) 110 | exit(1) 111 | 112 | if args.width_mm is None: 113 | args.width_mm = args.width * CELL_SIZE_MM 114 | 115 | if args.height_mm is None: 116 | args.height_mm = args.height * CELL_SIZE_MM 117 | 118 | if args.max_tile_size is None and args.max_tile_size_mm is not None: 119 | args.max_tile_size = math.floor((args.max_tile_size_mm - TOOTH_EXTRA_MM) / CELL_SIZE_MM) 120 | 121 | if args.max_tile_size is None: 122 | args.max_tile_size = MAX_DEFAULT_TILE_SIZE 123 | 124 | determine_tile_size(args) 125 | 126 | return args 127 | 128 | 129 | def determine_tile_size(args): 130 | """Figures out a useful tile size, if not specified explicitly.""" 131 | if args.tile_width is not None and args.tile_height is not None: 132 | return 133 | 134 | if args.tile_width is not None: 135 | args.tile_height = args.tile_width 136 | return 137 | 138 | if args.tile_height is not None: 139 | args.tile_width = args.tile_height 140 | return 141 | 142 | if args.width <= args.max_tile_size and args.height <= args.max_tile_size: 143 | # Only one tile is needed 144 | args.tile_width = args.width 145 | args.tile_height = args.height 146 | return 147 | 148 | min_x_tiles = math.ceil(args.width / args.max_tile_size) 149 | min_x_size = math.ceil(args.width / min_x_tiles) 150 | args.tile_width = min_x_size 151 | 152 | min_y_tiles = math.ceil(args.height / args.max_tile_size) 153 | min_y_size = math.ceil(args.height / min_y_tiles) 154 | args.tile_height = min_y_size 155 | 156 | 157 | def show_dimensions(args): 158 | print('The parameters for the board are:') 159 | print() 160 | print(' Area dimensions: {:0.2f}×{:0.2f} mm'.format(args.width_mm, args.height_mm)) 161 | print(' Board dimensions: {}×{} ({}×{} mm)'.format( 162 | args.width, args.height, 163 | args.width * CELL_SIZE_MM, args.height * CELL_SIZE_MM)) 164 | print(' Base tile size: {}×{}'.format(args.tile_width, args.tile_height)) 165 | print(' Board tile dimensions: {}×{}'.format(*board_tile_dimensions(args))) 166 | print() 167 | 168 | 169 | def determine_stacks(args): 170 | result = [[]] 171 | if core_tile_count(args) > 0: 172 | result[0].append(TileGroup( 173 | count=core_tile_count(args), 174 | width=args.tile_width, 175 | height=args.tile_height, 176 | shape='core')) 177 | if args.tile_width == args.tile_height \ 178 | and top_tile_height(args) == right_tile_width(args): 179 | if right_tile_count(args) + top_tile_count(args) > 0: 180 | result[0].append(TileGroup( 181 | count=right_tile_count(args) + top_tile_count(args), 182 | width=args.tile_width, 183 | height=top_tile_height(args), 184 | shape='side')) 185 | else: 186 | if right_tile_count(args) > 0: 187 | result[0].append(TileGroup( 188 | count=right_tile_count(args), 189 | width=right_tile_width(args), 190 | height=args.tile_height, 191 | shape='side')) 192 | if top_tile_count(args) > 0: 193 | if right_tile_count(args) == 0: 194 | # Without the other side piece, this side can go on the main stack 195 | top_stack = result[0] 196 | else: 197 | top_stack = [] 198 | result.append(top_stack) 199 | top_stack.append(TileGroup( 200 | count=top_tile_count(args), 201 | width=args.tile_width, 202 | height=top_tile_height(args), 203 | shape='rotated side')) 204 | result[0].append(TileGroup( 205 | count=1, 206 | width=right_tile_width(args), 207 | height=top_tile_height(args), 208 | shape='corner')) 209 | return result 210 | 211 | 212 | def confirm_stacks(stacks, args): 213 | errors = [] 214 | 215 | print('{} stack{} will be printed:'.format( 216 | len(stacks), 217 | '' if len(stacks) == 1 else 's')) 218 | for i, stack in enumerate(stacks): 219 | print() 220 | print(' Stack {} [{}]:'.format(i + 1, stack_name(stack, args))) 221 | for tile_group in stack: 222 | print(' {} {} {}×{} tile{}'.format( 223 | tile_group.count, 224 | tile_shape_text(tile_group.shape), 225 | tile_group.width, 226 | tile_group.height, 227 | '' if tile_group.count == 1 else 's')) 228 | if tile_group.width < 2 or tile_group.height < 2: 229 | errors.append('ERROR: Tiles must be at least 2×2, but {} tile is only {}×{}!'.format( 230 | tile_shape_text(tile_group.shape), tile_group.width, tile_group.height)) 231 | 232 | if len(errors) > 0: 233 | print(); 234 | for error in errors: 235 | print(error) 236 | 237 | if len(errors) > 0: 238 | exit(1) 239 | 240 | if args.stl or args.dxf: 241 | if not args.yes: 242 | print() 243 | answer = input('Okay to proceed? [Y/n] ') 244 | if answer != '' and answer.lower() != 'y' and answer.lower() != 'yes': 245 | exit(1) 246 | 247 | 248 | def generate_stacks(stacks, args): 249 | print() 250 | 251 | if shutil.which('openscad') is None: 252 | print('Cannot find openscad in PATH; exiting.', file=sys.stderr) 253 | print('STLs were NOT generated.', file=sys.stderr) 254 | exit(1) 255 | 256 | for stack in stacks: 257 | stack_path = pathlib.Path(stack_name(stack, args)) 258 | if stack_path.exists() and not args.yes: 259 | yn = input('{} exists; overwrite? [y/N] '.format(stack_path)) 260 | if yn.lower() != 'y' and yn.lower() != 'yes': 261 | print('Skipping...') 262 | print() 263 | continue 264 | print('Generating {}; this will take a while...'.format(stack_path)) 265 | cmd = [ 266 | 'openscad', 267 | '-o', stack_path, 268 | '-D', 'tiles=' + json.dumps(stack), 269 | STACK_SCAD_FILE, 270 | ] 271 | subprocess.run(cmd, check=True) 272 | print() 273 | 274 | 275 | def generate_dxf(stacks, args): 276 | import ezdxf 277 | 278 | dxf_file = ezdxf.new() 279 | dxf_file.layers.add(TILE_LAYER_NAME) 280 | dxf_file.layers.add(AREA_LAYER_NAME, linetype='DOT2') 281 | 282 | dxf_add_holes(dxf_file) 283 | core_block_name = dxf_add_tile(dxf_file, 'core', args.tile_width, args.tile_height) 284 | side_block_name = dxf_add_tile(dxf_file, 'side', right_tile_width(args), args.tile_height) 285 | top_block_name = dxf_add_tile(dxf_file, 'side', top_tile_height(args), args.tile_width) 286 | corner_block_name = dxf_add_tile(dxf_file, 'corner', right_tile_width(args), top_tile_height(args)) 287 | 288 | msp = dxf_file.modelspace() 289 | x_tiles, y_tiles = board_tile_dimensions(args) 290 | for x in range(0, x_tiles - 1): 291 | for y in range(0, y_tiles - 1): 292 | msp.add_blockref(core_block_name, 293 | (x * args.tile_width * CELL_SIZE_MM, y * args.tile_height * CELL_SIZE_MM), 294 | dxfattribs={'layer': TILE_LAYER_NAME}) 295 | for y in range(0, y_tiles - 1): 296 | msp.add_blockref(side_block_name, 297 | ((x_tiles - 1) * args.tile_width * CELL_SIZE_MM, y * args.tile_height * CELL_SIZE_MM), 298 | dxfattribs={'layer': TILE_LAYER_NAME}) 299 | for x in range(0, x_tiles - 1): 300 | msp.add_blockref(top_block_name, 301 | (x * args.tile_width * CELL_SIZE_MM, y_tiles * args.tile_height * CELL_SIZE_MM), 302 | dxfattribs={ 303 | 'layer': TILE_LAYER_NAME, 304 | 'rotation': -90, 305 | }) 306 | msp.add_blockref(corner_block_name, 307 | ((x_tiles - 1) * args.tile_width * CELL_SIZE_MM, (y_tiles - 1) * args.tile_height * CELL_SIZE_MM), 308 | dxfattribs={'layer': TILE_LAYER_NAME}) 309 | 310 | area_origin = (-(args.width_mm % CELL_SIZE_MM) / 2, -(args.height_mm % CELL_SIZE_MM) / 2) 311 | msp.add_lwpolyline([ 312 | area_origin, 313 | (area_origin[0] + args.width_mm, area_origin[1]), 314 | (area_origin[0] + args.width_mm, area_origin[1] + args.height_mm), 315 | (area_origin[0], area_origin[1] + args.height_mm), 316 | ], 317 | close=True, 318 | dxfattribs={'layer': AREA_LAYER_NAME}) 319 | 320 | dxf_file.set_modelspace_vport(args.height_mm, (args.width_mm / 2, args.height_mm / 2)) 321 | dxf_file.saveas('{}.dxf'.format(args.filename_prefix)) 322 | 323 | 324 | def dxf_add_tile(dxf_file, tile_type, tile_width, tile_height): 325 | import ezdxf 326 | 327 | octagon_side = CELL_SIZE_MM / (1 + 2 * math.cos(math.pi / 4)) 328 | side_offset = (CELL_SIZE_MM - octagon_side) / 2 329 | 330 | block_name = 'tile-{}-{}x{}'.format(tile_type, tile_width, tile_height) 331 | block = dxf_file.blocks.new(block_name) 332 | points = [] 333 | for x in range(0, tile_width): 334 | cell_x = x * CELL_SIZE_MM 335 | cell_y = 0 336 | y_offset = side_offset 337 | points.append((cell_x + side_offset, cell_y)) 338 | points.append((cell_x + CELL_SIZE_MM - side_offset, cell_y)) 339 | points.append((cell_x + CELL_SIZE_MM, cell_y + y_offset)) 340 | for y in range(0, tile_height): 341 | cell_x = tile_width * CELL_SIZE_MM 342 | cell_y = y * CELL_SIZE_MM 343 | if tile_type in set(['core', 'top']): 344 | x_offset = side_offset 345 | else: 346 | x_offset = -side_offset 347 | points.append((cell_x, cell_y + side_offset)) 348 | points.append((cell_x, cell_y + CELL_SIZE_MM - side_offset)) 349 | points.append((cell_x + x_offset, cell_y + CELL_SIZE_MM)) 350 | for x in range(tile_width, 0, -1): 351 | cell_x = (x - 1) * CELL_SIZE_MM 352 | cell_y = tile_height * CELL_SIZE_MM 353 | if tile_type in set(['core', 'side']): 354 | y_offset = side_offset 355 | else: 356 | y_offset = -side_offset 357 | points.append((cell_x + CELL_SIZE_MM, cell_y + y_offset)) 358 | points.append((cell_x + CELL_SIZE_MM - side_offset, cell_y)) 359 | points.append((cell_x + side_offset, cell_y)) 360 | for y in range(tile_height, 0, -1): 361 | cell_x = 0 362 | cell_y = (y - 1) * CELL_SIZE_MM 363 | x_offset = side_offset 364 | points.append((cell_x, cell_y + CELL_SIZE_MM - side_offset)) 365 | points.append((cell_x, cell_y + side_offset)) 366 | points.append((cell_x + x_offset, cell_y)) 367 | block.add_lwpolyline(points, close=True) 368 | 369 | for x in range(0, tile_width): 370 | for y in range(0, tile_height): 371 | block.add_blockref(MULTIHOLE_BLOCK_NAME, 372 | ((x + 0.5) * CELL_SIZE_MM, (y + 0.5) * CELL_SIZE_MM), 373 | dxfattribs={'layer': HOLE_LAYER_NAME}) 374 | if (tile_type == 'core') \ 375 | or (tile_type == 'side' and x < tile_width - 1) \ 376 | or (tile_type == 'top' and y < tile_height - 1) \ 377 | or (x < tile_width - 1 and y < tile_height - 1): 378 | block.add_blockref(PEG_HOLE_BLOCK_NAME, 379 | ((x + 1) * CELL_SIZE_MM, (y + 1) * CELL_SIZE_MM), 380 | dxfattribs={'layer': HOLE_LAYER_NAME}) 381 | 382 | return block_name 383 | 384 | 385 | def dxf_add_holes(dxf_file): 386 | import ezdxf 387 | dxf_file.layers.add(HOLE_LAYER_NAME, true_color=ezdxf.rgb2int((127, 127, 127))) 388 | 389 | multihole_block = dxf_file.blocks.new(MULTIHOLE_BLOCK_NAME) 390 | multihole_outer_side = MULTIHOLE_OUTER_SIZE_MM / (1 + 2 * math.cos(math.pi / 4)) 391 | multihole_outer_corner_distance = multihole_outer_side / math.sin(math.pi / 8) / 2; 392 | multihole_inner_side = MULTIHOLE_INNER_SIZE_MM / (1 + 2 * math.cos(math.pi / 4)) 393 | multihole_inner_corner_distance = multihole_inner_side / math.sin(math.pi / 8) / 2; 394 | 395 | outer_points = [] 396 | inner_points = [] 397 | for i in range(0, 8): 398 | angle = i * math.pi / 4 + math.pi / 8 399 | outer_points.append((multihole_outer_corner_distance * math.cos(angle), multihole_outer_corner_distance * math.sin(angle))) 400 | inner_points.append((multihole_inner_corner_distance * math.cos(angle), multihole_inner_corner_distance * math.sin(angle))) 401 | multihole_block.add_lwpolyline(outer_points, close=True) 402 | multihole_block.add_lwpolyline(inner_points, close=True) 403 | for op, ip in zip(outer_points, inner_points): 404 | multihole_block.add_line(op, ip) 405 | 406 | peg_hole_block = dxf_file.blocks.new(PEG_HOLE_BLOCK_NAME) 407 | peg_hole_block.add_circle((0, 0), PEG_HOLE_OUTER_SIZE_MM / 2) 408 | peg_hole_block.add_circle((0, 0), PEG_HOLE_INNER_SIZE_MM / 2) 409 | 410 | 411 | def board_tile_dimensions(args): 412 | return ( 413 | math.ceil(args.width / args.tile_width), 414 | math.ceil(args.height / args.tile_height) 415 | ) 416 | 417 | 418 | def core_tile_count(args): 419 | x_tiles, y_tiles = board_tile_dimensions(args) 420 | return (x_tiles - 1) * (y_tiles - 1) 421 | 422 | 423 | def top_tile_count(args): 424 | x_tiles, y_tiles = board_tile_dimensions(args) 425 | return (x_tiles - 1) 426 | 427 | 428 | def right_tile_count(args): 429 | x_tiles, y_tiles = board_tile_dimensions(args) 430 | return (y_tiles - 1) 431 | 432 | 433 | def top_tile_height(args): 434 | if args.height % args.tile_height == 0: 435 | return args.tile_height 436 | else: 437 | return args.height % args.tile_height 438 | 439 | 440 | def right_tile_width(args): 441 | if args.width % args.tile_width == 0: 442 | return args.tile_width 443 | else: 444 | return args.width % args.tile_width 445 | 446 | 447 | def stack_name(stack, args): 448 | result = args.filename_prefix 449 | for group in stack: 450 | if group.shape == 'rotated side': 451 | shape_name = 'top' 452 | elif group.shape == 'side': 453 | shape_name = 'right' 454 | else: 455 | shape_name = group.shape 456 | if group.count == 1: 457 | result += '-{}x{}_{}'.format( 458 | group.width, 459 | group.height, 460 | shape_name) 461 | else: 462 | result += '-{}x{}x{}_{}'.format( 463 | group.count, 464 | group.width, 465 | group.height, 466 | shape_name) 467 | result += '.stl' 468 | return result 469 | 470 | 471 | def tile_shape_text(shape): 472 | return { 473 | 'core': 'Core', 474 | 'side': 'Right Side', 475 | 'rotated side': 'Top Side', 476 | 'corner': 'Corner' 477 | }[shape] 478 | 479 | 480 | if __name__ == '__main__': 481 | main() 482 | -------------------------------------------------------------------------------- /multiboard_base.scad: -------------------------------------------------------------------------------- 1 | // Model to generate stacks of Multiboard tiles for assembly into large 2 | // boards. 3 | 4 | /* [Tile Size] */ 5 | 6 | // Number of cells along the X axis 7 | x_cells = 4; 8 | // Number of cells along the y axis 9 | y_cells = 4; 10 | 11 | /* [Tile Counts] */ 12 | 13 | // Number of core tiles (pegboard holes on the right and top) 14 | core_tiles = 4; 15 | // Number of side tiles (pegboard holes only on the top) 16 | side_tiles = 4; 17 | // Number of corner tiles (no pegboard holes) 18 | corner_tiles = 1; 19 | 20 | /* [Print Settings] */ 21 | 22 | // Your slicer's layer thickness in millimeters; 0.2 mm is strongly recommended 23 | layer_thickness = 0.2; 24 | 25 | /* [Per-Shape Tuning] */ 26 | 27 | // X size of the side tiles; "0" means to use the main x_cells setting 28 | side_x_cells = 0; 29 | // Y size of the side tiles; "0" means to use the main y_cells setting 30 | side_y_cells = 0; 31 | // X size of the corner tiles; "0" means to use the main x_cells setting 32 | corner_x_cells = 0; 33 | // Y size of the corner tiles; "0" means to use the main y_cells setting 34 | corner_y_cells = 0; 35 | 36 | 37 | // No user-servicable parts below this line. 38 | 39 | // Actual tile dimensions 40 | real_side_x_cells = side_x_cells > 0 ? side_x_cells : x_cells; 41 | real_side_y_cells = side_y_cells > 0 ? side_y_cells : y_cells; 42 | real_corner_x_cells = corner_x_cells > 0 ? corner_x_cells : x_cells; 43 | real_corner_y_cells = corner_y_cells > 0 ? corner_y_cells : y_cells; 44 | 45 | // Dimension validation 46 | assert(layer_thickness > 0, "Layer thickness must be larger than zero"); 47 | assert(min(core_tiles, side_tiles, corner_tiles) >= 0, "Can't make negative numbers of tiles"); 48 | assert(min(x_cells, y_cells, real_side_x_cells, real_side_y_cells, real_corner_x_cells, real_corner_y_cells) >= 1, 49 | "Not enough cells to actually make a tile") 50 | assert(real_side_x_cells <= x_cells, "Side tile X value larger than core tile X value"); 51 | assert(real_side_y_cells <= y_cells, "Side tile Y value larger than core tile Y value"); 52 | assert(real_corner_x_cells <= real_side_x_cells, "Corner tile X value larger than side tile X value"); 53 | assert(real_corner_y_cells <= real_side_y_cells, "Corner tile Y value larger than side tile Y value"); 54 | 55 | 56 | // All measurements are based on the Multiboard tile component remixing 57 | // files at https://than.gs/m/994681, uploaded 2024-01-19. 58 | 59 | // Main dimensions 60 | cell_size = 25+0; 61 | height = 6.4+0; 62 | default_fn = 32+0; 63 | 64 | // Single tile outer dimensions 65 | side_l = cell_size/(1+2*cos(45)); 66 | size_l_offset = (cell_size - side_l)/2; 67 | 68 | // Single tile hole dimensions 69 | 70 | // The "thick" part is the middle of the hole, where the sides are thicker 71 | // than the top and bottom. 72 | multihole_thick_height = 2.4+0; 73 | multihole_thick_size = 21.4+0; 74 | multihole_thin_size = 23.4+0; 75 | 76 | // The multihole is an octagon. We'll get OpenSCAD to generate that by 77 | // telling it to make a circle with a radius equal to the outer corners of 78 | // the hole, but with only eight sides. 79 | multihole_thick_side_l = multihole_thick_size/(1+2*cos(45)); 80 | multihole_thick_bound_circle_d = multihole_thick_side_l/sin(22.5); 81 | multihole_thin_side_l = multihole_thin_size/(1+2*cos(45)); 82 | multihole_thin_bound_circle_d = multihole_thin_side_l/sin(22.5); 83 | multihole_base_fn = 8+0; 84 | 85 | // The threads are formed using a spiral with a trapezoidal cross-section. 86 | // `d1` is the outer diameter of the spiral and `d2` is the inner 87 | // diameter. `h1` is the height of the outer wall and `h2` is the height 88 | // of the inner wall. `pitch` has its usual meaning: the distance from 89 | // one thread peak (or valley) to the next immediately above or below it. 90 | multihole_thread_d1 = 22.6+0; 91 | multihole_thread_d2 = multihole_thick_size+0; 92 | multihole_thread_h1 = 0.5+0; // Height of outer thread 93 | multihole_thread_h2 = 1.583+0; // Height of thread at inner cylinder 94 | multihole_thread_pitch = 2.5+0; 95 | 96 | peg_hole_thick_height = 2.9+0; 97 | peg_hole_thick_size = 6+0; 98 | peg_hole_thin_size = 7.5+0; 99 | 100 | peg_hole_thread_pitch = 3+0; 101 | peg_hole_thread_d1 = 7+0; 102 | peg_hole_thread_d2 = peg_hole_thick_size+0; 103 | peg_hole_thread_h1 = 0.77+0; 104 | peg_hole_thread_h2 = peg_hole_thread_pitch-0.5; 105 | 106 | // Distance between stacked layers. There should be at least one empty 107 | // layer between adjacent tiles. If the height of a single tile is not 108 | // evenly divisible by the layer thickness, we might need more space than 109 | // just the layer thickness to make sure that happens. 110 | layer_separation = abs(-height % layer_thickness) + layer_thickness; 111 | stack_height = height + layer_separation; 112 | 113 | 114 | // Here's the stack 115 | 116 | multiboard_tile_stack(core_tiles, x_cells, y_cells, right_peg_holes=true, top_peg_holes=true); 117 | 118 | translate([0, 0, stack_height * core_tiles]) 119 | multiboard_tile_stack(side_tiles, real_side_x_cells, real_side_y_cells, right_peg_holes=false, top_peg_holes=true); 120 | 121 | translate([0, 0, stack_height * (core_tiles + side_tiles)]) 122 | multiboard_tile_stack(corner_tiles, real_corner_x_cells, real_corner_y_cells, right_peg_holes=false, top_peg_holes=false); 123 | 124 | 125 | // Now, all the modules the stack uses 126 | 127 | module multiboard_tile_stack(tile_count, x_cells, y_cells, right_peg_holes, top_peg_holes, exceptions) { 128 | if (tile_count > 0) 129 | for (level = [0:tile_count-1]) 130 | translate([0, 0, stack_height * level]) 131 | multiboard_tile(x_cells, y_cells, right_peg_holes, top_peg_holes, exceptions); 132 | } 133 | 134 | 135 | module multiboard_tile(x_cells, y_cells, right_peg_holes, top_peg_holes, exceptions) { 136 | for (i=[0:x_cells-1]) 137 | for (j=[0:y_cells-1]) 138 | // A pretty good rule of thumb is that there should be a peg hole 139 | // between every two diagonally adjacent multiholes. 140 | let (render_this_cell = cell_at_coords(i, j, x_cells, y_cells, right_peg_holes, top_peg_holes, exceptions), 141 | has_diagonal_neighbor = cell_at_coords(i + 1, j + 1, x_cells, y_cells, right_peg_holes, top_peg_holes, exceptions), 142 | has_adjacent_neighbors = 143 | cell_at_coords(i + 1, j, x_cells, y_cells, right_peg_holes, top_peg_holes, exceptions) && 144 | cell_at_coords(i, j + 1, x_cells, y_cells, right_peg_holes, top_peg_holes, exceptions)) 145 | translate([i*cell_size, j*cell_size, 0]) 146 | if (render_this_cell) { 147 | multiboard_cell(with_peg_hole=has_diagonal_neighbor || has_adjacent_neighbors); 148 | } else if (has_adjacent_neighbors) { 149 | standalone_peg_hole(); 150 | } 151 | } 152 | 153 | 154 | module multiboard_cell(with_peg_hole) { 155 | $fn=default_fn; 156 | difference() { 157 | multiboard_cell_base(with_peg_hole); 158 | translate([cell_size/2, cell_size/2, 0]) 159 | multihole(); 160 | if (with_peg_hole) 161 | translate([cell_size, cell_size, 0]) 162 | peg_hole(); 163 | } 164 | } 165 | 166 | 167 | module multiboard_cell_base(with_peg_hole) { 168 | base_points = [ 169 | [cell_size - size_l_offset, cell_size], 170 | [size_l_offset, cell_size], 171 | [0, cell_size - size_l_offset], 172 | [0, size_l_offset], 173 | [size_l_offset, 0], 174 | [cell_size - size_l_offset, 0], 175 | [cell_size, size_l_offset], 176 | [cell_size, cell_size - size_l_offset], 177 | ]; 178 | points = with_peg_hole 179 | ? [ 180 | each base_points, 181 | [cell_size + size_l_offset, cell_size], 182 | [cell_size, cell_size + size_l_offset], 183 | ] 184 | : base_points; 185 | linear_extrude(height) 186 | polygon(points); 187 | } 188 | 189 | 190 | module standalone_peg_hole() { 191 | $fn=default_fn; 192 | points = [ 193 | [cell_size, cell_size - size_l_offset], 194 | [cell_size + size_l_offset, cell_size], 195 | [cell_size, cell_size + size_l_offset], 196 | [cell_size - size_l_offset, cell_size], 197 | ]; 198 | difference() { 199 | linear_extrude(height) 200 | polygon(points); 201 | translate([cell_size, cell_size, 0]) 202 | peg_hole(); 203 | } 204 | } 205 | 206 | 207 | module multihole() { 208 | multihole_base($fn=multihole_base_fn); 209 | // The rotation here isn't strictly necessary, but it makes the threads 210 | // line up with the Multiboard STEP files, which, in turn, makes 211 | // debugging easier. 212 | if (!$preview) 213 | rotate(-170, [0, 0, 1]) 214 | multihole_threads(); 215 | } 216 | 217 | 218 | module multihole_base() { 219 | // Rotation needed to align the hole sides with the cell's outer sides. 220 | rotate(22.5, [0, 0, 1]) 221 | tapered_hole_base( 222 | multihole_thin_bound_circle_d / 2, 223 | multihole_thick_bound_circle_d / 2, 224 | multihole_thick_height); 225 | } 226 | 227 | 228 | module multihole_threads() { 229 | translate([0, 0, -multihole_thread_h2/2]) 230 | trapz_thread(multihole_thread_d1, multihole_thread_d2, 231 | multihole_thread_h1, multihole_thread_h2, 232 | thread_len=height+multihole_thread_h2, 233 | pitch=multihole_thread_pitch); 234 | } 235 | 236 | 237 | module peg_hole() { 238 | // The rotation here isn't strictly necessary, but it makes the threads 239 | // line up with the Multiboard STEP files, which, in turn, makes 240 | // debugging easier. 241 | rotate(-129, [0, 0, 1]) { 242 | peg_hole_base(); 243 | if (!$preview) 244 | peg_hole_threads(); 245 | } 246 | } 247 | 248 | 249 | module peg_hole_base() { 250 | tapered_hole_base( 251 | peg_hole_thin_size / 2, 252 | peg_hole_thick_size / 2, 253 | peg_hole_thick_height); 254 | } 255 | 256 | 257 | module peg_hole_threads() { 258 | intersection() { 259 | translate([0, 0, stack_height/2]) 260 | cube([cell_size, cell_size, stack_height], center=true); 261 | translate([0, 0, -peg_hole_thread_h2/2]) 262 | trapz_thread(peg_hole_thread_d1, peg_hole_thread_d2, 263 | peg_hole_thread_h1, peg_hole_thread_h2, 264 | thread_len=height+peg_hole_thread_h2, 265 | pitch=peg_hole_thread_pitch); 266 | } 267 | } 268 | 269 | 270 | module tapered_hole_base(outer_offset, inner_offset, thick_height) { 271 | rotate_extrude() 272 | polygon([ 273 | [0, -layer_separation], 274 | [outer_offset, -layer_separation], 275 | [outer_offset, 0], 276 | [inner_offset, (height - thick_height)/2], 277 | [inner_offset, (height + thick_height)/2], 278 | [outer_offset, height], 279 | [outer_offset, stack_height], 280 | [0, stack_height], 281 | ]); 282 | } 283 | 284 | 285 | module trapz_thread(d1, d2, h1, h2, thread_len, pitch) { 286 | thread_profile = [ 287 | [d1/2, -h1/2], 288 | [d1/2, h1/2], 289 | [d2/2, h2/2], 290 | [d2/2, -h2/2], 291 | ]; 292 | points = spiral_points(thread_profile, thread_len, pitch); 293 | faces = [ 294 | [each [3:-1:0]], 295 | each spiral_paths(4, thread_len, pitch), 296 | [each [len(points)-4:len(points)-1]], 297 | ]; 298 | 299 | polyhedron(points=points, faces=faces); 300 | } 301 | 302 | 303 | function spiral_points(profile_points, spiral_len, spiral_loop_pitch) = 304 | [for (i=[0:round($fn*spiral_len/spiral_loop_pitch)]) 305 | each spiral_segment_points( 306 | profile_points, 307 | i * 360.0/$fn, 308 | i * spiral_loop_pitch/$fn) 309 | ]; 310 | 311 | 312 | function spiral_segment_points(profile_points, angle_offset, z_offset) = 313 | [for (p=profile_points) 314 | [ 315 | p[0] * cos(angle_offset), 316 | p[0] * sin(angle_offset), 317 | p[1] + z_offset, 318 | ] 319 | ]; 320 | 321 | 322 | function spiral_paths(profile_points_count, spiral_len, spiral_loop_pitch) = 323 | [for (i=[0:round($fn*spiral_len/spiral_loop_pitch)-1]) 324 | each spiral_segment_paths(profile_points_count, i) 325 | ]; 326 | 327 | 328 | function spiral_segment_paths(profile_points_count, segment_number) = 329 | [each [for(point=[0:profile_points_count-1]) 330 | [ 331 | segment_number*profile_points_count+limit_point_number(point+1, profile_points_count), 332 | segment_number*profile_points_count+limit_point_number(point+1, profile_points_count)+profile_points_count, 333 | segment_number*profile_points_count+limit_point_number(point, profile_points_count)+profile_points_count, 334 | segment_number*profile_points_count+limit_point_number(point, profile_points_count) 335 | ] 336 | ]]; 337 | 338 | 339 | function limit_point_number(point, profile_points_count) = 340 | point >= profile_points_count ? point - profile_points_count : point; 341 | 342 | 343 | function cell_at_coords(x, y, x_cells, y_cells, add_right_column, add_top_row, exceptions) = 344 | (0 <= x && x < (add_right_column ? x_cells + 1 : x_cells)) && 345 | (0 <= y && y < (add_top_row ? y_cells + 1 : y_cells)) && 346 | (!is_num(search([[x, y]], exceptions)[0])); 347 | -------------------------------------------------------------------------------- /single_cell.scad: -------------------------------------------------------------------------------- 1 | /* Generates a single tile cell, with multihole and peg hole. 2 | * 3 | * This is mostly for debugging. 4 | */ 5 | 6 | use 7 | 8 | // This should be the same alignment as the .STEP remixing files. 9 | translate([-12.5, -12.5, 0]) 10 | multiboard_cell(true); 11 | --------------------------------------------------------------------------------