/** * 把归一化后的图纸文档(Doc)展平成渲染用的「显示列表」。 * * 展平只做一次(加载时),之后缩放平移都不再重算几何。 * INSERT 会递归展开并把变换烘焙进坐标,因此显示列表里的坐标全是世界坐标。 */ import ACI from '../libs/dxf-parser/AutoCadColorIndex.js' import { applyX, applyY, arcBBox, arcPoints, bboxOfPoints, bulgeArcPoints, circlePoints, conformal, ellipsePoints, fitPointCurve, growBox, IDENTITY, makeInsertMatrix, matrixScale, mul, splinePoints, TAU, transformPoints, } from './curves.js' import { builtinDefinitionLines, hatchLoops, hatchPatternSegments } from './hatch.js' import { parseMText, parseSimpleText } from './mtext.js' const MAX_INSERT_DEPTH = 12 const MAX_ARRAY_CELLS = 2000 /** DWG 线宽枚举(值 = 1/100 mm);其余为特殊值 */ const LW_TABLE = new Set([0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211]) const LW_BYLAYER = -1, LW_BYBLOCK = -2, LW_DEFAULT = -3 function normLineweight(v) { if (v == null) return LW_BYLAYER if (v < 0) return v >= -3 ? v : LW_BYLAYER if (v === 28) return LW_BYLAYER if (v === 29) return LW_BYBLOCK if (v === 30 || v === 31) return LW_DEFAULT return LW_TABLE.has(v) ? v : LW_DEFAULT } /** ACI 索引 → 0xRRGGBB */ export function aciColor(i) { if (i == null || i < 0 || i > 255) return 0x000000 const c = ACI[i] return typeof c === 'number' ? c : 0x000000 } /** * 解析实体最终颜色。 * @returns {number} 0xRRGGBB */ function resolveColor(ent, layer, inherited) { if (typeof ent.trueColor === 'number') return ent.trueColor & 0xffffff const ci = ent.colorIndex if (ci === 0) return inherited != null ? inherited : (layer ? layerColor(layer) : 0x000000) // BYBLOCK if (ci == null || ci === 256) return layer ? layerColor(layer) : 0x000000 // BYLAYER if (ci > 0 && ci < 256) return aciColor(ci) return 0x000000 } function layerColor(layer) { if (typeof layer.trueColor === 'number') return layer.trueColor & 0xffffff if (layer.colorIndex != null && layer.colorIndex !== 0) return aciColor(Math.abs(layer.colorIndex)) if (typeof layer.color === 'number' && layer.color !== 16777215) return layer.color & 0xffffff return 0x000000 } // ---------------------------------------------------------------- 主入口 /** * @param {Doc} doc 归一化文档 * @param {object} opt { space: 'model' | layout 名 } * @returns {{shapes: Array, bbox: number[], stats: object}} */ export function flatten(doc, opt = {}) { const shapes = [] const bbox = [Infinity, Infinity, -Infinity, -Infinity] const stats = { entities: 0, shapes: 0, skipped: 0, byType: {} } const ctx = { doc, shapes, bbox, stats, blockStack: new Set() } const entities = opt.entities || doc.modelEntities || [] for (const ent of entities) { emitEntity(ctx, ent, IDENTITY, null, null, 0) } stats.shapes = shapes.length return { shapes, bbox, stats } } function emitEntity(ctx, ent, m, inheritColor, inheritLayer, depth) { if (!ent || ent.isVisible === false) return const type = ent.type ctx.stats.entities++ ctx.stats.byType[type] = (ctx.stats.byType[type] || 0) + 1 // 图层:块内实体用 "0" 图层时继承外层图层(AutoCAD 规则) let layerName = ent.layer || '0' if (layerName === '0' && inheritLayer) layerName = inheritLayer const layer = ctx.doc.layers.get(layerName) const base = { layer: layerName, color: resolveColor(ent, layer, inheritColor), lw: normLineweight(ent.lineweight), lt: resolveLineType(ent, layer), ltScale: ent.lineTypeScale || 1, ent, } switch (type) { case 'LINE': { const p = ent.startPoint, q = ent.endPoint if (!p || !q) return addPoly(ctx, base, new Float64Array([ applyX(m, p.x, p.y), applyY(m, p.x, p.y), applyX(m, q.x, q.y), applyY(m, q.x, q.y), ]), false) break } case 'CIRCLE': addCircleLike(ctx, base, m, ent.center, ent.radius, ent.radius, 0, 0, TAU) break case 'ARC': { const a0 = ang(ent.startAngle), a1 = ang(ent.endAngle) addCircleLike(ctx, base, m, ent.center, ent.radius, ent.radius, 0, a0, a1) break } case 'ELLIPSE': { const mj = ent.majorAxisEndPoint || { x: 1, y: 0 } const rx = Math.hypot(mj.x, mj.y) const ry = rx * (ent.axisRatio || 1) const rot = Math.atan2(mj.y, mj.x) let a0 = ent.startAngle || 0, a1 = ent.endAngle if (a1 == null || Math.abs((a1 - a0) % TAU) < 1e-9) { a0 = 0; a1 = TAU } addCircleLike(ctx, base, m, ent.center, rx, ry, rot, a0, a1) break } case 'LWPOLYLINE': { const pts = lwPolyPoints(ent) if (pts.length >= 4) addPoly(ctx, base, transformPoints(pts, m), isClosed(ent.flag)) break } case 'POLYLINE': case 'POLYLINE2D': case 'POLYLINE3D': { const pts = polyPoints(ent) if (pts.length >= 4) addPoly(ctx, base, transformPoints(pts, m), isClosed(ent.flag)) break } case 'SPLINE': { const pts = splineEntityPoints(ent) if (pts.length >= 4) addPoly(ctx, base, transformPoints(pts, m), !!(ent.flag & 1)) break } case 'POINT': { const p = ent.position || ent.startPoint || ent.center if (!p) return push(ctx, { ...base, kind: 'point', x: applyX(m, p.x, p.y), y: applyY(m, p.x, p.y) }, [applyX(m, p.x, p.y), applyY(m, p.x, p.y), applyX(m, p.x, p.y), applyY(m, p.x, p.y)]) break } case 'SOLID': case 'TRACE': { const cs = [ent.corner1, ent.corner2, ent.corner4, ent.corner3].filter(Boolean) if (cs.length < 3) return const pts = new Float64Array(cs.length * 2) cs.forEach((c, i) => { pts[i * 2] = applyX(m, c.x, c.y); pts[i * 2 + 1] = applyY(m, c.x, c.y) }) push(ctx, { ...base, kind: 'fill', loops: [pts] }, bboxOfPoints(pts)) break } case '3DFACE': { const cs = [ent.corner1 || ent.vertices?.[0], ent.corner2 || ent.vertices?.[1], ent.corner3 || ent.vertices?.[2], ent.corner4 || ent.vertices?.[3]].filter(Boolean) if (cs.length < 3) return const pts = new Float64Array(cs.length * 2) cs.forEach((c, i) => { pts[i * 2] = applyX(m, c.x, c.y); pts[i * 2 + 1] = applyY(m, c.x, c.y) }) addPoly(ctx, base, pts, true) break } case 'HATCH': emitHatch(ctx, base, ent, m) break case 'TEXT': case 'ATTRIB': case 'ATTDEF': emitText(ctx, base, ent, m) break case 'MTEXT': emitMText(ctx, base, ent, m) break case 'DIMENSION': case 'ARC_DIMENSION': emitBlockRef(ctx, base, ent, ent.name, m, inheritColor, layerName, depth, ent.definitionPoint) break case 'LEADER': emitLeader(ctx, base, ent, m) break case 'MULTILEADER': case 'MLEADER': emitMLeader(ctx, base, ent, m) break case 'INSERT': emitInsert(ctx, base, ent, m, inheritColor, layerName, depth) break case 'VIEWPORT': case 'SEQEND': case 'VERTEX': case 'BLOCK': case 'ENDBLK': break default: ctx.stats.skipped++ break } } // ---------------------------------------------------------------- 各类实体 function resolveLineType(ent, layer) { let lt = ent.lineType || ent.lineTypeName if (!lt || lt === 'ByLayer' || lt === 'BYLAYER') lt = layer ? layer.lineType : null if (!lt || lt === 'ByBlock' || lt === 'BYBLOCK') return null if (/^continuous$/i.test(lt)) return null return lt } const isClosed = (flag) => !!(flag & 1) /** * 文档内的角度约定:一律弧度。 * libredwg 本身就给弧度;dxf-parser 混用(圆弧是弧度、rotation 是度), * 已在 source.js 的归一化里统一,这里不再换算。 */ const ang = (a) => a || 0 function lwPolyPoints(ent) { const vs = ent.vertices || [] if (!vs.length) return new Float64Array(0) const out = [] const closed = isClosed(ent.flag) const n = vs.length out.push(vs[0].x, vs[0].y) for (let i = 0; i < (closed ? n : n - 1); i++) { const a = vs[i], b = vs[(i + 1) % n] if (a.bulge) out.push(...bulgeArcPoints(a.x, a.y, b.x, b.y, a.bulge)) else out.push(b.x, b.y) } return new Float64Array(out) } function polyPoints(ent) { const vs = (ent.vertices || []).filter((v) => v && v.x != null) if (!vs.length) return new Float64Array(0) // 曲线拟合的多段线(flag & 4 = spline-fit):顶点已是拟合点,直接连 const out = [] const closed = isClosed(ent.flag) const n = vs.length out.push(vs[0].x, vs[0].y) for (let i = 0; i < (closed ? n : n - 1); i++) { const a = vs[i], b = vs[(i + 1) % n] if (a.bulge) out.push(...bulgeArcPoints(a.x, a.y, b.x, b.y, a.bulge)) else out.push(b.x, b.y) } return new Float64Array(out) } function splineEntityPoints(ent) { const cps = ent.controlPoints || [] if (cps.length >= 2) { const weights = ent.weights const pts = cps.map((c, i) => ({ x: c.x, y: c.y, weight: weights ? weights[i] : c.weight })) return splinePoints(ent.degree || 3, pts, ent.knots, !!(ent.flag & 1)) } const fps = ent.fitPoints || [] if (fps.length >= 2) return fitPointCurve(fps, !!(ent.flag & 1)) return new Float64Array(0) } function addPoly(ctx, base, pts, closed) { if (pts.length < 4) return push(ctx, { ...base, kind: 'poly', pts, closed }, bboxOfPoints(pts)) } /** * 圆/圆弧/椭圆:变换若是共形的(等比缩放+旋转),保留解析形式交给 * canvas 的 ellipse() 精确绘制;否则退化成折线。 */ function addCircleLike(ctx, base, m, center, rx, ry, rot, a0, a1) { if (!center) return if (!(rx > 0)) return const full = Math.abs(a1 - a0) >= TAU - 1e-9 const cf = conformal(m) if (cf) { // 共形变换下圆仍是圆、椭圆仍是椭圆,保留解析形式让 canvas 画出无锯齿的曲线。 // 镜像会让参数角反向:a → -a,且弧的走向翻转,故起止角对调。 const s = cf.scale, phi = cf.rotation const R = cf.mirror ? phi - rot : rot + phi const A0 = cf.mirror ? -a1 : a0 const A1 = cf.mirror ? -a0 : a1 const cx = applyX(m, center.x, center.y) const cy = applyY(m, center.x, center.y) const RX = rx * s, RY = ry * s push(ctx, { ...base, kind: 'arc', cx, cy, rx: RX, ry: RY, rot: R, a0: A0, a1: A1 }, arcBBox(cx, cy, RX, RY, R, A0, A1)) return } const pts = rx === ry ? (full ? circlePoints(center.x, center.y, rx) : arcPoints(center.x, center.y, rx, a0, a1)) : ellipsePoints(center.x, center.y, rx * Math.cos(rot), rx * Math.sin(rot), ry / rx, a0, a1) addPoly(ctx, base, transformPoints(pts, m), full) } function emitHatch(ctx, base, ent, m) { const loops = hatchLoops(ent) if (!loops.length) return const solid = ent.solidFill === 1 || /^solid$/i.test(ent.patternName || '') if (solid) { const world = loops.map((l) => transformPoints(l, m)) const bb = [Infinity, Infinity, -Infinity, -Infinity] world.forEach((l) => growBox(bb, bboxOfPoints(l))) push(ctx, { ...base, kind: 'fill', loops: world }, bb) return } // 文件里没带定义线时(预定义图案只存名字),从内置图案表展开 let defs = ent.definitionLines if (!defs || !defs.length) { defs = builtinDefinitionLines(ent.patternName, ent.patternAngle || 0, ent.patternScale || 1) } const segs = hatchPatternSegments(loops, defs) if (!segs.length) { // 没有图案定义线(例如未知图案):至少把边界画出来 for (const l of loops) addPoly(ctx, base, transformPoints(l, m), true) return } const arr = transformPoints(new Float64Array(segs), m) push(ctx, { ...base, kind: 'segs', pts: arr }, bboxOfPoints(arr)) } function emitText(ctx, base, ent, m) { const raw = ent.text if (raw == null || raw === '') return const txt = parseSimpleText(raw) if (!txt) return const style = ctx.doc.styles.get(ent.styleName) || null const sc = matrixScale(m) const h = (ent.textHeight || (style && style.fixedTextHeight) || 2.5) * sc if (!(h > 0)) return // TEXT 的对齐点:halign/valign 非默认时用 endPoint(第二对齐点) const useAlt = (ent.halign && ent.halign !== 0) || (ent.valign && ent.valign !== 0) const p = (useAlt && ent.endPoint && (ent.endPoint.x || ent.endPoint.y)) ? ent.endPoint : (ent.startPoint || ent.position) if (!p) return const cf = conformal(m) const rot = ang(ent.rotation) + (cf ? cf.rotation : 0) push(ctx, { ...base, kind: 'text', x: applyX(m, p.x, p.y), y: applyY(m, p.x, p.y), h, rot, wFactor: ent.xScale || (style && style.widthFactor) || 1, oblique: ang(ent.obliqueAngle || (style && style.obliqueAngle) || 0), halign: ent.halign || 0, valign: ent.valign || 0, mirrorX: !!(ent.generationFlag & 2), mirrorY: !!(ent.generationFlag & 4), font: style && style.font, runs: [{ text: txt, hFactor: 1, wFactor: 1, rise: 0 }], plain: txt, }, textBox(applyX(m, p.x, p.y), applyY(m, p.x, p.y), charWidth(txt) * h * (ent.xScale || 1), h, ent.halign === 1 || ent.halign === 4 ? 0.5 : ent.halign === 2 ? 1 : 0, ent.valign === 3 ? 1 : ent.valign === 2 || ent.halign === 4 ? 0.5 : 0, rot)) } /** 估算字符串宽度(以字高为单位):中日韩按 1 个字宽,西文按 0.55 */ function charWidth(t) { let w = 0 for (const c of t) w += c.charCodeAt(0) > 0x2e80 ? 1 : 0.55 return w } /** * 文字包围盒。 * @param ax 0=左 0.5=中 1=右;ay 0=基线/底 0.5=中 1=顶 */ function textBox(x, y, w, h, ax, ay, rot) { const x0 = -w * ax, x1 = x0 + w const y1 = h * (1 - ay) + h * 0.25 // 多留一点给下伸部 const y0 = y1 - h * 1.25 const c = Math.cos(rot || 0), s = Math.sin(rot || 0) const b = [Infinity, Infinity, -Infinity, -Infinity] for (const [lx, ly] of [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]) { const px = x + lx * c - ly * s const py = y + lx * s + ly * c if (px < b[0]) b[0] = px if (py < b[1]) b[1] = py if (px > b[2]) b[2] = px if (py > b[3]) b[3] = py } return b } /** MTEXT 的 9 种对齐点 */ const MT_H = { 1: 0, 2: 0.5, 3: 1, 4: 0, 5: 0.5, 6: 1, 7: 0, 8: 0.5, 9: 1 } const MT_V = { 1: 1, 2: 1, 3: 1, 4: 0.5, 5: 0.5, 6: 0.5, 7: 0, 8: 0, 9: 0 } function emitMText(ctx, base, ent, m) { const { lines } = parseMText(ent.text) const plain = lines.map((l) => l.map((r) => r.text).join('')).join('\n') if (!plain.trim()) return const style = ctx.doc.styles.get(ent.styleName) || null const sc = matrixScale(m) const h = (ent.textHeight || 2.5) * sc const p = ent.insertionPoint if (!p || !(h > 0)) return const cf = conformal(m) let rot = 0 if (ent.direction && (ent.direction.x || ent.direction.y)) rot = Math.atan2(ent.direction.y, ent.direction.x) else rot = ang(ent.rotation) rot += cf ? cf.rotation : 0 const ap = ent.attachmentPoint || 1 const lineGap = (ent.lineSpacing || 1) * 1.667 // AutoCAD 默认行距系数 const ax = MT_H[ap] ?? 0 const ay = MT_V[ap] ?? 1 const width = (ent.rectWidth || 0) * sc const X = applyX(m, p.x, p.y), Y = applyY(m, p.x, p.y) // 包围盒:有 rectWidth 就按框宽算(正文会在这个宽度内折行), // 否则按最长一行估。行数同样按折行后估,否则长段落的盒子会短一截。 const longest = Math.max(...lines.map((l) => charWidth(l.map((r) => r.text).join(''))), 1) const boxW = width > 0 ? width : longest * h const nLines = width > 0 ? lines.reduce((n, l) => n + Math.max(1, Math.ceil((charWidth(l.map((r) => r.text).join('')) * h) / width)), 0) : lines.length const boxH = Math.max(1, nLines) * h * lineGap push(ctx, { ...base, kind: 'mtext', x: X, y: Y, h, rot, lines, lineGap, ax, ay, width, font: style && style.font, plain, }, textBox(X, Y, boxW, boxH, ax, ay, rot)) } function emitLeader(ctx, base, ent, m) { const vs = ent.vertices || ent.points || [] if (vs.length < 2) return const pts = new Float64Array(vs.length * 2) vs.forEach((v, i) => { pts[i * 2] = applyX(m, v.x, v.y); pts[i * 2 + 1] = applyY(m, v.x, v.y) }) addPoly(ctx, base, pts, false) } function emitMLeader(ctx, base, ent, m) { const ctxData = ent.contextData || ent.context || {} const leaders = ctxData.leaders || ent.leaders || [] for (const ld of leaders) { for (const line of ld.leaderLines || ld.lines || []) { const vs = line.vertices || line.points || [] if (vs.length < 2) continue const pts = new Float64Array(vs.length * 2) vs.forEach((v, i) => { pts[i * 2] = applyX(m, v.x, v.y); pts[i * 2 + 1] = applyY(m, v.x, v.y) }) addPoly(ctx, base, pts, false) } } const t = ctxData.text || ctxData.textLabel if (t && ctxData.textLocation) { emitMText(ctx, base, { text: t, insertionPoint: ctxData.textLocation, textHeight: ctxData.textHeight || ent.textHeight, rotation: 0, attachmentPoint: ctxData.textAttachmentPoint || 1, styleName: ctxData.textStyleName, }, m) } } /** DIMENSION:真正的图形放在一个匿名块里,直接展开那个块 */ function emitBlockRef(ctx, base, ent, blockName, m, inheritColor, layerName, depth, fallbackPt) { const blk = blockName && ctx.doc.blocks.get(blockName) if (!blk) return if (depth > MAX_INSERT_DEPTH) return const bp = blk.basePoint || { x: 0, y: 0 } const bm = mul(m, [1, 0, 0, 1, -bp.x, -bp.y]) for (const e of blk.entities || []) { emitEntity(ctx, e, bm, base.color, layerName, depth + 1) } } function emitInsert(ctx, base, ent, m, inheritColor, layerName, depth) { if (depth > MAX_INSERT_DEPTH) return const blk = ctx.doc.blocks.get(ent.name) // ATTRIB 无论块在不在都要画 for (const a of ent.attribs || []) { if (a && a.isVisible !== false) emitEntity(ctx, a, m, base.color, layerName, depth + 1) } if (!blk) { ctx.stats.skipped++; return } const p = ent.insertionPoint || { x: 0, y: 0 } const sx = ent.xScale === 0 || ent.xScale == null ? 1 : ent.xScale const sy = ent.yScale === 0 || ent.yScale == null ? 1 : ent.yScale const rot = ang(ent.rotation) const bp = blk.basePoint || { x: 0, y: 0 } const cols = Math.max(1, ent.columnCount || 1) const rows = Math.max(1, ent.rowCount || 1) const cSp = ent.columnSpacing || 0 const rSp = ent.rowSpacing || 0 if (cols * rows > MAX_ARRAY_CELLS) { ctx.stats.skipped++; return } for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const local = makeInsertMatrix(p.x + c * cSp, p.y + r * rSp, sx, sy, rot) // 先把块基点移到原点,再套插入变换 const bm = mul(mul(m, local), [1, 0, 0, 1, -bp.x, -bp.y]) for (const e of blk.entities || []) { emitEntity(ctx, e, bm, base.color, layerName, depth + 1) } } } } // ---------------------------------------------------------------- 输出 function push(ctx, shape, bb) { if (!bb || !isFinite(bb[0]) || !isFinite(bb[2])) return shape.bbox = bb shape.i = ctx.shapes.length ctx.shapes.push(shape) growBox(ctx.bbox, bb) }