Integrate flyfish CAD viewer

This commit is contained in:
2026-10-26 18:07:00 +08:00
parent e4eee1b3b7
commit e01c7eaecb
28 changed files with 560 additions and 5376 deletions
-845
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-1067
View File
File diff suppressed because it is too large Load Diff
-93
View File
@@ -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
}
-72
View File
@@ -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)
}
}
+381
View File
@@ -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
View File
@@ -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+$/, '')
}
-186
View File
@@ -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)
}
-643
View File
@@ -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
}
-21
View File
@@ -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
View File
@@ -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 : ['']
}
-196
View File
@@ -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