1
0

Bringing points stuff up to spec

This commit is contained in:
Bán Dénes 2020-06-26 21:00:10 +02:00
parent 0ab5a246e5
commit a5e686b059
11 changed files with 474 additions and 108 deletions

5
.gitignore vendored
View File

@ -113,4 +113,7 @@ dist
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.pnp.*
# Project specific
output

View File

@ -87,7 +87,7 @@ In the following, we'll have an in-depth discussion about each of these, with an
## Points
A point in this context refers to a 2D point `[x,y]` with a rotation/orientation `r` added in.
These can be thought of as the middle points of the keycaps in a resulting keyboard layout, with an additional handling of and angle of the keycap.
These can be thought of as the middle points of the keycaps in a resulting keyboard layout, with an additional handling of the angle of the keycap.
What makes this generator "ergo" is the implicit focus on the column-stagger.
Of course we could simulate the traditional row-stagger by defining everything with a 90 degree rotation, but that's really not the goal here.
@ -172,7 +172,7 @@ But if `key = {a: 1}` is extended by `key = {b: 2}`, the result is `key = {a: 1,
Lastly, while there are a few key-specific attributes that have special meaning in the context of points (listed below), any key with any data can be specified here.
This can be useful for storing arbitrary meta-info about the keys, or just configuring later stages with key-level parameters.
So, for example, when the outline phase specifies `bind` as a key-level parameter, it means that the global value can be extended just like any other key-level attribute.
So, for example, when the outline phase specifies `bind` as a key-level parameter (see below), it means that the global value can be extended just like any other key-level attribute.
Now for the "official" key-level attributes:
@ -181,13 +181,15 @@ name: name_override # default = a concatenation of column and row
shift: [x, y] # default = [0, 0]
rotate: num # default = 0
origin: [x, y] # default = the center of the key
padding: num # default = 19
skip: boolean # default = false
asym: left|right|both # default = both
asym: left | right | both # default = both
```
`name` is the unique identifier of this specific key.
It defaults to a `<row>_<column>` format, but can be overridden if necessary.
`shift` and `rotate`/`origin` declare an extra, key-level translation or rotation, respectively.
Then we leave `padding` amount of vertical space before moving on to the next key in the column.
`skip` signals that the point is just a "helper" and should not be included in the output.
This can happen when a _real_ point is more easily calculable through a "stepping stone", but then we don't actually want the stepping stone to be a key itself.
Finally, `asym` relates to mirroring, which we'll cover in a second.
@ -339,13 +341,11 @@ ref: <point reference>
shift: [x, y]
rotate: num
origin: [x, y]
relative: boolean # default = false
```
The section's `top` and `bottom` are both formatted the same, and describe the center line's top and bottom intersections, respectively.
In a one-piece case, this means that we project a line from a left-side reference point (optionally rotated and translated), another from the right, and converge them to where they meet.
Split designs can specify `right` as a single number to mean the x coordinate where the side should be "cut off".
(The `relative` flag means the unit of the translation specified in `shift` is not mm, but the size the point is laid out with; see below.)
This leads to a gluing middle patch that can be used to meld the left and right sides together, given by the counter-clockwise polygon:
@ -371,13 +371,13 @@ Now we can configure what we want to "export" as outlines from this phase, given
- `all` : the combined outline that we've just created. Its parameters include:
- `size: num | [num_x, num_y]` : the width/height of the rectangles to lay onto the points
- `corner: num # default = 0)` : corner radius of the rectangle
- `corner_style: rounded | beveled # default = rounded)` : the styleof the rectangle's corners
- `bevel: num # default = 0)` : corner bevel of the rectangle, can be combined with rounding
- `keys` : only one side of the laid out keys, without the glue. Parameters:
- everything we could specify for `all`
- `side: left | right` : the side we want
- `glue` : just the glue, but the "ideal" version of it. This means that instead of the `glue` we defined above, we get `all` - `left` - `right`, so the _exact_ middle piece we would have needed to glue everything together. Parameters:
- everything we could specify for `all` (since those are needed for the calculation)
- `side: left | right | both # default = both)` : optionally, wecould choose only one side of the glue as well
- `side: left | right | both # default = both)` : optionally, we could choose only one side of the glue as well
Additionally, we can use primitive shapes:
@ -396,7 +396,6 @@ Additionally, we can use primitive shapes:
Using these, we define exports as follows:
```yaml
exports:
my_name:
@ -476,7 +475,7 @@ If we only want to use it as a building block for further exports, we can start
## Case
Cases add a pretty basic and minimal 3D aspect to the generation process.
In this phase, we take different outlines (exported from the above section, even the "private" ones), extrude and position them in space, optionally add some chamfer to the edges, and combine them into one 3D-printable object.
In this phase, we take different outlines (exported from the above section, even the "private" ones), extrude and position them in space, and combine them into one 3D-printable object.
That's it.
Declarations might look like this:
@ -485,17 +484,16 @@ case:
case_name:
- outline: <outline ref>
extrude: num # default = 1
chamfer: [num_top, num_bottom] # default = [0, 0]
translate: [x, y, z] # default = [0, 0, 0]
rotate: [ax, ay, az] # default = [0, 0, 0]
op: add|sub|diff # default = add
op: add | sub | diff # default = add
- ...
...
```
`outline` specifies which outline to import onto the xy plane, while `extrude` specifies how much it should be extruded along the z axis.
After the camfer to the top and/or bottom outside edges, the object is `translate`d/`rotate`d, and combined with what we have so far according to `op`.
If we only want to use an object as a building block for further objects, we can employ the same "start with an underscore" trick we learned at the outlines section.
After that, the object is `translate`d, `rotate`d, and combined with what we have so far according to `op`.
If we only want to use an object as a building block for further objects, we can employ the same "start with an underscore" trick we learned at the outlines section to make it "private".

View File

@ -11,7 +11,7 @@
"bin": "./src/cli.js",
"scripts": {
"build": "rollup -c",
"test": "nyc mocha -r chai/register-should"
"test": "nyc --reporter=html --reporter=text mocha -r chai/register-should"
},
"dependencies": {
"fs-extra": "^9.0.1",
@ -28,7 +28,11 @@
},
"nyc": {
"all": true,
"include": ["src/*.js"],
"exclude": ["src/cli.js"]
"include": [
"src/*.js"
],
"exclude": [
"src/cli.js"
]
}
}

View File

@ -1,11 +1,28 @@
#!/usr/bin/env node
const m = require('makerjs')
const fs = require('fs-extra')
const path = require('path')
const yaml = require('js-yaml')
const yargs = require('yargs')
const points_lib = require('../helpers/points')
const u = require('./utils')
const points_lib = require('./points')
const outline_lib = require('./outline')
const dump_model = (model, file='model') => {
const assembly = m.model.originate({
models: u.deepcopy(model),
units: 'mm'
})
fs.mkdirpSync(path.dirname(`${file}.dxf`))
fs.writeFileSync(`${file}.dxf`, m.exporter.toDXF(assembly))
if (args.debug) {
fs.writeJSONSync(`${file}.json`, assembly, {spaces: 4})
}
}
const args = yargs
.option('config', {
alias: 'c',
@ -24,48 +41,20 @@ const args = yargs
hidden: true,
type: 'boolean'
})
.option('outline', {
default: true,
describe: 'Generate 2D outlines',
type: 'boolean'
})
.option('pcb', {
default: false,
describe: 'Generate PCB draft',
type: 'boolean'
})
.option('case', {
default: false,
describe: 'Generate case files',
type: 'boolean'
})
.argv
if (!args.outline && !args.pcb && !args.case) {
yargs.showHelp('log')
console.log('Nothing to do...')
process.exit(0)
}
fs.mkdirpSync(args.o)
const config = yaml.load(fs.readFileSync(args.c).toString())
const points = points_lib.parse(config)
const config_parser = args.c.endsWith('.yaml') ? yaml.load : JSON.parse
const config = config_parser(fs.readFileSync(args.c).toString())
const points = points_lib.parse(config.points)
if (args.debug) {
points_lib.dump(points)
}
if (args.outline) {
outline_lib.draw(points, config)
fs.writeJSONSync(path.join(args.o, 'points.json'), points, {spaces: 4})
const size = 14
const rect = u.rect(size, size, [-size/2, -size/2])
const points_demo = outline_lib.layout(points, rect)
dump_model(points_demo, path.join(args.o, 'points_demo'))
}
console.log('Done.')
// exports.dump_model = (model, file='model', json=false) => {
// const assembly = m.model.originate({
// models: deepcopy(model),
// units: 'mm'
// })
// if (json) fs.writeFileSync(`${file}.json`, JSON.stringify(assembly, null, ' '))
// fs.writeFileSync(`${file}.dxf`, m.exporter.toDXF(assembly))
// }

View File

@ -1,23 +1,25 @@
const m = require('makerjs')
const fs = require('fs-extra')
const assert = require('assert').strict
const u = require('./utils')
const layout = exports.layout = (points, shape) => {
const shapes = {}
for (const [pname, p] of Object.entries(points)) {
shapes[pname] = p.position(u.deepcopy(shape))
}
return {layout: {models: shapes}}
}
const outline = exports._outline = (points, config={}) => params => {
const outline = (points, config) => {
let size = params.size || [18, 18]
if (!Array.isArray(size)) size = [size, size]
const corner = params.corner || 0
assert.ok(config.outline)
const footprint = config.outline.footprint || 18
const corner = config.outline.corner || 0
const global_bind = config.outline.bind || 5
const global_bind = config.bind || 5
let glue = {paths: {}}
if (config.outline.glue) {
const glue_conf = config.outline.glue
if (config.glue) {
const internal_part = (line) => {
// taking the middle part only, so that we don't interfere with corner rounding
@ -25,23 +27,33 @@ const outline = (points, config) => {
}
const get_line = (def={}) => {
const point = points[def.key]
if (!point) throw new Error(`Point ${def.key} not found...`)
const ref = points[def.ref]
if (!ref) throw new Error(`Point ${def.ref} not found...`)
let from = [0, 0]
let to = [ref.meta.mirrored ? -1 : 1, 0]
// todo: position according to point to get the lines...
let point = ref.clone().shift(def.shift || [0, 0])
point.rotate(def.rotate || 0, point.add(def.origin || [0, 0]))
const rect = m.model.originate(point.rect(footprint))
line = rect.paths[def.line || 'top']
return internal_part(line)
}
assert.ok(glue_conf.top)
const tll = get_line(glue_conf.top.left)
const trl = get_line(glue_conf.top.right)
assert.ok(config.glue.top)
const tll = get_line(config.glue.top.left)
const trl = get_line(config.glue.top.right)
const tip = m.path.converge(tll, trl)
const tlp = u.eq(tll.origin, tip) ? tll.end : tll.origin
const trp = u.eq(trl.origin, tip) ? trl.end : trl.origin
assert.ok(glue_conf.bottom)
const bll = get_line(glue_conf.bottom.left)
const brl = get_line(glue_conf.bottom.right)
assert.ok(config.glue.bottom)
const bll = get_line(config.glue.bottom.left)
const brl = get_line(config.glue.bottom.right)
const bip = m.path.converge(bll, brl)
const blp = u.eq(bll.origin, bip) ? bll.end : bll.origin
const brp = u.eq(brl.origin, bip) ? brl.end : brl.origin
@ -49,7 +61,7 @@ const outline = (points, config) => {
const left_waypoints = []
const right_waypoints = []
for (const w of glue_conf.waypoints || []) {
for (const w of config.glue.waypoints || []) {
const percent = w.percent / 100
const center_x = tip[0] + percent * (bip[0] - tip[0])
const center_y = tip[1] + percent * (bip[1] - tip[1])

View File

@ -1,7 +1,7 @@
const m = require('makerjs')
const u = require('./utils')
class Point {
module.exports = class Point {
constructor(x=0, y=0, r=0, meta={}) {
if (Array.isArray(x)) {
this.x = x[0]
@ -67,5 +67,3 @@ class Point {
return this.position(rect)
}
}
module.exports = Point

View File

@ -1,7 +1,37 @@
const m = require('makerjs')
const u = require('./utils')
const Point = require('./point')
const push_rotation = (list, angle, origin) => {
const extend_pair = exports._extend_pair = (to, from) => {
const to_type = u.type(to)
const from_type = u.type(from)
if (!from && ['array', 'object'].includes(to_type)) return to
if (to_type != from_type) return from
if (from_type == 'object') {
const res = {}
for (const key of Object.keys(from)) {
res[key] = extend_pair(to[key], from[key])
}
return res
} else if (from_type == 'array') {
const res = u.deepcopy(to)
for (const [i, val] of from.entries()) {
res[i] = extend_pair(res[i], val)
}
return res
} else return from
}
const extend = exports._extend = (...args) => {
let res = args[0]
for (const arg of args) {
if (res == arg) continue
res = extend_pair(res, arg)
}
return res
}
const push_rotation = exports._push_rotation = (list, angle, origin) => {
let candidate = origin
for (const r of list) {
candidate = m.point.rotate(candidate, r.angle, r.origin)
@ -12,9 +42,8 @@ const push_rotation = (list, angle, origin) => {
})
}
const render_zone = (cols, rows, anchor=new Point(), reverse=false) => {
const render_zone = exports._render_zone = (cols, rows, zone_wide_key, anchor) => {
const sign = reverse ? -1 : 1
const points = {}
const rotations = []
@ -24,7 +53,7 @@ const render_zone = (cols, rows, anchor=new Point(), reverse=false) => {
origin: anchor.p
})
for (const col of cols) {
for (const [colname, col] of Object.entries(cols)) {
anchor.y += col.stagger || 0
const col_anchor = anchor.clone()
@ -33,26 +62,36 @@ const render_zone = (cols, rows, anchor=new Point(), reverse=false) => {
col_anchor.r = 0
// combine row data from zone-wide defs and col-specific defs
const col_specific = col.rows || []
const zone_wide = rows || []
const actual_rows = []
for (let i = 0; i < zone_wide.length && i < col_specific.length; ++i) {
actual_rows.push(Object.assign({}, zone_wide[i], col_specific[i]))
const col_specific_rows = col.rows || {}
const zone_wide_rows = rows || {}
const actual_rows = col_specific_rows || zone_wide_rows
// get key config through the 4-level extension
const keys = []
for (const row of Object.keys(actual_rows)) {
const key = extend(zone_wide_key, col.key || {}, zone_wide_rows[row] || {}, col_specific_rows[row] || {})
key.col = col
key.row = row
key.name = `${colname}_${row}`
keys.push(key)
}
for (const row of actual_rows) {
// lay out keys
for (const key of keys) {
let point = col_anchor.clone()
for (const r of rotations) {
point.rotate(r.angle, r.origin)
}
point.r += col.angle || 0
const name = `${col.name}_${row.name}`
point.meta = {col, row, name}
points[name] = point
if (key.rotate) {
point.rotate(key.rotate, point.add(key.origin || [0, 0]).p)
}
point.meta = key
points[key.name] = point
col_anchor.y += row.padding || 19
col_anchor.y += key.padding || 19
}
// apply col-level rotation for the next columns
if (col.rotate) {
push_rotation(
rotations,
@ -61,23 +100,23 @@ const render_zone = (cols, rows, anchor=new Point(), reverse=false) => {
)
}
anchor.x += sign * (col.padding || 19)
anchor.x += col.spread || 19
}
return points
}
const anchor = (raw, points={}) => {
const anchor = exports._anchor = (raw, points={}) => {
let a = new Point()
if (raw) {
if (raw.ref && points[raw.ref]) {
a = points[raw.ref].clone()
}
if (raw.shift) {
a.x += raw.shift[0]
a.y += raw.shift[1]
a.x += raw.shift[0] || 0
a.y += raw.shift[1] || 0
}
a.r += raw.angle || 0
a.r += raw.rotate || 0
}
return a
}
@ -90,29 +129,32 @@ exports.parse = (config) => {
points = Object.assign(points, render_zone(
zone.columns || [],
zone.rows || [{name: 'default'}],
anchor(zone.anchor, points),
!!zone.reverse
zone.key || {},
anchor(zone.anchor, points)
))
}
if (config.angle) {
if (config.rotate) {
for (const p of Object.values(points)) {
p.rotate(config.angle)
p.rotate(config.rotate)
}
}
if (config.mirror) {
let axis = anchor(config.mirror, points).x
axis += (config.mirror.distance || 0) / 2
let axis = config.mirror.axis
if (!axis) {
axis = anchor(config.mirror, points).x
axis += (config.mirror.distance || 0) / 2
}
const mirrored_points = {}
for (const [name, p] of Object.entries(points)) {
if (p.meta.col.asym == 'left' || p.meta.row.asym == 'left') continue
if (p.meta.asym == 'left') continue
const mp = p.clone().mirror(axis)
mp.meta.mirrored = true
delete mp.meta.asym
mirrored_points[`mirror_${name}`] = mp
if (p.meta.col.asym == 'right' || p.meta.row.asym == 'right') {
p.meta.col.skip = true
if (p.meta.asym == 'right') {
p.meta.skip = true
}
}
Object.assign(points, mirrored_points)
@ -120,7 +162,7 @@ exports.parse = (config) => {
const filtered = {}
for (const [k, p] of Object.entries(points)) {
if (p.meta.col.skip || p.meta.row.skip) continue
if (p.meta.skip) continue
filtered[k] = p
}

View File

@ -1,7 +1,19 @@
const m = require('makerjs')
exports.assert = (exp, msg) => {
if (!exp) {
throw new Error(msg)
}
}
exports.deepcopy = (value) => JSON.parse(JSON.stringify(value))
exports.type = (val) => {
if (Array.isArray(val)) return 'array'
if (val === null) return 'null'
return typeof val
}
const eq = exports.eq = (a=[], b=[]) => {
return a[0] === b[0] && a[1] === b[1]
}

17
test/complex.js Normal file
View File

@ -0,0 +1,17 @@
const fs = require('fs-extra')
const path = require('path')
const yaml = require('js-yaml')
const points_lib = require('../src/points')
const fixtures = path.join(__dirname, 'fixtures')
const absolem_config = yaml.load(fs.readFileSync(path.join(fixtures, 'absolem.yaml')).toString())
describe('Absolem', function() {
it('#points', function() {
const expected = fs.readJSONSync(path.join(fixtures, 'absolem_points.json'))
const actual = points_lib.parse(absolem_config.points)
// remove metadata, so that it only checks the points
Object.values(actual).map(val => delete val.meta)
actual.should.deep.equal(expected)
})
})

109
test/fixtures/absolem.yaml vendored Normal file
View File

@ -0,0 +1,109 @@
points:
zones:
matrix:
anchor:
rotate: 5
columns:
pinky:
rotate: -5
origin: [7, -7]
rows:
bottom:
home:
neighbors: [right]
top:
neighbors: [right]
ring:
stagger: 12
rows:
bottom:
neighbors: [left]
home:
neighbors: [right]
top:
neighbors: [right]
middle:
stagger: 5
rows:
bottom:
neighbors: [both]
home:
neighbors: [both]
top:
index:
stagger: -6
rows:
bottom:
neighbors: [right]
home:
neighbors: [left]
top:
neighbors: [left]
inner:
stagger: -2
rows:
bottom:
home:
neighbors: [left]
top:
neighbors: [left]
rows:
bottom:
neighbors: [,up]
home:
neighbors: [,up]
top:
thumbfan:
anchor:
ref: inner_bottom
shift: [-7, -19]
columns:
near:
spread: 21.25
rotate: -28
origin: [9.5, -9]
rows:
thumb:
neighbors: [right]
home:
spread: 21.25
rotate: -28
origin: [11.75, -9]
rows:
thumb:
neighbors: [both]
far:
rows:
thumb:
neighbors: [left]
rows:
thumb:
neighbors: [,up]
rotate: -20
mirror:
ref: pinky_home
distance: 223.7529778
outline:
bind: 10
glue:
top:
left:
ref: inner_top
shift: [,7]
right:
ref: mirror_inner_top
shift: [,7]
bottom:
left:
ref: far_thumb
shift: [7]
rotate: 90
right:
ref: mirror_far_thumb
shift: [-7]
rotate: 90
waypoints:
- percent: 50
width: 100
- percent: 90
width: 50

182
test/fixtures/absolem_points.json vendored Normal file
View File

@ -0,0 +1,182 @@
{
"pinky_bottom": {
"x": 0,
"y": 0,
"r": -15
},
"pinky_home": {
"x": 4.9175619,
"y": 18.3525907,
"r": -15
},
"pinky_top": {
"x": 9.8351237,
"y": 36.7051814,
"r": -15
},
"ring_bottom": {
"x": 22.7244416,
"y": 5.176704,
"r": -20
},
"ring_home": {
"x": 29.2228244,
"y": 23.0308638,
"r": -20
},
"ring_top": {
"x": 35.7212071,
"y": 40.8850235,
"r": -20
},
"middle_bottom": {
"x": 42.2887022,
"y": 3.3767843,
"r": -20
},
"middle_home": {
"x": 48.7870849,
"y": 21.2309442,
"r": -20
},
"middle_top": {
"x": 55.2854676,
"y": 39.0851039,
"r": -20
},
"index_bottom": {
"x": 58.0907411,
"y": -8.7597541,
"r": -20
},
"index_home": {
"x": 64.5891238,
"y": 9.0944057,
"r": -20
},
"index_top": {
"x": 71.0875065,
"y": 26.9485655,
"r": -20
},
"inner_bottom": {
"x": 75.2608606,
"y": -17.1375221,
"r": -20
},
"inner_home": {
"x": 81.7592433,
"y": 0.7166377,
"r": -20
},
"inner_top": {
"x": 88.257626,
"y": 18.5707976,
"r": -20
},
"near_thumb": {
"x": 62.1846295,
"y": -32.5975409,
"r": -20
},
"home_thumb": {
"x": 82.5841162,
"y": -47.013742,
"r": -48
},
"far_thumb": {
"x": 94.7890169,
"y": -68.8083815,
"r": -76
},
"mirror_pinky_bottom": {
"x": 233.5881016,
"y": 0,
"r": 15
},
"mirror_pinky_home": {
"x": 228.67053969999998,
"y": 18.3525907,
"r": 15
},
"mirror_pinky_top": {
"x": 223.7529779,
"y": 36.7051814,
"r": 15
},
"mirror_ring_bottom": {
"x": 210.86365999999998,
"y": 5.176704,
"r": 20
},
"mirror_ring_home": {
"x": 204.36527719999998,
"y": 23.0308638,
"r": 20
},
"mirror_ring_top": {
"x": 197.8668945,
"y": 40.8850235,
"r": 20
},
"mirror_middle_bottom": {
"x": 191.29939939999997,
"y": 3.3767843,
"r": 20
},
"mirror_middle_home": {
"x": 184.8010167,
"y": 21.2309442,
"r": 20
},
"mirror_middle_top": {
"x": 178.30263399999998,
"y": 39.0851039,
"r": 20
},
"mirror_index_bottom": {
"x": 175.49736049999998,
"y": -8.7597541,
"r": 20
},
"mirror_index_home": {
"x": 168.99897779999998,
"y": 9.0944057,
"r": 20
},
"mirror_index_top": {
"x": 162.5005951,
"y": 26.9485655,
"r": 20
},
"mirror_inner_bottom": {
"x": 158.327241,
"y": -17.1375221,
"r": 20
},
"mirror_inner_home": {
"x": 151.82885829999998,
"y": 0.7166377,
"r": 20
},
"mirror_inner_top": {
"x": 145.3304756,
"y": 18.5707976,
"r": 20
},
"mirror_near_thumb": {
"x": 171.4034721,
"y": -32.5975409,
"r": 20
},
"mirror_home_thumb": {
"x": 151.00398539999998,
"y": -47.013742,
"r": 48
},
"mirror_far_thumb": {
"x": 138.79908469999998,
"y": -68.8083815,
"r": 76
}
}