diff --git a/AGENTS.md b/AGENTS.md index 0572630..ef567b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,8 @@ lower case, or `@LAYOUT/INDEX` for one without an id: never change one. A dimension is refused inside a block, where AutoCAD draws nothing for it. - `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 - `fromPaper`, and everything that belongs to the paper inside `d.layout`, in paper units. + 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. - A view is drawn only where it can be seen. The clip of a view, the hidden rectangle and @@ -175,6 +175,10 @@ 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 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 + one viewport that shows the view takes that id and no other. - `lint` takes any plain data and never throws, since a `Drawing` can be written by hand. `src/shape.ts` holds the fields of each object and their types, once, for `fromJSON` and for `lint`. `lint` checks an object's shape there before a rule reads it, and skips the rules for diff --git a/GUIDE.md b/GUIDE.md index a3c969e..b01dc18 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -255,6 +255,16 @@ It is drawn to a standard of its own, which extends `BUILT_IN_STANDARD` with the pen and plot flag, and an entity's `pen` names one of the standard's pens. A layer the standard lacks is a compile error that says how to add it to `layers` in `defineStandard`. +A text names a `role` of the standard, which gives its text style and the height it plots at. +The plan is a view at one of the standard's scales, `const plan = d.view("plan", { scale: +"1in-10ft" })`, and what model space draws to be read in it names the view: +`d.text(at, "LOT 27", { role: "label", view: plan })`, `d.insert("PROPERTY CORNER", at, +{ view: plan })` for a symbol drawn in inches, and `d.linearDimension(from, to, line, { view: +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'. + ## Checking intent Export `intent` beside the drawing, and each build checks that every footprint keeps out of diff --git a/examples/README.md b/examples/README.md index 32dcc8a..a9db308 100644 --- a/examples/README.md +++ b/examples/README.md @@ -45,15 +45,16 @@ at 1" = 10'. It is the file to copy to start a drawing. - **Model space** holds the site at its true size, in feet: the lot from its plat dimensions, the setbacks, the building footprint, the driveway, the street and the trees. The annotation that is read in the plan is there too: - bearings and distances, dimensions and labels. Each is sized by how - large it plots, with `fromPaper` for the viewport's ratio. The dimensions - are dimension objects in a style with that scale, so CAD measures them. + bearings and distances, dimensions and labels. Each names the view `plan`, + which sizes it by how large it plots at the view's scale. The dimensions + are dimension objects in the standard's dimension style at that scale, so + CAD measures them. - **The layout `C-101`** holds the sheet, in inches of paper: the border, the title block, a column of site data, general notes, a legend and abbreviations, and under the plan the north arrow, the view's title and a graphic scale. A text there has the height it plots at. -One viewport shows the plan on the layout at the ratio. It is on a layer that +One viewport shows the view on the layout at its scale. It is on a layer that does not plot, so its border stays off the page. AutoCAD opens the file on the sheet and plots it at 1" = 10' on a page 36 by 24 inches. @@ -93,18 +94,21 @@ 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. Change the -ratio from 120 to 240 and nothing on the sheet moves: -the plan is half as large in its viewport, and its text keeps its plotted -height. The sheet holds the plan at 1" = 10' and at 1" = 20'. At a larger -scale, such as 1" = 8', the plan is taller than its viewport. +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 +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 +1" = 20'. At a larger scale the plan is taller than its viewport. The picture is the layout, as `toSVG(drawing, { layout: "C-101" })` draws the -sheet, in the page's colors. `sitePlan(ratio)` builds the sheet at a ratio and -returns the drawing with where its parts lie: `drawingArea`, `titleBlock`, the -box of each of its `fields` and the `viewport` on the paper, in inches, and -`view`, the part of model space the viewport shows. A test builds it at 120 -and at 240. It holds model space inside the view, the sheet inside the border, +sheet, in the page's colors. `sitePlan(scale)` builds the sheet at a scale of +the standard and returns the drawing with where its parts lie: `drawingArea`, +`titleBlock`, the box of each of its `fields` and the `viewport` on the paper, +in inches, and `view`, the part of model space the viewport shows. A test +builds it at `1in-10ft` and at `1in-20ft`. It holds model space inside the +view, the sheet inside the border, everything but the title block out of the title block's space and off the viewport, the texts of each space off one another, and each field inside its box. diff --git a/examples/site-plan.ts b/examples/site-plan.ts index 24c4311..94e6aca 100644 --- a/examples/site-plan.ts +++ b/examples/site-plan.ts @@ -33,36 +33,40 @@ // Run with `pnpm example`. It writes examples/out/site-plan.dxf (AutoCAD 2018 DXF), // site-plan.svg, which is model space, and site-plan-sheet.svg, which is the layout as it // plots. The drawing is also the default export, for a tool that takes a drawing file, and -// `sitePlan(ratio)` builds the sheet at another ratio. +// `sitePlan("1in-20ft")` builds the sheet at another scale of the standard. // // How it is put together: // - The parameters come first, and every position is worked out from them. // - What belongs to the site is drawn in model space, in feet. What belongs to the paper is // drawn inside `d.layout(...)`, in inches, at the size it plots. -// - An annotation of the plan is in model space, sized by how large it plots: `paper` turns -// inches on paper into feet for the viewport's ratio. So the same code draws the sheet at -// another ratio: the plan changes size in its viewport and its text keeps its plotted -// height. // - The drawing is drawn to a standard, `standard`, which extends the built-in one with the // layer `V-TREE`. A layer is named, and its color, linetype, pen and plot flag are the // standard's. Its entities draw ByLayer, and one that plots heavier than its layer names a // pen of the standard. -// - Every text is in the sheet's text style, `NOTES`, through `put`, and is justified to its -// point with `align` and `valign`. Columns and line breaks are laid out by the length -// `measureText` gives a text in the style's font. +// - The plan is the view `plan`, at a scale of the standard, which the viewport shows. An +// annotation of the plan is in model space and names the view, which sizes it by how large it +// plots: a text plots at its role's height, a symbol at its size in inches and a dimension in +// the standard's dimension style. `plan.paper` turns other inches on paper, such as a gap, +// into feet at the view's ratio. So the same code draws the sheet at another scale: the plan +// changes size in its viewport and its text keeps its plotted height. +// - A text names a role of the standard, which gives its text style and height, and is +// justified to its point with `align` and `valign`. Columns and line breaks are laid out by +// the length `measureText` gives a text in its role. The title block's texts, below, are the +// one exception. // - 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. `titleBlock` and `TITLE_BLOCK` can be copied by themselves. +// 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. import { mkdirSync, writeFileSync } from "node:fs"; import { BUILT_IN_STANDARD, checkIntent, createDrawing, defineStandard, - fromPaper, + measureText, PAPERS, toDXF, toSVG, @@ -70,10 +74,8 @@ import { type DrawingBuilder, type Intent, type Point, - type TextAlign, - type TextVerticalAlign, } from "../src/index.ts"; -import { arrowhead, textWidth } from "./lib/drafting.ts"; +import { arrowhead } from "./lib/drafting.ts"; /** * The standard the sheet is drawn to: the built-in one, with a layer for the trees. Its layers @@ -89,6 +91,12 @@ export const standard = defineStandard({ /** 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. @@ -104,16 +112,10 @@ const project = { // 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 }; -// Text heights on paper, in inches. -const size = { note: 3 / 32, label: 1 / 8, heading: 3 / 16, title: 1 / 4 }; -// The sheet's text style and its font, which every length of a text is measured in. +// The title block's text style and its font, which its lengths are measured in. const NOTES = "NOTES"; const FONT = "Arial"; -// The scale the viewport shows the plan at, as a ratio, and the drawing's units. A foot is 12 -// inches, so an inch on paper is `ratio / 12` feet. -const scale = { ratio: 120, units: "ft" } as const; - // The site, in feet. The lot's front left corner is the origin. const lot = { name: "LOT 12, BLOCK 3", width: 100, depth: 140 }; const zoning = { district: "R-1 SINGLE-FAMILY", maxCoverage: 40 }; // percent @@ -341,16 +343,21 @@ function titleBlock( return { name, drawingArea: area, box, fields }; } -/** Builds the sheet with its plan at `ratio`: 120 is 1" = 10', and 240 is 1" = 20'. */ -export function sitePlan(ratio: number = scale.ratio): Sheet { - const feetPerInch = ratio / 12; - const scaleLabel = `1" = ${feetPerInch}'`; - - /** - * A length on paper, in inches, as drawing units in the viewport. Every size and offset of - * an annotation in model space uses it. - */ - const paper = (inches: number) => fromPaper(inches, "in", { ratio, units: scale.units }); +/** + * Builds the sheet with its plan at a scale of the standard: `"1in-10ft"` is 1" = 10', and + * `"1in-20ft"` is 1" = 20'. + */ +export function sitePlan(scale: keyof typeof standard.scales = "1in-10ft"): Sheet { + // The standard's linetype patterns are sized for paper: DASHED is a 0.5 dash and a 0.25 gap. + // On a layout AutoCAD sizes dashes on the paper, in every viewport, so the standard's linetype + // scale, 1/2, is a paper length and no drawing length: the dashes plot at half their pattern, + // a 1/4 in dash, at every scale. + const d = createDrawing({ units: "ft", standard }); + d.textStyle({ name: NOTES, font: FONT }); + // 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. @@ -368,36 +375,15 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { top: area.top, }; // 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 ratio. + // 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]; const view: Box = { - left: planCenter[0] - paper(viewport.right - viewport.left) / 2, - bottom: planCenter[1] - paper(viewport.top - viewport.bottom) / 2, - right: planCenter[0] + paper(viewport.right - viewport.left) / 2, - top: planCenter[1] + paper(viewport.top - viewport.bottom) / 2, + left: planCenter[0] - plan.paper(viewport.right - viewport.left) / 2, + bottom: planCenter[1] - plan.paper(viewport.top - viewport.bottom) / 2, + right: planCenter[0] + plan.paper(viewport.right - viewport.left) / 2, + top: planCenter[1] + plan.paper(viewport.top - viewport.bottom) / 2, }; - // The standard's linetype patterns are sized for paper: DASHED is a 0.5 dash and a 0.25 gap. - // On a layout AutoCAD sizes dashes on the paper, in every viewport, so the standard's linetype - // scale, 1/2, is a paper length and no drawing length: the dashes plot at half their pattern, - // a 1/4 in dash, at every ratio. - const d = createDrawing({ units: scale.units, standard }); - d.textStyle({ name: NOTES, font: FONT }); - const widthOf = (value: string, height: number) => textWidth(value, height, FONT); - - /** - * A text in the sheet's style. `height` is in the units of the space it is drawn in: inches - * on the layout, and `paper(inches)` in model space. `options` justify it to `at` and turn it. - */ - function put( - at: Point, - value: string, - height: number, - options: { align?: TextAlign; valign?: TextVerticalAlign; rotation?: number } = {}, - ): void { - d.text(at, value, { height, style: NOTES, ...options }); - } - // The symbols. A block's coordinates are its own, and its base point is its origin. d.block("TREE", {}, () => { // The canopy, one foot in radius, and a cross at the trunk. @@ -406,13 +392,13 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { d.line([0, -0.15], [0, 0.15]); }); // The other symbols are in inches, at the size they plot. On the layout they are inserted as - // they are, and in model space at `paper(1)`. + // they are, and in model space with the view, which scales them by `plan.paper(1)`. const gap = 1 / 16; // between a line and its text, in inches d.block("PROPERTY CORNER", {}, () => d.circle([0, 0], 1 / 16)); const arrowRadius = 3 / 8; d.block("NORTH ARROW", {}, () => { // North is up the block. Its base point is the middle of the circle. - d.text([0, arrowRadius + gap], "N", { height: size.heading, style: NOTES, align: "center" }); + d.text([0, arrowRadius + gap], "N", { role: "heading", align: "center" }); d.circle([0, 0], arrowRadius); d.line([0, -arrowRadius], [0, arrowRadius * 0.4]); arrowhead(d, [0, arrowRadius], [0, 0], arrowRadius * 0.9); @@ -421,20 +407,18 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { // end of the line. The line has one fixed length, long enough for the sheet's view title, // and does not follow the title an insert sets. Its tags are its own: a tag of the title // block names another field. - const titleLine = widthOf(sheet.title, size.title) + 1 / 4; + const titleLine = widthOf(sheet.title, "title") + 1 / 4; d.block("VIEW TITLE", {}, () => { d.attribute("VIEW_TITLE", [0, gap], { value: "VIEW TITLE", prompt: "View title", - height: size.title, - style: NOTES, + role: "title", }); d.line([0, 0], [titleLine, 0]); d.attribute("VIEW_SCALE", [0, -gap], { value: "SCALE: 1\" = 10'", prompt: "Scale", - height: size.label, - style: NOTES, + role: "label", valign: "top", }); }); @@ -458,7 +442,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { }); d.layer("C-PROP-MONU", () => { for (const x of [0, lot.width]) { - for (const y of [0, lot.depth]) d.insert("PROPERTY CORNER", [x, y], { scale: paper(1) }); + for (const y of [0, lot.depth]) d.insert("PROPERTY CORNER", [x, y], { view: plan }); } }); d.layer("C-SETB", () => { @@ -489,33 +473,20 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { for (const tree of trees) d.insert("TREE", tree.at, { scale: tree.radius }); }); - // The plan's annotation, in model space with what it describes. Its sizes are paper sizes, - // turned into feet for the viewport's ratio. - const text = { note: paper(size.note), label: paper(size.label), heading: paper(size.heading) }; - const near = paper(gap); // between a line and its text - const clear = paper(1 / 8); // between a heavy line and its text + // The plan's annotation, in model space with what it describes. A text names the view it is + // read in, which sizes it to plot at its role's height. Gaps and offsets are lengths on paper, + // turned into feet for the view. + /** How tall a text of a role is in model space, in feet. */ + const inPlan = (role: Role) => plan.paper(roles[role].height); + const near = plan.paper(gap); // between a line and its text + const clear = plan.paper(1 / 8); // between a heavy line and its text // The dimensions are AutoCAD's own: each measures the two points it is given, and writes - // what it measures. Their style has paper sizes in inches and the scale of this view, so a - // sheet at another ratio needs the same style at another scale, under another name. - const dimensions = `DIMS-${feetPerInch}`; - d.dimensionStyle({ - name: dimensions, - textStyle: NOTES, - textHeight: size.note, - arrowhead: "architectural-tick", - arrowSize: 1 / 8, - extensionOffset: gap, - extensionBeyond: 1 / 8, - textGap: gap, - scale: paper(1), - precision: 1, - suffix: "'", - }); - // One string across the lot, behind the building, and one up it, outside the east line. + // what it measures. They are in the standard's dimension style, which the view sizes for its + // scale. One string across the lot, behind the building, and one up it, outside the east line. // Each runs through the building's corners, and ends on a lot line. - const across = bounds.rear + paper(3 / 8); - const up = lot.width + paper(5 / 8); + const across = bounds.rear + plan.paper(3 / 8); + const up = lot.width + plan.paper(5 / 8); d.layer("C-ANNO-DIMS", () => { const acrossLot: Point[] = [ [0, across], @@ -530,39 +501,54 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { [lot.width, lot.depth], ]; for (let i = 1; i < acrossLot.length; i++) { - d.linearDimension(acrossLot[i - 1]!, acrossLot[i]!, [0, across], { style: dimensions }); + d.linearDimension(acrossLot[i - 1]!, acrossLot[i]!, [0, across], { view: plan }); } for (let i = 1; i < upLot.length; i++) { - d.linearDimension(upLot[i - 1]!, upLot[i]!, [up, 0], { style: dimensions, along: "y" }); + d.linearDimension(upLot[i - 1]!, upLot[i]!, [up, 0], { view: plan, along: "y" }); } }); d.layer("C-ANNO-TEXT", () => { - const { note, label, heading } = text; + const note = inPlan("note"); + const label = inPlan("label"); const middle = lot.width / 2; // Bearings and distances, clockwise from the northwest corner. Each sits outside its line, // but for the front one, which is inside the lot, clear of the sidewalk and the driveway. const course = (bearing: string, length: number) => `${bearing} ${length.toFixed(2)}'`; const sideMiddle = (across + lot.depth) / 2; - put([middle, lot.depth + clear], course(`N 90°00'00" E`, lot.width), note, { align: "center" }); - put([lot.width + clear + note, sideMiddle], course(`S 00°00'00" E`, lot.depth), note, { + d.text([middle, lot.depth + clear], course(`N 90°00'00" E`, lot.width), { + role: "note", + view: plan, + align: "center", + }); + d.text([lot.width + clear + note, sideMiddle], course(`S 00°00'00" E`, lot.depth), { + role: "note", + view: plan, align: "center", rotation: 90, }); - put([driveX / 2, clear], course(`S 90°00'00" W`, lot.width), note, { align: "center" }); - put([-clear, sideMiddle], course(`N 00°00'00" W`, lot.depth), note, { + d.text([driveX / 2, clear], course(`S 90°00'00" W`, lot.width), { + role: "note", + view: plan, + align: "center", + }); + d.text([-clear, sideMiddle], course(`N 00°00'00" W`, lot.depth), { + role: "note", + view: plan, align: "center", rotation: 90, }); // Setbacks, each along its line. const { front, side, rear } = setbacks; - const inset = side + paper(1 / 4); - put([inset, front + near], `${front}' FRONT SETBACK`, note); - put([inset, lot.depth - rear + near], `${rear}' REAR SETBACK`, note); + const inset = side + plan.paper(1 / 4); + d.text([inset, front + near], `${front}' FRONT SETBACK`, { role: "note", view: plan }); + d.text([inset, lot.depth - rear + near], `${rear}' REAR SETBACK`, { role: "note", view: plan }); for (const x of [side, lot.width - side]) { - put([x - near, (front + bounds.rear) / 2], `${side}' SIDE SETBACK`, note, { + d.text([x - near, (front + bounds.rear) / 2], `${side}' SIDE SETBACK`, { + role: "note", + view: plan, align: "center", rotation: 90, }); @@ -572,58 +558,82 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { stack( [middle, (across + lot.depth - rear) / 2], [ - [lot.name, heading], - [`${squareFeet(lotArea)} (${acres(lotArea)})`, label], + [lot.name, "heading"], + [`${squareFeet(lotArea)} (${acres(lotArea)})`, "label"], ], ); stack( [(bounds.left + bounds.right) / 2, houseY + house.depth / 2], [ - ["PROPOSED", heading], - ["RESIDENCE", heading], - [`${squareFeet(footprintArea)} FOOTPRINT`, label], - [`F.F.E. ${house.floor.toFixed(2)}'`, label], + ["PROPOSED", "heading"], + ["RESIDENCE", "heading"], + [`${squareFeet(footprintArea)} FOOTPRINT`, "label"], + [`F.F.E. ${house.floor.toFixed(2)}'`, "label"], ], ); - put([driveX + driveway.width / 2, setbacks.front / 2], "CONC. DRIVEWAY", note, { + d.text([driveX + driveway.width / 2, setbacks.front / 2], "CONC. DRIVEWAY", { + role: "note", + view: plan, align: "center", valign: "middle", rotation: 90, }); - put([0, -sidewalk.fromLot - sidewalk.width / 2], `${sidewalk.width}' CONC. SIDEWALK`, note, { + d.text([0, -sidewalk.fromLot - sidewalk.width / 2], `${sidewalk.width}' CONC. SIDEWALK`, { + role: "note", + view: plan, valign: "middle", }); - put([0, pavement.near - near], "EDGE OF PAVEMENT", note, { valign: "top" }); + d.text([0, pavement.near - near], "EDGE OF PAVEMENT", { + role: "note", + view: plan, + valign: "top", + }); const rightOfWay = `${street.rightOfWay}' PUBLIC RIGHT-OF-WAY`; - put([middle, centerline + clear], rightOfWay, note, { align: "center" }); - put([middle, centerline + clear + note + near * 1.5], street.name, heading, { + d.text([middle, centerline + clear], rightOfWay, { role: "note", view: plan, align: "center" }); + d.text([middle, centerline + clear + note + near * 1.5], street.name, { + role: "heading", + view: plan, align: "center", }); - put([view.right - clear, centerline + near], "CENTERLINE", note, { align: "right" }); + d.text([view.right - clear, centerline + near], "CENTERLINE", { + role: "note", + view: plan, + align: "right", + }); // The adjoining lots, each an inch outside its lot line and reading along it. - put([-paper(1), lot.depth / 2], "LOT 11", label, { align: "center", rotation: 90 }); - put([lot.width + paper(1) + label, lot.depth / 2], "LOT 13", label, { + d.text([-plan.paper(1), lot.depth / 2], "LOT 11", { + role: "label", + view: plan, align: "center", rotation: 90, }); - put([middle, lot.depth + paper(1)], "LOT 27", label, { align: "center" }); + d.text([lot.width + plan.paper(1) + label, lot.depth / 2], "LOT 13", { + role: "label", + view: plan, + align: "center", + rotation: 90, + }); + d.text([middle, lot.depth + plan.paper(1)], "LOT 27", { + role: "label", + view: plan, + align: "center", + }); }); - /** Lines of text in model space, each with its height, centered on `center` as one block. */ - function stack( - center: Point, - lines: readonly (readonly [value: string, height: number])[], - ): void { - const height = lines.reduce((sum, line) => sum + line[1], 0) + near * 1.5 * (lines.length - 1); + /** Lines of text in model space, each in its role, centered on `center` as one block. */ + function stack(center: Point, lines: readonly (readonly [value: string, role: Role])[]): void { + const heights = lines.map(([, role]) => inPlan(role)); + const height = heights.reduce((sum, h) => sum + h, 0) + near * 1.5 * (lines.length - 1); let top = center[1] + height / 2; - for (const [value, lineHeight] of lines) { - put([center[0], top - lineHeight], value, lineHeight, { align: "center" }); - top -= lineHeight + near * 1.5; - } + lines.forEach(([value, role], i) => { + d.text([center[0], top - heights[i]!], value, { role, view: plan, align: "center" }); + top -= heights[i]! + near * 1.5; + }); } - // The sheet: everything drawn inside `d.layout` is on the paper, in inches. + // 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", () => { @@ -633,7 +643,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { PROJECT: project.name, TITLE: sheet.title, DATE: project.date, - SCALE: scaleLabel, + SCALE: plan.label, DRAWN: project.drawnBy, CHECKED: project.checkedBy, SHEET: paperSheet.name, @@ -641,13 +651,12 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { }); }); - // The plan, at the ratio. A viewport's border is on a layer that does not plot. + // The plan, in its view. A viewport's border is on a layer that does not plot. d.layer("G-ANNO-NPLT", () => { const [width, height] = [viewport.right - viewport.left, viewport.top - viewport.bottom]; paperSheet.viewport([viewport.left, viewport.bottom], width, height, { + view: plan, center: planCenter, - ratio, - id: "plan", }); }); @@ -657,7 +666,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { const titleX = area.left + layout.clear + arrowRadius * 2 + 3 / 8; d.layer("C-ANNO-TITL", () => { d.insert("VIEW TITLE", [titleX, underline], { - attributes: { VIEW_TITLE: sheet.title, VIEW_SCALE: `SCALE: ${scaleLabel}` }, + attributes: { VIEW_TITLE: sheet.title, VIEW_SCALE: `SCALE: ${plan.label}` }, }); }); d.layer("C-ANNO-SYMB", () => { @@ -665,7 +674,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { d.insert("NORTH ARROW", [area.left + layout.clear + 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 viewport's ratio. + // plan at the view's scale. const bar = { x: titleX + titleLine + 3 / 4, length: 2, height: 1 / 8 }; const y = underline - bar.height; const stops = [0, 1 / 4, 1 / 2, 1]; // shares of the bar's length @@ -676,22 +685,22 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { d.line([bar.x, y + bar.height / 2], [bar.x + bar.length / 4, y + bar.height / 2]); for (const stop of stops) { const at: Point = [bar.x + bar.length * stop, y + bar.height + gap]; - put(at, String(stop * bar.length * feetPerInch), size.note, { align: "center" }); + d.text(at, String(stop * bar.length * feetPerInch), { role: "note", align: "center" }); } - put([bar.x + bar.length + 1 / 8, y], "FEET", size.note); + 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 }; - const leading = size.label * 1.8; // from one line to the next + 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; function columnHeading(title: string): void { - row -= size.heading; + row -= roles.heading.height; d.layer("C-ANNO-TITL", () => { - put([column.left, row], title, size.heading); + d.text([column.left, row], title, { role: "heading" }); d.line([column.left, row - gap], [column.left + column.width, row - gap]); }); row -= gap + leading * 1.2; @@ -704,22 +713,24 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { function table(rows: readonly (readonly string[])[], first: number): void { const starts = [0, first]; for (let i = 1; i < Math.max(...rows.map((cells) => cells.length)) - 1; i++) { - const widest = Math.max(...rows.map((cells) => widthOf(cells[i] ?? "", size.label))); + const widest = Math.max(...rows.map((cells) => widthOf(cells[i] ?? "", "label"))); starts.push((starts[i] ?? 0) + widest + 1 / 2); } for (const cells of rows) { - cells.forEach((cell, i) => put([column.left + (starts[i] ?? 0), row], cell, size.label)); + cells.forEach((cell, i) => { + d.text([column.left + (starts[i] ?? 0), row], cell, { role: "label" }); + }); row -= leading; } } /** Breaks a sentence into lines no longer than `width`, by the length each plots at. */ - function wrap(sentence: string, height: number, width: number): string[] { + function wrap(sentence: string, role: Role, width: number): string[] { const lines: string[] = []; let line = ""; for (const word of sentence.split(" ")) { const longer = line ? `${line} ${word}` : word; - if (line && widthOf(longer, height) > width) { + if (line && widthOf(longer, role) > width) { lines.push(line); line = word; } else { @@ -732,7 +743,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { columnHeading("SITE DATA"); d.layer("C-ANNO-TEXT", () => { const coverage = (footprintArea / lotArea) * 100; - const nameColumn = widthOf("BUILDING FOOTPRINT", size.label) + 1 / 2; + const nameColumn = widthOf("BUILDING FOOTPRINT", "label") + 1 / 2; table( [ ["PARCEL", lot.name], @@ -772,11 +783,11 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { "WORK IN THE PUBLIC RIGHT-OF-WAY NEEDS ITS OWN PERMIT.", "REPLACE ANY SIDEWALK OR PAVEMENT DAMAGED DURING CONSTRUCTION.", ]; - const indent = widthOf("00. ", size.label); + const indent = widthOf("00. ", "label"); notes.forEach((note, i) => { - put([column.left, row], `${i + 1}.`, size.label); - for (const line of wrap(note, size.label, column.width - indent)) { - put([column.left + indent, row], line, size.label); + d.text([column.left, row], `${i + 1}.`, { role: "label" }); + for (const line of wrap(note, "label", column.width - indent)) { + d.text([column.left + indent, row], line, { role: "label" }); row -= leading; } row -= leading / 3; @@ -792,13 +803,16 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { function legendRow(layer: keyof typeof standard.layers, name: string, draw: () => void): void { d.layer(layer, draw); d.layer("C-ANNO-TEXT", () => { - put([column.left + sample + 3 / 8, row], name, size.label); + d.text([column.left + sample + 3 / 8, row], name, { role: "label" }); }); row -= leading * 1.5; } const sampleLine = () => - d.line([column.left, row + size.label / 2], [column.left + sample, row + size.label / 2]); - const sampleCenter = (): Point => [column.left + sample / 2, row + size.label / 2]; + d.line( + [column.left, row + roles.label.height / 2], + [column.left + sample, row + roles.label.height / 2], + ); + const sampleCenter = (): Point => [column.left + sample / 2, row + roles.label.height / 2]; legendRow("C-PROP", "PROPERTY LINE", sampleLine); legendRow("C-PROP-ADJC", "ADJOINING LOT LINE", sampleLine); legendRow("C-SETB", "BUILDING SETBACK LINE", sampleLine); @@ -824,7 +838,7 @@ export function sitePlan(ratio: number = scale.ratio): Sheet { ["MAX.", "MAXIMUM"], ["SF", "SQUARE FEET"], ], - widthOf("F.F.E.", size.label) + 1 / 2, + widthOf("F.F.E.", "label") + 1 / 2, ); }); }); diff --git a/src/drawing.ts b/src/drawing.ts index 4f6e293..b066edd 100644 --- a/src/drawing.ts +++ b/src/drawing.ts @@ -27,7 +27,7 @@ import { fmt, written } from "./format.ts"; import { validIdHandle } from "./handles.ts"; import { layerAuditKey, layerKey, layoutNameKey } from "./layers.ts"; import { validLayoutHandles } from "./layouts.ts"; -import type { PaperUnit } from "./paper.ts"; +import { fromPaper, type PaperUnit } from "./paper.ts"; import { paperInfo, type Paper, type PaperInfo, type PlotStyleTable } from "./papers.ts"; import { BUILT_IN_LINETYPES, builtInLinetype } from "./linetypes.ts"; import { DrawingError, type Diagnostic, type LintRule } from "./lint.ts"; @@ -125,6 +125,7 @@ import { type LayerColor, type LayerName, type LayoutName, + type PaperLength, type PaperOf, type Positive, type R12Layouts, @@ -132,28 +133,58 @@ import { type TEXT_ALIGNS, type TEXT_VERTICAL_ALIGNS, } from "./rules.ts"; -import type { Standard, StandardLayer, StandardLinetype } from "./standard/document.ts"; +import type { + Standard, + StandardDimensionStyle, + StandardLayer, + StandardLinetype, + StandardScale, + StandardTextRole, +} from "./standard/document.ts"; import { + dimensionStyleLacks, + dimensionStyleReserved, disciplineLacks, DRAWN, layerLacks, + layoutPaperUnitsProblem, linetypeLacks, + noDefaultDimensionStyle, penLacks, penLineweightProblem, + roleBlockWithoutView, + roleLacks, + roleTextStyleProblem, + scaleLacks, unnamedDisciplineProblem, + viewNotDeclared, + writingLacks, + type DimensionStyleDefined, + type DimensionStyleOfStandard, type DisciplineWithoutStandard, + type InsertViewAndScale, type LinetypeDefined, type PenWithoutStandard, type DisciplineOfStandard, type LayerOfStandard, + type RoleAndHeight, + type RoleAndStyle, + type RoleOfStandard, + type RoleWithoutStandard, + type ScaleOfStandard, + type ScaleWithoutStandard, type StandardLayerOptions, type LinetypeOfStandard, type StandardLinetypeScale, type LineweightUnderStandard, type PenOfStandard, type StandardR12, + type ViewportId, + type ViewportRatio, + type ViewWithoutStandard, } from "./standard/drawn.ts"; import { adopted } from "./standard/made.ts"; +import { capitals } 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]; @@ -802,8 +833,9 @@ export type TextStyleChoice extends EntityOptions { - readonly height?: Positive; + R extends string = string, +> = EntityOptions & + TextPlacement & + Sized< + Z, + R, + Positive, + V extends "R12" ? typeof STANDARD_TEXT_STYLE | R12Text : TextStyleChoice + >; + +/** Where a text is turned and justified, and in model space the view it is read in. */ +interface TextPlacement { /** Degrees counterclockwise. */ readonly rotation?: number; - /** A text style of the drawing, or a definition of one. Defaults to `"Standard"`. R2018 only. */ - readonly style?: V extends "R12" ? typeof STANDARD_TEXT_STYLE | R12Text : TextStyleChoice; + /** In model space, the view a text with a role is read in, which `view()` returns. */ + readonly view?: Z extends Standard ? View : ViewWithoutStandard; /** Where `at` is along the text's line. Defaults to `"left"`. R2018 only. */ readonly align?: V extends "R12" ? "left" | R12Text : TextAlign; /** Where `at` is up and down the text's line. Defaults to `"baseline"`. R2018 only. */ readonly valign?: V extends "R12" ? "baseline" | R12Text : TextVerticalAlign; } +/** + * 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. + */ +type Sized = + | Size< + Z extends Standard ? RoleOfStandard : RoleWithoutStandard, + RoleOr, + RoleOr + > + | (Z extends Standard ? (string extends R ? never : Size) : never); + +/** + * `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. + */ +type RoleOr = Z extends Standard + ? string extends R + ? Given + : Message + : Given; + +interface Size { + /** + * A text role of the drawing's standard, which gives the text its text style and the height it + * plots at: that height on a layout and in a block, and `view.paper` of it in model space. + */ + readonly role?: Role; + /** Defaults to 0.125. A text with a role takes the role's. */ + readonly height?: Height; + /** + * A text style of the drawing, or a definition of one. Defaults to `"Standard"`. R2018 only. A + * text with a role is in the role's text style. + */ + readonly style?: Style; +} + /** What `block()` sets on a block. */ export interface BlockOptions { /** The point of the block that an insert puts at its `at`. Defaults to `[0, 0]`. */ @@ -837,48 +920,74 @@ export interface BlockOptions { * rotation, style and justification, which are a text's. * * @typeParam D - The text style as written, a name or a definition, checked as a text's is. + * @typeParam R - The role as written. */ -export interface AttributeOptions< +export type AttributeOptions< H extends number = number, S extends string = string, D extends TextStyleValue = TextStyleDefinition, -> { + Z extends Standard | undefined = undefined, + R extends string = string, +> = AttributeFields & Sized, TextStyleChoice>; + +/** What an attribute definition holds besides its role, height and style. */ +interface AttributeFields { /** The value of an insert that does not set the attribute. Defaults to `""`. */ readonly value?: string; /** What CAD asks for when the block is inserted by hand. Defaults to the tag. */ readonly prompt?: string; - readonly height?: Positive; /** Degrees counterclockwise. */ readonly rotation?: number; - /** A text style of the drawing, or a definition of one. Defaults to `"Standard"`. */ - readonly style?: TextStyleChoice; /** Where `at` is along the text's line. Defaults to `"left"`. */ readonly align?: TextAlign; /** Where `at` is up and down the text's line. Defaults to `"baseline"`. */ readonly valign?: TextVerticalAlign; } -/** The options of `insert()`. */ -export interface InsertOptions< +/** + * The options of `insert()`. Under a standard they are one of two whole shapes, with a view or + * with a scale, and each branch of a conditional is checked as written. + * + * @typeParam Z - The drawing's standard, or `undefined` for none. + */ +export type InsertOptions< S extends number | readonly [number, number] = number, I extends string = string, -> { + Z extends Standard | undefined = undefined, +> = InsertFields & + (Z extends Standard + ? + | (Scaled & { readonly view: View }) + | Scaled> + : Scaled>); + +/** What an insert holds besides its scale and its view. */ +interface InsertFields { /** * A name for the insert that no other entity of the drawing has. The insert's attributes * keep their handles with it. */ readonly id?: EntityId; - /** - * One number for both axes, or `[x, y]`. Defaults to 1. A negative scale mirrors the block, - * and no scale may be 0. - */ - readonly scale?: InsertScale; /** Degrees counterclockwise, about the insert's point. Defaults to 0. */ readonly rotation?: number; /** A value for each attribute to set, by its tag. The others have their default values. */ readonly attributes?: Readonly>; } +interface Scaled { + /** + * One number for both axes, or `[x, y]`. Defaults to 1. A negative scale mirrors the block, + * and no scale may be 0. An insert with a view takes the view's. + */ + readonly scale?: Scale; + /** + * In model space, the view the insert is read in, which `view()` returns. The insert's scale + * is then `view.paper(1)`, so a block drawn in paper units plots at its size. An insert in model + * space of a block that holds texts with roles names one. + */ + readonly view?: ViewOf; +} + /** * A dimension style to define: a `DimensionStyle`, checked. Its sizes are lengths on paper, in * the unit that suits the sheet, and `scale` turns them into the units of the space its @@ -944,6 +1053,7 @@ interface DimensionOptions< readonly text?: DimensionText; /** A name for the dimension that no other entity of the drawing has. */ readonly id?: EntityId; + readonly view?: ViewWithoutStandard; } /** The options of `linearDimension()`. */ @@ -966,6 +1076,46 @@ export type AlignedDimensionOptions< I extends string = string, > = DimensionOptions; +/** + * What a dimension is drawn with under the standard `Z`: a dimension style of the standard by its + * id, and in model space the view it is read in. The builder defines the style's AutoCAD style on + * first use, with the standard's paper sizes, its role's text style and height, and the writing of + * the units it is drawn in: `DIMS-1in-10ft` for the style `dims` in a view at `1in-10ft`, scaled + * by `view.paper(1)`, and `DIMS` on a layout. + */ +export interface StandardAlignedDimensionOptions< + S extends string = string, + T extends string = string, + I extends string = string, + Z extends Standard = Standard, +> { + /** A dimension style of the standard, by its id. Defaults to its `defaultDimensionStyle`. */ + readonly style?: DimensionStyleOfStandard; + /** In model space, the view the dimension is read in, which `view()` returns. */ + readonly view?: View; + /** + * A text to draw in place of the measurement, in which `<>` stands for the measurement: + * `"<> TYP"`, or `"VARIES"`. + */ + readonly text?: DimensionText; + /** A name for the dimension that no other entity of the drawing has. */ + readonly id?: EntityId; +} + +/** The options of `linearDimension()` under the standard `Z`. */ +export interface StandardLinearDimensionOptions< + S extends string = string, + T extends string = string, + I extends string = string, + Z extends Standard = Standard, +> extends StandardAlignedDimensionOptions { + /** + * The direction it measures along: `"x"`, the default, `"y"`, or an angle in degrees + * counterclockwise from +X. + */ + readonly along?: "x" | "y" | number; +} + /** What `layout()` sets on a layout. */ export interface LayoutOptions

{ /** One of `PAPERS`, such as `"ARCH D"`. */ @@ -985,14 +1135,53 @@ export interface ViewportOptions; /** A name for the viewport that no entity and no other viewport of the drawing has. */ readonly id?: EntityId; + readonly view?: ViewWithoutStandard; +} + +/** What `viewport()` takes after the rectangle under a standard: the view it shows. */ +export interface StandardViewportOptions { + /** + * The view the viewport shows, which `view()` returns. The viewport takes its id, and its ratio + * from its scale. One viewport shows a view. + */ + readonly view: View; + /** The point of model space shown at the middle of the viewport, in drawing units. */ + readonly center: Point; + readonly ratio?: ViewportRatio; + readonly id?: ViewportId; +} + +/** + * A view: a scale of the drawing's standard under an id, which `view()` declares. One viewport + * shows it, and takes its id. A text, an insert or a dimension of model space names the view it is + * read in, so that it plots at its size in paper units. + */ +export interface View { + /** The view's id, an entity id of the drawing, which the viewport that shows it takes. */ + readonly id: I; + /** The id of its scale in the standard. */ + readonly scale: C; + /** The scale's label, such as `1" = 10'`. */ + readonly label: string; + /** The scale's ratio, such as 120 for 1" = 10'. */ + readonly ratio: number; + /** + * A length on paper, in the standard's paper units, in drawing units at the view's ratio, as + * `fromPaper(length, standard.paperUnits, { ratio, units })` works it out for the drawing's + * units. A length that `fromPaper` refuses throws a `DrawingError` under `geometry`, with the + * call. + */ + paper(length: PaperLength): number; } /** * The layout being drawn, which `layout()` gives its `draw`: the sheet's size, and the one * thing only a layout can hold, a viewport. Everything else is drawn with the builder's own * methods, which put it on the sheet while `draw` runs. + * + * @typeParam Z - The drawing's standard, or `undefined` for none. */ -export interface Sheet { +export interface Sheet { /** The layout's name. */ readonly name: string; readonly paper: Paper; @@ -1004,13 +1193,14 @@ export interface Sheet { /** * 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 - * its view is locked against zooming. + * its view is locked against zooming. Under a standard it shows the options' `view`, at the + * view's scale and with the view's id. */ viewport( corner: Point, width: Positive, height: Positive, - view: ViewportOptions, + view: Z extends Standard ? StandardViewportOptions : ViewportOptions, ): this; } @@ -1031,6 +1221,8 @@ interface OpenBlock { /** The handles and the id keys taken for the block and what is drawn in it. */ readonly handles: string[]; readonly ids: string[]; + /** True once a text or attribute with a role is in it, or an insert of a block that has one. */ + roles: boolean; } /** A layout while its `draw` runs. */ @@ -1047,10 +1239,18 @@ interface OpenLayout { /** The handles and the id keys taken for the layout and what is drawn on it. */ readonly handles: string[]; readonly ids: string[]; + /** The views its viewports show. */ + readonly views: Shown[]; /** True once `draw` has returned or thrown. */ closed: boolean; } +/** A view the drawing declared, and whether a viewport shows it. */ +interface Shown { + readonly view: View; + shown: boolean; +} + /** A broken rule, thrown inside a builder method and turned into a `DrawingError` on its way out. */ class Problem { readonly rule: LintRule; @@ -1125,8 +1325,11 @@ function recordsCalls(): boolean { * diagnostic has the rule, the entity's `id` and the `call` that broke it. * * Drawn to a standard, `Z`, the drawing's layers, linetypes, pens and linetype scale are the - * standard's: `layer()` names a layer of it, and an entity's `pen` one of its pens. `D` is the - * drawing's discipline as written, which the compiler checks against the standard's. + * standard's: `layer()` names a layer of it, and an entity's `pen` one of its pens. A text's + * `role` names a text role of it, `view(id, { scale })` declares a view at one of its scales, + * which a viewport shows and the annotation of model space names, and a dimension names a + * dimension style of it by id. `D` is the drawing's discipline as written, which the compiler + * checks against the standard's. */ export class DrawingBuilder< V extends DxfVersion = "R2018", @@ -1181,6 +1384,10 @@ export class DrawingBuilder< readonly #dimensionStylesAudited = new Map(); /** The blocks of the arrowheads the dimension styles use, whose handles are taken. */ readonly #arrowheadBlocks = new Set(); + /** The views `view()` declared, by `idKey` of their ids. */ + readonly #views = new Map(); + /** The blocks that hold texts with roles, directly or in a block they insert, by `layerKey`. */ + readonly #roleBlocks = new Set(); #current = "0"; constructor(options: DrawingOptions = {}) { @@ -1239,7 +1446,8 @@ 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`. + * `txt.shx`. Under a standard, a role's text style is the standard's, and `textStyle()` defines + * none of that name. */ textStyle< N extends string, @@ -1248,11 +1456,16 @@ export class DrawingBuilder< O extends number = number, >(style: 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)); + } this.#defineTextStyle(definition); } catch (thrown) { - const name: unknown = definition?.name; const about = typeof name === "string" ? { textStyle: name } : {}; throw located(thrown, DrawingBuilder.prototype.textStyle, about); } @@ -1376,13 +1589,7 @@ export class DrawingBuilder< } } const [name, entry] = found; - let fields: Omit = entry; - if (entry.byDiscipline) { - if (drawn.discipline === undefined) { - refuse("standard", unnamedDisciplineProblem(name, entry)); - } - fields = { ...entry, ...entry.byDiscipline[drawn.discipline] }; - } + const fields: StandardLayer = this.#inDiscipline("Layer", name, entry); const pen = fields.pen === undefined ? undefined : drawn.standard.pens[fields.pen]; return this.#layer(name, { color: fields.color as LayerColor, @@ -1395,6 +1602,77 @@ export class DrawingBuilder< } as LayerOptions); } + /** + * Declares a view: a scale of the standard, by its id, under an id of the drawing's, which + * `view()` takes. One viewport shows it, with its id and its scale's ratio, and a text, an + * insert or a dimension of model space names the view it is read in. `view.paper(length)` is a + * length on paper in drawing units at the view's ratio. + */ + view( + id: EntityId, + // Written out here: as an interface generic in `Z`, a builder without a standard would not be + // assignable to one whose standard may be any. + options: { + /** A scale of the drawing's standard, by its id, such as `"1in-10ft"`. */ + readonly scale: Z extends Standard ? ScaleOfStandard : ScaleWithoutStandard; + }, + ): View { + const requested: unknown = id; + try { + const drawn = this.#standard; + if (!drawn) return refuse("scale-outside-standard", DRAWN.scaleWithoutStandard); + const key = this.#idKey(requested); + this.#freeIdHandle(requested as string); + const scale: unknown = (options as { readonly scale?: unknown } | undefined)?.scale; + const found = typeof scale === "string" ? drawn.scales.get(idKey(scale)) : undefined; + if (found === undefined) { + return refuse( + "scale-outside-standard", + typeof scale === "string" + ? scaleLacks(scale, drawn.standard) + : "A view's scale is named by a string", + ); + } + const [scaleId, { label, ratio }] = found; + const { units } = this; + const { paperUnits } = drawn.standard; + const paper = (length: PaperLength): number => { + try { + try { + return fromPaper(length, paperUnits, { ratio, units }); + } catch (thrown) { + return refuse("geometry", (thrown as Error).message); + } + } catch (thrown) { + throw located(thrown, paper, { id: requested as string }); + } + }; + const view: View = Object.freeze({ + id: requested as I, + scale: scaleId as C, + label, + ratio, + paper, + }); + // The view belongs to the drawing, also when it is declared while a block or a layout is drawn. + this.#claim( + validIdHandle(requested as string), + `the id ${JSON.stringify(requested)}`, + requested as string, + [], + ); + this.#ids.set(key, requested as string); + this.#views.set(key, { view, shown: false }); + return view; + } catch (thrown) { + throw located( + thrown, + DrawingBuilder.prototype.view, + typeof requested === "string" ? { id: requested } : {}, + ); + } + } + line< T extends string = string, W extends number = number, @@ -1524,11 +1802,12 @@ export class DrawingBuilder< S extends string = string, const D extends TextStyleValue = TextStyleDefinition, P extends string = string, - >(at: Point, value: string, options: TextOptions = {}): this { + R extends string = string, + >(at: Point, value: string, options: TextOptions = {}): this { return this.#add(DrawingBuilder.prototype.text, options, () => ({ kind: "text", layer: this.#current, - ...this.#textFields(at, value, options as TextOptions), + ...this.#textFields(at, value, options as unknown as TextOptions), })); } @@ -1589,6 +1868,7 @@ export class DrawingBuilder< attributeCalls: [], handles: taken, ids: [], + roles: false, }; } catch (thrown) { throw located(thrown, DrawingBuilder.prototype.block, { block: requested }); @@ -1615,6 +1895,7 @@ export class DrawingBuilder< recordCalls(open.entities, open.calls); recordCalls(open.attributes, open.attributeCalls); } + if (open.roles) this.#roleBlocks.add(layerKey(requested)); this.#blocks.set(layerKey(requested), { name: open.name, ...(open.base ? { base: open.base } : {}), @@ -1675,10 +1956,11 @@ export class DrawingBuilder< H extends number = number, S extends string = string, const D extends TextStyleValue = TextStyleDefinition, + R extends string = string, >( tag: V extends "R12" ? R12Blocks : AttributeTag, at: Point, - options: AttributeOptions = {}, + options: AttributeOptions = {}, ): this { const requested: unknown = tag; const open = this.#open; @@ -1701,7 +1983,7 @@ export class DrawingBuilder< const problem = typeof prompt === "string" ? textProblem(prompt) : "must be a string"; if (problem) refuse("text", `prompt: ${problem}`); } - const fields = this.#textFields(at, options.value ?? "", options as TextOptions); + const fields = this.#textFields(at, options.value ?? "", options as unknown as TextOptions); this.#claim( definitionHandle(open.name, named), `the attribute ${JSON.stringify(named)} of the block ${JSON.stringify(open.name)}`, @@ -1712,6 +1994,7 @@ export class DrawingBuilder< layer: this.#current, ...fields, }); + if (options.role !== undefined) open.roles = true; } catch (thrown) { const about = typeof requested === "string" ? { attribute: requested } : {}; throw located(thrown, DrawingBuilder.prototype.attribute, { @@ -1725,7 +2008,9 @@ export class DrawingBuilder< /** * Places a block or an xref of the drawing with its base point at `at`, on the current - * layer. Inside `block()`, it places it in the block being drawn. + * layer. Inside `block()`, it places it in the block being drawn. In model space under a + * standard, `view` scales it by `view.paper(1)`, so a block drawn in paper units plots at its + * size. */ insert< N extends string, @@ -1734,7 +2019,7 @@ export class DrawingBuilder< >( block: V extends "R12" ? R12Blocks : BlockName, at: Point, - options: InsertOptions = {}, + options: InsertOptions = {}, ): this { return this.#add(DrawingBuilder.prototype.insert, options, () => { if (this.dxf === "R12") refuse("block", R12_ONLY.blocks); @@ -1761,7 +2046,17 @@ export class DrawingBuilder< block: (placed ?? xref)!.name, at: pt(at), }; - const scale: unknown = options.scale; + 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) { @@ -1828,7 +2123,8 @@ export class DrawingBuilder< * line, the gap under the text, and how a measurement is written. The sizes are the sizes a * dimension plots at, and `scale` multiplies each: `fromPaper(1, "in", view)` for dimensions * in model space that a viewport shows at the view's ratio, and 1, the default, for - * dimensions on a layout. A style is defined once, under a name no other has. + * dimensions on a layout. A style is defined once, under a name no other has. A drawing drawn + * to a standard defines none: its dimensions name the standard's styles by their ids. */ dimensionStyle< N extends string, @@ -1845,13 +2141,16 @@ export class DrawingBuilder< SUF extends string = string, MS extends number = number, >( - style: V extends "R12" - ? R12Dimensions - : DimensionStyleDefinition, + style: Z extends Standard + ? DimensionStyleDefined + : V extends "R12" + ? R12Dimensions + : DimensionStyleDefinition, ): this { const definition = style as unknown as DimensionStyle; try { if (this.dxf === "R12") refuse("dimension-style", R12_ONLY.dimensions); + if (this.#standard) refuse("dimension-style-outside-standard", DRAWN.dimensionStyleDefined); this.#defineDimensionStyle(definition); } catch (thrown) { const name: unknown = (definition as { name?: unknown } | null)?.name; @@ -1942,7 +2241,11 @@ export class DrawingBuilder< from: Point, to: Point, line: Point, - options: V extends "R12" ? R12Dimensions : LinearDimensionOptions, + options: Z extends Standard + ? StandardLinearDimensionOptions + : V extends "R12" + ? R12Dimensions + : LinearDimensionOptions, ): this { return this.#add(DrawingBuilder.prototype.linearDimension, idOf(options), () => { const o = options as unknown as LinearDimensionOptions | undefined; @@ -1977,7 +2280,11 @@ export class DrawingBuilder< from: Point, to: Point, offset: number, - options: V extends "R12" ? R12Dimensions : AlignedDimensionOptions, + options: Z extends Standard + ? StandardAlignedDimensionOptions + : V extends "R12" + ? R12Dimensions + : AlignedDimensionOptions, ): this { return this.#add(DrawingBuilder.prototype.alignedDimension, idOf(options), () => this.#dimension(options as unknown as AlignedDimensionOptions | undefined, (style, text) => ({ @@ -1994,23 +2301,30 @@ export class DrawingBuilder< /** Checks what a dimension names and what it measures, and returns the dimension `make` gives. */ #dimension( - options: { readonly style?: unknown; readonly text?: unknown } | undefined, + options: + | { readonly style?: unknown; readonly text?: unknown; readonly view?: unknown } + | undefined, make: (style: string, text: { readonly text?: string }) => D, ): D { if (this.dxf === "R12") refuse("dimension", R12_ONLY.dimensions); if (this.#open) refuse("dimension", DIMENSION_IN_BLOCK); - const named: unknown = options?.style; - const nameProblem = dimensionStyleNameProblem(named); - if (nameProblem) refuse("undefined-dimension-style", nameProblem); - const style = this.#dimensionStyles.get(layerKey(named as string)); - if (!style) { - const absent = undefinedDimensionStyleProblem(named as string); - refuse( - "undefined-dimension-style", - isStandardDimensionStyle(named as string) - ? absent - : `${absent}. Define it with dimensionStyle() first`, - ); + let style: DimensionStyle | undefined; + if (this.#standard) style = this.#standardDimensionStyle(options?.style, options?.view); + else { + if (options?.view !== undefined) refuse("dimension-style", DRAWN.viewWithoutStandard); + const named: unknown = options?.style; + const nameProblem = dimensionStyleNameProblem(named); + if (nameProblem) refuse("undefined-dimension-style", nameProblem); + style = this.#dimensionStyles.get(layerKey(named as string)); + if (!style) { + const absent = undefinedDimensionStyleProblem(named as string); + refuse( + "undefined-dimension-style", + isStandardDimensionStyle(named as string) + ? absent + : `${absent}. Define it with dimensionStyle() first`, + ); + } } const text: unknown = options?.text; if (text !== undefined) { @@ -2042,6 +2356,68 @@ export class DrawingBuilder< return made; } + /** + * The AutoCAD style of a dimension style of the standard, where the dimension is drawn: its id + * in capitals on a layout, with a scale of 1 and the writing of the paper's units, and with the + * view's scale id after it in model space, with a scale of `view.paper(1)` and the writing of + * the drawing's units. It is defined on first use, with its role's text style. + */ + #standardDimensionStyle(named: unknown, view: unknown): DimensionStyle { + const drawn = this.#standard!; + const id = named === undefined ? drawn.standard.defaultDimensionStyle : named; + if (id === undefined) { + return refuse("dimension-style-outside-standard", noDefaultDimensionStyle(drawn.standard)); + } + const found = typeof id === "string" ? drawn.dimensionStyles.get(idKey(id)) : undefined; + if (found === undefined) { + return refuse( + "dimension-style-outside-standard", + typeof id === "string" + ? (dimensionStyleReserved(id) ?? dimensionStyleLacks(id, drawn.standard)) + : "A dimension style is named by a string", + ); + } + const [styleId, entry] = found; + const fields = this.#inDiscipline("Dimension style", styleId, entry); + const sheet = this.#sheet; + let name = capitals(styleId); + let scale = 1; + let units: string = this.units; + if (sheet) { + if (view !== undefined) refuse("dimension-style", DRAWN.dimensionViewOnPaper); + units = sheet.info.units; + } else { + if (view === undefined) refuse("dimension-style", DRAWN.dimensionWithoutView); + const shown = this.#declared(view, "dimension-style").view; + name = `${name}-${shown.scale}`; + scale = onPaper(shown, 1, "scale"); + } + const writing = fields.writing[units as Units]; + if (writing === undefined) + refuse("dimension-style", writingLacks(styleId, units, drawn.standard)); + const existing = this.#dimensionStyles.get(layerKey(name)); + if (existing !== undefined) return existing; + const role = drawn.textRoles.get(idKey(fields.role))!; + const { precision, prefix, suffix, suppressZeros, measurementScale } = writing!; + this.#defineDimensionStyle({ + name, + textStyle: this.#roleStyle(role), + textHeight: this.#roleFields(role).height, + arrowhead: fields.arrowhead, + arrowSize: fields.arrowSize, + extensionOffset: fields.extensionOffset, + extensionBeyond: fields.extensionBeyond, + textGap: fields.textGap, + scale, + precision, + ...(prefix === undefined ? {} : { prefix }), + ...(suffix === undefined ? {} : { suffix }), + ...(suppressZeros === undefined ? {} : { suppressZeros }), + measurementScale, + }); + return this.#dimensionStyles.get(layerKey(name))!; + } + /** * Adds a layout: a sheet of `options.paper` that AutoCAD shows as a tab and plots as one * page, at 1:1 on `DWG To PDF.pc3`. What `draw` draws goes on the sheet and not in model @@ -2053,7 +2429,7 @@ export class DrawingBuilder< layout( name: V extends "R12" ? R12Layouts : LayoutName, options: LayoutOptions

, - 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; @@ -2085,6 +2461,11 @@ export class DrawingBuilder< const paper: unknown = options?.paper; 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)); + } const plotStyle: unknown = options.plotStyle; if (plotStyle !== undefined) { const styleProblem = plotStyleProblem(plotStyle); @@ -2096,7 +2477,7 @@ export class DrawingBuilder< open = { name: requested, paper: paper as Paper, - info: paperInfo(paper as Paper)!, + info, plotStyle: plotStyle as PlotStyleTable | undefined, viewports: [], entities: [], @@ -2104,6 +2485,7 @@ export class DrawingBuilder< viewportCalls: [], handles: taken, ids: [], + views: [], closed: false, }; } catch (thrown) { @@ -2117,14 +2499,14 @@ export class DrawingBuilder< const previous = this.#current; this.#current = "0"; this.#sheet = open; - const sheet: Sheet = { + const sheet: Sheet = { name: open.name, paper: open.paper, units: open.info.units, width: open.info.width, height: open.info.height, viewport: (corner, width, height, view) => { - this.#viewport(sheet.viewport, open, corner, width, height, view as ViewportOptions); + this.#viewport(sheet.viewport, open, corner, width, height, view); return sheet as never; }, }; @@ -2135,6 +2517,7 @@ export class DrawingBuilder< 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; } finally { this.#current = previous; @@ -2162,9 +2545,13 @@ export class DrawingBuilder< corner: Point, width: unknown, height: unknown, - view: ViewportOptions, + options: unknown, ): void { - const id: unknown = (view as ViewportOptions | undefined)?.id; + const view = options as + | { readonly center?: Point; readonly ratio?: unknown; readonly id?: unknown } + | undefined; + const shows: unknown = (options as { readonly view?: unknown } | undefined)?.view; + let id: unknown = view?.id; try { if (open.closed) { refuse( @@ -2172,7 +2559,19 @@ export class DrawingBuilder< "A viewport is added while its layout is drawn. Call viewport() inside the draw of layout()", ); } - const key = id === undefined ? undefined : this.#idKey(id); + let shown: Shown | undefined; + if (this.#standard) { + if (view?.ratio !== undefined || shows === undefined) + refuse("viewport", DRAWN.viewportRatio); + if (id !== undefined) refuse("viewport", DRAWN.viewportId); + shown = this.#declared(shows, "viewport"); + id = shown.view.id; + if (shown.shown) refuse("duplicate-id", duplicateIdProblem(shown.view.id, shown.view.id)); + } else if (shows !== undefined) { + refuse("viewport", DRAWN.viewWithoutStandard); + } + // A view's id was taken when view() declared it. + const key = id === undefined || shown ? undefined : this.#idKey(id); if (key !== undefined) this.#freeIdHandle(id as string); const made = { ...(id === undefined ? {} : { id: id as string }), @@ -2181,11 +2580,15 @@ export class DrawingBuilder< width, height, center: view?.center, - ratio: view?.ratio, + ratio: shown ? shown.view.ratio : view?.ratio, }; const problem = viewportProblem(made, open.info.units, this.units); if (problem) refuse("viewport", problem); - open.viewports.push({ ...(made as Viewport), corner: pt(corner), center: pt(view.center) }); + open.viewports.push({ ...(made as Viewport), corner: pt(corner), center: pt(view!.center!) }); + if (shown) { + shown.shown = true; + open.views.push(shown); + } if (key !== undefined) { this.#ids.set(key, id as string); open.ids.push(key); @@ -2234,17 +2637,20 @@ export class DrawingBuilder< ): Pick { const problem = typeof value === "string" ? textProblem(value) : "Text must be a string"; if (problem) refuse("text", problem); + const role = this.#role(options); const text: { -readonly [K in keyof Text]?: Text[K] } & Pick< Text, "at" | "value" | "height" | "rotation" > = { at: pt(at), value, - height: positive(options.height ?? 0.125, "height"), + height: positive(role ? role.height : (options.height ?? 0.125), "height"), rotation: kept(options.rotation ?? 0, "rotation"), }; const r12 = this.dxf === "R12"; - if (options.style !== undefined) { + if (role) { + text.style = role.style; + } else if (options.style !== undefined) { const named: unknown = options.style; // An R12 drawing has no style to look up but Standard. if (r12 && !(typeof named === "string" && isStandardTextStyle(named))) { @@ -2273,6 +2679,80 @@ export class DrawingBuilder< return text; } + /** + * The height and text style of a text with a role, where it is drawn: the role's height in a + * block and on a layout, and `view.paper` of it in model space. Undefined for a text with no + * role and no view. + */ + #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 (!drawn) { + return refuse( + "role", + role === undefined ? DRAWN.viewWithoutStandard : DRAWN.roleWithoutStandard, + ); + } + if (role === undefined) return refuse("role", DRAWN.viewWithoutRole); + const found = typeof role === "string" ? drawn.textRoles.get(idKey(role)) : undefined; + if (found === undefined) { + return refuse( + "role", + typeof role === "string" ? roleLacks(role, drawn.standard) : "A role is named by a string", + ); + } + if (options.height !== undefined) refuse("role", DRAWN.roleAndHeight); + if (options.style !== undefined) refuse("role", DRAWN.roleAndStyle); + let height = this.#roleFields(found).height; + if (this.#open || this.#sheet) { + if (view !== undefined) refuse("role", DRAWN.roleViewOnPaper); + } else { + if (view === undefined) refuse("role", DRAWN.roleWithoutView); + height = onPaper(this.#declared(view, "role").view, height, "height"); + } + return { height, style: this.#roleStyle(found) }; + } + + /** A role's fields in the drawing's discipline. */ + #roleFields([id, role]: readonly [string, StandardTextRole]): StandardTextRole { + return this.#inDiscipline("Text role", id, role); + } + + /** The text style of a role, which enters the drawing when the role is first used. */ + #roleStyle(found: readonly [string, StandardTextRole]): string { + const { font, widthFactor, obliqueAngle } = this.#roleFields(found); + return this.#defineTextStyle( + { name: capitals(found[0]), font, widthFactor, obliqueAngle } as TextStyleDefinition, + true, + ); + } + + /** + * An entry of the standard as the drawing's discipline resolves it: with that discipline's + * fields from `byDiscipline`. An entry with `byDiscipline` in a drawing that names no + * discipline is refused. + */ + #inDiscipline( + what: string, + name: string, + entry: E, + ): E { + if (!entry.byDiscipline) return entry; + const discipline = this.#standard!.discipline; + if (discipline === undefined) refuse("standard", unnamedDisciplineProblem(what, name, entry)); + return { ...entry, ...entry.byDiscipline[discipline!] }; + } + + /** The view `view()` of this drawing returned, or a refusal under `rule`. */ + #declared(view: unknown, rule: LintRule): Shown { + const id: unknown = (view as { readonly id?: unknown } | null | undefined)?.id; + const shown = typeof id === "string" ? this.#views.get(idKey(id)) : undefined; + if (shown === undefined || shown.view !== view) return refuse(rule, viewNotDeclared(view)); + return shown; + } + /** * Checks the entity that `make` gives and the `EntityOptions` it was drawn with, whose type * parameters differ from method to method, and stores it. `method` is the builder method the @@ -2287,6 +2767,7 @@ export class DrawingBuilder< const named = id === undefined ? {} : { id: id as string }; const made = { ...make(), ...named, ...this.#style(o) } as Entity; (this.#open ?? this.#sheet ?? { entities: this.#entities }).entities.push(made); + if (this.#open && this.#drawsRoles(made, o)) this.#open.roles = true; if (key !== undefined) { this.#ids.set(key, id as string); (this.#open ?? this.#sheet)?.ids.push(key); @@ -2304,6 +2785,12 @@ export class DrawingBuilder< return this; } + /** True for a text with a role, and for an insert of a block that holds one. */ + #drawsRoles(made: Entity, options: object): boolean { + if (made.kind === "text") return (options as { readonly role?: unknown }).role !== undefined; + return made.kind === "insert" && this.#roleBlocks.has(layerKey(made.block)); + } + /** Checks an id the drawing does not have yet, and returns the key it is kept by. */ #idKey(id: unknown): string { const problem = idProblem(id); @@ -2558,6 +3045,15 @@ interface Drawn { readonly linetypes: ReadonlyMap; /** Each pen's lineweight, by `idKey` of its id. */ readonly pens: ReadonlyMap; + /** Each text role, scale and dimension style as the standard spells its id, by `idKey`. */ + readonly textRoles: ReadonlyMap; + readonly scales: ReadonlyMap; + readonly dimensionStyles: ReadonlyMap< + string, + readonly [id: string, style: StandardDimensionStyle] + >; + /** The id of each text role, by `layerKey` of its text style's name. */ + readonly roleStyles: ReadonlyMap; /** The drawing's discipline, as the standard spells it. */ readonly discipline?: string; } @@ -2582,15 +3078,19 @@ function drawnTo( if (options.linetypeScale !== undefined) refuse("standard", DRAWN.linetypeScale); let drawn = drawnStandards.get(standard); if (!drawn) { - const byName = (entries: Readonly>) => - new Map( - Object.entries(entries).map(([name, entry]) => [layerKey(name), [name, entry] as const]), - ); + const byKey = (entries: Readonly>, key: (name: string) => string) => + new Map(Object.entries(entries).map(([name, entry]) => [key(name), [name, entry] as const])); drawn = { standard, - layers: byName(standard.layers), - linetypes: byName(standard.linetypes), + layers: byKey(standard.layers, layerKey), + linetypes: byKey(standard.linetypes, layerKey), pens: new Map(Object.entries(standard.pens).map(([id, pen]) => [idKey(id), pen.lineweight])), + 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]), + ), }; drawnStandards.set(standard, drawn); } @@ -2645,6 +3145,15 @@ function lostSide(size: string, axis: string, at: number): string { return `${size}: This value is too small at ${axis} = ${at}. Both sides of the rectangle are written as ${axis} = ${fmt(at)}, because tsdraft writes 6 decimals`; } +/** A length on paper in drawing units at a view's ratio, refused when it cannot be written. */ +function onPaper(view: View, length: number, label: string): number { + try { + return view.paper(length); + } catch (thrown) { + return refuse("geometry", `${label}: ${(thrown as Error).message}`); + } +} + /** An angle a dimension measures along, refused beyond a turn, and turned to less than one. */ function turned(angle: number): number { const problem = dimensionAngleProblem(angle); diff --git a/src/index.ts b/src/index.ts index a3d108c..cf8d516 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,9 @@ export { type Point, type Polyline, type Sheet, + type StandardAlignedDimensionOptions, + type StandardLinearDimensionOptions, + type StandardViewportOptions, type Text, type TextAlign, type TextOptions, @@ -66,6 +69,7 @@ export { type TextStyleDefinition, type TextVerticalAlign, type Units, + type View, type Viewport, type ViewportOptions, type Xref, diff --git a/src/lint.ts b/src/lint.ts index fbff826..f0b2287 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -159,7 +159,10 @@ export type LintRule = | "layer-outside-standard" | "layer-differs-from-standard" | "linetype-outside-standard" - | "pen-outside-standard"; + | "pen-outside-standard" + | "role" + | "scale-outside-standard" + | "dimension-style-outside-standard"; export interface Diagnostic { readonly rule: LintRule; diff --git a/src/standard/drawn.ts b/src/standard/drawn.ts index bef6c7c..0e20a8d 100644 --- a/src/standard/drawn.ts +++ b/src/standard/drawn.ts @@ -3,7 +3,7 @@ // `src/rules.ts` holds the rules of a drawing. A literal names an entry exactly as the standard // spells it, and the builder, as AutoCAD does, finds one in any case. import type { LayerName, LinetypeName } from "../rules.ts"; -import type { Standard, StandardLayer } from "./document.ts"; +import type { Standard } from "./document.ts"; /** How a message names a standard. */ type Of = `the standard "${Z["id"]}" version ${Z["version"]}`; @@ -31,6 +31,38 @@ const PEN_WITHOUT_STANDARD = const PEN_AND_LINEWEIGHT = "An entity takes a pen or a lineweight, not both"; const NOT_A_STANDARD = "standard must be a Standard that defineStandard made, unchanged, and this one does not check"; +const ROLE_WITHOUT_STANDARD = + "role names a text role of the drawing's standard, and this drawing has none. Draw to a standard with createDrawing({ standard }), or give the text a height"; +const ROLE_AND_HEIGHT = + "A text with a role plots at the role's height. Leave height out, or change the role in the standard"; +const ROLE_AND_STYLE = + "A text with a role is in the role's text style. Leave style out, or change the role in the standard"; +const ROLE_WITHOUT_VIEW = + "A text of model space with a role names the view it is read in, which sizes it to plot at the role's height: give it view, which view() returns"; +const ROLE_VIEW_ON_PAPER = + "A text in a block or on a layout is drawn in paper units, at its role's height, and names no view. Leave view out"; +const VIEW_WITHOUT_ROLE = + "A text names a view to plot at its role's height. Give it a role too, or leave view out"; +const VIEW_WITHOUT_STANDARD = + "view names a view that view() declares in a drawing drawn to a standard, and this drawing has none. Draw to a standard with createDrawing({ standard })"; +const SCALE_WITHOUT_STANDARD = + "view() declares a view at a scale of the drawing's standard, and this drawing has none. Draw to a standard with createDrawing({ standard }), or give a viewport its ratio"; +const INSERT_VIEW_AND_SCALE = + "An insert with a view is scaled by the view, to plot its block at the block's size in paper units. Leave scale out, or leave view out for a block drawn at its size in the model"; +const INSERT_VIEW_ON_PAPER = + "An insert in a block or on a layout is in paper units, and names no view. Leave view out"; +const VIEWPORT_RATIO = + "Under a standard a viewport shows a view, whose scale gives its ratio. Give it view, which view() returns, in place of ratio"; +const VIEWPORT_ID = + "A viewport that shows a view has the view's id, which view() took. Leave id out"; +const DIMENSION_STYLE_DEFINED = + "Under a standard the dimension styles are the standard's, and a drawing defines none of its own. Add the style to the standard's dimensionStyles in defineStandard, and name it by its id"; +const DIMENSION_WITHOUT_VIEW = + "A dimension of model space under a standard names the view it is read in, whose scale sizes its style: give it view, which view() returns"; +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"; export type StandardR12 = typeof STANDARD_R12; export type StandardLinetypeScale = typeof STANDARD_LINETYPE_SCALE; @@ -38,6 +70,15 @@ export type DisciplineWithoutStandard = typeof DISCIPLINE_WITHOUT_STANDARD; export type LayerProperties = typeof LAYER_PROPERTIES; export type LinetypeDefined = typeof LINETYPE_DEFINED; export type PenWithoutStandard = typeof PEN_WITHOUT_STANDARD; +export type RoleWithoutStandard = typeof ROLE_WITHOUT_STANDARD; +export type RoleAndHeight = typeof ROLE_AND_HEIGHT; +export type RoleAndStyle = typeof ROLE_AND_STYLE; +export type ViewWithoutStandard = typeof VIEW_WITHOUT_STANDARD; +export type ScaleWithoutStandard = typeof SCALE_WITHOUT_STANDARD; +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; /** The message for each rule of this file that has no name in it, for the builder. */ export const DRAWN = { @@ -51,6 +92,21 @@ export const DRAWN = { penWithoutStandard: PEN_WITHOUT_STANDARD, penAndLineweight: PEN_AND_LINEWEIGHT, notAStandard: NOT_A_STANDARD, + roleWithoutStandard: ROLE_WITHOUT_STANDARD, + roleAndHeight: ROLE_AND_HEIGHT, + roleAndStyle: ROLE_AND_STYLE, + roleWithoutView: ROLE_WITHOUT_VIEW, + roleViewOnPaper: ROLE_VIEW_ON_PAPER, + viewWithoutRole: VIEW_WITHOUT_ROLE, + viewWithoutStandard: VIEW_WITHOUT_STANDARD, + scaleWithoutStandard: SCALE_WITHOUT_STANDARD, + insertViewAndScale: INSERT_VIEW_AND_SCALE, + insertViewOnPaper: INSERT_VIEW_ON_PAPER, + viewportRatio: VIEWPORT_RATIO, + viewportId: VIEWPORT_ID, + dimensionStyleDefined: DIMENSION_STYLE_DEFINED, + dimensionWithoutView: DIMENSION_WITHOUT_VIEW, + dimensionViewOnPaper: DIMENSION_VIEW_ON_PAPER, } as const; type LayerLacks< @@ -165,12 +221,108 @@ export type DisciplineOfStandard = string ? D : DisciplineLacks; -/** What is wrong with a layer that has fields by discipline, in a drawing that names none. */ -export function unnamedDisciplineProblem(name: string, layer: StandardLayer): string { - const disciplines = Object.keys(layer.byDiscipline ?? {}); +/** + * What is wrong with an entry that has fields by discipline, in a drawing that names none: a + * `"Layer"`, a `"Text role"` or a `"Dimension style"`. + */ +export function unnamedDisciplineProblem( + what: string, + name: string, + entry: { readonly byDiscipline?: object }, +): string { + const disciplines = Object.keys(entry.byDiscipline ?? {}); const listed = disciplines.length === 1 ? `the discipline ${disciplines[0]}` : `the disciplines ${disciplines.slice(0, -1).join(", ")} and ${disciplines.at(-1)}`; - return `Layer ${JSON.stringify(name)} has fields of its own in ${listed} of the standard, so a drawing that uses it names its discipline, as createDrawing({ standard, discipline })`; + return `${what} ${JSON.stringify(name)} has fields of its own in ${listed} of the standard, so a drawing that uses it names its discipline, as createDrawing({ standard, discipline })`; } + +type RoleLacks< + R extends string, + Z extends Standard, +> = `Role "${R}" is not a text role of ${Of}. Add it to the standard, as textRoles: { "${R}": { font: "Arial", height: 0.125 } } in defineStandard, or use one of its roles`; +/** What is wrong with a text role the standard lacks. */ +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 + : R extends keyof Z["textRoles"] + ? R + : RoleLacks; + +type ScaleLacks< + C extends string, + Z extends Standard, +> = `Scale "${C}" is not a scale of ${Of}. Add it to the standard, as scales: { "${C}": { label: "${C}", ratio: 1 } } in defineStandard, or use one of its scales`; +/** What is wrong with a view's scale that the standard lacks. */ +export const scaleLacks = (name: string, standard: Standard): string => + `Scale ${JSON.stringify(name)} is not a scale of ${of(standard)}. Add it to the standard, as scales: { ${JSON.stringify(name)}: { label: ${JSON.stringify(name)}, ratio: 1 } } in defineStandard, or use one of its scales`; + +/** Resolves to `C` when it is a scale of the standard `Z`, and to the message otherwise. */ +export type ScaleOfStandard = string extends C + ? C + : C extends keyof Z["scales"] + ? C + : ScaleLacks; + +type DimensionStyleLacks< + S extends string, + Z extends Standard, +> = `Dimension style "${S}" is not a dimension style of ${Of}. Add it to the standard's dimensionStyles in defineStandard, with its role, arrowSize, extensionOffset, extensionBeyond, textGap and writing, or use one of its dimension styles`; +/** What is wrong with a dimension style the standard lacks. */ +export const dimensionStyleLacks = (name: string, standard: Standard): string => + `Dimension style ${JSON.stringify(name)} is not a dimension style of ${of(standard)}. Add it to the standard's dimensionStyles in defineStandard, with its role, arrowSize, extensionOffset, extensionBeyond, textGap and writing, or use one of its dimension styles`; + +type DimensionStyleReserved = + `Dimension style "${S}" is in no standard: its name in AutoCAD would be STANDARD, which AutoCAD takes for the dimension style Standard that every drawing has. Use one of the standard's dimension styles`; +/** What is wrong with the dimension style `standard`, in any case, which no standard holds. */ +export const dimensionStyleReserved = (name: string): string | undefined => + name.toUpperCase() === "STANDARD" + ? `Dimension style ${JSON.stringify(name)} is in no standard: its name in AutoCAD would be STANDARD, which AutoCAD takes for the dimension style Standard that every drawing has. Use one of the standard's dimension styles` + : undefined; + +/** + * Resolves to `S` when it is a dimension style of the standard `Z`, and to the message + * otherwise, for each member of `S`. No standard has a style whose id is `standard` in any case, + * so that has a message of its own for every standard, its type's names `string` or not. + */ +export type DimensionStyleOfStandard = string extends S + ? S + : S extends unknown + ? Uppercase extends "STANDARD" + ? DimensionStyleReserved + : S extends keyof Z["dimensionStyles"] + ? S + : DimensionStyleLacks + : never; + +/** What is wrong with a dimension that names no style, under a standard that has no default. */ +export const noDefaultDimensionStyle = (standard: Standard): string => + `${capitalized(of(standard))} has no defaultDimensionStyle. Give the dimension the style of the standard it is drawn in, or set defaultDimensionStyle in the standard`; + +/** What is wrong with a dimension in a unit its style does not say how to write. */ +export const writingLacks = (style: string, units: string, standard: Standard): string => + `The dimension style ${JSON.stringify(style)} of ${of(standard)} has no writing for ${JSON.stringify(units)}, the units it is drawn in. Add one, as writing: { ${units}: { precision: 2 } }, to the style in defineStandard, or use a style that has one`; + +/** What is wrong with a view that `view()` of this drawing did not return. */ +export const viewNotDeclared = (view: unknown): string => { + const id: unknown = (view as { readonly id?: unknown } | null)?.id; + return `${typeof id === "string" ? `The view ${JSON.stringify(id)}` : "This view"} is not a view of this drawing. Declare it with view(), and give what view() returns`; +}; + +/** What is wrong with an insert in model space, without a view, of a block with role texts. */ +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 })`; + +/** 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}`; + +const capitalized = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1); diff --git a/test/drawn.test.ts b/test/drawn.test.ts index 67d19e1..f5f4a56 100644 --- a/test/drawn.test.ts +++ b/test/drawn.test.ts @@ -9,6 +9,7 @@ import { DrawingError, toJSON, type LintRule, + type Standard, } from "../src/index.ts"; const standard = defineStandard({ @@ -19,6 +20,16 @@ const standard = defineStandard({ pens: { hair: { lineweight: 0 } }, }); +/** The sizes of a dimension style, which a drawing drawn to a standard may not define. */ +const DIMENSION_STYLE = { + textHeight: 0.1, + arrowSize: 0, + extensionOffset: 0, + extensionBeyond: 0, + textGap: 0, + precision: 0, +} as const; + /** Asserts that `draw` throws a `DrawingError` of `rule` whose message matches `message`. */ function refused(draw: () => unknown, rule: LintRule, message: RegExp): void { assert.throws(draw, (thrown) => { @@ -200,3 +211,331 @@ test("layer(name, draw) draws on the layer, with or without a standard", () => { options.layer("A", {}, () => options.line([0, 0], [1, 0])); assert.equal(toJSON(plain.build()), toJSON(options.build())); }); + +test("under a standard the builder refuses a role, a view or a dimension style it does not give", () => { + const d = createDrawing({ units: "ft", standard }); + const plan = d.view("plan", { scale: "1in-10ft" }); + refused( + // @ts-expect-error: the standard has no role caption. + () => d.text([0, 0], "A", { role: "caption", view: plan }), + "role", + /^Role "caption" is not a text role of the standard "acme" version 2026.1. Add it to the standard, as textRoles: \{ "caption": \{ font: "Arial", height: 0.125 \} \} in defineStandard, or use one of its roles$/, + ); + // @ts-expect-error: a role gives the height. + refused(() => d.text([0, 0], "A", { role: "note", view: plan, height: 1 }), "role", /height/); + // @ts-expect-error: a role gives the text style. + refused(() => d.text([0, 0], "A", { role: "note", view: plan, style: "X" }), "role", /style/); + refused( + // @ts-expect-error: the standard has no scale 1in-25ft. + () => d.view("other", { scale: "1in-25ft" }), + "scale-outside-standard", + /^Scale "1in-25ft" is not a scale of the standard "acme" version 2026.1/, + ); + refused( + // @ts-expect-error: the dimension styles are the standard's. + () => d.dimensionStyle({ name: "D", ...DIMENSION_STYLE }), + "dimension-style-outside-standard", + /defines none of its own/, + ); + refused( + // @ts-expect-error: the standard has no dimension style ticks. + () => d.linearDimension([0, 0], [10, 0], [0, 5], { style: "ticks", view: plan }), + "dimension-style-outside-standard", + /^Dimension style "ticks" is not a dimension style of the standard "acme" version 2026.1/, + ); + d.block("CORNER", {}, () => d.circle([0, 0], 1 / 16)); + refused( + // @ts-expect-error: the view gives the insert's scale. + () => d.insert("CORNER", [0, 0], { view: plan, scale: 2 }), + "role", + /^An insert with a view is scaled by the view/, + ); + d.layout("C-1", { paper: "ARCH D" }, (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 }), + "viewport", + /^Under a standard a viewport shows a view/, + ); + refused( + // @ts-expect-error: the view gives the viewport's id. + () => sheet.viewport([1, 1], 10, 10, { view: plan, center: [0, 0], id: "other" }), + "viewport", + /has the view's id/, + ); + }); + // Each member of a union of styles is checked, also where the standard may be any. + const anyStandard: DrawingBuilder<"R2018", number, Standard> = d; + const reserved = Number("1") === 1; + refused( + () => + anyStandard.linearDimension([0, 0], [10, 0], [0, 5], { + // @ts-expect-error: no standard holds a dimension style named standard. + style: reserved ? "Standard" : "dims", + view: plan, + }), + "dimension-style-outside-standard", + /^Dimension style "Standard" is in no standard: its name in AutoCAD would be STANDARD, which AutoCAD takes for the dimension style Standard that every drawing has. Use one of the standard's dimension styles$/, + ); + assert.throws( + // @ts-expect-error: a length on paper is greater than 0. + () => plan.paper(0), + (thrown) => { + assert.ok(thrown instanceof DrawingError); + assert.equal(thrown.diagnostics[0]?.rule, "geometry"); + assert.equal(thrown.message, "The paper length must be greater than 0"); + assert.match(thrown.diagnostics[0]?.call?.file ?? "", /test\/drawn\.test\.ts$/); + return true; + }, + ); + const plain = createDrawing(); + // @ts-expect-error: a role is the standard's. + refused(() => plain.text([0, 0], "A", { role: "note" }), "role", /this drawing has none/); + // @ts-expect-error: a view is at a scale of the standard. + refused(() => plain.view("plan", { scale: "1in-10ft" }), "scale-outside-standard", /has none/); + + // What only the code that runs can see. + refused(() => d.text([0, 0], "A", { role: "note" }), "role", /names the view it is read in/); + refused(() => d.text([0, 0], "A", { view: plan }), "role", /Give it a role too/); + refused( + () => d.block("B", {}, () => d.text([0, 0], "A", { role: "note", view: plan })), + "role", + /^A text in a block or on a layout is drawn in paper units/, + ); + const elsewhere = createDrawing({ units: "ft", standard }).view("plan", { scale: "1in-10ft" }); + refused( + () => d.text([0, 0], "A", { role: "note", view: elsewhere }), + "role", + /^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. + 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"/, + ); + refused( + () => d.linearDimension([0, 0], [10, 0], [0, 5], { style: null as never, view: plan }), + "dimension-style-outside-standard", + /named by a string/, + ); + refused( + () => d.linearDimension([0, 0], [10, 0], [0, 5], {}), + "dimension-style", + /^A dimension of model space under a standard names the view/, + ); + d.layout("C-2", { paper: "ARCH D" }, (sheet) => { + sheet.viewport([1, 1], 10, 10, { view: plan, center: [0, 0] }); + refused( + () => sheet.viewport([12, 1], 10, 10, { view: plan, center: [0, 0] }), + "duplicate-id", + /^The id "plan" is already used/, + ); + refused( + () => d.linearDimension([0, 0], [1, 0], [0, 1], { view: plan }), + "dimension-style", + /^A dimension on a layout is drawn in paper units/, + ); + refused(() => d.insert("CORNER", [0, 0], { view: plan }), "role", /names no view/); + }); + const metres = createDrawing({ units: "m", standard }); + refused( + () => + metres.linearDimension([0, 0], [1, 0], [0, 1], { + view: metres.view("v", { scale: "1in-10ft" }), + }), + "dimension-style", + /^The dimension style "dims" of the standard "acme" version 2026.1 has no writing for "m"/, + ); + const tiny = defineStandard({ + id: "tiny", + version: "1", + extends: BUILT_IN_STANDARD, + scales: { tiny: { label: "tiny", ratio: 1e-9 } }, + }); + const small = createDrawing({ units: "ft", standard: tiny }); + refused( + () => small.text([0, 0], "A", { role: "note", view: small.view("v", { scale: "tiny" }) }), + "geometry", + /^height: .*written as 0/, + ); + const millimetres = defineStandard({ + id: "mm", + version: "1", + paperUnits: "mm", + minTextHeight: 2, + }); + 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/, + ); + refused( + () => createDrawing({ standard: millimetres }).linearDimension([0, 0], [1, 0], [0, 1], {}), + "dimension-style-outside-standard", + /^The standard "mm" version 1 has no defaultDimensionStyle/, + ); +}); + +test("a role and a view size a text, a symbol and a dimension where each is drawn", () => { + const d = createDrawing({ units: "ft", standard }); + const plan = d.view("plan", { scale: "1in-10ft" }); + const detail = d.view("detail", { scale: "1in-10ft" }); + assert.deepEqual( + { ...plan, paper: plan.paper(1) }, + { id: "plan", scale: "1in-10ft", label: "1\" = 10'", ratio: 120, paper: 10 }, + ); + d.block("CORNER", {}, () => d.circle([0, 0], 1 / 16)); + d.block("TAG", {}, () => d.attribute("NO", [0, 0], { role: "label", value: "1" })); + // A block that inserts a block with role texts holds them too. + d.block("TAGGED", {}, () => d.insert("TAG", [0, 0])); + d.text([0, 0], "LOT", { role: "label", view: plan, id: "lot" }); + d.insert("CORNER", [0, 0], { view: plan }); + d.insert("TAGGED", [0, 0], { view: detail }); + refused(() => d.insert("TAGGED", [0, 0]), "role", /^Block "TAGGED" holds texts with roles/); + d.linearDimension([0, 0], [10, 0], [0, 5], { view: plan }); + // 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) => { + 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], {}); + }); + const drawing = d.build(); + assert.deepEqual(drawing.entities.slice(0, 3), [ + { + kind: "text", + layer: "0", + at: [0, 0], + value: "LOT", + height: 1.25, + rotation: 0, + style: "LABEL", + id: "lot", + }, + { kind: "insert", layer: "0", block: "CORNER", at: [0, 0], scale: [10, 10] }, + { kind: "insert", layer: "0", block: "TAGGED", at: [0, 0], scale: [10, 10] }, + ]); + assert.deepEqual(drawing.blocks?.[1]?.attributes?.[0]?.height, 0.125); + const [page] = drawing.layouts!; + assert.deepEqual(page!.viewports[0], { + id: "plan", + layer: "0", + corner: [1, 1], + width: 10, + height: 10, + center: [0, 0], + ratio: 120, + }); + assert.deepEqual(page!.entities[0], { + kind: "text", + layer: "0", + at: [1, 12], + value: "PLAN", + height: 0.25, + rotation: 0, + style: "TITLE", + }); + // Each role's text style enters the drawing when the role is first used, with its font. + assert.deepEqual(drawing.textStyles, [ + { name: "LABEL", font: "Arial" }, + { name: "DIMENSION", font: "Arial" }, + { name: "TITLE", font: "Arial" }, + ]); + // Two views at one scale share a style. On a layout the style has no scale, and writes inches. + const style = { + textStyle: "DIMENSION", + textHeight: 0.09375, + arrowhead: "architectural-tick", + arrowSize: 0.125, + extensionOffset: 0.0625, + extensionBeyond: 0.125, + textGap: 0.0625, + }; + assert.deepEqual(drawing.dimensionStyles, [ + { name: "DIMS-1in-10ft", ...style, scale: 10, precision: 1, suffix: "'" }, + { name: "DIMS", ...style, precision: 0, suffix: '"' }, + ]); + assert.deepEqual( + [...drawing.entities, ...page!.entities].flatMap((e) => + e.kind === "linear-dimension" ? [e.style] : [], + ), + ["DIMS-1in-10ft", "DIMS-1in-10ft", "DIMS"], + ); +}); + +test("each branch of a conditional is checked as written", () => { + const d = createDrawing({ units: "ft", standard }); + const plan = d.view("plan", { scale: "1in-10ft" }); + 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.insert("CORNER", [0, 0], viewed ? { view: plan } : { scale: 2 }); + d.text([0, 0], "A", viewed ? { role: "label", view: plan } : { height: 1 }); + } + 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], + ); + assert.deepEqual( + drawing.blocks?.map((b) => b.attributes?.[0]?.height), + [undefined, 0.125, 0.1], + ); +}); + +test("a role and a dimension style with fields by discipline take the drawing's discipline", () => { + const shared = defineStandard({ + id: "acme", + version: "1", + extends: BUILT_IN_STANDARD, + sheetFiles: "sheet-per-file", + textRoles: { + note: { font: "Arial", height: 0.1, byDiscipline: { C: { font: "romans.shx" } } }, + }, + dimensionStyles: { + dims: { + role: "dimension", + arrowhead: "architectural-tick", + arrowSize: 0.125, + extensionOffset: 0.0625, + extensionBeyond: 0.125, + textGap: 0.0625, + writing: { ft: { precision: 1 } }, + byDiscipline: { C: { arrowSize: 0.25 } }, + }, + }, + }); + const drawn = (discipline?: "C" | "V") => { + const d = createDrawing({ + units: "ft", + standard: shared, + ...(discipline ? { discipline } : {}), + }); + d.block("B", {}, () => d.text([0, 0], "A", { role: "note" })); + const plan = d.view("plan", { scale: "1in-10ft" }); + d.alignedDimension([0, 0], [10, 0], 5, { view: plan }); + return d.build(); + }; + const civil = drawn("C"); + assert.deepEqual(civil.textStyles?.[0], { name: "NOTE", font: "romans.shx" }); + assert.equal(civil.dimensionStyles?.[0]?.arrowSize, 0.25); + const survey = drawn("V"); + assert.deepEqual(survey.textStyles?.[0], { name: "NOTE", font: "Arial" }); + assert.equal(survey.dimensionStyles?.[0]?.arrowSize, 0.125); + refused( + () => drawn(), + "standard", + /^Text role "note" has fields of its own in the discipline C of the standard/, + ); +}); diff --git a/test/rules.test.ts b/test/rules.test.ts index 1c731ed..e6c6a4a 100644 --- a/test/rules.test.ts +++ b/test/rules.test.ts @@ -1009,9 +1009,12 @@ function textBox(e: Text): [left: number, bottom: number, right: number, top: nu return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]; } -for (const ratio of [120, 240]) { - test(`the example sheet at ratio ${ratio} is laid out in its two spaces`, () => { - const { drawing, layout, drawingArea, titleBlock, fields, viewport, view } = sitePlan(ratio); +for (const [scale, ratio] of [ + ["1in-10ft", 120], + ["1in-20ft", 240], +] as const) { + test(`the example sheet at ${scale} is laid out in its two spaces`, () => { + const { drawing, layout, drawingArea, titleBlock, fields, viewport, view } = sitePlan(scale); const [page] = drawing.layouts!; assert.equal(page!.name, layout); const sides = (box: Box) => [box.left, box.bottom, box.right, box.top];