@rulecad/shapes 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +23 -2
- package/package.json +36 -4
- package/shapes.mjs +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JoniBach and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rulecad/shapes
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Helpers for [JSCAD](https://github.com/jscad/OpenJSCAD.org) designs that get printed. JSCAD is a peer dependency: use
|
|
4
|
+
the one you already have.
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
import jscad from '@jscad/modeling';
|
|
8
|
+
import { text, anchor, cut } from '@rulecad/shapes';
|
|
9
|
+
|
|
10
|
+
const line = 0.42; // your printer's line width
|
|
11
|
+
const legend = text({ text: 'Esc', height: 3.5, stroke: line * 2 }); // a 2D outline
|
|
12
|
+
const [x, y] = anchor({ width: 18, depth: 18 }, [8, 4], 'tl'); // where it goes on an 18 mm face
|
|
13
|
+
const inlay = jscad.transforms.translate([x, y, 0], jscad.extrusions.extrudeLinear({ height: 0.6 }, legend));
|
|
14
|
+
const cap = cut(body, inlay); // only the faces near the legend are cut
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `text({ text, height, stroke, segments })` uses JSCAD's single-line font, scaled so a capital is `height` tall.
|
|
18
|
+
Every line is thickened to `stroke` wide, so make it whole print lines. Punctuation keeps its size, and the arrows
|
|
19
|
+
the font lacks (↑ ↓ ← →) are drawn in the same stroke.
|
|
20
|
+
- `anchor({ width, depth }, [w, d], at)` places something `w` × `d` on a face at one of nine anchors: `tl tc tr / ml
|
|
21
|
+
mc mr / bl bc br`. The face is centred on 0, with y to the back.
|
|
22
|
+
- `cut(a, b, margin)` is `subtract(a, b)`, but only the faces near `b` are cut. JSCAD's subtract splits every face of
|
|
23
|
+
`a` along `b`'s planes, however far away, and those splits can leave hairline gaps that slicers dislike.
|
|
24
|
+
- `size(geom2)` gives a 2D outline's `[width, depth]`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulecad/shapes",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shape helpers for printed JSCAD designs: text that prints cleanly, anchors on a face, and clean local cuts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": "./shapes.mjs",
|
|
7
|
+
"files": [
|
|
8
|
+
"shapes.mjs",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"jscad",
|
|
15
|
+
"3d-printing",
|
|
16
|
+
"text",
|
|
17
|
+
"engraving",
|
|
18
|
+
"cad"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/JoniBach/rulecad.git",
|
|
27
|
+
"directory": "packages/shapes"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@rulecad/variables": "^0.1.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@jscad/modeling": "^2.12.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/shapes.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Shape helpers for JSCAD designs that are printed: text that prints, and placing things on a face.
|
|
2
|
+
// text({ text, height, stroke, segments }) -> a 2D outline of the text, centred on 0: JSCAD's single-line
|
|
3
|
+
// font, scaled so a capital is height tall, each line thickened to stroke wide (whole print lines, so it prints).
|
|
4
|
+
// The arrows the font lacks (up, down, left, right) are drawn in the same stroke.
|
|
5
|
+
// anchor({ width, depth }, size, at) -> [x, y]: where something size wide and deep goes on a face width ×
|
|
6
|
+
// depth (centred on 0, y to the back) at one of 9 anchors: tl tc tr / ml mc mr / bl bc br
|
|
7
|
+
import jscad from '@jscad/modeling';
|
|
8
|
+
import { ANCHORS } from '@rulecad/variables';
|
|
9
|
+
|
|
10
|
+
const { vectorText } = jscad.text, { path2 } = jscad.geometries, { expand } = jscad.expansions, { circle } = jscad.primitives;
|
|
11
|
+
const { union, subtract } = jscad.booleans, { translate, scale } = jscad.transforms, { measureBoundingBox } = jscad.measurements;
|
|
12
|
+
|
|
13
|
+
// Arrows the font doesn't have, as lines in a box height tall: a shaft and a head, pointing up, then turned
|
|
14
|
+
const ARROWS = { '↑': 0, '→': -90, '↓': 180, '←': 90 };
|
|
15
|
+
function arrow(char, height) {
|
|
16
|
+
const h = height / 2, head = height * 0.35, turn = (ARROWS[char] * Math.PI) / 180;
|
|
17
|
+
const r = ([x, y]) => [x * Math.cos(turn) - y * Math.sin(turn), x * Math.sin(turn) + y * Math.cos(turn)];
|
|
18
|
+
return [[[0, -h], [0, h]], [[-head, h - head], [0, h], [head, h - head]]].map((l) => l.map(r));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function text({ text: input, height, stroke, segments = 8 }) {
|
|
22
|
+
if (!input) throw new Error('text: nothing to write');
|
|
23
|
+
if (input in ARROWS) return union(...arrow(input, height).map((l) => expand({ delta: stroke / 2, corners: 'round', segments }, path2.fromPoints({}, l))));
|
|
24
|
+
const lines = vectorText({ height, input }).filter((l) => l.length > 1);
|
|
25
|
+
if (!lines.length) throw new Error(`text: the font has none of "${input}"`);
|
|
26
|
+
// One scale for every legend: a capital H is height tall, so a dash or a full stop stays small. Then centre the text
|
|
27
|
+
// on its own outline, and thicken the lines (after scaling, so the stroke is exactly as wide as asked)
|
|
28
|
+
const capital = measureBoundingBox(path2.fromPoints({}, vectorText({ height, input: 'H' }).flat()));
|
|
29
|
+
const k = height / (capital[1][1] - capital[0][1] || 1);
|
|
30
|
+
const [lo, hi] = measureBoundingBox(path2.fromPoints({}, lines.flat()));
|
|
31
|
+
const cx = (lo[0] + hi[0]) / 2, cy = (lo[1] + hi[1]) / 2;
|
|
32
|
+
// Each line, scaled; points closer than half a stroke are dropped (thickening them would fold the outline over itself),
|
|
33
|
+
// and a line smaller than the stroke (a full stop's tiny loop) becomes a round dot
|
|
34
|
+
const strokes = lines.map((l) => {
|
|
35
|
+
const pts = l.map(([x, y]) => [(x - cx) * k, (y - cy) * k]), kept = [pts[0]];
|
|
36
|
+
for (const p of pts.slice(1)) if (Math.hypot(p[0] - kept.at(-1)[0], p[1] - kept.at(-1)[1]) >= stroke / 2) kept.push(p);
|
|
37
|
+
const span = Math.max(...[0, 1].map((i) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]))));
|
|
38
|
+
if (kept.length < 2 || span < stroke) {
|
|
39
|
+
const c = [0, 1].map((i) => (Math.max(...pts.map((p) => p[i])) + Math.min(...pts.map((p) => p[i]))) / 2);
|
|
40
|
+
return circle({ center: c, radius: Math.max(stroke * 0.75, (span + stroke) / 2), segments: segments * 2 });
|
|
41
|
+
}
|
|
42
|
+
// Each straight piece thickened on its own, then joined: thickening a whole line folds over itself at sharp turns
|
|
43
|
+
// (the point of an M), which leaves holes
|
|
44
|
+
return kept.slice(1).map((p, i) => expand({ delta: stroke / 2, corners: 'round', segments }, path2.fromPoints({}, [kept[i], p])));
|
|
45
|
+
}).flat();
|
|
46
|
+
return strokes.length > 1 ? union(...strokes) : strokes[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function anchor({ width, depth }, [w, d], at = 'mc') {
|
|
50
|
+
if (!ANCHORS.includes(at)) throw new Error(`anchor: one of ${ANCHORS.join(', ')} (got "${at}")`);
|
|
51
|
+
const col = { l: -1, c: 0, r: 1 }[at[1]], row = { t: 1, m: 0, b: -1 }[at[0]];
|
|
52
|
+
return [col * Math.max(width - w, 0) / 2, row * Math.max(depth - d, 0) / 2];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// cut(a, b): a with b taken out, touching only a's faces near b. JSCAD's subtract splits every face of a along the
|
|
56
|
+
// planes of b's faces, however far away, and the splits can leave tiny gaps (a keycap's skirt, cut by its legend 8 mm
|
|
57
|
+
// above). Here the faces clear of b's box pass through unchanged, and only the rest are cut.
|
|
58
|
+
export function cut(a, b, margin = 0.05) {
|
|
59
|
+
const { geom3 } = jscad.geometries, [lo, hi] = measureBoundingBox(b);
|
|
60
|
+
const near = [], far = [];
|
|
61
|
+
for (const p of geom3.toPolygons(a)) {
|
|
62
|
+
const vs = p.vertices, clear = [0, 1, 2].some((k) => Math.max(...vs.map((v) => v[k])) < lo[k] - margin || Math.min(...vs.map((v) => v[k])) > hi[k] + margin);
|
|
63
|
+
(clear ? far : near).push(p);
|
|
64
|
+
}
|
|
65
|
+
if (!near.length) return a;
|
|
66
|
+
return geom3.create([...far, ...geom3.toPolygons(subtract(geom3.create(near), b))]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const size = (g) => { const [lo, hi] = measureBoundingBox(g); return [hi[0] - lo[0], hi[1] - lo[1]]; };
|
|
70
|
+
export { scale };
|