linework 0.3.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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/dist/helpers.d.ts +44 -0
- package/dist/helpers.js +59 -0
- package/dist/import.d.ts +103 -0
- package/dist/import.js +296 -0
- package/dist/linework.d.ts +89 -0
- package/dist/linework.js +133 -0
- package/dist/orbit.d.ts +21 -0
- package/dist/orbit.js +53 -0
- package/dist/sketch.d.ts +78 -0
- package/dist/sketch.js +150 -0
- package/package.json +31 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.3.0] — 2026-07-24
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **More import formats** (`linework/import`): `parseSTL` (binary or ASCII — the
|
|
10
|
+
3D-printing format), `fromBufferGeometry` (adapt a three.js `BufferGeometry`
|
|
11
|
+
with no three.js dependency), and `fromOcct` (map [occt-import-js] STEP/IGES
|
|
12
|
+
results in — the CAD kernel stays an optional ~6 MB peer, not a dependency).
|
|
13
|
+
- Live demo importer now accepts `.stl` alongside `.glb` / `.obj`.
|
|
14
|
+
- 6 more tests (binary + ASCII STL round-trips, size-based binary detection,
|
|
15
|
+
three.js adapter indexed/non-indexed, occt result mapping). 25 total.
|
|
16
|
+
|
|
17
|
+
[occt-import-js]: https://github.com/kovacsv/occt-import-js
|
|
18
|
+
|
|
19
|
+
## [0.2.0] — 2026-07-24
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **Model import** (`linework/import`): turn a **glTF/GLB or OBJ mesh** into
|
|
23
|
+
linework strokes via feature-edge extraction — boundary edges plus sharp
|
|
24
|
+
creases (configurable `angle`), with vertices welded by position so shared
|
|
25
|
+
edges aren't mistaken for boundaries. `parseGLB` / `parseOBJ` / `featureEdges`
|
|
26
|
+
/ `meshToShapes`, all zero-dependency; no Draco, geometry-only.
|
|
27
|
+
- Live "drop a .glb / .obj" importer on the demo page, running entirely in the
|
|
28
|
+
browser, defaulting to a CC0 street lantern (5,394 tris → feature edges).
|
|
29
|
+
- 6 new tests (cube edge count, crease threshold, boundary detection, vertex
|
|
30
|
+
welding, OBJ parsing, fitted emit). 19 total.
|
|
31
|
+
|
|
32
|
+
[0.2.0]: https://github.com/isaacrowntree/linework/releases/tag/v0.2.0
|
|
33
|
+
|
|
34
|
+
## [0.1.0] — 2026-07-18
|
|
35
|
+
|
|
36
|
+
First public release.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- **Core renderer** (`linework`): the classic pipeline — 3D points → yaw/pitch
|
|
40
|
+
rotation → perspective projection → painter's-algorithm depth sorting → SVG
|
|
41
|
+
strings. Multi-stroke paths, plane-correct disc ellipses, backface-culled
|
|
42
|
+
quads, per-object sorting for animated part groups, depth cueing.
|
|
43
|
+
- **Sketch layer** (`linework/sketch`): fluent authoring API with scoped context
|
|
44
|
+
blocks (`part` / `tag` / `bias`), chainable paths, a view-aware `cyl`, paper-space
|
|
45
|
+
notes, and the `scene()` replay idiom for animation frames.
|
|
46
|
+
- **Orbit helper** (`linework/orbit`): drag-to-orbit boilerplate as a one-liner —
|
|
47
|
+
pointer capture, yaw/pitch clamping, rAF batching, double-click reset, and
|
|
48
|
+
reduced-motion-aware idle sway.
|
|
49
|
+
- **Helpers** (`linework/helpers`): the primitive-builder middle layer.
|
|
50
|
+
- 13 invariant tests, GitHub Actions CI, ESM build with type declarations.
|
|
51
|
+
- Blueprint demo site rendered entirely by the library; server-side hero image
|
|
52
|
+
via `npm run hero`.
|
|
53
|
+
|
|
54
|
+
[0.1.0]: https://github.com/isaacrowntree/linework/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Isaac Rowntree
|
|
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
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# linework
|
|
2
|
+
|
|
3
|
+
[](https://github.com/isaacrowntree/linework/actions)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](package.json)
|
|
6
|
+
[](https://isaacrowntree.com/linework/)
|
|
7
|
+
|
|
8
|
+
A tiny **true-3D renderer for annotated technical drawings**, output as plain SVG strings. Rotate → project → depth-sort → paint. Zero dependencies, ~180-line core, fully tested.
|
|
9
|
+
|
|
10
|
+
### ▶ [Try the live demo — drop a 3D model, watch it become a drawing](https://isaacrowntree.com/linework/)
|
|
11
|
+
|
|
12
|
+
[](https://isaacrowntree.com/linework/)
|
|
13
|
+
|
|
14
|
+
*The lantern above was **imported from a glTF mesh** and rendered by the library itself, in Node, with zero client JavaScript. On the [live demo](https://isaacrowntree.com/linework/) it orbits under your pointer — and you can drop your own model.*
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
The category is empty. WebGL libraries (three.js) make *shaded surfaces* easy and annotated linework painful — thin strokes, dash patterns, line-weight hierarchy, text callouts are all fights. Pseudo-3D toys (Zdog — last release 2019) can't do text or fine-grained depth sorting. Nothing ships "parametric technical illustration": exploded diagrams, dimension lines, balloons, title blocks. This does exactly that, and nothing else.
|
|
19
|
+
|
|
20
|
+
- **The classic pipeline, honestly implemented** — 3D points → yaw/pitch rotation → perspective projection → painter's-algorithm depth sorting *per frame* → SVG string. Paint order is computed, never authored.
|
|
21
|
+
- **Shapes built for drawings, not games** — multi-stroke paths (hollow-tube outlines as one shape), discs that project to correct ellipses, backface-culled boxes, per-object sorting for animated parts.
|
|
22
|
+
- **SVG strings are the point** — themeable with CSS variables, crawlable, printable, accessible, and **renderable server-side at build time**.
|
|
23
|
+
- **Styling is yours** — the library emits class names you define; it never dictates a look.
|
|
24
|
+
|
|
25
|
+
## The sketch layer — DX is a feature
|
|
26
|
+
|
|
27
|
+
Authoring should read like drafting, not like assembling tuples. Context blocks scope parts, tags and layering over everything drawn inside them:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { sketch, scene } from "linework/sketch";
|
|
31
|
+
|
|
32
|
+
const s = sketch({ yaw: 0.5, pitch: 0.16, f: 1500, cx: 460, cy: 320 });
|
|
33
|
+
|
|
34
|
+
s.box(300, 380, 320, 42, 40, 80); // base plate
|
|
35
|
+
|
|
36
|
+
s.part("housing", 'class="prt"', () => { // one <g>, one sort unit
|
|
37
|
+
s.cyl([460, 218], 70, 34, -34, "ink"); // bearing body
|
|
38
|
+
s.bias(0.6, () => s.cap([460, 218], 34, 32, "ink")); // bore, layered above
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
s.tube(9).M([180, 400]).Q([300, 420], 20, [420, 340], 10); // hollow frame tube
|
|
42
|
+
|
|
43
|
+
s.note("320 mm", [460, 452]); // paper-space annotation
|
|
44
|
+
el.innerHTML = s.render(); // depth-sorted SVG
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For animation, `scene()` gives you the frame-loop idiom — define once, replay with a new view per frame:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const draw = scene((s, { explode }) => { /* build with s.* */ });
|
|
51
|
+
el.innerHTML = draw({ yaw, pitch, f: 1500, cx: 460, cy: 320 }, { explode });
|
|
52
|
+
// ~1 ms for a few hundred shapes — drag-to-orbit rebuilds are free
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Prefer bare metal? `linework` exports the raw `Shape` types + `render()`/`xform()`, and `linework/helpers` sits in between.
|
|
56
|
+
|
|
57
|
+
## Import a 3D model → a technical drawing
|
|
58
|
+
|
|
59
|
+
`linework/import` turns a **3D mesh — glTF/GLB, OBJ, STL, or a three.js `BufferGeometry` — into linework strokes**. A shaded model carries no lines — its form lives in where the surface bends — so it recovers exactly the lines a draftsperson would draw: the outline and the hard creases, nothing from the smooth interior of a face. The result drops straight into `render()` and rotates like any other scene.
|
|
60
|
+
|
|
61
|
+
*(That lantern in the header is exactly this: a CC0 glTF — 5,394 triangles of shaded mesh → 2,479 feature edges → rotatable line drawing, in one `meshToShapes()` call. [Drop your own .glb on the demo](https://isaacrowntree.com/linework/).)*
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { parseGLB, meshToShapes } from "linework/import";
|
|
65
|
+
import { render } from "linework";
|
|
66
|
+
|
|
67
|
+
const meshes = parseGLB(await file.arrayBuffer()); // parseOBJ · parseSTL · fromBufferGeometry
|
|
68
|
+
const shapes = meshToShapes(meshes, {
|
|
69
|
+
angle: 25, // crease threshold°
|
|
70
|
+
fit: { cx: 400, cy: 300, size: 440 }, // fit into a screen box
|
|
71
|
+
});
|
|
72
|
+
el.innerHTML = render(shapes, { yaw: 0.6, pitch: 0.35, f: 1400, cx: 400, cy: 300 });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`featureEdges(mesh)` is exposed on its own if you want the raw edge list. Vertices are welded by position first, so meshes that split a shared edge across primitives still sort as one surface. **No Draco**, and geometry-only — textures and materials are ignored.
|
|
76
|
+
|
|
77
|
+
**Formats:**
|
|
78
|
+
|
|
79
|
+
| Input | Function | Notes |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| glTF / GLB | `parseGLB(buffer)` | embedded buffers, node transforms; no Draco |
|
|
82
|
+
| OBJ | `parseOBJ(text)` | fan-triangulated |
|
|
83
|
+
| STL | `parseSTL(buffer \| text)` | binary or ASCII; the 3D-printing format |
|
|
84
|
+
| three.js | `fromBufferGeometry(geo)` | reads the typed arrays; no three.js dependency |
|
|
85
|
+
| **STEP / IGES** | `fromOcct(result)` | via [occt-import-js](https://github.com/kovacsv/occt-import-js) (OpenCASCADE WASM) — you bring the kernel; linework stays tiny |
|
|
86
|
+
|
|
87
|
+
STEP is a trimmed-NURBS B-rep, not a mesh — tessellating it is a job for a real CAD kernel, so linework doesn't embed one. `occt-import-js` returns meshes that `fromOcct()` maps straight in, keeping the ~6 MB kernel an optional peer rather than a dependency.
|
|
88
|
+
|
|
89
|
+
## Coordinates (read this once)
|
|
90
|
+
|
|
91
|
+
| Field | Meaning |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `x` | right, in your SVG's user units |
|
|
94
|
+
| `y` | **down** — screen convention, not math convention |
|
|
95
|
+
| `z` | toward the viewer; negative recedes and depth-dims |
|
|
96
|
+
| `view.yaw` | rotation about the vertical axis through `cx` (radians) |
|
|
97
|
+
| `view.pitch` | rotation about the horizontal axis through `cy`; positive looks down |
|
|
98
|
+
| `view.f` | perspective focal distance — `k = f/(f−z)`; larger = flatter |
|
|
99
|
+
| `bias` | per-shape depth nudge for deliberate coplanar layering |
|
|
100
|
+
|
|
101
|
+
**Paper space:** annotations (dimensions, balloons, title blocks) are strings appended *after* the sorted scene — they never rotate, exactly like a real drawing's notes. `sketch.pt(p, z)` projects model points so leaders can pin paper to model.
|
|
102
|
+
|
|
103
|
+
**Known limitation** (shared by every painter's-algorithm renderer): cyclic overlaps can't sort correctly — split long members into segments if you construct one.
|
|
104
|
+
|
|
105
|
+
## Server-side rendering
|
|
106
|
+
|
|
107
|
+
`render()` is a pure string function. Static site generators can emit finished 3D-looking diagrams with **zero client JS**:
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
import { writeFileSync } from "node:fs";
|
|
111
|
+
writeFileSync("diagram.svg", wrapInSvgTag(render(shapes, view)));
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The hero image above and this repo's [demo page](https://isaacrowntree.com/linework/) are both drawn this way — see [`scripts/render-hero.mjs`](scripts/render-hero.mjs) and [`docs/scene.js`](docs/scene.js).
|
|
115
|
+
|
|
116
|
+
## Install & test
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm i linework # ESM, types included
|
|
120
|
+
npm test # 19 tests: projection, parallax, paint order, culling,
|
|
121
|
+
# sketch scoping, and feature-edge extraction
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Contributing
|
|
125
|
+
|
|
126
|
+
Small, dependency-free, and test-driven on purpose — see [CONTRIBUTING.md](CONTRIBUTING.md). Changes are tracked in [CHANGELOG.md](CHANGELOG.md). Issues and PRs welcome.
|
|
127
|
+
|
|
128
|
+
## Provenance
|
|
129
|
+
|
|
130
|
+
Extracted from **Fitment** — a "will that part fit your bike?" planner whose exploded service-manual drawings are rendered entirely by this engine, live-orbitable, with dimension callouts in paper space over the rotating model.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
[MIT](LICENSE) © Isaac Rowntree
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* helpers — the ergonomic layer over raw Shapes, extracted from real use
|
|
3
|
+
* (the Fitment bike planner drew its entire scene through these).
|
|
4
|
+
*
|
|
5
|
+
* Everything returns Shape[] fragments you concat into one scene, or
|
|
6
|
+
* pushes into the array you hand it. Class names are yours: the library
|
|
7
|
+
* never dictates styling.
|
|
8
|
+
*/
|
|
9
|
+
import type { Shape, Stroke, V3, Cmd } from "./linework.js";
|
|
10
|
+
export type P2 = [number, number];
|
|
11
|
+
export declare const v3: (p: P2, z?: number) => V3;
|
|
12
|
+
export declare const lerp2: (a: P2, b: P2, t: number) => P2;
|
|
13
|
+
export interface Opt {
|
|
14
|
+
bias?: number;
|
|
15
|
+
tag?: string;
|
|
16
|
+
part?: string;
|
|
17
|
+
fill?: string;
|
|
18
|
+
}
|
|
19
|
+
/** straight segment between two 3D points */
|
|
20
|
+
export declare const line3: (a: P2, za: number, b: P2, zb: number, cls: string, opt?: Opt) => Shape;
|
|
21
|
+
/** arbitrary path from Cmd list */
|
|
22
|
+
export declare const path3: (d: Cmd[], strokes: Stroke[], opt?: Opt) => Shape;
|
|
23
|
+
/** hollow-tube outline: fat outer stroke + panel-colored core, one shape */
|
|
24
|
+
export declare const tube: (d: Cmd[], w: number, outerCls?: string, innerCls?: string, opt?: Opt) => Shape;
|
|
25
|
+
/** circle lying in a constant-z plane (wheels, rings) — projects to a correct ellipse */
|
|
26
|
+
export declare const disc: (p: P2, z: number, r: number, strokes: Stroke[], opt?: Opt) => Shape;
|
|
27
|
+
/** circle facing along z (cylinder caps) — stylized: stays round and readable */
|
|
28
|
+
export declare const faceCap: (p: P2, z: number, r: number, strokes: Stroke[], opt?: Opt) => Shape;
|
|
29
|
+
/**
|
|
30
|
+
* A six-faced box: face plate at z, body extruded away from the viewer
|
|
31
|
+
* (z − dz). Outward windings + backface culling keep it contiguous at
|
|
32
|
+
* any yaw/pitch. Classes: face plate `faceCls`, top/bottom `topCls`,
|
|
33
|
+
* left/right `sideCls`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function box3(x: number, y: number, w: number, h: number, z: number, dz: number, faceCls?: string, topCls?: string, sideCls?: string, opt?: Opt): Shape[];
|
|
36
|
+
/**
|
|
37
|
+
* A stylized cylinder along the z axis: back cap, silhouette rails that
|
|
38
|
+
* hug the PROJECTED axis (correct at any view), front cap. Needs the
|
|
39
|
+
* current view's transform to place the rails; pass `xform(view)`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function cyl3(X: (p: V3) => {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
}, p: P2, r: number, zNear: number, zFar: number, capCls?: string, opt?: Opt): Shape[];
|
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const v3 = (p, z = 0) => [p[0], p[1], z];
|
|
2
|
+
export const lerp2 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
3
|
+
/** straight segment between two 3D points */
|
|
4
|
+
export const line3 = (a, za, b, zb, cls, opt = {}) => ({
|
|
5
|
+
t: "path", d: [["M", v3(a, za)], ["L", v3(b, zb)]], strokes: [{ cls }], ...opt,
|
|
6
|
+
});
|
|
7
|
+
/** arbitrary path from Cmd list */
|
|
8
|
+
export const path3 = (d, strokes, opt = {}) => ({
|
|
9
|
+
t: "path", d, strokes, ...opt,
|
|
10
|
+
});
|
|
11
|
+
/** hollow-tube outline: fat outer stroke + panel-colored core, one shape */
|
|
12
|
+
export const tube = (d, w, outerCls = "tube", innerCls = "tube-in", opt = {}) => ({
|
|
13
|
+
t: "path", d, strokes: [{ cls: outerCls, w }, { cls: innerCls, w: Math.max(1.5, w - 5) }], ...opt,
|
|
14
|
+
});
|
|
15
|
+
/** circle lying in a constant-z plane (wheels, rings) — projects to a correct ellipse */
|
|
16
|
+
export const disc = (p, z, r, strokes, opt = {}) => ({
|
|
17
|
+
t: "disc", c: v3(p, z), r, strokes, ...opt,
|
|
18
|
+
});
|
|
19
|
+
/** circle facing along z (cylinder caps) — stylized: stays round and readable */
|
|
20
|
+
export const faceCap = (p, z, r, strokes, opt = {}) => ({
|
|
21
|
+
t: "face", c: v3(p, z), r, strokes, ...opt,
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* A six-faced box: face plate at z, body extruded away from the viewer
|
|
25
|
+
* (z − dz). Outward windings + backface culling keep it contiguous at
|
|
26
|
+
* any yaw/pitch. Classes: face plate `faceCls`, top/bottom `topCls`,
|
|
27
|
+
* left/right `sideCls`.
|
|
28
|
+
*/
|
|
29
|
+
export function box3(x, y, w, h, z, dz, faceCls = "face3", topCls = "top3", sideCls = "side3", opt = {}) {
|
|
30
|
+
const zB = z - dz;
|
|
31
|
+
const A = [x, y, z], B = [x + w, y, z], C = [x + w, y + h, z], D = [x, y + h, z];
|
|
32
|
+
const A2 = [x, y, zB], B2 = [x + w, y, zB], C2 = [x + w, y + h, zB], D2 = [x, y + h, zB];
|
|
33
|
+
const b0 = opt.bias ?? 0;
|
|
34
|
+
const q = (qq, cls, bias) => ({ t: "quad", q: qq, cls, cull: true, ...opt, bias });
|
|
35
|
+
return [
|
|
36
|
+
q([A, A2, B2, B], topCls, b0),
|
|
37
|
+
q([D, C, C2, D2], topCls, b0),
|
|
38
|
+
q([A, A2, D2, D], sideCls, b0),
|
|
39
|
+
q([B, C, C2, B2], sideCls, b0),
|
|
40
|
+
q([A2, D2, C2, B2], faceCls, b0 + 0.1),
|
|
41
|
+
q([A, B, C, D], faceCls, b0 + 0.2),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A stylized cylinder along the z axis: back cap, silhouette rails that
|
|
46
|
+
* hug the PROJECTED axis (correct at any view), front cap. Needs the
|
|
47
|
+
* current view's transform to place the rails; pass `xform(view)`.
|
|
48
|
+
*/
|
|
49
|
+
export function cyl3(X, p, r, zNear, zFar, capCls = "cap3", opt = {}) {
|
|
50
|
+
const n = X(v3(p, zNear)), f = X(v3(p, zFar));
|
|
51
|
+
const dx = n.x - f.x, dy = n.y - f.y, m = Math.hypot(dx, dy) || 1;
|
|
52
|
+
const ox = (-dy / m) * r, oy = (dx / m) * r;
|
|
53
|
+
return [
|
|
54
|
+
{ t: "face", c: v3(p, zFar), r, strokes: [{ cls: capCls }], ...opt },
|
|
55
|
+
line3([p[0] + ox, p[1] + oy], zFar, [p[0] + ox, p[1] + oy], zNear, capCls, opt),
|
|
56
|
+
line3([p[0] - ox, p[1] - oy], zFar, [p[0] - ox, p[1] - oy], zNear, capCls, opt),
|
|
57
|
+
{ t: "face", c: v3(p, zNear), r, strokes: [{ cls: capCls }], ...opt, bias: (opt.bias ?? 0) + 0.5 },
|
|
58
|
+
];
|
|
59
|
+
}
|
package/dist/import.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* linework/import — turn a 3D mesh (glTF/GLB, OBJ, STL, or a three.js
|
|
3
|
+
* BufferGeometry) into linework strokes.
|
|
4
|
+
*
|
|
5
|
+
* The pipeline:
|
|
6
|
+
* parse mesh triangles (+ node transforms)
|
|
7
|
+
* → weld vertices by position
|
|
8
|
+
* → keep only FEATURE edges (boundary + sharp creases)
|
|
9
|
+
* → emit linework `path` Shapes at their 3D coordinates.
|
|
10
|
+
*
|
|
11
|
+
* A shaded 3D model carries no lines — its form lives in where the
|
|
12
|
+
* surface bends. Feature-edge extraction recovers exactly the lines a
|
|
13
|
+
* draftsperson would draw: the outline and the hard creases, nothing
|
|
14
|
+
* from the smooth interior of a face. The result drops straight into
|
|
15
|
+
* render()/sketch and rotates like any other linework scene.
|
|
16
|
+
*
|
|
17
|
+
* Zero dependencies. GLB, OBJ and STL are parsed by hand; no Draco.
|
|
18
|
+
*/
|
|
19
|
+
import type { Shape, V3 } from "./linework.js";
|
|
20
|
+
export interface Mesh {
|
|
21
|
+
/** flat [x,y,z, x,y,z, …] in world space (node transforms applied) */
|
|
22
|
+
positions: Float32Array;
|
|
23
|
+
/** triangle vertex indices */
|
|
24
|
+
indices: Uint32Array;
|
|
25
|
+
name?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Parse a .glb ArrayBuffer into world-space meshes. */
|
|
28
|
+
export declare function parseGLB(buffer: ArrayBuffer): Mesh[];
|
|
29
|
+
/** Parse a Wavefront .obj string into a single mesh (triangulated). */
|
|
30
|
+
export declare function parseOBJ(text: string): Mesh[];
|
|
31
|
+
/**
|
|
32
|
+
* Parse a binary or ASCII `.stl` into a single mesh. Binary is detected by
|
|
33
|
+
* the exact-size invariant (84 + 50·count bytes) rather than the unreliable
|
|
34
|
+
* "solid" prefix, since some binary exporters write "solid" in the header.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseSTL(data: ArrayBuffer | string): Mesh[];
|
|
37
|
+
/**
|
|
38
|
+
* Adapt a three.js `BufferGeometry` (or anything with the same shape) into a
|
|
39
|
+
* linework `Mesh` — no three.js dependency, it just reads the typed arrays.
|
|
40
|
+
* Bring an existing three scene's geometry straight into a technical drawing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function fromBufferGeometry(geometry: {
|
|
43
|
+
attributes: {
|
|
44
|
+
position: {
|
|
45
|
+
array: ArrayLike<number>;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
index?: {
|
|
49
|
+
array: ArrayLike<number>;
|
|
50
|
+
} | null;
|
|
51
|
+
}): Mesh;
|
|
52
|
+
/**
|
|
53
|
+
* Adapt the result of [occt-import-js](https://github.com/kovacsv/occt-import-js)
|
|
54
|
+
* — OpenCASCADE compiled to WASM — which is the practical way to get geometry
|
|
55
|
+
* out of a **STEP / IGES / BREP** CAD file. You bring the kernel (it's ~6 MB,
|
|
56
|
+
* so it stays an optional peer, not a linework dependency); each mesh it
|
|
57
|
+
* returns already matches a BufferGeometry, so this just maps and merges them.
|
|
58
|
+
*
|
|
59
|
+
* const occt = await occtimportjs();
|
|
60
|
+
* const result = occt.ReadStepFile(new Uint8Array(buf), null);
|
|
61
|
+
* const shapes = meshToShapes(fromOcct(result), { fit });
|
|
62
|
+
*/
|
|
63
|
+
export declare function fromOcct(result: {
|
|
64
|
+
meshes: Array<{
|
|
65
|
+
attributes: {
|
|
66
|
+
position: {
|
|
67
|
+
array: ArrayLike<number>;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
index?: {
|
|
71
|
+
array: ArrayLike<number>;
|
|
72
|
+
} | null;
|
|
73
|
+
}>;
|
|
74
|
+
}): Mesh[];
|
|
75
|
+
export interface EdgeOptions {
|
|
76
|
+
/** crease threshold in degrees; edges sharper than this are kept. Default 25. */
|
|
77
|
+
angle?: number;
|
|
78
|
+
/** position weld tolerance as a fraction of the model's bounding-box diagonal. Default 1e-4. */
|
|
79
|
+
weld?: number;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Feature edges of a mesh: every edge that is a boundary (belongs to one
|
|
83
|
+
* triangle) or a sharp crease (the two triangles sharing it meet at an
|
|
84
|
+
* angle greater than `angle`). Returns pairs of world-space points.
|
|
85
|
+
*/
|
|
86
|
+
export declare function featureEdges(mesh: Mesh, opts?: EdgeOptions): [V3, V3][];
|
|
87
|
+
export interface ImportOptions extends EdgeOptions {
|
|
88
|
+
/** stroke class for the emitted edges. Default "ink". */
|
|
89
|
+
cls?: string;
|
|
90
|
+
/** fit the model into this screen box; returns shapes already centered/scaled. */
|
|
91
|
+
fit?: {
|
|
92
|
+
cx: number;
|
|
93
|
+
cy: number;
|
|
94
|
+
size: number;
|
|
95
|
+
};
|
|
96
|
+
/** flip Y (glTF is Y-up; screen space is Y-down). Default true. */
|
|
97
|
+
flipY?: boolean;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* One-call convenience: meshes → feature edges → linework Shapes, fitted
|
|
101
|
+
* to a screen box. Feed the result straight to render()/sketch.
|
|
102
|
+
*/
|
|
103
|
+
export declare function meshToShapes(meshes: Mesh[], opts?: ImportOptions): Shape[];
|
package/dist/import.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
const IDENT = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
2
|
+
function matMul(a, b) {
|
|
3
|
+
const o = new Array(16).fill(0);
|
|
4
|
+
for (let c = 0; c < 4; c++)
|
|
5
|
+
for (let r = 0; r < 4; r++)
|
|
6
|
+
for (let k = 0; k < 4; k++)
|
|
7
|
+
o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
|
|
8
|
+
return o;
|
|
9
|
+
}
|
|
10
|
+
function fromTRS(t, r, s) {
|
|
11
|
+
const [tx, ty, tz] = t ?? [0, 0, 0];
|
|
12
|
+
const [x, y, z, w] = r ?? [0, 0, 0, 1];
|
|
13
|
+
const [sx, sy, sz] = s ?? [1, 1, 1];
|
|
14
|
+
const x2 = x + x, y2 = y + y, z2 = z + z;
|
|
15
|
+
const xx = x * x2, xy = x * y2, xz = x * z2;
|
|
16
|
+
const yy = y * y2, yz = y * z2, zz = z * z2;
|
|
17
|
+
const wx = w * x2, wy = w * y2, wz = w * z2;
|
|
18
|
+
return [
|
|
19
|
+
(1 - (yy + zz)) * sx, (xy + wz) * sx, (xz - wy) * sx, 0,
|
|
20
|
+
(xy - wz) * sy, (1 - (xx + zz)) * sy, (yz + wx) * sy, 0,
|
|
21
|
+
(xz + wy) * sz, (yz - wx) * sz, (1 - (xx + yy)) * sz, 0,
|
|
22
|
+
tx, ty, tz, 1,
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
function applyMat(m, x, y, z) {
|
|
26
|
+
return [
|
|
27
|
+
m[0] * x + m[4] * y + m[8] * z + m[12],
|
|
28
|
+
m[1] * x + m[5] * y + m[9] * z + m[13],
|
|
29
|
+
m[2] * x + m[6] * y + m[10] * z + m[14],
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
const COMPONENT = {
|
|
33
|
+
5120: { array: Int8Array, size: 1 },
|
|
34
|
+
5121: { array: Uint8Array, size: 1 },
|
|
35
|
+
5122: { array: Int16Array, size: 2 },
|
|
36
|
+
5123: { array: Uint16Array, size: 2 },
|
|
37
|
+
5125: { array: Uint32Array, size: 4 },
|
|
38
|
+
5126: { array: Float32Array, size: 4 },
|
|
39
|
+
};
|
|
40
|
+
const NUM_COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 };
|
|
41
|
+
/** Parse a .glb ArrayBuffer into world-space meshes. */
|
|
42
|
+
export function parseGLB(buffer) {
|
|
43
|
+
const dv = new DataView(buffer);
|
|
44
|
+
if (dv.getUint32(0, true) !== 0x46546c67)
|
|
45
|
+
throw new Error("[import] not a GLB (bad magic)");
|
|
46
|
+
const jsonLen = dv.getUint32(12, true);
|
|
47
|
+
const json = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 20, jsonLen)));
|
|
48
|
+
// BIN chunk follows the JSON chunk
|
|
49
|
+
let bin = null;
|
|
50
|
+
let off = 20 + jsonLen;
|
|
51
|
+
while (off < dv.byteLength) {
|
|
52
|
+
const len = dv.getUint32(off, true), type = dv.getUint32(off + 4, true);
|
|
53
|
+
if (type === 0x004e4942)
|
|
54
|
+
bin = new Uint8Array(buffer, off + 8, len); // "BIN\0"
|
|
55
|
+
off += 8 + len + ((4 - (len % 4)) % 4);
|
|
56
|
+
}
|
|
57
|
+
return extractMeshes(json, bin, buffer);
|
|
58
|
+
}
|
|
59
|
+
function readAccessor(json, bin, buffer, index) {
|
|
60
|
+
const acc = json.accessors[index];
|
|
61
|
+
const bv = json.bufferViews[acc.bufferView];
|
|
62
|
+
const comp = COMPONENT[acc.componentType];
|
|
63
|
+
const n = NUM_COMPONENTS[acc.type];
|
|
64
|
+
const base = (bv.byteOffset ?? 0) + (acc.byteOffset ?? 0);
|
|
65
|
+
if (!bin)
|
|
66
|
+
throw new Error("[import] external buffers not supported — use a .glb");
|
|
67
|
+
const src = new comp.array(bin.buffer, bin.byteOffset + base, acc.count * n);
|
|
68
|
+
return Float64Array.from(src);
|
|
69
|
+
}
|
|
70
|
+
function extractMeshes(json, bin, buffer) {
|
|
71
|
+
const out = [];
|
|
72
|
+
const scene = json.scenes?.[json.scene ?? 0];
|
|
73
|
+
const roots = scene?.nodes ?? json.nodes?.map((_, i) => i) ?? [];
|
|
74
|
+
const walk = (nodeIdx, parent) => {
|
|
75
|
+
const node = json.nodes[nodeIdx];
|
|
76
|
+
const local = node.matrix ? node.matrix : fromTRS(node.translation, node.rotation, node.scale);
|
|
77
|
+
const world = matMul(parent, local);
|
|
78
|
+
if (node.mesh != null) {
|
|
79
|
+
for (const prim of json.meshes[node.mesh].primitives) {
|
|
80
|
+
if (prim.attributes.POSITION == null)
|
|
81
|
+
continue;
|
|
82
|
+
const pos = readAccessor(json, bin, buffer, prim.attributes.POSITION);
|
|
83
|
+
const world3 = new Float32Array(pos.length);
|
|
84
|
+
for (let i = 0; i < pos.length; i += 3) {
|
|
85
|
+
const [x, y, z] = applyMat(world, pos[i], pos[i + 1], pos[i + 2]);
|
|
86
|
+
world3[i] = x;
|
|
87
|
+
world3[i + 1] = y;
|
|
88
|
+
world3[i + 2] = z;
|
|
89
|
+
}
|
|
90
|
+
let indices;
|
|
91
|
+
if (prim.indices != null) {
|
|
92
|
+
indices = Uint32Array.from(readAccessor(json, bin, buffer, prim.indices));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
indices = new Uint32Array(pos.length / 3);
|
|
96
|
+
for (let i = 0; i < indices.length; i++)
|
|
97
|
+
indices[i] = i;
|
|
98
|
+
}
|
|
99
|
+
out.push({ positions: world3, indices, name: json.meshes[node.mesh].name });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const child of node.children ?? [])
|
|
103
|
+
walk(child, world);
|
|
104
|
+
};
|
|
105
|
+
for (const r of roots)
|
|
106
|
+
walk(r, IDENT);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/* ============================ OBJ ============================ */
|
|
110
|
+
/** Parse a Wavefront .obj string into a single mesh (triangulated). */
|
|
111
|
+
export function parseOBJ(text) {
|
|
112
|
+
const verts = [];
|
|
113
|
+
const idx = [];
|
|
114
|
+
for (const line of text.split("\n")) {
|
|
115
|
+
const t = line.trim();
|
|
116
|
+
if (t.startsWith("v ")) {
|
|
117
|
+
const p = t.slice(2).trim().split(/\s+/).map(Number);
|
|
118
|
+
verts.push(p[0], p[1], p[2]);
|
|
119
|
+
}
|
|
120
|
+
else if (t.startsWith("f ")) {
|
|
121
|
+
const f = t.slice(2).trim().split(/\s+/).map((tok) => parseInt(tok.split("/")[0], 10) - 1);
|
|
122
|
+
for (let i = 1; i < f.length - 1; i++)
|
|
123
|
+
idx.push(f[0], f[i], f[i + 1]); // fan-triangulate
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [{ positions: Float32Array.from(verts), indices: Uint32Array.from(idx) }];
|
|
127
|
+
}
|
|
128
|
+
/* ============================ STL ============================ */
|
|
129
|
+
/** Build an unindexed mesh from flat triangle-soup positions (STL has no
|
|
130
|
+
* shared indices; featureEdges welds by position to recover topology). */
|
|
131
|
+
function meshFromSoup(positions) {
|
|
132
|
+
const indices = new Uint32Array(positions.length / 3);
|
|
133
|
+
for (let i = 0; i < indices.length; i++)
|
|
134
|
+
indices[i] = i;
|
|
135
|
+
return { positions, indices };
|
|
136
|
+
}
|
|
137
|
+
function parseAsciiSTL(text) {
|
|
138
|
+
const verts = [];
|
|
139
|
+
const re = /vertex\s+(-?[\d.eE+-]+)\s+(-?[\d.eE+-]+)\s+(-?[\d.eE+-]+)/g;
|
|
140
|
+
let m;
|
|
141
|
+
while ((m = re.exec(text)))
|
|
142
|
+
verts.push(+m[1], +m[2], +m[3]);
|
|
143
|
+
return [meshFromSoup(Float32Array.from(verts))];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Parse a binary or ASCII `.stl` into a single mesh. Binary is detected by
|
|
147
|
+
* the exact-size invariant (84 + 50·count bytes) rather than the unreliable
|
|
148
|
+
* "solid" prefix, since some binary exporters write "solid" in the header.
|
|
149
|
+
*/
|
|
150
|
+
export function parseSTL(data) {
|
|
151
|
+
if (typeof data === "string")
|
|
152
|
+
return parseAsciiSTL(data);
|
|
153
|
+
const dv = new DataView(data);
|
|
154
|
+
const count = dv.byteLength >= 84 ? dv.getUint32(80, true) : 0;
|
|
155
|
+
const isBinary = dv.byteLength === 84 + count * 50;
|
|
156
|
+
if (!isBinary) {
|
|
157
|
+
const text = new TextDecoder().decode(new Uint8Array(data));
|
|
158
|
+
if (/^\s*solid/.test(text) && text.includes("vertex"))
|
|
159
|
+
return parseAsciiSTL(text);
|
|
160
|
+
throw new Error("[import] not a recognisable STL (bad size and no ASCII facets)");
|
|
161
|
+
}
|
|
162
|
+
const positions = new Float32Array(count * 9);
|
|
163
|
+
for (let i = 0; i < count; i++) {
|
|
164
|
+
const o = 84 + i * 50 + 12; // skip the 12-byte per-face normal
|
|
165
|
+
for (let v = 0; v < 9; v++)
|
|
166
|
+
positions[i * 9 + v] = dv.getFloat32(o + v * 4, true);
|
|
167
|
+
}
|
|
168
|
+
return [meshFromSoup(positions)];
|
|
169
|
+
}
|
|
170
|
+
/* ==================== three.js adapter ==================== */
|
|
171
|
+
/**
|
|
172
|
+
* Adapt a three.js `BufferGeometry` (or anything with the same shape) into a
|
|
173
|
+
* linework `Mesh` — no three.js dependency, it just reads the typed arrays.
|
|
174
|
+
* Bring an existing three scene's geometry straight into a technical drawing.
|
|
175
|
+
*/
|
|
176
|
+
export function fromBufferGeometry(geometry) {
|
|
177
|
+
const src = geometry.attributes.position.array;
|
|
178
|
+
const positions = src instanceof Float32Array ? src : Float32Array.from(src);
|
|
179
|
+
const indices = geometry.index
|
|
180
|
+
? Uint32Array.from(geometry.index.array)
|
|
181
|
+
: meshFromSoup(positions).indices;
|
|
182
|
+
return { positions, indices };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Adapt the result of [occt-import-js](https://github.com/kovacsv/occt-import-js)
|
|
186
|
+
* — OpenCASCADE compiled to WASM — which is the practical way to get geometry
|
|
187
|
+
* out of a **STEP / IGES / BREP** CAD file. You bring the kernel (it's ~6 MB,
|
|
188
|
+
* so it stays an optional peer, not a linework dependency); each mesh it
|
|
189
|
+
* returns already matches a BufferGeometry, so this just maps and merges them.
|
|
190
|
+
*
|
|
191
|
+
* const occt = await occtimportjs();
|
|
192
|
+
* const result = occt.ReadStepFile(new Uint8Array(buf), null);
|
|
193
|
+
* const shapes = meshToShapes(fromOcct(result), { fit });
|
|
194
|
+
*/
|
|
195
|
+
export function fromOcct(result) {
|
|
196
|
+
return result.meshes.map(fromBufferGeometry);
|
|
197
|
+
}
|
|
198
|
+
function faceNormal(p, a, b, c) {
|
|
199
|
+
const ux = p[b * 3] - p[a * 3], uy = p[b * 3 + 1] - p[a * 3 + 1], uz = p[b * 3 + 2] - p[a * 3 + 2];
|
|
200
|
+
const vx = p[c * 3] - p[a * 3], vy = p[c * 3 + 1] - p[a * 3 + 1], vz = p[c * 3 + 2] - p[a * 3 + 2];
|
|
201
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
202
|
+
const m = Math.hypot(nx, ny, nz) || 1;
|
|
203
|
+
return [nx / m, ny / m, nz / m];
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Feature edges of a mesh: every edge that is a boundary (belongs to one
|
|
207
|
+
* triangle) or a sharp crease (the two triangles sharing it meet at an
|
|
208
|
+
* angle greater than `angle`). Returns pairs of world-space points.
|
|
209
|
+
*/
|
|
210
|
+
export function featureEdges(mesh, opts = {}) {
|
|
211
|
+
const angle = opts.angle ?? 25;
|
|
212
|
+
const p = mesh.positions, idx = mesh.indices;
|
|
213
|
+
// weld vertices by quantized position so shared edges are detected
|
|
214
|
+
let min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
215
|
+
for (let i = 0; i < p.length; i += 3)
|
|
216
|
+
for (let k = 0; k < 3; k++) {
|
|
217
|
+
min[k] = Math.min(min[k], p[i + k]);
|
|
218
|
+
max[k] = Math.max(max[k], p[i + k]);
|
|
219
|
+
}
|
|
220
|
+
const diag = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) || 1;
|
|
221
|
+
const q = diag * (opts.weld ?? 1e-4);
|
|
222
|
+
const keyOf = (v) => `${Math.round(p[v * 3] / q)},${Math.round(p[v * 3 + 1] / q)},${Math.round(p[v * 3 + 2] / q)}`;
|
|
223
|
+
const weld = new Map();
|
|
224
|
+
const rep = new Uint32Array(p.length / 3);
|
|
225
|
+
for (let v = 0; v < p.length / 3; v++) {
|
|
226
|
+
const k = keyOf(v);
|
|
227
|
+
let r = weld.get(k);
|
|
228
|
+
if (r == null) {
|
|
229
|
+
r = v;
|
|
230
|
+
weld.set(k, v);
|
|
231
|
+
}
|
|
232
|
+
rep[v] = r;
|
|
233
|
+
}
|
|
234
|
+
// accumulate the (up to two) face normals per undirected welded edge
|
|
235
|
+
const cos = Math.cos((angle * Math.PI) / 180);
|
|
236
|
+
const edges = new Map();
|
|
237
|
+
for (let t = 0; t < idx.length; t += 3) {
|
|
238
|
+
const A = rep[idx[t]], B = rep[idx[t + 1]], C = rep[idx[t + 2]];
|
|
239
|
+
const n = faceNormal(p, A, B, C);
|
|
240
|
+
for (const [u, v] of [[A, B], [B, C], [C, A]]) {
|
|
241
|
+
if (u === v)
|
|
242
|
+
continue;
|
|
243
|
+
const lo = Math.min(u, v), hi = Math.max(u, v);
|
|
244
|
+
const key = lo + "_" + hi;
|
|
245
|
+
const e = edges.get(key);
|
|
246
|
+
if (e)
|
|
247
|
+
e.n.push(n);
|
|
248
|
+
else
|
|
249
|
+
edges.set(key, { a: lo, b: hi, n: [n] });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const pt = (v) => [p[v * 3], p[v * 3 + 1], p[v * 3 + 2]];
|
|
253
|
+
const out = [];
|
|
254
|
+
for (const e of edges.values()) {
|
|
255
|
+
const keep = e.n.length === 1 || // boundary
|
|
256
|
+
e.n.some((n1, i) => e.n.slice(i + 1).some((n2) => n1[0] * n2[0] + n1[1] * n2[1] + n1[2] * n2[2] < cos));
|
|
257
|
+
if (keep)
|
|
258
|
+
out.push([pt(e.a), pt(e.b)]);
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* One-call convenience: meshes → feature edges → linework Shapes, fitted
|
|
264
|
+
* to a screen box. Feed the result straight to render()/sketch.
|
|
265
|
+
*/
|
|
266
|
+
export function meshToShapes(meshes, opts = {}) {
|
|
267
|
+
const cls = opts.cls ?? "ink";
|
|
268
|
+
const flipY = opts.flipY ?? true;
|
|
269
|
+
const allEdges = [];
|
|
270
|
+
for (const m of meshes)
|
|
271
|
+
allEdges.push(...featureEdges(m, opts));
|
|
272
|
+
// fit transform (uniform scale about the model center → screen box)
|
|
273
|
+
let map = (v) => v;
|
|
274
|
+
if (opts.fit) {
|
|
275
|
+
let min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
276
|
+
for (const [a, b] of allEdges)
|
|
277
|
+
for (const v of [a, b])
|
|
278
|
+
for (let k = 0; k < 3; k++) {
|
|
279
|
+
min[k] = Math.min(min[k], v[k]);
|
|
280
|
+
max[k] = Math.max(max[k], v[k]);
|
|
281
|
+
}
|
|
282
|
+
const span = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]) || 1;
|
|
283
|
+
const s = opts.fit.size / span;
|
|
284
|
+
const c = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
|
|
285
|
+
const sy = flipY ? -s : s;
|
|
286
|
+
map = (v) => [opts.fit.cx + (v[0] - c[0]) * s, opts.fit.cy + (v[1] - c[1]) * sy, (v[2] - c[2]) * s];
|
|
287
|
+
}
|
|
288
|
+
else if (flipY) {
|
|
289
|
+
map = (v) => [v[0], -v[1], v[2]];
|
|
290
|
+
}
|
|
291
|
+
return allEdges.map(([a, b]) => ({
|
|
292
|
+
t: "path",
|
|
293
|
+
d: [["M", map(a)], ["L", map(b)]],
|
|
294
|
+
strokes: [{ cls }],
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* linework — a tiny true-3D renderer for annotated technical drawings.
|
|
3
|
+
*
|
|
4
|
+
* The classic pipeline, properly this time:
|
|
5
|
+
* model (V3 points) → rotate (yaw, pitch) → perspective project
|
|
6
|
+
* → depth-sort (painter's algorithm) → emit SVG strings.
|
|
7
|
+
*
|
|
8
|
+
* Why not a library: nothing ships "parametric technical illustration"
|
|
9
|
+
* (multi-weight strokes, dashed line styles, hollow tubes, CSS theming,
|
|
10
|
+
* crawlable text). This is ~180 lines and does exactly that.
|
|
11
|
+
*
|
|
12
|
+
* Deliberate stylizations (schematic license, documented):
|
|
13
|
+
* - "face" discs (cylinder end-caps) stay round instead of thinning to
|
|
14
|
+
* lenses edge-on — they must stay readable as parts.
|
|
15
|
+
* - Depth cueing dims far strokes instead of hidden-line removal.
|
|
16
|
+
*/
|
|
17
|
+
export type V3 = [number, number, number];
|
|
18
|
+
export interface View {
|
|
19
|
+
yaw: number;
|
|
20
|
+
pitch: number;
|
|
21
|
+
f: number;
|
|
22
|
+
cx: number;
|
|
23
|
+
cy: number;
|
|
24
|
+
}
|
|
25
|
+
export interface Pt {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
z: number;
|
|
29
|
+
k: number;
|
|
30
|
+
}
|
|
31
|
+
/** Build the world→screen transform for a view. */
|
|
32
|
+
export declare function xform(v: View): (p: V3) => Pt;
|
|
33
|
+
export interface Stroke {
|
|
34
|
+
cls: string;
|
|
35
|
+
w?: number;
|
|
36
|
+
}
|
|
37
|
+
export type Cmd = ["M", V3] | ["L", V3] | ["Q", V3, V3] | ["Z"];
|
|
38
|
+
export type Shape =
|
|
39
|
+
/** polyline/curve; multi-stroke renders the same d repeatedly (hollow tubes) */
|
|
40
|
+
{
|
|
41
|
+
t: "path";
|
|
42
|
+
d: Cmd[];
|
|
43
|
+
strokes: Stroke[];
|
|
44
|
+
fill?: string;
|
|
45
|
+
bias?: number;
|
|
46
|
+
tag?: string;
|
|
47
|
+
part?: string;
|
|
48
|
+
}
|
|
49
|
+
/** circle lying in a constant-z model plane (wheels, rings) → ellipse */
|
|
50
|
+
| {
|
|
51
|
+
t: "disc";
|
|
52
|
+
c: V3;
|
|
53
|
+
r: number;
|
|
54
|
+
strokes: Stroke[];
|
|
55
|
+
fill?: string;
|
|
56
|
+
bias?: number;
|
|
57
|
+
tag?: string;
|
|
58
|
+
part?: string;
|
|
59
|
+
}
|
|
60
|
+
/** circle facing along z (cylinder caps) — stylized: stays round */
|
|
61
|
+
| {
|
|
62
|
+
t: "face";
|
|
63
|
+
c: V3;
|
|
64
|
+
r: number;
|
|
65
|
+
strokes: Stroke[];
|
|
66
|
+
fill?: string;
|
|
67
|
+
bias?: number;
|
|
68
|
+
tag?: string;
|
|
69
|
+
part?: string;
|
|
70
|
+
}
|
|
71
|
+
/** flat filled quad (box faces); cull=true drops backfaces */
|
|
72
|
+
| {
|
|
73
|
+
t: "quad";
|
|
74
|
+
q: [V3, V3, V3, V3];
|
|
75
|
+
cls: string;
|
|
76
|
+
cull?: boolean;
|
|
77
|
+
bias?: number;
|
|
78
|
+
tag?: string;
|
|
79
|
+
part?: string;
|
|
80
|
+
};
|
|
81
|
+
export interface PartDef {
|
|
82
|
+
attrs: string;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Render shapes with painter's-algorithm depth sorting.
|
|
86
|
+
* Shapes sharing a `part` key sort as one object (per-object sorting keeps
|
|
87
|
+
* animated <g part> wrappers intact) and are wrapped with that part's attrs.
|
|
88
|
+
*/
|
|
89
|
+
export declare function render(shapes: Shape[], view: View, parts?: Record<string, PartDef>): string;
|
package/dist/linework.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* linework — a tiny true-3D renderer for annotated technical drawings.
|
|
3
|
+
*
|
|
4
|
+
* The classic pipeline, properly this time:
|
|
5
|
+
* model (V3 points) → rotate (yaw, pitch) → perspective project
|
|
6
|
+
* → depth-sort (painter's algorithm) → emit SVG strings.
|
|
7
|
+
*
|
|
8
|
+
* Why not a library: nothing ships "parametric technical illustration"
|
|
9
|
+
* (multi-weight strokes, dashed line styles, hollow tubes, CSS theming,
|
|
10
|
+
* crawlable text). This is ~180 lines and does exactly that.
|
|
11
|
+
*
|
|
12
|
+
* Deliberate stylizations (schematic license, documented):
|
|
13
|
+
* - "face" discs (cylinder end-caps) stay round instead of thinning to
|
|
14
|
+
* lenses edge-on — they must stay readable as parts.
|
|
15
|
+
* - Depth cueing dims far strokes instead of hidden-line removal.
|
|
16
|
+
*/
|
|
17
|
+
/** Build the world→screen transform for a view. */
|
|
18
|
+
export function xform(v) {
|
|
19
|
+
const cy = Math.cos(v.yaw), sy = Math.sin(v.yaw);
|
|
20
|
+
const cp = Math.cos(v.pitch), sp = Math.sin(v.pitch);
|
|
21
|
+
return (p) => {
|
|
22
|
+
const dx = p[0] - v.cx, dy = p[1] - v.cy;
|
|
23
|
+
// yaw about vertical
|
|
24
|
+
const x1 = cy * dx - sy * p[2];
|
|
25
|
+
const z1 = sy * dx + cy * p[2];
|
|
26
|
+
// pitch about horizontal (screen y grows downward; +pitch = camera above)
|
|
27
|
+
const y2 = cp * dy + sp * z1;
|
|
28
|
+
const z2 = -sp * dy + cp * z1;
|
|
29
|
+
const k = v.f / (v.f - z2);
|
|
30
|
+
return { x: v.cx + x1 * k, y: v.cy + y2 * k, z: z2, k };
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const f1 = (n) => n.toFixed(1);
|
|
34
|
+
function pathD(d, X) {
|
|
35
|
+
const zs = [];
|
|
36
|
+
const str = d
|
|
37
|
+
.map((c) => {
|
|
38
|
+
if (c[0] === "Z")
|
|
39
|
+
return "Z";
|
|
40
|
+
if (c[0] === "Q") {
|
|
41
|
+
const a = X(c[1]), b = X(c[2]);
|
|
42
|
+
zs.push(a.z, b.z);
|
|
43
|
+
return `Q ${f1(a.x)} ${f1(a.y)} ${f1(b.x)} ${f1(b.y)}`;
|
|
44
|
+
}
|
|
45
|
+
const p = X(c[1]);
|
|
46
|
+
zs.push(p.z);
|
|
47
|
+
return `${c[0]} ${f1(p.x)} ${f1(p.y)}`;
|
|
48
|
+
})
|
|
49
|
+
.join(" ");
|
|
50
|
+
return { str, zs };
|
|
51
|
+
}
|
|
52
|
+
/** depth-cue opacity: far → gently dimmer. Tuned so the far side of the
|
|
53
|
+
* bike reads as "behind", never as "ghosted". */
|
|
54
|
+
const dim = (z) => (z < -12 ? Math.max(0.55, 1 + (z + 12) / 420).toFixed(2) : "");
|
|
55
|
+
function emit(sh, X) {
|
|
56
|
+
if (sh.t === "path") {
|
|
57
|
+
const { str, zs } = pathD(sh.d, X);
|
|
58
|
+
const depth = zs.reduce((a, b) => a + b, 0) / (zs.length || 1);
|
|
59
|
+
const op = dim(depth);
|
|
60
|
+
const fill = sh.fill ?? "none";
|
|
61
|
+
const svg = sh.strokes
|
|
62
|
+
.map((s, i) => `<path d="${str}" class="${s.cls}${sh.tag ? " " + sh.tag : ""}"${s.w ? ` stroke-width="${f1(s.w)}"` : ""} fill="${i === 0 ? fill : "none"}"${op && i === 0 ? ` opacity="${op}"` : op ? ` opacity="${op}"` : ""}/>`)
|
|
63
|
+
.join("");
|
|
64
|
+
return { svg, depth: depth + (sh.bias ?? 0) };
|
|
65
|
+
}
|
|
66
|
+
if (sh.t === "disc" || sh.t === "face") {
|
|
67
|
+
const c = X(sh.c);
|
|
68
|
+
const op = dim(c.z);
|
|
69
|
+
let core;
|
|
70
|
+
if (sh.t === "disc") {
|
|
71
|
+
// project the disc's plane basis to get the ellipse
|
|
72
|
+
const u = X([sh.c[0] + 1, sh.c[1], sh.c[2]]);
|
|
73
|
+
const v = X([sh.c[0], sh.c[1] + 1, sh.c[2]]);
|
|
74
|
+
const rx = Math.hypot(u.x - c.x, u.y - c.y) * sh.r;
|
|
75
|
+
const ry = Math.hypot(v.x - c.x, v.y - c.y) * sh.r;
|
|
76
|
+
const rot = (Math.atan2(u.y - c.y, u.x - c.x) * 180) / Math.PI;
|
|
77
|
+
core = `<ellipse cx="${f1(c.x)}" cy="${f1(c.y)}" rx="${f1(rx)}" ry="${f1(ry)}" transform="rotate(${f1(rot)} ${f1(c.x)} ${f1(c.y)})"`;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
core = `<circle cx="${f1(c.x)}" cy="${f1(c.y)}" r="${f1(sh.r * c.k)}"`;
|
|
81
|
+
}
|
|
82
|
+
const fill = sh.fill ?? "none";
|
|
83
|
+
const svg = sh.strokes
|
|
84
|
+
.map((s, i) => core +
|
|
85
|
+
` class="${s.cls}${sh.tag ? " " + sh.tag : ""}"${s.w ? ` stroke-width="${f1(s.w)}"` : ""} fill="${i === 0 ? fill : "none"}"${op ? ` opacity="${op}"` : ""}/>`)
|
|
86
|
+
.join("");
|
|
87
|
+
return { svg, depth: c.z + (sh.bias ?? 0) };
|
|
88
|
+
}
|
|
89
|
+
// quad
|
|
90
|
+
const q = sh.q.map(X);
|
|
91
|
+
// backface cull via signed screen area
|
|
92
|
+
const area = (q[1].x - q[0].x) * (q[2].y - q[0].y) - (q[2].x - q[0].x) * (q[1].y - q[0].y);
|
|
93
|
+
if (sh.cull && area <= 0)
|
|
94
|
+
return { svg: "", depth: -Infinity };
|
|
95
|
+
const depth = (q[0].z + q[1].z + q[2].z + q[3].z) / 4 + (sh.bias ?? 0);
|
|
96
|
+
const d = `M ${f1(q[0].x)} ${f1(q[0].y)} L ${f1(q[1].x)} ${f1(q[1].y)} L ${f1(q[2].x)} ${f1(q[2].y)} L ${f1(q[3].x)} ${f1(q[3].y)} Z`;
|
|
97
|
+
return { svg: `<path d="${d}" class="${sh.cls}${sh.tag ? " " + sh.tag : ""}"/>`, depth };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Render shapes with painter's-algorithm depth sorting.
|
|
101
|
+
* Shapes sharing a `part` key sort as one object (per-object sorting keeps
|
|
102
|
+
* animated <g part> wrappers intact) and are wrapped with that part's attrs.
|
|
103
|
+
*/
|
|
104
|
+
export function render(shapes, view, parts = {}) {
|
|
105
|
+
const X = xform(view);
|
|
106
|
+
const slots = [];
|
|
107
|
+
const byPart = new Map();
|
|
108
|
+
for (const sh of shapes) {
|
|
109
|
+
const e = emit(sh, X);
|
|
110
|
+
if (!e.svg)
|
|
111
|
+
continue;
|
|
112
|
+
if (sh.part) {
|
|
113
|
+
const b = byPart.get(sh.part) ?? { svgs: [], depths: [] };
|
|
114
|
+
b.svgs.push(e.svg);
|
|
115
|
+
b.depths.push(e.depth);
|
|
116
|
+
byPart.set(sh.part, b);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
slots.push(e);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [key, b] of byPart) {
|
|
123
|
+
const depth = b.depths.reduce((a, c) => a + c, 0) / b.depths.length;
|
|
124
|
+
const def = parts[key];
|
|
125
|
+
slots.push({
|
|
126
|
+
svg: `<g ${def ? def.attrs : ""}>${b.svgs.join("")}</g>`,
|
|
127
|
+
depth,
|
|
128
|
+
part: key,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
slots.sort((a, b) => a.depth - b.depth); // far → near
|
|
132
|
+
return slots.map((s) => s.svg).join("");
|
|
133
|
+
}
|
package/dist/orbit.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orbit — the drag-to-orbit boilerplate every consumer would otherwise
|
|
3
|
+
* copy-paste: pointer capture, yaw/pitch clamping, rAF batching,
|
|
4
|
+
* double-click reset, and a gentle idle sway until first touch
|
|
5
|
+
* (disabled under prefers-reduced-motion).
|
|
6
|
+
*/
|
|
7
|
+
import type { View } from "./linework.js";
|
|
8
|
+
export interface OrbitOptions {
|
|
9
|
+
view: View;
|
|
10
|
+
onFrame: (view: View) => void;
|
|
11
|
+
yawRange?: [number, number];
|
|
12
|
+
pitchRange?: [number, number];
|
|
13
|
+
/** idle sway amplitude in radians (0 disables). Default 0.12. */
|
|
14
|
+
idle?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface OrbitHandle {
|
|
17
|
+
view: View;
|
|
18
|
+
redraw: () => void;
|
|
19
|
+
stop: () => void;
|
|
20
|
+
}
|
|
21
|
+
export declare function orbit(el: Element, o: OrbitOptions): OrbitHandle;
|
package/dist/orbit.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function orbit(el, o) {
|
|
2
|
+
const v = { ...o.view };
|
|
3
|
+
const [ylo, yhi] = o.yawRange ?? [-1.1, 1.1];
|
|
4
|
+
const [plo, phi] = o.pitchRange ?? [-0.3, 0.6];
|
|
5
|
+
const idleAmp = o.idle ?? 0.12;
|
|
6
|
+
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
|
|
7
|
+
let dragging = false, x0 = 0, y0 = 0, yaw0 = 0, p0 = 0, raf = 0, stopped = false;
|
|
8
|
+
let idleOn = idleAmp > 0 &&
|
|
9
|
+
(typeof matchMedia === "undefined" || !matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
10
|
+
const frame = () => { raf = 0; o.onFrame({ ...v }); };
|
|
11
|
+
const req = () => { if (!raf)
|
|
12
|
+
raf = requestAnimationFrame(frame); };
|
|
13
|
+
el.addEventListener("pointerdown", (e) => {
|
|
14
|
+
const ev = e;
|
|
15
|
+
if (ev.target?.closest?.("[data-no-orbit]"))
|
|
16
|
+
return;
|
|
17
|
+
idleOn = false;
|
|
18
|
+
dragging = true;
|
|
19
|
+
x0 = ev.clientX;
|
|
20
|
+
y0 = ev.clientY;
|
|
21
|
+
yaw0 = v.yaw;
|
|
22
|
+
p0 = v.pitch;
|
|
23
|
+
el.setPointerCapture?.(ev.pointerId);
|
|
24
|
+
});
|
|
25
|
+
el.addEventListener("pointermove", (e) => {
|
|
26
|
+
if (!dragging)
|
|
27
|
+
return;
|
|
28
|
+
const ev = e;
|
|
29
|
+
v.yaw = clamp(yaw0 + (ev.clientX - x0) / 260, ylo, yhi);
|
|
30
|
+
v.pitch = clamp(p0 + (ev.clientY - y0) / 340, plo, phi);
|
|
31
|
+
req();
|
|
32
|
+
});
|
|
33
|
+
const end = () => { dragging = false; };
|
|
34
|
+
el.addEventListener("pointerup", end);
|
|
35
|
+
el.addEventListener("pointercancel", end);
|
|
36
|
+
el.addEventListener("dblclick", () => { v.yaw = o.view.yaw; v.pitch = o.view.pitch; req(); });
|
|
37
|
+
// gentle sway so the page is alive before anyone touches it
|
|
38
|
+
const t0 = performance.now();
|
|
39
|
+
const sway = (t) => {
|
|
40
|
+
if (stopped)
|
|
41
|
+
return;
|
|
42
|
+
if (idleOn) {
|
|
43
|
+
v.yaw = clamp(o.view.yaw + Math.sin((t - t0) / 1700) * idleAmp, ylo, yhi);
|
|
44
|
+
v.pitch = clamp(o.view.pitch + Math.sin((t - t0) / 2500) * idleAmp * 0.35, plo, phi);
|
|
45
|
+
o.onFrame({ ...v });
|
|
46
|
+
}
|
|
47
|
+
requestAnimationFrame(sway);
|
|
48
|
+
};
|
|
49
|
+
o.onFrame({ ...v }); // first paint
|
|
50
|
+
if (idleOn)
|
|
51
|
+
requestAnimationFrame(sway);
|
|
52
|
+
return { view: v, redraw: () => o.onFrame({ ...v }), stop: () => { stopped = true; idleOn = false; } };
|
|
53
|
+
}
|
package/dist/sketch.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sketch — the expressive authoring layer. Designing a drawing should be
|
|
3
|
+
* as pleasant as looking at one.
|
|
4
|
+
*
|
|
5
|
+
* Three ideas:
|
|
6
|
+
* 1. A Sketch holds the view, so nothing needs `xform` passed around.
|
|
7
|
+
* 2. Context blocks — `s.part()`, `s.tag()`, `s.bias()` — scope metadata
|
|
8
|
+
* over everything drawn inside them, killing per-call option noise.
|
|
9
|
+
* 3. `scene(build)` returns a replayable frame function: define once,
|
|
10
|
+
* call with a new view every frame for orbit/explode animation.
|
|
11
|
+
*
|
|
12
|
+
* const draw = scene((s, { e }) => {
|
|
13
|
+
* s.box(300, 380, 320, 42, 40, 80);
|
|
14
|
+
* s.part("shaft", () => s.cyl([460, 310], 19, 168, 24, "ink"));
|
|
15
|
+
* });
|
|
16
|
+
* el.innerHTML = draw({ yaw, pitch, f: 1500, cx: 460, cy: 320 }, { e });
|
|
17
|
+
*/
|
|
18
|
+
import { type Shape, type Stroke, type View, type V3, type Pt } from "./linework.js";
|
|
19
|
+
export type P2 = [number, number];
|
|
20
|
+
/** fluent path under construction — every verb returns itself */
|
|
21
|
+
export declare class Path {
|
|
22
|
+
private shape;
|
|
23
|
+
constructor(shape: Shape & {
|
|
24
|
+
t: "path";
|
|
25
|
+
});
|
|
26
|
+
M(p: P2, z?: number): this;
|
|
27
|
+
L(p: P2, z?: number): this;
|
|
28
|
+
Q(c: P2, cz: number, p: P2, z?: number): this;
|
|
29
|
+
close(): this;
|
|
30
|
+
/** additional stroke over the same geometry (hollow tubes, glows) */
|
|
31
|
+
stroke(cls: string, w?: number): this;
|
|
32
|
+
fill(paint: string): this;
|
|
33
|
+
}
|
|
34
|
+
export declare class Sketch {
|
|
35
|
+
readonly view: View;
|
|
36
|
+
readonly X: (p: V3) => Pt;
|
|
37
|
+
private shapes;
|
|
38
|
+
private parts;
|
|
39
|
+
private paperStrs;
|
|
40
|
+
private ctx;
|
|
41
|
+
constructor(view: View);
|
|
42
|
+
private scoped;
|
|
43
|
+
/** everything drawn inside belongs to one animated part (one <g>, one sort unit) */
|
|
44
|
+
part(name: string, attrs: string, fn: () => void): this;
|
|
45
|
+
/** everything inside carries an extra class — visibility groups, themes */
|
|
46
|
+
tag(cls: string, fn: () => void): this;
|
|
47
|
+
/** deliberate layering for coplanar geometry */
|
|
48
|
+
bias(n: number, fn: () => void): this;
|
|
49
|
+
private opt;
|
|
50
|
+
/** begin a fluent path: s.path("ink").M(a).Q(c, 10, b, 20) */
|
|
51
|
+
path(cls: string, w?: number): Path;
|
|
52
|
+
/** hollow-tube outline in one call: fat outer + core stroke */
|
|
53
|
+
tube(w: number, outer?: string, inner?: string): Path;
|
|
54
|
+
line(a: P2, za: number, b: P2, zb: number, cls: string, w?: number): this;
|
|
55
|
+
/** circle in a constant-z plane — projects to a correct ellipse */
|
|
56
|
+
disc(c: P2, z: number, r: number, cls: string | Stroke[], fill?: string): this;
|
|
57
|
+
/** cylinder end-cap facing the viewer — stylized, stays readable */
|
|
58
|
+
cap(c: P2, z: number, r: number, cls: string, fill?: string): this;
|
|
59
|
+
/** six-faced box: face plate at z, body extruded away (z − dz) */
|
|
60
|
+
box(x: number, y: number, w: number, h: number, z: number, dz: number, cls?: {
|
|
61
|
+
face?: string;
|
|
62
|
+
top?: string;
|
|
63
|
+
side?: string;
|
|
64
|
+
}): this;
|
|
65
|
+
/** cylinder along z with silhouette rails computed from THIS sketch's view */
|
|
66
|
+
cyl(c: P2, r: number, zNear: number, zFar: number, cls?: string, fill?: string): this;
|
|
67
|
+
/** project a model point to screen — for hand-placed annotations */
|
|
68
|
+
pt(p: P2, z?: number): P2;
|
|
69
|
+
/** raw SVG painted above the sorted scene */
|
|
70
|
+
paper(svg: string): this;
|
|
71
|
+
/** paper-space text at a projected anchor */
|
|
72
|
+
note(text: string, p: P2, z?: number, cls?: string, dx?: number, dy?: number): this;
|
|
73
|
+
count(): number;
|
|
74
|
+
render(): string;
|
|
75
|
+
}
|
|
76
|
+
export declare const sketch: (view: View) => Sketch;
|
|
77
|
+
/** define once, replay per frame with a fresh view — the orbit-loop idiom */
|
|
78
|
+
export declare function scene<P = void>(build: (s: Sketch, props: P) => void): (view: View, props: P) => string;
|
package/dist/sketch.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sketch — the expressive authoring layer. Designing a drawing should be
|
|
3
|
+
* as pleasant as looking at one.
|
|
4
|
+
*
|
|
5
|
+
* Three ideas:
|
|
6
|
+
* 1. A Sketch holds the view, so nothing needs `xform` passed around.
|
|
7
|
+
* 2. Context blocks — `s.part()`, `s.tag()`, `s.bias()` — scope metadata
|
|
8
|
+
* over everything drawn inside them, killing per-call option noise.
|
|
9
|
+
* 3. `scene(build)` returns a replayable frame function: define once,
|
|
10
|
+
* call with a new view every frame for orbit/explode animation.
|
|
11
|
+
*
|
|
12
|
+
* const draw = scene((s, { e }) => {
|
|
13
|
+
* s.box(300, 380, 320, 42, 40, 80);
|
|
14
|
+
* s.part("shaft", () => s.cyl([460, 310], 19, 168, 24, "ink"));
|
|
15
|
+
* });
|
|
16
|
+
* el.innerHTML = draw({ yaw, pitch, f: 1500, cx: 460, cy: 320 }, { e });
|
|
17
|
+
*/
|
|
18
|
+
import { render, xform } from "./linework.js";
|
|
19
|
+
/** fluent path under construction — every verb returns itself */
|
|
20
|
+
export class Path {
|
|
21
|
+
shape;
|
|
22
|
+
constructor(shape) { this.shape = shape; }
|
|
23
|
+
M(p, z = 0) { this.shape.d.push(["M", [p[0], p[1], z]]); return this; }
|
|
24
|
+
L(p, z = 0) { this.shape.d.push(["L", [p[0], p[1], z]]); return this; }
|
|
25
|
+
Q(c, cz, p, z = 0) { this.shape.d.push(["Q", [c[0], c[1], cz], [p[0], p[1], z]]); return this; }
|
|
26
|
+
close() { this.shape.d.push(["Z"]); return this; }
|
|
27
|
+
/** additional stroke over the same geometry (hollow tubes, glows) */
|
|
28
|
+
stroke(cls, w) { this.shape.strokes.push({ cls, w }); return this; }
|
|
29
|
+
fill(paint) { this.shape.fill = paint; return this; }
|
|
30
|
+
}
|
|
31
|
+
export class Sketch {
|
|
32
|
+
view;
|
|
33
|
+
X;
|
|
34
|
+
shapes = [];
|
|
35
|
+
parts = {};
|
|
36
|
+
paperStrs = [];
|
|
37
|
+
ctx = { bias: 0 };
|
|
38
|
+
constructor(view) {
|
|
39
|
+
this.view = view;
|
|
40
|
+
this.X = xform(view);
|
|
41
|
+
}
|
|
42
|
+
/* ---------- context blocks ---------- */
|
|
43
|
+
scoped(patch, fn) {
|
|
44
|
+
const prev = this.ctx;
|
|
45
|
+
this.ctx = { ...prev, ...patch, bias: prev.bias + (patch.bias ?? 0) };
|
|
46
|
+
try {
|
|
47
|
+
fn();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
this.ctx = prev;
|
|
51
|
+
}
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
/** everything drawn inside belongs to one animated part (one <g>, one sort unit) */
|
|
55
|
+
part(name, attrs, fn) {
|
|
56
|
+
this.parts[name] = { attrs };
|
|
57
|
+
return this.scoped({ part: name }, fn);
|
|
58
|
+
}
|
|
59
|
+
/** everything inside carries an extra class — visibility groups, themes */
|
|
60
|
+
tag(cls, fn) {
|
|
61
|
+
return this.scoped({ tag: this.ctx.tag ? `${this.ctx.tag} ${cls}` : cls }, fn);
|
|
62
|
+
}
|
|
63
|
+
/** deliberate layering for coplanar geometry */
|
|
64
|
+
bias(n, fn) { return this.scoped({ bias: n }, fn); }
|
|
65
|
+
opt() {
|
|
66
|
+
const o = {};
|
|
67
|
+
if (this.ctx.part)
|
|
68
|
+
o.part = this.ctx.part;
|
|
69
|
+
if (this.ctx.tag)
|
|
70
|
+
o.tag = this.ctx.tag;
|
|
71
|
+
if (this.ctx.bias)
|
|
72
|
+
o.bias = this.ctx.bias;
|
|
73
|
+
return o;
|
|
74
|
+
}
|
|
75
|
+
/* ---------- primitives ---------- */
|
|
76
|
+
/** begin a fluent path: s.path("ink").M(a).Q(c, 10, b, 20) */
|
|
77
|
+
path(cls, w) {
|
|
78
|
+
const shape = { t: "path", d: [], strokes: [{ cls, w }], ...this.opt() };
|
|
79
|
+
this.shapes.push(shape);
|
|
80
|
+
return new Path(shape);
|
|
81
|
+
}
|
|
82
|
+
/** hollow-tube outline in one call: fat outer + core stroke */
|
|
83
|
+
tube(w, outer = "tube", inner = "tube-in") {
|
|
84
|
+
return this.path(outer, w).stroke(inner, Math.max(1.5, w - 5));
|
|
85
|
+
}
|
|
86
|
+
line(a, za, b, zb, cls, w) {
|
|
87
|
+
this.path(cls, w).M(a, za).L(b, zb);
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
/** circle in a constant-z plane — projects to a correct ellipse */
|
|
91
|
+
disc(c, z, r, cls, fill) {
|
|
92
|
+
const strokes = typeof cls === "string" ? [{ cls }] : cls;
|
|
93
|
+
this.shapes.push({ t: "disc", c: [c[0], c[1], z], r, strokes, fill, ...this.opt() });
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
/** cylinder end-cap facing the viewer — stylized, stays readable */
|
|
97
|
+
cap(c, z, r, cls, fill) {
|
|
98
|
+
this.shapes.push({ t: "face", c: [c[0], c[1], z], r, strokes: [{ cls }], fill, ...this.opt() });
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
/** six-faced box: face plate at z, body extruded away (z − dz) */
|
|
102
|
+
box(x, y, w, h, z, dz, cls = {}) {
|
|
103
|
+
const { face = "face3", top = "top3", side = "side3" } = cls;
|
|
104
|
+
const zB = z - dz, o = this.opt(), b0 = o.bias ?? 0;
|
|
105
|
+
const A = [x, y, z], B = [x + w, y, z], C = [x + w, y + h, z], D = [x, y + h, z];
|
|
106
|
+
const A2 = [x, y, zB], B2 = [x + w, y, zB], C2 = [x + w, y + h, zB], D2 = [x, y + h, zB];
|
|
107
|
+
const q = (qq, c2, bias) => this.shapes.push({ t: "quad", q: qq, cls: c2, cull: true, ...o, bias });
|
|
108
|
+
q([A, A2, B2, B], top, b0);
|
|
109
|
+
q([D, C, C2, D2], top, b0);
|
|
110
|
+
q([A, A2, D2, D], side, b0);
|
|
111
|
+
q([B, C, C2, B2], side, b0);
|
|
112
|
+
q([A2, D2, C2, B2], face, b0 + 0.1);
|
|
113
|
+
q([A, B, C, D], face, b0 + 0.2);
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
/** cylinder along z with silhouette rails computed from THIS sketch's view */
|
|
117
|
+
cyl(c, r, zNear, zFar, cls = "ink", fill) {
|
|
118
|
+
const n = this.X([c[0], c[1], zNear]), f = this.X([c[0], c[1], zFar]);
|
|
119
|
+
const dx = n.x - f.x, dy = n.y - f.y, m = Math.hypot(dx, dy) || 1;
|
|
120
|
+
const ox = (-dy / m) * r, oy = (dx / m) * r;
|
|
121
|
+
this.cap(c, zFar, r, cls);
|
|
122
|
+
this.line([c[0] + ox, c[1] + oy], zFar, [c[0] + ox, c[1] + oy], zNear, cls);
|
|
123
|
+
this.line([c[0] - ox, c[1] - oy], zFar, [c[0] - ox, c[1] - oy], zNear, cls);
|
|
124
|
+
const o = this.opt();
|
|
125
|
+
this.shapes.push({ t: "face", c: [c[0], c[1], zNear], r, strokes: [{ cls }], fill, ...o, bias: (o.bias ?? 0) + 0.5 });
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
/* ---------- paper space (annotations that never rotate) ---------- */
|
|
129
|
+
/** project a model point to screen — for hand-placed annotations */
|
|
130
|
+
pt(p, z = 0) { const q = this.X([p[0], p[1], z]); return [q.x, q.y]; }
|
|
131
|
+
/** raw SVG painted above the sorted scene */
|
|
132
|
+
paper(svg) { this.paperStrs.push(svg); return this; }
|
|
133
|
+
/** paper-space text at a projected anchor */
|
|
134
|
+
note(text, p, z = 0, cls = "an", dx = 6, dy = 4) {
|
|
135
|
+
const q = this.pt(p, z);
|
|
136
|
+
return this.paper(`<text x="${(q[0] + dx).toFixed(1)}" y="${(q[1] + dy).toFixed(1)}" class="${cls}">${text}</text>`);
|
|
137
|
+
}
|
|
138
|
+
/* ---------- output ---------- */
|
|
139
|
+
count() { return this.shapes.length; }
|
|
140
|
+
render() { return render(this.shapes, this.view, this.parts) + this.paperStrs.join(""); }
|
|
141
|
+
}
|
|
142
|
+
export const sketch = (view) => new Sketch(view);
|
|
143
|
+
/** define once, replay per frame with a fresh view — the orbit-loop idiom */
|
|
144
|
+
export function scene(build) {
|
|
145
|
+
return (view, props) => {
|
|
146
|
+
const s = sketch(view);
|
|
147
|
+
build(s, props);
|
|
148
|
+
return s.render();
|
|
149
|
+
};
|
|
150
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "linework",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Tiny true-3D renderer for annotated technical drawings in SVG — rotate, project, depth-sort, emit strings. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/linework.js",
|
|
7
|
+
"types": "./dist/linework.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": { "types": "./dist/linework.d.ts", "import": "./dist/linework.js" },
|
|
10
|
+
"./helpers": { "types": "./dist/helpers.d.ts", "import": "./dist/helpers.js" },
|
|
11
|
+
"./sketch": { "types": "./dist/sketch.d.ts", "import": "./dist/sketch.js" },
|
|
12
|
+
"./orbit": { "types": "./dist/orbit.d.ts", "import": "./dist/orbit.js" },
|
|
13
|
+
"./import": { "types": "./dist/import.d.ts", "import": "./dist/import.js" }
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist", "README.md", "LICENSE", "CHANGELOG.md"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": "github:isaacrowntree/linework",
|
|
18
|
+
"homepage": "https://isaacrowntree.com/linework/",
|
|
19
|
+
"keywords": ["svg", "3d", "pseudo-3d", "isometric", "technical-illustration", "exploded-diagram", "painters-algorithm", "blueprint"],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json && cp dist/linework.js dist/helpers.js dist/sketch.js dist/orbit.js dist/import.js docs/",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"hero": "npm run build && node scripts/render-hero.mjs",
|
|
25
|
+
"prepublishOnly": "npm run build && npm test"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"typescript": "^5.9.3",
|
|
29
|
+
"vitest": "^4.1.10"
|
|
30
|
+
}
|
|
31
|
+
}
|