Outlines
#
OverviewTODO points -> outlines illustration
Once the raw points are available, we often want to turn them into solid, continuous outlines. We do this by selecting an arbitrary subset of points and placing shapes there to form a part, and then use boolean operations (i.e., addition, subtraction, or intersection) to combine parts into a final outline to export. We'll get back to how an individual part looks soon โ but first, we need to get familiar with binding and filtering.
#
BindingWhile the points are enough to place properly positioned and rotated shapes (most commonly, rectangles, representing the keys of the board), these usually won't combine into a contiguous shape since there won't be any overlap. So the first part of outline generation is thinking about "binding", where we can make the individual switch holes reach out towards (or, bind to) each other. Think of this as a kind of "neighbor declaration", telling Ergogen which directions to grow towards (and by how much) to reach the next-door point.
Of course, overlap could be achieved by placing larger shapes at each of the points, causing them to overlap by default, but since everything is placed by its center point, these larger shapes would result in larger outside margins as well. With bind, we can declare the selective directions in which to grow the shapes placed, so that their final combination can become contiguous, yet with as little (or as much) margin as we might want.
#
ExplicitThe fully customizable way to add binding to points is through the key-level attribute bind
:
bind: num | [num_x, num_y] | [num_t, num_r, num_b, num_l] # defer to autobind by default
To recap, key-level declaration means that bind
should be specified in the points
section, benefiting from the same extension process every key-level attribute does.
Valid values follow CSS standards, so num
applies to all directions, num_x
horizontally, num_y
vertically, and the t
/r
/b
/l
versions to top/right/bottom/left, respectively.
tip
Don't recall seeing bind
in the Keys section, where supposedly all key-level attributes were listed?
That's because those were only the ones with meaning to the layout system.
Apart from those, anything can be declared as a key-level attribute, and some might gain meaning in later stages, like bind
did just now.
#
AutomaticTo spare us the bind
declaration whenever possible, Ergogen offers an autobind
key-level attribute as well.
Its value is a single number (10
by default), and the relevant directions are calculated automatically (by looking at intra- and inter-column bounding boxes).
Basically, if we want bound shapes, we only need to say so (by setting bound: true
, see below) in most cases โ or specify a larger autobind
value once if 10
wasn't enough to bridge the gaps.
And if autobinding fails for a more complex shape, we can always fall back to explicit bind
declarations.
#
ExamplesExplicit bind
explicit bind and how it's smaller than just placing larger tiles
- Config
- Visualization
Autobind
autobind explanation/illustration
- Config
- Visualization
#
FilteringFiltering is how Ergogen decides which points to use when placing the shape we're currently placing. After all, the points section might contain lots of zones, multiple kinds of points, helpers for mounting holes or one-off PCB footprints, etc. So being able to easily select a subset of these points can come in handy.
#
BasicsFirst up, let's see what a filter means depending on what datatype we use when declaring it:
undefined
: if left empty, a filter produces the default[0, 0, 0ยฐ]
origin point.boolean: if the filter is
true
, all points are used; if it'sfalse
, no points are used.string: represents a single/simple filter โ the workings of which we'll discuss in a second.
object, or array that contains an object somewhere: will be parsed as an anchor, returning the single resulting point.
note
Although there can be valid anchor declarations that are neither objects, nor arrays containing an object at any depth, these are not supported where filters are expected because Ergogen would have no way to decide what it's looking at. Remember, however, that every anchor can be represented in full object form โ any other representation is just a shorthand for convenience.
array containing no objects at any depth: complex filter, see Advanced usage.
So the undefined and boolean cases are easy, objects just redirect to anchors, and arrays are more advanced. What about strings, then?
At their simplest, strings just compare the given value against the name of each key and check for straight equality. Since names are unique, this makes it easy to single out a point, but nothing more. How do we get "real" subsets?
Enter the tags
key-level attribute.
It can be either an array (containing string tags, or "labels" that should apply to the given point), or an object (in which case the keys from its key/value pairs count).
Arrays are probably more readable, while objects might be more easily extendable via inheritance or preprocessing.
Use whichever form makes sense.
tip
tags
is yet another key-level attribute that gains meaning during outlining only, like bind
did above.
By default, string filters consider not only the name of each key but their tags, too. And combining their basic exact matching behavior with a non-unique field leads to easy subset selection. Yay!
But wait, there's more!
If the string is surrounded by /
s (slashes), it's interpreted as a regex, and exact matching changes to pattern matching.
So we might not even need tags for, say, differentiating zones because we know that key names by default are formatted as zone_column_row
so we can just say something like /^matrix_.*/
to filter any key whose name starts with the substring matrix_
.
The usual regex flags are also supported if specified after the trailing slash, so feel free to use case-insensitive, multiline, or even unicode expressions should the need arise.
Finally, if it would be easier to select what we don't want instead of what we do want, filters support negation if prefixed by a -
(minus).
So while saying matrix_pinky_home
select only that one key, -matrix_pinky_home
selects everything except that key.
This also works with both tags and regexes, of course, so -alpha
selects everything that isn't tagged with alpha
(assuming the existence of an alpha tag), and -/pinky/
selects keys where the "pinky" substring isn't found anywhere within the name or any of its tags.
#
Advanced usageEvery single filter actually consists of three components:
- which key-level attributes to check against,
- how to check against them, and
- what value to check against them.
So far, we've only used the third component, as the which part was always the default name
and tags
, while the how part was interpreted as the special "similarity" operator, handling both exact matches and regexes.
But what if we want to check against some other key-level attribute; or check in a different way?
Enter full form filters.
In the background, writing something
gets translated as meta.name,meta.tags ~ something
, where meta
is each key's metadata containing all key-level attributes (see Keys) and ~
is the similarity operator.
So if we want to check against something else (say, we declared our own foobar
field among the other key-level attributes), then we can simply say meta.foobar ~ something
.
As for operators, only similarity (~
) is implemented for now, but others (such as mathematical relations) will be added in the future.
For even more advanced usage, we can combine simple filters with AND/OR logical relations into complex filters using arrays.
Odd levels of array nesting represent OR, while even levels represent AND.
So, for example, writing [something, other]
would mean that all points are returned where either something
OR other
matches the name/tags, while [[something, other]]
would only return points where both something
AND other
matches (note the double arrays in the latter case).
#
ExamplesTags
- Config
- Visualization
Regexes
- Config
- Visualization
Negation
- Config
- Visualization
Full filters
- Config
- Visualization
Combination
- Config
- Visualization
#
PartsWith this, we can finally move on to the outlines themselves. The relevant section in the config will look something like this:
- Array notation
- Object notation
outlines: <outline_name>: - <part> - <part> - ... ...
outlines: <outline_name>: part1: <part> part2: <part> ... ...
note
Listing parts within an outline can be an object as well as an array (see "Object notation" tab). Objects might be beneficial if part names are important for config readability (or when YAML or built-in inheritance is used), while arrays are a bit more terse. Use whichever form makes more sense.
Operations are performed in order, and the resulting shape is exported as an output.
Additionally, it is going to be available for further outline declarations to use (through the outline
type, see below) under the name specified (<outline_name>
, in this case).
Now let's see how those <part>
s are made.
#
Common attributesEach part has the following common attributes:
what
: declares what shape we want to place โ see Shapes.where
: declares where we want to place those shapes โ this is where we can use the previously discussed filters.operation
: indicates how we want the current part to combine with the cumulative result of previous parts. Options include:add
: produces an union โ this is the default operation.subtract
: subtracts this part from the in-progress result.intersect
: computes the intersection of this part and the in-progress result.stack
: just draws the current part "on top of" the in-progress result (possibly crossing lines instead of calculating unions).tip
stack
can be used as a computationally "cheaper"subtract
in some cases, but it's mostly for being able to visualize individual parts in the context of other parts and getting a sense of what happens (i.e., debugging).
bound
: boolean value, representing whether we want to activate binding on the shapes or not. Iffalse
, the shapes are placed as-is. Iftrue
, the corresponding binding rectangles are added to each relevant side of each shape and the results union'ed.asym
: the field is a companion to thewhere
filter and represents how filtering should treat mirrored points. The same values are available that we've discussed in the Mirroring section โ the canonical choices aresource
/clone
/both
.The default
source
only returns the points matched by the filter.clone
returns only the mirrored versions of the points that would be matched by the filter.both
returns both the regular matches and their mirror images.caution
If the filter translates to an anchor, this check is strict โ meaning that Ergogen will error out if the mirror image doesn't exist. On the other hand, the mirror check is permissive for regular filters, including them if they exist and ignoring the cases where they don't.
adjust
: a relative anchor by which to adjust the position of each shape โ similarly to the key-leveladjust
attribute.tip
This field makes it possible to place shapes not only at certain filtered points, but also below or next to those points.
scale
: an optional multiplier by which to scale the resulting shape. The default is1
for no scaling.expand
: a number in mm's by which to expand (or shrink, if the number is negative) the current outline. Differs fromscale
ing because it draws and external (or internal) "outline" for the starting shape, thereby usually changing the shape itself, too, not just its size. For more info, see the relevant Maker.JS docs.joints
: a companion toexpand
, specifying which type of treatment to apply to the joints during expansion/shrinking.0
orround
means the corners will be rounded (thereby having zero joints);1
orpointy
means the corners will stay (thereby still having one joint); and2
orbeveled
means the corners will get beveled (thereby having two joints).
fillet
: this number (if greater than the default zero) triggers a filleting operation on the (almost-)completed part and rounds its corners with the given radius. If the radius is larger than either of the corner's neighboring line segments, that corner is skipped.tip
Once a corner is filleted, it won't be filleted again, so it's safe to apply a
fillet
with increasingly smaller radii to catch every sharp corner if desired.
#
ShapesShapes can have their own, shape-specific attributes on top of the ones already discussed above. Additionally, each shape can introduce shape-specific units to the evaluation context to further avoid repetition.
tip
Say we'd want to express that a rectangle of size 10
should be adjusted half of its width to the right.
We could write adjust.shift: [5, 0]
, of course, but then if the size changes, the shift needs to change as well.
Instead, we could write adjust.shift: [.5 sx, 0]
, referencing the size's x value (i.e., its width).
With this, let's see a list of what actual shapes we can place, what extra attributes they have, and what extra units they introduce:
rectangle
: A basic rectangle primitive.size
: Either a number or an array in the form[num_x, num_y]
, representing the width/height of the rectangle(s) to place. If it's a single numbernum
, it's interpreted as[num, num]
(i.e., a square). Mandatory. Introducessx
andsy
as units for width and height, respectively.bevel
: Optional beveling for the rectangles, default is0
.corner
: Optional corner radius for the rectangles, default is0
.caution
size
represents the final size of the resulting rectangle, so anybevel
orcorner
values are subtracted from it appropriately to make room for the bevels/radii. This can lead to an error if the size is too small (or thebevel
/corner
values are too large).tip
Corners and bevels can be used simultaneously. Corner radii are applied after beveling, leading to rounded bevels.
circle
: A basic circle primitive.radius
: The radius of the circle to place. Mandatory. Introducesr
as a unit.
poly
: A custom polygon.points
: Mandatory array of anchors, representing the points of the polygon. Each item of the array is a regular anchor โ the only difference is that if itsref
is unspecified, the polygon's previous point will be assumed (to simulate a continuous chain). For the first point,[0, 0, 0ยฐ]
is assumed to be the starting point by default (as the polygon will be placed using a[0, 0]
origin anyway).
outline
: Allows reuse of an already existing outline as a primitive for further outlines.name
: The name to identify the outline to place. Mandatory.origin
: An optional anchor to specify which point in the existing outline to consider as the origin (i.e., the location of the outline by which it's placed at the requested points during outlining).tip
origin
is functionally identical to the globally availableadjust
, only it applies before placing each outline at the target points whileadjust
applies afterwards. Both options are available for flexibility, feel free to use either (or both in conjunction, if appropriate).
#
Syntactic sugarAt this point, we're done with actual outline functionality, but there are some extra shorthands and conveniences worth mentioning.
The first kind are string shorthands
, where a part within an outline is given by a single string instead of a whole object.
This is a streamlined way to refer to already existing outlines and combine them further.
The format of this string should start with a symbol from [+, -, ~, ^]
, followed by a name, and is equivalent to adding/subtracting/intersecting/stacking an outline of that name, respectively.
More specifically, ~something
is equivalent to:
what: outlinewhere: undefined # meaning [0, 0, 0ยฐ], so just placing the outline where it isname: somethingoperation: intersect
If the symbol prefix is missing, addition is assumed โ so simply naming outlines as parts works, too.
Another minor shorthand is declaring the expand
and joints
fields all at once using just the expand
field, and specifying its value as the number for the expansion, followed by either )
, >
, or ]
(representing round
, pointy
, and beveled
joints, respectively).
So an expand
value of 3]
would translate to:
expand: 3joints: beveled
Finally, "private" outlines: if we only want to use an outline as a building block for further outlines, we can start its name with an underscore (e.g., _my_name
) to prevent it from being actually exported.
(By convention, a starting underscore is kind of like a "private" marker.)
#
ExamplesShapes
- Config
- Visualization
Boolean operations
- Config
- Visualization
Asymmetry
- Config
- Visualization
Adjustments
- Config
- Visualization