diff --git a/GUIDE.md b/GUIDE.md
index 577560b..477fa44 100644
--- a/GUIDE.md
+++ b/GUIDE.md
@@ -236,6 +236,36 @@ Space lines one and a half text heights apart or more. Given `at`, `align`, `val
is the box the `text-` warnings and their `bounds` use. A character outside Latin-1 is measured
as a `?`, and the result then has `approximate: true`.
+## Drawing to a standard
+
+A standard is a firm's layers, pens, text roles, scales, dimension styles and title blocks as
+data. Extend the built-in one with what your drawing needs, and export it beside the drawing:
+
+```ts
+import { BUILT_IN_STANDARD, createDrawing, defineStandard } from "tsdraft";
+
+export const standard = defineStandard({
+ id: "my-firm",
+ version: "1",
+ extends: BUILT_IN_STANDARD,
+ layers: { "A-WALL": { color: 2, pen: "bold" } },
+});
+
+const d = createDrawing({ units: "ft", standard });
+d.layer("A-WALL", () => d.rect([24, 30], 52, 44));
+export default d.build();
+```
+
+`d.layer(name, draw)` names a layer of the standard, which gives its color, linetype, pen and
+plot flag, and an entity's `pen` names one of the standard's pens. A layer, role or scale the
+standard lacks is a compile error, and the builder throws the same message, which says how to add
+it to `defineStandard`. Add it there; never draw around the standard.
+
+The command holds the drawing to the exported standard with `lint`: its errors are diagnostics
+and give exit code 2, and `drawing.standard` names it.
+[docs/standard.md](https://github.com/Surfside-Software/tsdraft/blob/main/docs/standard.md) has
+the built-in standard and the rest.
+
## The file to copy
[`examples/site-plan.ts`](https://github.com/Surfside-Software/tsdraft/blob/main/examples/site-plan.ts)
@@ -251,20 +281,17 @@ Its parameters come first and every position is worked out from them. Its column
breaks are laid out with `measureText`. Its symbols are blocks.
It is drawn to a standard of its own, which extends `BUILT_IN_STANDARD` with the layer
-`V-TREE`. `d.layer(name, draw)` names a layer of the standard, which gives its color, linetype,
-pen and plot flag, and an entity's `pen` names one of the standard's pens. A layer the standard
-lacks is a compile error that says how to add it to `layers` in `defineStandard`.
-
-A text names a `role` of the standard, which gives its text style and the height it plots at.
-The plan is a view at one of the standard's scales, `const plan = d.view("plan", { scale:
-"1in-10ft" })`, and what model space draws to be read in it names the view:
-`d.text(at, "LOT 27", { role: "label", view: plan })`, `d.insert("PROPERTY CORNER", at,
-{ view: plan })` for a symbol drawn in inches, and `d.linearDimension(from, to, line, { view:
-plan })` in the standard's dimension style. The viewport shows the view, as
-`sheet.viewport(corner, width, height, { view: plan, center })`, and `plan.paper(inches)`
-turns a gap on paper into drawing units. On the sheet and in a block, a text with a role is at
-the role's height and names no view. Under a standard every text names a role: a `height`, a
-`style` and `textStyle()` are refused. `sitePlan("1in-20ft")` draws the sheet at 1" = 20'.
+`V-TREE`, as the section above shows. A text names a `role` of the standard, which gives its text
+style and the height it plots at. The plan is a view at one of the standard's scales,
+`const plan = d.view("plan", { scale: "1in-10ft" })`, and what model space draws to be read in it
+names the view: `d.text(at, "LOT 27", { role: "label", view: plan })`,
+`d.insert("PROPERTY CORNER", at, { view: plan })` for a symbol drawn in inches, and
+`d.linearDimension(from, to, line, { view: plan })` in the standard's dimension style. The
+viewport shows the view, as `sheet.viewport(corner, width, height, { view: plan, center })`, and
+`plan.paper(inches)` turns a gap on paper into drawing units. On the sheet and in a block, a text
+with a role is at the role's height and names no view. Under a standard every text names a
+role: a `height`, a `style` and `textStyle()` are refused. `sitePlan("1in-20ft")` draws the
+sheet at 1" = 20'.
Its layout is on a sheet format of the standard, `d.layout("C-101", { sheetFormat: "arch-d" },
(sheet) => …)`, which gives the paper, the title block and the zones the sheet is laid out by:
diff --git a/README.md b/README.md
index 0dfd9f5..42d6640 100644
--- a/README.md
+++ b/README.md
@@ -304,6 +304,9 @@ lint(drawing, { version: "R12" }); // check a drawing against another version
text plots with `measureText`, the warnings of `inspectText` about text that
overlaps or leaves its border, what tsdraft escapes in text, and what AutoCAD
does with it after a save.
+- [Standards](docs/standard.md) covers a firm's standard: the document,
+ `defineStandard`, the built-in standard, drawing to a standard, what `lint`
+ holds a drawing to, and the `standard` a drawing file exports.
- [Checks of intent](docs/intent.md) covers `checkIntent`, which warns of a
footprint that crosses a setback, and the `intent` a drawing file exports.
- [The `tsdraft` command](docs/command.md) covers `render`, `dev`, the JSON they
diff --git a/cli/AGENTS.md b/cli/AGENTS.md
index 30a9920..ca84fd5 100644
--- a/cli/AGENTS.md
+++ b/cli/AGENTS.md
@@ -48,6 +48,16 @@
to the exit code, and each warning's own `bounds`. When the copy of tsdraft the drawing
loaded has no `checkIntent`, or its `checkIntent` throws, the build gets one
`intent-unchecked` warning that says so, so an intent is never passed over without a word.
+- When the drawing file exports `standard`, the worker passes it to the library's `lint`, and
+ `drawing.standard` names it. A copy of tsdraft without `defineStandard` is from before
+ standards and would take the export and check nothing, so the worker does not pass it there,
+ and the build gets one `standard-unchecked` warning after `lint`'s: a standard is never passed
+ over without a word. A title block of the export whose border is an xref gives the pictures its
+ `preview` for that xref, unless the `xrefs` export gives a drawing for it: the file's own
+ drawing comes first. A preview comes only from a standard `lint` checked and accepted, since
+ its check refuses a getter at any depth. For `drawing.standard` the worker reads the
+ export's own data alone, never runs a getter and never asks a proxy at its top: one that
+ loops would end the build.
- `render --view` draws one rectangle with `toSVG`'s `view`, with no margin, as
`outputs.view`, and writes no other picture. `tsdraft dev` has no view: its server keeps
only SVG strings and a worker ends after one build. A `Builder` passes the view to the
@@ -70,7 +80,10 @@
That is the folder two above `src/worker.ts` or `dist/worker.js`, by its real path: its
`dist/index.js`, or `src/index.ts` in a checkout, and files in that one folder. No name in
a package.json decides it. `src/warmup.ts` runs that library on a sheet of its own and
- reads nothing. Any other copy of tsdraft, such as a second one in a project's
+ reads nothing. It also calls `defineStandard` once, on a standard that extends the built-in
+ one, and draws, lints and pictures a sheet to it, so that a build's own `defineStandard` and
+ its `lint` with a standard find their code compiled. A build waits for the step in progress,
+ so keep each step short. Any other copy of tsdraft, such as a second one in a project's
`node_modules`, is never loaded or run ahead: it may print, count its calls or loop, and a
build must answer the same whether its process waited or not. Nor is the drawing, a module
of the user's or a kit that passes the library on. For those the build runs in a process
diff --git a/cli/assets/page.html b/cli/assets/page.html
index 5e2a396..183c8bd 100644
--- a/cli/assets/page.html
+++ b/cli/assets/page.html
@@ -102,9 +102,14 @@
font-weight: 600;
letter-spacing: -0.01em;
}
- header .file {
+ header .file,
+ header .standard {
color: var(--muted);
}
+ header .standard b {
+ color: var(--text);
+ font-weight: 500;
+ }
#status {
margin-left: auto;
display: flex;
@@ -411,6 +416,7 @@
tsdraft
+
connecting
@@ -703,6 +709,22 @@
Diagnostics
return missing ? `${xrefs.length}, ${missing} not drawn` : `${xrefs.length}`;
}
+ // The standard the drawing file exports, beside the file's name.
+ function renderStandard(state) {
+ const standard = state?.drawing?.standard;
+ const line = $("standard");
+ line.hidden = !standard;
+ line.replaceChildren();
+ if (!standard) return;
+ const id = document.createElement("b");
+ id.textContent = standard.id;
+ line.replaceChildren(
+ "standard ",
+ id,
+ ` version ${standard.version}${standard.checked ? "" : " · not checked"}`,
+ );
+ }
+
function renderFacts(state) {
const d = state.drawing;
const fmt = (n) => (Math.round(n * 100) / 100).toString();
@@ -870,6 +892,7 @@
if (lastGood) {
renderLayers(lastGood);
renderFacts(lastGood);
+ renderStandard(lastGood);
} else $("empty").textContent = "No drawing yet";
}
renderDiagnostics(state);
@@ -920,6 +944,7 @@
Diagnostics
view = null;
moved = false;
lastGood = null;
+ renderStandard(null);
swatches = {};
for (const id of ["facts", "layers", "diagnostics"]) $(id).replaceChildren();
$("error").hidden = true;
diff --git a/cli/src/state.ts b/cli/src/state.ts
index c3d9aa8..d3f366a 100644
--- a/cli/src/state.ts
+++ b/cli/src/state.ts
@@ -53,7 +53,8 @@ export interface BuildState {
/** What the drawing holds. `null` when the build failed. */
readonly drawing: DrawingSummary | null;
/**
- * What `lint` reports for the drawing, errors first, and after it what `inspectText` warns of
+ * What `lint` reports for the drawing, errors first, held to the file's export `standard` when
+ * it has one, and after it what `inspectText` warns of
* in the text as it plots: a rule that starts with `text-`. Then, when the file has an export
* `intent`, what `checkIntent` warns of where the drawing does not hold to it. When the file
* threw a `DrawingError`, as `toDXF` and the builder do, its diagnostics, without `kind` and
@@ -179,9 +180,17 @@ export interface PngFont {
* and the footprint nearest each other, larger on every side by the setback, and for
* `footprint-off-property` the footprint's box. `footprint-in-setback` also gives `side`,
* `required` and `provided`.
+ *
+ * `standard-unchecked` is a warning of the command's own, among lint's: the file exports a
+ * standard, and the copy of tsdraft the drawing loaded is from before standards, so `lint` held
+ * the drawing to none of it.
*/
export interface BuildDiagnostic extends Omit {
- readonly rule: Diagnostic["rule"] | TextWarning["rule"] | IntentWarning["rule"];
+ readonly rule:
+ | Diagnostic["rule"]
+ | TextWarning["rule"]
+ | IntentWarning["rule"]
+ | "standard-unchecked";
readonly kind?: Entity["kind"];
/** The box around the entity, in drawing units, worked out as `extents` is. */
readonly bounds?: Box;
@@ -217,6 +226,8 @@ export interface Timings {
export interface DrawingSummary {
readonly units: Units;
readonly dxf: DxfVersion;
+ /** The standard the drawing file exports as `standard`, or `null` when it exports none. */
+ readonly standard: StandardSummary | null;
/**
* The smallest box around the entities on layers that are on, thawed and set to plot, in
* drawing units, as `toSVG` works it out. A build that draws the layers that do not plot
@@ -261,6 +272,24 @@ export interface DrawingSummary {
readonly layouts: readonly LayoutSummary[];
}
+/**
+ * A firm's standard, as `defineStandard` returns it, that the drawing file exports beside its
+ * drawing with `export { standard }`. An export that does not hold a string `id`, `version` and
+ * `hash` is no standard: `drawing.standard` is then `null`, and when `lint` was given it, its
+ * `standard` error says what is wrong with it.
+ */
+export interface StandardSummary {
+ readonly id: string;
+ readonly version: string;
+ /** The SHA-256 of what the standard states, as the export holds it. */
+ readonly hash: string;
+ /**
+ * Whether `lint` held the drawing to the standard. False when the copy of tsdraft the drawing
+ * loaded is from before standards, which a `standard-unchecked` warning says.
+ */
+ readonly checked: boolean;
+}
+
/** A layout: a sheet of paper, with what is drawn on it and the viewports that show the model. */
export interface LayoutSummary {
readonly name: string;
@@ -344,13 +373,16 @@ export interface XrefSummary {
/**
* Whether the pictures draw a drawing for the xref. The command opens no DWG file: it draws
* the drawing that the drawing file's `xrefs` export gives under the xref's name, when that
- * drawing has no lint error. It cannot tell whether that drawing is what the file at `path`
- * holds, so `true` says what the pictures drew and not what CAD will load. When this is
- * false, the pictures and the extents have nothing of the xref, and CAD will still draw it.
+ * drawing has no lint error. When it gives none, and a title block of the file's export
+ * `standard` has the xref as its border and a `preview`, it draws the preview. It cannot tell
+ * whether that drawing is what the file at `path` holds, so `true` says what the pictures
+ * drew and not what CAD will load. When this is false, the pictures and the extents have
+ * nothing of the xref, and CAD will still draw it.
*/
readonly shown: boolean;
/**
- * The units of the drawing given for the xref, or `null` when none is given. CAD draws an
+ * The units of the drawing given for the xref, or of the preview drawn for it, or `null` when
+ * there is neither. CAD draws an
* insert at the scale it has, whatever the units, so where these differ from
* `drawing.units` each insert needs the scale between the two.
*/
diff --git a/cli/src/warmup.ts b/cli/src/warmup.ts
index 54b9be8..7bfad33 100644
--- a/cli/src/warmup.ts
+++ b/cli/src/warmup.ts
@@ -44,28 +44,77 @@ export const formatSteps: readonly (() => void)[] = [
];
/**
- * The steps that run the library: a small sheet through the builder, `lint` and `toSVG`.
+ * The steps that run the library: a small sheet through the builder, `lint` and `toSVG`, then
+ * one call of `defineStandard` that extends the built-in standard, as a drawing file's does,
+ * and a sheet drawn to that standard and held to it.
*/
export function librarySteps(library: Library): readonly (() => void)[] {
let drawing: ReturnType | undefined;
+ let standard: ReturnType | undefined;
+ let drawn: ReturnType | undefined;
+ const sheets = (d: ReturnType | undefined) => {
+ for (const layout of d?.layouts ?? []) library.toSVG?.(d!, { layout: layout.name });
+ };
return [
() => (drawing = sheet(library)),
() => drawing && library.lint?.(drawing),
() => drawing && library.toSVG?.(drawing, { margin: 0 }),
- () => {
- for (const layout of drawing?.layouts ?? [])
- library.toSVG?.(drawing!, { layout: layout.name });
- },
+ () => sheets(drawing),
+ () => (standard = extended(library)),
+ () => standard && (drawn = sheetToStandard(library, standard)),
+ () => drawn && library.lint?.(drawn, { standard: standard! }),
+ () => sheets(drawn),
// Once more, since much of the code is compiled better on its second run.
() => {
const again = sheet(library);
library.lint?.(again);
library.toSVG?.(again, { margin: 0 });
- for (const layout of again.layouts ?? []) library.toSVG?.(again, { layout: layout.name });
+ sheets(again);
+ },
+ () => {
+ if (!standard) return;
+ const again = sheetToStandard(library, standard);
+ library.lint?.(again, { standard });
+ sheets(again);
},
];
}
+/** A standard that extends the built-in one with a layer. A copy from before standards has none. */
+function extended(library: Library) {
+ return library.defineStandard?.({
+ id: "warm-up",
+ version: "1",
+ extends: library.BUILT_IN_STANDARD!,
+ layers: { "V-TREE": { color: 3, pen: "fine", discipline: "V" } },
+ });
+}
+
+/** A sheet drawn to a standard: layers, roles, a view, a dimension, a sheet format and its title block. */
+function sheetToStandard(library: Library, standard: NonNullable>) {
+ const d = library.createDrawing!({
+ units: "ft",
+ standard,
+ fields: { project: "A HOUSE", date: "2026-01-01", drawnBy: "AB", checkedBy: "CD" },
+ });
+ const plan = d.view("plan", { scale: "1in-10ft" });
+ d.block("TREE", {}, () => d.circle([0, 0], 1));
+ d.layer("C-PROP", () => d.rect([0, 0], 80, 120));
+ d.layer("V-TREE", () => d.insert("TREE", [12, 100], { scale: 6 }));
+ d.layer("C-ANNO-TEXT", () => {
+ d.text([40, 60], "RESIDENCE", { role: "label", view: plan, align: "center" });
+ });
+ d.layer("C-ANNO-DIMS", () => d.linearDimension([0, 0], [80, 0], [0, -10], { view: plan }));
+ d.layout("C-101", { sheetFormat: "arch-d" }, (paperSheet) => {
+ paperSheet.titleBlock({ title: "SITE PLAN" });
+ d.layer("G-ANNO-NPLT", () => {
+ paperSheet.viewport([1, 2.5], 26, 20.5, { view: plan, center: [40, 60] });
+ });
+ d.layer("C-ANNO-TEXT", () => d.text([29, 22.5], "GENERAL NOTES", { role: "heading" }));
+ });
+ return d.build();
+}
+
function sheet(library: Library) {
const d = library.createDrawing!({ units: "ft", linetypeScale: 1 / 2 });
const ratio = 120;
diff --git a/cli/src/worker.ts b/cli/src/worker.ts
index 2338940..9a41848 100644
--- a/cli/src/worker.ts
+++ b/cli/src/worker.ts
@@ -13,6 +13,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
import { createRequire, isBuiltin, registerHooks } from "node:module";
import { dirname, isAbsolute, join, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
+import { types } from "node:util";
import { Worker } from "node:worker_threads";
import type {
AlignedDimension,
@@ -44,6 +45,7 @@ import type {
InsertSummary,
LayerSummary,
LayoutSummary,
+ StandardSummary,
XrefSummary,
} from "./state.ts";
import { LEFT_OUT, without } from "./omit.ts";
@@ -142,7 +144,9 @@ export interface WorkerResult {
}
interface Library {
- readonly lint: (drawing: Drawing) => Diagnostic[];
+ readonly lint: (drawing: Drawing, options?: { standard: unknown }) => Diagnostic[];
+ /** A copy of tsdraft from before standards has none, and its `lint` takes no standard. */
+ readonly defineStandard?: unknown;
readonly toSVG: (
drawing: Drawing,
options?: {
@@ -521,11 +525,13 @@ async function build({ file, root, drawNoPlot, view }: BuildRequest): Promise>;
let intent: unknown;
+ let standard: unknown;
try {
const module = (await import(pathToFileURL(file).href)) as {
default?: unknown;
xrefs?: unknown;
intent?: unknown;
+ standard?: unknown;
};
let value = module.default;
if (typeof value === "function") value = await (value as () => unknown)();
@@ -534,6 +540,7 @@ async function build({ file, root, drawNoPlot, view }: BuildRequest): Promise>;
intent = module.intent;
+ standard = module.standard;
} catch (thrown) {
timings.load = now() - t;
return fail(describe(thrown, root), thrown);
@@ -543,25 +550,46 @@ async function build({ file, root, drawNoPlot, view }: BuildRequest): Promise d.rule === "standard" && d.severity === "error");
+ const previews = checked && !refused ? titleBlockPreviews(standard) : [];
const size = (list: unknown): number => (Array.isArray(list) ? list.length : 0);
const errorsFirst = (a: Diagnostic, b: Diagnostic) =>
Number(a.severity === "warning") - Number(b.severity === "warning");
/** The build's answer, with `shown` drawn in place of the drawing when items are left out. */
const report = (shown: Drawing, omitted: readonly string[]): WorkerResult => {
- const given = givenDrawings(library, shown, referenced);
+ const given = givenDrawings(library, shown, referenced, previews);
const drawn = Object.fromEntries(
[...given.values()].filter((g) => g.errors.length === 0).map((g) => [g.name, g.drawing]),
);
const linted = [
...found,
- ...[...given].flatMap(([xref, g]) => g.errors.map((d) => inGiven(xref, d))),
+ ...[...given].flatMap(([xref, g]) => g.errors.map((d) => inGiven(xref, g, d))),
]
.sort(errorsFirst)
.map((d) => aboutEntity(library, shown, drawn, d));
timings.lint ??= now() - t;
t = now();
- const diagnostics = [...linted, ...textWarnings(library, shown, drawn, drawNoPlot === true)];
+ const diagnostics = [
+ ...linted,
+ ...unchecked,
+ ...textWarnings(library, shown, drawn, drawNoPlot === true),
+ ];
timings.inspect = now() - t;
if (intent !== undefined) {
t = now();
@@ -615,6 +643,7 @@ async function build({ file, root, drawNoPlot, view }: BuildRequest): Promise>,
+ previews: readonly (readonly [name: string, preview: Drawing])[],
): Map {
const given = new Map();
- for (const [name, other] of Object.entries(referenced)) {
+ const offered = [
+ ...Object.entries(referenced).map(([name, other]) => [name, other, false] as const),
+ ...previews.map(([name, preview]) => [name, preview, true] as const),
+ ];
+ for (const [name, other, preview] of offered) {
const xref = xrefNamed(library, drawing, name);
if (!xref || given.has(xref)) continue;
const errors = library.lint(other).filter((d) => d.severity === "error");
- given.set(xref, { name, drawing: other, errors });
+ given.set(xref, { name, drawing: other, preview, errors });
}
return given;
}
+/**
+ * The field `key` of `value` when it is `value`'s own data. A getter is never run and a proxy
+ * never asked, since the export `standard` may be anything.
+ */
+function ownData(value: unknown, key: string): unknown {
+ if (typeof value !== "object" || value === null || types.isProxy(value)) return undefined;
+ const field = Object.getOwnPropertyDescriptor(value, key);
+ return field !== undefined && "value" in field ? field.value : undefined;
+}
+
+/**
+ * Each title block of a standard whose border is an xref and that has a `preview`, as the
+ * xref's name and the preview. Only own data is read, and anything else gives nothing.
+ */
+function titleBlockPreviews(standard: unknown): [name: string, preview: Drawing][] {
+ try {
+ const blocks = ownData(standard, "titleBlocks");
+ if (typeof blocks !== "object" || blocks === null) return [];
+ return Object.keys(blocks).flatMap((key) => {
+ const block = ownData(blocks, key);
+ const name = ownData(ownData(block, "xref"), "name");
+ const preview = ownData(block, "preview");
+ return typeof name === "string" &&
+ Array.isArray(ownData(preview, "layers")) &&
+ Array.isArray(ownData(preview, "entities")) &&
+ typeof ownData(preview, "units") === "string"
+ ? [[name, preview as Drawing]]
+ : [];
+ });
+ } catch {
+ return [];
+ }
+}
+
+/** The id, version and hash of the file's export `standard`, when it holds all three. */
+function standardSummary(standard: unknown, checked: boolean): StandardSummary | null {
+ try {
+ const [id, version, hash] = ["id", "version", "hash"].map((key) => ownData(standard, key));
+ return typeof id === "string" && typeof version === "string" && typeof hash === "string"
+ ? { id, version, hash, checked }
+ : null;
+ } catch {
+ return null;
+ }
+}
+
/**
* A problem in the drawing given for an xref, as a problem of the xref. It keeps the rule, the
* id and the call, and leaves out the entity's index, which counts another drawing's entities.
*/
-function inGiven(xref: Xref, found: Diagnostic): Diagnostic {
+function inGiven(xref: Xref, given: Given, found: Diagnostic): Diagnostic {
+ const what = given.preview ? "its title block's preview" : "the drawing given for it";
return {
rule: found.rule,
severity: found.severity,
- message: `Xref ${JSON.stringify(xref.name)}: the drawing given for it has an error, so it is not drawn. ${found.message}`,
+ message: `Xref ${JSON.stringify(xref.name)}: ${what} has an error, so it is not drawn. ${found.message}`,
xref: xref.name,
...(found.id === undefined ? {} : { id: found.id }),
...(found.call === undefined ? {} : { call: found.call }),
diff --git a/cli/test/render.test.ts b/cli/test/render.test.ts
index b7dc068..f7e035b 100644
--- a/cli/test/render.test.ts
+++ b/cli/test/render.test.ts
@@ -16,6 +16,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { after, test } from "node:test";
+import { BUILT_IN_STANDARD } from "tsdraft";
import type { BuildState } from "../src/state.ts";
const root = fileURLToPath(new URL("..", import.meta.url));
@@ -1051,6 +1052,205 @@ ${checkIntent}`,
]);
});
+test("lint holds the drawing to the file's export `standard`, whose errors exit 2", () =>
+ inScratch(async (dir) => {
+ const file = join(dir, "wall.ts");
+ const write = (exported: string) =>
+ writeFileSync(
+ file,
+ `import { BUILT_IN_STANDARD as standard, createDrawing } from "tsdraft";
+const d = createDrawing({ units: "ft" });
+d.layer("A-WALL", { color: 2 }, () => d.rect([0, 0], 10, 10));
+export default d.build();
+${exported}\n`,
+ );
+ write("export { standard };");
+ const held = render(file, "--out", join(dir, "out"));
+ assert.equal(held.status, 2);
+ const { id, version, hash } = BUILT_IN_STANDARD;
+ assert.deepEqual(held.state.drawing?.standard, { id, version, hash, checked: true });
+ assert.ok(held.state.diagnostics.some((d) => d.rule === "layer-outside-standard"));
+ write("");
+ const alone = render(file, "--out", join(dir, "out"));
+ assert.deepEqual(
+ [alone.status, alone.state.drawing?.standard, alone.state.diagnostics],
+ [0, null, []],
+ );
+
+ const example = render(join(root, "../examples/site-plan.ts"), "--out", join(dir, "out"));
+ assert.equal(example.status, 0);
+ assert.deepEqual(example.state.diagnostics, []);
+ assert.deepEqual(
+ [example.state.drawing?.standard?.id, example.state.drawing?.standard?.checked],
+ ["site-plan-example", true],
+ );
+ }));
+
+test("a standard that the drawing's copy of tsdraft cannot check is warned of, never passed over", () => {
+ const dir = realpathSync(tempDir());
+ writeFileSync(join(dir, "package.json"), '{ "type": "module" }\n');
+ const library = join(dir, "node_modules/tsdraft");
+ mkdirSync(library, { recursive: true });
+ writeFileSync(
+ join(library, "package.json"),
+ '{ "name": "tsdraft", "type": "module", "exports": "./index.js" }\n',
+ );
+ // A copy from before standards: its lint would take the export and check nothing of it.
+ writeFileSync(
+ join(library, "index.js"),
+ `export const drawing = { format: 0, units: "ft", dxf: "R2018", layers: [], entities: [] };
+export const createDrawing = () => {};
+export const toDXF = () => "";
+export const lint = (d, options) =>
+ options?.standard ? [{ rule: "layer", severity: "error", message: "given a standard" }] : [];
+export const toSVG = () => '';\n`,
+ );
+ writeFileSync(
+ join(dir, "plan.ts"),
+ `import { drawing } from "tsdraft";
+export default drawing;
+export const standard = { id: "acme", version: "2026.1", hash: "0d1f" };\n`,
+ );
+ const { state, status } = render(join(dir, "plan.ts"));
+ assert.equal(status, 0);
+ assert.deepEqual(state.drawing?.standard, {
+ id: "acme",
+ version: "2026.1",
+ hash: "0d1f",
+ checked: false,
+ });
+ assert.deepEqual(
+ state.diagnostics.map((d) => [d.rule, d.severity, d.message]),
+ [
+ [
+ "standard-unchecked",
+ "warning",
+ "The copy of tsdraft this drawing imports is from before standards and has no defineStandard, so the drawing was not checked against the export `standard`. Update it",
+ ],
+ ],
+ );
+});
+
+test("the pictures draw a title block's preview for its border, unless `xrefs` gives the border", () =>
+ inScratch(async (dir) => {
+ const file = join(dir, "sheet.ts");
+ const write = (exported: string) =>
+ writeFileSync(
+ file,
+ `import { BUILT_IN_STANDARD, createDrawing, defineStandard } from "tsdraft";
+const drawn = (layer: string) => {
+ const b = createDrawing({ units: "in" });
+ b.layer(layer, { color: 1 }, () => b.rect([0.5, 0.5], 35, 23));
+ return b.build();
+};
+export const standard = defineStandard({
+ id: "firm",
+ version: "1",
+ extends: BUILT_IN_STANDARD,
+ defaultSheetFormat: "firm",
+ titleBlocks: {
+ firm: {
+ paper: "ARCH D",
+ xref: { name: "BORDER", path: "border.dwg" },
+ preview: drawn("PREVIEW"),
+ layer: "G-ANNO-TTLB",
+ drawingArea: [0.5, 0.5, 35.5, 23.5],
+ fields: {},
+ },
+ },
+ sheetFormats: { firm: { paper: "ARCH D", titleBlock: "firm" } },
+});
+const d = createDrawing({ units: "ft", standard });
+d.layout("A-101", (sheet) => sheet.titleBlock());
+export default d.build();
+${exported}\n`,
+ );
+ const sheet = (exported: string) => {
+ write(exported);
+ const { state, status } = render(file, "--out", join(dir, "out"));
+ assert.equal(status, 0);
+ const layers = readFileSync(state.outputs.layouts[0]!.svg, "utf8").matchAll(
+ /data-layer="([^"]*)"/g,
+ );
+ return { xrefs: state.drawing?.xrefs, layers: new Set([...layers].map((m) => m[1])) };
+ };
+ const preview = sheet("");
+ assert.deepEqual(preview.xrefs, [
+ { name: "BORDER", path: "border.dwg", inserts: 1, shown: true, units: "in" },
+ ]);
+ assert.ok(preview.layers.has("BORDER|PREVIEW"));
+ // The file's own drawing for the xref, by any case of its name, is drawn in its place.
+ const given = sheet('export const xrefs = { border: drawn("GIVEN") };');
+ assert.ok(given.layers.has("BORDER|GIVEN") && !given.layers.has("BORDER|PREVIEW"));
+ }));
+
+test("a getter in the export `standard` never runs: lint's error stands, with exit 2", () =>
+ inScratch(async (dir) => {
+ const file = join(dir, "wall.ts");
+ const { id, version, hash } = BUILT_IN_STANDARD;
+ for (const [field, body] of [
+ ["titleBlocks", 'throw new Error("read")'],
+ ["titleBlocks", "for (;;);"],
+ ["id", 'throw new Error("read")'],
+ ["hash", "for (;;);"],
+ ] as const) {
+ writeFileSync(
+ file,
+ `import { BUILT_IN_STANDARD, createDrawing } from "tsdraft";
+export default createDrawing({ units: "ft" }).build();
+export const standard = { ...BUILT_IN_STANDARD };
+Object.defineProperty(standard, "${field}", { get() { ${body} }, enumerable: true });\n`,
+ );
+ // A getter that ran would loop until the timeout, a failed build with exit 1.
+ const { state, status } = render(file, "--out", join(dir, "out"), "--timeout", "5000");
+ assert.deepEqual(
+ [status, state.drawing?.standard, state.diagnostics.map((d) => [d.rule, d.severity])],
+ [
+ 2,
+ field === "titleBlocks" ? { id, version, hash, checked: true } : null,
+ [["standard", "error"]],
+ ],
+ `${field}: ${body}`,
+ );
+ }
+ }));
+
+test("a refused standard gives no preview, and a proxy is never asked: exit 2, not a timeout", () =>
+ inScratch(async (dir) => {
+ const file = join(dir, "wall.ts");
+ const { id, version, hash } = BUILT_IN_STANDARD;
+ const looping = "{ for (;;); }";
+ for (const [name, standard, summary] of [
+ [
+ "a getter in a preview",
+ `{ ...BUILT_IN_STANDARD, titleBlocks: { firm: { xref: { name: "BORDER", path: "border.dwg" },
+ preview: { ...createDrawing({ units: "in" }).build(), entities: [Object.defineProperty({}, "kind", { get() ${looping}, enumerable: true })] } } } }`,
+ { id, version, hash, checked: true },
+ ],
+ [
+ "a proxy",
+ `new Proxy({ ...BUILT_IN_STANDARD }, { getPrototypeOf() { return Date.prototype; }, getOwnPropertyDescriptor() ${looping} })`,
+ null,
+ ],
+ ] as const) {
+ writeFileSync(
+ file,
+ `import { BUILT_IN_STANDARD, createDrawing } from "tsdraft";
+const d = createDrawing({ units: "ft" });
+d.xref("BORDER", "border.dwg");
+export default d.build();
+export const standard = ${standard};\n`,
+ );
+ const { state, status } = render(file, "--out", join(dir, "out"), "--timeout", "5000");
+ assert.equal(status, 2, name);
+ assert.deepEqual(state.drawing?.standard, summary, name);
+ assert.ok(
+ state.diagnostics.some((d) => d.rule === "standard" && d.severity === "error"),
+ name,
+ );
+ }
+ }));
+
test("render --view draws one rectangle of model space or of a layout, and nothing else", () => {
const out = tempDir();
const crowded = join(root, "test/fixtures/crowded.ts");
diff --git a/cli/test/warm.test.ts b/cli/test/warm.test.ts
index cb1535c..ad5ec00 100644
--- a/cli/test/warm.test.ts
+++ b/cli/test/warm.test.ts
@@ -17,6 +17,7 @@ import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { Builder, type Outcome } from "../src/build.ts";
+import { librarySteps } from "../src/warmup.ts";
const root = join(fileURLToPath(new URL("..", import.meta.url)), ".");
const built = join(root, "../dist");
@@ -127,6 +128,13 @@ async function ownCommand(dir: string, toSVG?: string) {
return { Builder: copy.Builder, file, mark };
}
+// A warm-up swallows a step's error, so a step broken by a change to the library would only make
+// builds slower.
+test("each step of the library's warm-up runs, and its sheets lint clean", async () => {
+ const results = librarySteps(await import("tsdraft")).map((step) => step());
+ assert.deepEqual(results.filter(Array.isArray), [[], []]);
+});
+
test("a build after another reads the drawing's files alone, and answers as the first did", async () => {
const [cold, warm] = await builds(join(root, "../examples/site-plan.ts"), 2);
assert.equal(cold!.ok, true);
diff --git a/docs/command.md b/docs/command.md
index 0513c95..81b7288 100644
--- a/docs/command.md
+++ b/docs/command.md
@@ -33,7 +33,9 @@ export default d.build();
```
A file may also export `intent`, what the drawing is meant to hold to, such as the setbacks a
-footprint keeps out of, which each build checks: see [Checks of intent](intent.md).
+footprint keeps out of, which each build checks: see [Checks of intent](intent.md). And it may
+export `standard`, the firm's standard the drawing is drawn to, which each build holds it to:
+see [Standards](standard.md#the-command).
Each build runs the file in a new child process, so a crash cannot take the command down and
every build sees the code as it is now. A build that runs longer than `--timeout`, 10 s by default, fails.
@@ -41,6 +43,7 @@ every build sees the code as it is now. A build that runs longer than `--timeout
## Commands
`tsdraft render [--out ] [--width ] [--draw-no-plot]` builds once, runs `lint`,
+with the file's export `standard` when it has one,
`inspectText` and, for a file that exports `intent`, `checkIntent`, writes `.svg` and a `.png` 1600 px wide to `out/` beside the
file, and prints one JSON object. With `--view`, it writes one rectangle of the drawing and
no other picture: see [A closer look](#a-closer-look). It exits 0 when the drawing built with no lint errors, 2 when `lint` reported
@@ -126,7 +129,9 @@ now 3, goes up when one changes its meaning or goes away. A failed build, trimme
```
When it builds, `drawing` gives the units, the extents, counts of layers, entities, blocks,
-xrefs, layouts, errors and warnings, and each layer. `drawing.blocks` gives each block's name, how many entities
+xrefs, layouts, errors and warnings, and each layer. `drawing.standard` gives the `id`,
+`version` and `hash` of the standard the file exports, and `checked`, which is true when `lint`
+held the drawing to it, or is `null` when the file exports none. `drawing.blocks` gives each block's name, how many entities
it holds, the tags of its attributes and how many inserts place it. `drawing.inserts` gives each
insert in model space: its `entity` index, `block`, `layer`, `id`, `at`, `scale`, `rotation` and
the value it shows for each attribute, and `xref`, which is true for an insert of an xref.
@@ -140,8 +145,10 @@ space, in blocks and on layouts, `shown` and `units`. The command opens no DWG f
the drawing file has a named export `xrefs`, an object that gives a `Drawing` under the xref's
name, such as `export const xrefs = { BASE: base }`. `shown` says that the pictures drew that
drawing. The command cannot tell whether it is what the file at `path` holds, so `shown` is
-about the pictures and not about what CAD will load. `units` is the units of that drawing, or
-`null` when none is given: where they differ from the drawing's own, each insert needs the
+about the pictures and not about what CAD will load. When `xrefs` gives no drawing for an xref
+that is the border of a title block of the file's `standard`, and the command checked the
+standard and found no error, the pictures draw the title block's `preview` in its place, and `shown` is true. `units` is the units of the drawing drawn,
+or `null` when there is none: where they differ from the drawing's own, each insert needs the
scale between the two. The command runs `lint` on each drawing it is given. One with an error
is not drawn, its `shown` is false, and each error is in `diagnostics` with the `xref` it
belongs to, counted in `counts.errors`. An xref that is not shown is left out of the pictures
@@ -173,6 +180,13 @@ the entity's `id` when it has one, and `call`, the call in the drawing's code th
entity: an absolute path, a line and a column, which is where to fix it. The command turns on
the builder's `recordCalls` for every drawing it runs.
+For a file that exports `standard`, what `lint` returned holds the rules of the standard too,
+which [Rules](rules.md#a-drawings-standard) lists: an error there gives exit code 2 as any
+other. After them comes `standard-unchecked`, a warning of the command's own, when the copy of
+tsdraft the drawing imports is from before standards and has no `defineStandard`: the drawing
+was not held to the export, and `drawing.standard.checked` is false. It never changes the exit
+code.
+
After what `lint` returned come the warnings of `inspectText`, about text as it plots: a rule
of `text-overlaps-text`, `text-leaves-boundary` or `text-off-paper`, which
[Text](text.md#text-as-it-plots) describes. They never change the exit code, and
@@ -374,7 +388,9 @@ painting it. It prints where the time of a build went, and the machine's load av
Each build runs in a process of its own, so that no build can change the next. While it waits
for a save, `tsdraft dev` keeps two such processes ready. When your drawing imports the
tsdraft that the command itself is part of, which is the usual case, the first has loaded that
-library and run it once on a small drawing of its own. It has read nothing of your drawing.
+library and run it once on a small drawing of its own, and once on a small drawing to a standard
+of its own, which it made with `defineStandard` from the built-in one. It has read nothing of
+your drawing.
When you save, the command checks that every file of the library is on disk as that process
loaded it. If so the process builds, and only your drawing file and what it imports are read
and run. If not, the build runs in the second process, which has started and loaded nothing,
diff --git a/docs/standard.md b/docs/standard.md
new file mode 100644
index 0000000..b20eed9
--- /dev/null
+++ b/docs/standard.md
@@ -0,0 +1,331 @@
+# Standards
+
+A standard is a firm's CAD standard as data: its layers, pens, linetypes, text roles, scales,
+dimension styles, title blocks and sheet formats. A drawing drawn to a standard names its
+entries, and takes every property from them: a layer its color, linetype, pen and plot flag, a
+text its style and plotted height, a dimension its style, a layout its paper and title block.
+The builder refuses what the standard lacks, `lint` holds a drawing to it, and the `tsdraft`
+command checks a drawing file against the standard it exports.
+
+AutoCAD opens a drawing that breaks its standard, and `toDXF` writes one: the rules of a
+standard are a firm's, not AutoCAD's. The rules that are AutoCAD's, such as a layer name it
+refuses, hold in a standard too.
+
+## A standard of your own
+
+Start from the built-in standard, and add what a drawing needs that it lacks:
+
+```ts
+import { BUILT_IN_STANDARD, createDrawing, defineStandard } from "tsdraft";
+
+export const standard = defineStandard({
+ id: "acme",
+ version: "2026.1",
+ extends: BUILT_IN_STANDARD,
+ disciplines: { A: { title: "Architectural" } },
+ layers: {
+ "A-WALL": { color: 2, pen: "bold", discipline: "A" },
+ "C-ROAD-CNTR": null,
+ },
+ textRoles: { note: { font: "romans.shx", height: 0.1 } },
+});
+
+const d = createDrawing({ units: "ft", standard });
+d.layer("A-WALL", () => d.rect([24, 30], 52, 44));
+
+export default d.build();
+```
+
+The child's entries are added to its parent's, an entry of the same name replaces the parent's
+whole, and `null` removes one. Export the standard beside the drawing, so that
+[the command](#the-command) holds the drawing to it.
+[`examples/site-plan.ts`](../examples/site-plan.ts) is a whole sheet drawn this way, to a
+standard that adds the layer `V-TREE` to the built-in one.
+
+## `defineStandard`
+
+`defineStandard(document, options?)` checks a standard's document, resolves it against its
+parent and returns a `Standard`: plain, frozen data, with every default filled in, no
+`$schema`, `extends` or `null`, each title block's drawings as `Drawing` objects, its `hash`,
+and `chain`, the id, version and hash of each parent from the nearest.
+
+- A problem in the document throws a `DrawingError` whose diagnostics have the rule `standard`
+ and an `item`, the path of the problem, such as `layers["A-WALL"].pen`. Its message lists the
+ first 10.
+- The document is a TypeScript literal, or the value `JSON.parse` gives for a standard kept as
+ JSON. A literal mistake is a compile error with the runtime check's message: a layer name
+ AutoCAD refuses, a color outside 1 to 255, a lineweight AutoCAD does not have, a font not in
+ `FONTS`, a paper not in `PAPERS`, and a discipline, pen, linetype, role, dimension style, title
+ block or sheet format that the standard and its parent do not have.
+- The compiler compares names exactly, and the runtime check as AutoCAD compares them: `"c-prop"`
+ is a compile error for a standard that spells it `C-PROP`, and the runtime takes it. The
+ `Standard` spells every name as its entry does.
+- In TypeScript, `extends` is a `Standard`. A string `extends`, and a title block's `block` or
+ `preview` given as a string, are looked up in `options.files`, by the string exactly as the
+ document writes it: a `Standard` for `extends`, and the value `JSON.parse` gives for a drawing.
+ `"tsdraft/standard.json"` and `"tsdraft/title-blocks/plain-arch-d.json"` need no entry. The
+ library reads no file.
+- `hash` is the SHA-256 of what the standard states for a check. Its `id`, `version` and
+ `title`, a discipline's `title`, and the `description` of a pen, a layer, a text role and a
+ dimension style are not part of it. A linetype's `description` is, since it is what AutoCAD's
+ `LINETYPE` command shows for the linetype. A `Standard` with `hash` and `chain` left out is a
+ document with no `extends` that resolves to the same standard, with the same hash, so a
+ standard written in TypeScript can be kept as JSON.
+- A `Standard` made by another copy of tsdraft is read again as data, and refused unless its
+ hash is its content's, by `defineStandard`'s `extends`, by `createDrawing` and by `lint`.
+
+The package ships `standard.schema.json`, a JSON Schema for the document before it is
+resolved, and `standard.json`, the built-in standard resolved, which `"tsdraft/standard.json"`
+names. Give a standard kept as JSON a `$schema` of
+`https://cdn.jsdelivr.net/npm/tsdraft@/standard.schema.json`, and an editor completes
+and checks it. What the schema cannot say, such as a name that refers to another entry, two names
+AutoCAD takes for one, or a box inside another, `defineStandard` checks.
+
+## The document
+
+A standard is one JSON object. Every length on paper is in its `paperUnits`. A lineweight is one
+of AutoCAD's millimetre values, in a standard in inches too. A ratio is a plain number, such as
+`120` for 1" = 10'.
+
+| Field | What it holds | Default |
+| ----------------------- | ------------------------------------------------------------------------------ | -------------------------- |
+| `$schema` | The JSON Schema of the document | none |
+| `id` | 1 to 128 letters, digits, `.`, `-`, `_`, `@` and `/`, so a package name is one | required |
+| `version` | 1 to 64 characters | required |
+| `title` | For people | the `id` |
+| `extends` | The parent | none |
+| `paperUnits` | `"in"` or `"mm"`. A child keeps its parent's | required without `extends` |
+| `minTextHeight` | The least height a text may plot at, in paper units | required without `extends` |
+| `linetypeScale` | A dash plots its pattern's length times this, at every scale | `1` |
+| `plotStyle` | The plot style table of each layout, one of `PLOT_STYLE_TABLES` | `"monochrome.ctb"` |
+| `sheetFiles` | `"one-drawing"` or `"sheet-per-file"` | `"one-drawing"` |
+| `defaultSheetFormat` | The sheet format of a layout that names none | none |
+| `defaultDimensionStyle` | The dimension style of a dimension that names none | none |
+| `disciplines` and on | The sections below, each an object of entries by name | `{}` |
+
+A field the document does not define is an error, at every level. Each object of the document
+may hold `$comment`, a string for people, which `defineStandard` drops. An object of entries by
+name, such as `layers`, has no `$comment`: a key there is a name.
+
+`id` and `version` contain no control character and nothing above U+FFFF. The ids of
+disciplines, pens, text roles, scales, dimension styles, title blocks, sheet formats and zones
+have 1 to 64 letters, digits, `.`, `-` and `_`, the characters every AutoCAD name takes, since
+an id ends up in one, and two of one section that differ only in case are an error. A layer's or
+a linetype's name follows AutoCAD's rules for a layer name in R2018, and two that AutoCAD's
+`AUDIT` takes for one are an error: AutoCAD would merge their entries. Layer `0` is in no
+standard.
+
+### Sections
+
+**`disciplines`**: `"C": { "title": "Civil" }`. A discipline groups the entries a firm keeps for
+one kind of drawing. An entry with no `discipline` belongs to every one.
+
+**`pens`**: `"heavy": { "lineweight": 0.7 }`. A named lineweight, with an optional
+`description`.
+
+**`linetypes`**: `"DASHED": { "description": "Dashed __ __", "pattern": [0.5, -0.25] }`. A
+pattern is in paper units before `linetypeScale`: AutoCAD sizes a dash on the paper in every
+viewport, so a dash plots `pattern` times `linetypeScale` long at every scale. `Continuous` is in
+every standard and is never listed.
+
+**`layers`**: `"C-PROP": { "color": 7, "linetype": "PHANTOM", "pen": "heavy", "discipline":
+"C" }`. `color` is an AutoCAD Color Index from 1 to 255 and is required. `linetype` names a
+linetype of the standard and defaults to `Continuous`. `pen` names a pen, and without one the
+layer has AutoCAD's `Default` lineweight. `plot` defaults to `true`. A layer's off, frozen and
+locked states are the drawing's to set.
+
+**`textRoles`**: `"note": { "font": "Arial", "height": 0.09375 }`, with `widthFactor` and
+`obliqueAngle` optional. `font` is one of `FONTS`. `height` is the plotted height of a capital,
+at least `minTextHeight`. The role's text style is its id in capitals, `NOTE`, so no role is
+named `standard`: AutoCAD takes `STANDARD` for the text style `Standard` that every drawing has.
+
+**`scales`**: `"1in-10ft": { "label": "1\" = 10'", "ratio": 120 }`. `label` is how the scale
+is written, at most 64 characters, and `ratio` is above 0.
+
+**`dimensionStyles`**: `role` names the text role of a dimension's text, `arrowhead`,
+`arrowSize`, `extensionOffset`, `extensionBeyond` and `textGap` are paper sizes, and `writing`
+says how a length is written in each unit of a drawing, with `precision`, `prefix`, `suffix`,
+`suppressZeros` and `measurementScale`. A drawing in a unit `writing` does not list draws no
+dimension in that style. The AutoCAD style is the id in capitals, `DIMS` on a layout, and with
+a scale's id after it in model space: `DIMS-1in-10ft`. No dimension style is named `standard`,
+and two names among every style at every scale that AutoCAD takes for one are an error.
+
+**`titleBlocks`**: a title block tsdraft draws as a block, or a firm's own drawing attached as an
+xref, with the fields a sheet fills, its `drawingArea` and its `box`. [Title
+blocks](title-blocks.md#the-title-block-of-a-standard) has both forms, the fields and where
+their values come from.
+
+**`sheetFormats`**: `"arch-d": { "paper": "ARCH D", "titleBlock": "plain", "zones": { "views":
+[0.5, 2, 27.5, 23.5] } }`. A sheet format gives a layout its paper, its title block, its plot
+style table when `plotStyle` gives one of its own, and its zones: named boxes inside the title
+block's drawing area that the code lays the sheet out by. A box is `[left, bottom, right, top]`.
+Every paper of `PAPERS` is in inches, so a standard in millimetres has no sheet format.
+
+`layers`, `textRoles` and `dimensionStyles` entries of a standard whose `sheetFiles` is
+`"sheet-per-file"` may hold `byDiscipline`, fields that replace the entry's own in the drawings
+of a discipline. Each discipline's drawing is a file of its own there, attached by xref, and
+AutoCAD keeps each one's layers apart. In one drawing a layer name has one set of properties, so
+`defineStandard` refuses `byDiscipline` under `"one-drawing"`.
+
+### `extends` and `null`
+
+- A standard extends at most one parent: a `Standard` in TypeScript, and in JSON a path from
+ the document's own file or a package specifier.
+- A child's entry replaces the parent's entry of that name whole, under the parent's spelling:
+ a child's `c-prop` replaces `C-PROP` and keeps `C-PROP`. The fields that are not sections
+ replace the parent's, apart from `paperUnits`, which a child keeps.
+- `null` removes the parent's entry of that name. A `null` that removes nothing is an error, and
+ so is an entry that names one removed, such as a layer whose pen a child removed.
+- A child that removes the entry its parent's `defaultSheetFormat` or `defaultDimensionStyle`
+ names sets its own.
+- A cycle of `extends` is an error.
+
+## The built-in standard
+
+`BUILT_IN_STANDARD` has the id `tsdraft` and the version `1`. Its version goes up when what it
+states changes, and the CHANGELOG says so. Its layer names follow the layer name format of the
+National CAD Standard.
+
+| Setting | Value |
+| ----------------------- | ---------------------------------- |
+| `paperUnits` | `in` |
+| `minTextHeight` | 0.0625 |
+| `linetypeScale` | 0.5 |
+| `plotStyle` | `monochrome.ctb` |
+| `defaultSheetFormat` | `arch-d` |
+| `defaultDimensionStyle` | `dims` |
+| `disciplines` | `G` General, `C` Civil, `V` Survey |
+
+| Pen | Lineweight, mm |
+| -------- | -------------- |
+| `heavy` | 0.7 |
+| `bold` | 0.5 |
+| `medium` | 0.35 |
+| `light` | 0.25 |
+| `fine` | 0.18 |
+
+| Layer | Color | Linetype | Pen | Plots |
+| ------------- | ----- | ---------- | -------- | ----- |
+| `C-PROP` | 7 | PHANTOM | `heavy` | yes |
+| `C-PROP-ADJC` | 8 | PHANTOM | `fine` | yes |
+| `C-PROP-MONU` | 7 | Continuous | `light` | yes |
+| `C-SETB` | 1 | DASHED | `light` | yes |
+| `C-BLDG` | 5 | Continuous | `heavy` | yes |
+| `C-PAVE` | 8 | Continuous | `medium` | yes |
+| `C-ROAD-CNTR` | 8 | CENTER | `fine` | yes |
+| `C-ANNO-DIMS` | 8 | Continuous | `fine` | yes |
+| `C-ANNO-TEXT` | 7 | Continuous | `light` | yes |
+| `C-ANNO-SYMB` | 7 | Continuous | `medium` | yes |
+| `C-ANNO-TITL` | 7 | Continuous | `bold` | yes |
+| `G-ANNO-TTLB` | 7 | Continuous | `medium` | yes |
+| `G-ANNO-NPLT` | 8 | Continuous | none | no |
+
+The `C-` layers are of the discipline `C` and the `G-` layers of `G`. The linetypes are the six
+the library exports: `DASHED`, `HIDDEN`, `CENTER`, `PHANTOM`, `DASHDOT` and `DOT`. The text
+roles are `note` and `dimension`, 3/32 in, `label`, 1/8 in, `heading`, 3/16 in, and `title`,
+1/4 in, each in Arial. The scales are `1in-10ft` to `1in-100ft`, labelled `1" = 10'` and so on,
+and `1-8in-1ft` to `3in-1ft`, labelled `1/8" = 1'-0"` and so on. The dimension style `dims`
+writes feet to a tenth with `'`, inches whole with `"`, and millimetres whole. The title block
+`plain` and the sheet format `arch-d`, on ARCH D, are in [Title
+blocks](title-blocks.md#the-title-block-of-a-standard).
+
+## Drawing to a standard
+
+`createDrawing({ standard })` draws to a standard. The drawing is R2018, its linetype scale is
+the standard's, and `fields` holds the values of title block fields the drawing's sheets share.
+`discipline` names the drawing's discipline, under `"sheet-per-file"`, which an entry with
+`byDiscipline` needs. A drawing without a standard is built as it was before standards.
+
+- **Layers.** `d.layer(name, draw)` names a layer of the standard, and the layer takes the
+ standard's color, linetype, pen and plot flag. `d.layer(name, { off, frozen, locked }, draw)`
+ sets the states, which are the drawing's. A layer enters the drawing when it is first named.
+- **Pens.** An entity's `pen` names a pen of the standard. Its `lineweight` may be `"ByLayer"` or
+ `"ByBlock"` and nothing else.
+- **Text.** `text()` and `attribute()` take `role` in place of `height` and `style`, and every text
+ names one. On a layout and in a block a text plots at its role's height. In model space it names
+ a `view` too, and is drawn at the height that plots at the role's height at the view's scale.
+ `measureText({ value, ...standard.textRoles.label }).length` is how long it plots.
+- **Views.** `const plan = d.view("plan", { scale: "1in-10ft" })` declares a view at a scale of
+ the standard. `plan.paper(length)` turns a length on paper into drawing units at its scale. One
+ viewport shows it, `sheet.viewport(corner, width, height, { view: plan, center })`, and takes
+ its id and ratio.
+- **Inserts.** An insert in model space of a block drawn in paper units, such as a symbol whose
+ texts have roles, names the view, `d.insert(name, at, { view: plan })`, and is scaled to plot
+ at its size. An insert of any other block takes `scale`.
+- **Dimensions.** A dimension names a dimension style of the standard by its id, or is in
+ `defaultDimensionStyle`, and in model space names its view. The builder defines the AutoCAD
+ style on first use, with the standard's sizes, its role's text style and height, the
+ `writing` of the drawing's units, and the view's scale.
+- **Layouts.** `d.layout(name, { sheetFormat }, draw)` is on a sheet format of the standard, and
+ `d.layout(name, draw)` on `defaultSheetFormat`. `sheet.zone(name)` gives a zone and
+ `sheet.titleBlock(fields)` places the title block and fills its fields: see [Title
+ blocks](title-blocks.md#the-title-block-of-a-standard).
+
+### What the builder refuses
+
+Each is a compile error for a literal, and a `DrawingError` with the call at run time. A message
+about a name the standard lacks says how to add it: `Layer "A-WALL" is not a layer of the
+standard "acme" version 2026.1. Add it to the standard, as layers: { "A-WALL": { color: 7 } } in
+defineStandard, or use one of its layers`.
+
+| Call | Rule |
+| ------------------------------------------------------------------------------------------ | ---------------------------------- |
+| `layer()` with a name the standard lacks, or `layer("0")` | `layer-outside-standard` |
+| `layer()` with `color`, `linetype`, `lineweight` or `plot` | `layer-differs-from-standard` |
+| `linetype()`, or a linetype the standard lacks | `linetype-outside-standard` |
+| A numeric `lineweight`, or a pen the standard lacks | `pen-outside-standard` |
+| `textStyle()`, a text's `height` or `style`, or a text without a `role` | `text-style-outside-standard` |
+| A role the standard lacks, a text of model space with no view, or one elsewhere with one | `role` |
+| An insert in model space of a block whose texts have roles without `view`, or with `scale` | `role` |
+| `view()` at a scale the standard lacks | `scale-outside-standard` |
+| A viewport with `ratio`, or with a view this drawing did not declare | `viewport` |
+| `dimensionStyle()`, or a dimension style the standard lacks | `dimension-style-outside-standard` |
+| A dimension in a unit its style's `writing` lacks | `dimension-style` |
+| A layout with `paper` or `plotStyle`, or a sheet format the standard lacks | `sheet-format-outside-standard` |
+| A layout whose name AutoCAD's `AUDIT` takes for another layout's | `duplicate-layout` |
+| A title block's block or text style the drawing has with another definition | `duplicate-block` |
+| `createDrawing` with `linetypeScale` or for R12, or a discipline under `"one-drawing"` | `standard` |
+| A second layout, or a sheet without a number, under `"sheet-per-file"` | `sheet-per-file` |
+
+An entity drawn outside any `layer()` is on layer `0`. The builder takes it, since in a block
+that is where it belongs, and `lint` reports one of model space or of a layout.
+
+## `lint` and the standard
+
+`lint(drawing, { standard })` holds a drawing to its standard after AutoCAD's rules, as the
+builder does at each call, so a drawing built by other code or read from JSON is held to it too.
+It takes the standard as plain data from any copy of tsdraft, checks it, and works out its hash
+again: a standard that does not check is one `standard` error. Without `standard` it checks none
+of this. `discipline` names the drawing's discipline, and without it an entry with
+`byDiscipline` is checked in none of its forms, which `discipline-unknown` warns of.
+[Rules](rules.md#a-drawings-standard) lists what it reports.
+
+`toDXF` calls `lint` without a standard, so it writes a drawing that breaks its standard.
+
+## The command
+
+A drawing file exports its standard beside its drawing:
+
+```ts
+export { standard };
+export default d.build();
+```
+
+`tsdraft render` and `tsdraft dev` pass the export to `lint` with the drawing, in the copy of
+tsdraft the drawing imports. A rule of the standard that the drawing breaks is a diagnostic as
+any other: its errors give exit code 2, and its warnings change nothing. A file that exports no
+standard is held to none, even when its drawing was built to one.
+
+- `drawing.standard` in the JSON gives the export's `id`, `version` and `hash`, and `checked`,
+ which is true when `lint` held the drawing to it. It is `null` when the file exports no
+ standard, or exports a value that is not one, which `lint`'s `standard` error describes.
+- `standard-unchecked` is a warning of the command's own: the copy of tsdraft the drawing
+ imports is from before standards, so the drawing was not held to the export. `checked` is then
+ false. Update tsdraft.
+- The page of `tsdraft dev` shows the standard's id and version beside the file's name, and "not
+ checked" when `checked` is false.
+- A title block of the xref form stands for a firm's drawing the command does not open. When the
+ command checked the standard and found no error, and the file's `xrefs` export gives no
+ drawing for its xref, the pictures draw the title block's `preview` in its place, and the
+ xref's `shown` is true. A drawing the `xrefs` export gives,
+ by any case of the xref's name, is drawn instead.
diff --git a/docs/title-blocks.md b/docs/title-blocks.md
index ee26b6c..ad2de6f 100644
--- a/docs/title-blocks.md
+++ b/docs/title-blocks.md
@@ -285,8 +285,9 @@ A title block of the xref form names the firm's drawing by a path from each shee
`d.xref` takes one. Its file holds to the rules of
[What the border's file may hold](#what-the-borders-file-may-hold). Its `preview`, a `Drawing`
that stands for the file, is what the pictures draw in its place: give it to `toSVG` as the
-drawing of the xref, `toSVG(drawing, { layout, xrefs: { BORDER: titleBlock.preview } })`, and to
-the command in the drawing file's `xrefs` export.
+drawing of the xref, `toSVG(drawing, { layout, xrefs: { BORDER: titleBlock.preview } })`. The
+command draws it for a drawing file that exports its standard, when the file's `xrefs` export
+gives no drawing for the xref: see [Standards](standard.md#the-command).
## The preview