@rulecad/shapes 0.0.0-stage → 0.1.1

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 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
- # Temporary Holding Version
1
+ # @rulecad/shapes
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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,43 @@
1
1
  {
2
2
  "name": "@rulecad/shapes",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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": {
7
+ "types": "./shapes.d.mts",
8
+ "default": "./shapes.mjs"
9
+ },
10
+ "files": [
11
+ "shapes.mjs",
12
+ "shapes.d.mts",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "license": "MIT",
17
+ "keywords": [
18
+ "jscad",
19
+ "3d-printing",
20
+ "text",
21
+ "engraving",
22
+ "cad"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "sideEffects": false,
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/JoniBach/rulecad.git",
31
+ "directory": "packages/shapes"
32
+ },
33
+ "scripts": {
34
+ "test": "node --test"
35
+ },
36
+ "dependencies": {
37
+ "@rulecad/variables": "^0.1.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@jscad/modeling": "^2.12.0"
41
+ },
42
+ "types": "./shapes.d.mts"
43
+ }
package/shapes.d.mts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Text as a 2D outline that prints: JSCAD's single-line font, a capital `height` tall, lines `stroke` wide.
3
+ * @param {{ text: string, height: number, stroke: number, segments?: number }} options
4
+ * @returns {object} a JSCAD geom2
5
+ */
6
+ export function text({ text: input, height, stroke, segments }: {
7
+ text: string;
8
+ height: number;
9
+ stroke: number;
10
+ segments?: number;
11
+ }): object;
12
+ /**
13
+ * Where something w × d goes on a face width × depth (centred on 0, y to the back).
14
+ * @param {{ width: number, depth: number }} face @param {[number, number]} size
15
+ * @param {'tl'|'tc'|'tr'|'ml'|'mc'|'mr'|'bl'|'bc'|'br'} [at]
16
+ * @returns {[number, number]}
17
+ */
18
+ export function anchor({ width, depth }: {
19
+ width: number;
20
+ depth: number;
21
+ }, [w, d]: [number, number], at?: "tl" | "tc" | "tr" | "ml" | "mc" | "mr" | "bl" | "bc" | "br"): [number, number];
22
+ /** a with b taken out, cutting only a's faces near b. @param {object} a JSCAD geom3 @param {object} b JSCAD geom3 @param {number} [margin] @returns {object} */
23
+ export function cut(a: object, b: object, margin?: number): object;
24
+ export function size(g: any): number[];
25
+ export const scale: typeof jscad.transforms.scale;
26
+ import jscad from '@jscad/modeling';
package/shapes.mjs ADDED
@@ -0,0 +1,82 @@
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
+ /**
22
+ * Text as a 2D outline that prints: JSCAD's single-line font, a capital `height` tall, lines `stroke` wide.
23
+ * @param {{ text: string, height: number, stroke: number, segments?: number }} options
24
+ * @returns {object} a JSCAD geom2
25
+ */
26
+ export function text({ text: input, height, stroke, segments = 8 }) {
27
+ if (!input) throw new Error('text: nothing to write');
28
+ if (input in ARROWS) return union(...arrow(input, height).map((l) => expand({ delta: stroke / 2, corners: 'round', segments }, path2.fromPoints({}, l))));
29
+ const lines = vectorText({ height, input }).filter((l) => l.length > 1);
30
+ if (!lines.length) throw new Error(`text: the font has none of "${input}"`);
31
+ // One scale for every legend: a capital H is height tall, so a dash or a full stop stays small. Then centre the text
32
+ // on its own outline, and thicken the lines (after scaling, so the stroke is exactly as wide as asked)
33
+ const capital = measureBoundingBox(path2.fromPoints({}, vectorText({ height, input: 'H' }).flat()));
34
+ const k = height / (capital[1][1] - capital[0][1] || 1);
35
+ const [lo, hi] = measureBoundingBox(path2.fromPoints({}, lines.flat()));
36
+ const cx = (lo[0] + hi[0]) / 2, cy = (lo[1] + hi[1]) / 2;
37
+ // Each line, scaled; points closer than half a stroke are dropped (thickening them would fold the outline over itself),
38
+ // and a line smaller than the stroke (a full stop's tiny loop) becomes a round dot
39
+ const strokes = lines.map((l) => {
40
+ const pts = l.map(([x, y]) => [(x - cx) * k, (y - cy) * k]), kept = [pts[0]];
41
+ 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);
42
+ const span = Math.max(...[0, 1].map((i) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]))));
43
+ if (kept.length < 2 || span < stroke) {
44
+ const c = [0, 1].map((i) => (Math.max(...pts.map((p) => p[i])) + Math.min(...pts.map((p) => p[i]))) / 2);
45
+ return circle({ center: c, radius: Math.max(stroke * 0.75, (span + stroke) / 2), segments: segments * 2 });
46
+ }
47
+ // Each straight piece thickened on its own, then joined: thickening a whole line folds over itself at sharp turns
48
+ // (the point of an M), which leaves holes
49
+ return kept.slice(1).map((p, i) => expand({ delta: stroke / 2, corners: 'round', segments }, path2.fromPoints({}, [kept[i], p])));
50
+ }).flat();
51
+ return strokes.length > 1 ? union(...strokes) : strokes[0];
52
+ }
53
+
54
+ /**
55
+ * Where something w × d goes on a face width × depth (centred on 0, y to the back).
56
+ * @param {{ width: number, depth: number }} face @param {[number, number]} size
57
+ * @param {'tl'|'tc'|'tr'|'ml'|'mc'|'mr'|'bl'|'bc'|'br'} [at]
58
+ * @returns {[number, number]}
59
+ */
60
+ export function anchor({ width, depth }, [w, d], at = 'mc') {
61
+ if (!ANCHORS.includes(at)) throw new Error(`anchor: one of ${ANCHORS.join(', ')} (got "${at}")`);
62
+ const col = { l: -1, c: 0, r: 1 }[at[1]], row = { t: 1, m: 0, b: -1 }[at[0]];
63
+ return [col * Math.max(width - w, 0) / 2, row * Math.max(depth - d, 0) / 2];
64
+ }
65
+
66
+ // 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
67
+ // 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
68
+ // above). Here the faces clear of b's box pass through unchanged, and only the rest are cut.
69
+ /** a with b taken out, cutting only a's faces near b. @param {object} a JSCAD geom3 @param {object} b JSCAD geom3 @param {number} [margin] @returns {object} */
70
+ export function cut(a, b, margin = 0.05) {
71
+ const { geom3 } = jscad.geometries, [lo, hi] = measureBoundingBox(b);
72
+ const near = [], far = [];
73
+ for (const p of geom3.toPolygons(a)) {
74
+ 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);
75
+ (clear ? far : near).push(p);
76
+ }
77
+ if (!near.length) return a;
78
+ return geom3.create([...far, ...geom3.toPolygons(subtract(geom3.create(near), b))]);
79
+ }
80
+
81
+ export const size = (g) => { const [lo, hi] = measureBoundingBox(g); return [hi[0] - lo[0], hi[1] - lo[1]]; };
82
+ export { scale };