tsdraft 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 +72 -2
- package/dist/drawing.d.ts +86 -0
- package/dist/drawing.js +116 -0
- package/dist/dxf.d.ts +9 -0
- package/dist/dxf.js +112 -0
- package/dist/format.d.ts +2 -0
- package/dist/format.js +5 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/svg.d.ts +9 -0
- package/dist/svg.js +108 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stephen Frederick
|
|
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,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tsdraft
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Write 2D CAD drawings in TypeScript and export them as DXF for AutoCAD or
|
|
4
|
+
BricsCAD, or as SVG for a quick preview. A drawing is a program, so dimensions
|
|
5
|
+
can come from variables, sheets can share code, and changes show up as diffs.
|
|
6
|
+
[tscircuit](https://github.com/tscircuit/tscircuit) takes the same approach to
|
|
7
|
+
circuit boards.
|
|
8
|
+
|
|
9
|
+
This is an early release. It has layers, lines, polylines, rectangles, circles,
|
|
10
|
+
arcs and single-line text. It does not yet have dimensions, hatches, blocks,
|
|
11
|
+
paper space or a JSX syntax.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install tsdraft
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
tsdraft is an ES module and needs Node 22 or later.
|
|
20
|
+
|
|
21
|
+
## Example
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createDrawing, toDXF, toSVG } from "tsdraft";
|
|
25
|
+
|
|
26
|
+
const d = createDrawing({ units: "ft" });
|
|
27
|
+
|
|
28
|
+
d.layer("C-PROP", { color: 3 }, () => {
|
|
29
|
+
d.rect([0, 0], 75, 120);
|
|
30
|
+
});
|
|
31
|
+
d.layer("A-BLDG", { color: 1 }, () => {
|
|
32
|
+
d.rect([17.5, 30], 40, 50);
|
|
33
|
+
d.text([19.5, 55], "PROPOSED RESIDENCE", { height: 2 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const drawing = d.build();
|
|
37
|
+
const dxf = toDXF(drawing);
|
|
38
|
+
const svg = toSVG(drawing);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Coordinates are in drawing units with Y pointing up, as in CAD. Angles are in
|
|
42
|
+
degrees, counterclockwise from +X. Layer colors are AutoCAD Color Index numbers.
|
|
43
|
+
Entities go on layer `0` unless they are drawn inside a `layer()` callback.
|
|
44
|
+
|
|
45
|
+
`toDXF` writes ASCII DXF R12, because nearly every CAD program can open that
|
|
46
|
+
version. R12 has no field for drawing units, so the file holds plain numbers.
|
|
47
|
+
Set the units in CAD after opening it, for example with `UNITS` in AutoCAD or
|
|
48
|
+
BricsCAD.
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
Contributors need Node 24 or later and pnpm. Node runs the TypeScript source
|
|
53
|
+
directly, so tests and examples need no build step.
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
pnpm install
|
|
57
|
+
pnpm verify # format check, lint, type check, tests, package smoke test
|
|
58
|
+
pnpm example # writes examples/out/site-plan.dxf and .svg
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`pnpm test:package` packs the package, installs the tarball into a throwaway
|
|
62
|
+
project and checks that it type-checks and runs there. `pnpm build` compiles
|
|
63
|
+
`src/` to `dist/`, and `npm pack` and `npm publish` run it automatically.
|
|
64
|
+
|
|
65
|
+
The official TypeScript 7 compiler checks and builds the package.
|
|
66
|
+
[tsrs](https://github.com/oxc-project/tsrs), the Rust port of TypeScript, runs
|
|
67
|
+
as `pnpm check:tsrs` and as an informational CI step. It tracks TypeScript 7.1
|
|
68
|
+
beta, so it does not gate releases yet. Its WebAssembly build is the planned
|
|
69
|
+
route for type-checking drawings in a browser playground.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** A model-space point, in drawing units. Y points up, as in CAD. */
|
|
2
|
+
export type Point = readonly [x: number, y: number];
|
|
3
|
+
/**
|
|
4
|
+
* The unit one drawing unit stands for. It is kept on the drawing for callers;
|
|
5
|
+
* the DXF R12 writer has no field for it, so set units in CAD after opening.
|
|
6
|
+
*/
|
|
7
|
+
export type Units = "in" | "ft" | "mm" | "cm" | "m";
|
|
8
|
+
export interface Layer {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/** AutoCAD Color Index, 1 to 255. 7 is white on dark backgrounds, black on light. */
|
|
11
|
+
readonly color: number;
|
|
12
|
+
}
|
|
13
|
+
interface EntityBase {
|
|
14
|
+
readonly layer: string;
|
|
15
|
+
}
|
|
16
|
+
export interface Line extends EntityBase {
|
|
17
|
+
readonly kind: "line";
|
|
18
|
+
readonly from: Point;
|
|
19
|
+
readonly to: Point;
|
|
20
|
+
}
|
|
21
|
+
export interface Polyline extends EntityBase {
|
|
22
|
+
readonly kind: "polyline";
|
|
23
|
+
readonly points: readonly Point[];
|
|
24
|
+
readonly closed: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface Circle extends EntityBase {
|
|
27
|
+
readonly kind: "circle";
|
|
28
|
+
readonly center: Point;
|
|
29
|
+
readonly radius: number;
|
|
30
|
+
}
|
|
31
|
+
/** Angles are in degrees, counterclockwise from +X, as in DXF. */
|
|
32
|
+
export interface Arc extends EntityBase {
|
|
33
|
+
readonly kind: "arc";
|
|
34
|
+
readonly center: Point;
|
|
35
|
+
readonly radius: number;
|
|
36
|
+
readonly startAngle: number;
|
|
37
|
+
readonly endAngle: number;
|
|
38
|
+
}
|
|
39
|
+
export interface Text extends EntityBase {
|
|
40
|
+
readonly kind: "text";
|
|
41
|
+
/** Left end of the text baseline. */
|
|
42
|
+
readonly at: Point;
|
|
43
|
+
readonly height: number;
|
|
44
|
+
readonly value: string;
|
|
45
|
+
/** Degrees counterclockwise. */
|
|
46
|
+
readonly rotation: number;
|
|
47
|
+
}
|
|
48
|
+
export type Entity = Line | Polyline | Circle | Arc | Text;
|
|
49
|
+
export interface Drawing {
|
|
50
|
+
readonly units: Units;
|
|
51
|
+
readonly layers: readonly Layer[];
|
|
52
|
+
readonly entities: readonly Entity[];
|
|
53
|
+
}
|
|
54
|
+
export interface DrawingOptions {
|
|
55
|
+
readonly units?: Units;
|
|
56
|
+
}
|
|
57
|
+
export interface LayerOptions {
|
|
58
|
+
readonly color?: number;
|
|
59
|
+
}
|
|
60
|
+
export interface TextOptions {
|
|
61
|
+
readonly height?: number;
|
|
62
|
+
readonly rotation?: number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Builds a drawing. Entities go on the current layer, which starts as "0".
|
|
66
|
+
* `layer(name, opts, draw)` scopes everything drawn inside `draw` to that layer.
|
|
67
|
+
*/
|
|
68
|
+
export declare class DrawingBuilder {
|
|
69
|
+
#private;
|
|
70
|
+
readonly units: Units;
|
|
71
|
+
constructor(options?: DrawingOptions);
|
|
72
|
+
/** Defines or updates a layer. With `draw`, entities drawn inside it go on this layer. */
|
|
73
|
+
layer(name: string, options?: LayerOptions, draw?: () => void): this;
|
|
74
|
+
line(from: Point, to: Point): this;
|
|
75
|
+
polyline(points: readonly Point[], closed?: boolean): this;
|
|
76
|
+
/** Closed rectangle from its lower-left corner. */
|
|
77
|
+
rect(corner: Point, width: number, height: number): this;
|
|
78
|
+
circle(center: Point, radius: number): this;
|
|
79
|
+
arc(center: Point, radius: number, startAngle: number, endAngle: number): this;
|
|
80
|
+
text(at: Point, value: string, options?: TextOptions): this;
|
|
81
|
+
build(): Drawing;
|
|
82
|
+
}
|
|
83
|
+
export declare function createDrawing(options?: DrawingOptions): DrawingBuilder;
|
|
84
|
+
/** Counterclockwise sweep from start to end, in degrees, within [0, 360). */
|
|
85
|
+
export declare function sweep(startAngle: number, endAngle: number): number;
|
|
86
|
+
export {};
|
package/dist/drawing.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a drawing. Entities go on the current layer, which starts as "0".
|
|
3
|
+
* `layer(name, opts, draw)` scopes everything drawn inside `draw` to that layer.
|
|
4
|
+
*/
|
|
5
|
+
export class DrawingBuilder {
|
|
6
|
+
units;
|
|
7
|
+
#layers = new Map([["0", { name: "0", color: 7 }]]);
|
|
8
|
+
#entities = [];
|
|
9
|
+
#current = "0";
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.units = options.units ?? "in";
|
|
12
|
+
}
|
|
13
|
+
/** Defines or updates a layer. With `draw`, entities drawn inside it go on this layer. */
|
|
14
|
+
layer(name, options = {}, draw) {
|
|
15
|
+
if (!/^[^<>/\\":;?*|=`]{1,255}$/.test(name)) {
|
|
16
|
+
throw new Error(`Invalid layer name: ${JSON.stringify(name)}`);
|
|
17
|
+
}
|
|
18
|
+
const color = options.color ?? this.#layers.get(name)?.color ?? 7;
|
|
19
|
+
if (!Number.isInteger(color) || color < 1 || color > 255) {
|
|
20
|
+
throw new Error(`Layer ${name}: color must be an ACI integer from 1 to 255`);
|
|
21
|
+
}
|
|
22
|
+
this.#layers.set(name, { name, color });
|
|
23
|
+
if (draw) {
|
|
24
|
+
const previous = this.#current;
|
|
25
|
+
this.#current = name;
|
|
26
|
+
try {
|
|
27
|
+
draw();
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
this.#current = previous;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
line(from, to) {
|
|
36
|
+
return this.#add({ kind: "line", layer: this.#current, from: pt(from), to: pt(to) });
|
|
37
|
+
}
|
|
38
|
+
polyline(points, closed = false) {
|
|
39
|
+
if (points.length < 2)
|
|
40
|
+
throw new Error("A polyline needs at least 2 points");
|
|
41
|
+
return this.#add({ kind: "polyline", layer: this.#current, points: points.map(pt), closed });
|
|
42
|
+
}
|
|
43
|
+
/** Closed rectangle from its lower-left corner. */
|
|
44
|
+
rect(corner, width, height) {
|
|
45
|
+
const [x, y] = pt(corner);
|
|
46
|
+
num(width, "width");
|
|
47
|
+
num(height, "height");
|
|
48
|
+
return this.polyline([
|
|
49
|
+
[x, y],
|
|
50
|
+
[x + width, y],
|
|
51
|
+
[x + width, y + height],
|
|
52
|
+
[x, y + height],
|
|
53
|
+
], true);
|
|
54
|
+
}
|
|
55
|
+
circle(center, radius) {
|
|
56
|
+
return this.#add({
|
|
57
|
+
kind: "circle",
|
|
58
|
+
layer: this.#current,
|
|
59
|
+
center: pt(center),
|
|
60
|
+
radius: positive(radius, "radius"),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
arc(center, radius, startAngle, endAngle) {
|
|
64
|
+
num(startAngle, "startAngle");
|
|
65
|
+
num(endAngle, "endAngle");
|
|
66
|
+
if (sweep(startAngle, endAngle) === 0)
|
|
67
|
+
throw new Error("An arc's start and end angles must differ");
|
|
68
|
+
return this.#add({
|
|
69
|
+
kind: "arc",
|
|
70
|
+
layer: this.#current,
|
|
71
|
+
center: pt(center),
|
|
72
|
+
radius: positive(radius, "radius"),
|
|
73
|
+
startAngle,
|
|
74
|
+
endAngle,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
text(at, value, options = {}) {
|
|
78
|
+
if (/[\r\n]/.test(value))
|
|
79
|
+
throw new Error("Text must be a single line");
|
|
80
|
+
return this.#add({
|
|
81
|
+
kind: "text",
|
|
82
|
+
layer: this.#current,
|
|
83
|
+
at: pt(at),
|
|
84
|
+
value,
|
|
85
|
+
height: positive(options.height ?? 0.125, "height"),
|
|
86
|
+
rotation: num(options.rotation ?? 0, "rotation"),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
build() {
|
|
90
|
+
return { units: this.units, layers: [...this.#layers.values()], entities: [...this.#entities] };
|
|
91
|
+
}
|
|
92
|
+
#add(entity) {
|
|
93
|
+
this.#entities.push(entity);
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function createDrawing(options) {
|
|
98
|
+
return new DrawingBuilder(options);
|
|
99
|
+
}
|
|
100
|
+
/** Counterclockwise sweep from start to end, in degrees, within [0, 360). */
|
|
101
|
+
export function sweep(startAngle, endAngle) {
|
|
102
|
+
return (((endAngle - startAngle) % 360) + 360) % 360;
|
|
103
|
+
}
|
|
104
|
+
function num(value, label) {
|
|
105
|
+
if (!Number.isFinite(value))
|
|
106
|
+
throw new Error(`${label} must be a finite number`);
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function positive(value, label) {
|
|
110
|
+
if (num(value, label) <= 0)
|
|
111
|
+
throw new Error(`${label} must be greater than 0`);
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function pt(point) {
|
|
115
|
+
return [num(point[0], "x"), num(point[1], "y")];
|
|
116
|
+
}
|
package/dist/dxf.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Drawing } from "./drawing.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Writes an ASCII DXF (AC1009, R12). R12 is the most widely readable DXF:
|
|
4
|
+
* AutoCAD, BricsCAD, LibreCAD, QCAD and most converters open it.
|
|
5
|
+
*
|
|
6
|
+
* R12 has no header variable for drawing units, so coordinates are written as
|
|
7
|
+
* plain numbers in the drawing's units and `drawing.units` is not written.
|
|
8
|
+
*/
|
|
9
|
+
export declare function toDXF(drawing: Drawing): string;
|
package/dist/dxf.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { fmt } from "./format.js";
|
|
2
|
+
/**
|
|
3
|
+
* Writes an ASCII DXF (AC1009, R12). R12 is the most widely readable DXF:
|
|
4
|
+
* AutoCAD, BricsCAD, LibreCAD, QCAD and most converters open it.
|
|
5
|
+
*
|
|
6
|
+
* R12 has no header variable for drawing units, so coordinates are written as
|
|
7
|
+
* plain numbers in the drawing's units and `drawing.units` is not written.
|
|
8
|
+
*/
|
|
9
|
+
export function toDXF(drawing) {
|
|
10
|
+
const out = [];
|
|
11
|
+
const g = (code, value) => {
|
|
12
|
+
out.push(String(code), typeof value === "number" ? fmt(value) : value);
|
|
13
|
+
};
|
|
14
|
+
g(0, "SECTION");
|
|
15
|
+
g(2, "HEADER");
|
|
16
|
+
g(9, "$ACADVER");
|
|
17
|
+
g(1, "AC1009");
|
|
18
|
+
g(0, "ENDSEC");
|
|
19
|
+
g(0, "SECTION");
|
|
20
|
+
g(2, "TABLES");
|
|
21
|
+
g(0, "TABLE");
|
|
22
|
+
g(2, "LTYPE");
|
|
23
|
+
g(70, "1");
|
|
24
|
+
g(0, "LTYPE");
|
|
25
|
+
g(2, "CONTINUOUS");
|
|
26
|
+
g(70, "0");
|
|
27
|
+
g(3, "Solid line");
|
|
28
|
+
g(72, "65");
|
|
29
|
+
g(73, "0");
|
|
30
|
+
g(40, "0.0");
|
|
31
|
+
g(0, "ENDTAB");
|
|
32
|
+
g(0, "TABLE");
|
|
33
|
+
g(2, "LAYER");
|
|
34
|
+
g(70, String(drawing.layers.length));
|
|
35
|
+
for (const layer of drawing.layers) {
|
|
36
|
+
g(0, "LAYER");
|
|
37
|
+
g(2, layer.name);
|
|
38
|
+
g(70, "0");
|
|
39
|
+
g(62, String(layer.color));
|
|
40
|
+
g(6, "CONTINUOUS");
|
|
41
|
+
}
|
|
42
|
+
g(0, "ENDTAB");
|
|
43
|
+
g(0, "ENDSEC");
|
|
44
|
+
g(0, "SECTION");
|
|
45
|
+
g(2, "ENTITIES");
|
|
46
|
+
for (const entity of drawing.entities)
|
|
47
|
+
writeEntity(entity, g);
|
|
48
|
+
g(0, "ENDSEC");
|
|
49
|
+
g(0, "EOF");
|
|
50
|
+
return out.join("\r\n") + "\r\n";
|
|
51
|
+
}
|
|
52
|
+
function writeEntity(e, g) {
|
|
53
|
+
switch (e.kind) {
|
|
54
|
+
case "line":
|
|
55
|
+
g(0, "LINE");
|
|
56
|
+
g(8, e.layer);
|
|
57
|
+
g(10, e.from[0]);
|
|
58
|
+
g(20, e.from[1]);
|
|
59
|
+
g(30, 0);
|
|
60
|
+
g(11, e.to[0]);
|
|
61
|
+
g(21, e.to[1]);
|
|
62
|
+
g(31, 0);
|
|
63
|
+
return;
|
|
64
|
+
case "polyline":
|
|
65
|
+
g(0, "POLYLINE");
|
|
66
|
+
g(8, e.layer);
|
|
67
|
+
g(66, "1");
|
|
68
|
+
g(10, 0);
|
|
69
|
+
g(20, 0);
|
|
70
|
+
g(30, 0);
|
|
71
|
+
g(70, e.closed ? "1" : "0");
|
|
72
|
+
for (const [x, y] of e.points) {
|
|
73
|
+
g(0, "VERTEX");
|
|
74
|
+
g(8, e.layer);
|
|
75
|
+
g(10, x);
|
|
76
|
+
g(20, y);
|
|
77
|
+
g(30, 0);
|
|
78
|
+
}
|
|
79
|
+
g(0, "SEQEND");
|
|
80
|
+
g(8, e.layer);
|
|
81
|
+
return;
|
|
82
|
+
case "circle":
|
|
83
|
+
g(0, "CIRCLE");
|
|
84
|
+
g(8, e.layer);
|
|
85
|
+
g(10, e.center[0]);
|
|
86
|
+
g(20, e.center[1]);
|
|
87
|
+
g(30, 0);
|
|
88
|
+
g(40, e.radius);
|
|
89
|
+
return;
|
|
90
|
+
case "arc":
|
|
91
|
+
g(0, "ARC");
|
|
92
|
+
g(8, e.layer);
|
|
93
|
+
g(10, e.center[0]);
|
|
94
|
+
g(20, e.center[1]);
|
|
95
|
+
g(30, 0);
|
|
96
|
+
g(40, e.radius);
|
|
97
|
+
g(50, e.startAngle);
|
|
98
|
+
g(51, e.endAngle);
|
|
99
|
+
return;
|
|
100
|
+
case "text":
|
|
101
|
+
g(0, "TEXT");
|
|
102
|
+
g(8, e.layer);
|
|
103
|
+
g(10, e.at[0]);
|
|
104
|
+
g(20, e.at[1]);
|
|
105
|
+
g(30, 0);
|
|
106
|
+
g(40, e.height);
|
|
107
|
+
g(1, e.value);
|
|
108
|
+
if (e.rotation !== 0)
|
|
109
|
+
g(50, e.rotation);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/format.d.ts
ADDED
package/dist/format.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createDrawing, DrawingBuilder, sweep, type Arc, type Circle, type Drawing, type DrawingOptions, type Entity, type Layer, type LayerOptions, type Line, type Point, type Polyline, type Text, type TextOptions, type Units, } from "./drawing.ts";
|
|
2
|
+
export { toDXF } from "./dxf.ts";
|
|
3
|
+
export { toSVG, type SvgOptions } from "./svg.ts";
|
package/dist/index.js
ADDED
package/dist/svg.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Drawing } from "./drawing.ts";
|
|
2
|
+
export interface SvgOptions {
|
|
3
|
+
/** Margin around the drawing's extents, in drawing units. Defaults to 2% of the larger side. */
|
|
4
|
+
readonly margin?: number;
|
|
5
|
+
/** Stroke width in screen pixels; it stays constant when the SVG scales. */
|
|
6
|
+
readonly strokeWidth?: number;
|
|
7
|
+
}
|
|
8
|
+
/** Renders a preview SVG. Model Y points up, so the drawing group flips Y. */
|
|
9
|
+
export declare function toSVG(drawing: Drawing, options?: SvgOptions): string;
|
package/dist/svg.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { sweep } from "./drawing.js";
|
|
2
|
+
import { fmt } from "./format.js";
|
|
3
|
+
/** Screen colors for the standard ACI colors 1 to 7. Other indexes draw in currentColor. */
|
|
4
|
+
const ACI = {
|
|
5
|
+
1: "#ff0000",
|
|
6
|
+
2: "#ffff00",
|
|
7
|
+
3: "#00ff00",
|
|
8
|
+
4: "#00ffff",
|
|
9
|
+
5: "#0000ff",
|
|
10
|
+
6: "#ff00ff",
|
|
11
|
+
7: "currentColor",
|
|
12
|
+
};
|
|
13
|
+
/** Renders a preview SVG. Model Y points up, so the drawing group flips Y. */
|
|
14
|
+
export function toSVG(drawing, options = {}) {
|
|
15
|
+
const [minX, minY, maxX, maxY] = extents(drawing.entities);
|
|
16
|
+
const margin = options.margin ?? Math.max(maxX - minX, maxY - minY, 1) * 0.02;
|
|
17
|
+
const x = minX - margin;
|
|
18
|
+
const y = -(maxY + margin);
|
|
19
|
+
const w = maxX - minX + margin * 2;
|
|
20
|
+
const h = maxY - minY + margin * 2;
|
|
21
|
+
const colors = new Map(drawing.layers.map((l) => [l.name, ACI[l.color] ?? "currentColor"]));
|
|
22
|
+
const stroke = options.strokeWidth ?? 1;
|
|
23
|
+
const body = drawing.entities
|
|
24
|
+
.map((e) => entityToSvg(e, colors.get(e.layer) ?? "currentColor"))
|
|
25
|
+
.join("\n ");
|
|
26
|
+
return [
|
|
27
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${fmt(x)} ${fmt(y)} ${fmt(w)} ${fmt(h)}">`,
|
|
28
|
+
` <g transform="scale(1 -1)" fill="none" stroke-width="${fmt(stroke)}" vector-effect="non-scaling-stroke">`,
|
|
29
|
+
` ${body}`,
|
|
30
|
+
` </g>`,
|
|
31
|
+
`</svg>`,
|
|
32
|
+
``,
|
|
33
|
+
].join("\n");
|
|
34
|
+
}
|
|
35
|
+
function entityToSvg(e, color) {
|
|
36
|
+
const s = `stroke="${color}" vector-effect="non-scaling-stroke" data-layer="${esc(e.layer)}"`;
|
|
37
|
+
switch (e.kind) {
|
|
38
|
+
case "line":
|
|
39
|
+
return `<line x1="${fmt(e.from[0])}" y1="${fmt(e.from[1])}" x2="${fmt(e.to[0])}" y2="${fmt(e.to[1])}" ${s}/>`;
|
|
40
|
+
case "polyline": {
|
|
41
|
+
const tag = e.closed ? "polygon" : "polyline";
|
|
42
|
+
const points = e.points.map(([px, py]) => `${fmt(px)},${fmt(py)}`).join(" ");
|
|
43
|
+
return `<${tag} points="${points}" ${s}/>`;
|
|
44
|
+
}
|
|
45
|
+
case "circle":
|
|
46
|
+
return `<circle cx="${fmt(e.center[0])}" cy="${fmt(e.center[1])}" r="${fmt(e.radius)}" ${s}/>`;
|
|
47
|
+
case "arc": {
|
|
48
|
+
const [cx, cy] = e.center;
|
|
49
|
+
const a0 = rad(e.startAngle);
|
|
50
|
+
const a1 = rad(e.endAngle);
|
|
51
|
+
const large = sweep(e.startAngle, e.endAngle) > 180 ? 1 : 0;
|
|
52
|
+
const start = `${fmt(cx + e.radius * Math.cos(a0))} ${fmt(cy + e.radius * Math.sin(a0))}`;
|
|
53
|
+
const end = `${fmt(cx + e.radius * Math.cos(a1))} ${fmt(cy + e.radius * Math.sin(a1))}`;
|
|
54
|
+
// sweep-flag 1 is the positive-angle direction in local (Y-up) coordinates: counterclockwise.
|
|
55
|
+
return `<path d="M ${start} A ${fmt(e.radius)} ${fmt(e.radius)} 0 ${large} 1 ${end}" ${s}/>`;
|
|
56
|
+
}
|
|
57
|
+
case "text": {
|
|
58
|
+
// Undo the group's Y flip so the text reads upright, then apply CAD rotation.
|
|
59
|
+
const t = `translate(${fmt(e.at[0])} ${fmt(e.at[1])}) scale(1 -1)${e.rotation ? ` rotate(${fmt(-e.rotation)})` : ""}`;
|
|
60
|
+
return `<text transform="${t}" font-size="${fmt(e.height)}" font-family="monospace" fill="${color}" stroke="none" data-layer="${esc(e.layer)}">${esc(e.value)}</text>`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Approximate extents. Arcs use their full circle; text assumes 0.6 × height per character. */
|
|
65
|
+
function extents(entities) {
|
|
66
|
+
let minX = Infinity;
|
|
67
|
+
let minY = Infinity;
|
|
68
|
+
let maxX = -Infinity;
|
|
69
|
+
let maxY = -Infinity;
|
|
70
|
+
const add = (x, y) => {
|
|
71
|
+
minX = Math.min(minX, x);
|
|
72
|
+
minY = Math.min(minY, y);
|
|
73
|
+
maxX = Math.max(maxX, x);
|
|
74
|
+
maxY = Math.max(maxY, y);
|
|
75
|
+
};
|
|
76
|
+
for (const e of entities) {
|
|
77
|
+
switch (e.kind) {
|
|
78
|
+
case "line":
|
|
79
|
+
add(...e.from);
|
|
80
|
+
add(...e.to);
|
|
81
|
+
break;
|
|
82
|
+
case "polyline":
|
|
83
|
+
for (const p of e.points)
|
|
84
|
+
add(...p);
|
|
85
|
+
break;
|
|
86
|
+
case "circle":
|
|
87
|
+
case "arc":
|
|
88
|
+
add(e.center[0] - e.radius, e.center[1] - e.radius);
|
|
89
|
+
add(e.center[0] + e.radius, e.center[1] + e.radius);
|
|
90
|
+
break;
|
|
91
|
+
case "text": {
|
|
92
|
+
const len = e.value.length * e.height * 0.6;
|
|
93
|
+
const r = rad(e.rotation);
|
|
94
|
+
add(...e.at);
|
|
95
|
+
add(e.at[0] + len * Math.cos(r), e.at[1] + len * Math.sin(r));
|
|
96
|
+
add(e.at[0] - e.height * Math.sin(r), e.at[1] + e.height * Math.cos(r));
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return minX === Infinity ? [0, 0, 1, 1] : [minX, minY, maxX, maxY];
|
|
102
|
+
}
|
|
103
|
+
function rad(degrees) {
|
|
104
|
+
return (degrees * Math.PI) / 180;
|
|
105
|
+
}
|
|
106
|
+
function esc(value) {
|
|
107
|
+
return value.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] ?? c);
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tsdraft",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "2D CAD drawings as TypeScript: write the drawing in code, export DXF and SVG.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"2d",
|
|
7
|
+
"autocad",
|
|
8
|
+
"bricscad",
|
|
9
|
+
"cad",
|
|
10
|
+
"drafting",
|
|
11
|
+
"drawing",
|
|
12
|
+
"dxf"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/Surfside-Software/tsdraft#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/Surfside-Software/tsdraft/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Stephen Frederick",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/Surfside-Software/tsdraft.git"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
37
|
+
"build": "pnpm clean && tsc -p tsconfig.build.json",
|
|
38
|
+
"prepack": "pnpm build",
|
|
39
|
+
"check": "tsc --noEmit",
|
|
40
|
+
"check:tsrs": "tsrs --noEmit",
|
|
41
|
+
"lint": "oxlint",
|
|
42
|
+
"fmt": "oxfmt",
|
|
43
|
+
"fmt:check": "oxfmt --check",
|
|
44
|
+
"test": "node --test test/*.test.ts",
|
|
45
|
+
"test:package": "node scripts/package-smoke.ts",
|
|
46
|
+
"example": "node examples/site-plan.ts",
|
|
47
|
+
"verify": "pnpm fmt:check && pnpm lint && pnpm check && pnpm test && pnpm test:package"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^24.0.0",
|
|
51
|
+
"oxfmt": "0.77.0",
|
|
52
|
+
"oxlint": "1.92.0",
|
|
53
|
+
"tsrs": "0.23.0-ts7.1.0-beta",
|
|
54
|
+
"typescript": "7.0.2"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22"
|
|
58
|
+
},
|
|
59
|
+
"packageManager": "pnpm@12.11.2"
|
|
60
|
+
}
|