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")); + } +});