From 2c45129ba9110f17c32c08fde2a8feecade1163b Mon Sep 17 00:00:00 2001 From: Stephen Frederick Date: Fri, 13 Nov 2026 13:52:58 -0500 Subject: [PATCH] Check a drawing against its standard with lint `lint(drawing, { standard })` holds a drawing to the standard it is drawn to, as the builder does at each call, so a drawing made by other code or read from JSON is held to it too. Without `standard`, `lint` reports what it did before. - `lint` takes the standard as plain data, from this copy of tsdraft or another: it checks it and works out its hash again. A standard that does not check, or whose hash is not its content's, is one `standard` error, and `lint` holds the drawing to nothing else of it. `discipline` names the drawing's discipline, which resolves the entries with fields by discipline. - A drawing that breaks what the builder refuses gets an error under the same rule: a layer, a linetype, a text style or a dimension style the standard does not have, an entity of model space or of a layout on layer `0`, an entity's lineweight that is no pen's, a text in no role's text style, a viewport at no scale of the standard, a layout on a paper that no sheet format is on, and a second layout under `sheet-per-file`. - And what only the whole drawing shows: a layer, a linetype, a role's text style or a dimension style whose values are not the standard's, a layout whose plot style table is not its sheet format's, a viewport past its drawing area or into the title block, a title block whose block, text styles, linetypes or xref are not the standard's, a text that plots at another height than its role's, or below the standard's least height, in a viewport that shows it or on its layout, and a dimension shown at another ratio than its style's. - Warnings say when a layout's sheet format cannot be told, when a layout has no title block, a field is unset or its value runs past its box, when entries with fields by discipline went unchecked, and where the checks of heights stopped at their limit. - A layout's sheet format is the one on its paper. Among several on its paper, it is one whose title block the layout inserts, and of those, one whose plot style table the layout has, so two sheet formats that differ only in their plot style table are told apart. Sheet formats that share a title block are checked alike. - `lint` stays bounded on any data. It reads a list up to its first empty slot, as its other rules do. The checks of heights enter only a block that can draw a text, and count each insert they walk, each item of the block it enters, each name the first time they read it, by its length, the length of each text, of what each dimension's label reads, its style's name, its own text and its style's prefix, suffix and text style, and of each error's message. They stop at their limit, or at blocks nested deeper than they can follow, with `standard-check-capped`. A title block's definition with a field its kind does not have is not the standard's, and its JSON is compared no further than the length of the standard's. Its work on the standard grows with the standard's size, not with its dimension styles times its scales. - `defineStandard` checks that no two names of its dimension styles, on a layout and at each scale, are one to AutoCAD in time that grows with its styles and scales, not with their product, and reports the same problems. - `LintRule` gains `discipline-unknown`, `linetype-differs-from-standard`, `text-style-differs-from-standard`, `dimension-style-differs-from-standard`, `sheet-format-ambiguous`, `height-below-standard`, `height-off-role`, `dimension-off-scale`, `viewport-outside-drawing-area`, `title-block-missing`, `title-block-differs-from-standard`, `title-block-field-unset`, `title-block-field-overflow` and `standard-check-capped`. Code that switches over every rule needs them. `LintOptions` is a type: `version` alone, or `standard` with `version` and `discipline`. [docs/rules.md](docs/rules.md) lists each rule. Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 10 + docs/rules.md | 93 +++ src/json.ts | 21 +- src/lint.ts | 61 +- src/shape.ts | 3 +- src/standard/define.ts | 159 +++- src/standard/lint.ts | 1482 ++++++++++++++++++++++++++++++++++++ src/standard/made.ts | 22 +- test/shape.test.ts | 36 + test/standard-lint.test.ts | 720 ++++++++++++++++++ 10 files changed, 2558 insertions(+), 49 deletions(-) create mode 100644 src/standard/lint.ts create mode 100644 test/standard-lint.test.ts diff --git a/AGENTS.md b/AGENTS.md index 99bd2b2..d20c9fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,16 @@ on first use, under `capitals` of the id, and in model space with the view's scale id after a dimension style's: `DIMS-1in-10ft`. A view's id is taken when `view()` declares it, and the one viewport that shows the view takes that id and no other. +- `lint(drawing, { standard })` runs the rules of a standard in `src/standard/lint.ts` after the + others, and without `standard` runs none of them. It takes the standard through `adopted` in + `src/standard/made.ts`, as the builder does, and reads only what `TYPED` in `src/shape.ts` finds + sound. A rule the builder refuses under a standard has the builder's rule name in `lint`, and + its message from `src/standard/drawn.ts` where that fits a `Drawing`. It must stay bounded by + construction: it reads a list up to its first empty slot, reads each item of the drawing once, + and pays for anything it does again, in proportion to its size, before it does it. The walk of + an insert reads blocks made once a drawing, with only what can draw a text and a short token for + each name, and pays for each block it enters. A title block's definition is compared as JSON + cut short at the length of the standard's. `test/standard-lint.test.ts` fuzzes it from a seed. - `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/docs/rules.md b/docs/rules.md index 62e105d..d9eb6c7 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -229,3 +229,96 @@ it would take `1e+100` for `1e+10` and report nothing. `toDXF` throws a `DrawingError` for errors only. Its `diagnostics` property holds them, and other code may throw one to refuse a drawing for its own reasons. + +## A drawing's standard + +`lint(drawing, { standard })` also holds a drawing to the standard it is drawn to, as the builder +does at each call, so a drawing made by other code or read from JSON is held to it too. AutoCAD +opens a drawing that breaks its standard, and `toDXF` writes one: these are a firm's rules, not +AutoCAD's. `lint` takes the standard as plain data, from any copy of tsdraft: it checks it, and +works out its hash again. A standard that does not check, or whose hash is not its content's, is +one `standard` error, and `lint` holds the drawing to nothing else of it. `discipline` names the +drawing's discipline, which resolves the entries with fields by discipline. Without `standard`, +`lint` checks none of this. + +Errors: + +- `layer-outside-standard`: a layer the standard does not have, and an entity of model space or + of a layout on layer `0`, which is in no standard. In a block, layer `0` draws as the insert's + layer does, and is the layer to draw on. +- `layer-differs-from-standard`: a layer whose color, linetype, lineweight or plot flag is not + the standard's, once for each. +- `linetype-outside-standard`: a linetype the standard does not have. +- `linetype-differs-from-standard`: a linetype whose pattern or description is not the + standard's, and a linetype scale other than the standard's. +- `pen-outside-standard`: an entity's lineweight that is no pen's. `ByLayer` and `ByBlock` are + every standard's. +- `text-style-outside-standard`: a text style that is no role's and no title block's, and a text + or an attribute definition in no role's text style, `Standard` among them. +- `text-style-differs-from-standard`: a role's text style whose font, width factor or oblique + angle is not the role's. +- `dimension-style-outside-standard`: a dimension style that is none of the standard's, which + are named after the style's id in capitals on a layout, such as `DIMS`, and with a scale's id + after it in model space, such as `DIMS-1in-10ft`. +- `dimension-style-differs-from-standard`: one of those whose text style, sizes, arrowhead, + scale or writing is not what the standard gives it, once for each. A style that the standard + says nothing about how to write in its units, the drawing's or the paper's, is + `dimension-style`. +- `scale-outside-standard`: a viewport whose ratio is no scale of the standard. +- `sheet-format-outside-standard`: a layout on a paper that no sheet format of the standard is + on, and one whose plot style table is not its sheet format's. +- `viewport-outside-drawing-area`: a viewport that reaches past the drawing area of its sheet + format's title block, or into the title block's box. +- `title-block-differs-from-standard`: a title block in the drawing that is not the standard's, + as below, and a layout that inserts the title block of another sheet format than its own. +- `height-off-role`: a text in a role's text style that plots at another height than the + role's. +- `height-below-standard`: a text that plots below the standard's `minTextHeight`. +- `dimension-off-scale`: a dimension shown in a viewport at another ratio than its style's + scale, and one on a layout in a style for model space. +- `sheet-per-file`: under `sheetFiles` `"sheet-per-file"`, a drawing with a second layout, and + a sheet number in the title block that cannot name a file. +- `layout`: under `sheetFiles` `"one-drawing"`, a sheet number in the title block that is not + its layout's name. +- `standard`: besides a standard that does not check, an R12 drawing, and a `discipline` that is + none of the standard's, or is given under `"one-drawing"`. + +Warnings: + +- `sheet-format-ambiguous`: a layout on a paper that several sheet formats are on, which inserts + the title block of none of them, or of several with other title blocks that its plot style + table does not tell apart. `lint` does not check its drawing area, its title block or its + fields. +- `title-block-missing`: a layout with no insert of its sheet format's title block. +- `title-block-field-unset`: a field of the title block, written as an attribute, that its insert + does not set, so it shows its attribute's default. +- `title-block-field-overflow`: a field's value that runs past the field's box, as `measureText` + measures it. +- `discipline-unknown`: the entries with fields by discipline that `lint` checked in none of + their forms, since it was not given the drawing's discipline. +- `standard-check-capped`: where the checks of text heights and dimension scales stopped. They + are bounded, counting each text, each dimension by the length of what its label reads, its + style's name, its own text and its style's prefix, suffix and text style, each insert they walk + and each item of the block it enters, each name they read the first time, by its length, and + each error by the length of its message. They enter only a block that can draw a text, so a + drawing with no text in its blocks costs nothing to walk. A drawing that draws more than they + look through, or nests blocks deeper than they can follow, gets one warning that says where + they stopped. The other checks take time in proportion to the drawing, the standard and the + problems they report: a standard given as data gets one problem for each pair of its names + that AutoCAD takes for one, however many there are. + +A layout is on the one sheet format of the standard on its paper. Among several on its paper, it +is on those whose title block it inserts, and of those, on the ones whose plot style table it +has. When they share one title block, `lint` checks the layout against that title block. + +A text is checked in each viewport that shows it, whether model space draws it itself or through +an insert: where its box meets the part of model space the viewport shows, and its layer is on, +thawed and plots. It plots at its height, times the scale of the insert that draws it, times the +viewport's scale. A text of a layout plots at its height, times its insert's scale. Heights are +compared as written, with 6 decimals. + +A title block in the drawing is the standard's when every definition of its closure is, by its +JSON: its block, each block that block inserts, each text style their texts and attributes name, +each linetype their entities name, and its xref's name and path. Then the rules for pens, text +styles and role heights leave out what it draws, and `height-below-standard` still holds its +texts. When one of them differs, or is missing, every rule holds it as any other block. diff --git a/src/json.ts b/src/json.ts index ef03f0c..f68b07d 100644 --- a/src/json.ts +++ b/src/json.ts @@ -22,20 +22,34 @@ export function toJSON(drawing: Drawing): string { * 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. + * + * It also throws once the JSON would run past `most` characters, or it has read more than + * `most` keys, so a value that holds one list many times over costs no more than `most`. */ export function canonical( value: unknown, what = "A value", written?: (value: object) => string | undefined, + most = Infinity, ): string { let out = ""; + // Every key read, those whose value is `undefined` among them, which are left out: so it counts + // the work, and is only an upper bound of the keys written. + let keys = 0; + const past = (more: number): void => { + if (out.length + more > most || keys > most) { + throw new RangeError(`${what} runs past ${most} characters of JSON`); + } + }; const write = (value: unknown): void => { + past(0); switch (typeof value) { case "number": if (!Number.isFinite(value)) break; out += Object.is(value, -0) ? "-0" : value; return; case "string": + past(value.length + 2); out += JSON.stringify(value); return; case "boolean": @@ -52,6 +66,8 @@ export function canonical( return; } if (Array.isArray(value)) { + // Its brackets and commas. + past(value.length + 1); out += "["; for (let i = 0; i < value.length; i++) { if (i > 0) out += ","; @@ -61,8 +77,11 @@ export function canonical( return; } const fields = value as Record; + const names = Object.keys(fields); + keys += names.length; + past(0); let first = true; - for (const key of Object.keys(fields).sort()) { + for (const key of names.sort()) { if (fields[key] === undefined) continue; out += `${first ? "{" : ","}${JSON.stringify(key)}:`; first = false; diff --git a/src/lint.ts b/src/lint.ts index 4cbcd0a..c13419c 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -39,6 +39,8 @@ import { validIdHandle } from "./handles.ts"; import { validLayoutHandles, validViewportView } from "./layouts.ts"; import { paperInfo } from "./papers.ts"; import { isUnits } from "./units.ts"; +import type { Standard } from "./standard/document.ts"; +import { lintStandard } from "./standard/lint.ts"; import { arrayProblem, isObject, @@ -165,11 +167,29 @@ export type LintRule = | "dimension-style-outside-standard" | "text-style-outside-standard" | "sheet-format-outside-standard" - | "sheet-per-file"; + | "sheet-per-file" + | "discipline-unknown" + | "linetype-differs-from-standard" + | "text-style-differs-from-standard" + | "dimension-style-differs-from-standard" + | "sheet-format-ambiguous" + | "height-below-standard" + | "height-off-role" + | "dimension-off-scale" + | "viewport-outside-drawing-area" + | "title-block-missing" + | "title-block-differs-from-standard" + | "title-block-field-unset" + | "title-block-field-overflow" + | "standard-check-capped"; export interface Diagnostic { readonly rule: LintRule; - /** An error means AutoCAD rejects, repairs or garbles the output. `toDXF` refuses to write it. */ + /** + * An error means AutoCAD rejects, repairs or garbles the output, and `toDXF` refuses to write + * it, or that the drawing breaks a rule of the standard `lint` was given, which `toDXF` does not + * check. + */ readonly severity: "error" | "warning"; readonly message: string; /** The layer the problem is in, when it is a layer problem. */ @@ -217,10 +237,31 @@ export interface Diagnostic { readonly call?: CallSite; } -export interface LintOptions { - /** The DXF version to check against. Defaults to the drawing's own. */ - readonly version?: DxfVersion; -} +/** + * What `lint` checks a drawing against: its DXF version, and the standard it is drawn to, with + * the drawing's discipline. + */ +export type LintOptions = + | { + /** The DXF version to check against. Defaults to the drawing's own. */ + readonly version?: DxfVersion; + readonly standard?: undefined; + readonly discipline?: undefined; + } + | { + /** The DXF version to check against. Defaults to the drawing's own. */ + readonly version?: DxfVersion; + /** + * The standard the drawing is drawn to, as `defineStandard` returns it, from this copy of + * tsdraft or another. `lint` then holds the drawing to its rules too. + */ + readonly standard: Standard; + /** + * The drawing's discipline, a discipline of the standard, which resolves the entries with + * fields by discipline. + */ + readonly discipline?: string; + }; /** * Checks a drawing against the rules AutoCAD enforces when it opens DXF. @@ -234,6 +275,12 @@ export interface LintOptions { * `Drawing`, such as a list that is not an array, an entity of an unknown kind or a field of * the wrong type, is an error that gives the path to it, and no other rule is checked on that * object. A field no object of its type has is an error too. + * + * With `standard`, it then holds the drawing to the standard: its layers, linetypes, pens, text + * styles, dimension styles, sheet formats and title blocks, the height each text plots at in + * each viewport that shows it and on its layout, and the scale each dimension is shown at. It + * takes the standard as plain data, checks it and works out its hash again, and a standard that + * does not check is one `standard` error. Without `standard` it checks none of this. */ export function lint(drawing: Drawing, options: LintOptions = {}): Diagnostic[] { if (typeof drawing !== "object" || drawing === null || Array.isArray(drawing)) { @@ -1211,6 +1258,8 @@ export function lint(drawing: Drawing, options: LintOptions = {}): Diagnostic[] } } + const given: unknown = options?.standard; + if (given !== undefined) lintStandard(drawing, given, options?.discipline, version, out); return out; } diff --git a/src/shape.ts b/src/shape.ts index 4d7512d..a891c04 100644 --- a/src/shape.ts +++ b/src/shape.ts @@ -327,7 +327,8 @@ function shapes(all: boolean, typed = all): Shapes { let found = 0; for (const key in value) { const check = checks[key]; - if (check === undefined) extra.push(unknownFieldProblem(at, key, names)); + // `isSound` reads no message, and the hint compares the key with every field's name. + if (check === undefined) extra.push(asking ? "" : unknownFieldProblem(at, key, names)); else if (check !== LEFT) { const field = value[key]; if (field === undefined) continue; diff --git a/src/standard/define.ts b/src/standard/define.ts index 15f5c5e..f4c58fe 100644 --- a/src/standard/define.ts +++ b/src/standard/define.ts @@ -7,7 +7,7 @@ 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 { isRecord, PLAIN_OBJECT } from "../shape.ts"; import { sha256Text } from "../sha256.ts"; import { BUILT_IN_DOCUMENT, BUILT_IN_SPECIFIER } from "./default.ts"; import { @@ -385,30 +385,24 @@ function resolve( // 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 dimensionStyles = each("dimensionStyles"); const scaleIds = [...merged.scales.values()].map(([name]) => name); - for (const [id, style, path] of each("dimensionStyles")) { + const styleName = ([style, scale]: StyleName): string => + `the dimension style ${shown(dimensionStyles[style]![0])}${scale < 0 ? "" : ` at the scale ${shown(scaleIds[scale])}`}`; + const shared = sharedStyleNames( + dimensionStyles.map(([id]) => id), + scaleIds, + ); + dimensionStyles.forEach(([, style, path], i) => { 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)}`, + for (const [name, other] of shared.get(i) ?? []) { + add( + path, + `${capital(styleName(name))} and ${styleName(other)} have names that AutoCAD takes for one dimension style. Rename the style or the scale`, ); } - } + }); const papers = (paper: string, path: string) => { const info = paperInfo(paper); @@ -645,21 +639,36 @@ function parentOf( /** * A standard from another copy of tsdraft, as data: read again as a document with no `extends`, - * and undefined unless its hash is its content's. + * and undefined unless its hash is its content's. Each problem that makes it none is added to + * `problems`, with its path in the standard. */ -function readAgain(given: unknown): Standard | undefined { - 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) return undefined; +function readAgain(given: unknown, problems: Problem[]): Standard | undefined { + if (!isRecord(given)) { + problems.push({ item: "", message: `A standard ${PLAIN_OBJECT}` }); + return undefined; + } + const { hash, chain, ...rest } = given; + const before = problems.length; + if (typeof hash !== "string") problems.push({ item: "hash", message: NO_HASH }); + let links = Array.isArray(chain); + // An empty slot is no link. `every` would skip it, and read a list of empty slots to its end. + for (let i = 0; links && i < (chain as unknown[]).length; i++) { + const link: unknown = (chain as unknown[])[i]; + links = + isRecord(link) && + typeof link.id === "string" && + typeof link.version === "string" && + typeof link.hash === "string"; + } + if (!links) problems.push({ item: "chain", message: CHAIN }); + if (rest.extends !== undefined) problems.push({ item: "extends", message: RESOLVED_EXTENDS }); + if (problems.length > before) return undefined; + const again = resolve(rest, {}, problems); + if (!again) return undefined; + if (again.hash !== hash) { + problems.push({ item: "hash", message: hashProblem(again.hash) }); + return undefined; + } // Its entries are `again`'s, whose drawings' JSON is known. const standard = Object.freeze({ ...again, @@ -782,12 +791,94 @@ function boxIn( const capital = (what: string): string => what.charAt(0).toUpperCase() + what.slice(1); +/** A dimension style's name in AutoCAD: its style's index, and its scale's, or -1 on a layout. */ +type StyleName = readonly [style: number, scale: number]; + +/** + * Each name of a dimension style that AUDIT takes for one met before it, when the styles are met + * in order and each style's name on a layout before its names at the scales in order: by its + * style's index, the name and the earlier one. + * + * Ids are ASCII, which `layerAuditKey` only lower-cases, so a name's key is its style's key, `-` + * and its scale's, and ids of one section have different keys. Two names are then one only when + * a style's key is another's, `-` and a scale's, or is another's, `-` and a part `w`, while a + * scale's key is `w`, `-` and another scale's. So every such name is found from the places `-` + * splits a key, in time linear in the ids and in the names found, not in styles times scales. + */ +function sharedStyleNames( + styles: readonly string[], + scales: readonly string[], +): ReadonlyMap { + const styleKeys = styles.map((id) => layerAuditKey(capitals(id))); + const scaleKeys = scales.map((id) => layerAuditKey(id)); + const styleAt = new Map(styleKeys.map((key, i) => [key, i])); + const scaleAt = new Map(scaleKeys.map((key, i) => [key, i])); + const splits = function* (key: string): Generator { + for (let at = key.indexOf("-"); at >= 0; at = key.indexOf("-", at + 1)) { + yield [key.slice(0, at), key.slice(at + 1)]; + } + }; + /** Every name that is one with another name. */ + const names: StyleName[] = []; + styleKeys.forEach((key, i) => { + for (const [head, tail] of splits(key)) { + const style = styleAt.get(head); + const scale = scaleAt.get(tail); + if (style !== undefined && scale !== undefined) names.push([i, -1], [style, scale]); + } + }); + const byPart = new Map(); + scaleKeys.forEach((key, i) => { + for (const [head, tail] of splits(key)) { + const rest = scaleAt.get(tail); + if (rest === undefined) continue; + const found = byPart.get(head); + if (found) found.push([i, rest]); + else byPart.set(head, [[i, rest]]); + } + }); + styleKeys.forEach((key, i) => { + for (const [head, part] of splits(key)) { + const style = styleAt.get(head); + if (style === undefined) continue; + for (const [scale, rest] of byPart.get(part) ?? []) names.push([style, scale], [i, rest]); + } + }); + + names.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const first = new Map(); + const shared = new Map(); + names.forEach((name, n) => { + const before = names[n - 1]; + if (before && before[0] === name[0] && before[1] === name[1]) return; + const [style, scale] = name; + const key = scale < 0 ? styleKeys[style]! : `${styleKeys[style]}-${scaleKeys[scale]}`; + const earlier = first.get(key); + if (earlier === undefined) first.set(key, name); + else { + const found = shared.get(style); + if (found) found.push([name, earlier]); + else shared.set(style, [[name, earlier]]); + } + }); + return shared; +} + 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 NO_HASH = + "A standard holds its hash, the SHA-256 of what it states, as 64 hexadecimal digits. Give the standard as defineStandard returns it"; +const CHAIN = + "A standard holds its chain, a list of the id, version and hash of each parent. Give the standard as defineStandard returns it"; +const RESOLVED_EXTENDS = + "A standard is resolved, with its parent's entries in it, and has no extends. Give the standard as defineStandard returns it"; +const hashProblem = (hash: string): string => + `The standard's hash is not the hash of what it states, ${hash}: it changed after defineStandard made it. Give the standard as defineStandard returns it`; + const REFERENCE_WORDS: Record = { disciplines: "discipline", pens: "pen", diff --git a/src/standard/lint.ts b/src/standard/lint.ts new file mode 100644 index 0000000..e864836 --- /dev/null +++ b/src/standard/lint.ts @@ -0,0 +1,1482 @@ +// The rules of a standard that `lint(drawing, { standard })` holds a drawing to: what the builder +// refuses under a standard, for a drawing made by other code or read from JSON, under the same +// rules, and what only the whole drawing shows, such as the height a text plots at in each +// viewport that shows it. AutoCAD opens a drawing that breaks them: they are the firm's. +import { DEFAULT_ARROWHEAD } from "../arrowheads.ts"; +import { + blocksByKey, + eachDrawn, + mapPoint, + placeAttribute, + type LayerRef, + type Matrix, + type Placement, +} from "../blocks.ts"; +import { callOf } from "../callsite.ts"; +import type { + AttributeDefinition, + Block, + DimensionStyle, + Drawing, + Entity, + Insert, + Layer, + Layout, + Linetype, + Point, + Text, + TextStyle, + Viewport, + Xref, +} from "../drawing.ts"; +import { boxer } from "../extents.ts"; +import { STANDARD_FONT } from "../fonts.ts"; +import { written } from "../format.ts"; +import { grid, type Box } from "../inspect.ts"; +import { canonical } from "../json.ts"; +import { layerKey } from "../layers.ts"; +import { validViewportView } from "../layouts.ts"; +import type { Diagnostic, LintRule } from "../lint.ts"; +import { measureText } from "../measure.ts"; +import { DEFAULT_PLOT_STYLE_TABLE, paperInfo } from "../papers.ts"; +import { + idKey, + isStandardDimensionStyle, + isStandardTextStyle, + sheetNumberProblem, + shown, + viewportProblem, +} from "../rules.ts"; +import { arrayProblem, isSound, TYPED, type Check } from "../shape.ts"; +import { visibility } from "../svg.ts"; +import { isUnits, modelLength } from "../units.ts"; +import type { + Standard, + StandardDimensionStyle, + StandardLayer, + StandardLinetype, + StandardScale, + StandardSheetFormat, + StandardTextRole, + StandardTitleBlock, +} from "./document.ts"; +import { + DRAWN, + disciplineLacks, + layerLacks, + linetypeLacks, + numberNotLayoutName, + sheetNumberOfFile, + writingLacks, +} from "./drawn.ts"; +import { adopted } from "./made.ts"; +import { capitals } from "./rules.ts"; +import type { Problem } from "./shape.ts"; + +/** + * The most work the checks of text heights and dimension scales do for one drawing, counted in + * what they look at: each text and dimension a space draws, a text one more for each 32 + * characters, a dimension one more for each 32 characters its label reads, each insert walked + * and each item of its block, each name read the first time, one more for each 32 characters, + * each box a grid looks at, each text or dimension held against a viewport, and each error, one + * more for each 32 characters of its message. + */ +const MAX_WORK = 1_000_000; + +const IN_VIEWPORT = + "it was sized for another scale. Draw it in the view of the viewport that shows it"; +const ON_LAYOUT = + "a text on a layout plots at its height. Draw it in its role, and insert its block at a scale of 1"; + +/** Thrown inside a walk to end it when the work reaches its limit. */ +const CAPPED = Symbol("capped"); + +/** + * Adds to `out` what breaks the standard `given`, which is taken as plain data from any copy of + * tsdraft. A standard that does not check is one `standard` error, and nothing else is checked. + * The checks of heights and scales do at most `most` units of work. + */ +export function lintStandard( + drawing: Drawing, + given: unknown, + discipline: unknown, + version: unknown, + out: Diagnostic[], + most = MAX_WORK, +): void { + const problems: Problem[] = []; + let standard: Standard | undefined; + try { + standard = adopted(given, problems); + } catch (thrown) { + // lint never throws, whatever it is given as a standard. + problems.push({ item: "", message: String((thrown as Error)?.message ?? thrown) }); + } + if (!standard) { + out.push({ rule: "standard", severity: "error", message: notChecked(problems) }); + return; + } + const index = indexOf(standard); + const error = (rule: LintRule, message: string, about: Partial = {}): void => { + out.push({ rule, severity: "error", message, ...about }); + }; + const warn = (rule: LintRule, message: string, about: Partial = {}): void => { + out.push({ rule, severity: "warning", message, ...about }); + }; + if (version === "R12") error("standard", DRAWN.r12); + + // The drawing's discipline, which resolves the entries with fields by discipline. + let known: string | undefined; + if (discipline !== undefined) { + if (typeof discipline !== "string") error("standard", "A discipline is named by a string"); + else if (standard.sheetFiles === "one-drawing") error("standard", DRAWN.disciplineOneDrawing); + else { + known = Object.keys(standard.disciplines).find((id) => idKey(id) === idKey(discipline)); + if (known === undefined) error("standard", disciplineLacks(discipline, standard)); + } + } + /** The entries with fields by discipline that went unchecked, for lack of a discipline. */ + const skipped = new Set(); + /** An entry as the drawing's discipline resolves it, or undefined when that is not known. */ + const resolved = ( + entry: E, + what: string, + ): E | undefined => { + if (!entry.byDiscipline) return entry; + if (known === undefined) { + skipped.add(what); + return undefined; + } + return { ...entry, ...entry.byDiscipline[known] }; + }; + + const units = isUnits(drawing.units) ? drawing.units : undefined; + const sound = (check: Check, list: readonly unknown[]): T[] => + list.filter((item) => isSound(check, item, false)) as T[]; + const layers = sound(TYPED.layer, listOf(drawing.layers)); + const linetypes = sound(TYPED.linetype, listOf(drawing.linetypes)); + const textStyles = sound(TYPED.textStyle, listOf(drawing.textStyles)); + const dimensionStyles = sound( + TYPED.dimensionStyle, + listOf(drawing.dimensionStyles), + ); + const blocks = sound(TYPED.block, listOf(drawing.blocks)); + const xrefs = sound(TYPED.xref, listOf(drawing.xrefs)); + const layoutList = listOf(drawing.layouts); + const layouts = sound(TYPED.layout, layoutList); + const isEntity = (e: unknown): e is Entity => isSound(TYPED.entity, e, false); + const isDefinition = (a: unknown): a is AttributeDefinition => isSound(TYPED.attribute, a, false); + const first = (list: readonly T[]): Map => { + const map = new Map(); + for (const item of list) if (!map.has(layerKey(item.name))) map.set(layerKey(item.name), item); + return map; + }; + const stylesByKey = first(textStyles); + const blocksByName = first(blocks); + const xrefsByName = first(xrefs); + + // The layers. + for (const layer of first(layers).values()) { + if (layerKey(layer.name) === "0") continue; + const found = index.layers.get(layerKey(layer.name)); + const about = { layer: layer.name }; + if (!found) { + error("layer-outside-standard", layerLacks(layer.name, standard), about); + continue; + } + const [name, entry] = found; + const fields = resolved(entry, `the layer ${shown(name)}`); + if (!fields) continue; + const pen = fields.pen === undefined ? undefined : standard.pens[fields.pen]; + /** Reports a field of the layer, each value as a message shows it. */ + const differs = (field: string, value: string, own: string): void => { + error( + "layer-differs-from-standard", + `Layer ${shown(layer.name)} has ${field} ${value}, and the standard gives it ${field} ${own}`, + about, + ); + }; + if (layer.color !== fields.color) differs("color", shown(layer.color), shown(fields.color)); + const linetype = layer.linetype ?? "Continuous"; + if (layerKey(linetype) !== layerKey(fields.linetype)) { + differs("linetype", shown(linetype), shown(fields.linetype)); + } + const lineweight = layer.lineweight ?? "Default"; + const own = pen?.lineweight ?? "Default"; + const mm = (weight: unknown) => (typeof weight === "number" ? `${weight} mm` : shown(weight)); + if (lineweight !== own) differs("lineweight", mm(lineweight), mm(own)); + const plot = layer.plot ?? true; + if (plot !== fields.plot) differs("plot", shown(plot), shown(fields.plot)); + } + + // The linetypes and the linetype scale. + for (const linetype of first(linetypes).values()) { + const found = index.linetypes.get(layerKey(linetype.name)); + const about = { linetype: linetype.name }; + if (!found) { + error("linetype-outside-standard", linetypeLacks(linetype.name, standard), about); + continue; + } + const [, own] = found; + const label = `Linetype ${shown(linetype.name)}`; + if (!samePattern(linetype.pattern, own.pattern)) { + error( + "linetype-differs-from-standard", + `${label} has the pattern [${linetype.pattern.join(", ")}], and the standard gives it [${own.pattern.join(", ")}]`, + about, + ); + } + if (linetype.description !== own.description) { + error( + "linetype-differs-from-standard", + `${label} has the description ${shown(linetype.description)}, and the standard gives it ${shown(own.description)}`, + about, + ); + } + } + const scale: unknown = drawing.linetypeScale ?? 1; + if (typeof scale === "number" && written(scale) !== written(standard.linetypeScale)) { + error( + "linetype-differs-from-standard", + `The drawing's linetypeScale is ${shown(scale)}, and the standard's is ${standard.linetypeScale}. Leave linetypeScale to the standard`, + ); + } + + // The text styles: a role's, or a title block's. + const roleOf = (style: string | undefined) => + style === undefined ? undefined : index.roleStyles.get(layerKey(style)); + for (const style of first(textStyles).values()) { + if (isStandardTextStyle(style.name)) continue; + const about = { textStyle: style.name }; + const role = roleOf(style.name); + if (!role) { + if (!index.titleBlockStyles.has(layerKey(style.name))) { + error( + "text-style-outside-standard", + `Text style ${shown(style.name)}: ${DRAWN.textStyleDefined}`, + about, + ); + } + continue; + } + const [id, entry] = role; + const fields = resolved(entry, `the text role ${shown(id)}`); + if (!fields) continue; + const differs = (field: string, value: unknown, own: unknown): void => { + error( + "text-style-differs-from-standard", + `Text style ${shown(style.name)} has ${field} ${shown(value)}, and the role ${shown(id)} gives it ${field} ${shown(own)}`, + about, + ); + }; + if (style.font !== fields.font) differs("font", style.font, fields.font); + const widthFactor = style.widthFactor ?? 1; + if (written(widthFactor) !== written(fields.widthFactor)) { + differs("widthFactor", widthFactor, fields.widthFactor); + } + const obliqueAngle = style.obliqueAngle ?? 0; + if (written(obliqueAngle) !== written(fields.obliqueAngle)) { + differs("obliqueAngle", obliqueAngle, fields.obliqueAngle); + } + } + + // The dimension styles: one of the standard's, on a layout or at one of its scales. + /** + * Each dimension style of the standard the drawing has: the ratio it is drawn at, a scale's or + * null on a layout, its name as a message shows it, and the length of what each of its labels + * reads of it, its prefix, its suffix and its text style's name. + */ + const styleRatio = new Map< + string, + { readonly shown: string; readonly scale: StandardScale | null; readonly reads: number } + >(); + const shownStyles = first(dimensionStyles); + for (const style of shownStyles.values()) { + if (isStandardDimensionStyle(style.name)) continue; + const about = { dimensionStyle: style.name }; + const label = `Dimension style ${shown(style.name)}`; + const found = index.dimensionStyle(layerKey(style.name)); + if (!found) { + error("dimension-style-outside-standard", `${label}: ${DRAWN.dimensionStyleDefined}`, about); + continue; + } + const { id, entry, scale: at } = found; + const reads = + (style.prefix?.length ?? 0) + (style.suffix?.length ?? 0) + (style.textStyle?.length ?? 0); + styleRatio.set(layerKey(style.name), { + shown: shown(style.name), + scale: at?.[1] ?? null, + reads, + }); + const fields = resolved(entry, `the dimension style ${shown(id)}`); + const roleFound = fields && index.roles.get(idKey(fields.role)); + const role = roleFound && resolved(roleFound[1], `the text role ${shown(roleFound[0])}`); + if (!fields || !roleFound || !role) continue; + const writingUnits = at ? units : standard.paperUnits; + if (writingUnits === undefined) continue; + const writing = fields.writing[writingUnits]; + if (!writing) { + error("dimension-style", `${label}: ${writingLacks(id, writingUnits, standard)}`, about); + continue; + } + const expected: readonly (readonly [string, unknown, unknown])[] = [ + ["textStyle", style.textStyle ?? "Standard", capitals(roleFound[0])], + ["textHeight", style.textHeight, role.height], + ["arrowhead", style.arrowhead ?? DEFAULT_ARROWHEAD, fields.arrowhead], + ["arrowSize", style.arrowSize, fields.arrowSize], + ["extensionOffset", style.extensionOffset, fields.extensionOffset], + ["extensionBeyond", style.extensionBeyond, fields.extensionBeyond], + ["textGap", style.textGap, fields.textGap], + [ + "scale", + style.scale ?? 1, + at && units ? modelLength(1, standard.paperUnits, at[1].ratio, units) : 1, + ], + ["precision", style.precision, writing.precision], + ["prefix", style.prefix ?? "", writing.prefix ?? ""], + ["suffix", style.suffix ?? "", writing.suffix ?? ""], + ["suppressZeros", style.suppressZeros ?? "none", writing.suppressZeros ?? "none"], + ["measurementScale", style.measurementScale ?? 1, writing.measurementScale], + ]; + for (const [field, value, own] of expected) { + const same = + field === "textStyle" + ? layerKey(value as string) === layerKey(own as string) + : typeof value === "number" && typeof own === "number" + ? written(value) === written(own) + : value === own; + if (same) continue; + error( + "dimension-style-differs-from-standard", + `${label} has ${field} ${shown(value)}, and the standard gives it ${field} ${shown(own)}`, + about, + ); + } + } + + // The title blocks: one in the drawing is the standard's when every definition of its closure + // is, and the rules for pens, text styles and role heights then skip what it draws. + const standardBlocks = new Set(); + const linetypesByKey = first(linetypes); + /** + * The canonical JSON of each definition the closures name, by its kind, worked out once: none + * for one with a field its kind does not have, and none past `length` characters, the length + * of the standard's, which it then cannot be. + */ + const drawnJson = new Map>(); + const jsonOfDrawn = (check: Check, found: object, length: number): string | undefined => { + let ofKind = drawnJson.get(check); + if (!ofKind) drawnJson.set(check, (ofKind = new Map())); + const known = ofKind.get(found); + if (known && known.length >= length) return known.json; + let json: string | undefined; + // Only JSON cut short at `length` is written again, for a longer definition of the standard. + let upTo = Infinity; + if (isSound(check, found)) { + json = guarded(() => canonical(found, "A definition", undefined, length)); + if (json === undefined) upTo = length; + } + ofKind.set(found, { json, length: upTo }); + return json; + }; + for (const block of index.titleBlocks) { + const placed = + (block.blockName !== undefined && blocksByName.has(layerKey(block.blockName))) || + (block.xref !== undefined && xrefsByName.has(layerKey(block.xref.name))); + if (!placed) continue; + const differ: string[] = []; + const missing: string[] = []; + const compare = ( + kind: string, + check: Check, + own: ReadonlyMap, + drawn: ReadonlyMap, + ): void => { + for (const [key, { name, json }] of own) { + const found = drawn.get(key); + if (!found) missing.push(`${kind} ${shown(name)}`); + else if (jsonOfDrawn(check, found, json.length) !== json) { + differ.push(`${kind} ${shown(name)}`); + } + } + }; + compare("block", TYPED.block, block.blocks, blocksByName); + compare("text style", TYPED.textStyle, block.textStyles, stylesByKey); + compare("linetype", TYPED.linetype, block.linetypes, linetypesByKey); + if (block.xref) { + const found = xrefsByName.get(layerKey(block.xref.name)); + if (!found) missing.push(`xref ${shown(block.xref.name)}`); + else if (found.path !== block.xref.path) differ.push(`xref ${shown(block.xref.name)}`); + } + if (differ.length === 0 && missing.length === 0) { + for (const key of block.blocks.keys()) standardBlocks.add(key); + continue; + } + const parts = [ + ...(differ.length > 0 + ? [`its ${listed(differ)} ${differ.length === 1 ? "is" : "are"} not the standard's`] + : []), + ...(missing.length > 0 ? [`the drawing has no ${listed(missing)} of it`] : []), + ]; + error( + "title-block-differs-from-standard", + `The title block ${shown(block.id)} of the standard: ${parts.join(", and ")}. Place it with sheet.titleBlock(), which copies it from the standard`, + block.blockName !== undefined && blocksByName.has(layerKey(block.blockName)) + ? { block: blocksByName.get(layerKey(block.blockName))!.name } + : { xref: block.xref!.name }, + ); + } + + // Each entity and attribute definition: its layer, its text style and its pen. + const lintEntities = ( + entities: unknown, + where: { readonly block?: string; readonly layout?: string }, + ): void => { + const of = + where.block !== undefined + ? ` of block ${shown(where.block)}` + : where.layout !== undefined + ? ` of layout ${shown(where.layout)}` + : ""; + listOf(entities).forEach((entity, i) => { + if (!isEntity(entity)) return; + const call = callOf(entities as object[], i); + const about = { + ...where, + entity: i, + ...(typeof entity.id === "string" ? { id: entity.id } : {}), + ...(call ? { call } : {}), + }; + const label = `${entity.kind} #${i}${of}`; + if (where.block === undefined && layerKey(entity.layer) === "0") { + error("layer-outside-standard", `${label}: ${DRAWN.layerZero}`, about); + } + if (entity.kind === "text" && !roleOf(entity.style)) { + error("text-style-outside-standard", `${label}: ${DRAWN.textWithoutRole}`, about); + } + if (entity.kind === "insert" || entity.kind.endsWith("-dimension")) return; + const weight = (entity as { readonly lineweight?: unknown }).lineweight; + if (weight === undefined || weight === "ByLayer" || weight === "ByBlock") return; + if (typeof weight === "number" && index.pens.has(written(weight))) return; + error("pen-outside-standard", `${label}: ${penProblem(weight, index)}`, about); + }); + }; + lintEntities(drawing.entities, {}); + for (const block of blocksByName.values()) { + if (standardBlocks.has(layerKey(block.name))) continue; + lintEntities(block.entities, { block: block.name }); + listOf(block.attributes).forEach((definition, i) => { + if (!isDefinition(definition) || roleOf(definition.style)) return; + const call = callOf(block.attributes!, i); + error( + "text-style-outside-standard", + `Block ${shown(block.name)}, attribute ${shown(definition.tag)}: ${DRAWN.textWithoutRole}`, + { block: block.name, attribute: definition.tag, ...(call ? { call } : {}) }, + ); + }); + } + for (const layout of layouts) lintEntities(layout.entities, { layout: layout.name }); + + // The sheets. + if (standard.sheetFiles === "sheet-per-file") { + for (const layout of sound(TYPED.layout, layoutList.slice(1))) { + error("sheet-per-file", `Layout ${shown(layout.name)}: ${DRAWN.secondLayout}`, { + layout: layout.name, + }); + } + } + /** The first sound attribute definition of each tag a block has, worked out once a block. */ + const definitions = new Map>(); + const definitionsOf = (block: Block): ReadonlyMap => { + let found = definitions.get(block); + if (!found) { + found = new Map(); + for (const a of listOf(block.attributes)) { + if (isDefinition(a) && !found.has(a.tag)) found.set(a.tag, a); + } + definitions.set(block, found); + } + return found; + }; + /** Each sound viewport of a layout, with its index and what a diagnostic about it carries. */ + const viewportsOf = (layout: Layout) => + listOf(layout.viewports).flatMap((viewport, i) => { + if (!isSound(TYPED.viewport, viewport, false)) return []; + const v = viewport as Viewport; + const call = callOf(layout.viewports, i); + const about = { + layout: layout.name, + viewport: i, + ...(typeof v.id === "string" ? { id: v.id } : {}), + ...(call ? { call } : {}), + }; + return [{ viewport: v, i, about, name: `viewport #${i} of layout ${shown(layout.name)}` }]; + }); + for (const layout of layouts) { + for (const { viewport, about, name } of viewportsOf(layout)) { + if (typeof viewport.ratio !== "number" || index.scalesByRatio.has(written(viewport.ratio))) { + continue; + } + error( + "scale-outside-standard", + `${name}: its ratio, ${viewport.ratio}, is no scale of the standard ${shown(standard.id)} version ${standard.version}. Show a view at one of its scales, as sheet.viewport(corner, width, height, { view, center })`, + about, + ); + } + const paper = paperInfo(layout.paper); + if (!paper) continue; + const label = `Layout ${shown(layout.name)}`; + const about = { layout: layout.name }; + const inserts = listOf(layout.entities).flatMap((e, i) => + isEntity(e) && e.kind === "insert" ? [[i, e] as const] : [], + ); + /** What a diagnostic about the layout's entity `i` carries. */ + const entityAbout = (i: number, id: string | undefined) => { + const call = callOf(layout.entities, i); + return { + layout: layout.name, + entity: i, + ...(id === undefined ? {} : { id }), + ...(call ? { call } : {}), + }; + }; + /** The blocks and xrefs the layout's inserts name, whether or not another rule can read them. */ + const named = new Set(); + for (const e of listOf(layout.entities)) { + const insert = (e as { readonly kind?: unknown; readonly block?: unknown } | null) ?? {}; + if (insert.kind === "insert" && typeof insert.block === "string") { + named.add(layerKey(insert.block)); + } + } + const placed = (name: string | undefined): boolean => + name !== undefined && named.has(layerKey(name)); + const insertsOf = (name: string | undefined) => + name === undefined ? [] : inserts.filter(([, e]) => layerKey(e.block) === layerKey(name)); + const onPaper = index.formatsByPaper.get(layout.paper); + if (!onPaper) { + error( + "sheet-format-outside-standard", + `${label}: the standard ${shown(standard.id)} version ${standard.version} has no sheet format on the paper ${shown(layout.paper)}. Put the layout on one of its sheet formats, as layout(name, { sheetFormat }, draw)`, + about, + ); + continue; + } + // Among several on its paper, the layout's sheet formats are those whose title block it + // inserts, and of those, the ones whose plot style table it has. Each step reads only the + // title blocks the layout inserts. + let blocks: readonly TitleBlockIndex[]; + if (onPaper.formats.length === 1) blocks = [onPaper.formats[0]!.block]; + else { + const found = new Set(); + for (const key of named) for (const b of onPaper.byPart.get(key) ?? []) found.add(b); + blocks = [...found].sort( + (a, b) => onPaper.byBlock.get(a)!.order - onPaper.byBlock.get(b)!.order, + ); + } + const table = layout.plotStyle ?? DEFAULT_PLOT_STYLE_TABLE; + const hasTable = (b: TitleBlockIndex) => onPaper.byBlock.get(b)!.byTable.has(table as string); + const held = blocks.length > 0 ? blocks.some(hasTable) : onPaper.tables.has(table as string); + if (!held) { + const first = + blocks.length > 0 ? onPaper.byBlock.get(blocks[0]!)!.all[0]! : onPaper.formats[0]!; + error( + "sheet-format-outside-standard", + `${label}: its plot style table is ${shown(table)}, and its sheet format gives it ${shown(first.table)}. Leave the plot style table to the sheet format`, + about, + ); + } + const narrowed = held ? blocks.filter(hasTable) : blocks; + // Sheet formats that share a title block are checked alike. + if (narrowed.length !== 1) { + warn( + "sheet-format-ambiguous", + `${label}: the standard has the sheet formats ${onPaper.listed} on the paper ${shown(layout.paper)}, and the layout ${narrowed.length === 0 ? "inserts the title block of none of them" : `inserts the title blocks ${listed(narrowed.map((b) => shown(b.id)))} of several of them`}, so lint cannot tell which it is on, and checks neither its drawing area, its title block nor its fields. Insert the title block of its sheet format with sheet.titleBlock()`, + about, + ); + continue; + } + const block = narrowed[0]!; + const ofBlock = onPaper.byBlock.get(block)!; + const formats = held ? ofBlock.byTable.get(table as string)! : ofBlock.all; + const format = formatLabel(formats); + + // The viewports keep inside the drawing area and out of the title block's box. + const [left, bottom, right, top] = block.titleBlock.drawingArea.map(written); + const box = block.titleBlock.box?.map(written); + for (const { viewport: v, about: where, name } of viewportsOf(layout)) { + const x0 = written(v.corner[0]); + const y0 = written(v.corner[1]); + const x1 = written(x0 + written(v.width)); + const y1 = written(y0 + written(v.height)); + if (x0 < left! || y0 < bottom! || x1 > right! || y1 > top!) { + error( + "viewport-outside-drawing-area", + `${name}: it reaches past the drawing area of the ${format}, [${block.titleBlock.drawingArea.join(", ")}]. Keep it inside, as the sheet's zones lay it out`, + where, + ); + } + if (box && x0 < box[2]! && x1 > box[0]! && y0 < box[3]! && y1 > box[1]!) { + error( + "viewport-outside-drawing-area", + `${name}: it reaches into the title block's box, [${block.titleBlock.box!.join(", ")}]. Keep it out of the title block`, + where, + ); + } + } + + // The title block of its sheet format, and no other. + const own = new Set( + [block.blockName, block.xref?.name].flatMap((name) => + name === undefined ? [] : [layerKey(name)], + ), + ); + for (const [i, insert] of inserts) { + const other = index.titleBlockParts.get(layerKey(insert.block)); + if (!other || own.has(layerKey(insert.block))) continue; + error( + "title-block-differs-from-standard", + `insert #${i} of layout ${shown(layout.name)}: it places the title block ${shown(other)}, and the layout's ${format} ${formats.length === 1 ? "has" : "have"} the title block ${shown(block.id)}. Place its title block with sheet.titleBlock()`, + entityAbout(i, insert.id), + ); + } + const absent = [ + ...(block.blockName !== undefined && !placed(block.blockName) + ? [`its block ${shown(block.blockName)}`] + : []), + ...(block.xref !== undefined && !placed(block.xref.name) + ? [`its xref ${shown(block.xref.name)}`] + : []), + ]; + if (absent.length > 0) { + warn( + "title-block-missing", + `${label}: it has no insert of ${absent.join(" or ")}, of the title block ${shown(block.id)} of its ${format}. Place the title block with sheet.titleBlock()`, + about, + ); + } + + // The fields its insert fills. + const [fieldIndex, fieldInsert] = insertsOf(block.blockName)[0] ?? []; + const definitionBlock = + block.blockName === undefined ? undefined : blocksByName.get(layerKey(block.blockName)); + if (fieldInsert === undefined || !definitionBlock) continue; + const set = fieldInsert.attributes ?? {}; + const insertAbout = entityAbout(fieldIndex!, fieldInsert.id); + for (const [tag, field] of Object.entries(block.titleBlock.fields)) { + if (field.source !== "attribute") continue; + const key = field.fills ?? tag; + const value = Object.hasOwn(set, tag) ? set[tag] : undefined; + if (typeof value !== "string") { + warn( + "title-block-field-unset", + `${label}: the title block's field ${shown(tag)} is not set, so it shows its attribute's default. Give ${key} in createDrawing's fields or in titleBlock()`, + insertAbout, + ); + continue; + } + if (key === "number") { + if (standard.sheetFiles === "one-drawing" && value !== layout.name) { + error("layout", numberNotLayoutName(value, layout.name), insertAbout); + } + const problem = + standard.sheetFiles === "sheet-per-file" ? sheetNumberProblem(value) : undefined; + if (problem) error("sheet-per-file", sheetNumberOfFile(problem), insertAbout); + } + if (!field.box) continue; + const definition = definitionsOf(definitionBlock).get(tag); + if (!definition) continue; + const style = + definition.style === undefined ? undefined : stylesByKey.get(layerKey(definition.style)); + const corners = guarded(() => { + const attribute = placeAttribute(definition, style, fieldInsert, definitionBlock.base); + if (attribute.mirrored) return undefined; + return measureText({ + value, + height: attribute.height, + font: style?.font ?? STANDARD_FONT, + widthFactor: attribute.widthFactor, + obliqueAngle: attribute.obliqueAngle, + at: attribute.at, + rotation: attribute.rotation, + ...(definition.align === undefined ? {} : { align: definition.align }), + ...(definition.valign === undefined ? {} : { valign: definition.valign }), + }).corners; + }); + if (!corners) continue; + const [l, b, r, t] = field.box.map(written); + if ( + corners.every( + ([x, y]) => written(x) >= l! && written(x) <= r! && written(y) >= b! && written(y) <= t!, + ) + ) { + continue; + } + warn( + "title-block-field-overflow", + `${label}: the value ${shown(value)} of the title block's field ${shown(tag)} runs past its box, [${field.box.join(", ")}]. Make it shorter`, + insertAbout, + ); + } + } + + // The heights texts plot at and the scales dimensions are shown at, as bounded work. + let work = 0; + let capped: string | undefined; + const spend = (cost: number): boolean => { + if (capped !== undefined) return false; + work += cost; + return work <= most; + }; + const shownLayers = visibility( + { ...drawing, layers, linetypes: [], textStyles: [], dimensionStyles: [] }, + false, + ); + const walk = lettering(blocksByName, shownLayers, stylesByKey, index.roleStyles, spend); + /** The role of a text style as the drawing's discipline resolves it, worked out once a style. */ + const roles = new Map(); + const roleIn = (styled: Styled | undefined) => { + if (!styled?.role) return undefined; + let role = roles.get(styled); + if (role === undefined) { + const [id, entry] = styled.role; + const fields = resolved(entry, `the text role ${shown(id)}`); + roles.set(styled, (role = fields ? { id, height: fields.height } : null)); + } + return role ?? undefined; + }; + const minHeight = written(standard.minTextHeight); + const paperUnits = standard.paperUnits; + /** + * Adds an error of these checks about the entity `where` names, paid for by the length of its + * message: one text or viewport may give many errors, each showing a value of the drawing. + */ + const report = (rule: LintRule, message: string, about: Partial, where: string) => { + if (spend(Math.floor(message.length / 32))) error(rule, message, about); + else capped ??= `at ${where}`; + }; + /** Checks the height a text plots at, `plotted`, `at` a viewport or on a layout. */ + const checkHeight = (text: Lettered, plotted: number, at: string, advice: string): void => { + const height = written(plotted); + const label = `${text.label}: ${text.what}`; + const { role } = text; + if (role && height !== written(role.height)) { + report( + "height-off-role", + `${label} of the role ${shown(role.id)} plots ${round(plotted)} ${paperUnits} tall ${at}, and the role is ${round(role.height)} ${paperUnits}: ${advice}`, + text.about, + text.label, + ); + } + if (height < minHeight) { + report( + "height-below-standard", + `${label} plots ${round(plotted)} ${paperUnits} tall ${at}, below the standard's least height of ${standard.minTextHeight} ${paperUnits}`, + text.about, + text.label, + ); + } + }; + // The first style of each name, as `styleRatio` measured it. + const dimensionBox = boxer([...stylesByKey.values()], [], undefined, undefined, undefined, [ + ...shownStyles.values(), + ]); + /** The texts and the dimensions a space draws, with what a diagnostic says of each. */ + const lettered = ( + entities: unknown, + where: { readonly layout?: string }, + ): { readonly texts: Lettered[]; readonly dimensions: Measured[] } => { + const texts: Lettered[] = []; + const dimensions: Measured[] = []; + const of = where.layout !== undefined ? ` of layout ${shown(where.layout)}` : ""; + listOf(entities).forEach((entity, i) => { + if (!isEntity(entity) || capped !== undefined) return; + const call = callOf(entities as object[], i); + const about = { + ...where, + entity: i, + ...(typeof entity.id === "string" ? { id: entity.id } : {}), + ...(call ? { call } : {}), + }; + const label = `${entity.kind} #${i}${of}`; + /** + * Keeps a text the entity draws, itself or through an insert at `placement`. It costs one + * more for each 32 characters of its value and its tag, which it is measured and named by. + */ + const keep = ( + text: Text, + styled: Styled | undefined, + placement: Placement | undefined, + titleBlock: boolean, + ) => { + const tag = placement?.attribute; + const size = (typeof text.value === "string" ? text.value.length : 0) + (tag?.length ?? 0); + if (!spend(1 + Math.floor(size / 32))) throw CAPPED; + const measured = guarded(() => placedText(text, styled?.style, placement?.matrix)); + if (!measured) return; + const role = titleBlock ? undefined : roleIn(styled); + const what = + placement === undefined + ? "This text" + : tag === undefined + ? `The text ${shown(text.value)} of its block` + : `The attribute ${shown(tag)} of its block`; + texts.push({ ...measured, ...(role ? { role } : {}), about, label, what }); + }; + try { + if (entity.kind === "linear-dimension" || entity.kind === "aligned-dimension") { + const style = styleRatio.get(walk.key(entity.style)); + if (!style || walk.hidden({ name: walk.layer(entity.layer) })) return; + // Its box reads its style's name, its own text and what its style gives every label. + const reads = entity.style.length + (entity.text?.length ?? 0) + style.reads; + if (!spend(1 + Math.floor(reads / 32))) throw CAPPED; + const box = guarded(() => dimensionBox([entity])); + if (box) dimensions.push({ about, label, box, style: style.shown, scale: style.scale }); + return; + } + if (entity.kind === "text") { + if (walk.hidden({ name: walk.layer(entity.layer) })) return; + keep(entity, walk.styled(walk.style(entity.style)), undefined, false); + return; + } + if (entity.kind !== "insert") return; + const placed = walk.insert(entity); + if (placed === undefined || walk.frozen({ name: placed.layer })) return; + const titleBlock = standardBlocks.has(walk.key(entity.block)); + eachDrawn(placed, walk.blocks(), walk.frozen, (drawn, placement) => { + if (drawn.kind !== "text" || placement.frozen || walk.hidden(placement.layer)) return; + keep(drawn, walk.styled(drawn.style), placement, titleBlock); + }); + } catch (thrown) { + // Blocks nested deeper than the stack end the walk as its limit does. + if (thrown !== CAPPED && !(thrown instanceof RangeError)) throw thrown; + capped ??= `at ${label}`; + } + }); + return { texts, dimensions }; + }; + + const scaleLabel = (ratio: number): string => + index.scalesByRatio.get(written(ratio))?.label ?? `a ratio of ${ratio}`; + const space = guarded(() => lettered(drawing.entities, {})) ?? { texts: [], dimensions: [] }; + const nearText = grid( + space.texts.map((t) => t.box), + spend, + ); + const nearDimension = grid( + space.dimensions.map((d) => d.box), + spend, + ); + for (const layout of layouts) { + const paper = paperInfo(layout.paper); + if (!paper || paper.units !== paperUnits) continue; + const name = `layout ${shown(layout.name)}`; + (listOf(layout.viewports) as unknown[]).forEach((viewport, i) => { + if (capped !== undefined || !units || !isSound(TYPED.viewport, viewport, false)) return; + const v = viewport as Viewport; + if (viewportProblem(v, paper.units, units)) return; + const view = validViewportView(v, paper.units, units); + const shownIn = `in ${typeof v.id === "string" ? `viewport ${shown(v.id)}` : `viewport #${i} of ${name}`} at ${scaleLabel(v.ratio)}`; + const found: number[] = []; + nearText(view.box, (t) => found.push(t)); + for (const t of found.sort((a, b) => a - b)) { + if (!spend(1)) break; + const text = space.texts[t]!; + checkHeight(text, text.height * view.scale, shownIn, IN_VIEWPORT); + } + const dimensions: number[] = []; + nearDimension(view.box, (d) => dimensions.push(d)); + for (const d of dimensions.sort((a, b) => a - b)) { + if (!spend(1)) break; + const dimension = space.dimensions[d]!; + if (dimension.scale && written(dimension.scale.ratio) === written(v.ratio)) continue; + const style = dimension.scale + ? `the dimension style ${dimension.style}, for ${dimension.scale.label}` + : `the dimension style ${dimension.style}, which is for a layout`; + report( + "dimension-off-scale", + `${dimension.label}: It is in ${style}, and is shown ${shownIn}. Draw it in the view of the viewport that shows it`, + dimension.about, + dimension.label, + ); + } + if (capped === undefined && work > most) capped = `at viewport #${i} of ${name}`; + }); + if (capped !== undefined) break; + const sheet = guarded(() => lettered(layout.entities, { layout: layout.name })); + for (const text of sheet?.texts ?? []) checkHeight(text, text.height, `on ${name}`, ON_LAYOUT); + for (const dimension of sheet?.dimensions ?? []) { + if (!dimension.scale) continue; + report( + "dimension-off-scale", + `${dimension.label}: It is in the dimension style ${dimension.style}, for ${dimension.scale.label} in model space, and a layout plots it at 1:1. Give it the style of a layout: a dimension on a layout names no view`, + dimension.about, + dimension.label, + ); + } + if (capped !== undefined) break; + } + if (capped !== undefined || work > most) { + warn( + "standard-check-capped", + `lint stopped its checks of the heights texts plot at and the scales dimensions are shown at their limit, ${capped ?? "at the end"}: the drawing draws more than they look through, so a text or a dimension past that point may break the standard with no error`, + ); + } + + if (skipped.size > 0) { + warn( + "discipline-unknown", + `lint does not know the drawing's discipline, so it checked none of the forms of ${listed([...skipped])}, which ${skipped.size === 1 ? "has" : "have"} fields by discipline. Give lint the drawing's discipline, as lint(drawing, { standard, discipline })`, + ); + } +} + +/** A text as a space draws it: its height and its box there, and what names it. */ +interface Lettered { + readonly height: number; + readonly box: Box; + /** + * The role whose text style the text is in, as the drawing's discipline resolves it. A text of + * the standard's title block has none, since no role sizes it. + */ + readonly role?: { readonly id: string; readonly height: number }; + readonly about: Partial; + /** The entity, as a message names it: `text #3 of layout "C-101"`. */ + readonly label: string; + /** The text, as a message names it: `This text` or `The attribute "TITLE" of its block`. */ + readonly what: string; +} + +/** A dimension of a space, with its box and the scale of its style, or null for a layout's. */ +interface Measured { + readonly about: Partial; + readonly label: string; + readonly box: Box; + /** The name of its style, as a message shows it. */ + readonly style: string; + readonly scale: StandardScale | null; +} + +/** + * The height a text is drawn at where `matrix` puts it, which an insert's scale changes, and the + * box `boxer` gives it there: its capitals, and what its font draws below the baseline. + */ +function placedText( + text: Text, + style: TextStyle | undefined, + matrix: Matrix | undefined, +): { readonly height: number; readonly box: Box } { + const { corners, descent } = measureText(style ? { ...text, ...style } : text); + const [c0, c1, c2, c3] = corners; + const below = descent / text.height || 0; + const points: Point[] = [ + ...corners, + [c0[0] - (c3[0] - c0[0]) * below, c0[1] - (c3[1] - c0[1]) * below], + [c1[0] - (c2[0] - c1[0]) * below, c1[1] - (c2[1] - c1[1]) * below], + ]; + const placed = matrix ? points.map((p) => mapPoint(matrix, p)) : points; + const xs = placed.map((p) => p[0]); + const ys = placed.map((p) => p[1]); + let height = text.height; + if (matrix) { + // The text's height across its baseline, which the matrix turns and stretches. + const [a, b, c, d] = matrix; + const turn = (text.rotation * Math.PI) / 180; + const along = Math.hypot( + a * Math.cos(turn) + c * Math.sin(turn), + b * Math.cos(turn) + d * Math.sin(turn), + ); + height = (text.height * Math.abs(a * d - b * c)) / along; + } + return { height, box: [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] }; +} + +/** + * The blocks the walk of an insert finds by name. Each block it enters spends one for the insert + * and one for each item it holds, before the walk reads any, whatever their layer, so a tree of + * blocks that each insert the one below twice ends at the limit. + */ +class Entered extends Map { + readonly #spend: (cost: number) => boolean; + + constructor(blocks: Iterable, spend: (cost: number) => boolean) { + super(blocks); + this.#spend = spend; + } + + override get(key: string): Block | undefined { + const block = super.get(key); + if (block && !this.#spend(1 + block.entities.length + (block.attributes?.length ?? 0))) { + throw CAPPED; + } + return block; + } +} + +/** A text style of the drawing, as the walk of an insert reads it, and the role it styles. */ +interface Styled { + readonly style: TextStyle | undefined; + readonly role: readonly [id: string, role: StandardTextRole] | undefined; +} + +/** + * What the walk of an insert reads, made once a drawing. Each name of the drawing it reads, it + * reads once: its `layerKey` is paid for by its length the first time, and the walk holds a short + * token in its place. The blocks are made when an insert is first walked, and hold only what can + * draw a text: a block that draws a text or an attribute, itself or through what it inserts, with + * its texts, its attribute definitions and its inserts of such blocks. An insert of any other + * block is not walked, and costs nothing. + */ +function lettering( + blocks: ReadonlyMap, + shownLayers: { hidden(layer: LayerRef): boolean; frozen(layer: LayerRef): boolean }, + styles: ReadonlyMap, + roleStyles: ReadonlyMap, + spend: (cost: number) => boolean, +) { + const keys = new Map(); + const key = (name: string): string => { + let found = keys.get(name); + if (found === undefined) { + if (!spend(1 + Math.floor(name.length / 32))) throw CAPPED; + keys.set(name, (found = layerKey(name))); + } + return found; + }; + /** The token of each layer by key, and the state of each by token. */ + const layerTokens = new Map(); + const layerStates = new Map(); + // Layer `0`'s token is "0", by which an entity of a block takes its insert's layer. + const layer = (name: string): string => { + const k = key(name); + let token = layerTokens.get(k); + if (token === undefined) { + token = k === "0" ? "0" : `#${layerTokens.size}`; + layerTokens.set(k, token); + const ref = { name: k }; + layerStates.set(token, { hidden: shownLayers.hidden(ref), frozen: shownLayers.frozen(ref) }); + } + return token; + }; + const styleTokens = new Map(); + const styled = new Map(); + const style = (name: string | undefined): string | undefined => { + if (name === undefined) return undefined; + const k = key(name); + let token = styleTokens.get(k); + if (token === undefined) { + styleTokens.set(k, (token = `#${styleTokens.size}`)); + styled.set(token, { style: styles.get(k), role: roleStyles.get(k) }); + } + return token; + }; + const placed = (insert: Insert, block: string): Insert => ({ + kind: "insert", + layer: layer(insert.layer), + block, + at: insert.at, + ...(insert.scale === undefined ? {} : { scale: insert.scale }), + ...(insert.rotation === undefined ? {} : { rotation: insert.rotation }), + ...(insert.attributes === undefined ? {} : { attributes: insert.attributes }), + }); + const justified = (text: Text | AttributeDefinition) => ({ + ...(text.style === undefined ? {} : { style: style(text.style)! }), + ...(text.align === undefined ? {} : { align: text.align }), + ...(text.valign === undefined ? {} : { valign: text.valign }), + }); + const textOf = (text: Text): Text => ({ + kind: "text", + layer: layer(text.layer), + at: text.at, + height: text.height, + value: text.value, + rotation: text.rotation, + ...justified(text), + }); + const definitionOf = (definition: AttributeDefinition): AttributeDefinition => ({ + tag: definition.tag, + value: definition.value, + layer: layer(definition.layer), + at: definition.at, + height: definition.height, + rotation: definition.rotation, + ...justified(definition), + }); + + const tokens = new Map(); + let entered: Entered | undefined; + const enter = (): Entered => { + if (entered) return entered; + // What each block holds that can draw a text, and the blocks that insert each. + type Item = { readonly text: Text } | { readonly insert: Insert; readonly block: Block }; + const held = new Map(); + const insertedBy = new Map(); + const lettered: Block[] = []; + for (const block of blocks.values()) { + const items: Item[] = []; + let texts = false; + for (const e of listOf(block.entities)) { + const kind = (e as { readonly kind?: unknown } | null | undefined)?.kind; + if ((kind !== "text" && kind !== "insert") || !isSound(TYPED.entity, e, false)) continue; + const entity = e as Text | Insert; + if (entity.kind === "text") { + items.push({ text: entity }); + texts = true; + continue; + } + const inner = blocks.get(key(entity.block)); + if (inner === undefined) continue; + items.push({ insert: entity, block: inner }); + const by = insertedBy.get(inner); + if (by) by.push(block); + else insertedBy.set(inner, [block]); + } + const definitions = listOf(block.attributes).filter((a) => + isSound(TYPED.attribute, a, false), + ) as AttributeDefinition[]; + held.set(block, { items, definitions }); + if (texts || definitions.length > 0) lettered.push(block); + } + const drawsText = new Set(lettered); + for (let i = 0; i < lettered.length; i++) { + for (const by of insertedBy.get(lettered[i]!) ?? []) { + if (drawsText.has(by)) continue; + drawsText.add(by); + lettered.push(by); + } + } + for (const block of drawsText) tokens.set(block, `#${tokens.size}`); + const walked: [string, Block][] = []; + for (const block of drawsText) { + const { items, definitions } = held.get(block)!; + const entities: Entity[] = []; + for (const item of items) { + if ("text" in item) entities.push(textOf(item.text)); + else if (drawsText.has(item.block)) + entities.push(placed(item.insert, tokens.get(item.block)!)); + } + const token = tokens.get(block)!; + walked.push([ + token, + { + name: token, + ...(block.base === undefined ? {} : { base: block.base }), + entities, + attributes: definitions.map(definitionOf), + }, + ]); + } + return (entered = new Entered(walked, spend)); + }; + + return { + key, + /** The token of a layer's name. */ + layer, + /** The token of a text style's name. */ + style, + styled: (token: string | undefined): Styled | undefined => + token === undefined ? undefined : styled.get(token), + hidden: (layer: LayerRef): boolean => layerStates.get(layer.name)?.hidden === true, + frozen: (layer: LayerRef): boolean => layerStates.get(layer.name)?.frozen === true, + blocks: enter, + /** An insert as the walk reads it, or undefined when its block draws no text. */ + insert: (insert: Insert): Insert | undefined => { + enter(); + const block = blocks.get(key(insert.block)); + const token = block && tokens.get(block); + return token === undefined ? undefined : placed(insert, token); + }, + }; +} + +/** What `fn` gives, or undefined when it throws on a value another rule reports. */ +function guarded(fn: () => T): T | undefined { + try { + return fn(); + } catch { + return undefined; + } +} + +/** + * The items of a list up to its first empty slot, where the other rules stop, or none when it is + * not a list. A list with no empty slot is given itself, so `callOf` finds its calls. + */ +function listOf(value: unknown): readonly unknown[] { + if (arrayProblem(value, "") !== undefined) return []; + const list = value as readonly unknown[]; + for (let i = 0; i < list.length; i++) { + if (list[i] === undefined && !(i in list)) return list.slice(0, i); + } + return list; +} + +function samePattern(a: readonly number[], b: readonly number[]): boolean { + return a.length === b.length && a.every((n, i) => written(n) === written(b[i]!)); +} + +const round = (n: number): string => String(Number(n.toFixed(3))); + +const listed = (items: readonly string[]): string => + items.length === 1 ? items[0]! : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`; + +/** What is wrong with an entity's lineweight that is no pen of the standard. */ +function penProblem(weight: unknown, index: Index): string { + const lineweight = typeof weight === "number" ? `${weight} mm` : shown(weight); + return index.pensListed === undefined + ? `A lineweight of ${lineweight} is no pen of the standard, which has none. Give the entity the lineweight ByLayer` + : `A lineweight of ${lineweight} is no pen of the standard. Its pens are ${index.pensListed} mm. Give the entity a pen`; +} + +/** The one error for a standard that does not check: its first problem, and how many more. */ +function notChecked(problems: readonly Problem[]): string { + const [problem] = problems; + const what = problem + ? `${problem.item ? `${problem.item}: ` : ""}${problem.message}` + : DRAWN.notAStandard; + const more = + problems.length > 1 + ? `, and ${problems.length - 1} more ${problems.length === 2 ? "problem" : "problems"}` + : ""; + return `The standard given to lint does not check, so lint held the drawing to none of its rules: ${what}${more}`; +} + +/** A title block of a standard, with the definitions of its closure as the drawing must hold them. */ +interface TitleBlockIndex { + readonly id: string; + readonly titleBlock: StandardTitleBlock; + readonly blockName: string | undefined; + readonly xref: StandardTitleBlock["xref"]; + /** Each block, text style and linetype of its closure, by `layerKey`, with its JSON. */ + readonly blocks: ReadonlyMap; + readonly textStyles: ReadonlyMap; + readonly linetypes: ReadonlyMap; +} + +/** A standard as `lint` reads it: its entries by the keys AutoCAD finds them by. */ +interface Index { + readonly layers: ReadonlyMap; + readonly linetypes: ReadonlyMap; + /** Each pen's lineweight, as written. */ + readonly pens: ReadonlySet; + /** Each pen and its lineweight, as a message lists them, or undefined when it has none. */ + readonly pensListed: string | undefined; + readonly roles: ReadonlyMap; + /** Each role by `layerKey` of its text style's name. */ + readonly roleStyles: ReadonlyMap; + readonly scalesByRatio: ReadonlyMap; + /** The dimension style of the standard whose AutoCAD name has this `layerKey`, and its scale. */ + readonly dimensionStyle: (key: string) => + | { + readonly id: string; + readonly entry: StandardDimensionStyle; + readonly scale: readonly [id: string, scale: StandardScale] | undefined; + } + | undefined; + readonly titleBlocks: readonly TitleBlockIndex[]; + /** The id of the title block each block or xref name of a title block places, by `layerKey`. */ + readonly titleBlockParts: ReadonlyMap; + /** Every text style of every title block's closure, by `layerKey`. */ + readonly titleBlockStyles: ReadonlySet; + /** The sheet formats on each paper. */ + readonly formatsByPaper: ReadonlyMap; +} + +/** A sheet format of a standard, with its title block and its plot style table. */ +interface Format { + readonly id: string; + readonly block: TitleBlockIndex; + readonly table: string; +} + +/** The sheet formats of a standard on one paper. */ +interface OnPaper { + /** In the standard's order. */ + readonly formats: readonly Format[]; + /** As a message lists them. */ + readonly listed: string; + /** Every plot style table they have. */ + readonly tables: ReadonlySet; + /** The formats of each title block, all and by plot style table, and where its first is. */ + readonly byBlock: ReadonlyMap< + TitleBlockIndex, + { + readonly order: number; + readonly all: readonly Format[]; + readonly byTable: ReadonlyMap; + } + >; + /** The title blocks each block or xref name places, by `layerKey`. */ + readonly byPart: ReadonlyMap>; +} + +const labels = new WeakMap(); + +/** Sheet formats as a message names them: `sheet formats "arch-d" and "arch-d-color"`. */ +function formatLabel(formats: readonly Format[]): string { + let label = labels.get(formats); + if (label === undefined) { + const ids = formats.map((format) => shown(format.id)); + label = `sheet format${ids.length === 1 ? "" : "s"} ${listed(ids)}`; + labels.set(formats, label); + } + return label; +} + +const indexes = new WeakMap(); + +function indexOf(standard: Standard): Index { + const found = indexes.get(standard); + if (found) return found; + const byKey = (entries: Readonly>, key: (name: string) => string) => + new Map(Object.entries(entries).map(([name, entry]) => [key(name), [name, entry] as const])); + const roles = byKey(standard.textRoles, idKey); + const scales = Object.entries(standard.scales); + const pens = Object.entries(standard.pens).map(([id, pen]) => `${id} ${pen.lineweight}`); + // A dimension style's name is its id in capitals on a layout, and with a scale's id after it in + // model space. Ids are ASCII, so a name's key is its style's key, `-` and its scale's key, and + // at most one style and scale give it: defineStandard refuses two names AutoCAD takes for one. + const styleKeys = new Map( + Object.entries(standard.dimensionStyles).map( + ([id, entry]) => [layerKey(capitals(id)), { id, entry, scale: undefined }] as const, + ), + ); + const scaleKeys = new Map(scales.map((scale) => [layerKey(scale[0]), scale])); + const longest = (keys: Iterable) => { + let most = 0; + for (const key of keys) most = Math.max(most, key.length); + return most; + }; + const longestStyle = longest(styleKeys.keys()); + const longestScale = longest(scaleKeys.keys()); + const dimensionStyle: Index["dimensionStyle"] = (key) => { + const own = styleKeys.get(key); + if (own || key.length > longestStyle + 1 + longestScale) return own; + for ( + let at = key.indexOf("-", 1); + at > 0 && at <= longestStyle; + at = key.indexOf("-", at + 1) + ) { + const style = styleKeys.get(key.slice(0, at)); + const scale = style && scaleKeys.get(key.slice(at + 1)); + if (scale) return { ...style, scale }; + } + return undefined; + }; + const titleBlocks = Object.entries(standard.titleBlocks).map( + ([id, titleBlock]) => closureOf(id, titleBlock), + ); + const titleBlocksById = new Map(titleBlocks.map((block) => [block.id, block])); + const titleBlockParts = new Map(); + for (const block of titleBlocks) { + for (const name of [block.blockName, block.xref?.name]) { + if (name !== undefined && !titleBlockParts.has(layerKey(name))) { + titleBlockParts.set(layerKey(name), block.id); + } + } + } + const byPaper = new Map(); + for (const [id, format] of Object.entries(standard.sheetFormats)) { + const block = titleBlocksById.get(format.titleBlock)!; + const list = byPaper.get(format.paper) ?? []; + list.push({ id, block, table: format.plotStyle ?? standard.plotStyle }); + byPaper.set(format.paper, list); + } + const formatsByPaper = new Map(); + for (const [paper, formats] of byPaper) { + const byBlock = new Map< + TitleBlockIndex, + { order: number; all: Format[]; byTable: Map } + >(); + const byPart = new Map>(); + formats.forEach((format, order) => { + let own = byBlock.get(format.block); + if (!own) { + byBlock.set(format.block, (own = { order, all: [], byTable: new Map() })); + for (const name of [format.block.blockName, format.block.xref?.name]) { + if (name === undefined) continue; + const placing = byPart.get(layerKey(name)) ?? new Set(); + byPart.set(layerKey(name), placing.add(format.block)); + } + } + own.all.push(format); + const same = own.byTable.get(format.table) ?? []; + same.push(format); + own.byTable.set(format.table, same); + }); + formatsByPaper.set(paper, { + formats, + listed: listed(formats.map((format) => shown(format.id))), + tables: new Set(formats.map((format) => format.table)), + byBlock, + byPart, + }); + } + const index: Index = { + layers: byKey(standard.layers, layerKey), + linetypes: byKey(standard.linetypes, layerKey), + pens: new Set(Object.values(standard.pens).map((pen) => written(pen.lineweight))), + pensListed: pens.length === 0 ? undefined : listed(pens), + roles, + roleStyles: new Map([...roles.values()].map((role) => [layerKey(capitals(role[0])), role])), + scalesByRatio: new Map(scales.map(([, scale]) => [written(scale.ratio), scale])), + dimensionStyle, + titleBlocks, + titleBlockParts, + titleBlockStyles: new Set(titleBlocks.flatMap((block) => [...block.textStyles.keys()])), + formatsByPaper, + }; + indexes.set(standard, index); + return index; +} + +/** + * A title block with its closure: its block, every block that block inserts, transitively, every + * text style their texts and attributes name and every linetype their entities name. + */ +function closureOf(id: string, titleBlock: StandardTitleBlock): TitleBlockIndex { + const blocks = new Map(); + const textStyles = new Map(); + const linetypes = new Map(); + const drawing = titleBlock.block; + if (drawing && titleBlock.blockName !== undefined) { + const byName = blocksByKey(drawing.blocks); + const queue = [byName.get(layerKey(titleBlock.blockName))].filter((b) => b !== undefined); + const queued = new Set(queue); + const styles = new Set(); + const lines = new Set(); + for (let i = 0; i < queue.length; i++) { + const block = queue[i]!; + blocks.set(layerKey(block.name), { name: block.name, json: canonical(block) }); + for (const e of block.entities) { + if (e.kind === "text" && e.style !== undefined) styles.add(layerKey(e.style)); + const linetype = (e as { readonly linetype?: string }).linetype; + if (linetype !== undefined) lines.add(layerKey(linetype)); + const inner = e.kind === "insert" ? byName.get(layerKey(e.block)) : undefined; + if (inner && !queued.has(inner)) { + queued.add(inner); + queue.push(inner); + } + } + for (const a of block.attributes ?? []) + if (a.style !== undefined) styles.add(layerKey(a.style)); + } + for (const style of drawing.textStyles ?? []) { + const key = layerKey(style.name); + if (styles.has(key)) textStyles.set(key, { name: style.name, json: canonical(style) }); + } + for (const linetype of drawing.linetypes ?? []) { + const key = layerKey(linetype.name); + if (lines.has(key)) linetypes.set(key, { name: linetype.name, json: canonical(linetype) }); + } + } + return { + id, + titleBlock, + blockName: drawing ? titleBlock.blockName : undefined, + xref: titleBlock.xref, + blocks, + textStyles, + linetypes, + }; +} diff --git a/src/standard/made.ts b/src/standard/made.ts index b40b4e6..b988b78 100644 --- a/src/standard/made.ts +++ b/src/standard/made.ts @@ -1,23 +1,31 @@ // The standards this copy of tsdraft made, and how it takes one that another copy made. The -// builder reads this module and not `define.ts`, which draws the built-in title block with the -// builder as it loads. +// builder and `lint` read this module and not `define.ts`, which draws the built-in title block +// with the builder as it loads. import type { Standard } from "./document.ts"; +import type { Problem } from "./shape.ts"; /** The standards this copy of tsdraft made, which need no second check. */ export const MADE = new WeakSet(); -let readAgain: ((value: unknown) => Standard | undefined) | undefined; +let readAgain: ((value: unknown, problems: Problem[]) => Standard | undefined) | undefined; /** Sets how a standard from another copy of tsdraft is read again. `define.ts` sets it. */ -export function readStandardsWith(read: (value: unknown) => Standard | undefined): void { +export function readStandardsWith( + read: (value: unknown, problems: Problem[]) => Standard | undefined, +): void { readAgain = read; } /** * `value` as a standard of this copy of tsdraft: itself when this copy made it, or else read - * again as data, and undefined when it is not a standard whose hash is its content's. + * again as data, and undefined when it is not a standard whose hash is its content's. Each + * problem that makes it none is added to `problems`. */ -export function adopted(value: unknown): Standard | undefined { +export function adopted(value: unknown, problems: Problem[] = []): Standard | undefined { if (typeof value === "object" && value !== null && MADE.has(value)) return value as Standard; - return readAgain?.(value); + if (!readAgain) { + problems.push({ item: "", message: "This copy of tsdraft has not loaded defineStandard" }); + return undefined; + } + return readAgain(value, problems); } diff --git a/test/shape.test.ts b/test/shape.test.ts index d5cb182..5ac55ca 100644 --- a/test/shape.test.ts +++ b/test/shape.test.ts @@ -4,6 +4,8 @@ import { inspect } from "node:util"; import { Script } from "node:vm"; import { without } from "../cli/src/omit.ts"; import { + BUILT_IN_STANDARD, + defineStandard, DrawingError, fromJSON, lint, @@ -258,6 +260,38 @@ function replacements(drawing: unknown, path: Path): unknown[] { ]; } +/** + * A standard whose title block is the full drawing's block `TREE` on its sheet, so that `lint` + * walks every rule of a standard over the drawing, its title block and its fields among them. + */ +const treeStandard = defineStandard({ + id: "tree", + version: "1", + paperUnits: "in", + minTextHeight: 0.05, + layers: { WALL: { color: 1 } }, + textRoles: { note: { font: "Arial", height: 0.1 } }, + titleBlocks: { + tree: { + paper: "ARCH D", + block: { + format: 0, + units: "in", + dxf: "R2018", + layers: [{ name: "0", color: 7 }], + entities: [], + textStyles: full.textStyles, + blocks: full.blocks, + }, + blockName: "TREE", + layer: "WALL", + drawingArea: [0.5, 0.5, 35.5, 23.5], + fields: { TAG: { box: [1, 1, 2, 2] } }, + }, + }, + sheetFormats: { d: { paper: "ARCH D", titleBlock: "tree" } }, +}); + const errors = (drawing: Drawing, version?: "R12" | "R2018") => lint(drawing, version ? { version } : {}).filter((d) => d.severity === "error"); @@ -295,6 +329,8 @@ function failure(value: unknown): string | undefined { let found: Diagnostic[]; try { for (const version of ["R12", "R2018"] as const) errors(drawing, version); + lint(drawing, { standard: BUILT_IN_STANDARD }); + lint(drawing, { standard: treeStandard }); found = errors(drawing); } catch (thrown) { return `lint threw ${String(thrown)}`; diff --git a/test/standard-lint.test.ts b/test/standard-lint.test.ts new file mode 100644 index 0000000..2b05bdc --- /dev/null +++ b/test/standard-lint.test.ts @@ -0,0 +1,720 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Script } from "node:vm"; +import sheet, { sitePlan, standard } from "../examples/site-plan.ts"; +import { + BUILT_IN_STANDARD, + createDrawing, + defineStandard, + fromJSON, + lint, + toJSON, + type Block, + type Diagnostic, + type Drawing, + type Entity, + type Insert, + type LintOptions, + type Standard, + type Text, +} from "../src/index.ts"; +import { lintStandard } from "../src/standard/lint.ts"; + +const layout = sheet.layouts![0]!; +const titleBlock = layout.entities[0] as Insert; +const rules = (drawing: Drawing, options: LintOptions = { standard }): string[] => + lint(drawing, options).map((d) => d.rule); +/** The example with model space or its sheet changed. */ +const withModel = (entities: readonly Entity[]): Drawing => ({ ...sheet, entities }); +const withSheet = (change: Partial): Drawing => ({ + ...sheet, + layouts: [{ ...layout, ...change }], +}); +const only = (found: readonly Diagnostic[], rule: string) => found.filter((d) => d.rule === rule); + +const linting = new Script("linting()"); +const checking = new Script("checking()"); +/** The rules `lint` reports with `options`, stopped after `ms`, so a run that never ends fails. */ +const within = (ms: number, drawing: unknown, options: LintOptions = { standard }): string[] => { + Object.assign(globalThis, { linting: () => rules(drawing as Drawing, options) }); + return linting.runInThisContext({ timeout: ms }) as string[]; +}; + +test("the example sheet breaks no rule of its standard at either scale, nor from JSON", () => { + for (const scale of ["1in-10ft", "1in-20ft"] as const) { + assert.deepEqual(lint(sitePlan(scale).drawing, { standard }), []); + } + const data = JSON.parse(JSON.stringify(standard)) as Standard; + assert.deepEqual(lint(fromJSON(toJSON(sheet)), { standard: data }), []); +}); + +test("a standard given as data is checked again, and one that does not check is one error", () => { + for (const given of [ + { ...standard, minTextHeight: 0.07 }, + { ...standard, layers: 3 }, + { ...standard, extends: BUILT_IN_STANDARD }, + 5, + ]) { + const found = lint(sheet, { standard: given as Standard }); + assert.deepEqual( + found.map((d) => d.rule), + ["standard"], + ); + assert.match(found[0]!.message, /^The standard given to lint does not check/); + } + assert.match( + lint(sheet, { standard: { ...standard, minTextHeight: 0.07 } })[0]!.message, + /: hash: The standard's hash is not the hash of what it states/, + ); + // Without a standard, lint holds the drawing to none of its rules. + assert.deepEqual( + lint(withModel([...sheet.entities, { kind: "line", layer: "0", from: [0, 0], to: [1, 1] }])), + [], + ); +}); + +test("lint reports what the builder refuses under a standard, under the same rules", () => { + const found = lint( + { + ...sheet, + layers: [...sheet.layers, { name: "A-WALL", color: 2 }], + entities: [ + ...sheet.entities, + { kind: "line", layer: "0", from: [0, 0], to: [1, 1], lineweight: 0.6 }, + { kind: "text", layer: "C-ANNO-TEXT", at: [50, 50], height: 1, value: "A", rotation: 0 }, + ], + textStyles: [...sheet.textStyles!, { name: "MINE", font: "Arial" }], + dimensionStyles: [...sheet.dimensionStyles!, { ...sheet.dimensionStyles![0]!, name: "MINE" }], + }, + { standard }, + ); + assert.deepEqual( + found.map((d) => [d.rule, d.layer ?? d.textStyle ?? d.dimensionStyle ?? d.entity]), + [ + ["layer-outside-standard", "A-WALL"], + ["text-style-outside-standard", "MINE"], + ["dimension-style-outside-standard", "MINE"], + ["layer-outside-standard", 57], + ["pen-outside-standard", 57], + ["text-style-outside-standard", 58], + ], + ); + assert.match( + found[0]!.message, + /^Layer "A-WALL" is not a layer of the standard "site-plan-example" version 1/, + ); + assert.match(found[3]!.message, /^line #57: Layer "0" is in every drawing and in no standard/); + assert.equal( + found[4]!.message, + "line #57: A lineweight of 0.6 mm is no pen of the standard. Its pens are bold 0.5, fine 0.18, heavy 0.7, light 0.25 and medium 0.35 mm. Give the entity a pen", + ); +}); + +test("a layer, a linetype, a role's text style and a dimension style hold the standard's values", () => { + const found = lint( + { + ...sheet, + layers: sheet.layers.map((l) => (l.name === "C-PROP" ? { ...l, color: 3, plot: false } : l)), + linetypes: sheet.linetypes!.map((l) => + l.name === "DASHED" ? { ...l, pattern: [1, -1] } : l, + ), + linetypeScale: 1, + textStyles: sheet.textStyles!.map((s) => + s.name === "NOTE" ? { ...s, font: "romans.shx" } : s, + ), + dimensionStyles: sheet.dimensionStyles!.map((s) => ({ ...s, arrowSize: 0.2 })), + }, + { standard }, + ); + assert.deepEqual( + found.map((d) => d.message), + [ + 'Layer "C-PROP" has color 3, and the standard gives it color 7', + 'Layer "C-PROP" has plot false, and the standard gives it plot true', + 'Linetype "DASHED" has the pattern [1, -1], and the standard gives it [0.5, -0.25]', + "The drawing's linetypeScale is 1, and the standard's is 0.5. Leave linetypeScale to the standard", + 'Text style "NOTE" has font "romans.shx", and the role "note" gives it font "Arial"', + 'Dimension style "DIMS-1in-10ft" has arrowSize 0.2, and the standard gives it arrowSize 0.125', + ], + ); + assert.deepEqual( + found.map((d) => d.rule), + [ + "layer-differs-from-standard", + "layer-differs-from-standard", + "linetype-differs-from-standard", + "linetype-differs-from-standard", + "text-style-differs-from-standard", + "dimension-style-differs-from-standard", + ], + ); + // A dimension style at a scale writes the drawing's units, and the built-in one has no metres. + const d = createDrawing({ units: "m" }); + const { textStyle: _, ...dims } = sheet.dimensionStyles![0]!; + d.dimensionStyle(dims); + assert.match( + lint(d.build(), { standard: BUILT_IN_STANDARD }).find((x) => x.rule === "dimension-style")! + .message, + /has no writing for "m"/, + ); +}); + +test("a text plots at its role's height in each viewport that shows it, and at least the least", () => { + const half = withModel( + sheet.entities.map((e) => + e.kind === "text" && e.value === "EDGE OF PAVEMENT" ? { ...e, height: e.height / 2 } : e, + ), + ); + assert.deepEqual( + lint(half, { standard }).map((d) => d.message), + [ + `text #50: This text of the role "note" plots 0.047 in tall in viewport "plan" at 1" = 10', and the role is 0.094 in: it was sized for another scale. Draw it in the view of the viewport that shows it`, + `text #50: This text plots 0.047 in tall in viewport "plan" at 1" = 10', below the standard's least height of 0.0625 in`, + ], + ); + // The model of the sheet at 1" = 10', shown at 1" = 20': every text and dimension is off. + const far = rules(withSheet({ viewports: layout.viewports.map((v) => ({ ...v, ratio: 240 })) })); + assert.ok(far.includes("height-off-role") && far.includes("dimension-off-scale")); + assert.ok(!far.includes("scale-outside-standard")); + // A viewport at a ratio of no scale of the standard. + const odd = withSheet({ viewports: layout.viewports.map((v) => ({ ...v, ratio: 100 })) }); + assert.equal(rules(odd)[0], "scale-outside-standard"); + // On the sheet, a text an insert draws plots at the insert's scale. The title block's texts + // have no role, and still plot at least the least height. + const scaled = withSheet({ + entities: layout.entities.map((e) => + e.kind === "insert" && (e.block === "NORTH ARROW" || e === titleBlock) + ? { ...e, scale: [0.5, 0.5] as const } + : e, + ), + }); + const found = lint(scaled, { standard }); + assert.equal( + only(found, "height-off-role") + .map((d) => d.message) + .join("\n"), + `insert #${layout.entities.findIndex((e) => e.kind === "insert" && e.block === "NORTH ARROW")} of layout "C-101": The text "N" of its block of the role "heading" plots 0.094 in tall on layout "C-101", and the role is 0.188 in: a text on a layout plots at its height. Draw it in its role, and insert its block at a scale of 1`, + ); + assert.ok(only(found, "height-below-standard").every((d) => d.entity === 0)); + assert.ok(only(found, "height-below-standard").length > 0); +}); + +test("a dimension is shown at its style's scale", () => { + const twenty = { ...sheet.dimensionStyles![0]!, name: "DIMS-1in-20ft", scale: 20 }; + const found = lint( + { + ...withModel( + sheet.entities.map((e) => + e.kind === "linear-dimension" ? { ...e, style: twenty.name } : e, + ), + ), + dimensionStyles: [...sheet.dimensionStyles!, twenty], + }, + { standard }, + ); + assert.equal(found.length, 6); + assert.match( + found[0]!.message, + /^linear-dimension #28: It is in the dimension style "DIMS-1in-20ft", for 1" = 20', and is shown in viewport "plan" at 1" = 10'/, + ); +}); + +test("a dimension style whose id starts with a hyphen is found at each scale", () => { + const dashed = defineStandard({ + id: "dashed", + version: "1", + extends: BUILT_IN_STANDARD, + dimensionStyles: { "-x": BUILT_IN_STANDARD.dimensionStyles.dims }, + }); + const d = createDrawing({ units: "ft", standard: dashed }); + const plan = d.view("plan", { scale: "1in-10ft" }); + d.linearDimension([0, 0], [10, 0], [0, 5], { style: "-x", view: plan }); + const drawing = d.build(); + assert.equal(drawing.dimensionStyles![0]!.name, "-X-1in-10ft"); + const found = lint(drawing, { standard: dashed }).map((d) => d.rule); + assert.ok(!found.includes("dimension-style-outside-standard"), found.join()); +}); + +test("a layout is on a sheet format of its paper, with its title block and inside its area", () => { + assert.deepEqual(rules(withSheet({ plotStyle: "acad.ctb" })), ["sheet-format-outside-standard"]); + assert.deepEqual(rules(withSheet({ paper: "ARCH E" })).slice(-1), [ + "sheet-format-outside-standard", + ]); + assert.deepEqual( + rules(withSheet({ viewports: layout.viewports.map((v) => ({ ...v, width: 30 })) })), + ["viewport-outside-drawing-area"], + ); + assert.deepEqual(rules(withSheet({ entities: layout.entities.slice(1) })), [ + "title-block-missing", + ]); + const { DATE: _, ...rest } = titleBlock.attributes!; + const fields = lint( + withSheet({ + entities: [ + { + ...titleBlock, + attributes: { + ...rest, + PROJECT: "A PROJECT NAME FAR TOO LONG FOR THE FIELD IT IS WRITTEN IN", + SHEET: "C-102", + }, + }, + ...layout.entities.slice(1), + ], + }), + { standard }, + ); + assert.deepEqual( + fields.map((d) => [d.rule, d.entity]), + [ + ["title-block-field-overflow", 0], + ["title-block-field-unset", 0], + ["layout", 0], + ], + ); + assert.match( + fields[2]!.message, + /^Under sheetFiles "one-drawing" a sheet's number is its layout's name, "C-101"/, + ); +}); + +test("a title block is the standard's when its block, text styles and linetypes are", () => { + const found = lint( + { + ...sheet, + blocks: sheet.blocks!.map((b) => + b.name === "TITLE BLOCK" ? { ...b, entities: b.entities.slice(1) } : b, + ), + }, + { standard }, + ); + assert.equal(found[0]!.rule, "title-block-differs-from-standard"); + assert.equal( + found[0]!.message, + 'The title block "plain" of the standard: its block "TITLE BLOCK" is not the standard\'s. Place it with sheet.titleBlock(), which copies it from the standard', + ); + // Read as any other block, its texts have no role. + assert.ok( + found + .slice(1) + .every((d) => d.rule === "text-style-outside-standard" && d.block === "TITLE BLOCK"), + ); + // A field of no definition makes it another, however much the field holds when written out. + let extra: unknown = "A"; + for (let i = 0; i < 40; i++) extra = [extra, extra]; + const [first, ...rest] = sheet.blocks!.find((b) => b.name === "TITLE BLOCK")!.entities; + for (const change of [ + { extra }, + { entities: [{ ...first!, extra } as unknown as Entity, ...rest] }, + ]) { + const changed: Drawing = { + ...sheet, + blocks: sheet.blocks!.map((b) => (b.name === "TITLE BLOCK" ? { ...b, ...change } : b)), + }; + assert.ok(within(1000, changed).includes("title-block-differs-from-standard")); + } +}); + +test("a layout's sheet format is the one on its paper whose title block it inserts", () => { + const plain = BUILT_IN_STANDARD.titleBlocks.plain; + const two = defineStandard({ + id: "two", + version: "1", + extends: standard, + titleBlocks: { + other: { + ...plain, + blockName: "OTHER", + block: { + ...plain.block!, + blocks: plain.block!.blocks!.map((b) => ({ ...b, name: "OTHER" })), + }, + }, + }, + sheetFormats: { "arch-d-other": { paper: "ARCH D", titleBlock: "other" } }, + }); + assert.deepEqual(rules(sheet, { standard: two }), []); + assert.deepEqual(rules(withSheet({ entities: layout.entities.slice(1) }), { standard: two }), [ + "sheet-format-ambiguous", + ]); +}); + +test("among sheet formats that share a title block, a layout's is told by its plot style table", () => { + const color = defineStandard({ + id: "color", + version: "1", + extends: standard, + sheetFormats: { + "arch-d-color": { paper: "ARCH D", titleBlock: "plain", plotStyle: "acad.ctb" }, + }, + }); + assert.deepEqual(rules(sheet, { standard: color }), []); + assert.deepEqual(rules(withSheet({ plotStyle: "acad.ctb" }), { standard: color }), []); + // A table of neither is reported, and the layout is still checked against the title block. + const wide = { ...layout.viewports[0]!, corner: [0, 0] as const }; + const found = lint(withSheet({ plotStyle: "Grayscale.ctb", viewports: [wide] }), { + standard: color, + }); + assert.deepEqual( + found.map((d) => d.rule), + ["sheet-format-outside-standard", "viewport-outside-drawing-area"], + ); + assert.match(found[1]!.message, /the sheet formats "arch-d" and "arch-d-color", \[/); +}); + +test("under sheet-per-file a drawing has one layout, and its number names a file", () => { + const perFile = defineStandard({ + id: "per-file", + version: "1", + extends: standard, + sheetFiles: "sheet-per-file", + }); + const second = { + ...layout, + name: "C-102", + viewports: [], + entities: [ + { + ...titleBlock, + id: "C-102.title-block", + attributes: { ...titleBlock.attributes, SHEET: "C/102" }, + }, + ], + }; + // A second layout is reported when the first cannot be read. + assert.deepEqual( + lint({ ...sheet, layouts: [5 as never, layout] }, { standard: perFile }) + .filter((d) => d.rule === "sheet-per-file") + .map((d) => d.layout), + [layout.name], + ); + const found = lint({ ...sheet, layouts: [layout, second] }, { standard: perFile }); + assert.deepEqual( + found.map((d) => [d.rule, d.layout]), + [ + ["sheet-per-file", "C-102"], + ["sheet-per-file", "C-102"], + ], + ); + assert.match( + found[0]!.message, + /^Layout "C-102": Under sheetFiles "sheet-per-file" a drawing is one sheet/, + ); + assert.match( + found[1]!.message, + /^Under sheetFiles "sheet-per-file" a sheet's number names its file/, + ); +}); + +test("an entry with fields by discipline is checked in the drawing's discipline", () => { + const perFile = defineStandard({ + id: "per-file", + version: "1", + extends: standard, + sheetFiles: "sheet-per-file", + layers: { + "C-PROP": { color: 7, linetype: "PHANTOM", pen: "heavy", byDiscipline: { C: { color: 1 } } }, + }, + }); + assert.deepEqual(rules(sheet, { standard: perFile }), ["discipline-unknown"]); + assert.deepEqual(rules(sheet, { standard: perFile, discipline: "c" }), [ + "layer-differs-from-standard", + ]); + assert.deepEqual(rules(sheet, { standard: perFile, discipline: "V" }), []); + assert.deepEqual(rules(sheet, { standard: perFile, discipline: "Q" }), [ + "standard", + "discipline-unknown", + ]); + assert.deepEqual(rules(sheet, { standard, discipline: "C" }), ["standard"]); +}); + +test("the checks of heights stop at a limit, and say so", () => { + const pile: Entity[] = Array.from({ length: 2000 }, () => ({ + kind: "text", + layer: "C-ANNO-TEXT", + at: [50, 45], + height: 0.9375, + value: "PILE", + rotation: 0, + style: "NOTE", + })); + const { id: _, ...viewport } = layout.viewports[0]!; + const viewports = Array.from({ length: 300 }, () => viewport); + const started = performance.now(); + const found = rules({ + ...withModel([...sheet.entities, ...pile]), + layouts: [{ ...layout, viewports }], + }); + const ms = performance.now() - started; + assert.ok(ms < 1000, `${ms} ms`); + assert.deepEqual(found, ["standard-check-capped"]); +}); + +test("the walk of an insert enters what can draw a text, and pays for each item and name", () => { + /** 25 blocks that each insert the one below twice, so the last draws `leaf` 2^24 times. */ + const tree = (leaf: Entity, prefix = "", layer = "C-PROP") => { + const blocks: Block[] = [{ name: `${prefix}B0`, base: [0, 0], entities: [leaf] }]; + for (let i = 1; i < 25; i++) { + const insert: Insert = { kind: "insert", layer, block: `${prefix}B${i - 1}`, at: [0, 0] }; + blocks.push({ + name: `${prefix}B${i}`, + base: [0, 0], + entities: [insert, { ...insert, at: [1, 0] }], + }); + } + const top: Insert = { kind: "insert", layer: "C-PROP", block: `${prefix}B24`, at: [0, 0] }; + return { blocks: [...sheet.blocks!, ...blocks], top }; + }; + // A tree of lines draws no text, so the walk does not enter it, and the text after it is checked. + const lines = tree({ kind: "line", layer: "0", from: [0, 0], to: [1, 1] }); + const note = layout.entities.find((e) => e.kind === "text" && e.style === "NOTE") as Text; + const { id: _, ...twice } = { ...note, height: note.height * 2 }; + const sheetTree: Drawing = { + ...withSheet({ entities: [...layout.entities, lines.top, twice] }), + blocks: lines.blocks, + }; + assert.deepEqual(within(3000, sheetTree), ["height-off-role"]); + // No layout, whose viewports have the other rules walk the inserts of model space. + const long = "A".repeat(10_000); + const text = (value: string): Entity => ({ + kind: "text", + layer: "0", + at: [0, 0], + height: 1, + value, + rotation: 0, + style: "NOTE", + }); + for (const { blocks, top } of [tree(text(long)), tree(text("A"), long, `L-${long}`)]) { + const found = within(3000, { ...sheet, entities: [top], blocks, layouts: [] }); + assert.equal(found.at(-1), "standard-check-capped"); + } +}); + +test("lint stops at the first empty slot of a list, as the other rules do", () => { + // oxlint-disable-next-line unicorn/no-new-array + const slots = () => new Array(2 ** 32 - 1); + const holed: Block = { name: "HOLED", base: [0, 0], entities: [], attributes: slots() }; + for (const drawing of [ + { ...sheet, layers: slots() }, + { ...sheet, entities: slots() }, + { ...sheet, blocks: slots() }, + { ...sheet, blocks: [...sheet.blocks!, holed] }, + { ...sheet, layouts: slots() }, + withSheet({ entities: slots() }), + withSheet({ viewports: slots() }), + ]) { + assert.ok(within(1000, drawing).length > 0); + } + const data = JSON.parse(JSON.stringify(standard)) as Standard; + assert.deepEqual(within(1000, sheet, { standard: { ...data, chain: slots() } }), ["standard"]); +}); + +/** Numbers from 0 up to 1, the same from one seed on every run. */ +function seeded(seed: number): () => number { + return () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +test("the checks of a standard stay bounded on any data, and never throw", () => { + const random = seeded(4); + const pick = (items: readonly T[]): T => items[Math.floor(random() * items.length)]!; + const long = "W".repeat(20_000); + const data = JSON.stringify(standard); + /** A value that is small in memory and 2^40 times its size written out. */ + const doubled = (): unknown => { + let value: unknown = pick([long, 0, null]); + for (let i = 0; i < 40; i++) value = random() < 0.5 ? [value, value] : { a: value, b: value }; + return value; + }; + const cyclic = (): unknown => { + const value: Record = { name: long }; + value.self = value; + return value; + }; + const hostile = (): unknown => + pick([doubled, cyclic, () => long, () => null, () => -1, () => NaN, () => ({}), () => []])(); + /** A list of `item` that is all empty slots, has a hole, or holds it many times. */ + const listed = (item: unknown): unknown[] => { + const holed = [item, item, item]; + delete holed[1]; + // oxlint-disable-next-line unicorn/no-new-array + return pick([() => new Array(2 ** 32 - 1), () => holed, () => new Array(500).fill(item)])(); + }; + type Data = Record; + const copies = (list: unknown): Data[] => + ((list as readonly Data[] | undefined) ?? []).map((item) => ({ ...item })); + const prefix = "P".repeat(400_000); + // A standard with 800 dimension styles and 800 scales, which name 640,800 dimension styles. + const dims = BUILT_IN_STANDARD.dimensionStyles.dims; + const wide = JSON.stringify( + defineStandard({ + id: "wide", + version: "1", + extends: BUILT_IN_STANDARD, + scales: Object.fromEntries( + Array.from({ length: 800 }, (_, i) => [`s${i}`, { label: `S${i}`, ratio: 1000 + i }]), + ), + dimensionStyles: Object.fromEntries(Array.from({ length: 800 }, (_, i) => [`d${i}`, dims])), + }), + ); + + for (let n = 0; n < 40; n++) { + const blocks = sheet.blocks!.map((b) => ({ + ...b, + entities: copies(b.entities), + attributes: copies(b.attributes), + })); + const sheetLayout = { + ...layout, + entities: copies(layout.entities), + viewports: copies(layout.viewports), + }; + const drawing: Data = { + ...sheet, + layers: copies(sheet.layers), + linetypes: copies(sheet.linetypes), + textStyles: copies(sheet.textStyles), + dimensionStyles: copies(sheet.dimensionStyles), + entities: copies(sheet.entities), + blocks, + layouts: [sheetLayout], + }; + const lists: [Data, string][] = [ + ...[ + "layers", + "linetypes", + "textStyles", + "dimensionStyles", + "blocks", + "layouts", + "entities", + ].map((field) => [drawing, field] as [Data, string]), + [drawing, "xrefs"], + [sheetLayout, "entities"], + [sheetLayout, "viewports"], + ...blocks.flatMap((b) => [ + [b, "entities"] as [Data, string], + [b, "attributes"] as [Data, string], + ]), + ]; + + // Trees of blocks, deep and wide, or one long chain, which may insert its own top. + if (random() < 0.7) { + const [depth, width] = pick([ + [20 + Math.floor(random() * 20), pick([2, 3])], + [5000, 1], + ]); + // Long names on a short tree: a long chain of them is a large drawing written out. + const prefix = depth < 100 ? pick(["", long]) : ""; + const layer = pick(["0", "C-PROP", long]); + const top: Data = { + kind: "insert", + layer: "C-PROP", + block: `${prefix}T`, + at: [0, 0], + attributes: { [pick(["TAG", long])]: pick(["V", long]) }, + }; + const text = { + kind: "text", + layer: pick(["0", long]), + at: [0, 0], + height: pick([1, 1e-9, 1e99]), + value: pick(["A", long]), + rotation: 0, + style: pick(["NOTE", long]), + }; + const definition = { tag: pick(["TAG", long]), value: pick(["V", long]), layer: "0" }; + const leaf = pick([text, { kind: "line", layer: "0", from: [0, 0], to: [1, 1] }, top]); + const tree: Data[] = [ + { + name: `${prefix}B0`, + base: [0, 0], + entities: [leaf], + attributes: [{ ...definition, at: [0, 0], height: 1, rotation: 0, style: "NOTE" }], + }, + ]; + for (let i = 1; i < depth; i++) { + const insert = { kind: "insert", layer, block: `${prefix}B${i - 1}` }; + tree.push({ + name: `${prefix}B${i}`, + base: [0, 0], + entities: Array.from({ length: width }, (_, j) => ({ ...insert, at: [j, 0] })), + }); + } + tree.push({ + name: `${prefix}T`, + base: [0, 0], + entities: [{ kind: "insert", layer, block: `${prefix}B${depth - 1}`, at: [0, 0] }], + }); + blocks.push(...(tree as typeof blocks)); + (random() < 0.5 ? (drawing.entities as Data[]) : sheetLayout.entities).push({ ...top }); + } + // Many texts in many viewports. + if (random() < 0.2) { + const note = sheet.entities.find((e) => e.kind === "text")!; + (drawing.entities as Data[]).push(...copies(Array.from({ length: 3000 }, () => note))); + sheetLayout.viewports = copies(Array.from({ length: 300 }, () => layout.viewports[0])); + } + // Many dimensions in a style with a long prefix, which every label reads. + if (random() < 0.15) { + const dimension = sheet.entities.find((e) => e.kind === "linear-dimension")!; + (drawing.entities as Data[]).push(...copies(Array.from({ length: 400 }, () => dimension))); + (drawing.dimensionStyles as Data[])[0]!.prefix = prefix; + } + // A field of something in the drawing: a long name, a value of the wrong type, or a field + // nobody knows that is large written out. + const records: Data[] = [drawing, sheetLayout, ...lists.flatMap(([o, f]) => o[f] as Data[])]; + for (const e of [...sheetLayout.entities, ...(drawing.entities as Data[])]) { + if (e.attributes) records.push((e.attributes = { ...(e.attributes as Data) })); + } + for (let changes = Math.floor(random() * 5); changes > 0; changes--) { + const record = pick(records); + const known = Object.keys(record); + const kind = random(); + if (kind < 0.4 && known.length > 0) { + const field = pick(known); + record[field] = typeof record[field] === "string" ? `${long}${record[field]}` : long; + } else if (kind < 0.7) record[pick(["extra", long])] = hostile(); + else if (known.length > 0) record[pick(known)] = hostile(); + } + // A list that is all empty slots, has a hole, or holds one item many times. + if (random() < 0.3) { + const [owner, field] = pick(lists); + const list = owner[field]; + owner[field] = listed(Array.isArray(list) && list.length > 0 ? pick(list) : hostile()); + } + + // The standard, as made or as data, or as data with a field changed, or the wide standard as + // data, against the drawing or an empty one. + const as = random(); + const given = as < 0.4 ? standard : (JSON.parse(as < 0.5 ? wide : data) as Data); + const checked = + as >= 0.4 && as < 0.5 && random() < 0.5 ? { units: "ft", entities: [] } : drawing; + if (as >= 0.7) { + const copy = given as Data; + const section = pick(Object.keys(copy)); + const value = copy[section]; + if (random() < 0.5 && value && typeof value === "object" && !Array.isArray(value)) { + (value as Data)[pick(["extra", long, ...Object.keys(value)])] = hostile(); + } else copy[section] = random() < 0.5 ? hostile() : listed(value); + } + const discipline = pick([undefined, undefined, "C", long, 5]); + + const out: Diagnostic[] = []; + Object.assign(globalThis, { + checking: () => + lintStandard(checked as unknown as Drawing, given, discipline, undefined, out, 20_000), + }); + try { + checking.runInThisContext({ timeout: 1000 }); + } catch (thrown) { + assert.fail(`case ${n}: ${String((thrown as Error)?.message ?? thrown)}`); + } + assert.ok(out.every((d) => typeof d.rule === "string" && typeof d.message === "string")); + } +});