Showing preview only (1,269K chars total). Download the full file or copy to clipboard to get everything.
Repository: sevmeyer/chrumm-keyboard
Branch: master
Commit: 0677d499e7c4
Files: 104
Total size: 1.2 MB
Directory structure:
gitextract_3f6rhsfw/
├── BUILD.md
├── CHANGELOG.md
├── LICENSE.txt
├── MATERIALS.md
├── README.md
├── body/
│ ├── .flake8
│ ├── .gitignore
│ ├── README.md
│ ├── chrumm/
│ │ ├── __init__.py
│ │ ├── __main__.py
│ │ ├── cfg.py
│ │ ├── geo/
│ │ │ ├── __init__.py
│ │ │ ├── circle.py
│ │ │ ├── edge.py
│ │ │ ├── epsilon.py
│ │ │ ├── face.py
│ │ │ ├── line.py
│ │ │ ├── matrix.py
│ │ │ ├── plane.py
│ │ │ ├── segment.py
│ │ │ ├── tests/
│ │ │ │ ├── README.md
│ │ │ │ ├── __init__.py
│ │ │ │ ├── helper.py
│ │ │ │ ├── test_circle.py
│ │ │ │ ├── test_edge.py
│ │ │ │ ├── test_face.py
│ │ │ │ ├── test_line.py
│ │ │ │ ├── test_matrix.py
│ │ │ │ ├── test_plane.py
│ │ │ │ ├── test_segment.py
│ │ │ │ ├── test_triangle.py
│ │ │ │ └── test_vector.py
│ │ │ ├── triangle.py
│ │ │ └── vector.py
│ │ ├── make.py
│ │ ├── part/
│ │ │ ├── __init__.py
│ │ │ ├── arc.py
│ │ │ ├── body.py
│ │ │ ├── boss.py
│ │ │ ├── bracket.py
│ │ │ ├── bumper.py
│ │ │ ├── cable.py
│ │ │ ├── encoder.py
│ │ │ ├── floor.py
│ │ │ ├── key.py
│ │ │ ├── knob.py
│ │ │ ├── layout.py
│ │ │ ├── palm.py
│ │ │ ├── plan.py
│ │ │ └── support.py
│ │ ├── pcb.py
│ │ └── stl.py
│ ├── chrumm.json
│ └── prusa/
│ ├── chrumm-body.ini
│ ├── chrumm-floor.ini
│ ├── chrumm-knob.ini
│ ├── chrumm-palm.ini
│ └── clean-3mf-seam.py
├── firmware/
│ ├── .gitignore
│ ├── CMakeLists.txt
│ ├── README.md
│ └── chrumm/
│ ├── config.h
│ ├── encoder.c
│ ├── encoder.h
│ ├── hid.c
│ ├── hid.h
│ ├── led.c
│ ├── led.h
│ ├── main.c
│ ├── matrix.c
│ ├── matrix.h
│ ├── usage.h
│ ├── usb.c
│ └── usb.h
└── pcb/
├── .gitignore
├── README.md
└── chrumm/
├── chrumm-plot.py
├── chrumm.kicad_dru
├── chrumm.kicad_pcb
├── chrumm.kicad_pro
├── chrumm.kicad_sch
├── chrumm.kicad_sym
├── chrumm.kicad_wks
├── footprints.pretty/
│ ├── Diode_1N4148_P7.6mm.kicad_mod
│ ├── Graphic_CHRUMM.kicad_mod
│ ├── Graphic_Hi.kicad_mod
│ ├── Graphic_OSHW.kicad_mod
│ ├── MountingHole_M3.kicad_mod
│ ├── MouseBites_1x3_P0.9mm.kicad_mod
│ ├── MouseBites_1x3_P1.35mm.kicad_mod
│ ├── MouseBites_1x4_P0.9mm.kicad_mod
│ ├── MouseBites_1x5_P0.9mm.kicad_mod
│ ├── MouseBites_1x5_P1.35mm.kicad_mod
│ ├── PinHeader_1x2_P2.54mm_Custom.kicad_mod
│ ├── PinHeader_1x3_P2.54mm.kicad_mod
│ ├── PinHeader_1x5_P2.54mm.kicad_mod
│ ├── RPi_Pico.kicad_mod
│ ├── RPi_Pico_Custom.kicad_mod
│ ├── RotaryEncoder_PEC11R_Custom.kicad_mod
│ ├── Switch_MX.kicad_mod
│ ├── Switch_MX_CTRL.kicad_mod
│ └── Switch_MX_RefPoints.kicad_mod
├── fp-lib-table
└── sym-lib-table
================================================
FILE CONTENTS
================================================
================================================
FILE: BUILD.md
================================================
Chrumm build advice
===================
Printing
--------
I printed the parts on a Prusa Mini, with PLA filament, on a
smooth PEI sheet. The gcode was generated with [PrusaSlicer] 2.6.0.
The 3MF files with the exact print settings and part configurations
are available on the [Releases] page.
The body halves are printed sideways, with custom supports.
In PrusaSlicer, custom supports can be added in the
[Object list] (Advanced mode). Right-click on the body object
and load the support STL with "Add Part". Right-click on the
support object to adjust its print settings.
[PrusaSlicer]: https://www.prusa3d.com/prusaslicer/
[Object list]: https://help.prusa3d.com/article/object-list_1758
[Releases]: https://github.com/sevmeyer/chrumm-keyboard/releases
Diodes
------
Note that there is not much room between the switch plate and
the PCB. Therefore, I soldered the diode legs on the same side
as the diode body, and cut the legs reasonably short on the
switch-facing side with a [flush cutter]. To hold up the PCB
while soldering the diodes, I used standoff jigs that clip
into the stem holes (see photos).
The diode pads have a common pitch of 7.62mm (300mil).
To bend the legs in a uniform way, I used a bender jig.
Both jigs are available on the [Releases] page.
[flush cutter]: https://en.wikipedia.org/wiki/Diagonal_pliers#Variations
Flexstrip
---------
The PCB halves are connected via Flexstrip jumpers. To compensate
for the default split angle, the strips should be folded in a
specific way before installation. Check the image for reference.
I wrapped the strips around a thin screwdriver shaft, to maintain
a minimum bend radius of about 2mm.

PCB installation
----------------
To install the PCB, I would first clip the corner switches
into the body, e.g. switches 12-64-58, 10-9-6, and 49-48-45.
Then align the PCB with the switch pins and push it flat
against the switch bottoms.
Before soldering any switches, check that the screw mount
next to the Pico does not bend the PCB. If the print is
misaligned, you might want to sand off a bit of the mount,
or extend its height with a washer.
When everything fits, the remaining switches can be inserted.
Note that the thumb switches are north-facing (upside down).
================================================
FILE: CHANGELOG.md
================================================
Development
-----------
body 1.0.2
- Add optional parameters for switches with clips on their side
* switch.clipNotch.isSideways (Left-right instead of front-back)
* support.relBasePosition (Sideways position relative to top)
* support.relBaseInset (From switch hole front to back)
* support.relTopInset (From switch hole front to back)
body 1.0.1
- Revise Face triangulation for better performance
- Remove obsolete draft.json
firmware 1.0.2
- Use PID 1209:5E7C as registered on pid.codes
firmware 1.0.1
- Add kBOOT keycode to trigger bootloader mode
- Remove cRWND and cFFWD from default layout
- Revise pin settle time and encoder keypress ticks
1.0 (2023-10-04)
----------------
- First release
================================================
FILE: LICENSE.txt
================================================
https://github.com/sevmeyer/chrumm-keyboard/
___ _ _ ____ _ _ __ __ __ __
.' __| |_| | _ '| | | | \/ | \/ |
| |__| _ | |_) | |_| | |\/| | |\/| |
'.___|_| |_|_| \_\.___.|_| |_|_| |_|
Copyright 2023 Severin Meyer
Licensed under CERN-OHL-W v2 or later
CERN Open Hardware Licence Version 2 - Weakly Reciprocal
Preamble
CERN has developed this licence to promote collaboration among
hardware designers and to provide a legal tool which supports the
freedom to use, study, modify, share and distribute hardware designs
and products based on those designs. Version 2 of the CERN Open
Hardware Licence comes in three variants: CERN-OHL-P (permissive); and
two reciprocal licences: this licence, CERN-OHL-W (weakly reciprocal)
and CERN-OHL-S (strongly reciprocal).
The CERN-OHL-W is copyright CERN 2020. Anyone is welcome to use it, in
unmodified form only.
Use of this Licence does not imply any endorsement by CERN of any
Licensor or their designs nor does it imply any involvement by CERN in
their development.
1 Definitions
1.1 'Licence' means this CERN-OHL-W.
1.2 'Compatible Licence' means
a) any earlier version of the CERN Open Hardware licence, or
b) any version of the CERN-OHL-S or the CERN-OHL-W, or
c) any licence which permits You to treat the Source to which
it applies as licensed under CERN-OHL-S or CERN-OHL-W
provided that on Conveyance of any such Source, or any
associated Product You treat the Source in question as being
licensed under CERN-OHL-S or CERN-OHL-W as appropriate.
1.3 'Source' means information such as design materials or digital
code which can be applied to Make or test a Product or to
prepare a Product for use, Conveyance or sale, regardless of its
medium or how it is expressed. It may include Notices.
1.4 'Covered Source' means Source that is explicitly made available
under this Licence.
1.5 'Product' means any device, component, work or physical object,
whether in finished or intermediate form, arising from the use,
application or processing of Covered Source.
1.6 'Make' means to create or configure something, whether by
manufacture, assembly, compiling, loading or applying Covered
Source or another Product or otherwise.
1.7 'Available Component' means any part, sub-assembly, library or
code which:
a) is licensed to You as Complete Source under a Compatible
Licence; or
b) is available, at the time a Product or the Source containing
it is first Conveyed, to You and any other prospective
licensees
i) with sufficient rights and information (including any
configuration and programming files and information
about its characteristics and interfaces) to enable it
either to be Made itself, or to be sourced and used to
Make the Product; or
ii) as part of the normal distribution of a tool used to
design or Make the Product.
1.8 'External Material' means anything (including Source) which:
a) is only combined with Covered Source in such a way that it
interfaces with the Covered Source using a documented
interface which is described in the Covered Source; and
b) is not a derivative of or contains Covered Source, or, if it
is, it is solely to the extent necessary to facilitate such
interfacing.
1.9 'Complete Source' means the set of all Source necessary to Make
a Product, in the preferred form for making modifications,
including necessary installation and interfacing information
both for the Product, and for any included Available Components.
If the format is proprietary, it must also be made available in
a format (if the proprietary tool can create it) which is
viewable with a tool available to potential licensees and
licensed under a licence approved by the Free Software
Foundation or the Open Source Initiative. Complete Source need
not include the Source of any Available Component, provided that
You include in the Complete Source sufficient information to
enable a recipient to Make or source and use the Available
Component to Make the Product.
1.10 'Source Location' means a location where a Licensor has placed
Covered Source, and which that Licensor reasonably believes will
remain easily accessible for at least three years for anyone to
obtain a digital copy.
1.11 'Notice' means copyright, acknowledgement and trademark notices,
Source Location references, modification notices (subsection
3.3(b)) and all notices that refer to this Licence and to the
disclaimer of warranties that are included in the Covered
Source.
1.12 'Licensee' or 'You' means any person exercising rights under
this Licence.
1.13 'Licensor' means a natural or legal person who creates or
modifies Covered Source. A person may be a Licensee and a
Licensor at the same time.
1.14 'Convey' means to communicate to the public or distribute.
2 Applicability
2.1 This Licence governs the use, copying, modification, Conveying
of Covered Source and Products, and the Making of Products. By
exercising any right granted under this Licence, You irrevocably
accept these terms and conditions.
2.2 This Licence is granted by the Licensor directly to You, and
shall apply worldwide and without limitation in time.
2.3 You shall not attempt to restrict by contract or otherwise the
rights granted under this Licence to other Licensees.
2.4 This Licence is not intended to restrict fair use, fair dealing,
or any other similar right.
3 Copying, Modifying and Conveying Covered Source
3.1 You may copy and Convey verbatim copies of Covered Source, in
any medium, provided You retain all Notices.
3.2 You may modify Covered Source, other than Notices, provided that
You irrevocably undertake to make that modified Covered Source
available from a Source Location should You Convey a Product in
circumstances where the recipient does not otherwise receive a
copy of the modified Covered Source. In each case subsection 3.3
shall apply.
You may only delete Notices if they are no longer applicable to
the corresponding Covered Source as modified by You and You may
add additional Notices applicable to Your modifications.
3.3 You may Convey modified Covered Source (with the effect that You
shall also become a Licensor) provided that You:
a) retain Notices as required in subsection 3.2;
b) add a Notice to the modified Covered Source stating that You
have modified it, with the date and brief description of how
You have modified it;
c) add a Source Location Notice for the modified Covered Source
if You Convey in circumstances where the recipient does not
otherwise receive a copy of the modified Covered Source; and
d) license the modified Covered Source under the terms and
conditions of this Licence (or, as set out in subsection
8.3, a later version, if permitted by the licence of the
original Covered Source). Such modified Covered Source must
be licensed as a whole, but excluding Available Components
contained in it or External Material to which it is
interfaced, which remain licensed under their own applicable
licences.
4 Making and Conveying Products
4.1 You may Make Products, and/or Convey them, provided that You
either provide each recipient with a copy of the Complete Source
or ensure that each recipient is notified of the Source Location
of the Complete Source. That Complete Source includes Covered
Source and You must accordingly satisfy Your obligations set out
in subsection 3.3. If specified in a Notice, the Product must
visibly and securely display the Source Location on it or its
packaging or documentation in the manner specified in that
Notice.
4.2 Where You Convey a Product which incorporates External Material,
the Complete Source for that Product which You are required to
provide under subsection 4.1 need not include any Source for the
External Material.
4.3 You may license Products under terms of Your choice, provided
that such terms do not restrict or attempt to restrict any
recipients' rights under this Licence to the Covered Source.
5 Research and Development
You may Convey Covered Source, modified Covered Source or Products to
a legal entity carrying out development, testing or quality assurance
work on Your behalf provided that the work is performed on terms which
prevent the entity from both using the Source or Products for its own
internal purposes and Conveying the Source or Products or any
modifications to them to any person other than You. Any modifications
made by the entity shall be deemed to be made by You pursuant to
subsection 3.2.
6 DISCLAIMER AND LIABILITY
6.1 DISCLAIMER OF WARRANTY -- The Covered Source and any Products
are provided 'as is' and any express or implied warranties,
including, but not limited to, implied warranties of
merchantability, of satisfactory quality, non-infringement of
third party rights, and fitness for a particular purpose or use
are disclaimed in respect of any Source or Product to the
maximum extent permitted by law. The Licensor makes no
representation that any Source or Product does not or will not
infringe any patent, copyright, trade secret or other
proprietary right. The entire risk as to the use, quality, and
performance of any Source or Product shall be with You and not
the Licensor. This disclaimer of warranty is an essential part
of this Licence and a condition for the grant of any rights
granted under this Licence.
6.2 EXCLUSION AND LIMITATION OF LIABILITY -- The Licensor shall, to
the maximum extent permitted by law, have no liability for
direct, indirect, special, incidental, consequential, exemplary,
punitive or other damages of any character including, without
limitation, procurement of substitute goods or services, loss of
use, data or profits, or business interruption, however caused
and on any theory of contract, warranty, tort (including
negligence), product liability or otherwise, arising in any way
in relation to the Covered Source, modified Covered Source
and/or the Making or Conveyance of a Product, even if advised of
the possibility of such damages, and You shall hold the
Licensor(s) free and harmless from any liability, costs,
damages, fees and expenses, including claims by third parties,
in relation to such use.
7 Patents
7.1 Subject to the terms and conditions of this Licence, each
Licensor hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable (except as
stated in subsections 7.2 and 8.4) patent licence to Make, have
Made, use, offer to sell, sell, import, and otherwise transfer
the Covered Source and Products, where such licence applies only
to those patent claims licensable by such Licensor that are
necessarily infringed by exercising rights under the Covered
Source as Conveyed by that Licensor.
7.2 If You institute patent litigation against any entity (including
a cross-claim or counterclaim in a lawsuit) alleging that the
Covered Source or a Product constitutes direct or contributory
patent infringement, or You seek any declaration that a patent
licensed to You under this Licence is invalid or unenforceable
then any rights granted to You under this Licence shall
terminate as of the date such process is initiated.
8 General
8.1 If any provisions of this Licence are or subsequently become
invalid or unenforceable for any reason, the remaining
provisions shall remain effective.
8.2 You shall not use any of the name (including acronyms and
abbreviations), image, or logo by which the Licensor or CERN is
known, except where needed to comply with section 3, or where
the use is otherwise allowed by law. Any such permitted use
shall be factual and shall not be made so as to suggest any kind
of endorsement or implication of involvement by the Licensor or
its personnel.
8.3 CERN may publish updated versions and variants of this Licence
which it considers to be in the spirit of this version, but may
differ in detail to address new problems or concerns. New
versions will be published with a unique version number and a
variant identifier specifying the variant. If the Licensor has
specified that a given variant applies to the Covered Source
without specifying a version, You may treat that Covered Source
as being released under any version of the CERN-OHL with that
variant. If no variant is specified, the Covered Source shall be
treated as being released under CERN-OHL-S. The Licensor may
also specify that the Covered Source is subject to a specific
version of the CERN-OHL or any later version in which case You
may apply this or any later version of CERN-OHL with the same
variant identifier published by CERN.
You may treat Covered Source licensed under CERN-OHL-W as
licensed under CERN-OHL-S if and only if all Available
Components referenced in the Covered Source comply with the
corresponding definition of Available Component for CERN-OHL-S.
8.4 This Licence shall terminate with immediate effect if You fail
to comply with any of its terms and conditions.
8.5 However, if You cease all breaches of this Licence, then Your
Licence from any Licensor is reinstated unless such Licensor has
terminated this Licence by giving You, while You remain in
breach, a notice specifying the breach and requiring You to cure
it within 30 days, and You have failed to come into compliance
in all material respects by the end of the 30 day period. Should
You repeat the breach after receipt of a cure notice and
subsequent reinstatement, this Licence will terminate
immediately and permanently. Section 6 shall continue to apply
after any termination.
8.6 This Licence shall not be enforceable except by a Licensor
acting as such, and third party beneficiary rights are
specifically excluded.
================================================
FILE: MATERIALS.md
================================================
Chrumm Bill Of Materials
========================
Mechanical
----------
- 12x Threaded insert, M3, 4mm hole diameter, max 5.7mm length
- 12x Countersunk screw, M3, 8mm total length, ISO 10642
- 7x Hex nut with nylon insert, M3, ISO 10511
- 2x Hex nut with nylon insert, M2
- 7x Socket head cap screw, M3, 8mm thread length, ISO 4762 (*)
- 2x Socket head cap screw, M2, 6mm thread length, ISO 4762
- 2x Ziptie, 2mm width, 1mm thickness
- 14x 3M Bumpon SJ5302, hemispherical, 8mm diameter, 2mm height
- 2x Artificial leather, ~190x130mm, max 1.2mm thickness
- Glue for artificial leather on printed filament
- Keycaps (in photo: Akko WOB Building Blocks, MDA profile)
(*) Some of the screws are difficult to reach and
require a ball-point driver, or a short-armed key.
Electronic
----------
- 2x PCB
- 1x Raspberry Pi Pico, SC0915, without pre-soldered headers
- 1x USB cable, A to micro-B with small head, shielded, max 4mm diameter
- 64x Diode, 1N4148, DO-35 through-hole format
- 64x MX switch
- 1x Bourns PEC11R-4215F-N0024 rotary encoder, M7 nut mount, 15mm flatted D-shaft
- 1x TE Flexstrip FSN-22A-8, 0.1" pitch, 2" length, 8 conductors (**)
- 1x TE Flexstrip FSN-22A-5, 0.1" pitch, 2" length, 5 conductors (**)
- 1x TE Flexstrip FSN-23A-3, 0.1" pitch, 3" length, 3 conductors (**)
(**) It might be cheaper to buy strips with
more conductors and cut them apart as needed.
3D-printed
----------
- 2x Body half (left, right)
- 2x Floor half (left, right)
- 2x Palm rest (left, right)
- 1x Rotary encoder knob
- 1x Diode bender jig, 7.62mm pitch (300mil)
- 12x Diode standoff jig
================================================
FILE: README.md
================================================
Chrumm keyboard
===============
Chrumm is an open-hardware ergonomic keyboard,
made of a 3D-printable body, a bendable PCB,
and custom firmware for the Raspberry Pi Pico.
This repository contains all relevant source files.
I share these files in the hope that they are useful, or
at least interesting to others. Keep in mind that this is
a free, do-it-yourself project. What you see is what you get.
Make sure to check the license.


Files
-----
- [Releases] - Download page for STL, 3MF, GBR, UF2 files
- [BUILD.md](BUILD.md) - Build advice
- [MATERIALS.md](MATERIALS.md) - Bill Of Materials
- [pcb/README.md](pcb/README.md) - PCB production details
- [body/README.md](body/README.md) - Body generator parameters
- [firmware/README.md](firmware/README.md) - Firmware overview and installation
[Releases]: https://github.com/sevmeyer/chrumm-keyboard/releases/
Features
--------
Chrumm features a column staggered layout with simple thumb clusters.
The right side has an additional column, to better approximate
the standard ANSI layout, and to provide dedicated arrow keys.
A central encoder allows for rotational input.
The body is a robust monoblock without visible screws. It has
integrated split, tent, and tilt angles, similar to commercial
ergonomic boards. The palm rests and the USB cable are firmly
attached, so that everything can be moved around without hassle.
The STL files are generated programmatically, with a pure
Python package that has no dependencies. They are optimized
for FFF 3D printing. Most parts are printed sideways, to
produce a smooth surface without the need of post-processing.
Custom supports minimize the print time and filament cost.
The body houses two reversible, bendable, interconnected PCBs.
They are powered by a Raspberry Pi Pico.
Layout
------

Credit
------
Chrumm would not exist without the shared knowledge of the
mechanical keyboard community.
I found inspiration on [Reddit], [KBD.news], [geekhack], and
learned a lot from the [PCB guides] by ai03 and Ruiqi Mao, the
[Keyboard posts] by Masterzen, and the [Matrix Help] by Dave Dribin.
The layout and body is influenced by projects like the [Ergodox],
[Dactyl], [Sofle], [Pteron], and everything from [Bastardkb].
I also used established open hardware repositories for reference,
including the [UHK60], [Skeletyl], [Sofle], [Corne], and [Torn].
[Reddit]: https://old.reddit.com/r/ErgoMechKeyboards+MechanicalKeyboards/
[KBD.news]: https://kbd.news/
[geekhack]: https://geekhack.org
[PCB guides]: https://wiki.ai03.com/books/pcb-design
[Keyboard posts]: https://www.masterzen.fr/tag/#mechanical-keyboards
[Matrix Help]: https://www.dribin.org/dave/keyboard/one_html/
[Ergodox]: https://www.ergodox.io/
[Dactyl]: https://github.com/adereth/dactyl-keyboard
[Sofle]: https://github.com/josefadamcik/SofleKeyboard
[Pteron]: https://github.com/FSund/pteron-keyboard
[Bastardkb]: https://bastardkb.com/
[UHK60]: https://github.com/UltimateHackingKeyboard/uhk60v1-electronics
[Skeletyl]: https://github.com/Bastardkb/Skeletyl-PCB-plate
[Corne]: https://github.com/foostan/crkbd
[Torn]: https://github.com/rtitmuss/torn
Gallery
-------



================================================
FILE: body/.flake8
================================================
[flake8]
max-line-length = 99
================================================
FILE: body/.gitignore
================================================
__pycache__/
*.py[cod]
*.3mf
*.stl
*.kicad_mod
================================================
FILE: body/README.md
================================================
Chrumm STL generator
====================
The STL files are generated with the `chrumm` package for Python 3.7+.
It has no dependencies and does not need to be compiled or installed.
Run it as a command-line tool from this directory:
python3 -m chrumm --help
To generate the default STL files:
python3 -m chrumm chrumm.json
Parameters
----------
The configuration parameters are provided via JSON files.
If a parameter appears multiple times, then its latest value
is used. Distances are given in millimeters, angles in degrees.
#### PCB compatibility
Unlike the body, the PCB is manually edited and not programmatic.
Changes to the body parameters may not be compatible with the PCB.
A flattened KiCad footprint (.kicad_mod) is generated to help with
the placement of the switches and screws on the PCB.
#### Parameter validation
The generator does not validate all of the parameters.
Most importantly, chamfers and switch notches are not
taken into account when placing the walls. Make sure
that the switch margin parameter provides enough room.
The generator should produce reasonable results for
split, tent, and tilt angles up to about 20 degrees.
Results may vary for more extreme angles.
#### Layout
The `layout.fingerStaggers` matrix represents the
offset of each key relative to its ortholinear position.
The matrix is a list of rows. Each row contains four
sublists, to represent the sections of the keyboard
(left pinky, left alnum, right alnum, right pinky).
Each section contains key offset coordinates.
A coordinate can be an empty list (key omitted), a single
number (y offset), or a list of two numbers (x and y offset).
Each section must have at least two key coordinates.
All rows must have the same column structure.
#### Omit items
Chamfers can be turned off by setting them to `0`.
The value of the following parameters can be set
to `false` in order to omit them from the output:
bracket
bumper
cable
encoder
floor.hexHoles
knob
palm
pcb
pcb.mount
support
switch.clipNotch
================================================
FILE: body/chrumm/__init__.py
================================================
r"""Chrumm keyboard STL generator
___ _ _ ____ _ _ __ __ __ __
.' __| |_| | _ '| | | | \/ | \/ |
| |__| _ | |_) | |_| | |\/| | |\/| |
'.___|_| |_|_| \_\.___.|_| |_|_| |_|
Copyright 2023 Severin Meyer
Licensed under CERN-OHL-W v2 or later
"""
__version__ = "1.0.2"
from .make import make
__all__ = ["make"]
================================================
FILE: body/chrumm/__main__.py
================================================
"""
Generate Chrumm keyboard STL files, based on JSON configuration files.
If a configuration parameter appears multiple times, then its latest
value is used. STL files are written to the current working directory.
Usage:
chrumm [--help] [--version] [--log LEVEL] [--threads N] [--knob] JSON...
Options:
-h, --help Print this help and exit
--version Print program version and exit
--log LEVEL Either DEBUG, INFO, WARNING, or ERROR (default: INFO)
--threads N Number of threads to use (default: 8)
--knob Generate the rotary encoder knob only
"""
import getopt
import json
import logging
import pathlib
import sys
import time
import traceback
import chrumm
logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.INFO)
log = logging.getLogger()
def main():
try:
threads = 8
isKnob = False
options, jsonFiles = getopt.getopt(
sys.argv[1:], "h", "help version log= threads= knob".split())
for name, arg in options:
if name == "-h" or name == "--help":
print(__doc__)
sys.exit(0)
elif name == "--version":
print(chrumm.__version__)
sys.exit(0)
elif name == "--log":
logging.getLogger().setLevel(arg)
elif name == "--threads":
threads = int(arg)
elif name == "--knob":
isKnob = True
if not jsonFiles:
raise getopt.GetoptError("Missing JSON argument.")
jsonPaths = [pathlib.Path(f) for f in jsonFiles]
jsonStrings = [p.read_text() for p in jsonPaths]
jsonStem = jsonPaths[-1].stem
log.info(r" ___ _ _ ____ _ _ __ __ __ __ ")
log.info(r".' __| |_| | _ '| | | | \/ | \/ |")
log.info(r"| |__| _ | |_) | |_| | |\/| | |\/| |")
log.info(r"'.___|_| |_|_| \_\.___.|_| |_|_| |_|")
log.info("")
log.info("This is chrumm %s", chrumm.__version__)
seconds = time.perf_counter()
files = chrumm.make(jsonStrings, threads, isKnob)
for name, data in files.items():
path = pathlib.Path(f"{jsonStem}-{name}")
log.info('Writing "%s"...', path)
if isinstance(data, str):
path.write_text(data)
else:
path.write_bytes(data)
seconds = time.perf_counter() - seconds
log.info("Done after %.3f seconds.", seconds)
except json.decoder.JSONDecodeError as e:
log.error("Could not parse JSON: %s", e)
log.debug(traceback.format_exc().strip())
sys.exit(1)
except ZeroDivisionError:
log.error(
"Encountered a division by zero.\n"
" This can be caused by overlapping points or malformed geometry.\n"
" Make sure to use sensible parameters, especially for margins and chamfers.")
log.debug(traceback.format_exc().strip())
sys.exit(1)
except Exception as e:
log.error(e)
log.debug(traceback.format_exc().strip())
sys.exit(1)
if __name__ == "__main__":
main()
================================================
FILE: body/chrumm/cfg.py
================================================
def _init(jsonStrings):
"""Make JSON values available as native module attributes."""
# Imports are done in local scope because
# all public names in globals() get deleted.
import json
import math
import types
def mergeDicts(source, target):
for key, value in source.items():
if key.isidentifier() and not key.startswith("_"):
if isinstance(value, dict):
obj = target.setdefault(key, types.SimpleNamespace())
mergeDicts(value, obj.__dict__)
else:
if key.lower().endswith("angle"):
value = math.radians(value)
target[key] = value
# Delete old attributes from globals()
for key in list(globals()):
if not key.startswith("_"):
del globals()[key]
# Add new attributes to globals()
for string in jsonStrings:
mergeDicts(json.loads(string), globals())
================================================
FILE: body/chrumm/geo/__init__.py
================================================
from .circle import Circle
from .edge import Edge
from .face import Face
from .line import Line
from .matrix import Matrix
from .plane import Plane
from .segment import Segment
from .triangle import Triangle
from .vector import Vector
__all__ = [
"Circle",
"Edge",
"Face",
"Line",
"Matrix",
"Plane",
"Segment",
"Triangle",
"Vector"]
================================================
FILE: body/chrumm/geo/circle.py
================================================
from .epsilon import isZero
from .line import Line
class Circle:
__slots__ = "center", "radius"
def __init__(self, center, radius):
self.center = center
self.radius = radius
def intersect2D(self, other):
"""Return a list of 0, 1, or 2 vectors"""
if isinstance(other, Circle):
return self._intersectCircle2D(other)
if isinstance(other, Line):
return self._intersectLine2D(other)
raise NotImplementedError()
def _intersectLine2D(self, line):
# Intersection of a Line and a Sphere (or circle) - Paul Bourke
# http://paulbourke.net/geometry/circlesphere/
center = self.center.xy
linePos = line.pos.xy
lineDir = line.dir.normalized2D()
b = 2*lineDir.dot(linePos - center)
c = (center.magSquared2D()
+ linePos.magSquared2D()
- 2*center.dot(linePos)
- self.radius**2)
exp = b**2 - 4*c
if isZero(exp):
return [linePos + lineDir*(-b/2)]
if exp < 0:
return []
uNeg = (-b - exp**0.5) / 2
uPos = (-b + exp**0.5) / 2
return [linePos + lineDir*uNeg, linePos + lineDir*uPos]
def _intersectCircle2D(self, other):
# Intersection of two circles - Paul Bourke
# http://paulbourke.net/geometry/circlesphere/
a = self.center.xy
b = other.center.xy
pitch = (b - a).magnitude2D()
isSeparate = pitch > self.radius + other.radius
isInside = pitch < abs(self.radius - other.radius)
if isZero(pitch) or isSeparate or isInside:
return []
midDir = (b - a).normalized()
midDist = (self.radius**2 - other.radius**2 + pitch**2) / (2*pitch)
midPos = a + midDir*midDist
chordHalf = (self.radius**2 - midDist**2)**0.5
if isZero(chordHalf):
return [midPos]
chordOffset = midDir.ortho2D() * chordHalf
return [midPos + chordOffset, midPos - chordOffset]
================================================
FILE: body/chrumm/geo/edge.py
================================================
import collections
import math
from .segment import Segment
from .triangle import Triangle
from .vector import Vector
class Edge(collections.UserList):
"""A flat list of Vectors with additional convenience functions."""
def __init__(self, *args):
super().__init__()
self.add(*args)
@staticmethod
def fromConvexHull2D(vectors):
# Another efficient algorithm for convex hulls in two dimensions - A. M. Andrew
# https://wikibooks.org/wiki/Algorithm_Implementation/Geometry/Convex_hull/Monotone_chain
vectors = sorted(v.xy for v in vectors)
lower = []
upper = []
for v in vectors:
while len(lower) >= 2 and (lower[-2] - v).cross(lower[-1] - v).z <= 0:
lower.pop()
lower.append(v)
for v in reversed(vectors):
while len(upper) >= 2 and (upper[-2] - v).cross(upper[-1] - v).z <= 0:
upper.pop()
upper.append(v)
return Edge(lower[:-1], upper[:-1])
def toSegments(self, isClosed=False):
vecCount = len(self.data)
segCount = vecCount - 1 + bool(isClosed)
return [Segment(self.data[i], self.data[(i+1) % vecCount]) for i in range(segCount)]
def add(self, *args):
for arg in args:
if isinstance(arg, Vector):
self.data.append(arg)
else:
self.add(*arg)
@property
def xy(self):
return Edge(v.xy for v in self.data)
@property
def xz(self):
return Edge(v.xz for v in self.data)
@property
def yz(self):
return Edge(v.yz for v in self.data)
def mirroredX(self):
return Edge(v.mirroredX() for v in self.data)
def mirroredY(self):
return Edge(v.mirroredY() for v in self.data)
def mirroredZ(self):
return Edge(v.mirroredZ() for v in self.data)
def reversed(self):
return Edge(reversed(self.data))
def scaled(self, scalar, center=Vector()):
return Edge((v - center)*scalar + center for v in self.data)
def translated(self, vector):
return Edge(v + vector for v in self.data)
def transformed(self, matrix):
return Edge(v.transformed(matrix) for v in self.data)
def snapped(self):
return Edge(p.snapped() for p in self.data)
def collapsed(self, threshold=1e-3):
"""Remove segments that are shorter than the threshold."""
return Edge(s.a for s in self.toSegments(True) if s.magnitude() >= threshold)
def meshPairwise(self, other, isClosed=False):
"""Triangulate each pair of edge segments in order.
Edges may overlap. If one edge has more segments
than the other, its remaining segments are
connected to the last point of the shorter edge.
"""
triangles = []
selfLen = len(self.data)
otherLen = len(other.data)
selfEnd = selfLen - 1 + int(isClosed)
otherEnd = otherLen - 1 + int(isClosed)
for i in range(max(otherEnd, selfEnd)):
a = self.data[min(selfEnd, i) % selfLen]
b = self.data[min(selfEnd, i+1) % selfLen]
c = other.data[min(otherEnd, i+1) % otherLen]
d = other.data[min(otherEnd, i) % otherLen]
# There are two possible pairs of triangles:
# --d----c-> --d----c-> other
# |1 / | | \ 3|
# | / 0| |2 \ |
# --a----b-> --a----b-> self
abc = Triangle(a, b, c)
cda = Triangle(c, d, a)
abd = Triangle(a, b, d)
dbc = Triangle(d, b, c)
# Lookup table to determine which triangles
# to use, based on which are valid
valid = bool(dbc)*8 + bool(abd)*4 + bool(cda)*2 + bool(abc)
table = (
0b0000, 0b0000, 0b0000, 0b0001,
0b0000, 0b0001, 0b0010, 0b0011,
0b0000, 0b0001, 0b0010, 0b0011,
0b0100, 0b1100, 0b1100, 0b0011)
bits = table[valid]
if valid == 0b1111:
# https://en.wikipedia.org/wiki/Delaunay_triangulation
abcAngle = (a - b).angleBetween(c - b)
cdaAngle = (c - d).angleBetween(a - d)
# The epsilon is not necessary, but it prevents
# irregular quad diagonals due to rounding errors.
if abcAngle + cdaAngle > math.pi + 1e-6:
bits = 0b1100
if bits & 0b0001:
triangles.append(abc)
if bits & 0b0010:
triangles.append(cda)
if bits & 0b0100:
triangles.append(abd)
if bits & 0b1000:
triangles.append(dbc)
return triangles
def meshParallel(self, other, isClosed=False):
"""Triangulate reasonably parallel, non-intersecting edges
Minimize the normal deviation between subsequent triangles.
In the case of multiple candidates, prioritize equilaterality.
"""
triangles = []
selfLen = len(self.data)
otherLen = len(other.data)
selfEnd = selfLen - 1 + int(isClosed)
otherEnd = otherLen - 1 + int(isClosed)
i = 0
j = 0
while i < selfEnd or j < otherEnd:
a = self.data[min(selfEnd, i) % selfLen]
b = self.data[min(selfEnd, i+1) % selfLen]
c = other.data[min(otherEnd, j+1) % otherLen]
d = other.data[min(otherEnd, j) % otherLen]
abd = Triangle(a, b, d)
acd = Triangle(a, c, d)
if i >= selfEnd:
triangles.append(acd)
j += 1
continue
if j >= otherEnd:
triangles.append(abd)
i += 1
continue
# Choose the triangle with the smaller normal deviation,
# if the difference is significant enough.
if triangles:
prevNorm = triangles[-1].normal()
abdDev = abd.normal().angleBetween(prevNorm)
acdDev = acd.normal().angleBetween(prevNorm)
if abdDev < acdDev - math.tau/16:
triangles.append(abd)
i += 1
continue
if acdDev < abdDev - math.tau/16:
triangles.append(acd)
j += 1
continue
# Otherwise, choose the most equilateral triangle,
# based on the circumcircle (Delaunay).
if abd.circumradius() < acd.circumradius() + 1e-6:
triangles.append(abd)
i += 1
else:
triangles.append(acd)
j += 1
return triangles
def contains2D(self, vector):
"""Check if the vector is inside the simple closed edge.
Vectors on the exact edge may or may not be considered inside.
"""
# Point Inclusion in Polygon Test - W. Randolph Franklin
# https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html
isIn = False
for i in range(len(self.data)):
a = self.data[i-1]
b = self.data[i]
if (a.y > vector.y) != (b.y > vector.y):
if vector.x < (b.x - a.x) * (vector.y - a.y) / (b.y - a.y) + a.x:
isIn = not isIn
return isIn
def distance2D(self, vector):
"""Return the minimum distance to the simple closed edge."""
if self.contains2D(vector):
return 0
return min(s.distance2D(vector) for s in self.toSegments(True))
================================================
FILE: body/chrumm/geo/epsilon.py
================================================
"""Provide float comparisons with an epsilon threshold."""
# Comparing floats is notoriously cumbersome. To keep it
# simple, this project uses an absolute epsilon of 1e-6.
#
# Considerations:
# - The base unit is 1mm.
# - The maximum expected workspace scale is 1m (1e+3).
# - The minimum expected parameter scale is 1um (1e-3).
# - STL stores 32bit floats with a machine epsilon of ~1e-7.
# Points that are considered separate during construction
# should not collapse to identical coordinates in STL.
# - Geometric comparisons should be consistent across the
# workspace. Whether two points are considered separate
# should not depend on their proximity to the origin.
# Therefore, relative tolerances are problematic:
# math.isclose(100.0000001, 100.0000002) -> False
# math.isclose(101.0000001, 101.0000002) -> True
#
# References:
# https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/
# https://peps.python.org/pep-0485/
# https://numpy.org/doc/stable/reference/generated/numpy.isclose.html
def isZero(n):
return abs(n) < 1e-6
================================================
FILE: body/chrumm/geo/face.py
================================================
import math
from .matrix import Matrix
from .triangle import Triangle
from .vector import Vector
class Face:
"""Coplanar 3D polygon with deferred triangulation."""
def __init__(self, edge, holes=[]):
"""Store polygon data for deferred triangulation.
Requirements:
No duplicate points
No intersections
No nested holes
Opposite point order for edge and holes
Reasonably coplanar points
Args:
edge (list[Vector])
holes (list[list[Vector]])
"""
self.edge = edge
self.holes = holes
def triangulate(self):
"""Triangulate the stored polygon.
Returns:
list[Triangle]
"""
realPoints = list(self.edge)
polyIndexes = list(range(len(realPoints)))
holeIndexes = []
for hole in self.holes:
if hole:
holeStart = len(realPoints)
holeEnd = holeStart + len(hole)
holeIndexes.append(list(range(holeStart, holeEnd)))
realPoints.extend(hole)
surfaceNormal = Vector.fromSurfaceNormal(self.edge)
uprightMatrix = Matrix.fromAlignment(surfaceNormal, Vector(0, 0, 1))
uprightPoints = [p.transformed(uprightMatrix).xy for p in realPoints]
Face._mergeHoles(uprightPoints, polyIndexes, holeIndexes)
triangles = Face._cutEars(uprightPoints, polyIndexes)
Face._flipTriangles(uprightPoints, triangles)
return [Triangle(
realPoints[i],
realPoints[j],
realPoints[k]) for i, j, k in triangles]
@staticmethod
def _mergeHoles(points, polyIndexes, holeIndexes):
# Triangulation by Ear Clipping - David Eberly
# https://www.geometrictools.com/Documentation/TriangulationByEarClipping.pdf
# Reorder each hole to start at the rightmost point
orderedHoles = []
for hole in holeIndexes:
start = max(range(len(hole)), key=lambda i: points[hole[i]])
orderedHoles.append(hole[start:] + hole[:start])
# Sort holes from right to left
orderedHoles.sort(key=lambda h: points[h[0]], reverse=True)
# Connect each hole to a visible point on the right
for hole in orderedHoles:
vis = None
# Determine rightward search triangle abc
# .c
# _____ .'/
# | .' /
# hole |.' /
# _____a---b--> ray
# /
# polygon
a = points[hole[0]] # Rightward ray origin
b = Vector(math.inf) # Ray intersection with polygon
c = Vector(math.inf) # Rightmost end of intersected segment
for i in range(len(polyIndexes)):
j = (i + 1) % len(polyIndexes)
p = points[polyIndexes[i]] # Polygon segment start
q = points[polyIndexes[j]] # Polygon segment end
if p.y == a.y == q.y:
if a.x < p.x and a.x < q.x:
if p.x < q.x and p.x < b.x:
b, c, vis = p, p, i
elif q.x < b.x:
b, c, vis = q, q, j
elif p.y <= a.y <= q.y:
x = p.x - (p.y - a.y)*(q.x - p.x)/(q.y - p.y)
if a.x < x < b.x:
b = Vector(x, a.y)
if p.x > q.x:
c, vis = p, i
else:
c, vis = q, j
# Check for better point inside search triangle
if b != c:
# Ensure triangle is counterclockwise
if c.y < b.y:
b, c = c, b
aDir = (b - a).normalized2D()
bDir = (c - b).normalized2D()
cDir = (a - c).normalized2D()
minDist = math.inf
for i in range(len(polyIndexes)):
p = points[polyIndexes[i]]
isInside = (
aDir.x*(a.y - p.y) - aDir.y*(a.x - p.x) < -1e-6 and
bDir.x*(b.y - p.y) - bDir.y*(b.x - p.x) < -1e-6 and
cDir.x*(c.y - p.y) - cDir.y*(c.x - p.x) < -1e-6)
if isInside:
o = points[polyIndexes[(i - 1) % len(polyIndexes)]]
q = points[polyIndexes[(i + 1) % len(polyIndexes)]]
isReflex = (p.x - o.x)*(q.y - p.y) - (q.x - p.x)*(p.y - o.y) < 0
if isReflex:
dist = (p.x - a.x)*(p.x - a.x) + (p.y - a.y)*(p.y - a.y)
if dist < minDist:
minDist = dist
vis = i
# Merge hole (vis -> hole -> hole[0] -> vis)
polyIndexes.insert(vis, polyIndexes[vis])
polyIndexes.insert(vis+1, hole[0])
polyIndexes[vis+1:vis+1] = hole
@staticmethod
def _cutEars(points, polyIndexes):
remaining = list(polyIndexes)
cache = [None] * len(remaining)
ears = []
for _ in range(len(remaining) - 2):
for i in range(len(remaining)):
# Cache reusable calculations
if cache[i] is None:
ear = [
remaining[i - 1],
remaining[i],
remaining[(i + 1) % len(remaining)]]
a = points[ear[0]]
b = points[ear[1]]
c = points[ear[2]]
aDir = (b - a).normalized2D()
bDir = (c - b).normalized2D()
cDir = (a - c).normalized2D()
aDot = aDir.y*a.x - aDir.x*a.y + 1e-6
bDot = bDir.y*b.x - bDir.x*b.y + 1e-6
cDot = cDir.y*c.x - cDir.x*c.y + 1e-6
earHeight = cDir.x*b.y - cDir.y*b.x + cDot
cache[i] = ear, aDir, bDir, cDir, aDot, bDot, cDot, earHeight
else:
ear, aDir, bDir, cDir, aDot, bDot, cDot, earHeight = cache[i]
if earHeight < 0:
continue
# Check if any point is inside ear
isInside = False
for j in remaining:
if j in ear:
continue
p = points[j]
isInside = (
cDir.y*p.x - cDir.x*p.y < cDot and
bDir.y*p.x - bDir.x*p.y < bDot and
aDir.y*p.x - aDir.x*p.y < aDot)
if isInside:
break
# Cut empty ear
if not isInside:
ears.append(ear)
del remaining[i]
del cache[i]
cache[i - 1] = None
cache[i % len(cache)] = None
break
return ears
@staticmethod
def _flipTriangles(points, triangles):
# Map counterclockwise edges to triangles for a fast lookup
lookup = {(t[i-1], t[i]): t for t in triangles for i in (0, 1, 2)}
remaining = set(lookup.keys())
while remaining:
a, b = remaining.pop()
# c<---- b
# \ 0 // \
# \ // 1 \
# a ---->d
try:
tri0 = lookup[(a, b)]
tri1 = lookup[(b, a)]
except KeyError:
continue
c = tri0[tri0.index(a) - 1]
d = tri1[tri1.index(b) - 1]
da = points[a] - points[d]
db = points[b] - points[d]
dc = points[c] - points[d]
# https://en.wikipedia.org/wiki/Delaunay_triangulation
isDelaunay = (
(da.x*da.x + da.y*da.y) * (db.x*dc.y-dc.x*db.y) -
(db.x*db.x + db.y*db.y) * (da.x*dc.y-dc.x*da.y) +
(dc.x*dc.x + dc.y*dc.y) * (da.x*db.y-db.x*da.y)) < 1e-6
if isDelaunay:
continue
# Flip triangles in-place
tri0[tri0.index(b)] = d
tri1[tri1.index(a)] = c
# Remap edges
del lookup[(a, b)]
del lookup[(b, a)]
lookup[(d, c)] = tri0
lookup[(c, a)] = tri0
lookup[(a, d)] = tri0
lookup[(c, d)] = tri1
lookup[(d, b)] = tri1
lookup[(b, c)] = tri1
# Revisit neighboring edges
remaining.add((c, a))
remaining.add((a, d))
remaining.add((d, b))
remaining.add((b, c))
================================================
FILE: body/chrumm/geo/line.py
================================================
from .epsilon import isZero
class Line:
__slots__ = "pos", "dir"
def __init__(self, pos, direction):
self.pos = pos
self.dir = direction.normalized()
def translated(self, vector):
return Line(self.pos + vector, self.dir)
def transformed(self, matrix):
return Line(
self.pos.transformed(matrix),
self.dir.transformedNormal(matrix))
def distance(self, vector):
"""Return the absolute distance to the line."""
# Distance from point to line 3d formula - Rabbid76
# https://stackoverflow.com/a/52792014
closest = self.pos + self.dir * self.dir.dot(vector - self.pos)
return (closest - vector).magnitude()
def distance2D(self, vector):
"""Return the signed distance to the line.
The distance is positive on the clockwise
side of the line and negative on the other.
"""
# https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
dir2D = self.dir.normalized2D()
return dir2D.x*(self.pos.y - vector.y) - dir2D.y*(self.pos.x - vector.x)
def intersect(self, other):
# The shortest line between two lines in 3D - Paul Bourke
# http://paulbourke.net/geometry/pointlineplane/
delta = (self.pos - other.pos)
do = delta.dot(other.dir)
ds = delta.dot(self.dir)
os = other.dir.dot(self.dir)
oo = other.dir.dot(other.dir)
ss = self.dir.dot(self.dir)
numer = do * os - ds * oo
denom = ss * oo - os * os
if isZero(denom):
raise ZeroDivisionError("Cannot find intersection of parallel lines.")
muA = numer / denom
muB = (do + muA*os) / oo
a = self.pos + self.dir*muA
b = other.pos + other.dir*muB
return (a + b) / 2
================================================
FILE: body/chrumm/geo/matrix.py
================================================
import math
class Matrix:
__slots__ = "data"
def __init__(self, data=(
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0)):
self.data = data
@staticmethod
def fromAlignment(sourceDir, targetDir):
"""Return a rotation matrix that aligns the source to the target vector."""
# Calculate Rotation Matrix to align Vector A to Vector B - Jur van den Berg
# https://math.stackexchange.com/a/476311
sourceDir = sourceDir.normalized()
targetDir = targetDir.normalized()
if sourceDir.isClose(targetDir):
return Matrix()
if sourceDir.isClose(-targetDir):
return Matrix().mirroredX()
c = sourceDir.cross(targetDir)
d = sourceDir.dot(targetDir)
skew = Matrix((
0.0, c.z, -c.y, 0.0,
-c.z, 0.0, c.x, 0.0,
c.y, -c.x, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0))
return Matrix() + skew + skew*skew*(1 / (1 + d))
def __add__(self, other):
return Matrix(tuple(a + b for a, b in zip(self.data, other.data)))
def __sub__(self, other):
return Matrix(tuple(a - b for a, b in zip(self.data, other.data)))
def __mul__(self, other):
if not isinstance(other, Matrix):
return Matrix(tuple(a * other for a in self.data))
# https://en.wikipedia.org/wiki/Matrix_multiplication
s = self.data
o = other.data
return Matrix((
s[0]*o[0] + s[1]*o[4] + s[2]*o[8] + s[3]*o[12],
s[0]*o[1] + s[1]*o[5] + s[2]*o[9] + s[3]*o[13],
s[0]*o[2] + s[1]*o[6] + s[2]*o[10] + s[3]*o[14],
s[0]*o[3] + s[1]*o[7] + s[2]*o[11] + s[3]*o[15],
s[4]*o[0] + s[5]*o[4] + s[6]*o[8] + s[7]*o[12],
s[4]*o[1] + s[5]*o[5] + s[6]*o[9] + s[7]*o[13],
s[4]*o[2] + s[5]*o[6] + s[6]*o[10] + s[7]*o[14],
s[4]*o[3] + s[5]*o[7] + s[6]*o[11] + s[7]*o[15],
s[8]*o[0] + s[9]*o[4] + s[10]*o[8] + s[11]*o[12],
s[8]*o[1] + s[9]*o[5] + s[10]*o[9] + s[11]*o[13],
s[8]*o[2] + s[9]*o[6] + s[10]*o[10] + s[11]*o[14],
s[8]*o[3] + s[9]*o[7] + s[10]*o[11] + s[11]*o[15],
s[12]*o[0] + s[13]*o[4] + s[14]*o[8] + s[15]*o[12],
s[12]*o[1] + s[13]*o[5] + s[14]*o[9] + s[15]*o[13],
s[12]*o[2] + s[13]*o[6] + s[14]*o[10] + s[15]*o[14],
s[12]*o[3] + s[13]*o[7] + s[14]*o[11] + s[15]*o[15]))
def mirroredX(self):
return self * Matrix((
-1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0))
def mirroredY(self):
return self * Matrix((
1.0, 0.0, 0.0, 0.0,
0.0, -1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0))
def mirroredZ(self):
return self * Matrix((
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, -1.0, 0.0,
0.0, 0.0, 0.0, 1.0))
def rotatedX(self, angle, center=None):
# https://en.wikipedia.org/wiki/Rotation_matrix
cos = math.cos(angle)
sin = math.sin(angle)
rotation = Matrix((
1.0, 0.0, 0.0, 0.0,
0.0, cos, sin, 0.0,
0.0, -sin, cos, 0.0,
0.0, 0.0, 0.0, 1.0))
if center is None:
return self * rotation
return self.translated(-center).__mul__(rotation).translated(center)
def rotatedY(self, angle, center=None):
# https://en.wikipedia.org/wiki/Rotation_matrix
cos = math.cos(angle)
sin = math.sin(angle)
rotation = Matrix((
cos, 0.0, -sin, 0.0,
0.0, 1.0, 0.0, 0.0,
sin, 0.0, cos, 0.0,
0.0, 0.0, 0.0, 1.0))
if center is None:
return self * rotation
return self.translated(-center).__mul__(rotation).translated(center)
def rotatedZ(self, angle, center=None):
# https://en.wikipedia.org/wiki/Rotation_matrix
cos = math.cos(angle)
sin = math.sin(angle)
rotation = Matrix((
cos, sin, 0.0, 0.0,
-sin, cos, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0))
if center is None:
return self * rotation
return self.translated(-center).__mul__(rotation).translated(center)
def translated(self, vector):
return self * Matrix((
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
vector.x, vector.y, vector.z, 1.0))
================================================
FILE: body/chrumm/geo/plane.py
================================================
from .epsilon import isZero
from .line import Line
from .vector import Vector
class Plane:
__slots__ = "pos", "normal"
def __init__(self, pos, normal):
self.pos = pos
self.normal = normal.normalized()
@staticmethod
def fromX(x):
return Plane(Vector(x, 0, 0), Vector(1, 0, 0))
@staticmethod
def fromY(y):
return Plane(Vector(0, y, 0), Vector(0, 1, 0))
@staticmethod
def fromZ(z):
return Plane(Vector(0, 0, z), Vector(0, 0, 1))
@staticmethod
def fromPoints(a, b, c):
return Plane(b, (b - a).cross(c - a))
@staticmethod
def fromLine2D(line):
"""Return vertical plane along the line."""
return Plane(line.pos.xy, line.dir.ortho2D())
def translated(self, vector):
return Plane(self.pos + vector, self.normal)
def transformed(self, matrix):
return Plane(
self.pos.transformed(matrix),
self.normal.transformedNormal(matrix))
def distance(self, vector):
"""Return signed distance in the direction of the normal."""
return (vector - self.pos).dot(self.normal)
def projectNormal(self, vector):
return self._intersectLine(Line(vector, self.normal))
def projectX(self, vector):
if isZero(self.normal.x):
raise ZeroDivisionError("Plane is parallel to the x axis.")
xDist = self.normal.dot(self.pos - vector) / self.normal.x
return Vector(vector.x + xDist, vector.y, vector.z)
def projectY(self, vector):
if isZero(self.normal.y):
raise ZeroDivisionError("Plane is parallel to the y axis.")
yDist = self.normal.dot(self.pos - vector) / self.normal.y
return Vector(vector.x, vector.y + yDist, vector.z)
def projectZ(self, vector):
if isZero(self.normal.z):
raise ZeroDivisionError("Plane is parallel to the z axis.")
zDist = self.normal.dot(self.pos - vector) / self.normal.z
return Vector(vector.x, vector.y, vector.z + zDist)
def intersect(self, other1, other2=None):
if isinstance(other1, Plane) and isinstance(other2, Plane):
return self._intersectPlanes(other1, other2)
if isinstance(other1, Line):
return self._intersectLine(other1)
raise NotImplementedError()
def _intersectPlanes(self, other1, other2):
# Intersection of three planes - Paul Bourke
# http://paulbourke.net/geometry/pointlineplane/
dot1 = other1.normal.dot(other1.pos)
dot2 = other2.normal.dot(other2.pos)
dot3 = self.normal.dot(self.pos)
cross23 = other2.normal.cross(self.normal)
cross31 = self.normal.cross(other1.normal)
cross12 = other1.normal.cross(other2.normal)
numer = cross23*dot1 + cross31*dot2 + cross12*dot3
denom = other1.normal.dot(cross23)
if isZero(denom):
raise ZeroDivisionError("Cannot find intersection of parallel planes.")
return numer / denom
def _intersectLine(self, line):
# Intersection of a plane and a line - Paul Bourke
# http://paulbourke.net/geometry/pointlineplane/
numer = self.normal.dot(self.pos - line.pos)
denom = self.normal.dot(line.dir)
if isZero(denom):
raise ZeroDivisionError("Cannot find intersection of parallel line.")
return line.pos + line.dir*(numer / denom)
================================================
FILE: body/chrumm/geo/segment.py
================================================
from .epsilon import isZero
from .vector import Vector
class Segment:
__slots__ = "a", "b"
def __init__(self, a, b):
self.a = a
self.b = b
def magnitude(self):
return (self.b - self.a).magnitude()
def offset2D(self, distance):
offset = (self.a - self.b).ortho2D().normalized2D() * distance
return Segment(self.a.xy + offset, self.b.xy + offset)
def magnitude2D(self):
return (self.a - self.b).magnitude2D()
def distance2D(self, vector):
# Minimum Distance between a Point and a Line - Paul Bourke
# http://paulbourke.net/geometry/pointlineplane/
a = self.a
b = self.b
# OPTIMIZED: Inline calculations to avoid function overhead
numer = (vector.x - a.x)*(b.x - a.x) + (vector.y - a.y)*(b.y - a.y)
denom = (b.x - a.x)**2 + (b.y - a.y)**2
u = max(0, min(numer / denom, 1)) if denom != 0 else 0
return ((a.x + u*(b.x - a.x) - vector.x)**2 +
(a.y + u*(b.y - a.y) - vector.y)**2)**0.5
def intersect2D(self, other, asLine=0):
"""Return intersection of segments, or None if they do not intersect.
Args:
asLine (int): Treat neither segment (0), the other segment (1),
or both segments (2) as infinite lines.
"""
# Intersection point of two line segments in 2 dimensions - Paul Bourke
# http://paulbourke.net/geometry/pointlineplane/
assert 0 <= asLine <= 2
a = self.a
b = self.b
c = other.a
d = other.b
# OPTIMIZED: Inline calculations to avoid function overhead
denom = (d.y - c.y)*(b.x - a.x) - (d.x - c.x)*(b.y - a.y)
if not isZero(denom):
abPos = ((d.x - c.x)*(a.y - c.y) - (d.y - c.y)*(a.x - c.x)) / denom
if (asLine == 2 or 0 <= abPos <= 1):
cdPos = ((b.x - a.x)*(a.y - c.y) - (b.y - a.y)*(a.x - c.x)) / denom
if (asLine >= 1 or 0 <= cdPos <= 1):
return Vector(a.x + (b.x - a.x)*abPos, a.y + (b.y - a.y)*abPos)
return None
================================================
FILE: body/chrumm/geo/tests/README.md
================================================
Run the tests from the parent directory of the chrumm package:
python3 -m unittest discover
================================================
FILE: body/chrumm/geo/tests/__init__.py
================================================
================================================
FILE: body/chrumm/geo/tests/helper.py
================================================
def findTriangulationProblems(triangles, outerSegments):
"""Check if triangles make reasonable sense (inefficient)."""
# Triangles are valid
# -> Non-zero area
# -> Not collinear
for tri in triangles:
if not tri:
return "Triangle is not valid."
# Triangle vertexes match segment vertexes
# -> No rounding errors
# -> No new points
vertexes = []
for segment in outerSegments:
for vertex in segment.a, segment.b:
if vertex not in vertexes:
vertexes.append(vertex)
for tri in triangles:
if tri.a not in vertexes or tri.b not in vertexes or tri.c not in vertexes:
return "Triangle vertex does not match outer segments."
# Segments are used an expected number of times
# -> Unique triangles
# -> No holes
outerSortedSegs = [sorted((s.a, s.b)) for s in outerSegments]
innerSortedSegs = []
outerCounts = [0]*len(outerSortedSegs)
innerCounts = []
for tri in triangles:
for seg in (tri.a, tri.b), (tri.b, tri.c), (tri.c, tri.a):
sortedSeg = sorted(seg)
isOuterSeg = False
for i, outerSeg in enumerate(outerSortedSegs):
if sortedSeg == outerSeg:
outerCounts[i] += 1
isOuterSeg = True
isInnerSeg = False
for i, innerSeg in enumerate(innerSortedSegs):
if sortedSeg == innerSeg:
innerCounts[i] += 1
isInnerSeg = True
if not isOuterSeg and not isInnerSeg:
innerSortedSegs.append(sortedSeg)
innerCounts.append(1)
for count in outerCounts:
if count > 1:
return "Outer segment is used more than once."
for count in innerCounts:
if count != 2:
return "Inner segment is not used exactly twice."
return None
================================================
FILE: body/chrumm/geo/tests/test_circle.py
================================================
import unittest
from ..circle import Circle
from ..line import Line
from ..vector import Vector
CIRCLE_ZERO = Circle(Vector(0, 0, 0), 0)
CIRCLE_UNIT = Circle(Vector(0, 0, 0), 1)
class CircleTest(unittest.TestCase):
def test_intersectLine2D(self):
# Separate
line = Line(Vector(0, 2, 3), Vector(1, 1, 1))
points = CIRCLE_UNIT.intersect2D(line)
self.assertEqual(len(points), 0)
# Zero tangent
line = Line(Vector(), Vector(1, 1, 1))
points = CIRCLE_ZERO.intersect2D(line)
self.assertEqual(len(points), 1)
self.assertEqual(points[0], Vector())
# Tangent
line = Line(Vector(1, 0, 4), Vector(0, 1, 2))
points = CIRCLE_UNIT.intersect2D(line)
self.assertEqual(len(points), 1)
self.assertEqual(points[0], Vector(1, 0, 0))
# Intersection
line = Line(Vector(), Vector(1, 1, 1))
points = CIRCLE_UNIT.intersect2D(line)
self.assertEqual(len(points), 2)
self.assertTrue(points[0].isClose(Vector(-0.5**0.5, -0.5**0.5)))
self.assertTrue(points[1].isClose(Vector(0.5**0.5, 0.5**0.5)))
def test_intersectCircle2D(self):
points = CIRCLE_ZERO.intersect2D(CIRCLE_ZERO)
self.assertEqual(len(points), 0)
# Inside
circle = Circle(Vector(0, 0, 3), 2)
points = CIRCLE_UNIT.intersect2D(circle)
self.assertEqual(len(points), 0)
# Separate
circle = Circle(Vector(4, 4, 3), 2)
points = CIRCLE_UNIT.intersect2D(circle)
self.assertEqual(len(points), 0)
# Tangent
circle = Circle(Vector(4.5**0.5, 4.5**0.5, 3), 2)
points = CIRCLE_UNIT.intersect2D(circle)
self.assertEqual(len(points), 1)
self.assertTrue(points[0].isClose(Vector(0.5**0.5, 0.5**0.5)))
# Intersection
circle = Circle(Vector(1, 1, 3), 1)
points = CIRCLE_UNIT.intersect2D(circle)
self.assertEqual(len(points), 2)
self.assertTrue(points[0].isClose(Vector(0, 1, 0)))
self.assertTrue(points[1].isClose(Vector(1, 0, 0)))
================================================
FILE: body/chrumm/geo/tests/test_edge.py
================================================
import unittest
from ..edge import Edge
from ..matrix import Matrix
from ..vector import Vector
from .helper import findTriangulationProblems
EDGE_EMPTY = Edge()
EDGE_SQUARE = Edge(
Vector(0, 0, 0),
Vector(1, 0, 1),
Vector(1, 1, 2),
Vector(0, 1, 3))
class EdgeTest(unittest.TestCase):
def test_fromConvexHull2D(self):
vectors = [
Vector(0.5, 0, 1),
Vector(1, 1, 2),
Vector(1, 0, 3),
Vector(0.5, 0.5, 4),
Vector(0, 1, 5),
Vector(0, 0.5, 6),
Vector(0, 0, 7)]
edge = Edge.fromConvexHull2D(vectors)
self.assertEqual(edge, EDGE_SQUARE.xy)
def test_toSegments(self):
# Open
segs = Edge().toSegments()
self.assertEqual(len(segs), 0)
segs = Edge(Vector()).toSegments()
self.assertEqual(len(segs), 0)
segs = Edge(Vector(), Vector(1, 2, 3)).toSegments()
self.assertEqual(len(segs), 1)
self.assertEqual(segs[0].a, Vector(0, 0, 0))
self.assertEqual(segs[0].b, Vector(1, 2, 3))
# Closed
segs = Edge().toSegments(True)
self.assertEqual(len(segs), 0)
segs = Edge(Vector()).toSegments(True)
self.assertEqual(len(segs), 1)
self.assertEqual(segs[0].a, Vector())
self.assertEqual(segs[0].b, Vector())
segs = Edge(Vector(), Vector(1, 2, 3)).toSegments(True)
self.assertEqual(len(segs), 2)
self.assertEqual(segs[0].a, Vector(0, 0, 0))
self.assertEqual(segs[0].b, Vector(1, 2, 3))
self.assertEqual(segs[1].a, Vector(1, 2, 3))
self.assertEqual(segs[1].b, Vector(0, 0, 0))
def test_add(self):
edge = Edge()
self.assertEqual(len(edge), 0)
edge.add([])
self.assertEqual(len(edge), 0)
edge.add(Vector(1))
self.assertEqual(len(edge), 1)
self.assertEqual(edge[0], Vector(1))
edge.add([Vector(2)])
self.assertEqual(len(edge), 2)
self.assertEqual(edge[0], Vector(1))
self.assertEqual(edge[1], Vector(2))
edge.add(Edge(Vector(3)))
self.assertEqual(len(edge), 3)
self.assertEqual(edge[0], Vector(1))
self.assertEqual(edge[1], Vector(2))
self.assertEqual(edge[2], Vector(3))
edge.add([Edge(Vector(4)), Edge(Vector(5))])
self.assertEqual(len(edge), 5)
self.assertEqual(edge[0], Vector(1))
self.assertEqual(edge[1], Vector(2))
self.assertEqual(edge[2], Vector(3))
self.assertEqual(edge[3], Vector(4))
self.assertEqual(edge[4], Vector(5))
def test_mirroredX(self):
edge = EDGE_SQUARE.mirroredX()
for i in range(len(edge)):
self.assertEqual(edge[i], EDGE_SQUARE[i].mirroredX())
def test_mirroredY(self):
edge = EDGE_SQUARE.mirroredY()
for i in range(len(edge)):
self.assertEqual(edge[i], EDGE_SQUARE[i].mirroredY())
def test_mirroredZ(self):
edge = EDGE_SQUARE.mirroredZ()
for i in range(len(edge)):
self.assertEqual(edge[i], EDGE_SQUARE[i].mirroredZ())
def test_reversed(self):
edge = EDGE_SQUARE.reversed()
for i in range(len(edge)):
self.assertEqual(edge[i], EDGE_SQUARE[len(EDGE_SQUARE)-1-i])
def test_scaled(self):
edge = EDGE_SQUARE.scaled(0)
for i in range(len(edge)):
self.assertEqual(edge[i], Vector())
edge = EDGE_SQUARE.scaled(1)
self.assertEqual(edge, EDGE_SQUARE)
edge = EDGE_SQUARE.scaled(2)
for i in range(len(edge)):
self.assertEqual(edge[i], EDGE_SQUARE[i]*2)
def test_translated(self):
edge = EDGE_SQUARE.translated(Vector(1, 2, 3))
for i in range(len(edge)):
self.assertEqual(edge[i], EDGE_SQUARE[i] + Vector(1, 2, 3))
def test_transformed(self):
matrix = Matrix().rotatedX(2**0.5)
edge = EDGE_SQUARE.transformed(matrix)
for i in range(len(edge)):
self.assertEqual(edge[i], EDGE_SQUARE[i].transformed(matrix))
def test_collapsed(self):
edge = Edge()
self.assertEqual(edge.collapsed(), Edge())
edge = Edge(Vector())
self.assertEqual(edge.collapsed(), Edge())
edge = Edge(Vector(), Vector())
self.assertEqual(edge.collapsed(), Edge())
edge = Edge(Vector(), Vector(1, 2, 3))
self.assertEqual(edge.collapsed(), edge)
edge = Edge(Vector(), Vector(), Vector(1, 2, 3), Vector(1, 2, 3))
expected = Edge(Vector(), Vector(1, 2, 3))
self.assertEqual(edge.collapsed(), expected)
edge = Edge(Vector(), Vector(1, 2, 3), Vector(1, 2, 3), Vector())
expected = Edge(Vector(), Vector(1, 2, 3))
self.assertEqual(edge.collapsed(), expected)
def test_meshPairwise(self):
tris = Edge().meshPairwise(Edge())
self.assertEqual(len(tris), 0)
tris = Edge().meshPairwise(Edge(Vector()))
self.assertEqual(len(tris), 0)
tris = Edge().meshPairwise(Edge(Vector(1, 2, 3)))
self.assertEqual(len(tris), 0)
tris = Edge(Vector()).meshPairwise(Edge(Vector(1, 2, 3)))
self.assertEqual(len(tris), 0)
tris = Edge(Vector()).meshPairwise(Edge(Vector(1, 2, 3), Vector(1, 2, 3)))
self.assertEqual(len(tris), 0)
tris = Edge(Vector()).meshPairwise(Edge(Vector(1, 2, 3), Vector(2, 4, 6)))
self.assertEqual(len(tris), 0)
tris = Edge(Vector()).meshPairwise(Edge(Vector(1, 3, 4), Vector(-1, 3, 4)))
self.assertEqual(len(tris), 1)
self.assertAlmostEqual(tris[0].area(), 5)
# Closed
edge0 = Edge(
Vector(0, 0, 0),
Vector(1, 0, 0),
Vector(1, 1, 0),
Vector(0, 1, 0))
edge1 = Edge(
Vector(0, 0, 1),
Vector(1, 0, 1),
Vector(1, 1, 1),
Vector(0, 1, 1))
tris = edge0.meshPairwise(edge1, isClosed=True)
segs = edge0.toSegments(True) + edge1.toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 8)
self.assertAlmostEqual(area, 4)
self.assertIsNone(findTriangulationProblems(tris, segs))
# Fanning
edge0 = Edge(
Vector(1, 1, 4),
Vector(2, 1, 4))
edge1 = Edge(
Vector(1, 0, 4),
Vector(2, 0, 4),
Vector(3, 1, 4),
Vector(2, 2, 4),
Vector(1, 2, 4))
tris = edge0.meshPairwise(edge1)
segs = Edge(edge0, reversed(edge1)).toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 5)
self.assertAlmostEqual(area, 2.5)
self.assertIsNone(findTriangulationProblems(tris, segs))
# Overlapping and collinear segments
edge0 = Edge(
Vector(2, 1, 2),
Vector(3, 1, 2),
Vector(4, 2, 2),
Vector(5, 3, 2),
Vector(6, 3, 2))
edge1 = Edge(
Vector(2, 1, 3),
Vector(3, 1, 2),
Vector(4, 2, 2),
Vector(5, 3, 2),
Vector(6, 3, 3))
tris = edge0.meshPairwise(edge1)
segs = Edge(edge0, reversed(edge1)).toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 2)
self.assertAlmostEqual(area, 1)
self.assertIsNone(findTriangulationProblems(tris, segs))
# Crossing edges
edge0 = Edge(
Vector(1, 1, 5),
Vector(2, 2, 5),
Vector(3, 1, 5))
edge1 = Edge(
Vector(1, 2, 5),
Vector(2, 1, 5),
Vector(3, 2, 5))
tris = edge0.meshPairwise(edge1)
segs = Edge(edge0, reversed(edge1)).toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 4)
self.assertAlmostEqual(area, 2)
self.assertIsNone(findTriangulationProblems(tris, segs))
def test_meshParallel(self):
tris = Edge().meshParallel(Edge())
self.assertEqual(len(tris), 0)
tris = Edge().meshParallel(Edge(Vector()))
self.assertEqual(len(tris), 0)
tris = Edge().meshParallel(Edge(Vector(1, 2, 3)))
self.assertEqual(len(tris), 0)
tris = Edge(Vector()).meshParallel(Edge(Vector(1, 2, 3)))
self.assertEqual(len(tris), 0)
tris = Edge(Vector()).meshParallel(Edge(Vector(1, 3, 4), Vector(-1, 3, 4)))
self.assertEqual(len(tris), 1)
self.assertAlmostEqual(tris[0].area(), 5)
# Closed
edge0 = Edge(
Vector(0, 0, 0),
Vector(1, 0, 0),
Vector(1, 1, 0),
Vector(0, 1, 0))
edge1 = Edge(
Vector(0, 0, 1),
Vector(1, 0, 1),
Vector(1, 1, 1),
Vector(0, 1, 1))
tris = edge0.meshParallel(edge1, isClosed=True)
segs = edge0.toSegments(True) + edge1.toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 8)
self.assertAlmostEqual(area, 4)
self.assertIsNone(findTriangulationProblems(tris, segs))
# Fanning at end
edge0 = Edge(
Vector(1, 1, 4),
Vector(2, 1, 4))
edge1 = Edge(
Vector(1, 0, 4),
Vector(2, 0, 4),
Vector(3, 1, 4),
Vector(2, 2, 4),
Vector(1, 2, 4))
tris = edge0.meshParallel(edge1)
segs = Edge(edge0, reversed(edge1)).toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 5)
self.assertAlmostEqual(area, 2.5)
self.assertIsNone(findTriangulationProblems(tris, segs))
# Fanning in middle
edge0 = Edge(
Vector(0, 0, 0),
Vector(8, 6, 0),
Vector(16, 0, 0))
edge1 = Edge(
Vector(0, 0, 6),
Vector(4, 3, 6),
Vector(8, 6, 6),
Vector(12, 3, 6),
Vector(16, 0, 6))
tris = edge0.meshParallel(edge1)
segs = Edge(edge0, reversed(edge1)).toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 6)
self.assertAlmostEqual(area, 120)
self.assertIsNone(findTriangulationProblems(tris, segs))
normL = Vector(3, -4).normalized()
normR = Vector(-3, -4).normalized()
self.assertTrue(tris[0].normal().isClose(normL))
self.assertTrue(tris[1].normal().isClose(normL))
self.assertTrue(tris[2].normal().isClose(normL))
self.assertTrue(tris[3].normal().isClose(normR))
self.assertTrue(tris[4].normal().isClose(normR))
self.assertTrue(tris[5].normal().isClose(normR))
def test_contains2D(self):
eps = 1e-9
self.assertTrue(EDGE_SQUARE.contains2D(Vector(eps, eps, 9)))
self.assertTrue(EDGE_SQUARE.contains2D(Vector(0.5, eps, 9)))
self.assertTrue(EDGE_SQUARE.contains2D(Vector(eps, 0.5, 9)))
self.assertTrue(EDGE_SQUARE.contains2D(Vector(1-eps, 1-eps, 9)))
self.assertFalse(EDGE_SQUARE.contains2D(Vector(-eps, -eps, 9)))
self.assertFalse(EDGE_SQUARE.contains2D(Vector(0.5, -eps, 9)))
self.assertFalse(EDGE_SQUARE.contains2D(Vector(-eps, 0.5, 9)))
self.assertFalse(EDGE_SQUARE.contains2D(Vector(1+eps, 1+eps, 9)))
def test_distance2D(self):
self.assertEqual(EDGE_SQUARE.distance2D(Vector(0, 0, 9)), 0)
self.assertEqual(EDGE_SQUARE.distance2D(Vector(0.5, 0.5, 9)), 0)
self.assertEqual(EDGE_SQUARE.distance2D(Vector(1, 1, 9)), 0)
self.assertEqual(EDGE_SQUARE.distance2D(Vector(-1, 0, 9)), 1)
self.assertEqual(EDGE_SQUARE.distance2D(Vector(0, -1, 9)), 1)
self.assertEqual(EDGE_SQUARE.distance2D(Vector(2, 1, 9)), 1)
self.assertEqual(EDGE_SQUARE.distance2D(Vector(1, 2, 9)), 1)
self.assertAlmostEqual(EDGE_SQUARE.distance2D(Vector(-1, -1, 9)), 2**0.5)
self.assertAlmostEqual(EDGE_SQUARE.distance2D(Vector(2, 2, 9)), 2**0.5)
================================================
FILE: body/chrumm/geo/tests/test_face.py
================================================
import unittest
from ..edge import Edge
from ..face import Face
from ..vector import Vector
from .helper import findTriangulationProblems
class FaceTest(unittest.TestCase):
def test_triangulate_simple(self):
# 9--------8
# | |
# 11------10 .3 |
# .-' | |
# 1--2 2' | 7
# | | '. | |
# 0 3 1 | |
# | | |
# 2--3 0 4 6
# | | .'
# 0--1 4--------5
edge = Edge(
Vector(10, 10),
Vector(20, 10),
Vector(20, 20),
Vector(30, 20),
Vector(30, 10),
Vector(60, 10),
Vector(70, 20),
Vector(70, 40),
Vector(70, 60),
Vector(40, 60),
Vector(40, 50),
Vector(10, 50))
hole0 = Edge(
Vector(20, 30),
Vector(20, 40),
Vector(30, 40),
Vector(30, 30))
hole1 = Edge(
Vector(50, 20),
Vector(50, 30),
Vector(40, 40),
Vector(60, 50),
Vector(60, 20))
# Without holes
tris = Face(edge).triangulate()
segs = edge.toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 10)
self.assertAlmostEqual(area, 2550)
self.assertIsNone(findTriangulationProblems(tris, segs))
# With holes
tris = Face(edge, [hole0, hole1]).triangulate()
segs = edge.toSegments(True) + hole0.toSegments(True) + hole1.toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 23)
self.assertAlmostEqual(area, 2100)
self.assertIsNone(findTriangulationProblems(tris, segs))
def test_triangulate_vertical(self):
edge = Edge(
Vector(-10, 0, -10),
Vector(10, 0, -10),
Vector(10, 0, 10),
Vector(-10, 0, 10))
tris = Face(edge).triangulate()
segs = edge.toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 2)
self.assertAlmostEqual(area, 400)
self.assertIsNone(findTriangulationProblems(tris, segs))
def test_triangulate_flipDelaunay(self):
# 3
# \ Prefer diagonal
# \ (0, 2) over (1, 3)
# \
# 0 2
# '. .'
# 1
edge = Edge(
Vector(10, 20),
Vector(20, 10),
Vector(30, 20),
Vector(20, 50))
tris = Face(edge).triangulate()
segs = edge.toSegments(True)
areas = sorted(t.area() for t in tris)
self.assertEqual(len(tris), 2)
self.assertAlmostEqual(areas[0], 100)
self.assertAlmostEqual(areas[1], 300)
self.assertIsNone(findTriangulationProblems(tris, segs))
def test_triangulate_sliverEar(self):
# 2--1
# .' | Avoid sliver
# 4--3 | ear (2, 3, 5)
# | |
# 5 |
# |
# 0
edge = Edge(
Vector(50, 10),
Vector(50, 50),
Vector(40, 50),
Vector(29.999, 40),
Vector(20, 40),
Vector(20, 30))
tris = Face(edge).triangulate()
segs = edge.toSegments(True)
for tri in tris:
self.assertGreater(tri.area(), 25)
self.assertEqual(len(tris), 4)
self.assertIsNone(findTriangulationProblems(tris, segs))
def test_triangulate_alignedHoles(self):
# 3--------------2
# |
# 0--1 0--1 |
# h2| h3| |
# 3--2 3--2 |
# |
# 0--1 0--1 |
# h0| h1| |
# 3--2 3--2 |
# |
# 0--------------1
edge = Edge(Vector(10, 10), Vector(60, 10), Vector(60, 60), Vector(10, 60))
hole0 = Edge(Vector(20, 30), Vector(30, 30), Vector(30, 20), Vector(20, 20))
hole1 = Edge(Vector(40, 30), Vector(50, 30), Vector(50, 20), Vector(40, 20))
hole2 = Edge(Vector(20, 50), Vector(30, 50), Vector(30, 40), Vector(20, 40))
hole3 = Edge(Vector(40, 50), Vector(50, 50), Vector(50, 40), Vector(40, 40))
tris = Face(edge, [hole0, hole1, hole2, hole3]).triangulate()
segs = (
edge.toSegments(True)
+ hole0.toSegments(True)
+ hole1.toSegments(True)
+ hole2.toSegments(True)
+ hole3.toSegments(True))
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 26)
self.assertAlmostEqual(area, 2100)
self.assertIsNone(findTriangulationProblems(tris, segs))
def test_triangulate_holeBridgeOrder(self):
# 2
# '.
# '.
# '.
# 1 '.
# |'. '.
# 0 2 '.
# 1 '.
# |'. 1 '.
# 0 2 |'. '.
# 0 2 '.
# 0--------------------1
edge = Edge(Vector(10, 10), Vector(80, 10), Vector(10, 80))
hole0 = Edge(Vector(20, 40), Vector(20, 50), Vector(30, 40))
hole1 = Edge(Vector(35, 25), Vector(35, 35), Vector(45, 25))
hole2 = Edge(Vector(50, 20), Vector(50, 30), Vector(60, 20))
tris = Face(edge, [hole0, hole1, hole2]).triangulate()
segs = (
edge.toSegments(True)
+ hole0.toSegments(True)
+ hole1.toSegments(True)
+ hole2.toSegments(True))
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 16)
self.assertAlmostEqual(area, 2300)
self.assertIsNone(findTriangulationProblems(tris, segs))
def test_triangulate_collinearDiagonalHoles(self):
# Data based on fixed bug
# 3
# |
# .3 |
# 2' |
# 0 .3 \ .0 2
# \ 2' 1' .-'
# \ \ .0 .-'
# \ 1' .-'
# \ .-'
# 1'
edge = Edge(
Vector(10, -83),
Vector(28, -137),
Vector(129, -114),
Vector(131, -55))
hole0 = Edge(
Vector(85.940795579, -103.214712478),
Vector(72.494488685, -106.319037028),
Vector(69.390164135, -92.872730134),
Vector(82.836471029, -89.768405584))
hole1 = Edge(
Vector(104.453826809, -98.940642445),
Vector(91.007519915, -102.044966995),
Vector(87.903195366, -88.598660101),
Vector(101.349502260, -85.494335551))
tris = Face(edge, [hole0, hole1]).triangulate()
segs = edge.toSegments(True) + hole0.toSegments(True) + hole1.toSegments(True)
area = sum(t.area() for t in tris)
self.assertEqual(len(tris), 14)
self.assertAlmostEqual(area, 6094.62)
self.assertIsNone(findTriangulationProblems(tris, segs))
================================================
FILE: body/chrumm/geo/tests/test_line.py
================================================
import unittest
from ..line import Line
from ..matrix import Matrix
from ..vector import Vector
LINE = Line(Vector(1, 2, 3), Vector(1, 1, 1))
class LineTest(unittest.TestCase):
def test_translated(self):
line = LINE.translated(Vector())
self.assertEqual(line.pos, LINE.pos)
self.assertEqual(line.dir, LINE.dir)
line = LINE.translated(Vector(4, 5, 6))
self.assertEqual(line.pos, Vector(5, 7, 9))
self.assertEqual(line.dir, LINE.dir)
def test_transformed(self):
line = LINE.transformed(Matrix())
self.assertEqual(line.pos, LINE.pos)
self.assertEqual(line.dir, LINE.dir)
line = LINE.transformed(Matrix().mirroredX())
self.assertEqual(line.pos, LINE.pos.mirroredX())
self.assertEqual(line.dir, LINE.dir.mirroredX())
def test_distance(self):
line = Line(Vector(1, 2, 3), Vector(1, 0, 0))
self.assertEqual(line.distance(Vector(1, 2, 3)), 0)
self.assertEqual(line.distance(Vector(1, 1, 3)), 1)
self.assertEqual(line.distance(Vector(1, 3, 3)), 1)
self.assertEqual(line.distance(Vector(1, 2, 1)), 2)
self.assertEqual(line.distance(Vector(1, 2, 5)), 2)
def test_distance2D(self):
self.assertEqual(LINE.distance2D(Vector(1, 2, 1)), 0)
self.assertAlmostEqual(LINE.distance2D(Vector(3, 2, 1)), 2**0.5)
self.assertAlmostEqual(LINE.distance2D(Vector(1, 4, 1)), -2**0.5)
def test_intersect(self):
with self.assertRaises(ZeroDivisionError):
lineX = Line(Vector(), Vector(1, 0, 0))
lineX.intersect(lineX)
lineX = Line(Vector(), Vector(1, 0, 0))
lineY = Line(Vector(), Vector(0, 1, 0))
self.assertEqual(lineX.intersect(lineY), Vector())
lineX = Line(Vector(0, 2, 2), Vector(1, 0, 1))
lineY = Line(Vector(1, 1, 3), Vector(0, 1, 0))
self.assertEqual(lineX.intersect(lineY), Vector(1, 2, 3))
# Skew
lineX = Line(Vector(0, 0, 0), Vector(1, 0, 0))
lineY = Line(Vector(0, 0, 2), Vector(0, 1, 0))
self.assertEqual(lineX.intersect(lineY), Vector(0, 0, 1))
================================================
FILE: body/chrumm/geo/tests/test_matrix.py
================================================
import unittest
from math import pi as PI
from ..matrix import Matrix
from ..vector import Vector
MAT_ZERO = Matrix((0,)*16)
MAT_EVEN = Matrix(tuple(range(2, 33, 2)))
MAT_ODD = Matrix(tuple(range(1, 32, 2)))
class MatrixTest(unittest.TestCase):
def test_fromAlignment(self):
a = Vector(1, 9, 2).normalized()
b = Vector(3, 8, 7).normalized()
matrix = Matrix.fromAlignment(a, a)
self.assertTrue(a.transformed(matrix).isClose(a))
matrix = Matrix.fromAlignment(a, b)
self.assertTrue(a.transformed(matrix).isClose(b))
def test_add(self):
expected = tuple(a+b for a, b in zip(MAT_ODD.data, MAT_EVEN.data))
self.assertEqual((MAT_ODD + MAT_ZERO).data, MAT_ODD.data)
self.assertEqual((MAT_ODD + MAT_EVEN).data, expected)
def test_sub(self):
expected = (-1,)*16
self.assertEqual((MAT_ODD - MAT_ZERO).data, MAT_ODD.data)
self.assertEqual((MAT_ODD - MAT_EVEN).data, expected)
def test_mulScalar(self):
expected = tuple(n*2 for n in MAT_ODD.data)
self.assertEqual((MAT_ODD * 0).data, MAT_ZERO.data)
self.assertEqual((MAT_ODD * 2).data, expected)
def test_mulMatrix(self):
oddMulEven = (
304, 336, 368, 400,
752, 848, 944, 1040,
1200, 1360, 1520, 1680,
1648, 1872, 2096, 2320)
self.assertEqual((Matrix() * Matrix()).data, Matrix().data)
self.assertEqual((MAT_ODD * MAT_ZERO).data, MAT_ZERO.data)
self.assertEqual((MAT_ODD * MAT_EVEN).data, oddMulEven)
def test_mirroredX(self):
matrix = Matrix().mirroredX()
self.assertEqual(Vector(1, 2, 3).transformed(matrix), Vector(-1, 2, 3))
def test_mirroredY(self):
matrix = Matrix().mirroredY()
self.assertEqual(Vector(1, 2, 3).transformed(matrix), Vector(1, -2, 3))
def test_mirroredZ(self):
matrix = Matrix().mirroredZ()
self.assertEqual(Vector(1, 2, 3).transformed(matrix), Vector(1, 2, -3))
def test_rotatedX(self):
matrix = Matrix().rotatedX(0)
vector = Vector(1, 2, 3).transformed(matrix)
self.assertEqual(vector, Vector(1, 2, 3))
matrix = Matrix().rotatedX(PI/2, Vector(1, 2, 3))
vector = Vector(1, 2, 3).transformed(matrix)
self.assertAlmostEqual(vector.x, 1)
self.assertAlmostEqual(vector.y, 2)
self.assertAlmostEqual(vector.z, 3)
vector = Vector(2, 3, 4).transformed(matrix)
self.assertAlmostEqual(vector.x, 2)
self.assertAlmostEqual(vector.y, 1)
self.assertAlmostEqual(vector.z, 4)
def test_rotatedY(self):
matrix = Matrix().rotatedY(0)
vector = Vector(1, 2, 3).transformed(matrix)
self.assertEqual(vector, Vector(1, 2, 3))
matrix = Matrix().rotatedY(PI/2, Vector(1, 2, 3))
vector = Vector(1, 2, 3).transformed(matrix)
self.assertAlmostEqual(vector.x, 1)
self.assertAlmostEqual(vector.y, 2)
self.assertAlmostEqual(vector.z, 3)
vector = Vector(2, 3, 4).transformed(matrix)
self.assertAlmostEqual(vector.x, 2)
self.assertAlmostEqual(vector.y, 3)
self.assertAlmostEqual(vector.z, 2)
def test_rotatedZ(self):
matrix = Matrix().rotatedZ(0)
vector = Vector(1, 2, 3).transformed(matrix)
self.assertEqual(vector, Vector(1, 2, 3))
matrix = Matrix().rotatedZ(PI/2, Vector(1, 2, 3))
vector = Vector(1, 2, 3).transformed(matrix)
self.assertAlmostEqual(vector.x, 1)
self.assertAlmostEqual(vector.y, 2)
self.assertAlmostEqual(vector.z, 3)
vector = Vector(2, 3, 4).transformed(matrix)
self.assertAlmostEqual(vector.x, 0)
self.assertAlmostEqual(vector.y, 3)
self.assertAlmostEqual(vector.z, 4)
def test_translated(self):
matrix = Matrix().translated(Vector(1, 2, 3))
self.assertEqual(Vector(1, 2, 3).transformed(matrix), Vector(2, 4, 6))
================================================
FILE: body/chrumm/geo/tests/test_plane.py
================================================
import unittest
from ..line import Line
from ..matrix import Matrix
from ..plane import Plane
from ..vector import Vector
PLANE = Plane(Vector(1, 2, 3), Vector(1, 1, 1))
class PlaneTest(unittest.TestCase):
def test_fromPoints(self):
a = Vector(1, 2, 4)
b = Vector(1, 2, 3)
c = Vector(2, 3, 3)
plane = Plane.fromPoints(a, b, c)
self.assertEqual(plane.pos, b)
self.assertEqual(plane.normal, Vector(1, -1, 0).normalized())
def test_fromLine2D(self):
line = Line(Vector(1, 2, 3), Vector(1, 1, 1))
plane = Plane.fromLine2D(line)
self.assertEqual(plane.pos, Vector(1, 2, 0))
self.assertTrue(plane.normal.isClose(Vector(-1, 1).normalized()))
def test_translated(self):
plane = PLANE.translated(Vector())
self.assertEqual(plane.pos, PLANE.pos)
self.assertEqual(plane.normal, PLANE.normal)
plane = PLANE.translated(Vector(4, 5, 6))
self.assertEqual(plane.pos, Vector(5, 7, 9))
self.assertEqual(plane.normal, PLANE.normal)
def test_transformed(self):
plane = PLANE.transformed(Matrix())
self.assertEqual(plane.pos, PLANE.pos)
self.assertEqual(plane.normal, PLANE.normal)
plane = PLANE.transformed(Matrix().mirroredX())
self.assertEqual(plane.pos, PLANE.pos.mirroredX())
self.assertEqual(plane.normal, PLANE.normal.mirroredX())
def test_distance(self):
plane = Plane(Vector(1, 2, 3), Vector(0, 0, 1))
self.assertEqual(plane.distance(Vector(0, 0, 3)), 0)
self.assertEqual(plane.distance(Vector(4, 5, 6)), 3)
self.assertEqual(plane.distance(Vector(-4, -5, -6)), -9)
def test_projectNormal(self):
self.assertTrue(PLANE.projectNormal(Vector(0, 1, 2)).isClose(PLANE.pos))
self.assertTrue(PLANE.projectNormal(Vector(4, 5, 6)).isClose(PLANE.pos))
def test_projectX(self):
plane = Plane(Vector(2, 3, 4), Vector(1, 0, 0))
self.assertEqual(plane.projectX(Vector(1, 1, 1)), Vector(2, 1, 1))
plane = Plane(Vector(4, 2, 2), Vector(1, 1, 1))
self.assertEqual(plane.projectX(Vector(1, 1, 1)), Vector(6, 1, 1))
plane = Plane(Vector(2, 3, 4), Vector(0, 1, 0))
with self.assertRaises(ZeroDivisionError):
plane.projectX(Vector(1, 1, 1))
def test_projectY(self):
plane = Plane(Vector(2, 3, 4), Vector(0, 1, 0))
self.assertEqual(plane.projectY(Vector(1, 1, 1)), Vector(1, 3, 1))
plane = Plane(Vector(2, 4, 2), Vector(1, 1, 1))
self.assertEqual(plane.projectY(Vector(1, 1, 1)), Vector(1, 6, 1))
plane = Plane(Vector(2, 3, 4), Vector(0, 0, 1))
with self.assertRaises(ZeroDivisionError):
plane.projectY(Vector(1, 1, 1))
def test_projectZ(self):
plane = Plane(Vector(2, 3, 4), Vector(0, 0, 1))
self.assertEqual(plane.projectZ(Vector(1, 1, 1)), Vector(1, 1, 4))
plane = Plane(Vector(2, 2, 4), Vector(1, 1, 1))
self.assertEqual(plane.projectZ(Vector(1, 1, 1)), Vector(1, 1, 6))
plane = Plane(Vector(2, 3, 4), Vector(1, 0, 0))
with self.assertRaises(ZeroDivisionError):
plane.projectZ(Vector(1, 1, 1))
def test_intersectPlanes(self):
planeX = Plane(Vector(1, 0, 0), Vector(1, 0, 0))
planeY = Plane(Vector(0, 2, 0), Vector(0, 1, 0))
planeZ = Plane(Vector(0, 0, 3), Vector(0, 0, 1))
self.assertEqual(planeX.intersect(planeY, planeZ), Vector(1, 2, 3))
with self.assertRaises(ZeroDivisionError):
planeX.intersect(planeX, planeY)
def test_intersectLine(self):
line = Line(Vector(2, 3, 4), Vector(1, 1, 1))
self.assertEqual(PLANE.intersect(line), Vector(1, 2, 3))
line = Line(Vector(2, 3, 4), Vector(1, -1, 0))
with self.assertRaises(ZeroDivisionError):
PLANE.intersect(line)
================================================
FILE: body/chrumm/geo/tests/test_segment.py
================================================
import unittest
from ..segment import Segment
from ..vector import Vector
SEG_ZERO = Segment(Vector(0, 0, 0), Vector(0, 0, 0))
SEG_DIAG = Segment(Vector(1, 2, 3), Vector(3, 4, 5))
class SegmentTest(unittest.TestCase):
def test_magnitude(self):
self.assertEqual(SEG_ZERO.magnitude(), 0)
self.assertAlmostEqual(SEG_DIAG.magnitude(), 12**0.5)
def test_offset2D(self):
seg = Segment(Vector(1, 2, 3), Vector(1, 3, 4))
off = seg.offset2D(0)
self.assertEqual(off.a, Vector(1, 2, 0))
self.assertEqual(off.b, Vector(1, 3, 0))
off = seg.offset2D(2)
self.assertEqual(off.a, Vector(3, 2, 0))
self.assertEqual(off.b, Vector(3, 3, 0))
off = seg.offset2D(-2)
self.assertEqual(off.a, Vector(-1, 2, 0))
self.assertEqual(off.b, Vector(-1, 3, 0))
with self.assertRaises(ZeroDivisionError):
SEG_ZERO.offset2D(0)
def test_magnitude2D(self):
self.assertEqual(SEG_ZERO.magnitude2D(), 0)
seg = Segment(Vector(1, 2, 3), Vector(1, 2, 5))
self.assertEqual(seg.magnitude2D(), 0)
seg = Segment(Vector(1, 2, 3), Vector(2, 3, 5))
self.assertAlmostEqual(seg.magnitude2D(), 2**0.5)
seg = Segment(Vector(2, 3, 5), Vector(1, 2, 3))
self.assertAlmostEqual(seg.magnitude2D(), 2**0.5)
def test_distance2D(self):
self.assertEqual(SEG_ZERO.distance2D(Vector()), 0)
self.assertAlmostEqual(SEG_ZERO.distance2D(Vector(1, 1, 1)), 2**0.5)
# On segment except z
self.assertEqual(SEG_DIAG.distance2D(Vector(1, 2, 1)), 0)
self.assertEqual(SEG_DIAG.distance2D(Vector(2, 3, 1)), 0)
self.assertEqual(SEG_DIAG.distance2D(Vector(3, 4, 1)), 0)
# On segment
self.assertEqual(SEG_DIAG.distance2D(Vector(1, 2, 3)), 0)
self.assertEqual(SEG_DIAG.distance2D(Vector(2, 3, 4)), 0)
self.assertEqual(SEG_DIAG.distance2D(Vector(3, 4, 5)), 0)
# Extended
self.assertEqual(SEG_DIAG.distance2D(Vector(0, 2, 3)), 1)
self.assertEqual(SEG_DIAG.distance2D(Vector(1, 1, 3)), 1)
self.assertEqual(SEG_DIAG.distance2D(Vector(4, 4, 5)), 1)
self.assertEqual(SEG_DIAG.distance2D(Vector(3, 5, 5)), 1)
self.assertAlmostEqual(SEG_DIAG.distance2D(Vector(0, 1, 2)), 2**0.5)
self.assertAlmostEqual(SEG_DIAG.distance2D(Vector(4, 5, 6)), 2**0.5)
def test_intersect2D(self):
self.assertEqual(SEG_ZERO.intersect2D(SEG_ZERO), None)
self.assertEqual(SEG_DIAG.intersect2D(SEG_ZERO), None)
# Intersecting
seg = Segment(Vector(3, 2, 1), Vector(1, 4, 3))
self.assertEqual(SEG_DIAG.intersect2D(seg, asLine=0), Vector(2, 3, 0))
self.assertEqual(SEG_DIAG.intersect2D(seg, asLine=1), Vector(2, 3, 0))
self.assertEqual(SEG_DIAG.intersect2D(seg, asLine=2), Vector(2, 3, 0))
# Touching ends
seg = Segment(Vector(3, 4, 5), Vector(5, 2, 1))
self.assertEqual(SEG_DIAG.intersect2D(seg, asLine=0), Vector(3, 4, 0))
self.assertEqual(SEG_DIAG.intersect2D(seg, asLine=1), Vector(3, 4, 0))
self.assertEqual(SEG_DIAG.intersect2D(seg, asLine=2), Vector(3, 4, 0))
# Separate
seg = Segment(Vector(3, 2, 1), Vector(5, 0, 4))
self.assertEqual(SEG_DIAG.intersect2D(seg, asLine=0), None)
self.assertEqual(seg.intersect2D(SEG_DIAG, asLine=1), None)
self.assertEqual(SEG_DIAG.intersect2D(seg, asLine=1), Vector(2, 3, 0))
self.assertEqual(SEG_DIAG.intersect2D(seg, asLine=2), Vector(2, 3, 0))
================================================
FILE: body/chrumm/geo/tests/test_triangle.py
================================================
import unittest
from ..matrix import Matrix
from ..triangle import Triangle
from ..vector import Vector
TRI_ZERO = Triangle(Vector(0, 0, 0), Vector(0, 0, 0), Vector(0, 0, 0))
TRI_AXIS = Triangle(Vector(1, 0, 0), Vector(0, 1, 0), Vector(0, 0, 1))
TRI_DIAG = Triangle(Vector(1, 2, 3), Vector(6, 5, 4), Vector(7, 8, 9))
class TriangleTest(unittest.TestCase):
def test_bool(self):
tri = Triangle(
Vector(1, 0, 0),
Vector(2, 0, 0),
Vector(3, 0, 0))
self.assertFalse(tri)
self.assertFalse(TRI_ZERO)
self.assertTrue(TRI_AXIS)
def test_mirroredX(self):
tri = TRI_DIAG.mirroredX()
self.assertEqual(tri.a, Vector(-1, 2, 3))
self.assertEqual(tri.b, Vector(-6, 5, 4))
self.assertEqual(tri.c, Vector(-7, 8, 9))
def test_mirroredY(self):
tri = TRI_DIAG.mirroredY()
self.assertEqual(tri.a, Vector(1, -2, 3))
self.assertEqual(tri.b, Vector(6, -5, 4))
self.assertEqual(tri.c, Vector(7, -8, 9))
def test_mirroredZ(self):
tri = TRI_DIAG.mirroredZ()
self.assertEqual(tri.a, Vector(1, 2, -3))
self.assertEqual(tri.b, Vector(6, 5, -4))
self.assertEqual(tri.c, Vector(7, 8, -9))
def test_reversed(self):
tri = TRI_AXIS.reversed()
self.assertEqual(tri.c, TRI_AXIS.a)
self.assertEqual(tri.b, TRI_AXIS.b)
self.assertEqual(tri.a, TRI_AXIS.c)
def test_translated(self):
tri = TRI_DIAG.translated(Vector())
self.assertEqual(tri.a, TRI_DIAG.a)
self.assertEqual(tri.b, TRI_DIAG.b)
self.assertEqual(tri.c, TRI_DIAG.c)
tri = TRI_DIAG.translated(Vector(10, 20, 30))
self.assertEqual(tri.a, Vector(11, 22, 33))
self.assertEqual(tri.b, Vector(16, 25, 34))
self.assertEqual(tri.c, Vector(17, 28, 39))
def test_transformed(self):
matrix = Matrix().translated(Vector(10, 20, 30))
tri = TRI_DIAG.transformed(matrix)
self.assertEqual(tri.a, Vector(11, 22, 33))
self.assertEqual(tri.b, Vector(16, 25, 34))
self.assertEqual(tri.c, Vector(17, 28, 39))
def test_area(self):
tri = Triangle(
Vector(0, 0, 0),
Vector(3, 0, 0),
Vector(0, 4, 0))
self.assertEqual(tri.area(), 6)
self.assertEqual(TRI_ZERO.area(), 0)
self.assertAlmostEqual(TRI_AXIS.area(), 3**0.5 / 2)
def test_circumradius(self):
self.assertEqual(TRI_ZERO.circumradius(), 0)
self.assertAlmostEqual(TRI_AXIS.circumradius(), 2**0.5 / 3**0.5)
self.assertAlmostEqual(TRI_DIAG.circumradius(), 27**0.5 * 35 / 864**0.5)
def test_normal(self):
with self.assertRaises(ZeroDivisionError):
TRI_ZERO.normal()
tri = Triangle(
Vector(0, 0, 0),
Vector(0, 2, 0),
Vector(0, 0, 2))
self.assertEqual(tri.normal(), Vector(1, 0, 0))
tri = Triangle(
Vector(0, 0, 0),
Vector(0, 0, 2),
Vector(0, 2, 0))
self.assertEqual(tri.normal(), Vector(-1, 0, 0))
tri = Triangle(
Vector(0, 0, 0),
Vector(0, 0, 3),
Vector(3, 0, 0))
self.assertEqual(tri.normal(), Vector(0, 1, 0))
tri = Triangle(
Vector(0, 0, 0),
Vector(3, 0, 0),
Vector(0, 0, 3))
self.assertEqual(tri.normal(), Vector(0, -1, 0))
tri = Triangle(
Vector(0, 0, 0),
Vector(4, 0, 0),
Vector(0, 4, 0))
self.assertEqual(tri.normal(), Vector(0, 0, 1))
tri = Triangle(
Vector(0, 0, 0),
Vector(0, 4, 0),
Vector(4, 0, 0))
self.assertEqual(tri.normal(), Vector(0, 0, -1))
================================================
FILE: body/chrumm/geo/tests/test_vector.py
================================================
import unittest
from math import pi as PI
from ..matrix import Matrix
from ..vector import Vector
# Based on considerations in chrumm.geo.epsilon
EPS_NONZERO = 1e-5
EPS_ZERO = 1e-7
MAT_ZERO = Matrix((0,)*16)
MAT_DIAG = Matrix(tuple(range(1, 17)))
class VectorTest(unittest.TestCase):
def test_init(self):
self.assertEqual(Vector(), Vector(0, 0, 0))
self.assertEqual(Vector(1), Vector(1, 0, 0))
self.assertEqual(Vector(1, 2), Vector(1, 2, 0))
self.assertEqual(Vector(1, 2, 3), Vector(1, 2, 3))
def test_fromSurfaceNormal(self):
with self.assertRaises(ZeroDivisionError):
Vector.fromSurfaceNormal([])
with self.assertRaises(ZeroDivisionError):
Vector.fromSurfaceNormal([Vector()])
with self.assertRaises(ZeroDivisionError):
Vector.fromSurfaceNormal([Vector(), Vector(1, 2, 3)])
with self.assertRaises(ZeroDivisionError):
Vector.fromSurfaceNormal([Vector(), Vector(1, 1, 1), Vector(2, 2, 2)])
vectors = [Vector(0, 0, 0), Vector(1, 0, 0), Vector(0, 1, 0)]
self.assertEqual(Vector.fromSurfaceNormal(vectors), Vector(0, 0, 1))
vectors = [Vector(0, 0, 0), Vector(0, 1, 0), Vector(1, 0, 0)]
self.assertEqual(Vector.fromSurfaceNormal(vectors), Vector(0, 0, -1))
vectors = [Vector(1, 2, 3), Vector(2, 3, 4), Vector(1, 2, 5)]
normal = Vector.fromSurfaceNormal(vectors)
self.assertAlmostEqual(normal.x, 0.5**0.5)
self.assertAlmostEqual(normal.y, -0.5**0.5)
self.assertEqual(normal.z, 0)
vectors = [Vector(1, 2, 3), Vector(1, 2, 5), Vector(2, 3, 4)]
normal = Vector.fromSurfaceNormal(vectors)
self.assertAlmostEqual(normal.x, -0.5**0.5)
self.assertAlmostEqual(normal.y, 0.5**0.5)
self.assertEqual(normal.z, 0)
def test_eq(self):
self.assertTrue(Vector(1, 2, 3) == Vector(1, 2, 3))
self.assertFalse(Vector(1, 1, 1) == Vector(0, 1, 1))
self.assertFalse(Vector(1, 1, 1) == Vector(1, 0, 1))
self.assertFalse(Vector(1, 1, 1) == Vector(1, 1, 0))
def test_lt(self):
self.assertTrue(Vector(1, 3, 3) < Vector(2, 2, 2))
self.assertTrue(Vector(1, 1, 3) < Vector(1, 2, 2))
self.assertTrue(Vector(1, 1, 1) < Vector(1, 1, 2))
self.assertFalse(Vector(2, 2, 2) < Vector(1, 3, 3))
self.assertFalse(Vector(1, 2, 2) < Vector(1, 1, 3))
self.assertFalse(Vector(1, 1, 2) < Vector(1, 1, 1))
def test_neg(self):
self.assertEqual(-Vector(), Vector())
self.assertEqual(-Vector(1, 2, 3), Vector(-1, -2, -3))
def test_add(self):
self.assertEqual(Vector(1, 2, 3) + Vector(), Vector(1, 2, 3))
self.assertEqual(Vector(1, 2, 3) + Vector(4, 5, 6), Vector(5, 7, 9))
def test_sub(self):
self.assertEqual(Vector(1, 2, 3) - Vector(), Vector(1, 2, 3))
self.assertEqual(Vector(1, 2, 3) - Vector(4, 5, 6), Vector(-3, -3, -3))
def test_mul(self):
self.assertEqual(Vector(1, 2, 3) * 0, Vector(0, 0, 0))
self.assertEqual(Vector(1, 2, 3) * 0.5, Vector(0.5, 1, 1.5))
self.assertEqual(Vector(1, 2, 3) * 2, Vector(2, 4, 6))
self.assertEqual(Vector(1, 2, 3) * -2, Vector(-2, -4, -6))
def test_truediv(self):
with self.assertRaises(ZeroDivisionError):
Vector(1, 2, 3) / 0
self.assertEqual(Vector(1, 2, 3) / 0.5, Vector(2, 4, 6))
self.assertEqual(Vector(1, 2, 3) / 1, Vector(1, 2, 3))
self.assertEqual(Vector(1, 2, 3) / 2, Vector(0.5, 1, 1.5))
self.assertEqual(Vector(1, 2, 3) / -2, Vector(-0.5, -1, -1.5))
def test_transformed(self):
self.assertEqual(Vector(1, 2, 3).transformed(Matrix()), Vector(1, 2, 3))
self.assertEqual(Vector(1, 2, 3).transformed(MAT_ZERO), Vector())
self.assertEqual(Vector(1, 2, 3).transformed(MAT_DIAG), Vector(51, 58, 65))
def test_transformedNormal(self):
with self.assertRaises(ZeroDivisionError):
Vector(1, 2, 3).transformedNormal(MAT_ZERO)
vector = Vector(2, 3, 6).transformedNormal(Matrix())
self.assertAlmostEqual(vector.x, 2 / 7)
self.assertAlmostEqual(vector.y, 3 / 7)
self.assertAlmostEqual(vector.z, 6 / 7)
vector = Vector(1, 2, 3).transformedNormal(MAT_DIAG)
self.assertAlmostEqual(vector.x, 38 / 5880**0.5)
self.assertAlmostEqual(vector.y, 44 / 5880**0.5)
self.assertAlmostEqual(vector.z, 50 / 5880**0.5)
def test_snapped(self):
self.assertEqual(Vector().snapped(), Vector())
vector = Vector(EPS_NONZERO, -EPS_NONZERO, EPS_NONZERO)
self.assertEqual(vector.snapped(), vector)
vector = Vector(EPS_ZERO, -EPS_ZERO, EPS_ZERO)
self.assertEqual(vector.snapped(), Vector(0, 0, 0))
def test_normalized(self):
with self.assertRaises(ZeroDivisionError):
Vector().normalized()
self.assertEqual(Vector(2, 3, 6).normalized(), Vector(2/7, 3/7, 6/7))
def test_cross(self):
self.assertEqual(Vector().cross(Vector()), Vector())
self.assertEqual(Vector(1, 2, 3).cross(Vector()), Vector())
self.assertEqual(Vector(1, 2, 3).cross(Vector(4, 5, 6)), Vector(-3, 6, -3))
def test_dot(self):
self.assertEqual(Vector().dot(Vector()), 0)
self.assertEqual(Vector(1, 2, 3).dot(Vector()), 0)
self.assertEqual(Vector(1, 2, 3).dot(Vector(4, 5, 6)), 32)
def test_magnitude(self):
self.assertEqual(Vector().magnitude(), 0)
self.assertEqual(Vector(2, 3, 6).magnitude(), 7)
def test_magSquared(self):
self.assertEqual(Vector().magSquared(), 0)
self.assertEqual(Vector(2, 3, 6).magSquared(), 49)
def test_angleBetween(self):
with self.assertRaises(ZeroDivisionError):
Vector().angleBetween(Vector())
start = Vector(1, 1, 0)
self.assertAlmostEqual(start.angleBetween(start), 0)
self.assertAlmostEqual(start.angleBetween(Vector(1, 1, 2**0.5)), PI*0.25)
self.assertAlmostEqual(start.angleBetween(Vector(0, 0, 1)), PI*0.5)
self.assertAlmostEqual(start.angleBetween(Vector(-1, -1, 2**0.5)), PI*0.75)
self.assertAlmostEqual(start.angleBetween(Vector(-1, -1, 0)), PI)
self.assertAlmostEqual(start.angleBetween(Vector(-1, -1, -2**0.5)), PI*0.75)
self.assertAlmostEqual(start.angleBetween(Vector(0, 0, -1)), PI*0.5)
self.assertAlmostEqual(start.angleBetween(Vector(1, 1, -2**0.5)), PI*0.25)
def test_isClose(self):
self.assertTrue(Vector().isClose(Vector()))
eps = EPS_ZERO
self.assertTrue(Vector(1, 20, 300).isClose(Vector(1+eps, 20, 300)))
self.assertTrue(Vector(1, 20, 300).isClose(Vector(1, 20-eps, 300)))
self.assertTrue(Vector(1, 20, 300).isClose(Vector(1, 20, 300+eps)))
self.assertTrue(Vector(1, 20, 300).isClose(Vector(1-eps, 20+eps, 300-eps)))
eps = EPS_NONZERO
self.assertFalse(Vector(1, 20, 300).isClose(Vector(1+eps, 20, 300)))
self.assertFalse(Vector(1, 20, 300).isClose(Vector(1, 20-eps, 300)))
self.assertFalse(Vector(1, 20, 300).isClose(Vector(1, 20, 300+eps)))
self.assertFalse(Vector(1, 20, 300).isClose(Vector(1-eps, 20+eps, 300-eps)))
def test_ortho2D(self):
self.assertEqual(Vector().ortho2D(), Vector())
self.assertEqual(Vector(1, 2, 3).ortho2D(), Vector(-2, 1, 0))
def test_normalized2D(self):
with self.assertRaises(ZeroDivisionError):
Vector().normalized2D()
self.assertEqual(Vector(3, 4, 6).normalized2D(), Vector(3/5, 4/5, 0))
def test_magnitude2D(self):
self.assertEqual(Vector().magnitude2D(), 0)
self.assertEqual(Vector(3, 4, 6).magnitude2D(), 5)
def test_magSquared2D(self):
self.assertEqual(Vector().magSquared2D(), 0)
self.assertEqual(Vector(3, 4, 6).magSquared2D(), 25)
def test_angle2D(self):
self.assertAlmostEqual(Vector(1, 0, 0).angle2D(), 0)
self.assertAlmostEqual(Vector(1, 1, 1).angle2D(), PI*0.25)
self.assertAlmostEqual(Vector(0, 2, 2).angle2D(), PI*0.5)
self.assertAlmostEqual(Vector(-3, 3, 3).angle2D(), PI*0.75)
self.assertAlmostEqual(Vector(-4, 0, 4).angle2D(), PI)
self.assertAlmostEqual(Vector(-5, -5, 5).angle2D(), -PI*0.75)
self.assertAlmostEqual(Vector(0, -6, 6).angle2D(), -PI*0.5)
self.assertAlmostEqual(Vector(7, -7, 7).angle2D(), -PI*0.25)
================================================
FILE: body/chrumm/geo/triangle.py
================================================
from .epsilon import isZero
class Triangle:
__slots__ = "a", "b", "c"
def __init__(self, a, b, c):
self.a = a
self.b = b
self.c = c
def __bool__(self):
return not isZero(self.area())
def mirroredX(self):
return Triangle(
self.a.mirroredX(),
self.b.mirroredX(),
self.c.mirroredX())
def mirroredY(self):
return Triangle(
self.a.mirroredY(),
self.b.mirroredY(),
self.c.mirroredY())
def mirroredZ(self):
return Triangle(
self.a.mirroredZ(),
self.b.mirroredZ(),
self.c.mirroredZ())
def reversed(self):
return Triangle(self.c, self.b, self.a)
def translated(self, vector):
return Triangle(
self.a + vector,
self.b + vector,
self.c + vector)
def transformed(self, matrix):
return Triangle(
self.a.transformed(matrix),
self.b.transformed(matrix),
self.c.transformed(matrix))
def area(self):
# https://en.wikipedia.org/wiki/Triangle#Using_vectors
ab = self.b - self.a
ac = self.c - self.a
return ab.cross(ac).magnitude() / 2
def circumradius(self):
# https://en.wikipedia.org/wiki/Circumcenter#Higher_dimensions
ca = self.a - self.c
cb = self.b - self.c
numer = ca.magnitude() * cb.magnitude() * (ca - cb).magnitude()
denom = 2 * ca.cross(cb).magnitude()
return 0 if isZero(denom) else numer/denom
def normal(self):
ab = self.b - self.a
ac = self.c - self.a
return ab.cross(ac).normalized()
================================================
FILE: body/chrumm/geo/vector.py
================================================
import math
from .epsilon import isZero
class Vector:
__slots__ = "x", "y", "z"
def __init__(self, x=0, y=0, z=0):
self.x = x
self.y = y
self.z = z
@staticmethod
def fromSurfaceNormal(vectors):
# Calculating a Surface Normal - Newell's Method
# https://www.khronos.org/opengl/wiki/Calculating_a_Surface_Normal
x, y, z = 0, 0, 0
for i in range(len(vectors)):
p = vectors[i]
q = vectors[(i+1) % len(vectors)]
x += (p.y - q.y) * (p.z + q.z)
y += (p.z - q.z) * (p.x + q.x)
z += (p.x - q.x) * (p.y + q.y)
return Vector(x, y, z).normalized()
def __repr__(self):
x = f"{self.x:.6f}".rstrip("0").rstrip(".")
y = f"{self.y:.6f}".rstrip("0").rstrip(".")
z = f"{self.z:.6f}".rstrip("0").rstrip(".")
return f"Vector({x}, {y}, {z})"
def __eq__(self, other):
return self.x == other.x and self.y == other.y and self.z == other.z
def __lt__(self, other):
if self.x != other.x:
return self.x < other.x
if self.y != other.y:
return self.y < other.y
return self.z < other.z
def __neg__(self):
return Vector(-self.x, -self.y, -self.z)
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
def __mul__(self, scalar):
return Vector(self.x*scalar, self.y*scalar, self.z*scalar)
def __truediv__(self, scalar):
return Vector(self.x/scalar, self.y/scalar, self.z/scalar)
@property
def xy(self):
return Vector(self.x, self.y, 0)
@property
def xz(self):
return Vector(self.x, 0, self.z)
@property
def yz(self):
return Vector(0, self.y, self.z)
def mirroredX(self):
return Vector(-self.x, self.y, self.z)
def mirroredY(self):
return Vector(self.x, -self.y, self.z)
def mirroredZ(self):
return Vector(self.x, self.y, -self.z)
def translated(self, vector):
return self + vector
def transformed(self, matrix):
m = matrix.data
return Vector(
self.x*m[0] + self.y*m[4] + self.z*m[8] + m[12],
self.x*m[1] + self.y*m[5] + self.z*m[9] + m[13],
self.x*m[2] + self.y*m[6] + self.z*m[10] + m[14])
def transformedNormal(self, matrix):
m = matrix.data
return Vector(
self.x*m[0] + self.y*m[4] + self.z*m[8],
self.x*m[1] + self.y*m[5] + self.z*m[9],
self.x*m[2] + self.y*m[6] + self.z*m[10]).normalized()
def snapped(self):
"""Snap almost-zero coordinates to positive zero exactly."""
return Vector(
0 if isZero(self.x) else self.x,
0 if isZero(self.y) else self.y,
0 if isZero(self.z) else self.z)
def normalized(self):
return self / self.magnitude()
def cross(self, other):
return Vector(
self.y*other.z - self.z*other.y,
self.z*other.x - self.x*other.z,
self.x*other.y - self.y*other.x)
def dot(self, other):
return self.x*other.x + self.y*other.y + self.z*other.z
def magnitude(self):
return (self.x*self.x + self.y*self.y + self.z*self.z)**0.5
def magSquared(self):
return self.x*self.x + self.y*self.y + self.z*self.z
def angleBetween(self, other):
"""Return angle difference to other vector, between 0 and pi."""
cos = self.normalized().dot(other.normalized())
return math.acos(max(-1, min(cos, 1)))
def isClose(self, other):
return (
isZero(self.x - other.x) and
isZero(self.y - other.y) and
isZero(self.z - other.z))
def ortho2D(self):
"""Return counterclockwise orthogonal vector."""
return Vector(-self.y, self.x)
def normalized2D(self):
# OPTIMIZED: Inline calculations to avoid function overhead
magnitude = (self.x*self.x + self.y*self.y)**0.5
return Vector(self.x/magnitude, self.y/magnitude)
def magnitude2D(self):
return (self.x*self.x + self.y*self.y)**0.5
def magSquared2D(self):
return self.x*self.x + self.y*self.y
def angle2D(self):
"""Return angle difference to x axis, between -pi and pi."""
return math.atan2(self.y, self.x)
================================================
FILE: body/chrumm/make.py
================================================
import logging
import multiprocessing
from chrumm import __version__
from chrumm import cfg
from chrumm import pcb
from chrumm import stl
from chrumm.part import Body
from chrumm.part import Floor
from chrumm.part import Knob
from chrumm.part import Palm
from chrumm.part import Plan
from chrumm.part import Support
log = logging.getLogger(__name__)
def make(jsonStrings, threads, isKnobOnly):
"""Generate files, based on JSON configuration strings.
Args:
jsonStrings (list[str]): List of JSON strings.
threads (int): Number of threads to use.
isKnobOnly (bool): Generate the encoder knob only.
Returns:
dict[str, bytes|str]: A dict of file names and data.
"""
files = {}
# Parse parameters
log.info("Parsing configuration parameters...")
cfg._init(jsonStrings)
if cfg.maker != "chrumm " + __version__:
log.warning("The parameters are intended for %s", cfg.maker)
if hasattr(cfg.quality, "bumpscosity"):
responses = {
0: "Where did all of the bumpscosity go?",
1: "Only a single bumpscosit. It will have to do.",
12: "Just a light breeze of bumpscosity, not bad.",
50: "Ah, quite a pleasant amount of bumpscosity.",
76: "The bumpscosity is really getting up there, isn't it?",
100: "Who turned up the bumpscosity so high?",
1000: "A thousand?! How can you stand this much bumpscosity?"}
if cfg.quality.bumpscosity in responses:
log.debug(responses[cfg.quality.bumpscosity])
# Generate knob
if cfg.knob:
files["rotary-knob.stl"] = stl.toBytes(Knob().triangles)
if isKnobOnly:
return files
# Generate parts
log.info("Constructing reference points...")
planR = Plan("right")
planL = Plan("left")
log.info("Constructing keyboard parts...")
parts = {}
parts["body-right"] = Body(planR)
parts["body-left"] = Body(planL)
parts["floor-right"] = Floor(planR, parts["body-right"])
parts["floor-left"] = Floor(planL, parts["body-left"])
if cfg.palm:
parts["palm-right"] = Palm(planR)
parts["palm-left"] = Palm(planL)
if cfg.support:
parts["support-right"] = Support(planR)
parts["support-left"] = Support(planL)
if cfg.pcb:
files["pcb-positions.kicad_mod"] = pcb.toKiCadFootprint(planR, planL)
# Triangulate faces
# The face objects are accumulated in a flat list, so that
# they can be passed to Pool and triangulated in parallel.
faces = [face for part in parts.values() for face in part.faces]
if threads <= 1:
log.info("Triangulating %i faces without multithreading...", len(faces))
faceTriangles = [face.triangulate() for face in faces]
else:
log.info("Triangulating %i faces with %i threads...", len(faces), threads)
with multiprocessing.Pool(processes=threads) as pool:
faceTriangles = pool.map(type(faces[0]).triangulate, faces)
# Combine triangles
triangles = {}
for name, part in parts.items():
triangles[name] = list(part.triangles)
for face in part.faces:
triangles[name].extend(faceTriangles.pop(0))
if "left" in name:
triangles[name] = [t.mirroredX().reversed() for t in triangles[name]]
# Generate files
for name in parts.keys():
files[name + ".stl"] = stl.toBytes(triangles[name])
return files
================================================
FILE: body/chrumm/part/__init__.py
================================================
from .body import Body
from .floor import Floor
from .knob import Knob
from .palm import Palm
from .plan import Plan
from .support import Support
__all__ = [
"Body",
"Floor",
"Knob",
"Palm",
"Plan",
"Support"]
================================================
FILE: body/chrumm/part/arc.py
================================================
import math
from chrumm import cfg
from chrumm.geo import Edge
from chrumm.geo import Vector
def arc2D(radius, startAngle=0, spanAngle=math.tau, center=Vector()):
if radius < 1e-6:
return Edge(center)
# https://en.wikipedia.org/wiki/Sagitta_(geometry)
maxChordHeight = cfg.quality.maxChordHeight
maxHeightAngle = math.acos(1 - maxChordHeight/radius) * 2
maxChordAngle = min(cfg.quality.maxChordAngle, maxHeightAngle)
spanAngle = max(-math.tau, min(spanAngle, math.tau))
chordCount = math.ceil(abs(spanAngle) / maxChordAngle)
chordAngle = spanAngle / chordCount
pointCount = chordCount
# Avoid duplicate start and end points of a full circle
if abs(abs(spanAngle) - math.tau) > 1e-6:
pointCount += 1
edge = Edge()
for i in range(pointCount):
angle = startAngle + i*chordAngle
x = center.x + radius*math.cos(angle)
y = center.y + radius*math.sin(angle)
edge.add(Vector(x, y))
return edge
def cornerArc2D(radius, a, b, c):
# a
# /
# b(
# \
# c
aDir = (a - b).normalized2D()
cDir = (c - b).normalized2D()
sign = 1 if cDir.cross(aDir).z > 0 else -1
cornerAngle = math.acos(aDir.dot(cDir))
arcAngle = (math.pi - cornerAngle)*sign
startAngle = (aDir.ortho2D()*sign).angle2D()
centerDist = radius / math.sin(cornerAngle/2)
centerDir = (aDir + cDir).normalized2D()
centerPos = b.xy + centerDir*centerDist
return arc2D(radius, startAngle, arcAngle, centerPos)
def uprightHole2D(radius):
"""Return hole sketch intended for upright FFF 3D printing."""
# _____ <- Clean top bridge
# / \ <- Straight tangent
# : + : <- Circular bottom
# : :
# '-...-'
if cfg.support:
angle = cfg.support.holeTaperAngle
if angle > 0:
arc = arc2D(radius, math.tau/4 + angle, math.tau - 2*angle)
top = Vector(-radius * math.sin(angle/2) / math.cos(angle/2), radius)
return Edge(top, arc, top.mirroredX())
return arc2D(radius, 0, math.tau)
def uprightHalfHole2D(radius):
"""Return upper hole sketch intended for upright FFF 3D printing."""
# _____ <- Clean top bridge
# / \ <- Straight tangent
# : + : <- Circular sides
if cfg.support:
angle = cfg.support.holeTaperAngle
if angle > 0:
arc = arc2D(radius, 0, math.tau/8)
arc.add(Vector(radius * math.sin(angle/2) / math.cos(angle/2), radius))
return Edge(arc, arc.mirroredX().reversed())
return arc2D(radius, 0, math.pi)
================================================
FILE: body/chrumm/part/body.py
================================================
import logging
from chrumm import cfg
from chrumm.geo import Edge
from chrumm.geo import Face
from chrumm.geo import Line
from chrumm.geo import Plane
from chrumm.geo import Vector
from chrumm.geo import Triangle
from .arc import cornerArc2D
from .bracket import CornerBracket
from .bracket import RoofBracket
from .cable import Cable
from .encoder import Encoder
log = logging.getLogger(__name__)
class Body:
def __init__(self, plan):
self.outlineI = Edge()
self.outlineO = Edge()
self.bracketF = None
self.bracketB = None
self.faces = []
self.triangles = []
wallThickness = cfg.body.wallThickness
innerChamfer = cfg.body.innerChamfer
outerChamfer = cfg.body.outerChamfer
outerCornerRadius = cfg.body.outerCornerRadius
innerCornerRadius = outerCornerRadius - wallThickness
floorLipHeight = cfg.floor.lipHeight + cfg.floor.lipMargin
if innerChamfer >= innerCornerRadius:
raise ValueError(f"body.innerChamfer must be less than: {innerCornerRadius:.3f}")
if innerChamfer >= cfg.boss.innerWallFillet:
raise ValueError("body.innerChamfer must be less than boss.innerWallFillet.")
if outerChamfer >= outerCornerRadius:
raise ValueError("body.outerChamfer must be less than body.outerCornerRadius.")
# Reference points
alnumILB = plan.points.alnumILB
alnumIRB = plan.points.alnumIRB
pinkyIRB = plan.points.pinkyIRB
pinkyIRF = plan.points.pinkyIRF
thumbILF = plan.points.thumbILF
thumbILB = plan.points.thumbILB
thumbIRF = plan.points.thumbIRF
alnumILF = plan.points.alnumILF
alnumIRF = plan.points.alnumIRF
ridgeIRF = plan.points.ridgeIRF
alnumOLB = plan.points.alnumOLB
alnumORB = plan.points.alnumORB
pinkyORB = plan.points.pinkyORB
pinkyORF = plan.points.pinkyORF
thumbOLF = plan.points.thumbOLF
thumbORF = plan.points.thumbORF
thumbOLB = plan.points.thumbOLB
alnumOLF = plan.points.alnumOLF
alnumORF = plan.points.alnumORF
ridgeORF = plan.points.ridgeORF
bossLB = plan.bosses.alnumB
bossRB = plan.bosses.pinkyB
bossRF = plan.bosses.pinkyF
bossLF = plan.bosses.thumbF
alnumNorm = plan.planes.alnumOT.normal
pinkyNorm = plan.planes.pinkyOT.normal
thumbNorm = plan.planes.thumbOT.normal
alnumKeys = plan.layout.alnum(plan.side)
thumbKeys = plan.layout.thumb(plan.side)
pinkyKeys = plan.layout.pinky(plan.side)
# Rounded corner edges
cornerILFG = cornerArc2D(innerCornerRadius, thumbIRF, thumbILF, thumbILF.yz)
cornerIRFG = cornerArc2D(innerCornerRadius, pinkyIRB, pinkyIRF, thumbILF)
cornerIRBG = cornerArc2D(innerCornerRadius, alnumIRB, pinkyIRB, pinkyIRF)
cornerOLFG = cornerArc2D(outerCornerRadius, thumbOLF.yz, thumbOLF, thumbORF)
cornerORFG = cornerArc2D(outerCornerRadius, thumbOLF, pinkyORF, pinkyORB)
cornerORBG = cornerArc2D(outerCornerRadius, pinkyORF, pinkyORB, alnumORB)
cornerILFT = Edge(plan.planes.thumbIT.projectZ(p) for p in cornerILFG)
cornerIRFT = Edge(plan.planes.pinkyIT.projectZ(p) for p in cornerIRFG)
cornerIRBT = Edge(plan.planes.pinkyIT.projectZ(p) for p in cornerIRBG)
cornerOLFT = Edge(plan.planes.thumbOT.projectZ(p) for p in cornerOLFG)
cornerORFT = Edge(plan.planes.pinkyOT.projectZ(p) for p in cornerORFG)
cornerORBT = Edge(plan.planes.pinkyOT.projectZ(p) for p in cornerORBG)
bossEdgeLBT = Edge(plan.planes.alnumIT.projectZ(p) for p in bossLB.wallEdge)
bossEdgeRBT = Edge(plan.planes.pinkyIT.projectZ(p) for p in bossRB.wallEdge)
bossEdgeRFT = Edge(plan.planes.pinkyIT.projectZ(p) for p in bossRF.wallEdge)
bossEdgeLFT = Edge(plan.planes.thumbIT.projectZ(p) for p in bossLF.wallEdge)
if (bossEdgeRBT[-1].x >= cornerIRBT[0].x
or bossEdgeRFT[-1].x <= thumbIRF.x
or bossEdgeLFT[-1].x <= cornerILFT[0].x):
raise ValueError(
"A screw boss is overlapping a wall corner.\n"
" Try to decrease the boss size,\n"
" support.minOverhangAngle, or body.splitAngle.")
# Alnum front wall (step)
stepDir = (thumbOLB - alnumOLF).normalized()
stepCornerRadiusO = outerCornerRadius if outerChamfer > 0 else 0
stepCornerRadiusI = outerCornerRadius + wallThickness if innerChamfer > 0 else 0
# Because the chamfer tapers off, its end is scaled up for visual balance.
alnumILF = _chamfer(ridgeIRF, alnumILF, alnumIRF, alnumNorm, innerChamfer*1.25)
alnumOLF = _chamfer(ridgeORF, alnumOLF, alnumORF, alnumNorm, outerChamfer*1.25)
stepChamferPlaneI = Plane.fromPoints(alnumIRF, alnumILF, ridgeIRF)
stepChamferPlaneO = Plane.fromPoints(alnumORF, alnumOLF, ridgeORF)
stepCornerArcI = cornerArc2D(stepCornerRadiusI, ridgeIRF, thumbILB, alnumIRF)
stepCornerArcO = cornerArc2D(stepCornerRadiusO, alnumORF, thumbOLB, ridgeORF)
stepCornerIG = Edge(plan.planes.thumbIT.projectZ(p) for p in stepCornerArcI)
stepCornerOG = Edge(plan.planes.thumbOT.projectZ(p) for p in stepCornerArcO)
stepCornerIT = Edge(stepChamferPlaneI.intersect(Line(p, stepDir)) for p in stepCornerIG)
stepCornerOT = Edge(stepChamferPlaneO.intersect(Line(p, stepDir)) for p in stepCornerOG)
stepEdgeIG = Edge(ridgeIRF, stepCornerIG, alnumIRF)
stepEdgeIT = Edge(ridgeIRF, stepCornerIT, alnumIRF)
stepEdgeOG = Edge(alnumORF, stepCornerOG, ridgeORF)
stepEdgeOT = Edge(alnumORF, stepCornerOT, ridgeORF)
self.triangles.extend(stepEdgeIT.meshPairwise(stepEdgeIG))
self.triangles.extend(stepEdgeOT.meshPairwise(stepEdgeOG))
self.triangles.extend(Edge(alnumILF).meshPairwise(stepEdgeIT))
self.triangles.extend(Edge(alnumOLF).meshPairwise(stepEdgeOT))
# Outer chamfer
chamferDownO = Vector(0, 0, -outerChamfer)
cornerOLFC = cornerOLFT.translated(chamferDownO)
cornerORFC = cornerORFT.translated(chamferDownO)
cornerORBC = cornerORBT.translated(chamferDownO)
thumbORFC = thumbORF + chamferDownO
alnumORBC = alnumORB + chamferDownO
alnumOLBC = alnumOLB + chamferDownO
cornerOLFT = _cornerChamfer(thumbOLF.yz, cornerOLFT, thumbORF, thumbNorm, outerChamfer)
cornerORFT = _cornerChamfer(thumbORF, cornerORFT, pinkyORB, pinkyNorm, outerChamfer)
cornerORBT = _cornerChamfer(pinkyORF, cornerORBT, alnumORB, pinkyNorm, outerChamfer)
thumbORFT = _parallelChamfer(pinkyORF, thumbORF, alnumORF, cornerORFT[0])
alnumORBT = _parallelChamfer(pinkyORB, alnumORB, alnumORF, cornerORBT[-1])
alnumOLBT = _parallelChamfer(alnumORB, alnumOLB, alnumOLF, alnumORBT)
alnumILBG = alnumILB.xy
alnumOLBG = alnumOLB.xy
# Inner chamfer
chamferDownI = Vector(0, 0, -innerChamfer)
bossEdgeLBC = bossEdgeLBT.translated(chamferDownI)
bossEdgeRBC = bossEdgeRBT.translated(chamferDownI)
bossEdgeRFC = bossEdgeRFT.translated(chamferDownI)
bossEdgeLFC = bossEdgeLFT.translated(chamferDownI)
cornerIRBC = cornerIRBT.translated(chamferDownI)
cornerIRFC = cornerIRFT.translated(chamferDownI)
cornerILFC = cornerILFT.translated(chamferDownI)
alnumILBC = alnumILB + chamferDownI
alnumIRBC = alnumIRB + chamferDownI
thumbIRFC = thumbIRF + chamferDownI
bossEdgeLBT = _cornerChamfer(alnumILB, bossEdgeLBT, alnumIRB, -alnumNorm, innerChamfer)
bossEdgeRBT = _cornerChamfer(alnumIRB, bossEdgeRBT, pinkyIRB, -pinkyNorm, innerChamfer)
bossEdgeRFT = _cornerChamfer(pinkyIRF, bossEdgeRFT, thumbIRF, -pinkyNorm, innerChamfer)
bossEdgeLFT = _cornerChamfer(thumbIRF, bossEdgeLFT, thumbILF, -thumbNorm, innerChamfer)
cornerIRBT = _cornerChamfer(alnumIRB, cornerIRBT, pinkyIRF, -pinkyNorm, innerChamfer)
cornerIRFT = _cornerChamfer(pinkyIRB, cornerIRFT, thumbIRF, -pinkyNorm, innerChamfer)
cornerILFT = _cornerChamfer(thumbIRF, cornerILFT, thumbILF.yz, -thumbNorm, innerChamfer)
if cfg.body.thumbTentAngle == cfg.body.pinkyTentAngle:
alnumIRBT = _parallelChamfer(pinkyIRB, alnumIRB, alnumIRF, cornerIRBT[0])
alnumILBT = _parallelChamfer(alnumIRB, alnumILB, alnumILF, alnumIRBT)
thumbIRFT = _parallelChamfer(pinkyIRF, thumbIRF, alnumIRF, cornerIRFT[-1])
else:
alnumIRBT = _parallelChamfer(pinkyIRB, alnumIRB, thumbIRF, cornerIRBT[0])
alnumILBT = _parallelChamfer(alnumIRB, alnumILB, alnumILF, alnumIRBT)
thumbIRFT = _parallelChamfer(pinkyIRF, thumbIRF, alnumIRB, cornerIRFT[-1])
# Key plates
alnumEdgeI = Edge(bossEdgeLBT, alnumIRBT, alnumIRF, alnumILF, alnumILBT)
pinkyEdgeI = Edge(alnumIRBT, bossEdgeRBT, cornerIRBT, cornerIRFT, bossEdgeRFT, thumbIRFT)
thumbEdgeI = Edge(thumbIRFT, bossEdgeLFT, cornerILFT, ridgeIRF, stepCornerIG, alnumIRF)
alnumEdgeO = Edge(alnumOLF, alnumORF, alnumORBT, alnumOLBT)
pinkyEdgeO = Edge(cornerORFT, cornerORBT, alnumORBT, alnumORF, thumbORFT)
thumbEdgeO = Edge(cornerOLFT, thumbORFT, alnumORF, stepCornerOG, ridgeORF)
self.faces.append(Face(alnumEdgeI, [k.roofHoleI for k in alnumKeys]))
self.faces.append(Face(pinkyEdgeI, [k.roofHoleI for k in pinkyKeys]))
self.faces.append(Face(thumbEdgeI, [k.roofHoleI for k in thumbKeys]))
self.faces.append(Face(alnumEdgeO, [k.roofHoleO for k in alnumKeys]))
self.faces.append(Face(pinkyEdgeO, [k.roofHoleO for k in pinkyKeys]))
self.faces.append(Face(thumbEdgeO, [k.roofHoleO for k in thumbKeys]))
self.triangles.append(Triangle(alnumIRF, alnumIRBT, thumbIRFT))
for key in alnumKeys + pinkyKeys + thumbKeys:
self.triangles.extend(key.triangles)
# Encoder hole
splitEdgeF = Edge()
splitEdgeB = Edge()
encoderEdgeI = Edge(alnumILF.yz, alnumILF, ridgeIRF, ridgeIRF.yz)
encoderEdgeO = Edge(ridgeORF.yz, ridgeORF, alnumOLF, alnumOLF.yz)
if cfg.encoder:
encoderRelPos = cfg.encoder.relPosition
encoderPos = ridgeORF.yz*(1-encoderRelPos) + alnumOLF.yz*encoderRelPos
encoderPlaneI = Plane.fromPoints(ridgeIRF, alnumILF, ridgeIRF.yz)
encoderPlaneO = Plane.fromPoints(ridgeORF, alnumOLF, ridgeORF.yz)
encoder = Encoder(Plane(encoderPos, encoderPlaneO.normal), encoderPlaneI)
for p in encoder.roofEdgeI:
if p.x > 0 and not encoderEdgeI.contains2D(p):
raise ValueError(
"The encoder does not fit inside the ridge.\n"
" Try to adjust the encoder cfg, or increase the ridge size.")
splitEdgeF.add(encoder.splitEdgeF)
splitEdgeB.add(encoder.splitEdgeB)
encoderEdgeI.add(encoder.roofEdgeI)
encoderEdgeO.add(encoder.roofEdgeO)
self.triangles.extend(encoder.triangles)
else:
splitEdgeF.add(ridgeORF, ridgeIRF)
splitEdgeB.add(ridgeIRF, ridgeORF)
self.faces.append(Face(encoderEdgeI))
self.faces.append(Face(encoderEdgeO))
# Ridge and brackets
ridgeEdgeIT = Edge(alnumILBT, alnumILF, alnumILF.yz)
ridgeEdgeIB = Edge(alnumILBG.yz, alnumILBG, alnumILBC)
ridgeEdgeOB = Edge(alnumOLBC.yz, alnumOLBC, alnumOLBG, alnumOLBG.yz)
ridgeEdgeORF = Edge(cornerOLFG[0], cornerOLFC[0], cornerOLFT[0], ridgeORF)
ridgeEdgeORB = Edge(alnumOLBC, alnumOLBT, alnumOLF)
ridgeEdgeIRF = Edge(
cornerILFG[-1].yz, cornerILFG[-1],
cornerILFC[-1], cornerILFT[-1],
ridgeIRF, ridgeIRF.yz)
ridgeEdgeILF = ridgeEdgeIRF.yz
splitEdgeF = Edge(cornerILFG[-1], ridgeEdgeORF, splitEdgeF, ridgeIRF)
splitEdgeB = Edge(alnumILF, splitEdgeB, alnumOLF, alnumOLBT, alnumOLBC)
splitHolesF = []
splitHolesB = []
if cfg.bracket:
bracketF = CornerBracket(cornerILFT[-1], ridgeIRF, cornerILFC[-1], plan.side)
bracketB = CornerBracket(alnumILBT, alnumILF, alnumILBC, plan.side)
bracketT = RoofBracket(alnumILBT, alnumILF, plan.layout.pinky()[0].matrix, plan.side)
# Check if brackets fit
thumbLineL = Line(ridgeIRF, cornerILFT[-1] - ridgeIRF)
bracketGapFB = ridgeIRF.y - max(p.y for p in bracketF.splitEdge)
bracketGapFR = min(thumbLineL.distance2D(p) for p in bracketF.roofEdge)
bracketGapFG = min(p.z for p in bracketF.splitEdge) - floorLipHeight
if bracketGapFB < 1e-3 or bracketGapFR < 1e-3 or bracketGapFG < 1e-3:
raise ValueError("The front bracket does not fit.")
bracketGapBR = alnumILBC.x - bracketB.wallEdge[-1].x
bracketGapBG = min(p.z for p in bracketB.splitEdge) - floorLipHeight
if bracketGapBR < 1e-3 or bracketGapBG < 1e-3:
raise ValueError("The back bracket does not fit.")
# Cable hole
if cfg.cable:
cable = Cable(bracketB.wallEdge[0], alnumOLBG.y - alnumILBG.y)
if cable.splitEdgeG[-1].z < 1e-3 or cable.splitEdgeG[0].z < floorLipHeight:
raise ValueError("The cable hole does not fit.")
splitEdgeB.add(cable.splitEdgeT)
ridgeEdgeOB.add(cable.wallEdgeB)
ridgeEdgeIB = Edge(cable.wallEdgeF, ridgeEdgeIB)
splitEdgeBG = Edge(alnumOLBG.yz, alnumILBG.yz, cable.splitEdgeG)
self.faces.append(Face(splitEdgeBG))
self.triangles.extend(cable.triangles)
else:
splitEdgeB.add(alnumOLBG.yz, alnumILBG.yz)
# Ridge edges
ridgeEdgeIT.add(bracketT.roofEdge)
ridgeEdgeIT.add(reversed(bracketB.roofEdge))
ridgeEdgeIB.add(reversed(bracketB.wallEdge))
ridgeEdgeILF = Edge(bracketF.wallEdge, bracketF.roofEdge)
splitEdgeF.add(bracketF.splitEdge)
splitEdgeB.add(bracketB.splitEdge)
splitEdgeB.add(bracketT.splitEdge)
splitHolesB.extend(bracketB.splitHoles)
splitHolesB.append(bracketT.splitHole)
splitHolesF.extend(bracketF.splitHoles)
self.triangles.extend(bracketB.triangles)
self.triangles.extend(bracketF.triangles)
self.triangles.extend(bracketT.triangles)
self.bracketF = bracketF
self.bracketB = bracketB
else:
ridgeEdgeIT.add(alnumILBT.yz)
ridgeEdgeIB.add(alnumILBC.yz)
splitEdgeB.add(alnumOLBG.yz, alnumILBG.yz, alnumILBC, alnumILBT)
splitEdgeF.add(cornerILFT[-1], cornerILFC[-1])
self.faces.append(Face(splitEdgeF.yz.collapsed(), splitHolesF))
self.faces.append(Face(splitEdgeB.yz.collapsed(), splitHolesB))
self.faces.append(Face(ridgeEdgeOB))
self.faces.append(Face(ridgeEdgeIT))
self.faces.append(Face(ridgeEdgeIB.collapsed()))
self.triangles.extend(ridgeEdgeORF.meshPairwise(ridgeEdgeORF.yz))
self.triangles.extend(ridgeEdgeORB.yz.meshPairwise(ridgeEdgeORB))
self.triangles.extend(ridgeEdgeILF.meshPairwise(ridgeEdgeIRF))
# Chamfer
chamferIC = Edge(
bracketB.wallEdge[-1] if cfg.bracket else alnumILBC.yz,
alnumILBC, bossEdgeLBC, alnumIRBC, bossEdgeRBC, cornerIRBC,
cornerIRFC, bossEdgeRFC, thumbIRFC, bossEdgeLFC, cornerILFC)
chamferIT = Edge(
bracketB.roofEdge[0] if cfg.bracket else alnumILBT.yz,
alnumILBT, bossEdgeLBT, alnumIRBT, bossEdgeRBT, cornerIRBT,
cornerIRFT, bossEdgeRFT, thumbIRFT, bossEdgeLFT, cornerILFT)
chamferOC = Edge(cornerOLFC, thumbORFC, cornerORFC, cornerORBC, alnumORBC, alnumOLBC)
chamferOT = Edge(cornerOLFT, thumbORFT, cornerORFT, cornerORBT, alnumORBT, alnumOLBT)
self.triangles.extend(chamferOC.meshPairwise(chamferOT))
self.triangles.extend(chamferIC.meshPairwise(chamferIT))
# Walls
wallEdgeOC = Edge(
cornerOLFC, thumbORFC, cornerORFC,
cornerORBC, alnumORBC, alnumOLBC)
wallEdgeIC = Edge(
alnumILBC, bossEdgeLBC, alnumIRBC, bossEdgeRBC, cornerIRBC,
cornerIRFC, bossEdgeRFC, thumbIRFC, bossEdgeLFC, cornerILFC)
self.triangles.extend(wallEdgeOC.xy.meshPairwise(wallEdgeOC))
self.triangles.extend(wallEdgeIC.xy.meshPairwise(wallEdgeIC))
# Boss threads
self.triangles.extend(bossLB.threadTriangles)
self.triangles.extend(bossRB.threadTriangles)
self.triangles.extend(bossRF.threadTriangles)
self.triangles.extend(bossLF.threadTriangles)
# Floor face
floorEdge = Edge(
alnumOLBG, alnumOLBG.yz, alnumILBG.yz, alnumILBG,
bossEdgeLBC, alnumIRB, bossEdgeRBC, cornerIRBG,
cornerIRFG, bossEdgeRFC, thumbIRF, bossEdgeLFC, cornerILFG,
thumbILF.yz, thumbOLF.yz, cornerOLFG, thumbORF,
cornerORFG, cornerORBG, alnumORB)
self.faces.append(Face(floorEdge.xy.collapsed().reversed(), [
bossLB.threadHole,
bossRB.threadHole,
bossRF.threadHole,
bossLF.threadHole]))
# Floor outlines
self.outlineO.add(thumbOLF.yz, cornerOLFG)
self.outlineI.add(
alnumILB.yz, alnumILB, bossEdgeLBC, bossEdgeRBC, cornerIRBG,
cornerIRFG, bossEdgeRFC, bossEdgeLFC, cornerILFG, thumbILF.yz)
if cfg.palm:
hitchRadius = cfg.palm.hitchCornerRadius
hitchOLB = plan.points.hitchOLB
hitchORB = plan.points.hitchORB
hitchOLF = plan.points.hitchOLF
hitchORF = plan.points.hitchORF
self.outlineO.add(
cornerArc2D(hitchRadius, thumbOLF, hitchOLB, hitchOLF),
cornerArc2D(hitchRadius, hitchOLB, hitchOLF, hitchORF),
cornerArc2D(hitchRadius, hitchOLF, hitchORF, hitchORB),
cornerArc2D(hitchRadius, hitchORF, hitchORB, cornerORFG[0]))
self.outlineO.add(cornerORFG, cornerORBG, alnumOLB, alnumOLB.yz)
self.outlineO = self.outlineO.xy.collapsed()
self.outlineI = self.outlineI.xy.collapsed().reversed()
def _chamfer(a, b, c, normal, chamferSize):
"""Project points onto normal plane and return chamfer offset."""
# c
# /
# b-->?
# \
# a
# Project lines onto normal plane
plane = Plane(b, normal)
ab = Line(b, b - plane.projectNormal(a))
bc = Line(b, plane.projectNormal(c) - b)
# Move lines inward
ab = ab.translated(normal.cross(ab.dir).normalized()*chamferSize)
bc = bc.translated(normal.cross(bc.dir).normalized()*chamferSize)
try:
return ab.intersect(bc)
except ZeroDivisionError:
return ab.pos
def _parallelChamfer(a, b, c, prevChamfer):
"""Extend existing chamfer to coplanar edge."""
# c
# /
# ?<----prevChamfer
# b-----a
parallel = Line(prevChamfer, b - a)
cutoff = Line(b, c - b)
return parallel.intersect(cutoff)
def _cornerChamfer(prevPoint, corner, nextPoint, normal, chamferSize):
"""Return chamfered corner edge"""
edge = Edge()
for i, b in enumerate(corner):
a = corner[i-1] if i > 0 else prevPoint
c = corner[i+1] if i < len(corner)-1 else nextPoint
edge.add(_chamfer(a, b, c, normal, chamferSize))
return edge
================================================
FILE: body/chrumm/part/boss.py
================================================
import logging
import math
from chrumm import cfg
from chrumm.geo import Edge
from chrumm.geo import Matrix
from chrumm.geo import Plane
from chrumm.geo import Vector
from .arc import arc2D
from .arc import uprightHole2D
log = logging.getLogger(__name__)
class Boss:
def __init__(self, pos, printDirection, wallDirection=None, roofPlane=None):
self.pos = pos
self.wallEdge = Edge()
self.headHole = Edge()
self.threadHole = Edge()
self.clearanceHole = Edge()
self.headTriangles = []
self.threadTriangles = []
self._initWall(pos, wallDirection)
self._initHead(pos)
self._initThread(pos, printDirection, roofPlane)
def _initWall(self, pos, wallDirection):
wallThickness = cfg.body.wallThickness
bossRadius = cfg.boss.diameter/2
bossFillet = cfg.boss.innerWallFillet
wallMargin = cfg.boss.outerWallMargin
protrusion = bossRadius*2 + wallMargin - wallThickness
if wallDirection is None or protrusion < wallThickness/10:
return
taperAngleR = 0
taperAngleL = 0
if cfg.support:
overhangAngle = cfg.support.minOverhangAngle
if wallDirection.x < 0:
taperAngleR = overhangAngle - (-wallDirection).angle2D()
else:
taperAngleL = overhangAngle + wallDirection.angle2D()
self.wallEdge.add(_smoothTransition(
protrusion,
bossRadius,
bossFillet,
taperAngleR))
self.wallEdge.add(_smoothTransition(
protrusion,
bossRadius,
bossFillet,
taperAngleL).mirroredX().reversed())
matrix = Matrix().rotatedZ(wallDirection.angle2D()).translated(pos)
self.wallEdge = self.wallEdge.collapsed().transformed(matrix)
def _initHead(self, pos):
outerHeight = cfg.floor.outerHeight
sinkRadius = cfg.boss.countersinkDiameter/2
clearRadius = cfg.boss.clearanceHoleDiameter/2
clearLength = cfg.boss.clearanceHoleLength
sinkLength = min(outerHeight, clearLength + (sinkRadius - clearRadius))
sinkRadius = min(sinkRadius, clearRadius + (outerHeight - clearLength))
# Head profile
# ___0 z=0
# | |
# |___1 -clearLength
# / \
# /_______2 -sinkLength
# | |
# |_______3 -outerHeight
arcs = []
profile = [
(clearRadius, 0),
(clearRadius, -clearLength),
(sinkRadius, -sinkLength),
(sinkRadius, -outerHeight)]
protoArc = arc2D(clearRadius, 0, math.tau).scaled(1 / clearRadius)
for scale, z in profile:
arcPos = Vector(pos.x, pos.y, z)
arcs.append(protoArc.scaled(scale).translated(arcPos))
for i in range(len(arcs) - 1):
self.headTriangles.extend(arcs[i].meshPairwise(arcs[i+1], True))
self.clearanceHole = arcs[0].reversed()
self.headHole = arcs[-1]
def _initThread(self, pos, printDirection, roofPlane):
radius = cfg.boss.threadDiameter/2
minLength = cfg.boss.minThreadLength
maxLength = cfg.boss.maxThreadLength
roofMargin = cfg.boss.threadRoofMargin
chamfer = cfg.boss.threadChamfer
# Trim parallel to roof
if roofPlane:
marginPlane = roofPlane.translated(roofPlane.normal * -roofMargin)
tipZ = min(maxLength, marginPlane.projectZ(pos).z)
if tipZ < minLength:
raise ValueError(
f"A boss thread overlaps the roof margin by: {minLength - tipZ:.3f}\n"
" Try to decrease boss.minThreadLength, boss.threadRoofMargin,\n"
" or increase body.minRoofHeight.")
if tipZ < maxLength:
log.debug("Excess length of trimmed boss thread: %.3f", tipZ - minLength)
tipPlane = Plane(Vector(pos.x, pos.y, tipZ), roofPlane.normal)
else:
tipPlane = Plane(Vector(pos.x, pos.y, maxLength), Vector(0, 0, 1))
# Thread profile
# ___2 tip
# | |
# |___1 chamfer
# / \
# /_______0 z=0
arcs = []
profile = [
(radius + chamfer/2, 0),
(radius, chamfer)]
rotation = Matrix().rotatedZ(printDirection.angle2D() - math.tau/4)
protoArc = uprightHole2D(radius).scaled(1/radius).transformed(rotation)
for scale, z in profile:
arcPos = Vector(pos.x, pos.y, z)
arcs.append(protoArc.scaled(scale).translated(arcPos))
arcs.append(Edge(tipPlane.projectZ(p) for p in arcs[-1]))
arcs.append(Edge(tipPlane.pos))
for i in range(len(arcs) - 1):
self.threadTriangles.extend(arcs[i+1].meshPairwise(arcs[i], True))
self.threadHole = arcs[0]
def _smoothTransition(protrusion, bossRadius, bossFillet, minTaperAngle):
# <--.. \ --------protrusion--
# '.\ ^
# boss o |
# \'. fillet |
# \ ''--o --wall--
# tangent
filletCenterY = bossRadius - protrusion + bossFillet
filletCenterX = max(0, (bossRadius + bossFillet)**2 - filletCenterY**2)**0.5
filletCenter = Vector(filletCenterX, filletCenterY)
tangentAngle = min(max(0, minTaperAngle, filletCenter.angle2D()), math.radians(89))
arcAngle = math.tau/4 - tangentAngle
filletArc = arc2D(bossFillet, -math.tau/4, -arcAngle, filletCenter)
bossArc = arc2D(bossRadius, tangentAngle, arcAngle)
gapY = bossArc[0].y - filletArc[-1].y
gapX = gapY / math.cos(tangentAngle) * math.sin(tangentAngle)
offset = Vector(gapX - (filletArc[-1].x - bossArc[0].x))
return Edge(filletArc.translated(offset), bossArc).collapsed()
================================================
FILE: body/chrumm/part/bracket.py
================================================
import math
from chrumm import cfg
from chrumm.geo import Edge
from chrumm.geo import Face
from chrumm.geo import Line
from chrumm.geo import Matrix
from chrumm.geo import Vector
from .arc import arc2D
from .arc import cornerArc2D
from .arc import uprightHole2D
class CornerBracket:
"""Bracket between wall and roof, snapped to the yz plane."""
def __init__(self, a, b, c, side):
# b<---------a
# \ \ ( ) c
# \ (\_____| z
# \___/ | xy
a = a.yz
b = b.yz
c = c.yz
self.wallEdge = Edge()
self.roofEdge = Edge()
self.splitEdge = Edge()
self.splitHoles = []
self.triangles = []
nutAcross = cfg.bracket.nutAcrossFlats
nutRadius = nutAcross / 3**0.5
boreRadius = cfg.bracket.counterboreDiameter/2
boreLength = cfg.bracket.counterboreLength
holeLength = cfg.bracket.holeLength
taperAngle = cfg.bracket.taperAngle
taperMargin = cfg.bracket.taperMargin
wallMargin = cfg.bracket.wallMargin
baseMargin = cfg.bracket.baseMargin
apexMargin = cfg.bracket.apexMargin
extraMargin = cfg.bracket.backCornerExtraMargin
cornerRadius = cfg.bracket.cornerRadius
cableInset = cfg.cable.bracketInset if cfg.cable else 0
# Always construct from back to front
isBackward = a.y < b.y
if isBackward:
a = a.mirroredY()
b = b.mirroredY()
c = c.mirroredY()
extraMargin = 0
cableInset = 0
# Sketch on yz plane
wallDir = Vector(0, 0, 1)
wallOrtho = Vector(0, 1, 0)
roofDir = (b - a).normalized()
roofOrtho = Vector(0, roofDir.z, -roofDir.y)
roofAngle = Vector(-roofDir.y, -roofDir.z).angle2D()
taperDir = Vector(0, -math.sin(taperAngle), math.cos(taperAngle))
taperOrtho = Vector(0, -taperDir.z, taperDir.y)
nutRoofDist = _hexTangentDist(nutRadius, roofAngle)
nutTaperDist = _hexTangentDist(nutRadius, taperAngle - math.pi/2)
roofOffset = max(boreRadius, nutRoofDist) + baseMargin + extraMargin
wallOffset = max(boreRadius, nutRadius) + wallMargin + extraMargin
apexOffset = max(boreRadius, nutAcross/2) + apexMargin
taperOffset = max(boreRadius, nutTaperDist) + taperMargin + cableInset
wallOffsetLine = Line(c - wallOrtho*wallOffset, wallDir)
roofOffsetLine = Line(a - roofOrtho*roofOffset, roofDir)
screwCenter = roofOffsetLine.intersect(wallOffsetLine)
wallLine = Line(c, wallDir)
roofLine = Line(a, roofDir)
apexLine = Line(screwCenter - Vector(0, 0, apexOffset), Vector(0, 1))
taperLine = Line(screwCenter + taperOrtho*taperOffset, taperDir)
# Edges and triangles
archLBG = wallLine.intersect(apexLine)
archLFG = taperLine.intersect(apexLine)
archLFT = taperLine.intersect(roofLine)
arcL = _archCorner(cornerRadius, archLBG, archLFG, archLFT)
archL = Edge(archLBG, arcL, archLFT)
moveR = Vector(holeLength + boreLength)
archR = archL.translated(moveR)
self.wallEdge = Edge(archL[0], archR[0], c + moveR)
self.roofEdge = Edge(a + moveR, archR[-1], archL[-1])
self.splitEdge = archL
holeTriangles, holeL, holeR = _screwHole(screwCenter, side, False)
edgeR = Edge(archR, self.roofEdge[0], self.wallEdge[-1])
faceR = Face(edgeR.collapsed().reversed(), [holeR])
self.triangles.extend(holeTriangles)
self.triangles.extend(faceR.triangulate())
self.splitHoles.append(holeL.reversed())
# Ziptie
if cfg.cable and not isBackward:
cableDiameter = cfg.cable.diameter
zipWidth = cfg.cable.ziptieWidth
zipHeight = cfg.cable.ziptieHeight
humpWidth = cfg.cable.gripHumpWidth
humpHeight = cfg.cable.gripHumpHeight
humpRadius = cfg.cable.minBendRadius
humpInset = humpRadius - humpHeight
# Ziptie hump
humpCenterF = taperLine.translated(taperOrtho*(zipHeight + taperMargin - humpRadius))
humpCenterT = apexLine.translated(Vector(0, 0, humpInset))
humpCenter = humpCenterF.intersect(humpCenterT)
bumpCenter = archLBG - Vector(0, 0, cableDiameter + humpRadius)
maxCableDiameter = (humpCenter - bumpCenter).magnitude() - 2*humpRadius
if cableDiameter > maxCableDiameter:
raise ValueError(
"The cable does not fit between the wall bump and grip hump.\n"
" Try to decrease cable.minBendRadius, cable.gripHumpHeight,\n"
" or increase cable.bracketInset.")
minHumpTaper = math.asin(humpInset / humpRadius)
humpSpan = math.pi - taperAngle - max(minHumpTaper, taperAngle)
humpArcXY = arc2D(humpRadius, taperAngle, humpSpan)
humpArc = Edge(Vector(0, -p.x, -p.y) + humpCenter for p in humpArcXY)
humpLineF = Line(humpArc[0], taperLine.dir)
humpLineB = Line(humpArc[-1], taperLine.dir.mirroredY())
humpLineG = Line(apexLine.pos - Vector(0, 0, humpHeight), Vector(0, 1))
humpLFT = humpLineF.intersect(roofLine)
humpLBT = humpLineB.intersect(apexLine)
humpLFG = humpLineG.intersect(humpLineF)
humpRBT = archLFT + Vector(humpWidth/2)
humpArchL = Edge(humpLFT, humpArc, humpLBT).collapsed()
humpArchR = humpArchL.translated(Vector(humpWidth/2))
# Ziptie hole
zipArcCenterXY = Vector(0, zipHeight/2 - zipWidth/2)
zipArcXY = arc2D(zipHeight/2, 0, -math.pi, zipArcCenterXY)
zipHoleXY = zipArcXY.transformed(Matrix().rotatedZ(taperAngle))
zipHoleXY.add(zipHoleXY.mirroredX().mirroredY())
zipCenter = humpLFT*0.4 + humpLFG*0.6 - taperOrtho*(zipHeight/2 + taperMargin)
zipHoleL = Edge(Vector(0, p.x, p.y) + zipCenter for p in zipHoleXY)
zipHoleR = zipHoleL.translated(Vector(humpWidth/2))
if zipHoleL[0].z <= arcL[-1].z:
overlap = taperDir*(zipHoleL[0] - arcL[-1]).magnitude()
zipHoleL = zipHoleL.translated(overlap)
zipHoleR = zipHoleL.translated(Vector(humpWidth/2))
arcR = arcL.translated(Vector(humpWidth/2))
humpEdgeR = Edge(humpArchR, arcR, zipHoleR, humpRBT).collapsed()
self.triangles.extend(Face(humpEdgeR).triangulate())
self.triangles.extend(zipHoleL.meshPairwise(zipHoleR, True))
self.triangles.extend(humpArchL.meshPairwise(humpArchR))
self.splitHoles.append(zipHoleL.reversed())
# Adjust bracket edges
archR = Edge(archL[0], archR)
archL = Edge(humpArchL[-1], humpArchR[-1], arcR, zipHoleR[0], zipHoleR[-1], humpRBT)
self.splitEdge = Edge(humpArchL, archR[0]).reversed()
self.roofEdge = Edge(a + moveR, archR[-1], archL[-1], humpArchR[0], humpArchL[0])
self.triangles.extend(archR.meshPairwise(archL))
if isBackward:
self.wallEdge = self.wallEdge.mirroredY()
self.roofEdge = self.roofEdge.mirroredY()
self.splitEdge = self.splitEdge.mirroredY().reversed()
self.splitHoles = [h.mirroredY().reversed() for h in self.splitHoles]
self.triangles = [t.mirroredY().reversed() for t in self.triangles]
class RoofBracket:
"""Bracket on roof with optional PCB mount, snapped to the yz plane."""
def __init__(self, a, b, refMatrix, side):
# b<------------a z
# \ ( )| |/ xy
# ----| |
# |_|
a = a.yz
b = b.yz
ab = b - a
self.roofEdge = Edge()
self.splitEdge = Edge()
self.splitHole = Edge()
self.triangles = []
nutAcross = cfg.bracket.nutAcrossFlats
nutRadius = nutAcross / 3**0.5
boreRadius = cfg.bracket.counterboreDiameter/2
boreLength = cfg.bracket.counterboreLength
holeLength = cfg.bracket.holeLength
taperAngle = cfg.bracket.taperAngle
taperMargin = cfg.bracket.taperMargin
wallMargin = cfg.bracket.wallMargin
baseMargin = cfg.bracket.baseMargin
apexMargin = cfg.bracket.apexMargin
cornerRadius = cfg.bracket.cornerRadius
# Sketch on yz plane
taperDir = Vector(0, math.sin(taperAngle), math.cos(taperAngle))
taperOrtho = Vector(0, taperDir.z, -taperDir.y)
nutTaperDist = _hexTangentDist(nutRadius, taperAngle - math.pi/2)
roofOffset = max(boreRadius, nutAcross/2) + baseMargin
apexOffset = max(boreRadius, nutAcross/2) + apexMargin
taperOffset = max(boreRadius, nutTaperDist) + taperMargin
lineB = Line(taperOrtho*taperOffset, taperDir)
lineF = Line(lineB.pos.mirroredY(), lineB.dir.mirroredY())
lineG = Line(Vector(0, 0, -apexOffset), Vector(0, 1))
lineT = Line(Vector(0, 0, roofOffset), Vector(0, 1))
archLFT = lineF.intersect(lineT)
archLFG = lineF.intersect(lineG)
archLBG = lineB.intersect(lineG)
archLBT = lineB.intersect(lineT)
archLF = Edge(archLFT, _archCorner(cornerRadius, archLFT, archLFG, archLBG))
archLB = Edge(_archCorner(cornerRadius, archLFG, archLBG, archLBT), archLBT)
# Placement
roofDir = ab.normalized()
roofOrtho = Vector(0, -roofDir.z, roofDir.y)
roofAngle = Vector(-ab.y, -ab.z).angle2D()
roofAlign = Matrix().translated(-lineT.pos).rotatedX(roofAngle)
moveR = Vector(boreLength + holeLength)
if cfg.pcb and cfg.pcb.mount:
bossFG, bossFT, bossBG, bossBT = self._makeBoss(refMatrix)
bossDir = (bossFT.yz - bossFG.yz).normalized()
bossOrtho = Vector(0, -bossDir.z, bossDir.y)
roofLine = Line(a, roofDir)
apexLine = Line(a + roofOrtho*(lineT.pos - lineG.pos).z, roofDir)
armLineF = Line(bossFT.yz, bossDir)
armLineB = Line(bossBT.yz, bossDir)
armOffset = max(boreRadius, nutRadius) + wallMargin
armOffsetLine = Line(bossFT.yz + bossOrtho*armOffset, bossDir)
roofPos = roofLine.intersect(armOffsetLine)
roofAlign = roofAlign.translated(roofPos)
archLF = archLF.transformed(roofAlign)
archLB = archLB.transformed(roofAlign)
armR = Edge(bossBT, bossBG, bossFG, bossFT)
armL = Edge(
roofLine.intersect(armLineB),
apexLine.intersect(armLineB),
apexLine.intersect(armLineF),
roofLine.intersect(armLineF)).translated(moveR)
archLB = archLB.translated(armL[1].yz - archLB[0])
archRF = archLF.translated(moveR)
archRB = archLB.translated(moveR)
archL = Edge(archLF, armL[2].yz, archLB)
archR = Edge(archRF, armL[2], archRB)
faceR = Edge(archRF, armL[2:])
self.triangles.extend(armR.meshPairwise(armL, True))
self.triangles.extend(armL[:2].meshPairwise(archRB.reversed()))
self.roofEdge = Edge(archL[0], archR[0], armL[-1], armL[0], archR[-1], archL[-1])
else:
archD = (archLF[0] - archLB[-1]).magnitude()
roofPos = a + roofDir*(0.85*(ab.magnitude() - archD) + archD/2)
roofAlign = roofAlign.translated(roofPos)
archL = Edge(archLF, archLB).transformed(roofAlign)
archR = archL.translated(moveR)
faceR = archR
self.roofEdge = Edge(archL[0], archR[0], archR[-1], archL[-1])
holeTriangles, holeL, holeR = _screwHole(Vector(), side, False)
holeTriangles = [t.transformed(roofAlign) for t in holeTriangles]
holeL = holeL.transformed(roofAlign)
holeR = holeR.transformed(roofAlign)
self.triangles.extend(holeTriangles)
self.triangles.extend(Face(faceR, [holeR]).triangulate())
self.triangles.extend(archL.meshPairwise(archR))
self.splitEdge = archL.reversed()
self.splitHole = holeL.reversed()
def _makeBoss(self, refMatrix):
splitAngle = cfg.body.splitAngle
bossHeight = cfg.pcb.mount.bossHeight
bossRadius = cfg.pcb.mount.bossDiameter/2
threadRadius = cfg.pcb.mount.threadDiameter/2
nutRadius = cfg.pcb.mount.nutDiameter/2
xOffset = cfg.pcb.mount.xDistToFirstPinky
yOffset = cfg.pcb.mount.yDistToFirstPinky
zOffset = cfg.switch.innerHeight
holeG = uprightHole2D(threadRadius)
holeT = holeG.translated(Vector(0, 0, bossHeight))
edgeG = arc2D(bossRadius, 0, math.pi)
edgeT = edgeG.translated(Vector(0, 0, bossHeight))
armFG = Vector(bossRadius, -bossRadius)
armBG = Vector(-bossRadius, -bossRadius)
armFT = Vector(bossRadius, -nutRadius, bossHeight)
armBT = Vector(-bossRadius, -nutRadius, bossHeight)
rotMatrix = Matrix().rotatedZ(-math.tau/4 - splitAngle)
holeG = holeG.transformed(rotMatrix)
holeT = holeT.transformed(rotMatrix)
edgeG = Edge(armFG, edgeG, armBG).transformed(rotMatrix)
edgeT = Edge(armFT, edgeT, armBT).transformed(rotMatrix)
offset = Vector(-xOffset, -yOffset, -zOffset)
holeG = holeG.translated(offset).transformed(refMatrix)
holeT = holeT.translated(offset).transformed(refMatrix)
edgeG = edgeG.translated(offset).transformed(refMatrix)
edgeT = edgeT.translated(offset).transformed(refMatrix)
self.triangles.extend(holeT.meshPairwise(holeG, True))
self.triangles.extend(edgeG.meshPairwise(edgeT))
self.triangles.extend(Face(edgeT, [holeT.reversed()]).triangulate())
self.triangles.extend(Face(edgeG.reversed(), [holeG]).triangulate())
return edgeG[0], edgeT[0], edgeG[-1], edgeT[-1]
class FloorBracket:
"""Bracket between lip and floor, snapped to the yz plane."""
def __init__(self, a, b, c, d, e, side):
# _____
# / \
# e_d \
# | ( ) \
# c \ z
# a------->b xy
a = a.yz
b = b.yz
c = c.yz
d = d.yz
e = e.yz
self.taperEdge = Edge()
self.splitEdge = Edge()
self.splitHole = Edge()
self.triangles = []
nutAcross = cfg.bracket.nutAcrossFlats
nutRadius = nutAcross / 3**0.5
boreRadius = cfg.bracket.counterboreDiameter/2
boreLength = cfg.bracket.counterboreLength
holeLength = cfg.bracket.holeLength
cornerRadius = cfg.bracket.cornerRadius
taperAngle = cfg.bracket.taperAngle
taperMargin = cfg.bracket.taperMargin
wallMargin = cfg.bracket.wallMargin
baseMargin = cfg.bracket.baseMargin
apexMargin = cfg.bracket.apexMargin
extraMargin = cfg.bracket.frontFloorExtraMargin
# Always construct from front to back
isBackward = a.y > b.y
if isBackward:
a = a.mirroredY()
b = b.mirroredY()
c = c.mirroredY()
d = d.mirroredY()
e = e.mirroredY()
extraMargin = 0
# Sketch on yz plane
taperDir = Vector(0, math.sin(taperAngle), math.cos(taperAngle))
taperOrtho = Vector(0, -taperDir.z, taperDir.y)
nutTaperDist = _hexTangentDist(nutRadius, taperAngle - math.pi/2)
wallOffset = max(boreRadius, nutRadius) + wallMargin + extraMargin
baseOffset = max(boreRadius, nutAcross/2) + baseMargin
apexOffset = max(boreRadius, nutAcross/2) + apexMargin
taperOffset = max(boreRadius, nutTaperDist) + taperMargin
lipT = d.z
bracketG = a.z
bracketT = bracketG + max(baseOffset + apexOffset, lipT)
centerZ = bracketG + baseOffset
lineF = Line(Vector(0, 0, centerZ) + taperOrtho*taperOffset, taperDir)
lineB = Line(lineF.pos.mirroredY(), lineF.dir.mirroredY())
lineG = Line(Vector(0, 0, bracketG), Vector(0, 1))
lineT = Line(Vector(0, 0, bracketT), Vector(0, 1))
lineLip = Line(Vector(0, 0, lipT), Vector(0, 1))
archLFG = lineF.intersect(lineLip)
archLFT = lineF.intersect(lineT)
archLBT = lineB.intersect(lineT)
archLBG = lineB.intersect(lineG)
if bracketT <= lipT:
raise ValueError("The floor bracket must be taller than the floor lip.")
cornerRadiusF = min(cornerRadius, bracketT - lipT)
cornerF = _archCorner(cornerRadiusF, archLBT, archLFT, archLFG)
cornerB = _archCorner(cornerRadius, archLBG, archLBT, archLFT)
archL = Edge(archLBG, cornerB, cornerF, archLFG).collapsed()
# Center position
centerY = e.y - archL[-1].y
if centerY - d.y < wallOffset:
# Avoid e.y < archL[-1].y < d.y
centerY = max(d.y + wallOffset, centerY + d.y - e.y)
screwCenter = Vector(0, centerY, centerZ)
# Edges and triangles
moveR = Vector(boreLength + holeLength)
archL = archL.translated(screwCenter.xy)
archR = archL.translated(moveR)
faceEdge = Edge(archL, d, c, a).translated(moveR)
holeTriangles, holeL, holeR = _screwHole(screwCenter, side, True)
if not archL[-1].isClose(e):
archL.add(e)
archR.add(d + moveR, e + moveR)
self.splitEdge = archL.reversed()
self.splitHole = holeL.reversed()
self.taperEdge = Edge(archL[0], archR[0])
self.lipEdge = Edge(e, d, c, a).translated(moveR)
self.triangles.extend(archL.meshPairwise(archR))
self.triangles.extend(Face(faceEdge.collapsed(), [holeR]).triangulate())
self.triangles.extend(holeTriangles)
if isBackward:
self.lipEdge = self.lipEdge.mirroredY()
self.taperEdge = self.taperEdge.mirroredY().reversed()
self.splitEdge = self.splitEdge.mirroredY().reversed()
self.splitHole = self.splitHole.mirroredY().reversed()
self.triangles = [t.mirroredY().reversed() for t in self.triangles]
def _hexTangentDist(outerRadius, tangentAngle):
"""Return the distance of an angled tangent to the hexagon center."""
# .'
# .'__ )angle
# .'/ \
# \___/
return outerRadius * math.cos(abs(tangentAngle) % (math.pi/3) - math.pi/6)
def _archCorner(radius, a, b, c):
return Edge(Vector(0, p.x, p.y) for p in cornerArc2D(
radius, Vector(a.y, a.z), Vector(b.y, b.z), Vector(c.y, c.z)))
def _screwHole(centerL, side, isUpright):
boreRadius = cfg.bracket.counterboreDiameter/2
boreLength = cfg.bracket.counterboreLength
holeRadius = cfg.bracket.holeDiameter/2
holeLength = cfg.bracket.holeLength
# +---
# | Counterbore
# ---+
# Hole
# ---+
# | z
# +--- yx
holeXY = uprightHole2D(holeRadius) if isUpright else arc2D(holeRadius)
holeYZ = Edge(Vector(0, -p.x, p.y) for p in holeXY)
centerR = centerL + Vector(holeLength)
holeL = holeYZ.translated(centerL)
holeR = holeYZ.translated(centerR)
if side == cfg.bracket.nutSide:
nutAcross = cfg.bracket.nutAcrossFlats
nutRadius = nutAcross / 3**0.5
boreXY = Edge(
Vector(-nutRadius/2, nutAcross/2),
Vector(-nutRadius),
Vector(-nutRadius/2, -nutAcross/2),
Vector(nutRadius/2, -nutAcross/2),
Vector(nutRadius),
Vector(nutRadius/2, nutAcross/2))
else:
boreXY = uprightHole2D(boreRadius) if isUpright else arc2D(boreRadius)
boreL = Edge(Vector(0, -p.x, p.y) + centerR for p in boreXY)
boreR = boreL.translated(Vector(boreLength))
triangles = Face(boreL.reversed(), [holeR]).triangulate()
triangles.extend(boreL.meshPairwise(boreR, True))
triangles.extend(holeL.meshPairwise(holeR, True))
return triangles, holeL, boreR
================================================
FILE: body/chrumm/part/bumper.py
================================================
import math
from chrumm import cfg
from chrumm.geo import Edge
from chrumm.geo import Vector
from .arc import arc2D
class Bumper:
def __init__(self, pos, isHalf=False):
self.triangles = []
self.floorEdge = Edge()
self.splitEdge = Edge()
floorHeight = cfg.floor.outerHeight
radius = cfg.bumper.diameter/2
height = cfg.bumper.height
arc = arc2D(radius, -math.pi/2, math.pi).snapped() if isHalf else arc2D(radius)
edgeG = arc.translated(pos.xy - Vector(0, 0, floorHeight))
edgeT = arc.translated(pos.xy - Vector(0, 0, floorHeight - height))
self.triangles.extend(edgeT[:1].meshPairwise(edgeT))
self.triangles.extend(edgeT.meshPairwise(edgeG, not isHalf))
self.floorEdge.add(edgeG)
if isHalf:
self.splitEdge.add(edgeG[-1], edgeT[-1], edgeT[0], edgeG[0])
================================================
FILE: body/chrumm/part/cable.py
================================================
import math
from chrumm import cfg
from chrumm.geo import Edge
from chrumm.geo import Vector
from .arc import arc2D
from .arc import uprightHalfHole2D
class Cable:
def __init__(self, pos, wallThickness):
# .-+-.
# / pos \
# \ / z
# '---' yx
self.triangles = []
self.wallEdgeF = Edge()
self.wallEdgeB = Edge()
self.splitEdgeG = Edge()
self.splitEdgeT = Edge()
cableRadius = cfg.cable.diameter/2
filletRadius = cfg.cable.wallExitFillet
bendRadius = cfg.cable.minBendRadius
bumpRadius = cfg.cable.wallBumpRadius
taperAngle = cfg.cable.wallBumpTaperAngle
bracketWidth = cfg.bracket.holeLength + cfg.bracket.counterboreLength
bumpWidth = bracketWidth*0.6 + cableRadius*0.4
# Hole
filletArcXY = arc2D(filletRadius, 0, math.tau/4)
filletArcYZ = Edge(Vector(0, p.y, p.x) for p in filletArcXY)
filletCenter = pos + Vector(0, wallThickness - filletRadius, -cableRadius)
holeArcXY = uprightHalfHole2D(cableRadius)
holeArcXZ = Edge(Vector(p.y, 0, p.x) for p in holeArcXY)
holeArcs = [holeArcXZ.translated(pos + Vector(0, 0, -cableRadius))]
holeEdgeT = holeArcs[0][:len(holeArcs[0])//2 + 1]
holeEdgeG = holeArcs[0][len(holeArcs[0])//2:].reversed()
for p in filletArcYZ:
arcScale = 1 + (filletRadius - p.z)/cableRadius
arcCenter = filletCenter + Vector(0, p.y)
holeArcs.append(holeArcXZ.scaled(arcScale).translated(arcCenter))
for i in range(len(holeArcs) - 1):
self.triangles.extend(holeArcs[i+1].meshPairwise(holeArcs[i]))
# Bump
# minBendRadius
# +--..
# | /. bendArc
# |---/--)--
# |45/ .' taperArc
# | /.'
# |/' wallBumpTaperAngle
tipCenterXY = Vector(1, 1).normalized() * (bendRadius - bumpRadius)
bendArcXY = arc2D(bendRadius, math.tau/4, -math.tau/8)
bendArcXY.add(arc2D(bumpRadius, math.tau/8, -math.tau/8, tipCenterXY)[1:])
bendArcYZ = Edge(Vector(0, -p.x, p.y) for p in bendArcXY)
bendArcYZ = bendArcYZ.translated(holeArcs[0][-1] - bendArcYZ[0])
taperFactor = math.sin(taperAngle) / math.cos(taperAngle)
taperArcXY = arc2D(bumpRadius, 0, -(math.tau/4 - taperAngle), tipCenterXY)
taperArcXY.add(Vector(0, taperArcXY[-1].y - taperArcXY[-1].x*taperFactor))
taperArcYZ = Edge(Vector(0, -p.x, p.y) for p in taperArcXY)
grooveArcs = [bendArcYZ.translated(p - bendArcYZ[0]) for p in holeEdgeG]
grooveWidth = grooveArcs[-1][-1].x - pos.x
grooveArcs.append(grooveArcs[-1].translated(Vector(bumpWidth - grooveWidth)))
taperEdgeL = taperArcYZ.translated(grooveArcs[0][-1] - taperArcYZ[0])
taperEdgeR = taperEdgeL.translated(Vector(bumpWidth))
bumpEdgeR = Edge(grooveArcs[-1], taperEdgeR)
bendEdgeB = Edge(arc[-1] for arc in reversed(grooveArcs))
self.triangles.extend(taperEdgeL.meshPairwise(taperEdgeR))
self.triangles.extend(bendEdgeB.meshPairwise(taperEdgeR[:1]))
self.triangles.extend(bumpEdgeR.meshPairwise(bumpEdgeR[:1]))
for i in range(len(grooveArcs) - 1):
self.triangles.extend(grooveArcs[i].meshPairwise(grooveArcs[i+1]))
# Edges
self.wallEdgeF = Edge(holeEdgeT, bumpEdgeR[0], bumpEdgeR[-1], taperEdgeL[-1])
self.wallEdgeB = holeArcs[-1].reversed()
self.splitEdgeT = Edge(arc[0] for arc in reversed(holeArcs))
self.splitEdgeG = Edge(
reversed(taperEdgeL),
reversed(grooveArcs[0]),
(arc[-1] for arc in holeArcs)).collapsed()
================================================
FILE: body/chrumm/part/encoder.py
================================================
from chrumm import cfg
from chrumm.geo import Edge
from chrumm.geo import Face
from chrumm.geo import Line
from chrumm.geo import Matrix
from chrumm.geo import Vector
from .arc import uprightHalfHole2D
class Encoder:
def __init__(self, roofPlaneO, roofPlaneI):
"""Place at roofPlaneO.pos and align to roofPlaneO.normal"""
self.triangles = []
self.roofEdgeI = Edge()
self.roofEdgeO = Edge()
self.splitEdgeF = Edge()
self.splitEdgeB = Edge()
width = cfg.encoder.width
depth = cfg.encoder.depth
holeRadius = cfg.encoder.holeDiameter/2
holeHeight = cfg.encoder.holeHeight
holeChamfer = cfg.encoder.holeChamfer
notchDepth = cfg.encoder.pinNotchDepth
pos = roofPlaneO.pos
normal = roofPlaneO.normal
align = Matrix.fromAlignment(Vector(0, 0, 1), normal).translated(pos)
# Hole
# __| |__ z
# |Box | xy
holeArc = Edge(Vector(p.y, p.x) for p in uprightHalfHole2D(holeRadius))
holeEdgeT = holeArc.scaled(1 + holeChamfer/holeRadius).transformed(align)
holeEdgeC = holeArc.translated(Vector(0, 0, -holeChamfer)).transformed(align)
holeEdgeG = holeArc.translated(Vector(0, 0, -holeHeight)).transformed(align)
# Box
# 0---1
# :\
# y : 2
# zx : | Notch
boxEdgeT = Edge(
Vector(0, depth/2, -holeHeight),
Vector(width/2, depth/2, -holeHeight),
Vector(width/2 + notchDepth, depth/2 - notchDepth, -holeHeight))
boxEdgeT.add(boxEdgeT.mirroredY().reversed())
boxEdgeT = boxEdgeT.transformed(align)
boxEdgeG = Edge(roofPlaneI.intersect(Line(p, normal)) for p in boxEdgeT)
# Chamfered notch
boxEdgeT[2] = boxEdgeT[1]
boxEdgeT[3] = boxEdgeT[4]
boxFaceEdge = Edge(
boxEdgeT[0],
boxEdgeT[1],
boxEdgeT[4],
boxEdgeT[5],
reversed(holeEdgeG))
edges = [boxEdgeG, boxEdgeT, holeEdgeG, holeEdgeC, holeEdgeT]
self.splitEdgeF.add(e[-1] for e in reversed(edges))
self.splitEdgeB.add(e[0] for e in edges)
self.roofEdgeI.add(boxEdgeG.collapsed().reversed())
self.roofEdgeO.add(holeEdgeT)
self.triangles.extend(holeEdgeC.meshPairwise(holeEdgeT))
self.triangles.extend(holeEdgeG.meshPairwise(holeEdgeC))
self.triangles.extend(boxEdgeG.meshPairwise(boxEdgeT))
self.triangles.extend(Face(boxFaceEdge).triangulate())
================================================
FILE: body/chrumm/part/floor.py
================================================
import math
from chrumm import cfg
from chrumm.geo import Edge
from chrumm.geo import Face
from chrumm.geo import Segment
from chrumm.geo import Vector
from .arc import cornerArc2D
from .bracket import FloorBracket
from .bumper import Bumper
class Floor:
def __init__(self, plan, body):
self.faces = []
self.triangles = []
outerHeight = cfg.floor.outerHeight
innerHeight = cfg.floor.innerHeight
innerChamfer = cfg.floor.innerChamfer
lipThickness = cfg.floor.lipThickness
lipHeight = cfg.floor.lipHeight
lipMargin = cfg.floor.lipMargin
lipT = Vector(0, 0, lipHeight)
floorOG = Vector(0, 0, -outerHeight)
floorIG = Vector(0, 0, floorOG.z + innerHeight)
chamferT = Vector(0, 0, floorIG.z + innerChamfer)
lipSketchI = _naiveOffset2D(body.outlineI, -lipMargin - lipThickness)
lipSketchO = _naiveOffset2D(body.outlineI, -lipMargin)
chamferSketch = _naiveOffset2D(body.outlineI, -lipMargin - lipThickness - innerChamfer)
# Wall profile edges
#
# 4-3
# | |
# | 2--1
# 5 |
# / |
# 6 |
# 0
profile = [
body.outlineO.translated(floorOG),
body.outlineO,
lipSketchO,
lipSketchO.translated(lipT),
lipSketchI.translated(lipT),
lipSketchI.translated(chamferT),
chamferSketch.translated(floorIG)]
# Face edges
floorEdge = profile[0].reversed()
innerEdge = Edge()
splitEdge = Edge()
bodyEdge = Edge()
lipEdge = Edge()
floorHoles = []
innerHoles = []
splitHoles = []
bodyHoles = []
# Optional parts
if cfg.bracket:
bracketF = FloorBracket(
profile[6][0],
profile[6][-1],
profile[5][0],
profile[4][0],
profile[3][0],
plan.side)
bracketB = FloorBracket(
profile[6][-1],
profile[6][0],
profile[5][-1],
profile[4][-1],
profile[3][-1],
plan.side)
# Check if front brackets overlap
sketchFG = Edge(Vector(p.y, p.z) for p in bracketF.splitEdge)
sketchFT = Edge(Vector(p.y, p.z) for p in body.bracketF.splitEdge)
for point in sketchFT:
if sketchFG.contains2D(point):
raise ValueError(
"The front brackets overlap.\n"
" Try to increase bracket.frontFloorExtraMargin, or\n"
" raise the front with a more negative body.tiltAngle.")
# Check if cable overlaps
if cfg.cable:
sketchBG = Edge(Vector(p.y, p.z) for p in bracketB.splitEdge)
sketchBT = Edge(Vector(p.y, p.z) for p in body.bracketB.splitEdge)
for point in _naiveOffset2D(sketchBT, -cfg.cable.diameter):
if sketchBG.contains2D(point):
raise ValueError(
"The cable overlaps the back floor brackets.\n"
" Try to decrease cable.gripHumpHeight, or\n"
" increase the body or floor height.")
# Integrate brackets
for i, edge in enumerate(profile[3:7]):
profile[-(i+1)][0] = bracketF.lipEdge[-(i+1)]
profile[-(i+1)][-1] = bracketB.lipEdge[-(i+1)]
innerEdge.add(bracketB.taperEdge)
innerEdge.add(bracketF.taperEdge)
splitEdge.add(e[0] for e in profile[:3])
splitEdge.add(bracketF.splitEdge)
splitEdge.add(bracketB.splitEdge)
splitEdge.add(e[-1] for e in reversed(profile[:3]))
splitHoles.append(bracketF.splitHole)
splitHoles.append(bracketB.splitHole)
self.triangles.extend(bracketF.triangles)
self.triangles.extend(bracketB.triangles)
else:
splitEdge.add(e[0] for e in profile)
splitEdge.add(e[-1] for e in reversed(profile))
if cfg.floor.hexHoles:
hexMargin = cfg.floor.hexHoles.wallMargin
hexBorder = _naiveOffset2D(profile[-1], -hexMargin, True)
hexagons = _hexGrid2D(hexBorder)
hexagonsI = [h.translated(floorIG) for h in hexagons]
hexagonsO = [h.translated(floorOG) for h in hexagons]
innerHoles.extend(h.reversed() for h in hexagonsI)
floorHoles.extend(hexagonsO)
for hexI, hexO in zip(hexagonsI, hexagonsO):
self.triangles.extend(hexI.meshPairwise(hexO, True))
if cfg.bumper:
bumperInset = cfg.bumper.margin + cfg.bumper.diameter/2
thumbOLF = plan.points.thumbORF
pinkyORF = plan.points.pinkyORF
pinkyORB = plan.points.pinkyORB
alnumOLB = plan.points.alnumOLB
bumperRF = _cornerBumper(alnumOLB, pinkyORB, pinkyORF, bumperInset)
bumperRB = _cornerBumper(pinkyORB, pinkyORF, thumbOLF, bumperInset)
bumperLF = Bumper(profile[0][0] + Vector(0, bumperInset), True)
bumperLB = Bumper(profile[0][-1] - Vector(0, bumperInset), True)
floorHoles.append(bumperRF.floorEdge)
floorHoles.append(bumperRB.floorEdge)
splitEdge.add(bumperLB.splitEdge)
splitEdge.add(bumperLF.splitEdge)
floorEdge.add(bumperLF.floorEdge)
floorEdge.add(bumperLB.floorEdge)
self.triangles.extend(bumperRF.triangles)
self.triangles.extend(bumperRB.triangles)
self.triangles.extend(bumperLF.triangles)
self.triangles.extend(bumperLB.triangles)
# Bosses
bosses = [
plan.bosses.alnumB,
plan.bosses.thumbF,
plan.bosses.pinkyB,
plan.bosses.pinkyF]
if cfg.palm:
bosses.append(plan.bosses.hitchL)
bosses.append(plan.bosses.hitchR)
for boss in bosses:
bodyHoles.append(boss.clearanceHole)
floorHoles.append(boss.headHole)
self.triangles.extend(boss.headTriangles)
# Triangles
innerEdge.add(profile[-1])
bodyEdge.add(profile[1], reversed(profile[2]))
lipEdge.add(profile[3], reversed(profile[4]))
# HACK: Fill gaps caused by brackets
profile[2] = Edge(profile[2][0].yz, profile[2], profile[2][-1].yz)
profile[3] = Edge(profile[3][0].yz, profile[3], profile[3][-1].yz)
for i in 0, 2, 4, 5:
self.triangles.extend(profile[i].meshPairwise(profile[i+1]))
self.faces.append(Face(splitEdge.collapsed(), splitHoles))
self.faces.append(Face(floorEdge, floorHoles))
self.faces.append(Face(bodyEdge, bodyHoles))
self.faces.append(Face(lipEdge.collapsed()))
self.faces.append(Face(innerEdge, innerHoles))
def _cornerBumper(a, b, c, bumperInset):
"""Place bumper into rounded corner with the correct margin."""
cornerRadius = cfg.body.outerCornerRadius
aDir = (a - b).normalized2D()
cDir = (c - b).normalized2D()
cornerAngle = math.acos(aDir.dot(cDir))
cornerDiag = cornerRadius / math.sin(cornerAngle/2)
bumperDiag = bumperInset / math.sin(cornerAngle/2)
diagOverlap = (cornerDiag - cornerRadius) - (bumperDiag - bumperInset)
bumperDiag += max(0, diagOverlap)
bumperDir = (aDir + cDir).normalized2D()
return Bumper(b + bumperDir*bumperDiag)
def _naiveOffset2D(edge, distance, isClosed=False, minSegLength=1e-3):
"""Grow or shrink a simple polygon edge by the given distance.
Note that the robust offsetting of arbitrary polygons is non-trivial
and out of scope for this function. Avoid overly sharp angles. Avoid
offset distances that cause a fundamental change of the topology.
One simple polygon is returned.
"""
def connect(segments):
"""Connect segments at their intersections as lines."""
for i in range(len(segments)):
seg0 = segments[i-1]
seg1 = segments[i]
middle = seg0.intersect2D(seg1, asLine=2)
if middle is None:
middle = (seg0.b + seg1.a) / 2
seg0.b = middle
seg1.a = middle
if abs(distance) < 1e-6:
return edge
# Offset segments
segments = edge.toSegments(True)
offset = [s.offset2D(distance) for s in segments]
if not isClosed:
offset[-1] = segments[-1].offset2D(0)
# Extend and reconnect offset segments.
# In case of a sharp angle, the intersection
# will be far away from the original point.
connect(offset)
# Reorder segments to start at a point that
# is not part of a self-intersecting loop
startIndex = 0
for i, startSegment in enumerate(offset):
isTooClose = False
for segment in segments:
if segment.distance2D(startSegment.a) < abs(distance) - 1e-6:
isTooClose = True
break
if not isTooClose:
startIndex = i
break
offset = offset[startIndex:] + offset[:startIndex]
# Cut off self-intersecting loops
i = 0
while i < len(offset):
segment = offset[i]
cutPos = None
cutDist = None
cutIndex = None
for j in range(i+2, len(offset) - int(i == 0)):
pos = segment.intersect2D(offset[j])
if pos is not None:
dist = (pos - segment.a).magSquared()
if cutDist is None or dist < cutDist:
cutPos = pos
cutDist = dist
cutIndex = j
if cutIndex is not None:
del offset[i+1:cutIndex]
offset[i].b = cutPos
offset[i+1].a = cutPos
i += 1
# Remove segments that are too short
offset = [s for s in offset if s.magnitude2D() >= minSegLength]
connect(offset)
# Reorder segments to start near the original start point
distances = [(s.a - edge[0]).magSquared() for s in offset]
minIndex = distances.index(min(distances))
offset = offset[minIndex:] + offset[:minIndex]
return Edge(s.a for s in offset)
def _hexGrid2D(edge):
"""Fill polygon edge with a hexagon grid.
If a hexagon does not fit completely, then scale it down
toward the vertex that is furthest inside the polygon.
"""
minDiameter = cfg.floor.hexHoles.minDiameter
maxDiameter = cfg.floor.hexHoles.maxDiameter
cornerRadius = cfg.floor.hexHoles.cornerRadius
xOffset = cfg.floor.hexHoles.xOffset
yOffset = cfg.floor.hexHoles.yOffset
holeMargin = cfg.floor.hexHoles.holeMargin
hexagons = []
segments = edge.toSegments(True)
minX = min(p.x for p in edge)
maxX = max(p.x for p in edge)
minY = min(p.y for p in edge)
maxY = max(p.y for p in edge)
maxRadius = maxDiameter/2
minRadius = maxRadius/2 * 3**0.5
xPitch = 3*maxRadius + holeMargin * 3**0.5
yPitch = 2*minRadius + holeMargin
xStart = minX + maxRadius + xOffset
yStart = minY + minRadius + yOffset - yPitch
row = 0
x = xStart
y = yStart
while y < maxY + minRadius:
while x < maxX + maxRadius:
center = Vector(x, y)
hexagon = Edge(
Vector(x - maxRadius, y),
Vector(x - maxRadius/2, y - minRadius),
Vector(x + maxRadius/2, y - minRadius),
Vector(x + maxRadius, y),
Vector(x + maxRadius/2, y + minRadius),
Vector(x - maxRadius/2, y + minRadius))
# Increment before possible continue statments
x += xPitch
# Check if hexagon is completely inside or outside (cheap)
centerDist = min(s.distance2D(center) for s in segments)
if centerDist >= maxRadius:
if edge.contains2D(center):
hexagons.append(hexagon)
continue
# Scale down partially contained hexagon (expensive)
bestFactor = 0.0
bestCenter = None
pointsInHex = [p for p in edge if hexagon.contains2D(p)]
for i, scaleCenter in enumerate(hexagon):
if not edge.contains2D(scaleCenter):
continue
minFactor = 1.0
# Find scale factor to exclude all polygon points
# _____
# / \. ray
# / p'\
# \ .' /
# \.' /
# c---- scaleCenter
if pointsInHex:
for point in pointsInHex:
if point.isClose(scaleCenter):
continue
ray = Segment(scaleCenter, point)
for j in range(1, len(hexagon) - 1):
hexSegment = Segment(hexagon[i-j-1], hexagon[i-j])
rayIntersect = hexSegment.intersect2D(ray, asLine=1)
if rayIntersect is None:
continue
pointDist = (point - scaleCenter).magnitude()
rayDist = (rayIntersect - scaleCenter).magnitude()
minFactor = min(pointDist / rayDist, minFactor)
# Find scale factor to exclude all polygon segments
# _____ diag
# / /\
# -/----p--\-- segment
# \ / /
# \ / /
# c---- scaleCenter
for j in range(1, len(hexagon)):
diag = Segment(scaleCenter, hexagon[i-j])
for segment in segments:
point = diag.intersect2D(segment)
if point is None:
continue
pointDist = (point - scaleCenter).magnitude()
diagDist = diag.magnitude2D()
minFactor = min(pointDist / diagDist, minFactor)
if minFactor > bestFactor:
bestFactor = minFactor
bestCenter = scaleCenter
# Add scaled hexagon
if maxDiameter*bestFactor >= minDiameter:
hexagons.append(hexagon.scaled(bestFactor, bestCenter))
row += 1
x = xStart + xPitch/2*(row % 2)
y = yStart + yPitch/2*row
if cornerRadius > 0:
hexagons = [Edge(cornerArc2D(
cornerRadius,
hexagon[i-2],
hexagon[i-1],
hexagon[i]) for i in range(6)) for hexagon in hexagons]
return hexagons
================================================
FILE: body/chrumm/part/key.py
================================================
import math
from chrumm import cfg
from chrumm.geo import Edge
from chrumm.geo import Matrix
from chrumm.geo import Vector
class KeyFactory:
"""Construct and cache the geometry of keys."""
def __init__(self):
self.boundsI = Edge()
self.boundsO = Edge()
self.roofHoleI = Edge()
self.roofHoleO = Edge()
self.triangles = []
holeW = cfg.switch.width
holeD = cfg.switch.depth
holeH = cfg.body.roofThickness
pinH = cfg.switch.pinHeight
innerH = cfg.switch.innerHeight
innerMargin = cfg.switch.innerMargin
outerMargin = cfg.switch.outerMargin
entryChamfer = cfg.switch.entryChamfer
hasClip = cfg.switch.clipNotch and cfg.switch.clipNotch.height < holeH
isSideways = hasClip and getattr(cfg.switch.clipNotch, "isSideways", False)
if isSideways:
holeW, holeD = holeD, holeW
holeL = -holeW/2
holeR = holeW/2
holeF = -holeD/2
holeB = holeD/2
# Bounds
self.boundsO.add(
Vector(holeL - outerMargin, holeF - outerMargin),
Vector(holeL - outerMargin, holeB + outerMargin),
Vector(holeR + outerMargin, holeB + outerMargin),
Vector(holeR + outerMargin, holeF - outerMargin))
self.boundsI.add(
Vector(holeL - innerMargin, holeF - innerMargin, -holeH),
Vector(holeL - innerMargin, holeB + innerMargin, -holeH),
Vector(holeR + innerMargin, holeB + innerMargin, -holeH),
Vector(holeR + innerMargin, holeF - innerMargin, -holeH),
Vector(holeL, holeF, -innerH - pinH),
Vector(holeL, holeB, -innerH - pinH),
Vector(holeR, holeB, -innerH - pinH),
Vector(holeR, holeF, -innerH - pinH))
# Left side
entryEdge = Edge(
Vector(holeL, holeF - entryChamfer),
Vector(holeL - entryChamfer, holeF))
chamferEdge = Edge(
Vector(holeL, holeF, -entryChamfer),
Vector(holeL, holeF, -entryChamfer))
exitEdge = Edge(
Vector(holeL, holeF, -holeH),
Vector(holeL, holeF, -holeH))
for edge in [entryEdge, chamferEdge, exitEdge]:
edge.add(edge.mirroredY().reversed())
self.triangles.extend(exitEdge.meshPairwise(chamferEdge))
self.triangles.extend(chamferEdge.meshPairwise(entryEdge))
# Back clip notch
if hasClip:
clipW = cfg.switch.clipNotch.width
clipD =
gitextract_3f6rhsfw/
├── BUILD.md
├── CHANGELOG.md
├── LICENSE.txt
├── MATERIALS.md
├── README.md
├── body/
│ ├── .flake8
│ ├── .gitignore
│ ├── README.md
│ ├── chrumm/
│ │ ├── __init__.py
│ │ ├── __main__.py
│ │ ├── cfg.py
│ │ ├── geo/
│ │ │ ├── __init__.py
│ │ │ ├── circle.py
│ │ │ ├── edge.py
│ │ │ ├── epsilon.py
│ │ │ ├── face.py
│ │ │ ├── line.py
│ │ │ ├── matrix.py
│ │ │ ├── plane.py
│ │ │ ├── segment.py
│ │ │ ├── tests/
│ │ │ │ ├── README.md
│ │ │ │ ├── __init__.py
│ │ │ │ ├── helper.py
│ │ │ │ ├── test_circle.py
│ │ │ │ ├── test_edge.py
│ │ │ │ ├── test_face.py
│ │ │ │ ├── test_line.py
│ │ │ │ ├── test_matrix.py
│ │ │ │ ├── test_plane.py
│ │ │ │ ├── test_segment.py
│ │ │ │ ├── test_triangle.py
│ │ │ │ └── test_vector.py
│ │ │ ├── triangle.py
│ │ │ └── vector.py
│ │ ├── make.py
│ │ ├── part/
│ │ │ ├── __init__.py
│ │ │ ├── arc.py
│ │ │ ├── body.py
│ │ │ ├── boss.py
│ │ │ ├── bracket.py
│ │ │ ├── bumper.py
│ │ │ ├── cable.py
│ │ │ ├── encoder.py
│ │ │ ├── floor.py
│ │ │ ├── key.py
│ │ │ ├── knob.py
│ │ │ ├── layout.py
│ │ │ ├── palm.py
│ │ │ ├── plan.py
│ │ │ └── support.py
│ │ ├── pcb.py
│ │ └── stl.py
│ ├── chrumm.json
│ └── prusa/
│ ├── chrumm-body.ini
│ ├── chrumm-floor.ini
│ ├── chrumm-knob.ini
│ ├── chrumm-palm.ini
│ └── clean-3mf-seam.py
├── firmware/
│ ├── .gitignore
│ ├── CMakeLists.txt
│ ├── README.md
│ └── chrumm/
│ ├── config.h
│ ├── encoder.c
│ ├── encoder.h
│ ├── hid.c
│ ├── hid.h
│ ├── led.c
│ ├── led.h
│ ├── main.c
│ ├── matrix.c
│ ├── matrix.h
│ ├── usage.h
│ ├── usb.c
│ └── usb.h
└── pcb/
├── .gitignore
├── README.md
└── chrumm/
├── chrumm-plot.py
├── chrumm.kicad_dru
├── chrumm.kicad_pcb
├── chrumm.kicad_pro
├── chrumm.kicad_sch
├── chrumm.kicad_sym
├── chrumm.kicad_wks
├── footprints.pretty/
│ ├── Diode_1N4148_P7.6mm.kicad_mod
│ ├── Graphic_CHRUMM.kicad_mod
│ ├── Graphic_Hi.kicad_mod
│ ├── Graphic_OSHW.kicad_mod
│ ├── MountingHole_M3.kicad_mod
│ ├── MouseBites_1x3_P0.9mm.kicad_mod
│ ├── MouseBites_1x3_P1.35mm.kicad_mod
│ ├── MouseBites_1x4_P0.9mm.kicad_mod
│ ├── MouseBites_1x5_P0.9mm.kicad_mod
│ ├── MouseBites_1x5_P1.35mm.kicad_mod
│ ├── PinHeader_1x2_P2.54mm_Custom.kicad_mod
│ ├── PinHeader_1x3_P2.54mm.kicad_mod
│ ├── PinHeader_1x5_P2.54mm.kicad_mod
│ ├── RPi_Pico.kicad_mod
│ ├── RPi_Pico_Custom.kicad_mod
│ ├── RotaryEncoder_PEC11R_Custom.kicad_mod
│ ├── Switch_MX.kicad_mod
│ ├── Switch_MX_CTRL.kicad_mod
│ └── Switch_MX_RefPoints.kicad_mod
├── fp-lib-table
└── sym-lib-table
SYMBOL INDEX (343 symbols across 47 files)
FILE: body/chrumm/__main__.py
function main (line 32) | def main():
FILE: body/chrumm/cfg.py
function _init (line 1) | def _init(jsonStrings):
FILE: body/chrumm/geo/circle.py
class Circle (line 5) | class Circle:
method __init__ (line 9) | def __init__(self, center, radius):
method intersect2D (line 13) | def intersect2D(self, other):
method _intersectLine2D (line 21) | def _intersectLine2D(self, line):
method _intersectCircle2D (line 44) | def _intersectCircle2D(self, other):
FILE: body/chrumm/geo/edge.py
class Edge (line 9) | class Edge(collections.UserList):
method __init__ (line 12) | def __init__(self, *args):
method fromConvexHull2D (line 17) | def fromConvexHull2D(vectors):
method toSegments (line 36) | def toSegments(self, isClosed=False):
method add (line 41) | def add(self, *args):
method xy (line 49) | def xy(self):
method xz (line 53) | def xz(self):
method yz (line 57) | def yz(self):
method mirroredX (line 60) | def mirroredX(self):
method mirroredY (line 63) | def mirroredY(self):
method mirroredZ (line 66) | def mirroredZ(self):
method reversed (line 69) | def reversed(self):
method scaled (line 72) | def scaled(self, scalar, center=Vector()):
method translated (line 75) | def translated(self, vector):
method transformed (line 78) | def transformed(self, matrix):
method snapped (line 81) | def snapped(self):
method collapsed (line 84) | def collapsed(self, threshold=1e-3):
method meshPairwise (line 88) | def meshPairwise(self, other, isClosed=False):
method meshParallel (line 150) | def meshParallel(self, other, isClosed=False):
method contains2D (line 210) | def contains2D(self, vector):
method distance2D (line 226) | def distance2D(self, vector):
FILE: body/chrumm/geo/epsilon.py
function isZero (line 25) | def isZero(n):
FILE: body/chrumm/geo/face.py
class Face (line 8) | class Face:
method __init__ (line 11) | def __init__(self, edge, holes=[]):
method triangulate (line 27) | def triangulate(self):
method _mergeHoles (line 58) | def _mergeHoles(points, polyIndexes, holeIndexes):
method _cutEars (line 139) | def _cutEars(points, polyIndexes):
method _flipTriangles (line 198) | def _flipTriangles(points, triangles):
FILE: body/chrumm/geo/line.py
class Line (line 4) | class Line:
method __init__ (line 8) | def __init__(self, pos, direction):
method translated (line 12) | def translated(self, vector):
method transformed (line 15) | def transformed(self, matrix):
method distance (line 20) | def distance(self, vector):
method distance2D (line 27) | def distance2D(self, vector):
method intersect (line 37) | def intersect(self, other):
FILE: body/chrumm/geo/matrix.py
class Matrix (line 4) | class Matrix:
method __init__ (line 8) | def __init__(self, data=(
method fromAlignment (line 16) | def fromAlignment(sourceDir, targetDir):
method __add__ (line 37) | def __add__(self, other):
method __sub__ (line 40) | def __sub__(self, other):
method __mul__ (line 43) | def __mul__(self, other):
method mirroredX (line 68) | def mirroredX(self):
method mirroredY (line 75) | def mirroredY(self):
method mirroredZ (line 82) | def mirroredZ(self):
method rotatedX (line 89) | def rotatedX(self, angle, center=None):
method rotatedY (line 102) | def rotatedY(self, angle, center=None):
method rotatedZ (line 115) | def rotatedZ(self, angle, center=None):
method translated (line 128) | def translated(self, vector):
FILE: body/chrumm/geo/plane.py
class Plane (line 6) | class Plane:
method __init__ (line 10) | def __init__(self, pos, normal):
method fromX (line 15) | def fromX(x):
method fromY (line 19) | def fromY(y):
method fromZ (line 23) | def fromZ(z):
method fromPoints (line 27) | def fromPoints(a, b, c):
method fromLine2D (line 31) | def fromLine2D(line):
method translated (line 35) | def translated(self, vector):
method transformed (line 38) | def transformed(self, matrix):
method distance (line 43) | def distance(self, vector):
method projectNormal (line 47) | def projectNormal(self, vector):
method projectX (line 50) | def projectX(self, vector):
method projectY (line 57) | def projectY(self, vector):
method projectZ (line 64) | def projectZ(self, vector):
method intersect (line 71) | def intersect(self, other1, other2=None):
method _intersectPlanes (line 78) | def _intersectPlanes(self, other1, other2):
method _intersectLine (line 97) | def _intersectLine(self, line):
FILE: body/chrumm/geo/segment.py
class Segment (line 5) | class Segment:
method __init__ (line 9) | def __init__(self, a, b):
method magnitude (line 13) | def magnitude(self):
method offset2D (line 16) | def offset2D(self, distance):
method magnitude2D (line 20) | def magnitude2D(self):
method distance2D (line 23) | def distance2D(self, vector):
method intersect2D (line 36) | def intersect2D(self, other, asLine=0):
FILE: body/chrumm/geo/tests/helper.py
function findTriangulationProblems (line 1) | def findTriangulationProblems(triangles, outerSegments):
FILE: body/chrumm/geo/tests/test_circle.py
class CircleTest (line 12) | class CircleTest(unittest.TestCase):
method test_intersectLine2D (line 14) | def test_intersectLine2D(self):
method test_intersectCircle2D (line 39) | def test_intersectCircle2D(self):
FILE: body/chrumm/geo/tests/test_edge.py
class EdgeTest (line 18) | class EdgeTest(unittest.TestCase):
method test_fromConvexHull2D (line 20) | def test_fromConvexHull2D(self):
method test_toSegments (line 32) | def test_toSegments(self):
method test_add (line 61) | def test_add(self):
method test_mirroredX (line 91) | def test_mirroredX(self):
method test_mirroredY (line 96) | def test_mirroredY(self):
method test_mirroredZ (line 101) | def test_mirroredZ(self):
method test_reversed (line 106) | def test_reversed(self):
method test_scaled (line 111) | def test_scaled(self):
method test_translated (line 123) | def test_translated(self):
method test_transformed (line 128) | def test_transformed(self):
method test_collapsed (line 134) | def test_collapsed(self):
method test_meshPairwise (line 155) | def test_meshPairwise(self):
method test_meshParallel (line 249) | def test_meshParallel(self):
method test_contains2D (line 328) | def test_contains2D(self):
method test_distance2D (line 340) | def test_distance2D(self):
FILE: body/chrumm/geo/tests/test_face.py
class FaceTest (line 10) | class FaceTest(unittest.TestCase):
method test_triangulate_simple (line 12) | def test_triangulate_simple(self):
method test_triangulate_vertical (line 66) | def test_triangulate_vertical(self):
method test_triangulate_flipDelaunay (line 80) | def test_triangulate_flipDelaunay(self):
method test_triangulate_sliverEar (line 103) | def test_triangulate_sliverEar(self):
method test_triangulate_alignedHoles (line 127) | def test_triangulate_alignedHoles(self):
method test_triangulate_holeBridgeOrder (line 158) | def test_triangulate_holeBridgeOrder(self):
method test_triangulate_collinearDiagonalHoles (line 188) | def test_triangulate_collinearDiagonalHoles(self):
FILE: body/chrumm/geo/tests/test_line.py
class LineTest (line 11) | class LineTest(unittest.TestCase):
method test_translated (line 13) | def test_translated(self):
method test_transformed (line 22) | def test_transformed(self):
method test_distance (line 31) | def test_distance(self):
method test_distance2D (line 39) | def test_distance2D(self):
method test_intersect (line 44) | def test_intersect(self):
FILE: body/chrumm/geo/tests/test_matrix.py
class MatrixTest (line 14) | class MatrixTest(unittest.TestCase):
method test_fromAlignment (line 16) | def test_fromAlignment(self):
method test_add (line 24) | def test_add(self):
method test_sub (line 29) | def test_sub(self):
method test_mulScalar (line 34) | def test_mulScalar(self):
method test_mulMatrix (line 39) | def test_mulMatrix(self):
method test_mirroredX (line 49) | def test_mirroredX(self):
method test_mirroredY (line 53) | def test_mirroredY(self):
method test_mirroredZ (line 57) | def test_mirroredZ(self):
method test_rotatedX (line 61) | def test_rotatedX(self):
method test_rotatedY (line 77) | def test_rotatedY(self):
method test_rotatedZ (line 93) | def test_rotatedZ(self):
method test_translated (line 109) | def test_translated(self):
FILE: body/chrumm/geo/tests/test_plane.py
class PlaneTest (line 12) | class PlaneTest(unittest.TestCase):
method test_fromPoints (line 14) | def test_fromPoints(self):
method test_fromLine2D (line 22) | def test_fromLine2D(self):
method test_translated (line 28) | def test_translated(self):
method test_transformed (line 37) | def test_transformed(self):
method test_distance (line 46) | def test_distance(self):
method test_projectNormal (line 52) | def test_projectNormal(self):
method test_projectX (line 56) | def test_projectX(self):
method test_projectY (line 67) | def test_projectY(self):
method test_projectZ (line 78) | def test_projectZ(self):
method test_intersectPlanes (line 89) | def test_intersectPlanes(self):
method test_intersectLine (line 97) | def test_intersectLine(self):
FILE: body/chrumm/geo/tests/test_segment.py
class SegmentTest (line 11) | class SegmentTest(unittest.TestCase):
method test_magnitude (line 13) | def test_magnitude(self):
method test_offset2D (line 17) | def test_offset2D(self):
method test_magnitude2D (line 35) | def test_magnitude2D(self):
method test_distance2D (line 47) | def test_distance2D(self):
method test_intersect2D (line 70) | def test_intersect2D(self):
FILE: body/chrumm/geo/tests/test_triangle.py
class TriangleTest (line 13) | class TriangleTest(unittest.TestCase):
method test_bool (line 15) | def test_bool(self):
method test_mirroredX (line 24) | def test_mirroredX(self):
method test_mirroredY (line 30) | def test_mirroredY(self):
method test_mirroredZ (line 36) | def test_mirroredZ(self):
method test_reversed (line 42) | def test_reversed(self):
method test_translated (line 48) | def test_translated(self):
method test_transformed (line 59) | def test_transformed(self):
method test_area (line 66) | def test_area(self):
method test_circumradius (line 75) | def test_circumradius(self):
method test_normal (line 80) | def test_normal(self):
FILE: body/chrumm/geo/tests/test_vector.py
class VectorTest (line 17) | class VectorTest(unittest.TestCase):
method test_init (line 19) | def test_init(self):
method test_fromSurfaceNormal (line 25) | def test_fromSurfaceNormal(self):
method test_eq (line 53) | def test_eq(self):
method test_lt (line 59) | def test_lt(self):
method test_neg (line 67) | def test_neg(self):
method test_add (line 71) | def test_add(self):
method test_sub (line 75) | def test_sub(self):
method test_mul (line 79) | def test_mul(self):
method test_truediv (line 85) | def test_truediv(self):
method test_transformed (line 93) | def test_transformed(self):
method test_transformedNormal (line 98) | def test_transformedNormal(self):
method test_snapped (line 112) | def test_snapped(self):
method test_normalized (line 121) | def test_normalized(self):
method test_cross (line 126) | def test_cross(self):
method test_dot (line 131) | def test_dot(self):
method test_magnitude (line 136) | def test_magnitude(self):
method test_magSquared (line 140) | def test_magSquared(self):
method test_angleBetween (line 144) | def test_angleBetween(self):
method test_isClose (line 158) | def test_isClose(self):
method test_ortho2D (line 173) | def test_ortho2D(self):
method test_normalized2D (line 177) | def test_normalized2D(self):
method test_magnitude2D (line 182) | def test_magnitude2D(self):
method test_magSquared2D (line 186) | def test_magSquared2D(self):
method test_angle2D (line 190) | def test_angle2D(self):
FILE: body/chrumm/geo/triangle.py
class Triangle (line 4) | class Triangle:
method __init__ (line 8) | def __init__(self, a, b, c):
method __bool__ (line 13) | def __bool__(self):
method mirroredX (line 16) | def mirroredX(self):
method mirroredY (line 22) | def mirroredY(self):
method mirroredZ (line 28) | def mirroredZ(self):
method reversed (line 34) | def reversed(self):
method translated (line 37) | def translated(self, vector):
method transformed (line 43) | def transformed(self, matrix):
method area (line 49) | def area(self):
method circumradius (line 55) | def circumradius(self):
method normal (line 63) | def normal(self):
FILE: body/chrumm/geo/vector.py
class Vector (line 6) | class Vector:
method __init__ (line 10) | def __init__(self, x=0, y=0, z=0):
method fromSurfaceNormal (line 16) | def fromSurfaceNormal(vectors):
method __repr__ (line 28) | def __repr__(self):
method __eq__ (line 34) | def __eq__(self, other):
method __lt__ (line 37) | def __lt__(self, other):
method __neg__ (line 44) | def __neg__(self):
method __add__ (line 47) | def __add__(self, other):
method __sub__ (line 50) | def __sub__(self, other):
method __mul__ (line 53) | def __mul__(self, scalar):
method __truediv__ (line 56) | def __truediv__(self, scalar):
method xy (line 60) | def xy(self):
method xz (line 64) | def xz(self):
method yz (line 68) | def yz(self):
method mirroredX (line 71) | def mirroredX(self):
method mirroredY (line 74) | def mirroredY(self):
method mirroredZ (line 77) | def mirroredZ(self):
method translated (line 80) | def translated(self, vector):
method transformed (line 83) | def transformed(self, matrix):
method transformedNormal (line 90) | def transformedNormal(self, matrix):
method snapped (line 97) | def snapped(self):
method normalized (line 104) | def normalized(self):
method cross (line 107) | def cross(self, other):
method dot (line 113) | def dot(self, other):
method magnitude (line 116) | def magnitude(self):
method magSquared (line 119) | def magSquared(self):
method angleBetween (line 122) | def angleBetween(self, other):
method isClose (line 127) | def isClose(self, other):
method ortho2D (line 133) | def ortho2D(self):
method normalized2D (line 137) | def normalized2D(self):
method magnitude2D (line 142) | def magnitude2D(self):
method magSquared2D (line 145) | def magSquared2D(self):
method angle2D (line 148) | def angle2D(self):
FILE: body/chrumm/make.py
function make (line 20) | def make(jsonStrings, threads, isKnobOnly):
FILE: body/chrumm/part/arc.py
function arc2D (line 9) | def arc2D(radius, startAngle=0, spanAngle=math.tau, center=Vector()):
function cornerArc2D (line 36) | def cornerArc2D(radius, a, b, c):
function uprightHole2D (line 56) | def uprightHole2D(radius):
function uprightHalfHole2D (line 74) | def uprightHalfHole2D(radius):
FILE: body/chrumm/part/body.py
class Body (line 22) | class Body:
method __init__ (line 24) | def __init__(self, plan):
function _chamfer (line 431) | def _chamfer(a, b, c, normal, chamferSize):
function _parallelChamfer (line 454) | def _parallelChamfer(a, b, c, prevChamfer):
function _cornerChamfer (line 466) | def _cornerChamfer(prevPoint, corner, nextPoint, normal, chamferSize):
FILE: body/chrumm/part/boss.py
class Boss (line 18) | class Boss:
method __init__ (line 20) | def __init__(self, pos, printDirection, wallDirection=None, roofPlane=...
method _initWall (line 33) | def _initWall(self, pos, wallDirection):
method _initHead (line 68) | def _initHead(self, pos):
method _initThread (line 105) | def _initThread(self, pos, printDirection, roofPlane):
function _smoothTransition (line 159) | def _smoothTransition(protrusion, bossRadius, bossFillet, minTaperAngle):
FILE: body/chrumm/part/bracket.py
class CornerBracket (line 16) | class CornerBracket:
method __init__ (line 19) | def __init__(self, a, b, c, side):
class RoofBracket (line 193) | class RoofBracket:
method __init__ (line 196) | def __init__(self, a, b, refMatrix, side):
method _makeBoss (line 314) | def _makeBoss(self, refMatrix):
class FloorBracket (line 354) | class FloorBracket:
method __init__ (line 357) | def __init__(self, a, b, c, d, e, side):
function _hexTangentDist (line 476) | def _hexTangentDist(outerRadius, tangentAngle):
function _archCorner (line 485) | def _archCorner(radius, a, b, c):
function _screwHole (line 490) | def _screwHole(centerL, side, isUpright):
FILE: body/chrumm/part/bumper.py
class Bumper (line 11) | class Bumper:
method __init__ (line 13) | def __init__(self, pos, isHalf=False):
FILE: body/chrumm/part/cable.py
class Cable (line 12) | class Cable:
method __init__ (line 13) | def __init__(self, pos, wallThickness):
FILE: body/chrumm/part/encoder.py
class Encoder (line 12) | class Encoder:
method __init__ (line 14) | def __init__(self, roofPlaneO, roofPlaneI):
FILE: body/chrumm/part/floor.py
class Floor (line 15) | class Floor:
method __init__ (line 17) | def __init__(self, plan, body):
function _cornerBumper (line 210) | def _cornerBumper(a, b, c, bumperInset):
function _naiveOffset2D (line 228) | def _naiveOffset2D(edge, distance, isClosed=False, minSegLength=1e-3):
function _hexGrid2D (line 308) | def _hexGrid2D(edge):
FILE: body/chrumm/part/key.py
class KeyFactory (line 10) | class KeyFactory:
method __init__ (line 13) | def __init__(self):
method make (line 127) | def make(self, units=1):
class Key (line 131) | class Key:
method __init__ (line 133) | def __init__(self, factory, units):
method translate (line 152) | def translate(self, vector):
method transform (line 155) | def transform(self, matrix):
method position (line 159) | def position(self):
method capPivotL (line 163) | def capPivotL(self):
method capPivotR (line 167) | def capPivotR(self):
method boundsI (line 171) | def boundsI(self):
method boundsO (line 175) | def boundsO(self):
method bounds (line 179) | def bounds(self):
method roofHoleI (line 183) | def roofHoleI(self):
method roofHoleO (line 187) | def roofHoleO(self):
method triangles (line 191) | def triangles(self):
FILE: body/chrumm/part/knob.py
class Knob (line 19) | class Knob:
method __init__ (line 21) | def __init__(self):
method _grooveSketch2D (line 70) | def _grooveSketch2D():
method _shaftSketch2D (line 130) | def _shaftSketch2D():
FILE: body/chrumm/part/layout.py
class Layout (line 11) | class Layout:
method __init__ (line 14) | def __init__(self):
method all (line 90) | def all(self, side="both"):
method alnum (line 93) | def alnum(self, side="both"):
method pinky (line 96) | def pinky(self, side="both"):
method alnumCol (line 99) | def alnumCol(self, index, side="both"):
method pinkyCol (line 102) | def pinkyCol(self, index, side="both"):
method perAlnumCol (line 105) | def perAlnumCol(self, index, side="both"):
method perPinkyCol (line 108) | def perPinkyCol(self, index, side="both"):
method maxAlnum (line 111) | def maxAlnum(self, side="both"):
method minPinky (line 114) | def minPinky(self, side="both"):
method thumb (line 117) | def thumb(self, side="both"):
method _halves (line 125) | def _halves(self, side="both"):
method _group (line 133) | def _group(self, group, side):
method _col (line 139) | def _col(self, group, index, side):
method _perCol (line 145) | def _perCol(self, group, index, side):
FILE: body/chrumm/part/palm.py
class Palm (line 17) | class Palm:
method __init__ (line 19) | def __init__(self, plan):
method _roofSpine (line 290) | def _roofSpine(inset=0):
FILE: body/chrumm/part/plan.py
class Plan (line 37) | class Plan:
method __init__ (line 45) | def __init__(self, side):
method _tentCreaseOffset (line 365) | def _tentCreaseOffset():
method _wallLines2D (line 377) | def _wallLines2D(keys, thickness, angle):
method _alnumBossKeys (line 399) | def _alnumBossKeys(keys, planeL, planeR, planeB, planeT):
method _fitBoss2D (line 416) | def _fitBoss2D(wallLines, key0, key1):
FILE: body/chrumm/part/support.py
class SupportFactory (line 12) | class SupportFactory:
method __init__ (line 15) | def __init__(self):
method make (line 69) | def make(self, key):
class Support (line 78) | class Support:
method __init__ (line 80) | def __init__(self, plan):
FILE: body/chrumm/pcb.py
function toKiCadFootprint (line 17) | def toKiCadFootprint(planR, planL):
function _flattenLayout (line 48) | def _flattenLayout(layout):
function _writeKeys (line 92) | def _writeKeys(stream, keys, layer):
function _writeMounts (line 99) | def _writeMounts(stream, layout, layer):
function _writeKiCadMarker (line 107) | def _writeKiCadMarker(stream, matrix, width, height, layer):
FILE: body/chrumm/stl.py
function toBytes (line 7) | def toBytes(triangles):
FILE: body/prusa/clean-3mf-seam.py
function readZip (line 18) | def readZip(path):
function writeZip (line 23) | def writeZip(path, fileDict):
FILE: firmware/chrumm/encoder.c
function encoder_init (line 13) | void encoder_init()
function encoder_tick (line 25) | void encoder_tick()
function report (line 72) | static void report(uint32_t usage)
function encoder_init (line 94) | void encoder_init() {}
function encoder_tick (line 95) | void encoder_tick() {}
FILE: firmware/chrumm/hid.c
function hid_tick (line 22) | void hid_tick()
function hid_add (line 32) | void hid_add(uint32_t usage)
function hid_remove (line 46) | void hid_remove(uint32_t usage)
function addKeycode (line 55) | static void addKeycode(uint8_t code)
function removeKeycode (line 79) | static void removeKeycode(uint8_t code)
function addConsumer (line 101) | static void addConsumer(uint16_t code)
function tud_hid_get_report_cb (line 111) | uint16_t tud_hid_get_report_cb(uint8_t itf, uint8_t id, hid_report_type_...
function tud_hid_set_report_cb (line 127) | void tud_hid_set_report_cb(uint8_t itf, uint8_t id, hid_report_type_t ty...
FILE: firmware/chrumm/led.c
function led_init (line 11) | void led_init()
function led_tick (line 18) | void led_tick()
function led_blink (line 35) | void led_blink(uint8_t pattern)
FILE: firmware/chrumm/main.c
function main (line 22) | int main()
FILE: firmware/chrumm/matrix.c
function matrix_init (line 18) | void matrix_init()
function matrix_tick (line 41) | void matrix_tick()
function debounce (line 63) | static void debounce(uint key, bool signal)
function report (line 110) | static void report(uint key, bool signal)
FILE: firmware/chrumm/usb.c
function tud_suspend_cb (line 146) | void tud_suspend_cb(bool remote_wakeup_en)
FILE: pcb/chrumm/chrumm-plot.py
function getRealSize (line 26) | def getRealSize(board):
Condensed preview — 104 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,300K chars).
[
{
"path": "BUILD.md",
"chars": 2302,
"preview": "Chrumm build advice\n===================\n\nPrinting\n--------\n\nI printed the parts on a Prusa Mini, with PLA filament, on a"
},
{
"path": "CHANGELOG.md",
"chars": 737,
"preview": "Development\n-----------\n\nbody 1.0.2\n- Add optional parameters for switches with clips on their side\n * switch.clipNotch"
},
{
"path": "LICENSE.txt",
"chars": 14810,
"preview": "https://github.com/sevmeyer/chrumm-keyboard/\n ___ _ _ ____ _ _ __ __ __ __\n.' __| |_| | _ '| | | | \\/ | \\/ "
},
{
"path": "MATERIALS.md",
"chars": 1610,
"preview": "Chrumm Bill Of Materials\n========================\n\nMechanical\n----------\n\n- 12x Threaded insert, M3, 4mm hole diameter, "
},
{
"path": "README.md",
"chars": 3504,
"preview": "Chrumm keyboard\n===============\n\nChrumm is an open-hardware ergonomic keyboard,\nmade of a 3D-printable body, a bendable "
},
{
"path": "body/.flake8",
"chars": 30,
"preview": "[flake8]\nmax-line-length = 99\n"
},
{
"path": "body/.gitignore",
"chars": 47,
"preview": "__pycache__/\n*.py[cod]\n*.3mf\n*.stl\n*.kicad_mod\n"
},
{
"path": "body/README.md",
"chars": 2066,
"preview": "Chrumm STL generator\n====================\n\nThe STL files are generated with the `chrumm` package for Python 3.7+.\nIt has"
},
{
"path": "body/chrumm/__init__.py",
"chars": 328,
"preview": "r\"\"\"Chrumm keyboard STL generator\n ___ _ _ ____ _ _ __ __ __ __\n.' __| |_| | _ '| | | | \\/ | \\/ |\n| |__| _"
},
{
"path": "body/chrumm/__main__.py",
"chars": 3153,
"preview": "\"\"\"\nGenerate Chrumm keyboard STL files, based on JSON configuration files.\nIf a configuration parameter appears multiple"
},
{
"path": "body/chrumm/cfg.py",
"chars": 977,
"preview": "def _init(jsonStrings):\n \"\"\"Make JSON values available as native module attributes.\"\"\"\n # Imports are done in loca"
},
{
"path": "body/chrumm/geo/__init__.py",
"chars": 370,
"preview": "from .circle import Circle\nfrom .edge import Edge\nfrom .face import Face\nfrom .line import Line\nfrom .matrix import Matr"
},
{
"path": "body/chrumm/geo/circle.py",
"chars": 2034,
"preview": "from .epsilon import isZero\nfrom .line import Line\n\n\nclass Circle:\n\n __slots__ = \"center\", \"radius\"\n\n def __init__"
},
{
"path": "body/chrumm/geo/edge.py",
"chars": 7685,
"preview": "import collections\nimport math\n\nfrom .segment import Segment\nfrom .triangle import Triangle\nfrom .vector import Vector\n\n"
},
{
"path": "body/chrumm/geo/epsilon.py",
"chars": 1097,
"preview": "\"\"\"Provide float comparisons with an epsilon threshold.\"\"\"\n# Comparing floats is notoriously cumbersome. To keep it\n# si"
},
{
"path": "body/chrumm/geo/face.py",
"chars": 8828,
"preview": "import math\n\nfrom .matrix import Matrix\nfrom .triangle import Triangle\nfrom .vector import Vector\n\n\nclass Face:\n \"\"\"C"
},
{
"path": "body/chrumm/geo/line.py",
"chars": 1834,
"preview": "from .epsilon import isZero\n\n\nclass Line:\n\n __slots__ = \"pos\", \"dir\"\n\n def __init__(self, pos, direction):\n "
},
{
"path": "body/chrumm/geo/matrix.py",
"chars": 4690,
"preview": "import math\n\n\nclass Matrix:\n\n __slots__ = \"data\"\n\n def __init__(self, data=(\n 1.0, 0.0, 0.0, 0.0,\n "
},
{
"path": "body/chrumm/geo/plane.py",
"chars": 3435,
"preview": "from .epsilon import isZero\nfrom .line import Line\nfrom .vector import Vector\n\n\nclass Plane:\n\n __slots__ = \"pos\", \"no"
},
{
"path": "body/chrumm/geo/segment.py",
"chars": 2112,
"preview": "from .epsilon import isZero\nfrom .vector import Vector\n\n\nclass Segment:\n\n __slots__ = \"a\", \"b\"\n\n def __init__(self"
},
{
"path": "body/chrumm/geo/tests/README.md",
"chars": 97,
"preview": "Run the tests from the parent directory of the chrumm package:\n\n python3 -m unittest discover\n"
},
{
"path": "body/chrumm/geo/tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "body/chrumm/geo/tests/helper.py",
"chars": 1931,
"preview": "def findTriangulationProblems(triangles, outerSegments):\n \"\"\"Check if triangles make reasonable sense (inefficient).\""
},
{
"path": "body/chrumm/geo/tests/test_circle.py",
"chars": 2096,
"preview": "import unittest\n\nfrom ..circle import Circle\nfrom ..line import Line\nfrom ..vector import Vector\n\n\nCIRCLE_ZERO = Circle("
},
{
"path": "body/chrumm/geo/tests/test_edge.py",
"chars": 12098,
"preview": "import unittest\n\nfrom ..edge import Edge\nfrom ..matrix import Matrix\nfrom ..vector import Vector\n\nfrom .helper import fi"
},
{
"path": "body/chrumm/geo/tests/test_face.py",
"chars": 7160,
"preview": "import unittest\n\nfrom ..edge import Edge\nfrom ..face import Face\nfrom ..vector import Vector\n\nfrom .helper import findTr"
},
{
"path": "body/chrumm/geo/tests/test_line.py",
"chars": 2144,
"preview": "import unittest\n\nfrom ..line import Line\nfrom ..matrix import Matrix\nfrom ..vector import Vector\n\n\nLINE = Line(Vector(1,"
},
{
"path": "body/chrumm/geo/tests/test_matrix.py",
"chars": 3984,
"preview": "import unittest\n\nfrom math import pi as PI\n\nfrom ..matrix import Matrix\nfrom ..vector import Vector\n\n\nMAT_ZERO = Matrix("
},
{
"path": "body/chrumm/geo/tests/test_plane.py",
"chars": 3900,
"preview": "import unittest\n\nfrom ..line import Line\nfrom ..matrix import Matrix\nfrom ..plane import Plane\nfrom ..vector import Vect"
},
{
"path": "body/chrumm/geo/tests/test_segment.py",
"chars": 3566,
"preview": "import unittest\n\nfrom ..segment import Segment\nfrom ..vector import Vector\n\n\nSEG_ZERO = Segment(Vector(0, 0, 0), Vector("
},
{
"path": "body/chrumm/geo/tests/test_triangle.py",
"chars": 3792,
"preview": "import unittest\n\nfrom ..matrix import Matrix\nfrom ..triangle import Triangle\nfrom ..vector import Vector\n\n\nTRI_ZERO = Tr"
},
{
"path": "body/chrumm/geo/tests/test_vector.py",
"chars": 8477,
"preview": "import unittest\n\nfrom math import pi as PI\n\nfrom ..matrix import Matrix\nfrom ..vector import Vector\n\n\n# Based on conside"
},
{
"path": "body/chrumm/geo/triangle.py",
"chars": 1708,
"preview": "from .epsilon import isZero\n\n\nclass Triangle:\n\n __slots__ = \"a\", \"b\", \"c\"\n\n def __init__(self, a, b, c):\n s"
},
{
"path": "body/chrumm/geo/vector.py",
"chars": 4505,
"preview": "import math\n\nfrom .epsilon import isZero\n\n\nclass Vector:\n\n __slots__ = \"x\", \"y\", \"z\"\n\n def __init__(self, x=0, y=0"
},
{
"path": "body/chrumm/make.py",
"chars": 3488,
"preview": "import logging\nimport multiprocessing\n\nfrom chrumm import __version__\nfrom chrumm import cfg\nfrom chrumm import pcb\nfrom"
},
{
"path": "body/chrumm/part/__init__.py",
"chars": 235,
"preview": "from .body import Body\nfrom .floor import Floor\nfrom .knob import Knob\nfrom .palm import Palm\nfrom .plan import Plan\nfro"
},
{
"path": "body/chrumm/part/arc.py",
"chars": 2620,
"preview": "import math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Vector\n\n\ndef arc2D(radius, start"
},
{
"path": "body/chrumm/part/body.py",
"chars": 19857,
"preview": "import logging\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Face\nfrom chrumm.geo import L"
},
{
"path": "body/chrumm/part/boss.py",
"chars": 5906,
"preview": "import logging\nimport math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Matrix\nfrom chrum"
},
{
"path": "body/chrumm/part/bracket.py",
"chars": 20173,
"preview": "import math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Face\nfrom chrumm.geo import Line"
},
{
"path": "body/chrumm/part/bumper.py",
"chars": 880,
"preview": "import math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Vector\n\nfrom .arc import arc2D\n\n"
},
{
"path": "body/chrumm/part/cable.py",
"chars": 3732,
"preview": "import math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Vector\n\nfrom .arc import arc2D\nf"
},
{
"path": "body/chrumm/part/encoder.py",
"chars": 2559,
"preview": "from chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Face\nfrom chrumm.geo import Line\nfrom chrumm."
},
{
"path": "body/chrumm/part/floor.py",
"chars": 14957,
"preview": "import math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Face\nfrom chrumm.geo import Segm"
},
{
"path": "body/chrumm/part/key.py",
"chars": 6630,
"preview": "import math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Matrix\nfrom chrumm.geo import Ve"
},
{
"path": "body/chrumm/part/knob.py",
"chars": 6919,
"preview": "import logging\nimport math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Circle\nfrom chrumm.geo import Edge\nfrom chrum"
},
{
"path": "body/chrumm/part/layout.py",
"chars": 4744,
"preview": "import math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Matrix\nfrom chrumm.geo import Vector\n\nfrom .key import KeyFa"
},
{
"path": "body/chrumm/part/palm.py",
"chars": 13554,
"preview": "import math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Face\nfrom chrumm.geo import Line"
},
{
"path": "body/chrumm/part/plan.py",
"chars": 18986,
"preview": "import logging\nimport math\nimport types\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Line"
},
{
"path": "body/chrumm/part/support.py",
"chars": 2596,
"preview": "import math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo import Face\nfrom chrumm.geo import Vect"
},
{
"path": "body/chrumm/pcb.py",
"chars": 3899,
"preview": "import copy\nimport io\nimport logging\nimport math\n\nfrom chrumm import cfg\n\nfrom chrumm.geo import Edge\nfrom chrumm.geo im"
},
{
"path": "body/chrumm/stl.py",
"chars": 658,
"preview": "import io\nimport struct\n\nfrom chrumm import __version__\n\n\ndef toBytes(triangles):\n \"\"\"Encode a list of triangles in t"
},
{
"path": "body/chrumm.json",
"chars": 4400,
"preview": "{\n\"maker\": \"chrumm 1.0.2\",\n\"quality\": {\n \"maxChordHeight\": 0.01,\n \"maxChordAngle\": 30,\n \"bumpscosity\": 50 },\n\"b"
},
{
"path": "body/prusa/chrumm-body.ini",
"chars": 5049,
"preview": "# generated by PrusaSlicer 2.6.0+linux-x64-GTK3 on 2023-08-15 at 17:34:23 UTC\navoid_crossing_curled_overhangs = 0\navoid_"
},
{
"path": "body/prusa/chrumm-floor.ini",
"chars": 5052,
"preview": "# generated by PrusaSlicer 2.6.0+linux-x64-GTK3 on 2023-08-12 at 15:13:24 UTC\navoid_crossing_curled_overhangs = 0\navoid_"
},
{
"path": "body/prusa/chrumm-knob.ini",
"chars": 5048,
"preview": "# generated by PrusaSlicer 2.6.0+linux-x64-GTK3 on 2023-07-31 at 20:27:00 UTC\navoid_crossing_curled_overhangs = 0\navoid_"
},
{
"path": "body/prusa/chrumm-palm.ini",
"chars": 5046,
"preview": "# generated by PrusaSlicer 2.6.0+linux-x64-GTK3 on 2023-07-31 at 20:33:42 UTC\navoid_crossing_curled_overhangs = 0\navoid_"
},
{
"path": "body/prusa/clean-3mf-seam.py",
"chars": 1047,
"preview": "# Clean up PrusaSlicer 2.6 seam paintings, by flood-filling\n# partially painted triangles. 3MF files are edited in-place"
},
{
"path": "firmware/.gitignore",
"chars": 7,
"preview": "build/\n"
},
{
"path": "firmware/CMakeLists.txt",
"chars": 929,
"preview": "cmake_minimum_required(VERSION 3.20)\n\n# Pico SDK (before project)\ninclude($ENV{PICO_SDK_PATH}/pico_sdk_init.cmake)\n\n# Pr"
},
{
"path": "firmware/README.md",
"chars": 2326,
"preview": "Chrumm firmware\n===============\n\nThe firmware is written in C for the [Raspberry Pi Pico].\nIf you instead prefer a more "
},
{
"path": "firmware/chrumm/config.h",
"chars": 1846,
"preview": "#pragma once\n\n#include \"chrumm/usage.h\"\n\n\n// GPIO pins\n// ---------\n\n#define MATRIX_ROWS 5\n#define MATRIX_COLS 13\n\n#de"
},
{
"path": "firmware/chrumm/encoder.c",
"chars": 2560,
"preview": "#include \"chrumm/encoder.h\"\n#include \"chrumm/config.h\"\n#include \"chrumm/hid.h\"\n#include \"chrumm/usage.h\"\n#include <pico/"
},
{
"path": "firmware/chrumm/encoder.h",
"chars": 56,
"preview": "#pragma once\n\nvoid encoder_init();\nvoid encoder_tick();\n"
},
{
"path": "firmware/chrumm/hid.c",
"chars": 3207,
"preview": "#include \"chrumm/hid.h\"\n#include \"chrumm/led.h\"\n#include \"chrumm/usb.h\"\n#include <tusb.h>\n\n\nstatic uint8_t keycodes[6] ="
},
{
"path": "firmware/chrumm/hid.h",
"chars": 116,
"preview": "#pragma once\n\n#include <stdint.h>\n\n\nvoid hid_tick();\nvoid hid_add(uint32_t usage);\nvoid hid_remove(uint32_t usage);\n"
},
{
"path": "firmware/chrumm/led.c",
"chars": 762,
"preview": "#include \"chrumm/led.h\"\n#include \"chrumm/config.h\"\n#include <pico/stdlib.h>\n\n\nstatic uint8_t blinkPattern = 0;\nstatic ui"
},
{
"path": "firmware/chrumm/led.h",
"chars": 103,
"preview": "#pragma once\n\n#include <stdint.h>\n\n\nvoid led_init();\nvoid led_tick();\nvoid led_blink(uint8_t pattern);\n"
},
{
"path": "firmware/chrumm/main.c",
"chars": 1015,
"preview": "// Chrumm keyboard firmware\n// ___ _ _ ____ _ _ __ __ __ __\n// .' __| |_| | _ '| | | | \\/ | \\/ |\n// | |__|"
},
{
"path": "firmware/chrumm/matrix.c",
"chars": 3955,
"preview": "#include \"chrumm/matrix.h\"\n#include \"chrumm/config.h\"\n#include \"chrumm/hid.h\"\n#include \"chrumm/usage.h\"\n#include <pico/b"
},
{
"path": "firmware/chrumm/matrix.h",
"chars": 54,
"preview": "#pragma once\n\nvoid matrix_init();\nvoid matrix_tick();\n"
},
{
"path": "firmware/chrumm/usage.h",
"chars": 5076,
"preview": "#pragma once\n\n// HID Usage Tables for USB\n// https://usb.org/document-library/hid-usage-tables-14\n\n\n#define kBOOT 0x000"
},
{
"path": "firmware/chrumm/usb.c",
"chars": 5374,
"preview": "#include \"chrumm/usb.h\"\n#include \"chrumm/config.h\"\n#include \"chrumm/led.h\"\n#include <tusb.h>\n#include <pico/unique_id.h>"
},
{
"path": "firmware/chrumm/usb.h",
"chars": 449,
"preview": "#pragma once\n\n// Reference:\n// https://github.com/hathach/tinyusb/tree/master/examples/device/hid_multiple_interface\n// "
},
{
"path": "pcb/.gitignore",
"chars": 38,
"preview": "fp-info-cache\n*.kicad_prl\n*.bak\n*.zip\n"
},
{
"path": "pcb/README.md",
"chars": 1424,
"preview": "Chrumm PCB\n==========\n\nThe PCB is made with [KiCad], version 7.\n\nThe PCB is reversible. It covers half of the keyboard,\n"
},
{
"path": "pcb/chrumm/chrumm-plot.py",
"chars": 3311,
"preview": "# Generate Gerber files and pack them into\n# a zip archive in the current directory.\n#\n# Usage: python3 chrumm-plot.py\n#"
},
{
"path": "pcb/chrumm/chrumm.kicad_dru",
"chars": 168,
"preview": "(version 1)\n\n(rule \"Ignore mousebite edge clearance\"\n (condition \"A.Pad_Type == 'NPTH, mechanical' && A.Size_X == 0.5"
},
{
"path": "pcb/chrumm/chrumm.kicad_pcb",
"chars": 712949,
"preview": "(kicad_pcb (version 20221018) (generator pcbnew)\n\n (general\n (thickness 0.8)\n )\n\n (paper \"A4\")\n (title_block\n "
},
{
"path": "pcb/chrumm/chrumm.kicad_pro",
"chars": 11725,
"preview": "{\n \"board\": {\n \"3dviewports\": [],\n \"design_settings\": {\n \"defaults\": {\n \"board_outline_line_width\": 0"
},
{
"path": "pcb/chrumm/chrumm.kicad_sch",
"chars": 114040,
"preview": "(kicad_sch (version 20230121) (generator eeschema)\n\n (uuid d33cdf19-4d42-4aea-8152-27b13651402a)\n\n (paper \"A4\")\n\n (ti"
},
{
"path": "pcb/chrumm/chrumm.kicad_sym",
"chars": 25907,
"preview": "(kicad_symbol_lib (version 20220914) (generator kicad_symbol_editor)\n (symbol \"Diode\" (pin_numbers hide) (pin_names hid"
},
{
"path": "pcb/chrumm/chrumm.kicad_wks",
"chars": 943,
"preview": "(kicad_wks (version 20220228) (generator pl_editor)\n (setup (textsize 1.5 1.5)(linewidth 0.15)(textlinewidth 0.15)\n (l"
},
{
"path": "pcb/chrumm/footprints.pretty/Diode_1N4148_P7.6mm.kicad_mod",
"chars": 3154,
"preview": "(footprint \"Diode_1N4148_P7.6mm\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr through_hole)\n (fp_text"
},
{
"path": "pcb/chrumm/footprints.pretty/Graphic_CHRUMM.kicad_mod",
"chars": 10763,
"preview": "(footprint \"Graphic_CHRUMM\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr board_only exclude_from_pos_f"
},
{
"path": "pcb/chrumm/footprints.pretty/Graphic_Hi.kicad_mod",
"chars": 3369,
"preview": "(footprint \"Graphic_Hi\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr board_only exclude_from_pos_files"
},
{
"path": "pcb/chrumm/footprints.pretty/Graphic_OSHW.kicad_mod",
"chars": 1426,
"preview": "(footprint \"Graphic_OSHW\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr board_only exclude_from_pos_fil"
},
{
"path": "pcb/chrumm/footprints.pretty/MountingHole_M3.kicad_mod",
"chars": 791,
"preview": "(footprint \"MountingHole_M3\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr board_only exclude_from_pos_"
},
{
"path": "pcb/chrumm/footprints.pretty/MouseBites_1x3_P0.9mm.kicad_mod",
"chars": 959,
"preview": "(footprint \"MouseBites_1x3_P0.9mm\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr board_only exclude_fro"
},
{
"path": "pcb/chrumm/footprints.pretty/MouseBites_1x3_P1.35mm.kicad_mod",
"chars": 963,
"preview": "(footprint \"MouseBites_1x3_P1.35mm\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr board_only exclude_fr"
},
{
"path": "pcb/chrumm/footprints.pretty/MouseBites_1x4_P0.9mm.kicad_mod",
"chars": 1108,
"preview": "(footprint \"MouseBites_1x4_P0.9mm\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr board_only exclude_fro"
},
{
"path": "pcb/chrumm/footprints.pretty/MouseBites_1x5_P0.9mm.kicad_mod",
"chars": 1244,
"preview": "(footprint \"MouseBites_1x5_P0.9mm\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr board_only exclude_fro"
},
{
"path": "pcb/chrumm/footprints.pretty/MouseBites_1x5_P1.35mm.kicad_mod",
"chars": 1248,
"preview": "(footprint \"MouseBites_1x5_P1.35mm\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr board_only exclude_fr"
},
{
"path": "pcb/chrumm/footprints.pretty/PinHeader_1x2_P2.54mm_Custom.kicad_mod",
"chars": 1413,
"preview": "(footprint \"PinHeader_1x2_P2.54mm_Custom\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr through_hole bo"
},
{
"path": "pcb/chrumm/footprints.pretty/PinHeader_1x3_P2.54mm.kicad_mod",
"chars": 1804,
"preview": "(footprint \"PinHeader_1x3_P2.54mm\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr through_hole board_onl"
},
{
"path": "pcb/chrumm/footprints.pretty/PinHeader_1x5_P2.54mm.kicad_mod",
"chars": 2615,
"preview": "(footprint \"PinHeader_1x5_P2.54mm\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr through_hole allow_mis"
},
{
"path": "pcb/chrumm/footprints.pretty/RPi_Pico.kicad_mod",
"chars": 16958,
"preview": "(footprint \"RPi_Pico\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr through_hole)\n (fp_text reference "
},
{
"path": "pcb/chrumm/footprints.pretty/RPi_Pico_Custom.kicad_mod",
"chars": 8574,
"preview": "(footprint \"RPi_Pico_Custom\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr smd allow_missing_courtyard)"
},
{
"path": "pcb/chrumm/footprints.pretty/RotaryEncoder_PEC11R_Custom.kicad_mod",
"chars": 2092,
"preview": "(footprint \"RotaryEncoder_PEC11R_Custom\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr through_hole all"
},
{
"path": "pcb/chrumm/footprints.pretty/Switch_MX.kicad_mod",
"chars": 5624,
"preview": "(footprint \"Switch_MX\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr through_hole allow_missing_courtya"
},
{
"path": "pcb/chrumm/footprints.pretty/Switch_MX_CTRL.kicad_mod",
"chars": 10271,
"preview": "(footprint \"Switch_MX_CTRL\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr smd allow_missing_courtyard)\n"
},
{
"path": "pcb/chrumm/footprints.pretty/Switch_MX_RefPoints.kicad_mod",
"chars": 1829,
"preview": "(footprint \"Switch_MX_RefPoints\" (version 20221018) (generator pcbnew)\n (layer \"F.Cu\")\n (attr through_hole board_only "
},
{
"path": "pcb/chrumm/fp-lib-table",
"chars": 117,
"preview": "(fp_lib_table\n (lib (name \"footprints\")(type \"KiCad\")(uri \"${KIPRJMOD}/footprints.pretty\")(options \"\")(descr \"\"))\n)\n"
},
{
"path": "pcb/chrumm/sym-lib-table",
"chars": 127,
"preview": "(sym_lib_table\n (version 7)\n (lib (name \"chrumm\")(type \"KiCad\")(uri \"${KIPRJMOD}/chrumm.kicad_sym\")(options \"\")(descr "
}
]
About this extraction
This page contains the full source code of the sevmeyer/chrumm-keyboard GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 104 files (1.2 MB), approximately 543.4k tokens, and a symbol index with 343 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.