diff --git a/AGENTS.md b/AGENTS.md index d9ef137..ef741d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,17 @@ drawings built another way. `toDXF` refuses to write a drawing with lint errors. Test a new rule with `@ts-expect-error` inside `assert.throws`, as `test/rules.test.ts` does, so both halves are proven. +- `src/standard/` is a firm's standard. `shape.ts` is the one table of a document's fields: each + field's type, the rule of its value, its default and whether the hash reads it. + `scripts/standard-files.ts` writes `standard.schema.json` from it, `standard.json` from the + built-in standard and `src/version.ts` from `package.json`, and `test/standard.test.ts` fails + until `pnpm standard:files` has run after a change to any of them. A rule of a document's + literal is a type in `src/standard/literal.ts` with the runtime check's message, as a rule of a + drawing is in `src/rules.ts`. A default, once released, never changes: a change of behavior + comes as a new field or value. The library makes `BUILT_IN_STANDARD` as it loads without the + checks, with `BUILT_IN_HASH` in `src/standard/define.ts`, and a test resolves its document with + them. A change to what it states raises its version, which the CHANGELOG gives, and sets that + hash. - `lint` takes any plain data and never throws, since a `Drawing` can be written by hand. `src/shape.ts` holds the fields of each object and their types, once, for `fromJSON` and for `lint`. `lint` checks an object's shape there before a rule reads it, and skips the rules for diff --git a/package.json b/package.json index 9e982b3..e00df6b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "cli/dist", "cli/assets", "GUIDE.md", - "CHANGELOG.md" + "CHANGELOG.md", + "standard.json", + "standard.schema.json" ], "type": "module", "sideEffects": false, @@ -40,7 +42,9 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./standard.json": "./standard.json", + "./standard.schema.json": "./standard.schema.json" }, "scripts": { "clean": "node -e \"for (const dir of ['dist', 'cli/dist']) require('node:fs').rmSync(dir, { recursive: true, force: true })\"", @@ -57,6 +61,7 @@ "example": "node examples/site-plan.ts && node examples/outdoor-kitchen.ts", "readme:drawing": "node scripts/readme-drawing.ts", "bench": "pnpm build:lib && node cli/scripts/bench.ts", + "standard:files": "node scripts/standard-files.ts", "verify": "pnpm fmt:check && pnpm lint && pnpm check && pnpm test && pnpm test:package" }, "devDependencies": { diff --git a/scripts/library-alone.ts b/scripts/library-alone.ts index e4797d7..3a37cbd 100644 --- a/scripts/library-alone.ts +++ b/scripts/library-alone.ts @@ -13,11 +13,14 @@ // --disallow-code-generation-from-strings. import assert from "node:assert/strict"; import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, posix } from "node:path"; import vm from "node:vm"; const dist = process.argv[2]!; -const names = readdirSync(dist).filter((name) => name.endsWith(".js")); +// Every built file, in its folders, by its path from `dist` with `/` between folders. +const names = readdirSync(dist, { recursive: true, encoding: "utf8" }) + .map((name) => name.split("\\").join("/")) + .filter((name) => name.endsWith(".js")); assert.ok(names.includes("index.js"), `${dist} has no index.js`); // The context is a browser's, not Node's: what the language itself defines, and the two globals @@ -58,7 +61,8 @@ for (const name of names) { } const link = (specifier: string, from: vm.Module): vm.Module => { - const found = modules.get(specifier); + const path = `./${posix.join(posix.dirname(from.identifier), specifier)}`; + const found = specifier.startsWith(".") ? modules.get(path) : undefined; assert.ok(found, `${from.identifier} imports ${specifier}, which is not a file of the library`); return found; }; @@ -69,7 +73,7 @@ for (const module of modules.values()) { } const use = new vm.SourceTextModule( - `import { createDrawing, DASHED, fromJSON, lint, toDXF, toJSON, toSVG } from "./index.js"; + `import { BUILT_IN_STANDARD, createDrawing, DASHED, defineStandard, fromJSON, lint, toDXF, toJSON, toSVG } from "./index.js"; const d = createDrawing({ units: "ft" }); d.layer("A-WALL", { color: 4, linetype: DASHED, lineweight: 0.5 }, () => { d.rect([0, 0], 10, 5, { id: "room" }); @@ -80,11 +84,12 @@ const use = new vm.SourceTextModule( }); const drawing = d.build(); const again = fromJSON(toJSON(drawing)); - export default [lint(drawing).length, toSVG(again).slice(0, 4), toDXF(again).slice(-5), again.entities.length];`, + const standard = defineStandard({ id: "alone", version: "1", extends: BUILT_IN_STANDARD, layers: { "A-WALL": { color: 4, pen: "bold" } } }); + export default [lint(drawing).length, toSVG(again).slice(0, 4), toDXF(again).slice(-5), again.entities.length, standard.chain[0].hash === BUILT_IN_STANDARD.hash];`, { identifier: "use.js", context, importModuleDynamically: refuse }, ); await use.link(link); await use.evaluate(); const result = (use.namespace as { default: unknown[] }).default; -assert.deepEqual([...result], [0, " - !/^(LICENSE|README\.md|GUIDE\.md|CHANGELOG\.md|package\.json|dist\/[\w-]+\.(js|d\.ts)|cli\/dist\/[\w-]+\.js|cli\/assets\/.+)$/.test( + !/^(LICENSE|README\.md|GUIDE\.md|CHANGELOG\.md|package\.json|standard(\.schema)?\.json|dist\/(standard\/)?[\w-]+\.(js|d\.ts)|cli\/dist\/[\w-]+\.js|cli\/assets\/.+)$/.test( f, ), ); - assert.deepEqual(stray, [], "tarball contains only dist/, cli/ and package metadata"); + assert.deepEqual( + stray, + [], + "tarball contains only dist/, cli/, the standard and package metadata", + ); const consumer = join(tmp, "consumer"); const installed = join(consumer, "node_modules", "tsdraft"); @@ -98,7 +105,8 @@ try { writeFileSync( join(consumer, "main.ts"), [ - `import { createDrawing, lint, toDXF, toSVG, type Drawing } from "tsdraft";`, + `import { BUILT_IN_STANDARD, createDrawing, defineStandard, lint, toDXF, toSVG, type Drawing } from "tsdraft";`, + `import builtIn from "tsdraft/standard.json" with { type: "json" };`, `const d: Drawing = createDrawing({ units: "ft" }).layer("A", { color: 1 }, () => {}).rect([0, 0], 10, 5).build();`, `if (lint(d).length > 0) throw new Error("unexpected lint problems");`, `// The published types must still reject a bad literal, and the code must still throw.`, @@ -115,6 +123,15 @@ try { `const lineweight = () => createDrawing().line([0, 0], [1, 1], { lineweight: 0.33 });`, `try { lineweight(); threw = false; } catch { threw = true; }`, `if (!threw) throw new Error("a bad lineweight was accepted");`, + `// A standard's literal mistake is a compile error too, and the JSON the package ships is`, + `// a document that resolves to the built-in standard.`, + `const standard = defineStandard({ id: "acme", version: "1", extends: BUILT_IN_STANDARD, layers: { "A-WALL": { color: 2, pen: "bold" } } });`, + `const wall: { readonly color: number } = standard.layers["A-WALL"];`, + `// @ts-expect-error: the standard has no pen thick.`, + `const thick = () => defineStandard({ id: "acme", version: "1", extends: BUILT_IN_STANDARD, layers: { A: { color: 2, pen: "thick" } } });`, + `try { thick(); threw = false; } catch { threw = true; }`, + `if (!threw || wall.color !== 2) throw new Error("a pen the standard lacks was accepted");`, + `if (defineStandard(builtIn).hash !== BUILT_IN_STANDARD.hash) throw new Error("standard.json is not the built-in standard");`, `const dxf: string = toDXF(d);`, `const svg: string = toSVG(d);`, `if (!dxf.endsWith("EOF\\r\\n") || !svg.startsWith(" + `https://cdn.jsdelivr.net/npm/tsdraft@${version}/standard.schema.json`; + +/** standard.json: the built-in standard as a document, without its hash and chain. */ +export function builtInDocument(version: string): Record { + const { hash: _, chain: __, ...document } = BUILT_IN_STANDARD; + return { $schema: schemaUrl(version), ...document }; +} + +/** src/version.ts. */ +export function versionSource(version: string): string { + return [ + "// Written by scripts/standard-files.ts from package.json. Run `pnpm standard:files` after a", + "// change to the version.", + "", + "/** The version of this copy of tsdraft. */", + `export const VERSION = ${JSON.stringify(version)};`, + "", + ].join("\n"); +} + +if (import.meta.main) { + const version = packageVersion(); + const json = { + "standard.json": builtInDocument(version), + "standard.schema.json": standardSchema(version), + }; + for (const [name, value] of Object.entries(json)) { + writeFileSync(join(root, name), `${JSON.stringify(value, null, 2)}\n`); + } + writeFileSync(join(root, "src", "version.ts"), versionSource(version)); + // The formatter lays the JSON out, so `pnpm fmt:check` passes on the files as written. + execFileSync(join(root, "node_modules", ".bin", "oxfmt"), Object.keys(json), { + cwd: root, + stdio: "inherit", + }); +} diff --git a/src/index.ts b/src/index.ts index 3842d68..a3d108c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,4 +133,28 @@ export type { WidthFactor, XrefPath, } from "./rules.ts"; +export { BUILT_IN_STANDARD, defineStandard, type BuiltInStandard } from "./standard/define.ts"; +export type { + Box, + FieldSource, + SheetFiles, + Standard, + StandardDimensionStyle, + StandardDiscipline, + StandardDocument, + StandardLayer, + StandardLinetype, + StandardLink, + StandardNames, + StandardOptions, + StandardPen, + StandardScale, + StandardSheetFormat, + StandardTextRole, + StandardTitleBlock, + StandardTitleBlockField, + StandardWriting, + StandardXref, + TitleBlockFill, +} from "./standard/document.ts"; export { toSVG, type SvgOptions } from "./svg.ts"; diff --git a/src/json.ts b/src/json.ts index 7a096e7..ef03f0c 100644 --- a/src/json.ts +++ b/src/json.ts @@ -15,6 +15,19 @@ import { isRecord, PLAIN_OBJECT, readDrawing } from "./shape.ts"; * Throws when the drawing holds a value JSON cannot, such as `NaN`. */ export function toJSON(drawing: Drawing): string { + return canonical(drawing, "A drawing"); +} + +/** + * Writes a value as canonical JSON, as `toJSON` writes a drawing. Throws when the value holds + * one JSON cannot, such as `NaN`, naming it as `what` does. `written` may give the canonical + * JSON of an object already worked out, which is then used as it is. + */ +export function canonical( + value: unknown, + what = "A value", + written?: (value: object) => string | undefined, +): string { let out = ""; const write = (value: unknown): void => { switch (typeof value) { @@ -33,6 +46,11 @@ export function toJSON(drawing: Drawing): string { out += "null"; return; } + const known = written?.(value); + if (known !== undefined) { + out += known; + return; + } if (Array.isArray(value)) { out += "["; for (let i = 0; i < value.length; i++) { @@ -55,9 +73,9 @@ export function toJSON(drawing: Drawing): string { } } const shown = typeof value === "number" ? String(value) : typeof value; - throw new Error(`A drawing with a value of ${shown} cannot be written as JSON`); + throw new Error(`${what} with a value of ${shown} cannot be written as JSON`); }; - write(drawing); + write(value); return out; } diff --git a/src/lint.ts b/src/lint.ts index 597ddeb..042404b 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -154,7 +154,8 @@ export type LintRule = | "duplicate-dimension-style" | "undefined-dimension-style" | "dimension" - | "zero-length"; + | "zero-length" + | "standard"; export interface Diagnostic { readonly rule: LintRule; diff --git a/src/rules.ts b/src/rules.ts index 466ceca..bf5cf4b 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -490,6 +490,13 @@ export type Precision

= number extends P ? P : typeof PRECISION; +/** Resolves to `Z` when it is a `suppressZeros`, and to the error message otherwise. */ +export type ZeroSuppressionOf = string extends Z + ? Z + : Z extends (typeof ZERO_SUPPRESSIONS)[number] + ? Z + : typeof ZERO_SUPPRESSION; + /** * Resolves to `S` when it is a suffix a dimension style can have, and to the error message * otherwise: no `\`, `{` or `}`, and no character a text cannot hold. @@ -587,6 +594,13 @@ export type LayoutName = string extends N /** Resolves to `P` when it is one of `PAPERS`, and to the error message otherwise. */ export type PaperOf

= string extends P ? P : P extends Paper ? P : typeof PAPER; +/** Resolves to `P` when it is one of `PLOT_STYLE_TABLES`, and to the error message otherwise. */ +export type PlotStyleOf

= string extends P + ? P + : P extends PlotStyleTable + ? P + : typeof PLOT_STYLE; + /** True for a path in lower case that ends in a dot other than the one after `.dwg`. */ type TrailingDot = I extends `${string}.` ? I extends `${string}.dwg.` @@ -987,17 +1001,48 @@ export function textStyleProblem( const nameError = textStyleNameProblem(name); if (nameError) return nameError; if (isStandardTextStyle(name)) return STANDARD_STYLE; - if (typeof font !== "string" || !fontInfo(font)) return FONT; - if (typeof widthFactor !== "number" || !Number.isFinite(widthFactor)) { + return fontProblem(font) ?? widthFactorProblem(widthFactor) ?? obliqueAngleProblem(obliqueAngle); +} + +/** Returns what is wrong with a font, or undefined when it is one of `FONTS`. Its type is `FontOf`. */ +export function fontProblem(font: unknown): string | undefined { + return typeof font === "string" && fontInfo(font) ? undefined : FONT; +} + +/** Returns what is wrong with a width factor, or undefined. Its type is `WidthFactor`. */ +export function widthFactorProblem(value: unknown): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { return "widthFactor must be a finite number"; } // Judged as written, as every number is. - if (written(widthFactor) < 0.01 || written(widthFactor) > 100) return WIDTH_FACTOR; - if (typeof obliqueAngle !== "number" || !Number.isFinite(obliqueAngle)) { + return written(value) < 0.01 || written(value) > 100 ? WIDTH_FACTOR : undefined; +} + +/** Returns what is wrong with an oblique angle, or undefined. Its type is `ObliqueAngle`. */ +export function obliqueAngleProblem(value: unknown): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { return "obliqueAngle must be a finite number"; } // Judged as written: 85.0000001 is written as 85. - return Math.abs(written(obliqueAngle)) > 85 ? OBLIQUE : undefined; + return Math.abs(written(value)) > 85 ? OBLIQUE : undefined; +} + +/** + * Returns what is wrong with a lineweight in millimetres, or undefined when it is one of + * AutoCAD's. Its type is `LineweightOf`. + */ +export function lineweightValueProblem(value: unknown): string | undefined { + return typeof value === "number" && (LINEWEIGHTS as readonly number[]).includes(value) + ? undefined + : LINEWEIGHT; +} + +/** Returns what is wrong with a scale ratio, or undefined. Its type is `Ratio`. */ +export function ratioProblem(value: unknown): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return "The ratio must be a finite number"; + } + return value > 0 ? magnitudeProblem(value) : RATIO; } /** @@ -1592,28 +1637,67 @@ export function isStandardDimensionStyle(name: string): boolean { * valid: the arrow size, and how far an extension line starts from its point and runs past the * dimension line. */ -function notNegativeProblem(value: unknown, message: string = NOT_NEGATIVE): string | undefined { +export function notNegativeProblem( + value: unknown, + message: string = NOT_NEGATIVE, +): string | undefined { if (typeof value !== "number" || !Number.isFinite(value)) return "must be a finite number"; // Judged as written: a size that is written as 0 is 0. return written(value) < 0 ? message : magnitudeProblem(value); } +/** Returns what is wrong with the gap of a dimension's text, or undefined. Its type is `TextGap`. */ +export function textGapProblem(value: unknown): string | undefined { + return notNegativeProblem(value, TEXT_GAP); +} + /** Returns what is wrong with one of a dimension style's two scales, or undefined. */ function styleScaleProblem(value: unknown, message: string): string | undefined { if (typeof value !== "number" || !Number.isFinite(value)) return "must be a finite number"; return value > 0 ? sizeProblem(value) : message; } +/** Returns what is wrong with a measurement scale, or undefined. Its type is `MeasurementScale`. */ +export function measurementScaleProblem(value: unknown): string | undefined { + return styleScaleProblem(value, MEASUREMENT_SCALE); +} + +/** Returns what is wrong with an arrowhead, or undefined when it is one of `ARROWHEADS`. */ +export function arrowheadProblem(value: unknown): string | undefined { + return arrowheadInfo(value) ? undefined : ARROWHEAD; +} + +/** Returns what is wrong with a precision, or undefined. Its type is `Precision`. */ +export function precisionProblem(value: unknown): string | undefined { + return typeof value === "number" && + Number.isInteger(value) && + value >= 0 && + value <= MAX_PRECISION + ? undefined + : PRECISION; +} + +/** Returns what is wrong with a `suppressZeros`, or undefined. */ +export function zeroSuppressionProblem(value: unknown): string | undefined { + return (ZERO_SUPPRESSIONS as readonly unknown[]).includes(value) ? undefined : ZERO_SUPPRESSION; +} + /** * Returns what is wrong with a prefix, a suffix or a dimension's own text as a string AutoCAD * draws as it is, or undefined: it is a text, with none of the characters AutoCAD reads as - * formatting. + * formatting. Its type, for a suffix, is `DimensionAffix`. */ -function plainDimensionText(value: unknown): string | undefined { +export function plainDimensionText(value: unknown): string | undefined { if (typeof value !== "string") return "must be a string"; return textProblem(value) ?? (/[\\{}]/.test(value) ? DIMENSION_FORMATTING : undefined); } +/** Returns what is wrong with a dimension style's prefix, or undefined. Its type is `DimensionPrefix`. */ +export function dimensionPrefixProblem(value: unknown): string | undefined { + if (typeof value === "string" && value.includes("<>")) return PREFIX_MEASUREMENT; + return plainDimensionText(value); +} + /** * Returns what is wrong with a dimension style, or undefined when it is valid. It takes a value * of any shape, so that `lint` can report a drawing built by hand. Whether the drawing has the @@ -1643,12 +1727,12 @@ export function dimensionStyleProblem(style: unknown): string | undefined { } const height = sizeProblem(s.textHeight); if (height) return `textHeight: ${height}`; - if (s.arrowhead !== undefined && !arrowheadInfo(s.arrowhead)) return ARROWHEAD; + if (s.arrowhead !== undefined && arrowheadProblem(s.arrowhead)) return ARROWHEAD; for (const field of ["arrowSize", "extensionOffset", "extensionBeyond"] as const) { const problem = notNegativeProblem(s[field]); if (problem) return `${field}: ${problem}`; } - const gap = notNegativeProblem(s.textGap, TEXT_GAP); + const gap = textGapProblem(s.textGap); if (gap) return `textGap: ${gap}`; if (s.scale !== undefined) { const problem = styleScaleProblem(s.scale, DIMENSION_SCALE); @@ -1675,29 +1759,20 @@ export function dimensionStyleProblem(style: unknown): string | undefined { return `${field}: times the scale it is ${size * scale}, which is written as 0 with the 6 decimals tsdraft writes, so the picture of a dimension would draw ${field === "textHeight" ? "its text" : "its arrowheads"} with no size. Use a larger size or scale${field === "arrowSize" ? ", or an arrow size of 0" : ""}`; } } - const precision = s.precision; - if ( - typeof precision !== "number" || - !Number.isInteger(precision) || - precision < 0 || - precision > MAX_PRECISION - ) { - return PRECISION; - } + const precision = precisionProblem(s.precision); + if (precision) return precision; for (const field of ["prefix", "suffix"] as const) { if (s[field] === undefined) continue; const problem = plainDimensionText(s[field]); if (problem) return `${field}: ${problem}`; } if (typeof s.prefix === "string" && s.prefix.includes("<>")) return PREFIX_MEASUREMENT; - if ( - s.suppressZeros !== undefined && - !(ZERO_SUPPRESSIONS as readonly unknown[]).includes(s.suppressZeros) - ) { - return ZERO_SUPPRESSION; + if (s.suppressZeros !== undefined) { + const problem = zeroSuppressionProblem(s.suppressZeros); + if (problem) return problem; } if (s.measurementScale !== undefined) { - const problem = styleScaleProblem(s.measurementScale, MEASUREMENT_SCALE); + const problem = measurementScaleProblem(s.measurementScale); if (problem) return `measurementScale: ${problem}`; } return undefined; diff --git a/src/sha256.ts b/src/sha256.ts new file mode 100644 index 0000000..0344720 --- /dev/null +++ b/src/sha256.ts @@ -0,0 +1,117 @@ +// SHA-256, as FIPS 180-4 defines it, worked out synchronously: the library runs in a browser, +// where WebCrypto's digest is asynchronous. + +const ROUND = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const START = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + +const rotate = (x: number, n: number): number => (x >>> n) | (x << (32 - n)); + +/** The SHA-256 of `bytes`, as 64 lower-case hexadecimal digits. */ +export function sha256(bytes: Uint8Array): string { + const blocks = Math.ceil((bytes.length + 9) / 64); + const message = new Uint8Array(blocks * 64); + message.set(bytes); + message[bytes.length] = 0x80; + const view = new DataView(message.buffer); + const bits = bytes.length * 8; + view.setUint32(message.length - 8, Math.floor(bits / 2 ** 32)); + view.setUint32(message.length - 4, bits >>> 0); + + const hash = new Uint32Array(START); + const w = new Uint32Array(64); + for (let offset = 0; offset < message.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4); + for (let i = 16; i < 64; i++) { + const a = w[i - 15]!; + const b = w[i - 2]!; + const s0 = rotate(a, 7) ^ rotate(a, 18) ^ (a >>> 3); + const s1 = rotate(b, 17) ^ rotate(b, 19) ^ (b >>> 10); + w[i] = w[i - 16]! + s0 + w[i - 7]! + s1; + } + let a = hash[0]!; + let b = hash[1]!; + let c = hash[2]!; + let d = hash[3]!; + let e = hash[4]!; + let f = hash[5]!; + let g = hash[6]!; + let h = hash[7]!; + for (let i = 0; i < 64; i++) { + const s1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25); + const choice = (e & f) ^ (~e & g); + const t1 = (h + s1 + choice + ROUND[i]! + w[i]!) | 0; + const s0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const t2 = (s0 + majority) | 0; + h = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + hash[0]! += a; + hash[1]! += b; + hash[2]! += c; + hash[3]! += d; + hash[4]! += e; + hash[5]! += f; + hash[6]! += g; + hash[7]! += h; + } + let hex = ""; + for (const word of hash) hex += word.toString(16).padStart(8, "0"); + return hex; +} + +/** + * The SHA-256 of the UTF-8 bytes of `text`. A lone surrogate is encoded as U+FFFD, as + * `TextEncoder` encodes it. + */ +export function sha256Text(text: string): string { + const bytes = new Uint8Array(text.length * 3); + let n = 0; + for (let i = 0; i < text.length; i++) { + let code = text.charCodeAt(i); + if (code < 0x80) { + bytes[n++] = code; + continue; + } + if (code >= 0xd800 && code <= 0xdbff) { + const low = text.charCodeAt(i + 1); + if (low >= 0xdc00 && low <= 0xdfff) { + code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00); + i++; + } + } + if (code >= 0xd800 && code <= 0xdfff) code = 0xfffd; + if (code < 0x800) { + bytes[n++] = 0xc0 | (code >> 6); + bytes[n++] = 0x80 | (code & 0x3f); + } else if (code < 0x10000) { + bytes[n++] = 0xe0 | (code >> 12); + bytes[n++] = 0x80 | ((code >> 6) & 0x3f); + bytes[n++] = 0x80 | (code & 0x3f); + } else { + bytes[n++] = 0xf0 | (code >> 18); + bytes[n++] = 0x80 | ((code >> 12) & 0x3f); + bytes[n++] = 0x80 | ((code >> 6) & 0x3f); + bytes[n++] = 0x80 | (code & 0x3f); + } + } + return sha256(bytes.subarray(0, n)); +} diff --git a/src/standard/default.ts b/src/standard/default.ts new file mode 100644 index 0000000..4284800 --- /dev/null +++ b/src/standard/default.ts @@ -0,0 +1,148 @@ +// The built-in standard, as a document. Its layer names follow the public layer name format of +// the National CAD Standard, with a short list of its own. Its version goes up whenever what it +// states changes, and the CHANGELOG says so. +import { CENTER, DASHDOT, DASHED, DOT, HIDDEN, PHANTOM } from "../linetypes.ts"; +import type { Linetype } from "../drawing.ts"; +import { + PLAIN_BOX, + PLAIN_DRAWING_AREA, + PLAIN_FIELDS, + PLAIN_TITLE_BLOCK_NAME, + PLAIN_TITLE_BLOCK_SPECIFIER, +} from "./title-block.ts"; + +/** What `extends` names the built-in standard by. */ +export const BUILT_IN_SPECIFIER = "tsdraft/standard.json"; + +const linetype = ({ description, pattern }: Linetype) => ({ description, pattern }); + +const engineering = (feet: number) => + [`1in-${feet}ft`, { label: `1" = ${feet}'`, ratio: feet * 12 }] as const; +const architectural = (id: string, label: string, inches: number) => + [id, { label: `${label} = 1'-0"`, ratio: 12 / inches }] as const; + +export const BUILT_IN_DOCUMENT = { + id: "tsdraft", + version: "1", + paperUnits: "in", + minTextHeight: 0.0625, + linetypeScale: 0.5, + plotStyle: "monochrome.ctb", + defaultSheetFormat: "arch-d", + defaultDimensionStyle: "dims", + disciplines: { + G: { title: "General" }, + C: { title: "Civil" }, + V: { title: "Survey" }, + }, + pens: { + heavy: { lineweight: 0.7 }, + bold: { lineweight: 0.5 }, + medium: { lineweight: 0.35 }, + light: { lineweight: 0.25 }, + fine: { lineweight: 0.18 }, + }, + linetypes: { + DASHED: linetype(DASHED), + HIDDEN: linetype(HIDDEN), + CENTER: linetype(CENTER), + PHANTOM: linetype(PHANTOM), + DASHDOT: linetype(DASHDOT), + DOT: linetype(DOT), + }, + layers: { + "C-PROP": { color: 7, linetype: "PHANTOM", pen: "heavy", discipline: "C" }, + "C-PROP-ADJC": { color: 8, linetype: "PHANTOM", pen: "fine", discipline: "C" }, + "C-PROP-MONU": { color: 7, pen: "light", discipline: "C" }, + "C-SETB": { color: 1, linetype: "DASHED", pen: "light", discipline: "C" }, + "C-BLDG": { color: 5, pen: "heavy", discipline: "C" }, + "C-PAVE": { color: 8, pen: "medium", discipline: "C" }, + "C-ROAD-CNTR": { color: 8, linetype: "CENTER", pen: "fine", discipline: "C" }, + "C-ANNO-DIMS": { color: 8, pen: "fine", discipline: "C" }, + "C-ANNO-TEXT": { color: 7, pen: "light", discipline: "C" }, + "C-ANNO-SYMB": { color: 7, pen: "medium", discipline: "C" }, + "C-ANNO-TITL": { color: 7, pen: "bold", discipline: "C" }, + "G-ANNO-TTLB": { color: 7, pen: "medium", discipline: "G" }, + "G-ANNO-NPLT": { color: 8, plot: false, discipline: "G" }, + }, + textRoles: { + note: { font: "Arial", height: 0.09375 }, + label: { font: "Arial", height: 0.125 }, + heading: { font: "Arial", height: 0.1875 }, + title: { font: "Arial", height: 0.25 }, + dimension: { font: "Arial", height: 0.09375 }, + }, + scales: Object.fromEntries([ + engineering(10), + engineering(20), + engineering(30), + engineering(40), + engineering(50), + engineering(60), + engineering(100), + architectural("1-8in-1ft", '1/8"', 1 / 8), + architectural("3-16in-1ft", '3/16"', 3 / 16), + architectural("1-4in-1ft", '1/4"', 1 / 4), + architectural("3-8in-1ft", '3/8"', 3 / 8), + architectural("1-2in-1ft", '1/2"', 1 / 2), + architectural("3-4in-1ft", '3/4"', 3 / 4), + architectural("1in-1ft", '1"', 1), + architectural("1-1-2in-1ft", '1 1/2"', 3 / 2), + architectural("3in-1ft", '3"', 3), + ]) as Readonly>, + dimensionStyles: { + dims: { + role: "dimension", + arrowhead: "architectural-tick", + arrowSize: 0.125, + extensionOffset: 0.0625, + extensionBeyond: 0.125, + textGap: 0.0625, + writing: { + ft: { precision: 1, suffix: "'" }, + in: { precision: 0, suffix: '"' }, + mm: { precision: 0 }, + }, + }, + }, + titleBlocks: { + plain: { + paper: "ARCH D", + block: PLAIN_TITLE_BLOCK_SPECIFIER, + blockName: PLAIN_TITLE_BLOCK_NAME, + layer: "G-ANNO-TTLB", + drawingArea: PLAIN_DRAWING_AREA, + box: PLAIN_BOX, + fields: PLAIN_FIELDS, + }, + }, + sheetFormats: { + "arch-d": { + paper: "ARCH D", + titleBlock: "plain", + zones: { + views: [0.5, 2, 27.5, 23.5], + viewTitles: [0.5, 0.5, 27.5, 2], + notes: [28.5, 3.5, 35.5, 23.5], + }, + }, + }, +} as const; + +type BuiltInScale = + | "1in-10ft" + | "1in-20ft" + | "1in-30ft" + | "1in-40ft" + | "1in-50ft" + | "1in-60ft" + | "1in-100ft" + | "1-8in-1ft" + | "3-16in-1ft" + | "1-4in-1ft" + | "3-8in-1ft" + | "1-2in-1ft" + | "3-4in-1ft" + | "1in-1ft" + | "1-1-2in-1ft" + | "3in-1ft"; diff --git a/src/standard/define.ts b/src/standard/define.ts new file mode 100644 index 0000000..16992ba --- /dev/null +++ b/src/standard/define.ts @@ -0,0 +1,812 @@ +// `defineStandard`: a document checked against the table of its fields, merged with its parent, +// checked as a whole, with every default filled in, and hashed. +import type { Block, Drawing, Entity, TextStyle } from "../drawing.ts"; +import { canonical, fromJSON } from "../json.ts"; +import { layerAuditKey, layerKey } from "../layers.ts"; +import { DrawingError, type Diagnostic } from "../lint.ts"; +import { paperInfo } from "../papers.ts"; +import { written } from "../format.ts"; +import { layerTwinProblem, shown } from "../rules.ts"; +import { isRecord } from "../shape.ts"; +import { sha256Text } from "../sha256.ts"; +import { BUILT_IN_DOCUMENT, BUILT_IN_SPECIFIER } from "./default.ts"; +import { + SECTIONS, + type Box, + type Section, + type Standard, + type StandardDimensionStyle, + type StandardDocument, + type StandardLayer, + type StandardLink, + type StandardOptions, + type StandardSheetFormat, + type StandardTextRole, + type StandardTitleBlock, +} from "./document.ts"; +import type { CheckDocument, NamesOf, ParentOf } from "./literal.ts"; +import { capitals, inheritedDefaultProblem, referenceProblem } from "./rules.ts"; +import { + DOCUMENT, + entriesObject, + hashedJson, + readDocument, + same, + withDefaults, + type MapNode, + type Problem, + type RecordNode, +} from "./shape.ts"; +import { PLAIN_TITLE_BLOCK_SPECIFIER, plainTitleBlock } from "./title-block.ts"; + +/** + * Checks a standard's document, resolves it against its parent and returns the `Standard`. + * + * A literal mistake in the document is a compile error with the message of the runtime check: + * 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, layer, role, + * dimension style, title block or sheet format that the standard and its parent do not have. + * The compiler compares names exactly; the runtime check folds case as AutoCAD does. + * + * @param document - The standard, as a TypeScript literal or as the value `JSON.parse` gives. + * @param options - `files` gives what a string `extends`, `block` or `preview` names. + * @returns The standard: plain, frozen data, every default filled in and each name a field holds + * spelled as its entry spells it, with its `hash` and the `chain` of its parents. + * @throws A `DrawingError` whose diagnostics have the rule `standard`, and the path of each + * problem in the document as `item`, such as `layers["A-WALL"].pen`. + */ +export function defineStandard< + const D extends StandardDocument & CheckDocument>, + const F extends Readonly> = {}, +>(document: D, options?: StandardOptions): Standard>> { + const problems: Problem[] = []; + const standard = resolve(document, options?.files ?? {}, problems); + if (!standard || problems.length > 0) throw standardError(problems); + return standard as never; +} + +/** The standards this copy of tsdraft made, which need no second check as a parent. */ +const MADE = new WeakSet(); + +function standardError(problems: readonly Problem[]): DrawingError { + const diagnostics = problems.map(({ item, message }): Diagnostic => ({ + rule: "standard", + severity: "error", + message, + ...(item ? { item } : {}), + })); + const count = problems.length === 1 ? "1 problem" : `${problems.length} problems`; + const shownProblems = problems + .slice(0, 10) + .map(({ item, message }) => `- ${item ? `${item}: ` : ""}${message}`); + const more = problems.length > 10 ? [`- and ${problems.length - 10} more`] : []; + return new DrawingError( + diagnostics, + [`The standard has ${count}:`, ...shownProblems, ...more].join("\n"), + ); +} + +/** `layerKey` of `Continuous`, the linetype every standard has. */ +const CONTINUOUS = layerKey("Continuous"); + +/** How the names of a section are compared: AutoCAD's way for its names, and ids without case. */ +const foldOf = (section: Section): ((name: string) => string) => + (DOCUMENT.fields[section]!.node as MapNode).fold!.key; + +/** The fields of an entry of each section that name an entry of another, and that section. */ +const REFERENCES: { readonly [S in Section]: readonly (readonly [string, Section])[] } = { + disciplines: [], + pens: [], + linetypes: [], + layers: [ + ["linetype", "linetypes"], + ["pen", "pens"], + ["discipline", "disciplines"], + ], + textRoles: [["discipline", "disciplines"]], + scales: [["discipline", "disciplines"]], + dimensionStyles: [ + ["role", "textRoles"], + ["discipline", "disciplines"], + ], + titleBlocks: [["layer", "layers"]], + sheetFormats: [ + ["titleBlock", "titleBlocks"], + ["discipline", "disciplines"], + ], +}; + +/** The entry of a section of the table. */ +const entryNode = (section: Section): RecordNode => + (DOCUMENT.fields[section]!.node as MapNode).value as RecordNode; + +/** A copy of plain data, frozen through. */ +function frozen(value: T): T { + if (Array.isArray(value)) return Object.freeze(value.map(frozen)) as T; + if (typeof value !== "object" || value === null) return value; + return Object.freeze( + entriesObject(Object.entries(value).map(([key, item]) => [key, frozen(item)])), + ) as T; +} + +let plain: Drawing | undefined; +/** The plain title block's drawing, made once. The builder has checked each of its calls. */ +const plainDrawing = (): Drawing => (plain ??= remember(frozen(plainTitleBlock()))); + +/** + * The built-in standard's hash. `resolve` works it out with every check, which would cost each + * load of the library several milliseconds, so the built-in standard is made from the table's + * defaults alone, and test/standard.test.ts resolves its document and holds the two to one + * standard. A change to what it states needs a new version, which the CHANGELOG gives, and then + * this hash. + */ +const BUILT_IN_HASH = "5cf072f6c9b4d19975a1b417039da159acf4a4b491ec3599ba9f05dcc8405403"; + +let builtIn: Standard | undefined; +/** The built-in standard, made once. */ +function builtInStandard(): Standard { + if (builtIn) return builtIn; + const { id, version, ...rest } = withDefaults(DOCUMENT, BUILT_IN_DOCUMENT, plainDrawing, "") as { + readonly id: string; + readonly version: string; + } & Record>>; + const sections = SECTIONS.map((section) => { + const entries = rest[section]; + const names = Object.keys(entries).sort((a, b) => (layerKey(a) < layerKey(b) ? -1 : 1)); + return [section, Object.freeze(entriesObject(names.map((name) => [name, entries[name]])))]; + }); + builtIn = Object.freeze({ + id, + version, + title: id, + ...rest, + ...Object.fromEntries(sections), + hash: BUILT_IN_HASH, + chain: Object.freeze([]), + }) as unknown as Standard; + MADE.add(builtIn); + return builtIn; +} + +/** The canonical JSON of each title block's drawing, which is frozen, worked out once. */ +const drawingJson = new WeakMap(); +const writtenDrawing = (value: object): string | undefined => { + if (!drawingJson.has(value)) return undefined; + let json = drawingJson.get(value); + if (json === undefined) drawingJson.set(value, (json = canonical(value, "A drawing"))); + return json; +}; +const remember = (drawing: Drawing): Drawing => { + drawingJson.set(drawing, undefined); + return drawing; +}; + +/** + * Resolves a document, adding each problem to `problems`, or returns undefined when it cannot + * get as far as a standard. + */ +function resolve( + document: unknown, + files: Readonly>, + problems: Problem[], +): Standard | undefined { + if (!readDocument(document, problems)) return undefined; + const doc = document as StandardDocument; + const add = (item: string, message: string) => problems.push({ item, message }); + + let parent: Standard | undefined; + if (doc.extends !== undefined) { + parent = parentOf(doc.extends, files, add); + if (!parent) return undefined; + const loop = [parent, ...parent.chain].find( + (link) => link.id === doc.id && link.version === doc.version, + ); + if (loop) { + add("extends", `The standard ${shown(doc.id)} version ${shown(doc.version)} extends itself`); + return undefined; + } + } + + const fallback = (field: K): Standard[K] => + (doc[field as keyof StandardDocument] ?? + parent?.[field] ?? + DOCUMENT.fields[field]!.default) as Standard[K]; + // A child keeps its parent's units, so the rest is checked in those. + const paperUnits = parent ? parent.paperUnits : fallback("paperUnits"); + if (parent && doc.paperUnits !== undefined && doc.paperUnits !== parent.paperUnits) { + add("paperUnits", `A standard keeps its parent's paperUnits, ${shown(parent.paperUnits)}`); + } + + // A title block's drawing, from the document or from what a string names. + const drawingOf = (value: unknown, path: string): Drawing | undefined => { + let given = value; + if (typeof value === "string") { + if (value === PLAIN_TITLE_BLOCK_SPECIFIER) return plainDrawing(); + if (!Object.hasOwn(files, value)) { + add( + path, + `${shown(value)} is not in options.files. The library reads no file: pass the value JSON.parse gives for it as files[${shown(value)}]`, + ); + return undefined; + } + given = files[value]; + } + try { + return remember(frozen(fromJSON(given))); + } catch (thrown) { + if (!(thrown instanceof DrawingError)) throw thrown; + for (const d of thrown.diagnostics) add(d.item ? `${path}.${d.item}` : path, d.message); + return undefined; + } + }; + + // Each section: the parent's entries, then the document's, by name as AutoCAD compares names. + const merged = {} as Record>; + for (const section of SECTIONS) { + const fold = foldOf(section); + const entries = new Map(); + for (const [name, entry] of Object.entries(parent?.[section] ?? {})) { + entries.set(fold(name), [name, entry]); + } + // `readDocument` has refused two names of one fold in the document. + for (const [name, entry] of Object.entries(doc[section] ?? {})) { + const key = fold(name); + const path = `${section}[${JSON.stringify(name)}]`; + const inherited = entries.get(key); + if (entry === null) { + if (inherited) entries.delete(key); + else { + add( + path, + `null removes the parent's entry of this name, and ${parent ? `the parent has no ${REFERENCE_WORDS[section]} of this name` : "the standard extends nothing"}`, + ); + } + continue; + } + entries.set(key, [ + inherited ? inherited[0] : name, + withDefaults(entryNode(section), entry, drawingOf, path), + ]); + } + merged[section] = entries; + } + + const lookup = (section: Section, name: string): T | undefined => + merged[section].get(foldOf(section)(name))?.[1] as T | undefined; + /** The name of the entry of `section` that `name` names, as the standard spells it. */ + const spelling = (section: Section, name: string): string | undefined => + section === "linetypes" && layerKey(name) === CONTINUOUS + ? "Continuous" + : merged[section].get(foldOf(section)(name))?.[0]; + /** The parent's entries, which the standard holds as they are unless the document wrote them. */ + const kept = new Set(SECTIONS.flatMap((section) => Object.values(parent?.[section] ?? {}))); + const refer = (section: Section, name: unknown, path: string, owner?: Section): void => { + if (typeof name === "string" && spelling(section, name) === undefined) { + add(path, referenceProblem(section, name, owner)); + } + }; + /** The rule of each field of an entry of `section` that names another entry. */ + const references = ( + section: Section, + entry: object, + path: string, + inherited = kept.has(entry), + ): void => { + for (const [field, target] of REFERENCES[section]) { + const name = (entry as Record)[field]; + refer(target, name, `${path}.${field}`, inherited ? section : undefined); + } + }; + const sheetFiles = fallback("sheetFiles"); + const minTextHeight = fallback("minTextHeight"); + const defaultSheetFormat = doc.defaultSheetFormat ?? parent?.defaultSheetFormat; + const defaultDimensionStyle = doc.defaultDimensionStyle ?? parent?.defaultDimensionStyle; + for (const [field, section, name] of [ + ["defaultSheetFormat", "sheetFormats", defaultSheetFormat], + ["defaultDimensionStyle", "dimensionStyles", defaultDimensionStyle], + ] as const) { + if (name === undefined || spelling(section, name) !== undefined) continue; + add( + field, + doc[field] === undefined + ? inheritedDefaultProblem(field, section, name) + : referenceProblem(section, name), + ); + } + + // Two names that AUDIT takes for one, which the merge by AutoCAD's own folding keeps apart. + for (const section of ["layers", "linetypes"] as const) { + const seen = new Map(); + for (const [name] of merged[section].values()) { + const key = layerAuditKey(name); + const other = seen.get(key); + const problem = + other === undefined + ? undefined + : layerTwinProblem(name, other, section === "layers" ? "layer" : "linetype"); + if (problem) add(`${section}[${JSON.stringify(name)}]`, problem); + else seen.set(key, name); + } + } + + /** + * Each entry of a section with its name and its path in the document. Every name has passed + * its rule here, and no rule lets a name hold a character that JSON escapes, so a path quotes + * it as it is. + */ + const each = (section: Section) => + [...merged[section].values()].map( + ([name, entry]) => [name, entry as T, `${section}["${name}"]`] as const, + ); + /** + * The rules of `byDiscipline` on an entry of `section`: each key a discipline, and each + * discipline's fields as the entry's, through `check`. + */ + const byDiscipline = ( + section: Section, + entry: { readonly discipline?: string; readonly byDiscipline?: Record }, + path: string, + check: (fields: T, path: string) => void = () => {}, + ): void => { + if (entry.byDiscipline === undefined) return; + const where = `${path}.byDiscipline`; + if (sheetFiles === "one-drawing") add(where, BY_DISCIPLINE_ONE_DRAWING); + if (entry.discipline !== undefined) add(where, BY_DISCIPLINE_OF_ONE); + const inherited = kept.has(entry); + for (const [discipline, fields] of Object.entries(entry.byDiscipline)) { + const at = `${where}[${JSON.stringify(discipline)}]`; + refer("disciplines", discipline, at, inherited ? section : undefined); + references(section, fields, at, inherited); + check(fields, at); + } + }; + + for (const [, layer, path] of each("layers")) { + references("layers", layer, path); + byDiscipline("layers", layer, path); + } + + const roleHeight = (role: Partial, path: string): void => { + if (role.height !== undefined && written(role.height) < written(minTextHeight)) { + add( + `${path}.height`, + `A text role's height, ${role.height}, is below the standard's minTextHeight, ${minTextHeight}`, + ); + } + }; + for (const [, role, path] of each("textRoles")) { + roleHeight(role, path); + references("textRoles", role, path); + byDiscipline("textRoles", role, path, roleHeight); + } + + for (const [, scale, path] of each("scales")) references("scales", scale, path); + + // A dimension style's name in AutoCAD is its id in capitals on a layout, and with a scale's id + // after it in model space. No two of those names may be one to AutoCAD. + const styleNames = new Map(); + const scaleIds = [...merged.scales.values()].map(([name]) => name); + for (const [id, style, path] of each("dimensionStyles")) { + references("dimensionStyles", style, path); + byDiscipline("dimensionStyles", style, path); + const named = (name: string, what: string): void => { + const key = layerAuditKey(name); + const other = styleNames.get(key); + if (other === undefined) styleNames.set(key, what); + else { + add( + path, + `${capital(what)} and ${other} have names that AutoCAD takes for one dimension style. Rename the style or the scale`, + ); + } + }; + named(capitals(id), `the dimension style ${shown(id)}`); + for (const scale of scaleIds) { + named( + `${capitals(id)}-${scale}`, + `the dimension style ${shown(id)} at the scale ${shown(scale)}`, + ); + } + } + + const papers = (paper: string, path: string) => { + const info = paperInfo(paper); + if (info && info.units !== paperUnits) { + add( + path, + `The paper ${shown(paper)} is in ${shown(info.units)}, and the standard's paperUnits are ${shown(paperUnits)}`, + ); + } + return info; + }; + const roleStyles = new Map( + [...merged.textRoles.values()].map(([id]) => [layerAuditKey(capitals(id)), id] as const), + ); + const blocks = new Map(); + const textStyles = new Map(); + const xrefs = new Map(); + for (const [id, block, path] of each("titleBlocks")) { + const info = papers(block.paper, `${path}.paper`); + references("titleBlocks", block, path); + if (!block.block && !block.xref) add(path, TITLE_BLOCK_FORM); + if (block.block && block.blockName === undefined) add(`${path}.blockName`, BLOCK_NAME_MISSING); + if (!block.block && block.blockName !== undefined) add(`${path}.blockName`, BLOCK_NAME_ALONE); + if (block.preview && !block.xref) add(`${path}.preview`, PREVIEW_ALONE); + if (info) { + boxIn( + block.drawingArea, + [0, 0, info.width, info.height], + "the paper", + `${path}.drawingArea`, + add, + ); + } + if (block.box) boxIn(block.box, block.drawingArea, "the drawing area", `${path}.box`, add); + const drawn = + block.block && block.blockName !== undefined + ? titleBlockDrawing(block.block, block.blockName, `${path}.block`, paperUnits, lookup, add) + : undefined; + for (const [tag, field] of Object.entries(block.fields)) { + const at = `${path}.fields[${JSON.stringify(tag)}]`; + if (field.box && info) { + boxIn(field.box, [0, 0, info.width, info.height], "the paper", `${at}.box`, add); + } + if (field.source !== "attribute" && !block.xref) + add(`${at}.source`, filledByAutoCAD(field.source)); + if ( + (field.source === "fileName" || field.source === "layoutName") && + sheetFiles === "one-drawing" + ) { + add(`${at}.source`, READS_A_NAME); + } + if (field.source === "attribute") { + if (!block.block) add(at, ATTRIBUTE_WITHOUT_BLOCK); + else if (drawn && !drawn.block.attributes?.some((a) => a.tag === tag)) { + add( + at, + `The block ${shown(drawn.block.name)} has no attribute with the tag ${shown(tag)}, which a field whose source is "attribute" is written in`, + ); + } + } + } + if (block.preview && block.preview.units !== paperUnits) { + add(`${path}.preview`, unitsProblem(block.preview.units, paperUnits)); + } + if (drawn) { + const owned = ( + kind: "block" | "text style", + found: Map, + name: string, + json: string, + ): void => { + const key = layerAuditKey(name); + const other = found.get(key); + if (other === undefined) found.set(key, { name, json, owner: id }); + else if (other.json !== json) { + add( + `${path}.block`, + `The title blocks ${shown(other.owner)} and ${shown(id)} both hold a ${kind} ${shown(name)}, with different definitions. A drawing holds one ${kind} of a name: give one of them another name`, + ); + } + }; + for (const b of drawn.blocks) owned("block", blocks, b.name, canonical(b)); + for (const style of drawn.textStyles) { + owned("text style", textStyles, style.name, canonical(style)); + const role = roleStyles.get(layerAuditKey(style.name)); + if (role !== undefined) { + add( + `${path}.block`, + `The title block's text style ${shown(style.name)} has the name of the text style of the role ${shown(role)}. Give one of them another name`, + ); + } + } + } + if (block.xref) { + const key = layerAuditKey(block.xref.name); + const other = xrefs.get(key); + if (other === undefined) xrefs.set(key, { ...block.xref, owner: id }); + else if (other.path !== block.xref.path) { + add( + `${path}.xref.path`, + `The title blocks ${shown(other.owner)} and ${shown(id)} both have an xref ${shown(block.xref.name)}, at different paths. A drawing holds one xref of a name: give one of them another name`, + ); + } + } + } + for (const [key, xref] of xrefs) { + const block = blocks.get(key); + if (block) { + add( + `titleBlocks[${JSON.stringify(xref.owner)}].xref.name`, + `The xref ${shown(xref.name)} has the name of the block ${shown(block.name)} of the title block ${shown(block.owner)}. An xref is a block to AutoCAD: give one of them another name`, + ); + } + } + + for (const [, format, path] of each("sheetFormats")) { + papers(format.paper, `${path}.paper`); + references("sheetFormats", format, path); + const block = lookup("titleBlocks", format.titleBlock); + if (!block) continue; + if (block.paper !== format.paper) { + add( + `${path}.paper`, + `The sheet format's paper, ${shown(format.paper)}, is not its title block's, ${shown(block.paper)}`, + ); + } + for (const [zone, box] of Object.entries(format.zones)) { + boxIn( + box, + block.drawingArea, + "the title block's drawing area", + `${path}.zones[${JSON.stringify(zone)}]`, + add, + ); + } + } + + if (problems.length > 0) return undefined; + + /** + * `entry` with each field that names another entry, and each key of its `byDiscipline`, spelled + * as that entry is, so that neither the standard nor its hash depends on how a name was written. + */ + const respelled = (section: Section, entry: Record): unknown => { + let out: Record | undefined; + for (const [field, target] of REFERENCES[section]) { + const name = entry[field]; + if (typeof name !== "string") continue; + const spelled = spelling(target, name)!; + if (spelled !== name) (out ??= { ...entry })[field] = spelled; + } + const by = entry.byDiscipline as Readonly>> | undefined; + if (by) { + let changed = false; + const disciplines = Object.entries(by).map(([discipline, fields]) => { + const name = spelling("disciplines", discipline)!; + const own = respelled(section, fields); + changed ||= name !== discipline || own !== fields; + return [name, own] as const; + }); + if (changed) (out ??= { ...entry }).byDiscipline = Object.freeze(entriesObject(disciplines)); + } + return out ? Object.freeze(out) : entry; + }; + const sections = Object.fromEntries( + SECTIONS.map((section) => { + // An entry's key is `layerKey` of its name, or for an id its letters in lower case, + // which is the same. + const entries = merged[section]; + const sorted = [...entries.keys()].sort().map((key) => { + const [name, entry] = entries.get(key)!; + return [name, respelled(section, entry as Record)] as const; + }); + return [section, Object.freeze(entriesObject(sorted))]; + }), + ); + const body = { + id: doc.id, + version: doc.version, + title: doc.title ?? doc.id, + paperUnits, + minTextHeight, + linetypeScale: fallback("linetypeScale"), + plotStyle: fallback("plotStyle"), + sheetFiles, + ...(defaultSheetFormat === undefined + ? {} + : { defaultSheetFormat: spelling("sheetFormats", defaultSheetFormat) }), + ...(defaultDimensionStyle === undefined + ? {} + : { defaultDimensionStyle: spelling("dimensionStyles", defaultDimensionStyle) }), + ...sections, + }; + const chain = parent + ? [{ id: parent.id, version: parent.version, hash: parent.hash }, ...parent.chain] + : []; + // Each entry is frozen already, and a parent's entries are the parent's own. + const standard = Object.freeze({ + ...body, + hash: sha256Text(hashedJson(body, writtenDrawing)), + chain: frozen(chain), + }) as unknown as Standard; + MADE.add(standard); + return standard; +} + +/** The parent a document's `extends` names, or undefined after adding the problem. */ +function parentOf( + value: string | Standard, + files: Readonly>, + add: (item: string, message: string) => void, +): Standard | undefined { + let given: unknown = value; + if (typeof value === "string") { + if (value === BUILT_IN_SPECIFIER) return builtInStandard(); + if (!Object.hasOwn(files, value)) { + add( + "extends", + `${shown(value)} is not in options.files. The library reads no file: pass the standard it names as files[${shown(value)}]`, + ); + return undefined; + } + given = files[value]; + } + if (typeof given === "object" && given !== null && MADE.has(given)) return given as Standard; + // A standard from another copy of tsdraft, as data: it is read again as a document with no + // `extends`, and its hash must be its content's. + const { hash, chain, ...rest } = isRecord(given) ? given : ({} as Record); + const links = + Array.isArray(chain) && + chain.every( + (link) => + isRecord(link) && + typeof link.id === "string" && + typeof link.version === "string" && + typeof link.hash === "string", + ); + const again = links && typeof hash === "string" ? resolve(rest, {}, []) : undefined; + if (!again || again.hash !== hash) { + add( + "extends", + "The parent is not a standard: give extends a Standard that defineStandard made, unchanged, or a string that options.files gives one for", + ); + return undefined; + } + // Its entries are `again`'s, whose drawings' JSON is known. + const standard = Object.freeze({ + ...again, + chain: frozen( + (chain as StandardLink[]).map(({ id, version, hash }) => ({ id, version, hash })), + ), + }) as Standard; + MADE.add(standard); + return standard; +} + +/** The closure of a title block's block: what a drawing that inserts it has to hold. */ +interface Drawn { + readonly block: Block; + readonly blocks: readonly Block[]; + readonly textStyles: readonly TextStyle[]; +} + +/** + * Checks the drawing of a title block's block against the standard, and returns its block with + * every block it inserts and every text style they use, or undefined when it has no such block. + */ +function titleBlockDrawing( + drawing: Drawing, + blockName: string, + path: string, + paperUnits: string, + lookup: (section: Section, name: string) => T | undefined, + add: (item: string, message: string) => void, +): Drawn | undefined { + if (drawing.units !== paperUnits) add(path, unitsProblem(drawing.units, paperUnits)); + const byName = new Map((drawing.blocks ?? []).map((b) => [layerKey(b.name), b])); + const start = byName.get(layerKey(blockName)); + if (!start) { + add(path, `The title block's drawing has no block ${shown(blockName)}, which blockName names`); + return undefined; + } + for (const [field, what] of [ + ["dimensionStyles", "a dimension style"], + ["layouts", "a layout"], + ["xrefs", "an xref"], + ] as const) { + if ((drawing[field]?.length ?? 0) > 0) add(path, `A title block's drawing cannot hold ${what}`); + } + const everything = [drawing.entities, ...(drawing.blocks ?? []).map((b) => b.entities)]; + if (everything.some((entities) => entities.some((e) => e.kind.endsWith("-dimension")))) { + add(path, "A title block's drawing cannot hold a dimension"); + } + for (const linetype of drawing.linetypes ?? []) { + const own = lookup<{ description: string; pattern: readonly number[] }>( + "linetypes", + linetype.name, + ); + if (!own) { + add( + path, + `The title block's drawing defines the linetype ${shown(linetype.name)}, which the standard does not have`, + ); + } else if ( + !same({ ...own }, { description: linetype.description, pattern: linetype.pattern }) + ) { + add( + path, + `The title block's drawing defines the linetype ${shown(linetype.name)} otherwise than the standard does`, + ); + } + } + + const blocks = [start]; + const styleNames = new Set(); + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]!; + const layers = [ + ...block.entities.map((e: Entity) => e.layer), + ...(block.attributes ?? []).map((a) => a.layer), + ]; + for (const layer of new Set(layers)) { + if (layer !== "0" && lookup("layers", layer) === undefined) { + add( + path, + `The block ${shown(block.name)} draws on the layer ${shown(layer)}, which is not a layer of the standard. A title block draws on layer 0 or on layers of the standard`, + ); + } + } + for (const e of block.entities) { + if (e.kind === "text" && e.style !== undefined) styleNames.add(layerKey(e.style)); + if (e.kind !== "insert") continue; + const inserted = byName.get(layerKey(e.block)); + if (inserted && !blocks.includes(inserted)) blocks.push(inserted); + } + for (const a of block.attributes ?? []) + if (a.style !== undefined) styleNames.add(layerKey(a.style)); + } + const textStyles = (drawing.textStyles ?? []).filter((s) => styleNames.has(layerKey(s.name))); + return { block: start, blocks, textStyles }; +} + +/** Adds a problem when `box` is not a box, or reaches past `outer`, which is `what`. */ +function boxIn( + box: Box, + outer: Box, + what: string, + path: string, + add: (item: string, message: string) => void, +): void { + const [left, bottom, right, top] = box.map(written) as unknown as Box; + if (!(left < right && bottom < top)) { + add( + path, + "A box is [left, bottom, right, top], with left less than right and bottom less than top", + ); + return; + } + const [l, b, r, t] = outer.map(written) as unknown as Box; + if (left < l || bottom < b || right > r || top > t) { + add(path, `The box reaches past ${what}, [${outer.join(", ")}]`); + } +} + +const capital = (what: string): string => what.charAt(0).toUpperCase() + what.slice(1); + +const unitsProblem = (units: string, paperUnits: string): string => + `The drawing is in ${shown(units)}, and the standard's paperUnits are ${shown(paperUnits)}. A title block is drawn in paper units`; + +const filledByAutoCAD = (source: string): string => + `A field whose source is ${shown(source)} is a field of the xref's drawing that AutoCAD fills, and this title block has no xref`; + +const REFERENCE_WORDS: Record = { + disciplines: "discipline", + pens: "pen", + linetypes: "linetype", + layers: "layer", + textRoles: "text role", + scales: "scale", + dimensionStyles: "dimension style", + titleBlocks: "title block", + sheetFormats: "sheet format", +}; + +const BY_DISCIPLINE_ONE_DRAWING = + "byDiscipline holds only under sheetFiles \"sheet-per-file\", where each discipline's model is a drawing of its own. In one drawing a name has one set of properties, so give each discipline's entry a name of its own"; +const BY_DISCIPLINE_OF_ONE = + "An entry of one discipline has one set of fields. byDiscipline is for an entry that every discipline shares"; +const TITLE_BLOCK_FORM = + "A title block has a block that tsdraft draws, an xref of the firm's own drawing, or both"; +const BLOCK_NAME_MISSING = "A title block with a block names it with blockName"; +const BLOCK_NAME_ALONE = "blockName names the block of block, and this title block has no block"; +const PREVIEW_ALONE = "A preview is drawn in place of an xref, and this title block has no xref"; +const READS_A_NAME = + "Under sheetFiles \"one-drawing\" no field reads the file's name or the layout's name. One drawing has one file name, and an xref that every layout shares shows one layout's name on every sheet. Make the field an attribute"; +const ATTRIBUTE_WITHOUT_BLOCK = + 'A field whose source is "attribute" is written in an attribute of the title block\'s block, and this title block has no block'; + +/** The built-in standard: its layers, pens, roles, scales, dimension style and plain title block. */ +export const BUILT_IN_STANDARD: BuiltInStandard = builtInStandard() as BuiltInStandard; + +export type BuiltInStandard = Standard>; diff --git a/src/standard/document.ts b/src/standard/document.ts new file mode 100644 index 0000000..990fba5 --- /dev/null +++ b/src/standard/document.ts @@ -0,0 +1,406 @@ +// A standard as a document, before `extends` and `null` are resolved, and as a `Standard`, after. +// The document's types are as wide as a JSON import gives them: `defineStandard` checks each +// literal through `CheckDocument`, and every value when the code runs. +import type { Arrowhead, ZERO_SUPPRESSIONS } from "../arrowheads.ts"; +import type { Drawing, Point, Units } from "../drawing.ts"; +import type { Font } from "../fonts.ts"; +import type { PaperUnit } from "../paper.ts"; +import type { Paper, PlotStyleTable } from "../papers.ts"; + +/** A rectangle on the paper, `[left, bottom, right, top]`, as `PaperInfo.printable` is. */ +export type Box = readonly [left: number, bottom: number, right: number, top: number]; + +/** How a firm keeps its sheets: as layouts of one drawing, or each as a file of its own. */ +export type SheetFiles = "one-drawing" | "sheet-per-file"; + +/** What a sheet fills a title block's field with. */ +export type TitleBlockFill = + | "number" + | "title" + | "project" + | "date" + | "scale" + | "drawnBy" + | "checkedBy"; + +/** + * Where a title block's field gets its value: an attribute the builder sets, or a field of the + * xref's drawing that AutoCAD fills with the sheet's file name, its layout's name or the date. + */ +export type FieldSource = "attribute" | "fileName" | "layoutName" | "currentDate"; + +/** The sections of a standard, each an object of entries by name. */ +export const SECTIONS = [ + "disciplines", + "pens", + "linetypes", + "layers", + "textRoles", + "scales", + "dimensionStyles", + "titleBlocks", + "sheetFormats", +] as const; + +export type Section = (typeof SECTIONS)[number]; + +interface Commented { + /** For people. No rule reads it, and `defineStandard` drops it. */ + readonly $comment?: string; +} + +/** A section of a document: an entry by name, or `null`, which removes the parent's entry. */ +type Entries = { readonly [name: string]: E | null }; + +/** A standard as JSON, or as a TypeScript literal given to `defineStandard`. */ +export interface StandardDocument extends Commented { + /** The JSON Schema of the document. */ + readonly $schema?: string; + /** 1 to 128 letters, digits, `.`, `-`, `_`, `@` and `/`, so a package name is one. */ + readonly id: string; + readonly version: string; + /** For people. Defaults to the `id`. */ + readonly title?: string; + /** + * The parent: a `Standard`, or a string that `options.files` gives the standard for. + * `"tsdraft/standard.json"` is `BUILT_IN_STANDARD`. + */ + readonly extends?: string | Standard; + /** The unit of every length on paper. Required without `extends`, and a child keeps its parent's. */ + readonly paperUnits?: string; + /** The least height a text may plot at, in paper units. Required without `extends`. */ + readonly minTextHeight?: number; + /** A dash plots its pattern's length times this. Defaults to 1. */ + readonly linetypeScale?: number; + /** One of `PLOT_STYLE_TABLES`. Defaults to `"monochrome.ctb"`. */ + readonly plotStyle?: string; + /** `"one-drawing"`, the default, or `"sheet-per-file"`. */ + readonly sheetFiles?: string; + readonly defaultSheetFormat?: string; + readonly defaultDimensionStyle?: string; + readonly disciplines?: Entries; + readonly pens?: Entries; + readonly linetypes?: Entries; + readonly layers?: Entries; + readonly textRoles?: Entries; + readonly scales?: Entries; + readonly dimensionStyles?: Entries; + readonly titleBlocks?: Entries; + readonly sheetFormats?: Entries; +} + +export interface DisciplineDocument extends Commented { + readonly title: string; +} + +export interface PenDocument extends Commented { + /** One of AutoCAD's lineweights, in millimetres. */ + readonly lineweight: number; + readonly description?: string; +} + +export interface LinetypeDocument extends Commented { + readonly description?: string; + /** Dashes and gaps in paper units, before `linetypeScale`, as `Linetype.pattern` holds them. */ + readonly pattern: readonly number[]; +} + +export interface LayerFieldsDocument extends Commented { + /** An AutoCAD Color Index from 1 to 255. */ + readonly color?: number; + /** A linetype of the standard, or `Continuous`, the default. */ + readonly linetype?: string; + /** A pen of the standard. Without one the layer has AutoCAD's lineweight `Default`. */ + readonly pen?: string; + /** Defaults to true. */ + readonly plot?: boolean; + readonly description?: string; +} + +export interface LayerDocument extends LayerFieldsDocument { + readonly color: number; + /** The discipline the layer belongs to. A layer without one belongs to every discipline. */ + readonly discipline?: string; + /** Fields that replace the layer's own in a discipline's drawings, under `sheet-per-file`. */ + readonly byDiscipline?: { readonly [discipline: string]: LayerFieldsDocument }; +} + +export interface TextRoleFieldsDocument extends Commented { + /** One of `FONTS`, as AutoCAD stores it. */ + readonly font?: string; + /** The plotted height of a capital, in paper units, at least `minTextHeight`. */ + readonly height?: number; + readonly widthFactor?: number; + readonly obliqueAngle?: number; + readonly description?: string; +} + +export interface TextRoleDocument extends TextRoleFieldsDocument { + readonly font: string; + readonly height: number; + readonly discipline?: string; + readonly byDiscipline?: { readonly [discipline: string]: TextRoleFieldsDocument }; +} + +export interface ScaleDocument extends Commented { + /** The name AutoCAD's scale list gives the scale, such as `1" = 10'`. */ + readonly label: string; + /** Drawing lengths per paper length, such as 120 for 1" = 10'. */ + readonly ratio: number; + readonly discipline?: string; +} + +/** How a dimension style writes a length in one unit of a drawing. */ +export interface WritingDocument extends Commented { + /** `"decimal"`, the default, which is the one way tsdraft writes a length. */ + readonly units?: string; + readonly precision: number; + readonly prefix?: string; + readonly suffix?: string; + readonly suppressZeros?: string; + readonly measurementScale?: number; +} + +export interface DimensionStyleFieldsDocument extends Commented { + /** The text role of the dimension's text, which gives its text style and height. */ + readonly role?: string; + readonly arrowhead?: string; + readonly arrowSize?: number; + readonly extensionOffset?: number; + readonly extensionBeyond?: number; + readonly textGap?: number; + readonly writing?: { readonly [U in Units]?: WritingDocument }; + readonly description?: string; +} + +export interface DimensionStyleDocument extends DimensionStyleFieldsDocument { + readonly role: string; + readonly arrowSize: number; + readonly extensionOffset: number; + readonly extensionBeyond: number; + readonly textGap: number; + readonly discipline?: string; + readonly byDiscipline?: { readonly [discipline: string]: DimensionStyleFieldsDocument }; +} + +export interface TitleBlockFieldDocument extends Commented { + readonly fills?: string; + /** With a box, `lint` warns of a value that runs past it. */ + readonly box?: readonly number[]; + /** `"attribute"`, the default, `"fileName"`, `"layoutName"` or `"currentDate"`. */ + readonly source?: string; +} + +export interface XrefDocument extends Commented { + readonly name: string; + /** The path as each sheet's file sees it, as `d.xref` takes it. */ + readonly path: string; + /** Where the xref is inserted on the paper. Defaults to `[0, 0]`. */ + readonly at?: readonly number[]; +} + +export interface TitleBlockDocument extends Commented { + /** One of `PAPERS`. */ + readonly paper: string; + /** A `Drawing` that holds the block `blockName`, inline, or a string `options.files` gives it for. */ + readonly block?: string | object; + readonly blockName?: string; + /** The firm's own drawing, referenced as an overlay. */ + readonly xref?: XrefDocument; + /** A `Drawing` the pictures draw in place of the xref, given as `block` is. */ + readonly preview?: string | object; + /** A layer of the standard, which the title block is inserted on. */ + readonly layer: string; + /** The part of the paper inside the border. */ + readonly drawingArea: readonly number[]; + /** The title block's own rectangle inside the drawing area, which no viewport may reach into. */ + readonly box?: readonly number[]; + /** The fields a sheet fills, by tag. */ + readonly fields?: { readonly [tag: string]: TitleBlockFieldDocument }; +} + +export interface SheetFormatDocument extends Commented { + /** One of `PAPERS`, which is its title block's paper. */ + readonly paper: string; + readonly titleBlock: string; + /** The sheet format's own plot style table, in place of the standard's. */ + readonly plotStyle?: string; + /** Named boxes inside the title block's drawing area, which the code lays the sheet out by. */ + readonly zones?: { readonly [zone: string]: readonly number[] }; + readonly discipline?: string; +} + +/** The names of the entries of each section of a standard. */ +export type StandardNames = { readonly [S in Section]: string }; + +/** A parent of a standard, nearest first. */ +export interface StandardLink { + readonly id: string; + readonly version: string; + readonly hash: string; +} + +export interface StandardDiscipline { + readonly title: string; +} + +export interface StandardPen { + readonly lineweight: number; + readonly description?: string; +} + +export interface StandardLinetype { + readonly description: string; + readonly pattern: readonly number[]; +} + +export interface StandardLayerFields { + readonly color?: number; + readonly linetype?: string; + readonly pen?: string; + readonly plot?: boolean; + readonly description?: string; +} + +export interface StandardLayer { + readonly color: number; + readonly linetype: string; + readonly pen?: string; + readonly plot: boolean; + readonly discipline?: string; + readonly description?: string; + readonly byDiscipline?: { readonly [discipline: string]: StandardLayerFields }; +} + +export interface StandardTextRoleFields { + readonly font?: Font; + readonly height?: number; + readonly widthFactor?: number; + readonly obliqueAngle?: number; + readonly description?: string; +} + +export interface StandardTextRole { + readonly font: Font; + readonly height: number; + readonly widthFactor: number; + readonly obliqueAngle: number; + readonly discipline?: string; + readonly description?: string; + readonly byDiscipline?: { readonly [discipline: string]: StandardTextRoleFields }; +} + +export interface StandardScale { + readonly label: string; + readonly ratio: number; + readonly discipline?: string; +} + +export interface StandardWriting { + readonly units: "decimal"; + readonly precision: number; + readonly prefix?: string; + readonly suffix?: string; + readonly suppressZeros?: (typeof ZERO_SUPPRESSIONS)[number]; + readonly measurementScale: number; +} + +export interface StandardDimensionStyleFields { + readonly role?: string; + readonly arrowhead?: Arrowhead; + readonly arrowSize?: number; + readonly extensionOffset?: number; + readonly extensionBeyond?: number; + readonly textGap?: number; + readonly writing?: { readonly [U in Units]?: StandardWriting }; + readonly description?: string; +} + +export interface StandardDimensionStyle { + readonly role: string; + readonly arrowhead: Arrowhead; + readonly arrowSize: number; + readonly extensionOffset: number; + readonly extensionBeyond: number; + readonly textGap: number; + readonly writing: { readonly [U in Units]?: StandardWriting }; + readonly discipline?: string; + readonly description?: string; + readonly byDiscipline?: { readonly [discipline: string]: StandardDimensionStyleFields }; +} + +export interface StandardTitleBlockField { + readonly fills?: TitleBlockFill; + readonly box?: Box; + readonly source: FieldSource; +} + +export interface StandardXref { + readonly name: string; + readonly path: string; + readonly at: Point; +} + +export interface StandardTitleBlock { + readonly paper: Paper; + readonly block?: Drawing; + readonly blockName?: string; + readonly xref?: StandardXref; + readonly preview?: Drawing; + readonly layer: string; + readonly drawingArea: Box; + readonly box?: Box; + readonly fields: { readonly [tag: string]: StandardTitleBlockField }; +} + +export interface StandardSheetFormat { + readonly paper: Paper; + readonly titleBlock: string; + readonly plotStyle?: PlotStyleTable; + readonly zones: { readonly [zone: string]: Box }; + readonly discipline?: string; +} + +/** + * A standard: the document resolved, with no `$schema`, `extends` or `null`, every default filled + * in and every title block's drawings as `Drawing` objects, with its `hash` and `chain`. It is + * plain, frozen data. Its type carries the names of its entries, `N`, as literal types. + * + * Left without `hash` and `chain`, it is a document with no `extends` that resolves to the same + * standard, with the same hash. + */ +export type Standard = { + readonly id: string; + readonly version: string; + readonly title: string; + readonly paperUnits: PaperUnit; + readonly minTextHeight: number; + readonly linetypeScale: number; + readonly plotStyle: PlotStyleTable; + readonly sheetFiles: SheetFiles; + readonly defaultSheetFormat?: N["sheetFormats"]; + readonly defaultDimensionStyle?: N["dimensionStyles"]; + readonly disciplines: { readonly [K in N["disciplines"]]: StandardDiscipline }; + readonly pens: { readonly [K in N["pens"]]: StandardPen }; + readonly linetypes: { readonly [K in N["linetypes"]]: StandardLinetype }; + readonly layers: { readonly [K in N["layers"]]: StandardLayer }; + readonly textRoles: { readonly [K in N["textRoles"]]: StandardTextRole }; + readonly scales: { readonly [K in N["scales"]]: StandardScale }; + readonly dimensionStyles: { readonly [K in N["dimensionStyles"]]: StandardDimensionStyle }; + readonly titleBlocks: { readonly [K in N["titleBlocks"]]: StandardTitleBlock }; + readonly sheetFormats: { readonly [K in N["sheetFormats"]]: StandardSheetFormat }; + /** The SHA-256 of what the standard states for a check. */ + readonly hash: string; + /** The id, version and hash of each parent, nearest first. */ + readonly chain: readonly StandardLink[]; +}; + +export interface StandardOptions>> { + /** + * What a string in the document names, by the string exactly as the document writes it: a + * `Standard` for `extends`, and the value `JSON.parse` gives for a title block's `block` or + * `preview`. `"tsdraft/standard.json"` and `"tsdraft/title-blocks/plain-arch-d.json"` need + * no entry. The library reads no file. + */ + readonly files?: F; +} diff --git a/src/standard/literal.ts b/src/standard/literal.ts new file mode 100644 index 0000000..f41036a --- /dev/null +++ b/src/standard/literal.ts @@ -0,0 +1,342 @@ +// The compile-time half of `defineStandard`: the names a document resolves to, with its parent's, +// and the check of each literal in the document, whose message is the runtime check's. +import type { + ArrowheadOf, + AttributeTag, + BlockName, + DimensionAffix, + DimensionPrefix, + EntityId, + FontOf, + LayerColor, + LayerName, + LineweightOf, + MeasurementScale, + NewLinetypeName, + NotNegative, + ObliqueAngle, + PaperOf, + Pattern, + PlotStyleOf, + Positive, + Precision, + Ratio, + TextGap, + WidthFactor, + XrefPath, + ZeroSuppressionOf, +} from "../rules.ts"; +import type { BuiltInStandard } from "./define.ts"; +import type { BUILT_IN_SPECIFIER } from "./default.ts"; +import type { Section, Standard, StandardNames } from "./document.ts"; +import type { + DimensionStyleStandard, + FillOf, + LayerZero, + NameIn, + PaperUnitsOf, + RoleStandard, + SheetFilesOf, + SourceOf, + WritingUnitsOf, +} from "./rules.ts"; + +/** + * The parent `D` names: a `Standard` given as itself, the built-in standard, or the standard + * `options.files` holds under the literal string `extends` holds. Any other string, or an + * `extends` that `D` may leave out, gives a parent whose names are `string`, which only the + * runtime check holds to. + */ +export type ParentOf = "extends" extends keyof D + ? undefined extends D["extends" & keyof D] + ? Standard + : Named + : undefined; +type Named = E extends string + ? string extends E + ? Standard + : E extends typeof BUILT_IN_SPECIFIER + ? BuiltInStandard + : E extends keyof F + ? F[E] extends Standard + ? F[E] + : Standard + : Standard + : E; + +type Own = S extends keyof D ? D[S] : {}; +/** True when the names of a section are not known: `D` may leave it out, or its keys are `string`. */ +type Unknown = undefined extends E ? true : string extends keyof E ? true : false; +/** The names of the entries that are there. An entry that may be `null` or left out may be gone. */ +type Live = { + [K in keyof E]-?: null extends E[K] ? never : {} extends Pick ? never : K; +}[keyof E] & + string; +type Removed = { [K in keyof E]-?: null extends E[K] ? K : never }[keyof E] & string; +type Inherited = P extends { readonly [K in S]: infer E } + ? keyof E & string + : never; +/** + * A name as the type compares it: in lower case, after Σ is written σ, since `Lowercase` makes Σ + * at the end of a word ς, where `layerKey` makes it σ. Two names `layerKey` or `idKey` takes for + * one are one here too. Where this takes two for one that `layerKey` keeps apart, such as Ÿ and + * ÿ, the type leaves a name out, and never names an entry the standard lacks: a removed name is + * left out even when a live entry folds to it. + */ +type Fold = Lowercase>; +type Sigma = N extends `${infer A}Σ${infer B}` ? `${A}σ${Sigma}` : N; +/** The parent's names by their fold, which a child's entry keeps. */ +type Folds = { readonly [N in Names as Fold]: N }; +type Spelled = K extends unknown + ? Fold extends keyof Folds + ? Folds[Fold] & string + : K + : never; +type SectionNames = + Unknown> extends true + ? string + : Exclude< + Inherited | Spelled>, Inherited>, + Spelled>, Inherited> + >; + +/** The names of each section of the standard that `D` resolves to over its parent `P`. */ +export type NamesOf = { readonly [S in Section]: SectionNames }; + +type Str = V extends string ? R : unknown; +type Num = V extends number ? R : unknown; + +/** `Problem` when a name breaks a rule, and `never` when it is valid. */ +type Valid = [Checked] extends [K] ? never : Checked; +type IdProblem = Valid>; + +/** + * The document `D` with each literal that breaks a rule replaced by the rule's message, so + * `D extends CheckDocument` holds only for a document with no such literal. + */ +export type CheckDocument> = { + readonly [K in keyof D]: K extends "paperUnits" + ? PaperUnitsOf + : K extends "minTextHeight" | "linetypeScale" + ? Num> + : K extends "plotStyle" + ? Str> + : K extends "sheetFiles" + ? SheetFilesOf + : K extends "defaultSheetFormat" + ? NameIn + : K extends "defaultDimensionStyle" + ? NameIn + : K extends Section + ? Entries + : unknown; +}; + +type Entries = { + readonly [K in keyof E]: E[K] extends null + ? null + : K extends string + ? [KeyProblem] extends [never] + ? Entry + : KeyProblem + : unknown; +}; + +type KeyProblem = string extends K + ? never + : S extends "layers" + ? LayerZero | Valid> + : S extends "linetypes" + ? Valid> + : S extends "textRoles" + ? IdProblem | RoleStandard + : S extends "dimensionStyles" + ? IdProblem | DimensionStyleStandard + : IdProblem; + +type Entry = S extends "pens" + ? Pen + : S extends "linetypes" + ? LinetypeEntry + : S extends "layers" + ? Layer + : S extends "textRoles" + ? Role + : S extends "scales" + ? Scale + : S extends "dimensionStyles" + ? DimensionStyle + : S extends "titleBlocks" + ? TitleBlock + : S extends "sheetFormats" + ? SheetFormat + : unknown; + +type Pen = { + readonly [F in keyof E]: F extends "lineweight" + ? Num> + : unknown; +}; + +type LinetypeEntry = { + readonly [F in keyof E]: F extends "pattern" + ? E[F] extends readonly number[] + ? Pattern + : unknown + : unknown; +}; + +type Discipline = NameIn; + +/** `byDiscipline`: each key a discipline of the standard, and its fields as the entry's of `S`. */ +type ByDiscipline = { + readonly [K in keyof B]: K extends string + ? [Discipline] extends [K] + ? S extends "layers" + ? LayerFields + : S extends "textRoles" + ? RoleFields + : StyleFields + : Discipline + : unknown; +}; + +type LayerFields = { + readonly [F in keyof E]: F extends "color" + ? Num> + : F extends "linetype" + ? NameIn + : F extends "pen" + ? NameIn + : F extends "discipline" + ? Discipline + : unknown; +}; + +type Layer = { + readonly [F in keyof E]: F extends "byDiscipline" + ? ByDiscipline + : LayerFields[F]; +}; + +type RoleFields = { + readonly [F in keyof E]: F extends "font" + ? Str> + : F extends "height" + ? Num> + : F extends "widthFactor" + ? Num> + : F extends "obliqueAngle" + ? Num> + : F extends "discipline" + ? Discipline + : unknown; +}; + +type Role = { + readonly [F in keyof E]: F extends "byDiscipline" + ? ByDiscipline + : RoleFields[F]; +}; + +type Scale = { + readonly [F in keyof E]: F extends "ratio" + ? Num> + : F extends "discipline" + ? Discipline + : unknown; +}; + +type Writing = { + readonly [F in keyof E]: F extends "units" + ? WritingUnitsOf + : F extends "precision" + ? Num> + : F extends "prefix" + ? Str> + : F extends "suffix" + ? Str> + : F extends "suppressZeros" + ? Str> + : F extends "measurementScale" + ? Num> + : unknown; +}; + +type StyleFields = { + readonly [F in keyof E]: F extends "role" + ? NameIn + : F extends "arrowhead" + ? Str> + : F extends "arrowSize" | "extensionOffset" | "extensionBeyond" + ? Num> + : F extends "textGap" + ? Num> + : F extends "writing" + ? { readonly [U in keyof E[F]]: Writing } + : F extends "discipline" + ? Discipline + : unknown; +}; + +type DimensionStyle = { + readonly [F in keyof E]: F extends "byDiscipline" + ? ByDiscipline + : StyleFields[F]; +}; + +type Field = { + readonly [F in keyof E]: F extends "fills" + ? FillOf + : F extends "source" + ? SourceOf + : unknown; +}; + +type Xref = { + readonly [F in keyof E]: F extends "name" + ? Str> + : F extends "path" + ? Str> + : unknown; +}; + +type TitleBlock = { + readonly [F in keyof E]: F extends "paper" + ? Str> + : F extends "layer" + ? NameIn + : F extends "blockName" + ? Str> + : F extends "xref" + ? Xref + : F extends "fields" + ? { + readonly [T in keyof E[F]]: T extends string + ? [AttributeTag] extends [T] + ? Field + : AttributeTag + : unknown; + } + : unknown; +}; + +type SheetFormat = { + readonly [F in keyof E]: F extends "paper" + ? Str> + : F extends "titleBlock" + ? NameIn + : F extends "plotStyle" + ? Str> + : F extends "zones" + ? { + readonly [Z in keyof E[F]]: Z extends string + ? [IdProblem] extends [never] + ? unknown + : IdProblem + : unknown; + } + : F extends "discipline" + ? Discipline + : unknown; +}; diff --git a/src/standard/rules.ts b/src/standard/rules.ts new file mode 100644 index 0000000..aa6d90c --- /dev/null +++ b/src/standard/rules.ts @@ -0,0 +1,169 @@ +// The rules of a standard's document that are its own, each twice: as a type, so a literal +// mistake is a compile error, and as a runtime check with the same message, as `src/rules.ts` +// holds the rules of a drawing. The rules a standard shares with a drawing, such as a layer's +// name and color, are there. +import type { Section } from "./document.ts"; + +const PAPER_UNITS = 'paperUnits must be "in" or "mm"'; +const SHEET_FILES = 'sheetFiles must be "one-drawing" or "sheet-per-file"'; +const FILLS = + 'fills must be "number", "title", "project", "date", "scale", "drawnBy" or "checkedBy"'; +const SOURCE = 'source must be "attribute", "fileName", "layoutName" or "currentDate"'; +const WRITING_UNITS = 'units must be "decimal", the one way tsdraft writes a length'; +const LAYER_ZERO = + 'Layer "0" is in every drawing and in no standard. AutoCAD makes it in every drawing, and an entity of a block on it draws on its insert\'s layer'; +const ROLE_STANDARD = + "A text role cannot be named standard, in any case of its letters. Its text style would be STANDARD, which AutoCAD takes for the text style Standard that every drawing has"; +const DIMENSION_STYLE_STANDARD = + "A dimension style cannot be named standard, in any case of its letters. Its name in AutoCAD would be STANDARD, which AutoCAD takes for the dimension style Standard that every drawing has"; +const STANDARD_ID = + "A standard's id has 1 to 128 characters, each a letter A to Z or a to z, a digit, ., -, _, @ or /"; +const STANDARD_VERSION = + "A standard's version has 1 to 64 characters, none of them a control character or a character above U+FFFF"; +const SCALE_LABEL = + "A scale's label has 1 to 64 characters, none of them a control character or a character above U+FFFF"; + +/** How a message names an entry of each section that a field names. */ +const REFERENCES = { + disciplines: ["Discipline", "a discipline", "disciplines"], + pens: ["Pen", "a pen", "pens"], + linetypes: ["Linetype", "a linetype", "linetypes or Continuous"], + layers: ["Layer", "a layer", "layers"], + textRoles: ["Text role", "a text role", "text roles"], + scales: ["Scale", "a scale", "scales"], + dimensionStyles: ["Dimension style", "a dimension style", "dimension styles"], + titleBlocks: ["Title block", "a title block", "title blocks"], + sheetFormats: ["Sheet format", "a sheet format", "sheet formats"], +} as const satisfies Record; + +type Reference< + S extends Section, + V extends string, +> = `${(typeof REFERENCES)[S][0]} "${V}" is not ${(typeof REFERENCES)[S][1]} of the standard. Add it to ${S}, or name one of its ${(typeof REFERENCES)[S][2]}`; + +/** + * Resolves to `V` when it names an entry of the section `S`, whose names are `Names`, and to + * the error message otherwise. A name that is not a literal, or a section whose names are not + * literals, passes the compiler and is checked when the code runs, where AutoCAD's folding of + * case applies. + */ +export type NameIn = V extends string + ? string extends V + ? V + : string extends Names + ? V + : V extends Names + ? V + : Reference + : V; + +/** + * What is wrong with a field that names an entry of `section` the standard does not have. + * `owner` is the section of the entry that holds the field, when that entry is the parent's. + */ +export function referenceProblem(section: Section, name: string, owner?: Section): string { + const [what, a, plural] = REFERENCES[section]; + const parents = owner ? `, which the parent's ${REFERENCES[owner][0].toLowerCase()} names,` : ""; + return `${what} ${JSON.stringify(name)}${parents} is not ${a} of the standard. Add it to ${section}, or name one of its ${plural}`; +} + +/** + * What is wrong with a default the standard takes from its parent, `field`, when it names an + * entry of `section` the standard removed. + */ +export function inheritedDefaultProblem(field: string, section: Section, name: string): string { + const [what, a, plural] = REFERENCES[section]; + return `${what} ${JSON.stringify(name)}, the parent's ${field}, is not ${a} of the standard. Set ${field} to one of its ${plural}`; +} + +/** Resolves to `V` when it is one of `Allowed`, and to `Message` otherwise. */ +type OneOf = V extends string + ? string extends V + ? V + : V extends Allowed + ? V + : Message + : V; + +const oneOf = + (allowed: readonly string[], message: string) => + (value: unknown): string | undefined => + allowed.includes(value as string) ? undefined : message; + +export type PaperUnitsOf = OneOf; +export const paperUnitsProblem = oneOf(["in", "mm"], PAPER_UNITS); + +export type SheetFilesOf = OneOf; +export const sheetFilesProblem = oneOf(["one-drawing", "sheet-per-file"], SHEET_FILES); + +export const FILL_KEYS = [ + "number", + "title", + "project", + "date", + "scale", + "drawnBy", + "checkedBy", +] as const; +export type FillOf = OneOf; +export const fillProblem = oneOf(FILL_KEYS, FILLS); + +export const SOURCES = ["attribute", "fileName", "layoutName", "currentDate"] as const; +export type SourceOf = OneOf; +export const sourceProblem = oneOf(SOURCES, SOURCE); + +export type WritingUnitsOf = OneOf; +export const writingUnitsProblem = oneOf(["decimal"], WRITING_UNITS); + +/** Resolves to the message when `N` is layer `0`, and to `never` otherwise. */ +export type LayerZero = N extends "0" ? typeof LAYER_ZERO : never; +export function layerZeroProblem(name: string): string | undefined { + return name === "0" ? LAYER_ZERO : undefined; +} + +/** The text style of a role, and the name of a dimension style: an id with a to z in capitals. */ +export function capitals(id: string): string { + return id.replace(/[a-z]+/g, (letters) => letters.toUpperCase()); +} + +/** Resolves to the message when the role `I` would have the text style `STANDARD`. */ +export type RoleStandard = + Uppercase extends "STANDARD" ? typeof ROLE_STANDARD : never; +export function roleNameProblem(id: string): string | undefined { + return capitals(id) === "STANDARD" ? ROLE_STANDARD : undefined; +} + +/** Resolves to the message when the dimension style `I` would be named `STANDARD`. */ +export type DimensionStyleStandard = + Uppercase extends "STANDARD" ? typeof DIMENSION_STYLE_STANDARD : never; +export function dimensionStyleIdProblem(id: string): string | undefined { + return capitals(id) === "STANDARD" ? DIMENSION_STYLE_STANDARD : undefined; +} + +/** Returns what is wrong with a standard's id, or undefined. */ +export function standardIdProblem(id: string): string | undefined { + return /^[A-Za-z0-9._@/-]{1,128}$/.test(id) ? undefined : STANDARD_ID; +} + +/** + * True for a value a custom drawing property can hold, of 1 to `most` characters: no control + * character, from U+0000 to U+001F and U+007F, and no character above U+FFFF. + */ +function propertyValue(value: string, most: number): boolean { + if (value.length < 1 || value.length > most) return false; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code === 0x7f || (code >= 0xd800 && code <= 0xdfff)) return false; + } + return true; +} + +/** Returns what is wrong with a standard's version, or undefined. */ +export function standardVersionProblem(version: string): string | undefined { + return propertyValue(version, 64) ? undefined : STANDARD_VERSION; +} + +/** Returns what is wrong with a scale's label, or undefined. */ +export function scaleLabelProblem(label: string): string | undefined { + return propertyValue(label, 64) ? undefined : SCALE_LABEL; +} diff --git a/src/standard/shape.ts b/src/standard/shape.ts new file mode 100644 index 0000000..56031d6 --- /dev/null +++ b/src/standard/shape.ts @@ -0,0 +1,1057 @@ +// The one table of a standard's fields. It gives each field's type, the rule of a single value, +// its default and whether the hash reads it. `readDocument` checks a document against it, +// `defineStandard` fills the defaults from it and the hash leaves out what it marks, and +// `standardSchema` writes the JSON Schema from it, so the three cannot drift apart. +import { ARROWHEADS, DEFAULT_ARROWHEAD, ZERO_SUPPRESSIONS } from "../arrowheads.ts"; +import { FONTS } from "../fonts.ts"; +import { layerKey, rejectedNameCharacter } from "../layers.ts"; +import { LINEWEIGHTS } from "../linetypes.ts"; +import { DEFAULT_PLOT_STYLE_TABLE, PAPERS, PLOT_STYLE_TABLES } from "../papers.ts"; +import { + arrowheadProblem, + attributeTagProblem, + blockNameProblem, + dimensionPrefixProblem, + fontProblem, + idKey, + idProblem, + layerColorProblem, + layerNameProblem, + lineweightValueProblem, + linetypeProblem, + linetypeScaleProblem, + measurementScaleProblem, + notNegativeProblem, + obliqueAngleProblem, + paperProblem, + plainDimensionText, + plotStyleProblem, + precisionProblem, + ratioProblem, + shown, + sizeProblem, + textGapProblem, + textProblem, + widthFactorProblem, + xrefPathProblem, + zeroSuppressionProblem, +} from "../rules.ts"; +import { canonical } from "../json.ts"; +import { isRecord, PLAIN_OBJECT } from "../shape.ts"; +import { VERSION } from "../version.ts"; +import type { + DimensionStyleDocument, + DimensionStyleFieldsDocument, + DisciplineDocument, + LayerDocument, + LayerFieldsDocument, + LinetypeDocument, + PenDocument, + ScaleDocument, + SheetFormatDocument, + StandardDocument, + TextRoleDocument, + TextRoleFieldsDocument, + TitleBlockDocument, + TitleBlockFieldDocument, + WritingDocument, + XrefDocument, +} from "./document.ts"; +import { + dimensionStyleIdProblem, + FILL_KEYS, + fillProblem, + layerZeroProblem, + paperUnitsProblem, + roleNameProblem, + scaleLabelProblem, + sheetFilesProblem, + SOURCES, + sourceProblem, + standardIdProblem, + standardVersionProblem, + writingUnitsProblem, +} from "./rules.ts"; + +/** A JSON Schema, or a part of one. */ +export type Json = { readonly [key: string]: unknown }; + +/** A field of an object of the document. */ +export interface Field { + readonly node: Node; + readonly description: string; + /** Required in every document. */ + readonly required?: true; + /** Required in a document that extends nothing. */ + readonly root?: true; + /** What a document that leaves the field out has. A default, once released, never changes. */ + readonly default?: unknown; + /** Left out of the hash: provenance, and text for people that no rule reads. */ + readonly unhashed?: true; + /** Left out of the `Standard`. */ + readonly dropped?: true; +} + +type Check = (value: T) => string | undefined; + +export type Node = + | { readonly kind: "string"; readonly schema: Json; readonly check?: Check } + | { readonly kind: "number"; readonly schema: Json; readonly check?: Check } + | { readonly kind: "boolean" } + | { readonly kind: "numbers"; readonly schema: Json; readonly check?: Check } + | RecordNode + | MapNode + | { readonly kind: "drawing"; readonly description: string } + | { readonly kind: "extends" }; + +export interface RecordNode { + readonly kind: "record"; + /** Its name among the schema's definitions. */ + readonly name: string; + /** What it is, for a message, such as "a layer". */ + readonly what: string; + readonly fields: Readonly>; + /** A rule of the whole object, given the key it has in its map, reported at the object. */ + readonly check?: (value: Readonly>, key: string) => string | undefined; +} + +export interface MapNode { + readonly kind: "map"; + /** The schema of its keys. */ + readonly keys: Json; + readonly key: Check; + /** For keys that are names: the form two are compared in, and the problem of a second. */ + readonly fold?: Fold; + readonly value: Node; + /** True for a section, where `null` removes the parent's entry. */ + readonly nullable?: true; +} + +export interface Fold { + readonly key: (name: string) => string; + /** What is wrong with a key whose fold is that of the key `first` before it. */ + readonly twin: (first: string) => string; +} + +type Keys = { readonly keys: Json; readonly key: Check; readonly fold?: Fold }; + +/** The fields of the document type `T`, each in the table, so a new field is a compile error here. */ +type FieldsOf = { readonly [K in Exclude]-?: Field }; + +function record( + name: string, + what: string, + fields: FieldsOf, + check?: RecordNode["check"], +): RecordNode { + return check + ? { kind: "record", name, what, fields, check } + : { kind: "record", name, what, fields }; +} + +/** The same fields, none required and none with a default: the fields `byDiscipline` replaces. */ +function replacing(fields: Readonly>): Readonly> { + const out: Record = {}; + for (const [key, field] of Object.entries(fields)) { + const { required: _, default: __, root: ___, ...rest } = field; + out[key] = rest; + } + return out; +} + +// The characters of each kind of text, as a class of a regular expression that reads the same +// with and without the `u` flag: it names only characters up to U+FFFF, so a character above +// U+FFFF matches it in neither reading. +const hex = (code: number): string => `\\u${code.toString(16).padStart(4, "0")}`; +function characterClass(allowed: (code: number) => boolean): string { + let out = ""; + for (let code = 0; code <= 0xffff; code++) { + if (!allowed(code)) continue; + let end = code; + while (end < 0xffff && allowed(end + 1)) end++; + out += end === code ? hex(code) : `${hex(code)}-${hex(end)}`; + code = end; + } + return `[${out}]`; +} +const surrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdfff; +const isText = (code: number): boolean => + code >= 0x20 && code !== 0x7f && !surrogate(code) && !(code >= 0xe800 && code <= 0xe8ff); +const isNameCharacter = (code: number): boolean => + code >= 0x20 && + code !== 0x7f && + !surrogate(code) && + !'<>/\\":;?*|=`^,'.includes(String.fromCharCode(code)) && + rejectedNameCharacter(String.fromCharCode(code)) === undefined; + +/** A word in any case of its letters, for a lookahead. */ +const anyCase = (word: string): string => + [...word].map((c) => (/[a-z]/i.test(c) ? `[${c.toUpperCase()}${c.toLowerCase()}]` : c)).join(""); + +/** True for a text character that is none of `characters`. */ +const textBut = + (characters: string) => + (code: number): boolean => + isText(code) && !characters.includes(String.fromCharCode(code)); + +const patterns = { + text: () => `^${characterClass(isText)}*$`, + /** A dimension style's suffix: a text with none of the characters AutoCAD reads as formatting. */ + suffix: () => `^${characterClass(textBut("\\{}"))}*$`, + /** A prefix: a suffix with no `<>`, which AutoCAD takes for the place of the measurement. */ + prefix: () => `^(?![\\s\\S]*<>)${characterClass(textBut("\\{}"))}*$`, + /** + * An xref's path: from the folder of the drawing's file, with no `* ? < > | : "` and no + * multibyte escape, and not ending in `.dxf`, or in a dot other than the one after `.dwg`. + */ + xrefPath: () => + `^(?![/\\\\]|[A-Za-z]:)(?![\\s\\S]*\\.[Dd][Xx][Ff]$)(?=(?![\\s\\S]*\\.$)|[\\s\\S]*\\.[Dd][Ww][Gg]\\.$)(?![\\s\\S]*\\\\[Mm]\\+[1-5][0-9A-Fa-f]{4})${characterClass(textBut('*?<>|:"'))}+$`, + tag: () => `^${characterClass((c) => isText(c) && !(c >= 0x61 && c <= 0x7a))}+$`, + property: () => `^${characterClass((c) => c >= 0x20 && c !== 0x7f && !surrogate(c))}{1,64}$`, + /** An AutoCAD name, of a layer, a linetype or a block, with `exclude` refused in any case. */ + name: (...exclude: string[]) => + `^(?! )(?![\\s\\S]* $)${exclude.length ? `(?!(?:${exclude.map(anyCase).join("|")})$)` : ""}${characterClass(isNameCharacter)}{1,255}$`, + id: (...exclude: string[]) => + `^${exclude.length ? `(?!(?:${exclude.map(anyCase).join("|")})$)` : ""}[A-Za-z0-9._-]{1,64}$`, +}; + +// Each pattern is worked out when a schema is written, not when the library loads. +const lazy = (pattern: () => string, extra: Json = {}): Json => + Object.defineProperty({ ...extra }, "pattern", { get: pattern, enumerable: true }); + +const string = (schema: Json = {}, check?: Check): Node => + check ? { kind: "string", schema, check } : { kind: "string", schema }; +const number = (schema: Json = {}, check?: Check): Node => + check ? { kind: "number", schema, check } : { kind: "number", schema }; +const boolean: Node = { kind: "boolean" }; +const text: Node = string(lazy(patterns.text), textProblem); +const box: Node = { + kind: "numbers", + schema: { minItems: 4, maxItems: 4, description: "[left, bottom, right, top] on the paper" }, + check: (value) => + value.length === 4 ? undefined : "A box is four numbers: [left, bottom, right, top]", +}; +const point: Node = { + kind: "numbers", + schema: { minItems: 2, maxItems: 2 }, + check: (value) => (value.length === 2 ? undefined : "A point is two numbers: [x, y]"), +}; +/** Two keys that are one: by `idKey`, or by `layerKey` for `names`, among `owner`'s `what`s. */ +const twins = (owner: string, what: string, names?: true): Fold => ({ + key: names ? layerKey : idKey, + twin: (first) => + names + ? `${owner} already has a ${what} of this name, as ${shown(first)}. AutoCAD takes the two names for one` + : `${owner} already has a ${what} of this id, as ${shown(first)}. Ids are compared without regard to case`, +}); +/** Keys that are ids of `owner`'s `what`s. */ +const ids = ( + owner: string, + what: string, + check: Check = () => undefined, + ...exclude: string[] +): Keys => ({ + keys: lazy(() => patterns.id(...exclude)), + key: (key: string) => idProblem(key) ?? check(key), + fold: twins(owner, what), +}); +const map = (keys: Keys, value: Node, nullable?: true): Node => + nullable ? { kind: "map", ...keys, value, nullable } : { kind: "map", ...keys, value }; +const discipline: Field = { + node: string(), + description: + "The discipline the entry belongs to, a discipline of the standard. An entry without one belongs to every discipline", +}; +const description: Field = { + node: text, + description: "For people and the specimen. No rule reads it", + unhashed: true, +}; + +const DISCIPLINE = record("discipline", "a discipline", { + title: { node: text, description: "For people", required: true, unhashed: true }, +}); + +const PEN = record("pen", "a pen", { + lineweight: { + node: number({ enum: LINEWEIGHTS }, lineweightValueProblem), + description: "One of AutoCAD's lineweights, in millimetres", + required: true, + }, + description, +}); + +const LINETYPE = record( + "linetype", + "a linetype", + { + description: { + node: string(lazy(patterns.text, { maxLength: 249 })), + description: "What AutoCAD's LINETYPE command shows for it", + default: "", + }, + pattern: { + node: { kind: "numbers", schema: { maxItems: 12, not: { minItems: 1, maxItems: 1 } } }, + description: + "2 to 12 lengths in paper units, before linetypeScale, or none for a solid line: a dash above 0, a gap below 0 and a dot 0", + required: true, + }, + }, + (value) => + linetypeProblem( + "linetype", + value.description === undefined ? "" : value.description, + value.pattern, + ), +); + +const LAYER_FIELDS = { + color: { + node: number({ type: "integer", minimum: 1, maximum: 255 }, layerColorProblem), + description: "An AutoCAD Color Index from 1 to 255", + required: true, + }, + linetype: { + node: string(), + description: "A linetype of the standard, or Continuous", + default: "Continuous", + }, + pen: { + node: string(), + description: 'A pen of the standard. Without one the layer has AutoCAD\'s lineweight "Default"', + }, + plot: { node: boolean, description: "Whether the layer plots", default: true }, + description, +} as const satisfies FieldsOf; + +const LAYER_REPLACED = record( + "layerFields", + "a layer's fields for a discipline", + replacing(LAYER_FIELDS) as FieldsOf, +); + +const byDiscipline = (fields: RecordNode): Field => ({ + node: map(ids("byDiscipline", "discipline"), fields), + description: + "Fields that replace the entry's own in each discipline's drawings, by discipline. Only under sheetFiles \"sheet-per-file\"", +}); + +const LAYER = record("layer", "a layer", { + ...LAYER_FIELDS, + discipline, + byDiscipline: byDiscipline(LAYER_REPLACED), +}); + +const ROLE_FIELDS = { + font: { + node: string({ enum: Object.keys(FONTS) }, fontProblem), + description: "One of FONTS, as AutoCAD stores it", + required: true, + }, + height: { + node: number({ exclusiveMinimum: 0 }, sizeProblem), + description: "The plotted height of a capital, in paper units, at least minTextHeight", + required: true, + }, + widthFactor: { + node: number({ minimum: 0.01, maximum: 100 }, widthFactorProblem), + description: "How wide each character is drawn, as a share of its own width", + default: 1, + }, + obliqueAngle: { + node: number({ minimum: -85, maximum: 85 }, obliqueAngleProblem), + description: "The slant of each character, in degrees to the right", + default: 0, + }, + description, +} as const satisfies FieldsOf; + +const ROLE = record("textRole", "a text role", { + ...ROLE_FIELDS, + discipline, + byDiscipline: byDiscipline( + record( + "textRoleFields", + "a text role's fields for a discipline", + replacing(ROLE_FIELDS) as FieldsOf, + ), + ), +}); + +const SCALE = record("scale", "a scale", { + label: { + node: string(lazy(patterns.property), scaleLabelProblem), + description: "The name AutoCAD's scale list gives the scale, such as 1\" = 10'", + required: true, + }, + ratio: { + node: number({ exclusiveMinimum: 0 }, ratioProblem), + description: "Drawing lengths per length on paper, such as 120 for 1\" = 10'", + required: true, + }, + discipline, +}); + +const WRITING = record("writing", "a dimension style's writing", { + units: { + node: string({ enum: ["decimal"] }, writingUnitsProblem), + description: "How a length is written: decimal, the one way tsdraft writes one", + default: "decimal", + }, + precision: { + node: number({ type: "integer", minimum: 0, maximum: 8 }, precisionProblem), + description: "The decimals a measurement is written with, from 0 to 8", + required: true, + }, + prefix: { + node: string(lazy(patterns.prefix), dimensionPrefixProblem), + description: "Written before each measurement", + }, + suffix: { + node: string(lazy(patterns.suffix), plainDimensionText), + description: "Written after each measurement", + }, + suppressZeros: { + node: string({ enum: ZERO_SUPPRESSIONS }, zeroSuppressionProblem), + description: "The zeros of a measurement that are left out", + }, + measurementScale: { + node: number({ exclusiveMinimum: 0 }, measurementScaleProblem), + description: "Each measurement is written times this", + default: 1, + }, +}); + +const unitsOf = (what: string): Node => + record>("writings", what, { + in: { node: WRITING, description: "In a drawing in inches" }, + ft: { node: WRITING, description: "In a drawing in feet" }, + mm: { node: WRITING, description: "In a drawing in millimetres" }, + cm: { node: WRITING, description: "In a drawing in centimetres" }, + m: { node: WRITING, description: "In a drawing in metres" }, + }); + +const size = (check: Check, what: string): Field => ({ + node: number({ minimum: 0 }, check), + description: `${what}, on paper`, + required: true, +}); + +const STYLE_FIELDS = { + role: { + node: string(), + description: "The text role of the dimension's text, which gives its text style and height", + required: true, + }, + arrowhead: { + node: string({ enum: Object.keys(ARROWHEADS) }, arrowheadProblem), + description: "One of ARROWHEADS", + default: DEFAULT_ARROWHEAD, + }, + arrowSize: size(notNegativeProblem, "The size of an arrowhead"), + extensionOffset: size(notNegativeProblem, "How far an extension line starts from its point"), + extensionBeyond: size( + notNegativeProblem, + "How far an extension line runs past the dimension line", + ), + textGap: size(textGapProblem, "The gap between the text and the dimension line"), + writing: { + node: unitsOf("a dimension style's writing"), + description: + "How a length is written in each unit of a drawing. A drawing in a unit it does not list cannot draw a dimension in the style", + default: {}, + }, + description, +} as const satisfies FieldsOf; + +const DIMENSION_STYLE = record("dimensionStyle", "a dimension style", { + ...STYLE_FIELDS, + discipline, + byDiscipline: byDiscipline( + record( + "dimensionStyleFields", + "a dimension style's fields for a discipline", + replacing(STYLE_FIELDS) as FieldsOf, + ), + ), +}); + +const FIELD = record("titleBlockField", "a title block's field", { + fills: { + node: string({ enum: FILL_KEYS }, fillProblem), + description: "What the sheet fills the field with. A field without one is filled by its tag", + }, + box: { node: box, description: "With a box, lint warns of a value that runs past it" }, + source: { + node: string({ enum: SOURCES }, sourceProblem), + description: + "attribute: an attribute of the block that the builder sets. fileName, layoutName and currentDate: a field of the xref's drawing that AutoCAD fills", + default: "attribute", + }, +}); + +const XREF = record("xref", "an xref", { + name: { + node: string( + lazy(() => patterns.name("_ArchTick")), + blockNameProblem, + ), + description: "The xref's name", + required: true, + }, + path: { + node: string(lazy(patterns.xrefPath, { maxLength: 32000 }), xrefPathProblem), + description: "The path of the firm's DWG as each sheet's file sees it", + required: true, + }, + at: { node: point, description: "Where the xref is inserted on the paper", default: [0, 0] }, +}); + +const drawing = (what: string): Node => ({ + kind: "drawing", + description: `${what}: a Drawing as JSON, or a string that names one`, +}); + +const TITLE_BLOCK = record("titleBlock", "a title block", { + paper: { + node: string({ enum: Object.keys(PAPERS) }, paperProblem), + description: "One of PAPERS", + required: true, + }, + block: { + node: drawing("The drawing that holds the block blockName, in paper units"), + description: "The drawing that holds the block blockName", + }, + blockName: { + node: string( + lazy(() => patterns.name("_ArchTick")), + blockNameProblem, + ), + description: "The block of block that the builder inserts at [0, 0] on each sheet", + }, + xref: { node: XREF, description: "The firm's own DWG, referenced by each sheet as an overlay" }, + preview: { + node: drawing("What the pictures draw in place of the xref"), + description: "What the pictures draw in place of the xref", + }, + layer: { + node: string(), + description: "A layer of the standard, which the title block is inserted on", + required: true, + }, + drawingArea: { + node: box, + description: "The part of the paper inside the border", + required: true, + }, + box: { + node: box, + description: + "The title block's own rectangle inside the drawing area, which no viewport may reach into", + }, + fields: { + node: map( + { + keys: lazy(patterns.tag), + key: attributeTagProblem, + }, + FIELD, + ), + description: "The fields a sheet fills, by tag", + default: {}, + }, +}); + +const SHEET_FORMAT = record("sheetFormat", "a sheet format", { + paper: { + node: string({ enum: Object.keys(PAPERS) }, paperProblem), + description: "One of PAPERS, which is its title block's paper", + required: true, + }, + titleBlock: { node: string(), description: "A title block of the standard", required: true }, + plotStyle: { + node: string({ enum: PLOT_STYLE_TABLES }, plotStyleProblem), + description: "The sheet format's own plot style table, in place of the standard's", + }, + zones: { + node: map(ids("The sheet format", "zone"), box), + description: + "Named boxes inside the title block's drawing area, which the code lays the sheet out by", + default: {}, + }, + discipline, +}); + +const section = (keys: Keys, entry: RecordNode, what: string): Field => ({ + node: map(keys, entry, true), + description: `${what}, by name. null removes the parent's entry of that name`, + default: {}, +}); + +const STANDARD = "The standard"; + +/** The document's top level. */ +export const DOCUMENT = record("standard", "a standard", { + $schema: { + node: string(), + description: "The JSON Schema of the document", + unhashed: true, + dropped: true, + }, + id: { + node: string({ pattern: "^[A-Za-z0-9._@/-]{1,128}$" }, standardIdProblem), + description: "1 to 128 letters, digits, ., -, _, @ and /, so a package name is one", + required: true, + unhashed: true, + }, + version: { + node: string(lazy(patterns.property), standardVersionProblem), + description: "1 to 64 characters", + required: true, + unhashed: true, + }, + title: { node: text, description: "For people. Defaults to the id", unhashed: true }, + extends: { + node: { kind: "extends" }, + description: + 'The parent: a path from this file, or a package specifier. "tsdraft/standard.json" is the built-in standard', + unhashed: true, + dropped: true, + }, + paperUnits: { + node: string({ enum: ["in", "mm"] }, paperUnitsProblem), + description: "The unit of every length on paper. A child keeps its parent's", + root: true, + }, + minTextHeight: { + node: number({ exclusiveMinimum: 0 }, sizeProblem), + description: "The least height a text may plot at, in paper units", + root: true, + }, + linetypeScale: { + node: number({ exclusiveMinimum: 0 }, linetypeScaleProblem), + description: "A dash plots its pattern's length times this, at every scale", + default: 1, + }, + plotStyle: { + node: string({ enum: PLOT_STYLE_TABLES }, plotStyleProblem), + description: "The plot style table of each layout. One of PLOT_STYLE_TABLES", + default: DEFAULT_PLOT_STYLE_TABLE, + }, + sheetFiles: { + node: string({ enum: ["one-drawing", "sheet-per-file"] }, sheetFilesProblem), + description: + "one-drawing: a set is one drawing with a layout for each sheet. sheet-per-file: each sheet is a file of its own", + default: "one-drawing", + }, + defaultSheetFormat: { + node: string(), + description: "The sheet format of a layout that names none", + }, + defaultDimensionStyle: { + node: string(), + description: "The dimension style of a dimension that names none", + }, + disciplines: section(ids(STANDARD, "discipline"), DISCIPLINE, "The disciplines"), + pens: section(ids(STANDARD, "pen"), PEN, "Named lineweights"), + linetypes: section( + { + keys: lazy(() => patterns.name("Continuous", "ByLayer", "ByBlock")), + key: (name) => linetypeProblem(name, "", []), + fold: twins(STANDARD, "linetype", true), + }, + LINETYPE, + "The linetypes. Continuous is in every standard and is never listed", + ), + layers: section( + { + keys: lazy(() => patterns.name("0")), + key: (name) => layerNameProblem(name, "R2018") ?? layerZeroProblem(name), + fold: twins(STANDARD, "layer", true), + }, + LAYER, + "The layers", + ), + textRoles: section( + ids(STANDARD, "text role", roleNameProblem, "standard"), + ROLE, + "The text roles", + ), + scales: section(ids(STANDARD, "scale"), SCALE, "The scales"), + dimensionStyles: section( + ids(STANDARD, "dimension style", dimensionStyleIdProblem, "standard"), + DIMENSION_STYLE, + "The dimension styles", + ), + titleBlocks: section(ids(STANDARD, "title block"), TITLE_BLOCK, "The title blocks"), + sheetFormats: section(ids(STANDARD, "sheet format"), SHEET_FORMAT, "The sheet formats"), +}); + +/** + * An object of these entries, as `Object.fromEntries` makes it, which is several times slower + * for an object of many keys. A key `__proto__` is a field like any other. + */ +export function entriesObject( + entries: Iterable, +): Record { + const out: Record = {}; + for (const [key, value] of entries) { + if (key !== "__proto__") out[key] = value; + else + Object.defineProperty(out, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + return out; +} + +const FIELDS = new WeakMap(); +/** The fields of a record, in the table's order, listed once. */ +function fieldsOf(node: RecordNode): readonly (readonly [string, Field])[] { + let fields = FIELDS.get(node); + if (!fields) FIELDS.set(node, (fields = Object.entries(node.fields))); + return fields; +} + +/** The nodes that hold a drawing. */ +const DRAWN: ReadonlySet = new Set([ + TITLE_BLOCK, + DOCUMENT.fields.titleBlocks!.node, + TITLE_BLOCK.fields.block!.node, + TITLE_BLOCK.fields.preview!.node, +]); + +/** A problem of a document: the path of what it is about, and what is wrong. */ +export interface Problem { + readonly item: string; + readonly message: string; +} + +const at = (path: string, key: string): string => (path ? `${path}.${key}` : key); +const entry = (path: string, key: string): string => `${path}[${JSON.stringify(key)}]`; + +/** + * The version a document's `$schema` names, when it names a later tsdraft than this one, such + * as `https://cdn.jsdelivr.net/npm/tsdraft@0.5.6/standard.schema.json`. + */ +function laterVersion(schema: unknown): string | undefined { + if (typeof schema !== "string") return undefined; + const named = /tsdraft@((\d+)\.(\d+)\.(\d+)[^/]*)\/standard\.schema\.json$/.exec(schema); + if (!named) return undefined; + const ours = VERSION.split(".").map(Number); + for (let i = 0; i < 3; i++) { + const theirs = Number(named[i + 2]); + if (theirs !== ours[i]) return theirs > ours[i]! ? named[1] : undefined; + } + return undefined; +} + +/** + * Checks a document against the table, field by field, and adds each problem to `problems`. + * True when it found none. + */ +export function readDocument(document: unknown, problems: Problem[]): boolean { + const before = problems.length; + const later = isRecord(document) ? laterVersion(document.$schema) : undefined; + const update = later ? `. This standard was written for tsdraft ${later}: update tsdraft` : ""; + const add = (item: string, message: string) => problems.push({ item, message }); + + /** + * Reads `value` at `path`. `name` is the field's name, or the key of a map's entry, in which + * case `key` is empty and a message shows the name quoted. + */ + const read = (node: Node, value: unknown, path: string, name: string, key: string): void => { + const label = (): string => (key ? name : shown(name)); + switch (node.kind) { + case "string": + if (typeof value !== "string") add(path, `${label()} must be a string`); + else { + const problem = node.check?.(value); + if (problem) add(path, problem); + } + return; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) { + add(path, `${label()} must be a finite number`); + } else { + const problem = node.check?.(value); + if (problem) add(path, problem); + } + return; + case "boolean": + if (typeof value !== "boolean") add(path, `${label()} must be true or false`); + return; + case "numbers": { + const items = value as readonly unknown[]; + let plain = Array.isArray(value); + for (let i = 0; plain && i < items.length; i++) { + plain = i in items && typeof items[i] === "number" && Number.isFinite(items[i]); + } + if (!plain) add(path, `${label()} must be an array of finite numbers`); + else { + const problem = node.check?.(items as readonly number[]); + if (problem) add(path, problem); + } + return; + } + case "drawing": + if (typeof value !== "string" && !isRecord(value)) { + add(path, `${label()} must be a drawing as JSON, or a string that names one`); + } + return; + case "extends": + if (typeof value !== "string" && (typeof value !== "object" || value === null)) { + add(path, `${label()} must be a standard, or a string that names one`); + } + return; + case "map": + if (!isRecord(value)) { + add(path, `${label()} ${PLAIN_OBJECT}`); + return; + } + const seen = node.fold && new Map(); + for (const [name, item] of Object.entries(value)) { + const where = entry(path, name); + const keyProblem = node.key(name); + if (keyProblem) add(where, keyProblem); + else if (seen) { + const key = node.fold!.key(name); + const first = seen.get(key); + if (first === undefined) seen.set(key, name); + else add(where, node.fold!.twin(first)); + } + if (item === null && node.nullable) continue; + read(node.value, item, where, name, ""); + } + return; + case "record": { + if (!isRecord(value)) { + add(path, `${label()} ${PLAIN_OBJECT}`); + return; + } + const start = problems.length; + for (const [name, field] of Object.entries(value)) { + if (name === "$comment") { + if (typeof field !== "string") add(at(path, name), "$comment must be a string"); + continue; + } + const known = Object.hasOwn(node.fields, name) ? node.fields[name] : undefined; + if (!known) { + add( + at(path, name), + `${JSON.stringify(name)} is not a field of ${node.what} in tsdraft ${VERSION}${update}`, + ); + } else if (field !== undefined) { + read(known.node, field, at(path, name), name, name); + } + } + const alone = node === DOCUMENT && value.extends === undefined; + for (const [name, field] of fieldsOf(node)) { + if (value[name] !== undefined) continue; + if (field.required) { + add( + at(path, name), + `${capital(node.what)} must have the field ${JSON.stringify(name)}`, + ); + } else if (field.root && alone) { + add( + at(path, name), + `A standard that extends nothing must have the field ${JSON.stringify(name)}`, + ); + } + } + if (problems.length === start && node.check) { + const problem = node.check(value, key || name); + if (problem) add(path, problem); + } + return; + } + } + }; + read(DOCUMENT, document, "", "The standard", "standard"); + return problems.length === before; +} + +const capital = (what: string): string => what.charAt(0).toUpperCase() + what.slice(1); + +/** + * Fills each default the table gives, for an object of the document whose fields have passed + * `readDocument`, and leaves out `$comment` and each dropped field. A drawing is left to + * `drawingOf`, which gives the one it names. + */ +export function withDefaults( + node: Node, + value: unknown, + drawingOf: (value: unknown, path: string) => unknown, + path: string, +): unknown { + // Only a drawing's path is ever shown, so no other path is worked out. + if (!DRAWN.has(node)) path = ""; + switch (node.kind) { + case "numbers": + return Object.freeze([...(value as readonly number[])]); + case "drawing": + return drawingOf(value, path); + case "map": + return Object.freeze( + entriesObject( + Object.entries(value as Record).map(([name, item]) => [ + name, + withDefaults(node.value, item, drawingOf, path && entry(path, name)), + ]), + ), + ); + case "record": { + const given = value as Record; + const out: Record = {}; + for (const [name, field] of fieldsOf(node)) { + if (field.dropped) continue; + const own = given[name]; + if (own !== undefined) out[name] = withDefaults(field.node, own, drawingOf, at(path, name)); + else if (field.default !== undefined) { + out[name] = withDefaults(field.node, field.default, drawingOf, at(path, name)); + } + } + return Object.freeze(out); + } + default: + return value; + } +} + +const SORTED = new WeakMap(); +/** The fields of a record by name in code-unit order, as `canonical` writes keys. */ +function sortedFieldsOf(node: RecordNode): readonly (readonly [string, Field])[] { + let fields = SORTED.get(node); + if (!fields) { + fields = fieldsOf(node).toSorted(([a], [b]) => (a < b ? -1 : 1)); + SORTED.set(node, fields); + } + return fields; +} + +/** True when two values are one value, as their canonical JSON says. */ +export const same = (a: unknown, b: unknown): boolean => + a === b || (typeof a === "object" && typeof b === "object" && canonical(a) === canonical(b)); + +/** + * The canonical JSON of what the hash reads of a resolved standard: every field but those the + * table marks as unhashed, and none whose value is its default, with each drawing whole. It is + * the JSON `canonical` writes for that value. `written` gives a drawing's JSON when it is known. + */ +export function hashedJson( + standard: unknown, + written: (value: object) => string | undefined, +): string { + let out = ""; + const write = (node: Node, value: unknown): void => { + if (node.kind === "record" || node.kind === "map") { + const given = value as Readonly>; + let first = true; + const field = (name: string, item: Node, own: unknown): void => { + out += `${first ? "{" : ","}${JSON.stringify(name)}:`; + first = false; + write(item, own); + }; + if (node.kind === "map") { + for (const key of Object.keys(given).sort()) field(key, node.value, given[key]); + } else { + for (const [name, known] of sortedFieldsOf(node)) { + const own = given[name]; + if (own === undefined || known.unhashed) continue; + if (known.default !== undefined && same(own, known.default)) continue; + field(name, known.node, own); + } + } + out += first ? "{}" : "}"; + } else { + out += canonical(value, "A standard", written); + } + }; + write(DOCUMENT, standard); + return out; +} + +/** The JSON Schema of a standard's document, for the tsdraft of `version`. */ +export function standardSchema(version: string): Json { + const definitions: Record = {}; + const schemaOf = (node: Node): Json => { + switch (node.kind) { + case "string": + return { type: "string", ...plain(node.schema) }; + case "number": + return { type: "number", ...plain(node.schema) }; + case "boolean": + return { type: "boolean" }; + case "numbers": + return { type: "array", items: { type: "number" }, ...plain(node.schema) }; + case "drawing": + return { description: node.description, anyOf: [{ type: "object" }, { type: "string" }] }; + case "extends": + return { type: "string" }; + case "map": { + const value = schemaOf(node.value); + return { + type: "object", + propertyNames: plain(node.keys), + additionalProperties: node.nullable ? { anyOf: [value, { type: "null" }] } : value, + }; + } + case "record": { + if (node !== DOCUMENT && !definitions[node.name]) { + definitions[node.name] = {}; + definitions[node.name] = objectOf(node); + } + return node === DOCUMENT ? objectOf(node) : { $ref: `#/$defs/${node.name}` }; + } + } + }; + const objectOf = (node: RecordNode): Json => { + const properties: Record = { + $comment: { type: "string", description: "For people. No rule reads it" }, + }; + for (const [name, field] of fieldsOf(node)) { + const schema = schemaOf(field.node); + properties[name] = { + ...schema, + ...(schema.description === undefined ? { description: field.description } : {}), + ...(field.default === undefined ? {} : { default: field.default }), + }; + } + const required = fieldsOf(node) + .filter(([, field]) => field.required) + .map(([name]) => name); + const root = fieldsOf(node) + .filter(([, field]) => field.root) + .map(([name]) => name); + return { + type: "object", + description: capital(node.what), + properties, + ...(required.length ? { required } : {}), + // A document that extends nothing has the root fields. + ...(root.length ? { anyOf: [{ required: ["extends"] }, { required: root }] } : {}), + additionalProperties: false, + }; + }; + const top = schemaOf(DOCUMENT); + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: `https://cdn.jsdelivr.net/npm/tsdraft@${version}/standard.schema.json`, + title: "A tsdraft standard", + ...top, + description: + "A firm's CAD standard: its layers, pens, linetypes, text roles, scales, dimension styles, title blocks and sheet formats. What a schema cannot hold, such as a name that refers to another entry, is checked by defineStandard", + $defs: Object.fromEntries(Object.entries(definitions).sort(([a], [b]) => (a < b ? -1 : 1))), + }; +} + +/** A schema fragment as plain data: its patterns worked out, and no check. */ +function plain(schema: Json): Json { + const out: Record = {}; + for (const key of Object.keys(schema)) if (key !== "check") out[key] = schema[key]; + return out; +} diff --git a/src/standard/title-block.ts b/src/standard/title-block.ts new file mode 100644 index 0000000..3265b66 --- /dev/null +++ b/src/standard/title-block.ts @@ -0,0 +1,134 @@ +// The built-in standard's plain title block, for an ARCH D sheet: a drawing in inches that holds +// one block, `TITLE BLOCK`, with the border, the title block in its lower right corner and an +// attribute for each field. +import { createDrawing, type Drawing } from "../drawing.ts"; +import type { Box } from "./document.ts"; + +/** What `"tsdraft/title-blocks/plain-arch-d.json"` names. */ +export const PLAIN_TITLE_BLOCK_SPECIFIER = "tsdraft/title-blocks/plain-arch-d.json"; + +/** The block's name, and its text style, which is in Arial. */ +export const PLAIN_TITLE_BLOCK_NAME = "TITLE BLOCK"; + +const PAPER = { width: 36, height: 24, margin: 1 / 2 }; +const SIZE = { width: 6.5, height: 3 }; +const TEXT = { label: 1 / 16, field: 1 / 8, title: 3 / 16, number: 5 / 16 }; + +const area = { + left: PAPER.margin, + bottom: PAPER.margin, + right: PAPER.width - PAPER.margin, + top: PAPER.height - PAPER.margin, +}; +const box = { + left: area.right - SIZE.width, + bottom: area.bottom, + right: area.right, + top: area.bottom + SIZE.height, +}; +// The seal and the revisions take the left 2 in. The fields are in rows at their right: the +// project, the sheet's title, then four small fields beside the sheet number. +const split = box.left + 2; +const number = box.right - 1.75; +const rows = [box.top - 0.6, box.top - 1.4, box.top - 2.2] as const; +const middle = (split + number) / 2; + +/** The part of the paper inside the border. */ +export const PLAIN_DRAWING_AREA: Box = [area.left, area.bottom, area.right, area.top]; +/** The title block's own rectangle, in the drawing area's lower right corner. */ +export const PLAIN_BOX: Box = [box.left, box.bottom, box.right, box.top]; + +/** Each field's cell, by its tag, with what a sheet fills it with. */ +export const PLAIN_FIELDS = { + PROJECT: { fills: "project", box: [split, rows[0], box.right, box.top] }, + TITLE: { fills: "title", box: [split, rows[1], box.right, rows[0]] }, + DATE: { fills: "date", box: [split, rows[2], middle, rows[1]] }, + SCALE: { fills: "scale", box: [middle, rows[2], number, rows[1]] }, + DRAWN: { fills: "drawnBy", box: [split, box.bottom, middle, rows[2]] }, + CHECKED: { fills: "checkedBy", box: [middle, box.bottom, number, rows[2]] }, + SHEET: { fills: "number", box: [number, box.bottom, box.right, rows[1]] }, +} as const satisfies Readonly>; + +/** + * The drawing of the plain title block. Its lines and texts are on layer 0, so they draw on the + * layer of the insert, and the border and the title block's outline carry a lineweight of their + * own. + */ +export function plainTitleBlock(): Drawing { + const d = createDrawing({ units: "in", dxf: "R2018", recordCalls: false }); + const style = PLAIN_TITLE_BLOCK_NAME; + d.textStyle({ name: style, font: "Arial" }); + const pad = 1 / 16; + type Cell = readonly [left: number, bottom: number, right: number, top: number]; + /** The small fixed text that names a cell, in its upper left corner. */ + const label = ([left, , , top]: Cell, value: string): void => { + d.text([left + pad, top - pad], value, { height: TEXT.label, style, valign: "top" }); + }; + /** A field: its label, and its attribute at the foot of its cell. */ + const field = (tag: keyof typeof PLAIN_FIELDS, prompt: string, height: number): void => { + const cell = PLAIN_FIELDS[tag].box; + label(cell, prompt.toUpperCase()); + d.attribute(tag, [cell[0] + pad, cell[1] + pad * 1.5], { value: "-", prompt, height, style }); + }; + const seal: Cell = [box.left, box.top - 2, split, box.top]; + const revisions: Cell = [box.left, box.bottom, split, seal[1]]; + + d.block(PLAIN_TITLE_BLOCK_NAME, {}, () => { + d.rect([area.left, area.bottom], area.right - area.left, area.top - area.bottom, { + lineweight: 0.7, + }); + // The title block's left and top edges. The border is its other two. + d.polyline( + [ + [box.left, box.bottom], + [box.left, box.top], + [box.right, box.top], + ], + false, + { lineweight: 0.7 }, + ); + d.line([split, box.bottom], [split, box.top]); + d.line([box.left, seal[1]], [split, seal[1]]); + for (const y of rows) d.line([split, y], [y === rows[2] ? number : box.right, y]); + d.line([middle, box.bottom], [middle, rows[1]]); + d.line([number, box.bottom], [number, rows[1]]); + + label(seal, "SEAL"); + // The revisions: a heading, a row that names the columns, and three rows to fill. + label(revisions, "REVISIONS"); + const rowHeight = (revisions[3] - revisions[1]) / 5; + const columns = [ + { left: revisions[0], name: "NO." }, + { left: revisions[0] + 0.3, name: "DATE" }, + { left: revisions[0] + 0.9, name: "DESCRIPTION" }, + ]; + for (let row = 1; row < 5; row++) { + const y = revisions[3] - rowHeight * row; + d.line([revisions[0], y], [revisions[2], y]); + } + for (const column of columns) { + const top = revisions[3] - rowHeight; + if (column.left > revisions[0]) d.line([column.left, revisions[1]], [column.left, top]); + label([column.left, revisions[1], revisions[2], top], column.name); + } + + field("PROJECT", "Project", TEXT.field); + field("TITLE", "Sheet title", TEXT.title); + field("DATE", "Date", TEXT.field); + field("SCALE", "Scale", TEXT.field); + field("DRAWN", "Drawn by", TEXT.field); + field("CHECKED", "Checked by", TEXT.field); + // The sheet number is the largest text of the sheet, centered in its cell. + const sheet = PLAIN_FIELDS.SHEET.box; + label(sheet, "SHEET"); + d.attribute("SHEET", [(number + box.right) / 2, (sheet[1] + sheet[3]) / 2 - pad], { + value: "-", + prompt: "Sheet number", + height: TEXT.number, + style, + align: "center", + valign: "middle", + }); + }); + return d.build(); +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..015042b --- /dev/null +++ b/src/version.ts @@ -0,0 +1,5 @@ +// Written by scripts/standard-files.ts from package.json. Run `pnpm standard:files` after a +// change to the version. + +/** The version of this copy of tsdraft. */ +export const VERSION = "0.5.2"; diff --git a/standard.json b/standard.json new file mode 100644 index 0000000..54f4eca --- /dev/null +++ b/standard.json @@ -0,0 +1,665 @@ +{ + "$schema": "https://cdn.jsdelivr.net/npm/tsdraft@0.5.2/standard.schema.json", + "id": "tsdraft", + "version": "1", + "title": "tsdraft", + "paperUnits": "in", + "minTextHeight": 0.0625, + "linetypeScale": 0.5, + "plotStyle": "monochrome.ctb", + "sheetFiles": "one-drawing", + "defaultSheetFormat": "arch-d", + "defaultDimensionStyle": "dims", + "disciplines": { + "C": { + "title": "Civil" + }, + "G": { + "title": "General" + }, + "V": { + "title": "Survey" + } + }, + "pens": { + "bold": { + "lineweight": 0.5 + }, + "fine": { + "lineweight": 0.18 + }, + "heavy": { + "lineweight": 0.7 + }, + "light": { + "lineweight": 0.25 + }, + "medium": { + "lineweight": 0.35 + } + }, + "linetypes": { + "CENTER": { + "description": "Center ____ _ ____ _ ____ _ ____ _ ____ _ ____", + "pattern": [1.25, -0.25, 0.25, -0.25] + }, + "DASHDOT": { + "description": "Dash dot __ . __ . __ . __ . __ . __ . __ . __", + "pattern": [0.5, -0.25, 0, -0.25] + }, + "DASHED": { + "description": "Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _", + "pattern": [0.5, -0.25] + }, + "DOT": { + "description": "Dot . . . . . . . . . . . . . . . . . . . . . .", + "pattern": [0, -0.25] + }, + "HIDDEN": { + "description": "Hidden __ __ __ __ __ __ __ __ __ __ __ __ __ _", + "pattern": [0.25, -0.125] + }, + "PHANTOM": { + "description": "Phantom ______ __ __ ______ __ __ ______", + "pattern": [1.25, -0.25, 0.25, -0.25, 0.25, -0.25] + } + }, + "layers": { + "C-ANNO-DIMS": { + "color": 8, + "linetype": "Continuous", + "pen": "fine", + "plot": true, + "discipline": "C" + }, + "C-ANNO-SYMB": { + "color": 7, + "linetype": "Continuous", + "pen": "medium", + "plot": true, + "discipline": "C" + }, + "C-ANNO-TEXT": { + "color": 7, + "linetype": "Continuous", + "pen": "light", + "plot": true, + "discipline": "C" + }, + "C-ANNO-TITL": { + "color": 7, + "linetype": "Continuous", + "pen": "bold", + "plot": true, + "discipline": "C" + }, + "C-BLDG": { + "color": 5, + "linetype": "Continuous", + "pen": "heavy", + "plot": true, + "discipline": "C" + }, + "C-PAVE": { + "color": 8, + "linetype": "Continuous", + "pen": "medium", + "plot": true, + "discipline": "C" + }, + "C-PROP": { + "color": 7, + "linetype": "PHANTOM", + "pen": "heavy", + "plot": true, + "discipline": "C" + }, + "C-PROP-ADJC": { + "color": 8, + "linetype": "PHANTOM", + "pen": "fine", + "plot": true, + "discipline": "C" + }, + "C-PROP-MONU": { + "color": 7, + "linetype": "Continuous", + "pen": "light", + "plot": true, + "discipline": "C" + }, + "C-ROAD-CNTR": { + "color": 8, + "linetype": "CENTER", + "pen": "fine", + "plot": true, + "discipline": "C" + }, + "C-SETB": { + "color": 1, + "linetype": "DASHED", + "pen": "light", + "plot": true, + "discipline": "C" + }, + "G-ANNO-NPLT": { + "color": 8, + "linetype": "Continuous", + "plot": false, + "discipline": "G" + }, + "G-ANNO-TTLB": { + "color": 7, + "linetype": "Continuous", + "pen": "medium", + "plot": true, + "discipline": "G" + } + }, + "textRoles": { + "dimension": { + "font": "Arial", + "height": 0.09375, + "widthFactor": 1, + "obliqueAngle": 0 + }, + "heading": { + "font": "Arial", + "height": 0.1875, + "widthFactor": 1, + "obliqueAngle": 0 + }, + "label": { + "font": "Arial", + "height": 0.125, + "widthFactor": 1, + "obliqueAngle": 0 + }, + "note": { + "font": "Arial", + "height": 0.09375, + "widthFactor": 1, + "obliqueAngle": 0 + }, + "title": { + "font": "Arial", + "height": 0.25, + "widthFactor": 1, + "obliqueAngle": 0 + } + }, + "scales": { + "1-1-2in-1ft": { + "label": "1 1/2\" = 1'-0\"", + "ratio": 8 + }, + "1-2in-1ft": { + "label": "1/2\" = 1'-0\"", + "ratio": 24 + }, + "1-4in-1ft": { + "label": "1/4\" = 1'-0\"", + "ratio": 48 + }, + "1-8in-1ft": { + "label": "1/8\" = 1'-0\"", + "ratio": 96 + }, + "1in-100ft": { + "label": "1\" = 100'", + "ratio": 1200 + }, + "1in-10ft": { + "label": "1\" = 10'", + "ratio": 120 + }, + "1in-1ft": { + "label": "1\" = 1'-0\"", + "ratio": 12 + }, + "1in-20ft": { + "label": "1\" = 20'", + "ratio": 240 + }, + "1in-30ft": { + "label": "1\" = 30'", + "ratio": 360 + }, + "1in-40ft": { + "label": "1\" = 40'", + "ratio": 480 + }, + "1in-50ft": { + "label": "1\" = 50'", + "ratio": 600 + }, + "1in-60ft": { + "label": "1\" = 60'", + "ratio": 720 + }, + "3-16in-1ft": { + "label": "3/16\" = 1'-0\"", + "ratio": 64 + }, + "3-4in-1ft": { + "label": "3/4\" = 1'-0\"", + "ratio": 16 + }, + "3-8in-1ft": { + "label": "3/8\" = 1'-0\"", + "ratio": 32 + }, + "3in-1ft": { + "label": "3\" = 1'-0\"", + "ratio": 4 + } + }, + "dimensionStyles": { + "dims": { + "role": "dimension", + "arrowhead": "architectural-tick", + "arrowSize": 0.125, + "extensionOffset": 0.0625, + "extensionBeyond": 0.125, + "textGap": 0.0625, + "writing": { + "in": { + "units": "decimal", + "precision": 0, + "suffix": "\"", + "measurementScale": 1 + }, + "ft": { + "units": "decimal", + "precision": 1, + "suffix": "'", + "measurementScale": 1 + }, + "mm": { + "units": "decimal", + "precision": 0, + "measurementScale": 1 + } + } + } + }, + "titleBlocks": { + "plain": { + "paper": "ARCH D", + "block": { + "format": 0, + "units": "in", + "dxf": "R2018", + "layers": [ + { + "name": "0", + "color": 7 + } + ], + "entities": [], + "linetypes": [], + "linetypeScale": 1, + "textStyles": [ + { + "name": "TITLE BLOCK", + "font": "Arial" + } + ], + "blocks": [ + { + "name": "TITLE BLOCK", + "entities": [ + { + "kind": "polyline", + "layer": "0", + "points": [ + [0.5, 0.5], + [35.5, 0.5], + [35.5, 23.5], + [0.5, 23.5] + ], + "closed": true, + "lineweight": 0.7 + }, + { + "kind": "polyline", + "layer": "0", + "points": [ + [29, 0.5], + [29, 3.5], + [35.5, 3.5] + ], + "closed": false, + "lineweight": 0.7 + }, + { + "kind": "line", + "layer": "0", + "from": [31, 0.5], + "to": [31, 3.5] + }, + { + "kind": "line", + "layer": "0", + "from": [29, 1.5], + "to": [31, 1.5] + }, + { + "kind": "line", + "layer": "0", + "from": [31, 2.9], + "to": [35.5, 2.9] + }, + { + "kind": "line", + "layer": "0", + "from": [31, 2.1], + "to": [35.5, 2.1] + }, + { + "kind": "line", + "layer": "0", + "from": [31, 1.2999999999999998], + "to": [33.75, 1.2999999999999998] + }, + { + "kind": "line", + "layer": "0", + "from": [32.375, 0.5], + "to": [32.375, 2.1] + }, + { + "kind": "line", + "layer": "0", + "from": [33.75, 0.5], + "to": [33.75, 2.1] + }, + { + "kind": "text", + "layer": "0", + "at": [29.0625, 3.4375], + "value": "SEAL", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "text", + "layer": "0", + "at": [29.0625, 1.4375], + "value": "REVISIONS", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "line", + "layer": "0", + "from": [29, 1.3], + "to": [31, 1.3] + }, + { + "kind": "line", + "layer": "0", + "from": [29, 1.1], + "to": [31, 1.1] + }, + { + "kind": "line", + "layer": "0", + "from": [29, 0.8999999999999999], + "to": [31, 0.8999999999999999] + }, + { + "kind": "line", + "layer": "0", + "from": [29, 0.7], + "to": [31, 0.7] + }, + { + "kind": "text", + "layer": "0", + "at": [29.0625, 1.2375], + "value": "NO.", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "line", + "layer": "0", + "from": [29.3, 0.5], + "to": [29.3, 1.3] + }, + { + "kind": "text", + "layer": "0", + "at": [29.3625, 1.2375], + "value": "DATE", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "line", + "layer": "0", + "from": [29.9, 0.5], + "to": [29.9, 1.3] + }, + { + "kind": "text", + "layer": "0", + "at": [29.9625, 1.2375], + "value": "DESCRIPTION", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "text", + "layer": "0", + "at": [31.0625, 3.4375], + "value": "PROJECT", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "text", + "layer": "0", + "at": [31.0625, 2.8375], + "value": "SHEET TITLE", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "text", + "layer": "0", + "at": [31.0625, 2.0375], + "value": "DATE", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "text", + "layer": "0", + "at": [32.4375, 2.0375], + "value": "SCALE", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "text", + "layer": "0", + "at": [31.0625, 1.2374999999999998], + "value": "DRAWN BY", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "text", + "layer": "0", + "at": [32.4375, 1.2374999999999998], + "value": "CHECKED BY", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + }, + { + "kind": "text", + "layer": "0", + "at": [33.8125, 2.0375], + "value": "SHEET", + "height": 0.0625, + "rotation": 0, + "style": "TITLE BLOCK", + "valign": "top" + } + ], + "attributes": [ + { + "tag": "PROJECT", + "prompt": "Project", + "layer": "0", + "at": [31.0625, 2.99375], + "value": "-", + "height": 0.125, + "rotation": 0, + "style": "TITLE BLOCK" + }, + { + "tag": "TITLE", + "prompt": "Sheet title", + "layer": "0", + "at": [31.0625, 2.19375], + "value": "-", + "height": 0.1875, + "rotation": 0, + "style": "TITLE BLOCK" + }, + { + "tag": "DATE", + "prompt": "Date", + "layer": "0", + "at": [31.0625, 1.3937499999999998], + "value": "-", + "height": 0.125, + "rotation": 0, + "style": "TITLE BLOCK" + }, + { + "tag": "SCALE", + "prompt": "Scale", + "layer": "0", + "at": [32.4375, 1.3937499999999998], + "value": "-", + "height": 0.125, + "rotation": 0, + "style": "TITLE BLOCK" + }, + { + "tag": "DRAWN", + "prompt": "Drawn by", + "layer": "0", + "at": [31.0625, 0.59375], + "value": "-", + "height": 0.125, + "rotation": 0, + "style": "TITLE BLOCK" + }, + { + "tag": "CHECKED", + "prompt": "Checked by", + "layer": "0", + "at": [32.4375, 0.59375], + "value": "-", + "height": 0.125, + "rotation": 0, + "style": "TITLE BLOCK" + }, + { + "tag": "SHEET", + "prompt": "Sheet number", + "layer": "0", + "at": [34.625, 1.2375], + "value": "-", + "height": 0.3125, + "rotation": 0, + "style": "TITLE BLOCK", + "align": "center", + "valign": "middle" + } + ] + } + ] + }, + "blockName": "TITLE BLOCK", + "layer": "G-ANNO-TTLB", + "drawingArea": [0.5, 0.5, 35.5, 23.5], + "box": [29, 0.5, 35.5, 3.5], + "fields": { + "PROJECT": { + "fills": "project", + "box": [31, 2.9, 35.5, 3.5], + "source": "attribute" + }, + "TITLE": { + "fills": "title", + "box": [31, 2.1, 35.5, 2.9], + "source": "attribute" + }, + "DATE": { + "fills": "date", + "box": [31, 1.2999999999999998, 32.375, 2.1], + "source": "attribute" + }, + "SCALE": { + "fills": "scale", + "box": [32.375, 1.2999999999999998, 33.75, 2.1], + "source": "attribute" + }, + "DRAWN": { + "fills": "drawnBy", + "box": [31, 0.5, 32.375, 1.2999999999999998], + "source": "attribute" + }, + "CHECKED": { + "fills": "checkedBy", + "box": [32.375, 0.5, 33.75, 1.2999999999999998], + "source": "attribute" + }, + "SHEET": { + "fills": "number", + "box": [33.75, 0.5, 35.5, 2.1], + "source": "attribute" + } + } + } + }, + "sheetFormats": { + "arch-d": { + "paper": "ARCH D", + "titleBlock": "plain", + "zones": { + "views": [0.5, 2, 27.5, 23.5], + "viewTitles": [0.5, 0.5, 27.5, 2], + "notes": [28.5, 3.5, 35.5, 23.5] + } + } + } +} diff --git a/standard.schema.json b/standard.schema.json new file mode 100644 index 0000000..8ac5234 --- /dev/null +++ b/standard.schema.json @@ -0,0 +1,950 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cdn.jsdelivr.net/npm/tsdraft@0.5.2/standard.schema.json", + "title": "A tsdraft standard", + "type": "object", + "description": "A firm's CAD standard: its layers, pens, linetypes, text roles, scales, dimension styles, title blocks and sheet formats. What a schema cannot hold, such as a name that refers to another entry, is checked by defineStandard", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "$schema": { + "type": "string", + "description": "The JSON Schema of the document" + }, + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9._@/-]{1,128}$", + "description": "1 to 128 letters, digits, ., -, _, @ and /, so a package name is one" + }, + "version": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\uffff]{1,64}$", + "description": "1 to 64 characters" + }, + "title": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "For people. Defaults to the id" + }, + "extends": { + "type": "string", + "description": "The parent: a path from this file, or a package specifier. \"tsdraft/standard.json\" is the built-in standard" + }, + "paperUnits": { + "type": "string", + "enum": ["in", "mm"], + "description": "The unit of every length on paper. A child keeps its parent's" + }, + "minTextHeight": { + "type": "number", + "exclusiveMinimum": 0, + "description": "The least height a text may plot at, in paper units" + }, + "linetypeScale": { + "type": "number", + "exclusiveMinimum": 0, + "description": "A dash plots its pattern's length times this, at every scale", + "default": 1 + }, + "plotStyle": { + "type": "string", + "enum": ["monochrome.ctb", "acad.ctb"], + "description": "The plot style table of each layout. One of PLOT_STYLE_TABLES", + "default": "monochrome.ctb" + }, + "sheetFiles": { + "type": "string", + "enum": ["one-drawing", "sheet-per-file"], + "description": "one-drawing: a set is one drawing with a layout for each sheet. sheet-per-file: each sheet is a file of its own", + "default": "one-drawing" + }, + "defaultSheetFormat": { + "type": "string", + "description": "The sheet format of a layout that names none" + }, + "defaultDimensionStyle": { + "type": "string", + "description": "The dimension style of a dimension that names none" + }, + "disciplines": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/discipline" + }, + { + "type": "null" + } + ] + }, + "description": "The disciplines, by name. null removes the parent's entry of that name", + "default": {} + }, + "pens": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/pen" + }, + { + "type": "null" + } + ] + }, + "description": "Named lineweights, by name. null removes the parent's entry of that name", + "default": {} + }, + "linetypes": { + "type": "object", + "propertyNames": { + "pattern": "^(?! )(?![\\s\\S]* $)(?!(?:[Cc][Oo][Nn][Tt][Ii][Nn][Uu][Oo][Uu][Ss]|[Bb][Yy][Ll][Aa][Yy][Ee][Rr]|[Bb][Yy][Bb][Ll][Oo][Cc][Kk])$)[\\u0020-\\u0021\\u0023-\\u0029\\u002b\\u002d-\\u002e\\u0030-\\u0039\\u0040-\\u005b\\u005d\\u005f\\u0061-\\u007b\\u007d-\\u007e\\u00a0-\\u05ff\\u0604-\\u06dc\\u06de-\\u070e\\u0710-\\u17b3\\u17b6-\\u200a\\u2010-\\u2029\\u202f-\\u205f\\u2065-\\u2069\\u2070\\u2072-\\u207e\\u2080-\\u208f\\u2095-\\u2101\\u2103-\\u2106\\u2108-\\u2109\\u2114\\u2116-\\u2118\\u211e-\\u2123\\u2125\\u2127\\u2129\\u212e\\u213a-\\u213b\\u2140-\\u2144\\u214a-\\u214d\\u214f-\\u217f\\u2189-\\u2bff\\u2c2f\\u2c5f\\u2c70\\u2c7e-\\u2c7f\\u2ce5-\\u2cff\\u2d26-\\u2d2f\\u2d66-\\u2d6e\\u2d70-\\u2d7f\\u2d97-\\u2d9f\\u2da7\\u2daf\\u2db7\\u2dbf\\u2dc7\\u2dcf\\u2dd7\\u2ddf-\\ud7ff\\ue000-\\ue7ff\\ue900-\\ufefe\\uff00-\\ufff8\\ufffc-\\uffff]{1,255}$" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/linetype" + }, + { + "type": "null" + } + ] + }, + "description": "The linetypes. Continuous is in every standard and is never listed, by name. null removes the parent's entry of that name", + "default": {} + }, + "layers": { + "type": "object", + "propertyNames": { + "pattern": "^(?! )(?![\\s\\S]* $)(?!(?:0)$)[\\u0020-\\u0021\\u0023-\\u0029\\u002b\\u002d-\\u002e\\u0030-\\u0039\\u0040-\\u005b\\u005d\\u005f\\u0061-\\u007b\\u007d-\\u007e\\u00a0-\\u05ff\\u0604-\\u06dc\\u06de-\\u070e\\u0710-\\u17b3\\u17b6-\\u200a\\u2010-\\u2029\\u202f-\\u205f\\u2065-\\u2069\\u2070\\u2072-\\u207e\\u2080-\\u208f\\u2095-\\u2101\\u2103-\\u2106\\u2108-\\u2109\\u2114\\u2116-\\u2118\\u211e-\\u2123\\u2125\\u2127\\u2129\\u212e\\u213a-\\u213b\\u2140-\\u2144\\u214a-\\u214d\\u214f-\\u217f\\u2189-\\u2bff\\u2c2f\\u2c5f\\u2c70\\u2c7e-\\u2c7f\\u2ce5-\\u2cff\\u2d26-\\u2d2f\\u2d66-\\u2d6e\\u2d70-\\u2d7f\\u2d97-\\u2d9f\\u2da7\\u2daf\\u2db7\\u2dbf\\u2dc7\\u2dcf\\u2dd7\\u2ddf-\\ud7ff\\ue000-\\ue7ff\\ue900-\\ufefe\\uff00-\\ufff8\\ufffc-\\uffff]{1,255}$" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/layer" + }, + { + "type": "null" + } + ] + }, + "description": "The layers, by name. null removes the parent's entry of that name", + "default": {} + }, + "textRoles": { + "type": "object", + "propertyNames": { + "pattern": "^(?!(?:[Ss][Tt][Aa][Nn][Dd][Aa][Rr][Dd])$)[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/textRole" + }, + { + "type": "null" + } + ] + }, + "description": "The text roles, by name. null removes the parent's entry of that name", + "default": {} + }, + "scales": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/scale" + }, + { + "type": "null" + } + ] + }, + "description": "The scales, by name. null removes the parent's entry of that name", + "default": {} + }, + "dimensionStyles": { + "type": "object", + "propertyNames": { + "pattern": "^(?!(?:[Ss][Tt][Aa][Nn][Dd][Aa][Rr][Dd])$)[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/dimensionStyle" + }, + { + "type": "null" + } + ] + }, + "description": "The dimension styles, by name. null removes the parent's entry of that name", + "default": {} + }, + "titleBlocks": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/titleBlock" + }, + { + "type": "null" + } + ] + }, + "description": "The title blocks, by name. null removes the parent's entry of that name", + "default": {} + }, + "sheetFormats": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/sheetFormat" + }, + { + "type": "null" + } + ] + }, + "description": "The sheet formats, by name. null removes the parent's entry of that name", + "default": {} + } + }, + "required": ["id", "version"], + "anyOf": [ + { + "required": ["extends"] + }, + { + "required": ["paperUnits", "minTextHeight"] + } + ], + "additionalProperties": false, + "$defs": { + "dimensionStyle": { + "type": "object", + "description": "A dimension style", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "role": { + "type": "string", + "description": "The text role of the dimension's text, which gives its text style and height" + }, + "arrowhead": { + "type": "string", + "enum": ["closed-filled", "architectural-tick"], + "description": "One of ARROWHEADS", + "default": "closed-filled" + }, + "arrowSize": { + "type": "number", + "minimum": 0, + "description": "The size of an arrowhead, on paper" + }, + "extensionOffset": { + "type": "number", + "minimum": 0, + "description": "How far an extension line starts from its point, on paper" + }, + "extensionBeyond": { + "type": "number", + "minimum": 0, + "description": "How far an extension line runs past the dimension line, on paper" + }, + "textGap": { + "type": "number", + "minimum": 0, + "description": "The gap between the text and the dimension line, on paper" + }, + "writing": { + "$ref": "#/$defs/writings", + "description": "How a length is written in each unit of a drawing. A drawing in a unit it does not list cannot draw a dimension in the style", + "default": {} + }, + "description": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "For people and the specimen. No rule reads it" + }, + "discipline": { + "type": "string", + "description": "The discipline the entry belongs to, a discipline of the standard. An entry without one belongs to every discipline" + }, + "byDiscipline": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "$ref": "#/$defs/dimensionStyleFields" + }, + "description": "Fields that replace the entry's own in each discipline's drawings, by discipline. Only under sheetFiles \"sheet-per-file\"" + } + }, + "required": ["role", "arrowSize", "extensionOffset", "extensionBeyond", "textGap"], + "additionalProperties": false + }, + "dimensionStyleFields": { + "type": "object", + "description": "A dimension style's fields for a discipline", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "role": { + "type": "string", + "description": "The text role of the dimension's text, which gives its text style and height" + }, + "arrowhead": { + "type": "string", + "enum": ["closed-filled", "architectural-tick"], + "description": "One of ARROWHEADS" + }, + "arrowSize": { + "type": "number", + "minimum": 0, + "description": "The size of an arrowhead, on paper" + }, + "extensionOffset": { + "type": "number", + "minimum": 0, + "description": "How far an extension line starts from its point, on paper" + }, + "extensionBeyond": { + "type": "number", + "minimum": 0, + "description": "How far an extension line runs past the dimension line, on paper" + }, + "textGap": { + "type": "number", + "minimum": 0, + "description": "The gap between the text and the dimension line, on paper" + }, + "writing": { + "$ref": "#/$defs/writings", + "description": "How a length is written in each unit of a drawing. A drawing in a unit it does not list cannot draw a dimension in the style" + }, + "description": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "For people and the specimen. No rule reads it" + } + }, + "additionalProperties": false + }, + "discipline": { + "type": "object", + "description": "A discipline", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "title": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "For people" + } + }, + "required": ["title"], + "additionalProperties": false + }, + "layer": { + "type": "object", + "description": "A layer", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "color": { + "type": "integer", + "minimum": 1, + "maximum": 255, + "description": "An AutoCAD Color Index from 1 to 255" + }, + "linetype": { + "type": "string", + "description": "A linetype of the standard, or Continuous", + "default": "Continuous" + }, + "pen": { + "type": "string", + "description": "A pen of the standard. Without one the layer has AutoCAD's lineweight \"Default\"" + }, + "plot": { + "type": "boolean", + "description": "Whether the layer plots", + "default": true + }, + "description": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "For people and the specimen. No rule reads it" + }, + "discipline": { + "type": "string", + "description": "The discipline the entry belongs to, a discipline of the standard. An entry without one belongs to every discipline" + }, + "byDiscipline": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "$ref": "#/$defs/layerFields" + }, + "description": "Fields that replace the entry's own in each discipline's drawings, by discipline. Only under sheetFiles \"sheet-per-file\"" + } + }, + "required": ["color"], + "additionalProperties": false + }, + "layerFields": { + "type": "object", + "description": "A layer's fields for a discipline", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "color": { + "type": "integer", + "minimum": 1, + "maximum": 255, + "description": "An AutoCAD Color Index from 1 to 255" + }, + "linetype": { + "type": "string", + "description": "A linetype of the standard, or Continuous" + }, + "pen": { + "type": "string", + "description": "A pen of the standard. Without one the layer has AutoCAD's lineweight \"Default\"" + }, + "plot": { + "type": "boolean", + "description": "Whether the layer plots" + }, + "description": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "For people and the specimen. No rule reads it" + } + }, + "additionalProperties": false + }, + "linetype": { + "type": "object", + "description": "A linetype", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "description": { + "type": "string", + "maxLength": 249, + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "What AutoCAD's LINETYPE command shows for it", + "default": "" + }, + "pattern": { + "type": "array", + "items": { + "type": "number" + }, + "maxItems": 12, + "not": { + "minItems": 1, + "maxItems": 1 + }, + "description": "2 to 12 lengths in paper units, before linetypeScale, or none for a solid line: a dash above 0, a gap below 0 and a dot 0" + } + }, + "required": ["pattern"], + "additionalProperties": false + }, + "pen": { + "type": "object", + "description": "A pen", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "lineweight": { + "type": "number", + "enum": [ + 0, 0.05, 0.09, 0.13, 0.15, 0.18, 0.2, 0.25, 0.3, 0.35, 0.4, 0.5, 0.53, 0.6, 0.7, 0.8, + 0.9, 1, 1.06, 1.2, 1.4, 1.58, 2, 2.11 + ], + "description": "One of AutoCAD's lineweights, in millimetres" + }, + "description": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "For people and the specimen. No rule reads it" + } + }, + "required": ["lineweight"], + "additionalProperties": false + }, + "scale": { + "type": "object", + "description": "A scale", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "label": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\uffff]{1,64}$", + "description": "The name AutoCAD's scale list gives the scale, such as 1\" = 10'" + }, + "ratio": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Drawing lengths per length on paper, such as 120 for 1\" = 10'" + }, + "discipline": { + "type": "string", + "description": "The discipline the entry belongs to, a discipline of the standard. An entry without one belongs to every discipline" + } + }, + "required": ["label", "ratio"], + "additionalProperties": false + }, + "sheetFormat": { + "type": "object", + "description": "A sheet format", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "paper": { + "type": "string", + "enum": ["ARCH A", "ARCH B", "ARCH C", "ARCH D", "ANSI A", "ANSI B", "ANSI C", "ANSI D"], + "description": "One of PAPERS, which is its title block's paper" + }, + "titleBlock": { + "type": "string", + "description": "A title block of the standard" + }, + "plotStyle": { + "type": "string", + "enum": ["monochrome.ctb", "acad.ctb"], + "description": "The sheet format's own plot style table, in place of the standard's" + }, + "zones": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 4, + "maxItems": 4, + "description": "[left, bottom, right, top] on the paper" + }, + "description": "Named boxes inside the title block's drawing area, which the code lays the sheet out by", + "default": {} + }, + "discipline": { + "type": "string", + "description": "The discipline the entry belongs to, a discipline of the standard. An entry without one belongs to every discipline" + } + }, + "required": ["paper", "titleBlock"], + "additionalProperties": false + }, + "textRole": { + "type": "object", + "description": "A text role", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "font": { + "type": "string", + "enum": [ + "txt.shx", + "monotxt.shx", + "simplex.shx", + "romans.shx", + "romand.shx", + "romanc.shx", + "romant.shx", + "complex.shx", + "italic.shx", + "isocp.shx", + "isoct.shx", + "Arial", + "Calibri", + "Consolas", + "Courier New", + "Georgia", + "Segoe UI", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Verdana" + ], + "description": "One of FONTS, as AutoCAD stores it" + }, + "height": { + "type": "number", + "exclusiveMinimum": 0, + "description": "The plotted height of a capital, in paper units, at least minTextHeight" + }, + "widthFactor": { + "type": "number", + "minimum": 0.01, + "maximum": 100, + "description": "How wide each character is drawn, as a share of its own width", + "default": 1 + }, + "obliqueAngle": { + "type": "number", + "minimum": -85, + "maximum": 85, + "description": "The slant of each character, in degrees to the right", + "default": 0 + }, + "description": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "For people and the specimen. No rule reads it" + }, + "discipline": { + "type": "string", + "description": "The discipline the entry belongs to, a discipline of the standard. An entry without one belongs to every discipline" + }, + "byDiscipline": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,64}$" + }, + "additionalProperties": { + "$ref": "#/$defs/textRoleFields" + }, + "description": "Fields that replace the entry's own in each discipline's drawings, by discipline. Only under sheetFiles \"sheet-per-file\"" + } + }, + "required": ["font", "height"], + "additionalProperties": false + }, + "textRoleFields": { + "type": "object", + "description": "A text role's fields for a discipline", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "font": { + "type": "string", + "enum": [ + "txt.shx", + "monotxt.shx", + "simplex.shx", + "romans.shx", + "romand.shx", + "romanc.shx", + "romant.shx", + "complex.shx", + "italic.shx", + "isocp.shx", + "isoct.shx", + "Arial", + "Calibri", + "Consolas", + "Courier New", + "Georgia", + "Segoe UI", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Verdana" + ], + "description": "One of FONTS, as AutoCAD stores it" + }, + "height": { + "type": "number", + "exclusiveMinimum": 0, + "description": "The plotted height of a capital, in paper units, at least minTextHeight" + }, + "widthFactor": { + "type": "number", + "minimum": 0.01, + "maximum": 100, + "description": "How wide each character is drawn, as a share of its own width" + }, + "obliqueAngle": { + "type": "number", + "minimum": -85, + "maximum": 85, + "description": "The slant of each character, in degrees to the right" + }, + "description": { + "type": "string", + "pattern": "^[\\u0020-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "For people and the specimen. No rule reads it" + } + }, + "additionalProperties": false + }, + "titleBlock": { + "type": "object", + "description": "A title block", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "paper": { + "type": "string", + "enum": ["ARCH A", "ARCH B", "ARCH C", "ARCH D", "ANSI A", "ANSI B", "ANSI C", "ANSI D"], + "description": "One of PAPERS" + }, + "block": { + "description": "The drawing that holds the block blockName, in paper units: a Drawing as JSON, or a string that names one", + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ] + }, + "blockName": { + "type": "string", + "pattern": "^(?! )(?![\\s\\S]* $)(?!(?:_[Aa][Rr][Cc][Hh][Tt][Ii][Cc][Kk])$)[\\u0020-\\u0021\\u0023-\\u0029\\u002b\\u002d-\\u002e\\u0030-\\u0039\\u0040-\\u005b\\u005d\\u005f\\u0061-\\u007b\\u007d-\\u007e\\u00a0-\\u05ff\\u0604-\\u06dc\\u06de-\\u070e\\u0710-\\u17b3\\u17b6-\\u200a\\u2010-\\u2029\\u202f-\\u205f\\u2065-\\u2069\\u2070\\u2072-\\u207e\\u2080-\\u208f\\u2095-\\u2101\\u2103-\\u2106\\u2108-\\u2109\\u2114\\u2116-\\u2118\\u211e-\\u2123\\u2125\\u2127\\u2129\\u212e\\u213a-\\u213b\\u2140-\\u2144\\u214a-\\u214d\\u214f-\\u217f\\u2189-\\u2bff\\u2c2f\\u2c5f\\u2c70\\u2c7e-\\u2c7f\\u2ce5-\\u2cff\\u2d26-\\u2d2f\\u2d66-\\u2d6e\\u2d70-\\u2d7f\\u2d97-\\u2d9f\\u2da7\\u2daf\\u2db7\\u2dbf\\u2dc7\\u2dcf\\u2dd7\\u2ddf-\\ud7ff\\ue000-\\ue7ff\\ue900-\\ufefe\\uff00-\\ufff8\\ufffc-\\uffff]{1,255}$", + "description": "The block of block that the builder inserts at [0, 0] on each sheet" + }, + "xref": { + "$ref": "#/$defs/xref", + "description": "The firm's own DWG, referenced by each sheet as an overlay" + }, + "preview": { + "description": "What the pictures draw in place of the xref: a Drawing as JSON, or a string that names one", + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ] + }, + "layer": { + "type": "string", + "description": "A layer of the standard, which the title block is inserted on" + }, + "drawingArea": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 4, + "maxItems": 4, + "description": "[left, bottom, right, top] on the paper" + }, + "box": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 4, + "maxItems": 4, + "description": "[left, bottom, right, top] on the paper" + }, + "fields": { + "type": "object", + "propertyNames": { + "pattern": "^[\\u0020-\\u0060\\u007b-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]+$" + }, + "additionalProperties": { + "$ref": "#/$defs/titleBlockField" + }, + "description": "The fields a sheet fills, by tag", + "default": {} + } + }, + "required": ["paper", "layer", "drawingArea"], + "additionalProperties": false + }, + "titleBlockField": { + "type": "object", + "description": "A title block's field", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "fills": { + "type": "string", + "enum": ["number", "title", "project", "date", "scale", "drawnBy", "checkedBy"], + "description": "What the sheet fills the field with. A field without one is filled by its tag" + }, + "box": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 4, + "maxItems": 4, + "description": "[left, bottom, right, top] on the paper" + }, + "source": { + "type": "string", + "enum": ["attribute", "fileName", "layoutName", "currentDate"], + "description": "attribute: an attribute of the block that the builder sets. fileName, layoutName and currentDate: a field of the xref's drawing that AutoCAD fills", + "default": "attribute" + } + }, + "additionalProperties": false + }, + "writing": { + "type": "object", + "description": "A dimension style's writing", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "units": { + "type": "string", + "enum": ["decimal"], + "description": "How a length is written: decimal, the one way tsdraft writes one", + "default": "decimal" + }, + "precision": { + "type": "integer", + "minimum": 0, + "maximum": 8, + "description": "The decimals a measurement is written with, from 0 to 8" + }, + "prefix": { + "type": "string", + "pattern": "^(?![\\s\\S]*<>)[\\u0020-\\u005b\\u005d-\\u007a\\u007c\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "Written before each measurement" + }, + "suffix": { + "type": "string", + "pattern": "^[\\u0020-\\u005b\\u005d-\\u007a\\u007c\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]*$", + "description": "Written after each measurement" + }, + "suppressZeros": { + "type": "string", + "enum": ["leading", "trailing", "both"], + "description": "The zeros of a measurement that are left out" + }, + "measurementScale": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Each measurement is written times this", + "default": 1 + } + }, + "required": ["precision"], + "additionalProperties": false + }, + "writings": { + "type": "object", + "description": "A dimension style's writing", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "in": { + "$ref": "#/$defs/writing", + "description": "In a drawing in inches" + }, + "ft": { + "$ref": "#/$defs/writing", + "description": "In a drawing in feet" + }, + "mm": { + "$ref": "#/$defs/writing", + "description": "In a drawing in millimetres" + }, + "cm": { + "$ref": "#/$defs/writing", + "description": "In a drawing in centimetres" + }, + "m": { + "$ref": "#/$defs/writing", + "description": "In a drawing in metres" + } + }, + "additionalProperties": false + }, + "xref": { + "type": "object", + "description": "An xref", + "properties": { + "$comment": { + "type": "string", + "description": "For people. No rule reads it" + }, + "name": { + "type": "string", + "pattern": "^(?! )(?![\\s\\S]* $)(?!(?:_[Aa][Rr][Cc][Hh][Tt][Ii][Cc][Kk])$)[\\u0020-\\u0021\\u0023-\\u0029\\u002b\\u002d-\\u002e\\u0030-\\u0039\\u0040-\\u005b\\u005d\\u005f\\u0061-\\u007b\\u007d-\\u007e\\u00a0-\\u05ff\\u0604-\\u06dc\\u06de-\\u070e\\u0710-\\u17b3\\u17b6-\\u200a\\u2010-\\u2029\\u202f-\\u205f\\u2065-\\u2069\\u2070\\u2072-\\u207e\\u2080-\\u208f\\u2095-\\u2101\\u2103-\\u2106\\u2108-\\u2109\\u2114\\u2116-\\u2118\\u211e-\\u2123\\u2125\\u2127\\u2129\\u212e\\u213a-\\u213b\\u2140-\\u2144\\u214a-\\u214d\\u214f-\\u217f\\u2189-\\u2bff\\u2c2f\\u2c5f\\u2c70\\u2c7e-\\u2c7f\\u2ce5-\\u2cff\\u2d26-\\u2d2f\\u2d66-\\u2d6e\\u2d70-\\u2d7f\\u2d97-\\u2d9f\\u2da7\\u2daf\\u2db7\\u2dbf\\u2dc7\\u2dcf\\u2dd7\\u2ddf-\\ud7ff\\ue000-\\ue7ff\\ue900-\\ufefe\\uff00-\\ufff8\\ufffc-\\uffff]{1,255}$", + "description": "The xref's name" + }, + "path": { + "type": "string", + "maxLength": 32000, + "pattern": "^(?![/\\\\]|[A-Za-z]:)(?![\\s\\S]*\\.[Dd][Xx][Ff]$)(?=(?![\\s\\S]*\\.$)|[\\s\\S]*\\.[Dd][Ww][Gg]\\.$)(?![\\s\\S]*\\\\[Mm]\\+[1-5][0-9A-Fa-f]{4})[\\u0020-\\u0021\\u0023-\\u0029\\u002b-\\u0039\\u003b\\u003d\\u0040-\\u007b\\u007d-\\u007e\\u0080-\\ud7ff\\ue000-\\ue7ff\\ue900-\\uffff]+$", + "description": "The path of the firm's DWG as each sheet's file sees it" + }, + "at": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 2, + "description": "Where the xref is inserted on the paper", + "default": [0, 0] + } + }, + "required": ["name", "path"], + "additionalProperties": false + } + } +} diff --git a/test/standard.test.ts b/test/standard.test.ts new file mode 100644 index 0000000..33cf5b6 --- /dev/null +++ b/test/standard.test.ts @@ -0,0 +1,852 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + BUILT_IN_STANDARD, + createDrawing, + defineStandard, + DrawingError, + lint, + type Drawing, + type DrawingBuilder, + type Standard, + type StandardDocument, + type Units, +} from "../src/index.ts"; +import { dimensionPrefixProblem, plainDimensionText, xrefPathProblem } from "../src/rules.ts"; +import { sha256, sha256Text } from "../src/sha256.ts"; +import { BUILT_IN_DOCUMENT } from "../src/standard/default.ts"; +import { VERSION } from "../src/version.ts"; +import { + builtInDocument, + packageVersion, + schemaUrl, + versionSource, +} from "../scripts/standard-files.ts"; +import { standardSchema } from "../src/standard/shape.ts"; + +const base = { id: "t", version: "1", extends: BUILT_IN_STANDARD } as const; +const alone = { id: "t", version: "1", paperUnits: "in", minTextHeight: 0.0625 } as const; +const shared = { ...base, sheetFiles: "sheet-per-file" } as const; + +/** The problems `defineStandard` finds in a document, as `item: message`. */ +function problems(document: unknown, files?: Record): string[] { + try { + defineStandard(document as never, files ? { files } : {}); + } catch (thrown) { + assert.ok(thrown instanceof DrawingError); + for (const d of thrown.diagnostics) assert.equal(d.rule, "standard"); + return thrown.diagnostics.map((d) => (d.item ? `${d.item}: ${d.message}` : d.message)); + } + return []; +} + +/** A title block's drawing, drawn by `draw`. */ +function fragment(draw: (d: DrawingBuilder) => void, units: Units = "in"): Drawing { + const d = createDrawing({ units }); + draw(d); + return d.build(); +} + +// Each `@ts-expect-error` below proves the compiler rejects the literal. The `assert.throws` +// around it proves the same mistake is caught, with the same message, when the code runs. +test("literal mistakes in a standard are compile errors and runtime errors", () => { + const style = { + arrowSize: 0.1, + extensionOffset: 0.1, + extensionBeyond: 0.1, + textGap: 0.1, + } as const; + const block = { paper: "ARCH D", layer: "G-ANNO-TTLB", drawingArea: [1, 1, 2, 2] } as const; + const xref = { name: "BORDER", path: "border.dwg" } as const; + const sheet = { paper: "ARCH D", titleBlock: "plain" } as const; + const role = { font: "Arial", height: 0.1 } as const; + const throws = assert.throws; + + // @ts-expect-error: AutoCAD refuses a comma in a layer name. + throws(() => defineStandard({ ...base, layers: { "A,B": { color: 1 } } }), /cannot contain/); + // @ts-expect-error: layer 0 is in every drawing. + throws(() => defineStandard({ ...base, layers: { "0": { color: 1 } } }), /Layer "0" is in/); + // @ts-expect-error: colors run from 1 to 255. + throws(() => defineStandard({ ...base, layers: { A: { color: 0 } } }), /Color Index/); + // @ts-expect-error: colors are integers. + throws(() => defineStandard({ ...base, layers: { A: { color: 1.5 } } }), /Color Index/); + // @ts-expect-error: 0.33 mm is not one of AutoCAD's lineweights. + throws(() => defineStandard({ ...base, pens: { p: { lineweight: 0.33 } } }), /AutoCAD's, in/); + // @ts-expect-error: a font must be one of FONTS. + throws(() => defineStandard({ ...base, textRoles: { r: { ...role, font: "Ariel" } } }), /FONTS/); + throws( + // @ts-expect-error: a paper must be one of PAPERS. + () => defineStandard({ ...base, sheetFormats: { s: { ...sheet, paper: "A9" } } }), + /PAPERS/, + ); + + // A name the standard and its parent do not have. + throws( + // @ts-expect-error: the standard has no discipline X. + () => defineStandard({ ...base, layers: { A: { color: 1, discipline: "X" } } }), + /^DrawingError: The standard has 1 problem:\n- layers\["A"\]\.discipline: Discipline "X" is not a discipline of the standard\. Add it to disciplines, or name one of its disciplines$/, + ); + throws( + // @ts-expect-error: the standard has no pen thick. + () => defineStandard({ ...base, layers: { A: { color: 1, pen: "thick" } } }), + /Pen "thick"/, + ); + throws( + // @ts-expect-error: the standard has no linetype DASHY. + () => defineStandard({ ...base, layers: { A: { color: 1, linetype: "DASHY" } } }), + /Linetype "DASHY" is not a linetype of the standard/, + ); + throws( + // @ts-expect-error: ByLayer is no linetype of a standard. + () => defineStandard({ ...base, layers: { A: { color: 1, linetype: "ByLayer" } } }), + /Linetype "ByLayer"/, + ); + throws( + // @ts-expect-error: the standard has no text role callout. + () => defineStandard({ ...base, dimensionStyles: { d: { ...style, role: "callout" } } }), + /Text role "callout" is not a text role/, + ); + throws( + // @ts-expect-error: the standard has no dimension style tick. + () => defineStandard({ ...base, defaultDimensionStyle: "tick" }), + /Dimension style "tick"/, + ); + throws( + // @ts-expect-error: the standard has no title block firm. + () => defineStandard({ ...base, sheetFormats: { s: { ...sheet, titleBlock: "firm" } } }), + /Title block "firm" is not a title block/, + ); + // @ts-expect-error: the standard has no sheet format arch-e. + throws(() => defineStandard({ ...base, defaultSheetFormat: "arch-e" }), /Sheet format "arch-e"/); + throws( + // @ts-expect-error: the standard has no layer A-TTLB. + () => defineStandard({ ...base, titleBlocks: { t: { ...block, xref, layer: "A-TTLB" } } }), + /Layer "A-TTLB" is not a layer/, + ); + throws( + () => + // @ts-expect-error: a child that removes a pen cannot name it. + defineStandard({ ...base, pens: { fine: null }, layers: { A: { color: 1, pen: "fine" } } }), + /Pen "fine"/, + ); + throws( + // @ts-expect-error: byDiscipline is by the standard's disciplines. + () => defineStandard({ ...shared, layers: { A: { color: 1, byDiscipline: { X: {} } } } }), + /Discipline "X"/, + ); + + throws( + // @ts-expect-error: Continuous is in every standard. + () => defineStandard({ ...base, linetypes: { continuous: { pattern: [] } } }), + /Continuous, ByLayer and ByBlock/, + ); + // @ts-expect-error: a pattern of one length. + throws(() => defineStandard({ ...base, linetypes: { ONE: { pattern: [1] } } }), /2 to 12/); + // @ts-expect-error: an id holds letters, digits, ., - and _. + throws(() => defineStandard({ ...base, pens: { "a b": { lineweight: 0.5 } } }), /An id can/); + throws( + // @ts-expect-error: a role named standard would have the text style STANDARD. + () => defineStandard({ ...base, textRoles: { Standard: role } }), + /A text role cannot be named standard/, + ); + throws( + // @ts-expect-error: a dimension style named standard would be AutoCAD's own. + () => defineStandard({ ...base, dimensionStyles: { standard: { ...style, role: "note" } } }), + /A dimension style cannot be named standard/, + ); + // @ts-expect-error: the least height a text plots at is above 0. + throws(() => defineStandard({ ...base, minTextHeight: 0 }), /greater than 0/); + // @ts-expect-error: the linetype scale is above 0. + throws(() => defineStandard({ ...base, linetypeScale: -1 }), /greater than 0/); + // @ts-expect-error: a role's height is above 0. + throws(() => defineStandard({ ...base, textRoles: { r: { ...role, height: -1 } } }), /than 0/); + throws( + // @ts-expect-error: a width factor runs from 0.01 to 100. + () => defineStandard({ ...base, textRoles: { r: { ...role, widthFactor: 0 } } }), + /A width factor must be/, + ); + throws( + // @ts-expect-error: an oblique angle runs from -85 to 85 degrees. + () => defineStandard({ ...base, textRoles: { r: { ...role, obliqueAngle: 90 } } }), + /An oblique angle must be/, + ); + throws( + // @ts-expect-error: a ratio is above 0. + () => defineStandard({ ...base, scales: { s: { label: "1:1", ratio: 0 } } }), + /The ratio must be greater than 0/, + ); + // @ts-expect-error: paper units are inches or millimetres. + throws(() => defineStandard({ ...alone, paperUnits: "cm" }), /paperUnits must be "in" or "mm"/); + // @ts-expect-error: sheets are one drawing, or a file each. + throws(() => defineStandard({ ...base, sheetFiles: "many" }), /sheetFiles must be/); + // @ts-expect-error: a plot style table must be one AutoCAD installs. + throws(() => defineStandard({ ...base, plotStyle: "pens.ctb" }), /plot style table must be/); + throws( + // @ts-expect-error: a sheet format's own plot style table too. + () => defineStandard({ ...base, sheetFormats: { s: { ...sheet, plotStyle: "pens.ctb" } } }), + /plot style table must be/, + ); + throws( + () => + defineStandard({ + ...base, + // @ts-expect-error: a zone's name is an id. + sheetFormats: { s: { ...sheet, zones: { "a b": [1, 1, 2, 2] } } }, + }), + /An id can only use/, + ); + + throws( + () => + defineStandard({ + ...base, + // @ts-expect-error: an arrowhead is one of ARROWHEADS. + dimensionStyles: { d: { ...style, role: "note", arrowhead: "dot" } }, + }), + /ARROWHEADS/, + ); + throws( + () => + defineStandard({ + ...base, + // @ts-expect-error: AUDIT reports a dimension style with a negative size. + dimensionStyles: { d: { ...style, role: "note", arrowSize: -1 } }, + }), + /This value cannot be less than 0/, + ); + throws( + () => + // @ts-expect-error: AutoCAD draws a box around a text whose gap is negative. + defineStandard({ ...base, dimensionStyles: { d: { ...style, role: "note", textGap: -1 } } }), + /The gap cannot be less than 0/, + ); + throws( + () => + defineStandard({ + ...base, + // @ts-expect-error: a precision runs from 0 to 8. + dimensionStyles: { d: { ...style, role: "note", writing: { ft: { precision: 9 } } } }, + }), + /A precision is a whole number/, + ); + throws( + () => + defineStandard({ + ...base, + dimensionStyles: { + d: { + ...style, + role: "note", + writing: { + ft: { + precision: 1, + // @ts-expect-error: a prefix cannot hold the place of the measurement. + prefix: "<>", + // @ts-expect-error: AutoCAD reads a brace as formatting. + suffix: "{", + // @ts-expect-error: a measurement scale is above 0. + measurementScale: 0, + // @ts-expect-error: decimal is the one way tsdraft writes a length. + units: "fractional", + // @ts-expect-error: the zeros left out are leading, trailing or both. + suppressZeros: "bogus", + }, + }, + }, + }, + }), + (thrown: DrawingError) => + /A prefix cannot contain <>/.test(thrown.message) && + /AutoCAD reads them as the formatting/.test(thrown.message) && + /The measurement scale must be greater than 0/.test(thrown.message) && + /units must be "decimal"/.test(thrown.message) && + /suppressZeros must be "leading", "trailing" or "both"/.test(thrown.message), + ); + + throws( + () => + // @ts-expect-error: a tag holds no lower-case letter. + defineStandard({ ...base, titleBlocks: { t: { ...block, xref, fields: { Sheet: {} } } } }), + /An attribute's tag cannot hold a lower-case letter/, + ); + throws( + () => + defineStandard({ + ...base, + titleBlocks: { + t: { + ...block, + xref, + // @ts-expect-error: fills is one of what a sheet fills, and a source is one of four. + fields: { N: { fills: "numbr" }, D: { source: "file" } }, + }, + }, + }), + (thrown: DrawingError) => + /fills must be "number"/.test(thrown.message) && /source must be/.test(thrown.message), + ); + throws( + () => + defineStandard({ + ...base, + // @ts-expect-error: AutoCAD draws an architectural tick with the block _ArchTick. + titleBlocks: { t: { ...block, xref: { ...xref, name: "_ArchTick" } } }, + }), + /_ArchTick/, + ); + throws( + () => + defineStandard({ + ...base, + // @ts-expect-error: an xref's path starts from the folder of the drawing's file. + titleBlocks: { t: { ...block, xref: { ...xref, path: "/b.dwg" } } }, + }), + /must start from the folder/, + ); +}); + +test("values the compiler cannot see are checked when the code runs, and names fold as AutoCAD folds them", () => { + const name: string = ["A", "B"].join(","); + assert.throws( + () => defineStandard({ ...base, layers: { [name]: { color: 1 } } }), + /cannot contain/, + ); + // The compiler compares names exactly; AutoCAD takes "HEAVY" for "heavy" and "c-prop" for + // "C-PROP", and so does the runtime, which keeps the name as the standard spells it. + const folded = defineStandard({ ...base, layers: { A: { color: 1, pen: "HEAVY" as string } } }); + assert.equal(folded.layers.A.pen, "heavy"); + // @ts-expect-error: the compiler holds a literal to the standard's spelling. + defineStandard({ ...base, layers: { A: { color: 1, pen: "HEAVY" } } }); + assert.deepEqual(problems({ ...base, layers: { A: { color: 1, linetype: "continuous" } } }), []); + assert.deepEqual(problems({ ...base, layers: { A: { color: 1, linetype: "phantom" } } }), []); +}); + +test("a child replaces an entry whole under its parent's spelling, and null removes one", () => { + const child = 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, + "c-prop": { color: 3 }, + }, + }); + assert.deepEqual(child.layers["C-PROP"], { color: 3, linetype: "Continuous", plot: true }); + assert.ok(!("c-prop" in child.layers) && !("C-ROAD-CNTR" in child.layers)); + // @ts-expect-error: the removed layer is not in the child's type. + void child.layers["C-ROAD-CNTR"]; + assert.equal(child.layers["A-WALL"].color, 2); + assert.deepEqual(child.chain, [{ id: "tsdraft", version: "1", hash: BUILT_IN_STANDARD.hash }]); + assert.equal(child.title, "acme"); + assert.equal(child.minTextHeight, BUILT_IN_STANDARD.minTextHeight); + assert.ok( + Object.isFrozen(child) && + Object.isFrozen(child.layers) && + Object.isFrozen(child.layers["A-WALL"]), + ); + // Entries are listed by layerKey of their names. + assert.deepEqual(Object.keys(child.layers).slice(0, 3), ["A-WALL", "C-ANNO-DIMS", "C-ANNO-SYMB"]); + + assert.deepEqual(problems({ ...base, pens: { thin: null } }), [ + `pens["thin"]: null removes the parent's entry of this name, and the parent has no pen of this name`, + ]); + // An inherited entry that names a removed one is an error, which says the parent wrote it. + const fine = (layer: string) => + `layers["${layer}"].pen: Pen "fine", which the parent's layer names, is not a pen of the standard. Add it to pens, or name one of its pens`; + assert.deepEqual(problems({ ...base, pens: { fine: null } }).toSorted(), [ + fine("C-ANNO-DIMS"), + fine("C-PROP-ADJC"), + fine("C-ROAD-CNTR"), + ]); + assert.match( + problems({ ...base, layers: { X: { color: 1 }, x: { color: 2 } } })[0]!, + /^layers\["x"\]: The standard already has a layer of this name, as "X"/, + ); + assert.match( + problems({ ...base, layers: { straße: { color: 1 }, strasse: { color: 2 } } })[0]!, + /AUDIT reports them as one duplicate name/, + ); + assert.match( + problems({ ...base, pens: { Thin: { lineweight: 0.2 }, thin: { lineweight: 0.2 } } })[0]!, + /compared without regard to case/, + ); + const zones = { views: [1, 1, 2, 2], VIEWS: [1, 1, 2, 2] }; + assert.deepEqual( + problems({ ...base, sheetFormats: { s: { paper: "ARCH D", titleBlock: "plain", zones } } }), + [ + `sheetFormats["s"].zones["VIEWS"]: The sheet format already has a zone of this id, as "views". Ids are compared without regard to case`, + ], + ); + assert.deepEqual( + problems({ ...shared, layers: { A: { color: 1, byDiscipline: { C: { color: 2 }, c: {} } } } }), + [ + `layers["A"].byDiscipline["c"]: byDiscipline already has a discipline of this id, as "C". Ids are compared without regard to case`, + ], + ); + assert.deepEqual(problems({ ...base, dimensionStyles: { dims: null } }), [ + `defaultDimensionStyle: Dimension style "dims", the parent's defaultDimensionStyle, is not a dimension style of the standard. Set defaultDimensionStyle to one of its dimension styles`, + ]); + assert.deepEqual(problems({ ...base, paperUnits: "mm" }), [ + `paperUnits: A standard keeps its parent's paperUnits, "in"`, + ]); + assert.deepEqual(problems({ id: "t", version: "1" }), [ + `paperUnits: A standard that extends nothing must have the field "paperUnits"`, + `minTextHeight: A standard that extends nothing must have the field "minTextHeight"`, + ]); + assert.deepEqual(problems({ ...base, id: "tsdraft" }), [ + `extends: The standard "tsdraft" version "1" extends itself`, + ]); + assert.match( + problems({ ...base, minTextHeight: 0.1 })[0]!, + /textRoles\["dimension"\]\.height: A text role's height, 0.09375, is below the standard's minTextHeight, 0.1/, + ); +}); + +test("a standard's type names only entries it has, and names that are not literals are strings", () => { + // AutoCAD takes ΟΣ for οσ, though Lowercase makes it ος, with a final sigma. + const greek = defineStandard({ ...alone, layers: { ΟΣ: { color: 1 }, ΟΣΑ: { color: 1 } } }); + const child = defineStandard({ id: "c", version: "1", extends: greek, layers: { οσ: null } }); + assert.deepEqual(Object.keys(child.layers), ["ΟΣΑ"]); + // @ts-expect-error: the child removed the layer. + void child.layers["ΟΣ"]; + // The type takes ÿ for Ÿ, which AutoCAD keeps apart, so it names neither. + const upper = defineStandard({ ...alone, layers: { Ÿ: { color: 1 } } }); + const lower = defineStandard({ + id: "c", + version: "1", + extends: upper, + layers: { Ÿ: null, ÿ: { color: 2 } }, + }); + assert.deepEqual(Object.keys(lower.layers), ["ÿ"]); + // @ts-expect-error: the child removed Ÿ. + void lower.layers.Ÿ; + + // An entry that may be null or left out may be gone, so the type leaves its name out. + const entry = { color: 3 } as NonNullable[string]; + const maybe = defineStandard({ ...base, layers: { B: entry } }); + // @ts-expect-error: B may be gone. + void maybe.layers.B; + const optional: { A?: { color: 1 }; C: { color: 1 } } = { C: { color: 1 } }; + const left = defineStandard({ ...base, layers: optional }); + assert.equal(left.layers.C.color, 1); + // @ts-expect-error: A may be left out. + void left.layers.A; + // A value typed as a document has names of type string. + const doc: StandardDocument = { ...base, layers: { A: { color: 1 }, "C-PROP": null } }; + const typed = defineStandard(doc); + assert.equal(typed.layers["A"]?.color, 1); + assert.equal(typed.layers["C-PROP"], undefined); +}); + +test("a standard reads only its own fields, and drops what is for people", () => { + assert.deepEqual(problems({ ...base, layers: { A: { color: 1, colour: 2 } } }), [ + `layers["A"].colour: "colour" is not a field of a layer in tsdraft ${VERSION}`, + ]); + const later = { ...base, $schema: schemaUrl("99.0.0"), layers: { A: { color: 1, hue: 2 } } }; + assert.deepEqual(problems(later), [ + `layers["A"].hue: "hue" is not a field of a layer in tsdraft ${VERSION}. This standard was written for tsdraft 99.0.0: update tsdraft`, + ]); + const commented = defineStandard({ + ...base, + $comment: "Question 1", + layers: { A: { color: 1, $comment: "x" } }, + }); + assert.deepEqual(commented.layers.A, { color: 1, linetype: "Continuous", plot: true }); + assert.ok(!("$comment" in commented)); + // A title block's drawing takes only the fields of a Drawing. + const drawing = { ...fragment(() => {}), $comment: "x" }; + const tb = { + paper: "ARCH D", + layer: "G-ANNO-TTLB", + drawingArea: [1, 1, 2, 2], + block: drawing, + blockName: "B", + }; + assert.match( + problems({ ...base, titleBlocks: { t: tb } }).join("\n"), + /titleBlocks\["t"\]\.block: The drawing: unknown field "\$comment"/, + ); +}); + +test("a string names a standard or a drawing in files, and the library reads no file", () => { + const acme = defineStandard({ ...base, id: "acme", layers: { "A-WALL": { color: 2 } } }); + const child = defineStandard( + { id: "job", version: "1", extends: "./acme.json" }, + { files: { "./acme.json": acme } }, + ); + assert.equal(child.layers["A-WALL"].color, 2); + const builtIn = defineStandard({ id: "job", version: "1", extends: "tsdraft/standard.json" }); + assert.equal(builtIn.chain[0]?.hash, BUILT_IN_STANDARD.hash); + assert.deepEqual(problems({ id: "job", version: "1", extends: "./acme.json" }), [ + `extends: "./acme.json" is not in options.files. The library reads no file: pass the standard it names as files["./acme.json"]`, + ]); + // A standard from another copy of tsdraft is plain data, and its hash must be its content's. + const copied = JSON.parse(JSON.stringify(acme)) as Standard; + assert.equal( + defineStandard({ id: "job", version: "1", extends: copied }).chain[0]?.hash, + acme.hash, + ); + const changed = { ...copied, minTextHeight: 0.07 }; + assert.match( + problems({ id: "job", version: "1", extends: changed })[0]!, + /^extends: The parent is not a standard/, + ); + + const border = fragment((d) => d.block("B", {}, () => d.line([0, 0], [1, 1]))); + const tb = { + paper: "ARCH D", + layer: "G-ANNO-TTLB", + drawingArea: [1, 1, 2, 2], + block: "./b.json", + blockName: "B", + }; + const named = defineStandard( + { ...base, titleBlocks: { t: tb } }, + { files: { "./b.json": JSON.parse(JSON.stringify(border)) } }, + ); + assert.deepEqual(named.titleBlocks.t.block, border); + assert.match( + problems({ ...base, titleBlocks: { t: tb } })[0]!, + /^titleBlocks\["t"\]\.block: "\.\/b\.json" is not in options\.files/, + ); + const plain = defineStandard({ + ...base, + titleBlocks: { + p: { + ...tb, + block: "tsdraft/title-blocks/plain-arch-d.json", + blockName: "TITLE BLOCK", + drawingArea: [0.5, 0.5, 35.5, 23.5], + }, + }, + }); + assert.equal(plain.titleBlocks.p.block, BUILT_IN_STANDARD.titleBlocks.plain.block); +}); + +test("how a firm keeps its sheets decides where a title block's fields come from", () => { + const area = { + paper: "ARCH D", + layer: "G-ANNO-TTLB", + drawingArea: [0.5, 0.5, 35.5, 23.5], + } as const; + const xref = { name: "BORDER", path: "xref/border.dwg" } as const; + const named = { + NUMBER: { fills: "number", source: "fileName" }, + TITLE: { fills: "title", source: "layoutName" }, + } as const; + assert.deepEqual( + problems({ ...shared, titleBlocks: { firm: { ...area, xref, fields: named } } }), + [], + ); + assert.deepEqual( + problems({ ...base, titleBlocks: { firm: { ...area, xref, fields: named } } }).map( + (p) => p.split(": ")[0], + ), + [`titleBlocks["firm"].fields["NUMBER"].source`, `titleBlocks["firm"].fields["TITLE"].source`], + ); + assert.match( + problems({ ...base, titleBlocks: { firm: { ...area, xref, fields: named } } })[0]!, + /one drawing has one file name/i, + ); + assert.match( + problems({ + ...base, + titleBlocks: { firm: { ...area, xref, fields: { D: { source: "currentDate" } } } }, + }).join(), + /^$/, + ); + // A field that AutoCAD fills is a field of the xref's drawing. + const plain = BUILT_IN_STANDARD.titleBlocks.plain; + const own = { ...area, block: plain.block, blockName: "TITLE BLOCK" }; + assert.match( + problems({ + ...shared, + titleBlocks: { t: { ...own, fields: { D: { source: "currentDate" } } } }, + })[0]!, + /this title block has no xref/, + ); + // A field that is an attribute is written in the block's attribute of its tag. + assert.match( + problems({ ...base, titleBlocks: { t: { ...area, xref, fields: { N: {} } } } })[0]!, + /this title block has no block/, + ); + assert.match( + problems({ ...base, titleBlocks: { t: { ...own, fields: { N: {} } } } })[0]!, + /has no attribute with the tag "N"/, + ); + assert.deepEqual( + problems({ + ...base, + titleBlocks: { t: { ...own, xref, fields: { SHEET: { fills: "number" } } } }, + }), + [], + ); + + // byDiscipline holds where each discipline's model is a drawing of its own. + const by = { layers: { TEXT: { color: 7, byDiscipline: { C: { color: 3, pen: "fine" } } } } }; + const sheets = defineStandard({ ...shared, ...by }); + assert.deepEqual(sheets.layers.TEXT.byDiscipline, { C: { color: 3, pen: "fine" } }); + assert.match( + problems({ ...base, ...by })[0]!, + /^layers\["TEXT"\]\.byDiscipline: byDiscipline holds only under sheetFiles "sheet-per-file"/, + ); + assert.match( + problems({ + ...shared, + layers: { TEXT: { color: 7, discipline: "C", byDiscipline: { V: { color: 3 } } } }, + })[0]!, + /An entry of one discipline has one set of fields/, + ); + assert.match( + problems({ + ...shared, + layers: { TEXT: { color: 7, byDiscipline: { C: { pen: "thin" } } } }, + })[0]!, + /^layers\["TEXT"\]\.byDiscipline\["C"\]\.pen: Pen "thin"/, + ); +}); + +test("a title block's names are one definition each in a drawing", () => { + const area = { + paper: "ARCH D", + layer: "G-ANNO-TTLB", + drawingArea: [0.5, 0.5, 35.5, 23.5], + } as const; + const plain = BUILT_IN_STANDARD.titleBlocks.plain; + // The same block and text style in two title blocks are one copy in a drawing. + assert.deepEqual( + problems({ + ...base, + titleBlocks: { again: { ...area, block: plain.block, blockName: "TITLE BLOCK" } }, + }), + [], + ); + const other = fragment((d) => { + d.textStyle({ name: "TITLE BLOCK", font: "Times New Roman" }); + d.block("TITLE BLOCK", {}, () => d.text([1, 1], "X", { height: 0.1, style: "TITLE BLOCK" })); + }); + const clash = problems({ + ...base, + titleBlocks: { other: { ...area, block: other, blockName: "title block" } }, + }); + assert.deepEqual(clash, [ + `titleBlocks["other"].block: The title blocks "plain" and "other" both hold a block "TITLE BLOCK", with different definitions. A drawing holds one block of a name: give one of them another name`, + `titleBlocks["other"].block: The title blocks "plain" and "other" both hold a text style "TITLE BLOCK", with different definitions. A drawing holds one text style of a name: give one of them another name`, + ]); + const titled = fragment((d) => { + d.textStyle({ name: "Title", font: "Arial" }); + d.block("SHEET", {}, () => d.text([1, 1], "X", { height: 0.1, style: "Title" })); + }); + assert.match( + problems({ ...base, titleBlocks: { t: { ...area, block: titled, blockName: "SHEET" } } })[0]!, + /text style "Title" has the name of the text style of the role "title"/, + ); + const xref = { ...area, xref: { name: "TITLE BLOCK", path: "tb.dwg" } }; + assert.match( + problems({ ...base, titleBlocks: { t: xref } })[0]!, + /The xref "TITLE BLOCK" has the name of the block "TITLE BLOCK" of the title block "plain"/, + ); + const border = (path: string) => ({ ...area, xref: { name: "BORDER", path } }); + assert.deepEqual( + problems({ ...base, titleBlocks: { a: border("b.dwg"), b: border("b.dwg") } }), + [], + ); + assert.match( + problems({ ...base, titleBlocks: { a: border("b.dwg"), b: border("c.dwg") } })[0]!, + /both have an xref "BORDER", at different paths/, + ); + + const on = (layer: string) => + fragment((d) => + d.block("B", {}, () => d.layer(layer, { color: 1 }, () => d.line([0, 0], [1, 1]))), + ); + const tb = (block: object) => ({ + ...base, + titleBlocks: { t: { ...area, block, blockName: "B" } }, + }); + assert.deepEqual(problems(tb(on("G-ANNO-TTLB"))), []); + assert.match( + problems(tb(on("X-RED")))[0]!, + /draws on the layer "X-RED", which is not a layer of the standard/, + ); + const dashed = fragment((d) => + d.linetype({ name: "DASHED", pattern: [1, -1] }).block("B", {}, () => d.line([0, 0], [1, 1])), + ); + assert.match( + problems(tb(dashed))[0]!, + /defines the linetype "DASHED" otherwise than the standard does/, + ); + const sheet = fragment((d) => + d.block("B", {}, () => d.line([0, 0], [1, 1])).layout("L", { paper: "ARCH D" }, () => {}), + ); + assert.match(problems(tb(sheet))[0]!, /cannot hold a layout/); + const feet = fragment((d) => d.block("B", {}, () => d.line([0, 0], [1, 1])), "ft"); + assert.match( + problems(tb(feet))[0]!, + /The drawing is in "ft", and the standard's paperUnits are "in"/, + ); + const unnamed = { ...base, titleBlocks: { t: { ...area, block: on("0"), blockName: "C" } } }; + assert.match( + problems(unnamed)[0]!, + /The title block's drawing has no block "C", which blockName names/, + ); +}); + +test("boxes lie inside the boxes that hold them, and AutoCAD's names of dimension styles are distinct", () => { + const area = { + paper: "ARCH D", + layer: "G-ANNO-TTLB", + xref: { name: "B", path: "b.dwg" }, + } as const; + assert.match( + problems({ ...base, titleBlocks: { t: { ...area, drawingArea: [0, 0, 40, 24] } } })[0]!, + /drawingArea: The box reaches past the paper, \[0, 0, 36, 24\]/, + ); + assert.match( + problems({ ...base, titleBlocks: { t: { ...area, drawingArea: [2, 2, 1, 3] } } })[0]!, + /with left less than right/, + ); + assert.match( + problems({ + ...base, + titleBlocks: { t: { ...area, drawingArea: [1, 1, 35, 23], box: [30, 0, 35, 3] } }, + })[0]!, + /\.box: The box reaches past the drawing area/, + ); + const format = { paper: "ARCH D", titleBlock: "plain" } as const; + assert.match( + problems({ ...base, sheetFormats: { s: { ...format, zones: { z: [0, 0, 1, 1] } } } })[0]!, + /zones\["z"\]: The box reaches past the title block's drawing area/, + ); + assert.match( + problems({ ...base, sheetFormats: { s: { ...format, paper: "ARCH C" } } })[0]!, + /The sheet format's paper, "ARCH C", is not its title block's, "ARCH D"/, + ); + const style = { + role: "note", + arrowSize: 0.1, + extensionOffset: 0.1, + extensionBeyond: 0.1, + textGap: 0.1, + }; + const twins = { + ...alone, + textRoles: { note: { font: "Arial", height: 0.1 } }, + scales: { "b-c": { label: "1", ratio: 1 }, c: { label: "2", ratio: 2 } }, + dimensionStyles: { a: style, "a-b": style }, + }; + assert.match( + problems(twins)[0]!, + /The dimension style "a-b" at the scale "c" and the dimension style "a" at the scale "b-c" have names that AutoCAD takes for one dimension style/, + ); +}); + +test("the hash is what the merged document states for a check", () => { + const child = defineStandard({ ...base, id: "acme", layers: { "A-WALL": { color: 2 } } }); + // Flattened into one document, a standard keeps its hash. + const { hash, chain: _, ...flat } = child; + assert.equal(defineStandard(flat as never).hash, hash); + const again = (fields: object) => + defineStandard({ ...base, id: "acme", layers: { "A-WALL": { color: 2, ...fields } } } as never) + .hash; + // Text for people, provenance and a default given or not leave it as it is. + assert.equal(again({ description: "Walls", $comment: "x" }), hash); + assert.equal(again({ plot: true, linetype: "CONTINUOUS" }), hash); + assert.equal( + defineStandard({ + ...base, + id: "other", + version: "9", + title: "Other", + layers: { "A-WALL": { color: 2 } }, + }).hash, + hash, + ); + // A rule, a discipline and a linetype's description change it. + assert.notEqual(again({ color: 3 }), hash); + assert.notEqual(again({ discipline: "G" }), hash); + const described = (description: string) => + defineStandard({ ...base, linetypes: { DASHED: { description, pattern: [0.5, -0.25] } } }).hash; + assert.notEqual(described("Dashes"), described("Dashed __ __ __")); + assert.equal(described(BUILT_IN_STANDARD.linetypes.DASHED.description), BUILT_IN_STANDARD.hash); + // So does anything in a title block's drawing. + const plain = BUILT_IN_STANDARD.titleBlocks.plain; + const moved = { + ...plain.block!, + blocks: plain.block!.blocks!.map((b) => ({ ...b, base: [0, 1] as const })), + }; + assert.notEqual( + defineStandard({ ...base, titleBlocks: { plain: { ...plain, block: moved } } }).hash, + BUILT_IN_STANDARD.hash, + ); + + // The JSON the package ships is a document that resolves to the built-in standard. + const json = JSON.parse(readFileSync(join(import.meta.dirname, "..", "standard.json"), "utf8")); + assert.equal(defineStandard(json).hash, BUILT_IN_STANDARD.hash); + // The library makes the built-in standard without the checks, which its document passes, and + // with the hash it has. A change to what it states needs a new version, which the CHANGELOG + // gives, and then the hash in src/standard/define.ts. + const checked = defineStandard(BUILT_IN_DOCUMENT as never); + assert.equal(JSON.stringify(checked), JSON.stringify(BUILT_IN_STANDARD)); + assert.deepEqual(lint(BUILT_IN_STANDARD.titleBlocks.plain.block!), []); + assert.equal(BUILT_IN_STANDARD.version, "1"); +}); + +test("SHA-256 gives the digests of FIPS 180-4 and of node:crypto", () => { + assert.equal(sha256Text(""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + assert.equal( + sha256Text("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + for (const length of [55, 56, 63, 64, 65, 1000]) { + const bytes = Uint8Array.from({ length }, (_, i) => (i * 37 + 11) % 256); + assert.equal(sha256(bytes), createHash("sha256").update(bytes).digest("hex")); + } + const text = "ÉΣ日本\u{1F600}\ud800"; + assert.equal(sha256Text(text), createHash("sha256").update(text).digest("hex")); +}); + +test("the schema refuses the prefixes, suffixes and xref paths the runtime refuses", () => { + type Rule = { readonly pattern: string; readonly maxLength?: number }; + const defs = standardSchema(VERSION).$defs as Record< + string, + { readonly properties: Record } + >; + const agree = (rule: Rule, problem: (value: string) => string | undefined, values: string[]) => { + for (const flags of ["", "u"]) { + const pattern = new RegExp(rule.pattern, flags); + for (const value of values) { + const schema = pattern.test(value) && [...value].length <= (rule.maxLength ?? Infinity); + assert.equal( + schema, + problem(value) === undefined, + `${JSON.stringify(value)}, flags "${flags}"`, + ); + } + } + }; + const text = ["", "Ø", "a\u0007", "\ue800", "\ud800", "😀", "\\P", "{", "}", "a\u007f"]; + const prefixes = [...text, "R=", "<>", "a<>b", "<"]; + agree(defs.writing!.properties.prefix!, dimensionPrefixProblem, prefixes); + agree(defs.writing!.properties.suffix!, plainDimensionText, [...text, "'", "<>"]); + const paths = ["b.dwg", "dir/b.dwg", "dir\\b.dwg", "b.dwg.", "B.DWG.", "Ø.dwg", "", "b.dxf"]; + paths.push("B.DXF", "b.", "b..", "/b.dwg", "\\b.dwg", "c:b.dwg", "a*b", 'a"b', "x\\M+12345"); + paths.push("x\\m+62345", "a\u0001", "\ue800", "😀", "x".repeat(32000), "x".repeat(32001)); + agree(defs.xref!.properties.path!, xrefPathProblem, paths); +}); + +test("the files the package ships are the ones the script writes", () => { + const root = join(import.meta.dirname, ".."); + const version = packageVersion(); + const read = (name: string) => JSON.stringify(JSON.parse(readFileSync(join(root, name), "utf8"))); + const fix = "Run `pnpm standard:files` and commit what it writes"; + assert.equal(read("standard.json"), JSON.stringify(builtInDocument(version)), fix); + assert.equal(read("standard.schema.json"), JSON.stringify(standardSchema(version)), fix); + assert.equal(readFileSync(join(root, "src", "version.ts"), "utf8"), versionSource(version), fix); + assert.equal(VERSION, version); +});