Full Code of sevmeyer/chrumm-keyboard for AI

master 0677d499e7c4 cached
104 files
1.2 MB
543.4k tokens
343 symbols
1 requests
Download .txt
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.

![Fold lines for flexstrip jumpers](images/flexstrip.svg)


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.

![Front view of the finished keyboard](images/front.jpg)

![Inside view with installed electronics](images/inside.jpg)


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
------

![Default logical layout with two layers](images/layout.svg)


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
-------

![Print and assembly of the body](images/body.jpg)

![Palm rests wrapped with artificial leather](images/palms.jpg)

![Preparation and installation of the PCB](images/pcb.jpg)


================================================
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 = 
Download .txt
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
Download .txt
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.

Copied to clipboard!