tsdraft 0.1.0 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,111 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1
4
+
5
+ Every fix below was found by opening tsdraft's output in AutoCAD 2025.
6
+
7
+ - Fixed: text holding `\U+XXXX` or `\M+NXXXX` came out in AutoCAD as the
8
+ character those escapes stand for, so `\U+00B0` became a degree sign and
9
+ `\U+000A` put a line feed inside a single-line text. Both DXF versions now
10
+ write such a backslash so that AutoCAD holds the text as the drawing has it
11
+ when it opens the file. AutoCAD decodes the text after all once it has saved
12
+ the drawing itself and opened it again: `\M+NXXXX` after a save as DWG, and
13
+ `\U+XXXX` too after a save as DXF.
14
+ - Fixed: a size too small for the 6 decimals tsdraft writes was written as 0.
15
+ AutoCAD gave such a text its default height, and held such a circle or arc
16
+ with radius 0. A radius, text height or rectangle size that would be written
17
+ as 0 is now an error, in the compiler for a literal and when the code runs.
18
+ `rect()` also throws when a side is lost because two corners round together.
19
+ - Fixed: AutoCAD reads only two exponent digits from DXF, so it took a number
20
+ written as `1e+100` for `1e+10`, with no `AUDIT` error. A coordinate, size or
21
+ text rotation of 1e100 or more is now an error, in the compiler for a size
22
+ written as a literal and when the code runs. The R2018 writer also caps the
23
+ extents and the view height it works out, which could pass 1e100 for a
24
+ drawing whose own numbers are all smaller.
25
+ - Fixed: an arc within 0.000001 degrees of a full turn was written with equal
26
+ angles, which AutoCAD holds as an arc of no length. It is now an error. An arc
27
+ angle that rounds up to 360 is written as 0.
28
+ - Fixed: `toDXF` wrote layer `0` wherever the drawing listed it, and `AUDIT`
29
+ reported "Layer zero name found at incorrect index" when another layer came
30
+ first. Layer `0` is now written first.
31
+ - Fixed: `lint` matched layer names with JavaScript's `toLowerCase()`, which
32
+ AutoCAD does not use. It accepted an entity on `οδος` for a layer `ΟΔΟΣ`,
33
+ where AutoCAD makes a second layer. `lint` and the builder now compare names
34
+ as AutoCAD does. The README lists the pairs that differ.
35
+ - Fixed: two layers such as `ΟΔΟΣ` and `οδος`, which AutoCAD keeps apart but
36
+ its `AUDIT` reports as a duplicate name, are now an error.
37
+ - Fixed: AutoCAD discarded a drawing whose layer name had a zero-width space,
38
+ another invisible formatting character, a letterlike symbol such as the ohm
39
+ sign U+2126, or a Glagolitic, Coptic, Tifinagh, Georgian Supplement, Latin
40
+ Extended-C or Ethiopic Extended letter. Those names are now errors, in the
41
+ compiler for a literal and when the code runs. The README lists them.
42
+ - Fixed: `lint` threw a `RangeError` on a polyline of about 100,000 points or
43
+ more.
44
+ - Fixed: a layer name that starts or ends with a no-break space, or another
45
+ space outside ASCII, threw when the code ran, though the compiler accepted
46
+ the literal and AutoCAD accepts the name. Only an ASCII space is refused now.
47
+ - Fixed: AutoCAD replaced the private-use characters U+E800 to U+E8FF in text
48
+ and layer names. They are now errors.
49
+ - Fixed: `toSVG` drew nothing for an arc so close to a full turn that its ends
50
+ rounded to one point, and ignored a layer's color when an entity spelled the
51
+ layer's name in another case.
52
+ - Changed: a `DrawingError` message starts "The drawing has 3 problems:" and no
53
+ longer names DXF, because code other than `toDXF` uses it to refuse a drawing.
54
+ - Changed: `lint` messages for a bad radius or height read "radius: This value
55
+ must be greater than 0", the wording the compiler uses for a literal.
56
+ - `lint` warns about a polyline or an arc with no length, as it does for a line,
57
+ and about a line, polyline or arc that loses its length to rounding.
58
+ - `lint` warns about text that contains `%%`, which AutoCAD stores as written
59
+ but draws as a control code: `%%d` is drawn as a degree sign.
60
+ - `pnpm test:cad` covers each fix above, and checks every character `lint`
61
+ accepts in text and in layer names, and each form of number the writers
62
+ print, against AutoCAD.
63
+
64
+ ## 0.2.0
65
+
66
+ 0.1.1 was never published, so its changes ship here too.
67
+
68
+ Every fix below comes from opening tsdraft's output in AutoCAD 2025.
69
+
70
+ - `createDrawing({ dxf: "R2018" })` targets AC1032, the DXF version AutoCAD has
71
+ used since 2018. It writes handles, owner links, the standard tables, model
72
+ and paper space blocks, plot styles and `$INSUNITS`. R12 stays the default.
73
+ `toDXF(drawing)` writes the drawing's version, and
74
+ `toDXF(drawing, { version })` writes another.
75
+ - Layer names, layer colors, radii, text heights and rectangle sizes written as
76
+ literals are checked by the compiler. The same checks run when the code runs.
77
+ - `lint(drawing, { version })` returns every problem in a drawing. `toDXF`
78
+ throws a `DrawingError` listing the errors instead of writing a file AutoCAD
79
+ would reject.
80
+ - Fixed: AutoCAD discarded R12 drawings whose layer names had spaces, dots or
81
+ other punctuation, or more than 31 characters. Those names are now errors in
82
+ an R12 drawing and allowed in an R2018 drawing.
83
+ - Fixed: a layer name containing a line break wrote extra records into the
84
+ file. Commas, carets and leading or trailing spaces are rejected too.
85
+ - Fixed: AutoCAD discarded any drawing with a `^` in its text. A caret is now
86
+ escaped.
87
+ - Fixed: R12 text outside ASCII came out garbled. It is now written as
88
+ `\U+XXXX` escapes.
89
+ - Fixed: arc angles outside 0 to 360 failed `AUDIT`. Angles are now stored from
90
+ 0 up to 360.
91
+ - Fixed: `wall` and `WALL` were two layers, and AutoCAD kept only the first.
92
+ They are now one layer.
93
+ - Text with control characters, or with emoji and other characters above
94
+ U+FFFF, is rejected, because AutoCAD cannot read it.
95
+ - Unknown units, and rectangle sizes of 0 or less, are rejected.
96
+ - Breaking: a `Drawing` now has a required `dxf` field, and R12 layer names
97
+ AutoCAD rejects now throw (see above).
98
+ - `pnpm test:cad` runs the output through AutoCAD over ssh.
99
+ - Published from GitHub Actions through npm trusted publishing, with a
100
+ provenance attestation linking the package to its source commit.
101
+ - `tsdraft/package.json` can be imported or resolved.
102
+ - The package now includes this changelog.
103
+
104
+ ## 0.1.0
105
+
106
+ First release.
107
+
108
+ - `createDrawing()` builds a drawing with layers (AutoCAD Color Index colors),
109
+ lines, polylines, rectangles, circles, arcs and single-line text.
110
+ - `toDXF()` writes ASCII DXF R12 (AC1009) with layer and linetype tables.
111
+ - `toSVG()` writes an SVG preview with model Y pointing up.
package/README.md CHANGED
@@ -1,14 +1,18 @@
1
1
  # tsdraft
2
2
 
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.
3
+ Write 2D CAD drawings in TypeScript and export them as DXF, the exchange format
4
+ that AutoCAD, BricsCAD and most other CAD programs open, or as SVG for a quick
5
+ preview. A drawing is a program, so dimensions can come from variables, drawings
6
+ can share code, and changes show up as diffs.
8
7
 
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.
8
+ ## Status
9
+
10
+ tsdraft is at an early stage. It draws layers, lines, polylines, rectangles,
11
+ circles, arcs and single-line text. It does not yet have dimensions, hatches,
12
+ blocks, linetypes, lineweights or paper space layouts. It writes DXF, not DWG.
13
+
14
+ The DXF output is tested by opening it in AutoCAD 2025 and running `AUDIT`. It
15
+ has not been tested in BricsCAD or any other CAD program yet.
12
16
 
13
17
  ## Install
14
18
 
@@ -42,10 +46,159 @@ Coordinates are in drawing units with Y pointing up, as in CAD. Angles are in
42
46
  degrees, counterclockwise from +X. Layer colors are AutoCAD Color Index numbers.
43
47
  Entities go on layer `0` unless they are drawn inside a `layer()` callback.
44
48
 
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
+ Numbers are written with 6 decimals, in DXF and in SVG. The checks below judge
50
+ a drawing as it will be written, so a size too small to survive the rounding is
51
+ an error and a line that rounds down to a point is a warning. Every number must
52
+ also be under 1e100 in size. AutoCAD reads only two exponent digits from DXF, so
53
+ it would take `1e+100` for `1e+10` and report nothing.
54
+
55
+ ## DXF versions
56
+
57
+ A drawing is written for one DXF version. `createDrawing()` targets R12, the
58
+ oldest version still in common use, which nearly every CAD program can open.
59
+ `createDrawing({ dxf: "R2018" })` targets the version AutoCAD has used since
60
+ 2018, which is the newest one Autodesk has published. `toDXF(drawing)` writes
61
+ the drawing's version, and `toDXF(drawing, { version })` writes another one.
62
+
63
+ | | `"R12"`, the default | `"R2018"` |
64
+ | ------------- | -------------------------------------------------- | ------------------------------------------------------ |
65
+ | Format code | AC1009 | AC1032 |
66
+ | Drawing units | Not stored. Set them in CAD with `UNITS`. | Stored in `$INSUNITS`. |
67
+ | Polylines | `POLYLINE` with `VERTEX` records | `LWPOLYLINE` |
68
+ | Text | ASCII, with `\U+XXXX` escapes for other characters | UTF-8 |
69
+ | Layer names | Up to 31 letters, digits, `$`, `-` and `_` | Up to 255 characters, with the exceptions listed below |
70
+
71
+ Choose R2018 when the drawing will be inserted into another drawing, because CAD
72
+ needs the stored units to scale it correctly. AutoCAD shows layer names from an
73
+ R12 file in upper case.
74
+
75
+ ## Checks
76
+
77
+ AutoCAD discards a whole drawing over one bad layer name, and it reports no
78
+ line of your code when it does. tsdraft checks the rules AutoCAD enforces at
79
+ three points, so the mistake shows up where you made it.
80
+
81
+ 1. **In the editor.** Layer names, colors and sizes written as literals are
82
+ checked by the TypeScript compiler, and the error message says what is wrong.
83
+ 2. **When the code runs.** The builder throws at the call that breaks a rule.
84
+ This covers values the compiler cannot see, such as a name read from a file.
85
+ 3. **Before export.** `lint(drawing)` returns every problem in a drawing, and
86
+ `toDXF` throws a `DrawingError` that lists them instead of writing a file
87
+ AutoCAD would reject.
88
+
89
+ ```ts
90
+ const d = createDrawing({ units: "ft" });
91
+
92
+ d.layer("A WALL");
93
+ // error TS2345: Argument of type '"A WALL"' is not assignable to parameter of
94
+ // type '"R12 layer names can only use letters, digits, $, - and _. Use DXF
95
+ // version \"R2018\" for other characters"'.
96
+
97
+ createDrawing({ dxf: "R2018" }).layer("A WALL"); // fine
98
+
99
+ lint(drawing, { version: "R12" }); // check a drawing against another version
100
+ ```
101
+
102
+ The rules:
103
+
104
+ - Layer names cannot be empty, start or end with a space, or contain a line
105
+ break or any of ``< > / \ " : ; ? * | = ` ^ ,``. R12 names have the tighter
106
+ limits in the table above.
107
+ - Layer names cannot contain the other characters AutoCAD refuses in a name. It
108
+ discards the drawing over any of them, and they are listed under
109
+ [Layer name characters](#layer-name-characters).
110
+ - `wall` and `WALL` are the same layer, as in CAD. The first spelling is kept.
111
+ tsdraft compares names the way AutoCAD does, which is not the way JavaScript
112
+ does: see [Layer names and case](#layer-names-and-case).
113
+ - Layer colors are whole numbers from 1 to 255.
114
+ - Radii, text heights and rectangle sizes are greater than 0, and large enough
115
+ not to be written as 0. Anything from 0.000001 up is safe. AutoCAD gives text
116
+ of height 0 its default height.
117
+ - Text is a single line. It cannot contain emoji or other characters above
118
+ U+FFFF, because AutoCAD cannot read them from DXF, or the private-use
119
+ characters U+E800 to U+E8FF, which AutoCAD replaces.
120
+ - Coordinates, sizes and text rotations are under 1e100 in size, because
121
+ AutoCAD reads only two exponent digits from DXF.
122
+ - Arc angles are stored from 0 up to 360, so -90 becomes 270. An arc cannot come
123
+ within 0.000001 degrees of a full turn, because its two angles would be
124
+ written the same and AutoCAD holds such an arc as having no length. Draw a
125
+ circle instead.
126
+
127
+ `lint` also returns warnings. Warnings do not stop an export. They are:
128
+
129
+ - A line, a polyline or an arc that has no length, or has none once its numbers
130
+ are rounded.
131
+ - Text that contains `%%`. AutoCAD stores the text as written but reads `%%` as
132
+ the start of a control code when it draws it: `%%d` is drawn as a degree sign
133
+ and `%%%` as one percent sign.
134
+
135
+ `toDXF` throws a `DrawingError` for errors only. Its `diagnostics` property holds
136
+ them, and other code may throw one to refuse a drawing for its own reasons.
137
+
138
+ ### Text
139
+
140
+ When AutoCAD opens tsdraft's DXF, it holds each text exactly as the drawing has
141
+ it. tsdraft escapes what DXF would otherwise read as something else: a caret,
142
+ and a backslash that starts `\U+XXXX` or `\M+NXXXX`, which AutoCAD decodes into
143
+ a character. A text `\U+00B0` reaches AutoCAD as those seven characters and not
144
+ as a degree sign. Write the character itself, `°`, to get the character.
145
+
146
+ That lasts until AutoCAD saves the drawing, because AutoCAD does not escape these
147
+ sequences in what it writes. After it saves as DWG and opens the file again, a
148
+ text `\M+18140` holds the character U+3000. After it saves as DXF and opens
149
+ that, `\U+00B0` holds a degree sign as well. tsdraft cannot change this.
150
+
151
+ ### Layer names and case
152
+
153
+ AutoCAD lower-cases a layer name one character at a time, from a table of its
154
+ own that is older and smaller than Unicode's. An entity on `mixedcase`, `café`,
155
+ `αβγ` or `СЛОЙ` goes on a layer declared as `MixedCase`, `CAFÉ`, `ΑΒΓ` or `Слой`,
156
+ and `lint` accepts it. AutoCAD does not pair these, and `lint` reports the
157
+ entity's layer as undefined:
158
+
159
+ - `ΟΔΟΣ` and `οδος`, because of the final sigma `ς`. `οδοσ` does match.
160
+ - `STRASSE` and `straße`, and `TITLE` and `tıtle` with a dotless `ı`.
161
+ - `Ÿ` and `ÿ`, `İ` and `i`, and the title-case digraphs such as `Dž`.
162
+ - Georgian, Cherokee and most other letters that gained a case after Unicode
163
+ 5.1.
164
+
165
+ Declaring both spellings as layers is an error too for some of these pairs,
166
+ among them `ΟΔΟΣ` and `οδος`, `Ÿ` and `ÿ`, and `ß` and `ẞ`. AutoCAD keeps two
167
+ layers, and its `AUDIT` then reports a duplicate name.
168
+
169
+ `AUDIT` compares names more loosely than `lint` can follow in full. It reports
170
+ each of these pairs as a duplicate name while AutoCAD keeps both layers, and
171
+ `lint` does not catch them:
172
+
173
+ - A letter and the two letters it stands for: `ß` and `ss`, `æ` and `ae`, `œ`
174
+ and `oe`, `ij` and `ij`, `fi` and `fi`, `dž` and `dž`, `þ` and `th`.
175
+ - A composed letter such as `é` and its decomposed form.
176
+ - Names that differ only by a soft hyphen.
177
+
178
+ ### Layer name characters
179
+
180
+ AutoCAD refuses these characters in a layer name, so `lint` does too. Over any
181
+ of them but the last row's, AutoCAD discards the whole drawing. All are outside
182
+ ASCII, so they only concern R2018 drawings.
183
+
184
+ | Characters | What they are |
185
+ | -------------------------------------------------------------------------- | ---------------------------------------------------------- |
186
+ | U+0080 to U+009F | C1 control characters |
187
+ | U+0600 to U+0603, U+06DD, U+070F, U+17B4, U+17B5 | Arabic, Syriac and Khmer formatting characters |
188
+ | U+200B to U+200F, U+202A to U+202E, U+2060 to U+2064, U+206A to U+206F | Zero-width spaces and joiners, and text direction controls |
189
+ | U+FEFF, U+FFF9 to U+FFFB | The byte order mark and annotation characters |
190
+ | U+2071, U+207F, U+2090 to U+2094 | Superscript and subscript letters |
191
+ | Letters in U+2102 to U+214E, such as `ℓ`, `ℕ`, the ohm sign and the kelvin | Letterlike symbols. Use the Greek `Ω`, U+03A9, for ohms. |
192
+ | U+2180 to U+2188 | Roman numeral characters |
193
+ | U+2C00 to U+2C5E, except U+2C2F | Glagolitic |
194
+ | U+2C60 to U+2C7D, except U+2C70 | Latin Extended-C |
195
+ | U+2C80 to U+2CE4 | Coptic |
196
+ | U+2D00 to U+2D25 | Georgian Supplement |
197
+ | U+2D30 to U+2D65, U+2D6F | Tifinagh |
198
+ | Letters in U+2D80 to U+2DDE | Ethiopic Extended |
199
+ | U+E800 to U+E8FF | Private use. AutoCAD replaces these and keeps the drawing. |
200
+
201
+ `src/layers.ts` has the exact list.
49
202
 
50
203
  ## Development
51
204
 
@@ -55,9 +208,17 @@ directly, so tests and examples need no build step.
55
208
  ```sh
56
209
  pnpm install
57
210
  pnpm verify # format check, lint, type check, tests, package smoke test
58
- pnpm example # writes examples/out/site-plan.dxf and .svg
211
+ pnpm example # writes a site plan to examples/out/ as DXF and SVG
59
212
  ```
60
213
 
214
+ `pnpm test:cad` opens the output in AutoCAD. It needs
215
+ `TSDRAFT_CAD_SSH=user@host`, an ssh login to a WSL shell on a Windows machine
216
+ that has AutoCAD. Each test drawing runs in AutoCAD's headless console, which
217
+ runs `AUDIT` and saves the drawing back as DXF. The script then compares that
218
+ file with what was drawn. Run it after any change to a DXF writer, because
219
+ AutoCAD rejects files that DXF parsing libraries accept. Some of its cases write
220
+ a file `toDXF` would refuse, to show that AutoCAD does what the rule says.
221
+
61
222
  `pnpm test:package` packs the package, installs the tarball into a throwaway
62
223
  project and checks that it type-checks and runs there. `pnpm build` compiles
63
224
  `src/` to `dist/`, and `npm pack` and `npm publish` run it automatically.
@@ -68,6 +229,16 @@ as `pnpm check:tsrs` and as an informational CI step. It tracks TypeScript 7.1
68
229
  beta, so it does not gate releases yet. Its WebAssembly build is the planned
69
230
  route for type-checking drawings in a browser playground.
70
231
 
232
+ ## Releasing
233
+
234
+ Bump `version` in `package.json` and add a CHANGELOG entry in a pull request.
235
+ After it merges, publish a GitHub release tagged `v<version>` on the merge
236
+ commit. The Release workflow checks that the tag matches the version, runs
237
+ `pnpm verify` and publishes to npm through
238
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm token
239
+ is stored anywhere. npm adds a provenance attestation that links each version to
240
+ the commit it was built from.
241
+
71
242
  ## License
72
243
 
73
244
  [MIT](LICENSE)
package/dist/drawing.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type LayerColor, type LayerName, type Positive } from "./rules.ts";
1
2
  /** A model-space point, in drawing units. Y points up, as in CAD. */
2
3
  export type Point = readonly [x: number, y: number];
3
4
  /**
@@ -5,6 +6,15 @@ export type Point = readonly [x: number, y: number];
5
6
  * the DXF R12 writer has no field for it, so set units in CAD after opening.
6
7
  */
7
8
  export type Units = "in" | "ft" | "mm" | "cm" | "m";
9
+ /**
10
+ * DXF versions tsdraft writes.
11
+ *
12
+ * - `"R12"` (AC1009) opens in nearly every CAD program, but stores no units and
13
+ * limits layer names to 31 letters, digits, `$`, `-` and `_`.
14
+ * - `"R2018"` (AC1032) is the newest published version and the one AutoCAD has
15
+ * used since 2018. It records the drawing's units.
16
+ */
17
+ export type DxfVersion = "R12" | "R2018";
8
18
  export interface Layer {
9
19
  readonly name: string;
10
20
  /** AutoCAD Color Index, 1 to 255. 7 is white on dark backgrounds, black on light. */
@@ -28,7 +38,7 @@ export interface Circle extends EntityBase {
28
38
  readonly center: Point;
29
39
  readonly radius: number;
30
40
  }
31
- /** Angles are in degrees, counterclockwise from +X, as in DXF. */
41
+ /** Angles are in degrees from 0 up to 360, counterclockwise from +X, as in DXF. */
32
42
  export interface Arc extends EntityBase {
33
43
  readonly kind: "arc";
34
44
  readonly center: Point;
@@ -48,39 +58,63 @@ export interface Text extends EntityBase {
48
58
  export type Entity = Line | Polyline | Circle | Arc | Text;
49
59
  export interface Drawing {
50
60
  readonly units: Units;
61
+ /** The DXF version the drawing is written for. `toDXF` uses it unless told otherwise. */
62
+ readonly dxf: DxfVersion;
51
63
  readonly layers: readonly Layer[];
52
64
  readonly entities: readonly Entity[];
53
65
  }
54
- export interface DrawingOptions {
66
+ export interface DrawingOptions<V extends DxfVersion = DxfVersion> {
67
+ /** Defaults to `"in"`. */
55
68
  readonly units?: Units;
69
+ /** The DXF version to check the drawing against and export. Defaults to `"R12"`. */
70
+ readonly dxf?: V;
56
71
  }
57
- export interface LayerOptions {
58
- readonly color?: number;
72
+ export interface LayerOptions<C extends number = number> {
73
+ /** AutoCAD Color Index, 1 to 255. */
74
+ readonly color?: LayerColor<C>;
59
75
  }
60
- export interface TextOptions {
61
- readonly height?: number;
76
+ export interface TextOptions<H extends number = number> {
77
+ readonly height?: Positive<H>;
78
+ /** Degrees counterclockwise. */
62
79
  readonly rotation?: number;
63
80
  }
64
81
  /**
65
82
  * Builds a drawing. Entities go on the current layer, which starts as "0".
66
83
  * `layer(name, opts, draw)` scopes everything drawn inside `draw` to that layer.
84
+ *
85
+ * Mistakes written as literals, such as a layer name the DXF version does not
86
+ * allow or a negative radius, are compile errors. The same checks run again
87
+ * when the code runs, for values the compiler cannot see.
67
88
  */
68
- export declare class DrawingBuilder {
89
+ export declare class DrawingBuilder<V extends DxfVersion = "R12"> {
69
90
  #private;
70
91
  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;
92
+ readonly dxf: V;
93
+ constructor(options?: DrawingOptions<V>);
94
+ /**
95
+ * Defines or updates a layer. With `draw`, entities drawn inside it go on this layer.
96
+ * `wall` and `WALL` are the same layer, as in CAD, and keep the first spelling.
97
+ */
98
+ layer<N extends string, C extends number = number>(name: LayerName<N, V>, options?: LayerOptions<C>, draw?: () => void): this;
74
99
  line(from: Point, to: Point): this;
75
100
  polyline(points: readonly Point[], closed?: boolean): this;
76
101
  /** 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;
102
+ rect<W extends number, H extends number>(corner: Point, width: Positive<W>, height: Positive<H>): this;
103
+ circle<R extends number>(center: Point, radius: Positive<R>): this;
104
+ /**
105
+ * Arc from `startAngle` counterclockwise to `endAngle`. Angles are stored
106
+ * from 0 up to 360, so -90 becomes 270, because AutoCAD's AUDIT rejects
107
+ * angles outside that range.
108
+ *
109
+ * Angles are written with 6 decimals. An arc within 0.000001 degrees of a
110
+ * full turn would be written with equal angles, which AutoCAD holds as an
111
+ * arc of no length, so it throws: draw a circle instead.
112
+ */
113
+ arc<R extends number>(center: Point, radius: Positive<R>, startAngle: number, endAngle: number): this;
114
+ text<H extends number = number>(at: Point, value: string, options?: TextOptions<H>): this;
81
115
  build(): Drawing;
82
116
  }
83
- export declare function createDrawing(options?: DrawingOptions): DrawingBuilder;
117
+ export declare function createDrawing<V extends DxfVersion = "R12">(options?: DrawingOptions<V>): DrawingBuilder<V>;
84
118
  /** Counterclockwise sweep from start to end, in degrees, within [0, 360). */
85
119
  export declare function sweep(startAngle: number, endAngle: number): number;
86
120
  export {};
package/dist/drawing.js CHANGED
@@ -1,28 +1,63 @@
1
+ import { fmt, written } from "./format.js";
2
+ import { layerAuditKey, layerKey } from "./layers.js";
3
+ import { arcProblem, layerColorProblem, magnitudeProblem, layerNameProblem, layerTwinProblem, sizeProblem, textProblem, } from "./rules.js";
4
+ const UNITS = ["in", "ft", "mm", "cm", "m"];
5
+ const DXF_VERSIONS = ["R12", "R2018"];
1
6
  /**
2
7
  * Builds a drawing. Entities go on the current layer, which starts as "0".
3
8
  * `layer(name, opts, draw)` scopes everything drawn inside `draw` to that layer.
9
+ *
10
+ * Mistakes written as literals, such as a layer name the DXF version does not
11
+ * allow or a negative radius, are compile errors. The same checks run again
12
+ * when the code runs, for values the compiler cannot see.
4
13
  */
5
14
  export class DrawingBuilder {
6
15
  units;
16
+ dxf;
17
+ /** Keyed by `layerKey`, because AutoCAD ignores case in layer names. */
7
18
  #layers = new Map([["0", { name: "0", color: 7 }]]);
19
+ /** The first layer name for each `layerAuditKey`, to find names AUDIT would call duplicates. */
20
+ #audited = new Map([["0", "0"]]);
8
21
  #entities = [];
9
22
  #current = "0";
10
23
  constructor(options = {}) {
11
- this.units = options.units ?? "in";
24
+ const units = options.units ?? "in";
25
+ if (!UNITS.includes(units))
26
+ throw new Error(`Unknown units: ${JSON.stringify(units)}`);
27
+ const dxf = options.dxf ?? "R12";
28
+ if (!DXF_VERSIONS.includes(dxf)) {
29
+ throw new Error(`Unsupported DXF version: ${JSON.stringify(dxf)}`);
30
+ }
31
+ this.units = units;
32
+ this.dxf = dxf;
12
33
  }
13
- /** Defines or updates a layer. With `draw`, entities drawn inside it go on this layer. */
34
+ /**
35
+ * Defines or updates a layer. With `draw`, entities drawn inside it go on this layer.
36
+ * `wall` and `WALL` are the same layer, as in CAD, and keep the first spelling.
37
+ */
14
38
  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 });
39
+ // The parameter type is the error message when a literal name is wrong, so read it as a string.
40
+ const requested = name;
41
+ const nameProblem = layerNameProblem(requested, this.dxf);
42
+ if (nameProblem)
43
+ throw new Error(`Layer ${JSON.stringify(requested)}: ${nameProblem}`);
44
+ const key = layerKey(requested);
45
+ const existing = this.#layers.get(key);
46
+ const auditKey = layerAuditKey(requested);
47
+ const twinProblem = layerTwinProblem(requested, this.#audited.get(auditKey) ?? requested);
48
+ if (twinProblem)
49
+ throw new Error(`Layer ${JSON.stringify(requested)}: ${twinProblem}`);
50
+ const color = options.color ?? existing?.color ?? 7;
51
+ const colorProblem = layerColorProblem(color);
52
+ if (colorProblem)
53
+ throw new Error(`Layer ${JSON.stringify(requested)}: ${colorProblem}`);
54
+ const layer = { name: existing?.name ?? requested, color };
55
+ this.#layers.set(key, layer);
56
+ if (!this.#audited.has(auditKey))
57
+ this.#audited.set(auditKey, layer.name);
23
58
  if (draw) {
24
59
  const previous = this.#current;
25
- this.#current = name;
60
+ this.#current = layer.name;
26
61
  try {
27
62
  draw();
28
63
  }
@@ -43,13 +78,18 @@ export class DrawingBuilder {
43
78
  /** Closed rectangle from its lower-left corner. */
44
79
  rect(corner, width, height) {
45
80
  const [x, y] = pt(corner);
46
- num(width, "width");
47
- num(height, "height");
81
+ const w = positive(width, "width");
82
+ const h = positive(height, "height");
83
+ // A side can still vanish in the file: a corner and the one across from it may round together.
84
+ if (written(x + w) === written(x))
85
+ throw new Error(lostSide("width", "x", x));
86
+ if (written(y + h) === written(y))
87
+ throw new Error(lostSide("height", "y", y));
48
88
  return this.polyline([
49
89
  [x, y],
50
- [x + width, y],
51
- [x + width, y + height],
52
- [x, y + height],
90
+ [x + w, y],
91
+ [x + w, y + h],
92
+ [x, y + h],
53
93
  ], true);
54
94
  }
55
95
  circle(center, radius) {
@@ -60,34 +100,52 @@ export class DrawingBuilder {
60
100
  radius: positive(radius, "radius"),
61
101
  });
62
102
  }
103
+ /**
104
+ * Arc from `startAngle` counterclockwise to `endAngle`. Angles are stored
105
+ * from 0 up to 360, so -90 becomes 270, because AutoCAD's AUDIT rejects
106
+ * angles outside that range.
107
+ *
108
+ * Angles are written with 6 decimals. An arc within 0.000001 degrees of a
109
+ * full turn would be written with equal angles, which AutoCAD holds as an
110
+ * arc of no length, so it throws: draw a circle instead.
111
+ */
63
112
  arc(center, radius, startAngle, endAngle) {
64
- num(startAngle, "startAngle");
65
- num(endAngle, "endAngle");
66
- if (sweep(startAngle, endAngle) === 0)
113
+ const start = turn(num(startAngle, "startAngle"));
114
+ const end = turn(num(endAngle, "endAngle"));
115
+ if (start === end)
67
116
  throw new Error("An arc's start and end angles must differ");
117
+ const problem = arcProblem(start, end);
118
+ if (problem)
119
+ throw new Error(problem);
68
120
  return this.#add({
69
121
  kind: "arc",
70
122
  layer: this.#current,
71
123
  center: pt(center),
72
124
  radius: positive(radius, "radius"),
73
- startAngle,
74
- endAngle,
125
+ startAngle: start,
126
+ endAngle: end,
75
127
  });
76
128
  }
77
129
  text(at, value, options = {}) {
78
- if (/[\r\n]/.test(value))
79
- throw new Error("Text must be a single line");
130
+ const problem = textProblem(value);
131
+ if (problem)
132
+ throw new Error(problem);
80
133
  return this.#add({
81
134
  kind: "text",
82
135
  layer: this.#current,
83
136
  at: pt(at),
84
137
  value,
85
138
  height: positive(options.height ?? 0.125, "height"),
86
- rotation: num(options.rotation ?? 0, "rotation"),
139
+ rotation: kept(options.rotation ?? 0, "rotation"),
87
140
  });
88
141
  }
89
142
  build() {
90
- return { units: this.units, layers: [...this.#layers.values()], entities: [...this.#entities] };
143
+ return {
144
+ units: this.units,
145
+ dxf: this.dxf,
146
+ layers: [...this.#layers.values()],
147
+ entities: [...this.#entities],
148
+ };
91
149
  }
92
150
  #add(entity) {
93
151
  this.#entities.push(entity);
@@ -99,18 +157,39 @@ export function createDrawing(options) {
99
157
  }
100
158
  /** Counterclockwise sweep from start to end, in degrees, within [0, 360). */
101
159
  export function sweep(startAngle, endAngle) {
102
- return (((endAngle - startAngle) % 360) + 360) % 360;
160
+ return turn(endAngle - startAngle);
161
+ }
162
+ /** Brings an angle in degrees into [0, 360). */
163
+ function turn(degrees) {
164
+ const wrapped = ((degrees % 360) + 360) % 360;
165
+ // A tiny negative angle rounds up to exactly 360.
166
+ return wrapped === 360 ? 0 : wrapped;
103
167
  }
104
168
  function num(value, label) {
105
169
  if (!Number.isFinite(value))
106
170
  throw new Error(`${label} must be a finite number`);
107
171
  return value;
108
172
  }
173
+ /** Takes `unknown` because a `Positive<N>` parameter is a message string when the literal is wrong. */
109
174
  function positive(value, label) {
110
- if (num(value, label) <= 0)
111
- throw new Error(`${label} must be greater than 0`);
175
+ if (typeof value !== "number" || !Number.isFinite(value)) {
176
+ throw new Error(`${label} must be a finite number`);
177
+ }
178
+ const problem = sizeProblem(value);
179
+ if (problem)
180
+ throw new Error(`${label}: ${problem}`);
181
+ return value;
182
+ }
183
+ function lostSide(size, axis, at) {
184
+ return `${size}: This value is too small at ${axis} = ${at}. Both sides of the rectangle are written as ${axis} = ${fmt(at)}, because tsdraft writes 6 decimals`;
185
+ }
186
+ /** A number the drawing keeps and the writers print, which AutoCAD must be able to read. */
187
+ function kept(value, label) {
188
+ const problem = magnitudeProblem(num(value, label));
189
+ if (problem)
190
+ throw new Error(`${label}: ${problem}`);
112
191
  return value;
113
192
  }
114
193
  function pt(point) {
115
- return [num(point[0], "x"), num(point[1], "y")];
194
+ return [kept(point[0], "x"), kept(point[1], "y")];
116
195
  }
@@ -0,0 +1,11 @@
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
+ * R12 predates Unicode DXF, so text outside ASCII is written as `\U+XXXX`.
9
+ * Layer `0` is written first, wherever it is in `drawing.layers`.
10
+ */
11
+ export declare function toDXFR12(drawing: Drawing): string;