Integrate flyfish CAD viewer
This commit is contained in:
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
手机 / 平板上的本地 DWG、PDF、EPUB、FB2、XPS/OXPS、CBZ 和图片浏览器。
|
手机 / 平板上的本地 DWG、PDF、EPUB、FB2、XPS/OXPS、CBZ 和图片浏览器。
|
||||||
|
|
||||||
- DWG 解析:`@node-projects/acad-ts`(MIT),支持模型空间、块、布局、代理图形数据、图层、文字、填充、样条、尺寸等常见 2D 实体。
|
- DWG 渲染:`@flyfish-dev/cad-viewer`(AGPL-3.0,GitHub: `flyfish-dev/cad-viewer`),WebGL 常驻渲染,支持块展开、图层、文字、填充、线型、样条、标注和常用 DWG 2006–2026 版本。
|
||||||
- 文档渲染:MuPDF.js。
|
- 文档渲染:MuPDF.js。
|
||||||
- 前端:Vite + TypeScript + Canvas 2D,解析在 Web Worker 中执行。
|
- 前端:Vite + TypeScript;DWG 解析在 Web Worker 中执行,运行时 WASM 位于 `public/wasm/`。
|
||||||
- Android:Kotlin WebView 壳,离线内置全部前端资源,支持文件关联、返回键和 PNG 保存。
|
- Android:Kotlin WebView 壳,离线内置全部前端资源,支持文件关联、返回键和 PNG 保存。
|
||||||
- 默认显示第三方代理对象,可在左侧立方体按钮中关闭;DWG 中代理数据本身没有图形时不会产生可见图元。
|
- 代理对象只有在文件中包含代理图形数据时才会显示;没有任何图形数据的字典代理不可见。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ class MainActivity : Activity() {
|
|||||||
private var lastOpenedMime = "application/octet-stream"
|
private var lastOpenedMime = "application/octet-stream"
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val APP_HOST = "appassets-v5.androidplatform.net"
|
const val APP_HOST = "appassets-v6.androidplatform.net"
|
||||||
const val APP_URL = "https://appassets-v5.androidplatform.net/"
|
const val APP_URL = "https://appassets-v6.androidplatform.net/"
|
||||||
const val FILE_CHOOSER_REQUEST = 1001
|
const val FILE_CHOOSER_REQUEST = 1001
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+72
-19
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "phone-viewer-app",
|
"name": "phone-viewer-app",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "phone-viewer-app",
|
"name": "phone-viewer-app",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-projects/acad-ts": "^3.6.0",
|
"@flyfish-dev/cad-viewer": "^0.8.3",
|
||||||
"mupdf": "1.28.1"
|
"mupdf": "1.28.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mlightcad/libredwg-web": "0.8.2",
|
|
||||||
"adm-zip": "^0.6.1",
|
"adm-zip": "^0.6.1",
|
||||||
"playwright-core": "^1.64.1",
|
"playwright-core": "^1.64.1",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
@@ -1974,6 +1974,30 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@flyfish-dev/cad-viewer": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@flyfish-dev/cad-viewer/-/cad-viewer-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-HR2+Rt3Pb17QpmzOwz5lG87vamwoYcRrKaeRT+CBvJgK350z3qAV9VtAe2kt0XZjrTLTKYAglGBTjGY0IgDDPg==",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"dependencies": {
|
||||||
|
"@mlightcad/libredwg-web": "0.7.9",
|
||||||
|
"@mlightcad/shx-parser": "^1.4.5",
|
||||||
|
"dwf-viewer": "^0.6.7",
|
||||||
|
"dxf-parser": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@flyfish-dev/cad-viewer/node_modules/@mlightcad/libredwg-web": {
|
||||||
|
"version": "0.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mlightcad/libredwg-web/-/libredwg-web-0.7.9.tgz",
|
||||||
|
"integrity": "sha512-tqjx0eCiR0CNI3TyO3LVYzl4ptuzSFm7asGn/C1YiJaEk7Vneto+lRrVUco85qw+PZDihaFLwWqdYIEse1swbA==",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||||
@@ -2045,15 +2069,11 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mlightcad/libredwg-web": {
|
"node_modules/@mlightcad/shx-parser": {
|
||||||
"version": "0.8.2",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/@mlightcad/libredwg-web/-/libredwg-web-0.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mlightcad/shx-parser/-/shx-parser-1.4.5.tgz",
|
||||||
"integrity": "sha512-0iX1lbASPEWwhljHAmaB6/mNHAPObCMmlDIza1a6B0egpm0DGfaMjXimwWmVSPE+FNEMhQxB3Tw0mupDwkWHIQ==",
|
"integrity": "sha512-wFY0X7vnuq1IYdP6JRggb7LHcKho1P8zmcWGtXa6XYthwyKZCUoZCswTXr7J26pbzyhBB7KsiCcCL48iwIevdQ==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "GPL-3.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
@@ -2075,12 +2095,6 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"node": "^22.20 || ^24.12 || >=25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@node-projects/acad-ts": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@node-projects/acad-ts/-/acad-ts-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-MQadFJafEuKDmzhi7GeoCV/YpPcbSVHMuuRhaQGuC9Xbzo8DdRpE8LWdV/fNT5FqbRXh60oK/B/2pPFR/ATpmw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/plugin-babel": {
|
"node_modules/@rollup/plugin-babel": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz",
|
||||||
@@ -3138,6 +3152,32 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dwf-viewer": {
|
||||||
|
"version": "0.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/dwf-viewer/-/dwf-viewer-0.6.7.tgz",
|
||||||
|
"integrity": "sha512-YIUmzt0Ck+TFxFsmuYlRydy15wrqDEaweqUd8uO44Q6r9NgfkIlLKIHbEQFX/CNqMzzjd7lSbNdFPAEXMBx3LQ==",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">=0.150.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"three": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dxf-parser": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dxf-parser/-/dxf-parser-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-GPTumUvRkounlIazLIyJMmTWt+nlg+ksS0Hdm8jWvejmZKBTz6gvHTam76wRm4PQMma5sgKLThblQyeIJcH79Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loglevel": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "3.1.10",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||||
@@ -4390,6 +4430,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/loglevel": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
|||||||
+7
-6
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "phone-viewer-app",
|
"name": "phone-viewer-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.1",
|
"version": "0.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"description": "Mobile/tablet all-in-one viewer: DWG (model & layout space) + PDF/EPUB/XPS/CBZ/images",
|
"description": "Mobile/tablet all-in-one viewer: DWG (model & layout space) + PDF/EPUB/XPS/CBZ/images",
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"icons": "node scripts/make-icons.mjs",
|
"icons": "node scripts/make-icons.mjs",
|
||||||
"patch:wasm": "node scripts/patch-wasm-memory.mjs",
|
"apk": "node scripts/build-apk.mjs",
|
||||||
"apk": "node scripts/build-apk.mjs"
|
"copy:cad-wasm": "node scripts/copy-cad-wasm.mjs",
|
||||||
|
"postinstall": "node scripts/copy-cad-wasm.mjs",
|
||||||
|
"prebuild": "node scripts/copy-cad-wasm.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-projects/acad-ts": "^3.6.0",
|
"@flyfish-dev/cad-viewer": "^0.8.3",
|
||||||
"mupdf": "1.28.1"
|
"mupdf": "1.28.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -26,7 +28,6 @@
|
|||||||
"playwright-core": "^1.64.1",
|
"playwright-core": "^1.64.1",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vite-plugin-pwa": "^0.20.5",
|
"vite-plugin-pwa": "^0.20.5"
|
||||||
"@mlightcad/libredwg-web": "0.8.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -47,7 +47,7 @@ const sourceDir = path.join(appDir, 'java')
|
|||||||
const outDir = path.join(root, 'android', 'build')
|
const outDir = path.join(root, 'android', 'build')
|
||||||
const classesDir = path.join(outDir, 'classes')
|
const classesDir = path.join(outDir, 'classes')
|
||||||
const dexDir = path.join(outDir, 'dex')
|
const dexDir = path.join(outDir, 'dex')
|
||||||
const apkName = 'PhoneViewer-0.4.1.apk'
|
const apkName = 'PhoneViewer-0.5.0.apk'
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
function run(command, args, options = {}) {
|
||||||
console.log('>', path.basename(command), args.join(' '))
|
console.log('>', path.basename(command), args.join(' '))
|
||||||
@@ -139,9 +139,9 @@ run(path.join(buildTools, 'aapt2.exe'), [
|
|||||||
'--target-sdk-version',
|
'--target-sdk-version',
|
||||||
'34',
|
'34',
|
||||||
'--version-code',
|
'--version-code',
|
||||||
'5',
|
'6',
|
||||||
'--version-name',
|
'--version-name',
|
||||||
'0.4.1',
|
'0.5.0',
|
||||||
'--auto-add-overlay'
|
'--auto-add-overlay'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const source = path.join('node_modules', '@flyfish-dev', 'cad-viewer', 'dist', 'wasm')
|
||||||
|
const target = path.join('public', 'wasm')
|
||||||
|
const files = [
|
||||||
|
'dwg-worker.js',
|
||||||
|
'dwfv-render.wasm',
|
||||||
|
'libredwg-web.js',
|
||||||
|
'libredwg-web.wasm'
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!fs.existsSync(source)) {
|
||||||
|
console.warn(`[cad-wasm] source not found: ${source}`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(target, { recursive: true })
|
||||||
|
for (const file of files) {
|
||||||
|
const from = path.join(source, file)
|
||||||
|
if (!fs.existsSync(from)) {
|
||||||
|
console.warn(`[cad-wasm] missing: ${from}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs.copyFileSync(from, path.join(target, file))
|
||||||
|
}
|
||||||
|
console.log(`[cad-wasm] copied ${files.length} runtime files to ${target}`)
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import fs from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
const source = process.argv[2] ?? 'node_modules/@mlightcad/libredwg-web/wasm/libredwg-web.wasm'
|
|
||||||
const target = process.argv[3] ?? 'public/vendor/libredwg/wasm/libredwg-web.wasm'
|
|
||||||
const INITIAL_PAGES = 4096 // 256 MiB; the module can still grow up to its 4 GiB maximum.
|
|
||||||
const MAX_PAGES = 65536
|
|
||||||
|
|
||||||
const buf = fs.readFileSync(source)
|
|
||||||
if (buf.readUInt32LE(0) !== 0x6d736100 || buf.readUInt32LE(4) !== 1) {
|
|
||||||
throw new Error('Not a WebAssembly module')
|
|
||||||
}
|
|
||||||
|
|
||||||
function readU32(data, offset) {
|
|
||||||
let value = 0
|
|
||||||
let shift = 0
|
|
||||||
let pos = offset
|
|
||||||
while (true) {
|
|
||||||
const byte = data[pos++]
|
|
||||||
value += (byte & 0x7f) * 2 ** shift
|
|
||||||
if (!(byte & 0x80)) break
|
|
||||||
shift += 7
|
|
||||||
if (shift > 35) throw new Error('Invalid LEB128 value')
|
|
||||||
}
|
|
||||||
return [value >>> 0, pos]
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeU32(value) {
|
|
||||||
const bytes = []
|
|
||||||
let v = value >>> 0
|
|
||||||
do {
|
|
||||||
let byte = v & 0x7f
|
|
||||||
v >>>= 7
|
|
||||||
if (v) byte |= 0x80
|
|
||||||
bytes.push(byte)
|
|
||||||
} while (v)
|
|
||||||
return Buffer.from(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeSection(id, payload) {
|
|
||||||
return Buffer.concat([Buffer.from([id]), encodeU32(payload.length), payload])
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = []
|
|
||||||
let pos = 8
|
|
||||||
while (pos < buf.length) {
|
|
||||||
const id = buf[pos++]
|
|
||||||
const [size, afterSize] = readU32(buf, pos)
|
|
||||||
const payload = buf.subarray(afterSize, afterSize + size)
|
|
||||||
sections.push({ id, payload })
|
|
||||||
pos = afterSize + size
|
|
||||||
}
|
|
||||||
|
|
||||||
let patched = false
|
|
||||||
const output = []
|
|
||||||
output.push(buf.subarray(0, 8))
|
|
||||||
for (const section of sections) {
|
|
||||||
if (section.id === 5 && !patched) {
|
|
||||||
const [count, afterCount] = readU32(section.payload, 0)
|
|
||||||
if (count < 1) throw new Error('Memory section has no memory')
|
|
||||||
const flags = section.payload[afterCount]
|
|
||||||
if ((flags & 0x01) === 0) throw new Error('Memory has no maximum; refusing to patch')
|
|
||||||
const [oldMin, afterMin] = readU32(section.payload, afterCount + 1)
|
|
||||||
const [oldMax] = readU32(section.payload, afterMin)
|
|
||||||
if (oldMin !== 16384) throw new Error(`Unexpected initial memory: ${oldMin}`)
|
|
||||||
const payload = Buffer.concat([
|
|
||||||
encodeU32(count),
|
|
||||||
Buffer.from([flags]),
|
|
||||||
encodeU32(INITIAL_PAGES),
|
|
||||||
encodeU32(MAX_PAGES),
|
|
||||||
section.payload.subarray(afterMin + encodeU32(oldMax).length)
|
|
||||||
])
|
|
||||||
output.push(encodeSection(5, payload))
|
|
||||||
patched = true
|
|
||||||
console.log(
|
|
||||||
`patched memory: ${oldMin} -> ${INITIAL_PAGES} pages (${Math.round((INITIAL_PAGES * 64) / 1024)} MiB)`
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
output.push(encodeSection(section.id, section.payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!patched) throw new Error('Memory section not found')
|
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
||||||
fs.writeFileSync(target, Buffer.concat(output))
|
|
||||||
console.log('wrote', target, fs.statSync(target).size, 'bytes')
|
|
||||||
@@ -1,845 +0,0 @@
|
|||||||
import './polyfills'
|
|
||||||
import { DwgReader } from '@node-projects/acad-ts'
|
|
||||||
import type { DwgDatabase } from '@mlightcad/libredwg-web'
|
|
||||||
import { IDENTITY, applyMat, matMul, matScaleAvg } from './geom'
|
|
||||||
import type { Mat } from './geom'
|
|
||||||
import { buildCadDocument } from './build'
|
|
||||||
import type { CadDocument } from './types'
|
|
||||||
|
|
||||||
type AcadDoc = ReturnType<typeof DwgReader.readFromStream>
|
|
||||||
type AcadEntity = Record<string, any>
|
|
||||||
|
|
||||||
interface NormalizeState {
|
|
||||||
unknown: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BuildOptions {
|
|
||||||
fileName: string
|
|
||||||
fileSize: number
|
|
||||||
unknownCount: number
|
|
||||||
warnings: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const GRAPHICS = {
|
|
||||||
Extents: 1,
|
|
||||||
Circle: 2,
|
|
||||||
CirclePt3: 3,
|
|
||||||
CircularArc: 4,
|
|
||||||
CircularArc3Pt: 5,
|
|
||||||
Polyline: 6,
|
|
||||||
Polygon: 7,
|
|
||||||
Mesh: 8,
|
|
||||||
Shell: 9,
|
|
||||||
Text: 10,
|
|
||||||
Text2: 11,
|
|
||||||
XLine: 12,
|
|
||||||
Ray: 13,
|
|
||||||
SubentColor: 14,
|
|
||||||
SubentLayer: 16,
|
|
||||||
SubentLineType: 18,
|
|
||||||
SubentMarker: 19,
|
|
||||||
SubentFillon: 20,
|
|
||||||
SubentTrueColor: 22,
|
|
||||||
SubentLineWeight: 23,
|
|
||||||
SubentLineTypeScale: 24,
|
|
||||||
SubentThickness: 25,
|
|
||||||
SubentPlotStyleName: 26,
|
|
||||||
PushClip: 27,
|
|
||||||
PopClip: 28,
|
|
||||||
PushModelTransform: 29,
|
|
||||||
PushModelTransform2: 30,
|
|
||||||
PopModelTransform: 31,
|
|
||||||
PolylineWithNormal: 32,
|
|
||||||
LwPolyine: 33,
|
|
||||||
UnicodeText: 36,
|
|
||||||
UnicodeText2: 38
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const DYNAMIC_BLOCK_TYPES = new Set([
|
|
||||||
'ACAD_PROXY_ENTITY',
|
|
||||||
'ACAD_PROXY_OBJECT',
|
|
||||||
'AEC_WALL',
|
|
||||||
'AEC_WINDOW',
|
|
||||||
'AEC_DOOR',
|
|
||||||
'AEC_OPENING',
|
|
||||||
'AEC_SLAB',
|
|
||||||
'AEC_ROOF',
|
|
||||||
'AEC_STAIR',
|
|
||||||
'AEC_RAILING',
|
|
||||||
'AEC_GRID',
|
|
||||||
'AEC_COLUMN',
|
|
||||||
'AEC_BEAM',
|
|
||||||
'MULTILEADER'
|
|
||||||
])
|
|
||||||
|
|
||||||
export function buildAcadDocument(
|
|
||||||
buffer: ArrayBuffer,
|
|
||||||
fileName: string,
|
|
||||||
fileSize: number
|
|
||||||
): CadDocument {
|
|
||||||
const acad = DwgReader.readFromStream(buffer)
|
|
||||||
if (!acad.modelSpace) throw new Error('DWG 中没有可显示的模型空间。')
|
|
||||||
const state: NormalizeState = { unknown: 0 }
|
|
||||||
const blocks = normalizeBlocks(acad, state)
|
|
||||||
const layouts = normalizeLayouts(acad, blocks.map)
|
|
||||||
const layers = normalizeLayers(acad)
|
|
||||||
const lineTypes = normalizeLineTypes(acad)
|
|
||||||
const vports = normalizeVPorts(acad)
|
|
||||||
const imageDefs = normalizeImageDefs(acad)
|
|
||||||
const header = normalizeHeader(acad)
|
|
||||||
const unknownCount = state.unknown
|
|
||||||
const warnings: string[] = []
|
|
||||||
if (unknownCount > 0) warnings.push(`${unknownCount} 个实体类型暂不支持显示。`)
|
|
||||||
const db = {
|
|
||||||
tables: {
|
|
||||||
LAYER: { entries: layers },
|
|
||||||
BLOCK_RECORD: { entries: blocks.entries },
|
|
||||||
LTYPE: { entries: lineTypes },
|
|
||||||
VPORT: { entries: vports }
|
|
||||||
},
|
|
||||||
objects: {
|
|
||||||
LAYOUT: layouts,
|
|
||||||
IMAGEDEF: imageDefs
|
|
||||||
},
|
|
||||||
header,
|
|
||||||
entities: []
|
|
||||||
} as unknown as DwgDatabase
|
|
||||||
return buildCadDocument(db, {
|
|
||||||
fileName,
|
|
||||||
fileSize,
|
|
||||||
unknownCount,
|
|
||||||
warnings
|
|
||||||
} satisfies BuildOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBlocks(acad: AcadDoc, state: NormalizeState) {
|
|
||||||
const entries: Record<string, any>[] = []
|
|
||||||
const map = new Map<number, Record<string, any>>()
|
|
||||||
let index = 0
|
|
||||||
for (const block of acad.blockRecords ?? []) {
|
|
||||||
const handle = String(block.handle ?? `block-${index}`)
|
|
||||||
const base = block.blockEntity?.basePoint ?? { x: 0, y: 0, z: 0 }
|
|
||||||
const entities: Record<string, any>[] = []
|
|
||||||
for (const entity of block.entities ?? []) {
|
|
||||||
entities.push(...normalizeEntity(entity, state))
|
|
||||||
}
|
|
||||||
const entry = {
|
|
||||||
name: block.name ?? `*BLOCK${index}`,
|
|
||||||
handle,
|
|
||||||
baseX: Number(base.x) || 0,
|
|
||||||
baseY: Number(base.y) || 0,
|
|
||||||
baseZ: Number(base.z) || 0,
|
|
||||||
layoutHandle: block.layout?.handle == null ? '' : String(block.layout.handle),
|
|
||||||
entities
|
|
||||||
}
|
|
||||||
entries.push(entry)
|
|
||||||
if (block.handle != null) map.set(block.handle, entry)
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return { entries, map }
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLayouts(acad: AcadDoc, blocks: Map<number, Record<string, any>>) {
|
|
||||||
const layouts: Record<string, any>[] = []
|
|
||||||
let index = 0
|
|
||||||
for (const layout of acad.layouts ?? []) {
|
|
||||||
const block = layout.associatedBlock
|
|
||||||
if (!block) continue
|
|
||||||
const entry = blocks.get(block.handle)
|
|
||||||
if (!entry) continue
|
|
||||||
const known = new Set(entry.entities.map((entity: any) => String(entity.handle ?? '')))
|
|
||||||
for (const viewport of layout.viewports ?? []) {
|
|
||||||
const handle = String(viewport.handle ?? '')
|
|
||||||
if (handle && known.has(handle)) continue
|
|
||||||
entry.entities.push(...normalizeEntity(viewport, { unknown: 0 }))
|
|
||||||
if (handle) known.add(handle)
|
|
||||||
}
|
|
||||||
layouts.push({
|
|
||||||
layoutName: layout.name ?? `Layout${index + 1}`,
|
|
||||||
tabOrder: Number(layout.tabOrder) || index,
|
|
||||||
handle: String(layout.handle ?? `layout-${index}`),
|
|
||||||
paperSpaceTableId: String(block.handle),
|
|
||||||
minExtent: layout.minExtents,
|
|
||||||
maxExtent: layout.maxExtents
|
|
||||||
})
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return layouts
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLayers(acad: AcadDoc) {
|
|
||||||
const entries: Record<string, any>[] = []
|
|
||||||
for (const layer of acad.layers ?? []) {
|
|
||||||
const color = layer.color
|
|
||||||
const aci = color?.isTrueColor ? 7 : Number(color?.index) || 7
|
|
||||||
const rgb = color?.isTrueColor ? Number(color.trueColor) >>> 0 : undefined
|
|
||||||
const flags = Number(layer.layerFlags) || 0
|
|
||||||
entries.push({
|
|
||||||
name: layer.name ?? '0',
|
|
||||||
handle: String(layer.handle ?? ''),
|
|
||||||
colorIndex: aci,
|
|
||||||
color: rgb,
|
|
||||||
off: layer.isOn === false,
|
|
||||||
frozen: (flags & 1) === 1,
|
|
||||||
lineType: layer.lineType?.name ?? 'Continuous',
|
|
||||||
lineweight: Number(layer.lineWeight)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLineTypes(acad: AcadDoc) {
|
|
||||||
const entries: Record<string, any>[] = []
|
|
||||||
for (const lineType of acad.lineTypes ?? []) {
|
|
||||||
const segments = lineType.segments ?? []
|
|
||||||
if (!segments.length) continue
|
|
||||||
entries.push({
|
|
||||||
name: lineType.name,
|
|
||||||
pattern: segments.map((segment: any) => ({
|
|
||||||
elementLength: Math.abs(Number(segment.length) || 0)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeVPorts(acad: AcadDoc) {
|
|
||||||
const entries: Record<string, any>[] = []
|
|
||||||
for (const vport of acad.vPorts ?? []) {
|
|
||||||
entries.push({
|
|
||||||
name: vport.name ?? '*Active',
|
|
||||||
center: { x: Number(vport.center?.x) || 0, y: Number(vport.center?.y) || 0 },
|
|
||||||
viewTarget: {
|
|
||||||
x: Number(vport.target?.x) || 0,
|
|
||||||
y: Number(vport.target?.y) || 0
|
|
||||||
},
|
|
||||||
viewHeight: Number(vport.viewHeight) || 0,
|
|
||||||
aspectRatio: Number(vport.aspectRatio) || 1
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeImageDefs(acad: AcadDoc) {
|
|
||||||
const entries: Record<string, any>[] = []
|
|
||||||
for (const image of acad.imageDefinitions ?? []) {
|
|
||||||
entries.push({
|
|
||||||
handle: String(image.handle ?? ''),
|
|
||||||
fileName: image.fileName ?? '栅格图像'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHeader(acad: AcadDoc) {
|
|
||||||
const header = acad.header
|
|
||||||
return {
|
|
||||||
EXTMIN: header?.modelSpaceExtMin ?? { x: 0, y: 0, z: 0 },
|
|
||||||
EXTMAX: header?.modelSpaceExtMax ?? { x: 0, y: 0, z: 0 },
|
|
||||||
INSUNITS: Number(header?.insUnits) || 0,
|
|
||||||
LTSCALE: Number(header?.lineTypeScale) || 1,
|
|
||||||
ACADVER: header?.versionString ?? ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEntity(entity: AcadEntity, state: NormalizeState): Record<string, any>[] {
|
|
||||||
if (!entity) return []
|
|
||||||
const type = String(entity.objectName ?? '').toUpperCase()
|
|
||||||
const base = commonFields(entity)
|
|
||||||
switch (type) {
|
|
||||||
case 'LINE': {
|
|
||||||
const start = entity.startPoint
|
|
||||||
const end = entity.endPoint
|
|
||||||
if (!start || !end) return []
|
|
||||||
return [{ ...base, type, startPoint: start, endPoint: end, extrusionDirection: entity.normal }]
|
|
||||||
}
|
|
||||||
case 'CIRCLE': {
|
|
||||||
if (!entity.center) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
center: entity.center,
|
|
||||||
radius: Number(entity.radius) || 0,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'ARC': {
|
|
||||||
if (!entity.center) return []
|
|
||||||
const start = Number(entity.startAngle) || 0
|
|
||||||
const end = Number(entity.endAngle) || 0
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
center: entity.center,
|
|
||||||
radius: Number(entity.radius) || 0,
|
|
||||||
startAngle: start,
|
|
||||||
endAngle: end,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'ELLIPSE': {
|
|
||||||
if (!entity.center || !entity.majorAxisEndPoint) return []
|
|
||||||
const start = entity.isFullEllipse ? 0 : Number(entity.startParameter) || 0
|
|
||||||
const end = entity.isFullEllipse ? Math.PI * 2 : Number(entity.endParameter) || 0
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
center: entity.center,
|
|
||||||
majorAxisEndPoint: entity.majorAxisEndPoint,
|
|
||||||
axisRatio: Number(entity.radiusRatio) || 1,
|
|
||||||
startAngle: start,
|
|
||||||
endAngle: end,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'LWPOLYLINE': {
|
|
||||||
const vertices = Array.isArray(entity.vertices) ? entity.vertices : []
|
|
||||||
if (vertices.length < 2) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
vertices: vertices.map((vertex: any) => ({
|
|
||||||
x: Number(vertex.location?.x) || 0,
|
|
||||||
y: Number(vertex.location?.y) || 0,
|
|
||||||
bulge: Number(vertex.bulge) || 0
|
|
||||||
})),
|
|
||||||
flag: entity.isClosed ? 1 : 0,
|
|
||||||
constantWidth: Number(entity.constantWidth) || 0,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'POLYLINE': {
|
|
||||||
const vertices = [...(entity.vertices ?? [])]
|
|
||||||
if (vertices.length < 2) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
vertices: vertices.map((vertex: any) => ({
|
|
||||||
x: Number(vertex.location?.x) || 0,
|
|
||||||
y: Number(vertex.location?.y) || 0,
|
|
||||||
bulge: Number(vertex.bulge) || 0
|
|
||||||
})),
|
|
||||||
flag: entity.isClosed ? 1 : 0,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'SPLINE': {
|
|
||||||
const control = Array.isArray(entity.controlPoints) ? entity.controlPoints : []
|
|
||||||
const fit = Array.isArray(entity.fitPoints) ? entity.fitPoints : []
|
|
||||||
if (control.length < 2 && fit.length < 2) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
controlPoints: control,
|
|
||||||
fitPoints: fit,
|
|
||||||
degree: Number(entity.degree) || 3,
|
|
||||||
knots: Array.isArray(entity.knots) ? entity.knots : [],
|
|
||||||
weights: Array.isArray(entity.weights) && entity.weights.length ? entity.weights : undefined,
|
|
||||||
flag: entity.isClosed ? 1 : 0,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'POINT': {
|
|
||||||
if (!entity.location) return []
|
|
||||||
return [{ ...base, type, position: entity.location, extrusionDirection: entity.normal }]
|
|
||||||
}
|
|
||||||
case 'TEXT': {
|
|
||||||
return [normalizeText(entity, base)]
|
|
||||||
}
|
|
||||||
case 'MTEXT': {
|
|
||||||
if (!entity.insertPoint) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
text: entity.value ?? '',
|
|
||||||
insertionPoint: entity.insertPoint,
|
|
||||||
textHeight: Number(entity.height) || 0,
|
|
||||||
rotation: Number(entity.rotation) || 0,
|
|
||||||
rectWidth: Number(entity.rectangleWidth) || 0,
|
|
||||||
attachmentPoint: Number(entity.attachmentPoint) || 1,
|
|
||||||
lineSpacing: Number(entity.lineSpacing) || 1,
|
|
||||||
styleName: entity.style?.name
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'INSERT': {
|
|
||||||
const block = entity.block
|
|
||||||
if (!block) return []
|
|
||||||
const attribs: Record<string, any>[] = []
|
|
||||||
for (const attribute of entity.attributes ?? []) {
|
|
||||||
if (!attribute || attribute.isInvisible) continue
|
|
||||||
if ((Number(attribute.flags) & 1) === 1) continue
|
|
||||||
attribs.push(normalizeText(attribute, commonFields(attribute), 'ATTRIB'))
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
name: block.name,
|
|
||||||
insertionPoint: entity.insertPoint,
|
|
||||||
rotation: Number(entity.rotation) || 0,
|
|
||||||
xScale: Number(entity.xScale) || 1,
|
|
||||||
yScale: Number(entity.yScale) || 1,
|
|
||||||
zScale: Number(entity.zScale) || 1,
|
|
||||||
rowCount: Number(entity.rowCount) || 1,
|
|
||||||
columnCount: Number(entity.columnCount) || 1,
|
|
||||||
rowSpacing: Number(entity.rowSpacing) || 0,
|
|
||||||
columnSpacing: Number(entity.columnSpacing) || 0,
|
|
||||||
extrusionDirection: entity.normal,
|
|
||||||
attribs
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'DIMENSION': {
|
|
||||||
const block = entity.block
|
|
||||||
if (!block) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
name: block.name,
|
|
||||||
blockName: block.name,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'HATCH': {
|
|
||||||
return [normalizeHatch(entity, base)]
|
|
||||||
}
|
|
||||||
case 'SOLID': {
|
|
||||||
if (!entity.firstCorner || !entity.secondCorner || !entity.thirdCorner) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
corner1: entity.firstCorner,
|
|
||||||
corner2: entity.secondCorner,
|
|
||||||
corner3: entity.thirdCorner,
|
|
||||||
corner4: entity.fourthCorner ?? entity.thirdCorner,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case '3DFACE': {
|
|
||||||
if (!entity.firstCorner || !entity.secondCorner || !entity.thirdCorner) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
corner1: entity.firstCorner,
|
|
||||||
corner2: entity.secondCorner,
|
|
||||||
corner3: entity.thirdCorner,
|
|
||||||
corner4: entity.fourthCorner ?? entity.thirdCorner,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'WIPEOUT': {
|
|
||||||
const points = wipeoutPoints(entity)
|
|
||||||
if (points.length < 6) return []
|
|
||||||
return [{ ...base, type, clippingBoundaryPath: points }]
|
|
||||||
}
|
|
||||||
case 'IMAGE': {
|
|
||||||
if (!entity.insertPoint) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
position: entity.insertPoint,
|
|
||||||
uPixel: entity.uVector,
|
|
||||||
vPixel: entity.vVector,
|
|
||||||
imageSize: entity.size,
|
|
||||||
imageDefHandle: entity.definition?.handle == null ? '' : String(entity.definition.handle)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'OLE2FRAME': {
|
|
||||||
if (!entity.upperLeftCorner || !entity.lowerRightCorner) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
corner1: entity.upperLeftCorner,
|
|
||||||
corner2: entity.lowerRightCorner,
|
|
||||||
label: entity.sourceApplication || 'OLE 对象'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'VIEWPORT': {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
width: Number(entity.width) || 0,
|
|
||||||
height: Number(entity.height) || 0,
|
|
||||||
viewHeight: Number(entity.viewHeight) || 0,
|
|
||||||
viewportCenter: entity.center,
|
|
||||||
displayCenter: entity.viewCenter,
|
|
||||||
viewTwistAngle: Number(entity.twistAngle) || 0,
|
|
||||||
statusBitFlags: Number(entity.status) || 0,
|
|
||||||
isVisible: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
case 'ACAD_PROXY_ENTITY': {
|
|
||||||
return normalizeProxyGeometries(entity, base, state)
|
|
||||||
}
|
|
||||||
case 'ATTDEF':
|
|
||||||
return []
|
|
||||||
default: {
|
|
||||||
if (entity.proxyGeometries?.length) {
|
|
||||||
return normalizeProxyGeometries(entity, base, state)
|
|
||||||
}
|
|
||||||
if (DYNAMIC_BLOCK_TYPES.has(type)) {
|
|
||||||
state.unknown++
|
|
||||||
} else if (type) {
|
|
||||||
state.unknown++
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function commonFields(entity: AcadEntity) {
|
|
||||||
const color = entity.color
|
|
||||||
let colorIndex = 256
|
|
||||||
let trueColor: number | undefined
|
|
||||||
if (color?.isByBlock) {
|
|
||||||
colorIndex = 0
|
|
||||||
} else if (color?.isByLayer) {
|
|
||||||
colorIndex = 256
|
|
||||||
} else if (color?.isTrueColor) {
|
|
||||||
colorIndex = 256
|
|
||||||
trueColor = Number(color.trueColor) >>> 0
|
|
||||||
} else if (Number.isFinite(color?.index)) {
|
|
||||||
colorIndex = Number(color.index)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
layer: entity.layer?.name ?? '0',
|
|
||||||
colorIndex,
|
|
||||||
color: trueColor,
|
|
||||||
lineweight: Number(entity.lineWeight),
|
|
||||||
lineType: entity.lineType?.name,
|
|
||||||
lineTypeScale: Number(entity.lineTypeScale) || 1,
|
|
||||||
isVisible: !entity.isInvisible
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeText(entity: AcadEntity, base: Record<string, any>, type = 'TEXT') {
|
|
||||||
const horizontal = Number(entity.horizontalAlignment) || 0
|
|
||||||
const vertical = Number(entity.verticalAlignment) || 0
|
|
||||||
const hasAlignment = horizontal !== 0 || vertical !== 0
|
|
||||||
const halign = horizontal === 2 ? 2 : horizontal === 1 || horizontal === 4 || horizontal === 3 || horizontal === 5 ? 1 : 0
|
|
||||||
const start = entity.insertPoint ?? { x: 0, y: 0, z: 0 }
|
|
||||||
const end = hasAlignment ? entity.alignmentPoint ?? start : start
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
type,
|
|
||||||
text: entity.value ?? '',
|
|
||||||
startPoint: start,
|
|
||||||
endPoint: end,
|
|
||||||
textHeight: Number(entity.height) || 0,
|
|
||||||
rotation: Number(entity.rotation) || 0,
|
|
||||||
xScale: Number(entity.widthFactor) || 1,
|
|
||||||
halign,
|
|
||||||
valign: vertical,
|
|
||||||
generationFlag: Number(entity.mirror) || 0,
|
|
||||||
flags: Number(entity.flags) || 0,
|
|
||||||
extrusionDirection: entity.normal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHatch(entity: AcadEntity, base: Record<string, any>) {
|
|
||||||
const paths: Record<string, any>[] = []
|
|
||||||
for (const path of entity.paths ?? []) {
|
|
||||||
let points: any[] = []
|
|
||||||
try {
|
|
||||||
points = path.getPoints(48) ?? []
|
|
||||||
} catch {
|
|
||||||
points = []
|
|
||||||
}
|
|
||||||
if (points.length < 2) continue
|
|
||||||
paths.push({
|
|
||||||
vertices: points.map((point: any) => ({
|
|
||||||
x: Number(point.x) || 0,
|
|
||||||
y: Number(point.y) || 0,
|
|
||||||
bulge: 0
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const patternAngle = Number(entity.patternAngle) || 0
|
|
||||||
const patternScale = Number(entity.patternScale) || 1
|
|
||||||
const definitionLines = (entity.pattern?.lines ?? []).map((line: any) => ({
|
|
||||||
offset: { x: Number(line.offset?.x) || 0, y: Number(line.offset?.y) || 0 }
|
|
||||||
}))
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
type: 'HATCH',
|
|
||||||
boundaryPaths: paths,
|
|
||||||
solidFill: entity.isSolid ? 1 : 0,
|
|
||||||
patternName: entity.pattern?.name ?? '',
|
|
||||||
patternAngle,
|
|
||||||
patternScale,
|
|
||||||
definitionLines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function wipeoutPoints(entity: AcadEntity) {
|
|
||||||
const vertices = entity.clipBoundaryVertices ?? []
|
|
||||||
if (!vertices.length) return []
|
|
||||||
const origin = entity.insertPoint ?? { x: 0, y: 0, z: 0 }
|
|
||||||
const u = entity.uVector ?? { x: 1, y: 0, z: 0 }
|
|
||||||
const v = entity.vVector ?? { x: 0, y: 1, z: 0 }
|
|
||||||
const points: number[] = []
|
|
||||||
for (const vertex of vertices) {
|
|
||||||
points.push(
|
|
||||||
(Number(origin.x) || 0) + (Number(u.x) || 0) * (Number(vertex.x) || 0) + (Number(v.x) || 0) * (Number(vertex.y) || 0),
|
|
||||||
(Number(origin.y) || 0) + (Number(u.y) || 0) * (Number(vertex.x) || 0) + (Number(v.y) || 0) * (Number(vertex.y) || 0)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return points
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProxyGeometries(
|
|
||||||
entity: AcadEntity,
|
|
||||||
base: Record<string, any>,
|
|
||||||
state: NormalizeState
|
|
||||||
): Record<string, any>[] {
|
|
||||||
const geometries = entity.proxyGeometries ?? []
|
|
||||||
if (!geometries.length) return []
|
|
||||||
const out: Record<string, any>[] = []
|
|
||||||
const stack: Mat[] = []
|
|
||||||
let current: Mat = IDENTITY
|
|
||||||
const add = (shape: Record<string, any>) => {
|
|
||||||
out.push({ ...base, ...shape, proxy: true })
|
|
||||||
}
|
|
||||||
for (const geometry of geometries) {
|
|
||||||
const type = Number(geometry?.graphicsType) || 0
|
|
||||||
switch (type) {
|
|
||||||
case GRAPHICS.Circle: {
|
|
||||||
const center = applyMat(current, Number(geometry.center?.x) || 0, Number(geometry.center?.y) || 0)
|
|
||||||
add({
|
|
||||||
type: 'CIRCLE',
|
|
||||||
center: { x: center[0], y: center[1] },
|
|
||||||
radius: Math.abs(Number(geometry.radius) || 0) * matScaleAvg(current)
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case GRAPHICS.CirclePt3: {
|
|
||||||
const circle = circleFrom3(geometry.point1, geometry.point2, geometry.point3)
|
|
||||||
if (!circle) break
|
|
||||||
const center = applyMat(current, circle.cx, circle.cy)
|
|
||||||
add({
|
|
||||||
type: 'CIRCLE',
|
|
||||||
center: { x: center[0], y: center[1] },
|
|
||||||
radius: circle.r * matScaleAvg(current)
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case GRAPHICS.CircularArc: {
|
|
||||||
const center = applyMat(current, Number(geometry.center?.x) || 0, Number(geometry.center?.y) || 0)
|
|
||||||
const start = Math.atan2(Number(geometry.startVectorDirection?.y) || 0, Number(geometry.startVectorDirection?.x) || 1)
|
|
||||||
const rotation = Math.atan2(current[1], current[0])
|
|
||||||
const sweep = Math.abs(Number(geometry.sweepAngle) || 0) * (Number(geometry.arcType) === 2 ? -1 : 1)
|
|
||||||
add({
|
|
||||||
type: 'ARC',
|
|
||||||
center: { x: center[0], y: center[1] },
|
|
||||||
radius: Math.abs(Number(geometry.radius) || 0) * matScaleAvg(current),
|
|
||||||
startAngle: start + rotation,
|
|
||||||
endAngle: start + rotation + sweep
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case GRAPHICS.CircularArc3Pt: {
|
|
||||||
const circle = circleFrom3(geometry.point1, geometry.point2, geometry.point3)
|
|
||||||
if (!circle) break
|
|
||||||
const center = applyMat(current, circle.cx, circle.cy)
|
|
||||||
const start = Math.atan2(Number(geometry.point1?.y) - circle.cy, Number(geometry.point1?.x) - circle.cx)
|
|
||||||
const mid = Math.atan2(Number(geometry.point2?.y) - circle.cy, Number(geometry.point2?.x) - circle.cx)
|
|
||||||
const end = Math.atan2(Number(geometry.point3?.y) - circle.cy, Number(geometry.point3?.x) - circle.cx)
|
|
||||||
const rotation = Math.atan2(current[1], current[0])
|
|
||||||
add({
|
|
||||||
type: 'ARC',
|
|
||||||
center: { x: center[0], y: center[1] },
|
|
||||||
radius: circle.r * matScaleAvg(current),
|
|
||||||
startAngle: start + rotation,
|
|
||||||
endAngle: start + rotation + sweepThrough(start, end, mid, Number(geometry.arcType) === 2)
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case GRAPHICS.Polyline:
|
|
||||||
case GRAPHICS.Polygon:
|
|
||||||
case GRAPHICS.PolylineWithNormal: {
|
|
||||||
const points = transformPoints(current, geometry.points)
|
|
||||||
if (points.length >= 4) {
|
|
||||||
add({
|
|
||||||
type: 'LWPOLYLINE',
|
|
||||||
vertices: pointsToVertices(points),
|
|
||||||
flag: type === GRAPHICS.Polygon ? 1 : 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case GRAPHICS.Mesh: {
|
|
||||||
const rows = Math.max(0, Number(geometry.rowCount) || 0)
|
|
||||||
const columns = Math.max(0, Number(geometry.columnCount) || 0)
|
|
||||||
const vertices = geometry.vertices ?? []
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const points: number[] = []
|
|
||||||
for (let col = 0; col < columns; col++) {
|
|
||||||
const vertex = vertices[row * columns + col]
|
|
||||||
if (!vertex) continue
|
|
||||||
const point = applyMat(current, Number(vertex.x) || 0, Number(vertex.y) || 0)
|
|
||||||
points.push(point[0], point[1])
|
|
||||||
}
|
|
||||||
if (points.length >= 4) {
|
|
||||||
add({ type: 'LWPOLYLINE', vertices: pointsToVertices(points), flag: 0 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let col = 0; col < columns; col++) {
|
|
||||||
const points: number[] = []
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const vertex = vertices[row * columns + col]
|
|
||||||
if (!vertex) continue
|
|
||||||
const point = applyMat(current, Number(vertex.x) || 0, Number(vertex.y) || 0)
|
|
||||||
points.push(point[0], point[1])
|
|
||||||
}
|
|
||||||
if (points.length >= 4) {
|
|
||||||
add({ type: 'LWPOLYLINE', vertices: pointsToVertices(points), flag: 0 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case GRAPHICS.Shell: {
|
|
||||||
const vertices = geometry.vertices ?? []
|
|
||||||
for (const face of geometry.faces ?? []) {
|
|
||||||
const points: number[] = []
|
|
||||||
for (const index of face) {
|
|
||||||
const vertex = vertices[Math.abs(Number(index)) - 1] ?? vertices[Number(index)]
|
|
||||||
if (!vertex) continue
|
|
||||||
const point = applyMat(current, Number(vertex.x) || 0, Number(vertex.y) || 0)
|
|
||||||
points.push(point[0], point[1])
|
|
||||||
}
|
|
||||||
if (points.length >= 6) {
|
|
||||||
add({ type: 'LWPOLYLINE', vertices: pointsToVertices(points), flag: 1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case GRAPHICS.XLine:
|
|
||||||
case GRAPHICS.Ray: {
|
|
||||||
const p1 = geometry.point1 ?? geometry.constructionLinePoint
|
|
||||||
const p2 = geometry.point2
|
|
||||||
if (!p1 || !p2) break
|
|
||||||
const a = applyMat(current, Number(p1.x) || 0, Number(p1.y) || 0)
|
|
||||||
const b = applyMat(current, Number(p2.x) || 0, Number(p2.y) || 0)
|
|
||||||
const dx = b[0] - a[0]
|
|
||||||
const dy = b[1] - a[1]
|
|
||||||
const len = Math.hypot(dx, dy) || 1
|
|
||||||
const ux = dx / len
|
|
||||||
const uy = dy / len
|
|
||||||
const length = 1e6
|
|
||||||
const points =
|
|
||||||
type === GRAPHICS.XLine
|
|
||||||
? [a[0] - ux * length, a[1] - uy * length, a[0] + ux * length, a[1] + uy * length]
|
|
||||||
: [a[0], a[1], a[0] + ux * length, a[1] + uy * length]
|
|
||||||
add({
|
|
||||||
type: type === GRAPHICS.XLine ? 'XLINE' : 'RAY',
|
|
||||||
firstPoint: { x: points[0], y: points[1] },
|
|
||||||
unitDirection: { x: ux, y: uy }
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case GRAPHICS.PushModelTransform:
|
|
||||||
case GRAPHICS.PushModelTransform2: {
|
|
||||||
stack.push(current)
|
|
||||||
current = matMul(current, matrixToMat(geometry.matrix))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case GRAPHICS.PopModelTransform: {
|
|
||||||
current = stack.pop() ?? IDENTITY
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!out.length && !entity.proxyGeometries?.length) state.unknown++
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function matrixToMat(matrix: any): Mat {
|
|
||||||
return [
|
|
||||||
Number(matrix?.m00) || 0,
|
|
||||||
Number(matrix?.m10) || 0,
|
|
||||||
Number(matrix?.m01) || 0,
|
|
||||||
Number(matrix?.m11) || 0,
|
|
||||||
Number(matrix?.m03) || 0,
|
|
||||||
Number(matrix?.m13) || 0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformPoints(mat: Mat, points: any[] = []) {
|
|
||||||
const out: number[] = []
|
|
||||||
for (const point of points) {
|
|
||||||
const mapped = applyMat(mat, Number(point?.x) || 0, Number(point?.y) || 0)
|
|
||||||
out.push(mapped[0], mapped[1])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointsToVertices(points: number[]) {
|
|
||||||
const vertices: Record<string, number>[] = []
|
|
||||||
for (let index = 0; index + 1 < points.length; index += 2) {
|
|
||||||
vertices.push({ x: points[index], y: points[index + 1], bulge: 0 })
|
|
||||||
}
|
|
||||||
return vertices
|
|
||||||
}
|
|
||||||
|
|
||||||
function circleFrom3(p1: any, p2: any, p3: any) {
|
|
||||||
if (!p1 || !p2 || !p3) return null
|
|
||||||
const ax = Number(p1.x) || 0
|
|
||||||
const ay = Number(p1.y) || 0
|
|
||||||
const bx = Number(p2.x) || 0
|
|
||||||
const by = Number(p2.y) || 0
|
|
||||||
const cx = Number(p3.x) || 0
|
|
||||||
const cy = Number(p3.y) || 0
|
|
||||||
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
|
|
||||||
if (Math.abs(d) < 1e-12) return null
|
|
||||||
const a2 = ax * ax + ay * ay
|
|
||||||
const b2 = bx * bx + by * by
|
|
||||||
const c2 = cx * cx + cy * cy
|
|
||||||
const ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d
|
|
||||||
const uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d
|
|
||||||
return { cx: ux, cy: uy, r: Math.hypot(ux - ax, uy - ay) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAngle(value: number) {
|
|
||||||
const tau = Math.PI * 2
|
|
||||||
return ((value % tau) + tau) % tau
|
|
||||||
}
|
|
||||||
|
|
||||||
function sweepThrough(start: number, end: number, mid: number, clockwise: boolean) {
|
|
||||||
if (clockwise) {
|
|
||||||
const full = Math.PI * 2
|
|
||||||
const s = normalizeAngle(start - end)
|
|
||||||
const m = normalizeAngle(start - mid)
|
|
||||||
return m <= s ? -s : -(s + full)
|
|
||||||
}
|
|
||||||
const s = normalizeAngle(end - start)
|
|
||||||
const m = normalizeAngle(mid - start)
|
|
||||||
return m <= s ? s : s - Math.PI * 2
|
|
||||||
}
|
|
||||||
-1593
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
|||||||
import type { Theme } from '../core/theme'
|
|
||||||
|
|
||||||
export type ColorSpec = { kind: 'aci'; value: number } | { kind: 'rgb'; value: number }
|
|
||||||
|
|
||||||
export const ACI_COLORS: readonly number[] = [
|
|
||||||
0, 16711680, 16776960, 65280, 65535, 255, 16711935, 16777215, 8421504, 12632256, 16711680, 16744319,
|
|
||||||
13369344, 13395558, 10027008, 10046540, 8323072, 8339263, 4980736, 4990502, 16727808, 16752511,
|
|
||||||
13382400, 13401958, 10036736, 10051404, 8331008, 8343359, 4985600, 4992806, 16744192, 16760703,
|
|
||||||
13395456, 13408614, 10046464, 10056268, 8339200, 8347455, 4990464, 4995366, 16760576, 16768895,
|
|
||||||
13408512, 13415014, 10056192, 10061132, 8347392, 8351551, 4995328, 4997670, 16776960, 16777087,
|
|
||||||
13421568, 13421670, 10000384, 10000460, 8355584, 8355647, 5000192, 5000230, 12582656, 14679935,
|
|
||||||
10079232, 11717734, 7510016, 8755276, 6258432, 7307071, 3755008, 4344870, 8388352, 12582783,
|
|
||||||
6736896, 10079334, 5019648, 7510092, 4161280, 6258495, 2509824, 3755046, 4194048, 10485631,
|
|
||||||
3394560, 8375398, 2529280, 6264908, 2064128, 5209919, 1264640, 3099686, 65280, 8388479, 52224,
|
|
||||||
6736998, 38912, 5019724, 32512, 4161343, 19456, 2509862, 65343, 8388511, 52275, 6737023, 38950,
|
|
||||||
5019743, 32543, 4161359, 19475, 2509871, 65407, 8388543, 52326, 6737049, 38988, 5019762, 32575,
|
|
||||||
4161375, 19494, 2509881, 65471, 8388575, 52377, 6737074, 39026, 5019781, 32607, 4161391, 19513,
|
|
||||||
2509890, 65535, 8388607, 52428, 6737100, 39064, 5019800, 32639, 4161407, 19532, 2509900, 49151,
|
|
||||||
8380415, 39372, 6730444, 29336, 5014936, 24447, 4157311, 14668, 2507340, 32767, 8372223, 26316,
|
|
||||||
6724044, 19608, 5010072, 16255, 4153215, 9804, 2505036, 16383, 8364031, 13260, 6717388, 9880,
|
|
||||||
5005208, 8063, 4149119, 4940, 2502476, 255, 8355839, 204, 6710988, 152, 5000344, 127, 4145023, 76,
|
|
||||||
2500172, 4129023, 10452991, 3342540, 8349388, 2490520, 6245528, 2031743, 5193599, 1245260, 3089996,
|
|
||||||
8323327, 12550143, 6684876, 10053324, 4980888, 7490712, 4128895, 6242175, 2490444, 3745356,
|
|
||||||
12517631, 14647295, 10027212, 11691724, 7471256, 8735896, 6226047, 7290751, 3735628, 4335180,
|
|
||||||
16711935, 16744447, 13369548, 13395660, 9961624, 9981080, 8323199, 8339327, 4980812, 4990540,
|
|
||||||
16711871, 16744415, 13369497, 13395634, 9961586, 9981061, 8323167, 8339311, 4980793, 4990530,
|
|
||||||
16711807, 16744383, 13369446, 13395609, 9961548, 9981042, 8323135, 8339295, 4980774, 4990521,
|
|
||||||
16711743, 16744351, 13369395, 13395583, 9961510, 9981023, 8323103, 8339279, 4980755, 4990511,
|
|
||||||
3355443, 5987163, 8684676, 11382189, 14079702, 16777215, 0
|
|
||||||
]
|
|
||||||
|
|
||||||
export function rgbFromInt(value: number): [number, number, number] {
|
|
||||||
return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rgbToHex(r: number, g: number, b: number): string {
|
|
||||||
return `#${((1 << 24) | (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b))
|
|
||||||
.toString(16)
|
|
||||||
.slice(1)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cssColor(spec: ColorSpec, paper: boolean, theme: Theme): string {
|
|
||||||
if (spec.kind === 'aci') {
|
|
||||||
const index = Math.max(0, Math.min(255, Math.round(spec.value)))
|
|
||||||
if (index === 7) return paper ? '#111318' : theme === 'dark' ? '#f4f7fb' : '#111318'
|
|
||||||
if (index === 0 || index === 256) return paper ? '#111318' : theme === 'dark' ? '#f4f7fb' : '#111318'
|
|
||||||
const rgb = ACI_COLORS[index] ?? 0xffffff
|
|
||||||
const [r, g, b] = rgbFromInt(rgb)
|
|
||||||
return rgbToCss(adjust(r, g, b, paper ? false : theme === 'dark'))
|
|
||||||
}
|
|
||||||
const [r, g, b] = rgbFromInt(spec.value)
|
|
||||||
return rgbToCss(adjustTrueColor(r, g, b, paper ? false : theme === 'dark'))
|
|
||||||
}
|
|
||||||
|
|
||||||
function rgbToCss([r, g, b]: [number, number, number]): string {
|
|
||||||
return `rgb(${r},${g},${b})`
|
|
||||||
}
|
|
||||||
|
|
||||||
function adjust(r: number, g: number, b: number, dark: boolean): [number, number, number] {
|
|
||||||
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
||||||
if (dark) {
|
|
||||||
if (lum < 0.16) return mix(r, g, b, 255, 0.58)
|
|
||||||
if (lum < 0.28) return mix(r, g, b, 255, 0.26)
|
|
||||||
} else {
|
|
||||||
if (lum > 0.9) return mix(r, g, b, 0, 0.62)
|
|
||||||
if (lum > 0.8) return mix(r, g, b, 0, 0.22)
|
|
||||||
}
|
|
||||||
return [r, g, b]
|
|
||||||
}
|
|
||||||
|
|
||||||
function adjustTrueColor(r: number, g: number, b: number, dark: boolean): [number, number, number] {
|
|
||||||
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
||||||
if (dark && lum < 0.1) return mix(r, g, b, 255, 0.72)
|
|
||||||
if (!dark && lum > 0.94) return mix(r, g, b, 0, 0.55)
|
|
||||||
return [r, g, b]
|
|
||||||
}
|
|
||||||
|
|
||||||
function mix(r: number, g: number, b: number, target: number, amount: number): [number, number, number] {
|
|
||||||
return [
|
|
||||||
Math.round(r + (target - r) * amount),
|
|
||||||
Math.round(g + (target - g) * amount),
|
|
||||||
Math.round(b + (target - b) * amount)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cssForAci(index: number, paper: boolean, theme: Theme): string {
|
|
||||||
return cssColor({ kind: 'aci', value: index }, paper, theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function luminanceOfRgb(value: number): number {
|
|
||||||
const [r, g, b] = rgbFromInt(value)
|
|
||||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { buildAcadDocument } from './acadAdapter'
|
|
||||||
import { slimDocument } from './slim'
|
|
||||||
import type { CadDocument } from './types'
|
|
||||||
|
|
||||||
interface WorkerContext {
|
|
||||||
onmessage: ((event: MessageEvent) => void) | null
|
|
||||||
postMessage: (message: unknown, transfer?: Transferable[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadMessage {
|
|
||||||
type: 'load'
|
|
||||||
file: File
|
|
||||||
name: string
|
|
||||||
size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type InMessage = { type: 'init' } | LoadMessage
|
|
||||||
|
|
||||||
const ctx = self as unknown as WorkerContext
|
|
||||||
|
|
||||||
let busy = false
|
|
||||||
|
|
||||||
function post(message: unknown): void {
|
|
||||||
ctx.postMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLoad(message: LoadMessage): Promise<void> {
|
|
||||||
if (busy) {
|
|
||||||
post({ type: 'error', code: 'busy', message: '正在解析其他图纸,请稍候。' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
busy = true
|
|
||||||
const started = performance.now()
|
|
||||||
try {
|
|
||||||
post({ type: 'progress', message: '正在读取文件…' })
|
|
||||||
const readStart = performance.now()
|
|
||||||
const buffer = await message.file.arrayBuffer()
|
|
||||||
const readMs = performance.now() - readStart
|
|
||||||
post({ type: 'progress', message: '正在解析 DWG 图纸…' })
|
|
||||||
const buildStart = performance.now()
|
|
||||||
const doc = buildAcadDocument(buffer, message.name, message.size)
|
|
||||||
const built = slimDocument(doc)
|
|
||||||
const buildMs = performance.now() - buildStart
|
|
||||||
post({
|
|
||||||
type: 'done',
|
|
||||||
doc: built,
|
|
||||||
timings: {
|
|
||||||
readMs,
|
|
||||||
parseMs: buildMs,
|
|
||||||
buildMs,
|
|
||||||
totalMs: performance.now() - started
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error)
|
|
||||||
post({ type: 'error', code: 'parse', message: detail })
|
|
||||||
} finally {
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.onmessage = (event: MessageEvent) => {
|
|
||||||
const message = event.data as InMessage
|
|
||||||
if (!message) return
|
|
||||||
if (message.type === 'init') {
|
|
||||||
post({ type: 'ready' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (message.type === 'load') {
|
|
||||||
void handleLoad(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import './polyfills'
|
||||||
|
import {
|
||||||
|
CadViewer,
|
||||||
|
type CadViewerLoadResult,
|
||||||
|
type CadDocument as FlyCadDocument
|
||||||
|
} from '@flyfish-dev/cad-viewer'
|
||||||
|
import '@flyfish-dev/cad-viewer/styles.css'
|
||||||
|
import { clear, h, icon, iconButton } from '../core/dom'
|
||||||
|
import { formatBytes } from '../core/files'
|
||||||
|
import { getTheme, onThemeChange, toggleTheme } from '../core/theme'
|
||||||
|
import { toast } from '../core/toast'
|
||||||
|
import type { ViewerHost } from '../core/viewerHost'
|
||||||
|
import { showAbout } from '../ui/about'
|
||||||
|
|
||||||
|
interface Callbacks {
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayerRow {
|
||||||
|
name: string
|
||||||
|
refs: Array<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FlyfishCadViewer {
|
||||||
|
private host: ViewerHost
|
||||||
|
private file: File
|
||||||
|
private callbacks: Callbacks
|
||||||
|
private viewer: CadViewer
|
||||||
|
private container: HTMLElement
|
||||||
|
private tools: HTMLElement
|
||||||
|
private sheetBackdrop: HTMLElement
|
||||||
|
private sheetList: HTMLElement
|
||||||
|
private themeBtn!: HTMLButtonElement
|
||||||
|
private fullscreenBtn!: HTMLButtonElement
|
||||||
|
private result: CadViewerLoadResult | null = null
|
||||||
|
private themeUnsub: (() => void) | null = null
|
||||||
|
private resizeObserver: ResizeObserver | null = null
|
||||||
|
private destroyed = false
|
||||||
|
|
||||||
|
constructor(host: ViewerHost, file: File, callbacks: Callbacks) {
|
||||||
|
this.host = host
|
||||||
|
this.file = file
|
||||||
|
this.callbacks = callbacks
|
||||||
|
this.container = h('div', { class: 'flyfish-cad' })
|
||||||
|
host.stage.appendChild(this.container)
|
||||||
|
this.host.setFileInfo(file.name, 'DWG 图纸', 'DWG', 'cad', formatBytes(file.size))
|
||||||
|
this.host.setSubbar(null)
|
||||||
|
this.host.setStatus('正在加载 DWG 引擎…')
|
||||||
|
|
||||||
|
this.viewer = new CadViewer({
|
||||||
|
container: this.container,
|
||||||
|
renderer: 'auto',
|
||||||
|
wasmPath: new URL('wasm/', document.baseURI).href,
|
||||||
|
workerUrl: new URL('wasm/dwg-worker.js', document.baseURI).href,
|
||||||
|
dwfWasmUrl: new URL('wasm/dwfv-render.wasm', document.baseURI).href,
|
||||||
|
useWorker: true,
|
||||||
|
workerTimeoutMs: 120000,
|
||||||
|
autoFit: true,
|
||||||
|
canvasOptions: {
|
||||||
|
fitMode: 'auto',
|
||||||
|
background: '#0d1117',
|
||||||
|
foreground: '#f4f7fb',
|
||||||
|
contrastMode: 'adaptive',
|
||||||
|
enableSpatialIndex: true,
|
||||||
|
spatialIndexCellCount: 96,
|
||||||
|
maxCurveSegments: 72,
|
||||||
|
maxVisibleTextLabels: 2400
|
||||||
|
},
|
||||||
|
onLoadProgress: (progress) => {
|
||||||
|
this.host.setLoaderMessage(progress.message || '正在解析 DWG 图纸…', this.file.name)
|
||||||
|
},
|
||||||
|
onViewChange: () => this.updateStatus(),
|
||||||
|
onError: (error) => {
|
||||||
|
this.host.setStatus(`DWG 错误:${error.message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.tools = this.buildTools()
|
||||||
|
this.sheetBackdrop = h('div', { class: 'sheet-backdrop' })
|
||||||
|
this.sheetList = h('div', { class: 'sheet-list' })
|
||||||
|
this.sheetBackdrop.appendChild(this.buildSheet())
|
||||||
|
host.stage.append(this.tools, this.sheetBackdrop)
|
||||||
|
this.buildHeaderActions()
|
||||||
|
this.applyTheme()
|
||||||
|
this.updateThemeButton()
|
||||||
|
this.themeUnsub = onThemeChange(() => {
|
||||||
|
this.applyTheme()
|
||||||
|
this.updateThemeButton()
|
||||||
|
})
|
||||||
|
this.resizeObserver = new ResizeObserver(() => this.viewer.resize())
|
||||||
|
this.resizeObserver.observe(this.host.stage)
|
||||||
|
document.addEventListener('fullscreenchange', this.onFullscreenChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.host.showLoader('正在加载 DWG 图纸…', this.file.name)
|
||||||
|
try {
|
||||||
|
const result = await this.viewer.loadFile(this.file)
|
||||||
|
this.result = result
|
||||||
|
this.host.hideLoader()
|
||||||
|
this.host.setFileInfo(
|
||||||
|
this.file.name,
|
||||||
|
'DWG 图纸',
|
||||||
|
'DWG',
|
||||||
|
'cad',
|
||||||
|
`${result.document.entities.length.toLocaleString()} 个实体 · ${formatBytes(this.file.size)}`
|
||||||
|
)
|
||||||
|
this.viewer.fit('auto')
|
||||||
|
this.applyTheme()
|
||||||
|
this.updateStatus()
|
||||||
|
if (result.warnings.length) toast(result.warnings[0], 'info', 6000)
|
||||||
|
this.refreshSheetRows()
|
||||||
|
} catch (error) {
|
||||||
|
this.host.hideLoader()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHeaderActions(): void {
|
||||||
|
const downloadBtn = iconButton('download', '保存当前视图为 PNG', () => void this.exportPng())
|
||||||
|
const fullscreenBtn = iconButton('maximize', '全屏', () => void this.toggleFullscreen())
|
||||||
|
fullscreenBtn.dataset.action = 'fullscreen'
|
||||||
|
const aboutBtn = iconButton('info', '关于', () => showAbout())
|
||||||
|
this.host.actions.append(downloadBtn, fullscreenBtn, aboutBtn)
|
||||||
|
this.fullscreenBtn = fullscreenBtn
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTools(): HTMLElement {
|
||||||
|
const tools = h('div', { class: 'cad-tools' })
|
||||||
|
const add = (name: string, label: string, action: string, onClick: () => void) => {
|
||||||
|
const btn = iconButton(name, label, (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
onClick()
|
||||||
|
})
|
||||||
|
btn.dataset.action = action
|
||||||
|
tools.appendChild(btn)
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
add('plus', '放大', 'zoom-in', () => {
|
||||||
|
this.viewer.zoomIn()
|
||||||
|
this.updateStatus()
|
||||||
|
})
|
||||||
|
add('minus', '缩小', 'zoom-out', () => {
|
||||||
|
this.viewer.zoomOut()
|
||||||
|
this.updateStatus()
|
||||||
|
})
|
||||||
|
add('fit', '适配全图', 'fit', () => {
|
||||||
|
this.viewer.fit('auto')
|
||||||
|
this.updateStatus()
|
||||||
|
})
|
||||||
|
tools.appendChild(h('div', { class: 'tool-divider' }))
|
||||||
|
add('layers', '图层', 'layers', () => this.openSheet())
|
||||||
|
this.themeBtn = add('sun', '切换背景', 'theme', () => {
|
||||||
|
toggleTheme()
|
||||||
|
this.applyTheme()
|
||||||
|
this.updateThemeButton()
|
||||||
|
})
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSheet(): HTMLElement {
|
||||||
|
const title = h('div', { class: 'sheet-title' }, h('span', { text: '图层' }))
|
||||||
|
const close = iconButton('x', '关闭', () => this.closeSheet())
|
||||||
|
const head = h('div', { class: 'sheet-head' }, title, close)
|
||||||
|
const actions = h('div', { class: 'sheet-actions' })
|
||||||
|
const showAll = h('button', { class: 'chip-btn', type: 'button', text: '全部显示' })
|
||||||
|
const hideAll = h('button', { class: 'chip-btn', type: 'button', text: '全部隐藏' })
|
||||||
|
const reset = h('button', { class: 'chip-btn', type: 'button', text: '恢复默认' })
|
||||||
|
showAll.addEventListener('click', () => this.setAllLayers(true))
|
||||||
|
hideAll.addEventListener('click', () => this.setAllLayers(false))
|
||||||
|
reset.addEventListener('click', () => {
|
||||||
|
for (const group of this.layerGroups()) {
|
||||||
|
for (const layer of group.refs) layer.isVisible = true
|
||||||
|
}
|
||||||
|
this.applyLayerChange()
|
||||||
|
this.refreshSheetRows()
|
||||||
|
})
|
||||||
|
actions.append(showAll, hideAll, reset)
|
||||||
|
this.sheetBackdrop.addEventListener('click', (event) => {
|
||||||
|
if (event.target === this.sheetBackdrop) this.closeSheet()
|
||||||
|
})
|
||||||
|
return h(
|
||||||
|
'aside',
|
||||||
|
{ class: 'sheet', role: 'dialog', 'aria-label': '图层' },
|
||||||
|
head,
|
||||||
|
actions,
|
||||||
|
this.sheetList
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private layerGroups(): LayerRow[] {
|
||||||
|
const doc = this.viewer.getDocument()
|
||||||
|
if (!doc) return []
|
||||||
|
const groups = new Map<string, LayerRow>()
|
||||||
|
for (const layer of Object.values(doc.layers)) {
|
||||||
|
const name = String(layer.name ?? '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
const key = name.toLowerCase()
|
||||||
|
const existing = groups.get(key)
|
||||||
|
if (existing) existing.refs.push(layer as unknown as Record<string, unknown>)
|
||||||
|
else groups.set(key, { name, refs: [layer as unknown as Record<string, unknown>] })
|
||||||
|
}
|
||||||
|
return [...groups.values()].sort((a, b) => {
|
||||||
|
if (a.name === '0') return -1
|
||||||
|
if (b.name === '0') return 1
|
||||||
|
return a.name.localeCompare(b.name, 'zh-Hans-CN')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshSheetRows(): void {
|
||||||
|
if (!this.sheetList) return
|
||||||
|
clear(this.sheetList)
|
||||||
|
for (const group of this.layerGroups()) {
|
||||||
|
const visible = group.refs.some((layer) => layer.isVisible !== false)
|
||||||
|
const row = h('button', {
|
||||||
|
class: `layer-row ${visible ? 'visible' : 'hidden'}`,
|
||||||
|
type: 'button',
|
||||||
|
dataset: { name: group.name.toLowerCase() }
|
||||||
|
})
|
||||||
|
const swatch = h('span', { class: 'layer-swatch' })
|
||||||
|
const first = group.refs[0] as { color?: unknown; colorIndex?: number }
|
||||||
|
swatch.style.background = layerSwatch(first.color, first.colorIndex)
|
||||||
|
const info = h(
|
||||||
|
'span',
|
||||||
|
{ class: 'layer-info' },
|
||||||
|
h('span', { class: 'layer-name', text: group.name }),
|
||||||
|
h('span', { class: 'layer-meta', text: visible ? '显示' : '隐藏' })
|
||||||
|
)
|
||||||
|
const eye = h('span', { class: 'layer-eye', html: icon(visible ? 'eye' : 'eye-off', 18) })
|
||||||
|
row.append(swatch, info, eye)
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
for (const layer of group.refs) layer.isVisible = !visible
|
||||||
|
this.applyLayerChange()
|
||||||
|
this.refreshSheetRows()
|
||||||
|
})
|
||||||
|
this.sheetList.appendChild(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAllLayers(visible: boolean): void {
|
||||||
|
for (const group of this.layerGroups()) {
|
||||||
|
for (const layer of group.refs) layer.isVisible = visible
|
||||||
|
}
|
||||||
|
this.applyLayerChange()
|
||||||
|
this.refreshSheetRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLayerChange(): void {
|
||||||
|
const doc = this.viewer.getDocument() as FlyCadDocument | undefined
|
||||||
|
if (!doc) return
|
||||||
|
const renderer = this.viewer.renderer
|
||||||
|
const state = renderer.getViewState()
|
||||||
|
renderer.setDocument(doc)
|
||||||
|
renderer.setViewState(state)
|
||||||
|
renderer.render()
|
||||||
|
this.updateStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private openSheet(): void {
|
||||||
|
this.refreshSheetRows()
|
||||||
|
this.sheetBackdrop.classList.add('open')
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeSheet(): void {
|
||||||
|
this.sheetBackdrop.classList.remove('open')
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTheme(): void {
|
||||||
|
const dark = getTheme() === 'dark'
|
||||||
|
this.container.style.background = dark ? '#0d1117' : '#f6f8fc'
|
||||||
|
this.viewer.setCanvasOptions({
|
||||||
|
background: dark ? '#0d1117' : '#f6f8fc',
|
||||||
|
foreground: dark ? '#f4f7fb' : '#111318',
|
||||||
|
contrastMode: 'adaptive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateThemeButton(): void {
|
||||||
|
if (!this.themeBtn) return
|
||||||
|
this.themeBtn.innerHTML = icon(getTheme() === 'dark' ? 'sun' : 'moon', 20)
|
||||||
|
this.themeBtn.setAttribute('title', getTheme() === 'dark' ? '切换为浅色背景' : '切换为深色背景')
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateStatus(): void {
|
||||||
|
if (this.destroyed || !this.result) return
|
||||||
|
const zoom = Math.round(this.viewer.getZoomPercent())
|
||||||
|
const backend = (this.viewer.renderer as { backend?: string }).backend ?? 'canvas2d'
|
||||||
|
const entities = this.result.document.entities.length.toLocaleString()
|
||||||
|
this.host.setStatus(`${this.file.name} · ${zoom}% · ${entities} 实体 · ${backend}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private onFullscreenChange = () => {
|
||||||
|
if (!this.fullscreenBtn) return
|
||||||
|
const active = !!document.fullscreenElement
|
||||||
|
this.fullscreenBtn.innerHTML = icon(active ? 'minimize' : 'maximize', 20)
|
||||||
|
this.fullscreenBtn.setAttribute('title', active ? '退出全屏' : '全屏')
|
||||||
|
window.setTimeout(() => this.viewer.resize(), 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toggleFullscreen(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) await document.exitFullscreen?.()
|
||||||
|
else await this.host.root.requestFullscreen?.()
|
||||||
|
} catch {
|
||||||
|
toast('当前浏览器不支持全屏', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportPng(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const source = await this.viewer.captureCanvas()
|
||||||
|
const output = document.createElement('canvas')
|
||||||
|
output.width = source.width
|
||||||
|
output.height = source.height
|
||||||
|
const ctx = output.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('无法创建导出画布')
|
||||||
|
ctx.drawImage(source, 0, 0)
|
||||||
|
output.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
toast('导出失败', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const baseName = this.file.name.replace(/\.[^.]+$/, '') || 'drawing'
|
||||||
|
const candidate = `${baseName}-当前视图.png`
|
||||||
|
const bridge = (window as unknown as { AndroidViewer?: { saveBase64?: (name: string, mime: string, data: string) => void } }).AndroidViewer
|
||||||
|
if (bridge?.saveBase64) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const data = String(reader.result ?? '')
|
||||||
|
bridge.saveBase64?.(candidate, 'image/png', data.includes(',') ? data.slice(data.indexOf(',') + 1) : data)
|
||||||
|
toast('已保存到 Android 下载目录', 'success')
|
||||||
|
}
|
||||||
|
reader.onerror = () => toast('保存失败', 'error')
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = candidate
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 4000)
|
||||||
|
toast('已保存当前视图', 'success')
|
||||||
|
}, 'image/png')
|
||||||
|
} catch (error) {
|
||||||
|
toast(error instanceof Error ? error.message : '导出失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.destroyed) return
|
||||||
|
this.destroyed = true
|
||||||
|
this.resizeObserver?.disconnect()
|
||||||
|
this.themeUnsub?.()
|
||||||
|
document.removeEventListener('fullscreenchange', this.onFullscreenChange)
|
||||||
|
try {
|
||||||
|
this.viewer.destroy()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.container.remove()
|
||||||
|
this.tools.remove()
|
||||||
|
this.sheetBackdrop.remove()
|
||||||
|
this.host.destroy()
|
||||||
|
this.callbacks.onBack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerSwatch(color: unknown, colorIndex?: number): string {
|
||||||
|
if (typeof color === 'string') return color
|
||||||
|
if (typeof color === 'number' && Number.isFinite(color)) {
|
||||||
|
const value = color & 0xffffff
|
||||||
|
return `#${value.toString(16).padStart(6, '0')}`
|
||||||
|
}
|
||||||
|
if (typeof colorIndex === 'number' && colorIndex >= 1 && colorIndex <= 255) {
|
||||||
|
return `hsl(${(colorIndex * 47) % 360} 60% 55%)`
|
||||||
|
}
|
||||||
|
return '#8a94a6'
|
||||||
|
}
|
||||||
-399
@@ -1,399 +0,0 @@
|
|||||||
export type Mat = readonly [number, number, number, number, number, number]
|
|
||||||
|
|
||||||
export const IDENTITY: Mat = [1, 0, 0, 1, 0, 0]
|
|
||||||
|
|
||||||
export function matMul(a: Mat, b: Mat): Mat {
|
|
||||||
return [
|
|
||||||
a[0] * b[0] + a[2] * b[1],
|
|
||||||
a[1] * b[0] + a[3] * b[1],
|
|
||||||
a[0] * b[2] + a[2] * b[3],
|
|
||||||
a[1] * b[2] + a[3] * b[3],
|
|
||||||
a[0] * b[4] + a[2] * b[5] + a[4],
|
|
||||||
a[1] * b[4] + a[3] * b[5] + a[5]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matTranslate(tx: number, ty: number): Mat {
|
|
||||||
return [1, 0, 0, 1, tx, ty]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matScale(sx: number, sy: number): Mat {
|
|
||||||
return [sx, 0, 0, sy, 0, 0]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matRotate(rad: number): Mat {
|
|
||||||
const c = Math.cos(rad)
|
|
||||||
const s = Math.sin(rad)
|
|
||||||
return [c, s, -s, c, 0, 0]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyMat(m: Mat, x: number, y: number): [number, number] {
|
|
||||||
return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyMatVec(m: Mat, x: number, y: number): [number, number] {
|
|
||||||
return [m[0] * x + m[2] * y, m[1] * x + m[3] * y]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matDet(m: Mat): number {
|
|
||||||
return m[0] * m[3] - m[1] * m[2]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matInvert(m: Mat): Mat | null {
|
|
||||||
const det = matDet(m)
|
|
||||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-14) return null
|
|
||||||
const a = m[3] / det
|
|
||||||
const b = -m[1] / det
|
|
||||||
const c = -m[2] / det
|
|
||||||
const d = m[0] / det
|
|
||||||
return [a, b, c, d, -(a * m[4] + c * m[5]), -(b * m[4] + d * m[5])]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matScaleX(m: Mat): number {
|
|
||||||
return Math.hypot(m[0], m[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matScaleY(m: Mat): number {
|
|
||||||
return Math.hypot(m[2], m[3])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matScaleAvg(m: Mat): number {
|
|
||||||
return Math.sqrt(Math.abs(matDet(m))) || matScaleX(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSimilarity(m: Mat, eps = 1e-5): boolean {
|
|
||||||
const sx = matScaleX(m)
|
|
||||||
const sy = matScaleY(m)
|
|
||||||
if (Math.abs(sx - sy) > eps * Math.max(1, sx, sy)) return false
|
|
||||||
const dot = m[0] * m[2] + m[1] * m[3]
|
|
||||||
return Math.abs(dot) <= eps * Math.max(1, sx * sy)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BBox {
|
|
||||||
minX: number
|
|
||||||
minY: number
|
|
||||||
maxX: number
|
|
||||||
maxY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emptyBBox(): BBox {
|
|
||||||
return { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxValid(b: BBox): boolean {
|
|
||||||
return (
|
|
||||||
Number.isFinite(b.minX) &&
|
|
||||||
Number.isFinite(b.minY) &&
|
|
||||||
Number.isFinite(b.maxX) &&
|
|
||||||
Number.isFinite(b.maxY) &&
|
|
||||||
b.maxX >= b.minX &&
|
|
||||||
b.maxY >= b.minY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxAddPoint(b: BBox, x: number, y: number): BBox {
|
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return b
|
|
||||||
if (x < b.minX) b.minX = x
|
|
||||||
if (y < b.minY) b.minY = y
|
|
||||||
if (x > b.maxX) b.maxX = x
|
|
||||||
if (y > b.maxY) b.maxY = y
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxFromPoints(points: ArrayLike<number>, b: BBox = emptyBBox()): BBox {
|
|
||||||
for (let i = 0; i + 1 < points.length; i += 2) bboxAddPoint(b, points[i], points[i + 1])
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxUnion(a: BBox, b: BBox): BBox {
|
|
||||||
if (!bboxValid(a)) return { ...b }
|
|
||||||
if (!bboxValid(b)) return { ...a }
|
|
||||||
return {
|
|
||||||
minX: Math.min(a.minX, b.minX),
|
|
||||||
minY: Math.min(a.minY, b.minY),
|
|
||||||
maxX: Math.max(a.maxX, b.maxX),
|
|
||||||
maxY: Math.max(a.maxY, b.maxY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxTransform(m: Mat, b: BBox): BBox {
|
|
||||||
const out = emptyBBox()
|
|
||||||
bboxAddPoint(out, ...applyMat(m, b.minX, b.minY))
|
|
||||||
bboxAddPoint(out, ...applyMat(m, b.maxX, b.minY))
|
|
||||||
bboxAddPoint(out, ...applyMat(m, b.minX, b.maxY))
|
|
||||||
bboxAddPoint(out, ...applyMat(m, b.maxX, b.maxY))
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxExpand(b: BBox, dx: number, dy = dx): BBox {
|
|
||||||
return { minX: b.minX - dx, minY: b.minY - dy, maxX: b.maxX + dx, maxY: b.maxY + dy }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxWidth(b: BBox): number {
|
|
||||||
return b.maxX - b.minX
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxHeight(b: BBox): number {
|
|
||||||
return b.maxY - b.minY
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxCenter(b: BBox): [number, number] {
|
|
||||||
return [(b.minX + b.maxX) / 2, (b.minY + b.maxY) / 2]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bboxIntersects(a: BBox, b: BBox): boolean {
|
|
||||||
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Camera {
|
|
||||||
scale: number
|
|
||||||
cx: number
|
|
||||||
cy: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cameraMatrix(cam: Camera, width: number, height: number): Mat {
|
|
||||||
const s = cam.scale
|
|
||||||
return [s, 0, 0, -s, width / 2 - s * cam.cx, height / 2 + s * cam.cy]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function worldToScreen(
|
|
||||||
cam: Camera,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
x: number,
|
|
||||||
y: number
|
|
||||||
): [number, number] {
|
|
||||||
return [width / 2 + (x - cam.cx) * cam.scale, height / 2 - (y - cam.cy) * cam.scale]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function screenToWorld(
|
|
||||||
cam: Camera,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
sx: number,
|
|
||||||
sy: number
|
|
||||||
): [number, number] {
|
|
||||||
return [cam.cx + (sx - width / 2) / cam.scale, cam.cy + (height / 2 - sy) / cam.scale]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clamp(value: number, min: number, max: number): number {
|
|
||||||
return value < min ? min : value > max ? max : value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function arcSegmentCount(radiusScreen: number, sweep: number): number {
|
|
||||||
const r = Math.max(radiusScreen, 0.35)
|
|
||||||
const tolerance = 0.22
|
|
||||||
const ratio = clamp(1 - tolerance / r, -1, 1)
|
|
||||||
const maxAngle = 2 * Math.acos(ratio)
|
|
||||||
const count = Math.ceil(Math.abs(sweep) / Math.max(maxAngle, 0.02))
|
|
||||||
return clamp(count, 4, 1600)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sampleArc(
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
r: number,
|
|
||||||
a0: number,
|
|
||||||
sweep: number,
|
|
||||||
radiusScreen: number,
|
|
||||||
out: number[] = []
|
|
||||||
): number[] {
|
|
||||||
const n = arcSegmentCount(radiusScreen, sweep)
|
|
||||||
out.push(cx + r * Math.cos(a0), cy + r * Math.sin(a0))
|
|
||||||
for (let i = 1; i <= n; i++) {
|
|
||||||
const a = a0 + (sweep * i) / n
|
|
||||||
out.push(cx + r * Math.cos(a), cy + r * Math.sin(a))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sampleArcToPath(
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
r: number,
|
|
||||||
a0: number,
|
|
||||||
sweep: number,
|
|
||||||
radiusScreen: number,
|
|
||||||
moveTo: (x: number, y: number) => void,
|
|
||||||
lineTo: (x: number, y: number) => void
|
|
||||||
): void {
|
|
||||||
const n = arcSegmentCount(radiusScreen, sweep)
|
|
||||||
moveTo(cx + r * Math.cos(a0), cy + r * Math.sin(a0))
|
|
||||||
for (let i = 1; i <= n; i++) {
|
|
||||||
const a = a0 + (sweep * i) / n
|
|
||||||
lineTo(cx + r * Math.cos(a), cy + r * Math.sin(a))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sampleEllipse(
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
mx: number,
|
|
||||||
my: number,
|
|
||||||
ratio: number,
|
|
||||||
a0: number,
|
|
||||||
sweep: number,
|
|
||||||
radiusScreen: number,
|
|
||||||
out: number[] = []
|
|
||||||
): number[] {
|
|
||||||
const nx = -my * ratio
|
|
||||||
const ny = mx * ratio
|
|
||||||
const n = arcSegmentCount(radiusScreen, sweep)
|
|
||||||
const c0 = Math.cos(a0)
|
|
||||||
const s0 = Math.sin(a0)
|
|
||||||
out.push(cx + mx * c0 + nx * s0, cy + my * c0 + ny * s0)
|
|
||||||
for (let i = 1; i <= n; i++) {
|
|
||||||
const a = a0 + (sweep * i) / n
|
|
||||||
const c = Math.cos(a)
|
|
||||||
const s = Math.sin(a)
|
|
||||||
out.push(cx + mx * c + nx * s, cy + my * c + ny * s)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sampleEllipseToPath(
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
mx: number,
|
|
||||||
my: number,
|
|
||||||
ratio: number,
|
|
||||||
a0: number,
|
|
||||||
sweep: number,
|
|
||||||
radiusScreen: number,
|
|
||||||
moveTo: (x: number, y: number) => void,
|
|
||||||
lineTo: (x: number, y: number) => void
|
|
||||||
): void {
|
|
||||||
const nx = -my * ratio
|
|
||||||
const ny = mx * ratio
|
|
||||||
const n = arcSegmentCount(radiusScreen, sweep)
|
|
||||||
const c0 = Math.cos(a0)
|
|
||||||
const s0 = Math.sin(a0)
|
|
||||||
moveTo(cx + mx * c0 + nx * s0, cy + my * c0 + ny * s0)
|
|
||||||
for (let i = 1; i <= n; i++) {
|
|
||||||
const a = a0 + (sweep * i) / n
|
|
||||||
const c = Math.cos(a)
|
|
||||||
const s = Math.sin(a)
|
|
||||||
lineTo(cx + mx * c + nx * s, cy + my * c + ny * s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulgeArc {
|
|
||||||
cx: number
|
|
||||||
cy: number
|
|
||||||
r: number
|
|
||||||
a0: number
|
|
||||||
sweep: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bulgeToArc(x1: number, y1: number, x2: number, y2: number, bulge: number): BulgeArc | null {
|
|
||||||
if (!Number.isFinite(bulge) || Math.abs(bulge) < 1e-12) return null
|
|
||||||
const dx = x2 - x1
|
|
||||||
const dy = y2 - y1
|
|
||||||
const chord = Math.hypot(dx, dy)
|
|
||||||
if (chord < 1e-12) return null
|
|
||||||
const theta = 4 * Math.atan(bulge)
|
|
||||||
const half = Math.abs(theta) / 2
|
|
||||||
const r = chord / (2 * Math.sin(half))
|
|
||||||
if (!Number.isFinite(r) || r <= 0) return null
|
|
||||||
const mx = (x1 + x2) / 2
|
|
||||||
const my = (y1 + y2) / 2
|
|
||||||
const nx = -dy / chord
|
|
||||||
const ny = dx / chord
|
|
||||||
const d = chord / 2 / Math.tan(theta / 2)
|
|
||||||
const cx = mx + nx * d
|
|
||||||
const cy = my + ny * d
|
|
||||||
const a0 = Math.atan2(y1 - cy, x1 - cx)
|
|
||||||
return { cx, cy, r, a0, sweep: theta }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splineDomain(knots: number[], degree: number, count: number): [number, number] {
|
|
||||||
if (knots.length < count + degree + 1 || count <= degree) {
|
|
||||||
return [0, 1]
|
|
||||||
}
|
|
||||||
return [knots[degree], knots[count]]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluateSpline(
|
|
||||||
control: number[],
|
|
||||||
degree: number,
|
|
||||||
knots: number[],
|
|
||||||
weights: number[] | undefined,
|
|
||||||
t: number
|
|
||||||
): [number, number] {
|
|
||||||
const count = control.length / 2
|
|
||||||
if (count === 0) return [0, 0]
|
|
||||||
if (count <= degree || knots.length < count + degree + 1) {
|
|
||||||
const firstX = control[0]
|
|
||||||
const firstY = control[1]
|
|
||||||
return [firstX, firstY]
|
|
||||||
}
|
|
||||||
let k = degree
|
|
||||||
for (let i = degree; i < count; i++) {
|
|
||||||
if (t >= knots[i] && t <= knots[i + 1]) {
|
|
||||||
k = i
|
|
||||||
if (t < knots[i + 1]) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const pts: Array<[number, number, number]> = []
|
|
||||||
for (let j = 0; j <= degree; j++) {
|
|
||||||
const idx = k - degree + j
|
|
||||||
const w = weights && weights.length > idx ? weights[idx] : 1
|
|
||||||
pts.push([control[idx * 2] * w, control[idx * 2 + 1] * w, w])
|
|
||||||
}
|
|
||||||
for (let r = 1; r <= degree; r++) {
|
|
||||||
for (let j = degree; j >= r; j--) {
|
|
||||||
const i = k - degree + j
|
|
||||||
const denom = knots[i + degree + 1 - r] - knots[i]
|
|
||||||
const alpha = denom === 0 ? 0 : (t - knots[i]) / denom
|
|
||||||
const prev = pts[j - 1]
|
|
||||||
const cur = pts[j]
|
|
||||||
pts[j] = [
|
|
||||||
prev[0] * (1 - alpha) + cur[0] * alpha,
|
|
||||||
prev[1] * (1 - alpha) + cur[1] * alpha,
|
|
||||||
prev[2] * (1 - alpha) + cur[2] * alpha
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const result = pts[degree]
|
|
||||||
return [result[2] === 0 ? 0 : result[0] / result[2], result[2] === 0 ? 0 : result[1] / result[2]]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sampleSpline(
|
|
||||||
control: number[],
|
|
||||||
degree: number,
|
|
||||||
knots: number[],
|
|
||||||
weights: number[] | undefined,
|
|
||||||
segments: number,
|
|
||||||
out: number[] = []
|
|
||||||
): number[] {
|
|
||||||
const count = control.length / 2
|
|
||||||
if (count === 0) return out
|
|
||||||
if (count === 1) {
|
|
||||||
out.push(control[0], control[1])
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
if (degree < 1 || count <= degree || knots.length < count + degree + 1) {
|
|
||||||
for (let i = 0; i < control.length; i++) out.push(control[i])
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
const [start, end] = splineDomain(knots, degree, count)
|
|
||||||
if (!(end > start)) {
|
|
||||||
for (let i = 0; i < control.length; i++) out.push(control[i])
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
const n = clamp(Math.round(segments), 8, 2048)
|
|
||||||
for (let i = 0; i <= n; i++) {
|
|
||||||
const t = start + ((end - start) * i) / n
|
|
||||||
const p = evaluateSpline(control, degree, knots, weights, t)
|
|
||||||
out.push(p[0], p[1])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatNumber(value: number, maxDecimals = 3): string {
|
|
||||||
if (!Number.isFinite(value)) return '—'
|
|
||||||
const abs = Math.abs(value)
|
|
||||||
if (abs >= 1e7 || (abs > 0 && abs < 1e-4)) return value.toExponential(2)
|
|
||||||
const fixed = value.toFixed(maxDecimals)
|
|
||||||
return fixed.replace(/\.?0+$/, '')
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import type { CadDocument } from './types'
|
|
||||||
import { slimDocument } from './slim'
|
|
||||||
|
|
||||||
interface WorkerDoneMessage {
|
|
||||||
type: 'done'
|
|
||||||
doc: CadDocument
|
|
||||||
timings?: { readMs: number; parseMs: number; buildMs: number; totalMs: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkerMessage {
|
|
||||||
type: 'progress' | 'ready' | 'done' | 'error'
|
|
||||||
message?: string
|
|
||||||
code?: string
|
|
||||||
doc?: CadDocument
|
|
||||||
timings?: WorkerDoneMessage['timings']
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveLoad {
|
|
||||||
resolve: (doc: CadDocument) => void
|
|
||||||
reject: (error: Error) => void
|
|
||||||
onProgress?: (message: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
let worker: Worker | null = null
|
|
||||||
let workerDisabled = false
|
|
||||||
let enginePromise: Promise<void> | null = null
|
|
||||||
let engineResolve: (() => void) | null = null
|
|
||||||
let engineReject: ((error: Error) => void) | null = null
|
|
||||||
let engineTimer = 0
|
|
||||||
let activeLoad: ActiveLoad | null = null
|
|
||||||
|
|
||||||
function disableWorker(error: Error): void {
|
|
||||||
workerDisabled = true
|
|
||||||
if (engineTimer) {
|
|
||||||
window.clearTimeout(engineTimer)
|
|
||||||
engineTimer = 0
|
|
||||||
}
|
|
||||||
if (engineReject) {
|
|
||||||
engineReject(error)
|
|
||||||
engineReject = null
|
|
||||||
engineResolve = null
|
|
||||||
enginePromise = null
|
|
||||||
}
|
|
||||||
if (activeLoad) {
|
|
||||||
activeLoad.reject(error)
|
|
||||||
activeLoad = null
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
worker?.terminate()
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
worker = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWorkerMessage(event: MessageEvent): void {
|
|
||||||
const message = event.data as WorkerMessage
|
|
||||||
if (!message) return
|
|
||||||
if (message.type === 'progress') {
|
|
||||||
activeLoad?.onProgress?.(message.message ?? '')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (message.type === 'ready') {
|
|
||||||
if (engineTimer) {
|
|
||||||
window.clearTimeout(engineTimer)
|
|
||||||
engineTimer = 0
|
|
||||||
}
|
|
||||||
const resolve = engineResolve
|
|
||||||
engineResolve = null
|
|
||||||
engineReject = null
|
|
||||||
resolve?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (message.type === 'done' && message.doc) {
|
|
||||||
if (message.timings) {
|
|
||||||
console.info(
|
|
||||||
`[dwg] read ${Math.round(message.timings.readMs)}ms · parse ${Math.round(
|
|
||||||
message.timings.parseMs
|
|
||||||
)}ms · build ${Math.round(message.timings.buildMs)}ms · total ${Math.round(
|
|
||||||
message.timings.totalMs
|
|
||||||
)}ms`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const active = activeLoad
|
|
||||||
activeLoad = null
|
|
||||||
active?.resolve(message.doc)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (message.type === 'error') {
|
|
||||||
const error = new Error(message.message || 'DWG 解析失败')
|
|
||||||
if (message.code === 'engine') {
|
|
||||||
disableWorker(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const active = activeLoad
|
|
||||||
activeLoad = null
|
|
||||||
active?.reject(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureWorker(): Worker | null {
|
|
||||||
if (workerDisabled) return null
|
|
||||||
if (!worker) {
|
|
||||||
try {
|
|
||||||
worker = new Worker(new URL('./dwgWorker.ts', import.meta.url), { type: 'module' })
|
|
||||||
} catch {
|
|
||||||
workerDisabled = true
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
worker.onmessage = handleWorkerMessage
|
|
||||||
worker.onerror = () => {
|
|
||||||
disableWorker(new Error('DWG 解析 Worker 启动失败'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!enginePromise) {
|
|
||||||
enginePromise = new Promise<void>((resolve, reject) => {
|
|
||||||
engineResolve = resolve
|
|
||||||
engineReject = reject
|
|
||||||
engineTimer = window.setTimeout(() => {
|
|
||||||
disableWorker(new Error('DWG 引擎加载超时'))
|
|
||||||
}, 60000)
|
|
||||||
try {
|
|
||||||
worker!.postMessage({ type: 'init' })
|
|
||||||
} catch (error) {
|
|
||||||
disableWorker(error instanceof Error ? error : new Error(String(error)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return worker
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function preloadDwgEngine(): Promise<void> {
|
|
||||||
const instance = ensureWorker()
|
|
||||||
if (!instance || !enginePromise) return
|
|
||||||
try {
|
|
||||||
await enginePromise
|
|
||||||
} catch {
|
|
||||||
/* main-thread fallback will be used on open */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadDwg(
|
|
||||||
file: File,
|
|
||||||
onProgress?: (message: string) => void
|
|
||||||
): Promise<CadDocument> {
|
|
||||||
const instance = ensureWorker()
|
|
||||||
if (!instance || !enginePromise) {
|
|
||||||
return loadDwgOnMainThread(file, onProgress)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await enginePromise
|
|
||||||
} catch {
|
|
||||||
return loadDwgOnMainThread(file, onProgress)
|
|
||||||
}
|
|
||||||
if (workerDisabled || !worker) {
|
|
||||||
return loadDwgOnMainThread(file, onProgress)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await new Promise<CadDocument>((resolve, reject) => {
|
|
||||||
activeLoad = { resolve, reject, onProgress }
|
|
||||||
worker!.postMessage({
|
|
||||||
type: 'load',
|
|
||||||
file,
|
|
||||||
name: file.name,
|
|
||||||
size: file.size
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
const failure = error instanceof Error ? error : new Error(String(error))
|
|
||||||
disableWorker(failure)
|
|
||||||
return loadDwgOnMainThread(file, onProgress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDwgOnMainThread(
|
|
||||||
file: File,
|
|
||||||
onProgress?: (message: string) => void
|
|
||||||
): Promise<CadDocument> {
|
|
||||||
onProgress?.('正在读取文件…')
|
|
||||||
const buffer = await file.arrayBuffer()
|
|
||||||
onProgress?.('正在解析 DWG 图纸…')
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 20))
|
|
||||||
const { buildAcadDocument } = await import('./acadAdapter')
|
|
||||||
const doc = buildAcadDocument(buffer, file.name, file.size)
|
|
||||||
return slimDocument(doc)
|
|
||||||
}
|
|
||||||
@@ -1,643 +0,0 @@
|
|||||||
import type { DwgViewportEntity } from '@mlightcad/libredwg-web'
|
|
||||||
import type { Camera, Mat } from './geom'
|
|
||||||
import {
|
|
||||||
applyMat,
|
|
||||||
applyMatVec,
|
|
||||||
bboxFromPoints,
|
|
||||||
bboxHeight,
|
|
||||||
bboxIntersects,
|
|
||||||
bboxTransform,
|
|
||||||
bboxUnion,
|
|
||||||
bboxValid,
|
|
||||||
bboxWidth,
|
|
||||||
bulgeToArc,
|
|
||||||
cameraMatrix,
|
|
||||||
clamp,
|
|
||||||
emptyBBox,
|
|
||||||
matInvert,
|
|
||||||
matMul,
|
|
||||||
matRotate,
|
|
||||||
matScale,
|
|
||||||
matScaleAvg,
|
|
||||||
matScaleY,
|
|
||||||
matTranslate,
|
|
||||||
sampleArcToPath,
|
|
||||||
sampleEllipseToPath,
|
|
||||||
sampleSpline
|
|
||||||
} from './geom'
|
|
||||||
import { cssColor } from './colors'
|
|
||||||
import type { CadDocument, CadSpace, Primitive } from './types'
|
|
||||||
import type { Theme } from '../core/theme'
|
|
||||||
|
|
||||||
export interface RenderOptions {
|
|
||||||
layerVisible: (name: string) => boolean
|
|
||||||
proxyVisible?: boolean
|
|
||||||
theme: Theme
|
|
||||||
}
|
|
||||||
|
|
||||||
const FONT_FAMILY =
|
|
||||||
'"Noto Sans SC", "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif'
|
|
||||||
|
|
||||||
const MODEL_BG = { dark: '#0d1117', light: '#f6f8fc' }
|
|
||||||
const LAYOUT_BG = { dark: '#121722', light: '#e8edf5' }
|
|
||||||
const PAGE_COLOR = '#ffffff'
|
|
||||||
|
|
||||||
export class CadRenderer {
|
|
||||||
private doc: CadDocument
|
|
||||||
private layerHandleToName = new Map<string, string>()
|
|
||||||
|
|
||||||
constructor(doc: CadDocument) {
|
|
||||||
this.doc = doc
|
|
||||||
for (const layer of doc.layers) {
|
|
||||||
if (layer.handle) this.layerHandleToName.set(layer.handle, layer.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draw(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
space: CadSpace,
|
|
||||||
camera: Camera,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
options: RenderOptions
|
|
||||||
): void {
|
|
||||||
const theme = options.theme
|
|
||||||
const isLayout = space.kind === 'layout'
|
|
||||||
ctx.save()
|
|
||||||
ctx.fillStyle = isLayout ? LAYOUT_BG[theme] : MODEL_BG[theme]
|
|
||||||
ctx.fillRect(0, 0, width, height)
|
|
||||||
|
|
||||||
const cameraMat = cameraMatrix(camera, width, height)
|
|
||||||
if (isLayout) {
|
|
||||||
this.drawLayout(ctx, space, camera, cameraMat, width, height, options)
|
|
||||||
} else {
|
|
||||||
this.drawPrimitives(ctx, space, cameraMat, camera, false, options)
|
|
||||||
}
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawLayout(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
space: CadSpace,
|
|
||||||
camera: Camera,
|
|
||||||
cameraMat: Mat,
|
|
||||||
_width: number,
|
|
||||||
_height: number,
|
|
||||||
options: RenderOptions
|
|
||||||
): void {
|
|
||||||
const page = space.bbox
|
|
||||||
if (!bboxValid(page)) return
|
|
||||||
const p0 = applyMat(cameraMat, page.minX, page.minY)
|
|
||||||
const p1 = applyMat(cameraMat, page.maxX, page.maxY)
|
|
||||||
const left = Math.min(p0[0], p1[0])
|
|
||||||
const top = Math.min(p0[1], p1[1])
|
|
||||||
const right = Math.max(p0[0], p1[0])
|
|
||||||
const bottom = Math.max(p0[1], p1[1])
|
|
||||||
const pageRect = { x: left, y: top, w: right - left, h: bottom - top }
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.38)'
|
|
||||||
ctx.shadowBlur = 22
|
|
||||||
ctx.shadowOffsetY = 8
|
|
||||||
ctx.fillStyle = PAGE_COLOR
|
|
||||||
ctx.fillRect(pageRect.x, pageRect.y, pageRect.w, pageRect.h)
|
|
||||||
ctx.restore()
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.rect(pageRect.x, pageRect.y, pageRect.w, pageRect.h)
|
|
||||||
ctx.clip()
|
|
||||||
|
|
||||||
for (const vp of space.viewports) {
|
|
||||||
if (!this.isRenderableViewport(vp)) continue
|
|
||||||
this.drawViewport(ctx, space, vp, camera, cameraMat, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.drawPrimitives(ctx, space, cameraMat, camera, true, options)
|
|
||||||
|
|
||||||
ctx.restore()
|
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.16)'
|
|
||||||
ctx.lineWidth = 1
|
|
||||||
ctx.strokeRect(pageRect.x + 0.5, pageRect.y + 0.5, Math.max(0, pageRect.w - 1), Math.max(0, pageRect.h - 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
private isRenderableViewport(vp: DwgViewportEntity): boolean {
|
|
||||||
const width = Math.abs(Number(vp.width) || 0)
|
|
||||||
const height = Math.abs(Number(vp.height) || 0)
|
|
||||||
const viewHeight = Math.abs(Number(vp.viewHeight) || 0)
|
|
||||||
if (width <= 0 || height <= 0 || viewHeight <= 0) return false
|
|
||||||
const flags = Number(vp.statusBitFlags) || 0
|
|
||||||
if (flags & 131072) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawViewport(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
space: CadSpace,
|
|
||||||
vp: DwgViewportEntity,
|
|
||||||
camera: Camera,
|
|
||||||
cameraMat: Mat,
|
|
||||||
options: RenderOptions
|
|
||||||
): void {
|
|
||||||
const model = this.doc.model
|
|
||||||
if (!model) return
|
|
||||||
const width = Math.abs(Number(vp.width) || 0)
|
|
||||||
const height = Math.abs(Number(vp.height) || 0)
|
|
||||||
const viewHeight = Math.abs(Number(vp.viewHeight) || 0)
|
|
||||||
if (width <= 0 || height <= 0 || viewHeight <= 0) return
|
|
||||||
const center = vp.viewportCenter ?? { x: 0, y: 0 }
|
|
||||||
const viewCenter = vp.displayCenter ?? { x: 0, y: 0 }
|
|
||||||
const twist = Number(vp.viewTwistAngle) || 0
|
|
||||||
const scale = height / viewHeight
|
|
||||||
let modelToPaper = matTranslate(Number(center.x) || 0, Number(center.y) || 0)
|
|
||||||
modelToPaper = matMul(modelToPaper, matRotate(-twist))
|
|
||||||
modelToPaper = matMul(modelToPaper, matScale(scale, scale))
|
|
||||||
modelToPaper = matMul(
|
|
||||||
modelToPaper,
|
|
||||||
matTranslate(-(Number(viewCenter.x) || 0), -(Number(viewCenter.y) || 0))
|
|
||||||
)
|
|
||||||
const totalMat = matMul(cameraMat, modelToPaper)
|
|
||||||
|
|
||||||
const corners = [
|
|
||||||
[Number(center.x) - width / 2, Number(center.y) - height / 2],
|
|
||||||
[Number(center.x) + width / 2, Number(center.y) - height / 2],
|
|
||||||
[Number(center.x) + width / 2, Number(center.y) + height / 2],
|
|
||||||
[Number(center.x) - width / 2, Number(center.y) + height / 2]
|
|
||||||
].map(([x, y]) => applyMat(cameraMat, x, y))
|
|
||||||
|
|
||||||
const frozen = new Set<string>()
|
|
||||||
if (Array.isArray(vp.frozenLayerIds)) {
|
|
||||||
for (const handle of vp.frozenLayerIds) {
|
|
||||||
const name = this.layerHandleToName.get(String(handle))
|
|
||||||
if (name) frozen.add(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(corners[0][0], corners[0][1])
|
|
||||||
for (let i = 1; i < corners.length; i++) ctx.lineTo(corners[i][0], corners[i][1])
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.clip()
|
|
||||||
this.drawPrimitives(
|
|
||||||
ctx,
|
|
||||||
model,
|
|
||||||
totalMat,
|
|
||||||
camera,
|
|
||||||
true,
|
|
||||||
options,
|
|
||||||
(prim) => !frozen.has(prim.layer)
|
|
||||||
)
|
|
||||||
ctx.restore()
|
|
||||||
|
|
||||||
if (this.isViewportFrameVisible(vp)) {
|
|
||||||
const c = corners
|
|
||||||
const minX = Math.min(...c.map((p) => p[0]))
|
|
||||||
const maxX = Math.max(...c.map((p) => p[0]))
|
|
||||||
const minY = Math.min(...c.map((p) => p[1]))
|
|
||||||
const maxY = Math.max(...c.map((p) => p[1]))
|
|
||||||
if (maxX - minX > 8 && maxY - minY > 8) {
|
|
||||||
ctx.save()
|
|
||||||
ctx.strokeStyle = options.theme === 'dark' ? 'rgba(0,0,0,0.22)' : 'rgba(30,50,90,0.18)'
|
|
||||||
ctx.lineWidth = 1
|
|
||||||
ctx.setLineDash([4, 4])
|
|
||||||
ctx.strokeRect(minX + 0.5, minY + 0.5, maxX - minX - 1, maxY - minY - 1)
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void camera
|
|
||||||
}
|
|
||||||
|
|
||||||
private isViewportFrameVisible(vp: DwgViewportEntity): boolean {
|
|
||||||
const flags = Number(vp.statusBitFlags) || 0
|
|
||||||
return (flags & 131072) === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawPrimitives(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
source: Pick<CadSpace, 'prims' | 'grid'>,
|
|
||||||
totalMat: Mat,
|
|
||||||
_camera: Camera,
|
|
||||||
paper: boolean,
|
|
||||||
options: RenderOptions,
|
|
||||||
extraFilter?: (prim: Primitive) => boolean
|
|
||||||
): void {
|
|
||||||
const cw = ctx.canvas.clientWidth || ctx.canvas.width
|
|
||||||
const ch = ctx.canvas.clientHeight || ctx.canvas.height
|
|
||||||
for (const prim of this.visiblePrimitives(source, totalMat, cw, ch)) {
|
|
||||||
if (!options.layerVisible(prim.layer)) continue
|
|
||||||
if (options.proxyVisible === false && prim.proxy) continue
|
|
||||||
if (extraFilter && !extraFilter(prim)) continue
|
|
||||||
let sb = null
|
|
||||||
if (!prim.ignoreBounds) {
|
|
||||||
sb = bboxTransform(totalMat, prim.bbox)
|
|
||||||
if (sb.maxX < -32 || sb.minX > cw + 32 || sb.maxY < -32 || sb.minY > ch + 32) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prim.kind === 'text') {
|
|
||||||
const size = prim.height * matScaleY(totalMat)
|
|
||||||
if (size < 2.8) continue
|
|
||||||
this.drawText(ctx, prim, totalMat, paper, options)
|
|
||||||
} else {
|
|
||||||
if (sb) {
|
|
||||||
const sw = sb.maxX - sb.minX
|
|
||||||
const sh = sb.maxY - sb.minY
|
|
||||||
if (Math.max(sw, sh) < 0.55) continue
|
|
||||||
}
|
|
||||||
this.drawPrimitive(ctx, prim, totalMat, paper, options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private visiblePrimitives(
|
|
||||||
source: Pick<CadSpace, 'prims' | 'grid'>,
|
|
||||||
mat: Mat,
|
|
||||||
width: number,
|
|
||||||
height: number
|
|
||||||
): Primitive[] {
|
|
||||||
const prims = source.prims
|
|
||||||
const grid = source.grid
|
|
||||||
const inverse = matInvert(mat)
|
|
||||||
if (!inverse) return []
|
|
||||||
|
|
||||||
const corners = [
|
|
||||||
applyMat(inverse, -32, -32),
|
|
||||||
applyMat(inverse, width + 32, -32),
|
|
||||||
applyMat(inverse, -32, height + 32),
|
|
||||||
applyMat(inverse, width + 32, height + 32)
|
|
||||||
]
|
|
||||||
let minX = Infinity
|
|
||||||
let minY = Infinity
|
|
||||||
let maxX = -Infinity
|
|
||||||
let maxY = -Infinity
|
|
||||||
for (const [x, y] of corners) {
|
|
||||||
if (x < minX) minX = x
|
|
||||||
if (y < minY) minY = y
|
|
||||||
if (x > maxX) maxX = x
|
|
||||||
if (y > maxY) maxY = y
|
|
||||||
}
|
|
||||||
const world = { minX, minY, maxX, maxY }
|
|
||||||
|
|
||||||
if (!grid) {
|
|
||||||
return prims.filter((prim) => prim.ignoreBounds || bboxIntersects(prim.bbox, world))
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Uint8Array(prims.length)
|
|
||||||
const indices: number[] = []
|
|
||||||
const add = (index: number) => {
|
|
||||||
if (seen[index]) return
|
|
||||||
seen[index] = 1
|
|
||||||
const prim = prims[index]
|
|
||||||
if (prim && (prim.ignoreBounds || bboxIntersects(prim.bbox, world))) indices.push(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const index of grid.big) add(index)
|
|
||||||
|
|
||||||
const c0 = clamp(Math.floor((minX - grid.minX) / grid.cellW), 0, grid.cols - 1)
|
|
||||||
const c1 = clamp(Math.floor((maxX - grid.minX) / grid.cellW), 0, grid.cols - 1)
|
|
||||||
const r0 = clamp(Math.floor((minY - grid.minY) / grid.cellH), 0, grid.rows - 1)
|
|
||||||
const r1 = clamp(Math.floor((maxY - grid.minY) / grid.cellH), 0, grid.rows - 1)
|
|
||||||
for (let row = r0; row <= r1; row++) {
|
|
||||||
const base = row * grid.cols
|
|
||||||
for (let col = c0; col <= c1; col++) {
|
|
||||||
const cell = grid.cells[base + col]
|
|
||||||
if (!cell) continue
|
|
||||||
for (const index of cell) add(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return indices.map((index) => prims[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawPrimitive(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
prim: Exclude<Primitive, { kind: 'text' }>,
|
|
||||||
mat: Mat,
|
|
||||||
paper: boolean,
|
|
||||||
options: RenderOptions
|
|
||||||
): void {
|
|
||||||
const scale = matScaleAvg(mat)
|
|
||||||
const color = cssColor(prim.color, paper, options.theme)
|
|
||||||
const lineWidth = Math.max(0.35, prim.width)
|
|
||||||
ctx.save()
|
|
||||||
ctx.lineWidth = lineWidth
|
|
||||||
ctx.strokeStyle = color
|
|
||||||
ctx.fillStyle = color
|
|
||||||
ctx.lineCap = 'butt'
|
|
||||||
ctx.lineJoin = 'round'
|
|
||||||
if (prim.alpha != null && prim.alpha < 1) ctx.globalAlpha = clamp(prim.alpha, 0.05, 1)
|
|
||||||
const dash = dashInPixels(prim.dash, scale)
|
|
||||||
if (dash) ctx.setLineDash(dash)
|
|
||||||
else ctx.setLineDash([])
|
|
||||||
|
|
||||||
switch (prim.kind) {
|
|
||||||
case 'poly': {
|
|
||||||
if (prim.points.length < 4) break
|
|
||||||
ctx.beginPath()
|
|
||||||
const p = applyMat(mat, prim.points[0], prim.points[1])
|
|
||||||
ctx.moveTo(p[0], p[1])
|
|
||||||
for (let i = 2; i < prim.points.length; i += 2) {
|
|
||||||
const q = applyMat(mat, prim.points[i], prim.points[i + 1])
|
|
||||||
ctx.lineTo(q[0], q[1])
|
|
||||||
}
|
|
||||||
if (prim.closed) ctx.closePath()
|
|
||||||
if (prim.fill) {
|
|
||||||
ctx.setLineDash([])
|
|
||||||
ctx.fillStyle = prim.fillBackground
|
|
||||||
? paper
|
|
||||||
? PAGE_COLOR
|
|
||||||
: options.theme === 'dark'
|
|
||||||
? MODEL_BG.dark
|
|
||||||
: MODEL_BG.light
|
|
||||||
: color
|
|
||||||
ctx.fill()
|
|
||||||
}
|
|
||||||
if (prim.stroke) {
|
|
||||||
if (prim.worldWidth && prim.worldWidth > 0) {
|
|
||||||
ctx.lineWidth = clamp(prim.worldWidth * scale, lineWidth, 48)
|
|
||||||
}
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'circle': {
|
|
||||||
const r = Math.max(0.1, prim.r * scale)
|
|
||||||
if (r < 0.25) break
|
|
||||||
ctx.beginPath()
|
|
||||||
sampleArcToPath(
|
|
||||||
prim.cx,
|
|
||||||
prim.cy,
|
|
||||||
prim.r,
|
|
||||||
0,
|
|
||||||
Math.PI * 2,
|
|
||||||
r,
|
|
||||||
(x, y) => {
|
|
||||||
const p = applyMat(mat, x, y)
|
|
||||||
ctx.moveTo(p[0], p[1])
|
|
||||||
},
|
|
||||||
(x, y) => {
|
|
||||||
const p = applyMat(mat, x, y)
|
|
||||||
ctx.lineTo(p[0], p[1])
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ctx.closePath()
|
|
||||||
if (prim.fill) ctx.fill()
|
|
||||||
ctx.stroke()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'arc': {
|
|
||||||
const r = Math.max(0.1, prim.r * scale)
|
|
||||||
if (r < 0.25) break
|
|
||||||
ctx.beginPath()
|
|
||||||
sampleArcToPath(
|
|
||||||
prim.cx,
|
|
||||||
prim.cy,
|
|
||||||
prim.r,
|
|
||||||
prim.a0,
|
|
||||||
prim.sweep,
|
|
||||||
r,
|
|
||||||
(x, y) => {
|
|
||||||
const p = applyMat(mat, x, y)
|
|
||||||
ctx.moveTo(p[0], p[1])
|
|
||||||
},
|
|
||||||
(x, y) => {
|
|
||||||
const p = applyMat(mat, x, y)
|
|
||||||
ctx.lineTo(p[0], p[1])
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ctx.stroke()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'ellipse': {
|
|
||||||
const r = Math.max(0.1, Math.hypot(prim.mx, prim.my) * scale)
|
|
||||||
if (r < 0.25) break
|
|
||||||
ctx.beginPath()
|
|
||||||
sampleEllipseToPath(
|
|
||||||
prim.cx,
|
|
||||||
prim.cy,
|
|
||||||
prim.mx,
|
|
||||||
prim.my,
|
|
||||||
prim.ratio,
|
|
||||||
prim.a0,
|
|
||||||
prim.sweep,
|
|
||||||
r,
|
|
||||||
(x, y) => {
|
|
||||||
const p = applyMat(mat, x, y)
|
|
||||||
ctx.moveTo(p[0], p[1])
|
|
||||||
},
|
|
||||||
(x, y) => {
|
|
||||||
const p = applyMat(mat, x, y)
|
|
||||||
ctx.lineTo(p[0], p[1])
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (prim.fill) {
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.fill()
|
|
||||||
}
|
|
||||||
ctx.stroke()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'bulge': {
|
|
||||||
if (prim.vertices.length < 2) break
|
|
||||||
ctx.beginPath()
|
|
||||||
const first = applyMat(mat, prim.vertices[0].x, prim.vertices[0].y)
|
|
||||||
ctx.moveTo(first[0], first[1])
|
|
||||||
for (let i = 0; i < prim.vertices.length; i++) {
|
|
||||||
const next = i + 1 < prim.vertices.length ? i + 1 : prim.closed ? 0 : -1
|
|
||||||
if (next < 0) break
|
|
||||||
const a = prim.vertices[i]
|
|
||||||
const b = prim.vertices[next]
|
|
||||||
const arc = bulgeToArc(a.x, a.y, b.x, b.y, a.bulge)
|
|
||||||
if (arc) {
|
|
||||||
const r = Math.max(0.1, arc.r * scale)
|
|
||||||
sampleArcToPath(
|
|
||||||
arc.cx,
|
|
||||||
arc.cy,
|
|
||||||
arc.r,
|
|
||||||
arc.a0,
|
|
||||||
arc.sweep,
|
|
||||||
r,
|
|
||||||
() => {},
|
|
||||||
(x, y) => {
|
|
||||||
const p = applyMat(mat, x, y)
|
|
||||||
ctx.lineTo(p[0], p[1])
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
const p = applyMat(mat, b.x, b.y)
|
|
||||||
ctx.lineTo(p[0], p[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prim.closed) ctx.closePath()
|
|
||||||
if (prim.worldWidth && prim.worldWidth > 0) ctx.lineWidth = clamp(prim.worldWidth * scale, lineWidth, 48)
|
|
||||||
ctx.stroke()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'spline': {
|
|
||||||
const local = bboxFromPoints(prim.control)
|
|
||||||
const extent = Math.max(bboxWidth(local), bboxHeight(local)) * scale
|
|
||||||
const spans = Math.max(1, prim.control.length / 2 - prim.degree)
|
|
||||||
const segments = clamp(Math.ceil(extent / 2.5) + spans * 6, 32, 1400)
|
|
||||||
const pts = sampleSpline(prim.control, prim.degree, prim.knots, prim.weights, segments)
|
|
||||||
if (pts.length < 4) break
|
|
||||||
ctx.beginPath()
|
|
||||||
const p0 = applyMat(mat, pts[0], pts[1])
|
|
||||||
ctx.moveTo(p0[0], p0[1])
|
|
||||||
for (let i = 2; i < pts.length; i += 2) {
|
|
||||||
const p = applyMat(mat, pts[i], pts[i + 1])
|
|
||||||
ctx.lineTo(p[0], p[1])
|
|
||||||
}
|
|
||||||
if (prim.closed) ctx.closePath()
|
|
||||||
ctx.stroke()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'hatch': {
|
|
||||||
ctx.beginPath()
|
|
||||||
for (const path of prim.paths) {
|
|
||||||
if (path.length < 6) continue
|
|
||||||
const p0 = applyMat(mat, path[0], path[1])
|
|
||||||
ctx.moveTo(p0[0], p0[1])
|
|
||||||
for (let i = 2; i < path.length; i += 2) {
|
|
||||||
const p = applyMat(mat, path[i], path[i + 1])
|
|
||||||
ctx.lineTo(p[0], p[1])
|
|
||||||
}
|
|
||||||
ctx.closePath()
|
|
||||||
}
|
|
||||||
if (prim.solid) {
|
|
||||||
ctx.setLineDash([])
|
|
||||||
ctx.fill('evenodd')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const sb = bboxTransform(mat, prim.bbox)
|
|
||||||
if (!bboxValid(sb)) break
|
|
||||||
if (prim.patternSpacing * scale < 1.4) break
|
|
||||||
ctx.save()
|
|
||||||
ctx.clip('evenodd')
|
|
||||||
let spacing = Math.max(2, prim.patternSpacing * scale)
|
|
||||||
const angle = -prim.patternAngle
|
|
||||||
const dx = Math.cos(angle)
|
|
||||||
const dy = Math.sin(angle)
|
|
||||||
const nx = -dy
|
|
||||||
const ny = dx
|
|
||||||
const cx = (sb.minX + sb.maxX) / 2
|
|
||||||
const cy = (sb.minY + sb.maxY) / 2
|
|
||||||
const diagonal = Math.hypot(bboxWidth(sb), bboxHeight(sb))
|
|
||||||
if ((diagonal * 2) / spacing > 12000) spacing = (diagonal * 2) / 12000
|
|
||||||
ctx.beginPath()
|
|
||||||
const limit = diagonal + spacing
|
|
||||||
for (let offset = -limit; offset <= limit; offset += spacing) {
|
|
||||||
const ox = cx + nx * offset
|
|
||||||
const oy = cy + ny * offset
|
|
||||||
ctx.moveTo(ox - dx * limit, oy - dy * limit)
|
|
||||||
ctx.lineTo(ox + dx * limit, oy + dy * limit)
|
|
||||||
}
|
|
||||||
ctx.lineWidth = 0.7
|
|
||||||
ctx.strokeStyle = color
|
|
||||||
ctx.setLineDash([])
|
|
||||||
ctx.stroke()
|
|
||||||
ctx.restore()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'point': {
|
|
||||||
const p = applyMat(mat, prim.x, prim.y)
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(p[0], p[1], 1.8, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.strokeStyle = color
|
|
||||||
ctx.lineWidth = 1
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(p[0] - 3, p[1])
|
|
||||||
ctx.lineTo(p[0] + 3, p[1])
|
|
||||||
ctx.moveTo(p[0], p[1] - 3)
|
|
||||||
ctx.lineTo(p[0], p[1] + 3)
|
|
||||||
ctx.stroke()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'image': {
|
|
||||||
ctx.beginPath()
|
|
||||||
if (prim.points && prim.points.length >= 6) {
|
|
||||||
const p0 = applyMat(mat, prim.points[0], prim.points[1])
|
|
||||||
ctx.moveTo(p0[0], p0[1])
|
|
||||||
for (let i = 2; i < prim.points.length; i += 2) {
|
|
||||||
const p = applyMat(mat, prim.points[i], prim.points[i + 1])
|
|
||||||
ctx.lineTo(p[0], p[1])
|
|
||||||
}
|
|
||||||
ctx.closePath()
|
|
||||||
} else {
|
|
||||||
const a = applyMat(mat, prim.x1, prim.y1)
|
|
||||||
const b = applyMat(mat, prim.x2, prim.y2)
|
|
||||||
ctx.rect(a[0], a[1], b[0] - a[0], b[1] - a[1])
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 0.16
|
|
||||||
ctx.fillStyle = color
|
|
||||||
ctx.fill()
|
|
||||||
ctx.globalAlpha = 1
|
|
||||||
ctx.setLineDash([5, 4])
|
|
||||||
ctx.strokeStyle = color
|
|
||||||
ctx.lineWidth = 1
|
|
||||||
ctx.stroke()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawText(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
prim: Extract<Primitive, { kind: 'text' }>,
|
|
||||||
mat: Mat,
|
|
||||||
paper: boolean,
|
|
||||||
options: RenderOptions
|
|
||||||
): void {
|
|
||||||
if (!prim.lines.length) return
|
|
||||||
const anchor = applyMat(mat, prim.x, prim.y)
|
|
||||||
const yScale = Math.hypot(mat[2], mat[3])
|
|
||||||
const size = prim.height * yScale
|
|
||||||
if (!Number.isFinite(size) || size < 2.4) return
|
|
||||||
if (size > 2600) return
|
|
||||||
const cos = Math.cos(prim.rotation)
|
|
||||||
const sin = Math.sin(prim.rotation)
|
|
||||||
const axis = applyMatVec(mat, cos, sin)
|
|
||||||
const angle = Math.atan2(axis[1], axis[0])
|
|
||||||
const color = cssColor(prim.color, paper, options.theme)
|
|
||||||
const lineHeightPx = Math.max(size * 1.1, prim.lineHeight * yScale)
|
|
||||||
const totalHeight = lineHeightPx * prim.lines.length
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.translate(anchor[0], anchor[1])
|
|
||||||
ctx.rotate(angle)
|
|
||||||
ctx.scale(prim.widthFactor * (prim.mirrored ? -1 : 1), 1)
|
|
||||||
ctx.font = `${Math.min(size, 2600)}px ${FONT_FAMILY}`
|
|
||||||
ctx.fillStyle = color
|
|
||||||
ctx.textAlign = prim.halign
|
|
||||||
ctx.setLineDash([])
|
|
||||||
|
|
||||||
if (prim.lines.length === 1 && (prim.valign === 'baseline' || prim.valign === 'bottom')) {
|
|
||||||
ctx.textBaseline = prim.valign === 'baseline' ? 'alphabetic' : 'bottom'
|
|
||||||
ctx.fillText(prim.lines[0], 0, 0)
|
|
||||||
} else {
|
|
||||||
ctx.textBaseline = 'top'
|
|
||||||
let y0 = 0
|
|
||||||
if (prim.valign === 'middle') y0 = -totalHeight / 2
|
|
||||||
else if (prim.valign === 'bottom') y0 = -totalHeight
|
|
||||||
for (let i = 0; i < prim.lines.length; i++) {
|
|
||||||
ctx.fillText(prim.lines[i], 0, y0 + i * lineHeightPx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dashInPixels(dash: number[] | undefined, scale: number): number[] | null {
|
|
||||||
if (!dash || !dash.length) return null
|
|
||||||
let meaningful = false
|
|
||||||
const out: number[] = []
|
|
||||||
for (const value of dash) {
|
|
||||||
const px = Math.max(0.3, value * scale)
|
|
||||||
if (px > 0.6) meaningful = true
|
|
||||||
out.push(Math.min(px, 400))
|
|
||||||
}
|
|
||||||
return meaningful ? out : null
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { DwgDatabase } from '@mlightcad/libredwg-web'
|
|
||||||
import type { CadDocument, CadSpace } from './types'
|
|
||||||
|
|
||||||
export function slimDocument(doc: CadDocument): CadDocument {
|
|
||||||
const spaces: CadSpace[] = doc.spaces.map((space) => ({
|
|
||||||
...space,
|
|
||||||
entities: [],
|
|
||||||
entityCount: space.entityCount ?? space.entities.length
|
|
||||||
}))
|
|
||||||
const model = spaces.find((space) => space.kind === 'model') ?? spaces[0]
|
|
||||||
return {
|
|
||||||
...doc,
|
|
||||||
db: undefined as unknown as DwgDatabase,
|
|
||||||
blocks: new Map(),
|
|
||||||
blockByHandle: new Map(),
|
|
||||||
dashPatterns: new Map(),
|
|
||||||
imageNames: new Map(),
|
|
||||||
spaces,
|
|
||||||
model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
export function decodeCadEscapes(text: string): string {
|
|
||||||
if (!text) return ''
|
|
||||||
let out = text
|
|
||||||
out = out.replace(/\\U\+([0-9a-fA-F]{4})/g, (_m, hex: string) => String.fromCharCode(parseInt(hex, 16)))
|
|
||||||
out = out.replace(/%%([dDpPcC%])/g, (_m, code: string) => {
|
|
||||||
switch (code.toLowerCase()) {
|
|
||||||
case 'd':
|
|
||||||
return '°'
|
|
||||||
case 'p':
|
|
||||||
return '±'
|
|
||||||
case 'c':
|
|
||||||
return 'Ø'
|
|
||||||
case '%':
|
|
||||||
return '%'
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
out = out.replace(/\\P/gi, '\n')
|
|
||||||
out = out.replace(/\\~/g, ' ')
|
|
||||||
out = out.replace(/\\\\/g, '\\')
|
|
||||||
out = out.replace(/\\\{/g, '{').replace(/\\\}/g, '}')
|
|
||||||
out = out.replace(/[{}]/g, '')
|
|
||||||
out = out.replace(/\r/g, '')
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMText(raw: string, width: number, height: number): string[] {
|
|
||||||
if (!raw) return []
|
|
||||||
let text = raw
|
|
||||||
text = text.replace(/\\U\+([0-9a-fA-F]{4})/g, (_m, hex: string) => String.fromCharCode(parseInt(hex, 16)))
|
|
||||||
text = text.replace(/\\P/gi, '\n')
|
|
||||||
text = text.replace(/\\~/g, ' ')
|
|
||||||
text = text.replace(/\\S([^;]*);/g, (_m, stacked: string) => stacked.replace(/[\^#]/g, '/'))
|
|
||||||
text = text.replace(/\\[AaCcFfHhQqTtWwXx][^;\\]*;/g, '')
|
|
||||||
text = text.replace(/\\[LlOoKkNn]/g, '')
|
|
||||||
text = text.replace(/\\[a-zA-Z][^;\\]*;?/g, '')
|
|
||||||
text = text.replace(/%%([dDpPcC%])/g, (_m, code: string) => {
|
|
||||||
switch (code.toLowerCase()) {
|
|
||||||
case 'd':
|
|
||||||
return '°'
|
|
||||||
case 'p':
|
|
||||||
return '±'
|
|
||||||
case 'c':
|
|
||||||
return 'Ø'
|
|
||||||
case '%':
|
|
||||||
return '%'
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
text = text.replace(/\\\\/g, '\\')
|
|
||||||
text = text.replace(/\\\{/g, '{').replace(/\\\}/g, '}')
|
|
||||||
text = text.replace(/[{}]/g, '')
|
|
||||||
text = text.replace(/\r/g, '')
|
|
||||||
const explicit = text.split('\n')
|
|
||||||
const result: string[] = []
|
|
||||||
for (const line of explicit) {
|
|
||||||
if (width > 0) {
|
|
||||||
result.push(...wrapLine(line, width, height))
|
|
||||||
} else {
|
|
||||||
result.push(line.replace(/\s+$/, ''))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.filter((line, index, arr) => line.length > 0 || (index > 0 && index < arr.length - 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
function charWidth(ch: string, height: number): number {
|
|
||||||
const code = ch.codePointAt(0) ?? 32
|
|
||||||
if (code >= 0x2e80) return height
|
|
||||||
if (ch === ' ') return height * 0.32
|
|
||||||
if (/[ilI1.,'`:;|!]/.test(ch)) return height * 0.28
|
|
||||||
if (/[mwMW@%&]/.test(ch)) return height * 0.85
|
|
||||||
if (/[A-Z]/.test(ch)) return height * 0.66
|
|
||||||
if (/[0-9]/.test(ch)) return height * 0.58
|
|
||||||
return height * 0.54
|
|
||||||
}
|
|
||||||
|
|
||||||
export function textWidth(text: string, height: number): number {
|
|
||||||
let width = 0
|
|
||||||
for (const ch of text) width += charWidth(ch, height)
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wrapLine(line: string, width: number, height: number): string[] {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
if (!trimmed) return ['']
|
|
||||||
if (width <= 0 || textWidth(trimmed, height) <= width) return [trimmed]
|
|
||||||
const lines: string[] = []
|
|
||||||
let current = ''
|
|
||||||
let currentWidth = 0
|
|
||||||
let lastBreak = -1
|
|
||||||
const chars = Array.from(trimmed)
|
|
||||||
for (let i = 0; i < chars.length; i++) {
|
|
||||||
const ch = chars[i]
|
|
||||||
const w = charWidth(ch, height)
|
|
||||||
if (currentWidth + w > width && current.length > 0) {
|
|
||||||
if (lastBreak > 0 && /[A-Za-z0-9]/.test(chars[i - 1] ?? '')) {
|
|
||||||
const head = current.slice(0, lastBreak)
|
|
||||||
const tail = current.slice(lastBreak).trimStart()
|
|
||||||
lines.push(head.trimEnd())
|
|
||||||
current = tail
|
|
||||||
currentWidth = textWidth(current, height)
|
|
||||||
lastBreak = -1
|
|
||||||
} else {
|
|
||||||
lines.push(current.trimEnd())
|
|
||||||
current = ''
|
|
||||||
currentWidth = 0
|
|
||||||
lastBreak = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current += ch
|
|
||||||
currentWidth += w
|
|
||||||
if (ch === ' ' || ch === '-' || ch === '/') lastBreak = current.length
|
|
||||||
}
|
|
||||||
if (current.trim()) lines.push(current.trimEnd())
|
|
||||||
return lines.length ? lines : ['']
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import type {
|
|
||||||
DwgBlockRecordTableEntry,
|
|
||||||
DwgDatabase,
|
|
||||||
DwgEntity,
|
|
||||||
DwgLayoutObject,
|
|
||||||
DwgViewportEntity
|
|
||||||
} from '@mlightcad/libredwg-web'
|
|
||||||
import type { BBox, Mat } from './geom'
|
|
||||||
import type { ColorSpec } from './colors'
|
|
||||||
|
|
||||||
export interface LayerInfo {
|
|
||||||
name: string
|
|
||||||
handle: string
|
|
||||||
aci: number
|
|
||||||
rgb?: number
|
|
||||||
visible: boolean
|
|
||||||
off: boolean
|
|
||||||
frozen: boolean
|
|
||||||
count: number
|
|
||||||
lineType: string
|
|
||||||
lineWeight: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CadBlockInfo {
|
|
||||||
name: string
|
|
||||||
handle: string
|
|
||||||
layoutHandle: string
|
|
||||||
baseX: number
|
|
||||||
baseY: number
|
|
||||||
entities: DwgEntity[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrimBase {
|
|
||||||
layer: string
|
|
||||||
color: ColorSpec
|
|
||||||
width: number
|
|
||||||
worldWidth?: number
|
|
||||||
dash?: number[]
|
|
||||||
alpha?: number
|
|
||||||
bbox: BBox
|
|
||||||
mat: Mat
|
|
||||||
ignoreBounds?: boolean
|
|
||||||
proxy?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolyPrim extends PrimBase {
|
|
||||||
kind: 'poly'
|
|
||||||
points: number[]
|
|
||||||
closed: boolean
|
|
||||||
fill: boolean
|
|
||||||
fillBackground?: boolean
|
|
||||||
stroke: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulgePrim extends PrimBase {
|
|
||||||
kind: 'bulge'
|
|
||||||
vertices: Array<{ x: number; y: number; bulge: number }>
|
|
||||||
closed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CirclePrim extends PrimBase {
|
|
||||||
kind: 'circle'
|
|
||||||
cx: number
|
|
||||||
cy: number
|
|
||||||
r: number
|
|
||||||
fill: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArcPrim extends PrimBase {
|
|
||||||
kind: 'arc'
|
|
||||||
cx: number
|
|
||||||
cy: number
|
|
||||||
r: number
|
|
||||||
a0: number
|
|
||||||
sweep: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EllipsePrim extends PrimBase {
|
|
||||||
kind: 'ellipse'
|
|
||||||
cx: number
|
|
||||||
cy: number
|
|
||||||
mx: number
|
|
||||||
my: number
|
|
||||||
ratio: number
|
|
||||||
a0: number
|
|
||||||
sweep: number
|
|
||||||
fill: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SplinePrim extends PrimBase {
|
|
||||||
kind: 'spline'
|
|
||||||
control: number[]
|
|
||||||
degree: number
|
|
||||||
knots: number[]
|
|
||||||
weights?: number[]
|
|
||||||
closed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextPrim extends PrimBase {
|
|
||||||
kind: 'text'
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
height: number
|
|
||||||
rotation: number
|
|
||||||
lines: string[]
|
|
||||||
halign: 'left' | 'center' | 'right'
|
|
||||||
valign: 'baseline' | 'bottom' | 'middle' | 'top'
|
|
||||||
lineHeight: number
|
|
||||||
widthFactor: number
|
|
||||||
mirrored: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HatchPrim extends PrimBase {
|
|
||||||
kind: 'hatch'
|
|
||||||
paths: number[][]
|
|
||||||
solid: boolean
|
|
||||||
patternName?: string
|
|
||||||
patternAngle: number
|
|
||||||
patternSpacing: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointPrim extends PrimBase {
|
|
||||||
kind: 'point'
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImagePrim extends PrimBase {
|
|
||||||
kind: 'image'
|
|
||||||
x1: number
|
|
||||||
y1: number
|
|
||||||
x2: number
|
|
||||||
y2: number
|
|
||||||
points?: number[]
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Primitive =
|
|
||||||
| PolyPrim
|
|
||||||
| BulgePrim
|
|
||||||
| CirclePrim
|
|
||||||
| ArcPrim
|
|
||||||
| EllipsePrim
|
|
||||||
| SplinePrim
|
|
||||||
| TextPrim
|
|
||||||
| HatchPrim
|
|
||||||
| PointPrim
|
|
||||||
| ImagePrim
|
|
||||||
|
|
||||||
export interface SpaceGrid {
|
|
||||||
minX: number
|
|
||||||
minY: number
|
|
||||||
cellW: number
|
|
||||||
cellH: number
|
|
||||||
cols: number
|
|
||||||
rows: number
|
|
||||||
cells: number[][]
|
|
||||||
big: number[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CadSpace {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
kind: 'model' | 'layout'
|
|
||||||
blockHandle: string
|
|
||||||
entities: DwgEntity[]
|
|
||||||
entityCount: number
|
|
||||||
prims: Primitive[]
|
|
||||||
grid?: SpaceGrid
|
|
||||||
bbox: BBox
|
|
||||||
fitBBox?: BBox
|
|
||||||
savedView?: { cx: number; cy: number; width: number; height: number }
|
|
||||||
viewports: DwgViewportEntity[]
|
|
||||||
layout?: DwgLayoutObject
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CadDocument {
|
|
||||||
db: DwgDatabase
|
|
||||||
fileName: string
|
|
||||||
fileSize: number
|
|
||||||
layers: LayerInfo[]
|
|
||||||
layerMap: Map<string, LayerInfo>
|
|
||||||
blocks: Map<string, CadBlockInfo>
|
|
||||||
blockByHandle: Map<string, CadBlockInfo>
|
|
||||||
dashPatterns: Map<string, number[]>
|
|
||||||
imageNames: Map<string, string>
|
|
||||||
spaces: CadSpace[]
|
|
||||||
model: CadSpace
|
|
||||||
units: string
|
|
||||||
unitLabel: string
|
|
||||||
version: string
|
|
||||||
unknownCount: number
|
|
||||||
warnings: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BlockRecord = DwgBlockRecordTableEntry
|
|
||||||
+9
-12
@@ -68,11 +68,6 @@ export function initApp(): void {
|
|||||||
bindGlobalEvents()
|
bindGlobalEvents()
|
||||||
void refreshRecent()
|
void refreshRecent()
|
||||||
handleLaunchQueue()
|
handleLaunchQueue()
|
||||||
window.setTimeout(() => {
|
|
||||||
void import('../cad/loader')
|
|
||||||
.then((module) => module.preloadDwgEngine())
|
|
||||||
.catch(() => {})
|
|
||||||
}, 300)
|
|
||||||
;(window as unknown as { __phoneViewerBack?: () => boolean }).__phoneViewerBack = () => {
|
;(window as unknown as { __phoneViewerBack?: () => boolean }).__phoneViewerBack = () => {
|
||||||
const modalClose = document.querySelector<HTMLButtonElement>('.modal-backdrop.show .modal-close')
|
const modalClose = document.querySelector<HTMLButtonElement>('.modal-backdrop.show .modal-close')
|
||||||
if (modalClose) {
|
if (modalClose) {
|
||||||
@@ -286,14 +281,16 @@ async function openFile(file: File): Promise<void> {
|
|||||||
let createdViewer: ActiveViewer | null = null
|
let createdViewer: ActiveViewer | null = null
|
||||||
try {
|
try {
|
||||||
if (target === 'cad') {
|
if (target === 'cad') {
|
||||||
const { loadDwg } = await import('../cad/loader')
|
const { FlyfishCadViewer } = await import('../cad/flyfishViewer')
|
||||||
const doc = await loadDwg(file, (message) => host?.setLoaderMessage(message, file.name))
|
const viewer = new FlyfishCadViewer(host!, file, { onBack: () => closeActiveViewer() })
|
||||||
if (token !== openToken) return
|
createdViewer = viewer
|
||||||
const { CadViewer } = await import('../cad/cadViewer')
|
await viewer.load()
|
||||||
createdViewer = new CadViewer(host!, doc, { onBack: () => closeActiveViewer() })
|
if (token !== openToken) {
|
||||||
activeViewer = createdViewer
|
viewer.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeViewer = viewer
|
||||||
host?.hideLoader()
|
host?.hideLoader()
|
||||||
if (doc.warnings.length) toast(doc.warnings[0], 'info', 5000)
|
|
||||||
} else {
|
} else {
|
||||||
const [{ loadMuPDF }, { DocViewer }] = await Promise.all([
|
const [{ loadMuPDF }, { DocViewer }] = await Promise.all([
|
||||||
import('../doc/mupdfLoader'),
|
import('../doc/mupdfLoader'),
|
||||||
|
|||||||
@@ -2359,3 +2359,15 @@ a {
|
|||||||
display: block;
|
display: block;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flyfish-cad {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flyfish-cad > div {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { h } from '../core/dom'
|
import { h } from '../core/dom'
|
||||||
import { openModal } from './modal'
|
import { openModal } from './modal'
|
||||||
|
|
||||||
const VERSION = '0.4.1'
|
const VERSION = '0.5.0'
|
||||||
|
|
||||||
export function showAbout(): void {
|
export function showAbout(): void {
|
||||||
const body = h(
|
const body = h(
|
||||||
@@ -32,7 +32,7 @@ export function showAbout(): void {
|
|||||||
h(
|
h(
|
||||||
'ul',
|
'ul',
|
||||||
{ class: 'about-list' },
|
{ class: 'about-list' },
|
||||||
h('li', {}, 'DWG 解析:@node-projects/acad-ts — MIT'),
|
h('li', {}, 'DWG 渲染:@flyfish-dev/cad-viewer — AGPL-3.0(含 LibreDWG WebAssembly,GPL-3.0)'),
|
||||||
h('li', {}, '文档渲染:MuPDF.js(SumatraPDF 同源渲染内核家族)— AGPL-3.0'),
|
h('li', {}, '文档渲染:MuPDF.js(SumatraPDF 同源渲染内核家族)— AGPL-3.0'),
|
||||||
h('li', {}, '本应用以 AGPL-3.0 发布;完整许可文本见项目 LICENSE 文件。')
|
h('li', {}, '本应用以 AGPL-3.0 发布;完整许可文本见项目 LICENSE 文件。')
|
||||||
),
|
),
|
||||||
|
|||||||
+4
-6
@@ -35,13 +35,11 @@ export function showGuide(): void {
|
|||||||
section(
|
section(
|
||||||
'DWG 图纸',
|
'DWG 图纸',
|
||||||
list(
|
list(
|
||||||
'顶部 Tab 切换模型空间和各个布局空间。',
|
'采用 WebGL 常驻渲染,支持块展开、图层、文字、填充、线型、样条和标注。',
|
||||||
'右侧图层按钮:按图层显示/隐藏,支持全部显示、全部隐藏、恢复默认。',
|
'右侧图层按钮:按图层显示/隐藏,支持全部显示、全部隐藏、恢复默认。',
|
||||||
'尺子按钮:点两点测量距离;清除按钮清空当前空间的测量线。',
|
'适配按钮:自动聚焦有意义的图形内容;放大/缩小按钮调整视图。',
|
||||||
'立方体按钮:显示/隐藏第三方代理对象,默认显示。',
|
'背景按钮切换深色/浅色图纸背景;保存按钮把当前视图导出为 PNG。',
|
||||||
'适配按钮:一键显示全图;背景按钮切换深色/浅色图纸背景。',
|
'支持 DWG 2006–2026 常用版本;代理对象没有图形数据时不会显示。'
|
||||||
'保存按钮:把当前视图导出为 PNG。',
|
|
||||||
'布局空间会按纸张和视口显示,模型内容自动裁剪到视口内。'
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
section(
|
section(
|
||||||
|
|||||||
+12
-4
@@ -17,7 +17,7 @@ export default defineConfig({
|
|||||||
includeAssets: ['icons/favicon.svg', 'icons/apple-touch-icon.png'],
|
includeAssets: ['icons/favicon.svg', 'icons/apple-touch-icon.png'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: '万能看图 · DWG/PDF 浏览',
|
name: '万能看图 · DWG/PDF 浏览',
|
||||||
short_name: '涓囪兘鐪嬪浘',
|
short_name: '万能看图',
|
||||||
description:
|
description:
|
||||||
'手机与平板上的 DWG 图纸浏览器,支持模型空间与布局空间、图层控制、测量,以及 PDF / EPUB / XPS / CBZ / 图片阅读。',
|
'手机与平板上的 DWG 图纸浏览器,支持模型空间与布局空间、图层控制、测量,以及 PDF / EPUB / XPS / CBZ / 图片阅读。',
|
||||||
lang: 'zh-CN',
|
lang: 'zh-CN',
|
||||||
@@ -55,20 +55,28 @@ export default defineConfig({
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2,wasm}'],
|
||||||
globIgnores: ['**/vendor/**', '**/samples/**'],
|
globIgnores: ['**/vendor/**', '**/samples/**'],
|
||||||
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 12 * 1024 * 1024,
|
||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
cleanupOutdatedCaches: true,
|
cleanupOutdatedCaches: true,
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /\/vendor\/(?:libredwg|mupdf)\//,
|
urlPattern: /\/vendor\/mupdf\//,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'viewer-engines-v1',
|
cacheName: 'viewer-engines-v1',
|
||||||
expiration: { maxEntries: 12, maxAgeSeconds: 60 * 60 * 24 * 365 }
|
expiration: { maxEntries: 12, maxAgeSeconds: 60 * 60 * 24 * 365 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /\/wasm\//,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'viewer-cad-wasm-v1',
|
||||||
|
expiration: { maxEntries: 8, maxAgeSeconds: 60 * 60 * 24 * 365 }
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /\/samples\//,
|
urlPattern: /\/samples\//,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
|
|||||||
Reference in New Issue
Block a user