From 1d82b112a06ab5bb0335dd39743c721deb471c6b Mon Sep 17 00:00:00 2001 From: devaraj3 Date: Fri, 21 Aug 2026 22:12:52 +0530 Subject: [PATCH] Add licenses for third-party dependencies: DOMPurify, html2canvas, and jsPDF - Added DOMPurify_LICENSE.txt for the DOMPurify library, detailing its dual licensing under MPL-2.0 and Apache-2.0. - Added HTML2CANVAS_LICENSE.txt for the html2canvas library, including its MIT License. - Added JSPDF_LICENSE.txt for the jsPDF library, also under the MIT License. --- package-lock.json | 226 +- package.json | 1 + public/sitemap.xml | 6 +- src/components/cad/cad-viewer.css | 608 ++ src/components/cad/cad-viewer.tsx | 2842 +++++++++- src/components/cad/drafting-rules.ts | 198 + src/components/cad/pdf-canvas-shim.ts | 498 ++ src/components/cad/sheet-checker.ts | 1182 ++++ src/components/cad/sheet-composer.ts | 4893 +++++++++++++++++ src/components/cad/sheet-dimension-plan.ts | 233 + .../cad/sheet-interactive-render.ts | 1325 +++++ src/components/cad/title-block-table.ts | 680 +++ src/components/cad/viewer.ts | 1976 ++++++- third_party/DOMPURIFY_LICENSE.txt | 211 + third_party/HTML2CANVAS_LICENSE.txt | 32 + third_party/JSPDF_LICENSE.txt | 27 + vite.config.ts | 44 +- 17 files changed, 14898 insertions(+), 84 deletions(-) create mode 100644 src/components/cad/drafting-rules.ts create mode 100644 src/components/cad/pdf-canvas-shim.ts create mode 100644 src/components/cad/sheet-checker.ts create mode 100644 src/components/cad/sheet-composer.ts create mode 100644 src/components/cad/sheet-dimension-plan.ts create mode 100644 src/components/cad/sheet-interactive-render.ts create mode 100644 src/components/cad/title-block-table.ts create mode 100644 third_party/DOMPURIFY_LICENSE.txt create mode 100644 third_party/HTML2CANVAS_LICENSE.txt create mode 100644 third_party/JSPDF_LICENSE.txt diff --git a/package-lock.json b/package-lock.json index 2ba540c..30a3b65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "clipper-lib": "^6.4.2", "dxf-parser": "^1.1.2", "framer-motion": "^12.23.24", + "jspdf": "^4.2.1", "lucide-react": "^0.544.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -295,7 +296,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1604,6 +1604,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1611,6 +1617,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -1655,6 +1668,13 @@ "meshoptimizer": "~0.22.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -2082,6 +2102,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.17", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", @@ -2185,6 +2215,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2270,6 +2320,21 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2285,6 +2350,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -2369,6 +2444,16 @@ "node": ">=0.4.0" } }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2762,6 +2847,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2784,7 +2880,6 @@ "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -3109,6 +3204,20 @@ "node": ">=18" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/html5parser": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html5parser/-/html5parser-2.0.2.tgz", @@ -3207,6 +3316,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3384,6 +3499,23 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3687,6 +3819,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3733,6 +3881,13 @@ "node": ">=8" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3850,6 +4005,16 @@ "dev": true, "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3949,6 +4114,13 @@ "react-dom": ">=16.8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3976,6 +4148,16 @@ "node": ">=4" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -4107,6 +4289,16 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4161,6 +4353,16 @@ "node": ">=8" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4168,6 +4370,16 @@ "dev": true, "license": "MIT" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/three": { "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", @@ -4371,6 +4583,16 @@ "requires-port": "^1.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/package.json b/package.json index 2b20fb7..4e671c9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "clipper-lib": "^6.4.2", "dxf-parser": "^1.1.2", "framer-motion": "^12.23.24", + "jspdf": "^4.2.1", "lucide-react": "^0.544.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/public/sitemap.xml b/public/sitemap.xml index 05c9d3b..e612650 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,19 +2,19 @@ https://cadviewer.xyz/ - 2026-08-07 + 2026-08-21 weekly 1 https://cadviewer.xyz/viewer - 2026-08-07 + 2026-08-21 monthly 0.8 https://cadviewer.xyz/guides/split-step-assembly - 2026-08-07 + 2026-08-21 monthly 0.9 diff --git a/src/components/cad/cad-viewer.css b/src/components/cad/cad-viewer.css index 99d1f37..14ea9c0 100644 --- a/src/components/cad/cad-viewer.css +++ b/src/components/cad/cad-viewer.css @@ -603,6 +603,614 @@ border-color: #1e293b; } +.cad-sheet-modal-overlay { + position: absolute; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: rgba(15, 23, 42, 0.45); + backdrop-filter: blur(4px); +} + +.cad-sheet-modal { + position: relative; + /* Grid, two explicit rows: the toolbar (auto-height, task 1 - every + control + the status row now lives ABOVE the canvas) then the + canvas-wrap (minmax(0, 1fr), takes whatever's left). DOM order matches + this directly now (toolbar first, canvas-wrap second) - unlike the old + "canvas first, grid-auto-rows for a variable tail below" layout this + replaced, so there's no longer any implicit-row bookkeeping to keep in + sync with what's actually rendered. */ + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 14px; + width: min(94vw, 1500px); + /* 92% of the OVERLAY's own box, not 92vh (task 3 root cause #1): the CAD + viewer sits below an app header (.cad-app's grid-template-rows) and its + own root is overflow: hidden, so it's shorter than the full viewport - + confirmed by measuring it directly (header+status bar ate 100px of a + 1000px window in testing). A vh-based size doesn't know that and can + ask for more room than the visible, clipped container actually has - + the real mechanism behind "title block cut off, scrollbar present". % + is relative to the overlay's own box (a definite size - it's inset: 0 + against that same, already-shorter root), so this can't happen + regardless of the header's height. A plain height (not max-height) is + deliberate too, though a smaller fix on its own: it makes the modal + always exactly this tall rather than shrinking for sparse content, so + canvas-wrap's own available space (which cad-viewer.tsx's + ResizeObserver measures for the canvas - see .cad-sheet-modal-canvas's + own doc comment for root cause #2) is the same predictable budget + regardless of how many of the optional warning/notice/hint rows in the + toolbar happen to be showing. */ + height: 92%; + /* Extra top clearance (vs. the other three sides) so the absolutely- + positioned close button (top:12px, height:32px) never sits over the + sheet canvas, which is now the modal's first in-flow child (task 2/3: + "nothing floating above the sheet"). */ + padding: 56px 20px 20px; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: #ffffff; + box-shadow: 0 30px 60px rgba(15, 23, 42, 0.35); +} + +.cad-sheet-modal-close { + position: absolute; + top: 12px; + right: 12px; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.5); + background: #f8fafc; + color: #475569; + cursor: pointer; + transition: all 0.15s ease; +} + +.cad-sheet-modal-close:hover { + background: #ffffff; + border-color: rgba(59, 130, 246, 0.4); + color: #1d4ed8; +} + +.cad-sheet-modal-canvas-wrap { + position: relative; + display: grid; + place-items: center; + /* Lets this shrink below the canvas's own unconstrained intrinsic size + instead of being forced to at least that size (the default min-height/ + min-width: auto on a grid/flex item) - this is what makes the + minmax(0, 1fr) row above actually able to give it only the real + leftover space rather than growing the whole modal to fit the canvas. */ + min-height: 0; + min-width: 0; + /* auto, not hidden (task 2: zoom/pan) - at 100% (the zoom floor) the + canvas exactly fills this box so no scrollbar appears, identical to + the old `hidden` behavior; above 100% the canvas (an explicit px + width/height set by cad-viewer.tsx, see .cad-sheet-modal-canvas's own + doc comment) overflows this box and the browser supplies native + scrollbars/wheel/trackpad panning to reach the rest of it - no custom + pan-drag gesture needed, and it can never conflict with the existing + Adjust Drawing/Annotations click-drag handlers on the canvas itself. */ + overflow: auto; + border-radius: 10px; + border: 1px solid #d7dce3; + background: #f0f2f5; +} + +/* max-width/max-height: 100% here are only the pre-measurement FALLBACK + (a brief instant before cad-viewer.tsx's ResizeObserver-driven + canvasFitSize - and the sheetFitSize/zoom math derived from it - sets a + real px width/height as an inline style, which then wins over this rule + automatically). Confirmed empirically that max-height as a percentage + does not reliably resolve against a 's containing block in + practice (a literal px value works, the equivalent % silently doesn't, + even when the ancestor chain's computed height is unambiguously definite + - see .cad-sheet-modal's own doc comment for the rest of that chain), so + cad-viewer.tsx measures .cad-sheet-modal-canvas-wrap directly and applies + px instead of depending on % here - and, since zoom (task 2) needs the + canvas to be able to exceed the wrap's own size (to make it scrollable), + the inline style sets a real width/height at every zoom level, not just + a max-width/max-height cap. */ +.cad-sheet-modal-canvas { + display: block; + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + touch-action: none; +} + +/* Delete-icon overlay (Adjust Annotations mode, task 3) - positioned via + inline left/top (CSS px relative to .cad-sheet-modal-canvas-wrap, computed + in cad-viewer.tsx's computeDeleteIconPos from the selected dimension's own + on-screen label position), so it always sits right next to whatever's + selected regardless of the canvas's current CSS-scaled size. */ +.cad-sheet-delete-icon { + position: absolute; + transform: translate(-20%, -50%); + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 999px; + border: 1px solid rgba(220, 38, 38, 0.4); + background: #fef2f2; + color: #dc2626; + cursor: pointer; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.2); + transition: all 0.15s ease; +} + +.cad-sheet-delete-icon:hover { + background: #dc2626; + border-color: #dc2626; + color: #ffffff; +} + +.cad-sheet-modal-hint { + text-align: center; + font-size: 12.5px; + color: #64748b; + padding: 2px 0; +} + +/* Scale control - a small inline group (label + select + busy text) that + reads as one item alongside the footer's buttons. */ +.cad-sheet-scale-group { + display: flex; + align-items: center; + gap: 8px; +} + +.cad-sheet-scale-busy { + font-size: 11.5px; + font-weight: 600; + color: #64748b; +} + +/* Error state (task 4) - a visible red border on the Scale control exactly + while content crosses the frame margin at the current scale/position, so + the error is visible at the control itself, not just in the banner below. */ +.cad-select--error { + border-color: #dc2626 !important; + box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.25); +} + +.cad-select--error:hover, +.cad-select--error:focus { + border-color: #dc2626 !important; +} + +/* Floating toast (replaces the old in-flow scale-change banner) - absolutely + positioned over .cad-sheet-modal (position: relative) so it never reflows + the toolbar/canvas, auto-dismissed a few seconds after it appears (see + cad-viewer.tsx's scaleChangeNotice effect). */ +.cad-sheet-toast { + position: absolute; + /* Sits in the modal's own top padding band (56px, cleared for the close + button) so it floats above the toolbar entirely rather than overlapping + its buttons. */ + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 5; + text-align: center; + font-size: 12.5px; + color: #2563eb; + background: rgba(219, 234, 254, 0.95); + border: 1px solid rgba(96, 165, 250, 0.45); + border-radius: 8px; + padding: 6px 14px; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.18); + white-space: nowrap; + pointer-events: none; +} + +/* Top toolbar - the grid's first row, holds the controls row and the status + row (warning/notice/hint), stacked in normal block flow above the + canvas-wrap. */ +.cad-sheet-modal-toolbar { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Control row (task 5: "visually congested... clear gaps between logical + groups") - each logical cluster (zoom | notes | scale | layout controls | + download) is its own .cad-sheet-toolbar-group flex item; the row-level gap + is the space BETWEEN groups, a vertical rule marks the boundary, and each + group's own internal gap (below) is tighter, so the grouping reads + visually instead of every control looking equally spaced. Wraps + (flex-wrap) rather than compressing when it doesn't fit one row. */ +.cad-sheet-modal-controls-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 22px; + row-gap: 14px; +} + +.cad-sheet-toolbar-group { + display: flex; + align-items: center; + gap: 10px; + position: relative; + padding-right: 22px; +} + +/* Vertical divider between groups - omitted from the last group via + :last-child so the row doesn't end with a trailing rule. */ +.cad-sheet-toolbar-group:not(:last-child)::after { + content: ""; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 22px; + background: rgba(148, 163, 184, 0.4); +} + +/* Consistent control height across the row (task 5: "consistent control + heights and alignment") - buttons/selects/the zoom group all resolve to + this same box height regardless of their own padding. */ +.cad-sheet-toolbar-group .cad-btn, +.cad-sheet-toolbar-group .cad-select, +.cad-sheet-toolbar-group .cad-sheet-zoom-group { + min-height: 34px; + box-sizing: border-box; +} + +/* Zoom control - "-" / value box / "+" read as one compact unit. */ +.cad-sheet-zoom-group { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 8px; + padding: 4px 6px; + background: #f8fafc; +} + +.cad-sheet-zoom-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.5); + background: #ffffff; + color: #334155; + font-size: 15px; + line-height: 1; + cursor: pointer; + transition: all 0.15s ease; +} + +.cad-sheet-zoom-btn:hover:not(:disabled) { + background: #eff6ff; + border-color: rgba(59, 130, 246, 0.4); + color: #1d4ed8; +} + +.cad-sheet-zoom-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.cad-sheet-zoom-input { + width: 40px; + text-align: right; + border: none; + background: transparent; + font-size: 12.5px; + font-weight: 600; + color: #1e293b; +} + +.cad-sheet-zoom-input:focus { + outline: none; +} + +.cad-sheet-zoom-suffix { + font-size: 12.5px; + color: #64748b; + margin-right: 2px; +} + +/* Notes checkbox (task: editing is inline on the sheet now, no side panel/ + "Edit Notes" button - see the pencil/entry overlays below). */ +.cad-sheet-notes-checkbox-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: #334155; + cursor: pointer; +} + +/* Notes pencil overlay (task 2) - positioned via inline left/top (CSS px + relative to .cad-sheet-modal-canvas-wrap, computeNotesPencilIconPos in + cad-viewer.tsx) at the notes block's own top-right corner, matching the + delete-icon overlay's own convention. */ +.cad-sheet-notes-pencil { + position: absolute; + transform: translate(-20%, -50%); + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 999px; + border: 1px solid rgba(37, 99, 235, 0.4); + background: #eff6ff; + color: #1d4ed8; + cursor: pointer; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.2); + transition: all 0.15s ease; +} + +.cad-sheet-notes-pencil:hover { + background: #2563eb; + border-color: #2563eb; + color: #ffffff; +} + +/* Direct in-document notes editing - one borderless input per point + (committed or the trailing new slot), positioned right after the + canvas-drawn "N." prefix (see notesLineOrigin/NOTES_NUMBER_PREFIX_W_PX). + Reads as "typing directly on the sheet" rather than a form field; the + underline only appears on the point actually focused, so the others don't + visually compete with it. */ +.cad-sheet-notes-line-input { + position: absolute; + z-index: 2; + width: 220px; + border: none; + border-bottom: 1.5px solid transparent; + background: rgba(255, 255, 255, 0.92); + font: 15px sans-serif; + color: #000000; + padding: 0 2px; + outline: none; +} + +.cad-sheet-notes-line-input:focus { + border-bottom-color: #2563eb; +} + +/* Static stand-in text for every title block cell NOT actively being edited + (bugfix: the canvas draws no cell content at all in edit mode - see + drawSheetTitleBlock's own editMode early return - so this is what keeps + existing labels/values visible instead of the table appearing wiped the + moment edit mode opens). Non-interactive: clicks must fall through to the + canvas underneath for cell selection/hit-testing. */ +.cad-titleblock-cell-static { + position: absolute; + z-index: 1; + display: flex; + align-items: center; + overflow: hidden; + padding: 0 8px; + font: 11px sans-serif; + color: #000000; + white-space: nowrap; + text-overflow: ellipsis; + pointer-events: none; +} + +.cad-titleblock-cell-static-bold { + font-weight: 700; +} + +/* Title block cell content (table-edit mode) - the ONE on-demand input for + whichever cell is actively being edited (titleEditingCellId in + cad-viewer.tsx), positioned/sized exactly over it. Left-aligned, + vertically centred, same "type directly on the sheet" reading as the + notes line inputs above. */ +.cad-titleblock-cell-input { + position: absolute; + z-index: 2; + border: none; + border-radius: 3px; + background: rgba(255, 255, 255, 0.92); + outline: 1.5px dashed transparent; + font: 11px sans-serif; + color: #000000; + padding: 0 4px; +} + +.cad-titleblock-cell-input:hover { + outline-color: rgba(37, 99, 235, 0.3); +} + +.cad-titleblock-cell-input:focus { + outline-color: #2563eb; +} + +.cad-titleblock-cell-input::placeholder { + color: #94a3b8; +} + +/* Contextual toolbar (task 2: SELECT/INSERT/DELETE/MERGE/SPLIT), anchored + above the current cell/range selection (computeTitleToolbarPos in + cad-viewer.tsx). A row of small icon buttons rather than a hover-only + affordance, so every structural action is visible and reachable the + instant something's selected. */ +.cad-titleblock-toolbar { + position: absolute; + z-index: 4; + transform: translate(0, -100%); + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.5); + background: #ffffff; + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.25); +} + +.cad-titleblock-toolbar button { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 5px; + border: none; + background: transparent; + color: #334155; + cursor: pointer; + transition: all 0.15s ease; +} + +.cad-titleblock-toolbar button:hover:not(:disabled) { + background: #eff6ff; + color: #1d4ed8; +} + +.cad-titleblock-toolbar button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.cad-titleblock-toolbar button.cad-titleblock-toolbar-danger:hover:not(:disabled) { + background: #fef2f2; + color: #dc2626; +} + +.cad-titleblock-toolbar-divider { + width: 1px; + height: 18px; + margin: 0 2px; + background: rgba(148, 163, 184, 0.5); +} + +/* Logo cell editor (task 4) - covers the logo cell exactly while in + table-edit mode, since the canvas draws nothing there either way (see + drawSheetTitleBlock's editMode guard). Two states: an upload dropzone + (empty) or a preview + remove control (logo set). */ +.cad-titleblock-logo-editor { + position: absolute; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border-radius: 4px; + outline: 1.5px dashed rgba(37, 99, 235, 0.35); + background: rgba(255, 255, 255, 0.85); +} + +/* Empty-logo state (task 4: "make both options apparent, not just upload") - + the personal/non-company path (generated avatar from a typed name) stacked + above the upload option, so a user with no company logo sees a complete + alternative rather than only a dead-end upload button. */ +.cad-titleblock-logo-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + width: 100%; + height: 100%; +} + +.cad-titleblock-avatar-preview { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 999px; + color: #ffffff; + font: 600 11px sans-serif; + flex-shrink: 0; +} + +.cad-titleblock-logo-name-input { + width: 90%; + border: none; + border-radius: 3px; + background: rgba(255, 255, 255, 0.9); + outline: 1px dashed rgba(37, 99, 235, 0.35); + font: 10px sans-serif; + color: #000000; + text-align: center; + padding: 1px 3px; +} + +.cad-titleblock-logo-name-input:focus { + outline-color: #2563eb; +} + +.cad-titleblock-logo-upload { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + border-radius: 4px; + font: 600 10px sans-serif; + color: #2563eb; + text-align: center; + cursor: pointer; + transition: background 0.15s ease; +} + +.cad-titleblock-logo-upload:hover { + background: #eff6ff; +} + +.cad-titleblock-logo-upload input[type="file"] { + display: none; +} + +.cad-titleblock-logo-preview { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.cad-titleblock-logo-remove { + position: absolute; + top: -8px; + right: -8px; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.5); + background: #ffffff; + color: #475569; + cursor: pointer; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.2); + transition: all 0.15s ease; +} + +.cad-titleblock-logo-remove:hover { + background: #fef2f2; + border-color: rgba(220, 38, 38, 0.4); + color: #dc2626; +} + @keyframes cadSpin { to { transform: rotate(360deg); diff --git a/src/components/cad/cad-viewer.tsx b/src/components/cad/cad-viewer.tsx index 3f44d89..114457d 100644 --- a/src/components/cad/cad-viewer.tsx +++ b/src/components/cad/cad-viewer.tsx @@ -1,5 +1,6 @@ import React, { useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -15,6 +16,7 @@ import { type ViewerRenderQualityProfile, type CompareObjectId, type CompareObjectTier, + type HiddenLineViewSetResult, } from "./viewer"; import { analyzeCadSheetMetal, @@ -36,8 +38,100 @@ import { setCachedCadAssembly, } from "../../utils/geometryCache"; import { parseDxfFromArrayBuffer } from "./dxf"; +import { + composeA4DrawingSheet, + defaultNotesPosition, + formatScaleLabel, + FRAME_SAFE_AREA, + hashStringToHue, + loadImage, + MANUAL_SCALE_RATIOS, + MAX_NOTE_CHARS, + MAX_NOTES, + NOTES_LINE_H_PX, + NOTES_NUMBER_PREFIX_W_PX, + notesBlockSize, + notesLineOrigin, + SHEET_H, + SHEET_PX_PER_MM, + SHEET_W, + TITLE_BLOCK_RECT, + type ScaleOverflowWarning, +} from "./sheet-composer"; +import { + cellAt, + cellRange, + cellRectPx, + cellsInRange, + defaultTitleBlockTable, + deleteColumns, + deleteRows, + hitTestTitleCell, + hitTestTitleGridLine, + hitTestTitleUnit, + insertColumnLeftOfCell, + insertColumnRightOfCell, + insertRowAboveCell, + insertRowBelowCell, + isCellTypable, + mergeRange, + rangeFromUnits, + rangeRectPx, + resizeColumnLine, + resizeRowLine, + setCellText, + setCellLogo, + splitCell, + type CellRange, + type TitleBlockCell, + type TitleBlockTable, +} from "./title-block-table"; +import { + captionGroupForView, + clampCaptionY, + clampCircularDimensionElbow, + clampCompositionOffset, + clampIsoViewOffset, + clampLinearDimensionDelta, + clampNotesPosition, + clampRightViewOffset, + clampTopViewOffset, + combinedViewOffset, + computeLiveOverflowWarning, + createEmptySheetLayoutAdjustments, + dragRangeForComposition, + findDimensionRecordById, + fullContentBounds, + hasPositionAdjustments, + hitTestDimension, + isEmptySheetLayoutAdjustments, + isValidNotesPosition, + paintInteractiveSheet, + reflowAllRecords, + type CaptionGroup, + type Offset, + type SheetLayoutAdjustments, + type SheetPaintBase, +} from "./sheet-interactive-render"; +import { jsPDF } from "jspdf"; +import { createPdfCanvasContext } from "./pdf-canvas-shim"; import { motion, AnimatePresence } from "framer-motion"; -import { ArrowLeft, Download, ExternalLink } from "lucide-react"; +import { + ArrowDownFromLine, + ArrowLeft, + ArrowLeftFromLine, + ArrowRightFromLine, + ArrowUpFromLine, + Columns3, + Combine, + Download, + ExternalLink, + Pencil, + Rows3, + Trash2, + Ungroup, + X, +} from "lucide-react"; import { getSafePartDisplayName } from "./part-display-name"; import { createCadModelSession, @@ -965,6 +1059,9 @@ export const CadViewer = forwardRef( setPartExportMessage(null); setLoadedDxfDocument(null); setDxfPreviewPanelState(createDefaultDxfPreviewPanelState()); + setSheetPaintBase(null); + sheetAdjustmentsRef.current = createEmptySheetLayoutAdjustments(); + setHasSheetAdjustments(false); setDxfFeatureModel(null); dxfPreviewRootRef.current = null; setDxfOverlayRevision(0); @@ -1075,6 +1172,424 @@ export const CadViewer = forwardRef( useState(null); const [comparePickerOpen, setComparePickerOpen] = useState(false); + const [drawingSheetProgress, setDrawingSheetProgress] = useState<{ + label: string; + index: number; + total: number; + } | null>(null); + // The composed sheet's static (part-independent-of-drag) data: the + // authoritative layout geometry + raw view rasters composeA4DrawingSheet + // produced, plus the caption fields the interactive repaint's title + // block needs. Presence of this (non-null) is what gates showing the + // "View 2D Drawing" affordance and the modal itself - the drag-time + // OFFSET on top of it lives in sheetAdjustmentsRef below, not in React + // state, so a drag never triggers a React re-render (see + // repaintSheetCanvas). + const [sheetPaintBase, setSheetPaintBase] = useState( + null, + ); + // The raw capture (hidden-line view set) the current sheet was composed + // from, plus the partName/date it was composed with - cached here so the + // "Scale" dropdown can recompose at a new ratio (composeA4DrawingSheet) + // WITHOUT re-running generateHiddenLineViewSet's own 3D re-render, which + // is the expensive step. Set once per "Generate 2D Drawing" click + // (handleGenerateDrawingSheet); a scale change never touches it. + const sheetCaptureRef = useRef<{ + captureResult: HiddenLineViewSetResult; + partName: string; + date: string; + } | null>(null); + // "auto" (the default - see task doc comment) or one of + // MANUAL_SCALE_RATIOS, chosen from the modal's "Scale" dropdown. + // sheetPaintBase.scaleLabel always reflects whichever of the two is + // CURRENTLY in effect (auto's own pick, or the forced manual ratio); + // this is the dropdown's own selection, kept separately so "Auto" stays + // selected/labeled correctly even while autoScaleLabel below is only + // updated on an auto compose. + const [sheetScaleMode, setSheetScaleMode] = useState( + "auto", + ); + // The scale Auto most recently chose - shown in the dropdown's "Auto" + // option (e.g. "Auto (1:1)") even while a manual ratio is currently + // selected, so switching back to "Auto" never requires guessing what + // it'll pick. Only updated by an auto compose (never by a manual one). + const [autoScaleLabel, setAutoScaleLabel] = useState(null); + // LIVE - recomputed via computeLiveOverflowWarning after every + // adjustment (drag frame, drag end, delete, reset, scale change), not + // just once at generate/scale-change time (task 4: "recompute live on + // every adjustment... if the user drags the drawing back inside the + // margin, it must disappear immediately"). Non-null exactly when the + // CURRENT content genuinely crosses the frame margin at its current + // size, regardless of auto or manual scale. Also drives the Scale + // dropdown's red error border (see the select's className below). + const [sheetOverflowWarning, setSheetOverflowWarning] = + useState(null); + // Brief one-line notice shown right after a scale change that cleared + // pre-existing manual position adjustments (task: "tell the user + // briefly that this happened rather than silently discarding them") - + // cleared on the NEXT scale change (whether or not that one also had + // adjustments to clear) so it never lingers describing a stale change, + // AND auto-dismissed a few seconds after it appears (see the effect + // below) so it never lingers indefinitely either. + const [scaleChangeNotice, setScaleChangeNotice] = useState( + null, + ); + useEffect(() => { + if (!scaleChangeNotice) return; + const timer = setTimeout(() => setScaleChangeNotice(null), 4000); + return () => clearTimeout(timer); + }, [scaleChangeNotice]); + // True while a scale change's recomposition (composeA4DrawingSheet) is + // in flight - disables the dropdown so a second change can't race the + // first. + const [sheetScaleBusy, setSheetScaleBusy] = useState(false); + // Whether the full-sheet review modal is open - opened automatically the + // moment a fresh sheet finishes composing (handleGenerateDrawingSheet), + // and reopenable afterwards via a small sidebar affordance. + const [drawingSheetModalOpen, setDrawingSheetModalOpen] = useState(false); + // Which of the two mutually-exclusive adjust modes (if either) is active + // inside the modal - "drawing" (whole-composition drag, PLUS the + // per-view-group options below) or "annotations" (individual dimensions/ + // captions become selectable/draggable, each constrained to its own + // drafting-correct axis - see sheet-interactive-render.ts's + // applyDimensionAdjustment). "none" means neither is active and the + // canvas is inert. + type SheetAdjustMode = "none" | "drawing" | "annotations"; + const [sheetAdjustMode, setSheetAdjustModeState] = + useState("none"); + // Which of Adjust Drawing's four view options is active - only + // meaningful while sheetAdjustMode === "drawing" (moved here from Adjust + // Annotations - task: moving a whole view is a layout operation, so it + // belongs with the whole-composition drag, not with individual + // dimensions/captions). "overall" is the original whole-composition drag + // (drags together, all directions). "top"/"right"/"iso" each turn the + // ENTIRE canvas into a direct drag surface for one whole view group + // instead - no hit-testing, no selection, any pointerdown+drag anywhere + // moves that view - see handleSheetPointerDown's branch for those three. + // "front" is deliberately not an option: it's the fixed anchor every + // other view is defined relative to (see ViewGroupOffsets' doc comment + // in sheet-interactive-render.ts). Always resets to "overall" whenever + // Drawing mode is (re-)entered - see handleSetAdjustMode - so it never + // carries a stale sub-mode into a fresh entry. Adjust Annotations no + // longer has a sub-filter at all - it's unconditionally the per- + // dimension/caption select+drag behavior (see handleSheetPointerDown). + type DrawingViewFilter = "overall" | "top" | "right" | "iso"; + const [drawingViewFilter, setDrawingViewFilter] = + useState("overall"); + // Which dimension (by DimensionRecord.id) is currently selected in + // Adjust Annotations mode - null when nothing is selected or that mode + // isn't active. Purely a live-view affordance (drives the highlight in + // paintInteractiveSheet); never baked into a download. Mirrored into a + // ref (like sheetAdjustmentsRef) so a same-tick repaint right after + // selecting - e.g. on pointerdown, before React has re-rendered - always + // reads the JUST-selected id instead of a stale closed-over value. + const [selectedDimensionId, setSelectedDimensionIdState] = useState< + string | null + >(null); + const selectedDimensionIdRef = useRef(null); + const setSelectedDimensionId = (id: string | null) => { + selectedDimensionIdRef.current = id; + setSelectedDimensionIdState(id); + }; + // Live, authoritative manual-adjustment state - a ref, not useState, so + // every pointermove of a drag can update it and repaint the canvas + // directly (paintInteractiveSheet) without going through a React + // re-render on every pixel of movement. hasSheetAdjustments below is the + // coarse-grained, render-triggering mirror UI actually reads (e.g. to + // enable/disable "Reset layout"). + const sheetAdjustmentsRef = useRef( + createEmptySheetLayoutAdjustments(), + ); + const [hasSheetAdjustments, setHasSheetAdjustments] = useState(false); + const sheetCanvasRef = useRef(null); + // Wraps the canvas - the positioned ancestor the delete-icon overlay + // (Adjust Annotations mode, task 3) is placed relative to, since the + // canvas itself is only CSS-scaled/centered, not a coordinate origin. + const sheetCanvasWrapRef = useRef(null); + // The wrap's live CSS-px content-box size (task 3: "fit the whole sheet, + // no scrolling") - the 100%-zoom baseline every canvas size is derived + // from (see sheetFitSize below, which resolves this into the actual + // aspect-preserving "whole sheet fitted" px size, then zoom multiplies + // that - see the zoom control's own doc comment). Confirmed empirically + // (native devtools measurement) that a percentage max-height does not + // reliably resolve against a element's containing block in + // this browser even when that ancestor's own computed height is + // unambiguously definite - a literal px value works every time, the + // equivalent % silently doesn't (falls back to the canvas's + // unconstrained intrinsic size). Measuring the wrap directly and + // applying px sidesteps that rather than depending on it. + const [canvasFitSize, setCanvasFitSize] = useState<{ + w: number; + h: number; + } | null>(null); + // Zoom level (task: "100% = the whole sheet fitted in the modal", no + // zoom out below that, zoom in well past it for precise editing) - + // ZOOM_STEPS is the preset sequence the +/- buttons step through; a + // typed value in the toolbar's zoom input is clamped to the same + // [ZOOM_MIN, ZOOM_MAX] range but isn't restricted to a preset value. + // Reset to 100 whenever the review modal closes (see the effect below) + // and on every fresh "Generate 2D Drawing" (handleGenerateDrawingSheet) + // - it's a view setting, not part of the sheet's own persisted state + // (unlike sheetAdjustmentsRef/sheetNotes, which do survive a close then + // reopen of the SAME sheet). + const ZOOM_MIN = 100; + const ZOOM_MAX = 400; + const ZOOM_STEPS = [100, 125, 150, 200, 300, 400]; + const [sheetZoomPercent, setSheetZoomPercent] = useState(100); + // Draft text for the zoom value box - kept separate from + // sheetZoomPercent so a partially-typed value (e.g. "2" while typing + // "250") doesn't get clamped/committed on every keystroke; only synced + // FROM sheetZoomPercent (see the effect below), never written back + // until commitZoomDraft (blur/Enter). + const [zoomDraft, setZoomDraft] = useState("100"); + useEffect(() => { + setZoomDraft(String(sheetZoomPercent)); + }, [sheetZoomPercent]); + useEffect(() => { + if (!drawingSheetModalOpen) setSheetZoomPercent(100); + }, [drawingSheetModalOpen]); + // Set right before a zoom change (see applyZoom) to the CURRENT + // viewport-center fraction of the wrap's scrollable content, then + // consumed exactly once by the useLayoutEffect below (after the canvas + // has actually resized) to re-center the view on that same fraction - + // so stepping/typing a new zoom level keeps whatever the user was + // looking at in view instead of jumping to the top-left corner. + const zoomRecenterRef = useRef<{ fracX: number; fracY: number } | null>( + null, + ); + useLayoutEffect(() => { + const wrap = sheetCanvasWrapRef.current; + const recenter = zoomRecenterRef.current; + if (!wrap || !recenter) return; + wrap.scrollLeft = recenter.fracX * wrap.scrollWidth - wrap.clientWidth / 2; + wrap.scrollTop = recenter.fracY * wrap.scrollHeight - wrap.clientHeight / 2; + zoomRecenterRef.current = null; + }, [sheetZoomPercent]); + // Optional general-notes block (task: checkbox-enabled, freely + // draggable, inline-editable furniture) - never part of + // SheetLayoutAdjustments (see defaultNotesPosition's doc comment in + // sheet-composer.ts). Persists for the session (survives a close/reopen + // of the same sheet, like sheetAdjustmentsRef), reset only on a fresh + // "Generate 2D Drawing". + const [sheetNotesEnabled, setSheetNotesEnabled] = useState(false); + const [sheetNotes, setSheetNotes] = useState([]); + // Inline edit mode (replaces the old "Edit Notes" side panel) - true + // while direct in-document editing is live. Every point (committed or + // the one trailing new slot) renders as a real DOM input positioned over + // its own line (see the render below), so "which line is active" is + // just native DOM focus - no index needs to live in React state here. + const [notesEditMode, setNotesEditMode] = useState(false); + // DOM nodes for each note line's input, keyed by index - populated by a + // callback ref on each rendered input, used to imperatively move focus + // (Enter -> next line, Backspace-merge -> previous line, pencil click -> + // the first empty line) since those are one-shot commands, not state. + const noteInputRefs = useRef>(new Map()); + // A one-shot "focus this line after the next render" request - consumed + // by the layout effect below. Needed (rather than focusing inline) + // whenever the target input doesn't exist in the DOM yet at the moment + // the request is made (e.g. right after the pencil click first mounts + // the inputs, or right after a Backspace-merge removes a line and the + // previous line's input needs the post-splice DOM). + const pendingNoteFocusRef = useRef<{ index: number; cursor: "start" | "end" } | null>( + null, + ); + useLayoutEffect(() => { + const req = pendingNoteFocusRef.current; + if (!req) return; + pendingNoteFocusRef.current = null; + const el = noteInputRefs.current.get(req.index); + if (!el) return; + // preventScroll: this fires right after zoomToNotesArea's own + // deliberate scroll positioning (pencil click) - the browser's default + // focus-scrolls-into-view behavior would otherwise fight it, jumping + // to wherever the (stale, pre-zoom-resize) element rect said at the + // moment focus() ran. + el.focus({ preventScroll: true }); + const pos = req.cursor === "end" ? el.value.length : 0; + el.setSelectionRange(pos, pos); + }); + // The notes block's own position (sheet px, top-left) - null means "use + // defaultNotesPosition for the current note count" (the rest position); + // once the user drags it, this is set and stays fixed regardless of + // later note-count changes (see recomputeNotesPositionIfInvalid, which + // only overrides it back to null if a later change makes the fixed spot + // collide with something). Mirrors the ref-during-drag/state-at-rest + // pattern sheetAdjustmentsRef/hasSheetAdjustments already use, so the + // pencil icon and the drag-collision check both always read live data + // without re-rendering on every drag frame. + const notesPositionRef = useRef<{ x: number; y: number } | null>(null); + const [notesPositionState, setNotesPositionState] = useState< + { x: number; y: number } | null + >(null); + const notesDragRef = useRef<{ + pointerId: number; + grabOffsetX: number; + grabOffsetY: number; + } | null>(null); + // Title block table-edit mode (task: "pencil icon in its top-right + // corner... same behavior as notes editing"). The table itself lives in + // a ref (titleTableRef) - mutated directly by every structural/content + // edit and read straight by repaintSheetCanvas, exactly like + // sheetAdjustmentsRef - titleTableVersion exists purely to force the DOM + // overlays (the toolbar, the one active-cell input) that enumerate the + // ref's current cells/lines to re-render after a mutation the canvas + // alone wouldn't surface. Reseeded from the live partName/date/ + // scaleLabel whenever sheetPaintBase changes (fresh generate OR a scale + // change - see the effect below), so edits made before a scale change + // never linger showing a stale scale label; explicitly cleared by Reset + // layout too (task: persistence), unlike notes. + const [titleEditMode, setTitleEditMode] = useState(false); + const titleTableRef = useRef(null); + const [titleTableVersion, setTitleTableVersion] = useState(0); + const bumpTitleTable = () => setTitleTableVersion((v) => v + 1); + const [hasTitleTableEdits, setHasTitleTableEdits] = useState(false); + // The current cell/range selection (task 2: SELECT) - null whenever + // nothing's selected. Drives the contextual toolbar, the canvas + // highlight rect, and which cell (if the range is exactly one) a + // double-click/typed-keystroke may open for editing. + const [titleCellSelection, setTitleCellSelection] = useState(null); + // The one cell (if any) currently showing a real overlay (task + // 2: EDIT) - at most one at a time, unlike the old always-mounted-per- + // cell approach, so a plain click on a cell can start canvas-driven + // range selection instead of always landing on an input first. + const [titleEditingCellId, setTitleEditingCellId] = useState(null); + const titleActiveInputRef = useRef(null); + // Live hover target while in table-edit mode - drives the resize cursor; + // recomputed every pointermove, never persisted past the pointer leaving. + const [titleHover, setTitleHover] = useState<{ kind: "resize"; axis: "v" | "h"; lineIndex: number } | null>( + null, + ); + const titleResizeDragRef = useRef<{ + pointerId: number; + axis: "v" | "h"; + lineIndex: number; + startPos: number; + unit: number; + moved: boolean; + } | null>(null); + // In-progress click-drag range selection (task 2: "click-drag... to + // select a range") - anchor is the grid unit the drag started on; + // `moved` disambiguates a plain click (selects the single cell under the + // pointer) from an actual drag (selects the swept range), the same + // pattern titleResizeDragRef/sheetDragRef already use elsewhere in this + // file. + const titleRangeDragRef = useRef<{ + pointerId: number; + anchor: { r: number; c: number }; + moved: boolean; + } | null>(null); + // The "sticky" anchor a shift-click range-extends from - set on every + // plain (non-shift) selection click/drag-start, left UNCHANGED by a + // shift-click, exactly matching spreadsheet shift-click chaining (click + // A, shift-click C selects A..C, shift-click B then selects A..B - the + // anchor stays A throughout, not the previous click). + const titleSelectionAnchorRef = useRef<{ r: number; c: number } | null>(null); + // Double-click emulation (task 2: "click into a cell to type directly") + // - the canvas is plain pointerdown/up, not a focusable element, so it + // can't rely on a native dblclick the way the eventual edit + // itself can once mounted. Same timestamp-comparison shape the old + // segment-selection model's own lastTitleLineClickRef used, just keyed + // by cell id instead of grid line. + const lastTitleCellClickRef = useRef<{ cellId: string; time: number } | null>(null); + // The logo cell's uploaded image, pre-resolved to a real + // HTMLImageElement (task 4) - drawSheetTitleBlock is called + // SYNCHRONOUSLY from both the heavy compose pipeline and the + // cheap-per-drag-frame interactive repaint (sheet-interactive-render.ts), + // so it can't itself await an image load; this ref/effect resolves it + // once, outside the paint path, exactly the way the isometric raster is + // pre-resolved before any synchronous paint touches it (see + // sheet-composer.ts's own loadImage/isoCapture pipeline). + const logoImageRef = useRef<{ dataUrl: string; img: HTMLImageElement } | null>(null); + // Screen position (CSS px, relative to sheetCanvasWrapRef) of the + // delete-icon overlay for the currently-selected DIMENSION - null + // whenever nothing selectable-and-deletable is selected (no selection, + // a caption is selected, or a drag is actively in progress - see + // refreshDeleteIconPos). Purely a live-view affordance, like + // selectedDimensionId itself. + const [deleteIconPos, setDeleteIconPos] = useState<{ + left: number; + top: number; + } | null>(null); + // Screen position of the notes/title pencil overlays - real state (not + // computed inline in JSX, which the title/notes pencils used to do) so + // it can be refreshed from a useLayoutEffect that runs AFTER the canvas's + // own zoom-driven resize commits. Computing it inline in JSX instead reads + // canvas.getBoundingClientRect() against the PRE-commit DOM whenever a + // zoom change and a re-render land in the same pass, leaving the icon + // stuck at its pre-zoom position with nothing to force a corrective + // render - the exact "floating" bug task 1 describes. Mirrors + // deleteIconPos's own state-plus-scroll/resize-listener convention above. + const [notesPencilIconPos, setNotesPencilIconPos] = useState<{ + left: number; + top: number; + } | null>(null); + const [titlePencilIconPos, setTitlePencilIconPos] = useState<{ + left: number; + top: number; + } | null>(null); + // Whole-composition drag session (Adjust Drawing mode only). + const sheetDragRef = useRef<{ + pointerId: number; + startSheetX: number; + startSheetY: number; + startComposition: Offset; + } | null>(null); + // Per-dimension/caption drag session (Adjust Annotations mode only) - + // `linear` tracks a delta relative to the record's OWN rest lane (see + // clampLinearDimensionDelta), `circular` tracks a fixed grab offset + // between the pointer and the leader's elbow so the drag doesn't jump on + // pointerdown (see clampCircularDimensionElbow), `caption` tracks the + // dragged caption's linked GROUP (see captionGroupForView) and a + // reference Y to add the pointer's own vertical movement to (see + // handleSheetPointerDown's caption branch for how that reference is + // chosen). + const sheetDimensionDragRef = useRef< + | { + pointerId: number; + id: string; + kind: "linear"; + axis: "horizontal" | "vertical"; + startSheetX: number; + startSheetY: number; + startDelta: number; + } + | { + pointerId: number; + id: string; + kind: "circular"; + grabOffsetX: number; + grabOffsetY: number; + } + | { + pointerId: number; + id: string; + kind: "caption"; + group: CaptionGroup; + startSheetY: number; + startY: number; + } + | null + >(null); + // View-group drag session (Adjust Annotations' "Top"/"Right"/"3D View" + // options only, task 2) - unlike sheetDimensionDragRef above, there is + // no hit-test/selection step: whichever of these three is active, + // ANY pointerdown on the canvas starts dragging that one whole view + // group immediately (see handleSheetPointerDown's early-return branch). + // `top`/`right` each track a plain scalar offset (vertical/horizontal + // only respectively - see ViewGroupOffsets); `iso` tracks both axes. + const sheetViewGroupDragRef = useRef< + | { pointerId: number; kind: "top"; startSheetY: number; startOffset: number } + | { pointerId: number; kind: "right"; startSheetX: number; startOffset: number } + | { + pointerId: number; + kind: "iso"; + startSheetX: number; + startSheetY: number; + startOffset: Offset; + } + | null + >(null); + useImperativeHandle(ref, () => ({ getSnapshot: (type: "normal" | "outline" = "normal") => { if (!viewerRef.current) return undefined; @@ -1090,6 +1605,10 @@ export const CadViewer = forwardRef( // Initialize viewer viewerRef.current = createViewer(containerRef.current); + // TEMPORARY DEBUG: expose viewer for hidden-line-detection spike testing. Remove after verification. + if (typeof window !== "undefined") { + (window as any).__cadViewer = viewerRef.current; + } wasDxfViewRef.current = false; viewerRef.current.setRenderQualityProfile(renderQualityProfile); viewerRef.current.setMeasurementGraphicsScale(dimScale); @@ -2474,6 +2993,1695 @@ export const CadViewer = forwardRef( document.body.removeChild(link); }; + // Front, Top, Right, the isometric reference view, then the 2D compose. + const DRAWING_SHEET_TOTAL_STEPS = 5; + + // Effective note count used for sizing/positioning RIGHT NOW - includes + // the trailing not-yet-used line while edit mode is active AND there's + // still room under MAX_NOTES, so the block is already the right size for + // it before the first keystroke there (see drawSheetNotes' own editMode + // param). Also the number of DOM line-inputs actually rendered (see the + // notes-editing overlay below) - every index in [0, count) gets one. + const effectiveNotesCount = () => + sheetNotes.length + (notesEditMode && sheetNotes.length < MAX_NOTES ? 1 : 0); + const effectiveNotesSize = () => notesBlockSize(effectiveNotesCount()); + // The block's live position - the dragged-to spot if there is one, + // otherwise the rest position for the CURRENT size (see + // defaultNotesPosition's own doc comment for why a fixed drag position + // stays fixed regardless of later count changes). + const effectiveNotesPosition = (): { x: number; y: number } => + notesPositionRef.current ?? defaultNotesPosition(effectiveNotesCount()); + + // Repaints the live sheet canvas from `base` + the current manual + // adjustments (sheetAdjustmentsRef) - the single function both the + // initial post-generate paint and every drag frame call, so what's on + // screen is always exactly paintInteractiveSheet(base, adjustments). + // Includes the current selection highlight (Adjust Annotations mode) - + // handleDownloadSheetPdf never touches this canvas at all, it paints an + // entirely separate (also unhighlighted) jsPDF document instead, so + // there's nothing here for it to restore afterward. + const repaintSheetCanvas = (base: SheetPaintBase | null) => { + if (!base) return; + const canvas = sheetCanvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!ctx) return; + paintInteractiveSheet( + ctx, + base, + sheetAdjustmentsRef.current, + selectedDimensionIdRef.current, + { + enabled: sheetNotesEnabled, + items: sheetNotes, + position: effectiveNotesPosition(), + editMode: notesEditMode, + }, + titleTableRef.current + ? { + table: titleTableRef.current, + editMode: titleEditMode, + selection: titleCellSelection, + logoImage: logoImageRef.current?.img ?? null, + } + : undefined, + ); + }; + + // Recomputes the live overflow warning (task 4) from the CURRENT + // sheetAdjustmentsRef - called after every adjustment that can change + // where/how big the content is (drag frames, drag end, delete, reset, + // scale change), so the warning (and the Scale dropdown's red border) + // always reflect the live geometry, never a stale one-shot snapshot. + const recomputeLiveOverflowWarning = () => { + if (!sheetPaintBase) return; + setSheetOverflowWarning( + computeLiveOverflowWarning(sheetPaintBase.layoutModel, sheetAdjustmentsRef.current), + ); + }; + + // Repaints once the sheet canvas has actually mounted - it only exists + // in the DOM while the review modal is open (drawingSheetModalOpen), so + // a same-tick repaint right after setSheetPaintBase, or right after + // reopening the modal, would otherwise still find sheetCanvasRef.current + // === null. Also the ONE place notes edits (handleNoteLineChange/ + // handleNoteLineKeyDown/handleToggleNotesEnabled) trigger a repaint - they + // just update state, this effect picks up the change, which sidesteps + // the stale-closure trap a repaint called inline right after setState + // would hit (repaintSheetCanvas reads sheetNotes/sheetNotesEnabled from + // its own render's closure, not whatever a just-called setState hasn't + // committed yet). + useEffect(() => { + repaintSheetCanvas(sheetPaintBase); + }, [ + sheetPaintBase, + drawingSheetModalOpen, + sheetNotesEnabled, + sheetNotes, + notesEditMode, + notesPositionState, + titleEditMode, + titleTableVersion, + titleCellSelection, + ]); + + // Standard modal dismissal: Esc closes the sheet review modal. + useEffect(() => { + if (!drawingSheetModalOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setDrawingSheetModalOpen(false); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [drawingSheetModalOpen]); + + // Screen position (CSS px, relative to sheetCanvasWrapRef) of `id`'s + // top-right corner, for the delete-icon overlay (task 3) - null for no + // id, a caption (never deletable), or if the canvas hasn't mounted/laid + // out yet. Reads the CURRENTLY DISPLAYED (reflowed) geometry, same as + // hit-testing, so the icon always sits next to whatever the user + // actually sees, including a dimension already dragged from its + // generated position. + const computeDeleteIconPos = ( + id: string | null, + ): { left: number; top: number } | null => { + if (!id || !sheetPaintBase) return null; + const reflowed = reflowAllRecords(sheetPaintBase.layoutModel, sheetAdjustmentsRef.current); + const record = Object.values(reflowed) + .flat() + .find((r) => r.id === id); + if (!record || record.kind === "caption") return null; + const anchor = record.labelRect + ? { x: record.labelRect.x + record.labelRect.w, y: record.labelRect.y } + : record.lineSegments[0] + ? { x: record.lineSegments[0].x2, y: record.lineSegments[0].y2 } + : null; + if (!anchor) return null; + return sheetSpaceToCssPos(anchor.x, anchor.y); + }; + + const refreshDeleteIconPos = (id: string | null) => { + setDeleteIconPos(computeDeleteIconPos(id)); + }; + + // Sheet-px -> CSS-px (relative to sheetCanvasWrapRef's own SCROLLABLE + // CONTENT, not its viewport box) conversion shared by every overlay + // position helper (delete icon above, notes pencil/input below) - the + // one place this scaling math is written, so they can never disagree + // about where the canvas actually is. Deliberately adds the wrap's own + // scrollLeft/scrollTop back in: canvasRect/wrapRect are both viewport- + // relative (so their difference alone is only valid at the current + // scroll position, drifting the moment the wrap pans at any zoom above + // 100%), while a `left`/`top` CSS style on an absolutely-positioned + // child of an overflow:auto container is anchored to the CONTENT origin + // - adding scrollLeft/scrollTop back converts to that content-relative + // frame, which is scroll-invariant and therefore never needs recomputing + // just because the user panned (confirmed empirically: without this, + // clicking an off-screen note point - which auto-scrolls it into view + // first - left every overlay positioned from the pre-scroll rect). + const sheetSpaceToCssPos = (x: number, y: number): { left: number; top: number } | null => { + const canvas = sheetCanvasRef.current; + const wrap = sheetCanvasWrapRef.current; + if (!canvas || !wrap) return null; + const canvasRect = canvas.getBoundingClientRect(); + const wrapRect = wrap.getBoundingClientRect(); + if (canvasRect.width === 0 || canvasRect.height === 0) return null; + const scaleX = canvasRect.width / SHEET_W; + const scaleY = canvasRect.height / SHEET_H; + return { + left: canvasRect.left - wrapRect.left + wrap.scrollLeft + x * scaleX, + top: canvasRect.top - wrapRect.top + wrap.scrollTop + y * scaleY, + }; + }; + + // Screen position of the notes block's pencil-icon overlay (task 2: + // "small pencil icon in its top-right corner") - null while the block + // isn't shown or the canvas hasn't mounted/laid out yet. + const computeNotesPencilIconPos = (): { left: number; top: number } | null => { + if (!sheetNotesEnabled || !sheetPaintBase) return null; + const pos = effectiveNotesPosition(); + const { w } = effectiveNotesSize(); + return sheetSpaceToCssPos(pos.x + w, pos.y); + }; + + // Screen position for the DOM overlay covering note line `index` + // (0-based) - shares notesLineOrigin with drawSheetNotes' own canvas + // layout, so a DOM element positioned from this always lines up with + // the "N." prefix drawn underneath it. + const computeNoteLineCssPos = (index: number): { left: number; top: number } | null => { + if (!sheetPaintBase) return null; + const origin = notesLineOrigin(effectiveNotesPosition(), index); + return sheetSpaceToCssPos(origin.x, origin.y); + }; + + // Sheet-px rect -> CSS-px rect (left/top/width/height), same conversion + // sheetSpaceToCssPos uses for a single point - the title block's cell + // inputs and insert-strip icons need a full box, not just an origin. + const sheetSpaceRectToCssRect = ( + r: { x: number; y: number; w: number; h: number }, + ): { left: number; top: number; width: number; height: number } | null => { + const topLeft = sheetSpaceToCssPos(r.x, r.y); + const bottomRight = sheetSpaceToCssPos(r.x + r.w, r.y + r.h); + if (!topLeft || !bottomRight) return null; + return { + left: topLeft.left, + top: topLeft.top, + width: bottomRight.left - topLeft.left, + height: bottomRight.top - topLeft.top, + }; + }; + + // Screen position of the title block's own pencil-icon overlay (task: + // "pencil icon in the title block's top-right corner") - matches the + // notes pencil's own top-right convention. + const computeTitlePencilIconPos = (): { left: number; top: number } | null => { + if (!sheetPaintBase) return null; + return sheetSpaceToCssPos(TITLE_BLOCK_RECT.x + TITLE_BLOCK_RECT.w, TITLE_BLOCK_RECT.y); + }; + + // Screen position of the contextual toolbar (task 2: "a small + // contextual toolbar on selection") - anchored just above the + // selection's own top-left corner, a fixed CSS-px gap (not a sheet-px + // one - unlike the drawing's own content, the toolbar itself should stay + // a consistent on-screen size/offset regardless of the document's + // current zoom level, matching every other overlay control in this + // file). + const TITLE_TOOLBAR_GAP_PX = 44; + const computeTitleToolbarPos = (): { left: number; top: number } | null => { + if (!titleCellSelection || !titleTableRef.current) return null; + const r = rangeRectPx(titleTableRef.current, TITLE_BLOCK_RECT, titleCellSelection); + const pos = sheetSpaceToCssPos(r.x, r.y); + if (!pos) return null; + return { left: pos.left, top: pos.top - TITLE_TOOLBAR_GAP_PX }; + }; + + // Keeps the delete-icon overlay correctly placed if the modal (and so + // the canvas's rendered size) changes without a selection change of its + // own, e.g. the browser window being resized while a dimension stays + // selected - and, since the wrap can now pan when zoomed (task 2), also + // on every scroll of the wrap itself, so the icon tracks the canvas + // while the user pans instead of freezing at its pre-pan screen spot. + useEffect(() => { + if (!selectedDimensionId || !drawingSheetModalOpen) return; + const onResize = () => refreshDeleteIconPos(selectedDimensionId); + window.addEventListener("resize", onResize); + const wrap = sheetCanvasWrapRef.current; + wrap?.addEventListener("scroll", onResize); + return () => { + window.removeEventListener("resize", onResize); + wrap?.removeEventListener("scroll", onResize); + }; + }, [selectedDimensionId, drawingSheetModalOpen, sheetPaintBase]); + + // Keeps the notes/title pencil overlays pinned at any zoom or scroll + // position (task 1: "anchor it... so it moves with the table and stays + // there at any zoom or scroll position"). useLayoutEffect (not a plain + // computation inline in JSX, which is what these used to do) so the + // refresh runs AFTER the canvas's own zoom-driven size change has + // committed to the DOM, not against the stale pre-commit layout a + // same-render inline read would see - see notesPencilIconPos/ + // titlePencilIconPos's own doc comment above. Scroll/resize listeners + // mirror deleteIconPos's effect above for the same reason (window resize, + // and panning the wrap once zoomed above 100%). + const refreshNotesPencilIconPos = () => setNotesPencilIconPos(computeNotesPencilIconPos()); + useLayoutEffect(() => { + refreshNotesPencilIconPos(); + const onResize = () => refreshNotesPencilIconPos(); + window.addEventListener("resize", onResize); + const wrap = sheetCanvasWrapRef.current; + wrap?.addEventListener("scroll", onResize); + return () => { + window.removeEventListener("resize", onResize); + wrap?.removeEventListener("scroll", onResize); + }; + }, [notesEditMode, sheetNotesEnabled, drawingSheetModalOpen, sheetPaintBase, sheetZoomPercent, canvasFitSize]); + + const refreshTitlePencilIconPos = () => setTitlePencilIconPos(computeTitlePencilIconPos()); + useLayoutEffect(() => { + refreshTitlePencilIconPos(); + const onResize = () => refreshTitlePencilIconPos(); + window.addEventListener("resize", onResize); + const wrap = sheetCanvasWrapRef.current; + wrap?.addEventListener("scroll", onResize); + return () => { + window.removeEventListener("resize", onResize); + wrap?.removeEventListener("scroll", onResize); + }; + }, [titleEditMode, drawingSheetModalOpen, sheetPaintBase, sheetZoomPercent, canvasFitSize]); + + // Drives canvasFitSize (task 3) - a ResizeObserver, not a window + // "resize" listener, because the wrap's available space can change from + // pure layout causes with no window resize at all: the hint row above + // the canvas appearing or disappearing as sheetAdjustMode changes eats + // into (or gives back) the wrap's own height (the overflow warning and + // scale-change notice are a hover tooltip and a floating toast now, so + // neither affects layout any more). Active only while the modal is + // open; disconnected otherwise so it isn't observing a detached element + // between sheets. + useEffect(() => { + if (!drawingSheetModalOpen) return; + const wrap = sheetCanvasWrapRef.current; + if (!wrap) return; + const observer = new ResizeObserver((entries) => { + const rect = entries[0]?.contentRect; + if (rect) setCanvasFitSize({ w: rect.width, h: rect.height }); + }); + observer.observe(wrap); + return () => observer.disconnect(); + }, [drawingSheetModalOpen]); + + // The actual "100% = whole sheet fitted" on-screen px size, derived + // from canvasFitSize (the wrap's raw content-box size, NOT necessarily + // SHEET_W/SHEET_H's own aspect ratio) by fitting SHEET_W x SHEET_H + // inside it exactly the way the old CSS max-width/max-height:100% + // + width/height:auto rule used to (a replaced element with an + // intrinsic aspect ratio, "contain"-fit within the box) - needed now + // because zoom sets the canvas's width/height explicitly rather than + // leaving the fit to CSS, so this has to be computed once in JS as the + // zoom multiplier's own 100% baseline. + const sheetFitSize = useMemo(() => { + if (!canvasFitSize || canvasFitSize.w <= 0 || canvasFitSize.h <= 0) { + return null; + } + const sheetAspect = SHEET_W / SHEET_H; + const containerAspect = canvasFitSize.w / canvasFitSize.h; + return containerAspect > sheetAspect + ? { w: canvasFitSize.h * sheetAspect, h: canvasFitSize.h } + : { w: canvasFitSize.w, h: canvasFitSize.w / sheetAspect }; + }, [canvasFitSize]); + + // Applies a new zoom level, clamped to [ZOOM_MIN, ZOOM_MAX] - the one + // place sheetZoomPercent is ever set from user input (+/- buttons and + // the typed value box both funnel through this), so the recenter-on- + // zoom behavior (zoomRecenterRef + its useLayoutEffect above) always + // fires consistently regardless of which control triggered the change. + const applyZoom = (next: number) => { + const clamped = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, next)); + if (clamped === sheetZoomPercent) return; + const wrap = sheetCanvasWrapRef.current; + if (wrap && wrap.scrollWidth > 0 && wrap.scrollHeight > 0) { + zoomRecenterRef.current = { + fracX: (wrap.scrollLeft + wrap.clientWidth / 2) / wrap.scrollWidth, + fracY: (wrap.scrollTop + wrap.clientHeight / 2) / wrap.scrollHeight, + }; + } + setSheetZoomPercent(clamped); + }; + + // "-"/"+" buttons: step through ZOOM_STEPS regardless of whether the + // current value is itself a preset (e.g. a typed 175% steps down to + // 150%, up to 200%) - task: "clicking either steps through a sensible + // sequence." + const stepZoom = (direction: "in" | "out") => { + if (direction === "in") { + const next = ZOOM_STEPS.find((v) => v > sheetZoomPercent); + applyZoom(next ?? ZOOM_MAX); + } else { + const lower = ZOOM_STEPS.filter((v) => v < sheetZoomPercent); + applyZoom(lower.length ? lower[lower.length - 1] : ZOOM_MIN); + } + }; + + const commitZoomDraft = () => { + const parsed = parseInt(zoomDraft, 10); + if (Number.isFinite(parsed)) { + applyZoom(parsed); + } else { + setZoomDraft(String(sheetZoomPercent)); + } + }; + + // Auto-zoom while editing notes (task: "zooms the document in on the + // notes area, at a level where the note text is comfortably readable") - + // a pure view change, entirely independent of notesPositionRef/ + // sheetAdjustmentsRef (no position is ever touched by this). Reuses + // zoomRecenterRef's own "center on this fraction of the sheet" mechanism + // (see applyZoom), just fed the notes block's own center instead of the + // current viewport's, so this can jump straight to it in one step. + // preNotesZoomRef remembers whatever zoom was active before entering + // notes edit mode so exiting restores exactly that (normal fit-to-window + // if that's where the user was), rather than a hardcoded value. + const NOTES_EDIT_ZOOM = 300; + const preNotesZoomRef = useRef(null); + const zoomToNotesArea = () => { + if (preNotesZoomRef.current === null) preNotesZoomRef.current = sheetZoomPercent; + const pos = effectiveNotesPosition(); + const size = effectiveNotesSize(); + zoomRecenterRef.current = { + fracX: (pos.x + size.w / 2) / SHEET_W, + fracY: (pos.y + size.h / 2) / SHEET_H, + }; + setSheetZoomPercent(NOTES_EDIT_ZOOM); + }; + const restoreZoomAfterNotes = () => { + const prev = preNotesZoomRef.current; + preNotesZoomRef.current = null; + if (prev !== null) applyZoom(prev); + }; + + // Auto-zoom into the title block on entering table-edit mode - same + // mechanism/level as notes' own zoomToNotesArea, just centred on the + // (fixed-position, non-draggable) TITLE_BLOCK_RECT instead. + const TITLE_EDIT_ZOOM = 300; + const preTitleZoomRef = useRef(null); + const zoomToTitleBlock = () => { + if (preTitleZoomRef.current === null) preTitleZoomRef.current = sheetZoomPercent; + zoomRecenterRef.current = { + fracX: (TITLE_BLOCK_RECT.x + TITLE_BLOCK_RECT.w / 2) / SHEET_W, + fracY: (TITLE_BLOCK_RECT.y + TITLE_BLOCK_RECT.h / 2) / SHEET_H, + }; + setSheetZoomPercent(TITLE_EDIT_ZOOM); + }; + const restoreZoomAfterTitle = () => { + const prev = preTitleZoomRef.current; + preTitleZoomRef.current = null; + if (prev !== null) applyZoom(prev); + }; + const exitTitleEditMode = () => { + setTitleEditMode(false); + setTitleCellSelection(null); + setTitleEditingCellId(null); + setTitleHover(null); + titleResizeDragRef.current = null; + titleRangeDragRef.current = null; + restoreZoomAfterTitle(); + }; + const handleTitlePencilClick = () => { + if (!sheetPaintBase || !titleTableRef.current) return; + if (notesEditMode) exitNotesEditMode(); + setTitleEditMode(true); + zoomToTitleBlock(); + }; + // Opens the one on-demand edit for `cellId` (task 2: EDIT) - + // never for a non-typable (special) cell, see isCellTypable's own doc + // comment; the caller is responsible for having already selected the + // cell (double-click and "start typing on a selected cell" both do). + const beginEditingTitleCell = (cellId: string) => { + const cell = titleTableRef.current?.cells.find((c) => c.id === cellId); + if (!cell || !isCellTypable(cell)) return; + setTitleEditingCellId(cellId); + // Input isn't mounted yet this render - focus it once it is. + requestAnimationFrame(() => titleActiveInputRef.current?.focus()); + }; + // A blur not immediately followed by another title-cell input taking + // focus means the user clicked/tabbed away from the input - but unlike + // the old always-mounted-per-cell approach, that only ends EDITING, not + // the whole table-edit mode (the cell stays selected, the toolbar stays + // up). Exiting table-edit mode entirely stays driven by clicking outside + // TITLE_BLOCK_RECT (see handleSheetPointerDown's title-block branch). + const handleTitleCellBlur = () => { + requestAnimationFrame(() => { + const active = document.activeElement; + if (active?.classList.contains("cad-titleblock-cell-input")) return; + setTitleEditingCellId(null); + }); + }; + const handleTitleCellKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape" || e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.blur(); + } + }; + const handleTitleCellChange = (cellId: string, value: string) => { + if (!titleTableRef.current) return; + titleTableRef.current = setCellText(titleTableRef.current, cellId, value); + setHasTitleTableEdits(true); + bumpTitleTable(); + repaintSheetCanvas(sheetPaintBase); + }; + + // "Click into a cell to type directly" (task 2: EDIT) - while a SINGLE + // cell is selected (not yet editing), typing a printable character + // opens the edit input seeded with just that character, REPLACING the + // cell's existing text - the same convention Excel itself uses (as + // opposed to double-click/Enter/F2, which edit in place preserving it, + // see beginEditingTitleCell/handleSheetPointerDown's double-click + // branch). Delete/Backspace clears the cell without entering edit mode + // at all. A multi-cell range selection is left alone - "type into every + // selected cell at once" isn't a spreadsheet behavior this task asks + // for. Reads titleTableRef/titleCellSelection fresh inside the handler + // (a ref plus values captured by the effect's own dependency array) + // rather than calling handleTitleCellChange/beginEditingTitleCell + // directly, so this never risks acting on a stale closure of them. + useEffect(() => { + if (!titleEditMode || titleEditingCellId) return; + // Guards against this SAME listener instance handling more than one + // keystroke - React state (titleEditingCellId) can't gate re-entry + // fast enough on its own: a fast typist's subsequent keydown events + // can arrive before React has committed the setTitleEditingCellId + // update and re-run this effect (which is what actually detaches this + // listener), and setCellText REPLACES a cell's text rather than + // appending, so a second event landing here would clobber the first + // character instead of the native input taking over. A plain local + // variable (not a ref/state) is enough since it's scoped to, and only + // ever read/written by, this one closure. + let openedEditing = false; + const onKeyDown = (e: KeyboardEvent) => { + if (openedEditing) return; + const table = titleTableRef.current; + const sel = titleCellSelection; + if (!table || !sel) return; + // Exactly ONE cell selected - not "exactly one grid unit", which + // would wrongly exclude any ordinary cell that legitimately spans + // several grid units by layout (e.g. the WEIGHT/PART NAME/SCALE + // value cells) rather than by user merge. A range selection always + // aligns exactly to whole cells (rangeFromUnits/mergeRange both + // snap outward - see expandRangeToCoverCells), so "exactly one cell + // covers this range" is the correct - and sufficient - test. + const covered = cellsInRange(table, sel); + if (covered.length !== 1) return; + const cell = covered[0]; + if (!isCellTypable(cell)) return; + + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + openedEditing = true; + titleTableRef.current = setCellText(table, cell.id, e.key); + setHasTitleTableEdits(true); + bumpTitleTable(); + setTitleEditingCellId(cell.id); + requestAnimationFrame(() => { + const el = titleActiveInputRef.current; + el?.focus(); + el?.setSelectionRange(el.value.length, el.value.length); + }); + } else if (e.key === "Enter" || e.key === "F2") { + e.preventDefault(); + openedEditing = true; + setTitleEditingCellId(cell.id); + requestAnimationFrame(() => titleActiveInputRef.current?.focus()); + } else if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + titleTableRef.current = setCellText(table, cell.id, ""); + setHasTitleTableEdits(true); + bumpTitleTable(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [titleEditMode, titleEditingCellId, titleCellSelection]); + + // Resolves logoImageRef to match `dataUrl` (task 4: logo upload) - + // pre-loads to a real HTMLImageElement OUTSIDE the paint path (see + // logoImageRef's own doc comment for why drawSheetTitleBlock can't do + // this itself), then bumps/repaints so the canvas picks it up. A failed + // load (corrupt/unreadable file) just falls back to the generated + // avatar rather than leaving a broken image or throwing. + const resolveLogoImage = (dataUrl: string | undefined) => { + if (!dataUrl) { + logoImageRef.current = null; + bumpTitleTable(); + repaintSheetCanvas(sheetPaintBase); + return; + } + loadImage(dataUrl) + .then((img) => { + logoImageRef.current = { dataUrl, img }; + bumpTitleTable(); + repaintSheetCanvas(sheetPaintBase); + }) + .catch(() => { + logoImageRef.current = null; + bumpTitleTable(); + repaintSheetCanvas(sheetPaintBase); + }); + }; + const handleUploadTitleLogo = (cellId: string, e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file || !titleTableRef.current) return; + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = typeof reader.result === "string" ? reader.result : undefined; + if (!dataUrl || !titleTableRef.current) return; + titleTableRef.current = setCellLogo(titleTableRef.current, cellId, dataUrl); + setHasTitleTableEdits(true); + bumpTitleTable(); + resolveLogoImage(dataUrl); + }; + reader.readAsDataURL(file); + }; + const handleRemoveTitleLogo = (cellId: string) => { + if (!titleTableRef.current) return; + titleTableRef.current = setCellLogo(titleTableRef.current, cellId, undefined); + setHasTitleTableEdits(true); + bumpTitleTable(); + resolveLogoImage(undefined); + }; + + // Notes handlers - checkbox toggles the dashed-border block on/off; + // pencil enters direct in-document edit mode, where every point + // (committed or the trailing new slot) is a real DOM input a click + // focuses natively, so any point is directly editable, not just the + // last one (see the render below). MAX_NOTES/MAX_NOTE_CHARS are the same + // constants drawSheetNotes' own sizing relies on (sheet-composer.ts), + // imported once so the UI's caps can never drift from the render's own + // capacity. + const handleToggleNotesEnabled = (enabled: boolean) => { + setSheetNotesEnabled(enabled); + if (!enabled && notesEditMode) exitNotesEditMode(); + }; + + // Exits edit mode (task: "Clicking outside / pressing Escape exits edit + // mode... dashed border and pencil remain visible") - prunes any + // whitespace-only point left behind (e.g. the untouched trailing slot, + // or an existing point emptied via select-all-delete rather than + // Backspace, which never adds/removes anything mid-typing) and restores + // whatever zoom level was active before editing started. + const exitNotesEditMode = () => { + setNotesEditMode(false); + setSheetNotes((prev) => prev.map((t) => t.trim()).filter((t) => t.length > 0)); + restoreZoomAfterNotes(); + }; + + const handlePencilClick = () => { + if (!sheetNotesEnabled || sheetNotes.length >= MAX_NOTES) return; + if (titleEditMode) exitTitleEditMode(); + setNotesEditMode(true); + pendingNoteFocusRef.current = { index: sheetNotes.length, cursor: "end" }; + zoomToNotesArea(); + }; + + // A blur that isn't immediately followed by another note-line input + // taking focus means the user clicked/tabbed away entirely - checked a + // frame later (rather than via blur's own relatedTarget, which is null + // for a click on the , an unfocusable element) so the newly + // focused element has actually landed by the time this checks. + const handleNoteLineBlur = () => { + requestAnimationFrame(() => { + const active = document.activeElement; + if (active?.classList.contains("cad-sheet-notes-line-input")) return; + exitNotesEditMode(); + }); + }; + + // Live in-place edit of note line `index` (0-based; may equal + // sheetNotes.length, the not-yet-existing trailing slot) - typing + // extends the array the moment there's real text, never before, so an + // untouched slot leaves no stray empty entry. + const handleNoteLineChange = (index: number, value: string) => { + const text = value.slice(0, MAX_NOTE_CHARS); + setSheetNotes((prev) => { + if (index < prev.length) { + const next = [...prev]; + next[index] = text; + return next; + } + return text.length === 0 ? prev : [...prev, text]; + }); + }; + + const handleNoteLineKeyDown = ( + index: number, + e: React.KeyboardEvent, + ) => { + const el = e.currentTarget; + if (e.key === "Enter") { + // Commits the current line and opens the next numbered point - a + // no-op on an empty line (never advances past nothing typed) or + // once there's no next line to open (MAX_NOTES already reached). + e.preventDefault(); + if (el.value.trim().length === 0) return; + const nextIndex = index + 1; + if (nextIndex >= effectiveNotesCount()) return; + const nextEl = noteInputRefs.current.get(nextIndex); + if (nextEl) { + nextEl.focus(); + nextEl.setSelectionRange(nextEl.value.length, nextEl.value.length); + } else { + pendingNoteFocusRef.current = { index: nextIndex, cursor: "end" }; + } + } else if (e.key === "Backspace" && el.value.length === 0) { + // Backspacing an already-empty point deletes it entirely and moves + // the cursor to the end of the previous point; remaining points + // renumber automatically since they're just array indices. + e.preventDefault(); + if (index < sheetNotes.length) { + setSheetNotes((prev) => prev.filter((_, i) => i !== index)); + } + if (index > 0) { + pendingNoteFocusRef.current = { index: index - 1, cursor: "end" }; + } + } else if (e.key === "Escape") { + // Stops this from also bubbling to the window listener that closes + // the WHOLE review modal on Escape - here it should only exit notes + // edit mode. + e.preventDefault(); + e.stopPropagation(); + el.blur(); + } + }; + + // Notes drag session (task 3: "the whole notes block can be dragged + // anywhere on the sheet") - independent of sheetAdjustMode (Adjust + // Drawing/Annotations govern the DRAWING's own content; the notes block + // is separate furniture draggable any time it's enabled and not mid-edit + // - see handleSheetPointerDown's own early check for this). + const handleNotesPointerMove = (e: React.PointerEvent) => { + const drag = notesDragRef.current; + if (!drag || drag.pointerId !== e.pointerId || !sheetPaintBase) return false; + const pos = sheetPointerToSheetSpace(e); + if (!pos) return true; + const size = effectiveNotesSize(); + const current = effectiveNotesPosition(); + const candidate = { x: pos.x - drag.grabOffsetX, y: pos.y - drag.grabOffsetY }; + const clamped = clampNotesPosition( + sheetPaintBase.layoutModel, + sheetAdjustmentsRef.current, + size, + current, + candidate, + ); + notesPositionRef.current = clamped; + repaintSheetCanvas(sheetPaintBase); + // Keeps the pencil overlay (a separate, state-positioned DOM element - + // see refreshNotesPencilIconPos's own doc comment) glued to the block's + // top-right corner DURING the drag too, not just once it lands - the + // canvas repaint above already moves the block itself every frame, so + // the icon needs the same per-frame treatment or it visibly lags behind + // until pointer-up. + refreshNotesPencilIconPos(); + return true; + }; + + const handleGenerateDrawingSheet = async () => { + if (!viewerRef.current) return; + setSheetPaintBase(null); + sheetAdjustmentsRef.current = createEmptySheetLayoutAdjustments(); + setHasSheetAdjustments(false); + setSheetAdjustModeState("none"); + setDrawingViewFilter("overall"); + setSelectedDimensionId(null); + setDeleteIconPos(null); + setSheetZoomPercent(100); + setSheetNotesEnabled(false); + setSheetNotes([]); + setNotesEditMode(false); + preNotesZoomRef.current = null; + notesPositionRef.current = null; + setNotesPositionState(null); + setTitleEditMode(false); + setTitleCellSelection(null); + setTitleEditingCellId(null); + preTitleZoomRef.current = null; + // A fresh generation always starts on Auto (task: "Default is Auto"), + // regardless of what scale a previous sheet's dropdown was left on. + sheetCaptureRef.current = null; + setSheetScaleMode("auto"); + setAutoScaleLabel(null); + setSheetOverflowWarning(null); + setScaleChangeNotice(null); + setDrawingSheetProgress({ + label: "Starting...", + index: 0, + total: DRAWING_SHEET_TOTAL_STEPS, + }); + try { + const captureResult = await viewerRef.current.generateHiddenLineViewSet( + (info) => { + if (info.done) return; + setDrawingSheetProgress({ + label: info.label, + index: info.index, + total: DRAWING_SHEET_TOTAL_STEPS, + }); + }, + ); + + const partName = (loadFileName || "part").replace(/\.[^./\\]+$/, ""); + const date = new Date().toLocaleDateString("en-CA"); + sheetCaptureRef.current = { captureResult, partName, date }; + + setDrawingSheetProgress({ + label: "Composing sheet...", + index: 4, + total: DRAWING_SHEET_TOTAL_STEPS, + }); + // Yield a frame so the label actually paints before the + // (synchronous) canvas composition work below runs. + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + + // Direction change: the composed sheet is delivered as-is - no + // completeness gate, no bounded retry loop. No manualRatio - this + // first composition is always Auto (see the scale-state reset above). + const composed = await composeA4DrawingSheet({ captureResult, partName, date }); + setSheetPaintBase({ + layoutModel: composed.layoutModel, + partName, + date, + scaleLabel: composed.scaleLabel, + }); + // Seeded fresh here (a genuinely new part/drawing) - NOT on a later + // scale change (handleScaleChange deliberately leaves titleTableRef/ + // logoImageRef untouched: content, structural edits, and the logo + // are scale-independent, and the boundScale cell already renders the + // live sheetPaintBase.scaleLabel regardless of its own stored text - + // see sheet-composer.ts's bound-cell substitution). + titleTableRef.current = defaultTitleBlockTable(partName, date, composed.scaleLabel); + logoImageRef.current = null; + bumpTitleTable(); + setHasTitleTableEdits(false); + setAutoScaleLabel(composed.scaleLabel); + // Live check (task 4), not composed.overflowWarning - adjustments + // are freshly empty here so this is equivalent, but going through + // the same function every other adjustment site uses keeps this + // from ever silently drifting from them. + setSheetOverflowWarning( + computeLiveOverflowWarning(composed.layoutModel, sheetAdjustmentsRef.current), + ); + // Open the full-sheet review modal - the user chooses "Adjust" or + // "Download" from there; no more implicit auto-download on generate. + setDrawingSheetModalOpen(true); + } finally { + setDrawingSheetProgress(null); + } + }; + + // Recomposes the CURRENT sheet at a new scale (modal "Scale" dropdown) - + // `next` is either "auto" (returns to automatic selection, unchanged + // from before - see composeA4DrawingSheet's own manualRatio doc comment) + // or one of MANUAL_SCALE_RATIOS. Reuses the cached capture + // (sheetCaptureRef) so this never re-runs the expensive 3D + // generateHiddenLineViewSet step - only the (comparatively cheap) 2D + // sheet composition. Any pre-existing manual POSITION adjustment is + // cleared (task: "meaningless at a different scale") and briefly + // surfaced via scaleChangeNotice rather than silently dropped - but + // `deletedIds` is content (which dimensions exist), not a position, so + // it's carried over untouched (see hasPositionAdjustments' own doc + // comment); dimension record ids are deterministic/feature-derived (e.g. + // "front-loc-horizontal-", see sheet-composer.ts's id + // construction), not per-composition-random, so a deletedIds entry from + // the old scale's composition still matches the right record in the new + // one. titleTableRef/logoImageRef are untouched here entirely - title + // block content, structural edits, and the logo are scale-independent + // (task) and were never part of SheetLayoutAdjustments to begin with. + const handleScaleChange = async (next: number | "auto") => { + const cached = sheetCaptureRef.current; + if (!cached || sheetScaleBusy || next === sheetScaleMode) return; + const hadPositionAdjustments = hasPositionAdjustments( + sheetAdjustmentsRef.current, + ); + sheetAdjustmentsRef.current = { + ...createEmptySheetLayoutAdjustments(), + deletedIds: sheetAdjustmentsRef.current.deletedIds, + }; + setHasSheetAdjustments(!isEmptySheetLayoutAdjustments(sheetAdjustmentsRef.current)); + setSelectedDimensionId(null); + setDeleteIconPos(null); + setScaleChangeNotice( + hadPositionAdjustments + ? "Manual position adjustments were cleared because the scale changed." + : null, + ); + setSheetScaleBusy(true); + try { + const { captureResult, partName, date } = cached; + const composed = await composeA4DrawingSheet({ + captureResult, + partName, + date, + manualRatio: next === "auto" ? undefined : next, + }); + setSheetPaintBase({ + layoutModel: composed.layoutModel, + partName, + date, + scaleLabel: composed.scaleLabel, + }); + setSheetScaleMode(next); + // Live check (task 4) - same reasoning as handleGenerateDrawingSheet: + // adjustments were just reset to empty above, so this reflects the + // freshly-composed content at this new scale, not a stale snapshot. + setSheetOverflowWarning( + computeLiveOverflowWarning(composed.layoutModel, sheetAdjustmentsRef.current), + ); + if (next === "auto") setAutoScaleLabel(composed.scaleLabel); + } finally { + setSheetScaleBusy(false); + } + }; + + // Clears composition offset, every per-dimension/caption adjustment, + // AND every deleted dimension (task 3: "Reset layout restores anything + // deleted along with clearing position adjustments") - all three live in + // the one SheetLayoutAdjustments object, so a fresh empty one clears all + // of them at once. + const handleResetSheetLayout = () => { + sheetAdjustmentsRef.current = createEmptySheetLayoutAdjustments(); + setHasSheetAdjustments(false); + setSelectedDimensionId(null); + setDeleteIconPos(null); + // Title block structural/content edits (task: "cleared by Reset + // layout") - unlike notes, which Reset layout deliberately leaves + // alone (see notesPositionRef's own doc comment). + if (sheetPaintBase) { + titleTableRef.current = defaultTitleBlockTable( + sheetPaintBase.partName, + sheetPaintBase.date, + sheetPaintBase.scaleLabel, + ); + logoImageRef.current = null; + bumpTitleTable(); + setHasTitleTableEdits(false); + setTitleCellSelection(null); + setTitleEditingCellId(null); + } + recomputeLiveOverflowWarning(); + repaintSheetCanvas(sheetPaintBase); + }; + + // Deletes the currently-selected dimension (task 3) - never a caption, + // captions have no delete affordance at all (the delete icon simply + // never renders for one, see computeDeleteIconPos). Also drops any + // stray per-dimension adjustment for the same id, so nothing dangles in + // sheetAdjustmentsRef.dimensions once the record itself is gone. + const handleDeleteSelectedDimension = () => { + const id = selectedDimensionIdRef.current; + if (!id || !sheetPaintBase) return; + const original = findDimensionRecordById(sheetPaintBase.layoutModel, id); + if (!original || original.kind === "caption") return; + const restDimensions = Object.fromEntries( + Object.entries(sheetAdjustmentsRef.current.dimensions).filter(([recordId]) => recordId !== id), + ); + sheetAdjustmentsRef.current = { + ...sheetAdjustmentsRef.current, + dimensions: restDimensions, + deletedIds: { ...sheetAdjustmentsRef.current.deletedIds, [id]: true }, + }; + setSelectedDimensionId(null); + setDeleteIconPos(null); + setHasSheetAdjustments(!isEmptySheetLayoutAdjustments(sheetAdjustmentsRef.current)); + recomputeLiveOverflowWarning(); + repaintSheetCanvas(sheetPaintBase); + }; + + // The contextual toolbar's actions (task 2: INSERT/DELETE/MERGE/SPLIT) - + // every one of them mutates titleTableRef, marks edits, and clears the + // current selection (whose r0/r1/c0/c1 would otherwise reference stale + // grid units after a structural change - simplest to just require a + // fresh click rather than trying to track how each op reindexes it). + const commitTitleTableChange = (next: TitleBlockTable) => { + titleTableRef.current = next; + setHasTitleTableEdits(true); + setTitleCellSelection(null); + setTitleEditingCellId(null); + bumpTitleTable(); + repaintSheetCanvas(sheetPaintBase); + }; + // Inserts act relative to the selection's own anchor cell (its r0,c0 + // corner) regardless of how many cells the selection spans - the task + // frames insert as a single-cell operation ("with a cell selected, + // insert row above/below..."). + const titleSelectionAnchorCell = (): TitleBlockCell | null => { + const table = titleTableRef.current; + if (!table || !titleCellSelection) return null; + return cellAt(table, titleCellSelection.r0, titleCellSelection.c0) ?? null; + }; + const handleTitleInsertRowAbove = () => { + const table = titleTableRef.current; + const cell = titleSelectionAnchorCell(); + if (!table || !cell) return; + commitTitleTableChange(insertRowAboveCell(table, cell)); + }; + const handleTitleInsertRowBelow = () => { + const table = titleTableRef.current; + const cell = titleSelectionAnchorCell(); + if (!table || !cell) return; + commitTitleTableChange(insertRowBelowCell(table, cell)); + }; + const handleTitleInsertColumnLeft = () => { + const table = titleTableRef.current; + const cell = titleSelectionAnchorCell(); + if (!table || !cell) return; + commitTitleTableChange(insertColumnLeftOfCell(table, cell)); + }; + const handleTitleInsertColumnRight = () => { + const table = titleTableRef.current; + const cell = titleSelectionAnchorCell(); + if (!table || !cell) return; + commitTitleTableChange(insertColumnRightOfCell(table, cell)); + }; + // Delete row(s)/column(s) act on every row/column the selection touches + // (task: "delete selected row(s) or column(s) entirely") - a range + // spanning multiple grid rows/columns removes all of them at once. + const handleTitleDeleteRows = () => { + const table = titleTableRef.current; + if (!table || !titleCellSelection) return; + commitTitleTableChange(deleteRows(table, titleCellSelection.r0, titleCellSelection.r1)); + }; + const handleTitleDeleteColumns = () => { + const table = titleTableRef.current; + if (!table || !titleCellSelection) return; + commitTitleTableChange(deleteColumns(table, titleCellSelection.c0, titleCellSelection.c1)); + }; + const handleTitleMergeSelection = () => { + const table = titleTableRef.current; + if (!table || !titleCellSelection) return; + commitTitleTableChange(mergeRange(table, titleCellSelection)); + }; + const handleTitleSplitSelection = () => { + const table = titleTableRef.current; + if (!table || !titleCellSelection) return; + const [cell] = cellsInRange(table, titleCellSelection); + if (!cell) return; + commitTitleTableChange(splitCell(table, cell.id)); + }; + + // Safety net for a FIXED (already-dragged) notes position that a later + // note-count change (a commit or a delete, both change the block's + // height - see notesBlockHeight) leaves colliding with something it + // didn't collide with before - falls back to the default rest position + // (recomputed for the new size) rather than leave the block visibly + // overlapping content. A no-op whenever the position hasn't been + // manually dragged yet (there's nothing fixed to invalidate) or nothing + // is actually wrong. + useEffect(() => { + if (!sheetPaintBase || !notesPositionRef.current) return; + const size = notesBlockSize(effectiveNotesCount()); + if ( + !isValidNotesPosition( + sheetPaintBase.layoutModel, + sheetAdjustmentsRef.current, + size, + notesPositionRef.current, + ) + ) { + notesPositionRef.current = null; + setNotesPositionState(null); + } + }, [sheetNotes, notesEditMode, sheetPaintBase]); + + // Switches between the two mutually-exclusive adjust modes - clicking + // the already-active one turns adjusting off entirely (back to "none"); + // clicking the other one switches directly to it. Leaving Annotations + // mode always clears the current selection (a fresh "which dimension" + // context each time it's entered), but never touches sheetAdjustmentsRef + // - both whole-composition and per-dimension adjustments persist across + // any mode switch, exactly like Reset (not this) is the only thing that + // clears them. + // + // Diagnostic dump (task 4: "dump the current computed min/max allowed + // offset per axis alongside the true content extents, so we can confirm + // the diagnosis rather than assume it") - the composition drag range + // dragRangeForComposition currently computes, PLUS the raw + // fullContentBounds/FRAME_SAFE_AREA numbers that range is derived from, + // so the two can always be cross-checked directly instead of trusting + // the range in isolation. If a part's content already fills the usable + // frame height (or width) with no slack to spare, that axis's range + // collapses to (near-)zero and this says so explicitly rather than + // leaving a drag that silently does nothing unexplained. Fires on every + // Adjust Drawing mode entry AND after every composition/view-group drag + // ends (see handleSheetPointerUp), so the numbers are always live, not + // a one-time snapshot - confirmed empirically that this genuinely + // changes after an independent adjustment (e.g. a caption dragged up) + // shrinks or grows the true extent, which is exactly the class of + // staleness clampCompositionOffset/dragRangeForComposition's own doc + // comments describe (sheet-interactive-render.ts). + const logDragRangeDiagnostic = (label: string) => { + if (!sheetPaintBase) return; + const model = sheetPaintBase.layoutModel; + const adjustments = sheetAdjustmentsRef.current; + const range = dragRangeForComposition(model, adjustments); + const box = fullContentBounds(model, { + ...adjustments, + composition: { dx: 0, dy: 0 }, + }); + const mm = (px: number) => (px / SHEET_PX_PER_MM).toFixed(1); + const slackMm = ([lo, hi]: [number, number]) => + ((hi - lo) / SHEET_PX_PER_MM).toFixed(1); + console.log( + `[2D Drawing] ${label}: allowed composition offset - ` + + `x [${mm(range.x[0])}, ${mm(range.x[1])}]mm (${slackMm(range.x)}mm slack), ` + + `y [${mm(range.y[0])}, ${mm(range.y[1])}]mm (${slackMm(range.y)}mm slack). ` + + `True content extent (current adjustments, composition zeroed): ` + + `x [${mm(box.x)}, ${mm(box.x + box.w)}]mm, y [${mm(box.y)}, ${mm(box.y + box.h)}]mm. ` + + `FRAME_SAFE_AREA: x [${mm(FRAME_SAFE_AREA.x)}, ${mm(FRAME_SAFE_AREA.x + FRAME_SAFE_AREA.w)}]mm, ` + + `y [${mm(FRAME_SAFE_AREA.y)}, ${mm(FRAME_SAFE_AREA.y + FRAME_SAFE_AREA.h)}]mm. ` + + `Composition offset now: dx ${mm(adjustments.composition.dx)}mm, dy ${mm(adjustments.composition.dy)}mm.`, + ); + }; + + const handleSetAdjustMode = (mode: "drawing" | "annotations") => { + const next = sheetAdjustMode === mode ? "none" : mode; + setSheetAdjustModeState(next); + if (next !== "annotations") { + setSelectedDimensionId(null); + setDeleteIconPos(null); + } + if (next === "drawing") { + // Fresh sub-mode context every time Drawing is (re-)entered - the + // four view options ("Overall" is the default) - so leaving it + // never carries a stale Top/Right/3D-View selection into the next + // entry. + setDrawingViewFilter("overall"); + } + if (next === "drawing" && sheetPaintBase) { + logDragRangeDiagnostic("Adjust Drawing mode entered"); + } + // Mode switches never touch sheetAdjustmentsRef (both kinds of + // adjustment persist across any mode change - only Reset clears + // them), but leaving Annotations mode above may just have cleared a + // live selection highlight, which needs an explicit repaint to + // actually disappear (nothing else about the painted sheet changed). + repaintSheetCanvas(sheetPaintBase); + }; + + // Switches Adjust Drawing's own view option (task 1: Overall/Top/Right/ + // 3D View, moved here from Adjust Annotations - see DrawingViewFilter's + // doc comment). Never touches sheetAdjustmentsRef - every kind of + // adjustment (composition, per-dimension, per-caption, per-view-group) + // persists across a filter switch exactly like it does across a + // top-level mode switch; only Reset clears any of them. Selection state + // doesn't need clearing here - it's exclusively an Adjust Annotations + // concept, and is already guaranteed clear while Drawing mode is active + // (see handleSetAdjustMode). + const handleSetDrawingViewFilter = (filter: DrawingViewFilter) => { + setDrawingViewFilter(filter); + repaintSheetCanvas(sheetPaintBase); + }; + + // The canvas's own cursor for the current mode - Drawing (whichever of + // its four view options) is always a "drag anywhere" surface, no + // selection step; Annotations keeps the click-to-select-then-drag + // semantics. One function so the static style (JSX below) and the reset + // after a drag ends (handleSheetPointerUp) can never say something + // different. + const sheetCanvasCursor = (): string => { + if (titleEditMode) { + if (titleResizeDragRef.current) { + return titleResizeDragRef.current.axis === "v" ? "col-resize" : "row-resize"; + } + if (titleHover?.kind === "resize") { + return titleHover.axis === "v" ? "col-resize" : "row-resize"; + } + return "text"; + } + if (sheetAdjustMode === "drawing") return "grab"; + if (sheetAdjustMode === "annotations") return "pointer"; + return "default"; + }; + + // Isometric corner view target DPI for a PDF export - the generation- + // time isoView.img is only as sharp as the live 3D viewport happened to + // be at capture time (see viewer.ts's captureHighResIsoView doc + // comment), which isn't guaranteed to print crisply at any given DPI - + // so the PDF path re-captures it fresh, sized specifically for its + // actual printed footprint on the sheet. + const ISO_EXPORT_DPI = 300; + + const handleDownloadSheetPdf = async () => { + if (!sheetPaintBase) return; + const partName = (loadFileName || "part").replace(/\.[^./\\]+$/, ""); + + // Swap in a freshly-captured, print-resolution isometric raster for + // just this export - never mutates the live sheetPaintBase state (a + // shallow copy), so the on-screen view/adjustments are untouched. + // Falls back to the existing lower-res capture if the viewer isn't + // available for any reason (not expected while the sheet modal, which + // requires a loaded part, is open). + let effectiveBase = sheetPaintBase; + const isoView = sheetPaintBase.layoutModel.isoView; + if (isoView && viewerRef.current) { + const destWMm = isoView.destRect.w / SHEET_PX_PER_MM; + const destHMm = isoView.destRect.h / SHEET_PX_PER_MM; + const targetW = Math.round((destWMm / 25.4) * ISO_EXPORT_DPI); + const targetH = Math.round((destHMm / 25.4) * ISO_EXPORT_DPI); + const highRes = viewerRef.current.captureHighResIsoView(targetW, targetH); + if (highRes) { + const img = await loadImage(highRes.dataURL); + effectiveBase = { + ...sheetPaintBase, + layoutModel: { + ...sheetPaintBase.layoutModel, + isoView: { img, srcRect: highRes.cropPx, destRect: isoView.destRect }, + }, + }; + } + } + + // True A4 landscape (297x210mm), a jsPDF doc in mm units directly - + // the shim converts every sheet-px coordinate paintInteractiveSheet + // passes it into mm via SHEET_PX_PER_MM, so the finished page is + // physically exact (see pdf-canvas-shim.ts's own doc comment). + const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" }); + doc.setProperties({ title: partName || "drawing" }); + const shimCtx = createPdfCanvasContext(doc); + // Same call, same "clean" args (no selection highlight, no dashed + // notes border, no title-block edit chrome) handleDownloadSheet always + // used for the PNG export - paintInteractiveSheet itself is + // completely unaware it's painting into a PDF rather than a canvas. + paintInteractiveSheet( + shimCtx, + effectiveBase, + sheetAdjustmentsRef.current, + null, + { + enabled: sheetNotesEnabled, + items: sheetNotes, + position: effectiveNotesPosition(), + editMode: false, + showBorder: false, + }, + titleTableRef.current + ? { + table: titleTableRef.current, + editMode: false, + selection: null, + logoImage: logoImageRef.current?.img ?? null, + } + : undefined, + ); + doc.save(`${partName || "drawing"}.pdf`); + }; + + // Maps a pointer event's client coordinates to sheet-internal px space + // (SHEET_W x SHEET_H) - the canvas is displayed at CSS width: 100% but + // has a fixed backing resolution, so client coords need rescaling by + // however much smaller/larger the canvas is currently rendered. + const sheetPointerToSheetSpace = ( + e: React.PointerEvent, + ): { x: number; y: number } | null => { + const canvas = sheetCanvasRef.current; + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + return { + x: ((e.clientX - rect.left) / rect.width) * SHEET_W, + y: ((e.clientY - rect.top) / rect.height) * SHEET_H, + }; + }; + + // Adjust Drawing: whole-composition drag (Overall) or a single + // whole-view-group drag (Top/Right/3D View - task 1, moved here from + // Adjust Annotations). Adjust Annotations: pointerdown always selects + // (or deselects) whatever's under the pointer first - a plain click + // still highlights a dimension/caption even if no drag follows - then, + // for a hit, starts that ONE record's own drag session: constrained to + // its drafting-correct axis (linear), pivoting around its fixed feature + // anchor (circular/size), or vertical-only and linked to its caption + // group (caption). Every drag session is computed against the PRISTINE + // record (findDimensionRecordById) so repeated drags recompute from + // rest instead of drifting. The delete-icon overlay is hidden the + // moment a pointerdown starts (whether or not a real drag follows) and + // only recomputed again on pointerup, so it never has to track mid-drag + // movement frame-by-frame. + // Hit-test/hover tolerances for the title block's structural editing + // (grid-line resize/select, "+" insert strip) - sheet px, shared by + // pointerdown's resize-start and pointermove's hover computation so they + // can never disagree about what's "near" a line. + const TITLE_HIT_TOL_PX = 14; + const TITLE_CLICK_MOVE_TOL_PX = 4; + const TITLE_DBLCLICK_MS = 400; + + const handleSheetPointerDown = ( + e: React.PointerEvent, + ) => { + if (!sheetPaintBase) return; + const pos = sheetPointerToSheetSpace(e); + if (!pos) return; + + // Title block table-edit mode - takes over the canvas entirely, + // independent of sheetNotesEnabled/sheetAdjustMode (same precedence + // notesEditMode already has over the rest of this handler). A hit on + // an interior grid line starts a resize-drag, settled into a resize + // vs. a select-click on pointerup by how far it actually moved + // (titleResizeDragRef.moved). A click entirely outside + // TITLE_BLOCK_RECT exits edit mode explicitly - the canvas isn't a + // focusable element, so the blur-heuristic notes' own exit relies on + // would never fire for it. + if (titleEditMode && titleTableRef.current) { + const rect = TITLE_BLOCK_RECT; + const inRect = + pos.x >= rect.x && pos.x <= rect.x + rect.w && pos.y >= rect.y && pos.y <= rect.y + rect.h; + if (!inRect) { + exitTitleEditMode(); + return; + } + const hit = hitTestTitleGridLine(titleTableRef.current, rect, pos.x, pos.y, TITLE_HIT_TOL_PX); + if (hit) { + titleResizeDragRef.current = { + pointerId: e.pointerId, + axis: hit.axis, + lineIndex: hit.lineIndex, + startPos: hit.axis === "v" ? pos.x : pos.y, + unit: hit.unit, + moved: false, + }; + e.currentTarget.setPointerCapture(e.pointerId); + e.currentTarget.style.cursor = hit.axis === "v" ? "col-resize" : "row-resize"; + return; + } + + // Cell/range selection (task 2: SELECT - "click a cell to select; + // click-drag or shift-click to select a range"). Shift-click always + // extends from the sticky anchor (titleSelectionAnchorRef), never + // starts a drag of its own. + const unit = hitTestTitleUnit(titleTableRef.current, rect, pos.x, pos.y); + if (e.shiftKey && titleSelectionAnchorRef.current) { + setTitleCellSelection(rangeFromUnits(titleTableRef.current, titleSelectionAnchorRef.current, unit)); + setTitleEditingCellId(null); + return; + } + + const clickedCell = hitTestTitleCell(titleTableRef.current, rect, pos.x, pos.y); + const now = Date.now(); + const last = lastTitleCellClickRef.current; + const isDoubleClick = !!clickedCell && !!last && last.cellId === clickedCell.id && now - last.time <= TITLE_DBLCLICK_MS; + lastTitleCellClickRef.current = clickedCell ? { cellId: clickedCell.id, time: now } : null; + if (isDoubleClick && clickedCell) { + setTitleCellSelection(cellRange(clickedCell)); + beginEditingTitleCell(clickedCell.id); + return; + } + + titleSelectionAnchorRef.current = unit; + titleRangeDragRef.current = { pointerId: e.pointerId, anchor: unit, moved: false }; + setTitleEditingCellId(null); + setTitleCellSelection( + clickedCell ? cellRange(clickedCell) : rangeFromUnits(titleTableRef.current, unit, unit), + ); + e.currentTarget.setPointerCapture(e.pointerId); + return; + } + + // Notes-block drag (task 3) - independent of sheetAdjustMode, so it + // works whether or not Adjust Drawing/Annotations is active. Only + // live while not actively text-editing (dragging the border while + // typing would be an odd, unintended interaction) and only when the + // pointer actually landed on the block itself - since the block is + // guaranteed collision-free against every real content rect (that's + // the whole point of clampNotesPosition), this can never accidentally + // steal a click meant for a dimension/caption underneath it. + if (sheetNotesEnabled && !notesEditMode) { + const notesPos = effectiveNotesPosition(); + const notesSize = effectiveNotesSize(); + if ( + pos.x >= notesPos.x && + pos.x <= notesPos.x + notesSize.w && + pos.y >= notesPos.y && + pos.y <= notesPos.y + notesSize.h + ) { + notesDragRef.current = { + pointerId: e.pointerId, + grabOffsetX: pos.x - notesPos.x, + grabOffsetY: pos.y - notesPos.y, + }; + e.currentTarget.setPointerCapture(e.pointerId); + e.currentTarget.style.cursor = "grabbing"; + return; + } + } + + if (sheetAdjustMode === "none") return; + + if (sheetAdjustMode === "drawing") { + if (drawingViewFilter === "overall") { + sheetDragRef.current = { + pointerId: e.pointerId, + startSheetX: pos.x, + startSheetY: pos.y, + startComposition: sheetAdjustmentsRef.current.composition, + }; + e.currentTarget.setPointerCapture(e.pointerId); + e.currentTarget.style.cursor = "grabbing"; + return; + } + + // Adjust Drawing's Top/Right/3D View options (task 1): no + // hit-test, no selection - any pointerdown anywhere on the canvas + // starts dragging that ONE whole view group immediately. + const vg = sheetAdjustmentsRef.current.viewGroups; + if (drawingViewFilter === "top") { + sheetViewGroupDragRef.current = { + pointerId: e.pointerId, + kind: "top", + startSheetY: pos.y, + startOffset: vg.top, + }; + } else if (drawingViewFilter === "right") { + sheetViewGroupDragRef.current = { + pointerId: e.pointerId, + kind: "right", + startSheetX: pos.x, + startOffset: vg.right, + }; + } else { + sheetViewGroupDragRef.current = { + pointerId: e.pointerId, + kind: "iso", + startSheetX: pos.x, + startSheetY: pos.y, + startOffset: vg.iso, + }; + } + e.currentTarget.setPointerCapture(e.pointerId); + e.currentTarget.style.cursor = "grabbing"; + repaintSheetCanvas(sheetPaintBase); + return; + } + + // Adjust Annotations (task 1: unconditionally the per-dimension/ + // caption select+drag - the view-group options above moved to Adjust + // Drawing). + const reflowed = reflowAllRecords(sheetPaintBase.layoutModel, sheetAdjustmentsRef.current); + const hit = hitTestDimension(reflowed, pos.x, pos.y); + setSelectedDimensionId(hit ? hit.id : null); + setDeleteIconPos(null); + const original = hit ? findDimensionRecordById(sheetPaintBase.layoutModel, hit.id) : null; + if (!hit || !original) { + repaintSheetCanvas(sheetPaintBase); + return; + } + + // The combined (composition + this record's own view-group) offset + // already baked into where the record currently sits on screen - see + // combinedViewOffset's own doc comment for why this, not the plain + // composition offset, is the correct baseline once a Top/Right + // view-group drag may already have shifted this record's own view. + const comp = combinedViewOffset(original.view, sheetAdjustmentsRef.current); + const existingAdj = sheetAdjustmentsRef.current.dimensions[hit.id]; + if (original.kind === "caption") { + const group = captionGroupForView(original.view); + const existingY = sheetAdjustmentsRef.current.captions[group]; + sheetDimensionDragRef.current = { + pointerId: e.pointerId, + id: hit.id, + kind: "caption", + group, + startSheetY: pos.y, + startY: existingY !== null ? existingY : (original.labelRect?.y ?? 0), + }; + } else if (original.kind === "size") { + const currentElbow = + existingAdj && existingAdj.kind === "circular" + ? { x: existingAdj.elbowX, y: existingAdj.elbowY } + : { x: original.lineSegments[0].x2, y: original.lineSegments[0].y2 }; + sheetDimensionDragRef.current = { + pointerId: e.pointerId, + id: hit.id, + kind: "circular", + grabOffsetX: pos.x - (comp.dx + currentElbow.x), + grabOffsetY: pos.y - (comp.dy + currentElbow.y), + }; + } else if (original.axis) { + sheetDimensionDragRef.current = { + pointerId: e.pointerId, + id: hit.id, + kind: "linear", + axis: original.axis, + startSheetX: pos.x, + startSheetY: pos.y, + startDelta: existingAdj && existingAdj.kind === "linear" ? existingAdj.crossDelta : 0, + }; + } else { + // Selectable but nothing to drag (shouldn't occur for a non-caption + // record in practice) - selection only. + repaintSheetCanvas(sheetPaintBase); + return; + } + e.currentTarget.setPointerCapture(e.pointerId); + e.currentTarget.style.cursor = "grabbing"; + repaintSheetCanvas(sheetPaintBase); + }; + + const handleSheetPointerMove = ( + e: React.PointerEvent, + ) => { + const pos = sheetPointerToSheetSpace(e); + if (!pos || !sheetPaintBase) return; + + if (titleEditMode && titleTableRef.current) { + const table = titleTableRef.current; + const rect = TITLE_BLOCK_RECT; + const resizeDrag = titleResizeDragRef.current; + if (resizeDrag && resizeDrag.pointerId === e.pointerId) { + const curPos = resizeDrag.axis === "v" ? pos.x : pos.y; + if (Math.abs(curPos - resizeDrag.startPos) > TITLE_CLICK_MOVE_TOL_PX) { + resizeDrag.moved = true; + } + const frac = + resizeDrag.axis === "v" + ? (pos.x - rect.x) / rect.w + : (pos.y - rect.y) / rect.h; + titleTableRef.current = + resizeDrag.axis === "v" + ? resizeColumnLine(table, resizeDrag.lineIndex, frac) + : resizeRowLine(table, resizeDrag.lineIndex, frac); + repaintSheetCanvas(sheetPaintBase); + return; + } + + // Range-drag continuation (task 2: "click-drag... to select a + // range") - extends live from the drag's own anchor as the pointer + // moves, the same moved-tolerance shape resizeDrag itself uses just + // above to tell a real drag apart from a plain click. + const rangeDrag = titleRangeDragRef.current; + if (rangeDrag && rangeDrag.pointerId === e.pointerId) { + const unit = hitTestTitleUnit(table, rect, pos.x, pos.y); + if (unit.r !== rangeDrag.anchor.r || unit.c !== rangeDrag.anchor.c) { + rangeDrag.moved = true; + } + setTitleCellSelection(rangeFromUnits(table, rangeDrag.anchor, unit)); + return; + } + + const lineHit = hitTestTitleGridLine(table, rect, pos.x, pos.y, TITLE_HIT_TOL_PX); + const next = lineHit ? { kind: "resize" as const, axis: lineHit.axis, lineIndex: lineHit.lineIndex } : null; + setTitleHover((prev) => { + if (prev?.axis === next?.axis && prev?.lineIndex === next?.lineIndex) { + return prev; + } + return next; + }); + return; + } + + if (handleNotesPointerMove(e)) return; + + const compositionDrag = sheetDragRef.current; + if (compositionDrag && compositionDrag.pointerId === e.pointerId) { + const deltaX = pos.x - compositionDrag.startSheetX; + const deltaY = pos.y - compositionDrag.startSheetY; + const candidate: Offset = { + dx: compositionDrag.startComposition.dx + deltaX, + dy: compositionDrag.startComposition.dy + deltaY, + }; + sheetAdjustmentsRef.current = { + ...sheetAdjustmentsRef.current, + composition: clampCompositionOffset( + sheetPaintBase.layoutModel, + sheetAdjustmentsRef.current, + candidate, + ), + }; + // Live (task 4: "immediately") - not just at drag end, so the + // warning/red border track the drag in real time. + recomputeLiveOverflowWarning(); + repaintSheetCanvas(sheetPaintBase); + return; + } + + const viewGroupDrag = sheetViewGroupDragRef.current; + if (viewGroupDrag && viewGroupDrag.pointerId === e.pointerId) { + const comp = sheetAdjustmentsRef.current.composition; + if (viewGroupDrag.kind === "top") { + const rawDelta = pos.y - viewGroupDrag.startSheetY; + const clamped = clampTopViewOffset( + sheetPaintBase.layoutModel, + sheetAdjustmentsRef.current, + viewGroupDrag.startOffset + rawDelta, + ); + sheetAdjustmentsRef.current = { + ...sheetAdjustmentsRef.current, + viewGroups: { ...sheetAdjustmentsRef.current.viewGroups, top: clamped }, + }; + } else if (viewGroupDrag.kind === "right") { + const rawDelta = pos.x - viewGroupDrag.startSheetX; + const clamped = clampRightViewOffset( + sheetPaintBase.layoutModel, + sheetAdjustmentsRef.current, + viewGroupDrag.startOffset + rawDelta, + ); + sheetAdjustmentsRef.current = { + ...sheetAdjustmentsRef.current, + viewGroups: { ...sheetAdjustmentsRef.current.viewGroups, right: clamped }, + }; + } else { + const candidate: Offset = { + dx: viewGroupDrag.startOffset.dx + (pos.x - viewGroupDrag.startSheetX), + dy: viewGroupDrag.startOffset.dy + (pos.y - viewGroupDrag.startSheetY), + }; + const clamped = clampIsoViewOffset(sheetPaintBase.layoutModel, comp, candidate); + sheetAdjustmentsRef.current = { + ...sheetAdjustmentsRef.current, + viewGroups: { ...sheetAdjustmentsRef.current.viewGroups, iso: clamped }, + }; + } + recomputeLiveOverflowWarning(); + repaintSheetCanvas(sheetPaintBase); + return; + } + + const dimDrag = sheetDimensionDragRef.current; + if (!dimDrag || dimDrag.pointerId !== e.pointerId) return; + const original = findDimensionRecordById(sheetPaintBase.layoutModel, dimDrag.id); + if (!original) return; + // Same combined (composition + this record's own view-group) offset + // pointerdown above used as the grab-offset baseline - see its own + // comment. + const comp = combinedViewOffset(original.view, sheetAdjustmentsRef.current); + + if (dimDrag.kind === "linear") { + const rawDelta = + dimDrag.axis === "vertical" ? pos.x - dimDrag.startSheetX : pos.y - dimDrag.startSheetY; + const clamped = clampLinearDimensionDelta(original, comp, dimDrag.startDelta + rawDelta); + sheetAdjustmentsRef.current = { + ...sheetAdjustmentsRef.current, + dimensions: { + ...sheetAdjustmentsRef.current.dimensions, + [dimDrag.id]: { kind: "linear", crossDelta: clamped }, + }, + }; + } else if (dimDrag.kind === "circular") { + const candidateLocal = { + x: pos.x - dimDrag.grabOffsetX - comp.dx, + y: pos.y - dimDrag.grabOffsetY - comp.dy, + }; + const clamped = clampCircularDimensionElbow(original, comp, candidateLocal); + sheetAdjustmentsRef.current = { + ...sheetAdjustmentsRef.current, + dimensions: { + ...sheetAdjustmentsRef.current.dimensions, + [dimDrag.id]: { kind: "circular", elbowX: clamped.x, elbowY: clamped.y }, + }, + }; + } else { + // Caption - vertical-only, shared across its whole linked group + // (task 2: FRONT+RIGHT move together, TOP independently). + const candidateY = dimDrag.startY + (pos.y - dimDrag.startSheetY); + const clampedY = clampCaptionY(original, comp, candidateY); + sheetAdjustmentsRef.current = { + ...sheetAdjustmentsRef.current, + captions: { ...sheetAdjustmentsRef.current.captions, [dimDrag.group]: clampedY }, + }; + } + recomputeLiveOverflowWarning(); + repaintSheetCanvas(sheetPaintBase); + }; + + const handleSheetPointerUp = (e: React.PointerEvent) => { + const titleResizeDrag = titleResizeDragRef.current; + if (titleResizeDrag && titleResizeDrag.pointerId === e.pointerId) { + titleResizeDragRef.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + e.currentTarget.style.cursor = sheetCanvasCursor(); + if (titleResizeDrag.moved) { + // A real resize, not a click - commits it (task: RESIZE persists). + // Unlike the old segment-selection model, a resize only changes + // rowFracs/colFracs, never which grid units a cell occupies, so + // the current cell/range selection (if any) stays perfectly valid + // and doesn't need clearing. + setHasTitleTableEdits(true); + bumpTitleTable(); + repaintSheetCanvas(sheetPaintBase); + } + // A plain click that landed on a line (no drag) is a no-op in the + // cell/range selection model - lines aren't selectable objects, only + // cells/ranges are (see handleSheetPointerDown's own cell-hit-test + // branch, checked only once a line-hit comes back null). + return; + } + + const titleRangeDrag = titleRangeDragRef.current; + if (titleRangeDrag && titleRangeDrag.pointerId === e.pointerId) { + titleRangeDragRef.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + e.currentTarget.style.cursor = sheetCanvasCursor(); + // Selection itself was already kept live during the drag (see + // handleSheetPointerMove's own range-drag-continuation branch) - + // nothing further to commit here. + return; + } + + const notesDrag = notesDragRef.current; + if (notesDrag && notesDrag.pointerId === e.pointerId) { + notesDragRef.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + e.currentTarget.style.cursor = sheetCanvasCursor(); + // Commits the ref-tracked drag position into state (task 3) - the + // one point this needs a real re-render, so the pencil icon's own + // (state-derived) screen position updates to match where the block + // actually landed. Mirrors hasSheetAdjustments' own ref-during-drag/ + // state-at-rest pattern. + setNotesPositionState(notesPositionRef.current); + return; + } + + const compositionDrag = sheetDragRef.current; + const dimDrag = sheetDimensionDragRef.current; + const viewGroupDrag = sheetViewGroupDragRef.current; + const wasActive = + (compositionDrag !== null && compositionDrag.pointerId === e.pointerId) || + (dimDrag !== null && dimDrag.pointerId === e.pointerId) || + (viewGroupDrag !== null && viewGroupDrag.pointerId === e.pointerId); + if (!wasActive) return; + // Live diagnostic (task 4) - logged BEFORE clearing the drag refs so + // it reflects the composition/view-group drag that just ended, using + // the CURRENT (just-updated) sheetAdjustmentsRef, not a stale one. + if (compositionDrag || viewGroupDrag) { + logDragRangeDiagnostic("Composition/view-group drag ended"); + } + sheetDragRef.current = null; + sheetDimensionDragRef.current = null; + sheetViewGroupDragRef.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + setHasSheetAdjustments(!isEmptySheetLayoutAdjustments(sheetAdjustmentsRef.current)); + refreshDeleteIconPos(selectedDimensionIdRef.current); + e.currentTarget.style.cursor = sheetCanvasCursor(); + }; + const workingPartExportPlan = modelSession ? getWorkingPartExportPlan(modelSession, workerCapabilities) : null; @@ -3297,6 +5505,28 @@ export const CadViewer = forwardRef( + + {sheetPaintBase && !drawingSheetModalOpen && ( + + )} + {/* Dimensions Info */} {dimsMM && ( <> @@ -3534,6 +5764,616 @@ export const CadViewer = forwardRef( )} + + {/* 2D Drawing Sheet review modal - opens automatically once + "Generate 2D Drawing" finishes. Standard dismissal: X / click + outside / Esc (Esc handled by the useEffect above). */} + + {drawingSheetModalOpen && sheetPaintBase && ( + setDrawingSheetModalOpen(false)} + > + e.stopPropagation()} + > + + {/* Floating toast (task: "doesn't reflow anything") for the + scale-change notice - absolutely positioned over the + modal rather than an in-flow banner, auto-dismissed by + the effect that owns scaleChangeNotice above. */} + + {scaleChangeNotice && ( + + {scaleChangeNotice} + + )} + + {/* Top toolbar (task 1) - every control lives here, above + the sheet: zoom, notes, Scale, Reset layout, Adjust + Drawing (+ its view dropdown when active), Adjust + Annotations, Download, plus the status row (warning/ + notice/hint). Nothing renders below the canvas at all. */} +
+
+ {/* Zoom control (task 2): "-"/"+" step through + ZOOM_STEPS, the value box also accepts a typed + value directly (commitZoomDraft, clamped to + [ZOOM_MIN, ZOOM_MAX]). 100% (the floor) is the whole + sheet fitted, matching the modal's old default. */} +
+
+ + + setZoomDraft(e.target.value.replace(/[^0-9]/g, "")) + } + onBlur={commitZoomDraft} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} + aria-label="Zoom percent" + /> + % + +
+
+ + {/* Notes (task) - unchecked/disabled by default, "not + rendered at all" until enabled. Editing is inline on + the sheet itself now (pencil icon overlay, below) - + this checkbox only ever toggles the block on/off. */} +
+ +
+ + {/* Scale override (task: manual override, Auto stays the + default) - the "Auto" option's own label always shows + what automatic selection most recently chose + (autoScaleLabel), so it never requires guessing what + switching back to it will do. Red border exactly + while sheetOverflowWarning is live, its own message + surfaced only as this control's native hover tooltip + (task: "no persistent banner") - both clear together + the moment content is back inside the margin, since + both are driven by the same state. */} +
+
+ + + {sheetScaleBusy && ( + Recomposing... + )} +
+
+ +
+ {sheetAdjustMode !== "none" && ( + + )} + + {/* View-selection dropdown (task 1+2): moved from Adjust + Annotations, consolidated from four buttons into one + select, shown only while Adjust Drawing is active. */} + {sheetAdjustMode === "drawing" && ( + + )} + +
+ +
+ +
+
+ + {sheetAdjustMode !== "none" && ( +
+ {sheetAdjustMode === "drawing" + ? drawingViewFilter === "top" + ? "Drag anywhere to move the Top view vertically - it stays horizontally centred on Front, and never moves the 3D reference view" + : drawingViewFilter === "right" + ? "Drag anywhere to move the Right view horizontally - it stays vertically centred on Front's centreline" + : drawingViewFilter === "iso" + ? "Drag anywhere to move the 3D reference view freely - completely independent of every other view" + : "Drag anywhere to reposition the entire drawing" + : (() => { + const selected = + selectedDimensionId && sheetPaintBase + ? findDimensionRecordById(sheetPaintBase.layoutModel, selectedDimensionId) + : null; + if (!selected) { + return "Click a dimension or view caption to select it, then drag to reposition"; + } + return selected.kind === "caption" + ? `Selected: ${selected.text} caption - drag vertically to reposition` + : `Selected: ${selected.text ?? selectedDimensionId} - drag to reposition, or use the delete icon to remove it`; + })()} +
+ )} +
+
+ + {/* Delete control (task 3) - only while a DIMENSION (never + a caption) is selected in Adjust Annotations mode; see + computeDeleteIconPos, which returns null for a caption + selection or no selection at all. */} + {sheetAdjustMode === "annotations" && deleteIconPos && ( + + )} + {/* Notes pencil (task 2) - shown whenever the block is + enabled and not already in edit mode; repositions on + every render, including the one setNotesPositionState + triggers right as a drag ends (see handleSheetPointerUp's + notes branch), so it always ends up at the block's + final resting spot even though it doesn't re-render on + every individual drag frame (the canvas repaint alone + already shows the block moving live). */} + {sheetNotesEnabled && !notesEditMode && notesPencilIconPos && ( + + )} + {/* Direct in-document editing - one real input per point + (committed or the trailing new slot), positioned right + over its own canvas-drawn "N." prefix. Native click-to- + focus already places the cursor exactly where clicked, + so any point - not just the last one - is directly + editable; typing/Enter/Backspace behavior lives in + handleNoteLineChange/handleNoteLineKeyDown above. */} + {sheetNotesEnabled && + notesEditMode && + Array.from({ length: effectiveNotesCount() }, (_, i) => { + const linePos = computeNoteLineCssPos(i); + if (!linePos) return null; + return ( + { + if (el) noteInputRefs.current.set(i, el); + else noteInputRefs.current.delete(i); + }} + type="text" + className="cad-sheet-notes-line-input" + style={{ + left: linePos.left + NOTES_NUMBER_PREFIX_W_PX, + top: linePos.top, + height: `${NOTES_LINE_H_PX}px`, + }} + value={sheetNotes[i] ?? ""} + maxLength={MAX_NOTE_CHARS} + onChange={(e) => handleNoteLineChange(i, e.target.value)} + onKeyDown={(e) => handleNoteLineKeyDown(i, e)} + onBlur={handleNoteLineBlur} + placeholder="Note text" + aria-label={`Note ${i + 1}`} + /> + ); + })} + {/* Title block pencil - same top-right-corner/auto-zoom + convention as the notes pencil above, just anchored to + the (fixed-position) title block instead. */} + {!titleEditMode && titlePencilIconPos && ( + + )} + {/* Static text for every cell NOT currently being typed + into (bugfix: drawSheetTitleBlock draws no cell content + at all while editMode is on - see its own early-return + comment - so without a DOM stand-in here, entering edit + mode made every label/value in the table appear to + vanish, even though titleTableRef's own data was + untouched the whole time). The active cell gets its own + below instead (skipped here to avoid double- + rendering it); the logo cell gets its own upload/avatar + editor below (skipped for the same reason). Mirrors + drawSheetTitleBlock's bound-value substitution for + boundScale/boundSize so those always show the LIVE + value, never stale cell.text. pointer-events: none (see + the CSS) so this never steals a click meant for + selection/hit-testing underneath it. */} + {titleEditMode && + titleTableRef.current && + sheetPaintBase && + titleTableRef.current.cells + .filter((cell) => cell.special !== "logo" && cell.id !== titleEditingCellId) + .map((cell) => { + const r = cellRectPx(titleTableRef.current!, TITLE_BLOCK_RECT, cell); + const cssRect = sheetSpaceRectToCssRect(r); + if (!cssRect) return null; + const text = + cell.special === "boundScale" + ? `SCALE ${sheetPaintBase.scaleLabel}` + : cell.special === "boundSize" + ? "SIZE A4" + : cell.text; + if (!text.trim()) return null; + return ( +
+ {text} +
+ ); + })} + {/* The one active-cell edit input (task 2: EDIT) - on + demand only (titleEditingCellId), not one per cell, so + a plain click on a cell's canvas position starts + selection instead of always landing on an input first + (see handleSheetPointerDown's title-block branch). */} + {titleEditMode && + titleEditingCellId && + titleTableRef.current && + (() => { + const cell = titleTableRef.current!.cells.find((c) => c.id === titleEditingCellId); + if (!cell) return null; + const r = cellRectPx(titleTableRef.current!, TITLE_BLOCK_RECT, cell); + const cssRect = sheetSpaceRectToCssRect(r); + if (!cssRect) return null; + return ( + handleTitleCellChange(cell.id, e.target.value)} + onKeyDown={handleTitleCellKeyDown} + onBlur={handleTitleCellBlur} + placeholder="Cell text" + aria-label="Title block cell" + /> + ); + })()} + {/* Contextual toolbar (task 2: "a small contextual toolbar + on selection is likely cleaner than hover-only + affordances") - every button mutates via + commitTitleTableChange, which clears the selection + afterward, so the toolbar disappears the instant an + action fires along with it. onMouseDown preventDefault + throughout so clicking a button never blurs the active + cell input first (which would otherwise fire + handleTitleCellBlur before the click handler runs). */} + {titleEditMode && + titleCellSelection && + titleTableRef.current && + (() => { + const pos = computeTitleToolbarPos(); + if (!pos) return null; + const covered = cellsInRange(titleTableRef.current!, titleCellSelection); + const canMerge = covered.length > 1; + const canSplit = + covered.length === 1 && (covered[0].r1 - covered[0].r0 > 1 || covered[0].c1 - covered[0].c0 > 1); + const stop = (e: React.MouseEvent) => e.preventDefault(); + return ( +
+ + + + + + + + + + +
+ ); + })()} + {/* Logo cell upload/remove/name+avatar controls (task 4) - + the canvas draws nothing for ANY cell in edit mode + (including the logo, see drawSheetTitleBlock's own + editMode guard), so this is the cell's only surface + while editing. Two states: a preview + remove control + once a logo's set, or - the personal/non-company path - + an upload option ALONGSIDE a name input and its live + generated avatar (drawTitleBlockAvatar's own DOM + equivalent, sharing its exact hashStringToHue color + formula so the preview never drifts from what renders + once edit mode closes). The name input writes straight + into the title block's own role:"drawnName" cell (the + DRAWN row's NAME field) via handleTitleCellChange, the + same cell drawTitleBlockAvatar already reads its name + from - not a separate field, so there's only ever one + place "the name" lives. */} + {titleEditMode && + titleTableRef.current && + (() => { + const logoCell = titleTableRef.current!.cells.find((c) => c.special === "logo"); + if (!logoCell) return null; + const r = cellRectPx(titleTableRef.current!, TITLE_BLOCK_RECT, logoCell); + const cssRect = sheetSpaceRectToCssRect(r); + if (!cssRect) return null; + return ( +
e.preventDefault()} + > + {logoCell.logoDataUrl ? ( + <> + Company logo + + + ) : ( + (() => { + const nameCell = titleTableRef.current!.cells.find((c) => c.role === "drawnName"); + const name = (nameCell?.text ?? "").trim(); + const initial = name ? name[0].toUpperCase() : ""; + return ( +
+
+ {initial} +
+ {nameCell && ( + handleTitleCellChange(nameCell.id, e.target.value)} + onMouseDown={(e) => e.stopPropagation()} + placeholder="Your name" + aria-label="Name for generated avatar" + /> + )} + +
+ ); + })() + )} +
+ ); + })()} +
+
+
+ )} +
); }, diff --git a/src/components/cad/drafting-rules.ts b/src/components/cad/drafting-rules.ts new file mode 100644 index 0000000..fae19bc --- /dev/null +++ b/src/components/cad/drafting-rules.ts @@ -0,0 +1,198 @@ +/** + * THE single canonical source for every drafting-standard spacing constant + * used by the A4 sheet composer (sheet-composer.ts) and enforced by the + * sheet checker (sheet-checker.ts). No other file may redefine any of + * these numbers - import them from here. + * + * Every mm constant below is expressed in PAPER mm - mm as measured on the + * finished A4 sheet itself, not scaled by the part's own drafting ratio (a + * dimension-line gap is a property of the drawing sheet, not of the part it + * depicts - a huge part drawn at 1:20 still wants the same on-paper spacing + * between its dimension lines as a tiny part drawn at 5:1). Convert to px + * ONLY via SHEET_PX_PER_MM (also defined here) - never a raw pixel literal + * standing in for one of these values anywhere else. + */ + +// A4 landscape at ~200 DPI (297mm x 210mm) - the standard orientation for +// engineering drawings. Chosen as a resolution that reads crisply on screen +// and prints reasonably without producing a huge PNG. +export const SHEET_W = 2339; +export const SHEET_H = 1654; +export const SHEET_MM_W = 297; +export const SHEET_MM_H = 210; +export const SHEET_PX_PER_MM = SHEET_W / SHEET_MM_W; + +// Sheet margin mode - the distance from the TRIMMED SHEET EDGE (the +// physical paper boundary, i.e. the full SHEET_W x SHEET_H canvas) inward +// to the drawing frame. Nothing rendered anywhere on the sheet may cross +// inside this band - see sheet-checker.ts's frame-containment check. +// +// "iso-filing" - real ISO 5457 filing-standard margins: 20mm on the +// binding (left) edge (room for a ring binder/filing +// punch), 10mm on the other three. Correct for a drawing +// that will actually be printed and physically bound. +// "uniform" - 10mm on all four sides. A digital-only drawing is never +// bound, so the extra 10mm the filing margin reserves on +// the left is pure lost usable width; uniform margins also +// let the composed view group center on the sheet without +// the asymmetry the filing margin otherwise imposes. +// +// ONE switch, here, so every consumer (frame render, frame-containment +// check, drawArea sizing/centering) agrees on which mode is active - never +// two different margins live at once. +// `as` (not a `: type` annotation) deliberately - a plain annotation still +// lets TS narrow this const to its literal initializer type for the +// same-file comparisons below, which then errors ("no overlap") on +// whichever branch isn't the current value. The cast keeps the true union +// type without that narrowing. +export const FRAME_MARGIN_MODE = "iso-filing" as "iso-filing" | "uniform"; + +const ISO_FILING_MARGIN_LEFT_MM = 20; +const ISO_FILING_MARGIN_OTHER_MM = 10; +const UNIFORM_MARGIN_MM = 10; + +export const FRAME_MARGIN_LEFT_MM = + FRAME_MARGIN_MODE === "uniform" ? UNIFORM_MARGIN_MM : ISO_FILING_MARGIN_LEFT_MM; +export const FRAME_MARGIN_OTHER_MM = + FRAME_MARGIN_MODE === "uniform" ? UNIFORM_MARGIN_MM : ISO_FILING_MARGIN_OTHER_MM; + +// Standard drafting-line spacing (ASME Y14.5 / ISO 129 style). +export const FIRST_DIM_LINE_OFFSET_MM = 10; +export const PARALLEL_DIM_SPACING_MM = 10; +// An extension line never touches the part outline/feature point it +// measures FROM - it starts this far clear of it, so it never reads as +// though it were part of the object's own geometry. +export const EXTENSION_VISIBLE_GAP_MM = 1.5; +// ...and it never stops exactly at the dimension line/arrowhead it serves +// either - it overshoots PAST it by this much, at the opposite end. +export const EXTENSION_OVERSHOOT_MM = 2.5; + +// Fixed, consistent clear gap between the three views of the third-angle +// projection group (Top-Front, Front-Right) - one deliberately-chosen +// named constant, not space reclaimed after the fact from however a given +// part's fit search happened to lay a trial out. Matches the sheet's own +// existing 10mm spacing rhythm (FIRST_DIM_LINE_OFFSET_MM, +// PARALLEL_DIM_SPACING_MM above) rather than an arbitrarily larger value - +// "just large enough to clear the dimension chains between them" means the +// same minimum clearance a chain already gets everywhere else on the +// sheet, not a bigger one reserved specifically for this gap (real +// fixtures confirmed a larger floor here measurably ate into scale-fit +// headroom for already-dense parts, with no offsetting benefit). A part +// whose own chain genuinely needs more than this (rare - most crowding is +// resolved by jog-routing/reassignment, see sheet-composer.ts's +// post-render remedy pass) still gets the room it needs; this is the +// floor every part gets by default, not a hard cap. +export const VIEW_GROUP_GAP_MM = 10; + +// --- Line-weight hierarchy (ISO 128-20 style) ----------------------------- +// THE one place any stroke width on the sheet is defined. Like every other +// constant in this file these are PAPER mm - a line weight is a property of +// the finished drawing sheet, never of the part or of the drafting ratio it +// happens to be drawn at (a part drawn at 1:2 gets the same 0.6mm visible +// outline as one drawn at 1:1), which is exactly why the view outlines are +// stroked as real vector geometry on the sheet (see sheet-composer.ts's +// strokeSheetEdgeRuns) rather than pasted in as a bitmap whose apparent +// stroke width would scale with the ratio. +// +// The hierarchy, heaviest first - this ORDER is the drafting convention +// being encoded, and every number below exists only to realize it: +// +// visible outline > hidden (dashed) > dimension line > +// extension line = leader > centerline +// +// Object lines started at ISO 128-24's two-group scheme (wide : narrow = +// 2 : 1, i.e. 0.6/0.3mm); visible was moderately reduced from that original +// 0.6mm (task: "visible outlines are now too heavy") to read lighter next to +// the dimension lines, then reduced again a second time (task: "reduce +// outline weight further") to 0.4mm - still clearly the heaviest weight on +// the sheet (0.4 : 0.3 is a 4:3 step - tighter than the original 2:1, but +// still unambiguous) while reading noticeably lighter overall. The +// annotation lines remain a further step down again so no dimension/ +// extension/leader can ever compete visually with the part itself. +export const LINE_WEIGHT_VISIBLE_MM = 0.4; +export const LINE_WEIGHT_HIDDEN_MM = 0.3; +export const LINE_WEIGHT_DIMENSION_MM = 0.2; +export const LINE_WEIGHT_EXTENSION_MM = 0.15; +export const LINE_WEIGHT_LEADER_MM = 0.15; +export const LINE_WEIGHT_CENTERLINE_MM = 0.12; +// Sheet furniture (drawing frame, title-block borders/rules). Not part of +// the object/annotation hierarchy above - the frame is the sheet, not the +// drawing - but defined here for the same reason: no raw pixel literals. +export const LINE_WEIGHT_FRAME_MM = 0.3; +export const LINE_WEIGHT_TITLE_BLOCK_MM = 0.25; +export const LINE_WEIGHT_TITLE_BLOCK_RULE_MM = 0.15; + +// Hidden-line dash pattern, also in paper mm (ISO 128-24 dash-and-gap): +// a hidden line reads as the same dash rhythm on every sheet regardless of +// how big the part is or what ratio it's drawn at. Deliberately finer than +// ISO 128-24's nominal 12d/3d (which at 0.3mm wide lines means 3.6mm dashes): +// a machined part on A4 routinely has hidden edges only a few paper mm long - +// a plate's bore through a 10mm thickness at 1:2 is 5mm on paper - and a dash +// longer than the edge renders the whole edge as one unbroken stroke, i.e. +// indistinguishable from a visible outline. Short enough that even those +// edges show a real gap; long enough to still read as "dashed" rather than +// dotted. +export const HIDDEN_DASH_MM = 1.5; +export const HIDDEN_GAP_MM = 0.8; + +// px conversions - the ONLY place any of the mm values above are turned +// into sheet px. +export const FRAME_MARGIN_LEFT_PX = FRAME_MARGIN_LEFT_MM * SHEET_PX_PER_MM; +export const FRAME_MARGIN_OTHER_PX = FRAME_MARGIN_OTHER_MM * SHEET_PX_PER_MM; +export const FIRST_DIM_LINE_OFFSET_PX = + FIRST_DIM_LINE_OFFSET_MM * SHEET_PX_PER_MM; +export const PARALLEL_DIM_SPACING_PX = + PARALLEL_DIM_SPACING_MM * SHEET_PX_PER_MM; +export const EXTENSION_VISIBLE_GAP_PX = + EXTENSION_VISIBLE_GAP_MM * SHEET_PX_PER_MM; +export const EXTENSION_OVERSHOOT_PX = EXTENSION_OVERSHOOT_MM * SHEET_PX_PER_MM; +export const VIEW_GROUP_GAP_PX = VIEW_GROUP_GAP_MM * SHEET_PX_PER_MM; +export const LINE_WEIGHT_VISIBLE_PX = LINE_WEIGHT_VISIBLE_MM * SHEET_PX_PER_MM; +export const LINE_WEIGHT_HIDDEN_PX = LINE_WEIGHT_HIDDEN_MM * SHEET_PX_PER_MM; +export const LINE_WEIGHT_DIMENSION_PX = + LINE_WEIGHT_DIMENSION_MM * SHEET_PX_PER_MM; +export const LINE_WEIGHT_EXTENSION_PX = + LINE_WEIGHT_EXTENSION_MM * SHEET_PX_PER_MM; +export const LINE_WEIGHT_LEADER_PX = LINE_WEIGHT_LEADER_MM * SHEET_PX_PER_MM; +export const LINE_WEIGHT_CENTERLINE_PX = + LINE_WEIGHT_CENTERLINE_MM * SHEET_PX_PER_MM; +export const LINE_WEIGHT_FRAME_PX = LINE_WEIGHT_FRAME_MM * SHEET_PX_PER_MM; +export const LINE_WEIGHT_TITLE_BLOCK_PX = + LINE_WEIGHT_TITLE_BLOCK_MM * SHEET_PX_PER_MM; +export const LINE_WEIGHT_TITLE_BLOCK_RULE_PX = + LINE_WEIGHT_TITLE_BLOCK_RULE_MM * SHEET_PX_PER_MM; +export const HIDDEN_DASH_PX = HIDDEN_DASH_MM * SHEET_PX_PER_MM; +export const HIDDEN_GAP_PX = HIDDEN_GAP_MM * SHEET_PX_PER_MM; + +/** The drawing frame itself, in sheet px: the trimmed sheet edge (the full + * SHEET_W x SHEET_H canvas) inset by the ISO 5457 margins above. Every + * rendered primitive on the sheet must stay within this rect - computed + * once here so sheet-composer.ts (which draws to it) and sheet-checker.ts + * (which validates against it) can never disagree about where it is. */ +export const FRAME_RECT = { + x: FRAME_MARGIN_LEFT_PX, + y: FRAME_MARGIN_OTHER_PX, + w: SHEET_W - FRAME_MARGIN_LEFT_PX - FRAME_MARGIN_OTHER_PX, + h: SHEET_H - FRAME_MARGIN_OTHER_PX * 2, +}; + +// --- ISO 5457 grid reference (border zone) system ------------------------- +// The sheet-edge field/letter/numeral system used to locate a spot on the +// drawing (like a map grid), plus the centring marks used for reprographic +// alignment. Fields are measured from the TRIMMED SHEET EDGE (SHEET_MM_W/H), +// not the inset drawing frame - see drawGridReferenceFrame in +// sheet-composer.ts, the only place these are consumed. +export const GRID_REF_FIELD_NOMINAL_MM = 50; +export const GRID_REF_CHAR_HEIGHT_MM = 3.5; +export const GRID_REF_LINE_WIDTH_MM = 0.35; +// How far a centring mark crosses PAST the drawing frame line, beyond the +// margin band it otherwise spans - a small deliberate overshoot (matching +// the same "crosses past what it serves" convention EXTENSION_OVERSHOOT_MM +// uses above) so the mark reads as crossing the frame rather than stopping +// exactly on it. +export const GRID_REF_CENTRING_MARK_CROSS_MM = 2; + +export const GRID_REF_CHAR_HEIGHT_PX = GRID_REF_CHAR_HEIGHT_MM * SHEET_PX_PER_MM; +export const GRID_REF_LINE_WIDTH_PX = GRID_REF_LINE_WIDTH_MM * SHEET_PX_PER_MM; +export const GRID_REF_CENTRING_MARK_CROSS_PX = + GRID_REF_CENTRING_MARK_CROSS_MM * SHEET_PX_PER_MM; diff --git a/src/components/cad/pdf-canvas-shim.ts b/src/components/cad/pdf-canvas-shim.ts new file mode 100644 index 0000000..0a83e95 --- /dev/null +++ b/src/components/cad/pdf-canvas-shim.ts @@ -0,0 +1,498 @@ +/** + * A CanvasRenderingContext2D-shaped shim, backed by jsPDF, so the existing + * sheet-painting functions (paintInteractiveSheet, drawSheetFrame, + * drawSheetTitleBlock, drawSheetNotes, strokeSheetEdgeRuns, drawArrowheadAt, + * buildLeaderLanding's callers, etc.) can be called completely UNCHANGED to + * produce a true-vector PDF instead of rasterizing a . + * + * Every one of those functions is typed `ctx: CanvasRenderingContext2D` and + * already only calls a small, closed subset of the Canvas2D API (confirmed + * by grep across sheet-composer.ts/sheet-interactive-render.ts): save/ + * restore, beginPath/closePath/moveTo/lineTo/stroke/fill, arc (ALWAYS a full + * 0..2*PI circle - never a partial arc, so no bezier approximation is + * needed), fillRect/strokeRect, fillStyle/strokeStyle/lineWidth/lineCap/ + * lineJoin, setLineDash, font/textAlign/textBaseline/fillText/measureText, + * translate/rotate (composed together for the vertical dimension-label + * case, never scaled), drawImage, clearRect. This file implements exactly + * that subset against a jsPDF document and is cast to + * CanvasRenderingContext2D at the call site - deliberately narrow, not a + * general-purpose shim. + * + * Coordinate model: every function above already works entirely in "sheet + * px" (the SHEET_W x SHEET_H space defined in drafting-rules.ts, exactly + * 297x210mm at SHEET_PX_PER_MM px/mm). This shim keeps that contract + * unchanged - callers keep passing the same sheet-px numbers they always + * have - and converts to mm only at the final jsPDF call, by dividing by + * SHEET_PX_PER_MM. jsPDF's own public coordinate system (confirmed by + * reading its source: getVerticalCoordinate = pageHeight - value in normal + * API mode) is already top-left-origin, Y-down, exactly like Canvas2D - so + * no Y-flip is needed anywhere here. + * + * Transform model: translate/rotate are composed into a running 2D affine + * matrix (no scale is ever used - verified by grep), applied to every path/ + * text point at the moment it's recorded, mirroring Canvas2D's own "bake + * the CTM in when the point is specified" semantics. save/restore snapshot + * the matrix plus every mutable style field; the current path itself is + * (per spec) NOT part of that snapshot. + */ +import { jsPDF } from "jspdf"; +import { SHEET_PX_PER_MM } from "./drafting-rules"; + +const PT_PER_MM = 72 / 25.4; + +// --- 2D affine matrix (translate + rotate only; canvas {a,b,c,d,e,f} form) - + +type Matrix = { a: number; b: number; c: number; d: number; e: number; f: number }; + +const IDENTITY: Matrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +function multiply(m1: Matrix, m2: Matrix): Matrix { + return { + a: m1.a * m2.a + m1.c * m2.b, + b: m1.b * m2.a + m1.d * m2.b, + c: m1.a * m2.c + m1.c * m2.d, + d: m1.b * m2.c + m1.d * m2.d, + e: m1.a * m2.e + m1.c * m2.f + m1.e, + f: m1.b * m2.e + m1.d * m2.f + m1.f, + }; +} + +function applyMatrix(m: Matrix, x: number, y: number): { x: number; y: number } { + return { x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f }; +} + +// --- Color parsing --------------------------------------------------------- + +/** jsPDF's setFillColor/setDrawColor/setTextColor accept a '#rrggbb' hex + * string directly - the only color format this codebase doesn't already use + * verbatim is the avatar's `hsl(h, s%, l%)` string, converted here. */ +function resolveColor(css: string): string { + const hsl = /^hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/.exec(css.trim()); + if (!hsl) return css; + const h = parseFloat(hsl[1]) / 360; + const s = parseFloat(hsl[2]) / 100; + const l = parseFloat(hsl[3]) / 100; + const hue2rgb = (p: number, q: number, t: number) => { + let tt = t; + if (tt < 0) tt += 1; + if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p + (q - p) * 6 * tt; + if (tt < 1 / 2) return q; + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; + return p; + }; + let r: number; + let g: number; + let b: number; + if (s === 0) { + r = g = b = l; + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + const toHex = (v: number) => + Math.round(v * 255) + .toString(16) + .padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +// --- Font parsing ------------------------------------------------------ + +type ParsedFont = { family: "helvetica" | "courier"; style: "normal" | "bold"; sizePx: number }; + +const DEFAULT_FONT: ParsedFont = { family: "helvetica", style: "normal", sizePx: 13 }; + +/** Parses the small, closed set of `ctx.font` strings this codebase ever + * sets: `"Npx sans-serif"`, `"bold Npx sans-serif"`, `"600 Npx sans-serif"`, + * `"Npx monospace"`. monospace -> courier (DIM_VALUE_FONT), everything else + * -> helvetica (no custom font files are used anywhere on the sheet - see + * drafting-rules.ts's font table); a leading "bold" or a numeric weight + * >=600 selects the bold PDF style. */ +function parseFont(fontStr: string): ParsedFont { + const m = /^(?:(bold|\d{2,3})\s+)?([\d.]+)px\s+(\S+)/.exec(fontStr.trim()); + if (!m) return DEFAULT_FONT; + const weightToken = m[1]; + const sizePx = parseFloat(m[2]); + const family = m[3].includes("monospace") ? "courier" : "helvetica"; + const bold = weightToken === "bold" || (!!weightToken && parseInt(weightToken, 10) >= 600); + return { family, style: bold ? "bold" : "normal", sizePx }; +} + +function fontSizePxToPt(sizePx: number): number { + return (sizePx / SHEET_PX_PER_MM) * PT_PER_MM; +} + +/** jsPDF's standard 14 fonts (helvetica/courier) use WinAnsiEncoding, which + * has no glyph for U+2300 DIAMETER SIGN (the character every size callout + * on the sheet uses - see viewer.ts's `prefix = kind === "circle" ? "⌀" + * : "R"`) - it renders as a "notdef" box/substitute glyph (confirmed by + * testing: pdf.js extracts it back out as "#"). Substituted with U+00D8 + * (LATIN CAPITAL LETTER O WITH STROKE), which IS in WinAnsiEncoding and is + * visually near-identical - a long-standing convention in CAD/drafting + * tooling for exactly this reason. Canvas rendering never needs this (every + * OS ships a system sans-serif font with a real diameter glyph), so this + * substitution is intentionally applied only on the PDF path. */ +function sanitizeForStandardFont(text: string): string { + return text.replace(/⌀/g, "Ø"); +} + +/** jsPDF's default text() lineHeightFactor (jsPDF.es.js's own default when + * options.lineHeightFactor isn't passed) - needed here because we replicate + * jsPDF's own baseline-offset formula below, in the local (pre-rotation) + * frame, instead of letting jsPDF apply it in page space. */ +const JSPDF_LINE_HEIGHT_FACTOR = 1.15; + +// --- Path model -------------------------------------------------------- + +type Subpath = { points: { x: number; y: number }[]; closed: boolean }; +type Circle = { cx: number; cy: number; r: number }; + +type StyleState = { + ctm: Matrix; + fillStyle: string; + strokeStyle: string; + lineWidth: number; + lineCap: string; + lineJoin: string; + lineDash: number[]; + font: string; + textAlign: string; + textBaseline: string; +}; + +function initialState(): StyleState { + return { + ctm: IDENTITY, + fillStyle: "#000000", + strokeStyle: "#000000", + lineWidth: 1, + lineCap: "butt", + lineJoin: "miter", + lineDash: [], + font: "13px sans-serif", + textAlign: "start", + textBaseline: "alphabetic", + }; +} + +/** Crops `img` to (sx,sy,sw,sh) via an offscreen - the ONE place + * this shim touches a real 2D canvas, purely as an image-cropping utility + * for the sheet's one raster element (the isometric view) / the uploaded + * title-block logo. Never used to rasterize any vector content - task's + * "don't rasterize the canvas to PNG" shortcut is about the WHOLE output, + * not about cropping a source image that's already raster. */ +function cropImageToCanvas( + img: CanvasImageSource, + sx: number, + sy: number, + sw: number, + sh: number, +): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(sw)); + canvas.height = Math.max(1, Math.round(sh)); + const c2d = canvas.getContext("2d"); + if (c2d) { + c2d.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); + } + return canvas; +} + +/** + * Builds a CanvasRenderingContext2D-shaped object backed by `doc`. Cast to + * CanvasRenderingContext2D at the call site (`as unknown as + * CanvasRenderingContext2D`) - deliberate given the closed, grep-verified + * method inventory in this file's own doc comment above. + */ +export function createPdfCanvasContext(doc: jsPDF): CanvasRenderingContext2D { + let state = initialState(); + const stack: StyleState[] = []; + let subpaths: Subpath[] = []; + let circle: Circle | null = null; + + const toMm = (px: number) => px / SHEET_PX_PER_MM; + + const applyPoint = (x: number, y: number) => applyMatrix(state.ctm, x, y); + + const setDrawStyleForStroke = () => { + doc.setDrawColor(resolveColor(state.strokeStyle)); + doc.setLineWidth(toMm(state.lineWidth)); + doc.setLineCap(state.lineCap); + doc.setLineJoin(state.lineJoin); + doc.setLineDashPattern( + state.lineDash.map((d) => toMm(d)), + 0, + ); + }; + + const setFillStyleForFill = () => { + doc.setFillColor(resolveColor(state.fillStyle)); + }; + + const strokeSubpath = (sp: Subpath) => { + if (sp.points.length < 2) return; + const [p0, ...rest] = sp.points; + const deltas = rest.map((p, i) => { + const prev = i === 0 ? p0 : rest[i - 1]; + return [toMm(p.x - prev.x), toMm(p.y - prev.y)]; + }); + setDrawStyleForStroke(); + doc.lines(deltas, toMm(p0.x), toMm(p0.y), [1, 1], "S", sp.closed); + }; + + const fillSubpath = (sp: Subpath) => { + if (sp.points.length < 2) return; + const [p0, ...rest] = sp.points; + const deltas = rest.map((p, i) => { + const prev = i === 0 ? p0 : rest[i - 1]; + return [toMm(p.x - prev.x), toMm(p.y - prev.y)]; + }); + setFillStyleForFill(); + doc.lines(deltas, toMm(p0.x), toMm(p0.y), [1, 1], "F", true); + }; + + const ctx: Record = { + // --- style properties (plain fields - canvas semantics, read at draw time) --- + get fillStyle() { + return state.fillStyle; + }, + set fillStyle(v: string) { + state.fillStyle = v; + }, + get strokeStyle() { + return state.strokeStyle; + }, + set strokeStyle(v: string) { + state.strokeStyle = v; + }, + get lineWidth() { + return state.lineWidth; + }, + set lineWidth(v: number) { + state.lineWidth = v; + }, + get lineCap() { + return state.lineCap; + }, + set lineCap(v: string) { + state.lineCap = v; + }, + get lineJoin() { + return state.lineJoin; + }, + set lineJoin(v: string) { + state.lineJoin = v; + }, + get font() { + return state.font; + }, + set font(v: string) { + state.font = v; + }, + get textAlign() { + return state.textAlign; + }, + set textAlign(v: string) { + state.textAlign = v; + }, + get textBaseline() { + return state.textBaseline; + }, + set textBaseline(v: string) { + state.textBaseline = v; + }, + + // --- state stack --- + save(): void { + stack.push({ ...state, lineDash: [...state.lineDash] }); + }, + restore(): void { + const prev = stack.pop(); + if (prev) state = prev; + }, + + // --- transform (translate/rotate only - no scale anywhere in this codebase) --- + translate(tx: number, ty: number): void { + state.ctm = multiply(state.ctm, { a: 1, b: 0, c: 0, d: 1, e: tx, f: ty }); + }, + rotate(angleRad: number): void { + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + state.ctm = multiply(state.ctm, { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 }); + }, + + // --- path construction --- + beginPath(): void { + subpaths = []; + circle = null; + }, + closePath(): void { + const last = subpaths[subpaths.length - 1]; + if (last) last.closed = true; + }, + moveTo(x: number, y: number): void { + subpaths.push({ points: [applyPoint(x, y)], closed: false }); + }, + lineTo(x: number, y: number): void { + let last = subpaths[subpaths.length - 1]; + if (!last) { + last = { points: [], closed: false }; + subpaths.push(last); + } + last.points.push(applyPoint(x, y)); + }, + arc(x: number, y: number, r: number, _start: number, _end: number): void { + // Every arc() call in this codebase sweeps a full 0..2*PI circle (grep- + // verified) - never a partial arc - so no bezier approximation is + // needed, just jsPDF's native circle primitive. Radius is unaffected + // by the CTM since only translate/rotate (never scale) are ever + // composed into it. + const c = applyPoint(x, y); + circle = { cx: c.x, cy: c.y, r }; + }, + + // --- painting the current path --- + stroke(): void { + if (circle) { + setDrawStyleForStroke(); + doc.circle(toMm(circle.cx), toMm(circle.cy), toMm(circle.r), "S"); + return; + } + for (const sp of subpaths) strokeSubpath(sp); + }, + fill(): void { + if (circle) { + setFillStyleForFill(); + doc.circle(toMm(circle.cx), toMm(circle.cy), toMm(circle.r), "F"); + return; + } + for (const sp of subpaths) fillSubpath(sp); + }, + + // --- rects (immediate - not part of the moveTo/lineTo path) --- + fillRect(x: number, y: number, w: number, h: number): void { + const corners = [ + applyPoint(x, y), + applyPoint(x + w, y), + applyPoint(x + w, y + h), + applyPoint(x, y + h), + ]; + fillSubpath({ points: corners, closed: true }); + }, + strokeRect(x: number, y: number, w: number, h: number): void { + const corners = [ + applyPoint(x, y), + applyPoint(x + w, y), + applyPoint(x + w, y + h), + applyPoint(x, y + h), + ]; + strokeSubpath({ points: corners, closed: true }); + }, + clearRect(): void { + // A fresh jsPDF page is already blank; drawSheetFrame immediately + // fills white over the full sheet anyway - no-op is correct. + }, + + // --- dash --- + setLineDash(segments: number[]): void { + state.lineDash = segments; + }, + + // --- text --- + fillText(text: string, x: number, y: number): void { + const { family, style, sizePx } = parseFont(state.font); + doc.setFont(family, style); + const sizePt = fontSizePxToPt(sizePx); + doc.setFontSize(sizePt); + doc.setTextColor(resolveColor(state.fillStyle)); + const p = applyPoint(x, y); + const angleDeg = -(Math.atan2(state.ctm.b, state.ctm.a) * (180 / Math.PI)); + + // Canvas bakes textAlign/textBaseline offsets into the LOCAL frame + // before translate/rotate are applied, so the offset rotates along + // with the glyphs. jsPDF's own align/baseline handling instead shifts + // the anchor in unrotated page space and rotates only afterward - for + // a 90deg-rotated label (the vertical dimension-label case) that swaps + // which axis gets the "half text width" shift vs. the baseline nudge, + // landing the label off-center. Fix: replicate jsPDF's own align/ + // baseline offset formulas ourselves in the local frame, rotate that + // offset by the CTM's linear (rotation-only, never scaled) part - same + // as canvas would - and hand jsPDF an already-positioned anchor with + // no further align/baseline adjustment to apply. + const cleanText = sanitizeForStandardFont(text); + const widthMm = (doc.getStringUnitWidth(cleanText) * sizePt) / doc.internal.scaleFactor; + const widthPx = widthMm * SHEET_PX_PER_MM; + const heightPx = sizePx; + const descentPx = heightPx * (JSPDF_LINE_HEIGHT_FACTOR - 1); + let dx = 0; + if (state.textAlign === "center") dx = -widthPx / 2; + else if (state.textAlign === "right" || state.textAlign === "end") dx = -widthPx; + let dy = 0; + switch (state.textBaseline) { + case "bottom": + dy = -descentPx; + break; + case "top": + dy = heightPx - descentPx; + break; + case "hanging": + dy = heightPx - 2 * descentPx; + break; + case "middle": + dy = heightPx / 2 - descentPx; + break; + } + const rotatedDx = state.ctm.a * dx + state.ctm.c * dy; + const rotatedDy = state.ctm.b * dx + state.ctm.d * dy; + doc.text(cleanText, toMm(p.x + rotatedDx), toMm(p.y + rotatedDy), { + align: "left", + baseline: "alphabetic", + angle: angleDeg || undefined, + }); + }, + measureText(text: string): { width: number } { + const { family, style, sizePx } = parseFont(state.font); + doc.setFont(family, style); + const sizePt = fontSizePxToPt(sizePx); + doc.setFontSize(sizePt); + const widthMm = + (doc.getStringUnitWidth(sanitizeForStandardFont(text)) * sizePt) / doc.internal.scaleFactor; + return { width: widthMm * SHEET_PX_PER_MM }; + }, + + // --- images --- + drawImage(...args: unknown[]): void { + const img = args[0] as CanvasImageSource & { width: number; height: number }; + let sx = 0; + let sy = 0; + let sw = img.width; + let sh = img.height; + let dx: number; + let dy: number; + let dw: number; + let dh: number; + if (args.length >= 9) { + [, sx, sy, sw, sh, dx, dy, dw, dh] = args as number[]; + } else { + [, dx, dy, dw, dh] = args as number[]; + } + const cropped = sx !== 0 || sy !== 0 || sw !== img.width || sh !== img.height; + const source = cropped ? cropImageToCanvas(img, sx, sy, sw, sh) : img; + const p = applyPoint(dx, dy); + doc.addImage( + source as HTMLCanvasElement | HTMLImageElement, + "PNG", + toMm(p.x), + toMm(p.y), + toMm(dw), + toMm(dh), + ); + }, + }; + + return ctx as unknown as CanvasRenderingContext2D; +} diff --git a/src/components/cad/sheet-checker.ts b/src/components/cad/sheet-checker.ts new file mode 100644 index 0000000..c1e3c56 --- /dev/null +++ b/src/components/cad/sheet-checker.ts @@ -0,0 +1,1182 @@ +import type { HiddenLineViewName, HiddenLineViewSetResult } from "./viewer"; +import { + EXACT_TIE_EPS_MM, + classifyLineCrossing, + computeViewContentBounds, + dimensionLineCrossCoord, + lineRoleOf, + rectsOverlap, + segmentIntersectsRect, + segmentsIntersectSeg, + type DimensionRecord, + type Rect, + type ScaleSelectionResult, + type Segment, + type SheetLayoutModel, +} from "./sheet-composer"; +import { + EXTENSION_OVERSHOOT_MM, + EXTENSION_OVERSHOOT_PX, + FIRST_DIM_LINE_OFFSET_MM, + FIRST_DIM_LINE_OFFSET_PX, + FRAME_MARGIN_LEFT_MM, + FRAME_MARGIN_OTHER_MM, + FRAME_RECT, + PARALLEL_DIM_SPACING_MM, + PARALLEL_DIM_SPACING_PX, +} from "./drafting-rules"; +import { VIEW_AXES, type DimensionPlan, type WorldAxis } from "./sheet-dimension-plan"; + +/** + * Single disabled flag for the whole checker (direction change: the + * composer's output is now delivered as-is, never gated/blocked/modified by + * this module). Flip to true to re-enable - every check below is left + * intact, untouched, so the gate is fully recoverable, just never consulted + * while this is false. checkSheetCompleteness itself short-circuits on this + * flag before running any section below, so no caller can end up + * re-enabling the gate accidentally by calling a section function directly. + */ +const SHEET_CHECKER_ENABLED = false; + +/** + * Automated completeness/correctness gate for a composed drawing sheet. + * Runs against the INTERNAL ANNOTATION DATA MODEL that composeA4DrawingSheet + * builds alongside its canvas render (SheetLayoutModel - plain geometry and + * metadata, see sheet-composer.ts), the sheet-wide DimensionPlan that + * governed it (see sheet-dimension-plan.ts), and the ScaleSelectionResult + * that governed the chosen drafting scale - never against the rendered + * PNG. Checks nine independent things, each reported as a structured + * pass/fail list rather than a single boolean, so a caller can see exactly + * what was verified and what (if anything) failed: + * + * scale-selection - the chosen drafting scale was reached by an + * anchored search (1:1 first, abandoned only + * when proven not to fit), every candidate's + * real measured fit is logged, and a fallback + * past the 1:20 safety floor is flagged as a + * suspicious anomaly rather than accepted + * quietly (see sheet-composer.ts's anchored + * search and ScaleSelectionResult). + * a) feature-coverage - every detected circle/arc feature has a size + * and/or location dimension somewhere. + * b) no-duplicate-overall - no identical overall width/height/depth value + * is rendered more than once on the sheet. + * c) no-unclear-clusters - any 2+ location dimensions sharing a value are + * rendered unambiguously: an EXACT real-world + * tie collapses to exactly one label instance, + * while values that merely round to the same + * display are ordinate-differentiated. + * d) plan-integrity - every feature+world-axis measurement has + * exactly ONE owning (view, axis) slot, per the + * sheet-wide DimensionPlan built before any + * view-specific rendering ran - queried + * directly against the plan's own claims and + * cross-checked against what was actually + * rendered, not inferred from value + * coincidences (see sheet-dimension-plan.ts; + * this is what catches the class of bug where + * Front and Right both independently dimension + * the same feature's Y-coordinate). + * e) spacing-standards - dimension-line placement matches the named + * drafting-standard constants (first-dimension- + * line offset, parallel-dimension spacing, + * extension-line overshoot - see + * drafting-rules.ts, the ONE place these are + * defined), for EVERY dimension kind sharing an + * edge's unified lane sequence (overall, + * location, and depth alike - see + * sheet-composer.ts's drawCell), not just + * "whatever fit". + * f) no-inferable-dimension - no directly-rendered dimension's value is + * redundantly reconstructable by adding/ + * subtracting two OTHER directly-rendered + * dimensions on the same view/axis. + * g) frame-containment - nothing rendered anywhere on any view crosses + * inside the ISO 5457 frame margin + * (FRAME_MARGIN_LEFT/OTHER_MM), measured from + * the trimmed sheet edge - using the exact same + * content-measurement function + * (computeViewContentBounds) the scale- + * selection fit-check itself is built on, so + * the two can never disagree about what "the + * content" is. + * h) final-geometric-validation - the sheet-wide "does everything actually + * fit together" pass: real line-segment-vs- + * line-segment intersection (not bounding-box + * overlap, which misses a diagonal leader line) + * between EVERY rendered dimension/extension/ + * leader line on the sheet, each one against + * every label's true text bounding box, and + * everything (lines, labels, and each view's + * silhouette) against the frame margin from (g) + * - run unconditionally, after every other + * check, since prior sections each validate + * their own piece in isolation and this is the + * only pass that looks at the whole sheet at + * once. + * + * This is a real gate, not a rubber stamp: every rule is evaluated fresh + * from the data model each call, so it fails just as readily against an + * older/regressed layout as it passes against a fixed one. + */ + +// "warning" is finding-level only (rule 1's dimension-x-extension line +// crossing - "avoid if possible", never a hard fail) - see sectionFrom, +// which never promotes a warning into a section-level "fail". +export type CheckStatus = "pass" | "warning" | "fail"; + +export type CheckFinding = { + id: string; + status: CheckStatus; + message: string; +}; + +export type CheckSectionId = + | "scale-selection" + | "feature-coverage" + | "no-duplicate-overall" + | "no-unclear-clusters" + | "plan-integrity" + | "spacing-standards" + | "no-inferable-dimension" + | "frame-containment" + | "final-geometric-validation"; + +export type CheckSection = { + id: CheckSectionId; + title: string; + status: CheckStatus; + findings: CheckFinding[]; +}; + +export type SheetCheckReport = { + status: CheckStatus; + sections: CheckSection[]; +}; + +function sectionFrom(id: CheckSectionId, title: string, findings: CheckFinding[]): CheckSection { + return { + id, + title, + // A "warning" finding never fails its section (rule 1: "avoid if + // possible", not a hard fail) - only an actual "fail" finding does. + status: findings.some((f) => f.status === "fail") ? "fail" : "pass", + findings, + }; +} + +function allRecords(model: SheetLayoutModel): DimensionRecord[] { + const out: DimensionRecord[] = []; + for (const view of Object.values(model.views)) out.push(...view.dimensions); + return out; +} + +const LOCATION_LIKE_KINDS = new Set([ + "location", + "location-shared", + "location-ordinate", +]); + +/** + * (scale-selection) The chosen drafting scale, re-verified against the + * ScaleSelectionResult sheet-composer.ts actually produced - never + * re-derived here. Two independent checks, both must pass for a candidate + * to be accepted: (a) the view-outline rule (two fixed 250/145mm limits, + * analytic) and (b) the overflow guard (real rendered content, including + * dimension/extension lines/labels/captions, vs. the real ~267/190mm usable + * page). This section just reports what the rule computed - every candidate + * ratio it evaluated gets a PASS finding stating both checks' own totals and + * limits, kept separate, plus one final finding naming the chosen scale. + * This section can no longer itself fail: the rule is deterministic, not a + * fallible search, so there's no "suspicious fallback" case left to guard + * against. + */ +function checkScaleSelection(scaleSelection: ScaleSelectionResult): CheckSection { + const findings: CheckFinding[] = scaleSelection.sizeRuleCandidates.map((c) => ({ + id: `scale-candidate-${c.scaleLabel}`, + status: "pass", + message: + `${c.scaleLabel}: [view-outline rule] width ${c.width.totalMm.toFixed(1)}mm vs limit ${c.width.limitMm}mm ` + + `(${c.width.ok ? "pass" : "fail"}), height ${c.height.totalMm.toFixed(1)}mm vs limit ` + + `${c.height.limitMm}mm (${c.height.ok ? "pass" : "fail"}); [overflow guard] width ` + + `${c.overflowWidth.totalMm.toFixed(1)}mm vs limit ${c.overflowWidth.limitMm.toFixed(1)}mm ` + + `(${c.overflowWidth.ok ? "pass" : "fail"}), height ${c.overflowHeight.totalMm.toFixed(1)}mm vs ` + + `limit ${c.overflowHeight.limitMm.toFixed(1)}mm (${c.overflowHeight.ok ? "pass" : "fail"}) - ` + + `${c.fits ? "FITS" : "rejected"} (both checks must pass).`, + })); + + findings.push({ + id: "scale-chosen", + status: "pass", + message: + `Chosen scale ${scaleSelection.chosenScaleLabel} (${scaleSelection.chosenRole}). Final delivered rendered ` + + `content, including dimension lines/extension lines/labels: width ${scaleSelection.renderedWidthMm.toFixed(1)}mm, ` + + `height ${scaleSelection.renderedHeightMm.toFixed(1)}mm.`, + }); + + return sectionFrom("scale-selection", "Scale selection (explicit size rule)", findings); +} + +/** (a) Feature coverage. */ +function checkFeatureCoverage( + model: SheetLayoutModel, + captureResult: HiddenLineViewSetResult, +): CheckSection { + const records = allRecords(model); + const findings: CheckFinding[] = []; + + for (const feature of captureResult.allCircularFeatures) { + const group = captureResult.circularFeatureGroups[feature.featureId]; + const repId = group?.representativeFeatureId ?? feature.featureId; + const directHit = records.filter((r) => r.featureIds.includes(feature.featureId)); + const viaRepresentative = + repId !== feature.featureId + ? records.filter((r) => r.featureIds.includes(repId)) + : []; + const covering = directHit.length > 0 ? directHit : viaRepresentative; + + const kindLabel = feature.kind === "circle" ? "⌀" : "R"; + const sizeDesc = feature.secondaryDiameterMm + ? `${kindLabel}${(feature.radiusMm * 2).toFixed(1)}/⌀${feature.secondaryDiameterMm.toFixed(1)}` + : `${kindLabel}${(feature.kind === "circle" ? feature.radiusMm * 2 : feature.radiusMm).toFixed(1)}`; + + if (covering.length > 0) { + const via = directHit.length > 0 ? "" : ` (via group representative ${repId})`; + const kinds = [...new Set(covering.map((r) => r.kind))].join(", "); + findings.push({ + id: feature.featureId, + status: "pass", + message: `Feature ${feature.featureId} (${sizeDesc}) is dimensioned${via}: ${kinds}.`, + }); + } else { + findings.push({ + id: feature.featureId, + status: "fail", + message: `Feature ${feature.featureId} (${sizeDesc}) has ZERO dimensions anywhere on the sheet (no size or location dimension, not even via a group representative).`, + }); + } + } + + if (findings.length === 0) { + findings.push({ + id: "no-features", + status: "pass", + message: "No circle/arc features were detected on this part - nothing to cover.", + }); + } + + return sectionFrom("feature-coverage", "Feature coverage (hole/fillet/slot/notch/step)", findings); +} + +/** Which real-world axis (X/Y/Z) an overall-envelope DimensionRecord + * represents, from its (view, screen-axis) pair - see VIEW_AXES + * (sheet-dimension-plan.ts). Only one (view, axis) combination is ever + * actually drawn per world axis (see OVERALL_DIM_VISIBILITY), so this + * mapping is total even though front/right-width are never emitted in + * practice. */ +function worldAxisOf(r: DimensionRecord): WorldAxis { + const axes = VIEW_AXES[r.view]; + return r.axis === "horizontal" ? axes.horizontal : axes.vertical; +} + +/** + * (b) No duplicate overall dimensions. + * + * Keys on the real-world AXIS (X/Y/Z) being measured, not the raw numeric + * value - two DIFFERENT quantities (e.g. a round part's Y-extent and + * Z-extent) can legitimately share a value without either being a + * redundant re-rendering of the other; that's an intrinsic property of the + * part's shape, not a placement bug. What this must never allow is the + * SAME axis being drawn more than once (e.g. a future OVERALL_DIM_VISIBILITY + * misconfiguration showing X on two views) - see this function's own test + * coverage against Sleeve.stp, a round part where Y-extent == Z-extent, + * which is exactly the case this distinction exists for. + */ +function checkNoDuplicateOverall(model: SheetLayoutModel): CheckSection { + const overall = allRecords(model).filter((r) => r.kind === "overall"); + const groups = new Map(); + for (const r of overall) { + const key = worldAxisOf(r); + const g = groups.get(key); + if (g) g.push(r); + else groups.set(key, [r]); + } + + const findings: CheckFinding[] = []; + for (const [axis, group] of groups) { + if (group.length > 1) { + const where = group.map((r) => `${r.view}/${r.axis} = ${(r.valueMm ?? NaN).toFixed(1)}mm`).join(", "); + findings.push({ + id: `overall-dup-${axis}`, + status: "fail", + message: `Overall ${axis}-axis dimension is rendered ${group.length} times (${where}) - should appear exactly once on the whole sheet.`, + }); + } else { + const r = group[0]; + const value = (r.valueMm ?? NaN).toFixed(1); + const coincident = overall.filter( + (o) => o !== r && worldAxisOf(o) !== axis && (o.valueMm ?? NaN).toFixed(1) === value, + ); + const note = + coincident.length > 0 + ? ` (shares this numeric value with the ${coincident + .map((o) => worldAxisOf(o)) + .join(", ")}-axis dimension - a different, independent quantity that simply coincides here, not a re-rendering of this one; expected for e.g. a round part's Y/Z extents)` + : ""; + findings.push({ + id: `overall-${axis}`, + status: "pass", + message: `Overall ${axis}-axis dimension (${value}mm, ${r.view}/${r.axis}) appears exactly once${note}.`, + }); + } + } + if (findings.length === 0) { + findings.push({ + id: "no-overall-dims", + status: "pass", + message: "No overall envelope dimensions were rendered.", + }); + } + return sectionFrom("no-duplicate-overall", "No duplicate overall dimensions", findings); +} + +/** + * (c) No unclear same-value location clusters. + * + * Groups by (view, axis, ROUNDED display value) first - that's what a + * viewer actually reads off the sheet, and the only thing that can be + * ambiguous at all. Within each such group, re-derives whether the members + * are a genuine EXACT tie (real-world coordinates equal within + * EXACT_TIE_EPS_MM, the same threshold renderLocationChain() uses to decide + * rendering) or merely coincide once rounded, and checks the actual + * resulting record shape for each case - not just which `kind` string was + * used, since a regression could claim "location-ordinate" while still + * emitting one label per feature instead of one shared label. + */ +function checkNoUnclearClusters(model: SheetLayoutModel): CheckSection { + const locationLike = allRecords(model).filter((r) => LOCATION_LIKE_KINDS.has(r.kind)); + const groups = new Map(); + for (const r of locationLike) { + const key = `${r.view}|${r.axis}|${(r.valueMm ?? NaN).toFixed(1)}`; + const g = groups.get(key); + if (g) g.push(r); + else groups.set(key, [r]); + } + + const findings: CheckFinding[] = []; + for (const [key, group] of groups) { + const totalFeatures = new Set(group.flatMap((r) => r.featureIds)).size; + if (totalFeatures < 2) continue; + const [view, axis, value] = key.split("|"); + const ids = group.flatMap((r) => r.featureIds).join(", "); + + const values = group.map((r) => r.valueMm ?? NaN); + const allExact = values.every((v) => Math.abs(v - values[0]) < EXACT_TIE_EPS_MM); + + if (allExact) { + // A genuine exact-value tie must collapse to exactly ONE label + // instance (a single "location-shared" record covering every + // feature, with an extension line per feature converging on it) - + // never one record/label per feature, regardless of what kind those + // records claim to be. + if (group.length === 1 && group[0].kind === "location-shared") { + findings.push({ + id: `cluster-${key}`, + status: "pass", + message: `${totalFeatures} features on ${view}/${axis} share the EXACT value ${value}mm (${ids}) and are rendered as exactly ONE shared dimension label with ${totalFeatures} converging extension lines.`, + }); + } else { + findings.push({ + id: `cluster-${key}`, + status: "fail", + message: `${totalFeatures} features on ${view}/${axis} share the EXACT value ${value}mm (${ids}) but produced ${group.length} separate label instance(s) (kinds: ${group.map((r) => r.kind).join(", ")}) instead of exactly one shared label - ambiguous/duplicated dimensioning.`, + }); + } + } else { + // Different real-world values that merely round to the same displayed + // number - must be visually differentiated via ordinate/baseline. + const allOrdinate = group.every((r) => r.kind === "location-ordinate"); + if (allOrdinate) { + findings.push({ + id: `cluster-${key}`, + status: "pass", + message: `${group.length} location dimensions on ${view}/${axis} share displayed value ${value}mm (${ids}, distinct real-world values) and ARE differentiated via ordinate/baseline dimensioning.`, + }); + } else { + findings.push({ + id: `cluster-${key}`, + status: "fail", + message: `${group.length} location dimensions on ${view}/${axis} share displayed value ${value}mm (${ids}, distinct real-world values) but are rendered as plain/ambiguous chain dimensions, not ordinate-differentiated.`, + }); + } + } + } + if (findings.length === 0) { + findings.push({ + id: "no-clusters", + status: "pass", + message: "No two location dimensions on the same view/axis share an identical value.", + }); + } + return sectionFrom("no-unclear-clusters", "No unclear same-value dimension clusters", findings); +} + +/** + * (d) Dimension-plan integrity: every feature+world-axis measurement has + * exactly one owner. Three independent angles, all queried against the + * PLAN's own structure (not against value coincidences in rendered output): + * + * 1. The plan itself never double-claims a (featureId, worldAxis) pair + * across location+depth - a construction invariant, verified fresh here + * rather than just trusted, so a future change to buildDimensionPlan + * that breaks it gets caught. + * 2. Every rendered location/location-shared/location-ordinate/depth + * record traces back to a plan entry that actually authorized it (same + * feature, view, and axis/kind) - a per-view renderer adding a + * dimension the plan never assigned it would fail here. + * 3. No two DIFFERENT views render a location/depth measurement for the + * same (featureId, worldAxis) - re-derived from the ACTUAL rendered + * records (not just the plan), so it also catches a renderer that + * silently diverged from a correct plan. This is the direct regression + * test for the bug this whole plan exists to prevent: Front and Right + * independently dimensioning the same stepped hole's Y-coordinate. + */ +function checkPlanIntegrity(model: SheetLayoutModel, plan: DimensionPlan): CheckSection { + const findings: CheckFinding[] = []; + + // (1) Plan's own claim uniqueness. + const planOwners = new Map(); + for (const m of plan.location) { + const key = `${m.featureId}|${m.worldAxis}`; + const arr = planOwners.get(key) ?? []; + arr.push({ kind: "location", view: m.view }); + planOwners.set(key, arr); + } + for (const m of plan.depth) { + const key = `${m.featureId}|Z`; + const arr = planOwners.get(key) ?? []; + arr.push({ kind: "depth", view: m.view }); + planOwners.set(key, arr); + } + for (const [key, owners] of planOwners) { + if (owners.length > 1) { + const [featureId, axis] = key.split("|"); + findings.push({ + id: `plan-dup-${key}`, + status: "fail", + message: `Dimension plan assigned feature ${featureId}'s ${axis}-axis measurement to ${owners.length} owners (${owners + .map((o) => `${o.kind}@${o.view}`) + .join(", ")}) - the plan must give each feature+axis exactly one owner.`, + }); + } + } + + // (2) Every rendered measurement was authorized by the plan. Includes + // "depth-ordinate" (task 3's generalized tight-depth-cluster rendering - + // a depth measurement exactly like plain "depth", just sharing a lane + // with others). + const planLocationKeys = new Set( + plan.location.map((m) => `${m.featureId}|${m.view}|${m.screenAxis}`), + ); + const planDepthKeys = new Set(plan.depth.map((m) => `${m.featureId}|${m.view}|depth`)); + const isDepthLike = (r: DimensionRecord) => + r.kind === "depth" || r.kind === "depth-ordinate"; + const measurementRecords = allRecords(model).filter( + (r) => LOCATION_LIKE_KINDS.has(r.kind) || isDepthLike(r), + ); + for (const r of measurementRecords) { + for (const featureId of r.featureIds) { + const authorized = isDepthLike(r) + ? planDepthKeys.has(`${featureId}|${r.view}|depth`) + : planLocationKeys.has(`${featureId}|${r.view}|${r.axis}`); + if (!authorized) { + findings.push({ + id: `unauthorized-${r.id}-${featureId}`, + status: "fail", + message: `Rendered ${r.kind} dimension "${r.text}" on ${r.view}/${r.axis ?? "?"} for feature ${featureId} was not authorized by the sheet-wide dimension plan.`, + }); + } + } + } + + // (3) No two views render the same feature+axis, checked against actual + // rendered records (not the plan) so it stands on its own even if (1)/(2) + // above somehow passed. + const renderedOwners = new Map< + string, + { view: HiddenLineViewName; valueMm: number | null; recordId: string }[] + >(); + for (const r of measurementRecords) { + const axis: WorldAxis = isDepthLike(r) ? "Z" : worldAxisOf(r); + for (const featureId of r.featureIds) { + const key = `${featureId}|${axis}`; + const arr = renderedOwners.get(key) ?? []; + arr.push({ view: r.view, valueMm: r.valueMm, recordId: r.id }); + renderedOwners.set(key, arr); + } + } + for (const [key, owners] of renderedOwners) { + const distinctViews = new Set(owners.map((o) => o.view)); + if (distinctViews.size > 1) { + const [featureId, axis] = key.split("|"); + const where = owners + .map((o) => `${o.view} = ${(o.valueMm ?? NaN).toFixed(1)}mm`) + .join(", "); + findings.push({ + id: `cross-view-dup-${key}`, + status: "fail", + message: `Feature ${featureId}'s ${axis}-axis is dimensioned on ${distinctViews.size} different views (${where}) - a real-world measurement must have exactly one home on the sheet.`, + }); + } + } + + if (findings.length === 0) { + findings.push({ + id: "plan-ok", + status: "pass", + message: `Every feature+axis measurement has exactly one owner (${plan.location.length} location + ${plan.depth.length} depth measurement(s) in the plan, all matched 1:1 by rendered output).`, + }); + } + return sectionFrom("plan-integrity", "Dimension plan integrity (one owner per feature+axis)", findings); +} + +/** How far PAST the dimension line (positive) or short of it (negative) a + * record's own extension-line segment(s) reach, for the "location", + * "location-shared", and "depth" kinds only - see checkSpacingStandards()'s + * doc comment for why those are the reliable/in-scope cases (every one of + * them draws its extension line(s) via the same extensionLineSpan overshoot + * math in sheet-composer.ts). + * + * An extension line that needed task 1's single-jog routing (see + * routedExtensionSegments in sheet-composer.ts) is stored as SEVERAL + * chained segments (near-straight, lateral jog leg(s), far-straight), not + * one straight span - and the record can carry more than one independent + * CHAIN at once (e.g. a location-shared cluster's several converging + * members, or a depth dimension's separate near+far extension lines). + * What "how far did THIS extension line reach" actually means is: start at + * each chain's own ROOT (a segment whose start point isn't any other + * segment's end point - nothing precedes it), follow start-to-end + * connectivity all the way to wherever that chain actually TERMINATES, and + * compare the chain's overall near point (the root's own start) against + * where it ends up - never a single segment's own local near/far, which + * for an intermediate jog leg is just a waypoint, not the line's true + * extent (that's what let a clean jog misread as a spacing-standards + * violation before this walked the whole chain). + */ +function extensionOvershootPx(r: DimensionRecord, crossCoord: number): number | null { + const relevantSpan = (s: Segment): [number, number] => + r.axis === "horizontal" ? [s.y1, s.y2] : [s.x1, s.x2]; + const isDimLine = (s: Segment) => { + const [a, b] = relevantSpan(s); + return a === b && a === crossCoord; + }; + const nonDimLine = r.lineSegments.filter((s) => !isDimLine(s)); + const pointKey = (x: number, y: number) => `${Math.round(x * 100)},${Math.round(y * 100)}`; + const byStart = new Map(nonDimLine.map((s) => [pointKey(s.x1, s.y1), s])); + const endKeys = new Set(nonDimLine.map((s) => pointKey(s.x2, s.y2))); + const roots = nonDimLine.filter((s) => !endKeys.has(pointKey(s.x1, s.y1))); + + let worst: number | null = null; + for (const root of roots) { + const [nearC] = relevantSpan(root); + let current = root; + const visited = new Set([root]); + for (;;) { + const next = byStart.get(pointKey(current.x2, current.y2)); + if (!next || visited.has(next)) break; + visited.add(next); + current = next; + } + const [, farC] = relevantSpan(current); + const farIsCloserToLine = Math.abs(farC - crossCoord) <= Math.abs(nearC - crossCoord); + if (!farIsCloserToLine) continue; + const travelSign = Math.sign(crossCoord - nearC) || 1; + const signedOvershoot = (farC - crossCoord) * travelSign; + worst = worst === null ? signedOvershoot : Math.min(worst, signedOvershoot); + } + return worst; +} + +/** Which side of the silhouette a dimension line's cross-coordinate sits + * on - "below"/"above" for a horizontal-axis chain, "left"/"right" for a + * vertical-axis chain. Needed because a vertical-oriented depth dimension + * now lives on the OPPOSITE side (right) from location/overall (left) - + * see sheet-composer.ts's verticalDepthEntries doc comment - so a single + * (view, axis) group can legitimately contain two physically unrelated + * sequences that must never be compared as one "ascending distance" chain. */ +type DimensionSide = "below" | "above" | "left" | "right"; + +function sideOf(axis: "horizontal" | "vertical", crossCoord: number, silhouette: Rect): DimensionSide { + if (axis === "horizontal") { + return crossCoord >= silhouette.y + silhouette.h ? "below" : "above"; + } + return crossCoord <= silhouette.x ? "left" : "right"; +} + +/** + * (e) Spacing standards, re-derived from recorded geometry against the + * named drafting constants (see drafting-rules.ts, the ONE place these are + * defined): the first dimension line off a view's silhouette must be >= + * FIRST_DIM_LINE_OFFSET_PX away, consecutive parallel dimension lines on the + * same view/axis/side must be >= PARALLEL_DIM_SPACING_PX apart, and an + * extension line must overshoot its dimension line by >= EXTENSION_OVERSHOOT_PX. + * Scoped to "overall", "location", "location-shared", "location-ordinate", + * and "depth" - every kind that now shares a per-edge unified lane sequence + * (task 3 - see sheet-composer.ts's drawCell); "size" callouts still use a + * free collision-avoidance search, not a lane sequence, so this section + * doesn't claim to verify them. Each (view, axis) pair is further split by + * DimensionSide (see sideOf) before checking - two DIFFERENT edges sharing + * one axis label (a vertical-axis location/overall column on the left, a + * vertical-axis depth column on the right) are two independent sequences, + * each internally consistent, never one merged chain. The extension-line- + * overshoot check itself is further scoped to "location"/"location-shared"/ + * "depth" only, where exactly which segment is the extension line is + * unambiguous (see extensionOvershootPx's doc comment). + */ +function checkSpacingStandards(model: SheetLayoutModel): CheckSection { + const findings: CheckFinding[] = []; + const TOLERANCE_PX = 2; + const LANE_KINDS = new Set([ + "overall", + "location", + "location-shared", + "location-ordinate", + "depth", + "depth-ordinate", + ]); + + for (const viewModel of Object.values(model.views)) { + const silhouette = viewModel.silhouetteRect; + const records = viewModel.dimensions.filter((r) => LANE_KINDS.has(r.kind)); + + for (const axis of ["horizontal", "vertical"] as const) { + const axisRecords = records.filter((r) => r.axis === axis); + + const bySide = new Map(); + for (const r of axisRecords) { + const c = dimensionLineCrossCoord(r); + if (c === null) continue; + const side = sideOf(axis, c, silhouette); + const arr = bySide.get(side) ?? []; + arr.push(r); + bySide.set(side, arr); + } + + for (const [side, sideRecords] of bySide) { + const partNear = + side === "below" + ? silhouette.y + silhouette.h + : side === "above" + ? silhouette.y + : side === "left" + ? silhouette.x + : silhouette.x + silhouette.w; + const tag = `${viewModel.view}/${axis} [${side} of part]`; + const idTag = `${viewModel.view}-${axis}-${side}`; + + const coordSet = new Map(); + for (const r of sideRecords) { + const c = dimensionLineCrossCoord(r); + if (c === null) continue; + const rounded = Math.round(c * 100) / 100; + if (!coordSet.has(rounded)) coordSet.set(rounded, r); + } + // Ascending distance FROM the part, regardless of screen direction + // ("below"/"right" chains run with increasing coordinates, "above"/ + // "left" chains run with decreasing coordinates). + const sortedCoords = [...coordSet.keys()].sort((a, b) => + side === "below" || side === "right" ? a - b : b - a, + ); + if (sortedCoords.length === 0) continue; + + const firstGap = Math.abs(sortedCoords[0] - partNear); + if (firstGap + TOLERANCE_PX < FIRST_DIM_LINE_OFFSET_PX) { + findings.push({ + id: `first-gap-${idTag}`, + status: "fail", + message: `${tag}: the nearest dimension line sits ${firstGap.toFixed(1)}px from the part - closer than the standard first-dimension-line offset (${FIRST_DIM_LINE_OFFSET_PX.toFixed(1)}px = ${FIRST_DIM_LINE_OFFSET_MM}mm on paper).`, + }); + } else { + findings.push({ + id: `first-gap-${idTag}`, + status: "pass", + message: `${tag}: nearest dimension line is ${firstGap.toFixed(1)}px from the part (>= ${FIRST_DIM_LINE_OFFSET_PX.toFixed(1)}px standard).`, + }); + } + + for (let i = 1; i < sortedCoords.length; i++) { + const gap = Math.abs(sortedCoords[i] - sortedCoords[i - 1]); + if (gap + TOLERANCE_PX < PARALLEL_DIM_SPACING_PX) { + findings.push({ + id: `parallel-gap-${idTag}-${i}`, + status: "fail", + message: `${tag}: two parallel dimension lines are only ${gap.toFixed(1)}px apart - closer than the standard parallel-dimension spacing (${PARALLEL_DIM_SPACING_PX.toFixed(1)}px = ${PARALLEL_DIM_SPACING_MM}mm on paper).`, + }); + } else { + findings.push({ + id: `parallel-gap-${idTag}-${i}`, + status: "pass", + message: `${tag}: parallel dimension lines ${i - 1}->${i} are ${gap.toFixed(1)}px apart (>= ${PARALLEL_DIM_SPACING_PX.toFixed(1)}px standard).`, + }); + } + } + + for (const r of sideRecords) { + if (r.kind !== "location" && r.kind !== "location-shared" && r.kind !== "depth") continue; + const crossCoord = dimensionLineCrossCoord(r); + if (crossCoord === null) continue; + const overshoot = extensionOvershootPx(r, crossCoord); + if (overshoot === null) continue; + if (overshoot + TOLERANCE_PX < EXTENSION_OVERSHOOT_PX) { + findings.push({ + id: `ext-gap-${r.id}`, + status: "fail", + message: `Extension line for "${r.text ?? r.id}" (${tag}) overshoots its dimension line by only ${overshoot.toFixed(1)}px - short of the standard extension-line overshoot (${EXTENSION_OVERSHOOT_PX.toFixed(1)}px = ${EXTENSION_OVERSHOOT_MM}mm on paper).`, + }); + } else { + findings.push({ + id: `ext-gap-${r.id}`, + status: "pass", + message: `Extension line for "${r.text ?? r.id}" (${tag}) overshoots its dimension line by ${overshoot.toFixed(1)}px (>= ${EXTENSION_OVERSHOOT_PX.toFixed(1)}px standard).`, + }); + } + } + } + } + } + + if (findings.length === 0) { + findings.push({ + id: "no-spacing-records", + status: "pass", + message: "No location/overall dimensions were rendered - nothing to check spacing on.", + }); + } + return sectionFrom("spacing-standards", "Dimension-line spacing matches drafting standards", findings); +} + +/** + * (f) No redundantly inferable dimension: for every directly-rendered + * location-like dimension on a given view/axis, checks whether its value + * equals the sum or difference of two OTHER directly-rendered dimensions on + * that same view/axis - if so, all three numbers are already on the sheet + * and a reader can derive the third from the other two, so drawing it + * directly added no new information (classic over-dimensioning). A tied + * cluster's members share one real value and count as ONE entry here (see + * checkNoUnclearClusters for whether that value collapsed to one label). + */ +function checkNoInferableDimensions(model: SheetLayoutModel): CheckSection { + const findings: CheckFinding[] = []; + const TOL_MM = 0.1; + + for (const viewModel of Object.values(model.views)) { + for (const axis of ["horizontal", "vertical"] as const) { + const records = viewModel.dimensions.filter( + (r) => r.axis === axis && LOCATION_LIKE_KINDS.has(r.kind), + ); + const distinct = [...new Map(records.map((r) => [(r.valueMm ?? NaN).toFixed(3), r])).values()]; + if (distinct.length < 3) continue; + + for (let i = 0; i < distinct.length; i++) { + const a = distinct[i].valueMm ?? NaN; + let redundantWith: { j: number; k: number } | null = null; + for (let j = 0; j < distinct.length && !redundantWith; j++) { + if (j === i) continue; + for (let k = j + 1; k < distinct.length; k++) { + if (k === i) continue; + const b = distinct[j].valueMm ?? NaN; + const c = distinct[k].valueMm ?? NaN; + if (Math.abs(a - (b + c)) < TOL_MM || Math.abs(a - Math.abs(b - c)) < TOL_MM) { + redundantWith = { j, k }; + break; + } + } + } + if (redundantWith) { + const b = distinct[redundantWith.j]; + const c = distinct[redundantWith.k]; + findings.push({ + id: `inferable-${viewModel.view}-${axis}-${distinct[i].id}`, + status: "fail", + message: `${viewModel.view}/${axis}: "${distinct[i].text}" (${a.toFixed(1)}mm) is inferable by adding/subtracting "${b.text}" (${(b.valueMm ?? NaN).toFixed(1)}mm) and "${c.text}" (${(c.valueMm ?? NaN).toFixed(1)}mm) - all three are already directly dimensioned, so one is redundant.`, + }); + } + } + } + } + + if (findings.length === 0) { + findings.push({ + id: "no-inferable", + status: "pass", + message: "No dimension's value is redundantly inferable by adding/subtracting two other directly-dimensioned values on the same view/axis.", + }); + } + return sectionFrom("no-inferable-dimension", "No redundantly inferable dimensions", findings); +} + +/** + * (g) Frame containment (task 2): nothing rendered anywhere on any view - + * nor the isometric reference view, which has no dimension records of its own + * to be measured through - may cross inside the ISO 5457 frame margin (FRAME_MARGIN_LEFT_MM / + * FRAME_MARGIN_OTHER_MM - see drafting-rules.ts), measured from the trimmed + * sheet edge. Uses computeViewContentBounds - THE SAME content-measurement + * function the scale-selection fit-check itself is built on (see + * sheet-composer.ts) - against the FINAL delivered SheetLayoutModel. That + * makes this an independent re-verification of the actual delivered + * sheet's geometry, not a re-derivation of the fit-check's own trial-time + * math: a candidate can pass the fit-check (which centers/tightens a + * NOMINAL trial render) and still be caught here if the final re-render's + * real geometry doesn't land where that trial assumed it would. + */ +function checkFrameContainment(model: SheetLayoutModel): CheckSection { + const findings: CheckFinding[] = []; + const TOLERANCE_PX = 1; + + // Every view's full content, PLUS the isometric reference view's own drawn + // rect - it carries no DimensionRecord (that's what keeps it out of Adjust + // Annotations), so nothing in the per-view walk below would ever see it, + // and "nothing rendered anywhere crosses the margin" has to mean + // everything actually rendered. + const targets: { id: string; label: string; bounds: Rect }[] = [ + ...Object.values(model.views).map((viewModel) => ({ + id: viewModel.view, + label: `${viewModel.view}'s true content bounds`, + bounds: computeViewContentBounds(viewModel), + })), + ...(model.isoView + ? [ + { + id: "iso", + label: "the isometric reference view", + bounds: model.isoView.destRect, + }, + ] + : []), + ]; + + for (const { id, label, bounds } of targets) { + const overLeft = FRAME_RECT.x - bounds.x; + const overTop = FRAME_RECT.y - bounds.y; + const overRight = bounds.x + bounds.w - (FRAME_RECT.x + FRAME_RECT.w); + const overBottom = bounds.y + bounds.h - (FRAME_RECT.y + FRAME_RECT.h); + const sides: string[] = []; + if (overLeft > TOLERANCE_PX) sides.push(`left by ${overLeft.toFixed(1)}px`); + if (overTop > TOLERANCE_PX) sides.push(`top by ${overTop.toFixed(1)}px`); + if (overRight > TOLERANCE_PX) sides.push(`right by ${overRight.toFixed(1)}px`); + if (overBottom > TOLERANCE_PX) sides.push(`bottom by ${overBottom.toFixed(1)}px`); + + if (sides.length > 0) { + findings.push({ + id: `frame-${id}`, + status: "fail", + message: + `${label} (${bounds.w.toFixed(0)}x${bounds.h.toFixed(0)}px ` + + `@ (${bounds.x.toFixed(0)},${bounds.y.toFixed(0)})) cross inside the ISO 5457 frame margin on its ` + + `${sides.join(", ")} - frame interior is (${FRAME_RECT.x.toFixed(0)},${FRAME_RECT.y.toFixed(0)}) ` + + `${FRAME_RECT.w.toFixed(0)}x${FRAME_RECT.h.toFixed(0)}px (${FRAME_MARGIN_LEFT_MM}mm left / ` + + `${FRAME_MARGIN_OTHER_MM}mm other margins from the trimmed sheet edge).`, + }); + } else { + const tightestClearance = Math.min(-overLeft, -overTop, -overRight, -overBottom); + findings.push({ + id: `frame-${id}`, + status: "pass", + message: + `${label} (${bounds.w.toFixed(0)}x${bounds.h.toFixed(0)}px ` + + `@ (${bounds.x.toFixed(0)},${bounds.y.toFixed(0)})) stays clear of the ISO 5457 frame margin by ` + + `>= ${tightestClearance.toFixed(1)}px on every side.`, + }); + } + } + + return sectionFrom( + "frame-containment", + "Frame containment (nothing crosses the ISO 5457 margin)", + findings, + ); +} + +/** Approximate (x,y) where two crossing segments intersect - for a + * human-readable diagnostic only (exact enough for "approximately here"), + * not a second correctness test; segmentsIntersectSeg is the actual + * yes/no. Falls back to the average of both segments' own midpoints if the + * two are (numerically) parallel. */ +function approxIntersectionPoint(a: Segment, b: Segment): { x: number; y: number } { + const d1x = a.x2 - a.x1; + const d1y = a.y2 - a.y1; + const d2x = b.x2 - b.x1; + const d2y = b.y2 - b.y1; + const denom = d1x * d2y - d1y * d2x; + if (Math.abs(denom) < 1e-9) { + return { + x: (a.x1 + a.x2 + b.x1 + b.x2) / 4, + y: (a.y1 + a.y2 + b.y1 + b.y2) / 4, + }; + } + const t = ((b.x1 - a.x1) * d2y - (b.y1 - a.y1) * d2x) / denom; + return { x: a.x1 + t * d1x, y: a.y1 + t * d1y }; +} + +/** True iff (x,y) stays clear of the ISO 5457 frame margin - i.e. lies + * inside FRAME_RECT, with a small tolerance for floating-point noise. */ +function pointWithinFrame(x: number, y: number): boolean { + const TOLERANCE_PX = 1; + return ( + x >= FRAME_RECT.x - TOLERANCE_PX && + x <= FRAME_RECT.x + FRAME_RECT.w + TOLERANCE_PX && + y >= FRAME_RECT.y - TOLERANCE_PX && + y <= FRAME_RECT.y + FRAME_RECT.h + TOLERANCE_PX + ); +} + +/** + * (h) Final geometric validation (task 4): the sheet-wide "does everything + * actually fit together" pass, run unconditionally, after every other + * section. Every prior check validates its own piece in isolation (one + * dimension's spacing, one cluster's clarity, one view's plan, one view's + * aggregate content bounds) - this is the only one that looks at the WHOLE + * delivered sheet at once, testing every rendered primitive against every + * OTHER one. Re-derived directly from recorded geometry, independent of + * whatever placement/collision-avoidance logic ran while composing it: + * + * - label vs. label (different records, any views) - real rect overlap; + * exact here since every label IS a true axis-aligned rect (a rotated + * vertical label's recorded rect is its own exact rotated footprint, not + * an approximation - see drawDimensionLine's vertical-label branch). + * Always a hard VIOLATION - text must always stay legible. + * - label vs. any view's silhouette. Hard VIOLATION. + * - label vs. the ISO 5457 frame margin. Hard VIOLATION. + * - line segment (dimension/extension/leader) vs. a DIFFERENT record's + * label - real segment-vs-rect intersection. Hard VIOLATION (any line + * crossing any label makes it illegible, regardless of the line's own + * role). + * - line segment vs. a silhouette it doesn't belong to (a view's own + * extension-line stubs legitimately touch/enter ITS OWN silhouette by + * design - e.g. an internal feature's extension line necessarily starts + * inside the part's bounding rect - so only cross-view crossings count). + * Rule 1: an EXTENSION line crossing a visible/outline line is + * explicitly ALLOWED (real drafting convention - no break drawn); + * every other role (dimension, leader, table/caption border) stays a + * hard VIOLATION, since convention draws no such exception for those. + * - line segment vs. the ISO 5457 frame margin. Hard VIOLATION, any role. + * - line segment vs. a DIFFERENT record's line segment - real + * segment-vs-segment intersection (NOT axis-aligned bounding-box + * overlap, which would both miss a diagonal leader line crossing another + * line at an angle and false-positive on two lines that pass near but + * not through each other). Classified by real per-pair-type drafting + * rule (rule 1 - see classifyLineCrossing in sheet-composer.ts, the + * single source findDimensionCrossings' fallback-eligibility gate also + * uses, so this check and task 4's own gating can never disagree about + * what's a genuine hard violation): + * extension x extension -> ALLOWED (no finding at all) + * dimension x extension -> WARNING ("avoid if possible", not a fail) + * dimension x dimension -> VIOLATION + * leader x leader -> VIOLATION + * anything else -> VIOLATION (conservative default) + * Same-record segments are excluded: they legitimately meet/connect at + * shared endpoints by construction (e.g. an extension line joining its + * own dimension line), not a crossing bug. + * + * A VIOLATION is a hard failure, named explicitly: which two elements (by + * label text/record id and view), and the approximate sheet-px coordinate + * where they cross. A WARNING is reported the same way but never fails its + * section (see sectionFrom). An ALLOWED crossing produces no finding at + * all - it's not a defect, it's permitted geometry. + */ +function checkFinalGeometricValidation(model: SheetLayoutModel): CheckSection { + const findings: CheckFinding[] = []; + const records = allRecords(model); + const silhouettes: { view: HiddenLineViewName; rect: Rect }[] = Object.values(model.views).map( + (v) => ({ view: v.view, rect: v.silhouetteRect }), + ); + const describe = (r: DimensionRecord) => `"${r.text ?? r.id}" (${r.view})`; + + const labeled = records.filter( + (r): r is DimensionRecord & { labelRect: Rect } => + !!r.labelRect && r.labelRect.w > 0 && r.labelRect.h > 0, + ); + const lines = records.flatMap((r) => r.lineSegments.map((seg) => ({ record: r, seg }))); + + let violationCount = 0; + + // label vs. label (different records, any views). + for (let i = 0; i < labeled.length; i++) { + for (let j = i + 1; j < labeled.length; j++) { + const a = labeled[i]; + const b = labeled[j]; + if (rectsOverlap(a.labelRect, b.labelRect)) { + violationCount++; + findings.push({ + id: `label-label-${a.id}-${b.id}`, + status: "fail", + message: `Label ${describe(a)} overlaps label ${describe(b)}, near (${a.labelRect.x.toFixed(0)},${a.labelRect.y.toFixed(0)}).`, + }); + } + } + } + + // label vs. any silhouette (own view or another). + for (const r of labeled) { + for (const s of silhouettes) { + if (rectsOverlap(r.labelRect, s.rect)) { + violationCount++; + findings.push({ + id: `label-outline-${r.id}-${s.view}`, + status: "fail", + message: `Label ${describe(r)} overlaps the ${s.view} view's part outline.`, + }); + } + } + } + + // label vs. the ISO 5457 frame margin. + for (const r of labeled) { + const rect = r.labelRect; + if (!pointWithinFrame(rect.x, rect.y) || !pointWithinFrame(rect.x + rect.w, rect.y + rect.h)) { + violationCount++; + findings.push({ + id: `label-frame-${r.id}`, + status: "fail", + message: `Label ${describe(r)} at (${rect.x.toFixed(0)},${rect.y.toFixed(0)}) crosses inside the ISO 5457 frame margin.`, + }); + } + } + + // line segment vs. a DIFFERENT record's label. + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + for (const lbl of labeled) { + if (ln.record.id === lbl.id) continue; + if (segmentIntersectsRect(ln.seg.x1, ln.seg.y1, ln.seg.x2, ln.seg.y2, lbl.labelRect)) { + violationCount++; + findings.push({ + id: `line-label-${ln.record.id}-${lbl.id}-${i}`, + status: "fail", + message: `A line of ${describe(ln.record)} crosses the label of ${describe(lbl)}.`, + }); + } + } + } + + // line segment vs. a silhouette it doesn't belong to (own-view lines + // legitimately touch/enter their own view's silhouette by design). Rule + // 1: an extension line crossing a visible/outline line is explicitly + // ALLOWED (no finding) - every other role stays a hard violation. + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + for (const s of silhouettes) { + if (s.view === ln.record.view) continue; + if (!segmentIntersectsRect(ln.seg.x1, ln.seg.y1, ln.seg.x2, ln.seg.y2, s.rect)) continue; + if (lineRoleOf(ln.record, ln.seg) === "extension") continue; + violationCount++; + findings.push({ + id: `line-outline-${ln.record.id}-${s.view}-${i}`, + status: "fail", + message: `A line of ${describe(ln.record)} crosses the ${s.view} view's part outline.`, + }); + } + } + + // line segment vs. the ISO 5457 frame margin. + for (let i = 0; i < lines.length; i++) { + const { seg, record } = lines[i]; + if (!pointWithinFrame(seg.x1, seg.y1) || !pointWithinFrame(seg.x2, seg.y2)) { + violationCount++; + findings.push({ + id: `line-frame-${record.id}-${i}`, + status: "fail", + message: `A line of ${describe(record)} from (${seg.x1.toFixed(0)},${seg.y1.toFixed(0)}) to (${seg.x2.toFixed(0)},${seg.y2.toFixed(0)}) crosses inside the ISO 5457 frame margin.`, + }); + } + } + + // line segment vs. a DIFFERENT record's line segment - real + // segment-vs-segment intersection, any angle, classified by the real + // per-pair-type drafting rule (rule 1 - see classifyLineCrossing in + // sheet-composer.ts). An ALLOWED pairing (extension x extension) + // produces no finding; a WARNING pairing (dimension x extension) is + // reported but never fails its section; everything else is a hard + // VIOLATION. + let warningCount = 0; + for (let i = 0; i < lines.length; i++) { + for (let j = i + 1; j < lines.length; j++) { + const a = lines[i]; + const b = lines[j]; + if (a.record.id === b.record.id) continue; + if (!segmentsIntersectSeg(a.seg, b.seg)) continue; + const roleA = lineRoleOf(a.record, a.seg); + const roleB = lineRoleOf(b.record, b.seg); + const verdict = classifyLineCrossing(roleA, roleB); + if (verdict === "allowed") continue; + const ix = approxIntersectionPoint(a.seg, b.seg); + if (verdict === "warning") { + warningCount++; + const dimSide = roleA === "dimension" ? a : b; + const extSide = roleA === "dimension" ? b : a; + findings.push({ + id: `line-line-warning-${a.record.id}-${b.record.id}-${i}-${j}`, + status: "warning", + message: `WARNING (avoid if possible, not a hard fail): dimension line of ${describe(dimSide.record)} crosses extension line of ${describe(extSide.record)} near (${ix.x.toFixed(0)},${ix.y.toFixed(0)}).`, + }); + continue; + } + violationCount++; + findings.push({ + id: `line-line-${a.record.id}-${b.record.id}-${i}-${j}`, + status: "fail", + message: `VIOLATION (${roleA} x ${roleB}): a line of ${describe(a.record)} crosses a line of ${describe(b.record)} near (${ix.x.toFixed(0)},${ix.y.toFixed(0)}).`, + }); + } + } + + if (violationCount === 0) { + findings.push({ + id: "final-validation-clean", + status: "pass", + message: + `Final geometric validation clean: checked ${lines.length} line segments and ${labeled.length} labels ` + + `across ${silhouettes.length} view outlines and the ISO 5457 frame margin, classified by real drafting ` + + `pair-type (rule 1 - label-label, label-outline, label-frame, line-label, line-outline, line-frame always ` + + `hard rules; line-line split into allowed/warning/violation by role) - zero hard violations` + + (warningCount > 0 + ? `, ${warningCount} dimension-line/extension-line warning(s) (avoid if possible, not a fail - see above).` + : `, zero warnings.`), + }); + } + + return sectionFrom( + "final-geometric-validation", + "Final geometric validation (real line/label/frame intersection, sheet-wide)", + findings, + ); +} + +export function checkSheetCompleteness( + model: SheetLayoutModel, + captureResult: HiddenLineViewSetResult, + plan: DimensionPlan, + scaleSelection: ScaleSelectionResult, +): SheetCheckReport { + if (!SHEET_CHECKER_ENABLED) { + return { status: "pass", sections: [] }; + } + const sections = [ + checkScaleSelection(scaleSelection), + checkFeatureCoverage(model, captureResult), + checkNoDuplicateOverall(model), + checkNoUnclearClusters(model), + checkPlanIntegrity(model, plan), + checkSpacingStandards(model), + checkNoInferableDimensions(model), + checkFrameContainment(model), + // Runs LAST, unconditionally - the sheet-wide "does everything actually + // fit together" pass (task 4), independent of and in addition to every + // section above it, each of which validates its own piece in isolation. + checkFinalGeometricValidation(model), + ]; + return { + status: sections.every((s) => s.status === "pass") ? "pass" : "fail", + sections, + }; +} diff --git a/src/components/cad/sheet-composer.ts b/src/components/cad/sheet-composer.ts new file mode 100644 index 0000000..186f06a --- /dev/null +++ b/src/components/cad/sheet-composer.ts @@ -0,0 +1,4893 @@ +import type { + HiddenLineAxialDepthAnnotation, + HiddenLineCircularAnnotation, + HiddenLineViewCapture, + HiddenLineViewName, + HiddenLineViewSetResult, +} from "./viewer"; +import { + buildDimensionPlan, + type DimensionPlan, + type PlannedLocationMeasurement, +} from "./sheet-dimension-plan"; +import { + EXTENSION_OVERSHOOT_PX, + EXTENSION_VISIBLE_GAP_PX, + FIRST_DIM_LINE_OFFSET_PX, + FRAME_MARGIN_LEFT_PX, + FRAME_MARGIN_OTHER_PX, + GRID_REF_CENTRING_MARK_CROSS_PX, + GRID_REF_CHAR_HEIGHT_PX, + GRID_REF_FIELD_NOMINAL_MM, + GRID_REF_LINE_WIDTH_PX, + HIDDEN_DASH_PX, + HIDDEN_GAP_PX, + LINE_WEIGHT_CENTERLINE_PX, + LINE_WEIGHT_DIMENSION_PX, + LINE_WEIGHT_EXTENSION_PX, + LINE_WEIGHT_FRAME_PX, + LINE_WEIGHT_HIDDEN_PX, + LINE_WEIGHT_LEADER_PX, + LINE_WEIGHT_TITLE_BLOCK_PX, + LINE_WEIGHT_TITLE_BLOCK_RULE_PX, + LINE_WEIGHT_VISIBLE_PX, + PARALLEL_DIM_SPACING_PX, + SHEET_MM_H, + SHEET_MM_W, + SHEET_H, + SHEET_PX_PER_MM, + SHEET_W, + VIEW_GROUP_GAP_MM, + VIEW_GROUP_GAP_PX, +} from "./drafting-rules"; +import { + cellRectPx, + defaultTitleBlockTable, + drawnGridSegmentsPx, + type TitleBlockTable, +} from "./title-block-table"; +export type { TitleBlockTable } from "./title-block-table"; +// Re-exported so existing importers (sheet-checker.ts, cad-viewer.tsx) can +// keep pulling drafting-rule constants through this module - drafting-rules.ts +// remains the one place any of these are actually DEFINED (see its own doc +// comment); this is a re-export of that same binding, not a second +// definition. +export * from "./drafting-rules"; + +/** + * Narrow, targeted knobs the bounded auto-retry loop (see cad-viewer.tsx's + * handleGenerateDrawingSheet) can nudge for one re-fix attempt after a + * specific checker section fails - never a blind full re-run with the same + * inputs, which would just reproduce the identical failure deterministically. + */ +export type SheetRetryHints = { + /** Widens the exact-tie epsilon (see EXACT_TIE_EPS_MM) - targets a + * no-unclear-clusters failure caused by floating-point noise placing a + * genuine real-world tie just outside the default epsilon. */ + exactTieEpsMm?: number; + /** Extra px added to the location-dimension lane spacing (row height for + * horizontal chains, column width for vertical chains) and the ordinate + * jog step - targets a no-geometric-overlap failure by giving cramped + * lanes/labels more room to clear each other. */ + extraLaneSpacingPx?: number; +}; + +export type A4SheetInput = { + captureResult: HiddenLineViewSetResult; + partName: string; + date: string; + retryHints?: SheetRetryHints; + /** Forces the sheet to compose at this exact ratio (e.g. 0.5 for "1:2", 2 + * for "2:1" - see MANUAL_SCALE_RATIOS), skipping the automatic + * view-outline/overflow-guard search in composeA4DrawingSheet's own + * "Scale selection" section entirely - that search (and its result) is + * otherwise unchanged. Omitted (or undefined) means "Auto": the normal + * automatic selection runs exactly as it always has. A manual ratio is + * still actually rendered and measured against both checks (see + * evaluateCandidate) so the returned overflowWarning reflects the real + * numbers - it's just never allowed to reject/step down from the chosen + * ratio the way Auto's search does. */ + manualRatio?: number; +}; + +// Sheet geometry and every named drafting-standard spacing constant +// (FIRST_DIM_LINE_OFFSET_PX, PARALLEL_DIM_SPACING_PX, +// EXTENSION_VISIBLE_GAP_PX, EXTENSION_OVERSHOOT_PX, FRAME_MARGIN_*_PX) are +// defined ONCE in drafting-rules.ts and imported above - this module draws +// with those exact numbers, never a local re-definition or a raw pixel +// literal standing in for one of them. +// Grown from the previous 560x210 (task 3: the default title block now +// carries a full DRAWN/CHK'D/DESIG'D x NAME/SIGN/DATE sub-grid plus +// MATERIAL/WEIGHT/PART NAME/PART NO/SCALE/SIZE/SHEET - 7 rows where there +// used to be 4. A DELIBERATELY MODERATE growth, not the ~300px height that +// would be most comfortable: CONTENT_SAFE_HEIGHT_PX below shrinks by +// whatever this grows, eating into the scale-search's headroom, and +// Pump-Housing's scale selection is known to be borderline (a past 0.4mm +// text-size change already flipped it 1:1 -> 1:2). If this needs revisiting, +// prefer shrinking the growth further (or growing width more than height, +// since FRAME_W has more relative slack than FRAME_H) over accepting an +// unwanted scale flip. +const TITLE_BLOCK_W = 640; +const TITLE_BLOCK_H = 260; + +// The drawing frame's border, in sheet px - the trimmed sheet edge (the +// full SHEET_W x SHEET_H canvas) inset by the ISO 5457 margins (20mm left, +// 10mm other three - see drafting-rules.ts). Nothing rendered anywhere on +// the sheet may cross inside this margin band - see sheet-checker.ts's +// frame-containment check, which validates the delivered sheet against +// this exact rect. +const FRAME_X = FRAME_MARGIN_LEFT_PX; +const FRAME_Y = FRAME_MARGIN_OTHER_PX; +const FRAME_W = SHEET_W - FRAME_MARGIN_LEFT_PX - FRAME_MARGIN_OTHER_PX; +const FRAME_H = SHEET_H - FRAME_MARGIN_OTHER_PX * 2; + +/** The real safe area content is laid out/centered within - the frame, + * minus a reserved strip at the bottom the height of the title block (plus + * a little clearance) so content never has to reason about the title + * block's own (narrower, bottom-right-only) footprint specifically. Used by + * composeA4DrawingSheet's own layout-size solve (as `drawArea`) as the + * NOMINAL centering target only - NOT the hard bound content is allowed to + * occupy (see FRAME_SAFE_AREA below for that; DRAW_AREA is deliberately + * more conservative, padded by an extra 30px beyond the title block's real + * height, and a candidate whose content is taller than that padded target + * but still within FRAME_SAFE_AREA is expected and fine - see + * clampCenterOffsetToFrame). Do NOT use this as a drag/placement CLAMP + * bound - that was a real bug (see FRAME_SAFE_AREA's doc comment). */ +export const DRAW_AREA: Rect = { + x: FRAME_X, + y: FRAME_Y, + w: FRAME_W, + h: FRAME_H - TITLE_BLOCK_H - 30, +}; + +/** The true (un-padded) vertical room between the frame's top margin and + * the title block's own top edge (tbY in drawSheetTitleBlock) - the real + * hard limit for "does this content actually reach the title block", as + * opposed to DRAW_AREA.h's deliberately-padded version (title block height + * PLUS a 30px cushion) used as the normal centering target. */ +const CONTENT_SAFE_HEIGHT_PX = FRAME_H - TITLE_BLOCK_H; + +/** THE hard bound content may occupy without crossing the frame margin or + * colliding with the title block - FRAME_X/Y/W (no width reservation needed, + * the title block is bottom-only) with height narrowed to + * CONTENT_SAFE_HEIGHT_PX instead of the full frame height. This is the ONE + * bound both the non-interactive placement (clampCenterOffsetToFrame below) + * AND the interactive whole-composition drag clamp + * (sheet-interactive-render.ts's clampCompositionOffset) use, so they can + * never disagree about where content is allowed to go - exported for + * exactly that reason. Real bug this fixes: the drag clamp used to import + * the shorter, EXTRA-padded DRAW_AREA instead - for any part whose content + * height falls between DRAW_AREA.h and CONTENT_SAFE_HEIGHT_PX (Pump-Housing + * at 1:1 is exactly this case, ~15px/1.9mm into that band), DRAW_AREA.h is + * SMALLER than the content's own height, so there is no valid Y offset at + * all under that bound - the clamp's independent top/bottom corrections + * fought each other (see the investigation this fixed, logged in + * [[scale_overflow_guard_and_composition_drag]]), pinning vertical drag to + * one of two fixed positions regardless of the requested delta - "vertical + * completely locked". FRAME_SAFE_AREA is exactly as tall as the composer's + * own placement ever actually needs, so a genuinely-fitting composition + * always has real (if sometimes small) slack to drag within. */ +export const FRAME_SAFE_AREA: Rect = { + x: FRAME_X, + y: FRAME_Y, + w: FRAME_W, + h: CONTENT_SAFE_HEIGHT_PX, +}; + +/** The title block's own rect (bottom-right corner, flush with the frame) - + * computed once here so drawSheetTitleBlock and the notes-block drag's + * collision check (sheet-interactive-render.ts) can never disagree about + * where it is, the same reason FRAME_SAFE_AREA is shared between placement + * and drag. */ +export const TITLE_BLOCK_RECT: Rect = { + x: FRAME_X + FRAME_W - TITLE_BLOCK_W, + y: FRAME_Y + FRAME_H - TITLE_BLOCK_H, + w: TITLE_BLOCK_W, + h: TITLE_BLOCK_H, +}; + +// --- General notes block (optional, task: "general notes... conventionally +// placed above the title block, numbered consecutively") ------------------ +// Fixed sheet furniture, like the title block itself - NOT part of the +// draggable composition (see sheet-interactive-render.ts's paintInteractiveSheet +// notes param and cad-viewer.tsx's notes state). It lives in the LEFT portion +// of the exact same bottom strip the title block already claims the right +// portion of - the strip FRAME_SAFE_AREA.h already permanently excludes from +// every other content bound (see CONTENT_SAFE_HEIGHT_PX above), regardless of +// the title block's own narrower width - so this rect is guaranteed disjoint +// from both the title block (disjoint x-range) and any view/dimension content +// (the whole strip is already off-limits to that content by construction, +// independent of notes existing at all). This is deliberately NOT unioned +// into fullContentBounds/the composition drag clamp: doing so would count a +// fixed, non-moving element against the MOVABLE content's available drag +// range, reintroducing the same class of bug the drag-clamp fix addresses +// (see sheet-interactive-render.ts's clampCompositionOffset doc comment). +export const MAX_NOTES = 8; +export const MAX_NOTE_CHARS = 100; +// Clearance the notes block's DEFAULT position keeps from the frame margin +// on every side (task: "sits too close to the frame margin... add clear +// padding") - it's now a freely-draggable element (see +// sheet-interactive-render.ts's clampNotesPosition), so this is only the +// REST position's inset; the drag clamp enforces the same margin for every +// other position too. +export const NOTES_MARGIN_PX = 24; +// Fixed block width (the block no longer claims the whole leftover strip +// beside the title block now that it can be dragged anywhere - a huge fixed +// width would make it nearly impossible to find a collision-free drop spot). +export const NOTES_BLOCK_W = 460; +export const NOTES_PADDING_X_PX = 18; +const NOTES_PADDING_TOP_PX = 16; +const NOTES_PADDING_BOTTOM_PX = 16; +const NOTES_HEADING_FONT = "bold 17px sans-serif"; +const NOTES_BODY_FONT = "15px sans-serif"; +export const NOTES_LINE_H_PX = 21; +const NOTES_HEADING_LINE_PX = 26; +// Rough width of a "N. " index prefix at NOTES_BODY_FONT's size - used only +// to offset the DOM text-entry input (cad-viewer.tsx) so it starts right +// after the canvas-drawn number, not to draw anything itself; doesn't need +// to be exact (a couple of stray px either way is imperceptible here). +export const NOTES_NUMBER_PREFIX_W_PX = 24; + +/** Sheet-px top-left origin of note line `lineIndex` (0-based, matching + * `notes[lineIndex]`/a pending entry at `notes.length`) relative to the + * block's own `position` - the exact point drawSheetNotes' own fillText call + * for that line starts from, so a DOM overlay (the inline text-entry input, + * or a per-line delete affordance) positioned from this always lines up with + * what's actually drawn underneath it. */ +export function notesLineOrigin( + position: { x: number; y: number }, + lineIndex: number, +): { x: number; y: number } { + return { + x: position.x + NOTES_PADDING_X_PX, + y: position.y + NOTES_PADDING_TOP_PX + NOTES_HEADING_LINE_PX + lineIndex * NOTES_LINE_H_PX, + }; +} + +/** Content-driven block height for `count` note lines (a blank/never-edited + * block still reserves one line's worth of room, so the dashed border+pencil + * affordance always has somewhere to show the next entry point). Used by + * both the renderer (drawSheetNotes) and the drag clamp/default-position + * helpers below, so the drawn box and the box collisions are checked against + * can never disagree. */ +export function notesBlockHeight(count: number): number { + return ( + NOTES_PADDING_TOP_PX + + NOTES_HEADING_LINE_PX + + Math.max(count, 1) * NOTES_LINE_H_PX + + NOTES_PADDING_BOTTOM_PX + ); +} + +export function notesBlockSize(count: number): { w: number; h: number } { + return { w: NOTES_BLOCK_W, h: notesBlockHeight(count) }; +} + +/** The notes block's rest position (task: inset from the frame margin, + * anchored to the bottom-left of the drawable area like the old fixed + * NOTES_AREA was) - only ever used as the starting point before any drag; + * once the user drags it, its position is remembered independently of this + * (see cad-viewer.tsx's notesPositionRef). */ +export function defaultNotesPosition(count: number): { x: number; y: number } { + const { h } = notesBlockSize(count); + return { + x: FRAME_X + NOTES_MARGIN_PX, + y: FRAME_Y + FRAME_H - NOTES_MARGIN_PX - h, + }; +} + +// Per-view "cell" reserved space (sheet px) around the cropped image, for +// the overall dimension lines and the view's text label. Location +// dimensions (see drawCell()) stack outward beyond this in their own rows/ +// columns, one per location-dimensioned feature (or feature cluster - see +// the ordinate-dimensioning path below). +const LEFT_DIM_W = FIRST_DIM_LINE_OFFSET_PX; +const BOTTOM_DIM_H = FIRST_DIM_LINE_OFFSET_PX; +// View caption box height - sized for CAPTION_FONT below, taller than a +// plain dimension label since captions render bold/larger (see +// CAPTION_FONT's own doc comment for that visual-hierarchy relationship). +const LABEL_H = 32; +const LOCATION_DIM_ROW_H = PARALLEL_DIM_SPACING_PX; +const LOCATION_DIM_COL_W = PARALLEL_DIM_SPACING_PX; +// Smallest isometric reference view worth drawing at all (both sides, in +// sheet px) - below this the corner space left over between the dimensioned +// views is too small for a shaded 3D view to read as anything, so the sheet +// simply omits it rather than placing a postage stamp. Deliberately +// expressed as a paper size: whether a reference view is legible is a +// property of the printed sheet, not of the part. +const ISO_MIN_SIDE_MM = 18; +const ISO_MIN_SIDE_PX = ISO_MIN_SIDE_MM * SHEET_PX_PER_MM; +// Enlarges the isometric beyond its natural (exactly-Top's-band) size (task: +// "it currently reads as small relative to the orthographic views") - see +// isoBoxFrom's own doc comment for how the growth is anchored so it can +// never crowd Right or cross the frame margin. 1.3 = 30% taller/wider than +// the un-boosted band - a clearly visible bump without dwarfing the +// dimensioned views it's meant to stay subordinate to. +const ISO_SIZE_BOOST = 1.3; + +// Perpendicular stagger between two ordinate ticks that share the same +// datum-relative value, inside one lane - see renderLocationCluster()'s doc +// comment for why a "jog" is used instead of e.g. spreading labels across +// the feature's own true position. +const ORDINATE_JOG_PX = 24; + +// THE dimension-value text style (overall/location/location-ordinate/ +// location-shared/depth/depth-ordinate labels, drawn by drawDimensionLine +// and drawIsolatedLabel) - one shared constant so every call site, plus +// sheet-interactive-render.ts's repaint of the exact same labelRect, always +// render at the identical size the box was actually measured for. Bumped a +// second time, from 15px, for readability at the review modal's on-screen +// display size (task: "increase dimension-value text size further"; see +// CAPTION_FONT for how captions stay visually distinct/larger on top of +// this bump). +export const DIM_VALUE_FONT_PX = 20; +export const DIM_VALUE_FONT = `${DIM_VALUE_FONT_PX}px monospace`; +// Fixed label-box height (and its half, for centering) drawDimensionLine/ +// drawIsolatedLabel size their white-backed text box to - grown from 20/10 +// alongside DIM_VALUE_FONT_PX's bump to comfortably contain the taller +// glyphs. LOCATION_DIM_ROW_H/COL_W (~79px, roughly 3x this) leave ample +// slack, so this bump can never make adjacent lanes collide. +const DIM_LABEL_BOX_H = 26; +const DIM_LABEL_BOX_HALF_H = DIM_LABEL_BOX_H / 2; +// View caption text style - bold and larger than DIM_VALUE_FONT, kept that +// way deliberately so a caption always reads as a heading over the +// dimension values beneath it, not merely "a bit bigger" - bumped alongside +// DIM_VALUE_FONT_PX's third increase to preserve that gap rather than let +// the two collide. Shared with sheet-interactive-render.ts's caption +// repaint for the same reason DIM_VALUE_FONT is shared. +export const CAPTION_FONT = "bold 22px sans-serif"; + +// Overall dims and size callouts are both already blue - location +// (distance-from-edge) dimensions get their own color so a viewer isn't +// stuck telling "how big" apart from "how far" apart from the plain overall +// envelope dims by shape alone. +const LOCATION_DIM_COLOR = "#059669"; +const EXTENSION_LINE_COLOR = "#9ca3af"; + +// Each real-world dimension (X/Y/Z) is the "width" or "height" of exactly +// TWO of the three views (Front width = Top width = X, Front height = +// Right height = Y, Top height = Right width = Z) - drawing it on both is +// a redundant duplicate, not a second useful piece of information. Standard +// convention: keep overall envelope dimensioning off the main Front view +// entirely (it's busy enough with feature/location dimensions) and put it +// on the two profile views instead - Top carries both of its dims (X and +// Z, since nothing else will show Z), Right carries only its height (Y), +// since its width (Z) is already shown on Top. +const OVERALL_DIM_VISIBILITY: Record< + HiddenLineViewCapture["view"], + { width: boolean; height: boolean } +> = { + front: { width: false, height: false }, + top: { width: true, height: true }, + right: { width: false, height: true }, +}; + +export type CalloutKeepClearBound = { + axis: "x" | "y"; + /** 1 = don't grow in the increasing direction of this axis (rightward + * for x, downward for y, since canvas Y grows downward); -1 = don't grow + * in the decreasing direction (leftward / upward). */ + direction: 1 | -1; + limitPx: number; +}; + +/** + * Per-view keep-clear bounds for drawCircularCallout's search (see its own + * doc comment) - built from THIS view's actual image rect (known once + * drawCell has computed imgX/Y/W/H for the current candidate scale) plus a + * small budget past the image edge, so a size-callout label can still + * swing a little past its own silhouette without the search treating that + * as "unsafe", but can't cross deep enough to threaten a neighboring + * view's own reserved minimum gap (CROSS_VIEW_GAP_PX). Derived from the + * SAME fixed third-angle arrangement OVERALL_DIM_VISIBILITY's own comment + * describes (Top directly above Front, Right directly beside Front): a + * callout searching toward a neighbor risks swinging past this view's own + * reserved cell and into theirs, which no per-view collision check alone + * can see coming. + */ +function keepClearBoundsForView( + view: HiddenLineViewCapture["view"], + imgX: number, + imgY: number, + imgW: number, + imgH: number, + crossViewGapPx: number, +): CalloutKeepClearBound[] { + // Deliberately small relative to crossViewGapPx (VIEW_GROUP_GAP_PX, the + // fixed inter-view gap - see this module's own CROSS_VIEW_GAP_PX doc + // comment), so there's very little real slack to spend. Just enough + // budget that a label anchored right at the image edge isn't + // flagged for its own unavoidable half-width, not a real allowance. A + // genuinely crowded feature is meant to fall back to tier 2 (overlapping + // ANOTHER label within its own view) in this case, never to cross into + // the neighbor. + const BUDGET_PX = Math.min(12, crossViewGapPx * 0.15); + if (view === "front") { + return [ + { axis: "x", direction: 1, limitPx: imgX + imgW + BUDGET_PX }, + { axis: "y", direction: -1, limitPx: imgY - BUDGET_PX }, + ]; + } + if (view === "top") { + return [{ axis: "y", direction: 1, limitPx: imgY + imgH + BUDGET_PX }]; + } + return [{ axis: "x", direction: -1, limitPx: imgX - BUDGET_PX }]; // right +} + +// --- Scale selection ------------------------------------------------------ +// TWO independent checks, BOTH of which must pass for a candidate ratio to +// be accepted - conflating them (as an earlier round wrongly did, by +// re-applying the view-outline limits to full rendered content) rejects +// scales that actually fit, since the view-outline limits are narrower than +// the real page: +// +// (a) VIEW-OUTLINE RULE - a direct, explicit size rule with two fixed +// limits (paper mm): Front width + inter-view gap + Right width <= +// 250mm; Front height + inter-view gap + Top height <= 145mm (Front +// width = Top width = X, Front height = Right height = Y, Top height = +// Right width = Z - see OVERALL_DIM_VISIBILITY's own doc comment above +// for why). Computed analytically from the part's bounding box, no +// rendering needed. This is the ORIGINAL rule, unchanged. +// (b) OVERFLOW GUARD - the full rendered content (view outlines + every +// dimension line + extension line + label + caption - see +// computeViewContentBounds) must fit within the real usable sheet area: +// A4 landscape minus the ISO margins (20mm left, 10mm top/right/bottom +// - see FRAME_RECT in drafting-rules.ts) on width, and - verified +// against real fixtures, see below - minus the title block's own +// height on top of that for height, since the title block is real +// content that already occupies the bottom of that same margin-inset +// area on every sheet. A view-outline total under its limit does not +// guarantee the real drawing fits the page - a dimension chain +// (extension lines + a label swung outward, e.g. a size callout) can +// reach past the outline. Conversely, the 250/145mm view-outline +// limits are deliberately TIGHTER than the real page itself (they +// leave room for exactly this kind of dimension overreach) - so full +// rendered content must be checked against the real page size, never +// the view-outline limits. +// +// Each candidate ratio is therefore actually RENDERED (via attemptAtRatio) +// and measured (its trueContentBoxPx - see ScaleCandidateLogEntry's doc +// comment) so the overflow guard reflects what was really drawn, not an +// estimate. 1:1 is tried first; if either check fails, step down the +// standard reduction series below, one step at a time, and use the first +// ratio where both hold. Every candidate tried is logged with all four +// numbers (view-outline width/height, overflow-guard width/height) and +// their own limits and pass/fail, kept separate, so it's always clear which +// check (if either) drove a rejection - see composeA4DrawingSheet's own +// size-rule loop (SizeRuleCandidate) for the per-candidate log this +// produces. +const REDUCTION_STEPS = [0.5, 0.2, 0.1, 0.05, 0.02, 0.01, 0.005, 0.002, 0.001]; +// = 1:2 1:5 1:10 1:20 1:50 1:100 1:200 1:500 1:1000 +const SCALE_STEPS = [1, ...REDUCTION_STEPS]; + +/** The standard-series ratios the modal's manual "Scale" dropdown offers + * (plus its separate "Auto" entry, which isn't a ratio at all - see + * A4SheetInput's manualRatio doc comment), largest-first so the rendered + * dropdown reads 2:1 down to 1:100. A DELIBERATELY shorter/different list + * than SCALE_STEPS (Auto's own search series, which never enlarges and + * steps all the way down to 1:1000): this is what a user is actually + * offered to pick by hand, not what Auto is willing to try. */ +export const MANUAL_SCALE_RATIOS = [2, 1, 0.5, 0.2, 0.1, 0.05, 0.02, 0.01]; + +// Check (a)'s two limits above (paper mm, view outlines only). +const SIZE_RULE_WIDTH_LIMIT_MM = 250; +const SIZE_RULE_HEIGHT_LIMIT_MM = 145; + +// Check (b)'s two limits - the real usable sheet area (paper mm). Width +// uses the full A4 frame (FRAME_W, the ISO-margin-inset rect - see +// drafting-rules.ts's FRAME_RECT, which this module-local px constant +// mirrors): the SAME bound sheet-checker.ts's checkFrameContainment +// validates the final delivered sheet against on that axis, since nothing +// else narrows the frame's width. Height uses CONTENT_SAFE_HEIGHT_PX +// (frame height minus the title block's own height, see its doc comment) +// rather than the full FRAME_H: a candidate whose content is exactly +// frame-height-tall would have nowhere left to put the title block without +// overlapping it - confirmed by actually regenerating flange.step's sheet, +// which showed a genuine title-block collision at a candidate ratio whose +// content fit the full frame height but not this tighter one. This is what +// keeps a candidate that clears the guard from ever needing +// clampCenterOffsetToFrame's title-block-avoiding bound (below) to make a +// trade-off between overshooting the frame margin and colliding with the +// title block. +const OVERFLOW_GUARD_WIDTH_LIMIT_MM = FRAME_W / SHEET_PX_PER_MM; +const OVERFLOW_GUARD_HEIGHT_LIMIT_MM = CONTENT_SAFE_HEIGHT_PX / SHEET_PX_PER_MM; + +type ViewBox = { widthMm: number; heightMm: number }; + +type LoadedView = { + view: HiddenLineViewCapture["view"]; + label: string; + /** This view's part outline, in SOURCE capture pixel space (see + * HiddenLineEdgeRun) - mapped to sheet px per candidate scale in drawCell. */ + edgeRuns: HiddenLineViewCapture["edgeRuns"]; + // Real-world silhouette size for this view (used for dimension lines). + partWidthMm: number; + partHeightMm: number; + // Crop region in SOURCE capture pixels (tight to silhouette + margin). + cropX: number; + cropY: number; + cropW: number; + cropH: number; + // Crop region size converted to real-world mm (source capture scale). + cropWmm: number; + cropHmm: number; + // Circle/arc callouts relevant to this view, in SOURCE capture pixel space. + annotations: HiddenLineCircularAnnotation[]; + // Stepped-hole axial-depth annotations relevant to this view (see + // computeAxialDepthAnnotationsForView in viewer.ts), SOURCE capture pixel + // space, for the two views where the hole reads edge-on rather than as a + // true circle. + axialDepth: HiddenLineAxialDepthAnnotation[]; +}; + +export type Rect = { x: number; y: number; w: number; h: number }; +export type Segment = { x1: number; y1: number; x2: number; y2: number }; + +// --- Internal annotation data model ----------------------------------- +// Built alongside (not after) the canvas render below, so it always +// reflects exactly what was drawn - but it's plain geometry/metadata, not +// pixels, which is what lets sheet-checker.ts validate the sheet without +// ever looking at the rendered PNG. See composeA4DrawingSheet()'s return +// value and sheet-checker.ts's doc comment. + +export type DimensionKind = + | "overall" + | "size" + | "location" + | "location-ordinate" + | "location-shared" + | "depth" + // Generalization of the location-ordinate/shared-baseline convention to + // depth: 2+ depth dimensions whose near/far extents are too tight for + // standard lane-offset to avoid crossing (see mergeTightDepthClusters) - + // every member keeps its own true near+far extension-line pair (depth's + // real between-two-points semantic, unlike location's from-datum one), + // converging onto ONE shared reference row instead of each claiming a + // separate lane. + | "depth-ordinate" + | "caption"; + +// Two real-world coordinates count as a genuine exact tie (not merely the +// same 1-decimal DISPLAY value) when they differ by less than this - loose +// enough to absorb floating-point noise from the centerPx -> mm conversion +// chain, tight enough that it can never accidentally merge two values that +// are actually distinct but happen to round to the same display text (the +// smallest such gap is 0.05mm, 50000x larger). Shared by sheet-checker.ts +// so the "did this get merged" check matches the "should this get merged" +// decision exactly. +export const EXACT_TIE_EPS_MM = 1e-6; + +export type DimensionRecord = { + id: string; + view: HiddenLineViewName; + kind: DimensionKind; + axis: "horizontal" | "vertical" | null; + /** Feature(s) this dimension represents - a size callout's "NX" group + * lists every member; a plain location/depth dimension lists just its + * own feature. Empty for a pure overall envelope dimension. */ + featureIds: string[]; + valueMm: number | null; + text: string | null; + lineSegments: Segment[]; + labelRect: Rect | null; +}; + +/** One projected outline polyline of a view, in SHEET px - the same run + * HiddenLineEdgeRun describes, mapped through this view's own capture->sheet + * transform (see drawCell's toSheetX/toSheetY) once at composition time, so + * every later consumer (the cheap interactive repaint, the download capture) + * just translates and strokes it. */ +export type SheetEdgeRun = { + hidden: boolean; + pts: number[]; +}; + +export type ViewLayoutModel = { + view: HiddenLineViewName; + silhouetteRect: Rect; + dimensions: DimensionRecord[]; + /** This view's part outline itself: visible and hidden edges as strokable + * polylines (see SheetEdgeRun). NOT included in computeViewContentBounds - + * silhouetteRect already IS the outline's extent, derived analytically from + * the part's own bounding box, and these runs are the same geometry drawn + * (they can exceed it only by half a stroke width, ~0.3mm against a 10mm + * frame margin). */ + edgeRuns: SheetEdgeRun[]; +}; + +/** The sheet's shaded isometric reference view (top-right corner): the raw + * capture, the sub-rect of it that holds the part, and the sheet-space rect + * it was drawn into. A raster - unlike the orthographic views, which are + * stroked as vectors (see SheetEdgeRun) - because it's shaded, undimensioned + * and explicitly not to scale. It carries no DimensionRecord of its own, + * which is exactly why it can never be selected or deleted in Adjust + * Annotations mode; it moves with the composition offset like everything + * else. */ +export type IsoViewLayout = { + img: HTMLImageElement; + srcRect: Rect; + destRect: Rect; +}; + +export type SheetLayoutModel = { + views: Record; + /** Null when the part yielded no isometric capture, or when the top-right + * corner had no room for one at the chosen scale (see ISO_MIN_SIDE_PX). */ + isoView: IsoViewLayout | null; +}; + +/** + * Strokes a view's outline runs, translated by (dx, dy), with the drafting + * line-weight hierarchy from drafting-rules.ts: visible edges heaviest, + * hidden edges half that and dashed. Two passes (hidden first, so a + * coincident visible edge wins the overlap and reads as solid - the same tie + * the 3D overlay's renderOrder used to settle), each a SINGLE canvas path + * over every run in the pass, so a whole view costs two strokes rather than + * thousands. Each run is its own subpath, so the dash pattern runs + * continuously along a real hidden run and restarts only at a genuine break. + * + * THE one function that draws a part outline anywhere: the compose pipeline + * calls it per candidate scale, the interactive layer calls it on every drag + * frame, so the two can never disagree about line weight. + */ +export function strokeSheetEdgeRuns( + ctx: CanvasRenderingContext2D, + runs: SheetEdgeRun[], + dx = 0, + dy = 0, +): void { + ctx.save(); + ctx.strokeStyle = "#000000"; + ctx.lineJoin = "round"; + for (const hiddenPass of [true, false]) { + let started = false; + ctx.beginPath(); + for (const run of runs) { + if (run.hidden !== hiddenPass || run.pts.length < 4) continue; + started = true; + ctx.moveTo(run.pts[0] + dx, run.pts[1] + dy); + for (let i = 2; i + 1 < run.pts.length; i += 2) { + ctx.lineTo(run.pts[i] + dx, run.pts[i + 1] + dy); + } + } + if (!started) continue; + ctx.lineWidth = hiddenPass ? LINE_WEIGHT_HIDDEN_PX : LINE_WEIGHT_VISIBLE_PX; + // Butt caps on the dashed pass so a dash is exactly its nominal paper + // length; round on the visible pass so a heavy outline's corners and + // chain ends close cleanly instead of showing square notches. + ctx.lineCap = hiddenPass ? "butt" : "round"; + ctx.setLineDash(hiddenPass ? [HIDDEN_DASH_PX, HIDDEN_GAP_PX] : []); + ctx.stroke(); + } + ctx.restore(); +} + +/** + * THE content-measurement function (task 2): unions the true bounds of + * every rendered primitive for a view - the part outline (silhouetteRect), + * every dimension line, every extension line (including its overshoot past + * the arrowhead - see extensionLineSpan, baked into the recorded segment + * endpoints), every leader line, every label's REAL measured text bounding + * box, and the view caption (itself just another labelRect-bearing record, + * kind "caption" - see drawCell). Not the part silhouette alone, and never + * an estimate: every DimensionRecord.lineSegments/labelRect entry here is + * the exact geometry that was actually drawn to the canvas - this walks + * every one of them, for every kind (overall, location, location-shared, + * location-ordinate, depth, size, caption) alike. + * + * ONE function, two callers that must never be able to disagree about what + * "the content" is: drawCell (inside composeA4DrawingSheet) calls this for + * its own per-view scale-selection fit-check return value, and + * sheet-checker.ts's frame-containment check calls it again, independently, + * against the FINAL delivered SheetLayoutModel, to verify nothing crosses + * inside the ISO 5457 frame margin (see drafting-rules.ts's FRAME_RECT). + */ +export function computeViewContentBounds( + view: Pick, +): Rect { + const { silhouetteRect } = view; + let minX = silhouetteRect.x; + let minY = silhouetteRect.y; + let maxX = silhouetteRect.x + silhouetteRect.w; + let maxY = silhouetteRect.y + silhouetteRect.h; + for (const r of view.dimensions) { + if (r.labelRect && (r.labelRect.w > 0 || r.labelRect.h > 0)) { + minX = Math.min(minX, r.labelRect.x); + minY = Math.min(minY, r.labelRect.y); + maxX = Math.max(maxX, r.labelRect.x + r.labelRect.w); + maxY = Math.max(maxY, r.labelRect.y + r.labelRect.h); + } + for (const s of r.lineSegments) { + minX = Math.min(minX, s.x1, s.x2); + minY = Math.min(minY, s.y1, s.y2); + maxX = Math.max(maxX, s.x1, s.x2); + maxY = Math.max(maxY, s.y1, s.y2); + } + } + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} + +// --- Scale-selection diagnostics ------------------------------------------ +// ScaleCandidateLogEntry/ScaleFitMeasurement below are per-RENDER +// diagnostics: what attemptAtRatio actually drew and measured at the one +// ratio the explicit size rule chose (see SizeRuleCandidate for the rule +// itself, which decides the ratio analytically, before any rendering). +// trueContentBoxPx/gapTightenVPx/gapTightenHPx are load-bearing - the +// centering/gap-tightening render pass in composeA4DrawingSheet depends on +// them - while `fits`/`measurements` are now just informational (real +// content vs. drawArea at this render), not part of the scale decision. +// Built fresh by every call to attemptAtRatio, logged unconditionally (not +// just in debug builds - see the console.log calls at the end of +// composeA4DrawingSheet). + +export type ScaleCandidateRole = "anchor" | "reduction" | "enlargement"; + +export type ScaleFitMeasurement = { + /** What was checked, e.g. "combined content width vs. usable sheet width". */ + check: string; + measuredPx: number; + boundPx: number; + comparison: "<=" | ">="; + ok: boolean; + /** 0 when ok; otherwise exactly how far past the bound this measurement fell. */ + shortfallPx: number; +}; + +export type ScaleCandidateLogEntry = { + ratio: number; + scaleLabel: string; + role: ScaleCandidateRole; + /** Real per-view rendered content bounds (sheet px) - actually drawn and + * measured, never estimated - at this candidate's own (arbitrary, + * nominal) trial position and trial inter-view gaps. Only the SIZE of + * these, and the gaps between them, are meaningful for the fit decision; + * see trueContentBoxPx's doc comment for why the raw position itself + * isn't. */ + contentBoundsPx: { front: Rect; top: Rect; right: Rect }; + /** How much SLACK (sheet px) the real measured Top<->Front and + * Front<->Right gaps had beyond the required CROSS_VIEW_GAP_PX minimum - + * 0 when a gap was already at (or under) the minimum. A full- + * circumference size-callout leader can swing to any angle around its + * own view (see drawCircularCallout), including toward a neighboring + * view, so the nominal gap this trial started from is frequently more + * generous than the real content actually needs. trueContentBoxPx is + * this trial's union box with that reclaimable slack already squeezed + * out - see composeA4DrawingSheet's final render pass, which applies + * these same amounts for real (shifting Front+Right up by + * gapTightenVPx, Right left by gapTightenHPx) rather than just noting + * them. */ + gapTightenVPx: number; + gapTightenHPx: number; + /** Union bounding box of all three views' real content AFTER squeezing + * out reclaimable gap slack (see gapTightenVPx/gapTightenHPx) - this + * box's own WIDTH/HEIGHT, and the (already-minimum) gaps between views, + * are position-independent facts, and are what the fit decision is + * actually made from - never the trial's raw position or raw (possibly + * slack-inflated) gaps. A candidate whose true content size fits the + * sheet can ALWAYS be centered to actually fit, regardless of where this + * particular trial render happened to place it - which is exactly what + * composeA4DrawingSheet's final re-center pass does for the winner. This + * is what makes the check position-independent instead of an artifact of + * a nominal, possibly-off-center-and-possibly-over-spaced, layout guess + * (see task 2 - "the second time a fit-check bug has caused a wrong + * scale to be chosen"). */ + trueContentBoxPx: Rect; + /** trueContentBoxPx minus the isometric reference view - i.e. the three + * DIMENSIONED views alone. Only ever differs from trueContentBoxPx in + * WIDTH (the iso's own box is bounded by its neighbors' content vertically, + * by construction - see isoBoxFrom), and exists so the diagnostic log can + * state outright whether adding the reference view is what pushed a + * candidate over the overflow guard, rather than leaving that to be + * inferred. */ + viewsOnlyContentBoxPx: Rect; + drawAreaPx: Rect; + measurements: ScaleFitMeasurement[]; + fits: boolean; + /** Human-readable one-liner - FITS, or REJECTED with the exact + * measurement(s) that failed and by how much. Precomputed once here so + * the console log and the checker report can never disagree. */ + summary: string; +}; + +/** One axis (width or height) of the explicit size rule's check for one + * candidate ratio - a real-world total (paper mm) against its fixed limit. */ +export type SizeRuleAxisResult = { + totalMm: number; + limitMm: number; + ok: boolean; +}; + +/** One ratio the TWO independent checks evaluated - see this module's own + * "Scale selection" doc comment above for both rules. Each candidate is + * actually rendered (attemptAtRatio) so the overflow guard reflects real + * measured content; the cheaper analytic view-outline totals are computed + * directly from the part's bounding box, no rendering needed. */ +export type SizeRuleCandidate = { + ratio: number; + scaleLabel: string; + /** Check (a), width axis: Front width + inter-view gap + Right width, at + * this ratio - view outlines only, analytic, vs. the 250mm limit. */ + width: SizeRuleAxisResult; + /** Check (a), height axis: Front height + inter-view gap + Top height, at + * this ratio - view outlines only, analytic, vs. the 145mm limit. */ + height: SizeRuleAxisResult; + /** Check (b), width axis: this candidate's real rendered content width - + * view outlines + dimension lines + extension lines + labels + captions + * (this candidate's own trueContentBoxPx.w, see ScaleCandidateLogEntry) - + * vs. the real usable-page width limit (~267mm). */ + overflowWidth: SizeRuleAxisResult; + /** Check (b), height axis: this candidate's real rendered content height + * (this candidate's own trueContentBoxPx.h) vs. the real usable-page + * height limit (~190mm). */ + overflowHeight: SizeRuleAxisResult; + /** Check (b), width axis again, but measured over the three DIMENSIONED + * views only (see ScaleCandidateLogEntry.viewsOnlyContentBoxPx). Reported + * so the log can say plainly whether the isometric reference view is what + * cost a candidate its overflow-guard width - the reference view is the + * only thing that can differ between this and overflowWidth, and it can + * never affect the height axis at all. */ + overflowWidthWithoutIso: SizeRuleAxisResult; + /** True only when ALL FOUR axes above pass - both checks are independent + * gates, either one failing rejects the candidate. */ + fits: boolean; +}; + +export type ScaleSelectionResult = { + /** Every ratio the two checks evaluated, 1:1 first, in stepping order, + * stopping at the first that satisfies all four axes (or ending at the + * smallest standard ratio if none does). */ + sizeRuleCandidates: SizeRuleCandidate[]; + chosenRatio: number; + chosenScaleLabel: string; + chosenRole: ScaleCandidateRole; + /** The winning candidate's own entry from sizeRuleCandidates - the exact + * totals/limits that decided the scale, including the overflowWidth/ + * overflowHeight full-content measurement that actually made the + * decision alongside the view-outline width/height. */ + chosenSizeRule: SizeRuleCandidate; + /** The SAME two full-content totals, re-measured from the FINAL delivered + * render - after centering, gap-tightening, and the crossing-remedy pass + * below have all run, which chosenSizeRule.overflowWidth/overflowHeight + * (measured from the chosen ratio's own nominal pre-remedy trial) predate. + * Should essentially never differ enough to cross a limit the nominal + * trial already cleared - centering/tightening only rearranges content + * inside the same content-box size, and the remedy pass only reassigns a + * crossing dimension to an alternate edge - but this is the true final + * number, logged here for visibility, not re-checked against the limits + * (if it ever did slip past a limit, that's a real gap worth knowing + * about, not something to silently re-trigger a further step-down for). */ + renderedWidthMm: number; + renderedHeightMm: number; +}; + +/** Non-blocking warning for a MANUAL scale override (see A4SheetInput's + * manualRatio doc comment) whose final delivered content exceeds the real + * usable sheet area (the same OVERFLOW_GUARD_WIDTH/HEIGHT_LIMIT_MM Auto's + * own overflow-guard check enforces) - the content is still rendered in + * full (task: "warn, don't block"), this just names the overflow. Auto mode + * never produces one of these: its search only ever accepts a ratio that + * already clears both checks (see composeA4DrawingSheet's own return). */ +export type ScaleOverflowWarning = { + /** mm by which the final rendered width exceeds the usable sheet width - 0 + * when width doesn't overflow (only height does). */ + widthExceedsMm: number; + /** mm by which the final rendered height exceeds the usable sheet height - + * 0 when height doesn't overflow (only width does). */ + heightExceedsMm: number; + /** Human-readable one-liner naming which axis (or axes) overflow and by + * how much, e.g. "Content exceeds sheet width by 34mm at this scale." - + * ready to show directly in the modal. */ + message: string; +}; + +/** + * Clamps a single-axis offset so [pos+offset, pos+size+offset] stays within + * [boundMin, boundMax] - a real interval intersection, computed once, not + * two independent corrections applied to the same un-corrected box. The + * latter was a real bug: for a box whose `size` exceeds `boundMax-boundMin` + * (doesn't fit the bound with room to spare), two sequential `if` + * corrections - one pushing the box back inside on the min side, one on the + * max side, each computed from the SAME starting position - fight each + * other instead of agreeing on a single result. Concretely, this pinned + * sheet-interactive-render.ts's whole-composition drag to one of exactly + * two fixed Y positions regardless of the requested delta ("vertical + * completely locked") for any part whose content height fell between + * DRAW_AREA.h and FRAME_SAFE_AREA.h (Pump-Housing at 1:1 is exactly this + * case) - see [[scale_overflow_guard_and_composition_drag]] for the + * investigation. When `size` genuinely exceeds the bound, this still + * returns the offset within the small (possibly inverted-order) range that + * best tracks `requested`, rather than pinning to one arbitrary spot - + * real, if sometimes small, slack instead of a dead zone. Shared by + * clampCenterOffsetToFrame (below, non-interactive placement) and + * sheet-interactive-render.ts's clampCompositionOffset (interactive drag), + * so the two can never disagree about how an offset gets bounded. + */ +export function clampAxisOffset( + pos: number, + size: number, + boundMin: number, + boundMax: number, + requested: number, +): number { + const lowerLimit = boundMin - pos; + const upperLimit = boundMax - size - pos; + const lo = Math.min(lowerLimit, upperLimit); + const hi = Math.max(lowerLimit, upperLimit); + return Math.max(lo, Math.min(hi, requested)); +} + +/** + * Clamps a center-within-drawArea offset so the resulting content box never + * crosses FRAME_SAFE_AREA (the frame margin on top/left/right, the title + * block's own top edge on the bottom - see its doc comment). drawArea is + * deliberately SHORTER than FRAME_SAFE_AREA (it adds a further 30px cushion + * on top of the title block's real height, as the normal centering target), + * so a candidate whose true content is taller than drawArea but still + * within FRAME_SAFE_AREA would otherwise get centered symmetrically around + * drawArea's smaller box, overshooting past the frame margin at the TOP + * (which has no reservation of its own) by half the shortfall - a real + * "renders off the page" bug, not merely an aesthetic one, confirmed by + * regenerating real fixtures. The overflow guard above already rejects any + * candidate whose content exceeds FRAME_SAFE_AREA.h, so this never has to + * trade a title-block collision for a margin overshoot - it only pushes the + * offset back toward the frame interior on whichever axis would otherwise + * cross it, and is a no-op whenever content already fits drawArea (the + * common case). */ +function clampCenterOffsetToFrame( + box: Rect, + offset: { x: number; y: number }, +): { x: number; y: number } { + return { + x: clampAxisOffset(box.x, box.w, FRAME_SAFE_AREA.x, FRAME_SAFE_AREA.x + FRAME_SAFE_AREA.w, offset.x), + y: clampAxisOffset(box.y, box.h, FRAME_SAFE_AREA.y, FRAME_SAFE_AREA.y + FRAME_SAFE_AREA.h, offset.y), + }; +} + +export function rectsOverlap(a: Rect, b: Rect): boolean { + return ( + a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y + ); +} + +// Standard segment-segment intersection via cross-product orientation tests +// (ignores exact collinear-overlap edge cases - fine here, real dimension +// geometry essentially never lines up exactly). Exported (plus the +// Segment-object wrapper below) so sheet-checker.ts's final geometric +// validation pass (task 4) can run the exact same real segment-vs-segment +// test this module already uses internally, rather than re-deriving a +// second implementation or falling back to bounding-box overlap - which +// would miss a diagonal leader line crossing another line at an angle. +export function segmentsIntersect( + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, + dx: number, + dy: number, +): boolean { + const cross = ( + ox: number, + oy: number, + px: number, + py: number, + qx: number, + qy: number, + ) => (px - ox) * (qy - oy) - (py - oy) * (qx - ox); + const d1 = cross(cx, cy, dx, dy, ax, ay); + const d2 = cross(cx, cy, dx, dy, bx, by); + const d3 = cross(ax, ay, bx, by, cx, cy); + const d4 = cross(ax, ay, bx, by, dx, dy); + return d1 > 0 !== d2 > 0 && d3 > 0 !== d4 > 0; +} + +/** Segment-object convenience wrapper around segmentsIntersect. */ +export function segmentsIntersectSeg(a: Segment, b: Segment): boolean { + return segmentsIntersect(a.x1, a.y1, a.x2, a.y2, b.x1, b.y1, b.x2, b.y2); +} + +/** True if the line segment (x1,y1)-(x2,y2) passes through or starts/ends inside `rect`. */ +export function segmentIntersectsRect( + x1: number, + y1: number, + x2: number, + y2: number, + rect: Rect, +): boolean { + const { x, y, w, h } = rect; + const inside = (px: number, py: number) => + px >= x && px <= x + w && py >= y && py <= y + h; + if (inside(x1, y1) || inside(x2, y2)) return true; + return ( + segmentsIntersect(x1, y1, x2, y2, x, y, x + w, y) || + segmentsIntersect(x1, y1, x2, y2, x + w, y, x + w, y + h) || + segmentsIntersect(x1, y1, x2, y2, x + w, y + h, x, y + h) || + segmentsIntersect(x1, y1, x2, y2, x, y + h, x, y) + ); +} + +/** True if segment `s` crosses `rect` - same test as segmentIntersectsRect, rect vs. a stored Segment. */ +function segmentRectCollide(s: Segment, rect: Rect): boolean { + return segmentIntersectsRect(s.x1, s.y1, s.x2, s.y2, rect); +} + +/** The dimension-LINE segment of a location-like/overall/depth-family + * record - the one lying AT the record's fixed lane/row cross-coordinate, + * distinguishable from every extension-line segment because it alone runs + * perpendicular to the measured axis (y1===y2 for a horizontal-axis + * chain's dimension line, x1===x2 for a vertical-axis chain's). Returns + * null for a record with no such segment of its own (size/caption, or an + * axis-less record). + * + * THE canonical lookup: sheet-checker.ts's spacing-standards check and + * this module's own lineRoleOf (below - final-geometric-validation's real + * per-pair-type crossing rule, and findDimensionCrossings' matching + * fallback-eligibility gate) all call this exact function, so none of them + * can ever disagree about which segment of a given record IS its + * dimension line. */ +export function dimensionLineCrossCoord(r: DimensionRecord): number | null { + if (r.axis === "horizontal") { + const seg = r.lineSegments.find((s) => s.y1 === s.y2); + return seg ? seg.y1 : null; + } + if (r.axis === "vertical") { + const seg = r.lineSegments.find((s) => s.x1 === s.x2); + return seg ? seg.x1 : null; + } + return null; +} + +/** Real drafting role of a single line segment within its own record - what + * classifyLineCrossing's per-pair-type rule actually keys on. A size + * callout's segment is always its leader; a caption border is neither a + * dimension nor an extension line ("other"); every remaining kind (overall, + * every location variant, every depth variant) splits its own segments into + * the one AT dimensionLineCrossCoord (the dimension line itself) vs. every + * other segment, which is an extension line by construction - including + * every leg of a jogged/routed extension line (see + * routedExtensionSegments): only the lateral jog leg that happens to land + * exactly at the record's own cross-coordinate would misread as the + * dimension line, and a jog leg is deliberately offset FROM that + * coordinate, so this never happens. */ +export type LineRole = "extension" | "dimension" | "leader" | "other"; + +export function lineRoleOf(r: DimensionRecord, seg: Segment): LineRole { + if (r.kind === "size") return "leader"; + if (r.kind === "caption") return "other"; + const crossCoord = dimensionLineCrossCoord(r); + if (crossCoord === null) return "extension"; + if (r.axis === "horizontal" && seg.y1 === seg.y2 && seg.y1 === crossCoord) return "dimension"; + if (r.axis === "vertical" && seg.x1 === seg.x2 && seg.x1 === crossCoord) return "dimension"; + return "extension"; +} + +export type LineCrossingVerdict = "allowed" | "warning" | "violation"; + +/** + * Real per-pair-type drafting rule for a line-vs-line crossing (ASME + * Y14.5/ISO 129 convention) - the single source both + * checkFinalGeometricValidation (sheet-checker.ts) and this module's own + * findDimensionCrossings key off of, so the delivered-sheet gate and the + * compose-time crossing scan can never disagree about what counts as a + * genuine hard violation: + * + * - extension x extension -> ALLOWED. Explicitly permitted; no break is + * drawn at the crossing. + * - dimension x extension -> WARNING. "Avoid if possible" per convention, + * but not a hard fail. + * - dimension x dimension -> VIOLATION. + * - leader x leader -> VIOLATION. + * - everything else (extension/leader, dimension/leader, or either + * against an "other" caption border) -> VIOLATION, the conservative + * default - convention draws no explicit exception for these, and a + * leader crossing anything makes a label ambiguous exactly like a + * dimension-dimension crossing would. + */ +export function classifyLineCrossing(a: LineRole, b: LineRole): LineCrossingVerdict { + const [x, y] = [a, b].sort(); + if (x === "extension" && y === "extension") return "allowed"; + if (x === "dimension" && y === "extension") return "warning"; + if (x === "dimension" && y === "dimension") return "violation"; + if (x === "leader" && y === "leader") return "violation"; + return "violation"; +} + +/** + * Distance along ray (ox,oy)+t*(dx,dy), t>=0, at which it exits `rect`'s far + * side - standard slab-method ray/AABB test. Returns 0 if the ray never + * enters the rect at all (e.g. already outside and pointing away), so + * callers can safely do Math.max(minimum, exitDistance). Used to keep a + * size callout's label clear of the part's own silhouette: starting the + * search at "anchor + a small fixed offset" isn't enough when the anchor is + * deep inside a large silhouette (e.g. a bore near the middle of a plate). + */ +function rayExitDistance( + ox: number, + oy: number, + dx: number, + dy: number, + rect: Rect, +): number { + const eps = 1e-9; + let tMin = -Infinity; + let tMax = Infinity; + if (Math.abs(dx) < eps) { + if (ox < rect.x || ox > rect.x + rect.w) return 0; + } else { + const tx1 = (rect.x - ox) / dx; + const tx2 = (rect.x + rect.w - ox) / dx; + tMin = Math.max(tMin, Math.min(tx1, tx2)); + tMax = Math.min(tMax, Math.max(tx1, tx2)); + } + if (Math.abs(dy) < eps) { + if (oy < rect.y || oy > rect.y + rect.h) return 0; + } else { + const ty1 = (rect.y - oy) / dy; + const ty2 = (rect.y + rect.h - oy) / dy; + tMin = Math.max(tMin, Math.min(ty1, ty2)); + tMax = Math.min(tMax, Math.max(ty1, ty2)); + } + if (tMax < Math.max(0, tMin)) return 0; + return Math.max(0, tMax); +} + +export function loadImage(dataURL: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = dataURL; + }); +} + +export function formatScaleLabel(ratio: number): string { + if (ratio >= 1) { + const n = Number.isInteger(ratio) ? ratio : Number(ratio.toFixed(2)); + return `${n}:1`; + } + const n = Math.round(1 / ratio); + return `1:${n}`; +} + +/** Builds the human-readable one-liner for a ScaleCandidateLogEntry (see + * its doc comment in the type-definitions section above) - a single shared + * implementation so the console log and the checker report + * (sheet-checker.ts) can never disagree about what a candidate's own + * measurements say. Always states the real measured numbers, and on + * rejection names exactly which measurement(s) missed their bound and by + * how much - never a bare "didn't fit" (see task 2). */ +function formatCandidateSummary(entry: Omit): string { + const { scaleLabel, role, contentBoundsPx, trueContentBoxPx, drawAreaPx, measurements, fits } = + entry; + const rect = (r: Rect) => + `${r.w.toFixed(0)}x${r.h.toFixed(0)}px @ (${r.x.toFixed(0)},${r.y.toFixed(0)})`; + const dims = (r: Rect) => `${r.w.toFixed(0)}x${r.h.toFixed(0)}px`; + // CROSS_VIEW_GAP_PX (composeA4DrawingSheet-local) is just an alias for + // this - see its own definition. + const crossViewGapPx = PARALLEL_DIM_SPACING_PX; + const tightenNote = + entry.gapTightenVPx > 0.5 || entry.gapTightenHPx > 0.5 + ? ` [reclaimed ${entry.gapTightenVPx.toFixed(1)}px of slack from the Top-Front gap and ` + + `${entry.gapTightenHPx.toFixed(1)}px from Front-Right, both squeezed to the ${crossViewGapPx.toFixed(1)}px minimum]` + : ""; + const base = + `${scaleLabel} (${role}): front ${rect(contentBoundsPx.front)}, ` + + `top ${rect(contentBoundsPx.top)}, right ${rect(contentBoundsPx.right)}; ` + + `combined content (post-tightening) ${rect(trueContentBoxPx)} vs. usable sheet area ${dims(drawAreaPx)} @ (${drawAreaPx.x.toFixed(0)},${drawAreaPx.y.toFixed(0)})${tightenNote}`; + if (fits) return `${base} - FITS.`; + const failures = measurements + .filter((m) => !m.ok) + .map( + (m) => + `${m.check}: measured ${m.measuredPx.toFixed(1)}px, required ${m.comparison} ${m.boundPx.toFixed(1)}px (short by ${m.shortfallPx.toFixed(1)}px)`, + ) + .join("; "); + return `${base} - REJECTED: ${failures}.`; +} + +/** + * Draws a standard third-angle-projection symbol: two truncated cones + * (frustums) side by side, one shown in front view (circle) and one in side + * view (trapezoid) - the conventional ISO/ASME third-angle glyph. Kept + * intentionally simple/schematic rather than a literal engineering-accurate + * rendering, since it's a label glyph, not a drawn part. + */ +function drawThirdAngleSymbol( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + scale: number, +) { + ctx.save(); + ctx.translate(cx, cy); + ctx.strokeStyle = "#000000"; + ctx.lineWidth = LINE_WEIGHT_TITLE_BLOCK_RULE_PX; + + // Left: circle (front view of a truncated cone, shown as two concentric circles). + const r1 = 11 * scale; + const r2 = 5 * scale; + ctx.beginPath(); + ctx.arc(-16 * scale, 0, r1, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(-16 * scale, 0, r2, 0, Math.PI * 2); + ctx.stroke(); + + // Right: trapezoid (side view of the same truncated cone). + const topW = 6 * scale; + const botW = 14 * scale; + const h = 20 * scale; + const bx = 16 * scale; + ctx.beginPath(); + ctx.moveTo(bx - topW / 2, -h / 2); + ctx.lineTo(bx + topW / 2, -h / 2); + ctx.lineTo(bx + botW / 2, h / 2); + ctx.lineTo(bx - botW / 2, h / 2); + ctx.closePath(); + ctx.stroke(); + // Centerline through the trapezoid - lightest weight in the hierarchy + // (see drafting-rules.ts), as a centerline always is. + ctx.lineWidth = LINE_WEIGHT_CENTERLINE_PX; + ctx.beginPath(); + ctx.moveTo(bx, -h / 2 - 3 * scale); + ctx.lineTo(bx, h / 2 + 3 * scale); + ctx.stroke(); + + ctx.restore(); +} + +/** + * Sheet background + drawing frame stroke ONLY (not the title block - see + * drawSheetTitleBlock below, kept as a separate function so the heavy + * compose pipeline can draw the frame first and the title block last, + * exactly as it always has, without changing paint order/z-stacking). + * Pulled out into its own function so the interactive-render layer (which + * repaints the sheet on every drag frame, cheaply, without re-running this + * module's scale-selection/dimension-planning pipeline - see + * sheet-interactive-render.ts) can reproduce this exact same background + * instead of a hand-duplicated copy that could silently drift out of sync + * with this one. + */ +export function drawSheetFrame(ctx: CanvasRenderingContext2D): void { + // Sheet background + drawing frame - the frame sits FRAME_MARGIN_LEFT_MM + // (20mm, ISO 5457 binding margin) from the trimmed sheet edge on the + // left and FRAME_MARGIN_OTHER_MM (10mm) on the other three sides, never + // a flat/symmetric margin. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, SHEET_W, SHEET_H); + ctx.strokeStyle = "#000000"; + ctx.lineWidth = LINE_WEIGHT_FRAME_PX; + ctx.strokeRect(FRAME_X, FRAME_Y, FRAME_W, FRAME_H); + drawGridReferenceFrame(ctx); +} + +// ISO 5457 uppercase letter sequence - skips I and O (indistinguishable from +// 1/0 at small sizes, exactly the reason the standard excludes them). +const GRID_REF_LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + +/** The letter for grid-reference row `index` (0-based) - beyond the 24-letter + * alphabet (never reached by A4's own 4 rows; kept general/correct anyway, + * same "don't special-case away real inputs" reasoning as elsewhere in this + * file) wraps with a numeric suffix rather than throwing. */ +function gridRefLetterAt(index: number): string { + const n = GRID_REF_LETTERS.length; + if (index < n) return GRID_REF_LETTERS[index]; + return `${GRID_REF_LETTERS[index % n]}${Math.floor(index / n) + 1}`; +} + +/** + * ISO 5457 border zone / grid reference system, plus centring marks. Numeral + * fields (1, 2, 3...) run left-to-right along the TOP edge; letter fields + * (A, B, C... skipping I/O) run top-to-bottom along the RIGHT edge - per the + * task, on A4 these markings appear on the top and right sides ONLY, never + * all four (unlike centring marks, which are a physical reprographic- + * alignment fixture independent of which sides carry letters/numerals, and + * so render on all four edge midpoints regardless). + * + * Field length is nominally GRID_REF_FIELD_NOMINAL_MM (50mm) but adjusted so + * an INTEGER number of equal-length fields exactly spans each edge; fields + * are measured from the trimmed SHEET edge (SHEET_MM_W/H), not the inset + * drawing frame. Labels and field-boundary tick marks live entirely inside + * the existing margin band between the raw sheet edge and the frame - never + * drawn across the actual drawing content area. + */ +function drawGridReferenceFrame(ctx: CanvasRenderingContext2D): void { + ctx.save(); + ctx.strokeStyle = "#000000"; + ctx.fillStyle = "#000000"; + ctx.lineWidth = GRID_REF_LINE_WIDTH_PX; + ctx.font = `${GRID_REF_CHAR_HEIGHT_PX.toFixed(2)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const nFieldsTop = Math.max(1, Math.round(SHEET_MM_W / GRID_REF_FIELD_NOMINAL_MM)); + const fieldTopPx = SHEET_W / nFieldsTop; + const nFieldsRight = Math.max(1, Math.round(SHEET_MM_H / GRID_REF_FIELD_NOMINAL_MM)); + const fieldRightPx = SHEET_H / nFieldsRight; + + // Top edge: numerals 1..nFieldsTop, left-to-right. Ticks + labels sit in + // the top margin band, y: 0 -> FRAME_Y. + for (let i = 0; i < nFieldsTop; i++) { + ctx.fillText(String(i + 1), (i + 0.5) * fieldTopPx, FRAME_Y / 2); + if (i > 0) { + const x = i * fieldTopPx; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, FRAME_Y); + ctx.stroke(); + } + } + + // Right edge: letters A, B, C... (skip I/O), top-to-bottom. Ticks + labels + // sit in the right margin band, x: SHEET_W - FRAME_MARGIN_OTHER_PX -> SHEET_W. + const rightBandX = SHEET_W - FRAME_MARGIN_OTHER_PX; + for (let i = 0; i < nFieldsRight; i++) { + ctx.fillText(gridRefLetterAt(i), SHEET_W - FRAME_MARGIN_OTHER_PX / 2, (i + 0.5) * fieldRightPx); + if (i > 0) { + const y = i * fieldRightPx; + ctx.beginPath(); + ctx.moveTo(rightBandX, y); + ctx.lineTo(SHEET_W, y); + ctx.stroke(); + } + } + + // Centring marks - all four edge midpoints, a short stroke from the raw + // sheet edge crossing GRID_REF_CENTRING_MARK_CROSS_PX past the frame line + // (each edge's own margin width, since the left margin is wider than the + // other three under the iso-filing margin mode - see FRAME_MARGIN_LEFT_MM). + const midX = SHEET_W / 2; + const midY = SHEET_H / 2; + // Top/bottom/right all share FRAME_MARGIN_OTHER_PX; only left is wider + // (the iso-filing binding margin). + const otherReach = FRAME_MARGIN_OTHER_PX + GRID_REF_CENTRING_MARK_CROSS_PX; + const leftReach = FRAME_MARGIN_LEFT_PX + GRID_REF_CENTRING_MARK_CROSS_PX; + ctx.beginPath(); + ctx.moveTo(midX, 0); + ctx.lineTo(midX, otherReach); + ctx.moveTo(midX, SHEET_H); + ctx.lineTo(midX, SHEET_H - otherReach); + ctx.moveTo(0, midY); + ctx.lineTo(leftReach, midY); + ctx.moveTo(SHEET_W, midY); + ctx.lineTo(SHEET_W - otherReach, midY); + ctx.stroke(); + + ctx.restore(); +} + +/** + * Title block (bottom-right corner, flush with the frame): borders/rows, + * name/date/scale/units fields, the third-angle-projection symbol. See + * drawSheetFrame's doc comment above for why this is a separate function + * from the frame itself - the heavy compose pipeline draws this LAST (after + * every view/dimension), the interactive-render layer draws it as part of + * its static background layer (drawArea already reserves this corner, so + * view/dimension content never actually overlaps it either way - see + * TITLE_BLOCK_H's use in drawArea's own height reservation above). + */ +export function drawSheetTitleBlock( + ctx: CanvasRenderingContext2D, + info: { partName: string; date: string; scaleLabel: string }, + table?: TitleBlockTable, + editMode?: boolean, + logoImage?: HTMLImageElement | null, +): void { + const t = table ?? defaultTitleBlockTable(info.partName, info.date, info.scaleLabel); + const rect = TITLE_BLOCK_RECT; + + ctx.save(); + ctx.strokeStyle = "#000000"; + ctx.lineWidth = LINE_WEIGHT_TITLE_BLOCK_PX; + ctx.strokeRect(rect.x, rect.y, rect.w, rect.h); + + // Interior dividers only - never through a merged cell (task: a cell + // spanning multiple grid units draws no line across its own interior). + ctx.lineWidth = LINE_WEIGHT_TITLE_BLOCK_RULE_PX; + for (const seg of drawnGridSegmentsPx(t, rect)) { + ctx.beginPath(); + ctx.moveTo(seg.x1, seg.y1); + ctx.lineTo(seg.x2, seg.y2); + ctx.stroke(); + } + + // While table-edit mode is live, real DOM inputs/overlays cover every + // cell (see cad-viewer.tsx) - never draw content here too, or it would + // double up underneath them (same convention drawSheetNotes' own editMode + // uses). This includes the logo cell's own upload/avatar overlay, so the + // logoImage/avatar branches below never need a separate editMode check of + // their own. + if (editMode) { + ctx.restore(); + return; + } + + ctx.textAlign = "left"; + ctx.fillStyle = "#000000"; + + for (const cell of t.cells) { + const r = cellRectPx(t, rect, cell); + + if (cell.special === "logo") { + if (logoImage) { + // Contain-fit within the cell, centered, preserving aspect ratio - + // an uploaded logo's own proportions are never known in advance. + const scale = Math.min(r.w / logoImage.width, r.h / logoImage.height); + const dw = logoImage.width * scale; + const dh = logoImage.height * scale; + ctx.drawImage(logoImage, r.x + (r.w - dw) / 2, r.y + (r.h - dh) / 2, dw, dh); + } else { + const name = t.cells.find((c) => c.role === "drawnName")?.text ?? ""; + drawTitleBlockAvatar(ctx, r, name); + } + continue; + } + + // Bound cells (task 3: "auto-populated... NOT user-editable") always + // render the live value, never their own stored text - see + // TitleBlockSpecialKind's own doc comment in title-block-table.ts. + const trimmed = + cell.special === "boundScale" + ? `SCALE ${info.scaleLabel}` + : cell.special === "boundSize" + ? "SIZE A4" + : cell.text.trim(); + + if (cell.special === "thirdAngleSymbol") { + const scale = Math.max(0.4, Math.min(1.3, r.h / 62)); + drawThirdAngleSymbol(ctx, r.x + r.w / 2, r.y + r.h * 0.4, scale); + ctx.font = "8px sans-serif"; + ctx.fillStyle = "#000000"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(fitNotesText(ctx, trimmed || "THIRD ANGLE PROJECTION", r.w - 6), r.x + r.w / 2, r.y + r.h * 0.72); + ctx.textAlign = "left"; + continue; + } + if (!trimmed) continue; + + // partNameTitle keeps the bold shrink-to-fit treatment every other cell's + // plain single line doesn't need - an explicit tag (see + // TitleBlockSpecialKind) rather than the old `cell.r0 === 0` heuristic, + // which only worked while row 0 was always the part name. + if (cell.special === "partNameTitle") { + const maxW = r.w - 16; + let fontPx = 14; + ctx.font = `bold ${fontPx}px sans-serif`; + while (fontPx > 8 && ctx.measureText(trimmed).width > maxW) { + fontPx -= 1; + ctx.font = `bold ${fontPx}px sans-serif`; + } + ctx.fillStyle = "#000000"; + ctx.textBaseline = "middle"; + ctx.fillText(fitNotesText(ctx, trimmed, maxW), r.x + 8, r.y + r.h / 2); + continue; + } + + ctx.font = "9px sans-serif"; + ctx.fillStyle = "#000000"; + ctx.textBaseline = "middle"; + ctx.fillText(fitNotesText(ctx, trimmed, r.w - 12), r.x + 8, r.y + r.h / 2); + } + ctx.restore(); +} + +/** Generated fallback avatar for the logo cell (task 4: "a coloured circle + * with the initial letter, in the style of a Google account avatar") - pure + * canvas drawing, no image loading involved. `name` is the title block's + * role:"drawnName" cell's current text; empty/missing draws a neutral grey + * circle with no letter rather than erroring or leaving the cell blank. */ +function drawTitleBlockAvatar(ctx: CanvasRenderingContext2D, r: Rect, name: string): void { + const trimmedName = name.trim(); + const initial = trimmedName ? trimmedName[0].toUpperCase() : ""; + const cx = r.x + r.w / 2; + const cy = r.y + r.h / 2; + const radius = Math.max(4, Math.min(r.w, r.h) / 2 - 4); + + ctx.save(); + ctx.fillStyle = trimmedName ? `hsl(${hashStringToHue(trimmedName)}, 55%, 45%)` : "#94a3b8"; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fill(); + if (initial) { + ctx.fillStyle = "#ffffff"; + ctx.font = `600 ${Math.max(10, radius).toFixed(1)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(initial, cx, cy + 1); + } + ctx.restore(); +} + +/** Deterministic name -> hue (0-359) for the avatar background - same name + * always gets the same color, different names are spread around the wheel. + * Exported so cad-viewer.tsx's logo-editor DOM overlay (the name+avatar + * fallback shown while editMode is on, when the canvas draws nothing - see + * drawTitleBlockAvatar's own doc comment) can render a live preview that's + * guaranteed to match what drawTitleBlockAvatar paints once edit mode + * closes, rather than a second color formula drifting out of sync with this + * one. */ +export function hashStringToHue(s: string): number { + let hash = 0; + for (let i = 0; i < s.length; i++) { + hash = (hash * 31 + s.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 360; +} + +/** Truncates `text` with an ellipsis if it's wider than `maxWidth` at the + * context's CURRENT font - used so a near-MAX_NOTE_CHARS note can never + * visually spill outside the (now much narrower, freely-draggable) + * NOTES_BLOCK_W the way it could when the block claimed the whole leftover + * strip beside the title block. `ctx.font` must already be set by the + * caller. */ +function fitNotesText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string { + if (ctx.measureText(text).width <= maxWidth) return text; + let out = text; + while (out.length > 1 && ctx.measureText(out + "…").width > maxWidth) { + out = out.slice(0, -1); + } + return out + "…"; +} + +/** + * Optional general-notes block (task: heading "NOTES", entries auto-numbered + * "1.", "2." ... consecutively downward, dashed border while editable) - + * drawn at `position` (task 3: freely draggable, so this is no longer a + * fixed rect - see defaultNotesPosition/notesBlockSize for how its rest + * position and size are derived). Font sized smaller than DIM_VALUE_FONT_PX + * so notes read as subordinate to actual dimension values, per the task. + * `editMode`, when true, draws every line's "N." prefix only, with no note + * text - direct in-document editing (cad-viewer.tsx) overlays a real DOM + * input per line (including already-committed ones, so any point is + * directly clickable/editable), so the canvas never draws text that would + * double up with what those inputs already show. A trailing blank point is + * included (below MAX_NOTES) as the next entry slot. + */ +export function drawSheetNotes( + ctx: CanvasRenderingContext2D, + notes: string[], + position: { x: number; y: number }, + editMode?: boolean, + showBorder: boolean = true, +): void { + const effectiveCount = notes.length + (editMode && notes.length < MAX_NOTES ? 1 : 0); + const { w, h } = notesBlockSize(effectiveCount); + const x = position.x + NOTES_PADDING_X_PX; + const textMaxW = w - NOTES_PADDING_X_PX * 2; + let y = position.y + NOTES_PADDING_TOP_PX; + + ctx.save(); + // The dashed border is a UI affordance (marks the block as furniture, not + // drawing content) - never part of the actual drawing, so exports omit it + // (showBorder: false) while the live editor always shows it. + if (showBorder) { + ctx.setLineDash([6, 4]); + ctx.strokeStyle = "rgba(100, 116, 139, 0.8)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(position.x, position.y, w, h); + ctx.setLineDash([]); + } + + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillStyle = "#000000"; + ctx.font = NOTES_HEADING_FONT; + ctx.fillText("NOTES", x, y); + y += NOTES_HEADING_LINE_PX; + ctx.font = NOTES_BODY_FONT; + if (editMode) { + for (let i = 0; i < effectiveCount; i++) { + ctx.fillText(`${i + 1}.`, x, y); + y += NOTES_LINE_H_PX; + } + } else { + notes.forEach((note, i) => { + ctx.fillText(`${i + 1}. ${fitNotesText(ctx, note, textMaxW)}`, x, y); + y += NOTES_LINE_H_PX; + }); + } + ctx.restore(); +} + +/** + * Draws a dimension line with inward-pointing arrowheads at both ends and, + * unless `drawLabel` is false, a text label at `labelFrac` of the way from + * a to b (default the midpoint) - either horizontal (for a width) or + * vertical (for a height). Positioned outside the part's silhouette so it + * never gets confused with real geometry. `drawLabel: false` is used for + * an ordinate cluster's shared baseline (see renderLocationCluster()), which + * carries arrows but no text of its own - each cluster member gets its own + * isolated label elsewhere instead. `labelFrac` lets a caller nudge an + * overall dimension's label off-center when the natural midpoint would + * collide with an unrelated extension line crossing the same row/column + * (see the pickLabelFrac() call sites) - the line's own geometry/endpoints + * are unaffected, only where the label sits along it. + */ +export const ARROWHEAD_LEN_PX = 8; +export const ARROWHEAD_WIDTH_PX = 3.5; + +/** + * Draws one filled arrowhead: the pointy tip sits exactly at (tipX, tipY) - + * the point being touched (an extension line's crossing, or a leader's + * feature anchor) - and the triangle widens going back along (dirX, dirY), + * a UNIT vector pointing away from the tip toward the line's other end. + * THE one arrowhead shape/size on the entire sheet: drawDimensionLine's two + * ends (linear dimensions) and drawCircularCallout's leader-to-feature end + * (diameter/radius/arc callouts - added so those leaders terminate the same + * way linear dimensions do, task: "same arrowhead style and size as linear + * dimension arrowheads") both delegate here, and so does + * sheet-interactive-render.ts's repaint (its own drawArrowAt wraps this), + * so a dragged callout's arrowhead can never drift out of sync with a + * freshly-composed one. Caller must set ctx.fillStyle first - this never + * touches stroke state, only fills the one triangle path. + */ +export function drawArrowheadAt( + ctx: CanvasRenderingContext2D, + tipX: number, + tipY: number, + dirX: number, + dirY: number, +): void { + const baseX = tipX + dirX * ARROWHEAD_LEN_PX; + const baseY = tipY + dirY * ARROWHEAD_LEN_PX; + const perpX = -dirY; + const perpY = dirX; + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.lineTo(baseX + perpX * ARROWHEAD_WIDTH_PX, baseY + perpY * ARROWHEAD_WIDTH_PX); + ctx.lineTo(baseX - perpX * ARROWHEAD_WIDTH_PX, baseY - perpY * ARROWHEAD_WIDTH_PX); + ctx.closePath(); + ctx.fill(); +} + +function drawDimensionLine( + ctx: CanvasRenderingContext2D, + orientation: "horizontal" | "vertical", + a: number, // start coordinate along the dimension axis (px) + b: number, // end coordinate along the dimension axis (px) + cross: number, // fixed coordinate on the perpendicular axis (px) + text: string, + color: string = "#1a56db", + drawLabel: boolean = true, + labelFrac: number = 0.5, +): Rect { + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = LINE_WEIGHT_DIMENSION_PX; + ctx.font = DIM_VALUE_FONT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const drawArrow = ( + tipX: number, + tipY: number, + dirX: number, + dirY: number, + ) => { + // dir points AWAY from the tip, back along the dimension line - see + // drawArrowheadAt's own doc comment for the shared shape this delegates + // to (every arrowhead on the sheet, linear or leader, is this same + // shape/size). + drawArrowheadAt(ctx, tipX, tipY, dirX, dirY); + }; + + let rect: Rect; + if (orientation === "horizontal") { + ctx.beginPath(); + ctx.moveTo(a, cross); + ctx.lineTo(b, cross); + ctx.stroke(); + drawArrow(a, cross, 1, 0); + drawArrow(b, cross, -1, 0); + const midX = a + (b - a) * labelFrac; + if (drawLabel) { + ctx.fillStyle = "#ffffff"; + const textW = ctx.measureText(text).width + 8; + rect = { x: midX - textW / 2, y: cross - DIM_LABEL_BOX_HALF_H, w: textW, h: DIM_LABEL_BOX_H }; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + ctx.fillStyle = color; + ctx.fillText(text, midX, cross); + } else { + rect = { x: midX, y: cross, w: 0, h: 0 }; + } + } else { + ctx.beginPath(); + ctx.moveTo(cross, a); + ctx.lineTo(cross, b); + ctx.stroke(); + drawArrow(cross, a, 0, 1); + drawArrow(cross, b, 0, -1); + const midY = a + (b - a) * labelFrac; + if (drawLabel) { + ctx.save(); + ctx.translate(cross, midY); + ctx.rotate(-Math.PI / 2); + ctx.fillStyle = "#ffffff"; + const textW = ctx.measureText(text).width + 8; + ctx.fillRect(-textW / 2, -DIM_LABEL_BOX_HALF_H, textW, DIM_LABEL_BOX_H); + ctx.fillStyle = color; + ctx.fillText(text, 0, 0); + ctx.restore(); + // Rect in unrotated (canvas) space: a vertical strip centered on `cross`. + rect = { x: cross - DIM_LABEL_BOX_HALF_H, y: midY - textW / 2, w: DIM_LABEL_BOX_H, h: textW }; + } else { + rect = { x: cross, y: midY, w: 0, h: 0 }; + } + } + ctx.restore(); + return rect; +} + +/** Thin connector from a feature center out to its location-dimension row/column - standard extension-line convention, deliberately understated relative to the dimension line itself. */ +function drawExtensionLine( + ctx: CanvasRenderingContext2D, + x1: number, + y1: number, + x2: number, + y2: number, +) { + ctx.save(); + ctx.strokeStyle = EXTENSION_LINE_COLOR; + ctx.lineWidth = LINE_WEIGHT_EXTENSION_PX; + ctx.setLineDash([3, 2]); + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); +} + +/** + * Standard drafting convention: an extension line never touches the part + * outline/feature point it measures FROM (`nearCoord`) - it starts + * EXTENSION_VISIBLE_GAP_PX clear of it, so it never reads as though it were + * part of the object's own geometry - and it never stops exactly at the + * dimension line it serves (`dimLineCoord`) either - it overshoots PAST it + * by EXTENSION_OVERSHOOT_PX, at the opposite end. Both offsets point the + * same direction of travel, from the feature coordinate toward and past the + * dimension line's cross coordinate, along a single axis - this is the ONE + * place that direction/both gaps are computed, reused by every extension + * line the composer draws (location chains, depth dimensions) so neither + * end can drift out of sync with the other's named constant. + */ +function extensionLineSpan( + nearCoord: number, + dimLineCoord: number, +): { startCoord: number; endCoord: number } { + const dirSign = Math.sign(dimLineCoord - nearCoord) || 1; + return { + startCoord: nearCoord + dirSign * EXTENSION_VISIBLE_GAP_PX, + endCoord: dimLineCoord + dirSign * EXTENSION_OVERSHOOT_PX, + }; +} + +/** + * Task 1, remedy (b) - "route with a single jog": computes the segment(s) + * an extension line should actually be drawn as, trying the standard + * straight path first and only detouring around an obstruction if the + * straight path genuinely crosses one. + * + * `perpAxis` is the axis the extension line travels ALONG (vertical for a + * horizontal-orientation chain's extension line, horizontal for a + * vertical-orientation chain's); `fixedCoord` is its other, fixed + * coordinate - it must land exactly on the dimension line/label this + * extension line serves and can never itself move, so only the PATH + * between the line's two ends may detour, never its endpoints. + * + * Checked only against `occupiedSegments` - every line already drawn + * earlier in this view's per-edge sequence (horizontal entries, then + * vertical, then vertical-depth - see drawCell's unified sequence doc + * comment for why a farther lane's extension line necessarily passes every + * closer lane's row on the way out, which is exactly what this exists to + * route around). A later-drawn size-callout leader independently avoids + * crossing INTO whatever is already occupied (drawCircularCallout already + * searches occupiedSegments), so checking only what's already down is + * sound for every draw order this module uses. + * + * If the straight path crosses something, jogs sideways just enough to + * clear that obstruction's own extent, hugs the offset for the short + * stretch needed to pass it, then returns to `fixedCoord` - a single + * lateral detour, standard drafting convention, not a permanent + * relocation of the line (its dimension-line/label attachment point is + * unaffected). The jogged path is re-checked against EVERY occupied + * segment before being accepted ("only accept if it's actually clean, + * don't assume" - task 1); if it's still blocked (a second, different + * obstruction in the way), falls back to the straight path rather than + * looping indefinitely - a genuinely unresolved crossing is left for + * composeA4DrawingSheet's post-render remedy pass (depth-dimension + * reassignment) and, failing that, rendered as-is: every dimension is + * always a direct line with its real value, never diverted to a fallback. + */ +function routedExtensionSegments( + perpAxis: "vertical" | "horizontal", + fixedCoord: number, + nearRaw: number, + dimLineCoord: number, + occupiedSegments: Segment[], +): Segment[] { + const { startCoord, endCoord } = extensionLineSpan(nearRaw, dimLineCoord); + const straight: Segment = + perpAxis === "vertical" + ? { x1: fixedCoord, y1: startCoord, x2: fixedCoord, y2: endCoord } + : { x1: startCoord, y1: fixedCoord, x2: endCoord, y2: fixedCoord }; + + const blocking = occupiedSegments.find((s) => segmentsIntersectSeg(straight, s)); + if (!blocking) return [straight]; + + const JOG_MARGIN_PX = 6; + const travelLo = Math.min(startCoord, endCoord); + const travelHi = Math.max(startCoord, endCoord); + const blockOtherLo = perpAxis === "vertical" ? Math.min(blocking.x1, blocking.x2) : Math.min(blocking.y1, blocking.y2); + const blockOtherHi = perpAxis === "vertical" ? Math.max(blocking.x1, blocking.x2) : Math.max(blocking.y1, blocking.y2); + const blockPerpLo = perpAxis === "vertical" ? Math.min(blocking.y1, blocking.y2) : Math.min(blocking.x1, blocking.x2); + const blockPerpHi = perpAxis === "vertical" ? Math.max(blocking.y1, blocking.y2) : Math.max(blocking.x1, blocking.x2); + + // Clamp the jog's along-travel extent to the straight path's own travel + // range, padded a small margin either side of the actual obstruction so + // the jog clears it, not just grazes it. + const kinkLo = Math.max(travelLo, blockPerpLo - JOG_MARGIN_PX); + const kinkHi = Math.min(travelHi, blockPerpHi + JOG_MARGIN_PX); + if (kinkLo >= kinkHi) return [straight]; + + // Jog to whichever side of the obstruction is the shorter detour. + const distLeft = fixedCoord - blockOtherLo; + const distRight = blockOtherHi - fixedCoord; + const jogTo = distLeft <= distRight ? blockOtherLo - JOG_MARGIN_PX : blockOtherHi + JOG_MARGIN_PX; + + const at = (perp: number, other: number): { x: number; y: number } => + perpAxis === "vertical" ? { x: other, y: perp } : { x: perp, y: other }; + + const pts = [ + at(startCoord, fixedCoord), + at(kinkLo, fixedCoord), + at(kinkLo, jogTo), + at(kinkHi, jogTo), + at(kinkHi, fixedCoord), + at(endCoord, fixedCoord), + ]; + const jogged: Segment[] = []; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i]; + const b = pts[i + 1]; + if (a.x === b.x && a.y === b.y) continue; // degenerate when a kink lands exactly on a travel endpoint + jogged.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y }); + } + + const stillBlocked = jogged.some((seg) => + occupiedSegments.some((s) => segmentsIntersectSeg(seg, s)), + ); + return stillBlocked ? [straight] : jogged; +} + +/** Draws every segment `routedExtensionSegments` returned (1 for a clean + * straight path, several for a jogged detour) with the standard extension- + * line style. */ +function drawRoutedExtensionLine( + ctx: CanvasRenderingContext2D, + segments: Segment[], +): void { + for (const s of segments) drawExtensionLine(ctx, s.x1, s.y1, s.x2, s.y2); +} + +/** + * Small perpendicular tick (a short 45-degree dash) marking one ordinate + * value's true tie-in point along a baseline - the standard ordinate- + * dimensioning mark, distinct from the arrowheads a two-point chain + * dimension line uses. + */ +function drawOrdinateTick( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + orientation: "horizontal" | "vertical", + color: string, +) { + const LEN = 5; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = LINE_WEIGHT_DIMENSION_PX; + ctx.beginPath(); + if (orientation === "horizontal") { + // Baseline runs vertically (a column) - tick crosses it at ~45deg. + ctx.moveTo(x - LEN, y - LEN); + ctx.lineTo(x + LEN, y + LEN); + } else { + ctx.moveTo(x - LEN, y - LEN); + ctx.lineTo(x + LEN, y + LEN); + } + ctx.stroke(); + ctx.restore(); +} + +/** + * Draws one isolated ordinate label: a small white-backed text tag, styled + * to match drawDimensionLine's label (same font/box), but placed freely at + * (x, y) rather than centered on a dimension line's midpoint. + */ +function drawIsolatedLabel( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + text: string, + color: string, + rotateVertical: boolean, +): Rect { + ctx.save(); + ctx.font = DIM_VALUE_FONT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + let rect: Rect; + if (rotateVertical) { + const textW = ctx.measureText(text).width + 8; + ctx.translate(x, y); + ctx.rotate(-Math.PI / 2); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(-textW / 2, -DIM_LABEL_BOX_HALF_H, textW, DIM_LABEL_BOX_H); + ctx.fillStyle = color; + ctx.fillText(text, 0, 0); + rect = { x: x - DIM_LABEL_BOX_HALF_H, y: y - textW / 2, w: DIM_LABEL_BOX_H, h: textW }; + } else { + const textW = ctx.measureText(text).width + 8; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(x - textW / 2, y - DIM_LABEL_BOX_HALF_H, textW, DIM_LABEL_BOX_H); + ctx.fillStyle = color; + ctx.fillText(text, x, y); + rect = { x: x - textW / 2, y: y - DIM_LABEL_BOX_HALF_H, w: textW, h: DIM_LABEL_BOX_H }; + } + ctx.restore(); + return rect; +} + +export type LocationChainTarget = { + featureId: string; + destCenterX: number; + destCenterY: number; + valueMm: number; +}; + +/** + * Groups location-chain targets into lanes: each unique (1-decimal-rounded) + * value gets exactly one lane/cluster, sorted ASCENDING by real-world + * magnitude - smallest nearest the datum, per standard drafting convention + * (see renderLocationCluster's doc comment for what a multi-member cluster + * renders as). Pulled out as its own step so the same grouping/ordering + * decision can be interleaved with whatever else competes for a lane on the + * same view-edge (the overall dimension, a depth dimension) into ONE shared + * sequence - see drawCell's per-edge sequence building, which is what task + * 3 (unify location/overall/axial placement) is actually about: every + * dimension line on an edge, regardless of kind, sorted into the same + * ascending-by-value list and lane-assigned together. + */ +function groupLocationTargets( + targets: LocationChainTarget[], +): LocationChainTarget[][] { + const groups = new Map(); + for (const t of targets) { + const key = t.valueMm.toFixed(1); + const g = groups.get(key); + if (g) g.push(t); + else groups.set(key, [t]); + } + return [...groups.values()].sort((a, b) => a[0].valueMm - b[0].valueMm); +} + +/** + * Renders ONE lane of a location-dimension chain (either the "horizontal" + * chain - rows below the view, dimensioning distance from the left edge - + * or the "vertical" chain - columns left of the view, dimensioning distance + * from the bottom edge) at an already-assigned cross coordinate + * (`laneCross`, see drawCell's unified per-edge sequence - this lane's + * position there is interleaved with the overall dimension's and any depth + * dimensions' own lanes on the same edge, by ascending value, not decided + * here). `allTargets` is the chain's FULL target list (not just this lane's + * own cluster), needed only so a single-member lane's label can dodge + * another lane's extension line (see the riskyPositions computation below) + * - it plays no role in this lane's own geometry. + * + * `cluster.length === 1` renders a plain two-arrow chain dimension from the + * datum. 2+ members - features that coincidentally sit at the identical + * (1-decimal-rounded) distance from the datum edge - need one of two + * different treatments depending on WHY they landed in the same lane: + * + * - Genuinely different real-world values that merely round to the same + * 1-decimal display (e.g. 40.96 and 41.04 both read "41.0"): ordinate/ + * baseline dimensioning - ONE continuous baseline from the datum out to + * the shared display value, extended a little further per extra member (a + * "jog"), each member keeping its own full extension line from its TRUE + * feature position and its own isolated label at its own tick, staggered + * by the label's own measured width so they can never touch. + * - An EXACT tie - every member sits at the identical real-world coordinate + * (checked via `exactTieEpsMm`, not the rounded display value) - gets a + * single shared dimension line and a single label instead: there is only + * one true value here, so drawing it more than once (even staggered) + * would be a duplicate, not a clarification. Every member still gets its + * own extension line in from its TRUE feature position, converging on + * that one shared dimension line/label. + */ +function renderLocationCluster( + ctx: CanvasRenderingContext2D, + orientation: "horizontal" | "vertical", + cluster: LocationChainTarget[], + laneCross: number, + datumCross: number, + allTargets: LocationChainTarget[], + view: HiddenLineViewName, + records: DimensionRecord[], + occupied: Rect[], + occupiedSegments: Segment[], + exactTieEpsMm: number = EXACT_TIE_EPS_MM, + jogPx: number = ORDINATE_JOG_PX, +): void { + if (cluster.length === 1) { + const item = cluster[0]; + const text = fmtMm(item.valueMm); + let rect: Rect; + const segs: Segment[] = []; + // A short dimension line's own label (fixed text width regardless of + // how short the line is) can be WIDER than the line itself - for a + // small value like this one, the default midpoint position can then + // reach clean past the line's own end and into another lane's + // extension-line path (which sweeps the full datum-to-lane span at + // that OTHER feature's fixed cross-axis position, passing through + // every closer lane's row along the way). Nudge off-center, same + // technique pickLabelFrac already uses for overall dimensions, + // whenever another target in this same chain risks exactly that + // crossing. + const riskyPositions = allTargets + .filter((t) => t !== item) + .map((t) => + orientation === "horizontal" ? t.destCenterX : t.destCenterY, + ); + const labelFrac = pickLabelFrac( + datumCross, + orientation === "horizontal" ? item.destCenterX : item.destCenterY, + riskyPositions, + 22, + ); + if (orientation === "horizontal") { + const routed = routedExtensionSegments( + "vertical", + item.destCenterX, + item.destCenterY, + laneCross, + occupiedSegments, + ); + drawRoutedExtensionLine(ctx, routed); + rect = drawDimensionLine( + ctx, + "horizontal", + datumCross, + item.destCenterX, + laneCross, + text, + LOCATION_DIM_COLOR, + true, + labelFrac, + ); + segs.push( + ...routed, + { + x1: datumCross, + y1: laneCross, + x2: item.destCenterX, + y2: laneCross, + }, + ); + } else { + const routed = routedExtensionSegments( + "horizontal", + item.destCenterY, + item.destCenterX, + laneCross, + occupiedSegments, + ); + drawRoutedExtensionLine(ctx, routed); + rect = drawDimensionLine( + ctx, + "vertical", + datumCross, + item.destCenterY, + laneCross, + text, + LOCATION_DIM_COLOR, + true, + labelFrac, + ); + segs.push( + ...routed, + { + x1: laneCross, + y1: datumCross, + x2: laneCross, + y2: item.destCenterY, + }, + ); + } + occupied.push(rect); + occupiedSegments.push(...segs); + records.push({ + id: `${view}-loc-${orientation}-${item.featureId}`, + view, + kind: "location", + axis: orientation, + featureIds: [item.featureId], + valueMm: item.valueMm, + text, + lineSegments: segs, + labelRect: rect, + }); + return; + } + + // Exact-tie path: every member of this lane sits at the SAME real-world + // coordinate (not merely the same rounded display value) - one true + // value, so render it once: a single dimension line/label like the + // single-item path above, plus one extension line per member (each + // from that member's own TRUE position) converging on it. Since the + // dimensioned axis coordinate is identical for all of them, every + // member's own destCenterX (horizontal chain) / destCenterY (vertical + // chain) is already the same point - no jog/stagger needed or wanted. + const firstValueMm = cluster[0].valueMm; + const allExactlyEqual = cluster.every( + (c) => Math.abs(c.valueMm - firstValueMm) < exactTieEpsMm, + ); + if (allExactlyEqual) { + const text = fmtMm(firstValueMm); + const sharedPos = + orientation === "horizontal" + ? cluster[0].destCenterX + : cluster[0].destCenterY; + let rect: Rect; + const dimLineSeg: Segment = + orientation === "horizontal" + ? { x1: datumCross, y1: laneCross, x2: sharedPos, y2: laneCross } + : { x1: laneCross, y1: datumCross, x2: laneCross, y2: sharedPos }; + if (orientation === "horizontal") { + rect = drawDimensionLine( + ctx, + "horizontal", + datumCross, + sharedPos, + laneCross, + text, + LOCATION_DIM_COLOR, + ); + } else { + rect = drawDimensionLine( + ctx, + "vertical", + datumCross, + sharedPos, + laneCross, + text, + LOCATION_DIM_COLOR, + ); + } + occupied.push(rect); + occupiedSegments.push(dimLineSeg); + const allSegs: Segment[] = [dimLineSeg]; + for (const item of cluster) { + let segs: Segment[]; + if (orientation === "horizontal") { + segs = routedExtensionSegments( + "vertical", + item.destCenterX, + item.destCenterY, + laneCross, + occupiedSegments, + ); + } else { + segs = routedExtensionSegments( + "horizontal", + item.destCenterY, + item.destCenterX, + laneCross, + occupiedSegments, + ); + } + drawRoutedExtensionLine(ctx, segs); + occupiedSegments.push(...segs); + allSegs.push(...segs); + } + records.push({ + id: `${view}-loc-${orientation}-shared-${cluster.map((c) => c.featureId).join("-")}`, + view, + kind: "location-shared", + axis: orientation, + featureIds: cluster.map((c) => c.featureId), + valueMm: firstValueMm, + text, + lineSegments: allSegs, + labelRect: rect, + }); + return; + } + + // Ordinate/baseline path: 2+ features share this lane's DISPLAY value + // but have genuinely different real-world coordinates. + const sharedValueMm = cluster[0].valueMm; + const text = fmtMm(sharedValueMm); + const n = cluster.length; + // The jog/stagger happens along the VALUE axis (X for the horizontal + // chain, Y for the vertical chain) starting from the features' own true + // shared position there - NOT along laneCross, which is the chain's + // fixed PERPENDICULAR row/column coordinate and has nothing to do with + // the value being measured. + const sharedPos = + orientation === "horizontal" + ? cluster[0].destCenterX + : cluster[0].destCenterY; + // "Away from the datum" is +X for the horizontal chain (values grow + // rightward from the left-edge datum) but -Y for the vertical chain + // (values grow UPWARD, i.e. decreasing screen-Y, from the bottom-edge + // datum). + const jogSign = orientation === "horizontal" ? 1 : -1; + // Jog step sized from the label's OWN measured extent (not a flat + // constant) - ORDINATE_JOG_PX is only a floor. A fixed 24px step looked + // fine on paper but every label here is an 18x39px box (monospace + // "NN.N", rotated 90deg for the vertical chain), so a flat 24px step + // left ~15px of unavoidable overlap between adjacent ticks; sizing the + // step from the actual text width guarantees clearance regardless of + // how wide a given value's digits happen to be. + ctx.save(); + ctx.font = DIM_VALUE_FONT; + const labelExtentPx = ctx.measureText(text).width + 8; + ctx.restore(); + const jogStep = Math.max(jogPx, labelExtentPx + 6); + const farJog = sharedPos + jogSign * (n - 1) * jogStep; + + // Baseline: one continuous arrowed-at-datum-end line from the datum to + // the farthest jog. No center label - each member gets its own below. + // Not attached to any record of its own - it's folded into the FIRST + // (i===0) member's record below, since every rendered primitive must + // belong to some record for computeViewContentBounds (and every + // sheet-wide geometric check built on it) to actually see it. + let baselineSeg: Segment; + if (orientation === "horizontal") { + const baselineRect = drawDimensionLine( + ctx, + "horizontal", + datumCross, + farJog, + laneCross, + "", + LOCATION_DIM_COLOR, + false, + ); + occupied.push(baselineRect); + baselineSeg = { x1: datumCross, y1: laneCross, x2: farJog, y2: laneCross }; + } else { + const baselineRect = drawDimensionLine( + ctx, + "vertical", + datumCross, + farJog, + laneCross, + "", + LOCATION_DIM_COLOR, + false, + ); + occupied.push(baselineRect); + baselineSeg = { x1: laneCross, y1: datumCross, x2: laneCross, y2: farJog }; + } + occupiedSegments.push(baselineSeg); + + const sorted = [...cluster].sort((p, q) => + orientation === "horizontal" + ? p.destCenterY - q.destCenterY + : p.destCenterX - q.destCenterX, + ); + sorted.forEach((item, i) => { + const tickPos = sharedPos + jogSign * i * jogStep; + const newSegs: Segment[] = []; + let labelRect: Rect; + if (orientation === "horizontal") { + const routed = routedExtensionSegments( + "vertical", + item.destCenterX, + item.destCenterY, + laneCross, + occupiedSegments, + ); + drawRoutedExtensionLine(ctx, routed); + newSegs.push(...routed); + if (i > 0) { + drawExtensionLine( + ctx, + item.destCenterX, + laneCross, + tickPos, + laneCross, + ); + newSegs.push({ + x1: item.destCenterX, + y1: laneCross, + x2: tickPos, + y2: laneCross, + }); + } + drawOrdinateTick( + ctx, + tickPos, + laneCross, + "vertical", + LOCATION_DIM_COLOR, + ); + labelRect = drawIsolatedLabel( + ctx, + tickPos, + laneCross + 16, + text, + LOCATION_DIM_COLOR, + false, + ); + } else { + const routed = routedExtensionSegments( + "horizontal", + item.destCenterY, + item.destCenterX, + laneCross, + occupiedSegments, + ); + drawRoutedExtensionLine(ctx, routed); + newSegs.push(...routed); + if (i > 0) { + drawExtensionLine( + ctx, + laneCross, + item.destCenterY, + laneCross, + tickPos, + ); + newSegs.push({ + x1: laneCross, + y1: item.destCenterY, + x2: laneCross, + y2: tickPos, + }); + } + drawOrdinateTick( + ctx, + laneCross, + tickPos, + "horizontal", + LOCATION_DIM_COLOR, + ); + labelRect = drawIsolatedLabel( + ctx, + laneCross - 24, + tickPos, + text, + LOCATION_DIM_COLOR, + true, + ); + } + occupied.push(labelRect); + occupiedSegments.push(...newSegs); + records.push({ + id: `${view}-loc-${orientation}-${item.featureId}`, + view, + kind: "location-ordinate", + axis: orientation, + featureIds: [item.featureId], + valueMm: sharedValueMm, + text, + lineSegments: i === 0 ? [baselineSeg, ...newSegs] : newSegs, + labelRect, + }); + }); +} + +/** + * Renders the overall envelope (width/height) dimension for one view-edge, + * at an already-assigned lane position (`laneCross`) in that edge's unified + * sequence (see drawCell's per-edge entry list, task 3) - this dimension + * line's own arrows terminate directly at the part's own extremities + * (partStart/partEnd), the standard alternate convention for a full-span + * envelope dimension, so unlike a location/depth entry it has no extension + * line of its own to draw. `riskyPositions` lets the label dodge a + * location-chain extension line that happens to cross this same row/column + * (see pickLabelFrac's doc comment) exactly as it did before this dimension + * competed for a shared lane. + */ +function renderOverallDimension( + ctx: CanvasRenderingContext2D, + orientation: "horizontal" | "vertical", + partStart: number, + partEnd: number, + laneCross: number, + valueMm: number, + riskyPositions: number[], + view: HiddenLineViewName, + records: DimensionRecord[], + occupied: Rect[], + occupiedSegments: Segment[], +): void { + const labelFrac = pickLabelFrac(partStart, partEnd, riskyPositions, 22); + const text = fmtMm(valueMm); + const seg: Segment = + orientation === "horizontal" + ? { x1: partStart, y1: laneCross, x2: partEnd, y2: laneCross } + : { x1: laneCross, y1: partStart, x2: laneCross, y2: partEnd }; + const rect = drawDimensionLine( + ctx, + orientation, + partStart, + partEnd, + laneCross, + text, + "#1a56db", + true, + labelFrac, + ); + occupied.push(rect); + occupiedSegments.push(seg); + records.push({ + id: `${view}-overall-${orientation === "horizontal" ? "width" : "height"}`, + view, + kind: "overall", + axis: orientation, + featureIds: [], + valueMm, + text, + lineSegments: [seg], + labelRect: rect, + }); +} + +function angularDelta(a: number, b: number): number { + let d = Math.abs(a - b) % (Math.PI * 2); + if (d > Math.PI) d = Math.PI * 2 - d; + return d; +} + +/** + * Draws a diameter (circle) or radius (arc) callout: a leader line from the + * feature's anchor point to a text label, placed via full-circumference- + * aware routing around the view's shared center - the standard drafting + * pattern for a radial cluster of features (a bolt-circle pattern is the + * clearest failure case for anything less: several holes evenly spaced + * around one center, each wanting to label "straight out"). + * + * For each feature: try its own TRUE radial direction (straight out from + * viewCenter through the anchor) first, at the shortest clear radius in + * that direction. If that direction is already CLAIMED (within + * ANGLE_TOLERANCE_RAD of a callout already placed on this view - + * `claimedAngles`, shared/mutated across every callout on the view), skip + * straight-line radius growth entirely and instead shift ANGULARLY around + * the full circle - nearest unclaimed direction first, searching both ways + * outward from the natural angle - until an unclaimed direction is found + * whose shortest-radius candidate is actually clear. "Clear" means the + * label rect doesn't overlap the silhouette or any already-placed rect, + * AND the leader line itself doesn't cross any already-placed rect or any + * other already-drawn leader/dimension line segment - checked directly + * against every other line on the view, not just label bounding boxes. + * + * `keepClearBounds` (see keepClearBoundsForView) biases this search away + * from candidate rects that cross into a NEIGHBORING view's side of the + * sheet's fixed third-angle arrangement: this function only ever sees its + * own view's occupied rects/segments, so nothing here would otherwise stop + * a label from swinging further out than any neighboring view's own + * reserved space, especially for a crowded corner (several coaxial + * features, or several fillets that are genuinely close together) at a + * small drafting ratio, where the label text's fixed pixel size dominates + * an ever-shrinking silhouette. Checked against the actual candidate RECT + * (not just its anchor angle/direction), since a wide "NX.0/NY.0 STEP" + * label can still cross a boundary its own anchor point technically + * doesn't. The search tries every in-bounds candidate first (same tiered + * fallback as always, just skipping out-of-bounds candidates); only if + * THAT sweep never even reaches tier 2 does it retry allowing out-of- + * bounds candidates too, so a genuinely single-sided feature (nothing + * clear anywhere else) still gets a real placement instead of no callout + * at all - crossing into a neighbor's space is a last resort, never a + * first choice. + */ +/** True iff `angle` (any radians) falls within `sector` (radians, [0,2π)- + * normalized min/max - `min > max` means the sector wraps past 0). */ +function angleInSector(angle: number, sector: { min: number; max: number }): boolean { + const TAU = Math.PI * 2; + const norm = (a: number) => ((a % TAU) + TAU) % TAU; + const a = norm(angle); + const lo = norm(sector.min); + const hi = norm(sector.max); + return lo <= hi ? a >= lo && a <= hi : a >= lo || a <= hi; +} + +/** Nudges `angle` to the nearest EDGE of `sector` if it falls outside it - + * used only for drawCircularCallout's absolute-last-resort seed, so even + * that never violates a caller-assigned sector's guarantee. */ +function clampAngleToSector(angle: number, sector: { min: number; max: number }): number { + if (angleInSector(angle, sector)) return angle; + return angularDelta(angle, sector.min) <= angularDelta(angle, sector.max) + ? sector.min + : sector.max; +} + +// --- Leader/landing geometry (diameter/radius/arc callouts) ----------- + +/** Short horizontal shoulder length past a leader's elbow - the standard + * drafting "landing" a size callout's value text sits on, distinct from the + * angled leader itself. */ +export const LEADER_LANDING_LEN_PX = 14; +const LEADER_TEXT_GAP_PX = 3; +// Grown alongside CALLOUT_VALUE_FONT below (was 18, for the prior 14px +// callout text) so the label box stays tall enough for the larger glyphs. +const LEADER_LABEL_H = 21; +// Size (diameter/radius/arc) callout text style - shared by +// measureCalloutTextWidth (below, used by both compose-time placement and +// sheet-interactive-render.ts's drag geometry via buildLeaderLanding) and +// drawCircularCallout's actual draw call, plus sheet-interactive-render.ts's +// repaint of the same labelRect, so every place that measures or draws this +// text always agrees on its size. Bumped a second time (from 14px) alongside +// DIM_VALUE_FONT_PX (task: "increase dimension-value text size further"). +export const CALLOUT_VALUE_FONT = "17px sans-serif"; + +let measureCtx: CanvasRenderingContext2D | null = null; +function getMeasureCtx(): CanvasRenderingContext2D { + if (!measureCtx) { + const c = document.createElement("canvas"); + const got = c.getContext("2d"); + if (!got) { + throw new Error( + "buildLeaderLanding: could not get 2d context for text measurement", + ); + } + measureCtx = got; + } + return measureCtx; +} + +/** Real-world text width for a callout's value label, backed by a dedicated + * offscreen canvas rather than whatever ctx happens to be live - so + * buildLeaderLanding's geometry can be recomputed identically during an + * interactive drag (sheet-interactive-render.ts), which has no compose-time + * ctx of its own. */ +export function measureCalloutTextWidth(text: string): number { + const ctx = getMeasureCtx(); + ctx.font = CALLOUT_VALUE_FONT; + return ctx.measureText(text).width; +} + +export type LeaderLanding = { + leaderSegment: Segment; + landingSegment: Segment; + labelRect: Rect; +}; + +/** + * Standard drafting leader form for a diameter/radius/arc callout: an + * angled leader from the fixed feature anchor to a free elbow point, then a + * short HORIZONTAL landing (shoulder) the value text sits on - flipped to + * whichever side keeps the text reading away from the part, i.e. continuing + * the leader's own left/right trend past the elbow (elbow right of anchor -> + * landing extends further right, text left-aligned starting past it; elbow + * left of anchor -> landing extends further left, text right-aligned ending + * just before it) rather than ever doubling back toward the anchor. Text + * itself always renders left-to-right and stays horizontal regardless of the + * leader's own angle - only the label rect's SIDE of the landing flips. + * + * ONE function, every caller that places or repaints a circular callout uses + * it - drawCircularCallout's placement search (compose time) and + * sheet-interactive-render.ts's drag/repaint (interactive time) - so the + * searched-for-clearance shape and the actually-drawn shape can never + * disagree, and auto-placed and user-dragged callouts render identically + * (same function, same geometry rule either way). + */ +export function buildLeaderLanding( + anchorX: number, + anchorY: number, + elbowX: number, + elbowY: number, + text: string, +): LeaderLanding { + const textW = measureCalloutTextWidth(text) + 10; + const direction = elbowX >= anchorX ? 1 : -1; + const landingEndX = elbowX + direction * LEADER_LANDING_LEN_PX; + const labelRect: Rect = + direction === 1 + ? { + x: landingEndX + LEADER_TEXT_GAP_PX, + y: elbowY - LEADER_LABEL_H / 2, + w: textW, + h: LEADER_LABEL_H, + } + : { + x: landingEndX - LEADER_TEXT_GAP_PX - textW, + y: elbowY - LEADER_LABEL_H / 2, + w: textW, + h: LEADER_LABEL_H, + }; + return { + leaderSegment: { x1: anchorX, y1: anchorY, x2: elbowX, y2: elbowY }, + landingSegment: { x1: elbowX, y1: elbowY, x2: landingEndX, y2: elbowY }, + labelRect, + }; +} + +function drawCircularCallout( + ctx: CanvasRenderingContext2D, + anchorX: number, + anchorY: number, + viewCenterX: number, + viewCenterY: number, + text: string, + occupied: Rect[], + occupiedSegments: Segment[], + silhouetteRect: Rect, + claimedAngles: number[], + keepClearBounds: CalloutKeepClearBound[] = [], + // Task 2: when set, this callout's ENTIRE angular search - including its + // absolute-last-resort seed - is confined inside this sector (radians, + // see angleInSector). Set by drawCell for a member of a coaxial group + // (2+ size-callout groups sharing one center point) so groups never + // search the same open angular space and compete - see the sector- + // partitioning block in drawCell's doc comment for how sectors are + // assigned. null (the default) preserves the prior full-360deg-sweep + // behavior for every callout that isn't part of such a group. + allowedSector: { min: number; max: number } | null = null, +): { labelRect: Rect; leaderSegment: Segment; landingSegment: Segment } { + const naturalAngleRaw = Math.atan2(anchorY - viewCenterY, anchorX - viewCenterX); + const naturalAngle = + allowedSector && !angleInSector(naturalAngleRaw, allowedSector) + ? clampAngleToSector(naturalAngleRaw, allowedSector) + : naturalAngleRaw; + + const RADIUS_STEP = 16; + const MAX_RADIUS_RINGS = 20; + const ANGLE_TOLERANCE_RAD = (12 * Math.PI) / 180; + const ANGLE_STEP_DEG = 10; + // Full-circle sweep, nearest-to-natural-direction first: 0, then + // alternating +step/-step outward to 180° - so a feature only shifts as + // far around the circle as it actually needs to, preferring to stay near + // its own true radial direction. + const angleSweepDeg: number[] = [0]; + for (let d = ANGLE_STEP_DEG; d <= 180; d += ANGLE_STEP_DEG) + angleSweepDeg.push(d, -d); + const isOutOfBounds = (rect: Rect) => + keepClearBounds.some((b) => + b.axis === "x" + ? b.direction === 1 + ? rect.x + rect.w > b.limitPx + : rect.x < b.limitPx + : b.direction === 1 + ? rect.y + rect.h > b.limitPx + : rect.y < b.limitPx, + ); + + const startRadiusAt = (angle: number) => { + // Never start inside the part's own silhouette - for a feature well + // inside a large outline (e.g. a bore near a plate's middle), a small + // fixed offset from the anchor would still land inside it. Walk out to + // where THIS angle's ray exits the silhouette rect first (direction- + // dependent for a non-square silhouette), then add clearance. + const dist = rayExitDistance( + anchorX, + anchorY, + Math.cos(angle), + Math.sin(angle), + silhouetteRect, + ); + return Math.max(30, dist + 14); + }; + const candidateAt = (radius: number, angle: number) => { + const elbowX = anchorX + Math.cos(angle) * radius; + const elbowY = anchorY + Math.sin(angle) * radius; + const landing = buildLeaderLanding(anchorX, anchorY, elbowX, elbowY, text); + return { elbowX, elbowY, rect: landing.labelRect, landing }; + }; + const clearsSilhouette = (rect: Rect) => !rectsOverlap(rect, silhouetteRect); + const clearsLines = ( + elbowX: number, + elbowY: number, + rect: Rect, + landingSegment: Segment, + ) => + !occupiedSegments.some((s) => segmentRectCollide(s, rect)) && + !occupied.some((r) => + segmentIntersectsRect(anchorX, anchorY, elbowX, elbowY, r), + ) && + !occupiedSegments.some((s) => + segmentsIntersect( + anchorX, + anchorY, + elbowX, + elbowY, + s.x1, + s.y1, + s.x2, + s.y2, + ), + ) && + !occupied.some((r) => segmentRectCollide(landingSegment, r)) && + !occupiedSegments.some((s) => segmentsIntersectSeg(landingSegment, s)); + const clearsLabels = (rect: Rect) => + !occupied.some((r) => rectsOverlap(rect, r)); + const isFree = ( + elbowX: number, + elbowY: number, + rect: Rect, + landingSegment: Segment, + ) => + clearsSilhouette(rect) && + clearsLabels(rect) && + clearsLines(elbowX, elbowY, rect, landingSegment); + + // Tiered fallback for a genuinely crowded corner (more features wanting + // labels than the valid outward arc has claimable room for) - tries to + // give up the LEAST-bad thing first if a fully free spot (tier 3) is + // never found anywhere in the sweep: + // tier 3: fully free - no overlap, no crossing. + // tier 2: clear of the silhouette AND of every line (no dimension/ + // leader line actually crosses the label or this leader) - may + // overlap another label's box, the least visually confusing kind of + // residual collision (still two readable, if adjacent, labels). + // tier 1: at least clear of the part's own silhouette. + // tier 0: the absolute last resort (the natural, possibly-claimed, + // direction) - only reached if literally nothing in the sweep ever + // cleared even the silhouette. + let best = candidateAt(startRadiusAt(naturalAngle), naturalAngle); + let bestAngle = naturalAngle; + let bestTier = 0; + // Runs the same tiered sweep over the full angle list, updating best/ + // bestAngle/bestTier in place. When `respectBounds` is set, any + // candidate keepClearBounds would flag as out-of-bounds is skipped + // entirely (never even considered for tier 1) rather than merely + // deprioritized - see this function's doc comment. Returns true iff it + // hit tier 3 (fully free), the only outcome that should stop the search + // early. + const runSweep = (respectBounds: boolean, respectSector: boolean, maxRings: number): boolean => { + for (const deg of angleSweepDeg) { + const angle = naturalAngle + (deg * Math.PI) / 180; + if (respectSector && allowedSector && !angleInSector(angle, allowedSector)) continue; + if (claimedAngles.some((a) => angularDelta(a, angle) < ANGLE_TOLERANCE_RAD)) + continue; + const start = startRadiusAt(angle); + for (let ring = 0; ring < maxRings; ring++) { + const candidate = candidateAt(start + ring * RADIUS_STEP, angle); + if (respectBounds && isOutOfBounds(candidate.rect)) continue; + if ( + isFree( + candidate.elbowX, + candidate.elbowY, + candidate.rect, + candidate.landing.landingSegment, + ) + ) { + best = candidate; + bestAngle = angle; + bestTier = 3; + return true; + } + const silhouetteOk = clearsSilhouette(candidate.rect); + if ( + bestTier < 2 && + silhouetteOk && + clearsLines( + candidate.elbowX, + candidate.elbowY, + candidate.rect, + candidate.landing.landingSegment, + ) + ) { + best = candidate; + bestAngle = angle; + bestTier = 2; + } else if (bestTier < 1 && silhouetteOk) { + best = candidate; + bestAngle = angle; + bestTier = 1; + } + } + } + return false; + }; + // Phase 1: only candidates that stay clear of a neighboring view's side + // of the sheet AND (for a coaxial-group member) inside its assigned + // sector - the common, successful case, giving both guarantees at once. + // If that's not enough, phase 2 relaxes the SECTOR first, still + // respecting keepClearBounds - widening a coaxial group's own search is + // preferable to crossing into a neighboring view's space, the same + // "crossing into a neighbor is a last resort" priority keepClearBounds + // already embodies elsewhere, now extended to rank ahead of the sector + // guarantee too. Only phase 3 relaxes bounds (sector still respected), + // and only phase 4 gives up both. + // + // Both sector-respecting phases (1 and 3) use a SMALLER ring budget + // (SECTOR_MAX_RADIUS_RINGS) than an unrestricted search would: a narrow + // sector (many groups sharing one center - see drawCell's sector- + // partitioning block) can have no usable room anywhere near the part at + // all, and letting the ring search run all the way out to + // MAX_RADIUS_RINGS hunting for one degrades into an arbitrarily large, + // scale-independent placement (hundreds of px, dwarfing an already- + // shrunk part at a reduced drafting ratio) that then makes the sheet- + // wide scale search believe NO ratio ever fits. The sector-ignoring + // phases (2 and 4, full ring budget restored) are what actually resolves + // a genuinely narrow sector - the angular-partitioning guarantee (task + // 2) is for the space every group has a REALISTIC shot at, not an + // unconditional promise that overrides a sector with no room in it - + // same "least-bad over no bound at all" philosophy the tier system + // itself already embodies. + const SECTOR_MAX_RADIUS_RINGS = 6; + // Only a sector-bearing call (a coaxial-group member) is affected by the + // smaller budget above - every other callout (the overwhelming majority) + // gets the exact same full-budget, two-phase search this function always + // ran, byte-for-byte unchanged. + const phaseSectorMaxRings = allowedSector ? SECTOR_MAX_RADIUS_RINGS : MAX_RADIUS_RINGS; + if (!runSweep(true, true, phaseSectorMaxRings) && bestTier < 2) { + if (!runSweep(true, false, MAX_RADIUS_RINGS) && bestTier < 2) { + if (!runSweep(false, true, phaseSectorMaxRings) && bestTier < 2) { + runSweep(false, false, MAX_RADIUS_RINGS); + } + } + } + claimedAngles.push(bestAngle); + const { elbowX, elbowY, rect, landing } = best; + + ctx.save(); + ctx.font = CALLOUT_VALUE_FONT; + + ctx.strokeStyle = "#1a56db"; + ctx.fillStyle = "#1a56db"; + ctx.lineWidth = LINE_WEIGHT_LEADER_PX; + ctx.beginPath(); + ctx.moveTo(anchorX, anchorY); + ctx.lineTo(elbowX, elbowY); + ctx.lineTo(landing.landingSegment.x2, landing.landingSegment.y2); + ctx.stroke(); + // Arrowhead where the leader meets the feature (task: "circular/arc + // callout leaders must terminate in a proper arrowhead ... touch the + // circle/arc edge cleanly") - tip exactly at anchorX/Y (the point already + // ON the circle/arc, same coordinate the leader stroke above ends at, so + // it can never overshoot), pointing back along the leader toward the + // elbow. Same shared shape/size as a linear dimension's own arrowheads + // (drawArrowheadAt/drawDimensionLine) - was a small dot before this task. + { + const dx = anchorX - elbowX; + const dy = anchorY - elbowY; + const len = Math.hypot(dx, dy) || 1; + drawArrowheadAt(ctx, anchorX, anchorY, dx / len, dy / len); + } + + ctx.fillStyle = "#ffffff"; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + ctx.fillStyle = "#1a56db"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText(text, rect.x + 4, rect.y + rect.h / 2); + ctx.restore(); + + occupied.push(rect); + occupiedSegments.push(landing.leaderSegment, landing.landingSegment); + return { + labelRect: rect, + leaderSegment: landing.leaderSegment, + landingSegment: landing.landingSegment, + }; +} + +function fmtMm(mm: number): string { + return `${mm.toFixed(1)}`; +} + +/** + * Picks where along a dimension's line (as a fraction from a to b, usually + * but not necessarily within 0-1) its label should sit. The natural/ + * default spot is the midpoint, but a location-chain extension line can + * legitimately need to cross this same row/column at whatever height/ + * position its feature truly sits at - for a feature that happens to sit + * exactly at the part's midpoint (e.g. a round part's own OD/bore, always + * centered), that's the SAME point the overall dimension's label would + * naturally occupy. Rather than leave that as an unconditional collision, + * try a few alternate fractions and use the first one that clears every + * known extension-line crossing by `marginPx`. The last two candidates sit + * just outside the line itself (a-side and b-side) - only reachable when + * every in-between fraction fails, which happens for a genuinely SHORT + * line whose fixed-width text label is wider than the line itself (a tiny + * location value like "4.0mm" next to another close one): no position + * along such a short line can clear a same-width neighbor, so the label + * has to step outside the line's own span instead, same as the standard + * drafting convention for a dimension too short to hold its own text. + * Falls back to the midpoint if nothing clears at all (rare - only matters + * for the label rect, not the line itself, so a residual near-miss here is + * a minor legibility nit, not a correctness bug). + */ +function pickLabelFrac( + a: number, + b: number, + riskyCrossPositions: number[], + marginPx: number, +): number { + const candidates = [0.5, 0.25, 0.75, 0.15, 0.85, -0.6, 1.6]; + for (const frac of candidates) { + const pos = a + (b - a) * frac; + if (!riskyCrossPositions.some((p) => Math.abs(p - pos) < marginPx)) + return frac; + } + return 0.5; +} + +/** Which screen orientation a stepped hole's depth dimension should render + * as - whichever axis its near/far annotation points actually differ along + * more. Computed once, up front (before lane assignment), so the caller can + * bucket this depth entry into the correct view-edge's shared sequence (see + * drawCell) BEFORE any lane position is decided. */ +function depthDimensionOrientation( + destNearX: number, + destNearY: number, + destFarX: number, + destFarY: number, +): "horizontal" | "vertical" { + return Math.abs(destNearY - destFarY) >= Math.abs(destNearX - destFarX) + ? "vertical" + : "horizontal"; +} + +/** + * Draws a stepped hole's depth dimension - the near/far points come from + * viewer.ts's computeAxialDepthAnnotationsForView, already projected into + * THIS view's own capture pixel space (rescaled to sheet space the same way + * every other annotation on this view is). `orientation` (see + * depthDimensionOrientation) and `laneCross` (this entry's assigned + * position in the unified per-edge sequence - see drawCell) are both + * decided by the caller, not here: a depth dimension is just one more kind + * of entry sharing its edge's ONE ordered lane sequence with the overall + * dimension and the location chain, per task 3 - it no longer gets its own + * independent offset. The dimension line/label is routed OUTSIDE the view's + * own silhouette via extension-line jogs (each with the same visible-gap- + * then-overshoot span as every other extension line, see + * extensionLineSpan) - a small fixed offset from the profile isn't enough + * here since this view (e.g. Top, a wide-but-short strip) can be thinner + * than the offset in one axis, leaving the label sitting inside the outline + * instead of clear of it. + */ +function renderDepthDimension( + ctx: CanvasRenderingContext2D, + a: HiddenLineAxialDepthAnnotation, + destNearX: number, + destNearY: number, + destFarX: number, + destFarY: number, + orientation: "horizontal" | "vertical", + laneCross: number, + view: HiddenLineViewName, + occupied: Rect[], + occupiedSegments: Segment[], + records: DimensionRecord[], +) { + const text = fmtMm(a.depthMm); + let rect: Rect; + const segs: Segment[] = []; + if (orientation === "vertical") { + const nearSegs = routedExtensionSegments("horizontal", destNearY, destNearX, laneCross, occupiedSegments); + drawRoutedExtensionLine(ctx, nearSegs); + occupiedSegments.push(...nearSegs); + const farSegs = routedExtensionSegments("horizontal", destFarY, destFarX, laneCross, occupiedSegments); + drawRoutedExtensionLine(ctx, farSegs); + rect = drawDimensionLine( + ctx, + "vertical", + destNearY, + destFarY, + laneCross, + text, + LOCATION_DIM_COLOR, + ); + segs.push( + ...nearSegs, + ...farSegs, + { x1: laneCross, y1: destNearY, x2: laneCross, y2: destFarY }, + ); + } else { + const nearSegs = routedExtensionSegments("vertical", destNearX, destNearY, laneCross, occupiedSegments); + drawRoutedExtensionLine(ctx, nearSegs); + occupiedSegments.push(...nearSegs); + const farSegs = routedExtensionSegments("vertical", destFarX, destFarY, laneCross, occupiedSegments); + drawRoutedExtensionLine(ctx, farSegs); + rect = drawDimensionLine( + ctx, + "horizontal", + destNearX, + destFarX, + laneCross, + text, + LOCATION_DIM_COLOR, + ); + segs.push( + ...nearSegs, + ...farSegs, + { x1: destNearX, y1: laneCross, x2: destFarX, y2: laneCross }, + ); + } + occupied.push(rect); + occupiedSegments.push(...segs); + records.push({ + id: `${view}-depth-${a.featureId}`, + view, + kind: "depth", + axis: orientation, + featureIds: [a.featureId], + valueMm: a.depthMm, + text, + lineSegments: segs, + labelRect: rect, + }); +} + +type DepthTrialEntry = { + a: HiddenLineAxialDepthAnnotation; + destNearX: number; + destNearY: number; + destFarX: number; + destFarY: number; +}; + +/** + * Task 3: real geometric check for whether two depth dimensions, lane- + * assigned independently at standard spacing with `inner` in the closer + * lane and `outer` in the farther one (see drawCell's unified per-edge + * sequence - "closer"/"farther" meaning smaller/larger depthMm, the sort + * key every depth sequence already uses), would have `outer`'s own + * extension lines cross `inner`'s dimension line on their way out. + * + * Depth's displayed value (how deep a step is) has no relation to its + * physical near/far screen position - unlike a location dimension, whose + * value IS its distance from the datum, so two depth entries sorted into + * adjacent lanes by ascending depthMm can easily have physical positions + * in the OPPOSITE order. That's exactly when `outer`'s own fixed-position + * extension line has to cross `inner`'s dimension-line span on its way + * past `inner`'s row - a crossing no amount of EXTRA lane spacing can ever + * avoid, since neither entry's own physical (value-axis) coordinate + * changes with how much spacing is used, only how far apart their ROWS + * are - which is the real, spacing-independent condition task 3 asks for + * ("would offsetting them by PARALLEL_DIM_SPACING_MM still leave their + * lines crossing"): tested here with two arbitrary, widely-separated + * nominal rows, since the outcome provably doesn't depend on which two are + * chosen. + */ +function wouldDepthEntriesCross( + inner: DepthTrialEntry, + outer: DepthTrialEntry, + orientation: "horizontal" | "vertical", +): boolean { + const INNER_CROSS = 0; + const OUTER_CROSS = 1000; + const innerDimLine: Segment = + orientation === "horizontal" + ? { + x1: Math.min(inner.destNearX, inner.destFarX), + y1: INNER_CROSS, + x2: Math.max(inner.destNearX, inner.destFarX), + y2: INNER_CROSS, + } + : { + x1: INNER_CROSS, + y1: Math.min(inner.destNearY, inner.destFarY), + x2: INNER_CROSS, + y2: Math.max(inner.destNearY, inner.destFarY), + }; + const outerExtSegs: Segment[] = + orientation === "horizontal" + ? [outer.destNearX, outer.destFarX].map((x, i) => { + const y = i === 0 ? outer.destNearY : outer.destFarY; + const s = extensionLineSpan(y, OUTER_CROSS); + return { x1: x, y1: s.startCoord, x2: x, y2: s.endCoord }; + }) + : [outer.destNearY, outer.destFarY].map((y, i) => { + const x = i === 0 ? outer.destNearX : outer.destFarX; + const s = extensionLineSpan(x, OUTER_CROSS); + return { x1: s.startCoord, y1: y, x2: s.endCoord, y2: y }; + }); + return outerExtSegs.some((s) => segmentsIntersectSeg(innerDimLine, s)); +} + +/** + * Task 3: partitions a view-edge's depth entries into groups - most groups + * singletons (rendered exactly as before, via renderDepthDimension), but + * 2+ adjacent (by ascending depthMm) entries merge into one group whenever + * wouldDepthEntriesCross says standard lane-offset could never keep them + * clean (see its doc comment) - checked against EVERY member already in + * the current group, not just the last, since a 3rd entry might clear the + * 2nd but still cross the 1st. + */ +function mergeTightDepthClusters( + entries: DepthTrialEntry[], + orientation: "horizontal" | "vertical", +): DepthTrialEntry[][] { + const sorted = [...entries].sort((a, b) => a.a.depthMm - b.a.depthMm); + if (sorted.length < 2) return sorted.map((e) => [e]); + const groups: DepthTrialEntry[][] = []; + let current: DepthTrialEntry[] = [sorted[0]]; + for (let i = 1; i < sorted.length; i++) { + const next = sorted[i]; + const crosses = current.some((member) => + wouldDepthEntriesCross(member, next, orientation), + ); + if (crosses) { + current.push(next); + } else { + groups.push(current); + current = [next]; + } + } + groups.push(current); + return groups; +} + +/** + * Task 3: generalizes the ordinate/shared-baseline convention (see + * renderLocationCluster) to a TIGHT CLUSTER of 2+ depth dimensions whose + * near/far extents are too close for standard lane-offset to ever avoid a + * crossing (see mergeTightDepthClusters). Same "one shared row, no member + * claims its own separate lane" structure as renderLocationCluster's + * exact-tie path, but generalized two ways depth specifically needs: + * every member keeps its own TRUE near+far extension-line pair (preserving + * depth's real between-two-points measurement, unlike location's single- + * point-from-datum one), and every member keeps its OWN label/value + * (unlike an exact location tie, these are genuinely different + * measurements that only happen to be physically tight - "same baseline- + * plus-tick-per-feature rendering as the existing exact-tie case", per + * task 3, with per-feature text instead of one shared value). + */ +function renderDepthCluster( + ctx: CanvasRenderingContext2D, + orientation: "horizontal" | "vertical", + cluster: DepthTrialEntry[], + laneCross: number, + view: HiddenLineViewName, + records: DimensionRecord[], + occupied: Rect[], + occupiedSegments: Segment[], + jogPx: number = ORDINATE_JOG_PX, +): void { + const unionMin = Math.min( + ...cluster.map((c) => (orientation === "horizontal" ? Math.min(c.destNearX, c.destFarX) : Math.min(c.destNearY, c.destFarY))), + ); + const unionMax = Math.max( + ...cluster.map((c) => (orientation === "horizontal" ? Math.max(c.destNearX, c.destFarX) : Math.max(c.destNearY, c.destFarY))), + ); + let baselineSeg: Segment; + if (orientation === "horizontal") { + const baselineRect = drawDimensionLine(ctx, "horizontal", unionMin, unionMax, laneCross, "", LOCATION_DIM_COLOR, false); + occupied.push(baselineRect); + baselineSeg = { x1: unionMin, y1: laneCross, x2: unionMax, y2: laneCross }; + } else { + const baselineRect = drawDimensionLine(ctx, "vertical", unionMin, unionMax, laneCross, "", LOCATION_DIM_COLOR, false); + occupied.push(baselineRect); + baselineSeg = { x1: laneCross, y1: unionMin, x2: laneCross, y2: unionMax }; + } + occupiedSegments.push(baselineSeg); + + ctx.save(); + ctx.font = DIM_VALUE_FONT; + const maxLabelExtentPx = Math.max( + ...cluster.map((c) => ctx.measureText(fmtMm(c.a.depthMm)).width + 8), + ); + ctx.restore(); + const jogStep = Math.max(jogPx, maxLabelExtentPx + 6); + + const midOf = (c: DepthTrialEntry) => + orientation === "horizontal" ? (c.destNearX + c.destFarX) / 2 : (c.destNearY + c.destFarY) / 2; + const sorted = [...cluster].sort((p, q) => midOf(p) - midOf(q)); + + sorted.forEach((item, i) => { + const text = fmtMm(item.a.depthMm); + const midPos = midOf(item); + const tickPos = i === 0 ? midPos : midPos + i * jogStep; + const newSegs: Segment[] = []; + let labelRect: Rect; + if (orientation === "horizontal") { + const nearSegs = routedExtensionSegments("vertical", item.destNearX, item.destNearY, laneCross, occupiedSegments); + drawRoutedExtensionLine(ctx, nearSegs); + occupiedSegments.push(...nearSegs); + newSegs.push(...nearSegs); + const farSegs = routedExtensionSegments("vertical", item.destFarX, item.destFarY, laneCross, occupiedSegments); + drawRoutedExtensionLine(ctx, farSegs); + newSegs.push(...farSegs); + if (tickPos !== midPos) { + drawExtensionLine(ctx, midPos, laneCross, tickPos, laneCross); + newSegs.push({ x1: midPos, y1: laneCross, x2: tickPos, y2: laneCross }); + } + drawOrdinateTick(ctx, tickPos, laneCross, "vertical", LOCATION_DIM_COLOR); + labelRect = drawIsolatedLabel(ctx, tickPos, laneCross + 16, text, LOCATION_DIM_COLOR, false); + } else { + const nearSegs = routedExtensionSegments("horizontal", item.destNearY, item.destNearX, laneCross, occupiedSegments); + drawRoutedExtensionLine(ctx, nearSegs); + occupiedSegments.push(...nearSegs); + newSegs.push(...nearSegs); + const farSegs = routedExtensionSegments("horizontal", item.destFarY, item.destFarX, laneCross, occupiedSegments); + drawRoutedExtensionLine(ctx, farSegs); + newSegs.push(...farSegs); + if (tickPos !== midPos) { + drawExtensionLine(ctx, laneCross, midPos, laneCross, tickPos); + newSegs.push({ x1: laneCross, y1: midPos, x2: laneCross, y2: tickPos }); + } + drawOrdinateTick(ctx, laneCross, tickPos, "horizontal", LOCATION_DIM_COLOR); + labelRect = drawIsolatedLabel(ctx, laneCross - 24, tickPos, text, LOCATION_DIM_COLOR, true); + } + occupied.push(labelRect); + occupiedSegments.push(...newSegs); + records.push({ + id: `${view}-depth-ordinate-${item.a.featureId}`, + view, + kind: "depth-ordinate", + axis: orientation, + featureIds: [item.a.featureId], + valueMm: item.a.depthMm, + text, + lineSegments: i === 0 ? [baselineSeg, ...newSegs] : newSegs, + labelRect, + }); + }); +} + +// --- Task 1(a): post-render remedy pass --------------------------------- +// composeA4DrawingSheet's winning-ratio render already routes every +// extension line clean of whatever was drawn before it (remedy (b), baked +// into routedExtensionSegments/drawRoutedExtensionLine unconditionally - +// see their own doc comments). What CAN'T be caught that way is a +// crossing against something drawn LATER in the same pass, or a crossing a +// local jog genuinely can't clear - this is where remedy (a) (reassign a +// depth dimension to its alternate valid edge) lives: it needs to see the +// WHOLE delivered sheet at once, so it runs as a bounded number of extra +// full-sheet re-renders AFTER the normal render. Direction change: there is +// no further fallback beyond this - a dimension that still crosses +// something after remedy (a) and the always-on jog remedy (b) is still +// rendered as a normal, direct dimension line with its real value, never +// diverted to a tag or reference table. + +const DIMENSION_BEARING_KINDS = new Set([ + "overall", + "location", + "location-shared", + "location-ordinate", + "depth", + "depth-ordinate", + // A size-callout leader is included as a SCAN participant (so a + // location/overall line that crosses one still gets caught here) even + // though a size callout already does its own collision-aware placement + // search and is never itself moved by remedy (a). Both sides of a + // crossing are always added to the result (see `involved.add` below), so + // the OTHER side of a location-vs-size crossing still gets its own + // remedy even though the size side doesn't move. + "size", +]); + +/** One record's involvement in the compose-time crossing scan (see + * findDimensionCrossings) plus WHY - the specific violation(s) that + * involved it, human-readable, surfaced in the remedy-pass console log so + * it's never a bare "this crossed something". */ +type DimensionCrossing = { + record: DimensionRecord; + reasons: string[]; +}; + +/** + * Real sheet-wide crossing scan between DIFFERENT records of a dimension- + * bearing kind (see DIMENSION_BEARING_KINDS) - the exact class of defect + * task 1's remedy (a) exists to fix ("an extension line's straight path + * crosses another dimension/extension line"). Checks line-vs-line (real + * segment intersection, gated by the SAME real per-pair-type drafting rule + * as checkFinalGeometricValidation - see classifyLineCrossing - so an + * ALLOWED extension-vs-extension crossing or a WARNING dimension-vs- + * extension crossing never triggers a remedy; only a genuine VIOLATION + * does), label-vs-label, and line-vs-label (a different record's) - the + * same geometric relationships sheet-checker.ts's own + * final-geometric-validation checks for these kinds, so a defect that would + * fail that check is never invisible to this one. Deliberately still + * narrower than that full check in one way: it never looks at a silhouette + * or the frame margin, since remedy (a) (depth-dimension reassignment) can't + * do anything about a purely dimension-vs-dimension defect's relationship + * to either - this is an internal compose-time signal for WHICH dimensions + * remedy (a) should try to fix, not the delivered correctness gate itself + * (that's still checkSheetCompleteness, run independently by the caller + * against whatever this function ultimately returns). + */ +function findDimensionCrossings( + viewLayouts: Record, +): DimensionCrossing[] { + const records = Object.values(viewLayouts) + .flatMap((v) => v.dimensions) + .filter((r) => DIMENSION_BEARING_KINDS.has(r.kind)); + const lines = records.flatMap((r) => r.lineSegments.map((seg) => ({ record: r, seg }))); + const labeled = records.filter( + (r): r is DimensionRecord & { labelRect: Rect } => !!r.labelRect && r.labelRect.w > 0 && r.labelRect.h > 0, + ); + const reasonsByRecord = new Map>(); + const addReason = (r: DimensionRecord, text: string) => { + const set = reasonsByRecord.get(r) ?? new Set(); + set.add(text); + reasonsByRecord.set(r, set); + }; + + for (let i = 0; i < lines.length; i++) { + for (let j = i + 1; j < lines.length; j++) { + const a = lines[i]; + const b = lines[j]; + if (a.record.id === b.record.id) continue; + if (!segmentsIntersectSeg(a.seg, b.seg)) continue; + const roleA = lineRoleOf(a.record, a.seg); + const roleB = lineRoleOf(b.record, b.seg); + const verdict = classifyLineCrossing(roleA, roleB); + if (verdict !== "violation") continue; // rule 1/2: ALLOWED/WARNING crossings never need a remedy + addReason( + a.record, + `${roleA} line crosses ${roleB} line of "${b.record.text ?? b.record.id}" (${b.record.view})`, + ); + addReason( + b.record, + `${roleB} line crosses ${roleA} line of "${a.record.text ?? a.record.id}" (${a.record.view})`, + ); + } + } + for (let i = 0; i < labeled.length; i++) { + for (let j = i + 1; j < labeled.length; j++) { + const a = labeled[i]; + const b = labeled[j]; + if (rectsOverlap(a.labelRect, b.labelRect)) { + addReason(a, `label overlaps label of "${b.text ?? b.id}" (${b.view})`); + addReason(b, `label overlaps label of "${a.text ?? a.id}" (${a.view})`); + } + } + } + for (const ln of lines) { + for (const lbl of labeled) { + if (ln.record.id === lbl.id) continue; + if (segmentIntersectsRect(ln.seg.x1, ln.seg.y1, ln.seg.x2, ln.seg.y2, lbl.labelRect)) { + addReason(ln.record, `line crosses label of "${lbl.text ?? lbl.id}" (${lbl.view})`); + addReason(lbl, `label is crossed by a line of "${ln.record.text ?? ln.record.id}" (${ln.record.view})`); + } + } + } + return [...reasonsByRecord.entries()].map(([record, reasons]) => ({ record, reasons: [...reasons] })); +} + +/** + * Composes the three captured hidden-line views (Front/Top/Right, already + * captured at one shared scale - see generateHiddenLineViewSet()) onto a + * single A4-proportioned sheet in standard third-angle arrangement: Top + * above Front, Right beside Front, a shaded isometric reference view in the + * empty top-right quadrant (see IsoViewLayout), plus a sheet border, title + * block, view labels, and overall width/height dimension lines per view. + * Every view outline is stroked as real vector geometry at the drafting line + * weights in drafting-rules.ts - see strokeSheetEdgeRuns. + * + * Alongside the rendered PNG, returns `layoutModel`: the same dimension + * geometry/metadata as was drawn, structured for sheet-checker.ts to + * validate without re-parsing pixels. See DimensionRecord/SheetLayoutModel. + */ +export async function composeA4DrawingSheet(input: A4SheetInput): Promise<{ + dataURL: string; + scaleLabel: string; + layoutModel: SheetLayoutModel; + plan: DimensionPlan; + scaleSelection: ScaleSelectionResult; + /** Set only for a manual scale override whose final content overflows the + * usable sheet area - see ScaleOverflowWarning's own doc comment. Always + * null for Auto. */ + overflowWarning: ScaleOverflowWarning | null; +}> { + const { captureResult, partName, date, retryHints, manualRatio } = input; + const { + views, + pxPerMm: capturePxPerMm, + canvasWidth, + canvasHeight, + modelBoundsMm, + circularAnnotations, + axialDepthAnnotations, + } = captureResult; + + // Effective values for this composition attempt - the base constants + // unless a bounded auto-retry attempt (see cad-viewer.tsx) is nudging one + // of them in response to a specific prior checker failure. Used + // consistently everywhere the base constants would otherwise appear below + // (both the layout-size solve and the actual lane drawing), so a retry + // attempt can never disagree with itself about how much space it reserved. + const exactTieEpsMm = retryHints?.exactTieEpsMm ?? EXACT_TIE_EPS_MM; + const locationDimRowH = + LOCATION_DIM_ROW_H + (retryHints?.extraLaneSpacingPx ?? 0); + const locationDimColW = + LOCATION_DIM_COL_W + (retryHints?.extraLaneSpacingPx ?? 0); + const ordinateJogPx = ORDINATE_JOG_PX + (retryHints?.extraLaneSpacingPx ?? 0); + + const byView = new Map(views.map((v) => [v.view, v])); + const front = byView.get("front"); + const top = byView.get("top"); + const right = byView.get("right"); + if (!front || !top || !right) { + throw new Error( + "composeA4DrawingSheet: missing one of front/top/right captures", + ); + } + + const viewBoxes: Record = { + front: { widthMm: modelBoundsMm.x, heightMm: modelBoundsMm.y }, + top: { widthMm: modelBoundsMm.x, heightMm: modelBoundsMm.z }, + right: { widthMm: modelBoundsMm.z, heightMm: modelBoundsMm.y }, + }; + + // ONE shared crop margin, from the model's own largest single-axis + // extent across ALL THREE views (not each view's own local max) - task + // 3's true-projection-alignment fix. Front and Top share their widthMm + // EXACTLY (both modelBoundsMm.x); Front and Right share their heightMm + // EXACTLY (both modelBoundsMm.y) - see viewBoxes above. A PER-VIEW + // margin (the previous behavior: each view picking its own + // max(width,height)*0.08) broke that shared dimension the moment the + // view's OTHER axis differed, since the margin - and therefore the + // crop's overall size - differed between two views that were supposed + // to show the identical width/height: Top and Front would each still + // render the part at the correct size, but the crop PADDING around it + // would differ, so drawCell's cell-aligned placement (same imgX for + // Top/Front, same imgY for Front/Right) no longer put the actual PART's + // center on the shared axis - a real, silent misalignment, not a + // rounding artifact. A single shared margin makes topImgW===frontImgW + // and rightImgH===frontImgH exactly, so the existing cell placement + // below (topCellX===frontCellX, rightCellY===frontCellY) delivers a true + // third-angle projection group by construction: Top's horizontal center + // over Front's, Right's vertical center level with Front's - never a + // per-view coincidence. Room to spare: the ortho camera's shared fit + // (see generateHiddenLineViewSet in viewer.ts) already reserves 1.5x the + // model's own largest extent as canvas headroom, far more than this 8% + // margin ever needs for any view. + const sharedMarginMm = Math.max( + 3, + Math.max(modelBoundsMm.x, modelBoundsMm.y, modelBoundsMm.z) * 0.08, + ); + + const prepareOne = (capture: HiddenLineViewCapture): LoadedView => { + const box = viewBoxes[capture.view]; + const marginMm = sharedMarginMm; + const cropWmm = box.widthMm + marginMm * 2; + const cropHmm = box.heightMm + marginMm * 2; + const cropW = cropWmm * capturePxPerMm; + const cropH = cropHmm * capturePxPerMm; + const cropX = canvasWidth / 2 - cropW / 2; + const cropY = canvasHeight / 2 - cropH / 2; + return { + view: capture.view, + label: capture.label, + edgeRuns: capture.edgeRuns, + partWidthMm: box.widthMm, + partHeightMm: box.heightMm, + cropX, + cropY, + cropW, + cropH, + cropWmm, + cropHmm, + annotations: circularAnnotations[capture.view] ?? [], + axialDepth: axialDepthAnnotations?.[capture.view] ?? [], + }; + }; + + const [frontV, topV, rightV] = [ + prepareOne(front), + prepareOne(top), + prepareOne(right), + ]; + // The ONE genuinely async step left in this pipeline: the isometric + // reference view is a raster (see IsoViewLayout), so its capture has to be + // decoded before any candidate render can draw it. The orthographic views + // need no decode at all now that they're stroked as vectors. + const isoImg = captureResult.isoCapture + ? await loadImage(captureResult.isoCapture.dataURL) + : null; + const isoSrcRect: Rect | null = captureResult.isoCapture + ? { ...captureResult.isoCapture.cropPx } + : null; + const loadedViewsByName: Record = { + front: frontV, + top: topV, + right: rightV, + }; + + // Sheet-wide dimension plan - built ONCE here, before any view-specific + // rendering runs, deciding which single (view, axis) slot every feature's + // every measurement belongs to. Everything below (the layout-size + // estimate AND the actual per-view drawing in drawCell) only ever reads + // its own slice of this plan; neither may independently decide a feature + // needs a dimension - see sheet-dimension-plan.ts's doc comment for why + // (this is what used to let Front and Right both dimension the same + // stepped hole's Y-coordinate, producing a duplicate "49.5mm"). + const plan = buildDimensionPlan(captureResult); + + // Real-world (mm) distance-from-datum-edge for every planned location + // measurement, computed ONCE here in CAPTURE pixel space (before the + // sheet scale below is even chosen) and reused unchanged both for the + // lane/cluster-count layout estimate and for the actual rendering in + // drawCell(). mm distances are scale-invariant, so computing them here + // instead of twice (once for estimating, once for drawing, in two + // different pixel spaces) guarantees the two can never disagree about + // which features cluster together. + const silhouetteCaptureRect = (v: LoadedView): Rect => ({ + x: v.cropX + (v.cropW - v.partWidthMm * capturePxPerMm) / 2, + y: v.cropY + (v.cropH - v.partHeightMm * capturePxPerMm) / 2, + w: v.partWidthMm * capturePxPerMm, + h: v.partHeightMm * capturePxPerMm, + }); + + /** Resolves ONE planned location measurement's raw capture-space point + * (from whichever source the plan says to use) and its scale-invariant + * mm value - the single formula both the layout-size estimate below and + * drawCell()'s actual rendering call, so they can never disagree. */ + const resolvePlannedLocation = ( + m: PlannedLocationMeasurement, + ): { capturePx: { x: number; y: number }; valueMm: number } | null => { + const v = loadedViewsByName[m.view]; + const capturePx = + m.positionSource === "circular" + ? v.annotations.find((a) => a.featureId === m.featureId)?.centerPx + : v.axialDepth.find((a) => a.featureId === m.featureId)?.nearPx; + if (!capturePx) return null; + const rect = silhouetteCaptureRect(v); + const valueMm = + m.screenAxis === "horizontal" + ? (capturePx.x - rect.x) / capturePxPerMm + : (rect.y + rect.h - capturePx.y) / capturePxPerMm; + return { capturePx, valueMm }; + }; + + // How many location-dimension lanes a view's chain will need - one per + // DISTINCT (1-decimal-rounded) value, matching groupLocationTargets()'s + // own clustering, so a tied-value pair collapses to the one lane it will + // actually render into instead of over-reserving space for it. + const countClusters = (values: number[]) => + new Set(values.map((v) => v.toFixed(1))).size; + const countPlanLocationLanes = ( + view: HiddenLineViewName, + screenAxis: "horizontal" | "vertical", + ) => { + const vals = plan.location + .filter((m) => m.view === view && m.screenAxis === screenAxis) + .map((m) => resolvePlannedLocation(m)?.valueMm) + .filter((v): v is number => v !== undefined && v !== null); + return countClusters(vals); + }; + + // Total reach of an edge's unified dimension sequence (task 3) with N + // lanes - overall + location-cluster + depth entries all counted together + // (see the callers below, which add 1 for the edge's overall dimension + // when shown) - plus a little clearance past the outermost lane's + // dimension line/text. A nominal STARTING estimate for where to place + // cells before anything is actually drawn; the real fit check below (see + // attemptAtRatio) verifies the ACTUAL rendered content against this and + // falls back to a smaller scale if it doesn't hold, so this only has to + // be a reasonable starting point, not exact. Matches the true-minimum + // lane pitch the unified sequence actually draws at (lane i at + // laneSize*i from the edge's own base) plus a full PARALLEL_DIM_SPACING_PX + // for the outermost label's own overhang past its line. + const chainReach = (numLanes: number, laneSize: number) => + numLanes > 0 ? laneSize * numLanes + PARALLEL_DIM_SPACING_PX : 0; + + const drawArea = DRAW_AREA; + + // Minimum clear gap between one view's real rendered content and the + // next view's - task 3's fixed, named constant (drafting-rules.ts), not + // a locally-repurposed alias of a spacing value meant for something else. + // The delivered gap converges to exactly this (see gapTightenV/H below, + // which squeeze any reserved-but-unused slack out down to this exact + // floor) for the common case; a part whose own dimension chain genuinely + // needs more room still gets it (see clusterGapV/H below, which reserve + // MORE than this floor when the plan's own lane count calls for it). + const CROSS_VIEW_GAP_PX = VIEW_GROUP_GAP_PX; + + type ViewContentBounds = Record; + + /** + * Composes the full sheet at ONE candidate drafting ratio and judges fit + * from POSITION-INDEPENDENT facts only: the true (post-tightening) + * content box's own width/height against drawArea's, and the real gaps + * between views against CROSS_VIEW_GAP_PX - never a view's raw on-sheet + * coordinates against drawArea's, and never the raw (possibly slack- + * inflated - see gapTightenVPx/HPx) gap a nominal trial happened to + * produce. A candidate whose true content size fits the sheet can ALWAYS + * be centered (and its inter-view gaps ALWAYS squeezed down to the + * required minimum) to actually land inside drawArea, regardless of + * where/how loosely this particular trial - laid out via `correction`, + * default all-zero, a nominal guess, see clusterW/H above - happened to + * place it. Judging fit from raw coordinates/gaps against that nominal + * layout instead is exactly the fit-check bug task 2 exists to kill: it + * can reject a ratio whose content would fit fine once correctly + * centered and tightened, purely because the ESTIMATE used to lay out + * the trial undershot on one side or over-reserved a gap a swung-out + * size-callout didn't end up needing - see trueContentBoxPx's doc + * comment. + * + * Every check is re-derived from what was ACTUALLY drawn, never an + * estimate - see the ScaleCandidateLogEntry this returns, which records + * the exact measured bounds, the usable area, and (on rejection) exactly + * which measurement missed its bound and by how much, so the search + * below never has to trust a bare true/false. + * + * composeA4DrawingSheet calls this once per candidate to decide fit, then + * calls it exactly once more for the WINNING candidate, with + * `correction` set to the centering offset and gap-tightening amounts + * that actually realize its true content box inside drawArea, to + * produce the final delivered render. + */ + function attemptAtRatio( + chosenRatio: number, + role: ScaleCandidateRole, + correction: { + centerOffset: { x: number; y: number }; + gapTightenV: number; + gapTightenH: number; + } = { centerOffset: { x: 0, y: 0 }, gapTightenV: 0, gapTightenH: 0 }, + // Task 1(a) remedy (see the doc comment above composeA4DrawingSheet's + // post-render remedy pass) - defaults to empty/off, which reproduces the + // plain render exactly (every prior call site is unaffected). + remedies: { + /** featureId -> forced depth orientation, overriding + * depthDimensionOrientation's own natural pick - task 1 remedy (a). */ + depthOrientationOverrides: Map; + } = { depthOrientationOverrides: new Map() }, + ): { + dataURL: string; + scaleLabel: string; + viewLayouts: Record; + isoView: IsoViewLayout | null; + log: ScaleCandidateLogEntry; + } { + const sheetPxPerMm = chosenRatio * SHEET_PX_PER_MM; + const scaleLabel = formatScaleLabel(chosenRatio); + + // Cell geometry, in sheet px, using this candidate scale. + const frontImgW = frontV.cropWmm * sheetPxPerMm; + const frontImgH = frontV.cropHmm * sheetPxPerMm; + const topImgW = topV.cropWmm * sheetPxPerMm; + const topImgH = topV.cropHmm * sheetPxPerMm; + const rightImgW = rightV.cropWmm * sheetPxPerMm; + const rightImgH = rightV.cropHmm * sheetPxPerMm; + + // Nominal per-view reserved height below the image: first-dim-line + // offset, the caption's own row, and one extra PARALLEL_DIM_SPACING_PX + // of headroom - the caption's actual position (see drawCell) is read + // from real drawn content and can overshoot this nominal estimate + // slightly (a dimension label's own half-height, the caption's small + // true-minimum clearance above it, etc.); this buffer keeps that real + // overshoot safely within what was reserved for the common case (an + // overall dim or a handful of lanes), so the fit-to-sheet search below + // only has to fall back to a smaller ratio for genuinely dense views, + // not because of this estimate/reality gap. + const NOMINAL_CAPTION_BUFFER_PX = PARALLEL_DIM_SPACING_PX; + const frontCellW = LEFT_DIM_W + frontImgW; + const frontCellH = + frontImgH + BOTTOM_DIM_H + LABEL_H + NOMINAL_CAPTION_BUFFER_PX; + const topCellH = + topImgH + BOTTOM_DIM_H + LABEL_H + NOMINAL_CAPTION_BUFFER_PX; + const rightCellW = LEFT_DIM_W + rightImgW; + + // Nominal starting gaps - chains extend OUTWARD from their own view + // (Right's column chain reaches back toward Front, Top's row chain + // reaches down toward Front), so the gap on that side needs to clear + // whichever neighbor's chain reaches into it, not just the fixed + // VIEW_GROUP_GAP_PX floor (task 3 - see CROSS_VIEW_GAP_PX's own doc + // comment above). Real fit is verified below from what's ACTUALLY + // drawn. +1 lane whenever that edge's overall dimension is shown - + // under the unified per-edge sequence (task 3) it now competes for a + // lane exactly like a location cluster, rather than living in its own + // fixed offset outside this count. + const rightVerticalLanes = + countPlanLocationLanes("right", "vertical") + + (OVERALL_DIM_VISIBILITY.right.height ? 1 : 0); + const clusterGapH = Math.max( + CROSS_VIEW_GAP_PX, + chainReach(rightVerticalLanes, locationDimColW), + ); + const topHorizontalLanes = + countPlanLocationLanes("top", "horizontal") + + (OVERALL_DIM_VISIBILITY.top.width ? 1 : 0); + const clusterGapV = Math.max( + CROSS_VIEW_GAP_PX, + chainReach(topHorizontalLanes, locationDimRowH), + ); + + const clusterW = frontCellW + clusterGapH + rightCellW; + const clusterH = topCellH + clusterGapV + frontCellH; + + // Nominal centering (a starting guess, not the fit decision - see this + // function's doc comment) PLUS correction.centerOffset, which the + // caller supplies once it knows the TRUE content box from a prior + // trial at this same ratio. Every downstream position (every cell, + // every dimension line, every label) derives from clusterX/clusterY, + // so adding a constant here rigidly translates the entire rendered + // composition - content size and inter-view gaps are unaffected. + const clusterX = + drawArea.x + (drawArea.w - clusterW) / 2 + correction.centerOffset.x; + const clusterY = + drawArea.y + (drawArea.h - clusterH) / 2 + correction.centerOffset.y; + + const topCellX = clusterX; + const topCellY = clusterY; + // correction.gapTightenV/H squeeze the Top<->Front and Front<->Right + // gaps down from this trial's nominal reservation toward the required + // CROSS_VIEW_GAP_PX minimum - see gapTightenVPx/HPx's doc comment on + // ScaleCandidateLogEntry for why the nominal gap is frequently more + // generous than the real content needs (a size callout can swing + // toward a neighboring view). Applied directly to Front/Right's own + // cell position rather than to clusterGapV/H themselves, so this never + // perturbs the nominal clusterW/H this trial's OWN centering above was + // computed from. + const frontCellX = clusterX; + const frontCellY = + clusterY + topCellH + clusterGapV - correction.gapTightenV; + const rightCellX = + clusterX + frontCellW + clusterGapH - correction.gapTightenH; + const rightCellY = frontCellY; + + const canvas = document.createElement("canvas"); + canvas.width = SHEET_W; + canvas.height = SHEET_H; + const ctx = canvas.getContext("2d"); + if (!ctx) + throw new Error("composeA4DrawingSheet: could not get 2d context"); + + drawSheetFrame(ctx); + + const viewLayouts: Record = + {} as Record; + + const drawCell = ( + v: LoadedView, + cellX: number, + cellY: number, + imgW: number, + imgH: number, + ) => { + const imgX = cellX + LEFT_DIM_W; + const imgY = cellY; + + // Maps a point in this view's SOURCE capture pixel space to sheet px - + // used for the outline runs below and for the axial-depth/location + // annotations further down (which arrive in capture space, like + // v.annotations does). Defined before anything is drawn since the + // outline itself now goes through it. + const toSheetX = (capX: number) => + imgX + ((capX - v.cropX) / v.cropW) * imgW; + const toSheetY = (capY: number) => + imgY + ((capY - v.cropY) / v.cropH) * imgH; + + // The part outline, stroked at real drafting line weights (see + // strokeSheetEdgeRuns) - drawn first, so every dimension line, label + // and leader below lands on top of it exactly as before. + const edgeRuns: SheetEdgeRun[] = v.edgeRuns.map((run) => { + const pts = new Array(run.pts.length); + for (let i = 0; i + 1 < run.pts.length; i += 2) { + pts[i] = toSheetX(run.pts[i]); + pts[i + 1] = toSheetY(run.pts[i + 1]); + } + return { hidden: run.hidden, pts }; + }); + strokeSheetEdgeRuns(ctx, edgeRuns); + + // Real silhouette rect within the (slightly padded) drawn image. + const partW = (v.partWidthMm / v.cropWmm) * imgW; + const partH = (v.partHeightMm / v.cropHmm) * imgH; + const partX = imgX + (imgW - partW) / 2; + const partY = imgY + (imgH - partH) / 2; + + const silhouetteRect: Rect = { x: partX, y: partY, w: partW, h: partH }; + const occupied: Rect[] = []; + const occupiedSegments: Segment[] = []; + const showDims = OVERALL_DIM_VISIBILITY[v.view]; + const records: DimensionRecord[] = []; + + // Location-dimension target lists are needed up front (before the + // overall width/height dimensions below) so their labels can dodge a + // known extension-line crossing - see pickLabelFrac()'s doc comment for + // why this can happen (a feature sitting exactly at the part's + // midpoint, e.g. a round part's own OD/bore, always centered). + // + // This view draws ONLY what the sheet-wide plan (see + // sheet-dimension-plan.ts, built once above) assigned to it - it has no + // authority to decide a feature needs a dimension on its own. Standard + // drafting convention already skipped arcs/fillets (position implied by + // tangency) and non-representative same-size-group members (implied by + // the representative already dimensioned) when the plan was built; what + // remains gets the same deterministic "dimension chain" as before - + // grouped by value (see groupLocationTargets()) and stacked outward, + // together with the overall dimension and any depth dimensions on the + // same edge, in ONE shared lane sequence (task 3 - see below). + const resolveTarget = (m: PlannedLocationMeasurement) => { + const resolved = resolvePlannedLocation(m); + if (!resolved) return null; + return { + featureId: m.featureId, + destCenterX: toSheetX(resolved.capturePx.x), + destCenterY: toSheetY(resolved.capturePx.y), + valueMm: resolved.valueMm, + }; + }; + const isTarget = (t: T | null): t is T => t !== null; + + const horizontalTargets = plan.location + .filter((m) => m.view === v.view && m.screenAxis === "horizontal") + .map(resolveTarget) + .filter(isTarget); + const verticalTargets = plan.location + .filter((m) => m.view === v.view && m.screenAxis === "vertical") + .map(resolveTarget) + .filter(isTarget); + + // --- Unified per-edge dimension sequence (task 3) --------------- + // Every dimension line on a given edge of this view - the overall + // envelope dimension, every location-chain lane, and any stepped-hole + // depth/axial dimension that lands on this edge - is placed through + // ONE ordered sequence per edge: first line at FIRST_DIM_LINE_OFFSET_PX + // from the outline, each subsequent one at exactly one more + // locationDimRowH/ColW (the drafting-standard PARALLEL_DIM_SPACING_PX, + // or an auto-retry-widened multiple of it - see the retryHints + // plumbing above) further out, SMALLEST real-world value nearest the + // part - standard drafting convention (the overall envelope dimension, + // almost always the single largest value on its edge, lands OUTERMOST + // as a direct consequence, not the innermost fixed slot it used to + // occupy). No dimension kind gets its own separate offset rule. + // + // Stepped-hole depth dimensions (Top view only - see buildDimensionPlan) + // join whichever edge matches their own screen orientation + // (depthDimensionOrientation) - horizontal-oriented depth dims share + // the bottom edge with the width/location sequence, vertical-oriented + // ones share the LEFT edge with the height/location sequence (not a + // separate right-hand edge - keeping exactly two edges per view, each + // with one unambiguous sequence, is what makes this checkable as + // "one sequence per edge" at all). + const depthEntriesForView = plan.depth + .filter((m) => m.view === v.view) + .map((m) => v.axialDepth.find((x) => x.featureId === m.featureId)) + .filter((a): a is HiddenLineAxialDepthAnnotation => !!a) + .map((a) => { + const destNearX = toSheetX(a.nearPx.x); + const destNearY = toSheetY(a.nearPx.y); + const destFarX = toSheetX(a.farPx.x); + const destFarY = toSheetY(a.farPx.y); + return { + a, + destNearX, + destNearY, + destFarX, + destFarY, + // Task 1, remedy (a): a caller-supplied override (from a prior + // render's crossing scan - see composeA4DrawingSheet's post- + // render remedy pass) wins over the natural pick. + orientation: + remedies.depthOrientationOverrides.get(a.featureId) ?? + depthDimensionOrientation(destNearX, destNearY, destFarX, destFarY), + }; + }); + + type EdgeEntry = + | { kind: "overall"; valueMm: number } + | { kind: "location"; cluster: LocationChainTarget[]; valueMm: number } + | { kind: "depth"; d: DepthTrialEntry; valueMm: number } + // Task 3: 2+ depth entries whose near/far extents are too tight for + // standard lane-offset to ever avoid a crossing (see + // mergeTightDepthClusters) - rendered as one generalized-ordinate + // group (renderDepthCluster) sharing a single lane instead of each + // claiming its own. + | { kind: "depth-cluster"; cluster: DepthTrialEntry[]; valueMm: number }; + + // Task 3: partition each edge's depth entries into singleton/ + // tight-cluster groups BEFORE lane assignment, same real geometric + // check regardless of which edge they land on (see + // wouldDepthEntriesCross - it's about physical extent vs. sort order, + // not about which edge is crowded). + const horizontalDepthGroups = mergeTightDepthClusters( + depthEntriesForView.filter((d) => d.orientation === "horizontal"), + "horizontal", + ); + const depthGroupToEdgeEntry = (group: DepthTrialEntry[]): EdgeEntry => + group.length === 1 + ? { kind: "depth" as const, d: group[0], valueMm: group[0].a.depthMm } + : { kind: "depth-cluster" as const, cluster: group, valueMm: group[0].a.depthMm }; + + const horizontalEntries: EdgeEntry[] = [ + ...(showDims.width + ? [{ kind: "overall" as const, valueMm: v.partWidthMm }] + : []), + ...groupLocationTargets(horizontalTargets).map((cluster) => ({ + kind: "location" as const, + cluster, + valueMm: cluster[0].valueMm, + })), + ...horizontalDepthGroups.map(depthGroupToEdgeEntry), + ].sort((a, b) => a.valueMm - b.valueMm); + + // Vertical-oriented depth dimensions do NOT join this edge's sequence + // (unlike horizontal-oriented ones, which do join the bottom edge + // above) - see the doc comment on verticalDepthEntries below for why: + // a depth annotation's near/far points can sit anywhere in the + // silhouette (often near ITS OWN center for a coaxial stepped bore), + // arbitrarily far from this edge's image-anchored lane column, which + // would force a long reach that sweeps past - and crosses - every + // closer lane it passes on the way out. + const verticalEntries: EdgeEntry[] = [ + ...(showDims.height + ? [{ kind: "overall" as const, valueMm: v.partHeightMm }] + : []), + ...groupLocationTargets(verticalTargets).map((cluster) => ({ + kind: "location" as const, + cluster, + valueMm: cluster[0].valueMm, + })), + ].sort((a, b) => a.valueMm - b.valueMm); + + // Vertical-oriented depth dimensions get their OWN sequence, on the + // OPPOSITE (right) edge, anchored to the silhouette itself rather + // than the image/cellX the location/overall column above uses. Two + // deliberate differences from that column, both required by a real, + // visually-confirmed failure (flange.step's 4 coaxial stepped-bore + // depths, all ~8mm, all sharing one near-center source point): + // 1. Anchored to silhouetteRect (the part's own tight bounds), not + // cellX/imgY+imgH (the wider image crop) - keeps the reach bounded + // by the part's own size, not by how far the crop margin sits from + // a centered feature. + // 2. Sorted and lane-assigned only AMONG themselves (still smallest- + // depth-nearest, still the same FIRST_DIM_LINE_OFFSET_PX + + // locationDimColW*lane mechanics as every other sequence) rather + // than interleaved by raw value with location/overall - a depth + // dimension's own displayed value is "how deep this step is", not + // "how far from this edge's datum", so interleaving it by that + // value with datum-anchored dimensions doesn't carry the same + // crossing-avoidance guarantee the convention exists for. + const verticalDepthGroups = mergeTightDepthClusters( + depthEntriesForView.filter((d) => d.orientation === "vertical"), + "vertical", + ); + + // Bottom edge - distance from the part's left edge (partX), stacking + // downward away from the image. + horizontalEntries.forEach((entry, lane) => { + const laneCross = + imgY + imgH + FIRST_DIM_LINE_OFFSET_PX + locationDimRowH * lane; + if (entry.kind === "overall") { + renderOverallDimension( + ctx, + "horizontal", + partX, + partX + partW, + laneCross, + entry.valueMm, + horizontalTargets.map((t) => t.destCenterX), + v.view, + records, + occupied, + occupiedSegments, + ); + } else if (entry.kind === "location") { + renderLocationCluster( + ctx, + "horizontal", + entry.cluster, + laneCross, + partX, + horizontalTargets, + v.view, + records, + occupied, + occupiedSegments, + exactTieEpsMm, + ordinateJogPx, + ); + } else if (entry.kind === "depth") { + renderDepthDimension( + ctx, + entry.d.a, + entry.d.destNearX, + entry.d.destNearY, + entry.d.destFarX, + entry.d.destFarY, + "horizontal", + laneCross, + v.view, + occupied, + occupiedSegments, + records, + ); + } else { + renderDepthCluster( + ctx, + "horizontal", + entry.cluster, + laneCross, + v.view, + records, + occupied, + occupiedSegments, + ordinateJogPx, + ); + } + }); + + // Left edge - distance from the part's bottom edge (partY + partH), + // stacking leftward away from the image. cellX is already exactly + // FIRST_DIM_LINE_OFFSET_PX inside imgX (imgX = cellX + LEFT_DIM_W, and + // LEFT_DIM_W === FIRST_DIM_LINE_OFFSET_PX), so lane 0 lands at the + // correct first-line offset with no extra term needed. Overall/ + // location only - see verticalDepthEntries above for why depth is + // deliberately excluded from this column. + verticalEntries.forEach((entry, lane) => { + const laneCross = cellX - locationDimColW * lane; + if (entry.kind === "overall") { + renderOverallDimension( + ctx, + "vertical", + partY, + partY + partH, + laneCross, + entry.valueMm, + verticalTargets.map((t) => t.destCenterY), + v.view, + records, + occupied, + occupiedSegments, + ); + } else if (entry.kind === "location") { + renderLocationCluster( + ctx, + "vertical", + entry.cluster, + laneCross, + partY + partH, + verticalTargets, + v.view, + records, + occupied, + occupiedSegments, + exactTieEpsMm, + ordinateJogPx, + ); + } + }); + + // Right edge - vertical-oriented depth dimensions' own independent + // sequence, anchored to the silhouette's own right edge (see + // verticalDepthGroups's doc comment above for why) - one lane per + // GROUP (task 3: a tight cluster of 2+ shares one lane, same as the + // horizontal edge above). + verticalDepthGroups.forEach((group, lane) => { + const laneCross = + silhouetteRect.x + + silhouetteRect.w + + FIRST_DIM_LINE_OFFSET_PX + + locationDimColW * lane; + if (group.length === 1) { + const d = group[0]; + renderDepthDimension( + ctx, + d.a, + d.destNearX, + d.destNearY, + d.destFarX, + d.destFarY, + "vertical", + laneCross, + v.view, + occupied, + occupiedSegments, + records, + ); + } else { + renderDepthCluster( + ctx, + "vertical", + group, + laneCross, + v.view, + records, + occupied, + occupiedSegments, + ordinateJogPx, + ); + } + }); + + // Circle/arc size callouts: diameter for circles, radius for arcs - + // skipped for non-representative members of a same-size group (see + // computeCircularAnnotationsForView()'s dedup). Full-circumference- + // aware routing (see drawCircularCallout) around the view's shared + // center - correct for a radial cluster like a bolt-circle pattern, + // where a local search cone around each feature's own natural angle + // gets stuck and produces crossing leader lines. `claimedAngles` is + // shared/mutated across every callout on this view so each one knows + // what direction its neighbors already used. + const viewCenterX = imgX + imgW / 2; + const viewCenterY = imgY + imgH / 2; + const claimedAngles: number[] = []; + + const sizeFeatureIds = new Set( + plan.size.filter((m) => m.view === v.view).map((m) => m.featureId), + ); + // Largest radius first - concentric circles (same center, different + // radii) share one natural angle, so whichever gets placed first + // claims the cleanest/shortest pick of it; the largest is the least + // flexible (its rim already sits closest to the silhouette edge, so + // its own search has the least room to work with), so it goes first + // rather than being left to fight over whatever's left. + const sizeCalloutTargets = v.annotations + .filter( + (a): a is typeof a & { sizeLabel: string } => + a.sizeLabel !== null && sizeFeatureIds.has(a.featureId), + ) + .sort((a, b) => b.radiusMm - a.radiusMm); + + // Task 2: guaranteed angular partitioning for 2+ size-callout GROUPS + // sharing one center point (a group may itself be a merged step-pair + // - see secondaryDiameterMm - not just a single circle). Grouped by + // real center (a.centerPx, the feature's true axis point - NOT + // anchorPx, which is just the 45deg rim point the leader starts + // from), converted to sheet px so coincidence is judged in the same + // space the search itself works in. A center with only ONE callout + // target is untouched (full 360deg sweep, exactly the prior + // behavior) - this only kicks in for genuine coaxial competition. + const coaxialSectorByFeatureId = new Map(); + { + const byCenter = new Map(); + for (const a of sizeCalloutTargets) { + const cx = toSheetX(a.centerPx.x); + const cy = toSheetY(a.centerPx.y); + const key = `${Math.round(cx)},${Math.round(cy)}`; + const arr = byCenter.get(key) ?? []; + arr.push(a); + byCenter.set(key, arr); + } + for (const group of byCenter.values()) { + if (group.length < 2) continue; + const n = group.length; + const sectorWidth = (Math.PI * 2) / n; + const slots: { min: number; max: number }[] = []; + for (let i = 0; i < n; i++) { + slots.push({ min: i * sectorWidth, max: (i + 1) * sectorWidth }); + } + const claimedSlots = new Set(); + // Largest-radius-first - the outermost member's own leader is the + // least flexible (shortest natural reach before it must clear the + // silhouette), so it claims the sector nearest its own true + // direction first; every subsequent member picks the nearest + // UNCLAIMED sector to ITS natural direction, so no two members of + // this group can ever be assigned overlapping search space, no + // matter how many share this center. + for (const a of [...group].sort((x, y) => y.radiusMm - x.radiusMm)) { + const destX = toSheetX(a.anchorPx.x); + const destY = toSheetY(a.anchorPx.y); + const natural = Math.atan2(destY - viewCenterY, destX - viewCenterX); + let bestSlot = -1; + let bestDist = Infinity; + for (let i = 0; i < n; i++) { + if (claimedSlots.has(i)) continue; + const center = slots[i].min + sectorWidth / 2; + const dist = angularDelta(natural, center); + if (dist < bestDist) { + bestDist = dist; + bestSlot = i; + } + } + claimedSlots.add(bestSlot); + coaxialSectorByFeatureId.set(a.featureId, slots[bestSlot]); + } + } + } + + for (const a of sizeCalloutTargets) { + const destX = toSheetX(a.anchorPx.x); + const destY = toSheetY(a.anchorPx.y); + + const { labelRect, leaderSegment, landingSegment } = drawCircularCallout( + ctx, + destX, + destY, + viewCenterX, + viewCenterY, + a.sizeLabel, + occupied, + occupiedSegments, + silhouetteRect, + claimedAngles, + keepClearBoundsForView(v.view, imgX, imgY, imgW, imgH, CROSS_VIEW_GAP_PX), + coaxialSectorByFeatureId.get(a.featureId) ?? null, + ); + const group = v.annotations.filter( + (x) => + x.groupRepresentativeFeatureId === a.groupRepresentativeFeatureId, + ); + records.push({ + id: `${v.view}-size-${a.featureId}`, + view: v.view, + kind: "size", + axis: null, + featureIds: group.map((g) => g.featureId), + valueMm: a.kind === "circle" ? a.radiusMm * 2 : a.radiusMm, + text: a.sizeLabel, + lineSegments: [leaderSegment, landingSegment], + labelRect, + }); + } + + // View label (caption): positioned from the ACTUAL lowest rendered + // element of this view - every occupied rect (overall dims, location + // dims, depth dims, size-callout labels) and every drawn line's + // endpoints, not a formula-derived guess about how many location lanes + // there "should" be. This is what lets a size-callout leader that + // happens to swing below the location-dimension rows (full- + // circumference routing can place one anywhere around the view) still + // never end up with the caption drawn on top of or ambiguously close + // to it - the caption always reads the real geometry that was just + // drawn, not an estimate of it. + const contentMaxY = Math.max( + imgY + imgH, + ...occupied.map((r) => r.y + r.h), + ...occupiedSegments.flatMap((s) => [s.y1, s.y2]), + ); + // Small true-minimum clearance so the caption text doesn't touch the + // last dimension line's own label - NOT the same thing as + // CROSS_VIEW_GAP_PX below, which is the (much larger) minimum gap + // between two DIFFERENT views' content; reusing that here would + // pointlessly re-inflate the tight packing task 1 asks for. + const captionGapPx = EXTENSION_VISIBLE_GAP_PX; + const captionY = contentMaxY + captionGapPx + LABEL_H / 2; + ctx.save(); + ctx.fillStyle = "#000000"; + ctx.font = CAPTION_FONT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const captionText = v.label.toUpperCase(); + const captionW = ctx.measureText(captionText).width + 8; + ctx.fillText(captionText, imgX + imgW / 2, captionY); + ctx.restore(); + const captionRect: Rect = { + x: imgX + imgW / 2 - captionW / 2, + y: captionY - LABEL_H / 2, + w: captionW, + h: LABEL_H, + }; + occupied.push(captionRect); + // The caption is just another labelRect-bearing record (kind + // "caption") rather than bookkeeping only in the local `occupied` + // array, so computeViewContentBounds - and every sheet-wide geometric + // check built on SheetLayoutModel - actually sees it. See task 2: "the + // view caption" is explicitly part of what content-measurement must + // capture. + records.push({ + id: `${v.view}-caption`, + view: v.view, + kind: "caption", + axis: null, + featureIds: [], + valueMm: null, + text: captionText, + lineSegments: [], + labelRect: captionRect, + }); + + viewLayouts[v.view] = { + view: v.view, + silhouetteRect, + dimensions: records, + edgeRuns, + }; + + // Full content bounds for this view - the ONE canonical + // content-measurement function (see its own doc comment), used + // identically here (for the scale-selection fit-check) and by + // sheet-checker.ts's frame-containment check (task 2) against the + // FINAL delivered sheet - never two different notions of "the + // content". + return computeViewContentBounds(viewLayouts[v.view]); + }; + + const contentBounds: ViewContentBounds = { + front: drawCell(frontV, frontCellX, frontCellY, frontImgW, frontImgH), + top: drawCell(topV, topCellX, topCellY, topImgW, topImgH), + right: drawCell(rightV, rightCellX, rightCellY, rightImgW, rightImgH), + }; + + // --- Isometric reference view (top-right corner) ------------------ + // Third-angle projection leaves exactly one quadrant of the 2x2 view + // grid empty - above Right, beside Top - which is the conventional home + // for an undimensioned isometric. Placed from the REAL measured content + // bounds of its two neighbors rather than from their nominal cells, so + // it clears whatever those views actually drew (a size callout swung + // outward, a depth-dimension lane column) instead of a guess about it. + // + // The VERTICAL band is the sizing rule. Its BOTTOM edge is a hard + // bound - exactly Right's own top edge, less the standard inter-view + // gap - so a bigger iso can never crowd Right (task 5: "keep it clear + // of... other content"). Its un-boosted height is exactly Top's own + // content band (Top's top edge down to that same bottom bound); + // ISO_SIZE_BOOST enlarges the box from there by extending the TOP edge + // further upward - never downward, which is what keeps Right untouched - + // clamped to FRAME_SAFE_AREA's own top bound so growth can never cross + // the frame margin either (task 5: "keep it clear of the frame + // margin"). It lands bottom-aligned with Top's row, growing upward, at + // a size proportional to the rest of the drawing rather than an + // arbitrary fraction of the page - readable, and visibly subordinate to + // the dimensioned views. + // + // Because the boosted growth extends above Top's own content, it DOES + // count toward the sheet's total content height (honestly unioned into + // trueContentBox below, same as every other axis - task 5: "keep it + // included in content-bounds calculations"): unlike the un-boosted + // size, a big enough iso genuinely can cost a scale step for a part + // whose content already fills the page vertically. The existing + // overflow guard/Auto step-down handles that exactly like any other + // content growth, which is why it's safe to measure honestly here + // rather than needing its own carve-out. + // + // Width is then just whatever the (boosted) band height needs at the + // capture's own aspect ratio, clamped to the width the sheet still has + // spare (total content width may not exceed drawArea.w - measured as a + // SIZE, not against an absolute page coordinate, since the whole + // composition gets centered afterwards). Like height, that width growth + // is real, which is exactly why the iso is unioned into trueContentBox + // below and judged by the overflow guard like everything else. + const unionOf = (rects: Rect[]): Rect => { + const minX = Math.min(...rects.map((r) => r.x)); + const minY = Math.min(...rects.map((r) => r.y)); + const maxX = Math.max(...rects.map((r) => r.x + r.w)); + const maxY = Math.max(...rects.map((r) => r.y + r.h)); + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; + }; + const isoBoxFrom = ( + topRect: Rect, + rightRect: Rect, + viewsBox: Rect, + ): Rect | null => { + if (!isoSrcRect) return null; + const x = topRect.x + topRect.w + CROSS_VIEW_GAP_PX; + const bottom = rightRect.y - CROSS_VIEW_GAP_PX; + const bandH = bottom - topRect.y; + const y = Math.max(FRAME_SAFE_AREA.y, bottom - bandH * ISO_SIZE_BOOST); + const h = bottom - y; + const widthBudgetPx = viewsBox.x + drawArea.w - x; + const w = Math.min(h * (isoSrcRect.w / isoSrcRect.h), widthBudgetPx); + if (w < ISO_MIN_SIDE_PX || h < ISO_MIN_SIDE_PX) return null; + return { x, y, w, h }; + }; + /** The iso raster's own aspect ratio, fitted inside `box` and centered - + * "fill the corner without crowding", never stretched. */ + const fitIsoInto = (box: Rect, src: Rect): Rect => { + const scale = Math.min(box.w / src.w, box.h / src.h); + const w = src.w * scale; + const h = src.h * scale; + return { + x: box.x + (box.w - w) / 2, + y: box.y + (box.h - h) / 2, + w, + h, + }; + }; + + let isoView: IsoViewLayout | null = null; + if (isoImg && isoSrcRect) { + const box = isoBoxFrom( + contentBounds.top, + contentBounds.right, + unionOf([contentBounds.front, contentBounds.top, contentBounds.right]), + ); + if (box) { + const destRect = fitIsoInto(box, isoSrcRect); + ctx.drawImage( + isoImg, + isoSrcRect.x, + isoSrcRect.y, + isoSrcRect.w, + isoSrcRect.h, + destRect.x, + destRect.y, + destRect.w, + destRect.h, + ); + isoView = { img: isoImg, srcRect: isoSrcRect, destRect }; + } + } + + // Real fit check, from what was ACTUALLY drawn - never the nominal + // estimate above, and never this trial's raw on-sheet coordinates or + // raw gaps either (see this function's doc comment for why: judging + // fit at an arbitrary/loosely-spaced nominal layout is exactly the bug + // task 2 exists to kill). Position-INDEPENDENT facts only. + const topFrontGap = + contentBounds.front.y - (contentBounds.top.y + contentBounds.top.h); + const frontRightGap = + contentBounds.right.x - (contentBounds.front.x + contentBounds.front.w); + + // Reclaimable slack in each gap - see gapTightenVPx/HPx's doc comment + // on ScaleCandidateLogEntry. 0 when a gap is already at (or under) the + // required minimum, never negative (tightening only ever REMOVES + // slack, it never manufactures missing clearance). + const gapTightenV = Math.max(0, topFrontGap - CROSS_VIEW_GAP_PX); + const gapTightenH = Math.max(0, frontRightGap - CROSS_VIEW_GAP_PX); + + // The TIGHTENED per-view rects - Front (and Right, which always tracks + // Front's own Y) shifted up by gapTightenV, Right additionally shifted + // left by gapTightenH, Top left exactly where it was. This is the same + // rigid per-view shift composeA4DrawingSheet's final render pass + // applies for real (see correction.gapTightenV/H above) - computed + // here arithmetically, from this one trial's measurements, so the fit + // decision reflects the TRUE achievable layout without needing a + // second render just to evaluate it. + const tightFront: Rect = { + ...contentBounds.front, + y: contentBounds.front.y - gapTightenV, + }; + const tightRight: Rect = { + ...contentBounds.right, + x: contentBounds.right.x - gapTightenH, + y: contentBounds.right.y - gapTightenV, + }; + const tightTop = contentBounds.top; + // The iso's box is derived from its neighbors' content rects, so its + // TIGHTENED position is that same derivation applied to the tightened + // rects - not the drawn rect above shifted by one of the two tighten + // amounts (it's anchored to Top on one axis and Right on the other, so + // no single rigid shift is correct for it). In the final delivered + // render both tighten amounts are ~0 and the two coincide exactly. + const tightIso: Rect | null = (() => { + if (!isoSrcRect || !isoView) return null; + const box = isoBoxFrom( + tightTop, + tightRight, + unionOf([tightFront, tightTop, tightRight]), + ); + return box ? fitIsoInto(box, isoSrcRect) : null; + })(); + const viewsOnlyContentBox = unionOf([tightFront, tightTop, tightRight]); + const trueContentBox: Rect = (() => { + const isoRects = tightIso ? [tightIso] : []; + const minX = Math.min(tightFront.x, tightTop.x, tightRight.x, ...isoRects.map((r) => r.x)); + const minY = Math.min(tightFront.y, tightTop.y, tightRight.y, ...isoRects.map((r) => r.y)); + const maxX = Math.max( + tightFront.x + tightFront.w, + tightTop.x + tightTop.w, + tightRight.x + tightRight.w, + ...isoRects.map((r) => r.x + r.w), + ); + const maxY = Math.max( + ...isoRects.map((r) => r.y + r.h), + tightFront.y + tightFront.h, + tightTop.y + tightTop.h, + tightRight.y + tightRight.h, + ); + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; + })(); + + const FIT_TOLERANCE_PX = 1; + const measurements: ScaleFitMeasurement[] = [ + { + check: "combined content width vs. usable sheet width (after gap tightening)", + measuredPx: trueContentBox.w, + boundPx: drawArea.w, + comparison: "<=", + ok: trueContentBox.w <= drawArea.w + FIT_TOLERANCE_PX, + shortfallPx: Math.max(0, trueContentBox.w - drawArea.w), + }, + { + check: "combined content height vs. usable sheet height (after gap tightening)", + measuredPx: trueContentBox.h, + boundPx: drawArea.h, + comparison: "<=", + ok: trueContentBox.h <= drawArea.h + FIT_TOLERANCE_PX, + shortfallPx: Math.max(0, trueContentBox.h - drawArea.h), + }, + { + check: "Top-Front cross-view gap (pre-tightening, i.e. a genuine deficit if this fails)", + measuredPx: topFrontGap, + boundPx: CROSS_VIEW_GAP_PX, + comparison: ">=", + ok: topFrontGap >= CROSS_VIEW_GAP_PX - FIT_TOLERANCE_PX, + shortfallPx: Math.max(0, CROSS_VIEW_GAP_PX - topFrontGap), + }, + { + check: "Front-Right cross-view gap (pre-tightening, i.e. a genuine deficit if this fails)", + measuredPx: frontRightGap, + boundPx: CROSS_VIEW_GAP_PX, + comparison: ">=", + ok: frontRightGap >= CROSS_VIEW_GAP_PX - FIT_TOLERANCE_PX, + shortfallPx: Math.max(0, CROSS_VIEW_GAP_PX - frontRightGap), + }, + ]; + // A true content box that fits drawArea's SIZE can always be centered + // to actually land inside it, and a gap that's already >= the minimum + // can always be tightened to exactly the minimum without colliding + // anything (see doc comment) - so, unlike an absolute-position check, + // this is a proof, not an estimate. + const fits = measurements.every((m) => m.ok); + const candidateLogWithoutSummary: Omit = { + ratio: chosenRatio, + scaleLabel, + role, + contentBoundsPx: contentBounds, + gapTightenVPx: gapTightenV, + gapTightenHPx: gapTightenH, + trueContentBoxPx: trueContentBox, + viewsOnlyContentBoxPx: viewsOnlyContentBox, + drawAreaPx: drawArea, + measurements, + fits, + }; + const candidateLog: ScaleCandidateLogEntry = { + ...candidateLogWithoutSummary, + summary: formatCandidateSummary(candidateLogWithoutSummary), + }; + + drawSheetTitleBlock(ctx, { partName, date, scaleLabel }); + + return { + dataURL: canvas.toDataURL("image/png"), + scaleLabel, + viewLayouts, + isoView, + log: candidateLog, + }; + } + + // --- Two independent checks, both must pass ----------------------------- + // See this module's own "Scale selection" doc comment above for both + // rules. 1:1 is tried first; if either check fails on either axis, step + // down the standard reduction series and use the first ratio where all + // four axes hold. Every candidate tried is logged with its view-outline + // totals (width/height, vs. the 250/145mm limits) AND its overflow-guard + // totals (overflowWidth/overflowHeight, vs. the real ~267/190mm usable- + // page limits) - see SizeRuleCandidate - kept as fully separate checks so + // it's always clear which one (if either) drove a rejection. + // + // `evaluateCandidate` is the one place either the auto search loop below + // OR a manual override (manualRatio, see A4SheetInput's doc comment) goes + // to actually render+measure a ratio - so a manual pick is judged by + // exactly the same two checks/numbers a candidate in the auto search + // would be, never a separate/looser path. + const roleForRatio = (ratio: number): ScaleCandidateRole => + ratio === 1 ? "anchor" : ratio > 1 ? "enlargement" : "reduction"; + const evaluateCandidate = (ratio: number) => { + const role = roleForRatio(ratio); + const trial = attemptAtRatio(ratio, role); + // Check (a): view-outline rule - analytic, from the part's bounding box. + const widthTotalMm = + (modelBoundsMm.x + modelBoundsMm.z) * ratio + VIEW_GROUP_GAP_MM; + const heightTotalMm = + (modelBoundsMm.y + modelBoundsMm.z) * ratio + VIEW_GROUP_GAP_MM; + const width: SizeRuleAxisResult = { + totalMm: widthTotalMm, + limitMm: SIZE_RULE_WIDTH_LIMIT_MM, + ok: widthTotalMm <= SIZE_RULE_WIDTH_LIMIT_MM, + }; + const height: SizeRuleAxisResult = { + totalMm: heightTotalMm, + limitMm: SIZE_RULE_HEIGHT_LIMIT_MM, + ok: heightTotalMm <= SIZE_RULE_HEIGHT_LIMIT_MM, + }; + // Check (b): overflow guard - real rendered content vs. the real page. + const overflowWidthMm = trial.log.trueContentBoxPx.w / SHEET_PX_PER_MM; + const overflowHeightMm = trial.log.trueContentBoxPx.h / SHEET_PX_PER_MM; + const overflowWidth: SizeRuleAxisResult = { + totalMm: overflowWidthMm, + limitMm: OVERFLOW_GUARD_WIDTH_LIMIT_MM, + ok: overflowWidthMm <= OVERFLOW_GUARD_WIDTH_LIMIT_MM, + }; + const overflowHeight: SizeRuleAxisResult = { + totalMm: overflowHeightMm, + limitMm: OVERFLOW_GUARD_HEIGHT_LIMIT_MM, + ok: overflowHeightMm <= OVERFLOW_GUARD_HEIGHT_LIMIT_MM, + }; + const overflowWidthWithoutIsoMm = + trial.log.viewsOnlyContentBoxPx.w / SHEET_PX_PER_MM; + const overflowWidthWithoutIso: SizeRuleAxisResult = { + totalMm: overflowWidthWithoutIsoMm, + limitMm: OVERFLOW_GUARD_WIDTH_LIMIT_MM, + ok: overflowWidthWithoutIsoMm <= OVERFLOW_GUARD_WIDTH_LIMIT_MM, + }; + // BOTH checks (all four axes) must pass - either one failing rejects + // the candidate (see this module's own "Scale selection" doc comment). + const fits = width.ok && height.ok && overflowWidth.ok && overflowHeight.ok; + const candidate: SizeRuleCandidate = { + ratio, + scaleLabel: formatScaleLabel(ratio), + width, + height, + overflowWidth, + overflowHeight, + overflowWidthWithoutIso, + fits, + }; + return { trial, candidate }; + }; + + const sizeRuleCandidates: SizeRuleCandidate[] = []; + let chosenRatio = SCALE_STEPS[SCALE_STEPS.length - 1]; + let chosenFound = false; + // The chosen ratio's own nominal trial render, captured directly from + // this loop - reused below as `winner` instead of re-rendering, since + // every candidate is now actually rendered to decide fit in the first + // place. Always ends up set to exactly the chosen ratio's trial: the loop + // only breaks early on the candidate that becomes chosenRatio, and + // otherwise runs to the last (smallest) ratio, which is what the + // !chosenFound fallback below also picks. + let lastTrial: ReturnType | null = null; + if (manualRatio != null) { + // Manual override (modal "Scale" dropdown, anything but "Auto") - the + // automatic search below is skipped entirely (it "stays completely + // unchanged" for Auto - see A4SheetInput's doc comment); this ratio is + // forced regardless of what either check says. Still actually + // rendered+measured via the SAME evaluateCandidate every auto candidate + // uses, so sizeRuleCandidates/chosenSizeRule below reflect the real + // numbers for this ratio (used to build the overflow warning further + // down), not a skipped/estimated check. + const { trial, candidate } = evaluateCandidate(manualRatio); + lastTrial = trial; + sizeRuleCandidates.push(candidate); + chosenRatio = manualRatio; + chosenFound = true; + } else { + for (const ratio of SCALE_STEPS) { + const { trial, candidate } = evaluateCandidate(ratio); + lastTrial = trial; + sizeRuleCandidates.push(candidate); + if (candidate.fits) { + chosenRatio = ratio; + chosenFound = true; + break; + } + } + // Never a hard failure - fall back to the smallest standard ratio if + // literally nothing in the series satisfies both axes (an extremely + // large part; still the best available option). + if (!chosenFound) chosenRatio = SCALE_STEPS[SCALE_STEPS.length - 1]; + } + const chosenRole: ScaleCandidateRole = roleForRatio(chosenRatio); + const chosenSizeRule = sizeRuleCandidates[sizeRuleCandidates.length - 1]; + + // --- Final corrected render (nominal trial, then centered/tightened) -- + // The chosen ratio's own nominal trial - see attemptAtRatio's doc comment + // - was already rendered by the size-rule search loop above (lastTrial); + // reused directly here rather than re-rendering. Once its TRUE (already + // tightened) content box is known, re-render exactly once more with BOTH + // the same gap-tightening amounts applied for real AND the centering + // offset that actually places that true box in drawArea, so the delivered + // sheet is never positioned - or spaced - from a guess. + const winner = lastTrial!; + const winnerRole = chosenRole; + const winnerBox = winner.log.trueContentBoxPx; + const centerOffset = clampCenterOffsetToFrame(winnerBox, { + x: drawArea.x + (drawArea.w - winnerBox.w) / 2 - winnerBox.x, + y: drawArea.y + (drawArea.h - winnerBox.h) / 2 - winnerBox.y, + }); + let finalRender = attemptAtRatio(winner.log.ratio, winnerRole, { + centerOffset, + gapTightenV: winner.log.gapTightenVPx, + gapTightenH: winner.log.gapTightenHPx, + }); + + // --- Task 1(a): post-render remedy pass (see the doc comment above + // findDimensionCrossings) - bounded, deterministic, and re-verified + // ("only accept if it's actually clean, don't assume"). Direction + // change: this is the ONLY remedy left - a dimension still crossing + // something after it (and after the always-on jog remedy (b)) is + // rendered as-is, a normal direct dimension line with its real value, + // never diverted to a tag or reference table. + let crossings = findDimensionCrossings(finalRender.viewLayouts); + let keptDepthOrientationOverrides = new Map(); + + // Remedy (a): a depth dimension involved in a crossing gets reassigned to + // its alternate valid edge (its OTHER screen orientation - see + // depthDimensionOrientation) and the whole sheet re-rendered once more at + // the same winning ratio/centering; kept only if it strictly reduced the + // number of crossing dimensions, never assumed to have helped. Only a + // dimension whose alternate axis is actually a VALID reading is eligible + // - depthDimensionOrientation picks whichever axis its near/far points + // differ along MORE, and forcing the other one for a near/far pair that's + // near-degenerate along it (e.g. differs by a fraction of a pixel) + // produces a near-zero-height/width dimension line with an absurdly long + // extension-line reach to the far edge's lane column, not a valid + // alternate rendering - see MIN_ALT_ORIENTATION_FRACTION below. + const MIN_ALT_ORIENTATION_FRACTION = 0.2; + const depthCrossingFeatureIds = new Set( + crossings + .filter((c) => c.record.kind === "depth" || c.record.kind === "depth-ordinate") + .flatMap((c) => c.record.featureIds), + ); + if (depthCrossingFeatureIds.size > 0) { + const depthOrientationOverrides = new Map(); + for (const c of crossings) { + const r = c.record; + if (r.kind !== "depth" && r.kind !== "depth-ordinate") continue; + const currentAxis = r.axis === "vertical" ? "vertical" : "horizontal"; + for (const featureId of r.featureIds) { + const annotation = (axialDepthAnnotations[r.view] ?? []).find((a) => a.featureId === featureId); + if (!annotation) continue; + const deltaX = Math.abs(annotation.nearPx.x - annotation.farPx.x); + const deltaY = Math.abs(annotation.nearPx.y - annotation.farPx.y); + const minDelta = Math.min(deltaX, deltaY); + const maxDelta = Math.max(deltaX, deltaY); + if (maxDelta === 0 || minDelta < MIN_ALT_ORIENTATION_FRACTION * maxDelta) continue; + depthOrientationOverrides.set(featureId, currentAxis === "horizontal" ? "vertical" : "horizontal"); + } + } + if (depthOrientationOverrides.size === 0) { + console.log( + `[2D Drawing] Remedy (a) not attempted - ${depthCrossingFeatureIds.size} depth dimension(s) crossed ` + + `something, but none has a geometrically valid alternate axis (its near/far points are too close to ` + + `degenerate along the other axis) - rendered as direct dimension lines regardless.`, + ); + } else { + const reassigned = attemptAtRatio( + winner.log.ratio, + winnerRole, + { centerOffset, gapTightenV: winner.log.gapTightenVPx, gapTightenH: winner.log.gapTightenHPx }, + { depthOrientationOverrides }, + ); + const reassignedCrossings = findDimensionCrossings(reassigned.viewLayouts); + if (reassignedCrossings.length < crossings.length) { + console.log( + `[2D Drawing] Remedy (a) - reassigned ${depthOrientationOverrides.size} depth dimension(s) to their ` + + `alternate edge: crossing dimension count ${crossings.length} -> ${reassignedCrossings.length}.`, + ); + finalRender = reassigned; + crossings = reassignedCrossings; + keptDepthOrientationOverrides = depthOrientationOverrides; + } else { + console.log( + `[2D Drawing] Remedy (a) tried (${depthOrientationOverrides.size} depth dimension(s) reassigned) but ` + + `did not reduce the crossing count (${crossings.length} -> ${reassignedCrossings.length}) - reverted.`, + ); + } + } + } + if (crossings.length > 0) { + console.log( + `[2D Drawing] ${crossings.length} dimension(s) still cross something after remedy (a) - rendered as direct ` + + `dimension lines with their real values regardless (no tag/reference-table fallback).`, + ); + } + + // Final re-centering/re-tightening pass: remedy (a) above (if it fired) + // can shift where the sheet's TRUE content actually sits - a reassigned + // depth dimension moves to a different edge entirely - so the + // centerOffset/gapTighten computed from the WINNER's pre-remedy trial + // (see above) no longer necessarily center/tighten what's actually being + // delivered. Fixed with the SAME two-step "nominal trial, then real + // render" recipe already used to finalize the winning scale candidate + // above (see winnerBox/centerOffset) - re-applied here for the FINAL + // remedies configuration: a zero-baseline trial (gapTightenV/H: 0) + // measures this exact remedies config's own true content box from a + // clean slate, THEN one real render applies the centerOffset/gapTighten + // that trial reveals. A pure center+tighten re-render can only ever + // rigidly translate/uniformly-compress inter-view gaps in the whole + // composition (never changes any WITHIN-view spacing), so it can neither + // create nor remove a line/label crossing - this pass never needs to + // re-verify crossings the way remedy (a) does. + if (keptDepthOrientationOverrides.size > 0) { + const finalRemedies = { depthOrientationOverrides: keptDepthOrientationOverrides }; + const trial = attemptAtRatio( + winner.log.ratio, + winnerRole, + { centerOffset: { x: 0, y: 0 }, gapTightenV: 0, gapTightenH: 0 }, + finalRemedies, + ); + const trueBox = trial.log.trueContentBoxPx; + const recentered = clampCenterOffsetToFrame(trueBox, { + x: drawArea.x + (drawArea.w - trueBox.w) / 2 - trueBox.x, + y: drawArea.y + (drawArea.h - trueBox.h) / 2 - trueBox.y, + }); + finalRender = attemptAtRatio( + winner.log.ratio, + winnerRole, + { centerOffset: recentered, gapTightenV: trial.log.gapTightenVPx, gapTightenH: trial.log.gapTightenHPx }, + finalRemedies, + ); + } + + // Real measured totals from the ACTUALLY delivered sheet - dimension + // lines, extension lines, and labels included, not just the view + // outlines. This is the FINAL post-remedy/post-centering number (see + // ScaleSelectionResult.renderedWidthMm's own doc comment for how it can + // differ, in principle, from chosenSizeRule.renderedWidth - the same + // measurement taken from the chosen ratio's pre-remedy nominal trial, + // which is what actually decided the scale). + const finalFrontContent = computeViewContentBounds(finalRender.viewLayouts.front); + const finalTopContent = computeViewContentBounds(finalRender.viewLayouts.top); + const finalRightContent = computeViewContentBounds(finalRender.viewLayouts.right); + // The isometric reference view counts as delivered content here for the + // same reason it's inside the candidates' own trueContentBoxPx: it's drawn + // on the sheet, so a number reported as "the delivered sheet's width" that + // silently omitted it would be measuring a different drawing than the one + // the overflow guard judged. It's the rightmost thing on the sheet when + // present (it lives in the top-right corner), and it can never be the + // topmost or bottommost - its vertical band is bounded by Top's and Right's + // own content, by construction (see isoBoxFrom). + const finalIsoRight = finalRender.isoView + ? finalRender.isoView.destRect.x + finalRender.isoView.destRect.w + : -Infinity; + const renderedWidthMm = + (Math.max(finalRightContent.x + finalRightContent.w, finalIsoRight) - + finalFrontContent.x) / + SHEET_PX_PER_MM; + const renderedHeightMm = + (finalFrontContent.y + finalFrontContent.h - finalTopContent.y) / SHEET_PX_PER_MM; + + const scaleSelection: ScaleSelectionResult = { + sizeRuleCandidates, + chosenRatio: winner.log.ratio, + chosenScaleLabel: winner.log.scaleLabel, + chosenRole: winnerRole, + chosenSizeRule, + renderedWidthMm, + renderedHeightMm, + }; + + // Manual-override overflow warning (task: "warn, don't block") - built + // from the SAME final post-remedy/centering numbers as the diagnostic log + // below, against the SAME overflow-guard limits Auto's own search enforces + // (see ScaleOverflowWarning's doc comment), so this is never a separate/ + // looser measurement than what actually decided Auto's own candidates. + // Deliberately gated on manualRatio (not on renderedWidth/HeightMm vs. the + // limits) - Auto's fallback ("never a hard failure", see the search above) + // could in principle still miss a limit for a pathologically large part, + // and that's a silent gap worth knowing about via the console log, not a + // user-facing warning Auto is specified to never produce. + let overflowWarning: ScaleOverflowWarning | null = null; + if (manualRatio != null) { + const OVERFLOW_EPS_MM = 0.05; + const widthExceedsMm = Math.max(0, renderedWidthMm - OVERFLOW_GUARD_WIDTH_LIMIT_MM); + const heightExceedsMm = Math.max(0, renderedHeightMm - OVERFLOW_GUARD_HEIGHT_LIMIT_MM); + if (widthExceedsMm > OVERFLOW_EPS_MM || heightExceedsMm > OVERFLOW_EPS_MM) { + const parts: string[] = []; + if (widthExceedsMm > OVERFLOW_EPS_MM) parts.push(`width by ${widthExceedsMm.toFixed(1)}mm`); + if (heightExceedsMm > OVERFLOW_EPS_MM) parts.push(`height by ${heightExceedsMm.toFixed(1)}mm`); + overflowWarning = { + widthExceedsMm, + heightExceedsMm, + message: `Content exceeds sheet ${parts.join(" and ")} at this scale.`, + }; + } + } + + // Diagnostic logging - unconditional, not gated behind a debug flag, so + // this is visible in the console on every real run, not just while + // debugging. + console.groupCollapsed( + `[2D Drawing] Scale selection for "${partName}": chose ${scaleSelection.chosenScaleLabel} (${scaleSelection.chosenRole})` + + `${manualRatio != null ? " [manual override]" : ""}`, + ); + for (const c of sizeRuleCandidates) { + console.log( + `${c.scaleLabel}: [view-outline rule] width ${c.width.totalMm.toFixed(1)}mm vs limit ${c.width.limitMm}mm ` + + `(${c.width.ok ? "PASS" : "FAIL"}), height ${c.height.totalMm.toFixed(1)}mm vs limit ` + + `${c.height.limitMm}mm (${c.height.ok ? "PASS" : "FAIL"}) - ${c.width.ok && c.height.ok ? "PASS" : "FAIL"}; ` + + `[overflow guard] width ${c.overflowWidth.totalMm.toFixed(1)}mm vs limit ${c.overflowWidth.limitMm.toFixed(1)}mm ` + + `(${c.overflowWidth.ok ? "PASS" : "FAIL"}), height ${c.overflowHeight.totalMm.toFixed(1)}mm ` + + `vs limit ${c.overflowHeight.limitMm.toFixed(1)}mm (${c.overflowHeight.ok ? "PASS" : "FAIL"}) - ` + + `${c.overflowWidth.ok && c.overflowHeight.ok ? "PASS" : "FAIL"}` + + `${ + c.overflowWidth.totalMm - c.overflowWidthWithoutIso.totalMm > 0.05 + ? ` [iso reference view adds ${(c.overflowWidth.totalMm - c.overflowWidthWithoutIso.totalMm).toFixed(1)}mm of width; ` + + `without it ${c.overflowWidthWithoutIso.totalMm.toFixed(1)}mm (${c.overflowWidthWithoutIso.ok ? "PASS" : "FAIL"}) - ` + + `${c.overflowWidth.ok === c.overflowWidthWithoutIso.ok ? "same verdict either way" : "IT CHANGED THE VERDICT"}]` + : "" + } - ` + + `${c.fits ? "FITS (both checks pass)" : "rejected"}.`, + ); + } + console.log( + `[2D Drawing] Final delivered sheet (post-remedy/centering): width ${renderedWidthMm.toFixed(1)}mm, height ` + + `${renderedHeightMm.toFixed(1)}mm vs overflow-guard limits ${OVERFLOW_GUARD_WIDTH_LIMIT_MM.toFixed(1)}mm/` + + `${OVERFLOW_GUARD_HEIGHT_LIMIT_MM.toFixed(1)}mm.`, + ); + if (overflowWarning) { + console.log(`[2D Drawing] ${overflowWarning.message}`); + } + console.groupEnd(); + + return { + dataURL: finalRender.dataURL, + scaleLabel: finalRender.scaleLabel, + layoutModel: { + views: finalRender.viewLayouts, + isoView: finalRender.isoView, + }, + plan, + overflowWarning, + scaleSelection, + }; +} diff --git a/src/components/cad/sheet-dimension-plan.ts b/src/components/cad/sheet-dimension-plan.ts new file mode 100644 index 0000000..9862d7d --- /dev/null +++ b/src/components/cad/sheet-dimension-plan.ts @@ -0,0 +1,233 @@ +import type { + HiddenLineAxialDepthAnnotation, + HiddenLineCircularAnnotation, + HiddenLineViewName, + HiddenLineViewSetResult, +} from "./viewer"; + +/** + * Sheet-wide dimension planning - a separate stage that runs BEFORE any + * view-specific rendering, deciding once and for all which single (view, + * axis) slot each feature's each measurement belongs to. Exists because + * per-view rendering code used to make this decision independently per + * view with no shared bookkeeping: Front's generic "dimension every + * representative circle's own two screen axes" path and Right's dedicated + * "stepped hole axial location" path both, correctly by their own local + * logic, decided to dimension the SAME stepped hole's Y-coordinate - + * producing two "49.5mm" labels for one real measurement. A plan built + * once, with one shared claimed-axes set, makes that structurally + * impossible: sheet-composer.ts's renderer now only reads its own slice of + * the finished plan and lays it out - it has no authority to decide a + * feature needs a dimension on its own. + */ + +export type WorldAxis = "X" | "Y" | "Z"; + +export type PlannedSizeMeasurement = { + id: string; + featureId: string; + view: HiddenLineViewName; +}; + +export type PlannedLocationMeasurement = { + id: string; + featureId: string; + worldAxis: WorldAxis; + view: HiddenLineViewName; + screenAxis: "horizontal" | "vertical"; + /** Which raw per-view data this measurement's screen position comes from: + * "circular" - the feature's own true, face-on circle (its centerPx) in + * this view, the PRIMARY/preferred source, most legible since the + * feature reads as an actual circle there. "axial-near" - a stepped + * hole's edge-on projection (nearPx) in a view where it doesn't read as + * a circle at all, a FALLBACK used only when no view's face-on circle + * already covers this feature+axis (see buildDimensionPlan). */ + positionSource: "circular" | "axial-near"; +}; + +export type PlannedDepthMeasurement = { + id: string; + featureId: string; + view: HiddenLineViewName; +}; + +export type DimensionPlan = { + size: PlannedSizeMeasurement[]; + location: PlannedLocationMeasurement[]; + depth: PlannedDepthMeasurement[]; +}; + +/** Which world axis each view's screen horizontal/vertical corresponds to, + * per this app's fixed third-angle Front/Top/Right arrangement (see + * composeA4DrawingSheet's viewBoxes): Front is X-wide/Y-tall, Top is + * X-wide/Z-tall, Right is Z-wide/Y-tall. */ +export const VIEW_AXES: Record< + HiddenLineViewName, + { horizontal: WorldAxis; vertical: WorldAxis } +> = { + front: { horizontal: "X", vertical: "Y" }, + top: { horizontal: "X", vertical: "Z" }, + right: { horizontal: "Z", vertical: "Y" }, +}; + +const VIEW_ORDER: HiddenLineViewName[] = ["front", "top", "right"]; + +/** Resolves a featureId to its dedup group's representative by searching + * every view's circularAnnotations (a stepped hole is only ever face-on/ + * recognized in whichever ONE view its axis points along, but which view + * that is isn't assumed here). Falls back to the featureId itself if it's + * not found anywhere (never grouped), so an ungrouped feature is always + * treated as its own representative. */ +export function circularFeatureRepresentative( + circularAnnotations: Record, + featureId: string, +): string { + for (const view of Object.keys(circularAnnotations) as HiddenLineViewName[]) { + const found = circularAnnotations[view].find((a) => a.featureId === featureId); + if (found) return found.groupRepresentativeFeatureId; + } + return featureId; +} + +/** Concentric circles (same center, different radii - e.g. a round part's + * OD and bore, both dimensioned representatives of their own size group) + * all share one identical location by construction: once the largest gets + * a location dimension, dimensioning the others' "distance from edge" + * again says nothing new (their position is already fully implied) - the + * same reasoning that already exempts arcs/fillets from a location + * dimension of their own. Keeps only the largest-radius circle per + * distinct center point, among this view's location-eligible (circle, + * representative) candidates. */ +function largestPerCenter( + candidates: HiddenLineCircularAnnotation[], +): HiddenLineCircularAnnotation[] { + const byCenter = new Map(); + for (const c of candidates) { + const key = `${Math.round(c.centerPx.x)},${Math.round(c.centerPx.y)}`; + const g = byCenter.get(key); + if (g) g.push(c); + else byCenter.set(key, [c]); + } + return [...byCenter.values()].map((group) => + group.reduce((best, item) => (item.radiusMm > best.radiusMm ? item : best)), + ); +} + +function isSteppedRepresentative( + circularAnnotations: Record, + a: HiddenLineAxialDepthAnnotation, +): boolean { + return circularFeatureRepresentative(circularAnnotations, a.featureId) === a.featureId; +} + +/** + * Builds the sheet-wide plan. Every claim below (location AND depth) shares + * ONE (featureId, worldAxis) claimed-set, so a world axis measured any way + * for a feature - a plain from-edge location OR a stepped hole's own + * through-depth - satisfies that feature's need for that axis and can never + * be independently re-added by a later, less-preferred source. + * + * Claim order (most-authoritative first): + * 1. size - whichever view already shows the feature as a true, face-on + * circle/arc. + * 2. location, PRIMARY - both of a face-on circle's own screen axes, + * claimed together from that same view (the most legible place to read + * a hole's position: an actual circle, not an edge-on sliver). + * 3. depth - a stepped hole's through-axis dimension, fixed to Top by + * drafting convention; claims that feature's Z need outright. + * 4. location, FALLBACK - a stepped hole's edge-on projection (Top/Right) + * supplies a location for whichever axis wasn't already claimed above - + * only actually fires when nothing face-on already covered it, which is + * exactly why this used to duplicate Front's Y-location on Right: the + * old Right-side code had no idea Front had already claimed it. + */ +export function buildDimensionPlan(captureResult: HiddenLineViewSetResult): DimensionPlan { + const { circularAnnotations, axialDepthAnnotations } = captureResult; + + const size: PlannedSizeMeasurement[] = []; + const sizeClaimed = new Set(); + for (const view of VIEW_ORDER) { + for (const a of circularAnnotations[view]) { + if (!a.sizeLabel) continue; + if (sizeClaimed.has(a.featureId)) continue; + sizeClaimed.add(a.featureId); + size.push({ id: `size-${a.featureId}`, featureId: a.featureId, view }); + } + } + + const location: PlannedLocationMeasurement[] = []; + const depth: PlannedDepthMeasurement[] = []; + const axisClaimed = new Set(); + const claim = (featureId: string, axis: WorldAxis): boolean => { + const key = `${featureId}|${axis}`; + if (axisClaimed.has(key)) return false; + axisClaimed.add(key); + return true; + }; + + for (const view of VIEW_ORDER) { + const axes = VIEW_AXES[view]; + // Same skip rules the old per-view logic used: arcs/fillets (position + // implied by tangency), non-representative same-size-group members + // (position implied by the representative already dimensioned), and + // concentric circles sharing one center (position implied by whichever + // one - the largest - gets dimensioned; see largestPerCenter). + const locationEligible = largestPerCenter( + circularAnnotations[view].filter((a) => a.kind === "circle" && a.sizeLabel !== null), + ); + for (const a of locationEligible) { + if (claim(a.featureId, axes.horizontal)) { + location.push({ + id: `loc-${a.featureId}-${axes.horizontal}`, + featureId: a.featureId, + worldAxis: axes.horizontal, + view, + screenAxis: "horizontal", + positionSource: "circular", + }); + } + if (claim(a.featureId, axes.vertical)) { + location.push({ + id: `loc-${a.featureId}-${axes.vertical}`, + featureId: a.featureId, + worldAxis: axes.vertical, + view, + screenAxis: "vertical", + positionSource: "circular", + }); + } + } + } + + for (const a of axialDepthAnnotations.top ?? []) { + if (!isSteppedRepresentative(circularAnnotations, a)) continue; + if (claim(a.featureId, "Z")) { + depth.push({ id: `depth-${a.featureId}`, featureId: a.featureId, view: "top" }); + } + } + + for (const view of ["top", "right"] as HiddenLineViewName[]) { + const axes = VIEW_AXES[view]; + for (const a of axialDepthAnnotations[view] ?? []) { + if (!isSteppedRepresentative(circularAnnotations, a)) continue; + const candidates: { screenAxis: "horizontal" | "vertical"; worldAxis: WorldAxis }[] = [ + { screenAxis: "horizontal", worldAxis: axes.horizontal }, + { screenAxis: "vertical", worldAxis: axes.vertical }, + ]; + for (const { screenAxis, worldAxis } of candidates) { + if (claim(a.featureId, worldAxis)) { + location.push({ + id: `loc-${a.featureId}-${worldAxis}-fallback`, + featureId: a.featureId, + worldAxis, + view, + screenAxis, + positionSource: "axial-near", + }); + } + } + } + } + + return { size, location, depth }; +} diff --git a/src/components/cad/sheet-interactive-render.ts b/src/components/cad/sheet-interactive-render.ts new file mode 100644 index 0000000..6d55311 --- /dev/null +++ b/src/components/cad/sheet-interactive-render.ts @@ -0,0 +1,1325 @@ +/** + * Cheap, drag-frame-safe repaint layer for the composed 2D drawing sheet. + * + * composeA4DrawingSheet (sheet-composer.ts) is expensive and delicate: scale + * search, dimension planning, and a bounded crossing-remedy pass, all + * re-derived from the live 3D capture. It must run exactly once per + * "Generate 2D Drawing" click - never on every pointer-move frame of a drag. + * + * This module instead reads the ALREADY-COMPUTED, authoritative geometry + * that call produced (SheetLayoutModel's per-view silhouetteRect/ + * DimensionRecord.lineSegments/labelRect, each view's outline as strokable + * sheet-space polylines via ViewLayoutModel.edgeRuns, plus the isometric + * reference view's raster) and repaints a canvas from it, with the current + * SheetLayoutAdjustments applied on top. Painting is a pure function of + * (base, adjustments) - there is no hidden mutable state here, so "what's on + * screen" and "what gets exported" are always the exact same computation + * (see cad-viewer.tsx's use of paintInteractiveSheet for both the live + * canvas and the download path). + * + * Four independent kinds of adjustment, composed together (see + * reflowAllRecords/combinedViewOffset): a whole-composition offset that + * moves the ENTIRE drawing - every view's raster, every dimension line, + * extension line, label, and caption - as one rigid unit ("Adjust Drawing" + * mode in cad-viewer.tsx); zero or more per-dimension adjustments that each + * move just ONE record along its own drafting-correct constraint (Adjust + * Annotations' "Overall" option): a linear (overall/location-family/ + * depth-family) dimension slides only perpendicular to its own measurement + * axis, with its extension lines stretching to stay connected + * (applyDimensionAdjustment's linear branch); a circular (size) callout's + * leader pivots freely around its fixed feature anchor (the circular + * branch, see buildLeaderLanding in sheet-composer.ts); each of the two + * caption groups' (see CaptionGroup) own vertical-only override, shared by + * both linked captions (applyCaptionAdjustment); and each of the three view + * groups' (see ViewGroupOffsets) own offset - Top vertical-only, Right + * horizontal-only, the isometric free on both axes - each moving its whole + * view (outline + every one of its own dimensions/caption, or for the iso, + * just the raster) as one rigid unit, independently of the other two + * (Adjust Annotations' "Top"/"Right"/"3D View" options). Every per-record + * and per-view-group adjustment is always computed fresh from the + * PRISTINE, generation-time geometry plus its own current adjustment value + * - never from a previously-adjusted one - so repeated drags can't drift. A + * dimension (never a caption) may also be permanently removed via + * `adjustments.deletedIds` (Adjust Annotations' delete control) - reflowed + * out before any of the above, so a deleted record is invisible to painting, + * hit-testing, and content-bounds alike. + */ +import type { HiddenLineViewName } from "./viewer"; +import { + buildLeaderLanding, + CALLOUT_VALUE_FONT, + CAPTION_FONT, + clampAxisOffset, + computeViewContentBounds, + DIM_VALUE_FONT, + dimensionLineCrossCoord, + drawArrowheadAt, + drawSheetFrame, + drawSheetNotes, + drawSheetTitleBlock, + EXTENSION_OVERSHOOT_PX, + FRAME_RECT, + FRAME_SAFE_AREA, + LINE_WEIGHT_DIMENSION_PX, + LINE_WEIGHT_EXTENSION_PX, + LINE_WEIGHT_LEADER_PX, + SHEET_H, + SHEET_PX_PER_MM, + SHEET_W, + strokeSheetEdgeRuns, + TITLE_BLOCK_RECT, + type DimensionRecord, + type Rect, + type ScaleOverflowWarning, + type Segment, + type SheetLayoutModel, + type TitleBlockTable, + type ViewLayoutModel, +} from "./sheet-composer"; +import { rangeRectPx, type CellRange } from "./title-block-table"; + +// --- Adjustment state ------------------------------------------------- + +export type Offset = { dx: number; dy: number }; + +/** A linear (overall/location-family/depth-family) dimension dragged along its own + * perpendicular lane axis - `crossDelta` is added to the record's ORIGINAL + * (generation-time) dimensionLineCrossCoord, always measured from rest, not + * accumulated incrementally, so repeated drags can never drift. */ +export type LinearDimensionAdjustment = { kind: "linear"; crossDelta: number }; + +/** A size (diameter/radius/arc) callout dragged freely around its fixed + * feature anchor - the elbow point (where the leader bends into its + * horizontal landing, see buildLeaderLanding) in the SAME native/pre- + * composition coordinate space the record was generated in. */ +export type CircularDimensionAdjustment = { + kind: "circular"; + elbowX: number; + elbowY: number; +}; + +export type DimensionAdjustment = + | LinearDimensionAdjustment + | CircularDimensionAdjustment; + +/** The two independently-draggable caption groups (task: "FRONT and RIGHT + * captions are a linked pair sharing one Y position... TOP's caption moves + * independently") - front+right share one entry, top gets its own. Not + * keyed by DimensionRecord.id like `dimensions` below, because the whole + * point is that dragging EITHER of front/right's captions must move both + * together, which a per-id map can't express directly. */ +export type CaptionGroup = "front-right" | "top"; + +/** Which caption group a given view's caption belongs to - see CaptionGroup's + * own doc comment. THE one mapping, so every caller (hit-testing/reflow here, + * drag-session bookkeeping in cad-viewer.tsx) agrees on the grouping. */ +export function captionGroupForView(view: HiddenLineViewName): CaptionGroup { + return view === "top" ? "top" : "front-right"; +} + +/** The three independently-draggable VIEW GROUPS inside Adjust Annotations' + * "Top"/"Right"/"3D View" options (task: per-view whole-group drag, distinct + * from both "Adjust Drawing"'s whole-composition offset and Overall's + * per-record drag) - each moves its view's outline + every one of its own + * DimensionRecords (dimensions AND caption) as one rigid unit, constrained + * to the axis the task specifies: `top` is a Y-only offset (Top view stays + * horizontally centred on Front by construction - this field is never added + * to any X coordinate), `right` is an X-only offset (stays vertically + * centred on Front's own centreline the same way), `iso` is free on both + * axes (the isometric reference view has no DimensionRecord of its own, so + * it's the only one of the three not reflected in reflowAllRecords - see + * paintInteractiveSheet's own iso draw). Front is deliberately absent - it's + * the fixed anchor every other view/offset is defined relative to. */ +export type ViewGroupOffsets = { + top: number; + right: number; + iso: Offset; +}; + +/** Whole-composition offset, plus zero or more individually-dragged + * dimensions (keyed by DimensionRecord.id), plus each caption group's own + * vertical override (native/pre-composition space Y for the label rect's + * top edge, or null if that group hasn't been dragged - see this module's + * own doc comment. Composition moves everything uniformly; a per-dimension + * entry moves just that one record's own lane/elbow, a caption-group entry + * overrides both linked captions' Y identically, and `viewGroups` moves one + * whole view's outline+dimensions+caption (or, for `iso`, just the + * isometric raster) together - all applied BEFORE the composition translate + * (see reflowAllRecords/combinedViewOffset) so every layer composes cleanly + * regardless of order. `deletedIds` (task: delete control) removes a + * dimension record - never a caption, see cad-viewer.tsx's delete handler - + * from every downstream computation (reflow, painting, content bounds) as + * if it had never been generated. */ +export type SheetLayoutAdjustments = { + composition: Offset; + dimensions: Record; + captions: Record; + viewGroups: ViewGroupOffsets; + deletedIds: Record; +}; + +export function createEmptySheetLayoutAdjustments(): SheetLayoutAdjustments { + return { + composition: { dx: 0, dy: 0 }, + dimensions: {}, + captions: { "front-right": null, top: null }, + viewGroups: { top: 0, right: 0, iso: { dx: 0, dy: 0 } }, + deletedIds: {}, + }; +} + +export function isEmptySheetLayoutAdjustments( + a: SheetLayoutAdjustments, +): boolean { + return ( + a.composition.dx === 0 && + a.composition.dy === 0 && + Object.keys(a.dimensions).length === 0 && + a.captions["front-right"] === null && + a.captions.top === null && + a.viewGroups.top === 0 && + a.viewGroups.right === 0 && + a.viewGroups.iso.dx === 0 && + a.viewGroups.iso.dy === 0 && + Object.keys(a.deletedIds).length === 0 + ); +} + +/** Same fields as isEmptySheetLayoutAdjustments EXCEPT `deletedIds` - a + * deleted dimension is content (which dimensions exist), not a position, so + * it's scale-independent and must survive a scale change unlike every other + * field here (composition/dimensions/captions/viewGroups are pure position + * offsets, meaningless at a different scale - see handleScaleChange in + * cad-viewer.tsx, which clears only those fields and uses this to decide + * whether its "adjustments were cleared" notice should fire). */ +export function hasPositionAdjustments(a: SheetLayoutAdjustments): boolean { + return ( + a.composition.dx !== 0 || + a.composition.dy !== 0 || + Object.keys(a.dimensions).length > 0 || + a.captions["front-right"] !== null || + a.captions.top !== null || + a.viewGroups.top !== 0 || + a.viewGroups.right !== 0 || + a.viewGroups.iso.dx !== 0 || + a.viewGroups.iso.dy !== 0 + ); +} + +/** The combined (whole-composition + this record's own view-group) offset a + * record's PRISTINE/native geometry must be translated by to reach its + * current on-screen position - reflowAllRecords applies exactly this for + * every record, and cad-viewer.tsx's per-record (Overall) drag/clamp code + * uses the SAME combination as the "already-applied" baseline its own delta + * layers on top of (see clampLinearDimensionDelta et al's compositionOffset + * param), so a per-record drag started on a view already shifted by its own + * Top/Right view-group offset clamps against where the record ACTUALLY is, + * not just the whole-composition offset alone. `view` picks which of + * viewGroups applies - `top` only ever contributes to dy, `right` only ever + * to dx (see ViewGroupOffsets' own doc comment for why), `front` gets + * neither (it's the fixed anchor). */ +export function combinedViewOffset( + view: HiddenLineViewName, + adjustments: SheetLayoutAdjustments, +): Offset { + return { + dx: adjustments.composition.dx + (view === "right" ? adjustments.viewGroups.right : 0), + dy: adjustments.composition.dy + (view === "top" ? adjustments.viewGroups.top : 0), + }; +} + +function translateRect(r: Rect, dx: number, dy: number): Rect { + return { x: r.x + dx, y: r.y + dy, w: r.w, h: r.h }; +} + +function translateSegment(s: Segment, dx: number, dy: number): Segment { + return { x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy }; +} + +/** Every segment and the label rect of one record, rigidly translated by + * the whole-composition offset - no per-kind reflow logic needed since the + * entire drawing moves together, preserving every relative position. */ +function translateRecord( + record: DimensionRecord, + dx: number, + dy: number, +): DimensionRecord { + return { + ...record, + lineSegments: record.lineSegments.map((s) => translateSegment(s, dx, dy)), + labelRect: record.labelRect ? translateRect(record.labelRect, dx, dy) : null, + }; +} + +// --- Per-dimension adjustment (Adjust Annotations mode) ---------------- + +/** A coordinate counts as "at the record's lane" (and therefore moves with + * a linear-dimension drag) when it's within this of the ORIGINAL + * dimensionLineCrossCoord - loose enough to catch the standard extension- + * line overshoot past the dimension line (EXTENSION_OVERSHOOT_PX, see + * extensionLineSpan in sheet-composer.ts) plus float noise, tight enough + * that a real feature-anchored coordinate (mm-scale distances in practice) + * is never mistaken for the lane itself. */ +const CROSS_MATCH_TOLERANCE_PX = EXTENSION_OVERSHOOT_PX + 2; + +function shiftIfNearCrossCoord( + v: number, + crossCoord: number, + delta: number, +): number { + return Math.abs(v - crossCoord) <= CROSS_MATCH_TOLERANCE_PX ? v + delta : v; +} + +/** + * Shifts a linear (overall/location-family/depth-family) record's lane by `delta`, + * perpendicular to its own measurement axis - the drafting-correct + * constraint (task: VERTICAL/height dimensions move only horizontally, + * HORIZONTAL/width dimensions move only vertically). Every dimension-line + * endpoint (both, by construction, sit exactly at the lane's cross + * coordinate) and every extension/jog segment's endpoint that's within + * CROSS_MATCH_TOLERANCE_PX of it (the far, dimension-line-attached end - + * see extensionLineSpan's overshoot) shifts by delta; every endpoint that + * ISN'T near the lane (the feature-anchored near end, and any obstruction- + * relative jog midpoint) stays exactly where it was, so extension lines + * stretch/shrink to stay connected to their features rather than the whole + * record translating rigidly. The label rect always moves in full - it's + * always positioned relative to the lane, never the feature. The measured + * value itself is never touched (this only ever moves lineSegments/ + * labelRect, never valueMm/text). + */ +function applyLinearAdjustment( + record: DimensionRecord, + delta: number, +): DimensionRecord { + const crossCoord = dimensionLineCrossCoord(record); + if (crossCoord === null || delta === 0) return record; + const isVerticalAxis = record.axis === "vertical"; // crossCoord is X + const lineSegments = record.lineSegments.map((s) => + isVerticalAxis + ? { + x1: shiftIfNearCrossCoord(s.x1, crossCoord, delta), + y1: s.y1, + x2: shiftIfNearCrossCoord(s.x2, crossCoord, delta), + y2: s.y2, + } + : { + x1: s.x1, + y1: shiftIfNearCrossCoord(s.y1, crossCoord, delta), + x2: s.x2, + y2: shiftIfNearCrossCoord(s.y2, crossCoord, delta), + }, + ); + const labelRect = record.labelRect + ? isVerticalAxis + ? { ...record.labelRect, x: record.labelRect.x + delta } + : { ...record.labelRect, y: record.labelRect.y + delta } + : null; + return { ...record, lineSegments, labelRect }; +} + +/** Rebuilds a size (diameter/radius/arc) record's leader/landing/label for + * a dragged elbow point, pivoting around the record's own fixed feature + * anchor (lineSegments[0]'s start point, untouched by any prior + * adjustment - see buildLeaderLanding in sheet-composer.ts, the exact same + * function the auto-placement search itself uses, so a dragged callout + * renders identically in form to an auto-placed one). */ +function applyCircularAdjustment( + record: DimensionRecord, + elbowX: number, + elbowY: number, +): DimensionRecord { + const anchor = record.lineSegments[0]; + if (!anchor || !record.text) return record; + const { leaderSegment, landingSegment, labelRect } = buildLeaderLanding( + anchor.x1, + anchor.y1, + elbowX, + elbowY, + record.text, + ); + return { ...record, lineSegments: [leaderSegment, landingSegment], labelRect }; +} + +/** Applies one record's own manual adjustment (if any) - pure, and always + * computed from the record as passed in (the caller is responsible for + * passing the PRISTINE, generation-time record - see reflowAllRecords - so + * repeated drags recompute from rest instead of drifting). */ +export function applyDimensionAdjustment( + record: DimensionRecord, + adjustment: DimensionAdjustment | undefined, +): DimensionRecord { + if (!adjustment) return record; + return adjustment.kind === "linear" + ? applyLinearAdjustment(record, adjustment.crossDelta) + : applyCircularAdjustment(record, adjustment.elbowX, adjustment.elbowY); +} + +/** Moves a caption's label rect to `overrideY` (native/pre-composition space, + * the rect's own top edge) - VERTICAL only, per task 2 ("captions drag + * vertically only"). The rect's x/w/h are untouched, so the caption stays + * exactly horizontally centered on its own view's centre axis at every + * adjustment - that horizontal lock falls out of simply never writing to + * `.x` here, the same way applyLinearAdjustment leaves the feature-anchored + * end alone by construction. `overrideY === null` (group never dragged) + * returns the record as-is, keeping its own auto-placed rest position. */ +function applyCaptionAdjustment( + record: DimensionRecord, + overrideY: number | null, +): DimensionRecord { + if (overrideY === null || !record.labelRect) return record; + return { ...record, labelRect: { ...record.labelRect, y: overrideY } }; +} + +/** Every record in `model`, first given its own manual adjustment (if any - + * always relative to the pristine model, never a previously-reflowed + * record; captions look up their linked GROUP's override via + * captionGroupForView rather than their own id, see CaptionGroup's doc + * comment), then translated by the whole-composition offset - the single + * source of truth paintInteractiveSheet (and hit-testing) build on. Order + * matters: a per-record adjustment is computed in the record's native/ + * generation-time coordinate space, and the composition offset is applied + * uniformly on top of that, exactly like it's applied to the view rasters + * those records annotate. Records in `adjustments.deletedIds` (task: delete + * control) are dropped entirely, before any adjustment/translation - as far + * as every downstream consumer (painting, hit-testing, content bounds) is + * concerned, a deleted record was never generated. */ +export function reflowAllRecords( + model: SheetLayoutModel, + adjustments: SheetLayoutAdjustments, +): Record { + const out = {} as Record; + for (const view of Object.keys(model.views) as HiddenLineViewName[]) { + const { dx, dy } = combinedViewOffset(view, adjustments); + out[view] = model.views[view].dimensions + .filter((r) => !adjustments.deletedIds[r.id]) + .map((r) => { + const adjusted = + r.kind === "caption" + ? applyCaptionAdjustment(r, adjustments.captions[captionGroupForView(r.view)]) + : applyDimensionAdjustment(r, adjustments.dimensions[r.id]); + return translateRecord(adjusted, dx, dy); + }); + } + return out; +} + +/** Full-content bbox (silhouette + every translated dimension's segments/ + * labelRect) for one view under the given offset - used for the + * whole-composition drag clamp. */ +function reflowedViewContentBounds( + view: ViewLayoutModel, + reflowedDims: DimensionRecord[], + dx: number, + dy: number, +): Rect { + return computeViewContentBounds({ + silhouetteRect: translateRect(view.silhouetteRect, dx, dy), + dimensions: reflowedDims, + }); +} + +function unionRect(a: Rect, b: Rect): Rect { + const minX = Math.min(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxX = Math.max(a.x + a.w, b.x + b.w); + const maxY = Math.max(a.y + a.h, b.y + b.h); + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} + +/** Union bbox of all three views' full content - plus the isometric + * reference view, which is part of the drawing and therefore part of what a + * whole-composition drag has to keep inside the frame, even though it carries + * no dimension records of its own - under the given offset. Each view's own + * bounds use its combinedViewOffset (composition + that view's own + * Top/Right group offset, if any), matching exactly how reflowAllRecords + * already translated `reflowed[view]` - and the iso view (which has no + * DimensionRecord, so never goes through reflowAllRecords) uses composition + * + its own free `viewGroups.iso` offset the same way paintInteractiveSheet + * draws it. */ +export function fullContentBounds( + model: SheetLayoutModel, + adjustments: SheetLayoutAdjustments, +): Rect { + const reflowed = reflowAllRecords(model, adjustments); + const views = Object.keys(model.views) as HiddenLineViewName[]; + let result: Rect | null = null; + for (const view of views) { + const { dx, dy } = combinedViewOffset(view, adjustments); + const r = reflowedViewContentBounds(model.views[view], reflowed[view], dx, dy); + result = result ? unionRect(result, r) : r; + } + if (!result) throw new Error("fullContentBounds: layoutModel has no views"); + if (model.isoView) { + const isoDx = adjustments.composition.dx + adjustments.viewGroups.iso.dx; + const isoDy = adjustments.composition.dy + adjustments.viewGroups.iso.dy; + result = unionRect(result, translateRect(model.isoView.destRect, isoDx, isoDy)); + } + return result; +} + +/** Live "does the content actually cross the frame margin RIGHT NOW" check + * (task 4: "show ONLY when content actually crosses the frame margin... + * recompute live on every adjustment") - replaces relying on + * composeA4DrawingSheet's own one-shot overflowWarning (computed once, at + * generation/scale-change time, against the more permissive real-page + * overflow guard, and never re-evaluated as the user drags/deletes/repositions + * anything afterward). Measures the SAME box0 (content bounds with + * composition zeroed out) dragRangeForComposition itself derives its range + * from, against FRAME_SAFE_AREA - so this is exactly "would ANY composition + * offset be needed to avoid crossing the margin, and is there room for one" - + * true content-size vs. available-space, not a specific offset's residual + * error. Call after every adjustment (drag frame, drag end, delete, reset, + * scale change) so a fix that shrinks the content back under the limit (e.g. + * dragging an overhanging label back in, or deleting a stray dimension) + * clears the warning immediately, and a change that grows it back out + * reinstates it - never a stale snapshot either way. */ +export function computeLiveOverflowWarning( + model: SheetLayoutModel, + adjustments: SheetLayoutAdjustments, +): ScaleOverflowWarning | null { + const box0 = fullContentBounds(model, { + ...adjustments, + composition: { dx: 0, dy: 0 }, + }); + const widthExceedsPx = Math.max(0, box0.w - FRAME_SAFE_AREA.w); + const heightExceedsPx = Math.max(0, box0.h - FRAME_SAFE_AREA.h); + if (widthExceedsPx <= 0 && heightExceedsPx <= 0) return null; + const widthExceedsMm = widthExceedsPx / SHEET_PX_PER_MM; + const heightExceedsMm = heightExceedsPx / SHEET_PX_PER_MM; + const parts: string[] = []; + if (widthExceedsMm > 0) parts.push(`width by ${widthExceedsMm.toFixed(1)}mm`); + if (heightExceedsMm > 0) parts.push(`height by ${heightExceedsMm.toFixed(1)}mm`); + return { + widthExceedsMm, + heightExceedsMm, + message: `Content exceeds sheet ${parts.join(" and ")}.`, + }; +} + +/** The real, currently-available whole-composition drag range on each axis, + * in sheet px - a closed [min, max] offset interval such that any offset + * inside it keeps the content box within FRAME_SAFE_AREA (the frame margin + * on three sides, the title block's real top edge on the fourth - see its + * own doc comment in sheet-composer.ts). Computed from the content's bounds + * at the CURRENT adjustments with composition itself zeroed out (`box0`) - + * NOT the pristine/empty adjustments (a real bug this replaced: see + * clampCompositionOffset's own doc comment) - so any per-dimension drag, + * per-view-group drag, deletion, or caption move already applied is + * reflected in the range. reflowAllRecords/reflowSilhouette translate every + * segment/labelRect uniformly under composition, so every other composition + * offset's box is just box0 rigidly shifted, and a single box0 measurement + * (at the CURRENT non-composition state) is enough to derive the whole + * range analytically instead of re-measuring per candidate. `min > max` on + * an axis is possible (content wider/taller than FRAME_SAFE_AREA on that + * axis) and means there is no offset that fully avoids the margin - + * exported so callers can detect and report that case explicitly (see + * cad-viewer.tsx's Adjust-mode entry) instead of leaving a drag that + * silently does nothing unexplained. */ +export function dragRangeForComposition( + model: SheetLayoutModel, + currentAdjustments: SheetLayoutAdjustments, +): { x: [number, number]; y: [number, number] } { + const box0 = fullContentBounds(model, { + ...currentAdjustments, + composition: { dx: 0, dy: 0 }, + }); + const xLimits: [number, number] = [ + FRAME_SAFE_AREA.x - box0.x, + FRAME_SAFE_AREA.x + FRAME_SAFE_AREA.w - box0.w - box0.x, + ]; + const yLimits: [number, number] = [ + FRAME_SAFE_AREA.y - box0.y, + FRAME_SAFE_AREA.y + FRAME_SAFE_AREA.h - box0.h - box0.y, + ]; + return { + x: [Math.min(...xLimits), Math.max(...xLimits)], + y: [Math.min(...yLimits), Math.max(...yLimits)], + }; +} + +/** Clamps a candidate whole-composition offset to dragRangeForComposition's + * real per-axis range - independent axes, a genuine interval clamp (see + * clampAxisOffset's own doc comment in sheet-composer.ts for the bug this + * replaces: two sequential corrections applied to the SAME un-corrected box + * used to fight each other whenever content didn't fit the bound with room + * to spare, pinning drag to one of two fixed positions on that axis + * regardless of the requested delta - the "vertical completely locked" + * bug). FRAME_SAFE_AREA (not the shorter, extra-padded DRAW_AREA) is the + * correct bound here: it's the SAME area composeA4DrawingSheet's own + * placement (clampCenterOffsetToFrame) is bounded by, so a drag can push + * content flush against the frame margin on any side but never lets it + * collide with the title block - matching what the sheet's own normal + * (non-dragged) layout already guarantees, and never disagreeing with it + * about how much room is actually available. + * + * `currentAdjustments` (the live SheetLayoutAdjustments BEFORE this candidate + * is applied) is required, not optional - a real bug this fixes: this used + * to measure the content box from createEmptySheetLayoutAdjustments() (the + * PRISTINE, generation-time geometry), completely ignoring any per-dimension + * drag, per-view-group drag (Top/Right/3D View), caption move, or deletion + * already in effect. Concretely: if an earlier adjustment left the true + * current content SMALLER or positioned differently than the pristine + * layout (e.g. a dimension dragged up, or a far-down record deleted), the + * stale pristine box over-restricted the allowed range - most visibly, a + * whole-composition drag downward would refuse to move (or move far less + * than real empty space allowed) because the clamp was still reasoning + * about the ORIGINAL, no-longer-current content extent. box0 here is + * measured with `composition` zeroed out (composition is exactly what's + * being solved for; every OTHER field of currentAdjustments is kept as-is), + * so the resulting range always reflects the sheet's true, currently- + * rendered outermost extent - see dragRangeForComposition, which shares the + * exact same box0 computation for the diagnostic log. */ +export function clampCompositionOffset( + model: SheetLayoutModel, + currentAdjustments: SheetLayoutAdjustments, + candidate: Offset, +): Offset { + const box0 = fullContentBounds(model, { + ...currentAdjustments, + composition: { dx: 0, dy: 0 }, + }); + return { + dx: clampAxisOffset( + box0.x, + box0.w, + FRAME_SAFE_AREA.x, + FRAME_SAFE_AREA.x + FRAME_SAFE_AREA.w, + candidate.dx, + ), + dy: clampAxisOffset( + box0.y, + box0.h, + FRAME_SAFE_AREA.y, + FRAME_SAFE_AREA.y + FRAME_SAFE_AREA.h, + candidate.dy, + ), + }; +} + +// --- Per-dimension drag clamp (Adjust Annotations mode) ----------------- + +/** The interval, along the record's own cross axis, of every coordinate + * that MOVES with a linear-dimension drag (see applyLinearAdjustment) at + * rest (delta 0) - the dimension line's own endpoints (always at the lane) + * plus every extension segment's far endpoint, plus the label rect's own + * span on that axis. The feature-anchored near endpoints are deliberately + * excluded: they never move, and are already guaranteed in-frame (they sit + * on the part itself), so they can never be the reason a drag needs + * clamping. Returns null only if the record has no matching coordinate at + * all (shouldn't happen for a real linear-kind record). */ +function linearMovingExtent( + record: DimensionRecord, + crossCoord: number, +): { min: number; max: number } | null { + let min = Infinity; + let max = -Infinity; + const consider = (v: number) => { + if (v < min) min = v; + if (v > max) max = v; + }; + const isVerticalAxis = record.axis === "vertical"; + for (const s of record.lineSegments) { + if (isVerticalAxis) { + if (Math.abs(s.x1 - crossCoord) <= CROSS_MATCH_TOLERANCE_PX) consider(s.x1); + if (Math.abs(s.x2 - crossCoord) <= CROSS_MATCH_TOLERANCE_PX) consider(s.x2); + } else { + if (Math.abs(s.y1 - crossCoord) <= CROSS_MATCH_TOLERANCE_PX) consider(s.y1); + if (Math.abs(s.y2 - crossCoord) <= CROSS_MATCH_TOLERANCE_PX) consider(s.y2); + } + } + if (record.labelRect) { + if (isVerticalAxis) { + consider(record.labelRect.x); + consider(record.labelRect.x + record.labelRect.w); + } else { + consider(record.labelRect.y); + consider(record.labelRect.y + record.labelRect.h); + } + } + if (!Number.isFinite(min) || !Number.isFinite(max)) return null; + return { min, max }; +} + +/** Clamps a candidate linear-dimension lane delta so the dragged dimension + * line/label never crosses FRAME_SAFE_AREA once the CURRENT whole- + * composition offset is also applied on top - the exact same bound whole- + * composition drag itself is clamped to (clampCompositionOffset), just + * scoped to this one record's own MOVING geometry (linearMovingExtent), + * since the feature-anchored ends never move and can never be what pushes + * it out of frame. `record` must be the PRISTINE (generation-time) record - + * see reflowAllRecords - so the extent is always measured from rest. */ +export function clampLinearDimensionDelta( + record: DimensionRecord, + compositionOffset: Offset, + candidateDelta: number, +): number { + const crossCoord = dimensionLineCrossCoord(record); + if (crossCoord === null) return 0; + const extent = linearMovingExtent(record, crossCoord); + if (!extent) return candidateDelta; + const isVerticalAxis = record.axis === "vertical"; + const compAxisOffset = isVerticalAxis ? compositionOffset.dx : compositionOffset.dy; + const boundMin = isVerticalAxis ? FRAME_SAFE_AREA.x : FRAME_SAFE_AREA.y; + const boundMax = isVerticalAxis + ? FRAME_SAFE_AREA.x + FRAME_SAFE_AREA.w + : FRAME_SAFE_AREA.y + FRAME_SAFE_AREA.h; + const clampedCombined = clampAxisOffset( + extent.min, + extent.max - extent.min, + boundMin, + boundMax, + compAxisOffset + candidateDelta, + ); + return clampedCombined - compAxisOffset; +} + +/** Clamps a candidate circular-callout elbow (native/pre-composition + * space) so its leader/landing/label - once the CURRENT whole-composition + * offset is also applied - never crosses FRAME_SAFE_AREA. Rather than + * reject the drag outright at the boundary, pushes the whole elbow back by + * whatever correction is needed (both axes independently), so it still + * tracks the pointer smoothly right up to the frame edge. `record` must be + * the PRISTINE (generation-time) record, for its unmoving anchor point. */ +export function clampCircularDimensionElbow( + record: DimensionRecord, + compositionOffset: Offset, + candidate: { x: number; y: number }, +): { x: number; y: number } { + const anchor = record.lineSegments[0]; + if (!anchor || !record.text) return candidate; + const { leaderSegment, landingSegment, labelRect } = buildLeaderLanding( + anchor.x1, + anchor.y1, + candidate.x, + candidate.y, + record.text, + ); + const minX = Math.min(leaderSegment.x2, landingSegment.x2, labelRect.x, labelRect.x + labelRect.w); + const maxX = Math.max(leaderSegment.x2, landingSegment.x2, labelRect.x, labelRect.x + labelRect.w); + const minY = Math.min(leaderSegment.y2, labelRect.y, labelRect.y + labelRect.h); + const maxY = Math.max(leaderSegment.y2, labelRect.y, labelRect.y + labelRect.h); + const correctionX = + clampAxisOffset( + minX, + maxX - minX, + FRAME_SAFE_AREA.x, + FRAME_SAFE_AREA.x + FRAME_SAFE_AREA.w, + compositionOffset.dx, + ) - compositionOffset.dx; + const correctionY = + clampAxisOffset( + minY, + maxY - minY, + FRAME_SAFE_AREA.y, + FRAME_SAFE_AREA.y + FRAME_SAFE_AREA.h, + compositionOffset.dy, + ) - compositionOffset.dy; + return { x: candidate.x + correctionX, y: candidate.y + correctionY }; +} + +/** Clamps a candidate caption label-rect top Y (native/pre-composition + * space) so it never crosses FRAME_SAFE_AREA vertically once the CURRENT + * whole-composition offset is applied on top - same bound/technique as + * clampLinearDimensionDelta, just for a caption's one axis of movement. + * `record` only needs its own labelRect.h (identical for every caption, see + * LABEL_H in sheet-composer.ts) - which specific view's pristine record is + * passed doesn't matter. */ +export function clampCaptionY( + record: DimensionRecord, + compositionOffset: Offset, + candidateY: number, +): number { + const rect = record.labelRect; + if (!rect) return candidateY; + const clampedCombined = clampAxisOffset( + 0, + rect.h, + FRAME_SAFE_AREA.y, + FRAME_SAFE_AREA.y + FRAME_SAFE_AREA.h, + candidateY + compositionOffset.dy, + ); + return clampedCombined - compositionOffset.dy; +} + +// --- View-group drag clamp (Adjust Annotations' Top/Right/3D View options) - + +/** Bounding box of one view's own outline + every one of its own + * DimensionRecords (including its caption) that's currently actually + * rendered - the content that moves as a rigid unit under a Top/Right + * view-group drag (task 2). Reflows `view` with the CURRENT `adjustments` + * (so a live per-dimension drag or a deletion within this same view is + * reflected - the same class of staleness bug clampCompositionOffset's own + * doc comment describes, just scoped to one view instead of the whole + * sheet) but with composition AND every viewGroups offset zeroed out - this + * view's own view-group offset is exactly what's being solved for + * (candidateDy/candidateDx below add it back separately), and composition + * is applied uniformly on top by the caller, never here. */ +function viewGroupRestBounds( + model: SheetLayoutModel, + adjustments: SheetLayoutAdjustments, + view: HiddenLineViewName, +): Rect { + const zeroed: SheetLayoutAdjustments = { + ...adjustments, + composition: { dx: 0, dy: 0 }, + viewGroups: { top: 0, right: 0, iso: { dx: 0, dy: 0 } }, + }; + const reflowed = reflowAllRecords(model, zeroed)[view]; + return computeViewContentBounds({ + silhouetteRect: model.views[view].silhouetteRect, + dimensions: reflowed, + }); +} + +/** Clamps a candidate TOP view-group offset (task 2: "TOP - drags the + * entire Top view ... vertically only ... keeps its horizontal centre + * alignment with Front") so the dragged Top outline+dimensions+caption, + * once the CURRENT whole-composition offset is also applied on top, never + * crosses FRAME_SAFE_AREA - same layered-clamp technique as + * clampLinearDimensionDelta/clampCaptionY, just scoped to the Top view's + * own rest content instead of one record. The horizontal-centre lock isn't + * enforced here - it falls out structurally from this offset only ever + * being added to Y (see ViewGroupOffsets/combinedViewOffset), so there is + * no X to clamp. Takes the full `adjustments` (not just the composition + * offset) so viewGroupRestBounds can reflect any live per-dimension/ + * deletion adjustment already applied within the Top view itself. */ +export function clampTopViewOffset( + model: SheetLayoutModel, + adjustments: SheetLayoutAdjustments, + candidateDy: number, +): number { + const rest = viewGroupRestBounds(model, adjustments, "top"); + const compositionOffset = adjustments.composition; + const clampedCombined = clampAxisOffset( + rest.y, + rest.h, + FRAME_SAFE_AREA.y, + FRAME_SAFE_AREA.y + FRAME_SAFE_AREA.h, + compositionOffset.dy + candidateDy, + ); + return clampedCombined - compositionOffset.dy; +} + +/** Clamps a candidate RIGHT view-group offset - the horizontal-only mirror + * of clampTopViewOffset (task 2's "RIGHT" option: horizontal-only, stays + * vertically centred on Front's own centreline, which likewise falls out of + * this offset only ever being added to X). */ +export function clampRightViewOffset( + model: SheetLayoutModel, + adjustments: SheetLayoutAdjustments, + candidateDx: number, +): number { + const rest = viewGroupRestBounds(model, adjustments, "right"); + const compositionOffset = adjustments.composition; + const clampedCombined = clampAxisOffset( + rest.x, + rest.w, + FRAME_SAFE_AREA.x, + FRAME_SAFE_AREA.x + FRAME_SAFE_AREA.w, + compositionOffset.dx + candidateDx, + ); + return clampedCombined - compositionOffset.dx; +} + +/** Clamps a candidate 3D-view offset (task 2's "3D VIEW" option: free on + * both axes, "completely independent - it never moves with, or causes + * movement in, any other view") against the isometric's own rest destRect - + * same layered technique as clampCircularDimensionElbow, just for a plain + * rect instead of a leader's derived geometry. Returns the candidate + * unchanged if the part yielded no isometric view at all (nothing to clamp + * against, and this option shouldn't be reachable in that case anyway). */ +export function clampIsoViewOffset( + model: SheetLayoutModel, + compositionOffset: Offset, + candidate: Offset, +): Offset { + const iso = model.isoView; + if (!iso) return candidate; + const rest = iso.destRect; + const dx = + clampAxisOffset( + rest.x, + rest.w, + FRAME_SAFE_AREA.x, + FRAME_SAFE_AREA.x + FRAME_SAFE_AREA.w, + compositionOffset.dx + candidate.dx, + ) - compositionOffset.dx; + const dy = + clampAxisOffset( + rest.y, + rest.h, + FRAME_SAFE_AREA.y, + FRAME_SAFE_AREA.y + FRAME_SAFE_AREA.h, + compositionOffset.dy + candidate.dy, + ) - compositionOffset.dy; + return { dx, dy }; +} + +// --- Notes block drag (task 3: free placement + collision) -------------- + +/** Bounding box of one dimension/caption record's own geometry (every line + * segment endpoint plus its label rect, if any) - the same shape the notes + * collision check treats every piece of drawing content as, since a caption + * has no lineSegments and a leader/extension-only record has no labelRect. */ +function recordBounds(record: DimensionRecord): Rect { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const consider = (x: number, y: number) => { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + }; + for (const s of record.lineSegments) { + consider(s.x1, s.y1); + consider(s.x2, s.y2); + } + if (record.labelRect) { + consider(record.labelRect.x, record.labelRect.y); + consider(record.labelRect.x + record.labelRect.w, record.labelRect.y + record.labelRect.h); + } + if (!Number.isFinite(minX)) return { x: 0, y: 0, w: 0, h: 0 }; + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} + +/** Every rect the notes block must not overlap, at the CURRENT adjustments + * (task 3: "check against live content bounds recomputed at drag time, not a + * fixed rectangle") - each view's own outline, every currently-rendered + * dimension/extension/leader/label (individually, not just their union, so + * the notes block can still sit in genuinely empty space BETWEEN two views), + * every caption, the isometric reference view, and the title block. Deleted + * records are already absent from `reflowAllRecords`'s output, so they never + * block a drop the way a stale/removed record otherwise could. */ +export function notesObstacleRects( + model: SheetLayoutModel, + adjustments: SheetLayoutAdjustments, +): Rect[] { + const reflowed = reflowAllRecords(model, adjustments); + const rects: Rect[] = [TITLE_BLOCK_RECT]; + for (const view of Object.keys(model.views) as HiddenLineViewName[]) { + const { dx, dy } = combinedViewOffset(view, adjustments); + rects.push(translateRect(model.views[view].silhouetteRect, dx, dy)); + for (const r of reflowed[view]) rects.push(recordBounds(r)); + } + if (model.isoView) { + const isoDx = adjustments.composition.dx + adjustments.viewGroups.iso.dx; + const isoDy = adjustments.composition.dy + adjustments.viewGroups.iso.dy; + rects.push(translateRect(model.isoView.destRect, isoDx, isoDy)); + } + return rects; +} + +// Small visual clearance kept between the dragged notes block and any +// obstacle, so it never reads as touching/overlapping even at the pixel +// boundary. +const NOTES_COLLISION_PAD_PX = 8; + +function rectsOverlap(a: Rect, b: Rect, pad: number): boolean { + return !( + a.x + a.w + pad <= b.x || + b.x + b.w + pad <= a.x || + a.y + a.h + pad <= b.y || + b.y + b.h + pad <= a.y + ); +} + +/** Clamps a candidate notes-block top-left position (task 3: "must not + * overlap any view outline, dimension/extension/leader line or label, any + * caption, the isometric, or the title block - and must stay inside the + * frame margin... prevent or visually reject positions that would collide + * rather than allowing an overlap") - first confines the candidate to + * FRAME_RECT (the frame margin band, task's "stay inside the frame + * margin"), then rejects it outright (falling back to `current`, the last + * known-good position) if it would overlap any live obstacle rect. Rejecting + * rather than nudging keeps the drag feel consistent with every other clamp + * in this module (a drag that can't proceed simply stops tracking the + * pointer) and guarantees the block can NEVER end up overlapping something, + * even transiently mid-drag. */ +export function clampNotesPosition( + model: SheetLayoutModel, + adjustments: SheetLayoutAdjustments, + size: { w: number; h: number }, + current: { x: number; y: number }, + candidate: { x: number; y: number }, +): { x: number; y: number } { + const x = Math.min(Math.max(candidate.x, FRAME_RECT.x), FRAME_RECT.x + FRAME_RECT.w - size.w); + const y = Math.min(Math.max(candidate.y, FRAME_RECT.y), FRAME_RECT.y + FRAME_RECT.h - size.h); + const rect: Rect = { x, y, w: size.w, h: size.h }; + const obstacles = notesObstacleRects(model, adjustments); + const collides = obstacles.some((o) => rectsOverlap(rect, o, NOTES_COLLISION_PAD_PX)); + return collides ? current : { x, y }; +} + +/** True if `pos` (the notes block's current top-left) is a valid, + * collision-free placement at `size` - used to detect a block that just grew + * (a new note added) past what its FIXED (already-dragged) position now has + * room for, so cad-viewer.tsx can fall back to a fresh default position + * instead of leaving it visibly overlapping something. */ +export function isValidNotesPosition( + model: SheetLayoutModel, + adjustments: SheetLayoutAdjustments, + size: { w: number; h: number }, + pos: { x: number; y: number }, +): boolean { + if ( + pos.x < FRAME_RECT.x || + pos.y < FRAME_RECT.y || + pos.x + size.w > FRAME_RECT.x + FRAME_RECT.w || + pos.y + size.h > FRAME_RECT.y + FRAME_RECT.h + ) { + return false; + } + const rect: Rect = { x: pos.x, y: pos.y, w: size.w, h: size.h }; + const obstacles = notesObstacleRects(model, adjustments); + return !obstacles.some((o) => rectsOverlap(rect, o, NOTES_COLLISION_PAD_PX)); +} + +// --- Hit-testing (Adjust Annotations mode) ------------------------------- + +const HIT_LABEL_PAD_PX = 6; +const HIT_LINE_DIST_PX = 6; + +function pointToSegmentDist(px: number, py: number, s: Segment): number { + const dx = s.x2 - s.x1; + const dy = s.y2 - s.y1; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(px - s.x1, py - s.y1); + const t = Math.max(0, Math.min(1, ((px - s.x1) * dx + (py - s.y1) * dy) / lenSq)); + return Math.hypot(px - (s.x1 + t * dx), py - (s.y1 + t * dy)); +} + +function pointInPaddedRect(px: number, py: number, r: Rect, pad: number): boolean { + return ( + px >= r.x - pad && px <= r.x + r.w + pad && py >= r.y - pad && py <= r.y + r.h + pad + ); +} + +/** Which selectable record (dimension OR caption - task 1: captions become + * part of Adjust Annotations) sits under (x, y) in sheet px, given the + * CURRENTLY DISPLAYED (reflowed) geometry - so a click always selects + * whatever the user actually sees, including a record already dragged away + * from its generated position. Label-rect hits (the larger, more forgiving + * target, and the only kind of hit a caption has - it carries no + * lineSegments) are checked across every view first, then line/leader + * proximity - so a click squarely on a label always wins even if a + * different record's thin line happens to pass nearby. */ +export function hitTestDimension( + reflowed: Record, + x: number, + y: number, +): DimensionRecord | null { + const views = Object.keys(reflowed) as HiddenLineViewName[]; + for (const view of views) { + for (const r of reflowed[view]) { + if (r.labelRect && r.labelRect.w > 0 && pointInPaddedRect(x, y, r.labelRect, HIT_LABEL_PAD_PX)) { + return r; + } + } + } + for (const view of views) { + for (const r of reflowed[view]) { + if (r.lineSegments.some((s) => pointToSegmentDist(x, y, s) <= HIT_LINE_DIST_PX)) { + return r; + } + } + } + return null; +} + +/** The PRISTINE (generation-time) record for `id`, or null - the geometry + * every per-dimension adjustment/clamp function above must be computed + * against (see reflowAllRecords's own doc comment on why: applying an + * adjustment to an already-adjusted record would drift). */ +export function findDimensionRecordById( + model: SheetLayoutModel, + id: string, +): DimensionRecord | null { + for (const view of Object.keys(model.views) as HiddenLineViewName[]) { + const match = model.views[view].dimensions.find((r) => r.id === id); + if (match) return match; + } + return null; +} + +// --- Painting ----------------------------------------------------------- + +export type SheetPaintBase = { + layoutModel: SheetLayoutModel; + partName: string; + date: string; + scaleLabel: string; +}; + +const FAMILY_LINE_COLOR = "#059669"; // LOCATION_DIM_COLOR in sheet-composer.ts +const OVERALL_AND_SIZE_COLOR = "#1a56db"; +const EXTENSION_COLOR = "#9ca3af"; // EXTENSION_LINE_COLOR in sheet-composer.ts +const CAPTION_COLOR = "#000000"; +const EPS_PX = 0.01; + +function colorForRecord(record: DimensionRecord): string { + if (record.kind === "caption") return CAPTION_COLOR; + if (record.kind === "overall" || record.kind === "size") { + return OVERALL_AND_SIZE_COLOR; + } + return FAMILY_LINE_COLOR; +} + +/** Thin wrapper around sheet-composer.ts's shared drawArrowheadAt - the + * canonical arrowhead shape/size - that also owns the fillStyle save/ + * restore, so every call site here can just pass (tip, other, color) + * without repeating that boilerplate. Used for both linear dimension-line + * ends and (since this task) a size callout's leader-to-feature end. */ +function drawArrowAt( + ctx: CanvasRenderingContext2D, + tipX: number, + tipY: number, + otherX: number, + otherY: number, + color: string, +) { + const len = Math.hypot(otherX - tipX, otherY - tipY) || 1; + const dirX = (otherX - tipX) / len; + const dirY = (otherY - tipY) / len; + ctx.save(); + ctx.fillStyle = color; + drawArrowheadAt(ctx, tipX, tipY, dirX, dirY); + ctx.restore(); +} + +function paintDimensionRecord( + ctx: CanvasRenderingContext2D, + record: DimensionRecord, +): void { + const color = colorForRecord(record); + const isFamily = + record.axis !== null && dimensionLineCrossCoord(record) !== null; + const dimLineCross = isFamily ? dimensionLineCrossCoord(record) : null; + + for (const s of record.lineSegments) { + const isDimLine = + isFamily && + dimLineCross !== null && + (record.axis === "horizontal" + ? Math.abs(s.y1 - dimLineCross) < EPS_PX && Math.abs(s.y2 - dimLineCross) < EPS_PX + : Math.abs(s.x1 - dimLineCross) < EPS_PX && Math.abs(s.x2 - dimLineCross) < EPS_PX); + ctx.save(); + // Same drafting line-weight hierarchy, from the same named constants, as + // the compose pipeline's own drawDimensionLine/drawExtensionLine/ + // drawCircularCallout use - this repaint IS what's displayed and + // downloaded, so a local literal here would silently become the real + // delivered weight. + if (isDimLine || record.kind === "size") { + ctx.strokeStyle = color; + ctx.lineWidth = + record.kind === "size" ? LINE_WEIGHT_LEADER_PX : LINE_WEIGHT_DIMENSION_PX; + ctx.setLineDash([]); + } else { + ctx.strokeStyle = EXTENSION_COLOR; + ctx.lineWidth = LINE_WEIGHT_EXTENSION_PX; + ctx.setLineDash([3, 2]); + } + ctx.beginPath(); + ctx.moveTo(s.x1, s.y1); + ctx.lineTo(s.x2, s.y2); + ctx.stroke(); + ctx.restore(); + if (isDimLine) { + drawArrowAt(ctx, s.x1, s.y1, s.x2, s.y2, color); + drawArrowAt(ctx, s.x2, s.y2, s.x1, s.y1, color); + } + } + + if (record.kind === "size" && record.lineSegments.length > 0) { + // Arrowhead where the leader meets the feature (task: arrowheads on + // circular/arc callouts, correctly oriented and touching the edge even + // after a drag) - anchor (x1,y1) is the fixed point on the circle/arc, + // (x2,y2) is the elbow this segment currently points at, recomputed by + // applyCircularAdjustment on every drag frame - see drawArrowAt's own + // doc comment for the shared shape this matches (drawCircularCallout's + // compose-time arrowhead, same tip/direction convention). + const anchor = record.lineSegments[0]; + drawArrowAt(ctx, anchor.x1, anchor.y1, anchor.x2, anchor.y2, color); + } + + if (!record.labelRect || !record.text) return; + const rect = record.labelRect; + ctx.save(); + if (record.kind === "caption") { + ctx.fillStyle = CAPTION_COLOR; + ctx.font = CAPTION_FONT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(record.text, rect.x + rect.w / 2, rect.y + rect.h / 2); + } else if (record.kind === "size") { + // Sits on its leader's landing (see buildLeaderLanding in + // sheet-composer.ts) - labelRect.x is already the text's correct left + // edge for either landing direction, so this always left-aligns rather + // than centering, keeping the text flush against the shoulder it reads + // away from regardless of which side the leader approached from. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + ctx.fillStyle = color; + ctx.font = CALLOUT_VALUE_FONT; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText(record.text, rect.x + 4, rect.y + rect.h / 2); + } else { + // Family label: rotated -90deg when vertical, matching + // drawDimensionLine/drawIsolatedLabel in sheet-composer.ts. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + ctx.fillStyle = color; + ctx.font = DIM_VALUE_FONT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + if (record.axis === "vertical") { + ctx.translate(rect.x + rect.w / 2, rect.y + rect.h / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText(record.text, 0, 0); + } else { + ctx.fillText(record.text, rect.x + rect.w / 2, rect.y + rect.h / 2); + } + } + ctx.restore(); +} + +const SELECTION_COLOR = "#f59e0b"; + +/** Clear visual highlight for the currently-selected dimension (Adjust + * Annotations mode) - a bright outline over its own lines plus a padded + * outline around its label. Drawn as a distinct final pass, never mixed + * into the record's own base geometry/colors, so it's trivial to omit for + * a download capture (see cad-viewer.tsx's handleDownloadSheet). */ +function highlightRecord(ctx: CanvasRenderingContext2D, record: DimensionRecord): void { + ctx.save(); + ctx.strokeStyle = SELECTION_COLOR; + ctx.lineWidth = 2.5; + ctx.setLineDash([]); + for (const s of record.lineSegments) { + ctx.beginPath(); + ctx.moveTo(s.x1, s.y1); + ctx.lineTo(s.x2, s.y2); + ctx.stroke(); + } + if (record.labelRect && record.labelRect.w > 0) { + const r = record.labelRect; + const pad = 3; + ctx.strokeRect(r.x - pad, r.y - pad, r.w + pad * 2, r.h + pad * 2); + } + ctx.restore(); +} + +/** The cheap per-frame repaint: static frame/title block, then each view's + * raster at its offset position, then every dimension record translated and + * drawn on top, then (if `selectedId` names a still-present record) a + * selection highlight over it. Never touches composeA4DrawingSheet's + * pipeline. `selectedId` is purely a live-view affordance - omit it (as + * handleDownloadSheet does) to capture the drawing exactly as delivered, + * with no highlight baked in. */ +export function paintInteractiveSheet( + ctx: CanvasRenderingContext2D, + base: SheetPaintBase, + adjustments: SheetLayoutAdjustments, + selectedId?: string | null, + notes?: { + enabled: boolean; + items: string[]; + position: { x: number; y: number }; + editMode?: boolean; + showBorder?: boolean; + }, + titleBlock?: { + table: TitleBlockTable; + editMode?: boolean; + selection?: CellRange | null; + logoImage?: HTMLImageElement | null; + }, +): void { + ctx.clearRect(0, 0, SHEET_W, SHEET_H); + drawSheetFrame(ctx); + + const views = Object.keys(base.layoutModel.views) as HiddenLineViewName[]; + const reflowed = reflowAllRecords(base.layoutModel, adjustments); + + // Part outlines, at the SAME drafting line weights the compose pipeline + // drew them at - literally the same function (strokeSheetEdgeRuns), so a + // dragged sheet and a freshly composed one can't differ in line weight. + // Each view's own combinedViewOffset (composition + that view's own + // Top/Right group offset, if any - task 2) so a view's outline always + // moves exactly together with its own dimensions/caption, which + // reflowAllRecords already translated the same way. + for (const view of views) { + const { dx, dy } = combinedViewOffset(view, adjustments); + strokeSheetEdgeRuns(ctx, base.layoutModel.views[view].edgeRuns, dx, dy); + } + // Isometric reference view: moves with the composition offset AND its own + // free `viewGroups.iso` offset (task 2's "3D VIEW" option) - never with + // Top's or Right's, since it carries no DimensionRecord and is + // deliberately independent of every other view (see ViewGroupOffsets' + // doc comment) - and carries no selectable record, so it is never + // hit-testable or deletable in Adjust Annotations mode. + const iso = base.layoutModel.isoView; + if (iso) { + const isoDx = adjustments.composition.dx + adjustments.viewGroups.iso.dx; + const isoDy = adjustments.composition.dy + adjustments.viewGroups.iso.dy; + ctx.drawImage( + iso.img, + iso.srcRect.x, + iso.srcRect.y, + iso.srcRect.w, + iso.srcRect.h, + iso.destRect.x + isoDx, + iso.destRect.y + isoDy, + iso.destRect.w, + iso.destRect.h, + ); + } + for (const view of views) { + for (const record of reflowed[view]) paintDimensionRecord(ctx, record); + } + + drawSheetTitleBlock( + ctx, + { partName: base.partName, date: base.date, scaleLabel: base.scaleLabel }, + titleBlock?.table, + titleBlock?.editMode ?? false, + titleBlock?.logoImage, + ); + if (titleBlock?.editMode && titleBlock.selection && titleBlock.table) { + const r = rangeRectPx(titleBlock.table, TITLE_BLOCK_RECT, titleBlock.selection); + ctx.save(); + ctx.strokeStyle = "#2563eb"; + ctx.lineWidth = 3; + ctx.strokeRect(r.x, r.y, r.w, r.h); + ctx.restore(); + } + + // Independently draggable furniture (task 3) - never part of + // SheetLayoutAdjustments/reflowAllRecords/fullContentBounds (see + // sheet-composer.ts's defaultNotesPosition doc comment); its own position + // is tracked separately (cad-viewer.tsx's notesPositionRef) and clamped + // against live content via clampNotesPosition instead. Drawn whenever + // enabled, even with zero notes yet, so the dashed border + pencil + // affordance has somewhere to show the first entry point. + if (notes?.enabled) { + drawSheetNotes( + ctx, + notes.items, + notes.position, + notes.editMode ?? false, + notes.showBorder ?? true, + ); + } + + if (selectedId) { + for (const view of views) { + const match = reflowed[view].find((r) => r.id === selectedId); + if (match) { + highlightRecord(ctx, match); + break; + } + } + } +} diff --git a/src/components/cad/title-block-table.ts b/src/components/cad/title-block-table.ts new file mode 100644 index 0000000..f826667 --- /dev/null +++ b/src/components/cad/title-block-table.ts @@ -0,0 +1,680 @@ +/** + * Data model + pure geometry/editing functions for the title block's + * spreadsheet-style table. Kept independent of sheet-composer.ts/ + * sheet-interactive-render.ts (only a structural Rect shape is shared, not + * imported, to avoid a circular import - sheet-composer.ts renders this + * model, cad-viewer.tsx drives it interactively). + * + * Model: a grid of row/column fraction boundaries (0..1 of the title block's + * own w/h) plus a list of cells, each spanning a rectangular [r0,r1)x[c0,c1) + * range of grid units - exactly Excel's "uniform grid + merged ranges" + * model, so "insert a row/column" and "delete a boundary segment (merge two + * cells)" both have well-defined, non-corrupting semantics. + */ + +export type Rect = { x: number; y: number; w: number; h: number }; + +/** + * A cell whose rendering/value is system-controlled rather than freeform + * typed text - the ONE mechanism behind every "special" cell kind (unifies + * what used to be a separate magic-string match for the third-angle symbol + * plus two new needs, the bound SCALE/SIZE fields and the logo), so cell + * behavior is never decided two different ways. `boundScale`/`boundSize`: + * the cell's stored `text` is ignored at render time and replaced with the + * live value (current scale label / literal "A4") - not typable in edit + * mode either, though the cell itself is still selectable/mergeable/ + * deletable/splittable like any other (see title-block-table.ts's own + * isCellTypable-style guards where this is consumed). `logo`: renders + * `logoDataUrl` if present, else a generated initial-letter avatar - not a + * text cell at all in edit mode. `thirdAngleSymbol`: renders the third-angle + * graphic; UNLIKE the bound kinds, its `text` stays user-typable (e.g. for + * localizing the caption) - the tag alone gates the graphic, it doesn't lock + * the text the way the old magic-string trigger accidentally did. + * `partNameTitle`: marks which cell gets the bold shrink-to-fit treatment, + * replacing the old `cell.r0 === 0` position-based heuristic (wrong once row + * 0 is no longer always the part name). + */ +export type TitleBlockSpecialKind = + | "boundScale" + | "boundSize" + | "logo" + | "thirdAngleSymbol" + | "partNameTitle"; + +export interface TitleBlockCell { + id: string; + r0: number; + r1: number; + c0: number; + c1: number; + text: string; + /** System-controlled rendering/value - see TitleBlockSpecialKind's own doc + * comment. Dropped whenever this cell is merged with another (merging is + * the user opting out of the bound behavior). */ + special?: TitleBlockSpecialKind; + /** Pure lookup marker, no effect on editability or rendering by itself - + * currently only "drawnName", letting the logo's avatar fallback find + * "the" name to derive an initial/color from without a dedicated + * top-level field. Survives a merge (unlike `special`), since it carries + * no bound-value semantics to opt out of. */ + role?: "drawnName"; + /** Only meaningful with special:"logo" - a data: URL for the uploaded + * image, or undefined to fall back to the generated avatar. */ + logoDataUrl?: string; +} + +export interface TitleBlockTable { + rowFracs: number[]; + colFracs: number[]; + cells: TitleBlockCell[]; +} + +// Floor on a grid line's distance from its neighbours (fraction of the +// table's own w/h) - keeps a resize or a run of inserts from ever collapsing +// a cell to zero (or negative) size. +const MIN_GAP_FRAC = 0.04; + +let idSeq = 0; +function makeCellId(): string { + idSeq += 1; + return `tbc-${idSeq}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * The title block's own default layout - a standard engineering title block + * (task: "matching the attached reference drawing's structure"), built from + * the same uniform-grid-plus-spans model every other table on this sheet + * uses. 7 rows x 8 columns: + * + * rows 0-3 (left, cols 0-3): Logo | "ALL DIMENSION ARE IN MM", spanning + * all 4 header rows. + * rows 0-3 (right, cols 4-7): a DRAWN/CHK'D/DESIG'D x NAME/SIGN/DATE + * sub-grid - row 0 is the NAME/SIGN/DATE header (col 4 left blank, the + * conventional corner), rows 1-3 are the three data rows. Row 1 + * (DRAWN)'s DATE cell is prefilled from the `date` arg (today's existing + * prefill behavior, just relocated); its NAME cell carries + * role:"drawnName" for the logo's avatar fallback to key off. + * row 4: MATERIAL label+value (left) | PART NAME label+value (right, + * value prefilled from `partName`, tagged special:"partNameTitle" for + * the bold shrink-to-fit treatment that used to be keyed off row 0). + * row 5: WEIGHT label+value (left) | PART NO label+value (right). + * row 6: third-angle symbol (left, special:"thirdAngleSymbol", caption + * stays user-typable) | SCALE (special:"boundScale") | SIZE + * (special:"boundSize") | SHEET - the three system/near-system fields + * each rendered as one combined "LABEL value" cell, matching this + * file's pre-existing SCALE/DATE convention rather than splitting every + * field into a separate label+value pair. + * + * No NOTE cell anywhere - the app's separate standalone notes-block feature + * is unrelated and untouched; duplicating it here would just invite + * confusion between the two. + */ +export function defaultTitleBlockTable( + partName: string, + date: string, + scaleLabel: string, +): TitleBlockTable { + const rowFracs = [0, 1 / 7, 2 / 7, 3 / 7, 4 / 7, 5 / 7, 6 / 7, 1]; + const colFracs = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1]; + const cell = ( + r0: number, + r1: number, + c0: number, + c1: number, + text: string, + extra?: Partial>, + ): TitleBlockCell => ({ id: makeCellId(), r0, r1, c0, c1, text, ...extra }); + + return { + rowFracs, + colFracs, + cells: [ + // Left column: logo + "all dimensions" note, spanning the 4 header rows. + cell(0, 4, 0, 2, "", { special: "logo" }), + cell(0, 4, 2, 4, "ALL DIMENSION ARE IN MM"), + + // DRAWN / CHK'D / DESIG'D x NAME / SIGN / DATE sub-grid. + cell(0, 1, 4, 5, ""), + cell(0, 1, 5, 6, "NAME"), + cell(0, 1, 6, 7, "SIGN"), + cell(0, 1, 7, 8, "DATE"), + cell(1, 2, 4, 5, "DRAWN"), + cell(1, 2, 5, 6, "", { role: "drawnName" }), + cell(1, 2, 6, 7, ""), + cell(1, 2, 7, 8, date), + cell(2, 3, 4, 5, "CHK'D"), + cell(2, 3, 5, 6, ""), + cell(2, 3, 6, 7, ""), + cell(2, 3, 7, 8, ""), + cell(3, 4, 4, 5, "DESIG'D"), + cell(3, 4, 5, 6, ""), + cell(3, 4, 6, 7, ""), + cell(3, 4, 7, 8, ""), + + // MATERIAL / PART NAME row. + cell(4, 5, 0, 2, "MATERIAL"), + cell(4, 5, 2, 4, ""), + cell(4, 5, 4, 5, "PART NAME"), + cell(4, 5, 5, 8, partName, { special: "partNameTitle" }), + + // WEIGHT / PART NO row. + cell(5, 6, 0, 2, "WEIGHT"), + cell(5, 6, 2, 4, ""), + cell(5, 6, 4, 5, "PART NO"), + cell(5, 6, 5, 8, ""), + + // Third-angle symbol / SCALE / SIZE / SHEET row. + cell(6, 7, 0, 4, "THIRD ANGLE PROJECTION", { special: "thirdAngleSymbol" }), + cell(6, 7, 4, 6, `SCALE ${scaleLabel}`, { special: "boundScale" }), + cell(6, 7, 6, 7, "SIZE A4", { special: "boundSize" }), + cell(6, 7, 7, 8, "SHEET 1"), + ], + }; +} + +export function numRows(table: TitleBlockTable): number { + return table.rowFracs.length - 1; +} +export function numCols(table: TitleBlockTable): number { + return table.colFracs.length - 1; +} + +export function cellAt(table: TitleBlockTable, r: number, c: number): TitleBlockCell | undefined { + return table.cells.find((cell) => r >= cell.r0 && r < cell.r1 && c >= cell.c0 && c < cell.c1); +} + +/** A rectangular grid-unit span - the same shape a TitleBlockCell occupies, + * but not tied to any one cell. The selection model (SELECT/MERGE/DELETE + * row(s)/column(s)) operates on this rather than on TitleBlockCell directly, + * since a selection can span multiple cells or a partial cell's worth of + * grid units before MERGE resolves it into one. */ +export interface CellRange { + r0: number; + r1: number; + c0: number; + c1: number; +} + +function boundsRectPx(table: TitleBlockTable, rect: Rect, bounds: CellRange): Rect { + const x0 = rect.x + table.colFracs[bounds.c0] * rect.w; + const x1 = rect.x + table.colFracs[bounds.c1] * rect.w; + const y0 = rect.y + table.rowFracs[bounds.r0] * rect.h; + const y1 = rect.y + table.rowFracs[bounds.r1] * rect.h; + return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; +} + +export function cellRectPx(table: TitleBlockTable, rect: Rect, cell: TitleBlockCell): Rect { + return boundsRectPx(table, rect, cell); +} + +export function rangeRectPx(table: TitleBlockTable, rect: Rect, range: CellRange): Rect { + return boundsRectPx(table, rect, range); +} + +/** Every cell that overlaps `range` at all (not just ones fully inside it) - + * used both by the fixpoint "snap outward" expansion (see + * expandRangeToCoverCells) and by the UI layer to decide toolbar enablement + * (e.g. Split only for a single already-merged cell). */ +export function cellsInRange(table: TitleBlockTable, range: CellRange): TitleBlockCell[] { + return table.cells.filter( + (cell) => cell.r0 < range.r1 && cell.r1 > range.r0 && cell.c0 < range.c1 && cell.c1 > range.c0, + ); +} + +/** Grows `range` outward until every cell it overlaps is fully contained in + * it - a FIXPOINT loop, not a single pass, because fully containing one + * partially-overlapping cell can newly overlap another cell outside the + * original range (a merge/selection can cascade like a staircase through a + * chain of merged cells). Shared by mergeRange (merging must never split an + * existing merged cell in half) and rangeFromUnits (a drag/shift-click + * selection should always snap to whole cells, the same way Excel's own + * range selection does when it clips a merged cell). */ +function expandRangeToCoverCells(table: TitleBlockTable, range: CellRange): CellRange { + let { r0, r1, c0, c1 } = range; + for (;;) { + let expanded = false; + for (const cell of table.cells) { + const overlaps = cell.r0 < r1 && cell.r1 > r0 && cell.c0 < c1 && cell.c1 > c0; + if (!overlaps) continue; + if (cell.r0 < r0) { + r0 = cell.r0; + expanded = true; + } + if (cell.r1 > r1) { + r1 = cell.r1; + expanded = true; + } + if (cell.c0 < c0) { + c0 = cell.c0; + expanded = true; + } + if (cell.c1 > c1) { + c1 = cell.c1; + expanded = true; + } + } + if (!expanded) break; + } + return { r0, r1, c0, c1 }; +} + +/** Builds a CellRange for a single cell - a click-to-select's starting + * point. */ +export function cellRange(cell: Pick): CellRange { + return { r0: cell.r0, r1: cell.r1, c0: cell.c0, c1: cell.c1 }; +} + +/** A click-drag or shift-click's anchor+focus grid units, normalized into a + * CellRange and snapped outward to fully include any cell either endpoint + * clips - see expandRangeToCoverCells. */ +export function rangeFromUnits( + table: TitleBlockTable, + a: { r: number; c: number }, + b: { r: number; c: number }, +): CellRange { + return expandRangeToCoverCells(table, { + r0: Math.min(a.r, b.r), + r1: Math.max(a.r, b.r) + 1, + c0: Math.min(a.c, b.c), + c1: Math.max(a.c, b.c) + 1, + }); +} + +function verticalBoundaryExists(table: TitleBlockTable, lineIndex: number, unitRow: number): boolean { + const left = cellAt(table, unitRow, lineIndex - 1); + const right = cellAt(table, unitRow, lineIndex); + return !!left && !!right && left.id !== right.id; +} +function horizontalBoundaryExists(table: TitleBlockTable, lineIndex: number, unitCol: number): boolean { + const top = cellAt(table, lineIndex - 1, unitCol); + const bottom = cellAt(table, lineIndex, unitCol); + return !!top && !!bottom && top.id !== bottom.id; +} + +/** Every drawn (i.e. not merged-through) run along interior grid lines, as + * pixel segments - the single source both the renderer (sheet-composer.ts) + * and the hover/hit-testing below use, so a line drawn on screen and a line + * hit-tested against can never disagree. */ +export function drawnGridSegmentsPx( + table: TitleBlockTable, + rect: Rect, +): { x1: number; y1: number; x2: number; y2: number }[] { + const segs: { x1: number; y1: number; x2: number; y2: number }[] = []; + const nRows = numRows(table); + const nCols = numCols(table); + for (let j = 1; j < nCols; j++) { + const x = rect.x + table.colFracs[j] * rect.w; + let runStart: number | null = null; + for (let r = 0; r <= nRows; r++) { + const exists = r < nRows && verticalBoundaryExists(table, j, r); + if (exists && runStart === null) runStart = r; + if (!exists && runStart !== null) { + segs.push({ + x1: x, + y1: rect.y + table.rowFracs[runStart] * rect.h, + x2: x, + y2: rect.y + table.rowFracs[r] * rect.h, + }); + runStart = null; + } + } + } + for (let i = 1; i < nRows; i++) { + const y = rect.y + table.rowFracs[i] * rect.h; + let runStart: number | null = null; + for (let c = 0; c <= nCols; c++) { + const exists = c < nCols && horizontalBoundaryExists(table, i, c); + if (exists && runStart === null) runStart = c; + if (!exists && runStart !== null) { + segs.push({ + x1: rect.x + table.colFracs[runStart] * rect.w, + y1: y, + x2: rect.x + table.colFracs[c] * rect.w, + y2: y, + }); + runStart = null; + } + } + } + return segs; +} + +function mergeText(a: string, b: string): string { + return [a, b] + .map((s) => s.trim()) + .filter(Boolean) + .join(" "); +} + +/** True unless `cell`'s value is system-controlled (see + * TitleBlockSpecialKind) or it isn't a text cell at all (the logo) - the UI + * layer uses this to decide whether a click/double-click on a cell may open + * a free-text edit input at all. Cells that return false here are still + * fully selectable/mergeable/deletable/splittable like any other. */ +export function isCellTypable(cell: Pick): boolean { + return cell.special !== "boundScale" && cell.special !== "boundSize" && cell.special !== "logo"; +} + +/** + * Merges every cell intersecting the grid-unit rectangle `range` into one + * cell spanning at least that rectangle - snapped outward first via + * expandRangeToCoverCells (see its own doc comment), so a range that clips a + * merged cell always absorbs it whole rather than corrupting the grid. + * Concatenates every covered cell's text left-to-right/top-to-bottom + * (mergeText, same convention the old segment-delete-based merge used). + * Drops `special` - merging a system-controlled cell is the user opting out + * of the bound behavior (see TitleBlockSpecialKind's own doc comment) - but + * keeps `role` if exactly one covered cell carries one, since it's a pure + * lookup marker with no bound-value semantics to opt out of. + */ +export function mergeRange(table: TitleBlockTable, range: CellRange): TitleBlockTable { + const { r0, r1, c0, c1 } = expandRangeToCoverCells(table, range); + const covered = cellsInRange(table, { r0, r1, c0, c1 }); + if (covered.length <= 1) return table; + + const text = covered.map((c) => c.text).reduce((acc, t) => mergeText(acc, t)); + const roles = covered.map((c) => c.role).filter((r): r is "drawnName" => !!r); + const merged: TitleBlockCell = { + id: covered[0].id, + r0, + r1, + c0, + c1, + text, + ...(roles.length === 1 ? { role: roles[0] } : {}), + }; + + const coveredIds = new Set(covered.map((c) => c.id)); + const cells = table.cells.filter((c) => !coveredIds.has(c.id)).concat(merged); + return { ...table, cells }; +} + +/** Splits a merged cell back into one blank cell per underlying grid unit it + * spans - there's no stored pre-merge structure to restore (the same + * limitation the old segment-delete-based merge always had), so this is + * standard spreadsheet "unmerge" behavior, not a true undo. The original + * cell's text/special/role/logoDataUrl land on its own top-left unit only; + * every other new unit starts a fresh blank cell. No-op if the cell isn't + * actually merged (already spans exactly one grid unit). */ +export function splitCell(table: TitleBlockTable, cellId: string): TitleBlockTable { + const cell = table.cells.find((c) => c.id === cellId); + if (!cell) return table; + if (cell.r1 - cell.r0 <= 1 && cell.c1 - cell.c0 <= 1) return table; + + const replacements: TitleBlockCell[] = []; + for (let r = cell.r0; r < cell.r1; r++) { + for (let c = cell.c0; c < cell.c1; c++) { + replacements.push( + r === cell.r0 && c === cell.c0 + ? { ...cell, r0: r, r1: r + 1, c0: c, c1: c + 1 } + : { id: makeCellId(), r0: r, r1: r + 1, c0: c, c1: c + 1, text: "" }, + ); + } + } + const cells = table.cells.filter((c) => c.id !== cellId).concat(replacements); + return { ...table, cells }; +} + +/** Removes grid rows [r0,r1) entirely - NOT the same as merging them away; + * the vacated space is reclaimed so every remaining row keeps its own + * proportional size (a naive splice of rowFracs would instead stretch + * whichever row abuts the deletion to cover the gap - see the equivalent + * math worked through in this function's sibling, deleteColumns). Cells are + * re-indexed with boundary CLAMPING, not a flat shift: a cell entirely + * before the deleted range is untouched, one entirely after shifts up by the + * deleted count, and one that only partially overlapped the deleted range + * gets clamped to whatever's left of it; a cell fully inside the deleted + * range collapses to zero height and is dropped. Never deletes every row + * (a no-op if it would). */ +export function deleteRows(table: TitleBlockTable, r0: number, r1: number): TitleBlockTable { + const nRows = numRows(table); + const start = Math.max(0, Math.min(r0, r1)); + const end = Math.min(nRows, Math.max(r0, r1)); + if (end <= start || end - start >= nRows) return table; + const removedCount = end - start; + const removedFrac = table.rowFracs[end] - table.rowFracs[start]; + + const rowFracs = table.rowFracs + .slice(0, start + 1) + .concat(table.rowFracs.slice(end + 1).map((f) => f - removedFrac)); + + const cells: TitleBlockCell[] = []; + for (const cell of table.cells) { + const newR0 = cell.r0 <= start ? cell.r0 : cell.r0 >= end ? cell.r0 - removedCount : start; + const newR1 = cell.r1 >= end ? cell.r1 - removedCount : cell.r1 <= start ? cell.r1 : start; + if (newR1 <= newR0) continue; + cells.push({ ...cell, r0: newR0, r1: newR1 }); + } + return { ...table, rowFracs, cells }; +} + +/** Column-axis mirror of deleteRows - see its doc comment for the full + * reasoning (identical, transposed). */ +export function deleteColumns(table: TitleBlockTable, c0: number, c1: number): TitleBlockTable { + const nCols = numCols(table); + const start = Math.max(0, Math.min(c0, c1)); + const end = Math.min(nCols, Math.max(c0, c1)); + if (end <= start || end - start >= nCols) return table; + const removedCount = end - start; + const removedFrac = table.colFracs[end] - table.colFracs[start]; + + const colFracs = table.colFracs + .slice(0, start + 1) + .concat(table.colFracs.slice(end + 1).map((f) => f - removedFrac)); + + const cells: TitleBlockCell[] = []; + for (const cell of table.cells) { + const newC0 = cell.c0 <= start ? cell.c0 : cell.c0 >= end ? cell.c0 - removedCount : start; + const newC1 = cell.c1 >= end ? cell.c1 - removedCount : cell.c1 <= start ? cell.c1 : start; + if (newC1 <= newC0) continue; + cells.push({ ...cell, c0: newC0, c1: newC1 }); + } + return { ...table, colFracs, cells }; +} + +/** Inserts a new column immediately right of interior/outer line `atLine` + * (0..numCols) - a cell that already spans across that point (a merge) + * silently widens to absorb it; a cell that ends exactly there keeps its own + * content and a fresh blank cell appears in the new column next to it (see + * this file's own doc comment - this is deliberately the same rule real + * spreadsheets use for inserting through/at the edge of a merged range). */ +export function insertColumn(table: TitleBlockTable, atLine: number): TitleBlockTable { + const nCols = numCols(table); + const insertIdx = Math.min(Math.max(atLine, 0) + 1, nCols); + return insertLineGeneric(table, "v", insertIdx); +} +export function insertRow(table: TitleBlockTable, atLine: number): TitleBlockTable { + const nRows = numRows(table); + const insertIdx = Math.min(Math.max(atLine, 0) + 1, nRows); + return insertLineGeneric(table, "h", insertIdx); +} + +// Toolbar-driven inserts (task 2: "insert row above/below column left/right +// - with a cell selected"), relative to a cell rather than a hovered line +// index - thin wrappers over insertRow/insertColumn above. "Above"/"left" +// splits the band immediately before the cell's own start; "below"/"right" +// splits the cell's own band (or, if the cell spans several bands - a merge +// - the band it ends on), which is what leaves the cell itself untouched and +// a fresh blank row/column landing on the correct side of it - see +// insertLineGeneric's own doc comment for why "split the neighbouring band" +// is what a clean insert reduces to in this model. At the table's own outer +// edge (cell.r0===0 etc., nothing to split above) this falls back to +// insertRow/insertColumn's own clamping, same as it always has - there's no +// space to insert into without growing the title block's own fixed rect. +export function insertRowAboveCell(table: TitleBlockTable, cell: Pick): TitleBlockTable { + return insertRow(table, cell.r0 - 1); +} +export function insertRowBelowCell(table: TitleBlockTable, cell: Pick): TitleBlockTable { + return insertRow(table, cell.r1 - 1); +} +export function insertColumnLeftOfCell(table: TitleBlockTable, cell: Pick): TitleBlockTable { + return insertColumn(table, cell.c0 - 1); +} +export function insertColumnRightOfCell(table: TitleBlockTable, cell: Pick): TitleBlockTable { + return insertColumn(table, cell.c1 - 1); +} + +function insertLineGeneric(table: TitleBlockTable, axis: "v" | "h", insertIdx: number): TitleBlockTable { + const fracs = axis === "v" ? table.colFracs : table.rowFracs; + const newFrac = (fracs[insertIdx - 1] + fracs[insertIdx]) / 2; + const nextFracs = [...fracs.slice(0, insertIdx), newFrac, ...fracs.slice(insertIdx)]; + + // The insertion always lands INSIDE old grid-band (insertIdx-1) (that's + // what "between old[insertIdx-1] and old[insertIdx]" means). A cell + // entirely left of it (c1 < insertIdx) or entirely right (c0 >= insertIdx) + // just keeps/shifts its index numbers. A cell that actually COVERS that + // band splits into two IFF it was exactly that one band alone (c1-c0===1, + // an ordinary unmerged cell) - it keeps its own numbers, becoming the new + // left/top half, and gap-fill below gives the new right/bottom half a + // fresh blank cell; a cell spanning SEVERAL bands (a real merge) instead + // silently widens (c1 += 1) to absorb the split without ever visibly + // dividing, exactly like a spreadsheet's merged range would. + const cells = table.cells.map((cell) => { + if (axis === "v") { + if (cell.c1 < insertIdx) return cell; + if (cell.c0 >= insertIdx) return { ...cell, c0: cell.c0 + 1, c1: cell.c1 + 1 }; + if (cell.c1 - cell.c0 === 1) return cell; + return { ...cell, c1: cell.c1 + 1 }; + } + if (cell.r1 < insertIdx) return cell; + if (cell.r0 >= insertIdx) return { ...cell, r0: cell.r0 + 1, r1: cell.r1 + 1 }; + if (cell.r1 - cell.r0 === 1) return cell; + return { ...cell, r1: cell.r1 + 1 }; + }); + + const next: TitleBlockTable = + axis === "v" ? { ...table, colFracs: nextFracs, cells } : { ...table, rowFracs: nextFracs, cells }; + + // Gap-fill: the freshly-split-off band (grid index insertIdx, the new + // right/bottom half - see above) may not be covered by any cell for some + // row/column runs (exactly the unmerged-split case above) - give each + // uncovered contiguous run its own new blank cell. + const bandIndex = insertIdx; + const perpCount = axis === "v" ? numRows(next) : numCols(next); + const extra: TitleBlockCell[] = []; + let runStart: number | null = null; + for (let u = 0; u <= perpCount; u++) { + const covered = u < perpCount && !!(axis === "v" ? cellAt(next, u, bandIndex) : cellAt(next, bandIndex, u)); + if (!covered && u < perpCount && runStart === null) runStart = u; + if ((covered || u === perpCount) && runStart !== null) { + extra.push( + axis === "v" + ? { id: makeCellId(), r0: runStart, r1: u, c0: bandIndex, c1: bandIndex + 1, text: "" } + : { id: makeCellId(), r0: bandIndex, r1: bandIndex + 1, c0: runStart, c1: u, text: "" }, + ); + runStart = null; + } + } + return { ...next, cells: [...next.cells, ...extra] }; +} + +export function resizeColumnLine(table: TitleBlockTable, lineIndex: number, newFrac: number): TitleBlockTable { + const fracs = table.colFracs; + const lo = fracs[lineIndex - 1] + MIN_GAP_FRAC; + const hi = fracs[lineIndex + 1] - MIN_GAP_FRAC; + if (lo > hi) return table; + const clamped = Math.max(lo, Math.min(hi, newFrac)); + const next = [...fracs]; + next[lineIndex] = clamped; + return { ...table, colFracs: next }; +} +export function resizeRowLine(table: TitleBlockTable, lineIndex: number, newFrac: number): TitleBlockTable { + const fracs = table.rowFracs; + const lo = fracs[lineIndex - 1] + MIN_GAP_FRAC; + const hi = fracs[lineIndex + 1] - MIN_GAP_FRAC; + if (lo > hi) return table; + const clamped = Math.max(lo, Math.min(hi, newFrac)); + const next = [...fracs]; + next[lineIndex] = clamped; + return { ...table, rowFracs: next }; +} + +export function setCellText(table: TitleBlockTable, cellId: string, text: string): TitleBlockTable { + return { ...table, cells: table.cells.map((c) => (c.id === cellId ? { ...c, text } : c)) }; +} + +/** Sets/clears the logo cell's uploaded image (task 4: upload + remove + * controls) - mirrors setCellText. `dataUrl` undefined clears back to the + * generated-avatar fallback. */ +export function setCellLogo(table: TitleBlockTable, cellId: string, dataUrl: string | undefined): TitleBlockTable { + return { ...table, cells: table.cells.map((c) => (c.id === cellId ? { ...c, logoDataUrl: dataUrl } : c)) }; +} + +// --- Hit-testing (sheet px, i.e. already through sheetPointerToSheetSpace) - + +function unitForFrac(fracs: number[], frac: number): number { + for (let k = 0; k < fracs.length - 1; k++) { + if (frac < fracs[k + 1] || k === fracs.length - 2) return k; + } + return 0; +} + +export interface TitleLineHit { + axis: "v" | "h"; + lineIndex: number; + unit: number; +} + +/** Interior grid lines only (task: "the four OUTER border lines can never + * be selected") - resize hit-testing. Consults verticalBoundaryExists/ + * horizontalBoundaryExists (not just raw colFracs/rowFracs proximity), so a + * point near where a line WOULD be if nothing were merged, but that isn't + * actually drawn there because a cell spans straight across it, correctly + * misses - otherwise a click meant to select/edit the interior of a merged + * cell could register as a resize-line hit instead. */ +export function hitTestTitleGridLine( + table: TitleBlockTable, + rect: Rect, + x: number, + y: number, + tolPx: number, +): TitleLineHit | null { + const nCols = numCols(table); + const nRows = numRows(table); + if (y >= rect.y && y <= rect.y + rect.h) { + for (let j = 1; j < nCols; j++) { + const lx = rect.x + table.colFracs[j] * rect.w; + if (Math.abs(x - lx) <= tolPx) { + const unit = unitForFrac(table.rowFracs, (y - rect.y) / rect.h); + if (verticalBoundaryExists(table, j, unit)) return { axis: "v", lineIndex: j, unit }; + } + } + } + if (x >= rect.x && x <= rect.x + rect.w) { + for (let i = 1; i < nRows; i++) { + const ly = rect.y + table.rowFracs[i] * rect.h; + if (Math.abs(y - ly) <= tolPx) { + const unit = unitForFrac(table.colFracs, (x - rect.x) / rect.w); + if (horizontalBoundaryExists(table, i, unit)) return { axis: "h", lineIndex: i, unit }; + } + } + } + return null; +} + +/** Pointer position (sheet px, inside `rect`) -> the cell it falls in - the + * one place UI cell-selection hit-testing happens, so clicking and + * rendering can never disagree about which cell a point is "in" (same + * reasoning drawnGridSegmentsPx's own doc comment gives for lines). Null + * only when x/y falls entirely outside rect. */ +export function hitTestTitleCell(table: TitleBlockTable, rect: Rect, x: number, y: number): TitleBlockCell | null { + if (x < rect.x || x > rect.x + rect.w || y < rect.y || y > rect.y + rect.h) return null; + const r = unitForFrac(table.rowFracs, (y - rect.y) / rect.h); + const c = unitForFrac(table.colFracs, (x - rect.x) / rect.w); + return cellAt(table, r, c) ?? null; +} + +/** Same idea as hitTestTitleCell but returns the raw grid-unit coordinate + * rather than the cell occupying it, clamping x/y into `rect` first rather + * than returning null outside it - used for click-drag range selection, + * where the pointer routinely strays outside the table mid-drag but the + * selection should still resolve to the nearest valid unit (standard + * spreadsheet drag behavior). */ +export function hitTestTitleUnit(table: TitleBlockTable, rect: Rect, x: number, y: number): { r: number; c: number } { + const clampedX = Math.max(rect.x, Math.min(rect.x + rect.w, x)); + const clampedY = Math.max(rect.y, Math.min(rect.y + rect.h, y)); + return { + r: unitForFrac(table.rowFracs, (clampedY - rect.y) / rect.h), + c: unitForFrac(table.colFracs, (clampedX - rect.x) / rect.w), + }; +} diff --git a/src/components/cad/viewer.ts b/src/components/cad/viewer.ts index 9b125d6..c104036 100644 --- a/src/components/cad/viewer.ts +++ b/src/components/cad/viewer.ts @@ -12,6 +12,8 @@ import { import { Line2 } from "three/examples/jsm/lines/Line2.js"; import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js"; +import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js"; +import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js"; import { measureApproximateMeshEdgeAtScreenPosition } from "./approx-mesh-measurement"; import { buildCircularFeatureCache, @@ -81,6 +83,270 @@ function buildBoundsTreeForObjectMeshes(root: THREE.Object3D): void { }); } +// --- TEMPORARY DEBUG: hidden-line detection spike ------------------------- +// Everything between these markers exists only to prove out ray-cast-based +// hidden-line detection in isolation before any dashed-line/sheet work +// begins. Remove once the approach is verified (or promoted into the real +// feature). + +export type HiddenLineDebugStats = { + edgeSource: "exact-cad" | "approx-cad" | "fallback-mesh"; + edgeCount: number; + totalSamples: number; + visibleSamples: number; + hiddenSamples: number; + visibleSegmentCount: number; + hiddenSegmentCount: number; + computeMs: number; +}; + +// A flat run of mini-segments sharing one visibility state. `distances` holds +// a cumulative arc-length per vertex, reset to 0 at each run boundary - +// consumed as the `lineDistance` attribute LineDashedMaterial needs to dash +// correctly across a LineSegments buffer (whose built-in +// computeLineDistances() resets every pair, which would break dashing across +// a real multi-sample-long hidden run). +type HiddenLineRunBuffers = { + positions: number[]; + distances: number[]; +}; + +export type HiddenLineComputeResult = { + stats: HiddenLineDebugStats; + visible: HiddenLineRunBuffers; + hidden: HiddenLineRunBuffers; +}; + +export type HiddenLineViewName = "front" | "top" | "right"; + +/** + * One continuous run of hidden-line result geometry, already projected into + * the captured view's own pixel space (the same space canvasWidth/ + * canvasHeight and every annotation's *Px point live in). + * + * VECTORS, not a bitmap, deliberately: the drawing sheet has to stroke these + * at a real drafting line weight in PAPER mm (see drafting-rules.ts's + * LINE_WEIGHT_* hierarchy), and a rasterized capture can't deliver that - it + * gets resampled by whatever ratio the sheet ends up composing at (bilinear- + * blurred when magnified, washed out below one pixel when reduced), so its + * apparent stroke width would track the part's drafting ratio instead of + * staying the fixed on-paper weight the convention specifies. `pts` is a + * flat [x0,y0,x1,y1,...] polyline so a whole view is a handful of canvas + * subpaths rather than thousands of independent strokes, and so a dash + * pattern runs continuously along a real hidden run instead of restarting at + * every ~1mm occlusion sample. + */ +export type HiddenLineEdgeRun = { + /** true = occluded (draw dashed/lighter), false = visible outline. */ + hidden: boolean; + pts: number[]; +}; + +export type HiddenLineViewCapture = { + view: HiddenLineViewName; + label: string; + edgeRuns: HiddenLineEdgeRun[]; +}; + +/** + * Shaded true-isometric snapshot of the whole part, for the drawing sheet's + * top-right reference view (see sheet-composer.ts). A real raster here, not + * vectors like the orthographic views above: this one is SHADED (that's the + * point of it), it carries no dimensions, and it's explicitly not to scale - + * none of the reasons the ortho views must be vectors apply. + */ +export type HiddenLineIsoCapture = { + dataURL: string; + /** The sub-rect of the captured image (capture px) that actually contains + * the part - the projected bounding box's own screen bounds plus a small + * margin, so the sheet can crop away the empty canvas around it without + * having to scan pixels. */ + cropPx: { x: number; y: number; w: number; h: number }; +}; + +export type HiddenLineProgressInfo = { + label: string; + index: number; + total: number; + done: boolean; +}; + +export type HiddenLineCircularAnnotation = { + featureId: string; + kind: "circle" | "arc"; + /** Real-world radius (mm). */ + radiusMm: number; + /** Where the leader line should touch the feature - a rim point (circles) + * or the arc's midpoint (arcs) - in the SAME pixel space as this view's + * captured dataURL/canvasWidth/canvasHeight. */ + anchorPx: { x: number; y: number }; + /** The feature's true center, same pixel space as anchorPx - used for + * location (distance-from-edge) dimensioning, which every instance gets + * regardless of size-label dedup. */ + centerPx: { x: number; y: number }; + /** Pre-formatted size callout text (e.g. "⌀3.0", "4X ⌀3.0", "R8.0"), or + * null when this feature is a non-representative member of a same-size + * group within this view and its size callout is suppressed to avoid + * drawing the same "⌀3.0" four times over - see + * computeCircularAnnotationsForView()'s dedup pass. Non-representative + * members also get no location dimension of their own downstream (see + * sheet-composer.ts's drawCell) - the group's "NX" prefix on the + * representative's label stands for all of them, standard drafting + * shorthand for a symmetric/repeated pattern. */ + sizeLabel: string | null; + /** The featureId of this feature's dedup-group representative (see + * computeCircularAnnotationsForView()'s dedup pass) - equal to this + * feature's own featureId when it IS the representative. Lets a + * downstream consumer (e.g. a completeness checker) tell whether a + * feature with sizeLabel===null is nonetheless "covered" by its group's + * shared label rather than truly undimensioned. */ + groupRepresentativeFeatureId: string; + /** How many features share this one's dedup group (>= 1). */ + groupSize: number; + /** Diameter (mm) of a coaxial partner circle at a DIFFERENT depth along + * this circle's own axis - i.e. this hole is stepped/counterbored, not a + * constant-diameter hole. Set by findSteppedPartner(). Only + * meaningful for kind "circle" (arcs/fillets are never stepped in this + * model). Used to keep a stepped hole's near-face opening out of the + * plain same-diameter dedup group it would otherwise coincidentally fall + * into (e.g. a ⌀3.0 counterbore throat next to four plain ⌀3.0 mounting + * holes), since the two are different real features despite one face + * measuring the same. */ + secondaryDiameterMm: number | null; +}; + +/** A stepped/counterbored hole's depth, as seen edge-on in a view where its + * axis lies in the screen plane (see computeAxialDepthAnnotationsForView) - + * the complementary case to HiddenLineCircularAnnotation, which only covers + * the view where the SAME hole reads as a true circle. */ +export type HiddenLineAxialDepthAnnotation = { + /** featureId of the near/pilot-side circle feature - the same id that + * feature carries in circularAnnotations on whichever view it reads + * face-on, so a completeness checker can treat the two as one feature. */ + featureId: string; + depthMm: number; + nearPx: { x: number; y: number }; + farPx: { x: number; y: number }; +}; + +export type HiddenLineViewSetResult = { + views: HiddenLineViewCapture[]; + /** World-units-per-pixel scale shared by all three captures (mm/px, since + * the app's models are authored in mm - see "Model Bounds" panel). Every + * view was captured through the identical orthographic frustum, so this + * single value converts pixels to real mm in any of the three images. */ + pxPerMm: number; + /** Pixel size of each captured image (they're all the same canvas). */ + canvasWidth: number; + canvasHeight: number; + /** The part's overall 3D bounding box (mm) used to compute the shared fit. */ + modelBoundsMm: { x: number; y: number; z: number }; + /** Visible circle/arc features that read as a true circle/arc in each + * view (i.e. the feature's plane faces the camera) - see + * computeCircularAnnotationsForView() for the visibility/relevance rules. */ + circularAnnotations: Record; + /** Every circle/arc feature in the model, regardless of whether it faces + * the camera (i.e. reads as a true circle) in any of the three captured + * views - the canonical feature inventory a completeness checker needs, + * since circularAnnotations alone only lists features that happened to be + * visible face-on and unoccluded in at least one view. A feature that + * never faces any of the three orthogonal views still needs to be known + * about so a checker can flag it as having zero dimension coverage. */ + allCircularFeatures: { + featureId: string; + kind: "circle" | "arc"; + radiusMm: number; + secondaryDiameterMm: number | null; + }[]; + /** Per-view dedup group membership for every circle/arc annotation + * (including non-representative members whose sizeLabel is null) - the + * completeness checker needs this to know a null-sizeLabel feature is + * legitimately covered by its group's shared "NX" label rather than + * genuinely undimensioned. Keyed by featureId; a feature that appears in + * more than one view (not expected for circles, but not impossible) will + * just carry whichever view's grouping was recorded last. */ + circularFeatureGroups: Record< + string, + { representativeFeatureId: string; groupSize: number } + >; + /** Stepped/counterbored hole depth dimensions, per view - see + * computeAxialDepthAnnotationsForView(). Empty for a view where no + * stepped hole's axis lies in the screen plane. */ + axialDepthAnnotations: Record; + /** Shaded isometric reference capture for the sheet's top-right corner - + * see HiddenLineIsoCapture. Null only when there's no part geometry to + * capture; the sheet simply omits the reference view in that case. */ + isoCapture: HiddenLineIsoCapture | null; +}; + +/** + * Procedural test part for hidden-line verification: a hollow tube (annular + * prism) with a vertical (Y-axis) through-hole. Viewed from Front/Right, the + * inner wall/rim is fully hidden (occluded by the tube's own near wall) + * while the outer rim is fully visible - a clean, reasoned-about ground + * truth. Viewed from an angled (iso) view, each rim circle (inner and + * outer, top and bottom) is naturally half-visible/half-hidden, exercising + * the "edge with both visible and hidden segments" case. + * + * Built by hand (no ExtrudeGeometry/earcut hole-bridging) specifically to + * avoid a triangulation artifact found while building this test: an + * ExtrudeGeometry shape-with-hole, once run through this app's normal + * BVH-build step, produced a spurious internal edge from EdgesGeometry that + * didn't correspond to any real surface (reproducible in the live app, not + * reproducible in an isolated Node/three.js repro with identical code - the + * exact trigger wasn't pinned down). Concentric same-segment-count circles + * triangulate as trivial quad strips, so there's no bridging step to go + * wrong. + */ +function buildHiddenLineDebugTestGeometry(): THREE.BufferGeometry { + const outerR = 25; + const innerR = 10; + const height = 30; + const segs = 48; + + const positions: number[] = []; + const indices: number[] = []; + + const ring = (r: number, y: number): number => { + const start = positions.length / 3; + for (let i = 0; i < segs; i++) { + const theta = (i / segs) * Math.PI * 2; + positions.push(r * Math.cos(theta), y, r * Math.sin(theta)); + } + return start; + }; + + const outerBottom = ring(outerR, 0); + const outerTop = ring(outerR, height); + const innerBottom = ring(innerR, 0); + const innerTop = ring(innerR, height); + + const quad = (a: number, b: number, c: number, d: number) => { + indices.push(a, b, c, a, c, d); + }; + + for (let i = 0; i < segs; i++) { + const j = (i + 1) % segs; + // Outer wall (outward-facing). + quad(outerBottom + i, outerBottom + j, outerTop + j, outerTop + i); + // Inner wall (inward-facing, reversed winding). + quad(innerBottom + j, innerBottom + i, innerTop + i, innerTop + j); + // Top annulus cap. + quad(outerTop + i, outerTop + j, innerTop + j, innerTop + i); + // Bottom annulus cap (reversed winding, faces downward). + quad(outerBottom + j, outerBottom + i, innerBottom + i, innerBottom + j); + } + + const geom = new THREE.BufferGeometry(); + geom.setAttribute( + "position", + new THREE.Float32BufferAttribute(positions, 3), + ); + geom.setIndex(indices); + geom.computeVertexNormals(); + return geom; +} + export type Viewer = { loadMeshFromGeometry: (geom: THREE.BufferGeometry) => void; replacePrimaryGeometry: ( @@ -145,6 +411,22 @@ export type Viewer = { setMeasurementGraphicsScale: (scale: number) => void; getScreenshotDataURL: () => string; getOutlineSnapshotDataURL: () => string; + /** + * Re-captures the isometric reference view (see HiddenLineIsoCapture) at + * an explicit pixel resolution, independent of the live 3D viewport's own + * on-screen backing-buffer size - generateHiddenLineViewSet's normal + * isoCapture reuses whatever that happens to be (capped by the render + * quality profile's DPR), which isn't guaranteed to hit a print target's + * DPI. Temporarily resizes the renderer's drawing buffer (pixel ratio + * forced to 1 so `targetWidthPx`/`targetHeightPx` land exactly), captures, + * then restores both the buffer size and the live view - fully + * synchronous, so nothing mid-resize is ever visible on screen. Null only + * when there's no part geometry loaded (mirrors captureIsoReferenceView). + */ + captureHighResIsoView: ( + targetWidthPx: number, + targetHeightPx: number, + ) => HiddenLineIsoCapture | null; setMaterialProperties: ( colorHex: number, wireframe: boolean, @@ -174,6 +456,32 @@ export type Viewer = { y: number; visible: boolean; }; + /** + * Generates Front/Top/Right hidden-line views of the currently loaded + * part - visible and hidden edges as projected polylines (see + * HiddenLineEdgeRun; the consumer strokes them at its own drafting line + * weights, so no rasterization happens here) - plus one shaded isometric + * reference capture (HiddenLineIsoCapture). Geometry only, no sheet/layout + * composition. Restores the camera to wherever it was before the call. + * onProgress fires once per view (before that view's compute, which can + * take several hundred ms on a real part), once for the isometric, and + * once more when done. + */ + generateHiddenLineViewSet: ( + onProgress?: (info: HiddenLineProgressInfo) => void, + ) => Promise; + /** TEMPORARY DEBUG: loads the procedural hidden-line test part. */ + debugLoadHiddenLineTestPart: () => void; + /** TEMPORARY DEBUG: runs ray-cast hidden-line detection on current edges/camera and visualizes it (solid black = visible, dashed black = hidden). */ + debugRunHiddenLineTest: () => HiddenLineDebugStats | null; + /** TEMPORARY DEBUG: reports which edge mode/source is currently active. */ + debugGetEdgeMode: () => { + isExactCadMode: boolean; + isApproxCadMode: boolean; + exactEdgeCount: number; + curveFeatureCount: number; + approxEdgeCount: number; + }; }; export type ViewerRenderQualityProfile = "normal" | "heavy" | "extreme"; @@ -3467,6 +3775,42 @@ export function createViewer(container: HTMLElement): Viewer { const curveFeatureRenderObjectsById = new Map(); const curveFeaturePickObjectsById = new Map(); const exactEdgeRenderObjectsById = new Map(); + // Purely cosmetic "fat line" twins of the two maps above (task: "increase + // the 3D isometric view's edge/outline weight") - plain THREE.LineBasicMaterial + // ignores `linewidth` on essentially every modern WebGL backend, so real + // width needs Line2/LineSegments2 (screen-space quads via LineMaterial, + // already used elsewhere in this file for edgeHoverLine). Kept STRICTLY + // separate from the render/pick objects above rather than converting them + // in place: exactEdgeRenderObjectsById IS the raycast target for straight + // edges (no separate pick object exists for them, see its own comment + // below), collectExactCadEdgeRaycastTargets filters on `.isLineSegments` + // (which LineSegments2 - a Mesh subtype - never sets), and several readers + // (getWorldPolylinePositions, the hidden-line chain builder, the outline + // snapshot) read a conventional `position` BufferAttribute directly, which + // fat-line geometries don't expose the same way. Same key space as their + // twin map (edge.id / feature.featureId) so visibility/style can be kept in + // lockstep by simple lookup - see updateEngineeringEdgeVisibility. Live + // under edgesGroup (not featureEdgesGroup directly), which already exists + // for exactly this ("Subgroup for world-space edge visuals (LineSegments2)") + // and already has its own disposal handled in clearFeatureEdges. + const curveFeatureFatOverlayById = new Map(); + const exactEdgeFatOverlayById = new Map(); + // Two shared materials (normal / tangent-phantom-dimmed), mirroring + // applyExactEdgeStyle's own two states - one pair reused across every twin + // rather than one material per edge, matching edgeHoverLineMaterial's own + // singleton convention. Lazily created (see ensureExactEdgeFatMaterials) + // so `.resolution` can be seeded from the container's real size at first + // use rather than needing `container` available at this earlier point in + // the closure. + let exactEdgeFatMaterialNormal: LineMaterial | null = null; + let exactEdgeFatMaterialTangentPhantom: LineMaterial | null = null; + // The fat twins above exist ONLY for the drawing sheet's isometric + // reference capture (task: "increase the 3D isometric view's edge/outline + // weight" meant the sheet's iso corner, not the live interactive viewer) - + // false the rest of the time so the interactive viewer keeps its normal + // hairline edges. captureSceneSnapshot flips this on for the one render + // call captureIsoReferenceView makes, then restores it - see both. + let showFatEdgeOverlaysForIsoCapture = false; const approxCadEdgeObjects: THREE.LineSegments[] = []; let curveFeatureCount = 0; let circleFeatureCount = 0; @@ -3849,17 +4193,34 @@ export function createViewer(container: HTMLElement): Viewer { if (isExactCadMode) { // Exact CAD mode controls exact topology edges only; silhouettes remain a // separate, view-dependent concept and are not part of edge kind mapping. - for (const line of exactEdgeRenderObjectsById.values()) { + const fatMaterials = ensureExactEdgeFatMaterials(); + for (const [id, line] of exactEdgeRenderObjectsById) { const kind = (line.userData?.__exactEdgeKind ?? "unknown") as ExactEdgeKind; const kindVisible = isExactEdgeKindVisible(kind); applyExactEdgeStyle(line, kind); line.visible = featureEdgesEnabled && kindVisible; + // Cosmetic fat-line twin (task: thicker isometric edges) stays in + // lockstep with its source line's own visibility/style - see + // exactEdgeFatOverlayById's doc comment for why it's a separate + // object rather than the source line itself. + const fatTwin = exactEdgeFatOverlayById.get(id); + if (fatTwin) { + fatTwin.visible = line.visible && showFatEdgeOverlaysForIsoCapture; + const isTangentPhantom = kind === "tangent" && exactCadEdgeDisplayOptions.tangentEdges === "phantom"; + fatTwin.material = isTangentPhantom ? fatMaterials.tangentPhantom : fatMaterials.normal; + } } - for (const line of curveFeatureRenderObjectsById.values()) { + for (const [id, line] of curveFeatureRenderObjectsById) { const kind = (line.userData?.__exactEdgeKind ?? "unknown") as ExactEdgeKind; const kindVisible = isExactEdgeKindVisible(kind); applyExactEdgeStyle(line, kind); line.visible = featureEdgesEnabled && kindVisible; + const fatTwin = curveFeatureFatOverlayById.get(id); + if (fatTwin) { + fatTwin.visible = line.visible && showFatEdgeOverlaysForIsoCapture; + const isTangentPhantom = kind === "tangent" && exactCadEdgeDisplayOptions.tangentEdges === "phantom"; + fatTwin.material = isTangentPhantom ? fatMaterials.tangentPhantom : fatMaterials.normal; + } } for (const line of curveFeaturePickObjectsById.values()) { const kind = (line.userData?.__exactEdgeKind ?? "unknown") as ExactEdgeKind; @@ -4147,6 +4508,39 @@ export function createViewer(container: HTMLElement): Viewer { }; } + // Weight for the cosmetic fat-line edge twins (task: "increase... edge/ + // outline weight so its silhouette and feature edges read clearly") - + // heavier than the effectively-1px hairline THREE.LineBasicMaterial + // renders today, lighter than edgeHoverLineMaterial's own linewidth:4 so + // hover still reads as extra emphasis over the resting state. + const EXACT_EDGE_FAT_LINEWIDTH_PX = 2.25; + function ensureExactEdgeFatMaterials(): { + normal: LineMaterial; + tangentPhantom: LineMaterial; + } { + if (!exactEdgeFatMaterialNormal) { + exactEdgeFatMaterialNormal = new LineMaterial({ + color: 0x111111, + linewidth: EXACT_EDGE_FAT_LINEWIDTH_PX, + transparent: true, + opacity: 0.9, + depthWrite: false, + }); + exactEdgeFatMaterialNormal.resolution.set(container.clientWidth, container.clientHeight); + } + if (!exactEdgeFatMaterialTangentPhantom) { + exactEdgeFatMaterialTangentPhantom = new LineMaterial({ + color: 0x4b5563, + linewidth: EXACT_EDGE_FAT_LINEWIDTH_PX, + transparent: true, + opacity: 0.32, + depthWrite: false, + }); + exactEdgeFatMaterialTangentPhantom.resolution.set(container.clientWidth, container.clientHeight); + } + return { normal: exactEdgeFatMaterialNormal, tangentPhantom: exactEdgeFatMaterialTangentPhantom }; + } + function rebuildExactCadEdges(reason = "unspecified") { clearFeatureEdges(); if (!isExactCadMode) return; @@ -4215,6 +4609,27 @@ export function createViewer(container: HTMLElement): Viewer { featureEdgeLines.push(renderLine); curveFeatureRenderObjectsById.set(feature.featureId, renderLine); + // Cosmetic fat-line twin (task: thicker isometric edges) - see this + // map's own doc comment above for why it's a separate object rather + // than a converted renderLine. Reuses the same preview.positions array + // already computed for renderGeometry above. + const fatCurveGeometry = new LineGeometry(); + fatCurveGeometry.setPositions(preview.positions); + const fatCurveLine = new Line2(fatCurveGeometry, ensureExactEdgeFatMaterials().normal); + fatCurveLine.name = "exactCadCurveFeatureFat"; + fatCurveLine.frustumCulled = false; + // Line2/LineSegments2 are Mesh subtypes under the hood (fat lines are + // tessellated screen-space quads), so anything doing a broad + // `.isMesh` scene traversal (e.g. collectVisibleMeshRaycastTargets, + // used for hidden-line occlusion testing) would otherwise treat this + // purely cosmetic twin as real solid geometry - __edgeOverlay is the + // exact flag that traversal (and others) already check to exclude + // overlay objects, matching every other line built in this function. + fatCurveLine.userData.__edgeOverlay = true; + fatCurveLine.userData.__isFeatureEdge = true; + edgesGroup.add(fatCurveLine); + curveFeatureFatOverlayById.set(feature.featureId, fatCurveLine); + const pickGeometry = renderGeometry.clone(); const pickMaterial = new THREE.LineBasicMaterial({ color: 0x000000, @@ -4289,6 +4704,21 @@ export function createViewer(container: HTMLElement): Viewer { // Exact CAD mode raycasts directly against exactEdgeRenderObjectsById. // Legacy edgePickables are reserved for fallback mesh overlays only. exactEdgeRenderObjectsById.set(edge.id, line); + + // Cosmetic fat-line twin - see exactEdgeFatOverlayById's own doc + // comment above. segmentPositions is already disconnected-pair-shaped + // (buildSegmentPositionsFromSamplePoints), exactly what + // LineSegmentsGeometry.setPositions expects. + const fatEdgeGeometry = new LineSegmentsGeometry(); + fatEdgeGeometry.setPositions(segmentPositions); + const fatEdgeLine = new LineSegments2(fatEdgeGeometry, ensureExactEdgeFatMaterials().normal); + fatEdgeLine.name = "exactCadEdgeFat"; + fatEdgeLine.frustumCulled = false; + // See the matching comment on fatCurveLine above. + fatEdgeLine.userData.__edgeOverlay = true; + fatEdgeLine.userData.__isFeatureEdge = true; + edgesGroup.add(fatEdgeLine); + exactEdgeFatOverlayById.set(edge.id, fatEdgeLine); } perfDebug("[CadViewer] Exact curve adaptive resample", { @@ -4667,12 +5097,20 @@ export function createViewer(container: HTMLElement): Viewer { exactEdgeRenderObjectsById.clear(); curveFeatureRenderObjectsById.clear(); curveFeaturePickObjectsById.clear(); + exactEdgeFatOverlayById.clear(); + curveFeatureFatOverlayById.clear(); approxCadEdgeObjects.length = 0; approxCadRenderedEdgeCount = 0; // (No separate LineMaterial tracking for simple LineSegments overlays) - // Also clear the edgesGroup children if any exist + // Also clear the edgesGroup children if any exist - this disposes each + // fat-line twin's geometry, plus calls .dispose() on the two SHARED + // exactEdgeFatMaterial* instances once per twin that referenced them + // (harmless - three.js Material.dispose() is idempotent). Explicitly + // null the shared refs below regardless, so ensureExactEdgeFatMaterials + // always constructs clean replacements on the next rebuild rather than + // reusing ones already told to release their GPU program. try { edgesGroup.traverse((obj: any) => { if (obj.geometry) obj.geometry.dispose?.(); @@ -4686,6 +5124,8 @@ export function createViewer(container: HTMLElement): Viewer { } catch { /* ignore */ } + exactEdgeFatMaterialNormal = null; + exactEdgeFatMaterialTangentPhantom = null; } catch { /* ignore */ } @@ -6127,6 +6567,42 @@ export function createViewer(container: HTMLElement): Viewer { return pickExactCadEntityAtScreenPosition(ndcX, ndcY); } + /** + * Builds a reusable "is this world point occluded, looking along viewDir" + * test - the same ray-cast-from-camera-side approach computeHiddenLineSegments() + * uses per edge sample, factored out so a single representative point (a + * circle/arc feature's rim or midpoint) can be classified the same way, + * without duplicating the raycaster/epsilon setup. + */ + function createPointOcclusionTester( + viewDir: THREE.Vector3, + meshTargets: THREE.Object3D[], + ): (p: THREE.Vector3) => boolean { + const box = new THREE.Box3().setFromObject(modelRoot); + const sphere = box.getBoundingSphere(new THREE.Sphere()); + const rayDistance = Math.max(sphere.radius * 2.5, modelDiagonal, 50); + + const sampleSpacing = Math.max(modelDiagonal * 0.004, 0.25); + // See computeHiddenLineSegments()'s identical eps for why: large enough + // to swallow a self-grazing hit near a shared vertex/edge, small enough + // to stay well under any real occluder's material thickness. + const eps = Math.max(sampleSpacing * 10, modelDiagonal * 0.01, 0.5); + + const raycaster = new THREE.Raycaster(); + raycaster.near = 0; + const maxHitDistance = rayDistance - eps; + const originScratch = new THREE.Vector3(); + + return (p: THREE.Vector3): boolean => { + originScratch.copy(p).addScaledVector(viewDir, -rayDistance); + raycaster.set(originScratch, viewDir); + const hits = raycaster + .intersectObjects(meshTargets, true) + .filter((h) => h.distance <= maxHitDistance); + return hits.length > 0; + }; + } + function collectVisibleMeshRaycastTargets(): THREE.Object3D[] { if (!visibleMeshTargetsDirty) { return visibleMeshRaycastTargets; @@ -6143,6 +6619,1312 @@ export function createViewer(container: HTMLElement): Viewer { return visibleMeshRaycastTargets; } + // --- Hidden-line detection engine ---------------------------------------- + // Ray-cast occlusion technique verified in isolation against both the + // fallback mesh-edge path and the real exact-CAD B-rep edge path. Pure + // computation lives in computeHiddenLineSegments(); styling/capture are + // separate so the same computation feeds both the interactive debug + // preview and the real multi-view generator. + + function computeHiddenLineSegments(): HiddenLineComputeResult | null { + if (!(activeCamera as any).isOrthographicCamera) { + console.warn( + "[hidden-line] active camera is not orthographic; call setProjection('orthographic') first", + ); + return null; + } + + const meshTargets = collectVisibleMeshRaycastTargets(); + if (meshTargets.length === 0) { + console.warn("[hidden-line] no visible mesh targets"); + return null; + } + + const t0 = performance.now(); + + // Gather world-space edge geometry from the same edge source + // getOutlineSnapshotDataURL prefers per mode, as CHAINS of connected + // points rather than a flat list of disconnected pairs. That distinction + // matters once dashing enters the picture: a LineSegments buffer is + // genuinely a set of independent pairs (no implied connectivity between + // them), but a Line buffer (e.g. one sampled circle/curve) is one + // continuous run - flattening it into pairs and resetting run-tracking + // at every pair boundary would restart the dash pattern every ~1mm + // instead of only at real visible/hidden transitions. + type Chain = THREE.Vector3[]; + const chains: Chain[] = []; + + // NOTE: takes an explicit world matrix rather than reading obj.matrixWorld + // directly, so it can also be used with synthetic (unparented) geometries + // whose matrixWorld would otherwise be reset to identity by updateWorldMatrix. + const pushChainsFromGeometry = ( + geom: THREE.BufferGeometry | undefined, + worldMatrix: THREE.Matrix4, + isSegs: boolean, + ) => { + if (!geom) return; + const pos = geom.getAttribute("position"); + if (!pos) return; + if (isSegs) { + for (let i = 0; i + 1 < pos.count; i += 2) { + const a = new THREE.Vector3() + .fromBufferAttribute(pos, i) + .applyMatrix4(worldMatrix); + const b = new THREE.Vector3() + .fromBufferAttribute(pos, i + 1) + .applyMatrix4(worldMatrix); + if (a.distanceToSquared(b) < 1e-10) continue; + chains.push([a, b]); + } + } else { + const chain: Chain = []; + for (let i = 0; i < pos.count; i++) { + const p = new THREE.Vector3() + .fromBufferAttribute(pos, i) + .applyMatrix4(worldMatrix); + if ( + chain.length === 0 || + chain[chain.length - 1].distanceToSquared(p) > 1e-10 + ) { + chain.push(p); + } + } + if (chain.length >= 2) chains.push(chain); + } + }; + + let edgeSource: HiddenLineDebugStats["edgeSource"]; + if ( + isExactCadMode && + (exactEdgeRenderObjectsById.size > 0 || curveFeatureRenderObjectsById.size > 0) + ) { + edgeSource = "exact-cad"; + for (const line of curveFeatureRenderObjectsById.values()) { + if (!line.visible) continue; + line.updateWorldMatrix(true, false); + pushChainsFromGeometry(line.geometry, line.matrixWorld, false); + } + for (const line of exactEdgeRenderObjectsById.values()) { + if (!line.visible) continue; + line.updateWorldMatrix(true, false); + pushChainsFromGeometry(line.geometry, line.matrixWorld, true); + } + } else if (isApproxCadMode && approxCadEdgeObjects.length > 0) { + edgeSource = "approx-cad"; + for (const line of approxCadEdgeObjects) { + if (!line.visible) continue; + line.updateWorldMatrix(true, false); + pushChainsFromGeometry(line.geometry, line.matrixWorld, true); + } + } else { + edgeSource = "fallback-mesh"; + for (const obj of meshTargets) { + const mesh = obj as THREE.Mesh; + if (!mesh.geometry) continue; + mesh.updateWorldMatrix(true, false); + const edgesGeom = new THREE.EdgesGeometry(mesh.geometry, 40); + pushChainsFromGeometry(edgesGeom, mesh.matrixWorld, true); + edgesGeom.dispose(); + } + } + + if (chains.length === 0) { + console.warn("[hidden-line] no edge segments found"); + return null; + } + + const viewDir = new THREE.Vector3(); + activeCamera.getWorldDirection(viewDir); + + const sampleSpacing = Math.max(modelDiagonal * 0.004, 0.25); + const maxSamplesPerSegment = 200; + const isHidden = createPointOcclusionTester(viewDir, meshTargets); + + const visibleBuf: HiddenLineRunBuffers = { positions: [], distances: [] }; + const hiddenBuf: HiddenLineRunBuffers = { positions: [], distances: [] }; + let totalSamples = 0; + let hiddenSamples = 0; + let visibleSegmentCount = 0; + let hiddenSegmentCount = 0; + + for (const chain of chains) { + // Tracks which bucket the previous mini-segment landed in and its + // cumulative in-run arc length, so LineDashedMaterial dashes + // continuously along a real multi-sample hidden/visible run instead + // of restarting the pattern at every tiny sample-spacing segment. + // Scoped per CHAIN (not per a/b pair) so a long continuous curve - + // e.g. a sampled circle - keeps one run across its whole length + // instead of resetting at every original tessellation vertex. + let lastBucketHidden: boolean | null = null; + let runDistance = 0; + + for (let c = 0; c + 1 < chain.length; c++) { + const a = chain[c]; + const b = chain[c + 1]; + const length = a.distanceTo(b); + const subdivisions = Math.min( + maxSamplesPerSegment, + Math.max(1, Math.ceil(length / sampleSpacing)), + ); + // Re-samples each chain joint once as an endpoint and once as the + // next pair's start - a deterministic, harmless bit of redundant + // raycasting (same point, same result) traded for much simpler code. + let prevPoint: THREE.Vector3 | null = null; + let prevHidden = false; + for (let i = 0; i <= subdivisions; i++) { + const t = i / subdivisions; + const p = new THREE.Vector3().lerpVectors(a, b, t); + const hidden = isHidden(p); + totalSamples++; + if (hidden) hiddenSamples++; + if (prevPoint) { + const stepLength = prevPoint.distanceTo(p); + const bucketHidden = prevHidden; + if (lastBucketHidden !== null && lastBucketHidden !== bucketHidden) { + runDistance = 0; + } + const bucket = bucketHidden ? hiddenBuf : visibleBuf; + bucket.positions.push(prevPoint.x, prevPoint.y, prevPoint.z, p.x, p.y, p.z); + bucket.distances.push(runDistance, runDistance + stepLength); + runDistance += stepLength; + lastBucketHidden = bucketHidden; + if (bucketHidden) hiddenSegmentCount++; + else visibleSegmentCount++; + } + prevPoint = p; + prevHidden = hidden; + } + } + } + + const computeMs = performance.now() - t0; + + const stats: HiddenLineDebugStats = { + edgeSource, + edgeCount: chains.length, + totalSamples, + visibleSamples: totalSamples - hiddenSamples, + hiddenSamples, + visibleSegmentCount, + hiddenSegmentCount, + computeMs, + }; + + return { stats, visible: visibleBuf, hidden: hiddenBuf }; + } + + /** + * Builds the styled visible/hidden overlay: visible segments as a solid + * black LineSegments (matching Outline Snap), hidden segments as a dashed + * black LineSegments (standard engineering hidden-line convention). Dash + * size is scaled off model size, clamped to a sane range. + */ + function buildHiddenLineStyledGroup(result: HiddenLineComputeResult): THREE.Group { + const group = new THREE.Group(); + group.userData.__hiddenLineOverlay = true; + + const dashSize = THREE.MathUtils.clamp(modelDiagonal * 0.01, 0.6, 4); + const gapSize = dashSize * 0.6; + + // renderOrder must be set on the renderable leaf objects, not the Group - + // THREE only reads it from objects it actually draws. Hidden is drawn + // first so a coincident visible edge (e.g. a prism's near/far edges + // that project on top of each other from an axis-aligned view) wins + // the tie and reads as solid, not dashed. + if (result.hidden.positions.length > 0) { + const geom = new THREE.BufferGeometry(); + geom.setAttribute( + "position", + new THREE.Float32BufferAttribute(result.hidden.positions, 3), + ); + geom.setAttribute( + "lineDistance", + new THREE.Float32BufferAttribute(result.hidden.distances, 1), + ); + const mat = new THREE.LineDashedMaterial({ + color: 0x000000, + dashSize, + gapSize, + depthTest: false, + depthWrite: false, + }); + const line = new THREE.LineSegments(geom, mat); + line.renderOrder = 9998; + group.add(line); + } + if (result.visible.positions.length > 0) { + const geom = new THREE.BufferGeometry(); + geom.setAttribute( + "position", + new THREE.Float32BufferAttribute(result.visible.positions, 3), + ); + const mat = new THREE.LineBasicMaterial({ + color: 0x000000, + depthTest: false, + depthWrite: false, + }); + const line = new THREE.LineSegments(geom, mat); + line.renderOrder = 9999; + group.add(line); + } + return group; + } + + function disposeHiddenLineGroup(group: THREE.Group): void { + group.traverse((obj: any) => { + if (obj.geometry) { + disposeGeometryBoundsTree(obj.geometry); + obj.geometry.dispose(); + } + if (obj.material) { + if (Array.isArray(obj.material)) { + obj.material.forEach((m: any) => m.dispose()); + } else { + obj.material.dispose(); + } + } + }); + } + + /** + * Renders overlayGroup alone against a blank background (model, grid, + * axes, and compare-reference all hidden) and captures a PNG data URL - + * the same hide/render/capture/restore dance getOutlineSnapshotDataURL + * uses, factored out so both can share it. + */ + function captureSceneSnapshotWithOverlay(overlayGroup: THREE.Group): string { + return captureSceneSnapshot(overlayGroup, { hideModel: true }); + } + + /** + * The shared hide/render/capture/restore dance behind every white- + * background snapshot this module takes. `hideModel: true` (the + * line-art cases - see captureSceneSnapshotWithOverlay above) renders + * ONLY the passed overlay group; `hideModel: false` keeps the real shaded + * model in the frame, which is what the drawing sheet's isometric + * reference view needs (see captureIsoReferenceView). + */ + function captureSceneSnapshot( + overlayGroup: THREE.Group | null, + options: { hideModel: boolean; fatEdgeOverlays?: boolean }, + ): string { + const prevGridVisible = gridHelper ? gridHelper.visible : false; + const prevAxesVisible = axesHelper ? axesHelper.visible : false; + + // Isometric-sheet-only heavier edges (see showFatEdgeOverlaysForIsoCapture's + // own doc comment) - only captureIsoReferenceView passes fatEdgeOverlays: + // true, so every other snapshot (and the live interactive viewer) keeps + // normal hairline edges. + const prevFatEdgeOverlays = showFatEdgeOverlaysForIsoCapture; + if (options.fatEdgeOverlays) { + showFatEdgeOverlaysForIsoCapture = true; + updateEngineeringEdgeVisibility(); + } + + const prevLineColor = measureMaterial.color.clone(); + const prevArrowColor = arrowMaterial.color.clone(); + let prevLabelColor: THREE.Color | null = null; + if (measureLabel && (measureLabel.material as any).color) { + prevLabelColor = (measureLabel.material as any).color.clone(); + } + + if (gridHelper) gridHelper.visible = false; + if (axesHelper) axesHelper.visible = false; + + measureMaterial.color.set(0x000000); + arrowMaterial.color.set(0x000000); + if (measureLabel && (measureLabel.material as any).color) { + (measureLabel.material as any).color.set(0x000000); + } + + const prevClearColor = renderer.getClearColor(new THREE.Color()).clone(); + const prevClearAlpha = renderer.getClearAlpha(); + const prevBackground = scene.background; + + if (overlayGroup) scene.add(overlayGroup); + + const prevModelVisible = modelRoot.visible; + if (options.hideModel) modelRoot.visible = false; + + const prevCompareGroupVisible = compareReferenceGroup?.visible ?? false; + if (compareReferenceGroup) compareReferenceGroup.visible = false; + + // White, not the app's usual light-gray canvas background: this capture + // gets cropped and pasted onto the (white) drawing sheet by + // sheet-composer.ts, and the shared renderer is created with alpha: + // false (a real per-pixel-transparent capture would need a second, + // alpha-enabled WebGL context just for this). Matching the destination + // white exactly is visually identical to true transparency once + // composited - no gray tile, no edge fringing. + renderer.setClearColor(0xffffff, 1); + scene.background = null; + + renderNow("scene_snapshot_capture"); + + const dataURL = renderer.domElement.toDataURL("image/png"); + + if (overlayGroup) scene.remove(overlayGroup); + + modelRoot.visible = prevModelVisible; + if (compareReferenceGroup) compareReferenceGroup.visible = prevCompareGroupVisible; + renderer.setClearColor(prevClearColor, prevClearAlpha); + scene.background = prevBackground; + + measureMaterial.color.copy(prevLineColor); + arrowMaterial.color.copy(prevArrowColor); + if ( + measureLabel && + prevLabelColor && + (measureLabel.material as any).color + ) { + (measureLabel.material as any).color.copy(prevLabelColor); + } + if (gridHelper) gridHelper.visible = prevGridVisible; + if (axesHelper) axesHelper.visible = prevAxesVisible; + if (options.fatEdgeOverlays) { + showFatEdgeOverlaysForIsoCapture = prevFatEdgeOverlays; + updateEngineeringEdgeVisibility(); + } + requestRender("scene_snapshot_restore"); + + return dataURL; + } + + /** + * Projects a hidden-line compute result into the current camera's pixel + * space as polyline runs - see HiddenLineEdgeRun for why the drawing sheet + * consumes vectors rather than the styled bitmap + * buildHiddenLineStyledGroup produces (which remains the interactive debug + * preview's path, unchanged). + * + * Run reconstruction: computeHiddenLineSegments pushes its occlusion + * samples in order, one bucket (visible/hidden) at a time, writing each + * mini-segment as prevPoint->p and then carrying p forward as the next + * prevPoint - so consecutive entries in one bucket whose start EXACTLY + * equals the previous entry's end (identical float values, copied from the + * same Vector3, never recomputed) are by construction one continuous run + * of that bucket, and a mismatch is a real break (bucket switch, or a jump + * to a different edge chain). Two adjacent chains that genuinely share a + * vertex merging into one run is harmless - it's one polyline through a + * corner, which is what the geometry actually is. + */ + function buildProjectedEdgeRuns( + result: HiddenLineComputeResult, + camera: THREE.OrthographicCamera, + canvasWidth: number, + canvasHeight: number, + ): HiddenLineEdgeRun[] { + const projection: MeasurementProjectionContext = { + camera, + viewportWidth: canvasWidth, + viewportHeight: canvasHeight, + }; + const runs: HiddenLineEdgeRun[] = []; + const scratch = new THREE.Vector3(); + const project = (x: number, y: number, z: number) => + projectWorldToScreenPx(scratch.set(x, y, z), projection); + + const emit = (buffer: HiddenLineRunBuffers, hidden: boolean) => { + const p = buffer.positions; + let pts: number[] | null = null; + let prevX = 0; + let prevY = 0; + let prevZ = 0; + const flush = () => { + if (pts && pts.length >= 4) runs.push({ hidden, pts }); + pts = null; + }; + for (let i = 0; i + 5 < p.length; i += 6) { + const [ax, ay, az, bx, by, bz] = [ + p[i], p[i + 1], p[i + 2], p[i + 3], p[i + 4], p[i + 5], + ]; + if (!(pts && ax === prevX && ay === prevY && az === prevZ)) { + flush(); + const a = project(ax, ay, az); + pts = [a.x, a.y]; + } + const b = project(bx, by, bz); + pts.push(b.x, b.y); + prevX = bx; + prevY = by; + prevZ = bz; + } + flush(); + }; + + emit(result.hidden, true); + emit(result.visible, false); + return runs; + } + + /** + * Captures the shaded TRUE-isometric reference view the drawing sheet puts + * in its top-right corner (see sheet-composer.ts): camera placed along the + * (1,1,1) body diagonal - equal foreshortening on all three axes, the + * standard isometric orientation, not the app's interactive "iso" preset + * (a deliberately less symmetric (1, 0.6, 1) framing tuned for on-screen + * orbiting) - orthographic, model shaded, sheet-white background. + * + * Gets its OWN frustum rather than reusing the three ortho views' shared + * fit: an isometric projection of the same box is up to ~1.41x wider than + * any axis-aligned view of it, so the shared fit can clip it. Sized in one + * exact step instead of a search - halving/doubling the ortho half-extent + * scales projected NDC by exactly the inverse, so measuring the projected + * corners once is enough to solve for the half-extent that lands the part + * at 90% of the frame. Must run AFTER everything that depends on the + * shared frustum (both annotation passes) - the caller restores the + * frustum afterwards. + */ + function captureIsoReferenceView( + box: THREE.Box3, + canvasWidth: number, + canvasHeight: number, + ): HiddenLineIsoCapture | null { + if (box.isEmpty()) return null; + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + if (!(maxDim > 0)) return null; + + // `ortho`'s frustum is what this function configures below, but the + // renderer always draws `activeCamera` (drawFrame's `renderer.render( + // scene, activeCamera)`) - if the caller hasn't already switched to + // orthographic (e.g. captureHighResIsoView, called standalone at PDF + // export time, long after the sheet-generation flow that switched + // projection modes has already restored perspective), the render would + // silently use `persp` instead, whose `aspect` reflects the live + // viewport rather than this capture's `canvasWidth`/`canvasHeight` - + // producing exactly the anisotropic squeeze/stretch this function's + // careful frustum math was supposed to prevent. Bypassing + // setProjection() (rather than calling it) avoids its visible side + // effects (control rebinding, view-changed events) for this + // synchronous, invisible capture. + const prevActiveCamera = activeCamera; + activeCamera = ortho; + try { + const direction = new THREE.Vector3(1, 1, 1).normalize(); + ortho.position.copy(center).addScaledVector(direction, Math.max(maxDim * 4, 1)); + ortho.up.set(0, 1, 0); + ortho.lookAt(center); + + const aspect = canvasWidth / Math.max(1, canvasHeight); + const applyHalfExtent = (half: number) => { + ortho.left = -half * aspect; + ortho.right = half * aspect; + ortho.top = half; + ortho.bottom = -half; + ortho.near = -10000; + ortho.far = 10000; + ortho.updateProjectionMatrix(); + ortho.updateMatrixWorld(true); + }; + + const corners: THREE.Vector3[] = []; + for (const x of [box.min.x, box.max.x]) { + for (const y of [box.min.y, box.max.y]) { + for (const z of [box.min.z, box.max.z]) { + corners.push(new THREE.Vector3(x, y, z)); + } + } + } + const projection: MeasurementProjectionContext = { + camera: ortho, + viewportWidth: canvasWidth, + viewportHeight: canvasHeight, + }; + const maxAbsNdc = () => + corners.reduce((acc, c) => { + const { ndc } = projectWorldToScreenPx(c, projection); + return Math.max(acc, Math.abs(ndc.x), Math.abs(ndc.y)); + }, 0); + + const startHalf = maxDim; + applyHalfExtent(startHalf); + const measured = maxAbsNdc(); + const FRAME_FILL = 0.9; + if (measured > 1e-6) applyHalfExtent((startHalf * measured) / FRAME_FILL); + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const c of corners) { + const { x, y } = projectWorldToScreenPx(c, projection); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + if (!Number.isFinite(minX) || maxX <= minX || maxY <= minY) return null; + // Small breathing margin so the outermost edge's own stroke width isn't + // clipped by the crop, then clamped to the real canvas. + const marginPx = Math.max(maxX - minX, maxY - minY) * 0.03; + const cropX = Math.max(0, minX - marginPx); + const cropY = Math.max(0, minY - marginPx); + const cropPx = { + x: cropX, + y: cropY, + w: Math.min(canvasWidth, maxX + marginPx) - cropX, + h: Math.min(canvasHeight, maxY + marginPx) - cropY, + }; + if (!(cropPx.w > 0) || !(cropPx.h > 0)) return null; + + return { + dataURL: captureSceneSnapshot(null, { hideModel: false, fatEdgeOverlays: true }), + cropPx, + }; + } finally { + activeCamera = prevActiveCamera; + } + } + + function captureHighResIsoView( + targetWidthPx: number, + targetHeightPx: number, + ): HiddenLineIsoCapture | null { + const box = getPartOnlyBox(); + if (box.isEmpty()) return null; + const w = Math.max(1, Math.round(targetWidthPx)); + const h = Math.max(1, Math.round(targetHeightPx)); + + const prevPixelRatio = renderer.getPixelRatio(); + const prevWidth = renderer.domElement.width; + const prevHeight = renderer.domElement.height; + + // Pixel ratio forced to 1 so the drawing buffer lands at EXACTLY + // (w,h) - avoids fractional-DPR rounding ambiguity. `updateStyle: + // false` leaves the on-screen CSS box untouched, so nothing visibly + // resizes; every step here through the restore below is synchronous + // (captureIsoReferenceView's own render+toDataURL is synchronous - see + // its doc comment), so the browser never gets a chance to paint the + // temporarily-resized buffer. + renderer.setPixelRatio(1); + renderer.setSize(w, h, false); + const capture = captureIsoReferenceView(box, w, h); + + renderer.setPixelRatio(prevPixelRatio); + renderer.setSize( + Math.max(1, Math.round(prevWidth / prevPixelRatio)), + Math.max(1, Math.round(prevHeight / prevPixelRatio)), + false, + ); + requestRender("capture_high_res_iso_view_restore"); + + return capture; + } + + const HIDDEN_LINE_VIEW_LABELS: Record = { + front: "Front", + top: "Top", + right: "Right", + }; + + function nextFrame(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + } + + /** + * Face-adjacency graph (faceId -> neighboring faceIds, i.e. faces sharing + * at least one edge with it), built fresh from edgesById each call - + * findSteppedPartner uses this to tell a genuine stepped-hole pair (two + * cylinder sections of ONE bore, meeting at a shoulder) apart from two + * UNRELATED coaxial cylinder faces that just happen to share an axis line + * (e.g. a tube's inner bore and outer OD are always coaxial by + * construction, but are not "steps" of each other - see + * findSteppedPartner's doc comment). Rebuilt per call rather than cached: + * called at most a few dozen times per sheet generation, against models + * with edge counts in the tens to low hundreds, so the rebuild cost is + * negligible next to the rest of the pipeline. + */ + function buildFaceAdjacencyGraph(): Map> { + const neighbors = new Map>(); + for (const edge of edgesById.values()) { + const faces = edge.adjacentFaceIds; + for (const a of faces) { + for (const b of faces) { + if (a === b) continue; + let set = neighbors.get(a); + if (!set) { + set = new Set(); + neighbors.set(a, set); + } + set.add(b); + } + } + } + return neighbors; + } + + /** The cylinder-kind faces bordering `feature`'s own rim edge(s) - for a + * closed loop split into two half-edges (as this app's CAD kernel export + * typically does), that's normally the two half-cylinder wall pieces of + * the SAME physical cylindrical surface. */ + function adjacentCylinderFaceIds(feature: ExactCircleOrArcCurveFeature): Set { + const out = new Set(); + for (const edgeId of feature.edgeIds) { + const edge = edgesById.get(edgeId); + if (!edge) continue; + for (const faceId of edge.adjacentFaceIds) { + if (facesById.get(faceId)?.kind === "cylinder") out.add(faceId); + } + } + return out; + } + + /** + * A hole can be a plain constant-diameter through-hole, or it can be + * stepped/counterbored: two (or more) coaxial circles at the same X/Y + * axis line but different depth and different radius, e.g. a ⌀3.0 pilot + * section for 5mm then a ⌀5.0 counterbore for the remaining depth. Such a + * hole's near-face opening can coincidentally measure the exact same + * diameter as an unrelated plain hole elsewhere on the part (e.g. ⌀3.0 + * mounting holes) - without this check, the dedup pass below would lump + * them into one "NX ⌀3.0" group even though they're physically different + * features, permanently hiding the stepped hole's own size and location + * dimensions (see the "6X ⌀3.0" investigation this fixes). + * + * Coaxial + different-radius alone is NOT enough to call two circles a + * "step": a hollow tube's inner bore and outer OD are coaxial and + * different-radius by definition too, but they're two independent + * surfaces, not sequential sections of one hole (confirmed by testing + * against Sleeve.stp, a tube whose OD/ID were incorrectly flagged as a + * "step" before this check existed). The real signature of a genuine + * step is topological, not just geometric: its two cylinder faces are + * bridged by exactly one shoulder (an annular plane face directly + * touching both), so this additionally requires the candidate's cylinder + * face(s) to share a common neighboring face with `feature`'s own + * cylinder face(s) in the shell's face-adjacency graph. + * + * Returns the OTHER coaxial circle (diameter + world center + the signed + * distance from `feature`'s own center to it along the shared axis) if + * `feature` has one, else null. Coaxial means: same axis direction + * (normals parallel) and centers differing only along that axis (no + * lateral offset). When a hole has more than one coaxial partner (e.g. a + * 3-diameter double-counterbore), the CLOSEST one along the axis wins - + * that's the partner whose shared step boundary is actually adjacent to + * `feature`'s own section. + */ + function findSteppedPartner( + feature: ExactCircleOrArcCurveFeature, + allFeatures: Iterable, + ): { diameterMm: number; center: THREE.Vector3; alongAxisMm: number } | null { + if (feature.kind !== "circle" || !feature.center || !feature.normal || feature.radius == null) { + return null; + } + const AXIS_PARALLEL_TOL = 0.01; + const LATERAL_TOL_MM = 0.05; + const RADIUS_SAME_TOL_MM = 0.01; + const faceAdjacency = buildFaceAdjacencyGraph(); + const featureCylFaces = adjacentCylinderFaceIds(feature); + // A face bridging two cylinder sections is only a genuine step + // shoulder if EVERY one of its own neighbors is itself a cylinder face + // - i.e. it exists purely to connect cylindrical sections, nothing + // else. A slot/pocket wall can ALSO happen to touch both a part's bore + // and its OD (if the pocket is cut radially through the wall - see the + // Sleeve.stp investigation this refines), including via genuine + // circular rim-fragment edges, so "shares a neighbor" alone isn't + // sufficient - but that pocket wall's OTHER neighbors are its sibling + // pocket walls (plane faces), which a pure annular shoulder never has. + const isPureShoulder = (faceId: string): boolean => { + const neigh = faceAdjacency.get(faceId); + if (!neigh || neigh.size === 0) return false; + for (const n of neigh) { + if (facesById.get(n)?.kind !== "cylinder") return false; + } + return true; + }; + // Two genuine step sections' cylinder faces are never directly + // adjacent to each other - they're bridged BY the shoulder (a plane + // face adjacent to both). So the test is "does featureCylFace's + // neighbor set intersect otherCylFace's neighbor set, at a face that + // is itself a pure shoulder" (a shared 1-hop neighbor = the shoulder), + // not "is otherCylFace itself one of featureCylFace's neighbors" (that + // would require them to touch directly, which two coaxial cylinder + // sections of different radii never do). + const shareShoulder = (otherCylFaces: Set): boolean => { + for (const a of featureCylFaces) { + const neighA = faceAdjacency.get(a); + if (!neighA) continue; + for (const b of otherCylFaces) { + const neighB = faceAdjacency.get(b); + if (!neighB) continue; + for (const shared of neighA) { + if (neighB.has(shared) && isPureShoulder(shared)) return true; + } + } + } + return false; + }; + let best: { diameterMm: number; center: THREE.Vector3; alongAxisMm: number } | null = null; + for (const other of allFeatures) { + if (other === feature || other.kind !== "circle") continue; + if (!other.center || !other.normal || other.radius == null) continue; + if (Math.abs(other.radius - feature.radius) < RADIUS_SAME_TOL_MM) continue; + if (Math.abs(Math.abs(other.normal.dot(feature.normal)) - 1) > AXIS_PARALLEL_TOL) continue; + const centerDelta = other.center.clone().sub(feature.center); + const alongAxis = centerDelta.dot(feature.normal); + const lateral = centerDelta + .clone() + .sub(feature.normal.clone().multiplyScalar(alongAxis)) + .length(); + if (lateral > LATERAL_TOL_MM) continue; + if (Math.abs(alongAxis) < RADIUS_SAME_TOL_MM) continue; + if (!shareShoulder(adjacentCylinderFaceIds(other))) continue; + if (best === null || Math.abs(alongAxis) < Math.abs(best.alongAxisMm)) { + best = { diameterMm: other.radius * 2, center: other.center.clone(), alongAxisMm: alongAxis }; + } + } + return best; + } + + /** + * Finds the circle/arc curve features that should get a diameter/radius + * callout in ONE captured view: only features whose plane faces the + * camera (so they read as a true circle/arc in this projection, not + * foreshortened into a line) AND whose rim/midpoint isn't occluded by + * other geometry in this view (so a hole's far-side/hidden circle - e.g. + * the bottom of a blind hole, or a counterbore's hidden rim - doesn't get + * the same callout treatment as a genuinely visible one). + */ + function computeCircularAnnotationsForView( + camera: THREE.OrthographicCamera, + meshTargets: THREE.Object3D[], + canvasWidth: number, + canvasHeight: number, + ): HiddenLineCircularAnnotation[] { + if (curveFeatureById.size === 0) return []; + + const viewDir = new THREE.Vector3(); + camera.getWorldDirection(viewDir); + const isOccluded = createPointOcclusionTester(viewDir, meshTargets); + + // The camera's actual current screen-right/up basis, read straight off + // its world matrix - for a face-on circle these are already an + // orthonormal basis for the circle's own plane (normal ~= viewDir), so + // they place the leader anchor at a conventional "upper right on + // screen" rim point without needing to reason about which world axis + // maps to which screen axis for this particular view. + const camRight = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0); + const camUp = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1); + + const projection: MeasurementProjectionContext = { + camera, + viewportWidth: canvasWidth, + viewportHeight: canvasHeight, + }; + + // Coaxial circles (same center, same axis, different radius - e.g. a + // flange's stack of stepped/counterbored diameters) otherwise ALL + // anchor at the identical conventional 45deg upper-right rim point + // below, since that formula only depends on center+radius+camRight/Up, + // none of which differ between coaxial members. That crowds every one + // of their size-callout leaders into the same narrow search cone in + // sheet-composer.ts's drawCircularCallout, which for 3+ members (each + // wanting a fairly wide "NX.0/NY.0 STEP" label) has repeatedly forced + // labels out far enough to collide with a NEIGHBORING view - see + // AVOID_CALLOUT_DIRECTIONS_RAD there for the complementary fix on the + // placement side. Fanning coaxial members across a spread of starting + // angles here fixes it at the source: grouped purely by LOCAL center + + // normal direction (not world space - every feature already shares one + // model transform, so local coincidence implies world coincidence, + // cheaper than transforming twice). + const COAXIAL_FAN_STEP_DEG = 28; + const coaxialSlot = new Map(); + { + const groups = new Map(); + const keyFor = (center: THREE.Vector3, normal: THREE.Vector3) => + [ + Math.round(center.x * 20), + Math.round(center.y * 20), + Math.round(center.z * 20), + Math.round(Math.abs(normal.x) * 100), + Math.round(Math.abs(normal.y) * 100), + Math.round(Math.abs(normal.z) * 100), + ].join("_"); + for (const feature of curveFeatureById.values()) { + if (feature.kind !== "circle") continue; + if (!feature.center || !feature.normal || feature.radius == null) continue; + const key = keyFor(feature.center, feature.normal); + const arr = groups.get(key) ?? []; + arr.push({ featureId: feature.featureId, radius: feature.radius }); + groups.set(key, arr); + } + for (const members of groups.values()) { + if (members.length < 2) continue; + // Largest radius first - the outermost ring's own search has the + // least room to work with (its rim already sits closest to the + // silhouette edge), so it gets the most "natural" (centermost) of + // the fanned angles, same reasoning sheet-composer.ts's own + // largest-first callout ordering uses. + members.sort((a, b) => b.radius - a.radius); + members.forEach((m, i) => { + coaxialSlot.set(m.featureId, { indexInGroup: i, groupSize: members.length }); + }); + } + } + + type RawAnnotation = { + featureId: string; + kind: "circle" | "arc"; + radiusMm: number; + anchorPx: { x: number; y: number }; + centerPx: { x: number; y: number }; + secondaryDiameterMm: number | null; + }; + + const raw: RawAnnotation[] = []; + for (const feature of curveFeatureById.values()) { + if (feature.kind !== "circle" && feature.kind !== "arc") continue; + if (!feature.center || !feature.normal || feature.radius == null) continue; + + const worldNormal = feature.normal.clone().transformDirection(modelRoot.matrixWorld); + // Face-on check: the feature's plane must be (near) perpendicular to + // the view direction, i.e. normal (near) parallel to it - otherwise + // this view foreshortens it into a line/ellipse, not a true circle. + if (Math.abs(worldNormal.dot(viewDir)) < 0.99) continue; + + const worldCenter = exactCadPointToWorld(feature.center); + if (!worldCenter) continue; + + let anchorWorld: THREE.Vector3; + if (feature.kind === "arc" && feature.midPoint) { + const worldMid = exactCadPointToWorld(feature.midPoint); + if (!worldMid) continue; + anchorWorld = worldMid; + } else { + // Conventional upper-right rim point (45deg), in this view's own + // screen basis - fanned out per coaxialSlot for a coaxial group + // (see its own doc comment) so its members don't all pile onto the + // identical angle. + const slot = coaxialSlot.get(feature.featureId); + const angleDeg = slot + ? 45 + (slot.indexInGroup - (slot.groupSize - 1) / 2) * COAXIAL_FAN_STEP_DEG + : 45; + const angleRad = (angleDeg * Math.PI) / 180; + anchorWorld = worldCenter + .clone() + .addScaledVector(camRight, feature.radius * Math.cos(angleRad)) + .addScaledVector(camUp, feature.radius * Math.sin(angleRad)); + } + + if (isOccluded(anchorWorld)) continue; + + const anchorScreen = projectWorldToScreenPx(anchorWorld, projection); + if (!anchorScreen.visible) continue; + const centerScreen = projectWorldToScreenPx(worldCenter, projection); + + raw.push({ + featureId: feature.featureId, + kind: feature.kind, + radiusMm: feature.radius, + anchorPx: { x: anchorScreen.x, y: anchorScreen.y }, + centerPx: { x: centerScreen.x, y: centerScreen.y }, + secondaryDiameterMm: findSteppedPartner(feature, curveFeatureById.values())?.diameterMm ?? null, + }); + } + + // Dedup size callouts: group same-kind features whose DISPLAYED value + // (same rounding as the label text) matches within this view, then only + // the group's representative (top-left-most, for a deterministic pick) + // gets a size label - "4X ⌀3.0" instead of four "⌀3.0"s. Every feature + // still gets a full annotation entry (and, downstream, its own location + // dimensions) regardless of which side of this dedup it lands on. + // + // Stepped/counterbored holes (secondaryDiameterMm set) key separately + // from plain constant-diameter holes even when this face's diameter + // happens to match - a ⌀3.0 counterbore throat is a different real + // feature from a plain ⌀3.0 mounting hole and must not be silently + // folded into that group (see findSteppedPartner's doc + // comment for the investigation that found this). + const groups = new Map(); + for (const r of raw) { + const displayValue = r.kind === "circle" ? r.radiusMm * 2 : r.radiusMm; + const steppedKey = + r.secondaryDiameterMm != null ? `/${r.secondaryDiameterMm.toFixed(1)}` : ""; + const key = `${r.kind}:${displayValue.toFixed(1)}${steppedKey}`; + const group = groups.get(key); + if (group) group.push(r); + else groups.set(key, [r]); + } + const representativeFeatureIds = new Set(); + const countByFeatureId = new Map(); + const representativeIdByFeatureId = new Map(); + for (const group of groups.values()) { + const representative = group.reduce((best, item) => + item.centerPx.y < best.centerPx.y || + (item.centerPx.y === best.centerPx.y && item.centerPx.x < best.centerPx.x) + ? item + : best, + ); + representativeFeatureIds.add(representative.featureId); + for (const item of group) { + countByFeatureId.set(item.featureId, group.length); + representativeIdByFeatureId.set(item.featureId, representative.featureId); + } + } + + return raw.map((r) => { + const displayValue = r.kind === "circle" ? r.radiusMm * 2 : r.radiusMm; + const prefix = r.kind === "circle" ? "⌀" : "R"; + const count = countByFeatureId.get(r.featureId) ?? 1; + const baseLabel = `${prefix}${displayValue.toFixed(1)}`; + const steppedSuffix = + r.secondaryDiameterMm != null ? `/⌀${r.secondaryDiameterMm.toFixed(1)} STEP` : ""; + const sizeLabel = representativeFeatureIds.has(r.featureId) + ? `${count > 1 ? `${count}X ` : ""}${baseLabel}${steppedSuffix}` + : null; + return { + featureId: r.featureId, + kind: r.kind, + radiusMm: r.radiusMm, + anchorPx: r.anchorPx, + centerPx: r.centerPx, + sizeLabel, + secondaryDiameterMm: r.secondaryDiameterMm, + groupRepresentativeFeatureId: representativeIdByFeatureId.get(r.featureId) ?? r.featureId, + groupSize: count, + }; + }); + } + + /** + * Finds the axial-depth dimension for a stepped/counterbored hole in ONE + * captured view: the complementary case to computeCircularAnnotationsForView + * above. A stepped hole's diameter only reads as a true circle in the ONE + * view whose sight line runs along its axis (Front, for a Z-axis hole); + * in the other two views the same hole is edge-on, its axis lying flat in + * the screen plane - exactly where its DEPTH (not visible/measurable from + * Front at all) can be dimensioned instead. Returns one entry per stepped + * hole whose axis is (near) perpendicular to this view's sight line, + * giving the screen-space near/far points of its first section so the + * sheet composer can draw a depth dimension without needing any 3D math + * of its own. + */ + function computeAxialDepthAnnotationsForView( + camera: THREE.OrthographicCamera, + canvasWidth: number, + canvasHeight: number, + // Restrict candidates to featureIds computeCircularAnnotationsForView + // already recognized (on whichever view faces this hole) as a stepped + // hole's near/pilot circle. A stepped hole's topology usually carries + // MULTIPLE same-radius coaxial circle pairs along its own axis (e.g. a + // step's shoulder rim shares the pilot's radius on one side and the + // counterbore's radius on the other), each of which independently looks + // like a valid "stepped pair" to findSteppedPartner - without this + // filter every one of those would surface as its own depth annotation, + // producing several redundant/competing depth dimensions for what a + // drafter would draw as ONE. Restricting to the known-recognized near + // circle keeps exactly one depth dimension per real stepped hole. + allowedFeatureIds: ReadonlySet, + ): HiddenLineAxialDepthAnnotation[] { + if (curveFeatureById.size === 0 || allowedFeatureIds.size === 0) return []; + + const viewDir = new THREE.Vector3(); + camera.getWorldDirection(viewDir); + const projection: MeasurementProjectionContext = { + camera, + viewportWidth: canvasWidth, + viewportHeight: canvasHeight, + }; + + const results: HiddenLineAxialDepthAnnotation[] = []; + for (const feature of curveFeatureById.values()) { + if (feature.kind !== "circle" || !feature.center || !feature.normal || feature.radius == null) { + continue; + } + if (!allowedFeatureIds.has(feature.featureId)) continue; + const worldNormal = feature.normal.clone().transformDirection(modelRoot.matrixWorld); + // Edge-on check: the axis must lie (near) IN this view's screen plane, + // i.e. (near) perpendicular to the sight line - the opposite test + // from computeCircularAnnotationsForView's face-on check. + if (Math.abs(worldNormal.dot(viewDir)) > 0.1) continue; + + const partner = findSteppedPartner(feature, curveFeatureById.values()); + if (!partner) continue; + + const worldNear = exactCadPointToWorld(feature.center); + const worldFar = exactCadPointToWorld(partner.center); + if (!worldNear || !worldFar) continue; + const nearScreen = projectWorldToScreenPx(worldNear, projection); + const farScreen = projectWorldToScreenPx(worldFar, projection); + if (!nearScreen.visible || !farScreen.visible) continue; + + results.push({ + featureId: feature.featureId, + depthMm: Math.abs(partner.alongAxisMm), + nearPx: { x: nearScreen.x, y: nearScreen.y }, + farPx: { x: farScreen.x, y: farScreen.y }, + }); + } + return results; + } + + /** + * Generates Front/Top/Right hidden-line views in sequence: reuses the + * existing camera presets, runs the verified ray-cast occlusion pass on + * each, and captures a clean visible-solid/hidden-dashed snapshot per + * view. Returns the three labeled images only - no sheet/layout + * composition. Restores the camera to wherever it was before the call. + * + * Scale consistency: fitCameraToBox() is called exactly ONCE, before the + * view loop, using the part's overall 3D bounding box (all three of + * X/Y/Z, not just what's visible from one angle). That box's largest + * dimension sets the ortho frustum half-height, and that same frustum is + * then reused unchanged for all three captures - so Front/Top/Right are + * guaranteed the same world-units-per-pixel scale. Fitting per-view off + * each view's own 2D silhouette would size each view to fill the frame + * independently, breaking that shared scale (e.g. a long part would + * render "shorter" in Front than the same length appears in Top). + */ + async function generateHiddenLineViewSet( + onProgress?: (info: HiddenLineProgressInfo) => void, + ): Promise { + const views: HiddenLineViewName[] = ["front", "top", "right"]; + + const wasPerspective = activeCamera === persp; + const prevPosition = activeCamera.position.clone(); + const prevTarget = controls.target.clone(); + const prevOrtho = { + left: ortho.left, + right: ortho.right, + top: ortho.top, + bottom: ortho.bottom, + near: ortho.near, + far: ortho.far, + }; + const prevPerspNearFar = { near: persp.near, far: persp.far }; + + const overallBox = getPartOnlyBox(); + if (!overallBox.isEmpty()) { + // Single shared fit for all three views - see doc comment above. + fitCameraToBox(overallBox, 1.5); + } + // Snap straight to the first view's final orthographic pose here, still + // synchronous with the fit above (no await has happened yet). Both calls' + // pending renders coalesce into the same not-yet-fired animation frame, + // so the browser's next actual paint already shows the correct Front + // view - never an intermediate frame with the fitted scale but the old + // (possibly perspective) camera/orientation still on screen. + setProjection("orthographic"); + setViewExact(views[0]); + + // Captured once, right after the shared fit - every view below reuses + // this same frustum, so this is the one true mm<->px conversion for all + // three resulting images (see HiddenLineViewSetResult's doc comment). + const canvasWidth = renderer.domElement.width; + const canvasHeight = renderer.domElement.height; + const pxPerMm = canvasHeight / (ortho.top - ortho.bottom); + const boxSize = overallBox.isEmpty() + ? new THREE.Vector3(0, 0, 0) + : overallBox.getSize(new THREE.Vector3()); + const modelBoundsMm = { x: boxSize.x, y: boxSize.y, z: boxSize.z }; + + const results: HiddenLineViewCapture[] = []; + const circularAnnotations: Record = { + front: [], + top: [], + right: [], + }; + const axialDepthAnnotations: Record = { + front: [], + top: [], + right: [], + }; + + for (let i = 0; i < views.length; i++) { + const view = views[i]; + const label = HIDDEN_LINE_VIEW_LABELS[view]; + onProgress?.({ + label: `Generating ${label} view...`, + index: i, + total: views.length, + done: false, + }); + // Yield so the progress update above actually paints before the + // upcoming synchronous ray-cast pass blocks the main thread again. + await nextFrame(); + + setProjection("orthographic"); + // Exact (untilted) Top/Bottom - see setViewExact()'s doc comment for + // why this differs from the interactive setView() used elsewhere. + // Note: setViewExact() only repositions/reorients the camera - it + // never touches the ortho frustum set above, so the shared scale + // survives each of these calls untouched. + setViewExact(view); + + // Vectors, not a snapshot: the sheet strokes these itself at a real + // paper-mm line weight - see HiddenLineEdgeRun's doc comment. + const result = computeHiddenLineSegments(); + const edgeRuns = result + ? buildProjectedEdgeRuns(result, ortho, canvasWidth, canvasHeight) + : []; + + results.push({ view, label, edgeRuns }); + circularAnnotations[view] = computeCircularAnnotationsForView( + ortho, + collectVisibleMeshRaycastTargets(), + canvasWidth, + canvasHeight, + ); + } + + // Second pass for axial-depth (stepped-hole) annotations: needs the + // FULL set of stepped-hole featureIds recognized across all three + // views (see computeAxialDepthAnnotationsForView's doc comment), which + // isn't known until every view's circularAnnotations above has been + // computed - so this can't be folded into the loop above. Cheap: no + // re-capture, just repositioning the already-fitted camera and reusing + // curveFeatureById. + const steppedNearFeatureIds = new Set(); + for (const view of views) { + for (const a of circularAnnotations[view]) { + if (a.secondaryDiameterMm != null) steppedNearFeatureIds.add(a.featureId); + } + } + if (steppedNearFeatureIds.size > 0) { + for (const view of views) { + setViewExact(view); + axialDepthAnnotations[view] = computeAxialDepthAnnotationsForView( + ortho, + canvasWidth, + canvasHeight, + steppedNearFeatureIds, + ); + } + } + + // Isometric reference view - LAST, after both annotation passes above, + // because it needs its own orthographic frustum (see + // captureIsoReferenceView) and everything that depends on the three + // views' shared fit is finished by this point. The restore immediately + // below puts the shared frustum back either way. + onProgress?.({ + label: "Generating isometric view...", + index: views.length, + total: views.length + 1, + done: false, + }); + await nextFrame(); + const isoCapture = captureIsoReferenceView( + overallBox, + canvasWidth, + canvasHeight, + ); + + setProjection(wasPerspective ? "perspective" : "orthographic"); + activeCamera.position.copy(prevPosition); + controls.target.copy(prevTarget); + ortho.left = prevOrtho.left; + ortho.right = prevOrtho.right; + ortho.top = prevOrtho.top; + ortho.bottom = prevOrtho.bottom; + ortho.near = prevOrtho.near; + ortho.far = prevOrtho.far; + ortho.updateProjectionMatrix(); + persp.near = prevPerspNearFar.near; + persp.far = prevPerspNearFar.far; + persp.updateProjectionMatrix(); + controls.update(); + requestUpdateSilhouette?.(); + requestRender("hidden_line_view_set_restore"); + + onProgress?.({ + label: "Done", + index: views.length, + total: views.length, + done: true, + }); + + // Canonical feature inventory for a completeness checker: the union of + // every circle/arc annotation that turned up face-on and unoccluded in + // ANY of the three views, deduped by featureId. Built from + // circularAnnotations (not a raw curveFeatureById scan) deliberately - + // a raw scan would also pick up internal step-shoulder rims (e.g. the + // mid-depth transition circle inside a counterbore) that were never + // meant to get their own dimension, producing false-positive coverage + // failures. This can still miss a feature that's foreshortened/occluded + // in all three orthogonal views, which is a known limitation, not + // silently "fixed" here. + const allCircularFeatures: HiddenLineViewSetResult["allCircularFeatures"] = []; + const seenFeatureIds = new Set(); + const circularFeatureGroups: HiddenLineViewSetResult["circularFeatureGroups"] = {}; + for (const view of views) { + for (const a of circularAnnotations[view]) { + circularFeatureGroups[a.featureId] = { + representativeFeatureId: a.groupRepresentativeFeatureId, + groupSize: a.groupSize, + }; + if (seenFeatureIds.has(a.featureId)) continue; + seenFeatureIds.add(a.featureId); + allCircularFeatures.push({ + featureId: a.featureId, + kind: a.kind, + radiusMm: a.radiusMm, + secondaryDiameterMm: a.secondaryDiameterMm, + }); + } + } + + return { + views: results, + pxPerMm, + canvasWidth, + canvasHeight, + modelBoundsMm, + circularAnnotations, + allCircularFeatures, + circularFeatureGroups, + axialDepthAnnotations, + isoCapture, + }; + } + // --- end hidden-line detection engine ------------------------------------ + + // --- TEMPORARY DEBUG: hidden-line detection spike ----------------------- + let hiddenLineDebugGroup: THREE.Group | null = null; + + function clearHiddenLineDebugVisualization(): void { + if (!hiddenLineDebugGroup) return; + scene.remove(hiddenLineDebugGroup); + disposeHiddenLineGroup(hiddenLineDebugGroup); + hiddenLineDebugGroup = null; + } + + function debugGetEdgeMode(): { + isExactCadMode: boolean; + isApproxCadMode: boolean; + exactEdgeCount: number; + curveFeatureCount: number; + approxEdgeCount: number; + } { + return { + isExactCadMode, + isApproxCadMode, + exactEdgeCount: exactEdgeRenderObjectsById.size, + curveFeatureCount: curveFeatureRenderObjectsById.size, + approxEdgeCount: approxCadEdgeObjects.length, + }; + } + + function debugLoadHiddenLineTestPart(): void { + const geom = buildHiddenLineDebugTestGeometry(); + loadMeshFromGeometry(geom); + } + + function debugRunHiddenLineTest(): HiddenLineDebugStats | null { + clearHiddenLineDebugVisualization(); + const result = computeHiddenLineSegments(); + if (!result) return null; + hiddenLineDebugGroup = buildHiddenLineStyledGroup(result); + scene.add(hiddenLineDebugGroup); + requestRender("hidden_line_debug"); + console.log("[hidden-line-debug] stats", result.stats); + return result.stats; + } + // --- end TEMPORARY DEBUG -------------------------------------------------- + function exactCadPointToWorld( point: THREE.Vector3 | null | undefined, ): THREE.Vector3 | null { @@ -6866,30 +8648,6 @@ export function createViewer(container: HTMLElement): Viewer { } function getOutlineSnapshotDataURL(): string { - const prevGridVisible = gridHelper ? gridHelper.visible : false; - const prevAxesVisible = axesHelper ? axesHelper.visible : false; - - const prevLineColor = measureMaterial.color.clone(); - const prevArrowColor = arrowMaterial.color.clone(); - let prevLabelColor: THREE.Color | null = null; - if (measureLabel && (measureLabel.material as any).color) { - prevLabelColor = (measureLabel.material as any).color.clone(); - } - - if (gridHelper) gridHelper.visible = false; - if (axesHelper) axesHelper.visible = false; - - measureMaterial.color.set(0x000000); - arrowMaterial.color.set(0x000000); - if (measureLabel && (measureLabel.material as any).color) { - (measureLabel.material as any).color.set(0x000000); - } - - const prevClearColor = renderer.getClearColor(new THREE.Color()).clone(); - const prevClearAlpha = renderer.getClearAlpha(); - const prevBackground = scene.background; - const prevModelVisible = modelRoot.visible; - const edgesGroup = new THREE.Group(); if ( @@ -6949,57 +8707,12 @@ export function createViewer(container: HTMLElement): Viewer { }); } - scene.add(edgesGroup); - - const prevModelVisibleForCube = modelRoot.visible; - modelRoot.visible = false; - // Outline Snap traces the actual part's edges only — a reference object's // solid-color linework would be indistinguishable from real part geometry - // in this black-on-white export, so hide it for this capture. - const prevCompareGroupVisible = compareReferenceGroup?.visible ?? false; - if (compareReferenceGroup) compareReferenceGroup.visible = false; - - renderer.setClearColor(0xf0f2f5, 1); - scene.background = null; - - renderNow("outline_snapshot_capture"); - - const dataURL = renderer.domElement.toDataURL("image/png"); - - scene.remove(edgesGroup); - edgesGroup.traverse((obj: any) => { - const asAny = obj as any; - if (asAny.geometry) { - disposeGeometryBoundsTree(asAny.geometry); - asAny.geometry.dispose(); - } - if (asAny.material) { - if (Array.isArray(asAny.material)) { - asAny.material.forEach((m: any) => m.dispose()); - } else { - asAny.material.dispose(); - } - } - }); - - modelRoot.visible = prevModelVisibleForCube; - if (compareReferenceGroup) compareReferenceGroup.visible = prevCompareGroupVisible; - renderer.setClearColor(prevClearColor, prevClearAlpha); - scene.background = prevBackground; - - measureMaterial.color.copy(prevLineColor); - arrowMaterial.color.copy(prevArrowColor); - if ( - measureLabel && - prevLabelColor && - (measureLabel.material as any).color - ) { - (measureLabel.material as any).color.copy(prevLabelColor); - } - if (gridHelper) gridHelper.visible = prevGridVisible; - if (axesHelper) axesHelper.visible = prevAxesVisible; - requestRender("outline_snapshot_restore"); + // in this black-on-white export, so captureSceneSnapshotWithOverlay's + // hiding of the compare-reference group applies here too. + const dataURL = captureSceneSnapshotWithOverlay(edgesGroup); + disposeHiddenLineGroup(edgesGroup); return dataURL; } @@ -8661,6 +10374,106 @@ export function createViewer(container: HTMLElement): Viewer { requestRender("set_view"); } + /** + * Like setView(), but Top/Bottom use the TRUE perpendicular direction - + * no off-axis tilt. setView() deliberately tilts Top/Bottom slightly to + * dodge an OrbitControls pole-singularity bug that shows up during + * INTERACTIVE dragging after a view-cube click - a real fix, and it must + * stay exactly as-is (do not "fix" setView() itself). But that + * singularity only ever manifests through subsequent incremental drag + * deltas; a single static render/capture never touches that code path, + * so a one-shot, non-interactive use (e.g. generateHiddenLineViewSet()) + * has nothing to dodge, while the tilt itself actively makes a Top/Bottom + * capture geometrically wrong for a real engineering drawing (circles + * render as ellipses, edges misalign). This function exists so capture + * paths can get a geometrically exact view without touching setView()'s + * interactive behavior at all. Front/Back/Left/Right/Iso were never + * tilted, so they behave identically to setView() here. + */ + function setViewExact( + preset: "top" | "front" | "right" | "iso" | "bottom" | "left" | "back", + ) { + const isFiniteVec3 = (value: THREE.Vector3): boolean => + Number.isFinite(value.x) && + Number.isFinite(value.y) && + Number.isFinite(value.z); + + const resolveStableOrbitTarget = (): THREE.Vector3 => { + const modelBoundsBox = new THREE.Box3(); + let hasModelBounds = false; + for (const child of getTopLevelModelChildren()) { + const childBounds = new THREE.Box3().setFromObject(child); + if (childBounds.isEmpty()) continue; + if (!hasModelBounds) { + modelBoundsBox.copy(childBounds); + hasModelBounds = true; + } else { + modelBoundsBox.union(childBounds); + } + } + if (hasModelBounds) { + const center = modelBoundsBox.getCenter(new THREE.Vector3()); + if (isFiniteVec3(center)) return center; + } + const fallbackTarget = controls.target.clone(); + return isFiniteVec3(fallbackTarget) + ? fallbackTarget + : new THREE.Vector3(0, 0, 0); + }; + + const target = resolveStableOrbitTarget(); + const rawRadius = activeCamera.position.distanceTo(target); + const radius = + Number.isFinite(rawRadius) && rawRadius > 1e-3 + ? rawRadius + : Math.max(modelDiagonal * 0.6, 300); + + // No off-axis tilt for top/bottom here - see doc comment above. + const direction = (() => { + switch (preset) { + case "top": + return new THREE.Vector3(0, 1, 0); + case "bottom": + return new THREE.Vector3(0, -1, 0); + case "front": + return new THREE.Vector3(0, 0, 1); + case "back": + return new THREE.Vector3(0, 0, -1); + case "right": + return new THREE.Vector3(1, 0, 0); + case "left": + return new THREE.Vector3(-1, 0, 0); + case "iso": + default: + return new THREE.Vector3(1, 0.6, 1); + } + })(); + if (direction.lengthSq() <= 1e-12) { + direction.set(1, 0.6, 1); + } + direction.normalize(); + const up = getViewerViewUpVector(preset); + + const syncCameraToPreset = (camera: THREE.Camera) => { + camera.position.copy(target).addScaledVector(direction, radius); + camera.up.copy(up); + camera.lookAt(target); + camera.up.set(0, 1, 0); + (camera as any).updateProjectionMatrix?.(); + camera.updateMatrixWorld(true); + }; + + syncCameraToPreset(persp); + syncCameraToPreset(ortho); + + controls.target.copy(target); + controls.update(); + requestUpdateSilhouette?.(); + scheduleExactCurveFeatureResample("set_view_exact"); + emitViewChanged(); + requestRender("set_view_exact"); + } + function setProjection(mode: "perspective" | "orthographic") { const nextCamera = mode === "perspective" ? persp : ortho; if (activeCamera !== nextCamera) { @@ -8698,6 +10511,12 @@ export function createViewer(container: HTMLElement): Viewer { if (edgeHoverLineMaterial) { edgeHoverLineMaterial.resolution.set(w, h); } + if (exactEdgeFatMaterialNormal) { + exactEdgeFatMaterialNormal.resolution.set(w, h); + } + if (exactEdgeFatMaterialTangentPhantom) { + exactEdgeFatMaterialTangentPhantom.resolution.set(w, h); + } updateCubeSize(); scheduleExactCurveFeatureResample("resize"); emitViewChanged(); @@ -9392,6 +11211,7 @@ export function createViewer(container: HTMLElement): Viewer { setMeasurementGraphicsScale, getScreenshotDataURL, getOutlineSnapshotDataURL, + captureHighResIsoView, setMaterialProperties, setFlatSurfaceDensityPercent, setCurvedSurfaceDetailPercent, @@ -9418,5 +11238,9 @@ export function createViewer(container: HTMLElement): Viewer { onViewChanged, requestRender, projectWorldToScreen, + generateHiddenLineViewSet, + debugLoadHiddenLineTestPart, + debugRunHiddenLineTest, + debugGetEdgeMode, }; } diff --git a/third_party/DOMPURIFY_LICENSE.txt b/third_party/DOMPURIFY_LICENSE.txt new file mode 100644 index 0000000..72ab285 --- /dev/null +++ b/third_party/DOMPURIFY_LICENSE.txt @@ -0,0 +1,211 @@ +DOMPurify (https://github.com/cure53/DOMPurify) - an optional dependency of +jsPDF's .html() / SVG-import plugin. This app never calls that jsPDF +feature, so DOMPurify is never loaded at runtime - it ships only as part of +the same lazily-code-split chunk as html2canvas (see +HTML2CANVAS_LICENSE.txt), never fetched unless that unused code path +executes. Included here for completeness since its code is still present in +the built output. DOMPurify is dual-licensed (MPL-2.0 OR Apache-2.0); the +permissive Apache-2.0 option is reproduced below. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/HTML2CANVAS_LICENSE.txt b/third_party/HTML2CANVAS_LICENSE.txt new file mode 100644 index 0000000..5f2a895 --- /dev/null +++ b/third_party/HTML2CANVAS_LICENSE.txt @@ -0,0 +1,32 @@ +html2canvas (https://github.com/niklasvh/html2canvas) - an optional +dependency of jsPDF's `.html()` / SVG-import plugin. This app never calls +that jsPDF feature (only the plain vector-drawing API used by the drawing- +sheet PDF export), so html2canvas is never loaded at runtime - it ships only +as a separate, lazily-code-split chunk that a browser never fetches unless +that unused code path executes. Included here for completeness since its +code is still present in the built output. + +MIT License + +Copyright (c) 2012 Niklas von Hertzen + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/third_party/JSPDF_LICENSE.txt b/third_party/JSPDF_LICENSE.txt new file mode 100644 index 0000000..91c8999 --- /dev/null +++ b/third_party/JSPDF_LICENSE.txt @@ -0,0 +1,27 @@ +jsPDF (https://github.com/parallax/jsPDF) - used for client-side vector PDF generation +of the drawing-sheet export. + +MIT License + +Copyright +(c) 2010-2025 James Hall, https://github.com/MrRio/jsPDF +(c) 2015-2025 yWorks GmbH, https://www.yworks.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vite.config.ts b/vite.config.ts index eca6e2f..20bdeb8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,46 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import react from '@vitejs/plugin-react' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +// jsPDF's own LICENSE file (MIT) - Rollup's bundling step drops the source +// comment banner from node_modules/jspdf's ESM build before esbuild's +// minifier ever runs (confirmed by testing: even an UNMINIFIED build is +// already missing it), so preserving it isn't a matter of an esbuild/terser +// comment-retention flag - it has to be re-added explicitly instead. +const jspdfLicenseBanner = `/*!\n${readFileSync( + fileURLToPath(new URL('./node_modules/jspdf/LICENSE', import.meta.url)), + 'utf-8', +).trim()}\n*/\n` + +/** + * Prepends jspdfLicenseBanner to any emitted chunk that actually bundles + * jsPDF. Implemented as a plugin (generateBundle hook) rather than + * `build.rollupOptions.output.banner` because this project's build + * (`vite-react-ssg build`) runs `vite build` a second time internally with + * its own `rollupOptions` override, and Vite's `mergeConfig` does not + * reliably deep-merge a `rollupOptions.output` function past that second + * override (confirmed by testing: the banner function ran and returned the + * right text, but never reached the written file). `plugins` arrays, unlike + * nested rollupOptions, are reliably concatenated across such merges, and + * `generateBundle` runs after minification, directly on the final bundle + * about to be written - so this is not vulnerable to the same loss. + */ +function jspdfLicenseBannerPlugin(): Plugin { + return { + name: 'jspdf-license-banner', + generateBundle(_options, bundle) { + for (const file of Object.values(bundle)) { + if (file.type !== 'chunk') continue; + const bundlesJspdf = Object.keys(file.modules).some((id) => + id.toLowerCase().includes('/node_modules/jspdf/'), + ); + if (bundlesJspdf) file.code = jspdfLicenseBanner + file.code; + } + }, + }; +} export default defineConfig({ - plugins: [react()] + plugins: [react(), jspdfLicenseBannerPlugin()], })