diff --git a/AGENTS.md b/AGENTS.md index ef741d1..0572630 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,13 @@ checks, with `BUILT_IN_HASH` in `src/standard/define.ts`, and a test resolves its document with them. A change to what it states raises its version, which the CHANGELOG gives, and sets that hash. +- A rule of a drawing drawn to a standard, which the builder refuses, is a type in + `src/standard/drawn.ts` with the runtime check's message, and its message names the standard + by the id and version its type carries. The builder finds an entry of the standard in any case, + as AutoCAD does, and the type compares the exact spelling. `drawing.ts` reads a standard + through `src/standard/made.ts` and never imports `define.ts`, which draws the plain title + block with the builder as it loads. A drawing without a standard is built as it was before + standards. - `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/GUIDE.md b/GUIDE.md index 37e4758..a3c969e 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -250,6 +250,11 @@ keeping `drafting.ts` in a `lib` folder beside it. Change two import strings to Its parameters come first and every position is worked out from them. Its columns and line breaks are laid out with `measureText`. Its symbols are blocks, and so is its title block. +It is drawn to a standard of its own, which extends `BUILT_IN_STANDARD` with the layer +`V-TREE`. `d.layer(name, draw)` names a layer of the standard, which gives its color, linetype, +pen and plot flag, and an entity's `pen` names one of the standard's pens. A layer the standard +lacks is a compile error that says how to add it to `layers` in `defineStandard`. + ## Checking intent Export `intent` beside the drawing, and each build checks that every footprint keeps out of diff --git a/examples/README.md b/examples/README.md index fd8aaf3..32dcc8a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -90,8 +90,11 @@ too. The tree and the property corner are in the plan and in the legend, so each is inserted in model space and on the layout: the tree at each tree's radius in the plan and at 3/16 inch in the legend. -Each layer sets a linetype and a pen weight. The linetype scale is a length of -paper, 1/2. Change the ratio from 120 to 240 and nothing on the sheet moves: +The sheet is drawn to a standard of its own, `standard`, which extends +`BUILT_IN_STANDARD` with the layer `V-TREE`. Each layer is named, and its color, +linetype, pen and plot flag are the standard's. The border names the standard's +heavy pen. The standard's linetype scale is a length of paper, 1/2. Change the +ratio from 120 to 240 and nothing on the sheet moves: the plan is half as large in its viewport, and its text keeps its plotted height. The sheet holds the plan at 1" = 10' and at 1" = 20'. At a larger scale, such as 1" = 8', the plan is taller than its viewport. diff --git a/examples/lib/drafting.ts b/examples/lib/drafting.ts index 7ddfe6f..ce45ce8 100644 --- a/examples/lib/drafting.ts +++ b/examples/lib/drafting.ts @@ -1,10 +1,35 @@ // Drafting helpers for the examples. tsdraft draws lines, polylines, circles, arcs and text, and // these functions compose them into dimensions, leaders, patterns and symbols. In CAD they arrive // as the plain lines and text they are made of. -import { measureText, type DrawingBuilder, type Font, type Point } from "../../src/index.ts"; +import { + measureText, + type Font, + type Point, + type TextAlign, + type TextVerticalAlign, +} from "../../src/index.ts"; -/** The builder methods the helpers draw with. */ -export type Pen = Pick; +/** + * The builder methods the helpers draw with, as they call them, so a drawing with a standard or + * without one can be given. + */ +export interface Pen { + line(from: Point, to: Point): unknown; + polyline(points: readonly Point[], closed?: boolean): unknown; + circle(center: Point, radius: number): unknown; + arc(center: Point, radius: number, startAngle: number, endAngle: number): unknown; + text( + at: Point, + value: string, + options: { + readonly height: number; + readonly style?: string; + readonly align?: TextAlign; + readonly valign?: TextVerticalAlign; + readonly rotation?: number; + }, + ): unknown; +} /** A rectangle as its lower-left corner, width and height. */ export type Box = readonly [x: number, y: number, width: number, height: number]; diff --git a/examples/site-plan.ts b/examples/site-plan.ts index ed403fe..24c4311 100644 --- a/examples/site-plan.ts +++ b/examples/site-plan.ts @@ -43,7 +43,10 @@ // inches on paper into feet for the viewport's ratio. So the same code draws the sheet at // another ratio: the plan changes size in its viewport and its text keeps its plotted // height. -// - Each layer sets a linetype and a pen weight, and its entities draw ByLayer. +// - The drawing is drawn to a standard, `standard`, which extends the built-in one with the +// layer `V-TREE`. A layer is named, and its color, linetype, pen and plot flag are the +// standard's. Its entities draw ByLayer, and one that plots heavier than its layer names a +// pen of the standard. // - Every text is in the sheet's text style, `NOTES`, through `put`, and is justified to its // point with `align` and `valign`. Columns and line breaks are laid out by the length // `measureText` gives a text in the style's font. @@ -55,13 +58,12 @@ // stays an attribute. `titleBlock` and `TITLE_BLOCK` can be copied by themselves. import { mkdirSync, writeFileSync } from "node:fs"; import { - CENTER, + BUILT_IN_STANDARD, checkIntent, createDrawing, - DASHED, + defineStandard, fromPaper, PAPERS, - PHANTOM, toDXF, toSVG, type Drawing, @@ -73,6 +75,20 @@ import { } from "../src/index.ts"; import { arrowhead, textWidth } from "./lib/drafting.ts"; +/** + * The standard the sheet is drawn to: the built-in one, with a layer for the trees. Its layers + * give the plan its colors, linetypes and pens, and its linetype scale sizes the dashes. + */ +export const standard = defineStandard({ + id: "site-plan-example", + version: "1", + extends: BUILT_IN_STANDARD, + layers: { "V-TREE": { color: 3, pen: "fine", discipline: "V" } }, +}); + +/** A builder of a drawing drawn to the standard. */ +type Builder = DrawingBuilder<"R2018", number, typeof standard>; + // The sheet. Lengths on it are inches of paper, from its lower-left corner. The paper is full // bleed, with no margin a plotter keeps clear, so the border sets the edge of the drawing: // it is `margin` inside the paper, and nothing is drawn outside it. @@ -199,10 +215,10 @@ const TITLE_BLOCK = { * corner, so it is inserted on a layout at `[0, 0]`, with the fields in `attributes`. * * Its lines and texts are on layer 0, so they draw on the layer of the insert. The border and - * the outline carry a lineweight of their own. + * the outline are drawn with the standard's heavy pen. */ function titleBlock( - d: DrawingBuilder, + d: Builder, { width, height, @@ -264,7 +280,7 @@ function titleBlock( d.block(name, {}, () => { d.rect([area.left, area.bottom], area.right - area.left, area.top - area.bottom, { - lineweight: 0.7, + pen: "heavy", }); // The title block's left and top edges. The border is its other two. d.polyline( @@ -274,7 +290,7 @@ function titleBlock( [box.right, box.top], ], false, - { lineweight: 0.7 }, + { pen: "heavy" }, ); d.line([split, box.bottom], [split, box.top]); d.line([box.left, seal.bottom], [split, seal.bottom]); @@ -361,11 +377,11 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { top: planCenter[1] + paper(viewport.top - viewport.bottom) / 2, }; - // AutoCAD's linetype patterns are sized for paper: DASHED is a 0.5 dash and a 0.25 gap. On a - // layout AutoCAD sizes dashes on the paper, in every viewport, so the linetype scale is a - // paper length and no drawing length: at 1/2 the dashes plot at half their pattern, a 1/4 in - // dash, at every ratio. - const d = createDrawing({ units: scale.units, linetypeScale: 1 / 2 }); + // The standard's linetype patterns are sized for paper: DASHED is a 0.5 dash and a 0.25 gap. + // On a layout AutoCAD sizes dashes on the paper, in every viewport, so the standard's linetype + // scale, 1/2, is a paper length and no drawing length: the dashes plot at half their pattern, + // a 1/4 in dash, at every ratio. + const d = createDrawing({ units: scale.units, standard }); d.textStyle({ name: NOTES, font: FONT }); const widthOf = (value: string, height: number) => textWidth(value, height, FONT); @@ -382,22 +398,6 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { d.text(at, value, { height, style: NOTES, ...options }); } - d.layer("C-PROP", { color: 7, linetype: PHANTOM, lineweight: 0.7 }); - d.layer("C-PROP-ADJC", { color: 8, linetype: PHANTOM, lineweight: 0.18 }); - d.layer("C-PROP-MONU", { color: 7, lineweight: 0.25 }); - d.layer("C-SETB", { color: 1, linetype: DASHED, lineweight: 0.25 }); - d.layer("C-BLDG", { color: 5, lineweight: 0.7 }); - d.layer("C-PAVE", { color: 8, lineweight: 0.35 }); - d.layer("C-ROAD-CNTR", { color: 8, linetype: CENTER, lineweight: 0.18 }); - d.layer("V-TREE", { color: 3, lineweight: 0.18 }); - d.layer("C-ANNO-DIMS", { color: 8, lineweight: 0.18 }); - d.layer("C-ANNO-TEXT", { color: 7, lineweight: 0.25 }); - d.layer("C-ANNO-SYMB", { color: 7, lineweight: 0.35 }); - d.layer("C-ANNO-TITL", { color: 7, lineweight: 0.5 }); - d.layer("G-ANNO-TTLB", { color: 7, lineweight: 0.35 }); - // A viewport's border is on a layer that does not plot. - d.layer("G-ANNO-NPLT", { color: 8, plot: false }); - // The symbols. A block's coordinates are its own, and its base point is its origin. d.block("TREE", {}, () => { // The canopy, one foot in radius, and a cross at the trunk. @@ -446,8 +446,8 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { }); // Model space: the site, at its true size in feet. - d.layer("C-PROP", {}, () => d.rect([0, 0], lot.width, lot.depth, { id: "lot" })); - d.layer("C-PROP-ADJC", {}, () => { + d.layer("C-PROP", () => d.rect([0, 0], lot.width, lot.depth, { id: "lot" })); + d.layer("C-PROP-ADJC", () => { // The adjoining lot lines and the street run to the sides of the view. for (const y of [0, lot.depth]) { d.line([view.left, y], [0, y]); @@ -456,18 +456,18 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { // The far side of the street's right-of-way. d.line([view.left, -street.rightOfWay], [view.right, -street.rightOfWay]); }); - d.layer("C-PROP-MONU", {}, () => { + d.layer("C-PROP-MONU", () => { for (const x of [0, lot.width]) { for (const y of [0, lot.depth]) d.insert("PROPERTY CORNER", [x, y], { scale: paper(1) }); } }); - d.layer("C-SETB", {}, () => { + d.layer("C-SETB", () => { const { front, side, rear } = setbacks; d.rect([side, front], lot.width - side * 2, lot.depth - front - rear); }); - d.layer("C-BLDG", {}, () => d.polyline(footprint, true, { id: "residence" })); + d.layer("C-BLDG", () => d.polyline(footprint, true, { id: "residence" })); - d.layer("C-PAVE", {}, () => { + d.layer("C-PAVE", () => { // The driveway, flared to the street with a quarter circle on each side. const flareY = pavement.near + driveway.flare; d.line([driveX, bounds.front], [driveX, flareY]); @@ -484,8 +484,8 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { d.line([driveX + driveway.width, y], [view.right, y]); } }); - d.layer("C-ROAD-CNTR", {}, () => d.line([view.left, centerline], [view.right, centerline])); - d.layer("V-TREE", {}, () => { + d.layer("C-ROAD-CNTR", () => d.line([view.left, centerline], [view.right, centerline])); + d.layer("V-TREE", () => { for (const tree of trees) d.insert("TREE", tree.at, { scale: tree.radius }); }); @@ -516,7 +516,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { // Each runs through the building's corners, and ends on a lot line. const across = bounds.rear + paper(3 / 8); const up = lot.width + paper(5 / 8); - d.layer("C-ANNO-DIMS", {}, () => { + d.layer("C-ANNO-DIMS", () => { const acrossLot: Point[] = [ [0, across], [bounds.left, bounds.rear], @@ -537,7 +537,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { } }); - d.layer("C-ANNO-TEXT", {}, () => { + d.layer("C-ANNO-TEXT", () => { const { note, label, heading } = text; const middle = lot.width / 2; @@ -626,7 +626,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { // The sheet: everything drawn inside `d.layout` is on the paper, in inches. d.layout(sheet.number, { paper: sheet.paper }, (paperSheet) => { // The border and the title block, with the sheet's fields. - d.layer("G-ANNO-TTLB", {}, () => { + d.layer("G-ANNO-TTLB", () => { d.insert(block.name, [0, 0], { id: "title-block", attributes: { @@ -641,8 +641,8 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { }); }); - // The plan, at the ratio. - d.layer("G-ANNO-NPLT", {}, () => { + // The plan, at the ratio. A viewport's border is on a layer that does not plot. + d.layer("G-ANNO-NPLT", () => { const [width, height] = [viewport.right - viewport.left, viewport.top - viewport.bottom]; paperSheet.viewport([viewport.left, viewport.bottom], width, height, { center: planCenter, @@ -655,12 +655,12 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { // a row. The underline of the title is level with the middle of the north arrow. const underline = area.bottom + layout.clear + arrowRadius; const titleX = area.left + layout.clear + arrowRadius * 2 + 3 / 8; - d.layer("C-ANNO-TITL", {}, () => { + d.layer("C-ANNO-TITL", () => { d.insert("VIEW TITLE", [titleX, underline], { attributes: { VIEW_TITLE: sheet.title, VIEW_SCALE: `SCALE: ${scaleLabel}` }, }); }); - d.layer("C-ANNO-SYMB", {}, () => { + d.layer("C-ANNO-SYMB", () => { // The viewport is not turned, so north is up the sheet. d.insert("NORTH ARROW", [area.left + layout.clear + arrowRadius, underline]); @@ -690,7 +690,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { function columnHeading(title: string): void { row -= size.heading; - d.layer("C-ANNO-TITL", {}, () => { + d.layer("C-ANNO-TITL", () => { put([column.left, row], title, size.heading); d.line([column.left, row - gap], [column.left + column.width, row - gap]); }); @@ -730,7 +730,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { } columnHeading("SITE DATA"); - d.layer("C-ANNO-TEXT", {}, () => { + d.layer("C-ANNO-TEXT", () => { const coverage = (footprintArea / lotArea) * 100; const nameColumn = widthOf("BUILDING FOOTPRINT", size.label) + 1 / 2; table( @@ -759,7 +759,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { row -= blockGap; columnHeading("GENERAL NOTES"); - d.layer("C-ANNO-TEXT", {}, () => { + d.layer("C-ANNO-TEXT", () => { const notes = [ "PROPERTY LINES ARE DRAWN FROM THE RECORDED PLAT. NO FIELD SURVEY WAS MADE FOR THIS PLAN.", "DIMENSIONS ARE IN FEET, MEASURED TO THE OUTSIDE FACE OF THE FOUNDATION WALL.", @@ -789,9 +789,9 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { row -= blockGap; columnHeading("LEGEND"); const sample = 2; // the length of a legend line - function legendRow(layer: Parameters[0], name: string, draw: () => void): void { - d.layer(layer, {}, draw); - d.layer("C-ANNO-TEXT", {}, () => { + function legendRow(layer: keyof typeof standard.layers, name: string, draw: () => void): void { + d.layer(layer, draw); + d.layer("C-ANNO-TEXT", () => { put([column.left + sample + 3 / 8, row], name, size.label); }); row -= leading * 1.5; @@ -814,7 +814,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { row -= blockGap; columnHeading("ABBREVIATIONS"); - d.layer("C-ANNO-TEXT", {}, () => { + d.layer("C-ANNO-TEXT", () => { table( [ ["AC", "ACRES"], diff --git a/src/drawing.ts b/src/drawing.ts index f3be5fc..4f6e293 100644 --- a/src/drawing.ts +++ b/src/drawing.ts @@ -11,7 +11,7 @@ import { type Arrowhead, type ZERO_SUPPRESSIONS, } from "./arrowheads.ts"; -import { holdCall, readCall, recordCalls, type HeldCall } from "./callsite.ts"; +import { holdCall, readCall, recordCalls, type CallSite, type HeldCall } from "./callsite.ts"; import { arrowheadEntityHandle, dimensionAngle, @@ -132,6 +132,28 @@ import { type TEXT_ALIGNS, type TEXT_VERTICAL_ALIGNS, } from "./rules.ts"; +import type { Standard, StandardLayer, StandardLinetype } from "./standard/document.ts"; +import { + disciplineLacks, + DRAWN, + layerLacks, + linetypeLacks, + penLacks, + penLineweightProblem, + unnamedDisciplineProblem, + type DisciplineWithoutStandard, + type LinetypeDefined, + type PenWithoutStandard, + type DisciplineOfStandard, + type LayerOfStandard, + type StandardLayerOptions, + type LinetypeOfStandard, + type StandardLinetypeScale, + type LineweightUnderStandard, + type PenOfStandard, + type StandardR12, +} from "./standard/drawn.ts"; +import { adopted } from "./standard/made.ts"; /** A model-space point, in drawing units. Y points up, as in CAD. */ export type Point = readonly [x: number, y: number]; @@ -534,13 +556,46 @@ export interface Drawing { readonly dimensionStyles?: readonly DimensionStyle[]; } -export interface DrawingOptions { +/** + * What `createDrawing` takes. + * + * @typeParam Z - The standard the drawing is drawn to, or `undefined` for none. + * @typeParam D - The drawing's discipline as written. + */ +export interface DrawingOptions< + V extends DxfVersion = DxfVersion, + S extends number = number, + Z extends Standard | undefined = undefined, + D extends string = string, +> { /** Defaults to `"in"`. */ readonly units?: Units; - /** The DXF version to check the drawing against and export. Defaults to `"R2018"`. */ - readonly dxf?: V; - /** The global linetype scale, which multiplies every linetype's pattern. Defaults to 1. */ - readonly linetypeScale?: V extends "R12" ? 1 | R12Linetypes : Positive; + /** + * The DXF version to check the drawing against and export. Defaults to `"R2018"`, which a + * drawing drawn to a standard is. + */ + readonly dxf?: Z extends Standard ? (V extends "R12" ? StandardR12 : V) : V; + /** + * The global linetype scale, which multiplies every linetype's pattern. Defaults to 1. Under a + * standard it is the standard's, and may not be given. + */ + readonly linetypeScale?: Z extends Standard + ? StandardLinetypeScale + : V extends "R12" + ? 1 | R12Linetypes + : Positive; + /** + * The standard the drawing is drawn to, which `defineStandard` returns. `layer()` then names a + * layer of the standard, which gives its color, linetype, lineweight and plot flag, an entity's + * `pen` names a pen of it, and the linetype scale is the standard's. + */ + readonly standard?: Z; + /** + * The drawing's discipline, a discipline of the standard: a model drawing's, or a sheet's, under + * `sheetFiles` `"sheet-per-file"`. An entry of the standard with `byDiscipline` takes that + * discipline's fields. + */ + readonly discipline?: Z extends Standard ? DisciplineOfStandard : DisciplineWithoutStandard; /** * Whether to record the call that draws each entity, so that a `lint` diagnostic names it in * `call`. It more than doubles the time the builder takes. Defaults to `false`, or to `true` when @@ -659,6 +714,8 @@ export interface LayerOptions< * * @typeParam L - The linetype as written, a name or a definition. A definition's literal mistakes * are compile errors. + * @typeParam Z - The drawing's standard, or `undefined` for none. + * @typeParam P - The pen as written. */ export interface EntityOptions< V extends DxfVersion = "R2018", @@ -666,20 +723,34 @@ export interface EntityOptions< W extends number = number, L extends LinetypeValue = LinetypeDefinition, I extends string = string, + Z extends Standard | undefined = undefined, + P extends string = string, > { /** * A name for the entity that no other entity of the drawing has: 1 to 64 letters A to Z or a * to z, digits, `.`, `-` and `_`. Diagnostics and `toSVG` carry it. */ readonly id?: EntityId; - /** A linetype of the drawing, `"Continuous"`, `"ByBlock"` or `"ByLayer"`. R2018 only. */ - readonly linetype?: V extends "R12" - ? R12LinetypeChoice - : LinetypeChoice; - /** In millimetres, or `"ByLayer"`, `"ByBlock"` or `"Default"`. R2018 only. */ - readonly lineweight?: V extends "R12" - ? "ByLayer" | R12EntityStyle - : LineweightOf | "ByLayer" | "ByBlock" | "Default"; + /** + * A linetype of the drawing, `"Continuous"`, `"ByBlock"` or `"ByLayer"`. R2018 only. Under a + * standard, a linetype of the standard by name, which the drawing then has. + */ + readonly linetype?: Z extends Standard + ? LinetypeOfStandard + : V extends "R12" + ? R12LinetypeChoice + : LinetypeChoice; + /** + * In millimetres, or `"ByLayer"`, `"ByBlock"` or `"Default"`. R2018 only. Under a standard, + * `"ByLayer"` or `"ByBlock"`, and a pen gives a lineweight. + */ + readonly lineweight?: Z extends Standard + ? LineweightUnderStandard + : V extends "R12" + ? "ByLayer" | R12EntityStyle + : LineweightOf | "ByLayer" | "ByBlock" | "Default"; + /** A pen of the drawing's standard, whose lineweight the entity takes. */ + readonly pen?: Z extends Standard ? PenOfStandard : PenWithoutStandard; } /** A text style to define: a `TextStyle`, checked. */ @@ -741,7 +812,9 @@ export interface TextOptions< I extends string = string, S extends string = string, D extends TextStyleValue = TextStyleDefinition, -> extends EntityOptions { + Z extends Standard | undefined = undefined, + P extends string = string, +> extends EntityOptions { readonly height?: Positive; /** Degrees counterclockwise. */ readonly rotation?: number; @@ -999,7 +1072,7 @@ function refuse(rule: LintRule, message: string): never { */ function located( thrown: unknown, - method: Method, + method: Method | readonly Method[], about: Pick< Diagnostic, | "id" @@ -1014,7 +1087,11 @@ function located( >, ): unknown { if (!(thrown instanceof Problem)) return thrown; - const call = readCall(holdCall(method)); + // The first of the methods that is on the stack is the one the caller called. + let call: CallSite | undefined; + for (const each of Array.isArray(method) ? method : [method]) { + call ??= readCall(holdCall(each as Method)); + } const diagnostic: Diagnostic = { rule: thrown.rule, severity: "error", @@ -1046,11 +1123,22 @@ function recordsCalls(): boolean { * when the code runs, for values the compiler cannot see: `layer`, `linetype`, * `textStyle`, `block`, `attribute`, `xref`, `layout`, `viewport` and the entity methods then throw a `DrawingError` whose * diagnostic has the rule, the entity's `id` and the `call` that broke it. + * + * Drawn to a standard, `Z`, the drawing's layers, linetypes, pens and linetype scale are the + * standard's: `layer()` names a layer of it, and an entity's `pen` one of its pens. `D` is the + * drawing's discipline as written, which the compiler checks against the standard's. */ -export class DrawingBuilder { +export class DrawingBuilder< + V extends DxfVersion = "R2018", + S extends number = number, + Z extends Standard | undefined = undefined, + D extends string = string, +> { readonly units: Units; readonly dxf: V; readonly linetypeScale: number; + /** The standard the drawing is drawn to, with its entries by name as AutoCAD compares them. */ + readonly #standard: Drawn | undefined; /** Keyed by `layerKey`, because AutoCAD ignores case in layer names. */ readonly #layers = new Map([["0", { name: "0", color: 7 }]]); /** The first layer name for each `layerAuditKey`, to find names AUDIT would call duplicates. */ @@ -1095,13 +1183,19 @@ export class DrawingBuilder(); #current = "0"; - constructor(options: DrawingOptions = {}) { + constructor(options: DrawingOptions = {}) { const units = options.units ?? "in"; if (!UNITS.includes(units)) throw new Error(`Unknown units: ${JSON.stringify(units)}`); - const dxf = options.dxf ?? "R2018"; + const dxf: string = options.dxf ?? "R2018"; if (!DXF_VERSIONS.includes(dxf)) { throw new Error(`Unsupported DXF version: ${JSON.stringify(dxf)}`); } + let standard: Drawn | undefined; + try { + standard = drawnTo(options as DrawingOptions, dxf); + } catch (thrown) { + throw located(thrown, [createDrawing, DrawingBuilder as unknown as Method], {}); + } const scale = options.linetypeScale as unknown; if (scale !== undefined) { if (typeof scale !== "number") throw new Error("linetypeScale must be a finite number"); @@ -1111,7 +1205,8 @@ export class DrawingBuilder( - linetype: V extends "R12" ? R12Linetypes : LinetypeDefinition, + linetype: Z extends Standard + ? LinetypeDefined + : V extends "R12" + ? R12Linetypes + : LinetypeDefinition, ): this { const definition = linetype as unknown as LinetypeDefinition; try { if (this.dxf === "R12") refuse("linetype", R12_ONLY.linetypes); + if (this.#standard) refuse("linetype-outside-standard", DRAWN.linetypeDefined); this.#define(definition); } catch (thrown) { const name: unknown = definition?.name; @@ -1159,9 +1260,13 @@ export class DrawingBuilder(name: LayerName, options: LayerOptions = {}, draw?: () => void): this { + >( + name: Z extends Standard ? LayerOfStandard : LayerName, + options: + | (Z extends Standard ? StandardLayerOptions : LayerOptions) + | (() => void) = {}, + draw?: () => void, + ): this { // The parameter type is the error message when a literal name is wrong, so read it as a string. const requested = name as string; + const given = typeof options === "function" ? {} : options; + if (typeof options === "function") draw = options; let layer: Layer; try { - layer = this.#layer(requested, options as unknown as LayerOptions); + layer = this.#standard + ? this.#standardLayer(requested, given as unknown as LayerOptions) + : this.#layer(requested, given as unknown as LayerOptions); } catch (thrown) { throw located(thrown, DrawingBuilder.prototype.layer, { layer: requested }); } @@ -1244,12 +1359,49 @@ export class DrawingBuilder = entry; + if (entry.byDiscipline) { + if (drawn.discipline === undefined) { + refuse("standard", unnamedDisciplineProblem(name, entry)); + } + fields = { ...entry, ...entry.byDiscipline[drawn.discipline] }; + } + const pen = fields.pen === undefined ? undefined : drawn.standard.pens[fields.pen]; + return this.#layer(name, { + color: fields.color as LayerColor, + linetype: fields.linetype, + lineweight: (pen?.lineweight ?? "Default") as LineweightOf, + plot: fields.plot, + off: o.off, + frozen: o.frozen, + locked: o.locked, + } as LayerOptions); + } + line< T extends string = string, W extends number = number, const L extends LinetypeValue = LinetypeDefinition, I extends string = string, - >(from: Point, to: Point, options: EntityOptions = {}): this { + P extends string = string, + >(from: Point, to: Point, options: EntityOptions = {}): this { return this.#add(DrawingBuilder.prototype.line, options, () => ({ kind: "line", layer: this.#current, @@ -1263,7 +1415,12 @@ export class DrawingBuilder(points: readonly Point[], closed = false, options: EntityOptions = {}): this { + P extends string = string, + >( + points: readonly Point[], + closed = false, + options: EntityOptions = {}, + ): this { return this.#add(DrawingBuilder.prototype.polyline, options, () => { if (points.length < 2) refuse("geometry", "A polyline needs at least 2 points"); return { kind: "polyline", layer: this.#current, points: points.map(pt), closed }; @@ -1278,11 +1435,12 @@ export class DrawingBuilder( corner: Point, width: Positive, height: Positive, - options: EntityOptions = {}, + options: EntityOptions = {}, ): this { return this.#add(DrawingBuilder.prototype.rect, options, () => { const [x, y] = pt(corner); @@ -1307,7 +1465,8 @@ export class DrawingBuilder(center: Point, radius: Positive, options: EntityOptions = {}): this { + P extends string = string, + >(center: Point, radius: Positive, options: EntityOptions = {}): this { return this.#add(DrawingBuilder.prototype.circle, options, () => ({ kind: "circle", layer: this.#current, @@ -1331,12 +1490,13 @@ export class DrawingBuilder( center: Point, radius: Positive, startAngle: number, endAngle: number, - options: EntityOptions = {}, + options: EntityOptions = {}, ): this { return this.#add(DrawingBuilder.prototype.arc, options, () => { const start = turn(num(startAngle, "startAngle")); @@ -1363,7 +1523,8 @@ export class DrawingBuilder(at: Point, value: string, options: TextOptions = {}): this { + P extends string = string, + >(at: Point, value: string, options: TextOptions = {}): this { return this.#add(DrawingBuilder.prototype.text, options, () => ({ kind: "text", layer: this.#current, @@ -1784,7 +1945,7 @@ export class DrawingBuilder, ): this { return this.#add(DrawingBuilder.prototype.linearDimension, idOf(options), () => { - const o = options as LinearDimensionOptions | undefined; + const o = options as unknown as LinearDimensionOptions | undefined; const along: unknown = o?.along ?? "x"; const angle = along === "x" @@ -1819,7 +1980,7 @@ export class DrawingBuilder, ): this { return this.#add(DrawingBuilder.prototype.alignedDimension, idOf(options), () => - this.#dimension(options as AlignedDimensionOptions | undefined, (style, text) => ({ + this.#dimension(options as unknown as AlignedDimensionOptions | undefined, (style, text) => ({ kind: "aligned-dimension", layer: this.#current, style, @@ -2198,6 +2359,24 @@ export class DrawingBuilder never): string { + const drawn = this.#standard; if (typeof choice === "object" && choice !== null) { + if (drawn) fail("linetype-outside-standard", DRAWN.linetypeDefined); return this.#define(choice as LinetypeDefinition, true); } if (typeof choice !== "string") { @@ -2220,6 +2401,15 @@ export class DrawingBuilder( - options?: DrawingOptions, -): DrawingBuilder { - return new DrawingBuilder(options as DrawingOptions); +/** + * Starts a drawing. With `standard`, the drawing is drawn to it: see `DrawingOptions.standard`. + */ +export function createDrawing< + V extends DxfVersion = "R2018", + S extends number = number, + Z extends Standard | undefined = undefined, + D extends string = string, +>(options?: DrawingOptions): DrawingBuilder { + return new DrawingBuilder(options as DrawingOptions); +} + +/** A standard as the builder reads it: its entries by the keys AutoCAD finds them by. */ +interface Drawn { + readonly standard: Standard; + /** Each layer as the standard spells its name, by `layerKey`. */ + readonly layers: ReadonlyMap; + /** Each linetype as the standard spells its name, by `layerKey`. */ + readonly linetypes: ReadonlyMap; + /** Each pen's lineweight, by `idKey` of its id. */ + readonly pens: ReadonlyMap; + /** The drawing's discipline, as the standard spells it. */ + readonly discipline?: string; +} + +/** The maps of each standard a drawing has been drawn to. */ +const drawnStandards = new WeakMap(); + +/** The standard `options` draws the drawing to, checked with the options it rules out. */ +function drawnTo( + options: DrawingOptions, + dxf: string, +): Drawn | undefined { + const given: unknown = options.standard; + const discipline: unknown = options.discipline; + if (given === undefined) { + if (discipline !== undefined) refuse("standard", DRAWN.disciplineWithoutStandard); + return undefined; + } + const standard = adopted(given); + if (!standard) return refuse("standard", DRAWN.notAStandard); + if (dxf === "R12") refuse("standard", DRAWN.r12); + if (options.linetypeScale !== undefined) refuse("standard", DRAWN.linetypeScale); + let drawn = drawnStandards.get(standard); + if (!drawn) { + const byName = (entries: Readonly>) => + new Map( + Object.entries(entries).map(([name, entry]) => [layerKey(name), [name, entry] as const]), + ); + drawn = { + standard, + layers: byName(standard.layers), + linetypes: byName(standard.linetypes), + pens: new Map(Object.entries(standard.pens).map(([id, pen]) => [idKey(id), pen.lineweight])), + }; + drawnStandards.set(standard, drawn); + } + if (discipline === undefined) return drawn; + if (typeof discipline !== "string") + return refuse("standard", "A discipline is named by a string"); + const named = Object.keys(standard.disciplines).find((id) => idKey(id) === idKey(discipline)); + if (named === undefined) refuse("standard", disciplineLacks(discipline, standard)); + if (standard.sheetFiles === "one-drawing") refuse("standard", DRAWN.disciplineOneDrawing); + return { ...drawn, discipline: named }; } /** The id among the options of a dimension, which have no linetype or lineweight to take. */ diff --git a/src/lint.ts b/src/lint.ts index 042404b..fbff826 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -155,7 +155,11 @@ export type LintRule = | "undefined-dimension-style" | "dimension" | "zero-length" - | "standard"; + | "standard" + | "layer-outside-standard" + | "layer-differs-from-standard" + | "linetype-outside-standard" + | "pen-outside-standard"; export interface Diagnostic { readonly rule: LintRule; diff --git a/src/standard/define.ts b/src/standard/define.ts index 16992ba..2b4e86a 100644 --- a/src/standard/define.ts +++ b/src/standard/define.ts @@ -25,6 +25,7 @@ import { type StandardTitleBlock, } from "./document.ts"; import type { CheckDocument, NamesOf, ParentOf } from "./literal.ts"; +import { adopted, MADE, readStandardsWith } from "./made.ts"; import { capitals, inheritedDefaultProblem, referenceProblem } from "./rules.ts"; import { DOCUMENT, @@ -58,16 +59,16 @@ import { PLAIN_TITLE_BLOCK_SPECIFIER, plainTitleBlock } from "./title-block.ts"; export function defineStandard< const D extends StandardDocument & CheckDocument>, const F extends Readonly> = {}, ->(document: D, options?: StandardOptions): Standard>> { +>( + document: D, + options?: StandardOptions, +): Standard>, D["id"], D["version"]> { const problems: Problem[] = []; const standard = resolve(document, options?.files ?? {}, problems); if (!standard || problems.length > 0) throw standardError(problems); return standard as never; } -/** The standards this copy of tsdraft made, which need no second check as a parent. */ -const MADE = new WeakSet(); - function standardError(problems: readonly Problem[]): DrawingError { const diagnostics = problems.map(({ item, message }): Diagnostic => ({ rule: "standard", @@ -632,9 +633,21 @@ function parentOf( } given = files[value]; } - if (typeof given === "object" && given !== null && MADE.has(given)) return given as Standard; - // A standard from another copy of tsdraft, as data: it is read again as a document with no - // `extends`, and its hash must be its content's. + const standard = adopted(given); + if (!standard) { + add( + "extends", + "The parent is not a standard: give extends a Standard that defineStandard made, unchanged, or a string that options.files gives one for", + ); + } + return standard; +} + +/** + * 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. + */ +function readAgain(given: unknown): Standard | undefined { const { hash, chain, ...rest } = isRecord(given) ? given : ({} as Record); const links = Array.isArray(chain) && @@ -646,13 +659,7 @@ function parentOf( typeof link.hash === "string", ); const again = links && typeof hash === "string" ? resolve(rest, {}, []) : undefined; - if (!again || again.hash !== hash) { - add( - "extends", - "The parent is not a standard: give extends a Standard that defineStandard made, unchanged, or a string that options.files gives one for", - ); - return undefined; - } + if (!again || again.hash !== hash) return undefined; // Its entries are `again`'s, whose drawings' JSON is known. const standard = Object.freeze({ ...again, @@ -663,6 +670,7 @@ function parentOf( MADE.add(standard); return standard; } +readStandardsWith(readAgain); /** The closure of a title block's block: what a drawing that inserts it has to hold. */ interface Drawn { @@ -809,4 +817,8 @@ const ATTRIBUTE_WITHOUT_BLOCK = /** The built-in standard: its layers, pens, roles, scales, dimension style and plain title block. */ export const BUILT_IN_STANDARD: BuiltInStandard = builtInStandard() as BuiltInStandard; -export type BuiltInStandard = Standard>; +export type BuiltInStandard = Standard< + NamesOf, + (typeof BUILT_IN_DOCUMENT)["id"], + (typeof BUILT_IN_DOCUMENT)["version"] +>; diff --git a/src/standard/document.ts b/src/standard/document.ts index 990fba5..73af202 100644 --- a/src/standard/document.ts +++ b/src/standard/document.ts @@ -364,14 +364,19 @@ export interface StandardSheetFormat { /** * A standard: the document resolved, with no `$schema`, `extends` or `null`, every default filled * in and every title block's drawings as `Drawing` objects, with its `hash` and `chain`. It is - * plain, frozen data. Its type carries the names of its entries, `N`, as literal types. + * plain, frozen data. Its type carries the names of its entries, `N`, and its id `I` and version + * `R`, as literal types. * * Left without `hash` and `chain`, it is a document with no `extends` that resolves to the same * standard, with the same hash. */ -export type Standard = { - readonly id: string; - readonly version: string; +export type Standard< + N extends StandardNames = StandardNames, + I extends string = string, + R extends string = string, +> = { + readonly id: I; + readonly version: R; readonly title: string; readonly paperUnits: PaperUnit; readonly minTextHeight: number; diff --git a/src/standard/drawn.ts b/src/standard/drawn.ts new file mode 100644 index 0000000..bef6c7c --- /dev/null +++ b/src/standard/drawn.ts @@ -0,0 +1,176 @@ +// What the builder refuses in a drawing drawn to a standard, each rule twice: as a type, so a +// literal mistake is a compile error, and as a runtime message that is the same, as +// `src/rules.ts` holds the rules of a drawing. A literal names an entry exactly as the standard +// spells it, and the builder, as AutoCAD does, finds one in any case. +import type { LayerName, LinetypeName } from "../rules.ts"; +import type { Standard, StandardLayer } from "./document.ts"; + +/** How a message names a standard. */ +type Of = `the standard "${Z["id"]}" version ${Z["version"]}`; +const of = (standard: Standard): string => + `the standard ${JSON.stringify(standard.id)} version ${standard.version}`; + +const STANDARD_R12 = + "Under a standard a drawing is R2018: a standard gives its layers linetypes and pens, which R12 cannot hold"; +const STANDARD_LINETYPE_SCALE = + "Under a standard the linetype scale is the standard's. Leave linetypeScale out, or set it in the standard"; +const DISCIPLINE_WITHOUT_STANDARD = + "discipline names a discipline of the drawing's standard, and this drawing has none"; +const DISCIPLINE_ONE_DRAWING = + 'Under sheetFiles "one-drawing" a drawing names no discipline: the model and every sheet are one drawing, where a layer has one set of properties'; +const LAYER_ZERO = + 'Layer "0" is in every drawing and in no standard. Draw on a layer of the standard: in a block, what is drawn outside any layer() is on layer "0" already, and draws on the layer of its insert'; +const LAYER_PROPERTIES = + "Under a standard a layer's color, linetype, lineweight and plot flag are the standard's. Give layer() only off, frozen or locked, or change the layer in the standard"; +const LINETYPE_DEFINED = + "Under a standard the linetypes are the standard's, and a drawing defines none of its own. Add the linetype to the standard's linetypes in defineStandard, and name it"; +const PEN_LINEWEIGHT = + 'Under a standard an entity\'s lineweight comes from a pen of the standard: give it pen, or lineweight "ByLayer" or "ByBlock"'; +const PEN_WITHOUT_STANDARD = + "pen names a pen of the drawing's standard, and this drawing has none. Draw to a standard with createDrawing({ standard }), or give the entity a lineweight"; +const PEN_AND_LINEWEIGHT = "An entity takes a pen or a lineweight, not both"; +const NOT_A_STANDARD = + "standard must be a Standard that defineStandard made, unchanged, and this one does not check"; + +export type StandardR12 = typeof STANDARD_R12; +export type StandardLinetypeScale = typeof STANDARD_LINETYPE_SCALE; +export type DisciplineWithoutStandard = typeof DISCIPLINE_WITHOUT_STANDARD; +export type LayerProperties = typeof LAYER_PROPERTIES; +export type LinetypeDefined = typeof LINETYPE_DEFINED; +export type PenWithoutStandard = typeof PEN_WITHOUT_STANDARD; + +/** The message for each rule of this file that has no name in it, for the builder. */ +export const DRAWN = { + r12: STANDARD_R12, + linetypeScale: STANDARD_LINETYPE_SCALE, + disciplineWithoutStandard: DISCIPLINE_WITHOUT_STANDARD, + disciplineOneDrawing: DISCIPLINE_ONE_DRAWING, + layerZero: LAYER_ZERO, + layerProperties: LAYER_PROPERTIES, + linetypeDefined: LINETYPE_DEFINED, + penWithoutStandard: PEN_WITHOUT_STANDARD, + penAndLineweight: PEN_AND_LINEWEIGHT, + notAStandard: NOT_A_STANDARD, +} as const; + +type LayerLacks< + N extends string, + Z extends Standard, +> = `Layer "${N}" is not a layer of ${Of}. Add it to the standard, as layers: { "${N}": { color: 7 } } in defineStandard, or use one of its layers`; +/** What is wrong with a layer the standard lacks. */ +export const layerLacks = (name: string, standard: Standard): string => + `Layer ${JSON.stringify(name)} is not a layer of ${of(standard)}. Add it to the standard, as layers: { ${JSON.stringify(name)}: { color: 7 } } in defineStandard, or use one of its layers`; + +/** + * Resolves to `N` when it is a layer of the standard `Z`, and to the error message otherwise: a + * name AutoCAD refuses, layer `0`, or a layer the standard lacks. + */ +export type LayerOfStandard = string extends N + ? N + : N extends keyof Z["layers"] + ? N + : [LayerName] extends [N] + ? N extends "0" + ? typeof LAYER_ZERO + : LayerLacks + : LayerName; + +/** What `layer()` sets on a layer of a standard: its states, which are the drawing's. */ +export interface StandardLayerOptions { + /** Hides the layer. */ + readonly off?: boolean; + /** Hides the layer and leaves it out of regeneration. */ + readonly frozen?: boolean; + /** Keeps the layer's entities from being edited in CAD. */ + readonly locked?: boolean; + readonly color?: LayerProperties; + readonly linetype?: LayerProperties; + readonly lineweight?: LayerProperties; + readonly plot?: LayerProperties; +} + +type LinetypeLacks< + L extends string, + Z extends Standard, +> = `Linetype "${L}" is not a linetype of ${Of}. Add it to the standard, as linetypes: { "${L}": { pattern: [0.5, -0.25] } } in defineStandard, or use one of its linetypes or Continuous`; +/** What is wrong with an entity's linetype that the standard lacks. */ +export const linetypeLacks = (name: string, standard: Standard): string => + `Linetype ${JSON.stringify(name)} is not a linetype of ${of(standard)}. Add it to the standard, as linetypes: { ${JSON.stringify(name)}: { pattern: [0.5, -0.25] } } in defineStandard, or use one of its linetypes or Continuous`; + +/** + * An entity's linetype under the standard `Z`, as the whole value given, `L`: a linetype of the + * standard, `Continuous`, `ByLayer` or `ByBlock`, by name. A definition resolves to the message, + * since under a standard a drawing defines no linetype. The last branch, never taken, lets `L` + * be inferred, as in `LinetypeChoice`. + */ +export type LinetypeOfStandard = [L] extends [string | object] + ? L extends string + ? string extends L + ? L + : Lowercase extends "continuous" | "bylayer" | "byblock" + ? L + : L extends keyof Z["linetypes"] + ? L + : [LinetypeName] extends [L] + ? LinetypeLacks + : LinetypeName + : LinetypeDefined + : L; + +type PenLacks< + P extends string, + Z extends Standard, +> = `Pen "${P}" is not a pen of ${Of}. Add it to the standard, as pens: { "${P}": { lineweight: 0.25 } } in defineStandard, or use one of its pens`; +/** What is wrong with a pen the standard lacks. */ +export const penLacks = (name: string, standard: Standard): string => + `Pen ${JSON.stringify(name)} is not a pen of ${of(standard)}. Add it to the standard, as pens: { ${JSON.stringify(name)}: { lineweight: 0.25 } } in defineStandard, or use one of its pens`; + +/** Resolves to `P` when it is a pen of the standard `Z`, and to the error message otherwise. */ +export type PenOfStandard

= string extends P + ? P + : P extends keyof Z["pens"] + ? P + : PenLacks; + +/** + * An entity's lineweight under a standard: `"ByLayer"` or `"ByBlock"`. A literal number + * resolves to the message, and a number that is not a literal is checked when the code runs. + */ +export type LineweightUnderStandard = number extends W + ? W | "ByLayer" | "ByBlock" + : typeof PEN_LINEWEIGHT; + +/** What is wrong with an entity's lineweight under a standard, with the standard's pens. */ +export function penLineweightProblem(standard: Standard): string { + const pens = Object.entries(standard.pens).map(([name, pen]) => `${name} ${pen.lineweight}`); + const list = + pens.length === 0 + ? "The standard has no pen" + : `The standard's pens are ${pens.length === 1 ? pens[0] : `${pens.slice(0, -1).join(", ")} and ${pens.at(-1)}`} mm`; + return `${PEN_LINEWEIGHT}. ${list}`; +} + +type DisciplineLacks< + D extends string, + Z extends Standard, +> = `Discipline "${D}" is not a discipline of ${Of}. Add it to the standard, as disciplines: { "${D}": { title: "${D}" } } in defineStandard, or use one of its disciplines`; +/** What is wrong with a drawing's discipline that the standard lacks. */ +export const disciplineLacks = (name: string, standard: Standard): string => + `Discipline ${JSON.stringify(name)} is not a discipline of ${of(standard)}. Add it to the standard, as disciplines: { ${JSON.stringify(name)}: { title: ${JSON.stringify(name)} } } in defineStandard, or use one of its disciplines`; + +/** Resolves to `D` when it is a discipline of the standard `Z`, and to the message otherwise. */ +export type DisciplineOfStandard = string extends D + ? D + : D extends keyof Z["disciplines"] + ? D + : DisciplineLacks; + +/** What is wrong with a layer that has fields by discipline, in a drawing that names none. */ +export function unnamedDisciplineProblem(name: string, layer: StandardLayer): string { + const disciplines = Object.keys(layer.byDiscipline ?? {}); + const listed = + disciplines.length === 1 + ? `the discipline ${disciplines[0]}` + : `the disciplines ${disciplines.slice(0, -1).join(", ")} and ${disciplines.at(-1)}`; + return `Layer ${JSON.stringify(name)} has fields of its own in ${listed} of the standard, so a drawing that uses it names its discipline, as createDrawing({ standard, discipline })`; +} diff --git a/src/standard/literal.ts b/src/standard/literal.ts index f41036a..902ef3d 100644 --- a/src/standard/literal.ts +++ b/src/standard/literal.ts @@ -67,12 +67,22 @@ type Named = E extends string type Own = S extends keyof D ? D[S] : {}; /** True when the names of a section are not known: `D` may leave it out, or its keys are `string`. */ type Unknown = undefined extends E ? true : string extends keyof E ? true : false; -/** The names of the entries that are there. An entry that may be `null` or left out may be gone. */ -type Live = { - [K in keyof E]-?: null extends E[K] ? never : {} extends Pick ? never : K; -}[keyof E] & +/** + * The names of the entries that are there. An entry that may be `null` or left out may be gone, + * and so may one that a member of a union of sections lacks. + */ +type Live = Exclude, Gone>> & string; +type Keys = E extends unknown ? keyof E : never; +type Gone = E extends unknown + ? + | Exclude + | { [K in keyof E]-?: null extends E[K] ? K : {} extends Pick ? K : never }[keyof E] + : never; +/** The names an entry may remove: each that is, or may be, `null` in any member of `E`. */ +type Removed = (E extends unknown + ? { [K in keyof E]-?: null extends E[K] ? K : never }[keyof E] + : never) & string; -type Removed = { [K in keyof E]-?: null extends E[K] ? K : never }[keyof E] & string; type Inherited = P extends { readonly [K in S]: infer E } ? keyof E & string : never; diff --git a/src/standard/made.ts b/src/standard/made.ts new file mode 100644 index 0000000..b40b4e6 --- /dev/null +++ b/src/standard/made.ts @@ -0,0 +1,23 @@ +// 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. +import type { Standard } from "./document.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; + +/** 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 { + 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. + */ +export function adopted(value: unknown): Standard | undefined { + if (typeof value === "object" && value !== null && MADE.has(value)) return value as Standard; + return readAgain?.(value); +} diff --git a/test/drawn.test.ts b/test/drawn.test.ts new file mode 100644 index 0000000..67d19e1 --- /dev/null +++ b/test/drawn.test.ts @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + BUILT_IN_STANDARD, + createDrawing, + DASHED, + defineStandard, + DrawingBuilder, + DrawingError, + toJSON, + type LintRule, +} from "../src/index.ts"; + +const standard = defineStandard({ + id: "acme", + version: "2026.1", + extends: BUILT_IN_STANDARD, + layers: { "V-TREE": { color: 3, pen: "fine", discipline: "V" } }, + pens: { hair: { lineweight: 0 } }, +}); + +/** Asserts that `draw` throws a `DrawingError` of `rule` whose message matches `message`. */ +function refused(draw: () => unknown, rule: LintRule, message: RegExp): void { + assert.throws(draw, (thrown) => { + assert.ok(thrown instanceof DrawingError); + assert.equal(thrown.diagnostics[0]?.rule, rule); + assert.match(thrown.message, message); + return true; + }); +} + +// Each `@ts-expect-error` proves the compiler refuses the literal, and the `refused` around it +// that the builder refuses the same mistake when the code runs. +test("under a standard the builder refuses what the standard does not give", () => { + const d = createDrawing({ units: "ft", standard }); + refused( + // @ts-expect-error: the standard has no layer A-WALL. + () => d.layer("A-WALL", () => {}), + "layer-outside-standard", + /^Layer "A-WALL" is not a layer of the standard "acme" version 2026.1. Add it to the standard, as layers: \{ "A-WALL": \{ color: 7 \} \} in defineStandard, or use one of its layers$/, + ); + // @ts-expect-error: layer 0 is in no standard. + refused(() => d.layer("0"), "layer-outside-standard", /^Layer "0" is in every drawing/); + // @ts-expect-error: AutoCAD refuses the name, which no standard can hold either. + refused(() => d.layer("A,B"), "layer-name", /cannot contain/); + refused( + // @ts-expect-error: the standard gives the layer's color. + () => d.layer("C-PROP", { color: 3 }), + "layer-differs-from-standard", + /^Layer "C-PROP": Under a standard a layer's color, linetype, lineweight and plot flag are the standard's/, + ); + // @ts-expect-error: the standard gives the layer's plot flag. + refused(() => d.layer("C-PROP", { plot: false }), "layer-differs-from-standard", /plot flag/); + refused( + // @ts-expect-error: the standard has no pen "hairline". + () => d.line([0, 0], [1, 0], { pen: "hairline" }), + "pen-outside-standard", + /^Pen "hairline" is not a pen of the standard "acme" version 2026.1/, + ); + refused( + // @ts-expect-error: a lineweight comes from a pen. + () => d.line([0, 0], [1, 0], { lineweight: 0.5 }), + "pen-outside-standard", + /comes from a pen of the standard.*The standard's pens are bold 0.5, fine 0.18, hair 0, heavy 0.7, light 0.25 and medium 0.35 mm$/, + ); + refused( + // @ts-expect-error: the standard has no linetype ZIGZAG. + () => d.line([0, 0], [1, 0], { linetype: "ZIGZAG" }), + "linetype-outside-standard", + /^Linetype "ZIGZAG" is not a linetype of the standard "acme" version 2026.1/, + ); + refused( + // @ts-expect-error: a drawing drawn to a standard defines no linetype. + () => d.line([0, 0], [1, 0], { linetype: DASHED }), + "linetype-outside-standard", + /defines none of its own/, + ); + // @ts-expect-error: a drawing drawn to a standard defines no linetype. + refused(() => d.linetype(DASHED), "linetype-outside-standard", /defines none of its own/); + refused( + // @ts-expect-error: the linetype scale is the standard's. + () => createDrawing({ standard, linetypeScale: 2 }), + "standard", + /^Under a standard the linetype scale is the standard's/, + ); + // @ts-expect-error: a drawing drawn to a standard is R2018. + refused(() => createDrawing({ standard, dxf: "R12" }), "standard", /is R2018/); + refused( + // @ts-expect-error: the standard has no discipline Q. + () => createDrawing({ standard, discipline: "Q" }), + "standard", + /^Discipline "Q" is not a discipline of the standard "acme" version 2026.1/, + ); + refused( + // @ts-expect-error: the constructor checks the discipline as createDrawing does. + () => new DrawingBuilder({ standard, discipline: "Q" }), + "standard", + /^Discipline "Q" is not a discipline/, + ); + // @ts-expect-error: a discipline is the standard's. + refused(() => createDrawing({ discipline: "C" }), "standard", /this drawing has none/); + refused( + // @ts-expect-error: a pen is the standard's. + () => createDrawing().line([0, 0], [1, 0], { pen: "heavy" }), + "pen-outside-standard", + /this drawing has none/, + ); + + // What only the code that runs can see. + refused( + () => d.line([0, 0], [1, 0], { pen: "heavy", lineweight: "ByLayer" }), + "pen-outside-standard", + /a pen or a lineweight, not both/, + ); + refused( + () => createDrawing({ standard, discipline: "C" }), + "standard", + /^Under sheetFiles "one-drawing" a drawing names no discipline/, + ); + refused( + () => createDrawing({ standard: { ...standard, linetypeScale: 2 } }), + "standard", + /must be a Standard that defineStandard made/, + ); +}); + +test("a layer of a standard has the standard's properties and the drawing's states", () => { + const d = createDrawing({ units: "ft", standard }); + // The compiler compares names exactly, and the builder finds one in any case, as AutoCAD does. + const [prop, dashed]: string[] = ["c-prop", "dashed"]; + d.layer(prop!, { locked: true }, () => d.line([0, 0], [10, 0])); + d.layer("V-TREE", () => { + d.circle([0, 0], 1, { pen: "heavy", linetype: dashed! }); + d.line([0, 0], [1, 0], { pen: "hair" }); + }); + d.layer("G-ANNO-NPLT", () => d.line([0, 0], [1, 0], { lineweight: "ByBlock" })); + const drawing = d.build(); + assert.equal(drawing.linetypeScale, 0.5); + assert.deepEqual(drawing.layers.slice(1), [ + { name: "C-PROP", color: 7, linetype: "PHANTOM", lineweight: 0.7, locked: true }, + { name: "V-TREE", color: 3, lineweight: 0.18 }, + { name: "G-ANNO-NPLT", color: 8, plot: false }, + ]); + // A linetype enters the drawing when a layer or an entity first names it. + assert.deepEqual( + drawing.linetypes?.map((l) => l.name), + ["PHANTOM", "DASHED"], + ); + assert.deepEqual(drawing.linetypes?.[1], { + name: "DASHED", + description: DASHED.description, + pattern: DASHED.pattern, + }); + assert.deepEqual(drawing.entities, [ + { kind: "line", layer: "C-PROP", from: [0, 0], to: [10, 0] }, + { + kind: "circle", + layer: "V-TREE", + center: [0, 0], + radius: 1, + linetype: "DASHED", + lineweight: 0.7, + }, + { kind: "line", layer: "V-TREE", from: [0, 0], to: [1, 0], lineweight: 0 }, + { kind: "line", layer: "G-ANNO-NPLT", from: [0, 0], to: [1, 0], lineweight: "ByBlock" }, + ]); + // A standard as data, from another copy of tsdraft, is taken when it checks. + const copy = createDrawing({ units: "ft", standard: JSON.parse(JSON.stringify(standard)) }); + copy.layer("C-PROP", { locked: true }, () => copy.line([0, 0], [10, 0])); + assert.deepEqual(copy.build().layers[1], drawing.layers[1]); +}); + +test("a layer with fields by discipline takes the drawing's discipline", () => { + const shared = defineStandard({ + id: "acme", + version: "1", + extends: BUILT_IN_STANDARD, + sheetFiles: "sheet-per-file", + layers: { TEXT: { color: 7, pen: "light", byDiscipline: { C: { color: 3, pen: "fine" } } } }, + }); + const layer = (discipline?: "C" | "V") => + createDrawing({ standard: shared, ...(discipline ? { discipline } : {}) }) + .layer("TEXT") + .build().layers[1]; + assert.deepEqual(layer("C"), { name: "TEXT", color: 3, lineweight: 0.18 }); + assert.deepEqual(layer("V"), { name: "TEXT", color: 7, lineweight: 0.25 }); + refused( + () => layer(), + "standard", + /^Layer "TEXT" has fields of its own in the discipline C of the standard, so a drawing that uses it names its discipline/, + ); +}); + +test("layer(name, draw) draws on the layer, with or without a standard", () => { + const plain = createDrawing(); + plain.layer("A", { color: 2 }); + plain.layer("A", () => plain.line([0, 0], [1, 0])); + const options = createDrawing(); + options.layer("A", { color: 2 }); + options.layer("A", {}, () => options.line([0, 0], [1, 0])); + assert.equal(toJSON(plain.build()), toJSON(options.build())); +}); diff --git a/test/standard.test.ts b/test/standard.test.ts index 33cf5b6..2449361 100644 --- a/test/standard.test.ts +++ b/test/standard.test.ts @@ -436,6 +436,31 @@ test("a standard's type names only entries it has, and names that are not litera assert.equal(left.layers.C.color, 1); // @ts-expect-error: A may be left out. void left.layers.A; + // A section that is one of two literals names only what each of them leaves in place. + const parent = defineStandard({ ...alone, layers: { A: { color: 1 }, Q: { color: 1 } } }); + const either = (removes: boolean) => + defineStandard({ + id: "c", + version: "1", + extends: parent, + layers: removes ? { A: null } : { B: { color: 1 } }, + }); + assert.deepEqual(Object.keys(either(true).layers), ["Q"]); + assert.equal(either(false).layers.Q.color, 1); + // @ts-expect-error: B is gone when the first literal is taken. + void either(false).layers.B; + const removal: { readonly A: null } = { A: null }; + const addition: { readonly B: { readonly color: 1 } } = { B: { color: 1 } }; + const one = (removes: boolean) => + defineStandard({ + id: "c", + version: "1", + extends: parent, + layers: removes ? removal : addition, + }); + assert.equal(one(false).layers.Q.color, 1); + // @ts-expect-error: the first section removes A. + void one(false).layers.A; // A value typed as a document has names of type string. const doc: StandardDocument = { ...base, layers: { A: { color: 1 }, "C-PROP": null } }; const typed = defineStandard(doc);