diff --git a/AGENTS.md b/AGENTS.md index ef567b1..99bd2b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,8 +85,9 @@ - `examples/site-plan.ts` is the sheet an agent copies, so it must read as a model: the site in model space at its true size, with the annotation that is read in the plan sized by the view it names, and everything that belongs to the paper inside `d.layout`, in paper units. - Its border and title block are one block whose fields are attributes. Never draw a sheet - in model space there. + Its layout is on its standard's sheet format and laid out by its zones, and its border and + title block are the standard's, placed by `sheet.titleBlock()`. Never draw a sheet in model + space there. - A view is drawn only where it can be seen. The clip of a view, the hidden rectangle and the cull all use one rectangle: the viewport, cut to the paper, cut to the picture `toSVG` is drawing (its `viewBox`), with every number as it will be written. If that rectangle has @@ -175,6 +176,18 @@ 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. +- Under a standard, a top-level conditional on the standard's type parameter `Z` whose branches + mention `Z`, such as `Z extends Standard ? A : B` as a parameter's type, makes + `DrawingBuilder<…, undefined>` not assignable to `DrawingBuilder<…, Standard | undefined>`, + which a helper typed with `ReturnType` needs. A conditional inside a + field's type, a conditional whose branches do not mention `Z`, and a conditional that is one + member of a union are fine. In a rest tuple, an element whose type is that conditional alone + makes `DrawingBuilder<…, S>` for a standard `S` not assignable to `DrawingBuilder<…, Standard>`, + and so do overloads; `layout()` keeps it one member of a union with `| undefined`. +- Under a standard, a title block's block, the blocks it inserts and their text styles enter the + drawing under their own names when `sheet.titleBlock()` first places it, and one the drawing + has with another definition is refused: never rename one, since its handles come from its + name. The builder fills a title block's scale when the layout's `draw` returns. - Under a standard, a role's text style and a dimension style of the standard enter the drawing 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 diff --git a/GUIDE.md b/GUIDE.md index a66f207..577560b 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -248,7 +248,7 @@ keeping `drafting.ts` in a `lib` folder beside it. Change two import strings to `"../src/index.ts"` in `site-plan.ts`, and `"../../src/index.ts"` in `lib/drafting.ts`. 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. +breaks are laid out with `measureText`. Its symbols are blocks. It is drawn to a standard of its own, which extends `BUILT_IN_STANDARD` with the layer `V-TREE`. `d.layer(name, draw)` names a layer of the standard, which gives its color, linetype, @@ -263,7 +263,16 @@ The plan is a view at one of the standard's scales, `const plan = d.view("plan", plan })` in the standard's dimension style. The viewport shows the view, as `sheet.viewport(corner, width, height, { view: plan, center })`, and `plan.paper(inches)` turns a gap on paper into drawing units. On the sheet and in a block, a text with a role is at -the role's height and names no view. `sitePlan("1in-20ft")` draws the sheet at 1" = 20'. +the role's height and names no view. Under a standard every text names a role: a `height`, a +`style` and `textStyle()` are refused. `sitePlan("1in-20ft")` draws the sheet at 1" = 20'. + +Its layout is on a sheet format of the standard, `d.layout("C-101", { sheetFormat: "arch-d" }, +(sheet) => …)`, which gives the paper, the title block and the zones the sheet is laid out by: +`sheet.zone("views")` is the box the viewport fills, as `{ left, bottom, right, top, width, +height }`. `sheet.titleBlock({ title: "SITE PLAN" })` places the border and the title block +and fills its fields: the project, the date and who drew and checked it come from +`createDrawing({ standard, fields: { project, date, drawnBy, checkedBy } })`, the number is the +layout's name and the scale is the viewport's. ## Checking intent diff --git a/docs/layouts.md b/docs/layouts.md index a139598..5846efc 100644 --- a/docs/layouts.md +++ b/docs/layouts.md @@ -41,6 +41,13 @@ puts entities on a layer as it does anywhere. belongs to the drawing. - `draw` is given the sheet: `sheet.name`, `sheet.paper`, `sheet.units`, `sheet.width` and `sheet.height`, so that what is drawn can be placed from the paper's size. +- Drawn to a standard, a layout is on a sheet format of the standard, which gives its paper and + its plot style table: `d.layout(name, { sheetFormat }, draw)`, or `d.layout(name, draw)` on the + standard's `defaultSheetFormat`. The sheet then has `sheet.drawingArea`, `sheet.zone(name)` and + `sheet.titleBlock()`, which [Title blocks](title-blocks.md#the-title-block-of-a-standard) + describes. A second layout whose name AutoCAD's `AUDIT` takes for another's, such as `STRAßE` + beside `STRASSE`, is refused there. AutoCAD keeps both layouts, and a drawing drawn to a + standard holds its layouts' names to `AUDIT`'s comparison. ### Paper diff --git a/docs/title-blocks.md b/docs/title-blocks.md index 4ff5f36..ee26b6c 100644 --- a/docs/title-blocks.md +++ b/docs/title-blocks.md @@ -80,8 +80,10 @@ toSVG(drawing, { layout: "C-101" }); // the sheet, with its fields filled - A tag names one field of the whole drawing where it can: a second block with a tag `TITLE` is told from the first only by its block's name. -[`examples/site-plan.ts`](../examples/site-plan.ts) has a fuller one: `titleBlock` there draws -a border and a title block with seven fields, a table for revisions and an area for a seal. +The built-in standard's title block `plain` is a fuller one: a border and a title block with +seven fields, a table for revisions and an area for a seal, which +[`examples/site-plan.ts`](../examples/site-plan.ts) places from its standard, as the next +section but one says. ### The edge of the paper @@ -185,6 +187,107 @@ The border has to be a DWG file, by a path from the folder of the sheet's file: value, and the drawing keeps it when it is saved. The next export from the code writes the code's values again. +## The title block of a standard + +A standard names its title blocks, and its sheet formats, each of which gives a sheet its paper, +its plot style table, its title block and its zones. Drawn to a standard, a layout is on a sheet +format, and `sheet.titleBlock()` places the title block and fills its fields: + +```ts +import { BUILT_IN_STANDARD, createDrawing } from "tsdraft"; + +const d = createDrawing({ + units: "ft", + standard: BUILT_IN_STANDARD, + fields: { project: "RESIDENCE AT LOT 12", date: "2026-11-13", drawnBy: "AB", checkedBy: "CD" }, +}); +const plan = d.view("plan", { scale: "1in-10ft" }); +d.layer("C-PROP", () => d.rect([0, 0], 100, 140)); + +d.layout("C-101", { sheetFormat: "arch-d" }, (sheet) => { + sheet.titleBlock({ title: "SITE PLAN" }); + const views = sheet.zone("views"); + d.layer("G-ANNO-NPLT", () => + sheet.viewport([views.left, views.bottom], views.width, views.height, { + view: plan, + center: [50, 70], + }), + ); +}); +``` + +- `layout(name, { sheetFormat }, draw)` names the sheet format, and `layout(name, draw)` is on + the standard's `defaultSheetFormat`. A layout of a standard takes no `paper` and no + `plotStyle`: the sheet format gives them. +- `sheet.drawingArea` is the part of the paper inside the border, and `sheet.zone(name)` a box of + it that the sheet format names, such as `views`, each as `{ left, bottom, right, top, width, + height }` in paper units. +- `sheet.titleBlock(fields)` places the title block once on its layout, on the title block's + layer. A title block that tsdraft draws is a block, inserted at `[0, 0]`. A firm's own is its + drawing as an xref, inserted at its point, and the block of the sheet's fields goes over it + when the title block has one. The block's insert has the id `.title-block` and + the xref's `.border`, when that is a valid id, and none otherwise. +- A field is filled from `fields` given to `titleBlock()`, then from `createDrawing`'s `fields`, + then by the builder: the number is the layout's name, as below, and the scale is the label of + the one scale the layout's viewports show, or `AS NOTED` when they show more than one. The + builder writes the scale when the layout's `draw` returns, so a title block placed before the + viewports gets it. A key of `fields` is what a field fills, such as `title` or `drawnBy`, or the + tag of a field that fills none of them. +- The title block's block, every block it inserts and every text style they use enter the + drawing under their own names, once however many layouts place it. A drawing that has a block + or a text style of one of those names with another definition is refused, and so is a later + `block()` of the name. The builder renames none: a block's handles come from its name, and a + renamed block would move the handles that a drawing referencing this one holds. When one of + them is refused none of them enters, and a refused `titleBlock()` places nothing on the layout. +- What the block draws is on layer `0`, which draws as the insert's layer does, or on layers of + the standard, with the standard's properties. + +A field's `source` says where its value comes from: an attribute of the block, which tsdraft +writes, or a field of the firm's drawing that AutoCAD fills in the sheet, from the sheet's file +name, its layout's name, or the date it opens or plots the sheet. tsdraft writes nothing for +the last three. + +### One drawing, or a file per sheet + +A standard's `sheetFiles` says how a firm keeps its sheets. + +**`"one-drawing"`**, the default, keeps a set's sheets as the layouts of one drawing. + +- A sheet's number is its layout's name, which is unique in the drawing, and the builder writes it + in the field that fills `number`. `createDrawing`'s `fields` give no number, which would be every + sheet's, and a number given to `titleBlock()` is the layout's name. +- No field reads the file's name or the layout's name: each field the sheet fills is an attribute, + which the builder writes over the firm's drawing. One drawing has one file name, which such a + field would show on every sheet. A field that reads `CTAB`, the layout's name, plots each + layout's own name, with `-PLOT` and with `-PUBLISH`, since AutoCAD works it out as it plots each + one. But under one drawing the layouts' names are the sheets' numbers, so such a field in the + title's place would plot the number there. + +**`"sheet-per-file"`** keeps each sheet as a drawing of its own, with one layout, which +references the firm's title block and the model drawings as xrefs. + +- A drawing has one layout, a sheet, or none, a model drawing. A second layout is refused. +- A sheet has a number, from `createDrawing`'s `fields` or from `titleBlock()`. It names the + sheet's file, so it has 1 to 64 characters, none of `< > : " / \ | ? *`, no control character + and nothing above U+FFFF, no space or dot at its end, and is not a name Windows keeps for a + device: `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9` or `LPT1` to `LPT9`, in any case and with + any extension. Windows names no file with such a character, drops a space or a dot from the end + of a name, and opens the device for a file of a device's name. +- Save a sheet as `.dwg`, or `.dxf`. AutoCAD fills a field that reads the file's + name from the name the file has, so a sheet saved under another name shows that name as its + number, and AutoCAD says nothing. +- A field that reads the layout's name shows the layout's name, and `titleBlock()` refuses + another value for it. A field of the date AutoCAD opens or plots the sheet takes no value. + +### A firm's title block as an xref + +A title block of the xref form names the firm's drawing by a path from each sheet's file, as +`d.xref` takes one. Its file holds to the rules of +[What the border's file may hold](#what-the-borders-file-may-hold). Its `preview`, a `Drawing` +that stands for the file, is what the pictures draw in its place: give it to `toSVG` as the +drawing of the xref, `toSVG(drawing, { layout, xrefs: { BORDER: titleBlock.preview } })`, and to +the command in the drawing file's `xrefs` export. + ## The preview `toSVG(drawing, { layout })` draws an insert on a sheet with its attributes, each with the diff --git a/examples/README.md b/examples/README.md index a9db308..3e99614 100644 --- a/examples/README.md +++ b/examples/README.md @@ -67,24 +67,25 @@ AutoCAD sizes dashes on the paper in a viewport and on the sheet alike. The street and the adjoining lot lines are part of the site: they run to the sides of what the viewport shows, where it cuts them off. -The sheet is laid out from its parameters, in inches: the border half an inch -inside the paper, the viewport in the left 27 inches of the drawing area above -a strip for the view's title, a column 7 inches wide for the notes, and the -title block at its foot, 6 1/2 by 3 inches in the lower right corner. The -paper is full bleed, and the border is still inside it: AutoCAD plots a line -on the left, bottom or right edge of the paper with half its width off the -page, and does not plot a line on the top edge. - -The border and the title block are one block, `TITLE BLOCK`, which the -function `titleBlock` draws for the sheet's size. Its fields are attributes: -the project, the sheet's title and number, the date, the scale, and who drew -and checked it. It also has a table for revisions and an area for a seal. The -insert on the layout gives each field its value, and in CAD each stays an -attribute. `titleBlock` reads its arguments and the constant `TITLE_BLOCK` -beside it, so the two can be copied by themselves. The view title's -attributes have tags of their own, `VIEW_TITLE` and `VIEW_SCALE`, so no tag -names two fields. [Title blocks](../docs/title-blocks.md) -has more, and the way to use a firm's own border. +The layout is on the standard's sheet format `arch-d`, which gives its paper, +its title block and the zones it is laid out by, in inches: the border half an +inch inside the paper, the zone `views` for the viewport in the left 27 inches +of the drawing area above the zone `viewTitles` for the view's title, and the +zone `notes`, a column 7 inches wide, above the title block, 6 1/2 by 3 inches +in the lower right corner. The paper is full bleed, and the border is still +inside it: AutoCAD plots a line on the left, bottom or right edge of the paper +with half its width off the page, and does not plot a line on the top edge. + +The border and the title block are the standard's, one block, `TITLE BLOCK`, +which `sheet.titleBlock()` places. Its fields are attributes: the project, the +sheet's title and number, the date, the scale, and who drew and checked it. It +also has a table for revisions and an area for a seal. The title comes from the +call, the project, the date and who drew and checked it from `createDrawing`'s +`fields`, the number from the layout's name and the scale from the viewport, +and in CAD each stays an attribute. The view title's attributes have tags of +their own, `VIEW_TITLE` and `VIEW_SCALE`, so no tag names two fields. +[Title blocks](../docs/title-blocks.md) has more, and the way to use a firm's +own border. The trees, the property corners, the north arrow and the view title are blocks too. The tree and the property corner are in the plan and in the legend, so @@ -93,10 +94,10 @@ radius in the plan and at 3/16 inch in the legend. 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. Each text -names a role of the standard, which gives its text style and the height it -plots at; the title block's texts are in a text style of its own, `NOTES`. The +linetype, pen and plot flag are the standard's. The standard's linetype scale +is a length of paper, 1/2. Each text names a role of the standard, which gives +its text style and the height it plots at; the title block's texts are in a +text style of its own, `TITLE BLOCK`. The view `plan` is at a scale of the standard, `1in-10ft`. Draw it at `1in-20ft` 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 diff --git a/examples/lib/drafting.ts b/examples/lib/drafting.ts index ce45ce8..77269a8 100644 --- a/examples/lib/drafting.ts +++ b/examples/lib/drafting.ts @@ -185,8 +185,11 @@ export function leader( }); } -/** A slim closed arrowhead with its tip at `tip`, pointing away from `from`. */ -export function arrowhead(d: Pen, tip: Point, from: Point, length: number): void { +/** + * A slim closed arrowhead with its tip at `tip`, pointing away from `from`. It draws no text, so + * a drawing whose texts name roles of a standard can be given. + */ +export function arrowhead(d: Pick, tip: Point, from: Point, length: number): void { const dx = tip[0] - from[0]; const dy = tip[1] - from[1]; const size = Math.hypot(dx, dy); diff --git a/examples/site-plan.ts b/examples/site-plan.ts index 94e6aca..778f37e 100644 --- a/examples/site-plan.ts +++ b/examples/site-plan.ts @@ -56,10 +56,13 @@ // - A symbol is a block, drawn once and inserted where it is needed. 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. A block's entities are on layer 0, so each insert draws on its own layer. -// - The border and the title block are one block, `TITLE BLOCK`, drawn by `titleBlock`. Its -// fields are attributes, and the insert on the layout gives each its value. In CAD each -// stays an attribute. Its texts are in a text style of its own, `NOTES`, at the heights in -// `TITLE_BLOCK`. `titleBlock` and `TITLE_BLOCK` can be copied by themselves. +// - The layout is on the standard's sheet format `arch-d`, which gives its paper, its title +// block and the zones it is laid out by: the views, the strip under them for the view titles, +// and the column of notes above the title block. `sheet.titleBlock()` places the border and +// the title block, one block whose fields are attributes, and fills them: the title from the +// call, the project, the date and who drew and checked it from `createDrawing`'s `fields`, the +// number from the layout's name and the scale from the viewport. In CAD each field stays an +// attribute. import { mkdirSync, writeFileSync } from "node:fs"; import { BUILT_IN_STANDARD, @@ -67,11 +70,9 @@ import { createDrawing, defineStandard, measureText, - PAPERS, toDXF, toSVG, type Drawing, - type DrawingBuilder, type Intent, type Point, } from "../src/index.ts"; @@ -88,33 +89,25 @@ export const standard = defineStandard({ 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>; - /** A text role of the standard, which gives a text its text style and the height it plots at. */ type Role = keyof typeof standard.textRoles; const roles = standard.textRoles; /** How long a text plots in a role, in inches. */ const widthOf = (value: string, role: Role) => measureText({ value, ...roles[role] }).length; -// 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. -const sheet = { number: "C-101", title: "SITE PLAN", paper: "ARCH D", margin: 1 / 2 } as const; +// The sheet: its number, which is its layout's name, its title and its sheet format. 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, and nothing is drawn outside +// it. +const sheet = { number: "C-101", title: "SITE PLAN", format: "arch-d" } as const; const project = { name: "RESIDENCE AT LOT 12, BLOCK 3", date: "2026-11-11", drawnBy: "AB", checkedBy: "CD", }; -// The layout of the sheet: the width of the column of notes at the right of the drawing area, -// the space between it and the viewport, the height of the strip under the viewport that -// holds the view title, and how far text and symbols keep from the border. The title block is -// at the foot of the column. -const layout = { column: 7, gutter: 1, titleStrip: 1 + 1 / 2, clear: 1 / 4 }; -// The title block's text style and its font, which its lengths are measured in. -const NOTES = "NOTES"; -const FONT = "Arial"; +// How far text and symbols keep from the edge of their zone. +const clearOfEdge = 1 / 4; // The site, in feet. The lot's front left corner is the origin. const lot = { name: "LOT 12, BLOCK 3", width: 100, depth: 140 }; @@ -190,159 +183,6 @@ export interface Sheet { readonly view: Box; } -/** A sheet's title block as a block: its name, and where its parts lie on the sheet. */ -interface TitleBlock { - readonly name: string; - readonly drawingArea: Box; - readonly box: Box; - readonly fields: Readonly>; -} - -/** - * The title block: the name of its block, its size on the sheet and the heights of its texts, - * in inches. `titleBlock` reads this and its arguments, and nothing else of this file. - */ -const TITLE_BLOCK = { - name: "TITLE BLOCK", - width: 6.5, - height: 3, - text: { label: 1 / 16, field: 1 / 8, title: 3 / 16, number: 5 / 16 }, -} as const; - -/** - * Defines the title block's block for a sheet `width` by `height`, in inches: the border, - * `margin` inside the paper, and a title block in its lower right corner, with an attribute - * for each field, a table for revisions and an area for a seal. Its texts are in the text - * style `style`, which the drawing has. The block's base point is the sheet's lower-left - * 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 are drawn with the standard's heavy pen. - */ -function titleBlock( - d: Builder, - { - width, - height, - margin, - style, - }: { width: number; height: number; margin: number; style: string }, -): TitleBlock { - const { name, text } = TITLE_BLOCK; - const area: Box = { left: margin, bottom: margin, right: width - margin, top: height - margin }; - const box: Box = { - left: area.right - TITLE_BLOCK.width, - bottom: area.bottom, - right: area.right, - top: area.bottom + TITLE_BLOCK.height, - }; - // The seal and the revisions take the left 2 in. The fields are in rows at their right: the - // project, the sheet's title, then four small fields beside the sheet number. - const split = box.left + 2; - const number = box.right - 1.75; - const rows = [box.top - 0.6, box.top - 1.4, box.top - 2.2]; - const middle = (split + number) / 2; - const cell = (left: number, bottom: number, right: number, top: number): Box => ({ - left, - bottom, - right, - top, - }); - const seal = cell(box.left, box.top - 2, split, box.top); - const revisions = cell(box.left, box.bottom, split, seal.bottom); - const fields = { - PROJECT: cell(split, rows[0]!, box.right, box.top), - TITLE: cell(split, rows[1]!, box.right, rows[0]!), - DATE: cell(split, rows[2]!, middle, rows[1]!), - SCALE: cell(middle, rows[2]!, number, rows[1]!), - DRAWN: cell(split, box.bottom, middle, rows[2]!), - CHECKED: cell(middle, box.bottom, number, rows[2]!), - SHEET: cell(number, box.bottom, box.right, rows[1]!), - }; - const pad = 1 / 16; - /** The small fixed text that names a cell, in its upper left corner. */ - const label = (within: Box, value: string): void => { - d.text([within.left + pad, within.top - pad], value, { - height: text.label, - style, - valign: "top", - }); - }; - /** A field: its label, and its attribute at the foot of its cell. */ - const field = (tag: keyof typeof fields, prompt: string, textHeight: number): void => { - const within = fields[tag]; - label(within, prompt.toUpperCase()); - d.attribute(tag, [within.left + pad, within.bottom + pad * 1.5], { - value: "-", - prompt, - height: textHeight, - style, - }); - }; - - d.block(name, {}, () => { - d.rect([area.left, area.bottom], area.right - area.left, area.top - area.bottom, { - pen: "heavy", - }); - // The title block's left and top edges. The border is its other two. - d.polyline( - [ - [box.left, box.bottom], - [box.left, box.top], - [box.right, box.top], - ], - false, - { pen: "heavy" }, - ); - d.line([split, box.bottom], [split, box.top]); - d.line([box.left, seal.bottom], [split, seal.bottom]); - for (const y of rows) d.line([split, y], [y === rows[2] ? number : box.right, y]); - d.line([middle, box.bottom], [middle, rows[1]!]); - d.line([number, box.bottom], [number, rows[1]!]); - - label(seal, "SEAL"); - // The revisions: a heading, a row that names the columns, and three rows to fill. - label(revisions, "REVISIONS"); - const rowHeight = (revisions.top - revisions.bottom) / 5; - const columns = [ - { left: revisions.left, name: "NO." }, - { left: revisions.left + 0.3, name: "DATE" }, - { left: revisions.left + 0.9, name: "DESCRIPTION" }, - ]; - for (let row = 1; row < 5; row++) { - const y = revisions.top - rowHeight * row; - d.line([revisions.left, y], [revisions.right, y]); - } - for (const column of columns) { - const top = revisions.top - rowHeight; - if (column.left > revisions.left) d.line([column.left, revisions.bottom], [column.left, top]); - label({ ...revisions, left: column.left, top }, column.name); - } - - field("PROJECT", "Project", text.field); - field("TITLE", "Sheet title", text.title); - field("DATE", "Date", text.field); - field("SCALE", "Scale", text.field); - field("DRAWN", "Drawn by", text.field); - field("CHECKED", "Checked by", text.field); - // The sheet number is the largest text of the sheet, centered in its cell. - label(fields.SHEET, "SHEET"); - d.attribute( - "SHEET", - [(number + box.right) / 2, (fields.SHEET.bottom + fields.SHEET.top) / 2 - pad], - { - value: "-", - prompt: "Sheet number", - height: text.number, - style, - align: "center", - valign: "middle", - }, - ); - }); - return { name, drawingArea: area, box, fields }; -} - /** * Builds the sheet with its plan at a scale of the standard: `"1in-10ft"` is 1" = 10', and * `"1in-20ft"` is 1" = 20'. @@ -352,28 +192,32 @@ export function sitePlan(scale: keyof typeof standard.scales = "1in-10ft"): Shee // 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 scale. - const d = createDrawing({ units: "ft", standard }); - d.textStyle({ name: NOTES, font: FONT }); + // The fields every sheet of the drawing shares in its title block. + const d = createDrawing({ + units: "ft", + standard, + fields: { + project: project.name, + date: project.date, + drawnBy: project.drawnBy, + checkedBy: project.checkedBy, + }, + }); // The view of the plan, at the scale. `plan.paper` turns inches on paper into feet at its // ratio: a foot is 12 inches, so an inch on paper is `ratio / 12` feet. const plan = d.view("plan", { scale }); const feetPerInch = plan.ratio / 12; - // The paper, and the viewport on it: the left part of the drawing area, above the strip for - // the view title. - const page = PAPERS[sheet.paper]; - const area: Box = { - left: sheet.margin, - bottom: sheet.margin, - right: page.width - sheet.margin, - top: page.height - sheet.margin, - }; - const viewport: Box = { - left: area.left, - bottom: area.bottom + layout.titleStrip, - right: area.right - layout.column - layout.gutter, - top: area.top, - }; + // The viewport fills the sheet format's zone `views`. Model space is drawn before the sheet, + // so it reads the zone's size from the standard: the plan's lines run to the sides of the view. + const format = standard.sheetFormats[sheet.format]; + const box = ([left, bottom, right, top]: readonly number[]): Box => ({ + left: left!, + bottom: bottom!, + right: right!, + top: top!, + }); + const viewport = box(format.zones.views!); // The viewport is centered on the plan: the lot and the street's right-of-way. `view` is the // part of model space it shows, and the lot stays at the origin at every scale. const planCenter: Point = [lot.width / 2, (lot.depth - street.rightOfWay) / 2]; @@ -422,12 +266,6 @@ export function sitePlan(scale: keyof typeof standard.scales = "1in-10ft"): Shee valign: "top", }); }); - const block = titleBlock(d, { - width: page.width, - height: page.height, - margin: sheet.margin, - style: NOTES, - }); // Model space: the site, at its true size in feet. d.layer("C-PROP", () => d.rect([0, 0], lot.width, lot.depth, { id: "lot" })); @@ -634,36 +472,25 @@ export function sitePlan(scale: keyof typeof standard.scales = "1in-10ft"): Shee // The sheet: everything drawn inside `d.layout` is on the paper, in inches, and a text there // plots at its role's height. - 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.insert(block.name, [0, 0], { - id: "title-block", - attributes: { - PROJECT: project.name, - TITLE: sheet.title, - DATE: project.date, - SCALE: plan.label, - DRAWN: project.drawnBy, - CHECKED: project.checkedBy, - SHEET: paperSheet.name, - }, - }); - }); + d.layout(sheet.number, { sheetFormat: sheet.format }, (paperSheet) => { + // The border and the title block, with the sheet's title. The builder fills the rest. + paperSheet.titleBlock({ title: sheet.title }); // The plan, in its view. A viewport's border is on a layer that does not plot. + const views = paperSheet.zone("views"); d.layer("G-ANNO-NPLT", () => { - const [width, height] = [viewport.right - viewport.left, viewport.top - viewport.bottom]; - paperSheet.viewport([viewport.left, viewport.bottom], width, height, { + paperSheet.viewport([views.left, views.bottom], views.width, views.height, { view: plan, center: planCenter, }); }); - // The view title, under the viewport: the north arrow, the title and the graphic scale in - // 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; + // The view title, in the strip under the viewport: the north arrow, the title and the + // graphic scale in a row. The underline of the title is level with the middle of the north + // arrow. + const strip = paperSheet.zone("viewTitles"); + const underline = strip.bottom + clearOfEdge + arrowRadius; + const titleX = strip.left + clearOfEdge + arrowRadius * 2 + 3 / 8; d.layer("C-ANNO-TITL", () => { d.insert("VIEW TITLE", [titleX, underline], { attributes: { VIEW_TITLE: sheet.title, VIEW_SCALE: `SCALE: ${plan.label}` }, @@ -671,7 +498,7 @@ export function sitePlan(scale: keyof typeof standard.scales = "1in-10ft"): Shee }); 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]); + d.insert("NORTH ARROW", [strip.left + clearOfEdge + arrowRadius, underline]); // A graphic scale 2 in long, divided at 1/2 in and 1 in, and numbered in feet of the // plan at the view's scale. @@ -690,12 +517,13 @@ export function sitePlan(scale: keyof typeof standard.scales = "1in-10ft"): Shee d.text([bar.x + bar.length + 1 / 8, y], "FEET", { role: "note" }); }); - // The column of notes: site data, general notes, legend and abbreviations, from the top of - // the drawing area down. `row` is the baseline of the next line. - const column = { left: area.right - layout.column, width: layout.column - layout.clear }; + // The column of notes, in its zone above the title block: site data, general notes, legend + // and abbreviations, from the top down. `row` is the baseline of the next line. + const notes = paperSheet.zone("notes"); + const column = { left: notes.left, width: notes.width - clearOfEdge }; const leading = roles.label.height * 1.8; // from one line to the next const blockGap = 3 / 4; // between two blocks of the column - let row = area.top - layout.clear; + let row = notes.top - clearOfEdge; function columnHeading(title: string): void { row -= roles.heading.height; @@ -843,12 +671,16 @@ export function sitePlan(scale: keyof typeof standard.scales = "1in-10ft"): Shee }); }); + // Where the title block's parts lie on the sheet, from the standard's title block. + const titleBlock = standard.titleBlocks[format.titleBlock as keyof typeof standard.titleBlocks]; return { drawing: d.build(), layout: sheet.number, - drawingArea: block.drawingArea, - titleBlock: block.box, - fields: block.fields, + drawingArea: box(titleBlock.drawingArea), + titleBlock: box(titleBlock.box!), + fields: Object.fromEntries( + Object.entries(titleBlock.fields).map(([tag, field]) => [tag, box(field.box!)]), + ), viewport, view, }; diff --git a/src/drawing.ts b/src/drawing.ts index b066edd..b3f7180 100644 --- a/src/drawing.ts +++ b/src/drawing.ts @@ -28,8 +28,15 @@ import { validIdHandle } from "./handles.ts"; import { layerAuditKey, layerKey, layoutNameKey } from "./layers.ts"; import { validLayoutHandles } from "./layouts.ts"; import { fromPaper, type PaperUnit } from "./paper.ts"; -import { paperInfo, type Paper, type PaperInfo, type PlotStyleTable } from "./papers.ts"; +import { + DEFAULT_PLOT_STYLE_TABLE, + paperInfo, + type Paper, + type PaperInfo, + type PlotStyleTable, +} from "./papers.ts"; import { BUILT_IN_LINETYPES, builtInLinetype } from "./linetypes.ts"; +import { canonical } from "./json.ts"; import { DrawingError, type Diagnostic, type LintRule } from "./lint.ts"; import { arcProblem, @@ -70,6 +77,7 @@ import { plotStyleProblem, R12_ONLY, scaleProblem, + sheetNumberProblem, sizeProblem, textAlignProblem, textProblem, @@ -139,29 +147,40 @@ import type { StandardLayer, StandardLinetype, StandardScale, + StandardSheetFormat, StandardTextRole, + StandardTitleBlock, } from "./standard/document.ts"; import { dimensionStyleLacks, dimensionStyleReserved, disciplineLacks, DRAWN, + fieldLacks, + filledByAutoCAD, layerLacks, + layoutAuditTwin, + layoutNameField, layoutPaperUnitsProblem, linetypeLacks, noDefaultDimensionStyle, + noDefaultSheetFormat, + numberNotLayoutName, penLacks, penLineweightProblem, roleBlockWithoutView, roleLacks, - roleTextStyleProblem, scaleLacks, + sheetFormatLacks, + sheetNumberOfFile, unnamedDisciplineProblem, viewNotDeclared, writingLacks, + zoneLacks, type DimensionStyleDefined, type DimensionStyleOfStandard, type DisciplineWithoutStandard, + type FieldsWithoutStandard, type InsertViewAndScale, type LinetypeDefined, type PenWithoutStandard, @@ -173,18 +192,24 @@ import { type RoleWithoutStandard, type ScaleOfStandard, type ScaleWithoutStandard, + type SheetFormatWithoutStandard, type StandardLayerOptions, + type StandardLayoutOptions, type LinetypeOfStandard, type StandardLinetypeScale, type LineweightUnderStandard, type PenOfStandard, type StandardR12, + type TextStyleDefined, + type TextWithoutRole, + type TitleBlockWithoutStandard, type ViewportId, type ViewportRatio, type ViewWithoutStandard, + type ZoneWithoutStandard, } from "./standard/drawn.ts"; import { adopted } from "./standard/made.ts"; -import { capitals } from "./standard/rules.ts"; +import { capitals, FILL_KEYS, READS_A_NAME } from "./standard/rules.ts"; /** A model-space point, in drawing units. Y points up, as in CAD. */ export type Point = readonly [x: number, y: number]; @@ -627,6 +652,13 @@ export interface DrawingOptions< * discipline's fields. */ readonly discipline?: Z extends Standard ? DisciplineOfStandard : DisciplineWithoutStandard; + /** + * The values the drawing's sheets share in their title blocks, by what a field fills, such as + * `project`, and by its tag for a field that fills none of them. `titleBlock()` gives a sheet's + * own, which take their place. Under `sheetFiles` `"one-drawing"` a sheet's number is its + * layout's name, and `fields` gives none. + */ + readonly fields?: Z extends Standard ? TitleBlockFields : FieldsWithoutStandard; /** * 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 @@ -870,27 +902,24 @@ interface TextPlacement { /** * The role, height and style of a text or an attribute, where `Height` and `Style` are what it - * takes without a role. Under a standard a literal role gives the height and the style, and the - * options are one of two whole shapes, with the role or with none, so each branch of a - * conditional is checked as written. A role that is not a literal is checked when the code runs. - * One conditional on `Z` around both shapes would make a builder without a standard not - * assignable to one whose standard may be any. + * takes without a standard. Under a standard a text names a role, which gives its height and its + * style, so a literal height or style is a compile error with the builder's message: with a + * literal role it names the role, and without one it says to give one. Each conditional is inside + * the field it rules, so each branch of a conditional is checked as written. */ -type Sized = - | Size< - Z extends Standard ? RoleOfStandard : RoleWithoutStandard, - RoleOr, - RoleOr - > - | (Z extends Standard ? (string extends R ? never : Size) : never); +type Sized = Size< + Z extends Standard ? RoleOfStandard : RoleWithoutStandard, + RoleOr, + RoleOr +>; /** - * `Given` for a text with no role, or a role that is not a literal, and `Message` for a text with - * a literal role under a standard, which takes no height and no style of its own. + * `Given` without a standard. Under one, `Message` for a text with a literal role, which takes no + * height and no style of its own, and the message that says to give a role for a text with none. */ type RoleOr = Z extends Standard ? string extends R - ? Given + ? TextWithoutRole : Message : Given; @@ -1122,6 +1151,7 @@ export interface LayoutOptions

{ readonly paper: PaperOf

; /** Defaults to `"monochrome.ctb"`, which plots every color in black. */ readonly plotStyle?: PlotStyleTable; + readonly sheetFormat?: SheetFormatWithoutStandard; } /** The view of a viewport: what `viewport()` takes after the rectangle. */ @@ -1190,6 +1220,26 @@ export interface Sheet { /** The paper's size, in its units. The sheet's lower-left corner is `[0, 0]`. */ readonly width: number; readonly height: number; + /** Under a standard, the part of the paper inside the border of the sheet format's title block. */ + readonly drawingArea: Z extends Standard ? Zone : undefined; + /** + * A zone of the layout's sheet format, a box of its drawing area that the sheet is laid out by, + * such as `views`. Under a standard alone. + */ + zone(name: Z extends Standard ? string : ZoneWithoutStandard): Zone; + /** + * Places the layout's title block: the firm's drawing as an xref at its point, when the title + * block has one, and the block of its fields at `[0, 0]` over it, on the title block's layer. A + * field is filled from `fields`, then from `createDrawing`'s `fields`, then by the builder: + * under `sheetFiles` `"one-drawing"` the number is the layout's name, and the scale is the label + * of the one scale the layout's viewports show, or `AS NOTED` when they show more, which the + * builder writes when the layout's `draw` returns. Under a standard alone, once on a layout. + */ + titleBlock( + ...fields: Z extends Standard + ? [fields?: TitleBlockFields] + : [fields: TitleBlockWithoutStandard] + ): this; /** * Adds a viewport: a rectangle from its lower-left `corner`, in paper units, that shows * model space about `view.center` at `view.ratio`. Its border is on the current layer, and @@ -1204,6 +1254,35 @@ export interface Sheet { ): this; } +/** A box of the paper, in paper units: a zone of a sheet format, or its drawing area. */ +export interface Zone { + readonly left: number; + readonly bottom: number; + readonly right: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +/** + * The values a title block's fields are filled with, by what a field fills, and by its tag for a + * field that fills none of these. A field whose source is `attribute` is written as the value of + * its attribute. + */ +export interface TitleBlockFields { + /** The sheet's number. Under `sheetFiles` `"one-drawing"` it is the layout's name. */ + readonly number?: string; + readonly title?: string; + readonly project?: string; + readonly date?: string; + /** Defaults to the label of the one scale the layout's viewports show, or `AS NOTED`. */ + readonly scale?: string; + readonly drawnBy?: string; + readonly checkedBy?: string; + /** A field that fills none of the above, by its tag. */ + readonly [tag: string]: string | undefined; +} + /** The linetype and lineweight an entity is stored with: only what differs from ByLayer. */ type EntityStyle = Pick; @@ -1241,6 +1320,21 @@ interface OpenLayout { readonly ids: string[]; /** The views its viewports show. */ readonly views: Shown[]; + /** Under a standard, its sheet format, by its id as the standard spells it. */ + readonly format: readonly [id: string, format: StandardSheetFormat] | undefined; + /** + * The title block once `titleBlock()` has placed it: the index of the insert of its fields, the + * tags of its attributes in order, those the builder fills with the scale when `draw` returns, + * and the sheet's number under `sheet-per-file`. + */ + titleBlock: + | { + readonly index: number | undefined; + readonly tags: readonly string[]; + readonly scaleTags: readonly string[]; + readonly number: string | undefined; + } + | undefined; /** True once `draw` has returned or thrown. */ closed: boolean; } @@ -1446,24 +1540,25 @@ export class DrawingBuilder< /** * Defines a text style, or updates the one of the same name. `notes` and `NOTES` are the * same style, and keep the first spelling. `Standard` is in every drawing, with the font - * `txt.shx`. Under a standard, a role's text style is the standard's, and `textStyle()` defines - * none of that name. + * `txt.shx`. A drawing drawn to a standard defines none: its texts' styles are its roles'. */ textStyle< N extends string, F extends string, W extends number = number, O extends number = number, - >(style: V extends "R12" ? R12Text : TextStyleDefinition): this { + >( + style: Z extends Standard + ? TextStyleDefined + : V extends "R12" + ? R12Text + : TextStyleDefinition, + ): this { const definition = style as unknown as TextStyleDefinition; const name: unknown = definition?.name; try { if (this.dxf === "R12") refuse("text-style", R12_ONLY.text); - const role = - typeof name === "string" ? this.#standard?.roleStyles.get(layerKey(name)) : undefined; - if (role !== undefined) { - refuse("text-style", roleTextStyleProblem(name as string, role, this.#standard!.standard)); - } + if (this.#standard) refuse("text-style-outside-standard", DRAWN.textStyleDefined); this.#defineTextStyle(definition); } catch (thrown) { const about = typeof name === "string" ? { textStyle: name } : {}; @@ -1918,34 +2013,43 @@ export class DrawingBuilder< // The parameter type is the error message when a literal is wrong, so read it as a string. const requested = name as string; try { - const fail = (rule: LintRule, problem: string): never => - refuse(rule, `Xref ${JSON.stringify(requested)}: ${problem}`); - if (this.dxf === "R12") fail("xref", R12_ONLY.xrefs); - const nameProblem = blockNameProblem(requested); - if (nameProblem) fail("xref", nameProblem); - const first = this.#blocksAudited.get(layerAuditKey(requested)); - if (first !== undefined) { - fail( - "duplicate-block", - xrefTwinProblem(requested, first.name, first.xref ? "xref" : "block"), - ); - } - const pathProblem = xrefPathProblem(path); - if (pathProblem) fail("xref", pathProblem); - const handles = blockHandles(requested); - const what = `the xref ${JSON.stringify(requested)}`; - // An xref belongs to the drawing, also when it is referenced while a block is drawn. - for (const handle of [handles.record, handles.begin, handles.end]) { - this.#claim(handle, what, undefined, []); - } - this.#xrefs.set(layerKey(requested), { name: requested, path: path as string }); - this.#blocksAudited.set(layerAuditKey(requested), { name: requested, xref: true }); + this.#defineXref(requested, path); } catch (thrown) { throw located(thrown, DrawingBuilder.prototype.xref, { xref: requested }); } return this; } + /** + * Checks an xref and stores it. With `shared`, an xref the drawing has of the same name and + * path is the one it keeps, as a title block's border on each layout is. + */ + #defineXref(requested: string, path: unknown, shared = false): void { + const fail = (rule: LintRule, problem: string): never => + refuse(rule, `Xref ${JSON.stringify(requested)}: ${problem}`); + if (this.dxf === "R12") fail("xref", R12_ONLY.xrefs); + const nameProblem = blockNameProblem(requested); + if (nameProblem) fail("xref", nameProblem); + if (shared && this.#xrefs.get(layerKey(requested))?.path === path) return; + const first = this.#blocksAudited.get(layerAuditKey(requested)); + if (first !== undefined) { + fail( + "duplicate-block", + xrefTwinProblem(requested, first.name, first.xref ? "xref" : "block"), + ); + } + const pathProblem = xrefPathProblem(path); + if (pathProblem) fail("xref", pathProblem); + const handles = blockHandles(requested); + const what = `the xref ${JSON.stringify(requested)}`; + // An xref belongs to the drawing, also when it is referenced while a block or a layout is drawn. + for (const handle of [handles.record, handles.begin, handles.end]) { + this.#claim(handle, what, undefined, []); + } + this.#xrefs.set(layerKey(requested), { name: requested, path: path as string }); + this.#blocksAudited.set(layerAuditKey(requested), { name: requested, xref: true }); + } + /** * Adds an attribute definition to the block being drawn: a text that each insert of the * block carries with a value of its own, under `tag`. It is placed and justified as `text()` @@ -2021,100 +2125,115 @@ export class DrawingBuilder< at: Point, options: InsertOptions = {}, ): this { - return this.#add(DrawingBuilder.prototype.insert, options, () => { - if (this.dxf === "R12") refuse("block", R12_ONLY.blocks); - const requested: unknown = block; - if (typeof requested !== "string") - return refuse("undefined-block", "A block is named by a string"); - const nameProblem = blockNameProblem(requested); - if (nameProblem) - refuse("undefined-block", `Block ${JSON.stringify(requested)}: ${nameProblem}`); - if (this.#open && layerAuditKey(this.#open.name) === layerAuditKey(requested)) { - refuse("block-cycle", blockCycleProblem([this.#open.name, this.#open.name])); - } - const xref = this.#xrefs.get(layerKey(requested)); - const placed = this.#blocks.get(layerKey(requested)); - if (!placed && !xref) { - refuse( - "undefined-block", - `${undefinedBlockProblem(requested)}. Define it with block() or xref() first`, - ); - } - const insert: { -readonly [K in keyof Insert]: Insert[K] } = { - kind: "insert", - layer: this.#current, - block: (placed ?? xref)!.name, - at: pt(at), - }; - let scale: unknown = options.scale; - const view: unknown = options.view; - const inModel = !this.#open && !this.#sheet; - if (view !== undefined) { - if (!this.#standard) refuse("role", DRAWN.viewWithoutStandard); - if (!inModel) refuse("role", DRAWN.insertViewOnPaper); - if (scale !== undefined) refuse("role", DRAWN.insertViewAndScale); - scale = onPaper(this.#declared(view, "role").view, 1, "scale"); - } else if (inModel && placed && this.#roleBlocks.has(layerKey(placed.name))) { - refuse("role", roleBlockWithoutView(placed.name)); - } - if (scale !== undefined) { - const pair = (typeof scale === "number" ? [scale, scale] : scale) as readonly unknown[]; - if (!Array.isArray(pair) || pair.length !== 2) { - refuse("geometry", "scale must be a number or [x, y]"); - } - const problem = scaleProblem(pair[0]) ?? scaleProblem(pair[1]); - if (problem) refuse("geometry", `scale: ${problem}`); - if (pair[0] !== 1 || pair[1] !== 1) insert.scale = [pair[0] as number, pair[1] as number]; - } - const rotation = kept(options.rotation ?? 0, "rotation"); - if (rotation !== 0) insert.rotation = rotation; - const set: unknown = options.attributes; - if (set !== undefined && (typeof set !== "object" || set === null || Array.isArray(set))) { - return refuse("undefined-attribute", ATTRIBUTES_SHAPE); + return this.#add(DrawingBuilder.prototype.insert, options, () => + this.#placed(block, at, options), + ); + } + + /** Checks an insert of a block or an xref, and returns it on the current layer. */ + #placed( + block: unknown, + at: Point, + options: { + readonly scale?: unknown; + readonly view?: unknown; + readonly rotation?: number; + readonly attributes?: unknown; + readonly id?: unknown; + }, + ): Insert { + if (this.dxf === "R12") refuse("block", R12_ONLY.blocks); + const requested: unknown = block; + if (typeof requested !== "string") + return refuse("undefined-block", "A block is named by a string"); + const nameProblem = blockNameProblem(requested); + if (nameProblem) + refuse("undefined-block", `Block ${JSON.stringify(requested)}: ${nameProblem}`); + if (this.#open && layerAuditKey(this.#open.name) === layerAuditKey(requested)) { + refuse("block-cycle", blockCycleProblem([this.#open.name, this.#open.name])); + } + const xref = this.#xrefs.get(layerKey(requested)); + const placed = this.#blocks.get(layerKey(requested)); + if (!placed && !xref) { + refuse( + "undefined-block", + `${undefinedBlockProblem(requested)}. Define it with block() or xref() first`, + ); + } + const insert: { -readonly [K in keyof Insert]: Insert[K] } = { + kind: "insert", + layer: this.#current, + block: (placed ?? xref)!.name, + at: pt(at), + }; + let scale: unknown = options.scale; + const view: unknown = options.view; + const inModel = !this.#open && !this.#sheet; + if (view !== undefined) { + if (!this.#standard) refuse("role", DRAWN.viewWithoutStandard); + if (!inModel) refuse("role", DRAWN.insertViewOnPaper); + if (scale !== undefined) refuse("role", DRAWN.insertViewAndScale); + scale = onPaper(this.#declared(view, "role").view, 1, "scale"); + } else if (inModel && placed && this.#roleBlocks.has(layerKey(placed.name))) { + refuse("role", roleBlockWithoutView(placed.name)); + } + if (scale !== undefined) { + const pair = (typeof scale === "number" ? [scale, scale] : scale) as readonly unknown[]; + if (!Array.isArray(pair) || pair.length !== 2) { + refuse("geometry", "scale must be a number or [x, y]"); } - if (!placed) { - if (set !== undefined && Object.keys(set).length > 0) { - refuse("undefined-attribute", XREF_ATTRIBUTES); - } - return insert; + const problem = scaleProblem(pair[0]) ?? scaleProblem(pair[1]); + if (problem) refuse("geometry", `scale: ${problem}`); + if (pair[0] !== 1 || pair[1] !== 1) insert.scale = [pair[0] as number, pair[1] as number]; + } + const rotation = kept(options.rotation ?? 0, "rotation"); + if (rotation !== 0) insert.rotation = rotation; + const set: unknown = options.attributes; + if (set !== undefined && (typeof set !== "object" || set === null || Array.isArray(set))) { + return refuse("undefined-attribute", ATTRIBUTES_SHAPE); + } + if (!placed) { + if (set !== undefined && Object.keys(set).length > 0) { + refuse("undefined-attribute", XREF_ATTRIBUTES); } - const definitions = placed.attributes ?? []; - if (set !== undefined) { - const values: Record = {}; - for (const [tag, value] of Object.entries(set as Record)) { - if (!definitions.some((definition) => definition.tag === tag)) { - refuse("undefined-attribute", undefinedAttributeProblem(tag, placed.name)); - } - const problem = typeof value === "string" ? textProblem(value) : "must be a string"; - if (problem) refuse("text", `attribute ${JSON.stringify(tag)}: ${problem}`); - values[tag] = value as string; + return insert; + } + const definitions = placed.attributes ?? []; + if (set !== undefined) { + const values: Record = {}; + for (const [tag, value] of Object.entries(set as Record)) { + if (!definitions.some((definition) => definition.tag === tag)) { + refuse("undefined-attribute", undefinedAttributeProblem(tag, placed.name)); } - if (Object.keys(values).length > 0) insert.attributes = values; + const problem = typeof value === "string" ? textProblem(value) : "must be a string"; + if (problem) refuse("text", `attribute ${JSON.stringify(tag)}: ${problem}`); + values[tag] = value as string; } - for (const definition of definitions) { - const style = - definition.style === undefined - ? undefined - : this.#textStyles.get(layerKey(definition.style)); - const problem = placedAttributeProblem( - definition.tag, - placeAttribute(definition, style, insert, placed.base), + if (Object.keys(values).length > 0) insert.attributes = values; + } + for (const definition of definitions) { + const style = + definition.style === undefined + ? undefined + : this.#textStyles.get(layerKey(definition.style)); + const problem = placedAttributeProblem( + definition.tag, + placeAttribute(definition, style, insert, placed.base), + ); + if (problem) refuse("geometry", problem); + } + const id: unknown = options.id; + if (typeof id === "string" && definitions.length > 0) { + const what = `the insert ${JSON.stringify(id)}`; + for (const { tag } of definitions) { + this.#claim( + insertAttributeHandle(id, tag), + `the attribute ${JSON.stringify(tag)} of ${what}`, ); - if (problem) refuse("geometry", problem); - } - const id: unknown = options.id; - if (typeof id === "string" && definitions.length > 0) { - const what = `the insert ${JSON.stringify(id)}`; - for (const { tag } of definitions) { - this.#claim( - insertAttributeHandle(id, tag), - `the attribute ${JSON.stringify(tag)} of ${what}`, - ); - } - this.#claim(insertEndHandle(id), `the end of the attributes of ${what}`); } - return insert; - }); + this.#claim(insertEndHandle(id), `the end of the attributes of ${what}`); + } + return insert; } /** @@ -2425,19 +2544,40 @@ export class DrawingBuilder< * shows model space on it at a scale. Inside `draw` the current layer starts as `"0"`. A * layout is defined once, and not inside a block or another layout. AutoCAD opens a drawing * on its first layout. + * + * Drawn to a standard, a layout is on a sheet format of the standard, which gives its paper, + * its plot style table, its title block and its zones: `layout(name, { sheetFormat }, draw)`, + * or `layout(name, draw)` for the standard's `defaultSheetFormat`. The sheet's `zone()` and + * `drawingArea` lay it out, and `titleBlock()` places its title block. */ - layout( + layout( name: V extends "R12" ? R12Layouts : LayoutName, - options: LayoutOptions

, - draw: (sheet: Sheet) => void, + // Options of `undefined` are none. The conditional over `Z` stays one member of a union, so a + // builder of one standard is still a builder of any. + ...args: + | [ + options: + | (Z extends Standard ? StandardLayoutOptions : LayoutOptions

) + | undefined, + draw: (sheet: Sheet) => void, + ] + | [draw: (sheet: Sheet) => 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; + // `draw` is last: the second argument, or the third after the options. + const [first, second] = args as readonly unknown[]; + const drawFirst = typeof first === "function"; + const given = drawFirst ? undefined : first; + const draw = (drawFirst ? first : second) as (sheet: Sheet) => void; let open: OpenLayout; const taken: string[] = []; try { const fail = (rule: LintRule, problem: string): never => refuse(rule, `Layout ${JSON.stringify(requested)}: ${problem}`); + if (typeof draw !== "function" || args.length !== (drawFirst ? 1 : 2)) { + fail("layout", DRAWN.layoutDraw); + } if (this.dxf === "R12") fail("layout", R12_ONLY.layouts); const nameProblem = layoutNameProblem(requested); if (nameProblem) fail("layout", nameProblem); @@ -2458,15 +2598,56 @@ export class DrawingBuilder< fail("duplicate-layout", layoutTwinProblem(requested, other?.name ?? requested)!); } } - const paper: unknown = options?.paper; + const drawn = this.#standard; + const o = (given ?? {}) as { + readonly paper?: unknown; + readonly plotStyle?: unknown; + readonly sheetFormat?: unknown; + }; + let paper: unknown = o.paper; + let plotStyle: unknown = o.plotStyle; + let format: OpenLayout["format"]; + if (drawn) { + const { standard } = drawn; + if (paper !== undefined || plotStyle !== undefined) { + fail("sheet-format-outside-standard", DRAWN.layoutPaper); + } + const id = o.sheetFormat === undefined ? standard.defaultSheetFormat : o.sheetFormat; + if (id === undefined) fail("sheet-format-outside-standard", noDefaultSheetFormat(standard)); + format = typeof id === "string" ? drawn.sheetFormats.get(idKey(id)) : undefined; + if (format === undefined) { + fail( + "sheet-format-outside-standard", + typeof id === "string" + ? sheetFormatLacks(id, standard) + : "A sheet format is named by a string", + ); + } + if (standard.sheetFiles === "sheet-per-file" && this.#layouts.size > 0) { + fail("sheet-per-file", DRAWN.secondLayout); + } + // AutoCAD keeps two layouts apart whose names only its AUDIT takes for one, and the + // record of each one's sheet format would have one key for the two. + for (const other of this.#layouts.values()) { + if (other && layerAuditKey(other.name) === layerAuditKey(requested)) { + fail("duplicate-layout", layoutAuditTwin(other.name)); + } + } + paper = format![1].paper; + const table = format![1].plotStyle ?? standard.plotStyle; + plotStyle = table === DEFAULT_PLOT_STYLE_TABLE ? undefined : table; + } else { + if (given === undefined) fail("layout", DRAWN.layoutWithoutPaper); + if (o.sheetFormat !== undefined) { + fail("sheet-format-outside-standard", DRAWN.sheetFormatWithoutStandard); + } + } const problem = paperProblem(paper); if (problem) fail("layout", problem); const info = paperInfo(paper as Paper)!; - const standard = this.#standard?.standard; - if (standard && info.units !== standard.paperUnits) { - fail("layout", layoutPaperUnitsProblem(paper as string, info.units, standard)); + if (drawn && info.units !== drawn.standard.paperUnits) { + fail("layout", layoutPaperUnitsProblem(paper as string, info.units, drawn.standard)); } - const plotStyle: unknown = options.plotStyle; if (plotStyle !== undefined) { const styleProblem = plotStyleProblem(plotStyle); if (styleProblem) fail("layout", styleProblem); @@ -2486,6 +2667,8 @@ export class DrawingBuilder< handles: taken, ids: [], views: [], + format, + titleBlock: undefined, closed: false, }; } catch (thrown) { @@ -2499,12 +2682,25 @@ export class DrawingBuilder< const previous = this.#current; this.#current = "0"; this.#sheet = open; + const area = open.format && this.#sheetTitleBlock(open.format)[1].drawingArea; const sheet: Sheet = { name: open.name, paper: open.paper, units: open.info.units, width: open.info.width, height: open.info.height, + drawingArea: (area ? zoneOf(area) : undefined) as Sheet["drawingArea"], + zone: (zone) => { + try { + return this.#zone(open, zone); + } catch (thrown) { + throw located(thrown, sheet.zone, { layout: open.name }); + } + }, + titleBlock: (...fields) => { + this.#titleBlock(sheet.titleBlock, open, fields[0]); + return sheet as never; + }, viewport: (corner, width, height, view) => { this.#viewport(sheet.viewport, open, corner, width, height, view); return sheet as never; @@ -2512,13 +2708,14 @@ export class DrawingBuilder< }; try { draw(sheet); + this.#finishSheet(open); } catch (thrown) { // Nothing of a layout that was not finished stays, so the call can be made again. this.#layouts.delete(key); for (const handle of open.handles) this.#handles.delete(handle); for (const id of open.ids) this.#ids.delete(id); for (const shown of open.views) shown.shown = false; - throw thrown; + throw located(thrown, DrawingBuilder.prototype.layout, { layout: requested }); } finally { this.#current = previous; this.#sheet = undefined; @@ -2538,6 +2735,317 @@ export class DrawingBuilder< return this; } + /** The title block of a sheet format, as the standard spells its id. */ + #sheetTitleBlock([, format]: readonly [string, StandardSheetFormat]): readonly [ + string, + StandardTitleBlock, + ] { + return this.#standard!.titleBlocks.get(idKey(format.titleBlock))!; + } + + /** A zone of the sheet format of the layout being drawn, by its id in any case. */ + #zone(open: OpenLayout, name: unknown): Zone { + if (!open.format) return refuse("layout", DRAWN.zoneWithoutStandard); + const [id, format] = open.format; + const found = + typeof name === "string" + ? Object.entries(format.zones).find(([zone]) => idKey(zone) === idKey(name)) + : undefined; + if (!found) { + return refuse( + "layout", + typeof name === "string" + ? zoneLacks(name, id, Object.keys(format.zones)) + : "A zone is named by a string", + ); + } + return zoneOf(found[1]); + } + + /** + * Places the title block of the layout being drawn: its xref, and the block of its fields over + * it with the values the fields are filled with. `method` is the sheet's own. + */ + #titleBlock(method: Method, open: OpenLayout, given: unknown): void { + try { + const drawn = this.#standard; + if (!drawn) refuse("layout", DRAWN.titleBlockWithoutStandard); + if (open.closed) refuse("layout", DRAWN.titleBlockClosed); + if (open.titleBlock) refuse("layout", DRAWN.titleBlockTwice); + const [id, block] = this.#sheetTitleBlock(open.format!); + const { standard } = drawn!; + const oneDrawing = standard.sheetFiles === "one-drawing"; + const fields = Object.entries(block.fields); + if ( + oneDrawing && + fields.some(([, f]) => f.source === "fileName" || f.source === "layoutName") + ) { + refuse("standard", READS_A_NAME); + } + const own = titleBlockValues(given, id, fields); + if (own.number !== undefined) { + if (oneDrawing && own.number !== open.name) { + refuse("layout", numberNotLayoutName(own.number, open.name)); + } + const problem = oneDrawing ? undefined : sheetNumberProblem(own.number); + if (problem) refuse("sheet-per-file", sheetNumberOfFile(problem)); + } + const values: Record = { ...drawn!.fields, ...own }; + if (oneDrawing) values.number = open.name; + const attributes: Record = {}; + const tags: string[] = []; + const scaleTags: string[] = []; + for (const [tag, field] of fields) { + const key = field.fills ?? tag; + if (field.source === "layoutName") { + // Such a field is in a file of one sheet, so the drawing's own value is this sheet's. + if (values[key] !== undefined && values[key] !== open.name) { + refuse("layout", layoutNameField(tag, key, open.name)); + } + } else if (field.source === "currentDate") { + const taken = fields.some(([, f]) => f.source === "attribute" && (f.fills ?? "") === key); + if (own[key] !== undefined && !taken) { + refuse( + "layout", + filledByAutoCAD(tag, key, "the date AutoCAD opens or plots the sheet"), + ); + } + } else if (field.source === "attribute") { + tags.push(tag); + const value = values[key]; + if (value !== undefined) attributes[tag] = value; + else if (key === "scale") scaleTags.push(tag); + } + } + const [layer, copied] = this.#layersOrNone( + () => + [ + this.#standardLayer(block.layer, {} as LayerOptions), + block.block && this.#copyTitleBlock(id, block), + ] as const, + ); + const target = open.entities; + const entities = target.length; + const calls = open.calls?.length ?? 0; + const ids = open.ids.length; + const handles = open.handles.length; + const previous = this.#current; + this.#current = layer.name; + let index: number | undefined; + try { + if (block.xref) { + const { name, path, at } = block.xref; + this.#defineXref(name, path, true); + const border = idWith(`${open.name}.border`); + this.#add(method, border, () => this.#placed(name, at, border)); + } + if (copied !== undefined) { + index = target.length; + const fields = idWith(`${open.name}.title-block`); + this.#add(method, fields, () => this.#placed(copied, [0, 0], { ...fields, attributes })); + } + } catch (thrown) { + // A refused insert leaves nothing on the layout. + target.length = entities; + open.calls?.splice(calls); + for (const key of open.ids.splice(ids)) this.#ids.delete(key); + for (const handle of open.handles.splice(handles)) this.#handles.delete(handle); + throw thrown; + } finally { + this.#current = previous; + } + open.titleBlock = { index, tags, scaleTags, number: oneDrawing ? undefined : own.number }; + } catch (thrown) { + throw located(thrown, method, { layout: open.name }); + } + } + + /** + * What the builder does when a layout's `draw` returns: it fills the scale of the title block's + * fields that were given none, and under `sheet-per-file` it requires the sheet's number. + */ + #finishSheet(open: OpenLayout): void { + const drawn = this.#standard; + if (!drawn) return; + const block = open.titleBlock; + const labels = [...new Set(open.views.map((shown) => shown.view.label))]; + if (block?.index !== undefined && block.scaleTags.length > 0 && labels.length > 0) { + const scale = labels.length === 1 ? labels[0]! : "AS NOTED"; + const insert = open.entities[block.index] as Insert; + const set: Record = {}; + for (const tag of block.tags) { + const value = block.scaleTags.includes(tag) ? scale : insert.attributes?.[tag]; + if (value !== undefined) set[tag] = value; + } + const placed = this.#blocks.get(layerKey(insert.block))!; + const filled: Insert = { ...insert, attributes: set }; + for (const definition of placed.attributes ?? []) { + if (!block.scaleTags.includes(definition.tag)) continue; + const style = + definition.style === undefined + ? undefined + : this.#textStyles.get(layerKey(definition.style)); + const problem = placedAttributeProblem( + definition.tag, + placeAttribute(definition, style, filled, placed.base), + ); + if (problem) refuse("geometry", problem); + } + open.entities[block.index] = filled; + } + if (drawn.standard.sheetFiles === "sheet-per-file") { + if ((block?.number ?? drawn.fields.number) === undefined) { + refuse("sheet-per-file", DRAWN.sheetWithoutNumber); + } + } + } + + /** + * Copies the block of a title block into the drawing, with every block it inserts and every + * text style they use, under their own names, and the layers and linetypes of the standard + * they draw on. A definition the drawing has with the same definition is the one it keeps, and + * one it has with another is refused. Returns the name of the block of the fields. + */ + #copyTitleBlock(owner: string, titleBlock: StandardTitleBlock): string { + const drawing = titleBlock.block!; + const what = `The title block ${JSON.stringify(owner)}`; + const byName = new Map((drawing.blocks ?? []).map((b) => [layerKey(b.name), b])); + const start = byName.get(layerKey(titleBlock.blockName!))!; + // Each block comes after the blocks it inserts. + const closure: Block[] = []; + const seen = new Set(); + const visit = (block: Block): void => { + if (seen.has(block)) return; + seen.add(block); + for (const e of block.entities) { + const inner = e.kind === "insert" ? byName.get(layerKey(e.block)) : undefined; + if (inner) visit(inner); + } + closure.push(block); + }; + visit(start); + const styles = new Set(); + for (const block of closure) { + for (const e of block.entities) + if (e.kind === "text" && e.style) styles.add(layerKey(e.style)); + for (const a of block.attributes ?? []) if (a.style) styles.add(layerKey(a.style)); + } + // Every refusal comes before the drawing takes a text style, block, id or handle, and + // #titleBlock gives back the layers and linetypes, so a refused copy leaves nothing. + const added: TextStyle[] = []; + for (const style of drawing.textStyles ?? []) { + const key = layerKey(style.name); + if (!styles.has(key)) continue; + const existing = this.#textStyles.get(key); + if (existing) { + if (canonical(existing) !== canonical(style)) { + refuse( + "duplicate-block", + `${what} holds the text style ${JSON.stringify(style.name)}, and the drawing has a text style of this name with another font, width factor or oblique angle. A drawing holds one text style of a name`, + ); + } + continue; + } + const twin = this.#textStylesAudited.get(layerAuditKey(style.name)); + if (twin !== undefined) { + refuse( + "duplicate-block", + `${what} holds the text style ${JSON.stringify(style.name)}, which AutoCAD takes for the drawing's text style ${JSON.stringify(twin)}. A drawing holds one text style of a name`, + ); + } + added.push(style); + } + const copied: Block[] = []; + const ids = new Map(); + const claims = new Map(); + const claim = (handle: string, what: string, id?: string): void => { + this.#free(handle, what, id, claims); + claims.set(handle, id === undefined ? { what } : { id, what }); + }; + for (const block of closure) { + const first = this.#blocksAudited.get(layerAuditKey(block.name)); + if (first !== undefined) { + const existing = first.xref ? undefined : this.#blocks.get(layerKey(block.name)); + if (existing && canonical(existing) === canonical(block)) continue; + refuse( + "duplicate-block", + `${what} holds the block ${JSON.stringify(block.name)}, and the drawing has ${first.xref ? "an xref" : "a block"} ${JSON.stringify(first.name)} that AutoCAD takes for it, with another definition. A block's handles come from its name, so the builder renames neither: give one of them another name`, + ); + } + copied.push(block); + const handles = blockHandles(block.name); + const named = `the block ${JSON.stringify(block.name)}`; + for (const handle of [handles.record, handles.begin, handles.end]) claim(handle, named); + for (const { tag } of block.attributes ?? []) { + claim( + definitionHandle(block.name, tag), + `the attribute ${JSON.stringify(tag)} of ${named}`, + ); + } + for (const e of block.entities) { + if (e.id === undefined) continue; + const key = this.#idKey(e.id); + const first = ids.get(key); + if (first !== undefined) refuse("duplicate-id", duplicateIdProblem(e.id, first)); + ids.set(key, e.id); + claim(validIdHandle(e.id), `the id ${JSON.stringify(e.id)}`, e.id); + // The handles #placed takes for an insert with an id. + const tags = e.kind === "insert" ? (byName.get(layerKey(e.block))?.attributes ?? []) : []; + if (tags.length === 0) continue; + const insert = `the insert ${JSON.stringify(e.id)}`; + for (const { tag } of tags) { + claim( + insertAttributeHandle(e.id, tag), + `the attribute ${JSON.stringify(tag)} of ${insert}`, + ); + } + claim(insertEndHandle(e.id), `the end of the attributes of ${insert}`); + } + } + for (const block of copied) { + const layers = [...block.entities, ...(block.attributes ?? [])].map((e) => e.layer); + for (const layer of new Set(layers)) { + if (layer !== "0") this.#standardLayer(layer, {} as LayerOptions); + } + for (const e of block.entities) { + const linetype: unknown = (e as { readonly linetype?: unknown }).linetype; + if (linetype !== undefined) this.#linetypeName(linetype, refuse); + } + } + for (const style of added) { + this.#textStyles.set(layerKey(style.name), style); + this.#textStylesAudited.set(layerAuditKey(style.name), style.name); + } + // The blocks belong to the drawing, also when a layout that is not finished copies them. + for (const [handle, holder] of claims) this.#claim(handle, holder.what, holder.id, []); + for (const [key, id] of ids) this.#ids.set(key, id); + for (const block of copied) { + this.#blocks.set(layerKey(block.name), block); + this.#blocksAudited.set(layerAuditKey(block.name), { name: block.name, xref: false }); + } + return start.name; + } + + /** Runs `take`, and gives back every layer and linetype it stored when it throws. */ + #layersOrNone(take: () => T): T { + const tables: Map[] = [ + this.#layers, + this.#audited, + this.#linetypes, + this.#linetypesAudited, + ]; + const kept = tables.map((table) => [...table]); + try { + return take(); + } catch (thrown) { + tables.forEach((table, i) => { + table.clear(); + for (const [key, value] of kept[i]!) table.set(key, value); + }); + throw thrown; + } + } + /** Checks a viewport and adds it to the layout being drawn. `method` is the sheet's own. */ #viewport( method: Method, @@ -2687,8 +3195,10 @@ export class DrawingBuilder< #role(options: TextOptions): { readonly height: number; readonly style: string } | undefined { const role: unknown = options.role; const view: unknown = options.view; - if (role === undefined && view === undefined) return undefined; const drawn = this.#standard; + if (role === undefined && view === undefined) { + return drawn ? refuse("text-style-outside-standard", DRAWN.textWithoutRole) : undefined; + } if (!drawn) { return refuse( "role", @@ -2821,19 +3331,28 @@ export class DrawingBuilder< id?: string, held = (this.#open ?? this.#sheet)?.handles, ): void { - const other = this.#handles.get(handle); - if (other !== undefined) { - refuse( - "id-handle", - id !== undefined && other.id !== undefined - ? idHandleProblem(id, other.id, handle) - : handleProblem(what, other.what, handle), - ); - } + this.#free(handle, what, id); this.#handles.set(handle, id === undefined ? { what } : { id, what }); held?.push(handle); } + /** Refuses a handle that the drawing, or `pending`, already holds. */ + #free( + handle: string, + what: string, + id?: string, + pending?: ReadonlyMap, + ): void { + const other = this.#handles.get(handle) ?? pending?.get(handle); + if (other === undefined) return; + refuse( + "id-handle", + id !== undefined && other.id !== undefined + ? idHandleProblem(id, other.id, handle) + : handleProblem(what, other.what, handle), + ); + } + /** The linetype and lineweight an entity is stored with. */ #style(options: EntityOptions): EntityStyle { const style: { -readonly [K in keyof EntityStyle]: EntityStyle[K] } = {}; @@ -3052,10 +3571,13 @@ interface Drawn { string, readonly [id: string, style: StandardDimensionStyle] >; - /** The id of each text role, by `layerKey` of its text style's name. */ - readonly roleStyles: ReadonlyMap; + /** Each sheet format and title block as the standard spells its id, by `idKey`. */ + readonly sheetFormats: ReadonlyMap; + readonly titleBlocks: ReadonlyMap; /** The drawing's discipline, as the standard spells it. */ readonly discipline?: string; + /** The values `createDrawing`'s `fields` gives the drawing's title blocks. */ + readonly fields: Readonly>; } /** The maps of each standard a drawing has been drawn to. */ @@ -3070,6 +3592,7 @@ function drawnTo( const discipline: unknown = options.discipline; if (given === undefined) { if (discipline !== undefined) refuse("standard", DRAWN.disciplineWithoutStandard); + if (options.fields !== undefined) refuse("standard", DRAWN.fieldsWithoutStandard); return undefined; } const standard = adopted(given); @@ -3088,19 +3611,91 @@ function drawnTo( textRoles: byKey(standard.textRoles, idKey), scales: byKey(standard.scales, idKey), dimensionStyles: byKey(standard.dimensionStyles, idKey), - roleStyles: new Map( - Object.keys(standard.textRoles).map((id) => [layerKey(capitals(id)), id]), - ), + sheetFormats: byKey(standard.sheetFormats, idKey), + titleBlocks: byKey(standard.titleBlocks, idKey), + fields: {}, }; drawnStandards.set(standard, drawn); } - if (discipline === undefined) return drawn; + const fields = drawingFields(options.fields, standard); + if (discipline === undefined) return { ...drawn, fields }; 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 }; + return { ...drawn, discipline: named, fields }; +} + +/** + * The values `createDrawing`'s `fields` gives the title blocks, checked: each key is a value of + * `fills` or the tag of a field of a title block of the standard that fills nothing, and each + * value a text a field can hold. Under `one-drawing` it gives no number, and under + * `sheet-per-file` a number that names a file. + */ +function drawingFields(given: unknown, standard: Standard): Readonly> { + if (given === undefined) return {}; + if (typeof given !== "object" || given === null || Array.isArray(given)) { + return refuse("standard", "fields is an object of the values of title block fields, by key"); + } + const tags = new Set(); + for (const block of Object.values(standard.titleBlocks)) { + for (const [tag, field] of Object.entries(block.fields)) { + if (field.fills === undefined) tags.add(tag); + } + } + const values: Record = {}; + for (const [key, value] of Object.entries(given as Record)) { + if (value === undefined) continue; + if (!(FILL_KEYS as readonly string[]).includes(key) && !tags.has(key)) { + refuse("standard", fieldLacks(key, "fields")); + } + const problem = typeof value === "string" ? textProblem(value) : "must be a string"; + if (problem) refuse("standard", `fields.${key}: ${problem}`); + values[key] = value as string; + } + const number = values.number; + if (number !== undefined) { + if (standard.sheetFiles === "one-drawing") refuse("layout", DRAWN.numberOneDrawing); + const problem = sheetNumberProblem(number); + if (problem) refuse("sheet-per-file", sheetNumberOfFile(problem)); + } + return values; +} + +/** `{ id }` for an insert the builder places, when `id` is a valid id, and `{}` otherwise. */ +function idWith(id: string): { readonly id?: string } { + return idProblem(id) === undefined ? { id } : {}; +} + +/** A box of a sheet format, `[left, bottom, right, top]`, as a `Zone`. */ +function zoneOf([left, bottom, right, top]: readonly [number, number, number, number]): Zone { + return Object.freeze({ left, bottom, right, top, width: right - left, height: top - bottom }); +} + +/** + * The values `titleBlock()` is given, checked: each key is what a field of the title block fills, + * or the tag of one of its fields that fills nothing, and each value a text a field can hold. + */ +function titleBlockValues( + given: unknown, + id: string, + fields: readonly (readonly [string, { readonly fills?: string }])[], +): Readonly> { + if (given === undefined) return {}; + if (typeof given !== "object" || given === null || Array.isArray(given)) { + return refuse("layout", "titleBlock() takes an object of the values of its fields, by key"); + } + const keys = new Set(fields.map(([tag, field]) => field.fills ?? tag)); + const values: Record = {}; + for (const [key, value] of Object.entries(given as Record)) { + if (value === undefined) continue; + if (!keys.has(key)) refuse("layout", fieldLacks(key, `The title block ${JSON.stringify(id)}`)); + const problem = typeof value === "string" ? textProblem(value) : "must be a string"; + if (problem) refuse("text", `${key}: ${problem}`); + values[key] = value as string; + } + return values; } /** The id among the options of a dimension, which have no linetype or lineweight to take. */ diff --git a/src/index.ts b/src/index.ts index c31d7ef..7781aad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,11 +68,13 @@ export { type TextStyleChoice, type TextStyleDefinition, type TextVerticalAlign, + type TitleBlockFields, type Units, type View, type Viewport, type ViewportOptions, type Xref, + type Zone, } from "./drawing.ts"; export { toDXF, type DxfOptions } from "./dxf.ts"; export { FONT_METRICS, FONT_METRICS_RANGES, type FontMetrics } from "./font-metrics.ts"; diff --git a/src/lint.ts b/src/lint.ts index f0b2287..4cbcd0a 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -162,7 +162,10 @@ export type LintRule = | "pen-outside-standard" | "role" | "scale-outside-standard" - | "dimension-style-outside-standard"; + | "dimension-style-outside-standard" + | "text-style-outside-standard" + | "sheet-format-outside-standard" + | "sheet-per-file"; export interface Diagnostic { readonly rule: LintRule; diff --git a/src/rules.ts b/src/rules.ts index dd8c2a2..0620fc9 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -153,6 +153,13 @@ const PAPER = 'A paper must be one of PAPERS, the full-bleed sizes of AutoCAD\'s DWG To PDF.pc3, such as "ARCH D", each laid out landscape. A page setup names its paper as the plotter does, and AutoCAD plots nothing for a layout whose paper the plotter knows neither by its name nor by its size'; const PLOT_STYLE = 'A plot style table must be "monochrome.ctb", "acad.ctb", "Grayscale.ctb" or "DWF Virtual Pens.ctb", which AutoCAD installs. AutoCAD plots a layout whose plot style table it does not have with no table, each pen in its own color'; +const SHEET_NUMBER_LENGTH = "A sheet number has 1 to 64 characters"; +const SHEET_NUMBER_CHARS = + 'A sheet number cannot hold < > : " / \\ | ? *, a control character or a character above U+FFFF. Windows names no file with one of them'; +const SHEET_NUMBER_END = + "A sheet number cannot end with a space or a dot. Windows drops them from the end of a file's name, so the file would not have the number's name"; +const SHEET_NUMBER_DEVICE = + "A sheet number cannot be a name Windows keeps for a device: CON, PRN, AUX, NUL, COM1 to COM9 or LPT1 to LPT9, in any case and with any extension. Windows opens the device in place of a file of that name"; const XREF_PATH_EMPTY = "An xref's path cannot be empty. AutoCAD then looks for a DWG file named as the xref is"; const XREF_PATH_DXF = @@ -1511,6 +1518,25 @@ export function layoutTwinProblem(name: string, other: string): string | undefin return `The drawing already has a layout of this name${spelled}. AutoCAD takes the two for one layout, keeps one, and its AUDIT reports the other as missing from the dictionary of layouts`; } +/** + * Returns what is wrong with a sheet number, which names a sheet's file, or undefined when a file + * of Windows can have it as its name: `.dwg`. + */ +export function sheetNumberProblem(number: unknown): string | undefined { + if (typeof number !== "string") return "A sheet number is a string"; + if (number.length < 1 || number.length > 64) return SHEET_NUMBER_LENGTH; + for (let i = 0; i < number.length; i++) { + const code = number.charCodeAt(i); + if (code < 0x20 || code === 0x7f || (code >= 0xd800 && code <= 0xdfff)) { + return SHEET_NUMBER_CHARS; + } + } + if (/[<>:"/\\|?*]/.test(number)) return SHEET_NUMBER_CHARS; + if (/[ .]$/.test(number)) return SHEET_NUMBER_END; + if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(number)) return SHEET_NUMBER_DEVICE; + return undefined; +} + /** * Returns what is wrong with a layout's paper, or undefined when it is one of `PAPERS`. It * takes `unknown` so that `lint` can report a drawing built by hand. diff --git a/src/standard/define.ts b/src/standard/define.ts index 2b4e86a..15f5c5e 100644 --- a/src/standard/define.ts +++ b/src/standard/define.ts @@ -26,7 +26,7 @@ import { } 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 { capitals, inheritedDefaultProblem, READS_A_NAME, referenceProblem } from "./rules.ts"; import { DOCUMENT, entriesObject, @@ -809,8 +809,6 @@ const TITLE_BLOCK_FORM = const BLOCK_NAME_MISSING = "A title block with a block names it with blockName"; const BLOCK_NAME_ALONE = "blockName names the block of block, and this title block has no block"; const PREVIEW_ALONE = "A preview is drawn in place of an xref, and this title block has no xref"; -const READS_A_NAME = - "Under sheetFiles \"one-drawing\" no field reads the file's name or the layout's name. One drawing has one file name, and an xref that every layout shares shows one layout's name on every sheet. Make the field an attribute"; const ATTRIBUTE_WITHOUT_BLOCK = 'A field whose source is "attribute" is written in an attribute of the title block\'s block, and this title block has no block'; diff --git a/src/standard/drawn.ts b/src/standard/drawn.ts index 0e20a8d..071d72b 100644 --- a/src/standard/drawn.ts +++ b/src/standard/drawn.ts @@ -63,6 +63,34 @@ const DIMENSION_VIEW_ON_PAPER = "A dimension on a layout is drawn in paper units, and names no view. Leave view out"; const LAYOUT_PAPER_UNITS = "a sheet of a standard is in the standard's paper units, which its text roles and dimension styles are sized in"; +const TEXT_STYLE_DEFINED = + "Under a standard a text's style comes from its role, and a drawing defines no text style of its own. Add a role to the standard's textRoles in defineStandard, and name it"; +const TEXT_WITHOUT_ROLE = + "Under a standard a text names a role of the standard, which gives its text style and the height it plots at. Give it role, in place of height and style"; +const LAYOUT_PAPER = + "Under a standard a layout's paper and plot style table come from its sheet format. Give it sheetFormat, a sheet format of the standard, or leave it out for the standard's defaultSheetFormat"; +const SHEET_FORMAT_WITHOUT_STANDARD = + "sheetFormat names a sheet format of the drawing's standard, and this drawing has none. Draw to a standard with createDrawing({ standard }), or give the layout its paper"; +const LAYOUT_WITHOUT_PAPER = + "A layout of a drawing without a standard names its paper, as layout(name, { paper }, draw)"; +const LAYOUT_DRAW = + "layout() takes the function that draws the sheet last, and nothing after it: layout(name, { paper }, draw), or under a standard layout(name, { sheetFormat }, draw) or layout(name, draw)"; +const TITLE_BLOCK_WITHOUT_STANDARD = + "titleBlock() places the title block of the layout's sheet format, and this drawing has no standard. Draw to a standard with createDrawing({ standard }), or insert a block of your own"; +const TITLE_BLOCK_TWICE = + "A layout has one title block, and titleBlock() has placed this layout's already"; +const TITLE_BLOCK_CLOSED = + "A title block is placed while its layout is drawn. Call titleBlock() inside the draw of layout()"; +const ZONE_WITHOUT_STANDARD = + "A zone is a part of the layout's sheet format, and this drawing has no standard. Lay the sheet out from its width and height"; +const FIELDS_WITHOUT_STANDARD = + "fields fills the title blocks of the drawing's standard, and this drawing has none. Draw to a standard with createDrawing({ standard }), or give an insert its attributes"; +const NUMBER_ONE_DRAWING = + "Under sheetFiles \"one-drawing\" a sheet's number is its layout's name, which the builder writes in the title block, so createDrawing's fields give no number: one would stamp every sheet with it. Leave number out"; +const SECOND_LAYOUT = + 'Under sheetFiles "sheet-per-file" a drawing is one sheet, with one layout, or a model drawing, with none. Draw each other sheet in a drawing of its own'; +const SHEET_WITHOUT_NUMBER = + 'Under sheetFiles "sheet-per-file" a sheet has a number, which names its file. Give number in createDrawing\'s fields or in titleBlock()'; export type StandardR12 = typeof STANDARD_R12; export type StandardLinetypeScale = typeof STANDARD_LINETYPE_SCALE; @@ -79,6 +107,13 @@ export type InsertViewAndScale = typeof INSERT_VIEW_AND_SCALE; export type ViewportRatio = typeof VIEWPORT_RATIO; export type ViewportId = typeof VIEWPORT_ID; export type DimensionStyleDefined = typeof DIMENSION_STYLE_DEFINED; +export type TextStyleDefined = typeof TEXT_STYLE_DEFINED; +export type TextWithoutRole = typeof TEXT_WITHOUT_ROLE; +export type LayoutPaper = typeof LAYOUT_PAPER; +export type SheetFormatWithoutStandard = typeof SHEET_FORMAT_WITHOUT_STANDARD; +export type TitleBlockWithoutStandard = typeof TITLE_BLOCK_WITHOUT_STANDARD; +export type ZoneWithoutStandard = typeof ZONE_WITHOUT_STANDARD; +export type FieldsWithoutStandard = typeof FIELDS_WITHOUT_STANDARD; /** The message for each rule of this file that has no name in it, for the builder. */ export const DRAWN = { @@ -107,6 +142,20 @@ export const DRAWN = { dimensionStyleDefined: DIMENSION_STYLE_DEFINED, dimensionWithoutView: DIMENSION_WITHOUT_VIEW, dimensionViewOnPaper: DIMENSION_VIEW_ON_PAPER, + textStyleDefined: TEXT_STYLE_DEFINED, + textWithoutRole: TEXT_WITHOUT_ROLE, + layoutPaper: LAYOUT_PAPER, + sheetFormatWithoutStandard: SHEET_FORMAT_WITHOUT_STANDARD, + layoutWithoutPaper: LAYOUT_WITHOUT_PAPER, + layoutDraw: LAYOUT_DRAW, + titleBlockWithoutStandard: TITLE_BLOCK_WITHOUT_STANDARD, + titleBlockTwice: TITLE_BLOCK_TWICE, + titleBlockClosed: TITLE_BLOCK_CLOSED, + zoneWithoutStandard: ZONE_WITHOUT_STANDARD, + fieldsWithoutStandard: FIELDS_WITHOUT_STANDARD, + numberOneDrawing: NUMBER_ONE_DRAWING, + secondLayout: SECOND_LAYOUT, + sheetWithoutNumber: SHEET_WITHOUT_NUMBER, } as const; type LayerLacks< @@ -246,10 +295,6 @@ type RoleLacks< export const roleLacks = (name: string, standard: Standard): string => `Role ${JSON.stringify(name)} is not a text role of ${of(standard)}. Add it to the standard, as textRoles: { ${JSON.stringify(name)}: { font: "Arial", height: 0.125 } } in defineStandard, or use one of its roles`; -/** What is wrong with a text style `textStyle()` defines under the name of a role's text style. */ -export const roleTextStyleProblem = (name: string, role: string, standard: Standard): string => - `Text style ${JSON.stringify(name)} is the text style of the role ${JSON.stringify(role)} of ${of(standard)}, which gives its font, width factor and oblique angle. Give the style another name, or change the role in the standard`; - /** Resolves to `R` when it is a text role of the standard `Z`, and to the message otherwise. */ export type RoleOfStandard = string extends R ? R @@ -321,6 +366,70 @@ export const viewNotDeclared = (view: unknown): string => { export const roleBlockWithoutView = (block: string): string => `Block ${JSON.stringify(block)} holds texts with roles, which are drawn in paper units, so an insert of it in model space names the view it is read in, as insert(name, at, { view })`; +type SheetFormatLacks< + F extends string, + Z extends Standard, +> = `Sheet format "${F}" is not a sheet format of ${Of}. Add it to the standard's sheetFormats in defineStandard, with its paper and its titleBlock, or use one of its sheet formats`; +/** What is wrong with a sheet format the standard lacks. */ +export const sheetFormatLacks = (name: string, standard: Standard): string => + `Sheet format ${JSON.stringify(name)} is not a sheet format of ${of(standard)}. Add it to the standard's sheetFormats in defineStandard, with its paper and its titleBlock, or use one of its sheet formats`; + +/** Resolves to `F` when it is a sheet format of the standard `Z`, and to the message otherwise. */ +export type SheetFormatOfStandard = string extends F + ? F + : F extends keyof Z["sheetFormats"] + ? F + : SheetFormatLacks; + +/** What is wrong with a layout that names no sheet format, under a standard that has no default. */ +export const noDefaultSheetFormat = (standard: Standard): string => + `${capitalized(of(standard))} has no defaultSheetFormat. Give the layout the sheetFormat it is drawn on, or set defaultSheetFormat in the standard`; + +/** What `layout()` takes under a standard: the sheet format, which gives the paper and the rest. */ +export interface StandardLayoutOptions { + /** A sheet format of the standard, by its id. Defaults to its `defaultSheetFormat`. */ + readonly sheetFormat?: SheetFormatOfStandard; + readonly paper?: LayoutPaper; + readonly plotStyle?: LayoutPaper; +} + +/** What is wrong with a zone the layout's sheet format lacks. */ +export function zoneLacks(zone: string, format: string, zones: readonly string[]): string { + const listed = + zones.length === 0 + ? "It has no zone, so lay the sheet out by its drawingArea" + : `Its zones are ${zones.length === 1 ? zones[0] : `${zones.slice(0, -1).join(", ")} and ${zones.at(-1)}`}`; + return `The sheet format ${JSON.stringify(format)} has no zone ${JSON.stringify(zone)}. ${listed}`; +} + +/** What is wrong with a key of `fields` that names no field. */ +export function fieldLacks(key: string, where: string): string { + return `${where} has no field ${JSON.stringify(key)}. A key is one of number, title, project, date, scale, drawnBy and checkedBy, or the tag of a title block's field that fills none of them`; +} + +/** What is wrong with a number, under `one-drawing`, that is not its layout's name. */ +export const numberNotLayoutName = (number: string, layout: string): string => + `Under sheetFiles "one-drawing" a sheet's number is its layout's name, ${JSON.stringify(layout)}, and this one is ${JSON.stringify(number)}. Name the layout ${JSON.stringify(number)}, or leave number out`; + +/** What is wrong with a value given for a field that reads the layout's name. */ +export const layoutNameField = (tag: string, key: string, layout: string): string => + `The title block's field ${JSON.stringify(tag)} reads the layout's name, ${JSON.stringify(layout)}, which AutoCAD writes in it. Leave ${key} out, or give it the layout's name`; + +/** What is wrong with a value given for a field that AutoCAD fills by itself. */ +export const filledByAutoCAD = (tag: string, key: string, what: string): string => + `The title block's field ${JSON.stringify(tag)} holds ${what}, which AutoCAD writes in it. Leave ${key} out`; + +/** What is wrong with a sheet number that cannot name a file. */ +export const sheetNumberOfFile = (problem: string): string => + `Under sheetFiles "sheet-per-file" a sheet's number names its file. ${problem}`; + +/** + * What is wrong with a layout whose name `layerAuditKey` folds to another layout's, where + * AutoCAD's own comparison of layout names keeps the two apart. + */ +export const layoutAuditTwin = (other: string): string => + `AutoCAD's AUDIT takes this name and that of the layout ${JSON.stringify(other)} for one, as it takes a composed letter and its decomposed form, ß and ss, a name with a soft hyphen and without, and σ and ς. AutoCAD keeps both layouts, and a drawing drawn to a standard holds its layouts' names to AUDIT's comparison, as it holds its blocks' and its text styles'. Give this layout another name`; + /** What is wrong with a layout whose paper is not in the standard's paper units. */ export const layoutPaperUnitsProblem = (paper: string, units: string, standard: Standard): string => `the paper ${JSON.stringify(paper)} is in ${units}, and ${of(standard)} has its paper units in ${standard.paperUnits}: ${LAYOUT_PAPER_UNITS}`; diff --git a/src/standard/rules.ts b/src/standard/rules.ts index aa6d90c..af3c847 100644 --- a/src/standard/rules.ts +++ b/src/standard/rules.ts @@ -108,6 +108,10 @@ export const FILL_KEYS = [ export type FillOf = OneOf; export const fillProblem = oneOf(FILL_KEYS, FILLS); +/** What is wrong with a field that reads a name, under `one-drawing`. */ +export const READS_A_NAME = + "Under sheetFiles \"one-drawing\" no field reads the file's name or the layout's name: the builder writes each field a sheet fills as an attribute. One drawing has one file name, and its layouts' names are its sheets' numbers, which the builder writes as attributes, so a field that read the layout's name in the title's place would plot the number there. Make the field an attribute"; + export const SOURCES = ["attribute", "fileName", "layoutName", "currentDate"] as const; export type SourceOf = OneOf; export const sourceProblem = oneOf(SOURCES, SOURCE); diff --git a/test/drawn.test.ts b/test/drawn.test.ts index f5f4a56..fc1b945 100644 --- a/test/drawn.test.ts +++ b/test/drawn.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { insertAttributeHandle } from "../src/blocks.ts"; import { BUILT_IN_STANDARD, createDrawing, @@ -7,7 +8,11 @@ import { defineStandard, DrawingBuilder, DrawingError, + idHandle, + toDXF, toJSON, + type Drawing, + type Insert, type LintRule, type Standard, } from "../src/index.ts"; @@ -250,7 +255,7 @@ test("under a standard the builder refuses a role, a view or a dimension style i "role", /^An insert with a view is scaled by the view/, ); - d.layout("C-1", { paper: "ARCH D" }, (sheet) => { + d.layout("C-1", (sheet) => { refused( // @ts-expect-error: the view gives the viewport's ratio. () => sheet.viewport([1, 1], 10, 10, { view: plan, center: [0, 0], ratio: 120 }), @@ -309,17 +314,12 @@ test("under a standard the builder refuses a role, a view or a dimension style i /^The view "plan" is not a view of this drawing/, ); refused(() => d.view("PLAN", { scale: "1in-20ft" }), "duplicate-id", /already used/); - // A role's text style is the standard's, before the role is used and after. + // A text's style is its role's, and the drawing defines none of its own. refused( - () => d.textStyle({ name: "Dimension", font: "romans.shx" }), - "text-style", - /^Text style "Dimension" is the text style of the role "dimension" of the standard "acme" version 2026.1, which gives its font, width factor and oblique angle. Give the style another name, or change the role in the standard$/, - ); - d.text([0, 0], "A", { role: "note", view: plan }); - refused( - () => d.textStyle({ name: "NOTE", font: "romans.shx" }), - "text-style", - /^Text style "NOTE" is the text style of the role "note"/, + // @ts-expect-error: under a standard a drawing defines no text style. + () => d.textStyle({ name: "NOTES", font: "romans.shx" }), + "text-style-outside-standard", + /^Under a standard a text's style comes from its role, and a drawing defines no text style of its own/, ); refused( () => d.linearDimension([0, 0], [10, 0], [0, 5], { style: null as never, view: plan }), @@ -331,7 +331,7 @@ test("under a standard the builder refuses a role, a view or a dimension style i "dimension-style", /^A dimension of model space under a standard names the view/, ); - d.layout("C-2", { paper: "ARCH D" }, (sheet) => { + d.layout("C-2", (sheet) => { sheet.viewport([1, 1], 10, 10, { view: plan, center: [0, 0] }); refused( () => sheet.viewport([12, 1], 10, 10, { view: plan, center: [0, 0] }), @@ -372,10 +372,11 @@ test("under a standard the builder refuses a role, a view or a dimension style i paperUnits: "mm", minTextHeight: 2, }); + // Every paper is in inches, so a standard in millimetres has no sheet format yet. refused( - () => createDrawing({ standard: millimetres }).layout("A", { paper: "ARCH D" }, () => {}), - "layout", - /is in in, and the standard "mm" version 1 has its paper units in mm/, + () => createDrawing({ standard: millimetres }).layout("A", () => {}), + "sheet-format-outside-standard", + /^Layout "A": The standard "mm" version 1 has no defaultSheetFormat/, ); refused( () => createDrawing({ standard: millimetres }).linearDimension([0, 0], [1, 0], [0, 1], {}), @@ -404,7 +405,7 @@ test("a role and a view size a text, a symbol and a dimension where each is draw // The compiler compares the id exactly, and the builder finds it in any case. const [upper]: string[] = ["DIMS"]; d.linearDimension([0, 0], [10, 0], [0, 5], { view: detail, style: upper! }); - d.layout("C-1", { paper: "ARCH D" }, (sheet) => { + d.layout("C-1", (sheet) => { sheet.viewport([1, 1], 10, 10, { view: plan, center: [0, 0] }); d.text([1, 12], "PLAN", { role: "title" }); d.linearDimension([1, 13], [3, 13], [1, 14], {}); @@ -478,19 +479,27 @@ test("each branch of a conditional is checked as written", () => { d.block("CORNER", {}, () => d.circle([0, 0], 1 / 16)); for (const viewed of [true, false]) { d.block(viewed ? "A" : "B", {}, () => - d.attribute("T", [0, 0], viewed ? { role: "label" } : { height: 0.1 }), + d.attribute("T", [0, 0], viewed ? { role: "label" } : { role: "note" }), ); d.insert("CORNER", [0, 0], viewed ? { view: plan } : { scale: 2 }); - d.text([0, 0], "A", viewed ? { role: "label", view: plan } : { height: 1 }); + d.text([0, 0], "A", viewed ? { role: "label", view: plan } : { role: "note", view: plan }); } const drawing = d.build(); assert.deepEqual( drawing.entities.map((e) => (e.kind === "insert" ? e.scale : e.kind === "text" && e.height)), - [[10, 10], 1.25, [2, 2], 1], + [[10, 10], 1.25, [2, 2], 0.9375], ); assert.deepEqual( drawing.blocks?.map((b) => b.attributes?.[0]?.height), - [undefined, 0.125, 0.1], + [undefined, 0.125, 0.09375], + ); + // A branch without a role is a compile error with the builder's message. + const flag = d.build().layers.length > 0; + refused( + // @ts-expect-error: under a standard a text names a role. + () => d.text([0, 0], "A", flag ? { height: 1 } : { role: "label", view: plan }), + "text-style-outside-standard", + /^Under a standard a text names a role of the standard/, ); }); @@ -539,3 +548,498 @@ test("a role and a dimension style with fields by discipline take the drawing's /^Text role "note" has fields of its own in the discipline C of the standard/, ); }); + +/** A title block's block of two fields over an xref, drawn in inches without a standard. */ +function fieldsOverBorder(): Drawing { + const d = createDrawing({ units: "in" }); + d.textStyle({ name: "FIELDS", font: "Arial" }); + d.block("FIELDS", {}, () => { + d.attribute("NUMBER", [33, 1], { value: "-", height: 0.25, style: "FIELDS" }); + d.attribute("TITLE", [29.5, 2], { value: "-", height: 0.1875, style: "FIELDS" }); + }); + return d.build(); +} + +/** A standard whose title block is a firm's border as an xref, with its fields over it. */ +const firm = defineStandard({ + id: "firm", + version: "1", + extends: BUILT_IN_STANDARD, + defaultSheetFormat: "firm", + titleBlocks: { + firm: { + paper: "ARCH D", + xref: { name: "BORDER", path: "border.dwg" }, + block: fieldsOverBorder(), + blockName: "FIELDS", + layer: "G-ANNO-TTLB", + drawingArea: [0.5, 0.5, 35.5, 23.5], + fields: { NUMBER: { fills: "number" }, TITLE: { fills: "title" } }, + }, + }, + sheetFormats: { firm: { paper: "ARCH D", titleBlock: "firm", zones: { views: [1, 3, 35, 23] } } }, +}); + +/** A standard of a file per sheet, whose xref title block AutoCAD fills from the names. */ +const perFile = defineStandard({ + id: "per-file", + version: "1", + extends: BUILT_IN_STANDARD, + sheetFiles: "sheet-per-file", + titleBlocks: { + named: { + paper: "ARCH D", + xref: { name: "TITLE", path: "../setup/title.dwg" }, + layer: "G-ANNO-TTLB", + drawingArea: [0.5, 0.5, 35.5, 23.5], + fields: { + NUMBER: { fills: "number", source: "fileName" }, + TITLE: { fills: "title", source: "layoutName" }, + DATE: { fills: "date", source: "currentDate" }, + }, + }, + }, + sheetFormats: { named: { paper: "ARCH D", titleBlock: "named" } }, +}); + +test("a layout is on a sheet format of the standard, laid out by its zones", () => { + const d = createDrawing({ units: "ft", standard }); + d.layout("C-101", (sheet) => { + assert.equal(sheet.paper, "ARCH D"); + assert.deepEqual(sheet.drawingArea, { + left: 0.5, + bottom: 0.5, + right: 35.5, + top: 23.5, + width: 35, + height: 23, + }); + // A zone is found in any case. + assert.deepEqual(sheet.zone("VIEWS"), sheet.zone("views")); + assert.deepEqual(sheet.zone("notes"), { + left: 28.5, + bottom: 3.5, + right: 35.5, + top: 23.5, + width: 7, + height: 20, + }); + refused( + () => sheet.zone("legend"), + "layout", + /^The sheet format "arch-d" has no zone "legend". Its zones are views, viewTitles and notes$/, + ); + }); + d.layout("C-102", { sheetFormat: "arch-d" }, () => {}); + refused( + // @ts-expect-error: a sheet format gives the paper. + () => d.layout("C-103", { paper: "ARCH D" }, () => {}), + "sheet-format-outside-standard", + /^Layout "C-103": Under a standard a layout's paper and plot style table come from its sheet format/, + ); + refused( + // @ts-expect-error: the standard has no sheet format arch-e. + () => d.layout("C-103", { sheetFormat: "arch-e" }, () => {}), + "sheet-format-outside-standard", + /^Layout "C-103": Sheet format "arch-e" is not a sheet format of the standard "acme" version 2026.1. Add it/, + ); + // Without a standard a layout names its paper, and no sheet format. + const plain = createDrawing({ units: "ft" }); + refused( + () => plain.layout("A", (() => {}) as never), + "layout", + /^Layout "A": A layout of a drawing without a standard names its paper/, + ); + refused( + // @ts-expect-error: a sheet format is a standard's. + () => plain.layout("A", { paper: "ARCH D", sheetFormat: "arch-d" }, () => {}), + "sheet-format-outside-standard", + /^Layout "A": sheetFormat names a sheet format of the drawing's standard/, + ); + plain.layout("A", { paper: "ARCH D" }, (sheet) => { + assert.equal(sheet.drawingArea, undefined); + refused( + // @ts-expect-error: a zone is a standard's. + () => sheet.zone("views"), + "layout", + /^A zone is a part of the layout's sheet format/, + ); + for (const place of [ + // @ts-expect-error: a title block is a standard's. + () => sheet.titleBlock({ title: "A" }), + // @ts-expect-error: a title block is a standard's. + () => sheet.titleBlock(), + ]) { + refused( + place, + "layout", + /^titleBlock\(\) places the title block of the layout's sheet format/, + ); + } + }); + // draw is the last argument, and a function. + const draw = (): void => {}; + for (const call of [ + // @ts-expect-error: a layout has its draw. + () => plain.layout("B", { paper: "ARCH D" }), + // @ts-expect-error: a layout has its draw. + () => d.layout("C-104", { sheetFormat: "arch-d" }), + // @ts-expect-error: nothing follows draw. + () => d.layout("C-104", draw, draw), + // @ts-expect-error: draw is a function. + () => d.layout("C-104", {}, "draw"), + ]) { + refused( + call, + "layout", + /^Layout "(B|C-104)": layout\(\) takes the function that draws the sheet last, and nothing after it/, + ); + } + // Options of undefined are none. + d.layout("C-104", undefined, (sheet) => assert.equal(sheet.paper, "ARCH D")); + // A layout whose name only AUDIT takes for another's is refused under a standard. + d.layout("STRAßE", () => {}); + refused( + () => d.layout("STRASSE", () => {}), + "duplicate-layout", + /^Layout "STRASSE": AutoCAD's AUDIT takes this name and that of the layout "STRAßE" for one/, + ); + plain.layout("STRAßE", { paper: "ARCH D" }, () => {}); + plain.layout("STRASSE", { paper: "ARCH D" }, () => {}); +}); + +test("the title block is the standard's, filled from the sheet, the drawing and the builder", () => { + const d = createDrawing({ + units: "ft", + standard, + fields: { project: "LOT 12", date: "2026-11-13", drawnBy: "AB", checkedBy: "CD" }, + }); + const plan = d.view("plan", { scale: "1in-10ft" }); + const detail = d.view("detail", { scale: "1in-20ft" }); + const lot = d.view("lot", { scale: "1in-10ft" }); + d.layout("C-101", (sheet) => { + // Placed before the viewports, it gets their scale when draw returns. + sheet.titleBlock({ title: "SITE PLAN" }); + refused(() => sheet.titleBlock(), "layout", /^A layout has one title block/); + d.layer("G-ANNO-NPLT", () => { + sheet.viewport([1, 3], 10, 10, { view: plan, center: [0, 0] }); + sheet.viewport([12, 3], 10, 10, { view: lot, center: [0, 0] }); + }); + }); + d.layout("C-102", (sheet) => { + sheet.titleBlock({ title: "DETAILS", scale: "AS SHOWN" }); + d.layer("G-ANNO-NPLT", () => sheet.viewport([1, 3], 10, 10, { view: detail, center: [0, 0] })); + }); + d.layout("C-103", (sheet) => { + sheet.titleBlock({ number: "C-103", title: "PLANS" }); + d.layer("G-ANNO-NPLT", () => { + sheet.viewport([1, 3], 10, 10, { view: d.view("a", { scale: "1in-10ft" }), center: [0, 0] }); + sheet.viewport([12, 3], 10, 10, { view: d.view("b", { scale: "1in-20ft" }), center: [0, 0] }); + }); + }); + const drawing = d.build(); + const inserts = drawing.layouts!.map((layout) => layout.entities[0]); + assert.deepEqual(inserts[0], { + kind: "insert", + layer: "G-ANNO-TTLB", + block: "TITLE BLOCK", + at: [0, 0], + id: "C-101.title-block", + attributes: { + PROJECT: "LOT 12", + TITLE: "SITE PLAN", + DATE: "2026-11-13", + SCALE: `1" = 10'`, + DRAWN: "AB", + CHECKED: "CD", + SHEET: "C-101", + }, + }); + assert.equal((inserts[1] as Insert).attributes?.SCALE, "AS SHOWN"); + assert.equal((inserts[2] as Insert).attributes?.SCALE, "AS NOTED"); + // The title block's definitions enter the drawing once, under their own names. + assert.deepEqual( + drawing.blocks?.map((b) => b.name), + ["TITLE BLOCK"], + ); + assert.deepEqual(drawing.blocks?.[0], BUILT_IN_STANDARD.titleBlocks.plain.block!.blocks![0]); + assert.deepEqual(drawing.textStyles, [{ name: "TITLE BLOCK", font: "Arial" }]); + refused(() => d.block("Title Block", {}, () => {}), "duplicate-block", /already has a block/); + // One drawing: the number is the layout's name, and the drawing's fields give none. + d.layout("C-104", (sheet) => + refused( + () => sheet.titleBlock({ number: "C-105" }), + "layout", + /^Under sheetFiles "one-drawing" a sheet's number is its layout's name, "C-104", and this one is "C-105"/, + ), + ); + refused( + () => createDrawing({ standard, fields: { number: "C-1" } }), + "layout", + /^Under sheetFiles "one-drawing" a sheet's number is its layout's name/, + ); + refused( + () => createDrawing({ standard, fields: { projet: "X" } }), + "standard", + /^fields has no field "projet"/, + ); + d.layout("C-105", (sheet) => + refused( + () => sheet.titleBlock({ REVISION: "A" }), + "layout", + /^The title block "plain" has no field "REVISION"/, + ), + ); + refused( + // @ts-expect-error: fields fill a standard's title blocks. + () => createDrawing({ fields: { title: "A" } }), + "standard", + /^fields fills the title blocks of the drawing's standard, and this drawing has none/, + ); + // A block of the title block's name is refused before it is placed too. + const before = createDrawing({ units: "ft", standard }); + before.block("TITLE BLOCK", {}, () => {}); + before.layout("C-1", (sheet) => + refused( + () => sheet.titleBlock(), + "duplicate-block", + /^The title block "plain" holds the block "TITLE BLOCK", and the drawing has a block "TITLE BLOCK" that AutoCAD takes for it, with another definition/, + ), + ); +}); + +test("the title block's insert holds the handles of its attributes", () => { + // Found by hashing the attribute handles of the title block inserts of 10^7 layouts and + // searching ids against them. + const handle = insertAttributeHandle("S6980407.title-block", "SCALE"); + assert.equal(handle, "1B30312DFF4658"); + assert.equal(idHandle("t74230822"), handle); + const after = createDrawing({ units: "ft", standard }); + after.layout("S6980407", (sheet) => sheet.titleBlock()); + refused( + () => after.line([0, 0], [1, 0], { id: "t74230822" }), + "id-handle", + /the attribute "SCALE" of the insert "S6980407.title-block"/, + ); + const before = createDrawing({ units: "ft", standard }); + before.line([0, 0], [1, 0], { id: "t74230822" }); + before.layout("S6980407", (sheet) => { + refused(() => sheet.titleBlock(), "id-handle", /the id "t74230822"/); + // The refused title block left nothing on the layout, so it can be placed again. + assert.throws(() => sheet.titleBlock(), /the id "t74230822"/); + }); + assert.deepEqual(before.build().layouts![0]!.entities, []); +}); + +/** + * A standard of a file per sheet whose title block's block inserts a block of an attribute with + * the id "S6980407.title-block", and draws on a layer with fields by discipline. + */ +const nested = defineStandard({ + id: "nested", + version: "1", + extends: BUILT_IN_STANDARD, + sheetFiles: "sheet-per-file", + defaultSheetFormat: "nested", + layers: { "TB-CIVIL": { color: 7, byDiscipline: { C: { color: 3 } } } }, + titleBlocks: { + nested: { + paper: "ARCH D", + block: (() => { + const d = createDrawing({ units: "in" }); + d.layer("TB-CIVIL"); + d.block("SCALE BOX", {}, () => + d.attribute("SCALE", [33, 0.5], { value: "-", height: 0.1 }), + ); + d.block("FIELDS", {}, () => { + d.attribute("NUMBER", [33, 1], { value: "-", height: 0.25 }); + d.insert("SCALE BOX", [0, 0], { id: "S6980407.title-block" }); + d.layer("TB-CIVIL", () => d.line([0.5, 0.5], [35.5, 0.5])); + }); + return d.build(); + })(), + blockName: "FIELDS", + layer: "C-PROP", + drawingArea: [0.5, 0.5, 35.5, 23.5], + fields: { NUMBER: { fills: "number" } }, + }, + }, + sheetFormats: { nested: { paper: "ARCH D", titleBlock: "nested" } }, +}); + +test("an insert with an id in a title block's block holds the handles of its attributes", () => { + assert.equal(insertAttributeHandle("S6980407.title-block", "SCALE"), idHandle("t74230822")); + const sheet = () => + createDrawing({ units: "ft", standard: nested, discipline: "C", fields: { number: "A-1" } }); + const after = sheet(); + after.layout("A-1", (s) => s.titleBlock()); + refused( + () => after.line([0, 0], [1, 0], { id: "t74230822" }), + "id-handle", + /the attribute "SCALE" of the insert "S6980407.title-block"/, + ); + toDXF(after.build()); + const before = sheet(); + before.line([0, 0], [1, 0], { id: "t74230822" }); + before.layout("A-1", (s) => refused(() => s.titleBlock(), "id-handle", /the id "t74230822"/)); + toDXF(before.build()); +}); + +test("a title block refused for a layer it draws on takes no layer or linetype", () => { + const d = createDrawing({ units: "ft", standard: nested, fields: { number: "A-1" } }); + d.layout("A-1", (s) => + refused( + () => s.titleBlock(), + "standard", + /^Layer "TB-CIVIL" has fields of its own in the discipline C/, + ), + ); + const left = d.build(); + assert.deepEqual( + [left.layers.map((l) => l.name), left.linetypes, left.blocks, left.textStyles], + [["0"], [], undefined, undefined], + ); +}); + +test("a firm's border is an xref under the block of the fields, shared by every layout", () => { + const d = createDrawing({ units: "ft", standard: firm }); + for (const [name, title] of [ + ["A-101", "FIRST FLOOR"], + ["A-102", "SECOND FLOOR"], + ] as const) { + d.layout(name, (sheet) => { + sheet.titleBlock({ title }); + assert.deepEqual(sheet.zone("views").width, 34); + }); + } + const drawing = d.build(); + assert.deepEqual(drawing.xrefs, [{ name: "BORDER", path: "border.dwg" }]); + assert.deepEqual( + drawing.layouts!.map((layout) => + layout.entities.map((e) => [e.id, (e as Insert).block, (e as Insert).attributes]), + ), + [ + [ + ["A-101.border", "BORDER", undefined], + ["A-101.title-block", "FIELDS", { NUMBER: "A-101", TITLE: "FIRST FLOOR" }], + ], + [ + ["A-102.border", "BORDER", undefined], + ["A-102.title-block", "FIELDS", { NUMBER: "A-102", TITLE: "SECOND FLOOR" }], + ], + ], + ); + assert.deepEqual(drawing.textStyles, [{ name: "FIELDS", font: "Arial" }]); + // A title block refused for a block of its name leaves nothing of it in the drawing. + const taken = createDrawing({ units: "ft", standard: firm }); + taken.block("FIELDS", {}, () => {}); + taken.layout("A-101", (sheet) => + refused(() => sheet.titleBlock(), "duplicate-block", /holds the block "FIELDS"/), + ); + const left = taken.build(); + assert.deepEqual( + [left.blocks?.map((b) => b.name), left.textStyles, left.xrefs, left.layouts![0]!.entities], + [["FIELDS"], undefined, undefined, []], + ); + // A layout whose name is no id has inserts with none. + const spaced = createDrawing({ units: "ft", standard: firm }); + spaced.layout("A 101", (sheet) => sheet.titleBlock()); + assert.deepEqual( + spaced.build().layouts![0]!.entities.map((e) => e.id), + [undefined, undefined], + ); +}); + +test("under sheet-per-file a drawing is one sheet, with a number that names its file", () => { + const sheet = (number?: string) => { + const d = createDrawing({ + units: "ft", + standard: perFile, + ...(number ? { fields: { number } } : {}), + }); + return d; + }; + const d = sheet("C-101"); + d.layout("SITE PLAN", { sheetFormat: "named" }, (page) => + page.titleBlock({ title: "SITE PLAN" }), + ); + const drawing = d.build(); + // AutoCAD fills every field from the names: the builder writes the xref alone. + assert.deepEqual( + drawing.layouts![0]!.entities.map((e) => [e.id, (e as Insert).block]), + [[undefined, "TITLE"]], + ); + refused( + () => d.layout("DETAILS", { sheetFormat: "named" }, () => {}), + "sheet-per-file", + /^Layout "DETAILS": Under sheetFiles "sheet-per-file" a drawing is one sheet, with one layout/, + ); + refused( + () => sheet().layout("SITE PLAN", { sheetFormat: "named" }, () => {}), + "sheet-per-file", + /^Under sheetFiles "sheet-per-file" a sheet has a number, which names its file/, + ); + sheet() + .layout("SITE PLAN", { sheetFormat: "named" }, (page) => page.titleBlock({ number: "C-102" })) + .build(); + for (const [number, problem] of [ + ["CON", /Windows keeps for a device/], + ["lpt1.dwg", /Windows keeps for a device/], + ["C-101.", /cannot end with a space or a dot/], + ["C/101", /cannot hold/], + ["x".repeat(65), /has 1 to 64 characters/], + ] as const) { + refused(() => sheet(number), "sheet-per-file", problem); + } + sheet().layout("SITE PLAN", { sheetFormat: "named" }, (page) => { + refused( + () => page.titleBlock({ number: "C-1", title: "SITE" }), + "layout", + /^The title block's field "TITLE" reads the layout's name, "SITE PLAN", which AutoCAD writes in it/, + ); + refused( + () => page.titleBlock({ number: "C-1", date: "2026-11-13" }), + "layout", + /^The title block's field "DATE" holds the date AutoCAD opens or plots the sheet/, + ); + page.titleBlock({ number: "C-1", title: "SITE PLAN" }); + }); + createDrawing({ + units: "ft", + standard: perFile, + fields: { number: "C-1", title: "SITE" }, + }).layout("SITE PLAN", { sheetFormat: "named" }, (page) => + refused( + () => page.titleBlock(), + "layout", + /^The title block's field "TITLE" reads the layout's name, "SITE PLAN"/, + ), + ); + // A model drawing has no layout, and needs no number. + createDrawing({ units: "ft", standard: perFile, discipline: "C" }).build(); +}); + +test("under a standard a text names a role, and the drawing defines no text style", () => { + const d = createDrawing({ units: "ft", standard }); + d.layout("C-1", () => { + refused( + // @ts-expect-error: a text names a role. + () => d.text([0, 0], "A", { height: 0.1 }), + "text-style-outside-standard", + /^Under a standard a text names a role of the standard/, + ); + refused( + () => d.text([0, 0], "A"), + "text-style-outside-standard", + /^Under a standard a text names a role of the standard/, + ); + }); + d.block("B", {}, () => + refused( + // @ts-expect-error: an attribute names a role too. + () => d.attribute("T", [0, 0], { style: "Standard" }), + "text-style-outside-standard", + /^Under a standard a text names a role/, + ), + ); +});