Integrate flyfish CAD viewer
This commit is contained in:
@@ -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()
|
||||
void refreshRecent()
|
||||
handleLaunchQueue()
|
||||
window.setTimeout(() => {
|
||||
void import('../cad/loader')
|
||||
.then((module) => module.preloadDwgEngine())
|
||||
.catch(() => {})
|
||||
}, 300)
|
||||
;(window as unknown as { __phoneViewerBack?: () => boolean }).__phoneViewerBack = () => {
|
||||
const modalClose = document.querySelector<HTMLButtonElement>('.modal-backdrop.show .modal-close')
|
||||
if (modalClose) {
|
||||
@@ -286,14 +281,16 @@ async function openFile(file: File): Promise<void> {
|
||||
let createdViewer: ActiveViewer | null = null
|
||||
try {
|
||||
if (target === 'cad') {
|
||||
const { loadDwg } = await import('../cad/loader')
|
||||
const doc = await loadDwg(file, (message) => host?.setLoaderMessage(message, file.name))
|
||||
if (token !== openToken) return
|
||||
const { CadViewer } = await import('../cad/cadViewer')
|
||||
createdViewer = new CadViewer(host!, doc, { onBack: () => closeActiveViewer() })
|
||||
activeViewer = createdViewer
|
||||
const { FlyfishCadViewer } = await import('../cad/flyfishViewer')
|
||||
const viewer = new FlyfishCadViewer(host!, file, { onBack: () => closeActiveViewer() })
|
||||
createdViewer = viewer
|
||||
await viewer.load()
|
||||
if (token !== openToken) {
|
||||
viewer.destroy()
|
||||
return
|
||||
}
|
||||
activeViewer = viewer
|
||||
host?.hideLoader()
|
||||
if (doc.warnings.length) toast(doc.warnings[0], 'info', 5000)
|
||||
} else {
|
||||
const [{ loadMuPDF }, { DocViewer }] = await Promise.all([
|
||||
import('../doc/mupdfLoader'),
|
||||
|
||||
@@ -2359,3 +2359,15 @@ a {
|
||||
display: block;
|
||||
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 { openModal } from './modal'
|
||||
|
||||
const VERSION = '0.4.1'
|
||||
const VERSION = '0.5.0'
|
||||
|
||||
export function showAbout(): void {
|
||||
const body = h(
|
||||
@@ -32,7 +32,7 @@ export function showAbout(): void {
|
||||
h(
|
||||
'ul',
|
||||
{ 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', {}, '本应用以 AGPL-3.0 发布;完整许可文本见项目 LICENSE 文件。')
|
||||
),
|
||||
|
||||
+4
-6
@@ -35,13 +35,11 @@ export function showGuide(): void {
|
||||
section(
|
||||
'DWG 图纸',
|
||||
list(
|
||||
'顶部 Tab 切换模型空间和各个布局空间。',
|
||||
'采用 WebGL 常驻渲染,支持块展开、图层、文字、填充、线型、样条和标注。',
|
||||
'右侧图层按钮:按图层显示/隐藏,支持全部显示、全部隐藏、恢复默认。',
|
||||
'尺子按钮:点两点测量距离;清除按钮清空当前空间的测量线。',
|
||||
'立方体按钮:显示/隐藏第三方代理对象,默认显示。',
|
||||
'适配按钮:一键显示全图;背景按钮切换深色/浅色图纸背景。',
|
||||
'保存按钮:把当前视图导出为 PNG。',
|
||||
'布局空间会按纸张和视口显示,模型内容自动裁剪到视口内。'
|
||||
'适配按钮:自动聚焦有意义的图形内容;放大/缩小按钮调整视图。',
|
||||
'背景按钮切换深色/浅色图纸背景;保存按钮把当前视图导出为 PNG。',
|
||||
'支持 DWG 2006–2026 常用版本;代理对象没有图形数据时不会显示。'
|
||||
)
|
||||
),
|
||||
section(
|
||||
|
||||
Reference in New Issue
Block a user